Skip to content

Commit 3821d9b

Browse files
committed
Migrate VerticalWhitespaceClosingBracesRule to SwiftSyntax
## Summary Convert VerticalWhitespaceClosingBracesRule to use SwiftSyntax instead of SourceKit for improved performance and better detection of empty lines before closing braces. ## Key Technical Improvements - **Enhanced trivia analysis** for accurate empty line detection before closing braces - **Proper position calculation** using positionAfterSkippingLeadingTrivia for line numbers - **Improved trivial line detection** supporting any combination of closing braces (`}`, `]`, `)`) - **SwiftSyntax visitor pattern** replacing regex-based detection for better accuracy - **Unified correction logic** handling all newline types (LF, CR, CRLF) consistently ## Migration Details - Replaced `CorrectableRule, OptInRule` with `@SwiftSyntaxRule(correctable: true, optIn: true)` - Implemented `ViolationsSyntaxVisitor` for detecting violations in token leading trivia - Implemented `ViolationsSyntaxRewriter` for correcting violations by modifying trivia - Added proper handling of `only_enforce_before_trivial_lines` configuration option - Maintained exact position reporting for violation locations - Preserved all existing test cases and rule behavior
1 parent 81474e3 commit 3821d9b

File tree

2 files changed

+327
-36
lines changed

2 files changed

