Skip to content

Commit

Permalink
Rewrite nimble_operator rule with SwiftSyntax (realm#4522)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcelofabri authored Jan 6, 2024
1 parent 0d37f9d commit 56ed87f
Show file tree
Hide file tree
Showing 2 changed files with 95 additions and 117 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
* `identifier_name`
* `let_var_whitespace`
* `multiline_literal_brackets`
* `nimble_operator`
* `opening_brace`
* `void_return`

Expand Down
211 changes: 94 additions & 117 deletions Source/SwiftLintBuiltInRules/Rules/Idiomatic/NimbleOperatorRule.swift
Original file line number Diff line number Diff line change
@@ -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<Self>(.warning)

static let description = RuleDescription(
Expand Down Expand Up @@ -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"),
Expand All @@ -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
private extension NimbleOperatorRule {
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
override func visitPost(_ node: FunctionCallExprSyntax) {
guard predicateDescription(for: node) != nil else {
return
}
violations.append(node.positionAfterSkippingLeadingTrivia)
}
}

var hasArguments: Bool {
guard case .withArguments = self else {
return false
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)
}
return true

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))
}
}

fileprivate typealias PredicateDescription = (to: String?, toNot: String?, arity: Arity)
typealias MatcherFunction = String

private let predicatesMapping: [MatcherFunction: PredicateDescription] = [
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
}
return Self.predicatesMapping[expectation.operatorExpr.baseName.text]
}
}

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 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
}
}

private func rawRegexResults(in file: SwiftLintFile) -> [NSRange] {
let operandPattern = "(.(?!expect\\())+?"

let operatorsPattern = "(" + predicatesMapping.map { name, predicateDescription in
let argumentsPattern = predicateDescription.arity.hasArguments
? operandPattern
: ""
let expected = predicateExpr.arguments.first?.expression
return Expectation(kind: kind, baseExpr: baseExpr, operatorExpr: operatorExpr, expected: expected)
}
}

return "\(name)\\(\(argumentsPattern)\\)"
}.joined(separator: "|") + ")"
private typealias PredicateDescription = (to: String, toNot: String?, arity: Arity)

let pattern = "expect\\(\(operandPattern)\\)\\.to(Not)?\\(\(operatorsPattern)\\)"
let excludingKinds = SyntaxKind.commentKinds
private enum Arity {
case nullary(analogueValue: any ExprSyntaxProtocol)
case withArguments
}

return file.match(pattern: pattern)
.filter { _, kinds in
excludingKinds.isDisjoint(with: kinds) && kinds.first == .identifier
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
}
.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
}
func expectedValueExpr(for predicate: PredicateDescription) -> ExprSyntax? {
switch predicate.arity {
case .withArguments:
expected
case .nullary(let analogueValue):
analogueValue.cast(ExprSyntax.self)
}

file.write(contents)
return corrections
}
}

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
func operatorExpr(for predicate: PredicateDescription) -> BinaryOperatorExprSyntax? {
let operatorStr =
switch kind {
case .negative:
predicate.toNot
case .positive:
predicate.to
}

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)
}

return nil
return operatorStr.map(BinaryOperatorExprSyntax.init)
}
}

0 comments on commit 56ed87f

Please sign in to comment.