import Foundation import SourceKittenFramework struct BlanketDisableCommandRule: Rule, SourceKitFreeRule { var configuration = BlanketDisableCommandConfiguration() static let description = RuleDescription( identifier: "blanket_disable_command", name: "Blanket Disable Command", description: """ `swiftlint:disable` commands should use `next`, `this` or `previous` to disable rules for a \ single line, or `swiftlint:enable` to re-enable the rules immediately after the violations \ to be ignored, instead of disabling the rule for the rest of the file. """, rationale: """ The intent of this rule is to prevent code like ``` // swiftlint:disable force_unwrapping let foo = bar! ``` which disables the `force_unwrapping` rule for the remainder of the file, instead of just for the specific \ violation. `next`, `this`, or `previous` can be used to restrict the disable command's scope to a single line, or it \ can be re-enabled after the violations. To disable this rule in code you will need to do something like ``` // swiftlint:disable:next blanket_disable_command // swiftlint:disable force_unwrapping ``` """, kind: .lint, nonTriggeringExamples: [ Example(""" // swiftlint:disable unused_import // swiftlint:enable unused_import """), Example(""" // swiftlint:disable unused_import unused_declaration // swiftlint:enable unused_import // swiftlint:enable unused_declaration """), Example("// swiftlint:disable:this unused_import"), Example("// swiftlint:disable:next unused_import"), Example("// swiftlint:disable:previous unused_import"), ], triggeringExamples: [ Example("// swiftlint:disable ↓unused_import"), Example(""" // swiftlint:disable unused_import ↓unused_declaration // swiftlint:enable unused_import """), Example(""" // swiftlint:disable unused_import // swiftlint:disable ↓unused_import // swiftlint:enable unused_import """), Example(""" // swiftlint:enable ↓unused_import """), Example("// swiftlint:disable all"), ].skipWrappingInCommentTests().skipDisableCommandTests() ) func validate(file: SwiftLintFile) -> [StyleViolation] { var violations: [StyleViolation] = [] var ruleIdentifierToCommandMap: [RuleIdentifier: Command] = [:] var disabledRuleIdentifiers: Set = [] for command in file.commands { if command.action == .disable { violations += validateAlreadyDisabledRules( for: command, in: file, disabledRuleIdentifiers: disabledRuleIdentifiers ) } if command.action == .enable { violations += validateAlreadyEnabledRules( for: command, in: file, disabledRuleIdentifiers: disabledRuleIdentifiers ) } if command.modifier != nil { continue } if command.action == .disable { disabledRuleIdentifiers.formUnion(command.ruleIdentifiers) command.ruleIdentifiers.forEach { ruleIdentifierToCommandMap[$0] = command } } if command.action == .enable { disabledRuleIdentifiers.subtract(command.ruleIdentifiers) command.ruleIdentifiers.forEach { ruleIdentifierToCommandMap.removeValue(forKey: $0) } } } violations += validateBlanketDisables( in: file, disabledRuleIdentifiers: disabledRuleIdentifiers, ruleIdentifierToCommandMap: ruleIdentifierToCommandMap ) violations += validateAlwaysBlanketDisable(file: file) return violations } private func violation( for command: Command, ruleIdentifier: RuleIdentifier, in file: SwiftLintFile, reason: String ) -> StyleViolation { violation(for: command, ruleIdentifier: ruleIdentifier.stringRepresentation, in: file, reason: reason) } private func violation( for command: Command, ruleIdentifier: String, in file: SwiftLintFile, reason: String ) -> StyleViolation { StyleViolation( ruleDescription: Self.description, severity: configuration.severity, location: command.location(of: ruleIdentifier, in: file), reason: reason ) } private func validateAlreadyDisabledRules( for command: Command, in file: SwiftLintFile, disabledRuleIdentifiers: Set ) -> [StyleViolation] { let alreadyDisabledRuleIdentifiers = command.ruleIdentifiers.intersection(disabledRuleIdentifiers) return alreadyDisabledRuleIdentifiers.map { let reason = "The disabled '\($0.stringRepresentation)' rule was already disabled" return violation(for: command, ruleIdentifier: $0, in: file, reason: reason) } } private func validateAlreadyEnabledRules( for command: Command, in file: SwiftLintFile, disabledRuleIdentifiers: Set ) -> [StyleViolation] { let notDisabledRuleIdentifiers = command.ruleIdentifiers.subtracting(disabledRuleIdentifiers) return notDisabledRuleIdentifiers.map { let reason = "The enabled '\($0.stringRepresentation)' rule was not disabled" return violation(for: command, ruleIdentifier: $0, in: file, reason: reason) } } private func validateBlanketDisables( in file: SwiftLintFile, disabledRuleIdentifiers: Set, ruleIdentifierToCommandMap: [RuleIdentifier: Command] ) -> [StyleViolation] { let allowedRuleIdentifiers = configuration.allowedRuleIdentifiers.union( configuration.alwaysBlanketDisableRuleIdentifiers ) return disabledRuleIdentifiers.compactMap { disabledRuleIdentifier in if allowedRuleIdentifiers.contains(disabledRuleIdentifier.stringRepresentation) { return nil } if let command = ruleIdentifierToCommandMap[disabledRuleIdentifier] { let reason = """ Use 'next', 'this' or 'previous' instead to disable the \ '\(disabledRuleIdentifier.stringRepresentation)' rule once, \ or re-enable it as soon as possible` """ return violation(for: command, ruleIdentifier: disabledRuleIdentifier, in: file, reason: reason) } return nil } } private func validateAlwaysBlanketDisable(file: SwiftLintFile) -> [StyleViolation] { var violations: [StyleViolation] = [] guard configuration.alwaysBlanketDisableRuleIdentifiers.isEmpty == false else { return [] } for command in file.commands { let ruleIdentifiers: Set = Set(command.ruleIdentifiers.map(\.stringRepresentation)) let intersection = ruleIdentifiers.intersection(configuration.alwaysBlanketDisableRuleIdentifiers) if command.action == .enable { violations.append(contentsOf: intersection.map { let reason = "The '\($0)' rule applies to the whole file and thus doesn't need to be re-enabled" return violation(for: command, ruleIdentifier: $0, in: file, reason: reason) }) } else if command.modifier != nil { violations.append(contentsOf: intersection.map { let reason = "The '\($0)' rule applies to the whole file and thus cannot be disabled locally " + "with 'previous', 'this' or 'next'" return violation(for: command, ruleIdentifier: $0, in: file, reason: reason) }) } } return violations } } private extension Command { func location(of ruleIdentifier: String, in file: SwiftLintFile) -> Location { var location = range?.upperBound if line > 0, line <= file.lines.count { let line = file.lines[line - 1].content if let ruleIdentifierIndex = line.range(of: ruleIdentifier)?.lowerBound { location = line.distance(from: line.startIndex, to: ruleIdentifierIndex) + 1 } } return Location(file: file.file.path, line: line, character: location) } }