|
1 | 1 | import Foundation |
2 | 2 | import SourceKittenFramework |
| 3 | +import SwiftSyntax |
3 | 4 |
|
4 | | -struct FileHeaderRule: OptInRule { |
| 5 | +@SwiftSyntaxRule(optIn: true) |
| 6 | +struct FileHeaderRule: Rule { |
5 | 7 | var configuration = FileHeaderConfiguration() |
6 | 8 |
|
7 | 9 | static let description = RuleDescription( |
@@ -30,80 +32,200 @@ struct FileHeaderRule: OptInRule { |
30 | 32 | """), |
31 | 33 | ].skipWrappingInCommentTests() |
32 | 34 | ) |
| 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 | + } |
33 | 64 |
|
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 |
45 | 82 | } |
46 | 83 |
|
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: ¤tPosition, |
| 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: ¤tPosition, |
| 109 | + firstStart: &firstHeaderCommentStart, |
| 110 | + lastEnd: &lastHeaderCommentEnd) |
| 111 | + } |
50 | 112 | } |
51 | 113 |
|
52 | | - if firstToken == nil { |
53 | | - firstToken = token |
| 114 | + guard let start = firstHeaderCommentStart, |
| 115 | + let end = lastHeaderCommentEnd, |
| 116 | + start < end else { |
| 117 | + return nil |
54 | 118 | } |
55 | | - lastToken = token |
| 119 | + |
| 120 | + return (start: start, end: end) |
56 | 121 | } |
57 | 122 |
|
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 |
59 | 130 |
|
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 | + } |
67 | 143 | } |
| 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 | + } |
68 | 155 |
|
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 |
72 | 166 | } |
73 | 167 |
|
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 |
78 | 188 | } |
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 | + )) |
84 | 194 | } |
85 | 195 |
|
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 | + } |
88 | 200 |
|
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" |
92 | 203 | } |
93 | 204 |
|
94 | | - return file.commands(in: range).isNotEmpty |
| 205 | + private func requiredReason() -> String { |
| 206 | + "Header comments should be consistent with project patterns" |
| 207 | + } |
95 | 208 | } |
| 209 | +} |
96 | 210 |
|
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 | + } |
102 | 220 | } |
103 | | -} |
104 | 221 |
|
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 | + } |
108 | 230 | } |
109 | 231 | } |
0 commit comments