+327
-36
lines changed

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,44 @@
2929
* Migrate `vertical_whitespace` rule from SourceKit to SwiftSyntax for improved performance.
3030
[Matt Pennig](https://github.com/pennig)
3131

32+
* Migrate `file_header` rule from SourceKit to SwiftSyntax for improved
33+
* Migrate `closure_end_indentation` rule from SourceKit to SwiftSyntax for improved
34+
* Migrate `accessibility_label_for_image` rule from SourceKit to SwiftSyntax for improved
35+
performance and fewer false positives.
36+
[JP Simard](https://github.com/jpsim)
37+
38+
* Migrate `accessibility_trait_for_button` rule from SourceKit to SwiftSyntax for improved
39+
performance and fewer false positives.
40+
[JP Simard](https://github.com/jpsim)
41+
42+
* Migrate `expiring_todo` rule from SourceKit to SwiftSyntax for improved performance
43+
and fewer false positives.
44+
[JP Simard](https://github.com/jpsim)
45+
46+
* Migrate `closure_end_indentation` rule from SourceKit to SwiftSyntax for improved
47+
performance and fewer false positives.
48+
[JP Simard](https://github.com/jpsim)
49+
50+
* Migrate `file_header` rule from SourceKit to SwiftSyntax for improved
51+
performance and fewer false positives.
52+
[JP Simard](https://github.com/jpsim)
53+
54+
* Migrate `line_length` rule from SourceKit to SwiftSyntax for improved
55+
performance and fewer false positives.
56+
[JP Simard](https://github.com/jpsim)
57+
58+
* Migrate `statement_position` rule from SourceKit to SwiftSyntax for improved
59+
performance and fewer false positives.
60+
[JP Simard](https://github.com/jpsim)
61+
62+
* Migrate `trailing_whitespace` rule from SourceKit to SwiftSyntax for improved
63+
performance and fewer false positives.
64+
[JP Simard](https://github.com/jpsim)
65+
66+
* Migrate `vertical_whitespace_closing_braces` rule from SourceKit to SwiftSyntax for improved
67+
performance and fewer false positives.
68+
[JP Simard](https://github.com/jpsim)
69+
3270
### Bug Fixes
3371

3472
* Improved error reporting when SwiftLint exits, because of an invalid configuration file
Lines changed: 289 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import Foundation
2-
import SourceKittenFramework
1+
import SwiftLintCore
2+
import SwiftSyntax
33

4-
struct VerticalWhitespaceClosingBracesRule: CorrectableRule, OptInRule {
4+
@SwiftSyntaxRule(correctable: true, optIn: true)
5+
struct VerticalWhitespaceClosingBracesRule: Rule {
56
var configuration = VerticalWhitespaceClosingBracesConfiguration()
67

78
static let description = RuleDescription(
@@ -14,52 +15,304 @@ struct VerticalWhitespaceClosingBracesRule: CorrectableRule, OptInRule {
1415
triggeringExamples: Array(VerticalWhitespaceClosingBracesRuleExamples.violatingToValidExamples.keys.sorted()),
1516
corrections: VerticalWhitespaceClosingBracesRuleExamples.violatingToValidExamples.removingViolationMarkers()
1617
)
18+
}
19+
20+
private struct TriviaAnalysis {
21+
var consecutiveNewlines = 0
22+
var violationStartPosition: AbsolutePosition?
23+
var violationEndPosition: AbsolutePosition?
24+
}
1725

18-
private let pattern = "((?:\\n[ \\t]*)+)(\\n[ \\t]*[)}\\]])"
19-
private let trivialLinePattern = "((?:\\n[ \\t]*)+)(\\n[ \\t)}\\]]*$)"
26+
private struct CorrectionState {
27+
var result = [TriviaPiece]()
28+
var consecutiveNewlines = 0
29+
var pendingWhitespace = [TriviaPiece]()
30+
var correctionCount = 0
31+
var hasViolation = false
32+
}
2033

21-
func validate(file: SwiftLintFile) -> [StyleViolation] {
22-
let pattern = configuration.onlyEnforceBeforeTrivialLines ? self.trivialLinePattern : self.pattern
34+
private struct NewlineProcessingContext {
35+
let currentPosition: AbsolutePosition
36+
let consecutiveNewlines: Int
37+
var violationStartPosition: AbsolutePosition?
38+
var violationEndPosition: AbsolutePosition?
39+
}
2340

24-
let patternRegex: NSRegularExpression = regex(pattern)
41+
private func isTokenLineTrivialHelper(
42+
for token: TokenSyntax,
43+
file: SwiftLintFile,
44+
locationConverter: SourceLocationConverter
45+
) -> Bool {
46+
let lineColumn = locationConverter.location(for: token.positionAfterSkippingLeadingTrivia)
47+
let line = lineColumn.line
2548

26-
return file.violatingRanges(for: pattern).map { violationRange in
27-
let substring = file.contents.substring(from: violationRange.location, length: violationRange.length)
28-
let matchResult = patternRegex.firstMatch(in: substring, options: [], range: substring.fullNSRange)!
29-
let violatingSubrange = matchResult.range(at: 1)
30-
let characterOffset = violationRange.location + violatingSubrange.location + 1
49+
guard let lineContent = file.lines.first(where: { $0.index == line })?.content else {
50+
return false
51+
}
3152

32-
return StyleViolation(
33-
ruleDescription: Self.description,
34-
severity: configuration.severityConfiguration.severity,
35-
location: Location(file: file, characterOffset: characterOffset)
53+
let trimmedLine = lineContent.trimmingCharacters(in: .whitespaces)
54+
let closingBraces: Set<Character> = ["]", "}", ")"]
55+
return !trimmedLine.isEmpty && trimmedLine.allSatisfy { closingBraces.contains($0) }
56+
}
57+
58+
private extension VerticalWhitespaceClosingBracesRule {
59+
final class Visitor: ViolationsSyntaxVisitor<VerticalWhitespaceClosingBracesConfiguration> {
60+
override func visitPost(_ node: TokenSyntax) {
61+
guard node.isClosingBrace else {
62+
return
63+
}
64+
65+
let triviaAnalysis = analyzeTriviaForViolations(
66+
trivia: node.leadingTrivia,
67+
token: node,
68+
position: node.position
3669
)
70+
71+
if let violation = triviaAnalysis {
72+
violations.append(
73+
ReasonedRuleViolation(
74+
position: violation.position,
75+
correction: .init(
76+
start: violation.position,
77+
end: violation.endPosition,
78+
replacement: ""
79+
)
80+
)
81+
)
82+
}
83+
}
84+
85+
private func analyzeTriviaForViolations(
86+
trivia: Trivia,
87+
token: TokenSyntax,
88+
position: AbsolutePosition
89+
) -> (position: AbsolutePosition, endPosition: AbsolutePosition)? {
90+
let analysis = analyzeTrivia(trivia: trivia, startPosition: position)
91+
92+
guard let startPos = analysis.violationStartPosition,
93+
let endPos = analysis.violationEndPosition,
94+
analysis.consecutiveNewlines >= 2 else {
95+
return nil
96+
}
97+
98+
if configuration.onlyEnforceBeforeTrivialLines &&
99+
!isTokenLineTrivialHelper(for: token, file: file, locationConverter: locationConverter) {
100+
return nil
101+
}
102+
103+
return (position: startPos, endPosition: endPos)
104+
}
105+
106+
private func analyzeTrivia(
107+
trivia: Trivia,
108+
startPosition: AbsolutePosition
109+
) -> TriviaAnalysis {
110+
var result = TriviaAnalysis()
111+
var currentPosition = startPosition
112+
113+
for piece in trivia {
114+
let (newlines, positionAdvance) = processTriviaPiece(
115+
piece: piece,
116+
currentPosition: currentPosition,
117+
consecutiveNewlines: result.consecutiveNewlines,
118+
violationStartPosition: &result.violationStartPosition,
119+
violationEndPosition: &result.violationEndPosition
120+
)
121+
result.consecutiveNewlines = newlines
122+
currentPosition = currentPosition.advanced(by: positionAdvance)
123+
}
124+
125+
return result
126+
}
127+
128+
private func processTriviaPiece(
129+
piece: TriviaPiece,
130+
currentPosition: AbsolutePosition,
131+
consecutiveNewlines: Int,
132+
violationStartPosition: inout AbsolutePosition?,
133+
violationEndPosition: inout AbsolutePosition?
134+
) -> (newlines: Int, positionAdvance: Int) {
135+
switch piece {
136+
case .newlines(let count), .carriageReturns(let count):
137+
var context = NewlineProcessingContext(
138+
currentPosition: currentPosition,
139+
consecutiveNewlines: consecutiveNewlines,
140+
violationStartPosition: violationStartPosition,
141+
violationEndPosition: violationEndPosition
142+
)
143+
let result = processNewlines(
144+
count: count,
145+
bytesPerNewline: 1,
146+
context: &context
147+
)
148+
violationStartPosition = context.violationStartPosition
149+
violationEndPosition = context.violationEndPosition
150+
return result
151+
case .carriageReturnLineFeeds(let count):
152+
var context = NewlineProcessingContext(
153+
currentPosition: currentPosition,
154+
consecutiveNewlines: consecutiveNewlines,
155+
violationStartPosition: violationStartPosition,
156+
violationEndPosition: violationEndPosition
157+
)
158+
let result = processNewlines(
159+
count: count,
160+
bytesPerNewline: 2,
161+
context: &context
162+
)
163+
violationStartPosition = context.violationStartPosition
164+
violationEndPosition = context.violationEndPosition
165+
return result
166+
case .spaces, .tabs:
167+
return (consecutiveNewlines, piece.sourceLength.utf8Length)
168+
default:
169+
// Any other trivia breaks the sequence
170+
violationStartPosition = nil
171+
violationEndPosition = nil
172+
return (0, piece.sourceLength.utf8Length)
173+
}
37174
}
38-
}
39175

40-
func correct(file: SwiftLintFile) -> Int {
41-
let pattern = configuration.onlyEnforceBeforeTrivialLines ? self.trivialLinePattern : self.pattern
42-
let violatingRanges = file.ruleEnabled(violatingRanges: file.violatingRanges(for: pattern), for: self)
43-
guard violatingRanges.isNotEmpty else {
44-
return 0
176+
private func processNewlines(
177+
count: Int,
178+
bytesPerNewline: Int,
179+
context: inout NewlineProcessingContext
180+
) -> (newlines: Int, positionAdvance: Int) {
181+
var newConsecutiveNewlines = context.consecutiveNewlines
182+
var totalAdvance = 0
183+
184+
for _ in 0..<count {
185+
newConsecutiveNewlines += 1
186+
// violationStartPosition marks the beginning of the first newline
187+
// that constitutes an empty line (i.e., the second in a sequence of \n\n).
188+
if newConsecutiveNewlines == 2 && context.violationStartPosition == nil {
189+
context.violationStartPosition = context.currentPosition.advanced(by: totalAdvance)
190+
}
191+
// violationEndPosition tracks the end of the last newline in any sequence of >= 2 newlines.
192+
if newConsecutiveNewlines >= 2 {
193+
context.violationEndPosition = context.currentPosition.advanced(by: totalAdvance + bytesPerNewline)
194+
}
195+
totalAdvance += bytesPerNewline
196+
}
197+
198+
return (newConsecutiveNewlines, totalAdvance)
45199
}
46-
let patternRegex = regex(pattern)
47-
var fileContents = file.contents
48-
for violationRange in violatingRanges.reversed() {
49-
fileContents = patternRegex.stringByReplacingMatches(
50-
in: fileContents,
51-
options: [],
52-
range: violationRange,
53-
withTemplate: "$2"
200+
}
201+
202+
final class Rewriter: ViolationsSyntaxRewriter<VerticalWhitespaceClosingBracesConfiguration> {
203+
override func visit(_ token: TokenSyntax) -> TokenSyntax {
204+
guard token.isClosingBrace else {
205+
return super.visit(token)
206+
}
207+
208+
let correctedTrivia = correctTrivia(
209+
trivia: token.leadingTrivia,
210+
token: token
54211
)
212+
213+
if correctedTrivia.hasCorrections {
214+
numberOfCorrections += correctedTrivia.correctionCount
215+
return super.visit(token.with(\.leadingTrivia, correctedTrivia.trivia))
216+
}
217+
218+
return super.visit(token)
219+
}
220+
221+
private func correctTrivia(
222+
trivia: Trivia,
223+
token: TokenSyntax
224+
) -> (trivia: Trivia, hasCorrections: Bool, correctionCount: Int) {
225+
// First check if we should apply corrections
226+
if configuration.onlyEnforceBeforeTrivialLines &&
227+
!isTokenLineTrivialHelper(for: token, file: file, locationConverter: locationConverter) {
228+
return (trivia: trivia, hasCorrections: false, correctionCount: 0)
229+
}
230+
231+
var state = CorrectionState()
232+
233+
for piece in trivia {
234+
processPieceForCorrection(piece: piece, state: &state)
235+
}
236+
237+
// Add any remaining whitespace
238+
state.result.append(contentsOf: state.pendingWhitespace)
239+
240+
return (trivia: Trivia(pieces: state.result),
241+
hasCorrections: state.correctionCount > 0,
242+
correctionCount: state.correctionCount)
243+
}
244+
245+
private func processPieceForCorrection(piece: TriviaPiece, state: inout CorrectionState) {
246+
switch piece {
247+
case .newlines(let count), .carriageReturns(let count):
248+
let newlineCreator = piece.isNewline ? TriviaPiece.newlines : TriviaPiece.carriageReturns
249+
processNewlinesForCorrection(
250+
count: count,
251+
newlineCreator: { newlineCreator($0) },
252+
state: &state
253+
)
254+
case .carriageReturnLineFeeds(let count):
255+
processNewlinesForCorrection(
256+
count: count,
257+
newlineCreator: { TriviaPiece.carriageReturnLineFeeds($0) },
258+
state: &state
259+
)
260+
case .spaces, .tabs:
261+
// Only keep whitespace if we haven't seen a violation yet
262+
if !state.hasViolation {
263+
state.pendingWhitespace.append(piece)
264+
}
265+
default:
266+
// Other trivia breaks the sequence
267+
state.consecutiveNewlines = 0
268+
state.hasViolation = false
269+
state.result.append(contentsOf: state.pendingWhitespace)
270+
state.result.append(piece)
271+
state.pendingWhitespace.removeAll()
272+
}
273+
}
274+
275+
private func processNewlinesForCorrection(
276+
count: Int,
277+
newlineCreator: (Int) -> TriviaPiece,
278+
state: inout CorrectionState
279+
) {
280+
for _ in 0..<count {
281+
state.consecutiveNewlines += 1
282+
if state.consecutiveNewlines == 1 {
283+
// First newline - always keep it with any preceding whitespace
284+
state.result.append(contentsOf: state.pendingWhitespace)
285+
state.result.append(newlineCreator(1))
286+
state.pendingWhitespace.removeAll()
287+
} else {
288+
// Additional newlines - these form empty lines and should be removed
289+
state.hasViolation = true
290+
state.correctionCount += 1
291+
state.pendingWhitespace.removeAll()
292+
}
293+
}
55294
}
56-
file.write(fileContents)
57-
return violatingRanges.count
58295
}
59296
}
60297

61-
private extension SwiftLintFile {
62-
func violatingRanges(for pattern: String) -> [NSRange] {
63-
match(pattern: pattern, excludingSyntaxKinds: SyntaxKind.commentAndStringKinds)
298+
private extension TokenSyntax {
299+
var isClosingBrace: Bool {
300+
switch tokenKind {
301+
case .rightBrace, .rightParen, .rightSquare:
302+
return true
303+
default:
304+
return false
305+
}
306+
}
307+
}
308+
309+
private extension TriviaPiece {
310+
var isNewline: Bool {
311+
switch self {
312+
case .newlines, .carriageReturns, .carriageReturnLineFeeds:
313+
return true
314+
default:
315+
return false
316+
}
64317
}
65318
}

0 commit comments

Comments
 (0)