From f3ccaf45e83ba46dceee4e4205100def09ae8540 Mon Sep 17 00:00:00 2001 From: Ben Musoke-Lubega Date: Fri, 31 Jul 2026 04:40:50 -0700 Subject: [PATCH] Run SwiftSyntax-mode custom rules without SourceKit `execution_mode: swiftsyntax` only affected how `custom_rules` was classified: `isEffectivelySourceKitFree` reported the rule as SourceKit-free, but `validate` still resolved syntax kinds through the SourceKit-backed syntax map. With SourceKit access prohibited (the fully static Linux binary or `--disable-sourcekit`), SwiftSyntax-mode rules therefore crashed with "SourceKit is disabled by configuration" instead of linting. Derive syntax kinds from SwiftSyntax classifications via the existing SwiftSyntaxKindBridge when a custom rule's effective execution mode is `swiftsyntax`, leaving the SourceKit path untouched for rules in SourceKit mode. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 +++ .../Extensions/SwiftLintFile+Regex.swift | 30 +++++++++++- .../Models/SwiftLintSyntaxMap.swift | 8 +++ .../Rules/CustomRules.swift | 25 ++++++++-- Tests/FrameworkTests/CustomRulesTests.swift | 49 +++++++++++++++++++ 5 files changed, 113 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e6abfa5fe..58e3f060636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,14 @@ ### Bug Fixes +* Make custom rules in SwiftSyntax execution mode actually run without SourceKit. Previously + `execution_mode: swiftsyntax` only marked `custom_rules` as SourceKit-free: matching still + queried SourceKit for syntax kinds, so linting with SourceKit disabled (for example with the + fully static Linux binary or `--disable-sourcekit`) crashed instead of reporting violations. + Syntax kinds are now derived from SwiftSyntax classifications in this mode. + [bprinsta](https://github.com/bprinsta) + [#6129](https://github.com/realm/SwiftLint/issues/6129) + * Fix baseline writing to store file locations as paths relative to the current working directory, restoring baseline portability and avoiding absolute `file://` paths in generated baseline files. [SimplyDanny](https://github.com/SimplyDanny) diff --git a/Source/SwiftLintCore/Extensions/SwiftLintFile+Regex.swift b/Source/SwiftLintCore/Extensions/SwiftLintFile+Regex.swift index 7c96561d0d9..1b282cdfcd2 100644 --- a/Source/SwiftLintCore/Extensions/SwiftLintFile+Regex.swift +++ b/Source/SwiftLintCore/Extensions/SwiftLintFile+Regex.swift @@ -100,13 +100,19 @@ extension SwiftLintFile { } public func match(pattern: String, range: NSRange? = nil, captureGroup: Int = 0) -> [(NSRange, [SyntaxKind])] { + match(pattern: pattern, syntaxMap: syntaxMap, range: range, captureGroup: captureGroup) + } + + private func match(pattern: String, + syntaxMap: SwiftLintSyntaxMap, + range: NSRange? = nil, + captureGroup: Int = 0) -> [(NSRange, [SyntaxKind])] { let contents = stringView let range = range ?? contents.range - let syntax = syntaxMap return regex(pattern).matches(in: contents, options: [], range: range).compactMap { match in let matchByteRange = contents.NSRangeToByteRange( start: match.range.location, length: match.range.length) - return matchByteRange.map { (match.range(at: captureGroup), syntax.tokens(inByteRange: $0).kinds) } + return matchByteRange.map { (match.range(at: captureGroup), syntaxMap.tokens(inByteRange: $0).kinds) } } } @@ -130,6 +136,26 @@ extension SwiftLintFile { .map(\.0) } + /// Like `match(pattern:excludingSyntaxKinds:range:captureGroup:)`, but derives syntax kinds + /// from SwiftSyntax classifications instead of SourceKit, so it can be used when SourceKit + /// is disabled or unavailable. + /// + /// - parameter pattern: regex pattern to be matched inside file. + /// - parameter excludingSyntaxKinds: syntax kinds the matches to be filtered + /// when inside them. + /// + /// - returns: An array of [NSRange] objects consisting of regex matches inside + /// file contents. + package func matchWithSwiftSyntaxKinds(pattern: String, + excludingSyntaxKinds syntaxKinds: Set, + range: NSRange? = nil, + captureGroup: Int = 0) -> [NSRange] { + let syntaxMap = SwiftLintSyntaxMap(tokens: swiftSyntaxDerivedSourceKittenTokens ?? []) + return match(pattern: pattern, syntaxMap: syntaxMap, range: range, captureGroup: captureGroup) + .filter { syntaxKinds.isDisjoint(with: $0.1) } + .map(\.0) + } + public func append(_ string: String) { guard string.isNotEmpty else { return diff --git a/Source/SwiftLintCore/Models/SwiftLintSyntaxMap.swift b/Source/SwiftLintCore/Models/SwiftLintSyntaxMap.swift index 99214dbfda0..9f430bcd746 100644 --- a/Source/SwiftLintCore/Models/SwiftLintSyntaxMap.swift +++ b/Source/SwiftLintCore/Models/SwiftLintSyntaxMap.swift @@ -12,6 +12,14 @@ public struct SwiftLintSyntaxMap { self.tokens = value.tokens.map(SwiftLintSyntaxToken.init) } + /// Creates a `SwiftLintSyntaxMap` from tokens already in SwiftLint's representation, such as + /// tokens derived from SwiftSyntax classifications. Tokens must be sorted by position. + /// + /// - parameter tokens: The syntax tokens for this syntax map. + package init(tokens: [SwiftLintSyntaxToken]) { + self.tokens = tokens + } + /// Returns array of syntax tokens intersecting with byte range. /// /// - parameter byteRange: Byte-based NSRange. diff --git a/Source/SwiftLintFramework/Rules/CustomRules.swift b/Source/SwiftLintFramework/Rules/CustomRules.swift index 61fed7d29a6..679c2b676fa 100644 --- a/Source/SwiftLintFramework/Rules/CustomRules.swift +++ b/Source/SwiftLintFramework/Rules/CustomRules.swift @@ -20,6 +20,16 @@ package struct CustomRulesConfiguration: RuleConfiguration, CacheDescriptionProv var customRuleConfigurations = [RegexConfiguration]() var defaultExecutionMode: RegexConfiguration.ExecutionMode? + /// The mode a custom rule effectively runs with, resolving `.default` against + /// `default_execution_mode` and falling back to SourceKit mode. + package func effectiveExecutionMode( + for configuration: RegexConfiguration + ) -> RegexConfiguration.ExecutionMode { + configuration.executionMode == .default + ? (defaultExecutionMode ?? .sourcekit) + : configuration.executionMode + } + package mutating func apply(configuration: Any) throws(Issue) { guard let configurationDict = configuration as? [String: Any] else { throw .invalidConfiguration(ruleID: Parent.identifier) @@ -80,10 +90,7 @@ package struct CustomRules: Rule, CacheDescriptionProvider, ConditionallySourceK /// Returns true if all configured custom rules use SwiftSyntax mode, making this rule effectively SourceKit-free. package var isEffectivelySourceKitFree: Bool { configuration.customRuleConfigurations.allSatisfy { config in - let effectiveMode = config.executionMode == .default - ? (configuration.defaultExecutionMode ?? .sourcekit) - : config.executionMode - return effectiveMode == .swiftsyntax + configuration.effectiveExecutionMode(for: config) == .swiftsyntax } } @@ -109,7 +116,15 @@ package struct CustomRules: Rule, CacheDescriptionProvider, ConditionallySourceK let pattern = configuration.regex.pattern let captureGroup = configuration.captureGroup let excludingKinds = configuration.excludedMatchKinds - return file.match(pattern: pattern, excludingSyntaxKinds: excludingKinds, captureGroup: captureGroup).map({ + // Rules in SwiftSyntax mode must not consult SourceKit: they are reported as + // SourceKit-free, so they also run when SourceKit is disabled or unavailable. + let matches = if self.configuration.effectiveExecutionMode(for: configuration) == .swiftsyntax { + file.matchWithSwiftSyntaxKinds( + pattern: pattern, excludingSyntaxKinds: excludingKinds, captureGroup: captureGroup) + } else { + 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), diff --git a/Tests/FrameworkTests/CustomRulesTests.swift b/Tests/FrameworkTests/CustomRulesTests.swift index c6bcfd587bf..24c7ebcd897 100644 --- a/Tests/FrameworkTests/CustomRulesTests.swift +++ b/Tests/FrameworkTests/CustomRulesTests.swift @@ -703,6 +703,55 @@ struct CustomRulesTests { // swiftlint:disable:this type_body_length #expect(violations[0].location.character == 5) } + @Test + func swiftSyntaxModeRunsWithSourceKitDisabled() throws { + // SwiftSyntax-mode rules are reported as SourceKit-free, so they must also run — without + // consulting SourceKit — when SourceKit access is prohibited, as in the prebuilt fully + // static Linux binary or under `--disable-sourcekit`. + Request.disableSourceKitOverride = true + defer { Request.disableSourceKitOverride = false } + + let customRules: [String: Any] = [ + "no_foo": [ + "regex": "\\bfoo\\b", + "execution_mode": "swiftsyntax", + "message": "Don't use foo", + ], + ] + + let example = Example(code: "let foo = 42") + let violations = try violations(forExample: example, customRules: customRules) + + #expect(violations.count == 1) + #expect(violations[0].ruleIdentifier == "no_foo") + #expect(violations[0].location.character == 5) + } + + @Test + func swiftSyntaxModeFiltersMatchKindsWithSourceKitDisabled() throws { + Request.disableSourceKitOverride = true + defer { Request.disableSourceKitOverride = false } + + let customRules: [String: Any] = [ + "comment_foo": [ + "regex": "foo", + "execution_mode": "swiftsyntax", + "match_kinds": "comment", + "message": "No foo in comments", + ], + ] + + let example = Example(code: """ + let foo = 42 // This foo should match + let bar = 42 // This should not match + """) + let violations = try violations(forExample: example, customRules: customRules) + + #expect(violations.count == 1) + #expect(violations[0].location.line == 1) + #expect(violations[0].location.character == 23) + } + @Test func customRuleWithoutMatchKindsUsesSwiftSyntaxByDefault() throws { // When default_execution_mode is swiftsyntax, rules without match_kinds should use it