diff --git a/CHANGELOG.md b/CHANGELOG.md index a295f13f2..56b0da3a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ * `identifier_name` * `let_var_whitespace` * `multiline_literal_brackets` + * `nimble_operator` * `opening_brace` * `void_return` diff --git a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/NimbleOperatorRule.swift b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/NimbleOperatorRule.swift index 6739f7827..4b46b58a5 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Idiomatic/NimbleOperatorRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Idiomatic/NimbleOperatorRule.swift @@ -1,7 +1,8 @@ -import Foundation -import SourceKittenFramework +import SwiftLintCore +import SwiftSyntax -struct NimbleOperatorRule: OptInRule, CorrectableRule { +@SwiftSyntaxRule(explicitRewriter: true) +struct NimbleOperatorRule: OptInRule { var configuration = SeverityConfiguration(.warning) static let description = RuleDescription( @@ -51,7 +52,7 @@ struct NimbleOperatorRule: OptInRule, CorrectableRule { Example("↓expect(\"Hi!\").to(equal(\"Hi!\"))"): Example("expect(\"Hi!\") == \"Hi!\""), Example("↓expect(12).toNot(equal(10))"): Example("expect(12) != 10"), Example("↓expect(value1).to(equal(value2))"): Example("expect(value1) == value2"), - Example("↓expect( value1 ).to(equal( value2.foo))"): Example("expect(value1) == value2.foo"), + Example("↓expect( value1 ).to(equal( value2.foo))"): Example("expect( value1 ) == value2.foo"), Example("↓expect(value1).to(equal(10))"): Example("expect(value1) == 10"), Example("↓expect(10).to(beGreaterThan(8))"): Example("expect(10) > 8"), Example("↓expect(10).to(beGreaterThanOrEqualTo(10))"): Example("expect(10) >= 10"), @@ -67,148 +68,124 @@ struct NimbleOperatorRule: OptInRule, CorrectableRule { Example("expect(10) > 2\n ↓expect(10).to(beGreaterThan(2))"): Example("expect(10) > 2\n expect(10) > 2") ] ) +} - fileprivate typealias MatcherFunction = String - - fileprivate enum Arity { - case nullary(analogueValue: String) - case withArguments - - var hasArguments: Bool { - guard case .withArguments = self else { - return false +private extension NimbleOperatorRule { + final class Visitor: ViolationsSyntaxVisitor { + override func visitPost(_ node: FunctionCallExprSyntax) { + guard predicateDescription(for: node) != nil else { + return } - return true + violations.append(node.positionAfterSkippingLeadingTrivia) } } - fileprivate typealias PredicateDescription = (to: String?, toNot: String?, arity: Arity) + final class Rewriter: ViolationsSyntaxRewriter { + override func visit(_ node: FunctionCallExprSyntax) -> ExprSyntax { + guard let expectation = node.expectation(), + let predicate = predicatesMapping[expectation.operatorExpr.baseName.text], + let operatorExpr = expectation.operatorExpr(for: predicate), + let expectedValueExpr = expectation.expectedValueExpr(for: predicate) else { + return super.visit(node) + } - private let predicatesMapping: [MatcherFunction: PredicateDescription] = [ + correctionPositions.append(node.positionAfterSkippingLeadingTrivia) + + let elements = ExprListSyntax([ + expectation.baseExpr.with(\.trailingTrivia, .space).cast(ExprSyntax.self), + operatorExpr.with(\.trailingTrivia, .space).cast(ExprSyntax.self), + expectedValueExpr.with(\.trailingTrivia, node.trailingTrivia) + ]) + return super.visit(SequenceExprSyntax(elements: elements)) + } + } + + typealias MatcherFunction = String + + static let predicatesMapping: [MatcherFunction: PredicateDescription] = [ "equal": (to: "==", toNot: "!=", .withArguments), "beIdenticalTo": (to: "===", toNot: "!==", .withArguments), "beGreaterThan": (to: ">", toNot: nil, .withArguments), "beGreaterThanOrEqualTo": (to: ">=", toNot: nil, .withArguments), "beLessThan": (to: "<", toNot: nil, .withArguments), "beLessThanOrEqualTo": (to: "<=", toNot: nil, .withArguments), - "beTrue": (to: "==", toNot: "!=", .nullary(analogueValue: "true")), - "beFalse": (to: "==", toNot: "!=", .nullary(analogueValue: "false")), - "beNil": (to: "==", toNot: "!=", .nullary(analogueValue: "nil")) + "beTrue": (to: "==", toNot: "!=", .nullary(analogueValue: BooleanLiteralExprSyntax(booleanLiteral: true))), + "beFalse": (to: "==", toNot: "!=", .nullary(analogueValue: BooleanLiteralExprSyntax(booleanLiteral: false))), + "beNil": (to: "==", toNot: "!=", .nullary(analogueValue: NilLiteralExprSyntax(nilKeyword: .keyword(.nil)))) ] - func validate(file: SwiftLintFile) -> [StyleViolation] { - let matches = violationMatchesRanges(in: file) - return matches.map { - StyleViolation(ruleDescription: Self.description, - severity: configuration.severity, - location: Location(file: file, characterOffset: $0.location)) + static func predicateDescription(for node: FunctionCallExprSyntax) -> PredicateDescription? { + guard let expectation = node.expectation() else { + return nil } - } - - private func violationMatchesRanges(in file: SwiftLintFile) -> [NSRange] { - let contents = file.stringView - return rawRegexResults(in: file).filter { range in - guard let byteRange = contents.NSRangeToByteRange(start: range.location, length: range.length) else { - return false - } - - return file.structureDictionary.structures(forByteOffset: byteRange.upperBound - 1) - .contains(where: { dict -> Bool in - return dict.expressionKind == .call && (dict.name ?? "").starts(with: "expect") - }) - } - } - - private func rawRegexResults(in file: SwiftLintFile) -> [NSRange] { - let operandPattern = "(.(?!expect\\())+?" - - let operatorsPattern = "(" + predicatesMapping.map { name, predicateDescription in - let argumentsPattern = predicateDescription.arity.hasArguments - ? operandPattern - : "" - - return "\(name)\\(\(argumentsPattern)\\)" - }.joined(separator: "|") + ")" - - let pattern = "expect\\(\(operandPattern)\\)\\.to(Not)?\\(\(operatorsPattern)\\)" - let excludingKinds = SyntaxKind.commentKinds - - return file.match(pattern: pattern) - .filter { _, kinds in - excludingKinds.isDisjoint(with: kinds) && kinds.first == .identifier - } - .map { $0.0 } - } - - func correct(file: SwiftLintFile) -> [Correction] { - let matches = violationMatchesRanges(in: file) - .filter { file.ruleEnabled(violatingRanges: [$0], for: self).isNotEmpty } - guard matches.isNotEmpty else { return [] } - - let description = Self.description - var corrections: [Correction] = [] - var contents = file.contents - - for range in matches.sorted(by: { $0.location > $1.location }) { - for (functionName, operatorCorrections) in predicatesMapping { - guard let correctedString = contents.replace(function: functionName, - with: operatorCorrections, - in: range) - else { - continue - } - - contents = correctedString - let correction = Correction(ruleDescription: description, - location: Location(file: file, characterOffset: range.location)) - corrections.insert(correction, at: 0) - break - } - } - - file.write(contents) - return corrections + return Self.predicatesMapping[expectation.operatorExpr.baseName.text] } } -private extension String { - /// Returns corrected string if the correction is possible, otherwise returns nil. - /// - /// - parameter name: The function name to replace. - /// - parameter predicateDescription: The Nimble operators to replace functions with. - /// - parameter range: The range in which replacements should be applied. - /// - /// - returns: The corrected string if the correction is possible, otherwise returns nil. - func replace(function name: NimbleOperatorRule.MatcherFunction, - with predicateDescription: NimbleOperatorRule.PredicateDescription, - in range: NSRange) -> String? { - let anything = "\\s*(.*?)\\s*" - - let toPattern = ("expect\\(\(anything)\\)\\.to\\(\(name)\\(\(anything)\\)\\)", predicateDescription.to) - let toNotPattern = ("expect\\(\(anything)\\)\\.toNot\\(\(name)\\(\(anything)\\)\\)", predicateDescription.toNot) - - for case let (pattern, operatorString?) in [toPattern, toNotPattern] { - let expression = regex(pattern) - guard expression.matches(in: self, options: [], range: range).isNotEmpty else { - continue - } - - let valueReplacementPattern: String - switch predicateDescription.arity { - case .nullary(let analogueValue): - valueReplacementPattern = analogueValue - case .withArguments: - valueReplacementPattern = "$2" - } - - let replacementPattern = "expect($1) \(operatorString) \(valueReplacementPattern)" - - return expression.stringByReplacingMatches(in: self, - options: [], - range: range, - withTemplate: replacementPattern) +private extension FunctionCallExprSyntax { + func expectation() -> Expectation? { + guard trailingClosure == nil, + arguments.count == 1, + let memberExpr = calledExpression.as(MemberAccessExprSyntax.self), + let kind = Expectation.Kind(rawValue: memberExpr.declName.baseName.text), + let baseExpr = memberExpr.base?.as(FunctionCallExprSyntax.self), + baseExpr.calledExpression.as(DeclReferenceExprSyntax.self)?.baseName.text == "expect", + let predicateExpr = arguments.first?.expression.as(FunctionCallExprSyntax.self), + let operatorExpr = predicateExpr.calledExpression.as(DeclReferenceExprSyntax.self) else { + return nil } - return nil + let expected = predicateExpr.arguments.first?.expression + return Expectation(kind: kind, baseExpr: baseExpr, operatorExpr: operatorExpr, expected: expected) + } +} + +private typealias PredicateDescription = (to: String, toNot: String?, arity: Arity) + +private enum Arity { + case nullary(analogueValue: any ExprSyntaxProtocol) + case withArguments +} + +private struct Expectation { + let kind: Kind + let baseExpr: FunctionCallExprSyntax + let operatorExpr: DeclReferenceExprSyntax + let expected: ExprSyntax? + + enum Kind { + case positive + case negative + + init?(rawValue: String) { + switch rawValue { + case "to": + self = .positive + case "toNot", "notTo": + self = .negative + default: + return nil + } + } + } + + func expectedValueExpr(for predicate: PredicateDescription) -> ExprSyntax? { + switch predicate.arity { + case .withArguments: + expected + case .nullary(let analogueValue): + analogueValue.cast(ExprSyntax.self) + } + } + + func operatorExpr(for predicate: PredicateDescription) -> BinaryOperatorExprSyntax? { + let operatorStr = + switch kind { + case .negative: + predicate.toNot + case .positive: + predicate.to + } + return operatorStr.map(BinaryOperatorExprSyntax.init) } }