Skip to content
Draft
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 @@ -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)
Expand Down
30 changes: 28 additions & 2 deletions Source/SwiftLintCore/Extensions/SwiftLintFile+Regex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}

Expand All @@ -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<SyntaxKind>,
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
Expand Down
8 changes: 8 additions & 0 deletions Source/SwiftLintCore/Models/SwiftLintSyntaxMap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 20 additions & 5 deletions Source/SwiftLintFramework/Rules/CustomRules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ package struct CustomRulesConfiguration: RuleConfiguration, CacheDescriptionProv
var customRuleConfigurations = [RegexConfiguration<Parent>]()
var defaultExecutionMode: RegexConfiguration<Parent>.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<Parent>
) -> RegexConfiguration<Parent>.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)
Expand Down Expand Up @@ -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
}
}

Expand All @@ -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),
Expand Down
49 changes: 49 additions & 0 deletions Tests/FrameworkTests/CustomRulesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down