@@ -19,103 +19,188 @@ import SwiftSyntax
19
19
/// Lint: If a comment does not begin with a single-line summary, a lint error is raised.
20
20
///
21
21
/// - SeeAlso: https://google.github.io/swift#single-sentence-summary
22
- public final class BeginDocumentationCommentWithOneLineSummary : SyntaxLintRule {
22
+ public final class BeginDocumentationCommentWithOneLineSummary : SyntaxLintRule {
23
+
24
+ /// Unit tests can testably import this module and set this to true in order to force the rule
25
+ /// to use the fallback (simple period separator) mode instead of the `NSLinguisticTag` mode,
26
+ /// even on platforms that support the latter (currently only Apple OSes).
27
+ ///
28
+ /// This allows test runs on those platforms to test both implementations.
29
+ static var forcesFallbackModeForTesting = false
30
+
23
31
override public func visit( _ node: FunctionDeclSyntax ) {
24
- diagnoseDocComments ( node)
32
+ diagnoseDocComments ( in : node)
25
33
}
26
34
27
35
override public func visit( _ node: EnumDeclSyntax ) {
28
- diagnoseDocComments ( node)
36
+ diagnoseDocComments ( in : node)
29
37
}
30
38
31
39
override public func visit( _ node: InitializerDeclSyntax ) {
32
- diagnoseDocComments ( node)
40
+ diagnoseDocComments ( in : node)
33
41
}
34
42
35
43
override public func visit( _ node: DeinitializerDeclSyntax ) {
36
- diagnoseDocComments ( node)
44
+ diagnoseDocComments ( in : node)
37
45
}
38
46
39
47
override public func visit( _ node: SubscriptDeclSyntax ) {
40
- diagnoseDocComments ( node)
48
+ diagnoseDocComments ( in : node)
41
49
}
42
50
43
51
override public func visit( _ node: ClassDeclSyntax ) {
44
- diagnoseDocComments ( node)
52
+ diagnoseDocComments ( in : node)
45
53
}
46
54
47
55
override public func visit( _ node: VariableDeclSyntax ) {
48
- diagnoseDocComments ( node)
56
+ diagnoseDocComments ( in : node)
49
57
}
50
58
51
59
override public func visit( _ node: StructDeclSyntax ) {
52
- diagnoseDocComments ( node)
60
+ diagnoseDocComments ( in : node)
53
61
}
54
62
55
63
override public func visit( _ node: ProtocolDeclSyntax ) {
56
- diagnoseDocComments ( node)
64
+ diagnoseDocComments ( in : node)
57
65
}
58
66
59
67
override public func visit( _ node: TypealiasDeclSyntax ) {
60
- diagnoseDocComments ( node)
68
+ diagnoseDocComments ( in : node)
61
69
}
62
70
63
- /// Diagnose documentation comments that don't start
64
- /// with one sentence summary.
65
- func diagnoseDocComments( _ decl: DeclSyntax ) {
71
+ override public func visit( _ node: AssociatedtypeDeclSyntax ) {
72
+ diagnoseDocComments ( in: node)
73
+ }
74
+
75
+ /// Diagnose documentation comments that don't start with one sentence summary.
76
+ private func diagnoseDocComments( in decl: DeclSyntax ) {
66
77
guard let commentText = decl. docComment else { return }
67
78
let docComments = commentText. components ( separatedBy: " \n " )
68
79
guard let firstPart = firstParagraph ( docComments) else { return }
69
80
70
- let commentSentences = sentences ( in: firstPart)
71
- if commentSentences. count > 1 {
72
- diagnose ( . docCommentRequiresOneSentenceSummary( commentSentences. first!) , on: decl)
81
+ let trimmedText = firstPart. trimmingCharacters ( in: . whitespacesAndNewlines)
82
+ let ( commentSentences, trailingText) = sentences ( in: trimmedText)
83
+ if commentSentences. count == 0 {
84
+ diagnose ( . terminateSentenceWithPeriod( trimmedText) , on: decl)
85
+ }
86
+ else if commentSentences. count > 1 {
87
+ diagnose ( . addBlankLineAfterFirstSentence( commentSentences [ 0 ] ) , on: decl)
88
+ if !trailingText. isEmpty {
89
+ diagnose ( . terminateSentenceWithPeriod( trailingText) , on: decl)
90
+ }
73
91
}
74
92
}
75
93
76
94
/// Returns the text of the first part of the comment,
77
- func firstParagraph( _ comments: [ String ] ) -> String ? {
95
+ private func firstParagraph( _ comments: [ String ] ) -> String ? {
78
96
var text = [ String] ( )
79
97
var index = 0
80
- while index < comments. count &&
81
- comments [ index] != " * " &&
82
- comments [ index] != " " {
98
+ while index < comments. count && comments [ index] != " * " && comments [ index] != " " {
83
99
text. append ( comments [ index] )
84
- index = index + 1
100
+ index += 1
85
101
}
86
102
return comments. isEmpty ? nil : text. joined ( separator: " " )
87
103
}
88
104
89
105
/// Returns all the sentences in the given text.
90
- func sentences( in text: String ) -> [ String ] {
91
- var sentences = [ String] ( )
92
- if #available( OSX 10 . 13 , * ) { /// add linux condition
93
- let tagger = NSLinguisticTagger ( tagSchemes: [ . tokenType] , options: 0 )
94
- tagger. string = text
95
- let range = NSRange ( location: 0 , length: text. utf16. count)
96
- let options : NSLinguisticTagger . Options = [ . omitWhitespace, . omitOther]
97
- tagger. enumerateTags (
98
- in: range,
99
- unit: . sentence,
100
- scheme: . tokenType,
101
- options: options
102
- ) { _, tokenRange, _ in
103
- let sentence = ( text as NSString ) . substring ( with: tokenRange)
104
- sentences. append ( sentence)
106
+ ///
107
+ /// This function uses linguistic APIs if they are available on the current platform; otherwise,
108
+ /// simpler (and less accurate) character-based string APIs are substituted.
109
+ ///
110
+ /// - Parameter text: The text from which sentences should be extracted.
111
+ /// - Returns: A tuple of two values: `sentences`, the array of sentences that were found, and
112
+ /// `trailingText`, which is any non-whitespace text after the last sentence that was not
113
+ /// terminated by sentence terminating punctuation. Note that if the entire string is a sequence
114
+ /// of words that contains _no_ terminating punctuation, the returned array will be empty to
115
+ /// indicate that there were no _complete_ sentences found, and `trailingText` will contain the
116
+ /// actual text).
117
+ private func sentences( in text: String ) -> ( sentences: [ String ] , trailingText: Substring ) {
118
+ #if os(macOS)
119
+ if BeginDocumentationCommentWithOneLineSummary . forcesFallbackModeForTesting {
120
+ return nonLinguisticSentenceApproximations ( in: text)
121
+ }
122
+
123
+ var sentences = [ String] ( )
124
+ var tokenRanges = [ Range < String . Index > ] ( )
125
+ let tags = text. linguisticTags (
126
+ in: text. startIndex..< text. endIndex,
127
+ scheme: NSLinguisticTagScheme . lexicalClass. rawValue,
128
+ tokenRanges: & tokenRanges)
129
+ let sentenceTerminatorIndices =
130
+ tags. enumerated ( ) . filter { $0. element == " SentenceTerminator " }
131
+ . map { tokenRanges [ $0. offset] . lowerBound }
132
+
133
+ var previous = text. startIndex
134
+ for index in sentenceTerminatorIndices {
135
+ let sentenceRange = previous... index
136
+ sentences. append ( text [ sentenceRange] . trimmingCharacters ( in: . whitespaces) )
137
+ previous = text. index ( after: index)
105
138
}
106
- } else {
107
- return text. components ( separatedBy: " . " )
139
+
140
+ return ( sentences: sentences, trailingText: text [ previous..< text. endIndex] )
141
+ #else
142
+ return nonLinguisticSentenceApproximations ( in: text)
143
+ #endif
144
+ }
145
+
146
+ /// Returns the best approximation of sentences in the given text using string splitting around
147
+ /// periods that are followed by spaces.
148
+ ///
149
+ /// This method is a fallback for platforms (like Linux, currently) where `String` does not
150
+ /// support `NSLinguisticTagger` and its related APIs. It will fail to catch certain kinds of
151
+ /// sentences (such as those containing abbrevations that are followed by a period, like "Dr.")
152
+ /// that the more advanced API can handle.
153
+ private func nonLinguisticSentenceApproximations( in text: String ) -> (
154
+ sentences: [ String ] , trailingText: Substring
155
+ ) {
156
+ // If we find a period followed by a space, then there is definitely one (approximate) sentence;
157
+ // there may be more.
158
+ let possiblyHasMultipleSentences = text. range ( of: " . " ) != nil
159
+
160
+ // If the string does not end in a period, then the text preceding it (up until the last
161
+ // sentence terminator, or the beginning of the string, whichever comes first), is trailing
162
+ // text.
163
+ let hasTrailingText = !text. hasSuffix ( " . " )
164
+
165
+ if !possiblyHasMultipleSentences {
166
+ // If we didn't find a ". " sequence, then we either have trailing text (if there is no period
167
+ // at the end of the string) or we have a single sentence (if there is a final period).
168
+ if hasTrailingText {
169
+ return ( sentences: [ ] , trailingText: text [ ... ] )
170
+ }
171
+ else {
172
+ return ( sentences: [ text] , trailingText: " " )
173
+ }
174
+ }
175
+
176
+ // Otherwise, split the string around ". " sequences. All of these but the last one are
177
+ // definitely (approximate) sentences. The last one is either trailing text or another sentence,
178
+ // depending on whether the entire string ended with a period.
179
+ let splitText = text. components ( separatedBy: " . " )
180
+ let definiteApproximateSentences = splitText. dropLast ( ) . map { " \( $0) . " }
181
+ let trailingText = splitText. last ?? " "
182
+ if hasTrailingText {
183
+ return ( sentences: Array ( definiteApproximateSentences) , trailingText: trailingText [ ... ] )
184
+ }
185
+ else {
186
+ var sentences = Array ( definiteApproximateSentences)
187
+ sentences. append ( trailingText)
188
+ return ( sentences: sentences, trailingText: " " )
108
189
}
109
- return sentences
110
190
}
111
191
}
112
192
113
193
extension Diagnostic . Message {
114
- static func docCommentRequiresOneSentenceSummary( _ firstSentence: String ) -> Diagnostic . Message {
115
- let sentenceWithoutExtraSpaces = firstSentence. trimmingCharacters ( in: . whitespacesAndNewlines)
116
- return . init(
117
- . warning,
118
- " add a blank comment line after this sentence: \" \( sentenceWithoutExtraSpaces) \" "
119
- )
194
+
195
+ static func terminateSentenceWithPeriod< Sentence: StringProtocol > ( _ text: Sentence )
196
+ -> Diagnostic . Message
197
+ {
198
+ return . init( . warning, " terminate this sentence with a period: \" \( text) \" " )
199
+ }
200
+
201
+ static func addBlankLineAfterFirstSentence< Sentence: StringProtocol > ( _ text: Sentence )
202
+ -> Diagnostic . Message
203
+ {
204
+ return . init( . warning, " add a blank comment line after this sentence: \" \( text) \" " )
120
205
}
121
206
}
0 commit comments