Skip to content

Commit 601c496

Browse files
committed
Migrate Custom Rules from SourceKit to SwiftSyntax
**Breaking Change**: Custom rules now default to SwiftSyntax mode for pattern matching. This provides significant performance improvements but may have subtle behavioral differences from the previous SourceKit implementation. Users should test their custom rules and adjust as needed. A temporary `sourcekit` mode is available for rules that cannot be migrated yet. ## Breaking Change Details - **New Default**: Custom rules now use `swiftsyntax` mode by default - **Legacy Option**: Set `mode: sourcekit` or `default_execution_mode: sourcekit` to restore previous behavior temporarily - **Action Required**: Test your custom rules after upgrading and migrate to SwiftSyntax mode where possible ## Configuration Control execution mode at two levels: ```yaml custom_rules: # To restore previous behavior for all rules (temporary): default_execution_mode: sourcekit my_rule: regex: "pattern" # Defaults to swiftsyntax now legacy_rule: regex: "pattern" mode: sourcekit # Use legacy mode for specific rules ``` ## Why This Change? - **Performance**: SwiftSyntax mode eliminates SourceKit process overhead - **Reliability**: No more SourceKit crashes or hangs - **Future-Proof**: SwiftLint is migrating away from SourceKit entirely ## Migration Guide 1. **Test First**: Run your existing custom rules and check for differences 2. **Adjust Rules**: Most rules should work identically in SwiftSyntax mode 3. **Use Legacy Mode**: For rules that must maintain exact SourceKit behavior, set `mode: sourcekit` temporarily 4. **Report Issues**: Help us improve SwiftSyntax mode by reporting incompatibilities ## Technical Implementation ### SwiftSyntaxKindBridge Enables kind filtering (`match_kinds`/`excluded_match_kinds`) without SourceKit: - Maps SwiftSyntax classifications to SourceKit syntax kinds - Covers common use cases with best-effort compatibility - Some edge cases may differ from SourceKit behavior ### ConditionallySourceKitFree Protocol Allows CustomRules to skip SourceKit initialization when all rules use SwiftSyntax: - Dramatic performance improvement when SourceKit isn't needed - Maintains SourceKit availability for legacy rules ## Important Notes - **Not Full Compatibility**: While we've worked to maintain compatibility, differences exist. Test thoroughly. - **Temporary Legacy Support**: The `sourcekit` mode is a temporary measure. Plan to migrate all rules to SwiftSyntax. - **Future Deprecation**: SourceKit mode will be removed in a future version. ## Benefits of Migrating - Faster linting (no SourceKit startup cost) - Lower memory usage - Better reliability (no SourceKit crashes) - Future-proof your configuration This change represents a major step in SwiftLint's evolution toward a fully SwiftSyntax-based architecture. We encourage all users to test and migrate their custom rules to the new SwiftSyntax mode, using the legacy SourceKit mode only as a temporary measure for rules that cannot yet be migrated.
1 parent 9bd9ca8 commit 601c496

File tree

5 files changed

+469
-7
lines changed

5 files changed

