11import Foundation
2- import SourceKittenFramework
2+ import SwiftLintCore
3+ import SwiftSyntax
34
4- struct TrailingWhitespaceRule : CorrectableRule {
5+ @SwiftSyntaxRule ( correctable: true )
6+ struct TrailingWhitespaceRule : Rule {
57 var configuration = TrailingWhitespaceConfiguration ( )
68
79 static let description = RuleDescription (
@@ -14,77 +16,314 @@ struct TrailingWhitespaceRule: CorrectableRule {
1416 Example ( " let name: String // \n " ) , Example ( " let name: String // \n " ) ,
1517 ] ,
1618 triggeringExamples: [
17- Example ( " let name: String \n " ) , Example ( " /* */ let name: String \n " )
19+ Example ( " let name: String↓ \n " ) , Example ( " /* */ let name: String↓ \n " )
1820 ] ,
1921 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 " ) ,
2224 ]
2325 )
26+ }
2427
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 > ( )
2833
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)
3438 }
3539
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
3982 }
4083
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)
4592 }
46- }
4793
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+ }
56186 }
57187
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
64241 }
65242
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 }
68245
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+ }
72274 }
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+ }
73284
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
77302 }
303+ }
304+ return charCount > 0 ? TrailingWhitespaceInfo ( characterCount: charCount, byteLength: byteLen) : nil
305+ }
78306
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
81313 }
82- correctedLines . append ( correctedLine )
314+ end = index
83315 }
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
87327 }
88- return numberOfCorrections
89328 }
90329}
0 commit comments