Skip to content

Commit 388d452

Browse files
authored
Migrate ExpiringTodoRule from SourceKit to SwiftSyntax (#6113)
1 parent ac476aa commit 388d452

File tree

2 files changed

+148
-63
lines changed

2 files changed

+148
-63
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
* `accessibility_label_for_image`
2727
* `accessibility_trait_for_button`
2828
* `closure_end_indentation`
29+
* `expiring_todo`
2930
* `file_length`
3031
* `line_length`
3132
* `vertical_whitespace`
Lines changed: 147 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import Foundation
2-
import SourceKittenFramework
2+
import SwiftSyntax
33

4-
struct ExpiringTodoRule: OptInRule {
4+
@SwiftSyntaxRule(optIn: true)
5+
struct ExpiringTodoRule: Rule {
56
enum ExpiryViolationLevel {
67
case approachingExpiry
78
case expired
@@ -10,11 +11,11 @@ struct ExpiringTodoRule: OptInRule {
1011
var reason: String {
1112
switch self {
1213
case .approachingExpiry:
13-
return "TODO/FIXME is approaching its expiry and should be resolved soon"
14+
"TODO/FIXME is approaching its expiry and should be resolved soon"
1415
case .expired:
15-
return "TODO/FIXME has expired and must be resolved"
16+
"TODO/FIXME has expired and must be resolved"
1617
case .badFormatting:
17-
return "Expiring TODO/FIXME is incorrectly formatted"
18+
"Expiring TODO/FIXME is incorrectly formatted"
1819
}
1920
}
2021
}
@@ -45,73 +46,142 @@ struct ExpiringTodoRule: OptInRule {
4546
)
4647

4748
var configuration = ExpiringTodoConfiguration()
49+
}
4850

49-
func validate(file: SwiftLintFile) -> [StyleViolation] {
50-
let regex = #"""
51-
\b(?:TODO|FIXME)(?::|\b)(?:(?!\b(?:TODO|FIXME)(?::|\b)).)*?\#
52-
\\#(configuration.dateDelimiters.opening)\#
53-
(\d{1,4}\\#(configuration.dateSeparator)\d{1,4}\\#(configuration.dateSeparator)\d{1,4})\#
54-
\\#(configuration.dateDelimiters.closing)
55-
"""#
56-
57-
return file.matchesAndSyntaxKinds(matching: regex).compactMap { checkingResult, syntaxKinds in
58-
guard
59-
syntaxKinds.allSatisfy(\.isCommentLike),
60-
checkingResult.numberOfRanges > 1,
61-
case let range = checkingResult.range(at: 1),
62-
let violationLevel = violationLevel(for: expiryDate(file: file, range: range)),
63-
let severity = severity(for: violationLevel) else {
64-
return nil
51+
private extension ExpiringTodoRule {
52+
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
53+
private lazy var regex: NSRegularExpression = {
54+
let pattern = #"""
55+
\b(?:TODO|FIXME)(?::|\b)(?:(?!\b(?:TODO|FIXME)(?::|\b)).)*?\#
56+
\\#(configuration.dateDelimiters.opening)\#
57+
(\d{1,4}\\#(configuration.dateSeparator)\d{1,4}\\#(configuration.dateSeparator)\d{1,4})\#
58+
\\#(configuration.dateDelimiters.closing)
59+
"""#
60+
return SwiftLintCore.regex(pattern)
61+
}()
62+
63+
override func visit(_ node: SourceFileSyntax) -> SyntaxVisitorContinueKind {
64+
// Process each comment individually
65+
for token in node.tokens(viewMode: .sourceAccurate) {
66+
processTrivia(
67+
token.leadingTrivia,
68+
baseOffset: token.position.utf8Offset
69+
)
70+
processTrivia(
71+
token.trailingTrivia,
72+
baseOffset: token.endPositionBeforeTrailingTrivia.utf8Offset
73+
)
6574
}
6675

67-
return StyleViolation(
68-
ruleDescription: Self.description,
69-
severity: severity,
70-
location: Location(file: file, characterOffset: range.location),
71-
reason: violationLevel.reason
72-
)
76+
return .skipChildren
7377
}
74-
}
7578

76-
private func expiryDate(file: SwiftLintFile, range: NSRange) -> Date? {
77-
let expiryDateString = file.contents.bridge()
78-
.substring(with: range)
79-
.trimmingCharacters(in: .whitespacesAndNewlines)
79+
private func processTrivia(_ trivia: Trivia, baseOffset: Int) {
80+
var triviaOffset = baseOffset
8081

81-
let formatter = DateFormatter()
82-
formatter.calendar = .current
83-
formatter.dateFormat = configuration.dateFormat
82+
for (index, piece) in trivia.enumerated() {
83+
defer { triviaOffset += piece.sourceLength.utf8Length }
8484

85-
return formatter.date(from: expiryDateString)
86-
}
85+
guard let commentText = piece.commentText else { continue }
86+
87+
// Handle multiline comments by checking consecutive line comments
88+
if piece.isLineComment {
89+
var combinedText = commentText
90+
let currentOffset = triviaOffset
91+
92+
// Look ahead for consecutive line comments
93+
let remainingTrivia = trivia.dropFirst(index + 1)
8794

88-
private func severity(for violationLevel: ExpiryViolationLevel) -> ViolationSeverity? {
89-
switch violationLevel {
90-
case .approachingExpiry:
91-
return configuration.approachingExpirySeverity.severity
92-
case .expired:
93-
return configuration.expiredSeverity.severity
94-
case .badFormatting:
95-
return configuration.badFormattingSeverity.severity
95+
for nextPiece in remainingTrivia {
96+
if case .lineComment(let nextText) = nextPiece {
97+
// Check if it's a continuation (starts with //)
98+
if nextText.hasPrefix("//") {
99+
combinedText += "\n" + nextText
100+
} else {
101+
break
102+
}
103+
} else if !nextPiece.isWhitespace {
104+
break
105+
}
106+
}
107+
108+
processComment(combinedText, offset: currentOffset)
109+
} else {
110+
processComment(commentText, offset: triviaOffset)
111+
}
112+
}
113+
}
114+
115+
private func processComment(_ commentText: String, offset: Int) {
116+
let matches = regex.matches(in: commentText, options: [], range: commentText.fullNSRange)
117+
let nsStringComment = commentText.bridge()
118+
119+
for match in matches {
120+
guard match.numberOfRanges > 1 else { continue }
121+
122+
// Get the date capture group (second capture group, index 1)
123+
let dateRange = match.range(at: 1)
124+
guard dateRange.location != NSNotFound else { continue }
125+
126+
let matchOffset = offset + dateRange.location
127+
let matchPosition = AbsolutePosition(utf8Offset: matchOffset)
128+
129+
let dateString = nsStringComment.substring(with: dateRange)
130+
.trimmingCharacters(in: .whitespacesAndNewlines)
131+
132+
if let violationLevel = getViolationLevel(for: parseDate(dateString: dateString)),
133+
let severity = getSeverity(for: violationLevel) {
134+
let violation = ReasonedRuleViolation(
135+
position: matchPosition,
136+
reason: violationLevel.reason,
137+
severity: severity
138+
)
139+
violations.append(violation)
140+
}
141+
}
96142
}
97-
}
98143

99-
private func violationLevel(for expiryDate: Date?) -> ExpiryViolationLevel? {
100-
guard let expiryDate else {
101-
return .badFormatting
144+
private func parseDate(dateString: String) -> Date? {
145+
let formatter = DateFormatter()
146+
formatter.calendar = .current
147+
formatter.dateFormat = configuration.dateFormat
148+
return formatter.date(from: dateString)
102149
}
103-
guard expiryDate.isAfterToday else {
104-
return .expired
150+
151+
private func getSeverity(for violationLevel: ExpiryViolationLevel) -> ViolationSeverity? {
152+
switch violationLevel {
153+
case .approachingExpiry:
154+
configuration.approachingExpirySeverity.severity
155+
case .expired:
156+
configuration.expiredSeverity.severity
157+
case .badFormatting:
158+
configuration.badFormattingSeverity.severity
159+
}
105160
}
106-
guard let approachingDate = Calendar.current.date(
107-
byAdding: .day,
108-
value: -configuration.approachingExpiryThreshold,
109-
to: expiryDate) else {
161+
162+
private func getViolationLevel(for expiryDate: Date?) -> ExpiryViolationLevel? {
163+
guard let expiryDate else {
164+
return .badFormatting
165+
}
166+
167+
guard expiryDate.isAfterToday else {
168+
return .expired
169+
}
170+
171+
let approachingDate = Calendar.current.date(
172+
byAdding: .day,
173+
value: -configuration.approachingExpiryThreshold,
174+
to: expiryDate
175+
)
176+
177+
guard let approachingDate else {
110178
return nil
179+
}
180+
181+
return approachingDate.isAfterToday ?
182+
nil :
183+
.approachingExpiry
111184
}
112-
return approachingDate.isAfterToday ?
113-
nil :
114-
.approachingExpiry
115185
}
116186
}
117187

@@ -121,9 +191,23 @@ private extension Date {
121191
}
122192
}
123193

124-
private extension SyntaxKind {
125-
/// Returns if the syntax kind is comment-like.
126-
var isCommentLike: Bool {
127-
Self.commentKinds.contains(self)
128-
}
194+
private extension TriviaPiece {
195+
var isLineComment: Bool {
196+
switch self {
197+
case .lineComment, .docLineComment:
198+
true
199+
default:
200+
false
201+
}
202+
}
203+
204+
var commentText: String? {
205+
switch self {
206+
case .lineComment(let text), .blockComment(let text),
207+
.docLineComment(let text), .docBlockComment(let text):
208+
text
209+
default:
210+
nil
211+
}
212+
}
129213
}

0 commit comments

Comments
 (0)