Skip to content

Commit a7878fc

Browse files
Ignore case patterns in multiline_call_arguments (#6444)
Co-authored-by: Danny Mösch <danny.moesch@icloud.com>
1 parent bd662b7 commit a7878fc

5 files changed

Lines changed: 817 additions & 78 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@
9595
[SimplyDanny](https://github.com/SimplyDanny)
9696
[#6080](https://github.com/realm/SwiftLint/issues/6080)
9797

98+
* `multiline_call_arguments` no longer reports violations for enum-case patterns in
99+
pattern matching (e.g. if case, switch case, for case, catch).
100+
[GandaLF2006](https://github.com/GandaLF2006)
101+
98102
## 0.63.2: High-Speed Extraction
99103

100104
### Breaking

Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRule.swift

Lines changed: 157 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -5,105 +5,188 @@ import SwiftSyntax
55
struct MultilineCallArgumentsRule: Rule {
66
var configuration = MultilineCallArgumentsConfiguration()
77

8+
enum Reason {
9+
static let singleLineMultipleArgumentsNotAllowed =
10+
"Single-line calls with multiple arguments are not allowed"
11+
12+
static func tooManyArgumentsOnSingleLine(max: Int) -> String {
13+
"Too many arguments on a single line (max: \(max))"
14+
}
15+
16+
static let eachArgumentMustStartOnOwnLine =
17+
"In multi-line calls, each argument must start on its own line"
18+
19+
static let newlineRequiredAfterCommaInMultilineCall =
20+
"In multi-line calls, a newline is required after each comma"
21+
}
22+
823
static let description = RuleDescription(
924
identifier: "multiline_call_arguments",
1025
name: "Multiline Call Arguments",
11-
description: "Call should have each parameter on a separate line",
26+
description: """
27+
Enforces one-argument-per-line for multi-line calls and requires a newline after commas \
28+
when arguments are split across lines;
29+
optionally limits or forbids multi-argument single-line calls via configuration.
30+
""",
1231
kind: .style,
13-
nonTriggeringExamples: [
14-
Example("""
15-
foo(
16-
param1: "param1",
17-
param2: false,
18-
param3: []
19-
)
20-
""",
21-
configuration: ["max_number_of_single_line_parameters": 2]),
22-
Example("""
23-
foo(param1: 1,
24-
param2: false,
25-
param3: [])
26-
""",
27-
configuration: ["max_number_of_single_line_parameters": 1]),
28-
Example(
29-
"foo(param1: 1, param2: false)",
30-
configuration: ["max_number_of_single_line_parameters": 2]),
31-
Example(
32-
"Enum.foo(param1: 1, param2: false)",
33-
configuration: ["max_number_of_single_line_parameters": 2]),
34-
Example("foo(param1: 1)", configuration: ["allows_single_line": false]),
35-
Example("Enum.foo(param1: 1)", configuration: ["allows_single_line": false]),
36-
Example(
37-
"Enum.foo(param1: 1, param2: 2, param3: 3)",
38-
configuration: ["allows_single_line": true]),
39-
Example("""
40-
foo(
41-
param1: 1,
42-
param2: 2,
43-
param3: 3
44-
)
45-
""",
46-
configuration: ["allows_single_line": false]),
47-
],
48-
triggeringExamples: [
49-
Example(
50-
"↓foo(param1: 1, param2: false, param3: [])",
51-
configuration: ["max_number_of_single_line_parameters": 2]),
52-
Example(
53-
"↓Enum.foo(param1: 1, param2: false, param3: [])",
54-
configuration: ["max_number_of_single_line_parameters": 2]),
55-
Example("""
56-
↓foo(param1: 1, param2: false,
57-
param3: [])
58-
""",
59-
configuration: ["max_number_of_single_line_parameters": 3]),
60-
Example("""
61-
↓Enum.foo(param1: 1, param2: false,
62-
param3: [])
63-
""",
64-
configuration: ["max_number_of_single_line_parameters": 3]),
65-
Example("↓foo(param1: 1, param2: false)", configuration: ["allows_single_line": false]),
66-
Example("↓Enum.foo(param1: 1, param2: false)", configuration: ["allows_single_line": false]),
67-
]
32+
nonTriggeringExamples: MultilineCallArgumentsRuleExamples.nonTriggeringExamples,
33+
triggeringExamples: MultilineCallArgumentsRuleExamples.triggeringExamples
6834
)
6935
}
7036

7137
private extension MultilineCallArgumentsRule {
7238
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
39+
/// Cache line lookups by utf8Offset (stable, cheap key)
40+
private var lineCache: [Int: Int] = [:]
41+
42+
override init(configuration: ConfigurationType, file: SwiftLintFile) {
43+
super.init(configuration: configuration, file: file)
44+
45+
// Most files trigger O(10–100) unique line lookups for this rule.
46+
// Reserving a small initial capacity reduces rehashing; it is NOT a hard limit.
47+
lineCache.reserveCapacity(64)
48+
}
49+
7350
override func visitPost(_ node: FunctionCallExprSyntax) {
74-
if containsViolation(parameterPositions: node.arguments.map(\.positionAfterSkippingLeadingTrivia)) {
75-
violations.append(node.calledExpression.positionAfterSkippingLeadingTrivia)
51+
// Ignore calls that are part of pattern-matching syntax (patterns only, not bodies).
52+
guard !node.isInPatternMatchingPatternPosition else { return }
53+
54+
let args = node.arguments
55+
guard args.count > 1 else { return }
56+
57+
let argumentPositions = args.map(\.positionAfterSkippingLeadingTrivia)
58+
guard let violation = reasonedViolation(argumentPositions: argumentPositions, arguments: args) else {
59+
return
7660
}
61+
violations.append(violation)
7762
}
7863

79-
private func containsViolation(parameterPositions: [AbsolutePosition]) -> Bool {
80-
var numberOfParameters = 0
81-
var linesWithParameters: Set<Int> = []
82-
var hasMultipleParametersOnSameLine = false
64+
private func reasonedViolation(
65+
argumentPositions: [AbsolutePosition],
66+
arguments: LabeledExprListSyntax
67+
) -> ReasonedRuleViolation? {
68+
guard let firstPos = argumentPositions.first else { return nil }
8369

84-
for position in parameterPositions {
85-
let line = locationConverter.location(for: position).line
70+
let firstLine = line(for: firstPos)
71+
var allOnSameLine = true
72+
for pos in argumentPositions.dropFirst() where line(for: pos) != firstLine {
73+
allOnSameLine = false
74+
break
75+
}
76+
77+
if allOnSameLine {
78+
if !configuration.allowsSingleLine {
79+
return ReasonedRuleViolation(
80+
position: argumentPositions[1],
81+
reason: Reason.singleLineMultipleArgumentsNotAllowed
82+
)
83+
}
8684

87-
if !linesWithParameters.insert(line).inserted {
88-
hasMultipleParametersOnSameLine = true
85+
if let max = configuration.maxNumberOfSingleLineParameters,
86+
argumentPositions.count > max {
87+
return ReasonedRuleViolation(
88+
position: argumentPositions[max],
89+
reason: Reason.tooManyArgumentsOnSingleLine(max: max)
90+
)
8991
}
9092

91-
numberOfParameters += 1
93+
return nil
94+
}
95+
96+
if let startLineViolation = duplicateArgumentStartLineViolation(in: arguments) {
97+
return startLineViolation
98+
}
99+
100+
if let commaViolation = newlineAfterCommaViolation(in: arguments) {
101+
return commaViolation
92102
}
93103

94-
if linesWithParameters.count == 1 {
95-
guard configuration.allowsSingleLine else {
96-
return numberOfParameters > 1
104+
return nil
105+
}
106+
107+
private func duplicateArgumentStartLineViolation(
108+
in arguments: LabeledExprListSyntax
109+
) -> ReasonedRuleViolation? {
110+
let args = Array(arguments)
111+
guard args.count > 1 else { return nil }
112+
113+
var seen: Set<Int> = []
114+
for arg in args {
115+
let startPos = startPosition(of: arg)
116+
let line = line(for: startPos)
117+
if !seen.insert(line).inserted {
118+
return ReasonedRuleViolation(
119+
position: startPos,
120+
reason: Reason.eachArgumentMustStartOnOwnLine
121+
)
97122
}
123+
}
124+
125+
return nil
126+
}
127+
128+
private func newlineAfterCommaViolation(in arguments: LabeledExprListSyntax) -> ReasonedRuleViolation? {
129+
let args = Array(arguments)
130+
guard args.count > 1 else { return nil }
131+
132+
for index in args.indices.dropLast() {
133+
let current = args[index]
134+
let next = args[index + 1]
98135

99-
if let maxNumberOfSingleLineParameters = configuration.maxNumberOfSingleLineParameters {
100-
return numberOfParameters > maxNumberOfSingleLineParameters
136+
guard let comma = current.trailingComma, comma.presence != .missing else { continue }
137+
138+
if let lastToken = current.expression.lastToken(viewMode: .sourceAccurate) {
139+
switch lastToken.tokenKind {
140+
case .rightBrace,
141+
.rightSquare:
142+
continue
143+
default:
144+
break
145+
}
101146
}
102147

103-
return false
148+
let commaLine = line(for: comma.positionAfterSkippingLeadingTrivia)
149+
let currentStartLine = line(for: startPosition(of: current))
150+
let nextStartPos = startPosition(of: next)
151+
let nextStartLine = line(for: nextStartPos)
152+
153+
if commaLine == nextStartLine, currentStartLine != nextStartLine {
154+
return ReasonedRuleViolation(
155+
position: nextStartPos,
156+
reason: Reason.newlineRequiredAfterCommaInMultilineCall
157+
)
158+
}
104159
}
105160

106-
return hasMultipleParametersOnSameLine
161+
return nil
107162
}
163+
164+
private func startPosition(of argument: LabeledExprSyntax) -> AbsolutePosition {
165+
if let label = argument.label, label.presence != .missing {
166+
return label.positionAfterSkippingLeadingTrivia
167+
}
168+
return argument.expression.positionAfterSkippingLeadingTrivia
169+
}
170+
171+
private func line(for position: AbsolutePosition) -> Int {
172+
let key = position.utf8Offset
173+
if let cached = lineCache[key] { return cached }
174+
let line = locationConverter.location(for: position).line
175+
lineCache[key] = line
176+
return line
177+
}
178+
}
179+
}
180+
181+
private extension FunctionCallExprSyntax {
182+
/// Returns `true` if this call appears in a pattern position (e.g., `case .foo(a)`).
183+
///
184+
/// Works because SwiftSyntax wraps pattern expressions in `ExpressionPatternSyntax`:
185+
/// - `if case let .foo(a) = x` → parent is ExpressionPatternSyntax
186+
/// - `switch x { case let .foo(a): }` → parent is ExpressionPatternSyntax
187+
/// - `for case let .foo(a) in items` → parent is ExpressionPatternSyntax
188+
/// - `catch .foo(1, 2)` → parent is ExpressionPatternSyntax
189+
var isInPatternMatchingPatternPosition: Bool {
190+
parent?.is(ExpressionPatternSyntax.self) == true
108191
}
109192
}

0 commit comments

Comments
 (0)