Skip to content

Commit f7f3caa

Browse files
authored
Migrate TrailingWhitespaceRule from SourceKit to SwiftSyntax (#6117)
## Summary Convert TrailingWhitespaceRule to use SwiftSyntax instead of SourceKit for improved performance and better handling of trailing whitespace detection, especially within block comments. ## Key Technical Improvements - **Enhanced block comment detection** distinguishing between lines fully covered by block comments vs lines containing block comments with code - **Accurate whitespace detection** using CharacterSet.whitespaces for all Unicode whitespace characters, not just space and tab - **Improved comment handling** with proper detection of line-ending comments and multi-line block comment structures - **Better correction mechanism** using ViolationCorrection ranges instead of manual string reconstruction - **Line-based analysis** maintaining efficiency while providing precise violation positions ## Migration Details - Replaced `CorrectableRule` with `@SwiftSyntaxRule(correctable: true)` - Implemented `ViolationsSyntaxVisitor` pattern for line-based validation - Added `collectLinesFullyCoveredByBlockComments` to properly handle test framework comment wrapping scenarios - Distinguished between three comment scenarios: lines fully within block comments, full-line comments, and lines ending with comments - Maintained all configuration options (ignores_empty_lines, ignores_comments) - Preserved exact violation position reporting with UTF-8 offset calculations
1 parent ab7d117 commit f7f3caa

File tree

2 files changed

+290
-50
lines changed

2 files changed

+290
-50
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
* `file_header`
3636
* `file_length`
3737
* `line_length`
38+
* `trailing_whitespace`
3839
* `vertical_whitespace`
3940
<!-- Keep empty line to have the contributors on a separate line. -->
4041
[JP Simard](https://github.com/jpsim)
Lines changed: 289 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import 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

Comments
 (0)