Skip to content

Commit 5e6e59f

Browse files
authored
Rewrite quick_discouraged_call rule with SwiftSyntax (#6237)
1 parent 6c28c79 commit 5e6e59f

File tree

3 files changed

+128
-76
lines changed

3 files changed

+128
-76
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@
6565
* Improve detection of comment-only lines in `file_length` rule.
6666
[SimplyDanny](https://github.com/SimplyDanny)
6767
[#6219](https://github.com/realm/SwiftLint/issues/6219)
68+
69+
* Rewrite `quick_discouraged_call` rule with SwiftSyntax.
70+
[SimplyDanny](https://github.com/SimplyDanny)
6871

6972
### Bug Fixes
7073

Lines changed: 98 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import SourceKittenFramework
1+
import SwiftSyntax
22

3-
@DisabledWithoutSourceKit
4-
struct QuickDiscouragedCallRule: OptInRule {
3+
@SwiftSyntaxRule(foldExpressions: true, optIn: true)
4+
struct QuickDiscouragedCallRule: Rule {
55
var configuration = SeverityConfiguration<Self>(.warning)
66

77
static let description = RuleDescription(
@@ -12,93 +12,94 @@ struct QuickDiscouragedCallRule: OptInRule {
1212
nonTriggeringExamples: QuickDiscouragedCallRuleExamples.nonTriggeringExamples,
1313
triggeringExamples: QuickDiscouragedCallRuleExamples.triggeringExamples
1414
)
15+
}
1516

16-
func validate(file: SwiftLintFile) -> [StyleViolation] {
17-
let dict = file.structureDictionary
18-
let testClasses = dict.substructure.filter {
19-
$0.inheritedTypes.isNotEmpty &&
20-
$0.declarationKind == .class
21-
}
22-
23-
let specDeclarations = testClasses.flatMap { classDict in
24-
classDict.substructure.filter { structure in
25-
structure.name == "spec()"
26-
&& structure.enclosedVarParameters.isEmpty
27-
&& [.functionMethodInstance, .functionMethodStatic].contains(structure.declarationKind)
28-
&& structure.enclosedSwiftAttributes.contains(.override)
29-
}
30-
}
17+
private typealias ScopeElement = (kind: QuickCallKind, blockId: SyntaxIdentifier)?
3118

32-
return specDeclarations.flatMap {
33-
validate(file: file, dictionary: $0)
34-
}
35-
}
19+
private extension QuickDiscouragedCallRule {
20+
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
21+
private var quickScope = Stack<ScopeElement>()
3622

37-
private func validate(file: SwiftLintFile, dictionary: SourceKittenDictionary) -> [StyleViolation] {
38-
dictionary.traverseDepthFirst { subDict in
39-
guard let kind = subDict.expressionKind else { return nil }
40-
return validate(file: file, kind: kind, dictionary: subDict)
23+
override var skippableDeclarations: [any DeclSyntaxProtocol.Type] {
24+
.allExcept(ClassDeclSyntax.self, FunctionDeclSyntax.self, VariableDeclSyntax.self)
4125
}
42-
}
4326

44-
private func validate(file: SwiftLintFile,
45-
kind: SwiftExpressionKind,
46-
dictionary: SourceKittenDictionary) -> [StyleViolation] {
47-
// is it a call to a restricted method?
48-
guard kind == .call,
49-
let name = dictionary.name,
50-
let kindName = QuickCallKind(rawValue: name),
51-
QuickCallKind.restrictiveKinds.contains(kindName) else {
52-
return []
27+
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
28+
if node.inheritanceClause?.inheritedTypes.isNotEmpty == true {
29+
return .visitChildren
30+
}
31+
return .skipChildren
5332
}
5433

55-
return violationOffsets(in: dictionary.enclosedArguments).map {
56-
StyleViolation(ruleDescription: Self.description,
57-
severity: configuration.severity,
58-
location: Location(file: file, byteOffset: $0),
59-
reason: "Discouraged call inside a '\(name)' block")
34+
override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind {
35+
if node.name.text == "spec",
36+
node.signature.parameterClause.parameters.isEmpty,
37+
node.signature.returnClause == nil {
38+
return .visitChildren
39+
}
40+
return .skipChildren
6041
}
61-
}
6242

63-
private func violationOffsets(in substructure: [SourceKittenDictionary]) -> [ByteCount] {
64-
substructure.flatMap { dictionary -> [ByteCount] in
65-
let substructure = dictionary.substructure.flatMap { dict -> [SourceKittenDictionary] in
66-
if dict.expressionKind == .closure {
67-
return dict.substructure
43+
override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {
44+
if let calledName = node.calledExpression.as(DeclReferenceExprSyntax.self)?.baseName.text {
45+
if let kind = QuickCallKind(rawValue: calledName) {
46+
if let closure = node.trailingClosure {
47+
quickScope.push((kind, closure.statements.id))
48+
return .visitChildren
49+
}
50+
quickScope.push(nil)
51+
return .skipChildren
6852
}
69-
return [dict]
7053
}
71-
72-
return substructure.flatMap(toViolationOffsets)
54+
if let scope = quickScope.lastSuspiciousScope(node) {
55+
violations.append(.violation(at: node.positionAfterSkippingLeadingTrivia, kind: scope.kind))
56+
return .skipChildren
57+
}
58+
quickScope.push(nil)
59+
return .visitChildren
7360
}
74-
}
7561

76-
private func toViolationOffsets(dictionary: SourceKittenDictionary) -> [ByteCount] {
77-
guard dictionary.kind != nil,
78-
let offset = dictionary.offset else {
79-
return []
62+
override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind {
63+
for binding in node.bindings {
64+
if let scope = quickScope.lastSuspiciousScope(node),
65+
let initializer = binding.initializer,
66+
FunctionCallFinder(viewMode: .sourceAccurate).walk(tree: initializer.value, handler: \.found) {
67+
violations.append(.violation(
68+
at: initializer.value.positionAfterSkippingLeadingTrivia,
69+
kind: scope.kind
70+
))
71+
}
72+
}
73+
return .skipChildren
8074
}
8175

82-
if dictionary.expressionKind == .call,
83-
let name = dictionary.name, QuickCallKind(rawValue: name) == nil {
84-
return [offset]
76+
override func visit(_ node: InfixOperatorExprSyntax) -> SyntaxVisitorContinueKind {
77+
guard let scope = quickScope.lastSuspiciousScope(node),
78+
node.operator.is(AssignmentExprSyntax.self),
79+
node.leftOperand.is(DeclReferenceExprSyntax.self) || node.leftOperand.is(MemberAccessExprSyntax.self),
80+
let call = node.rightOperand.as(FunctionCallExprSyntax.self) else {
81+
return .visitChildren
82+
}
83+
violations.append(.violation(at: call.positionAfterSkippingLeadingTrivia, kind: scope.kind))
84+
return .skipChildren
8585
}
8686

87-
guard dictionary.expressionKind != .call else {
88-
return []
87+
override func visitPost(_: FunctionCallExprSyntax) {
88+
quickScope.pop()
8989
}
9090

91-
return dictionary.substructure.compactMap(toViolationOffset)
92-
}
93-
94-
private func toViolationOffset(dictionary: SourceKittenDictionary) -> ByteCount? {
95-
guard let name = dictionary.name,
96-
let offset = dictionary.offset,
97-
dictionary.expressionKind == .call,
98-
QuickCallKind(rawValue: name) == nil else {
99-
return nil
91+
override func visit(_ node: IfConfigClauseSyntax) -> SyntaxVisitorContinueKind {
92+
if let elements = node.elements?.as(CodeBlockItemListSyntax.self) {
93+
if let scope = quickScope.peek(), let scope {
94+
quickScope.push((kind: scope.kind, blockId: elements.id))
95+
} else {
96+
quickScope.push(nil)
97+
}
98+
walk(elements)
99+
quickScope.pop()
100+
}
101+
return .skipChildren
100102
}
101-
return offset
102103
}
103104
}
104105

@@ -128,3 +129,30 @@ private enum QuickCallKind: String {
128129
.describe, .fdescribe, .xdescribe, .context, .fcontext, .xcontext, .sharedExamples
129130
]
130131
}
132+
133+
private extension Stack where Element == ScopeElement {
134+
func lastSuspiciousScope(_ node: any SyntaxProtocol) -> ScopeElement {
135+
if let scope = peek(), let scope,
136+
QuickCallKind.restrictiveKinds.contains(scope.kind),
137+
node.parent?.is(CodeBlockItemSyntax.self) == true,
138+
node.parent?.parent?.id == scope.blockId {
139+
return scope
140+
}
141+
return nil
142+
}
143+
}
144+
145+
private extension ReasonedRuleViolation {
146+
static func violation(at position: AbsolutePosition, kind: QuickCallKind) -> Self {
147+
.init(position: position, reason: "Discouraged call inside a '\(kind)' block")
148+
}
149+
}
150+
151+
private final class FunctionCallFinder: SyntaxVisitor {
152+
private(set) var found = false
153+
154+
override func visit(_: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {
155+
found = true
156+
return .skipChildren
157+
}
158+
}

Source/SwiftLintBuiltInRules/Rules/Lint/QuickDiscouragedCallRuleExamples.swift

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,9 @@ internal struct QuickDiscouragedCallRuleExamples {
151151
class TotoTests: QuickSpec {
152152
override func spec() {
153153
describe("foo") {
154-
fit("does something") {
155-
let foo = Foo()
156-
foo.toto()
157-
}
154+
#if os(iOS)
155+
let foo = 1
156+
#endif
158157
}
159158
}
160159
}
@@ -265,7 +264,9 @@ internal struct QuickDiscouragedCallRuleExamples {
265264
class TotoTests: QuickSpec {
266265
override func spec() {
267266
describe("foo") {
267+
#if os(iOS)
268268
↓foo()
269+
#endif
269270
}
270271
}
271272
}
@@ -289,6 +290,26 @@ internal struct QuickDiscouragedCallRuleExamples {
289290
}
290291
"""),
291292
Example("""
293+
#if os(macOS)
294+
class TotoTests: QuickSpec {
295+
override func spec() {
296+
sharedExamples("foo") {
297+
↓foo()
298+
}
299+
}
300+
}
301+
#endif
302+
"""),
303+
Example("""
304+
class TotoTests: QuickSpec {
305+
override func spec() {
306+
sharedExamples("foo") {
307+
bar = ↓foo()
308+
}
309+
}
310+
}
311+
"""),
312+
Example("""
292313
class TotoTests: QuickSpec {
293314
override func spec() {
294315
xdescribe("foo") {
@@ -307,7 +328,7 @@ internal struct QuickDiscouragedCallRuleExamples {
307328
let foo = ↓Foo()
308329
}
309330
fcontext("foo") {
310-
let foo = ↓Foo()
331+
let foo = ↓f() + g()
311332
}
312333
}
313334
}
@@ -319,7 +340,7 @@ internal struct QuickDiscouragedCallRuleExamples {
319340
let foo = ↓Foo()
320341
}
321342
fcontext("foo") {
322-
let foo = ↓Foo()
343+
let foo = ↓{}()
323344
}
324345
}
325346
}

0 commit comments

Comments
 (0)