1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230
|
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//
import Foundation
#if os(macOS)
import NaturalLanguage
#endif
import SwiftSyntax
/// All documentation comments must begin with a one-line summary of the declaration.
///
/// Lint: If a comment does not begin with a single-line summary, a lint error is raised.
@_spi(Rules)
public final class BeginDocumentationCommentWithOneLineSummary: SyntaxLintRule {
/// Unit tests can testably import this module and set this to true in order to force the rule
/// to use the fallback (simple period separator) mode instead of the `NSLinguisticTag` mode,
/// even on platforms that support the latter (currently only Apple OSes).
///
/// This allows test runs on those platforms to test both implementations.
public static var _forcesFallbackModeForTesting = false
/// Identifies this rule as being opt-in. Well written docs on declarations are important, but
/// this rule isn't linguistically advanced enough on all platforms to be applied universally.
public override class var isOptIn: Bool { return true }
public override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind {
diagnoseDocComments(in: DeclSyntax(node))
return .skipChildren
}
public override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind {
diagnoseDocComments(in: DeclSyntax(node))
return .skipChildren
}
public override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind {
diagnoseDocComments(in: DeclSyntax(node))
return .skipChildren
}
public override func visit(_ node: DeinitializerDeclSyntax) -> SyntaxVisitorContinueKind {
diagnoseDocComments(in: DeclSyntax(node))
return .skipChildren
}
public override func visit(_ node: SubscriptDeclSyntax) -> SyntaxVisitorContinueKind {
diagnoseDocComments(in: DeclSyntax(node))
return .skipChildren
}
public override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
diagnoseDocComments(in: DeclSyntax(node))
return .skipChildren
}
public override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind {
diagnoseDocComments(in: DeclSyntax(node))
return .skipChildren
}
public override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
diagnoseDocComments(in: DeclSyntax(node))
return .skipChildren
}
public override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind {
diagnoseDocComments(in: DeclSyntax(node))
return .skipChildren
}
public override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind {
diagnoseDocComments(in: DeclSyntax(node))
return .skipChildren
}
public override func visit(_ node: AssociatedTypeDeclSyntax) -> SyntaxVisitorContinueKind {
diagnoseDocComments(in: DeclSyntax(node))
return .skipChildren
}
/// Diagnose documentation comments that don't start with one sentence summary.
private func diagnoseDocComments(in decl: DeclSyntax) {
guard
let docComment = DocumentationComment(extractedFrom: decl),
let briefSummary = docComment.briefSummary
else { return }
// For the purposes of checking the sentence structure of the comment, we can operate on the
// plain text; we don't need any of the styling.
let trimmedText = briefSummary.plainText.trimmingCharacters(in: .whitespacesAndNewlines)
let (commentSentences, trailingText) = sentences(in: trimmedText)
if commentSentences.count == 0 {
diagnose(.terminateSentenceWithPeriod(trimmedText), on: decl)
} else if commentSentences.count > 1 {
diagnose(.addBlankLineAfterFirstSentence(commentSentences[0]), on: decl)
if !trailingText.isEmpty {
diagnose(.terminateSentenceWithPeriod(trailingText), on: decl)
}
}
}
/// Returns all the sentences in the given text.
///
/// This function uses linguistic APIs if they are available on the current platform; otherwise,
/// simpler (and less accurate) character-based string APIs are substituted.
///
/// - Parameter text: The text from which sentences should be extracted.
/// - Returns: A tuple of two values: `sentences`, the array of sentences that were found, and
/// `trailingText`, which is any non-whitespace text after the last sentence that was not
/// terminated by sentence terminating punctuation. Note that if the entire string is a sequence
/// of words that contains _no_ terminating punctuation, the returned array will be empty to
/// indicate that there were no _complete_ sentences found, and `trailingText` will contain the
/// actual text).
private func sentences(in text: String) -> (sentences: [String], trailingText: Substring) {
#if os(macOS)
if BeginDocumentationCommentWithOneLineSummary._forcesFallbackModeForTesting {
return nonLinguisticSentenceApproximations(in: text)
}
var sentences = [String]()
var tags = [NLTag]()
var tokenRanges = [Range<String.Index>]()
let tagger = NLTagger(tagSchemes: [.lexicalClass])
tagger.string = text
tagger.enumerateTags(
in: text.startIndex..<text.endIndex,
unit: .word,
scheme: .lexicalClass
) { tag, range in
if let tag {
tags.append(tag)
tokenRanges.append(range)
}
return true
}
var isInsideQuotes = false
let sentenceTerminatorIndices = tags.enumerated().filter {
if $0.element == NLTag.openQuote {
isInsideQuotes = true
} else if $0.element == NLTag.closeQuote {
isInsideQuotes = false
}
return !isInsideQuotes && $0.element == NLTag.sentenceTerminator
}.map {
tokenRanges[$0.offset].lowerBound
}
var previous = text.startIndex
for index in sentenceTerminatorIndices {
let sentenceRange = previous...index
sentences.append(text[sentenceRange].trimmingCharacters(in: .whitespaces))
previous = text.index(after: index)
}
return (sentences: sentences, trailingText: text[previous..<text.endIndex])
#else
return nonLinguisticSentenceApproximations(in: text)
#endif
}
/// Returns the best approximation of sentences in the given text using string splitting around
/// periods that are followed by spaces.
///
/// This method is a fallback for platforms (like Linux, currently) that does not
/// support `NaturalLanguage` and its related APIs. It will fail to catch certain kinds of
/// sentences (such as those containing abbreviations that are followed by a period, like "Dr.")
/// that the more advanced API can handle.
private func nonLinguisticSentenceApproximations(in text: String) -> (
sentences: [String], trailingText: Substring
) {
// If we find a period followed by a space, then there is definitely one (approximate) sentence;
// there may be more.
let possiblyHasMultipleSentences = text.range(of: ". ") != nil
// If the string does not end in a period, then the text preceding it (up until the last
// sentence terminator, or the beginning of the string, whichever comes first), is trailing
// text.
let hasTrailingText = !text.hasSuffix(".")
if !possiblyHasMultipleSentences {
// If we didn't find a ". " sequence, then we either have trailing text (if there is no period
// at the end of the string) or we have a single sentence (if there is a final period).
if hasTrailingText {
return (sentences: [], trailingText: text[...])
} else {
return (sentences: [text], trailingText: "")
}
}
// Otherwise, split the string around ". " sequences. All of these but the last one are
// definitely (approximate) sentences. The last one is either trailing text or another sentence,
// depending on whether the entire string ended with a period.
let splitText = text.components(separatedBy: ". ")
let definiteApproximateSentences = splitText.dropLast().map { "\($0)." }
let trailingText = splitText.last ?? ""
if hasTrailingText {
return (sentences: Array(definiteApproximateSentences), trailingText: trailingText[...])
} else {
var sentences = Array(definiteApproximateSentences)
sentences.append(trailingText)
return (sentences: sentences, trailingText: "")
}
}
}
extension Finding.Message {
fileprivate static func terminateSentenceWithPeriod<Sentence: StringProtocol>(_ text: Sentence)
-> Finding.Message
{
"terminate this sentence with a period: \"\(text)\""
}
fileprivate static func addBlankLineAfterFirstSentence<Sentence: StringProtocol>(_ text: Sentence)
-> Finding.Message
{
"add a blank comment line after this sentence: \"\(text)\""
}
}
|