diff --git a/CHANGELOG.md b/CHANGELOG.md index 1996672d53..83766b0741 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -237,6 +237,16 @@ ### Bug Fixes +* Make `--only-rule` and `only_rules:` actually run custom rules. + `activateCustomRuleIdentifiers` previously only expanded custom rules for + the `only_rules:` config key when `custom_rules` itself was listed, + never for the `--only-rule` command-line flag or for an individual + custom rule identifier. As a result `--only-rule custom_rules`, + `--only-rule ` and `only_rules: []` + silently produced no violations. Now any of these enables the parent + `custom_rules` rule together with the requested custom rules. + [Chupik](https://github.com/Chupik) + * Recognize `isolated` as an isolation modifier in `modifier_order`, so it can be ordered via the `isolation` entry in `preferred_modifier_order`. [leno23](https://github.com/leno23) diff --git a/Source/SwiftLintFramework/Configuration/Configuration+RulesMode.swift b/Source/SwiftLintFramework/Configuration/Configuration+RulesMode.swift index cfc5902261..ff71222647 100644 --- a/Source/SwiftLintFramework/Configuration/Configuration+RulesMode.swift +++ b/Source/SwiftLintFramework/Configuration/Configuration+RulesMode.swift @@ -116,18 +116,38 @@ public extension Configuration { } internal func activateCustomRuleIdentifiers(allRulesWrapped: [ConfigurationRuleWrapper]) -> Self { - // In the only mode, if the custom rules rule is enabled, all custom rules are also enabled implicitly - // This method makes the implicitly explicit + // In the "only" modes the parent `custom_rules` rule and the individual custom rules are + // entangled: enabling `custom_rules` implies enabling every custom rule, and enabling any + // individual custom rule requires the parent `custom_rules` rule to be enabled too. + // This method makes those implicit relations explicit so that both `only_rules` (config) + // and `--only-rule` (command line) behave consistently — previously `--only-rule` left + // custom rules silently disabled, and `only_rules: []` did too. switch self { - case let .onlyConfiguration(onlyRules) where onlyRules.contains { - $0 == CustomRules.identifier - }: - let customRulesRule = allRulesWrapped.customRules - return .onlyConfiguration(onlyRules.union(Set(customRulesRule?.customRuleIdentifiers ?? []))) + case let .onlyConfiguration(onlyRules): + return .onlyConfiguration(Self.withCustomRulesActivated(onlyRules, allRulesWrapped: allRulesWrapped)) + + case let .onlyCommandLine(onlyRules): + return .onlyCommandLine(Self.withCustomRulesActivated(onlyRules, allRulesWrapped: allRulesWrapped)) default: return self } } + + private static func withCustomRulesActivated( + _ onlyRules: Set, + allRulesWrapped: [ConfigurationRuleWrapper] + ) -> Set { + let customRuleIdentifiers = Set(allRulesWrapped.customRules?.customRuleIdentifiers ?? []) + var onlyRules = onlyRules + if onlyRules.contains(CustomRules.identifier) { + // `custom_rules` is enabled → enable all of its custom rules. + onlyRules.formUnion(customRuleIdentifiers) + } else if !onlyRules.isDisjoint(with: customRuleIdentifiers) { + // At least one individual custom rule is enabled → enable the parent `custom_rules` rule. + onlyRules.insert(CustomRules.identifier) + } + return onlyRules + } } } diff --git a/Tests/FrameworkTests/OnlyRuleCustomRulesTests.swift b/Tests/FrameworkTests/OnlyRuleCustomRulesTests.swift new file mode 100644 index 0000000000..0bba477161 --- /dev/null +++ b/Tests/FrameworkTests/OnlyRuleCustomRulesTests.swift @@ -0,0 +1,74 @@ +@testable import SwiftLintCore +@testable import SwiftLintFramework +import TestHelpers +import XCTest + +/// Tests that custom (regex) rules can be selected via `--only-rule` from the command line and via +/// `only_rules:` from a configuration file. +/// +/// Before the fix, `activateCustomRuleIdentifiers` only expanded custom rules for the +/// `.onlyConfiguration` mode (and even there, only when `custom_rules` itself was the listed +/// identifier), so: +/// +/// * `--only-rule custom_rules` ran the parent rule but had every individual custom rule filtered +/// out, producing zero violations. +/// * `--only-rule my_custom_rule` left the parent `custom_rules` rule out of the resulting rule set +/// entirely, so the custom rule never executed. +/// * `only_rules: [my_custom_rule]` had the same problem as the second case. +final class OnlyRuleCustomRulesTests: SwiftLintTestCase { + /// Instance-scoped so that a non-`Sendable` `[String: Any]` literal is not held as shared + /// mutable global state. A `static let` here is rejected by the Swift 6 language mode that + /// Bazel CI compiles with. + private var customRulesDict: [String: Any] { + [ + "custom_rules": [ + "rule_a": ["name": "RuleA", "regex": "a", "message": "msg"], + "rule_b": ["name": "RuleB", "regex": "b", "message": "msg"], + ], + ] + } + + // MARK: - `--only-rule` from the command line + + func testOnlyRuleFromCommandLineForIndividualCustomRule() throws { + let config = try Configuration(dict: customRulesDict, onlyRule: ["rule_a"]) + let activeCustomRules = activeCustomRuleIdentifiers(in: config) + XCTAssertEqual(activeCustomRules, ["rule_a"]) + } + + func testOnlyRuleFromCommandLineForCustomRulesParentEnablesAll() throws { + let config = try Configuration(dict: customRulesDict, onlyRule: ["custom_rules"]) + let activeCustomRules = activeCustomRuleIdentifiers(in: config) + XCTAssertEqual(activeCustomRules, ["rule_a", "rule_b"]) + } + + func testOnlyRuleFromCommandLineForUnrelatedRuleDoesNotEnableCustomRules() throws { + let config = try Configuration(dict: customRulesDict, onlyRule: ["line_length"]) + XCTAssertNil( + config.rules.first(where: { $0 is CustomRules }), + "`--only-rule` for an unrelated built-in rule must not pull in the custom_rules parent rule" + ) + } + + // MARK: - `only_rules:` in configuration + + func testOnlyRulesInConfigForIndividualCustomRule() throws { + var dict = customRulesDict + dict["only_rules"] = ["rule_a"] + let config = try Configuration(dict: dict) + let activeCustomRules = activeCustomRuleIdentifiers(in: config) + XCTAssertEqual(activeCustomRules, ["rule_a"]) + } + + // MARK: - Helpers + + /// Returns the identifiers of the custom rule configurations that remain active in `config`'s + /// `resultingRules`, sorted for deterministic comparison. Empty if the parent `custom_rules` + /// rule was filtered out entirely. + private func activeCustomRuleIdentifiers(in config: Configuration) -> [String] { + guard let customRules = config.rules.first(where: { $0 is CustomRules }) as? CustomRules else { + return [] + } + return customRules.configuration.customRuleConfigurations.map(\.identifier).sorted() + } +}