@@ -5,105 +5,188 @@ import SwiftSyntax
55struct 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
7137private 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