+469
-7
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@
1111
* SwiftLint now requires macOS 13 or higher to run.
1212
[JP Simard](https://github.com/jpsim)
1313

14+
* Custom rules now default to SwiftSyntax mode for pattern matching instead of SourceKit.
15+
This may result in subtle behavioral differences. While performance is significantly improved,
16+
rules that rely on specific SourceKit behaviors may need adjustment. Users can temporarily
17+
revert to the legacy SourceKit behavior by setting `default_execution_mode: sourcekit` in
18+
their custom rules configuration or `mode: sourcekit` for individual rules. The SourceKit
19+
mode is deprecated and will be removed in a future version.
20+
[JP Simard](https://github.com/jpsim)
21+
1422
### Experimental
1523

1624
* None.

Source/SwiftLintCore/Extensions/StringView+SwiftLint.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Foundation
12
import SourceKittenFramework
23

34
public extension StringView {
@@ -12,4 +13,17 @@ public extension StringView {
1213
}
1314
return lines[Int(line) - 1].byteRange.location + ByteCount(bytePosition - 1)
1415
}
16+
17+
/// Matches a pattern in the string view and returns ranges for the specified capture group.
18+
/// This method does not use SourceKit and is suitable for SwiftSyntax mode.
19+
/// - Parameters:
20+
/// - pattern: The regular expression pattern to match.
21+
/// - captureGroup: The capture group index to extract (0 for the full match).
22+
/// - Returns: An array of NSRange objects for the matched capture groups.
23+
func match(pattern: String, captureGroup: Int = 0) -> [NSRange] {
24+
regex(pattern).matches(in: self).compactMap { match in
25+
let range = match.range(at: captureGroup)
26+
return range.location != NSNotFound ? range : nil
27+
}
28+
}
1529
}

Source/SwiftLintFramework/Rules/CustomRules.swift

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import SourceKittenFramework
23

34
// MARK: - CustomRulesConfiguration
45

@@ -106,7 +107,47 @@ struct CustomRules: Rule, CacheDescriptionProvider, ConditionallySourceKitFree {
106107
let pattern = configuration.regex.pattern
107108
let captureGroup = configuration.captureGroup
108109
let excludingKinds = configuration.excludedMatchKinds
109-
return file.match(pattern: pattern, excludingSyntaxKinds: excludingKinds, captureGroup: captureGroup).map({
110+
111+
// Determine effective execution mode (defaults to swiftsyntax if not specified)
112+
let effectiveMode = configuration.executionMode ?? self.configuration.defaultExecutionMode ?? .swiftsyntax
113+
let needsKindMatching = !excludingKinds.isEmpty
114+
115+
let matches: [NSRange]
116+
if effectiveMode == .swiftsyntax {
117+
if needsKindMatching {
118+
// SwiftSyntax mode WITH kind filtering
119+
// CRITICAL: This path must not trigger any SourceKit requests
120+
guard let bridgedTokens = file.swiftSyntaxDerivedSourceKittenTokens else {
121+
// Log error/warning: Bridging failed
122+
queuedPrintError(
123+
"Warning: SwiftSyntax bridging failed for custom rule '\(configuration.identifier)'"
124+
)
125+
return []
126+
}
127+
let syntaxMapFromBridgedTokens = SwiftLintSyntaxMap(
128+
value: SyntaxMap(tokens: bridgedTokens.map(\.value))
129+
)
130+
131+
// Use the performMatchingWithSyntaxMap helper that operates on stringView and syntaxMap ONLY
132+
matches = performMatchingWithSyntaxMap(
133+
stringView: file.stringView,
134+
syntaxMap: syntaxMapFromBridgedTokens,
135+
pattern: pattern,
136+
excludingSyntaxKinds: excludingKinds,
137+
captureGroup: captureGroup
138+
)
139+
} else {
140+
// SwiftSyntax mode WITHOUT kind filtering
141+
// This path must not trigger any SourceKit requests
142+
matches = file.stringView.match(pattern: pattern, captureGroup: captureGroup)
143+
}
144+
} else {
145+
// SourceKit mode
146+
// SourceKit calls ARE EXPECTED AND PERMITTED here because CustomRules is not SourceKitFreeRule
147+
matches = file.match(pattern: pattern, excludingSyntaxKinds: excludingKinds, captureGroup: captureGroup)
148+
}
149+
150+
return matches.map({
110151
StyleViolation(ruleDescription: configuration.description,
111152
severity: configuration.severity,
112153
location: Location(file: file, characterOffset: $0.location),
@@ -137,3 +178,42 @@ struct CustomRules: Rule, CacheDescriptionProvider, ConditionallySourceKitFree {
137178
&& !region.disabledRuleIdentifiers.contains(.all)
138179
}
139180
}
181+
182+
// MARK: - Helpers
183+
184+
private func performMatchingWithSyntaxMap(
185+
stringView: StringView,
186+
syntaxMap: SwiftLintSyntaxMap,
187+
pattern: String,
188+
excludingSyntaxKinds: Set<SyntaxKind>,
189+
captureGroup: Int
190+
) -> [NSRange] {
191+
// This helper method must not access any part of SwiftLintFile that could trigger SourceKit requests
192+
// It operates only on the provided stringView and syntaxMap
193+
194+
let regex = regex(pattern)
195+
let range = stringView.range
196+
let matches = regex.matches(in: stringView, options: [], range: range)
197+
198+
return matches.compactMap { match in
199+
let matchRange = match.range(at: captureGroup)
200+
201+
// Get tokens in the match range
202+
guard let byteRange = stringView.NSRangeToByteRange(
203+
start: matchRange.location,
204+
length: matchRange.length
205+
) else {
206+
return nil
207+
}
208+
209+
let tokensInRange = syntaxMap.tokens(inByteRange: byteRange)
210+
let kindsInRange = Set(tokensInRange.kinds)
211+
212+
// Check if any excluded kinds are present
213+
if excludingSyntaxKinds.isDisjoint(with: kindsInRange) {
214+
return matchRange
215+
}
216+
217+
return nil
218+
}
219+
}

Tests/FrameworkTests/CustomRulesTests.swift

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,6 @@ final class CustomRulesTests: SwiftLintTestCase {
1111

1212
private var testFile: SwiftLintFile { SwiftLintFile(path: "\(TestResources.path())/test.txt")! }
1313

14-
override func invokeTest() {
15-
CurrentRule.$allowSourceKitRequestWithoutRule.withValue(true) {
16-
super.invokeTest()
17-
}
18-
}
19-
2014
func testCustomRuleConfigurationSetsCorrectlyWithMatchKinds() {
2115
let configDict = [
2216
"my_custom_rule": [

0 commit comments

Comments
 (0)