Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
* SwiftLint now requires macOS 13 or higher to run.
[JP Simard](https://github.com/jpsim)

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

### Experimental

* None.
Expand Down
14 changes: 14 additions & 0 deletions Source/SwiftLintCore/Extensions/StringView+SwiftLint.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import SourceKittenFramework

public extension StringView {
Expand All @@ -12,4 +13,17 @@ public extension StringView {
}
return lines[Int(line) - 1].byteRange.location + ByteCount(bytePosition - 1)
}

/// Matches a pattern in the string view and returns ranges for the specified capture group.
/// This method does not use SourceKit and is suitable for SwiftSyntax mode.
/// - Parameters:
/// - pattern: The regular expression pattern to match.
/// - captureGroup: The capture group index to extract (0 for the full match).
/// - Returns: An array of NSRange objects for the matched capture groups.
func match(pattern: String, captureGroup: Int = 0) -> [NSRange] {
regex(pattern).matches(in: self).compactMap { match in
let range = match.range(at: captureGroup)
return range.location != NSNotFound ? range : nil
}
}
}
84 changes: 83 additions & 1 deletion Source/SwiftLintFramework/Rules/CustomRules.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import SourceKittenFramework

// MARK: - CustomRulesConfiguration

Expand Down Expand Up @@ -103,7 +104,49 @@ struct CustomRules: Rule, CacheDescriptionProvider, ConditionallySourceKitFree {
let pattern = configuration.regex.pattern
let captureGroup = configuration.captureGroup
let excludingKinds = configuration.excludedMatchKinds
return file.match(pattern: pattern, excludingSyntaxKinds: excludingKinds, captureGroup: captureGroup).map({

// Determine effective execution mode (defaults to swiftsyntax if not specified)
let effectiveMode = configuration.executionMode == .default
? (self.configuration.defaultExecutionMode ?? .swiftsyntax)
: configuration.executionMode
let needsKindMatching = !excludingKinds.isEmpty

let matches: [NSRange]
if effectiveMode == .swiftsyntax {
if needsKindMatching {
// SwiftSyntax mode WITH kind filtering
// CRITICAL: This path must not trigger any SourceKit requests
guard let bridgedTokens = file.swiftSyntaxDerivedSourceKittenTokens else {
// Log error/warning: Bridging failed
queuedPrintError(
"Warning: SwiftSyntax bridging failed for custom rule '\(configuration.identifier)'"
)
return []
}
let syntaxMapFromBridgedTokens = SwiftLintSyntaxMap(
value: SyntaxMap(tokens: bridgedTokens.map(\.value))
)

// Use the performMatchingWithSyntaxMap helper that operates on stringView and syntaxMap ONLY
matches = performMatchingWithSyntaxMap(
stringView: file.stringView,
syntaxMap: syntaxMapFromBridgedTokens,
pattern: pattern,
excludingSyntaxKinds: excludingKinds,
captureGroup: captureGroup
)
} else {
// SwiftSyntax mode WITHOUT kind filtering
// This path must not trigger any SourceKit requests
matches = file.stringView.match(pattern: pattern, captureGroup: captureGroup)
}
} else {
// SourceKit mode
// SourceKit calls ARE EXPECTED AND PERMITTED here because CustomRules is not SourceKitFreeRule
matches = file.match(pattern: pattern, excludingSyntaxKinds: excludingKinds, captureGroup: captureGroup)
}

return matches.map({
StyleViolation(ruleDescription: configuration.description,
severity: configuration.severity,
location: Location(file: file, characterOffset: $0.location),
Expand Down Expand Up @@ -134,3 +177,42 @@ struct CustomRules: Rule, CacheDescriptionProvider, ConditionallySourceKitFree {
&& !region.disabledRuleIdentifiers.contains(.all)
}
}

// MARK: - Helpers

private func performMatchingWithSyntaxMap(
stringView: StringView,
syntaxMap: SwiftLintSyntaxMap,
pattern: String,
excludingSyntaxKinds: Set<SyntaxKind>,
captureGroup: Int
) -> [NSRange] {
// This helper method must not access any part of SwiftLintFile that could trigger SourceKit requests
// It operates only on the provided stringView and syntaxMap

let regex = regex(pattern)
let range = stringView.range
let matches = regex.matches(in: stringView, options: [], range: range)

return matches.compactMap { match in
let matchRange = match.range(at: captureGroup)

// Get tokens in the match range
guard let byteRange = stringView.NSRangeToByteRange(
start: matchRange.location,
length: matchRange.length
) else {
return nil
}

let tokensInRange = syntaxMap.tokens(inByteRange: byteRange)
let kindsInRange = Set(tokensInRange.kinds)

// Check if any excluded kinds are present
if excludingSyntaxKinds.isDisjoint(with: kindsInRange) {
return matchRange
}

return nil
}
}
6 changes: 0 additions & 6 deletions Tests/FrameworkTests/CustomRulesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@ final class CustomRulesTests: SwiftLintTestCase {

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

override func invokeTest() {
CurrentRule.$allowSourceKitRequestWithoutRule.withValue(true) {
super.invokeTest()
}
}

func testCustomRuleConfigurationSetsCorrectlyWithMatchKinds() {
let configDict = [
"my_custom_rule": [
Expand Down
Loading