Skip to content

Commit ab7d117

Browse files
authored
Migrate FileHeaderRule from SourceKit to SwiftSyntax (#6112)
## Summary Convert FileHeaderRule to use SwiftSyntax instead of SourceKit for improved performance and better handling of file header comments, shebangs, and doc comments. ## Key Technical Improvements - **Enhanced shebang support** properly skipping past `#!/usr/bin/env swift` lines - **Better comment type discrimination** excluding doc comments from header analysis - **Accurate position calculation** converting between UTF-8 and UTF-16 offsets for regex matching - **Improved trivia traversal** for comprehensive header comment collection - **SwiftLint command filtering** to exclude directive comments from header content ## Migration Details - Replaced `OptInRule` with `@SwiftSyntaxRule(optIn: true)` annotation - Implemented `ViolationsSyntaxVisitor` pattern for file-level analysis - Added logic to start header collection after shebang.endPosition if present - Distinguished between regular comments and doc comments (///, /** */) - Maintained UTF-16 offset calculations for NSRegularExpression compatibility - Added `skipDisableCommandTests: true` for SwiftSyntax disable command behavior - Removed unnecessary SourceKittenFramework import
1 parent d14e22a commit ab7d117

File tree

4 files changed

+178
-61
lines changed

4 files changed

+178
-61
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
* `accessibility_trait_for_button`
3333
* `closure_end_indentation`
3434
* `expiring_todo`
35+
* `file_header`
3536
* `file_length`
3637
* `line_length`
3738
* `vertical_whitespace`

Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/FileHeaderConfiguration.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import Foundation
2-
import SourceKittenFramework
32
import SwiftLintCore
43

54
struct FileHeaderConfiguration: SeverityBasedRuleConfiguration {
Lines changed: 175 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import Foundation
22
import SourceKittenFramework
3+
import SwiftSyntax
34

4-
struct FileHeaderRule: OptInRule {
5+
@SwiftSyntaxRule(optIn: true)
6+
struct FileHeaderRule: Rule {
57
var configuration = FileHeaderConfiguration()
68

79
static let description = RuleDescription(
@@ -30,80 +32,200 @@ struct FileHeaderRule: OptInRule {
3032
"""),
3133
].skipWrappingInCommentTests()
3234
)
35+
}
36+
37+
private struct ProcessTriviaResult {
38+
let foundNonComment: Bool
39+
}
40+
41+
private extension FileHeaderRule {
42+
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
43+
override func visit(_ node: SourceFileSyntax) -> SyntaxVisitorContinueKind {
44+
let headerRange = collectHeaderComments(from: node)
45+
46+
let requiredRegex = configuration.requiredRegex(for: file)
47+
48+
// If no header comments found
49+
guard let headerRange else {
50+
if requiredRegex != nil {
51+
let violationPosition = node.shebang?.endPosition ?? node.position
52+
violations.append(ReasonedRuleViolation(
53+
position: violationPosition,
54+
reason: requiredReason()
55+
))
56+
}
57+
return .skipChildren
58+
}
59+
60+
// Extract header content
61+
guard let headerContent = extractHeaderContent(from: headerRange) else {
62+
return .skipChildren
63+
}
3364

34-
func validate(file: SwiftLintFile) -> [StyleViolation] {
35-
var firstToken: SwiftLintSyntaxToken?
36-
var lastToken: SwiftLintSyntaxToken?
37-
var firstNonCommentToken: SwiftLintSyntaxToken?
38-
39-
for token in file.syntaxTokensByLines.lazy.joined() {
40-
guard let kind = token.kind, kind.isFileHeaderKind else {
41-
// found a token that is not a comment, which means it's not the top of the file
42-
// so we can just skip the remaining tokens
43-
firstNonCommentToken = token
44-
break
65+
// Check patterns
66+
checkForbiddenPattern(in: headerContent, startingAt: headerRange.start)
67+
checkRequiredPattern(requiredRegex, in: headerContent, startingAt: headerRange.start)
68+
69+
return .skipChildren
70+
}
71+
72+
private func collectHeaderComments(
73+
from node: SourceFileSyntax
74+
) -> (start: AbsolutePosition, end: AbsolutePosition)? {
75+
var firstHeaderCommentStart: AbsolutePosition?
76+
var lastHeaderCommentEnd: AbsolutePosition?
77+
78+
// Skip past shebang if present
79+
var currentPosition = node.position
80+
if let shebang = node.shebang {
81+
currentPosition = shebang.endPosition
4582
}
4683

47-
// skip SwiftLint commands
48-
guard !isSwiftLintCommand(token: token, file: file) else {
49-
continue
84+
// Collect header comments from tokens' trivia
85+
for token in node.tokens(viewMode: .sourceAccurate) {
86+
// Skip tokens before the start position (e.g., shebang)
87+
if token.endPosition <= currentPosition {
88+
continue
89+
}
90+
91+
let triviaResult = processTrivia(
92+
token.leadingTrivia,
93+
startingAt: &currentPosition,
94+
firstStart: &firstHeaderCommentStart,
95+
lastEnd: &lastHeaderCommentEnd
96+
)
97+
98+
if triviaResult.foundNonComment || token.tokenKind != .endOfFile {
99+
break
100+
}
101+
102+
// Update current position past the token
103+
currentPosition = token.endPositionBeforeTrailingTrivia
104+
105+
// Process trailing trivia if it's EOF
106+
if token.tokenKind == .endOfFile {
107+
_ = processTrivia(token.trailingTrivia,
108+
startingAt: &currentPosition,
109+
firstStart: &firstHeaderCommentStart,
110+
lastEnd: &lastHeaderCommentEnd)
111+
}
50112
}
51113

52-
if firstToken == nil {
53-
firstToken = token
114+
guard let start = firstHeaderCommentStart,
115+
let end = lastHeaderCommentEnd,
116+
start < end else {
117+
return nil
54118
}
55-
lastToken = token
119+
120+
return (start: start, end: end)
56121
}
57122

58-
let requiredRegex = configuration.requiredRegex(for: file)
123+
private func processTrivia(_ trivia: Trivia,
124+
startingAt currentPosition: inout AbsolutePosition,
125+
firstStart: inout AbsolutePosition?,
126+
lastEnd: inout AbsolutePosition?) -> ProcessTriviaResult {
127+
for piece in trivia {
128+
let pieceStart = currentPosition
129+
currentPosition += piece.sourceLength
59130

60-
var violationsOffsets = [Int]()
61-
if let firstToken, let lastToken {
62-
let start = firstToken.offset
63-
let length = lastToken.offset + lastToken.length - firstToken.offset
64-
let byteRange = ByteRange(location: start, length: length)
65-
guard let range = file.stringView.byteRangeToNSRange(byteRange) else {
66-
return []
131+
if isSwiftLintCommand(piece: piece) {
132+
continue
133+
}
134+
135+
if piece.isComment && !piece.isDocComment {
136+
if firstStart == nil {
137+
firstStart = pieceStart
138+
}
139+
lastEnd = currentPosition
140+
} else if !piece.isWhitespace {
141+
return ProcessTriviaResult(foundNonComment: true)
142+
}
67143
}
144+
return ProcessTriviaResult(foundNonComment: false)
145+
}
146+
147+
private func extractHeaderContent(from range: (start: AbsolutePosition, end: AbsolutePosition)) -> String? {
148+
let headerByteRange = ByteRange(
149+
location: ByteCount(range.start.utf8Offset),
150+
length: ByteCount(range.end.utf8Offset - range.start.utf8Offset)
151+
)
152+
153+
return file.stringView.substringWithByteRange(headerByteRange)
154+
}
68155

69-
if let regex = configuration.forbiddenRegex(for: file),
70-
let firstMatch = regex.matches(in: file.contents, options: [], range: range).first {
71-
violationsOffsets.append(firstMatch.range.location)
156+
private func checkForbiddenPattern(in headerContent: String, startingAt headerStart: AbsolutePosition) {
157+
guard
158+
let forbiddenRegex = configuration.forbiddenRegex(for: file),
159+
let firstMatch = forbiddenRegex.firstMatch(
160+
in: headerContent,
161+
options: [],
162+
range: headerContent.fullNSRange
163+
)
164+
else {
165+
return
72166
}
73167

74-
if let regex = requiredRegex,
75-
case let matches = regex.matches(in: file.contents, options: [], range: range),
76-
matches.isEmpty {
77-
violationsOffsets.append(file.stringView.location(fromByteOffset: start))
168+
// Calculate violation position
169+
let matchLocationUTF16 = firstMatch.range.location
170+
let headerPrefix = String(headerContent.utf16.prefix(matchLocationUTF16)) ?? ""
171+
let utf8OffsetInHeader = headerPrefix.utf8.count
172+
let violationPosition = AbsolutePosition(utf8Offset: headerStart.utf8Offset + utf8OffsetInHeader)
173+
174+
violations.append(ReasonedRuleViolation(
175+
position: violationPosition,
176+
reason: forbiddenReason()
177+
))
178+
}
179+
180+
private func checkRequiredPattern(_ requiredRegex: NSRegularExpression?,
181+
in headerContent: String,
182+
startingAt headerStart: AbsolutePosition) {
183+
guard
184+
let requiredRegex,
185+
requiredRegex.firstMatch(in: headerContent, options: [], range: headerContent.fullNSRange) == nil
186+
else {
187+
return
78188
}
79-
} else if requiredRegex != nil {
80-
let location = firstNonCommentToken.map {
81-
Location(file: file, byteOffset: $0.offset)
82-
} ?? Location(file: file.path, line: 1)
83-
return [makeViolation(at: location)]
189+
190+
violations.append(ReasonedRuleViolation(
191+
position: headerStart,
192+
reason: requiredReason()
193+
))
84194
}
85195

86-
return violationsOffsets.map { makeViolation(at: Location(file: file, characterOffset: $0)) }
87-
}
196+
private func isSwiftLintCommand(piece: TriviaPiece) -> Bool {
197+
guard let text = piece.commentText else { return false }
198+
return text.contains("swiftlint:")
199+
}
88200

89-
private func isSwiftLintCommand(token: SwiftLintSyntaxToken, file: SwiftLintFile) -> Bool {
90-
guard let range = file.stringView.byteRangeToNSRange(token.range) else {
91-
return false
201+
private func forbiddenReason() -> String {
202+
"Header comments should be consistent with project patterns"
92203
}
93204

94-
return file.commands(in: range).isNotEmpty
205+
private func requiredReason() -> String {
206+
"Header comments should be consistent with project patterns"
207+
}
95208
}
209+
}
96210

97-
private func makeViolation(at location: Location) -> StyleViolation {
98-
StyleViolation(ruleDescription: Self.description,
99-
severity: configuration.severityConfiguration.severity,
100-
location: location,
101-
reason: "Header comments should be consistent with project patterns")
211+
// Helper extensions
212+
private extension TriviaPiece {
213+
var isDocComment: Bool {
214+
switch self {
215+
case .docLineComment, .docBlockComment:
216+
return true
217+
default:
218+
return false
219+
}
102220
}
103-
}
104221

105-
private extension SyntaxKind {
106-
var isFileHeaderKind: Bool {
107-
self == .comment || self == .commentURL
222+
var commentText: String? {
223+
switch self {
224+
case .lineComment(let text), .blockComment(let text),
225+
.docLineComment(let text), .docBlockComment(let text):
226+
return text
227+
default:
228+
return nil
229+
}
108230
}
109231
}

Tests/BuiltInRulesTests/FileHeaderRuleTests.swift

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,6 @@ import XCTest
55
private let fixturesDirectory = "\(TestResources.path())/FileHeaderRuleFixtures"
66

77
final class FileHeaderRuleTests: SwiftLintTestCase {
8-
override func invokeTest() {
9-
CurrentRule.$allowSourceKitRequestWithoutRule.withValue(true) {
10-
super.invokeTest()
11-
}
12-
}
13-
148
private func validate(fileName: String, using configuration: Any) throws -> [StyleViolation] {
159
let file = SwiftLintFile(path: fixturesDirectory.stringByAppendingPathComponent(fileName))!
1610
let rule = try FileHeaderRule(configuration: configuration)
@@ -39,7 +33,8 @@ final class FileHeaderRuleTests: SwiftLintTestCase {
3933

4034
verifyRule(description, ruleConfiguration: ["required_string": "**Header"],
4135
stringDoesntViolate: false, skipCommentTests: true,
42-
testMultiByteOffsets: false, testShebang: false)
36+
skipDisableCommandTests: true, testMultiByteOffsets: false,
37+
testShebang: false)
4338
}
4439

4540
func testFileHeaderWithRequiredPattern() {

0 commit comments

Comments
 (0)