Skip to content

Commit d22e733

Browse files
authored
Add SwiftSyntaxKindBridge to help migrate custom rules to SwiftSyntax (#6126)
This provides an alternative to getting syntax kinds from SourceKit. The mappings aren't 100% equivalent, but this should serve as a useful compatibility layer.
1 parent 5a2cf4b commit d22e733

File tree

3 files changed

+242
-0
lines changed

3 files changed

+242
-0
lines changed

Source/SwiftLintCore/Extensions/SwiftLintFile+Cache.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ private let syntaxClassificationsCache = Cache { $0.syntaxTree.classifications }
4949
private let syntaxKindsByLinesCache = Cache { $0.syntaxKindsByLine() }
5050
private let syntaxTokensByLinesCache = Cache { $0.syntaxTokensByLine() }
5151
private let linesWithTokensCache = Cache { $0.computeLinesWithTokens() }
52+
private let swiftSyntaxTokensCache = Cache { file -> [SwiftLintSyntaxToken]? in
53+
// Use SwiftSyntaxKindBridge to derive SourceKitten-compatible tokens from SwiftSyntax
54+
SwiftSyntaxKindBridge.sourceKittenSyntaxKinds(for: file)
55+
}
5256

5357
package typealias AssertHandler = () -> Void
5458
// Re-enable once all parser diagnostics in tests have been addressed.
@@ -190,6 +194,10 @@ extension SwiftLintFile {
190194
return syntaxKindsByLines
191195
}
192196

197+
public var swiftSyntaxDerivedSourceKittenTokens: [SwiftLintSyntaxToken]? {
198+
swiftSyntaxTokensCache.get(self)
199+
}
200+
193201
/// Invalidates all cached data for this file.
194202
public func invalidateCache() {
195203
file.clearCaches()
@@ -200,6 +208,7 @@ extension SwiftLintFile {
200208
syntaxMapCache.invalidate(self)
201209
syntaxTokensByLinesCache.invalidate(self)
202210
syntaxKindsByLinesCache.invalidate(self)
211+
swiftSyntaxTokensCache.invalidate(self)
203212
syntaxTreeCache.invalidate(self)
204213
foldedSyntaxTreeCache.invalidate(self)
205214
locationConverterCache.invalidate(self)
@@ -215,6 +224,7 @@ extension SwiftLintFile {
215224
syntaxMapCache.clear()
216225
syntaxTokensByLinesCache.clear()
217226
syntaxKindsByLinesCache.clear()
227+
swiftSyntaxTokensCache.clear()
218228
syntaxTreeCache.clear()
219229
foldedSyntaxTreeCache.clear()
220230
locationConverterCache.clear()
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import SourceKittenFramework
2+
import SwiftIDEUtils
3+
import SwiftSyntax
4+
5+
/// Bridge to convert SwiftSyntax classifications to SourceKitten syntax kinds.
6+
/// This enables SwiftSyntax-based custom rules to work with kind filtering
7+
/// without making any SourceKit calls.
8+
public enum SwiftSyntaxKindBridge {
9+
/// Map a SwiftSyntax classification to SourceKitten syntax kind.
10+
static func mapClassification(_ classification: SyntaxClassification) -> SourceKittenFramework.SyntaxKind? {
11+
// swiftlint:disable:previous cyclomatic_complexity
12+
switch classification {
13+
case .attribute:
14+
return .attributeID
15+
case .blockComment, .lineComment:
16+
return .comment
17+
case .docBlockComment, .docLineComment:
18+
return .docComment
19+
case .dollarIdentifier, .identifier:
20+
return .identifier
21+
case .editorPlaceholder:
22+
return .placeholder
23+
case .floatLiteral, .integerLiteral:
24+
return .number
25+
case .ifConfigDirective:
26+
return .poundDirectiveKeyword
27+
case .keyword:
28+
return .keyword
29+
case .none, .regexLiteral:
30+
return nil
31+
case .operator:
32+
return .operator
33+
case .stringLiteral:
34+
return .string
35+
case .type:
36+
return .typeidentifier
37+
case .argumentLabel:
38+
return .argument
39+
@unknown default:
40+
return nil
41+
}
42+
}
43+
44+
/// Convert SwiftSyntax syntax classifications to SourceKitten-compatible syntax tokens.
45+
public static func sourceKittenSyntaxKinds(for file: SwiftLintFile) -> [SwiftLintSyntaxToken] {
46+
file.syntaxClassifications.compactMap { classifiedRange in
47+
guard let syntaxKind = mapClassification(classifiedRange.kind) else {
48+
return nil
49+
}
50+
51+
let byteRange = classifiedRange.range.toSourceKittenByteRange()
52+
let token = SyntaxToken(
53+
type: syntaxKind.rawValue,
54+
offset: byteRange.location,
55+
length: byteRange.length
56+
)
57+
58+
return SwiftLintSyntaxToken(value: token)
59+
}
60+
}
61+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import SourceKittenFramework
2+
import SwiftIDEUtils
3+
@testable import SwiftLintCore
4+
import SwiftSyntax
5+
import TestHelpers
6+
import XCTest
7+
8+
final class SwiftSyntaxKindBridgeTests: SwiftLintTestCase {
9+
func testBasicKeywordMapping() {
10+
// Test basic keyword mappings
11+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.keyword), .keyword)
12+
}
13+
14+
func testIdentifierMapping() {
15+
// Test identifier mappings
16+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.identifier), .identifier)
17+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.dollarIdentifier), .identifier)
18+
}
19+
20+
func testCommentMapping() {
21+
// Test comment mappings
22+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.lineComment), .comment)
23+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.blockComment), .comment)
24+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.docLineComment), .docComment)
25+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.docBlockComment), .docComment)
26+
}
27+
28+
func testLiteralMapping() {
29+
// Test literal mappings
30+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.stringLiteral), .string)
31+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.integerLiteral), .number)
32+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.floatLiteral), .number)
33+
}
34+
35+
func testOperatorAndTypeMapping() {
36+
// Test operator and type mappings
37+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.operator), .operator)
38+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.type), .typeidentifier)
39+
}
40+
41+
func testSpecialCaseMapping() {
42+
// Test special case mappings
43+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.attribute), .attributeID)
44+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.editorPlaceholder), .placeholder)
45+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.ifConfigDirective), .poundDirectiveKeyword)
46+
XCTAssertEqual(SwiftSyntaxKindBridge.mapClassification(.argumentLabel), .argument)
47+
}
48+
49+
func testUnmappedClassifications() {
50+
// Test classifications that have no mapping
51+
XCTAssertNil(SwiftSyntaxKindBridge.mapClassification(.none))
52+
XCTAssertNil(SwiftSyntaxKindBridge.mapClassification(.regexLiteral))
53+
}
54+
55+
func testSourceKittenSyntaxKindsGeneration() {
56+
// Test that we can generate SourceKitten-compatible tokens from a simple Swift file
57+
let contents = """
58+
// This is a comment
59+
let x = 42
60+
"""
61+
let file = SwiftLintFile(contents: contents)
62+
63+
// Get the tokens from the bridge
64+
let tokens = SwiftSyntaxKindBridge.sourceKittenSyntaxKinds(for: file)
65+
66+
// Verify we got some tokens
67+
XCTAssertFalse(tokens.isEmpty)
68+
69+
// Check that we have expected token types
70+
let tokenTypes = Set(tokens.map { $0.value.type })
71+
XCTAssertTrue(tokenTypes.contains(SyntaxKind.comment.rawValue))
72+
XCTAssertTrue(tokenTypes.contains(SyntaxKind.keyword.rawValue))
73+
XCTAssertTrue(tokenTypes.contains(SyntaxKind.identifier.rawValue))
74+
XCTAssertTrue(tokenTypes.contains(SyntaxKind.number.rawValue))
75+
}
76+
77+
func testTokenOffsetAndLength() {
78+
// Test that token offsets and lengths are correct
79+
let contents = "let x = 42"
80+
let file = SwiftLintFile(contents: contents)
81+
82+
let tokens = SwiftSyntaxKindBridge.sourceKittenSyntaxKinds(for: file)
83+
84+
// Find the "let" keyword token
85+
let letToken = tokens.first { token in
86+
if token.value.type == SyntaxKind.keyword.rawValue {
87+
let start = token.value.offset.value
88+
let end = token.value.offset.value + token.value.length.value
89+
let startIndex = contents.index(contents.startIndex, offsetBy: start)
90+
let endIndex = contents.index(contents.startIndex, offsetBy: end)
91+
let substring = String(contents[startIndex..<endIndex])
92+
return substring == "let"
93+
}
94+
return false
95+
}
96+
XCTAssertNotNil(letToken)
97+
XCTAssertEqual(letToken?.value.offset.value, 0)
98+
XCTAssertEqual(letToken?.value.length.value, 3)
99+
100+
// Find the number token
101+
let numberToken = tokens.first { $0.value.type == SyntaxKind.number.rawValue }
102+
XCTAssertNotNil(numberToken)
103+
// "42" starts at offset 8 and has length 2
104+
XCTAssertEqual(numberToken?.value.offset.value, 8)
105+
XCTAssertEqual(numberToken?.value.length.value, 2)
106+
}
107+
108+
func testComplexCodeStructure() {
109+
// Test with more complex Swift code
110+
let contents = """
111+
import Foundation
112+
113+
/// A sample class
114+
@objc
115+
class MyClass {
116+
// Properties
117+
var name: String = "test"
118+
let id = UUID()
119+
120+
func doSomething() {
121+
print("Hello, \\(name)!")
122+
}
123+
}
124+
"""
125+
let file = SwiftLintFile(contents: contents)
126+
127+
let tokens = SwiftSyntaxKindBridge.sourceKittenSyntaxKinds(for: file)
128+
129+
// Verify we have various token types
130+
let tokenTypes = Set(tokens.map { $0.value.type })
131+
XCTAssertTrue(tokenTypes.contains(SyntaxKind.keyword.rawValue)) // import, class, var, let, func
132+
XCTAssertTrue(tokenTypes.contains(SyntaxKind.identifier.rawValue)) // Foundation, MyClass, name, etc.
133+
XCTAssertTrue(tokenTypes.contains(SyntaxKind.docComment.rawValue)) // /// A sample class
134+
XCTAssertTrue(tokenTypes.contains(SyntaxKind.comment.rawValue)) // // Properties
135+
XCTAssertTrue(tokenTypes.contains(SyntaxKind.attributeID.rawValue)) // @objc
136+
XCTAssertTrue(tokenTypes.contains(SyntaxKind.typeidentifier.rawValue)) // String, UUID
137+
XCTAssertTrue(tokenTypes.contains(SyntaxKind.string.rawValue)) // "test", "Hello, \\(name)!"
138+
}
139+
140+
func testNoSourceKitCallsAreMade() {
141+
// This test verifies that the bridge doesn't make any SourceKit calls
142+
// If it did, the validation system would fatal error in test mode
143+
144+
let contents = """
145+
struct Test {
146+
let value = 123
147+
func method() -> Int { return value }
148+
}
149+
"""
150+
let file = SwiftLintFile(contents: contents)
151+
152+
// This should succeed without any fatal errors from the validation system
153+
let tokens = SwiftSyntaxKindBridge.sourceKittenSyntaxKinds(for: file)
154+
XCTAssertFalse(tokens.isEmpty)
155+
}
156+
157+
func testEmptyFileHandling() {
158+
// Test that empty files are handled gracefully
159+
let file = SwiftLintFile(contents: "")
160+
let tokens = SwiftSyntaxKindBridge.sourceKittenSyntaxKinds(for: file)
161+
XCTAssertTrue(tokens.isEmpty)
162+
}
163+
164+
func testWhitespaceOnlyFile() {
165+
// Test files with only whitespace
166+
let file = SwiftLintFile(contents: " \n\n \t \n")
167+
let tokens = SwiftSyntaxKindBridge.sourceKittenSyntaxKinds(for: file)
168+
// Whitespace is not classified, so we should get no tokens
169+
XCTAssertTrue(tokens.isEmpty)
170+
}
171+
}

0 commit comments

Comments
 (0)