1
1
import Foundation
2
- import SourceKittenFramework
2
+ import SwiftLintCore
3
+ import SwiftSyntax
3
4
4
- struct TrailingWhitespaceRule : CorrectableRule {
5
+ @SwiftSyntaxRule ( correctable: true )
6
+ struct TrailingWhitespaceRule : Rule {
5
7
var configuration = TrailingWhitespaceConfiguration ( )
6
8
7
9
static let description = RuleDescription (
@@ -14,77 +16,314 @@ struct TrailingWhitespaceRule: CorrectableRule {
14
16
Example ( " let name: String // \n " ) , Example ( " let name: String // \n " ) ,
15
17
] ,
16
18
triggeringExamples: [
17
- Example ( " let name: String \n " ) , Example ( " /* */ let name: String \n " )
19
+ Example ( " let name: String↓ \n " ) , Example ( " /* */ let name: String↓ \n " )
18
20
] ,
19
21
corrections: [
20
- Example ( " let name: String \n " ) : Example ( " let name: String \n " ) ,
21
- Example ( " /* */ let name: String \n " ) : Example ( " /* */ let name: String \n " ) ,
22
+ Example ( " let name: String↓ \n " ) : Example ( " let name: String \n " ) ,
23
+ Example ( " /* */ let name: String↓ \n " ) : Example ( " /* */ let name: String \n " ) ,
22
24
]
23
25
)
26
+ }
24
27
25
- func validate( file: SwiftLintFile ) -> [ StyleViolation ] {
26
- let filteredLines = file. lines. filter {
27
- guard $0. content. hasTrailingWhitespace ( ) else { return false }
28
+ private extension TrailingWhitespaceRule {
29
+ final class Visitor : ViolationsSyntaxVisitor < ConfigurationType > {
30
+ // Pre-computed comment information for performance
31
+ private var linesFullyCoveredByBlockComments = Set < Int > ( )
32
+ private var linesEndingWithComment = Set < Int > ( )
28
33
29
- let commentKinds = SyntaxKind . commentKinds
30
- if configuration. ignoresComments,
31
- let lastSyntaxKind = file. syntaxKindsByLines [ $0. index] . last,
32
- commentKinds. contains ( lastSyntaxKind) {
33
- return false
34
+ override func visit( _ node: SourceFileSyntax ) -> SyntaxVisitorContinueKind {
35
+ // Pre-compute all comment information in a single pass if needed
36
+ if configuration. ignoresComments {
37
+ precomputeCommentInformation ( node)
34
38
}
35
39
36
- return !configuration. ignoresEmptyLines ||
37
- // If configured, ignore lines that contain nothing but whitespace (empty lines)
38
- $0. content. trimmingCharacters ( in: . whitespaces) . isNotEmpty
40
+ // Process each line for trailing whitespace violations
41
+ for lineContents in file. lines {
42
+ let line = lineContents. content
43
+ let lineNumber = lineContents. index // 1-based
44
+
45
+ // Calculate trailing whitespace info
46
+ guard let trailingWhitespaceInfo = line. trailingWhitespaceInfo ( ) else {
47
+ continue // No trailing whitespace
48
+ }
49
+
50
+ // Apply `ignoresEmptyLines` configuration
51
+ if configuration. ignoresEmptyLines &&
52
+ line. trimmingCharacters ( in: . whitespaces) . isEmpty {
53
+ continue
54
+ }
55
+
56
+ // Apply `ignoresComments` configuration
57
+ if configuration. ignoresComments {
58
+ // Check if line is fully within a block comment
59
+ if linesFullyCoveredByBlockComments. contains ( lineNumber) {
60
+ continue
61
+ }
62
+
63
+ // Check if line ends with a comment (using pre-computed info)
64
+ if linesEndingWithComment. contains ( lineNumber) {
65
+ continue
66
+ }
67
+ }
68
+
69
+ // Calculate violation position
70
+ let lineStartPos = locationConverter. position ( ofLine: lineNumber, column: 1 )
71
+ let violationStartOffset = line. utf8. count - trailingWhitespaceInfo. byteLength
72
+ let violationPosition = lineStartPos. advanced ( by: violationStartOffset)
73
+
74
+ let correctionEnd = lineStartPos. advanced ( by: line. utf8. count)
75
+
76
+ violations. append ( ReasonedRuleViolation (
77
+ position: violationPosition,
78
+ correction: . init( start: violationPosition, end: correctionEnd, replacement: " " )
79
+ ) )
80
+ }
81
+ return . skipChildren
39
82
}
40
83
41
- return filteredLines. map {
42
- StyleViolation ( ruleDescription: Self . description,
43
- severity: configuration. severityConfiguration. severity,
44
- location: Location ( file: file. path, line: $0. index) )
84
+ /// Pre-computes all comment information in a single pass for better performance
85
+ private func precomputeCommentInformation( _ node: SourceFileSyntax ) {
86
+ // First, collect block comment information
87
+ collectLinesFullyCoveredByBlockComments ( node)
88
+
89
+ // Then, collect line comment ranges and determine which lines end with comments
90
+ let lineCommentRanges = collectLineCommentRanges ( from: node)
91
+ determineLineEndingComments ( using: lineCommentRanges)
45
92
}
46
- }
47
93
48
- func correct( file: SwiftLintFile ) -> Int {
49
- let whitespaceCharacterSet = CharacterSet . whitespaces
50
- var correctedLines = [ String] ( )
51
- var numberOfCorrections = 0
52
- for line in file. lines {
53
- guard line. content. hasTrailingWhitespace ( ) else {
54
- correctedLines. append ( line. content)
55
- continue
94
+ /// Collects ranges of line comments organized by line number
95
+ private func collectLineCommentRanges( from node: SourceFileSyntax ) -> [ Int : [ Range < AbsolutePosition > ] ] {
96
+ var lineCommentRanges : [ Int : [ Range < AbsolutePosition > ] ] = [ : ]
97
+
98
+ for token in node. tokens ( viewMode: . sourceAccurate) {
99
+ // Process leading trivia
100
+ var currentPos = token. position
101
+ for piece in token. leadingTrivia {
102
+ let pieceStart = currentPos
103
+ currentPos += piece. sourceLength
104
+
105
+ if piece. isComment && !piece. isBlockComment {
106
+ let pieceStartLine = locationConverter. location ( for: pieceStart) . line
107
+ lineCommentRanges [ pieceStartLine, default: [ ] ] . append ( pieceStart..< currentPos)
108
+ }
109
+ }
110
+
111
+ // Process trailing trivia
112
+ currentPos = token. endPositionBeforeTrailingTrivia
113
+ for piece in token. trailingTrivia {
114
+ let pieceStart = currentPos
115
+ currentPos += piece. sourceLength
116
+
117
+ if piece. isComment && !piece. isBlockComment {
118
+ let pieceStartLine = locationConverter. location ( for: pieceStart) . line
119
+ lineCommentRanges [ pieceStartLine, default: [ ] ] . append ( pieceStart..< currentPos)
120
+ }
121
+ }
122
+ }
123
+
124
+ return lineCommentRanges
125
+ }
126
+
127
+ /// Determines which lines end with comments based on line comment ranges
128
+ private func determineLineEndingComments( using lineCommentRanges: [ Int : [ Range < AbsolutePosition > ] ] ) {
129
+ for lineNumber in 1 ... file. lines. count {
130
+ let line = file. lines [ lineNumber - 1 ] . content
131
+
132
+ // Skip if no trailing whitespace
133
+ guard let trailingWhitespaceInfo = line. trailingWhitespaceInfo ( ) else {
134
+ continue
135
+ }
136
+
137
+ // Get the effective content (before trailing whitespace)
138
+ let effectiveContent = getEffectiveContent ( from: line, removing: trailingWhitespaceInfo)
139
+
140
+ // Check if the effective content ends with a comment
141
+ if checkIfContentEndsWithComment (
142
+ effectiveContent,
143
+ lineNumber: lineNumber,
144
+ lineCommentRanges: lineCommentRanges
145
+ ) {
146
+ linesEndingWithComment. insert ( lineNumber)
147
+ }
148
+ }
149
+ }
150
+
151
+ /// Gets the content of a line before its trailing whitespace
152
+ private func getEffectiveContent(
153
+ from line: String ,
154
+ removing trailingWhitespaceInfo: TrailingWhitespaceInfo
155
+ ) -> String {
156
+ if trailingWhitespaceInfo. characterCount > 0 && line. count >= trailingWhitespaceInfo. characterCount {
157
+ return String ( line. prefix ( line. count - trailingWhitespaceInfo. characterCount) )
158
+ }
159
+ return " "
160
+ }
161
+
162
+ /// Checks if the given content ends with a comment
163
+ private func checkIfContentEndsWithComment(
164
+ _ effectiveContent: String ,
165
+ lineNumber: Int ,
166
+ lineCommentRanges: [ Int : [ Range < AbsolutePosition > ] ]
167
+ ) -> Bool {
168
+ guard !effectiveContent. isEmpty,
169
+ let lastNonWhitespaceIdx = effectiveContent. lastIndex ( where: { !$0. isWhitespace } ) else {
170
+ return false
171
+ }
172
+
173
+ // Calculate the byte position of the last non-whitespace character
174
+ let contentUpToLastChar = effectiveContent. prefix ( through: lastNonWhitespaceIdx)
175
+ let byteOffsetToLastChar = contentUpToLastChar. utf8. count - 1 // -1 for position of char
176
+ let lineStartPos = locationConverter. position ( ofLine: lineNumber, column: 1 )
177
+ let lastNonWhitespacePos = lineStartPos. advanced ( by: byteOffsetToLastChar)
178
+
179
+ // Check if this position falls within any comment range on this line
180
+ if let ranges = lineCommentRanges [ lineNumber] {
181
+ for range in ranges {
182
+ if range. lowerBound <= lastNonWhitespacePos && lastNonWhitespacePos < range. upperBound {
183
+ return true
184
+ }
185
+ }
56
186
}
57
187
58
- let commentKinds = SyntaxKind . commentKinds
59
- if configuration. ignoresComments,
60
- let lastSyntaxKind = file. syntaxKindsByLines [ line. index] . last,
61
- commentKinds. contains ( lastSyntaxKind) {
62
- correctedLines. append ( line. content)
63
- continue
188
+ return false
189
+ }
190
+
191
+ /// Collects line numbers that are fully covered by block comments
192
+ private func collectLinesFullyCoveredByBlockComments( _ sourceFile: SourceFileSyntax ) {
193
+ for token in sourceFile. tokens ( viewMode: . sourceAccurate) {
194
+ var currentPos = token. position
195
+
196
+ // Process leading trivia
197
+ for piece in token. leadingTrivia {
198
+ let pieceStartPos = currentPos
199
+ currentPos += piece. sourceLength
200
+
201
+ if piece. isBlockComment {
202
+ markLinesFullyCoveredByBlockComment (
203
+ blockCommentStart: pieceStartPos,
204
+ blockCommentEnd: currentPos
205
+ )
206
+ }
207
+ }
208
+
209
+ // Advance past token content
210
+ currentPos = token. endPositionBeforeTrailingTrivia
211
+
212
+ // Process trailing trivia
213
+ for piece in token. trailingTrivia {
214
+ let pieceStartPos = currentPos
215
+ currentPos += piece. sourceLength
216
+
217
+ if piece. isBlockComment {
218
+ markLinesFullyCoveredByBlockComment (
219
+ blockCommentStart: pieceStartPos,
220
+ blockCommentEnd: currentPos
221
+ )
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ /// Marks lines that are fully covered by a block comment
228
+ private func markLinesFullyCoveredByBlockComment(
229
+ blockCommentStart: AbsolutePosition ,
230
+ blockCommentEnd: AbsolutePosition
231
+ ) {
232
+ let startLocation = locationConverter. location ( for: blockCommentStart)
233
+ let endLocation = locationConverter. location ( for: blockCommentEnd)
234
+
235
+ let startLine = startLocation. line
236
+ var endLine = endLocation. line
237
+
238
+ // If comment ends at column 1, it actually ended on the previous line
239
+ if endLocation. column == 1 && endLine > startLine {
240
+ endLine -= 1
64
241
}
65
242
66
- let correctedLine = line . content . bridge ( )
67
- . trimmingTrailingCharacters ( in : whitespaceCharacterSet )
243
+ for lineNum in startLine ... endLine {
244
+ if lineNum <= 0 || lineNum > file . lines . count { continue }
68
245
69
- if configuration. ignoresEmptyLines && correctedLine. isEmpty {
70
- correctedLines. append ( line. content)
71
- continue
246
+ let lineInfo = file. lines [ lineNum - 1 ]
247
+ let lineContent = lineInfo. content
248
+ let lineStartPos = locationConverter. position ( ofLine: lineNum, column: 1 )
249
+
250
+ // Check if the line's non-whitespace content is fully within the block comment
251
+ if let firstNonWhitespaceIdx = lineContent. firstIndex ( where: { !$0. isWhitespace } ) ,
252
+ let lastNonWhitespaceIdx = lineContent. lastIndex ( where: { !$0. isWhitespace } ) {
253
+ // Line has non-whitespace content
254
+ // Calculate byte offsets (not character offsets) for AbsolutePosition
255
+ let contentBeforeFirstNonWS = lineContent. prefix ( upTo: firstNonWhitespaceIdx)
256
+ let byteOffsetToFirstNonWS = contentBeforeFirstNonWS. utf8. count
257
+ let firstNonWhitespacePos = lineStartPos. advanced ( by: byteOffsetToFirstNonWS)
258
+
259
+ let contentBeforeLastNonWS = lineContent. prefix ( upTo: lastNonWhitespaceIdx)
260
+ let byteOffsetToLastNonWS = contentBeforeLastNonWS. utf8. count
261
+ let lastNonWhitespacePos = lineStartPos. advanced ( by: byteOffsetToLastNonWS)
262
+
263
+ // Check if both first and last non-whitespace positions are within the comment
264
+ if firstNonWhitespacePos >= blockCommentStart && lastNonWhitespacePos < blockCommentEnd {
265
+ linesFullyCoveredByBlockComments. insert ( lineNum)
266
+ }
267
+ } else {
268
+ // Line is all whitespace - check if it's within the comment bounds
269
+ let lineEndPos = lineStartPos. advanced ( by: lineContent. utf8. count)
270
+ if lineStartPos >= blockCommentStart && lineEndPos <= blockCommentEnd {
271
+ linesFullyCoveredByBlockComments. insert ( lineNum)
272
+ }
273
+ }
72
274
}
275
+ }
276
+ }
277
+ }
278
+
279
+ // Helper struct to return both character count and byte length for whitespace
280
+ private struct TrailingWhitespaceInfo {
281
+ let characterCount : Int
282
+ let byteLength : Int
283
+ }
73
284
74
- if file. ruleEnabled ( violatingRanges: [ line. range] , for: self ) . isEmpty {
75
- correctedLines. append ( line. content)
76
- continue
285
+ private extension String {
286
+ func hasTrailingWhitespace( ) -> Bool {
287
+ if isEmpty { return false }
288
+ guard let lastScalar = unicodeScalars. last else { return false }
289
+ return CharacterSet . whitespaces. contains ( lastScalar)
290
+ }
291
+
292
+ /// Returns information about trailing whitespace (spaces and tabs only)
293
+ func trailingWhitespaceInfo( ) -> TrailingWhitespaceInfo ? {
294
+ var charCount = 0
295
+ var byteLen = 0
296
+ for char in self . reversed ( ) {
297
+ if char. isWhitespace && ( char == " " || char == " \t " ) { // Only count spaces and tabs
298
+ charCount += 1
299
+ byteLen += char. utf8. count
300
+ } else {
301
+ break
77
302
}
303
+ }
304
+ return charCount > 0 ? TrailingWhitespaceInfo ( characterCount: charCount, byteLength: byteLen) : nil
305
+ }
78
306
79
- if line. content != correctedLine {
80
- numberOfCorrections += 1
307
+ func trimmingTrailingCharacters( in characterSet: CharacterSet ) -> String {
308
+ var end = endIndex
309
+ while end > startIndex {
310
+ let index = index ( before: end)
311
+ if !characterSet. contains ( self [ index] . unicodeScalars. first!) {
312
+ break
81
313
}
82
- correctedLines . append ( correctedLine )
314
+ end = index
83
315
}
84
- if numberOfCorrections > 0 {
85
- // join and re-add trailing newline
86
- file. write ( correctedLines. joined ( separator: " \n " ) + " \n " )
316
+ return String ( self [ ..< end] )
317
+ }
318
+ }
319
+
320
+ private extension TriviaPiece {
321
+ var isBlockComment : Bool {
322
+ switch self {
323
+ case . blockComment, . docBlockComment:
324
+ return true
325
+ default :
326
+ return false
87
327
}
88
- return numberOfCorrections
89
328
}
90
329
}
0 commit comments