From 30b173a85790c7e9c7992b2d48a380f9903ca669 Mon Sep 17 00:00:00 2001 From: Rodion Ivashkov Date: Mon, 6 Jul 2026 23:01:11 +0300 Subject: [PATCH 1/4] Add auto-correction, indentation config, and multi-violation support to multiline_call_arguments # Conflicts: # Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRuleExamples.swift --- CHANGELOG.md | 5 + .../Lint/MultilineCallArgumentsRule.swift | 400 ++++++++++--- .../MultilineCallArgumentsRuleExamples.swift | 527 +++++++++++++++--- .../Models/IndentationStyle.swift | 8 + .../MultilineCallArgumentsRuleTests.swift | 511 ++++++++++++++--- .../GlobalConfigurationTests.swift | 109 ---- .../Resources/default_rule_configurations.yml | 2 +- 7 files changed, 1224 insertions(+), 338 deletions(-) delete mode 100644 Tests/FrameworkTests/GlobalConfigurationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0925b8e0d9..feab80b8c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,11 @@ blocks with any whitespace or newlines between the braces. [arimu1](https://github.com/arimu1) [#6839](https://github.com/realm/SwiftLint/issues/6839) +* Add autocorrection to the `multiline_call_arguments` rule, expanding single-line + and multi-line calls to one-argument-per-line, including nested calls whose closing + `)` would otherwise be stranded with the last argument + (e.g. `foo(bar(\n a: 1\n), for: .normal)` → `foo(\n bar(\n a: 1\n ),\n for: .normal\n)`). + [GandaLF2006](https://github.com/GandaLF2006) ### Bug Fixes diff --git a/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRule.swift b/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRule.swift index 52020a037b..d161ed792a 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRule.swift @@ -1,7 +1,11 @@ +// swiftlint:disable file_length + +import Foundation +import SourceKittenFramework import SwiftLintCore import SwiftSyntax -@SwiftSyntaxRule(optIn: true) +@SwiftSyntaxRule(correctable: true, optIn: true) struct MultilineCallArgumentsRule: Rule { var configuration = MultilineCallArgumentsConfiguration() @@ -28,9 +32,15 @@ struct MultilineCallArgumentsRule: Rule { when arguments are split across lines; optionally limits or forbids multi-argument single-line calls via configuration. """, + rationale: """ + Keeping each argument on its own line in multi-line calls improves readability and + reduces merge conflicts. Requiring a newline after commas makes the call's structure + immediately visible and avoids ambiguous layouts where arguments appear to share a line. + """, kind: .style, nonTriggeringExamples: MultilineCallArgumentsRuleExamples.nonTriggeringExamples, - triggeringExamples: MultilineCallArgumentsRuleExamples.triggeringExamples + triggeringExamples: MultilineCallArgumentsRuleExamples.triggeringExamples, + corrections: MultilineCallArgumentsRuleExamples.corrections ) } @@ -39,6 +49,8 @@ private extension MultilineCallArgumentsRule { /// Cache line lookups by utf8Offset (stable, cheap key) private var lineCache: [Int: Int] = [:] + private static let closingTokensThatSkipNewlineAfterComma: [TokenKind] = [.rightBrace, .rightSquare] + override init(configuration: ConfigurationType, file: SwiftLintFile) { super.init(configuration: configuration, file: file) @@ -51,121 +63,316 @@ private extension MultilineCallArgumentsRule { // Ignore calls that are part of pattern-matching syntax (patterns only, not bodies). guard !node.isInPatternMatchingPatternPosition else { return } - let args = node.arguments - guard args.count > 1 else { return } - - let argumentPositions = args.map(\.positionAfterSkippingLeadingTrivia) - guard let violation = reasonedViolation(argumentPositions: argumentPositions, arguments: args) else { - return - } - violations.append(violation) + let arguments = Array(node.arguments) + guard arguments.count > 1 else { return } + + let argumentPositions = arguments.map(\.positionAfterSkippingLeadingTrivia) + violations.append( + contentsOf: violations( + argumentPositions: argumentPositions, + arguments: arguments, + callNode: node + ) + ) } - private func reasonedViolation( + private func violations( argumentPositions: [AbsolutePosition], - arguments: LabeledExprListSyntax - ) -> ReasonedRuleViolation? { - guard let firstPos = argumentPositions.first else { return nil } + arguments: [LabeledExprSyntax], + callNode: FunctionCallExprSyntax + ) -> [ReasonedRuleViolation] { + guard let firstPosition = argumentPositions.first else { return [] } - let firstLine = line(for: firstPos) - var allOnSameLine = true - for pos in argumentPositions.dropFirst() where line(for: pos) != firstLine { - allOnSameLine = false - break - } + let firstLine = line(for: firstPosition) + let allOnSameLine = argumentPositions.allSatisfy { line(for: $0) == firstLine } if allOnSameLine { if !configuration.allowsSingleLine { - return ReasonedRuleViolation( - position: argumentPositions[1], - reason: Reason.singleLineMultipleArgumentsNotAllowed - ) + return [ + ReasonedRuleViolation( + position: argumentPositions[1], + reason: Reason.singleLineMultipleArgumentsNotAllowed, + correction: correctSingleLine(arguments: arguments, callNode: callNode) + ), + ] } if let max = configuration.maxNumberOfSingleLineParameters, argumentPositions.count > max { - return ReasonedRuleViolation( - position: argumentPositions[max], - reason: Reason.tooManyArgumentsOnSingleLine(max: max) - ) + return [ + ReasonedRuleViolation( + position: argumentPositions[max], + reason: Reason.tooManyArgumentsOnSingleLine(max: max), + correction: correctSingleLine(arguments: arguments, callNode: callNode) + ), + ] } - return nil - } - - if let startLineViolation = duplicateArgumentStartLineViolation(in: arguments) { - return startLineViolation + return [] } - if let commaViolation = newlineAfterCommaViolation(in: arguments) { - return commaViolation + let hasArgsComments = arguments.contains(where: \.hasComments) + let baseIndent = baseIndent(for: callNode) + + guard let violation = fullExpansionViolation( + arguments: arguments, + callNode: callNode, + hasComments: hasArgsComments, + baseIndent: baseIndent + ) else { + return multilineViolations( + arguments: arguments, + callNode: callNode, + hasComments: hasArgsComments, + baseIndent: baseIndent + ) } - - return nil + return [violation] } - private func duplicateArgumentStartLineViolation( - in arguments: LabeledExprListSyntax + private func fullExpansionViolation( + arguments: [LabeledExprSyntax], + callNode: FunctionCallExprSyntax, + hasComments: Bool, + baseIndent: String ) -> ReasonedRuleViolation? { - let args = Array(arguments) - guard args.count > 1 else { return nil } - - var seen: Set = [] - for arg in args { - let startPos = startPosition(of: arg) - let line = line(for: startPos) - if !seen.insert(line).inserted { - return ReasonedRuleViolation( - position: startPos, - reason: Reason.eachArgumentMustStartOnOwnLine + guard + arguments.count == 2, + let rightParen = callNode.rightParen, + !hasComments, + let comma = arguments[0].trailingComma, comma.presence != .missing, + !endsWithClosingToken(argument: arguments[0]) + else { return nil } + + let first = arguments[0] + let second = arguments[1] + let callStartLine = line(for: callNode.positionAfterSkippingLeadingTrivia) + let firstStartLine = line(for: startPosition(of: first)) + let secondStart = startPosition(of: second) + let secondStartLine = line(for: secondStart) + let commaLine = line(for: comma.positionAfterSkippingLeadingTrivia) + + guard firstStartLine == callStartLine, + commaLine == secondStartLine, + firstStartLine != secondStartLine, + line(for: rightParen.positionAfterSkippingLeadingTrivia) == secondStartLine else { return nil } + + return ReasonedRuleViolation( + position: secondStart, + reason: Reason.newlineRequiredAfterCommaInMultilineCall, + correction: correctFullyExpanded( + firstArgument: first, + lastArgument: second, + rightParen: rightParen, + baseIndent: baseIndent + ) + ) + } + + private func multilineViolations( + arguments: [LabeledExprSyntax], + callNode: FunctionCallExprSyntax, + hasComments: Bool, + baseIndent: String + ) -> [ReasonedRuleViolation] { + var result: [ReasonedRuleViolation] = [] + for index in arguments.indices.dropLast() { + let currentArgument = arguments[index] + let next = arguments[index + 1] + + guard let comma = currentArgument.trailingComma, comma.presence != .missing else { continue } + + let currentStartLine = line(for: startPosition(of: currentArgument)) + let nextStart = startPosition(of: next) + let nextStartLine = line(for: nextStart) + + if currentStartLine == nextStartLine { + let correction: ReasonedRuleViolation.ViolationCorrection? = if hasComments { + nil + } else { + correctNewlineAndIndent( + start: currentArgument.endPositionBeforeTrailingTrivia, + end: nextStart, + baseIndent: baseIndent + ) + } + result.append( + ReasonedRuleViolation( + position: nextStart, + reason: Reason.eachArgumentMustStartOnOwnLine, + correction: correction + ) + ) + } else if !endsWithClosingToken(argument: currentArgument) { + let commaLine = line(for: comma.positionAfterSkippingLeadingTrivia) + guard commaLine == nextStartLine else { continue } + + let correction: ReasonedRuleViolation.ViolationCorrection? = if hasComments { + nil + } else if let rightParen = callNode.rightParen, + index == arguments.count - 2, + line(for: rightParen.positionAfterSkippingLeadingTrivia) == nextStartLine { + correctCloseParen( + comma: comma, + lastArgument: next, + rightParen: rightParen, + baseIndent: baseIndent + ) + } else { + correctNewlineAndIndent( + start: comma.endPositionBeforeTrailingTrivia, + end: nextStart, + baseIndent: baseIndent + ) + } + + result.append( + ReasonedRuleViolation( + position: nextStart, + reason: Reason.newlineRequiredAfterCommaInMultilineCall, + correction: correction + ) ) } } + return result + } + + private func correctNewlineAndIndent( + start: AbsolutePosition, + end: AbsolutePosition, + baseIndent: String + ) -> ReasonedRuleViolation.ViolationCorrection { + ReasonedRuleViolation.ViolationCorrection( + start: start, + end: end, + replacement: "\n" + (baseIndent + oneLevel) + ) + } - return nil + private func normalizedText(argument: LabeledExprSyntax) -> String { + argument.description.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") } - private func newlineAfterCommaViolation(in arguments: LabeledExprListSyntax) -> ReasonedRuleViolation? { - let args = Array(arguments) - guard args.count > 1 else { return nil } + private func correctCloseParen( + comma: TokenSyntax, + lastArgument: LabeledExprSyntax, + rightParen: TokenSyntax, + baseIndent: String + ) -> ReasonedRuleViolation.ViolationCorrection { + ReasonedRuleViolation.ViolationCorrection( + start: comma.endPositionBeforeTrailingTrivia, + end: rightParen.positionAfterSkippingLeadingTrivia, + replacement: "\n" + baseIndent + oneLevel + normalizedText(argument: lastArgument) + "\n" + baseIndent + ) + } - for index in args.indices.dropLast() { - let current = args[index] - let next = args[index + 1] + private func correctFullyExpanded( + firstArgument: LabeledExprSyntax, + lastArgument: LabeledExprSyntax, + rightParen: TokenSyntax, + baseIndent: String + ) -> ReasonedRuleViolation.ViolationCorrection { + let indentUnit = oneLevel + let indent = baseIndent + indentUnit + let reindented = normalizedText(argument: firstArgument) + .split(separator: "\n", omittingEmptySubsequences: false) + .enumerated() + .map { $0.offset == 0 ? String($0.element) : indentUnit + $0.element } + .joined(separator: "\n") + + return ReasonedRuleViolation.ViolationCorrection( + start: firstArgument.position, + end: rightParen.endPositionBeforeTrailingTrivia, + replacement: "\n" + indent + reindented + "\n" + indent + normalizedText(argument: lastArgument) + + "\n" + baseIndent + ")" + ) + } - guard let comma = current.trailingComma, comma.presence != .missing else { continue } + private func correctSingleLine( + arguments: [LabeledExprSyntax], + callNode: FunctionCallExprSyntax + ) -> ReasonedRuleViolation.ViolationCorrection? { + guard + let firstArgument = arguments.first, + !shouldSuppressSingleLineCorrection(for: callNode), + !arguments.contains(where: \.hasComments), + let rightParen = callNode.rightParen + else { return nil } + + let baseIndent = baseIndent(for: callNode) + let indent = baseIndent + oneLevel + + let argLines = arguments.enumerated().map { index, arg -> String in + let argText = normalizedText(argument: arg) + .split(separator: "\n", omittingEmptySubsequences: false) + .enumerated() + .map { $0.offset == 0 ? String($0.element) : oneLevel + $0.element } + .joined(separator: "\n") + let needsComma = index < arguments.count - 1 && arg.trailingComma?.presence == .missing + return indent + argText + (needsComma ? "," : "") + } - if let lastToken = current.expression.lastToken(viewMode: .sourceAccurate) { - switch lastToken.tokenKind { - case .rightBrace, - .rightSquare: - continue - default: - break - } - } + return ReasonedRuleViolation.ViolationCorrection( + start: firstArgument.position, + end: rightParen.endPositionBeforeTrailingTrivia, + replacement: "\n" + (argLines + [baseIndent + ")"]).joined(separator: "\n") + ) + } - let commaLine = line(for: comma.positionAfterSkippingLeadingTrivia) - let currentStartLine = line(for: startPosition(of: current)) - let nextStartPos = startPosition(of: next) - let nextStartLine = line(for: nextStartPos) + private var oneLevel: String { + (CurrentRule.configuration?.indentation ?? .default).indentationString + } - if commaLine == nextStartLine, currentStartLine != nextStartLine { - return ReasonedRuleViolation( - position: nextStartPos, - reason: Reason.newlineRequiredAfterCommaInMultilineCall - ) + private func endsWithClosingToken(argument: LabeledExprSyntax) -> Bool { + guard let lastToken = argument.expression.lastToken(viewMode: .sourceAccurate) else { return false } + return Self.closingTokensThatSkipNewlineAfterComma.contains(lastToken.tokenKind) + } + + private func baseIndent(for callNode: FunctionCallExprSyntax) -> String { + let lineNumber = line(for: callNode.positionAfterSkippingLeadingTrivia) + guard lineNumber > 0, lineNumber <= file.lines.count else { return "" } + return String(file.lines[lineNumber - 1].content.prefix(while: { $0.isWhitespace && $0 != "\r" })) + } + + private func shouldSuppressSingleLineCorrection(for callNode: FunctionCallExprSyntax) -> Bool { + var node = Syntax(callNode) + var inArguments = false + while let parent = node.parent { + if parent.is(LabeledExprSyntax.self) || parent.is(LabeledExprListSyntax.self) { + inArguments = true + } else if let outerCall = parent.as(FunctionCallExprSyntax.self), inArguments { + let outerArguments = Array(outerCall.arguments) + if outerArguments.count > 1 { + let outerPositions = outerArguments.map(\.positionAfterSkippingLeadingTrivia) + let firstLine = outerPositions.first.map { line(for: $0) } ?? 0 + if outerPositions.allSatisfy({ line(for: $0) == firstLine }), + !outerArguments.contains(where: \.hasComments) { + if !configuration.allowsSingleLine { + return true + } + if let max = configuration.maxNumberOfSingleLineParameters, + outerPositions.count > max { + return true + } + } + } + } else if inArguments { + break } + node = parent } - - return nil + return false } private func startPosition(of argument: LabeledExprSyntax) -> AbsolutePosition { if let label = argument.label, label.presence != .missing { - return label.positionAfterSkippingLeadingTrivia + label.positionAfterSkippingLeadingTrivia + } else { + argument.expression.positionAfterSkippingLeadingTrivia } - return argument.expression.positionAfterSkippingLeadingTrivia } private func line(for position: AbsolutePosition) -> Int { @@ -178,15 +385,32 @@ private extension MultilineCallArgumentsRule { } } +private extension LabeledExprSyntax { + var hasComments: Bool { + tokens(viewMode: .sourceAccurate).contains { token in + token.leadingTrivia.containsComments || token.trailingTrivia.containsComments + } + } +} + private extension FunctionCallExprSyntax { - /// Returns `true` if this call appears in a pattern position (e.g., `case .foo(a)`). - /// - /// Works because SwiftSyntax wraps pattern expressions in `ExpressionPatternSyntax`: - /// - `if case let .foo(a) = x` → parent is ExpressionPatternSyntax - /// - `switch x { case let .foo(a): }` → parent is ExpressionPatternSyntax - /// - `for case let .foo(a) in items` → parent is ExpressionPatternSyntax - /// - `catch .foo(1, 2)` → parent is ExpressionPatternSyntax + /// Returns `true` when this call (or a call containing this call) appears inside a + /// pattern-matching pattern (`if case`, `switch case`, `for case`, `catch`), + /// where SwiftSyntax wraps expressions in `ExpressionPatternSyntax`. + /// Walks up through intermediate call/argument nodes to detect nesting. var isInPatternMatchingPatternPosition: Bool { - parent?.is(ExpressionPatternSyntax.self) == true + var node = Syntax(self) + while let parent = node.parent { + if parent.is(ExpressionPatternSyntax.self) { + return true + } + if parent.is(LabeledExprSyntax.self) || parent.is(LabeledExprListSyntax.self) + || parent.is(FunctionCallExprSyntax.self) { + node = parent + continue + } + return false + } + return false } } diff --git a/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRuleExamples.swift b/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRuleExamples.swift index 99b978569c..87052857ef 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRuleExamples.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRuleExamples.swift @@ -1,17 +1,35 @@ import SwiftLintCore // swiftlint:disable file_length - -// swiftlint:disable:next type_body_length +// swiftlint:disable type_body_length +// swiftlint:disable collection_alignment struct MultilineCallArgumentsRuleExamples { static let nonTriggeringExamples: [Example] = #examples([ - // MARK: - Baseline: multi-line OK - """ + // MARK: - All configuration options shown + """ + foo( + param1: 1, + param2: false, + param3: [] + ) + """.asExample(configuration: ["allows_single_line": false]), + """ + foo(param1: 1, param2: false) + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + """ + foo( + \tparam1: 1, + \tparam2: false + ) + """.asExample(configuration: ["allows_single_line": false]), + + // MARK: - Baseline: multi-line OK + """ foo(param1: 1, param2: false, param3: []) """.asExample(configuration: ["max_number_of_single_line_parameters": 1]), - """ + """ func foo(one: [Int], animated: Bool) {} add(one: [ 1, @@ -19,7 +37,7 @@ struct MultilineCallArgumentsRuleExamples { 3 ], animated: true) """, - """ + """ foo( param1: 1, param2: 2, @@ -27,28 +45,32 @@ struct MultilineCallArgumentsRuleExamples { ) """.asExample(configuration: ["allows_single_line": false]), - // MARK: - Baseline: single-line OK - "foo(param1: 1, param2: false)".asExample(configuration: ["max_number_of_single_line_parameters": 2]), - "Enum.foo(param1: 1, param2: false)".asExample(configuration: ["max_number_of_single_line_parameters": 2]), + // MARK: - Baseline: single-line OK - // allows_single_line=false does NOT affect 0/1-arg calls - "foo()".asExample(configuration: ["allows_single_line": false]), - "foo(param1: 1)".asExample(configuration: ["allows_single_line": false]), - "Enum.foo(param1: 1)".asExample(configuration: ["allows_single_line": false]), + "foo(param1: 1, param2: false)".asExample(configuration: ["max_number_of_single_line_parameters": 2] + ), - // MARK: - Unlabeled / mixed arguments - "foo(1, 2)".asExample(configuration: ["max_number_of_single_line_parameters": 2]), - "foo(1, b: 2)".asExample(configuration: ["max_number_of_single_line_parameters": 2]), - "foo(1, b: 2, c: 3)".asExample(configuration: ["max_number_of_single_line_parameters": 3]), + "Enum.foo(param1: 1, param2: false)".asExample(configuration: ["max_number_of_single_line_parameters": 2] + ), - // MARK: - Enum-case constructor calls are normal calls (stable by declaring the enum) - """ + // allows_single_line=false does NOT affect 0/1-arg calls + "foo()".asExample(configuration: ["allows_single_line": false]), + "foo(param1: 1)".asExample(configuration: ["allows_single_line": false]), + "Enum.foo(param1: 1)".asExample(configuration: ["allows_single_line": false]), + + // MARK: - Unlabeled / mixed arguments + "foo(1, 2)".asExample(configuration: ["max_number_of_single_line_parameters": 2]), + "foo(1, b: 2)".asExample(configuration: ["max_number_of_single_line_parameters": 2]), + "foo(1, b: 2, c: 3)".asExample(configuration: ["max_number_of_single_line_parameters": 3]), + + // MARK: - Enum-case constructor calls are normal calls (stable by declaring the enum) + """ enum EnumCase { case first(one: Int, two: Int, three: Int, four: Int) } EnumCase.first(one: 1, two: 2, three: 3, four: 4) """.asExample(configuration: ["allows_single_line": true]), - """ + """ enum EnumCase { case first(one: Int, two: Int, three: Int, four: Int) } @@ -60,15 +82,15 @@ struct MultilineCallArgumentsRuleExamples { ) """.asExample(configuration: ["allows_single_line": false]), - // MARK: - Trailing closures are ignored by this rule (args-only) - // Single-line args still use max_number_of_single_line_parameters - """ + // MARK: - Trailing closures are ignored by this rule (args-only) + // Single-line args still use max_number_of_single_line_parameters + """ foo(a: 1, b: 2) { value in print(value) } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - // Multi-line args remain valid regardless of closure placement - """ + // Multi-line args remain valid regardless of closure placement + """ foo( a: 1, b: 2 @@ -76,7 +98,7 @@ struct MultilineCallArgumentsRuleExamples { print(value) } """.asExample(configuration: ["allows_single_line": false]), - """ + """ foo( a: 1, b: 2 @@ -85,21 +107,21 @@ struct MultilineCallArgumentsRuleExamples { print(value) } """.asExample(configuration: ["allows_single_line": false]), - // No-parens form: no arguments list -> never violates - """ + // No-parens form: no arguments list -> never violates + """ foo { value in print(value) } """.asExample(configuration: ["max_number_of_single_line_parameters": 1]), - // Multiple trailing closures: still args-only - """ + // Multiple trailing closures: still args-only + """ foo(a: 1, b: 2) { _ in print("main") } trailing: { _ in print("extra") } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ + """ foo(with: { _ in 9_999 }, and: { _ in @@ -107,8 +129,8 @@ struct MultilineCallArgumentsRuleExamples { }) """, - // MARK: - Trivia / comments - """ + // MARK: - Trivia / comments + """ foo( a: 1, // comment @@ -116,28 +138,27 @@ struct MultilineCallArgumentsRuleExamples { c: 3 ) """, - // Note: arguments start on the same line, so this is treated as a single-line-args call; - // the comma-newline check applies only when argument start lines are already split. - """ + // Note: arguments start on the same line, so this is treated as a single-line-args call; + // the comma-newline check applies only when argument start lines are already split. + """ foo( a: (1, 2), b: 3 ) """, - """ + """ foo( a: (1, 2), b: 3 ) """.asExample(configuration: ["allows_single_line": false]), - """ + """ foo( a: 1, // comment b: 2, c: 3 ) """, - - """ + """ enum EnumCase { case caseOne(Int, Int, Int, Int) } @@ -150,7 +171,7 @@ struct MultilineCallArgumentsRuleExamples { if case let .caseOne(_, _, three, _) = enumCase { } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ + """ enum EnumCase { case caseOne(one: Int, two: Int, three: Int, four: Int) } @@ -164,7 +185,7 @@ struct MultilineCallArgumentsRuleExamples { case let .caseOne(one: _, two: _, three: three, four: _): } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ + """ enum EnumCase { case caseOne(Int, Int, Int, Int) } let array: [EnumCase] = [ .caseOne( @@ -177,7 +198,7 @@ struct MultilineCallArgumentsRuleExamples { for case let .caseOne(_, _, three, _) in array { } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ + """ enum EnumCase { case caseOne(Int, Int, Int, Int) } @@ -189,7 +210,7 @@ struct MultilineCallArgumentsRuleExamples { ) guard case let .caseOne(_, _, three, _) = enumCase else { return } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ + """ enum EnumCase { case caseOne(Int, Int, Int, Int) } @@ -203,8 +224,8 @@ struct MultilineCallArgumentsRuleExamples { } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - // MARK: - Pattern matching MUST be ignored: catch patterns - """ + // MARK: - Pattern matching MUST be ignored: catch patterns + """ enum EnumCase: Error { case caseOne(Int, Int, Int, Int) } @@ -217,7 +238,7 @@ struct MultilineCallArgumentsRuleExamples { } catch let EnumCase.caseOne(_, _, three, _) { } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ + """ enum EnumCase: Error { case caseOne(one: Int, two: Int, three: Int, four: Int) } @@ -231,8 +252,8 @@ struct MultilineCallArgumentsRuleExamples { } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - // MARK: - Regular calls near patterns are still linted - """ + // MARK: - Regular calls near patterns are still linted + """ func foo(a: Int, b: Int, c: Int) -> Int { a + b + c } enum EnumCase { case caseOne(Int, Int, Int, Int) } @@ -249,8 +270,8 @@ struct MultilineCallArgumentsRuleExamples { ) } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - // MARK: - Pattern matching MUST be ignored: enum-case patterns with literal subpatterns - """ + // MARK: - Pattern matching MUST be ignored: enum-case patterns with literal subpatterns + """ enum EnumCase { case caseOne(Int, Int, Int, Int) } @@ -269,7 +290,7 @@ struct MultilineCallArgumentsRuleExamples { } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ + """ enum EnumCase { case caseOne(one: Int, two: Int, three: Int, four: Int) } @@ -289,7 +310,7 @@ struct MultilineCallArgumentsRuleExamples { } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ + """ enum EnumCase: Error { case caseOne(Int, Int, Int, Int) } @@ -302,12 +323,29 @@ struct MultilineCallArgumentsRuleExamples { // pattern — must be ignored } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + // MARK: - Nested call inside a pattern constructor must be ignored too + """ + enum Outer { case foo(Inner) } + enum Inner { case bar(Int, Int, Int) } + func test(_ x: Outer) { + if case .foo(.bar(1, 2, 3)) = x {} + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + // MARK: - First arg ending with `]` or `}` skips full expansion + """ + foo([ + 1, + 2 + ], b: 3) + """.asExample(configuration: ["allows_single_line": false]), ]) static let triggeringExamples: [Example] = #examples([ // MARK: - Single-line: too many args + "foo(param1: 1, param2: false, ↓param3: [])" .asExample(configuration: ["max_number_of_single_line_parameters": 2]), + "Enum.foo(param1: 1, param2: false, ↓param3: [])" .asExample(configuration: ["max_number_of_single_line_parameters": 2]), "foo(1, 2, ↓3)".asExample(configuration: ["max_number_of_single_line_parameters": 2]), @@ -318,19 +356,19 @@ struct MultilineCallArgumentsRuleExamples { "Enum.foo(param1: 1, ↓param2: false)".asExample(configuration: ["allows_single_line": false]), // MARK: - Multi-line: two args start on the same line - """ + """ foo( a: 1, ↓b: 2, c: 3 ) """, - """ + """ foo( a: 1, b: 2, ↓c: 3 ) """, - """ + """ foo( a: 1, b: 2, @@ -338,29 +376,38 @@ struct MultilineCallArgumentsRuleExamples { e: 5 ) """, - """ + """ foo( a: ( 1, 2 ), ↓b: 3 ) - """.asExample(configuration: ["max_number_of_single_line_parameters": 1]), - """ + """.asExample(configuration: ["max_number_of_single_line_parameters": 1] + ), + """ foo( a: 1, /* comment */ ↓b: 2, c: 3 ) """, + // MARK: - Multi-line: multiple duplicate argument start lines + """ + foo( + a: 1, ↓b: 2, + c: 3, ↓d: 4 + ) + """, + // MARK: - Enum-case constructor calls are linted like normal calls - """ + """ enum EnumCase { case first(one: Int, two: Int, three: Int, four: Int) } EnumCase.first(one: 1, ↓two: 2, three: 3, four: 4) """.asExample(configuration: ["allows_single_line": false]), - """ + """ enum EnumCase { case first(one: Int, two: Int, three: Int, four: Int) } @@ -368,7 +415,7 @@ struct MultilineCallArgumentsRuleExamples { """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), // MARK: - Trailing closure: parentheses args still checked - """ + """ foo(a: 1, ↓b: 2) { _ in print("x") } @@ -377,14 +424,14 @@ struct MultilineCallArgumentsRuleExamples { // MARK: - Targeted tests // Targeted: real `.caseOne(1,2,3,4)` call MUST be linted (not a pattern) - """ + """ enum EnumCase { case caseOne(Int, Int, Int, Int) } let x: EnumCase = .caseOne(1, 2, ↓3, 4) _ = x """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), // Targeted: labeled enum-case constructor call MUST be linted - """ + """ enum EnumCase { case caseOne(one: Int, two: Int, three: Int, four: Int) } @@ -393,7 +440,7 @@ struct MultilineCallArgumentsRuleExamples { """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), // Targeted: pattern-part ignored, RHS call linted - """ + """ func foo(a: Int, b: Int, c: Int) -> Int { a + b + c } enum EnumCase { case caseOne(Int, Int, Int, Int) } let enumCase: EnumCase = .caseOne( @@ -408,7 +455,7 @@ struct MultilineCallArgumentsRuleExamples { """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), // Targeted: switch-where RHS call linted, pattern ignored - """ + """ func foo(a: Int, b: Int, c: Int) -> Bool { a + b == c } enum EnumCase { case caseOne(Int, Int, Int, Int) } let enumCase: EnumCase = .caseOne( @@ -426,7 +473,7 @@ struct MultilineCallArgumentsRuleExamples { """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), // Targeted: for-case pattern ignored, body call linted - """ + """ func foo(a: Int, b: Int, c: Int) -> Int { a + b + c } enum EnumCase { case caseOne(Int, Int, Int, Int) } let array: [EnumCase] = [ @@ -441,19 +488,347 @@ struct MultilineCallArgumentsRuleExamples { _ = foo(a: 1, b: 2, ↓c: 3) } """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ - func foo(a: Int, b: Int, c: Int) -> Int { a + b + c } - enum EnumCase: Error { case caseOne(Int, Int, Int, Int) } + // MARK: - Tab indentation (uses global indentation setting) - func mayThrow() throws { + "foo(param1: 1, ↓param2: false)".asExample(configuration: ["allows_single_line": false]), + """ + class Test { + func method() { + if condition { + foo(param1: 1, ↓param2: false) + } + } } + """.asExample(configuration: ["allows_single_line": false]), + // MARK: - Comments between arguments (violation still detected) - do { - try mayThrow() - } catch let EnumCase.caseOne(_, _, _, _) { - _ = foo(a: 1, b: 2, ↓c: 3) + "foo(param1: 1, /* comment */ ↓param2: false)".asExample(configuration: ["allows_single_line": false]), + // MARK: - Nested single-line calls (both violate) + + "foo(bar(1, ↓2), ↓baz: 3)".asExample(configuration: ["allows_single_line": false] + ), + // MARK: - Nested multiline (closing `)` stranded with last arg) + """ + foo(bar( + a: 1, + b: 2, + c: 3 + ), ↓d: 4) + """, + // MARK: - Stranded `)` with first arg on its own line (closeParen branch, 2 args) + """ + foo( + a: bar( + 1 + ), ↓b: 2) + """, + // MARK: - Stranded `)` with 3+ args (no full expansion; converges over passes) + """ + foo(a: bar( + 1 + ), + b: 2, ↓c: 3) + """, + ]) + + static let corrections: [Example: Example] = #corrections([ + // MARK: - Single-line corrections + + "foo(param1: 1, ↓param2: false)" + .asExample(configuration: ["allows_single_line": false]): + """ + foo( + param1: 1, + param2: false + ) + """, + + "foo(param1: 1, param2: false, ↓param3: [])" + .asExample(configuration: ["max_number_of_single_line_parameters": 2]): + """ + foo( + param1: 1, + param2: false, + param3: [] + ) + """, + + "foo(1, ↓2)".asExample(configuration: ["allows_single_line": false]): + """ + foo( + 1, + 2 + ) + """, + + "foo(1, b: 2, ↓3)".asExample(configuration: ["max_number_of_single_line_parameters": 2]) + : """ + foo( + 1, + b: 2, + 3 + ) + """, + // MARK: - Multi-line: duplicate argument start line + """ + foo( + a: 1, ↓b: 2, + c: 3 + ) + """: """ + foo( + a: 1, + b: 2, + c: 3 + ) + """, + // MARK: - Multi-line: four args, two on same line + """ + foo( + a: 1, ↓b: 2, + c: 3, + d: 4 + ) + """: """ + foo( + a: 1, + b: 2, + c: 3, + d: 4 + ) + """, + // MARK: - Multi-line: multiple duplicate argument start lines + """ + foo( + a: 1, ↓b: 2, + c: 3, ↓d: 4 + ) + """: """ + foo( + a: 1, + b: 2, + c: 3, + d: 4 + ) + """, + // MARK: - Multi-line: newline after comma + """ + foo( + a: 1, + b: 2, ↓c: 3 + ) + """: """ + foo( + a: 1, + b: 2, + c: 3 + ) + """, + // MARK: - Call with trailing closure + + "foo(a: 1, ↓b: 2) { _ in }".asExample(configuration: ["allows_single_line": false]) + : """ + foo( + a: 1, + b: 2 + ) { _ in } + """, + // MARK: - Closure as argument + + "foo(a: 1, ↓b: { x in x })".asExample(configuration: ["allows_single_line": false]): + """ + foo( + a: 1, + b: { x in x } + ) + """, + // MARK: - Enum case call + + "Enum.foo(param1: 1, ↓param2: false)".asExample(configuration: ["allows_single_line": false]) + : """ + Enum.foo( + param1: 1, + param2: false + ) + """, + // MARK: - Tuple argument (stays on same line) + + "foo(a: (1, 2), ↓b: 3)".asExample(configuration: ["max_number_of_single_line_parameters": 1]) + : """ + foo( + a: (1, 2), + b: 3 + ) + """, + // MARK: - Enum-case constructor call + + "EnumCase.first(one: 1, ↓two: 2)".asExample(configuration: ["allows_single_line": false]) + : """ + EnumCase.first( + one: 1, + two: 2 + ) + """, + // MARK: - Multi-line with tuple argument + """ + foo( + a: (1, 2), ↓b: 3 + ) + """.asExample(configuration: ["max_number_of_single_line_parameters": 1]): """ + foo( + a: (1, 2), + b: 3 + ) + """, + // MARK: - Nested indentation (4 spaces default) + """ + class Test { + func method() { + if condition { + foo(param1: 1, ↓param2: false) + } + } + } + """.asExample(configuration: ["allows_single_line": false]): + """ + class Test { + func method() { + if condition { + foo( + param1: 1, + param2: false + ) + } + } } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + """, + // MARK: - Nested calls (inner call already correct, outer has violation) + "foo(bar(1), ↓baz: 3)".asExample(configuration: ["allows_single_line": false]) + : """ + foo( + bar(1), + baz: 3 + ) + """, + // MARK: - Nested multiline (inner call already multiline, outer duplicate start line) + """ + foo( + bar( + 1, + 2 + ), ↓baz: 3 + ) + """: """ + foo( + bar( + 1, + 2 + ), + baz: 3 + ) + """, + // MARK: - Nested multiline (closing `)` stranded with last argument) + """ + foo(bar( + a: 1, + b: 2, + c: 3 + ), ↓d: 4) + """: """ + foo( + bar( + a: 1, + b: 2, + c: 3 + ), + d: 4 + ) + """, + // MARK: - Stranded `)` with first arg on its own line (closeParen branch) + """ + foo( + a: bar( + 1 + ), ↓b: 2) + """: """ + foo( + a: bar( + 1 + ), + b: 2 + ) + """, + // MARK: - Nested single-line calls (inner correction suppressed) + "foo(bar(1, ↓2), ↓baz: 3)".asExample( + configuration: ["allows_single_line": false], + excludeFromDocumentation: true + ): """ + foo( + bar(1, 2), + baz: 3 + ) + """, + "grandFoo(singleArgFoo(bar(1, ↓2)), ↓baz: 3)" + .asExample( + configuration: ["allows_single_line": false], + excludeFromDocumentation: true + ): """ + grandFoo( + singleArgFoo(bar(1, 2)), + baz: 3 + ) + """, + "foo(bar(1, ↓2, ↓3), ↓baz: 4, ↓qux: 5)".asExample( + configuration: ["max_number_of_single_line_parameters": 2], + excludeFromDocumentation: true + ): """ + foo( + bar(1, 2, 3), + baz: 4, + qux: 5 + ) + """, + // MARK: - Nested with comments (inner correction allowed) + "foo(bar(1, ↓2) /* c */, ↓baz: 3)".asExample( + configuration: ["allows_single_line": false], + excludeFromDocumentation: true + ): """ + foo(bar( + 1, + 2 + ) /* c */, baz: 3) + """, + // MARK: - Open paren on new line + """ + foo( + a: 1, ↓b: 2) + """.asExample( + configuration: ["allows_single_line": false], + excludeFromDocumentation: true + ): """ + foo( + a: 1, + b: 2 + ) + """, + // MARK: - Multi-line closure argument reindentation + """ + foo(a: 1, ↓b: { + x + }) + """.asExample( + configuration: ["allows_single_line": false], + excludeFromDocumentation: true + ): """ + foo( + a: 1, + b: { + x + } + ) + """, ]) } +// swiftlint:enable collection_alignment +// swiftlint:enable type_body_length // swiftlint:enable file_length diff --git a/Source/SwiftLintCore/Models/IndentationStyle.swift b/Source/SwiftLintCore/Models/IndentationStyle.swift index 03f0d81064..663e1e7f4c 100644 --- a/Source/SwiftLintCore/Models/IndentationStyle.swift +++ b/Source/SwiftLintCore/Models/IndentationStyle.swift @@ -8,6 +8,14 @@ public enum IndentationStyle: Hashable, Sendable { /// The default indentation style if none is explicitly provided. package static let `default` = spaces(count: 4) + /// The string representation of one level of indentation. + public var indentationString: String { + switch self { + case .tabs: return "\t" + case .spaces(let count): return String(repeating: " ", count: count) + } + } + /// Creates an indentation style based on an untyped configuration value. /// /// - parameter object: The configuration value. diff --git a/Tests/BuiltInRulesTests/MultilineCallArgumentsRuleTests.swift b/Tests/BuiltInRulesTests/MultilineCallArgumentsRuleTests.swift index 0ba076096c..2e90b9bc81 100644 --- a/Tests/BuiltInRulesTests/MultilineCallArgumentsRuleTests.swift +++ b/Tests/BuiltInRulesTests/MultilineCallArgumentsRuleTests.swift @@ -1,14 +1,23 @@ +// swiftlint:disable file_length + @testable import SwiftLintBuiltInRules +import SwiftLintCore +@testable import SwiftLintFramework import TestHelpers import Testing @Suite(.rulesRegistered) struct MultilineCallArgumentsRuleTests { + // MARK: - Reason tests + @Test - func reasonSingleLineMultipleArgumentsNotAllowed() throws { + func reasonSingleLineNotAllowedHasPriorityOverMaxNumberOfSingleLineParameters() throws { let violations = try validate( - "foo(a: 1, b: 2)", - config: ["allows_single_line": false] + "foo(a: 1, b: 2, c: 3)", + config: [ + "allows_single_line": false, + "max_number_of_single_line_parameters": 1, + ] ) #expect(violations.count == 1) @@ -18,100 +27,448 @@ struct MultilineCallArgumentsRuleTests { } @Test - func reasonTooManyArgumentsOnASingleLine() throws { - let max = 2 + func reasonMixedDuplicateStartLineAndNewlineAfterComma() throws { + let violations = try validate(""" + foo( + a: 1, b: 2, + c: ( + 3, + 4 + ), d: 5 + ) + """) + #expect(violations.count == 2) + #expect(violations[0].reason == MultilineCallArgumentsRule.Reason.eachArgumentMustStartOnOwnLine) + #expect(violations[1].reason == MultilineCallArgumentsRule.Reason.newlineRequiredAfterCommaInMultilineCall) + } + + @Test + func reasonMultipleNewlineAfterCommaViolations() throws { + let violations = try validate(""" + foo( + a: ( + 1, + 2 + ), b: 3, + c: ( + 4, + 5 + ), d: 6 + ) + """) + #expect(violations.count == 2) + #expect(violations.allSatisfy { + $0.reason == MultilineCallArgumentsRule.Reason.newlineRequiredAfterCommaInMultilineCall + }) + } + + @Test + func reasonTooManyArgumentsOnSingleLine() throws { let violations = try validate( "foo(a: 1, b: 2, c: 3)", - config: ["max_number_of_single_line_parameters": max] + config: ["max_number_of_single_line_parameters": 2] ) - #expect(violations.count == 1) - #expect( - violations.first?.reason == MultilineCallArgumentsRule.Reason.tooManyArgumentsOnSingleLine(max: max) - ) + #expect(violations.first?.reason == MultilineCallArgumentsRule.Reason.tooManyArgumentsOnSingleLine(max: 2)) } + // MARK: - Auto-correction: no correction when comments present + @Test - func reasonMultilineEachArgumentMustStartOnItsOwnLine() throws { - let violations = try validate(""" + func correctionWithCommentBetweenArgumentsDoesNotAutoCorrect() throws { + let contents = "foo(a: 1, /* comment */ b: 2)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 0) + #expect(file.contents == contents) + } + + @Test + func correctionWithCommentBetweenLabelAndColonDoesNotAutoCorrect() throws { + let contents = "foo(a /* comment */: 1, b: 2)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 0) + #expect(file.contents == contents) + } + + @Test + func correctionWithCommentInStringLiteralDoesAutoCorrect() throws { + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file1 = SwiftLintFile(contents: "foo(a: \"/* not a comment */\", b: 2)") + #expect(rule.correct(file: file1) == 1) + let file2 = SwiftLintFile(contents: "foo(a: \"/* not a comment */\", b: \"// also not\")") + #expect(rule.correct(file: file2) == 1) + } + + @Test + func correctionMultilineWithCommentsDoesNotAutoCorrect() throws { + let rule = try MultilineCallArgumentsRule(configuration: [:]) + let cases = [ + "foo(\n a: 1, /* comment */ b: 2,\n c: 3\n)", + "foo(\n a: 1 /* comment */, b: 2,\n c: 3\n)", + "foo(\n a: (\n 1,\n 2\n ), /* comment */ b: 3\n)", + ] + for contents in cases { + let file = SwiftLintFile(contents: contents) + #expect(!rule.validate(file: file).isEmpty, "violations must be reported: \(contents)") + #expect(rule.correct(file: file) == 0, "Should not correct: \(contents)") + #expect(file.contents == contents) + } + } + + // MARK: - Configuration + + @Test + func configurationInvalidValuesThrow() { + #expect(throws: Issue.self) { + _ = try MultilineCallArgumentsRule(configuration: ["max_number_of_single_line_parameters": 0]) + } + #expect(throws: Issue.self) { + _ = try MultilineCallArgumentsRule(configuration: ["max_number_of_single_line_parameters": -1]) + } + #expect(throws: Issue.self) { + _ = try MultilineCallArgumentsRule(configuration: [ + "allows_single_line": false, + "max_number_of_single_line_parameters": 2, + ]) + } + } + + @Test + func configurationAllowsSingleLineFalseWithMaxParametersOneIsValid() { + #expect(throws: Never.self) { + _ = try MultilineCallArgumentsRule(configuration: [ + "allows_single_line": false, + "max_number_of_single_line_parameters": 1, + ]) + } + } + + // MARK: - Line cache: multiple calls on different lines + + @Test + func lineCacheMultipleCallsOnDifferentLinesViolationsHaveCorrectLineNumbers() throws { + let contents = """ + foo(a: 1, b: 2, c: 3) + bar(x: 1, y: 2, z: 3, w: 4) + baz(1, 2, 3, 4, 5) + """ + let violations = try validate(contents, config: ["max_number_of_single_line_parameters": 2]) + #expect(violations.count == 3) + #expect(violations[0].location.line == 1) + #expect(violations[1].location.line == 2) + #expect(violations[2].location.line == 3) + } + + // MARK: - Global indentation via CurrentRule.configuration + + @Test + func correctionUsesGlobalTabIndentation() throws { + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: "foo(a: 1, b: 2)") + CurrentRule.$configuration.withValue(GlobalConfiguration(indentation: .tabs)) { + #expect(rule.correct(file: file) == 1) + } + #expect(file.contents == "foo(\n\ta: 1,\n\tb: 2\n)") + } + + @Test + func correctionUsesGlobal2SpaceIndentation() throws { + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: "foo(a: 1, b: 2)") + CurrentRule.$configuration.withValue(GlobalConfiguration(indentation: .spaces(count: 2))) { + #expect(rule.correct(file: file) == 1) + } + #expect(file.contents == "foo(\n a: 1,\n b: 2\n)") + } + + @Test + func correctionMultilineUsesGlobalTabIndentation() throws { + let contents = """ foo( a: 1, b: 2, c: 3 ) """ - ) + let rule = try MultilineCallArgumentsRule(configuration: [:]) + let file = SwiftLintFile(contents: contents) + CurrentRule.$configuration.withValue(GlobalConfiguration(indentation: .tabs)) { + #expect(rule.correct(file: file) == 1) + } + // Only the violation (b: 2 on same line as a: 1) is corrected; + // existing indentation of a: 1 and c: 3 is preserved. + #expect(file.contents == "foo(\n a: 1,\n\tb: 2,\n c: 3\n)") + } - #expect(violations.count == 1) - #expect( - violations.first?.reason == MultilineCallArgumentsRule.Reason.eachArgumentMustStartOnOwnLine - ) + // MARK: - Deeply nested suppression + + @Test + func correctionDeeplyNestedSuppressesInnermostCorrection() throws { + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: "outer(middle(inner(1, 2), 3), 4)") + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "outer(\n middle(inner(1, 2), 3),\n 4\n)") } @Test - func reasonMultilineEachArgumentMustStartOnItsOwnLineDetectsSameLineAfterNewline() throws { - let violations = try validate(""" - foo( - a: 1, - b: 2, c: 3 - ) + func correctionDeeplyNestedThroughNonViolatingMiddleSuppressesInner() throws { + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: "outer(single(middle(1, 2)), 3)") + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "outer(\n single(middle(1, 2)),\n 3\n)") + } + + @Test + func correctionDeeplyNestedWithMaxSuppressesInner() throws { + let rule = try MultilineCallArgumentsRule(configuration: ["max_number_of_single_line_parameters": 2]) + let file = SwiftLintFile(contents: "outer(middle(1, 2, 3), 4, 5)") + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "outer(\n middle(1, 2, 3),\n 4,\n 5\n)") + } + + // MARK: - Full expansion (first arg glued to `(`, last arg stranded with `)`) + + @Test + func correctionFullyExpandsWhenFirstArgGluedAndLastArgStranded() throws { + let contents = "foo(bar(\n a: 1,\n b: 2\n), c: 3)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "foo(\n bar(\n a: 1,\n b: 2\n ),\n c: 3\n)") + } + + @Test + func correctionFullyExpandedUsesGlobalTabIndentation() throws { + let contents = "foo(bar(\n\ta: 1,\n\tb: 2\n), c: 3)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + CurrentRule.$configuration.withValue(GlobalConfiguration(indentation: .tabs)) { + #expect(rule.correct(file: file) == 1) + } + #expect(file.contents == "foo(\n\tbar(\n\t\ta: 1,\n\t\tb: 2\n\t),\n\tc: 3\n)") + } + + @Test + func correctionFullyExpandedIsIdempotent() throws { + let contents = "foo(bar(\n a: 1,\n b: 2\n), c: 3)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + let firstPass = file.contents + #expect(rule.correct(file: file) == 0, "second pass must not change a corrected call") + #expect(file.contents == firstPass) + } + + @Test + func correctionFullyExpandedWorksWithUnlabeledArguments() throws { + let contents = "foo(bar(\n 1,\n 2\n), 3)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "foo(\n bar(\n 1,\n 2\n ),\n 3\n)") + } + + // MARK: - Stranded `)` with first arg on its own line (closeParen branch) + + @Test + func correctionCloseParenMovesLastArgAndParen() throws { + let contents = "foo(\n a: bar(\n 1\n ), b: 2)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "foo(\n a: bar(\n 1\n ),\n b: 2\n)") + } + + @Test + func correctionCloseParenUsesGlobalTabIndentation() throws { + let contents = "foo(\n\ta: bar(\n\t\t1\n\t), b: 2)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + CurrentRule.$configuration.withValue(GlobalConfiguration(indentation: .tabs)) { + #expect(rule.correct(file: file) == 1) + } + #expect(file.contents == "foo(\n\ta: bar(\n\t\t1\n\t),\n\tb: 2\n)") + } + + @Test + func correctionCloseParenWithUnlabeledArgs() throws { + let contents = "foo(\n bar(\n 1\n ), 2)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "foo(\n bar(\n 1\n ),\n 2\n)") + } + + @Test + func correctionFullyExpandedHandlesCRLF() throws { + let contents = "foo(bar(\r\n a: 1,\r\n b: 2\r\n), c: 3)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "foo(\n bar(\n a: 1,\n b: 2\n ),\n c: 3\n)") + } + + @Test + func correctionCloseParenHandlesCRLF() throws { + let contents = "foo(\r\n a: bar(\r\n 1\r\n ), b: 2)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "foo(\r\n a: bar(\r\n 1\r\n ),\n b: 2\n)") + } + + // MARK: - 3+ arguments: no data loss and idempotent convergence + + @Test + func correctionThreeArgsDoesNotDropMiddleArguments() throws { + let contents = "foo(a: bar(\n 1\n), b: 2, c: 3)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + // Correct to convergence: no pass may drop an argument. + var remaining = 5 + while remaining > 0, rule.correct(file: file) > 0 { + remaining -= 1 + } + // All three original argument labels must still be present. + #expect(file.contents.contains("a: bar(")) + #expect(file.contents.contains("b: 2")) + #expect(file.contents.contains("c: 3")) + // Exact converged output. + #expect(file.contents == "foo(a: bar(\n 1\n),\n b: 2,\n c: 3)") + // Converged: another pass produces no corrections. + #expect(rule.correct(file: file) == 0) + } + + // MARK: - Pattern matching: nested call inside a pattern constructor + + @Test + func nestedCallInsidePatternIsNotLinted() throws { + let contents = """ + enum Outer { case foo(Inner) } + enum Inner { case bar(Int, Int, Int) } + func test(_ x: Outer) { + if case .foo(.bar(1, 2, 3)) = x {} + } """ - ) + let violations = try validate(contents, config: ["max_number_of_single_line_parameters": 2]) + #expect(violations.isEmpty, "inner `.bar(1, 2, 3)` is part of the pattern, not a call to lint") + } - #expect(violations.count == 1) - #expect( - violations.first?.reason == MultilineCallArgumentsRule.Reason.eachArgumentMustStartOnOwnLine - ) + // MARK: - closeParen branch: 3+ arguments and idempotency + + @Test + func correctionCloseParenWithThreeArgsMovesLastArgAndParen() throws { + let contents = "foo(\n a: 1,\n b: bar(\n 2\n ), c: 3)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "foo(\n a: 1,\n b: bar(\n 2\n ),\n c: 3\n)") } @Test - func reasonMultilineRequiresNewlineAfterCommaInSplitLayout() throws { - let violations = try validate(""" + func correctionCloseParenIsIdempotent() throws { + let contents = "foo(\n a: bar(\n 1\n ), b: 2)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + let firstPass = file.contents + #expect(rule.correct(file: file) == 0, "second pass must not change a corrected call") + #expect(file.contents == firstPass) + } + + // MARK: - Idempotency of standard corrections + + @Test + func correctionSingleLineIsIdempotent() throws { + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: "foo(a: 1, b: 2)") + #expect(rule.correct(file: file) == 1) + let firstPass = file.contents + #expect(rule.correct(file: file) == 0, "second pass must not change a corrected call") + #expect(file.contents == firstPass) + } + + @Test + func correctionNewlineAfterCommaIsIdempotent() throws { + let contents = """ foo( - a: ( - 1, - 2 - ), b: 3 + a: 1, + b: 2, c: 3 ) """ - ) + let rule = try MultilineCallArgumentsRule(configuration: [:]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + let firstPass = file.contents + #expect(rule.correct(file: file) == 0, "second pass must not change a corrected call") + #expect(file.contents == firstPass) + } - #expect(violations.count == 1) - #expect( - violations.first?.reason == MultilineCallArgumentsRule.Reason.newlineRequiredAfterCommaInMultilineCall - ) + // MARK: - Multi-line closure argument reindentation + + @Test + func correctionSingleLineWithMultilineClosureReindentsInnerLines() throws { + let contents = "foo(a: 1, b: {\n x\n})" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "foo(\n a: 1,\n b: {\n x\n }\n)") } @Test - func reasonTooManyArgumentsOnASingleLineWorksWithTrailingClosure() throws { - let max = 1 - let violations = try validate(""" - foo(a: 1, b: 2) { _ in - print("x") - } - """, - config: ["max_number_of_single_line_parameters": max] - ) + func correctionSingleLineWithMultilineClosureIsIdempotent() throws { + let contents = "foo(a: 1, b: {\n x\n})" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + let firstPass = file.contents + #expect(rule.correct(file: file) == 0, "second pass must not change a corrected call") + #expect(file.contents == firstPass) + } - #expect(violations.count == 1) - #expect( - violations.first?.reason == MultilineCallArgumentsRule.Reason.tooManyArgumentsOnSingleLine(max: max) - ) + // MARK: - Full expansion with labeled first argument + + @Test + func correctionFullyExpandedWithLabeledFirstArg() throws { + let contents = "foo(a: bar(\n 1\n), b: 2)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "foo(\n a: bar(\n 1\n ),\n b: 2\n)") } @Test - func reasonSingleLineNotAllowedHasPriorityOverMaxNumberOfSingleLineParameters() throws { - let violations = try validate( - "foo(a: 1, b: 2, c: 3)", - config: [ - "allows_single_line": false, - "max_number_of_single_line_parameters": 1, - ] - ) + func correctionFullyExpandedHandlesPureCR() throws { + let contents = "foo(bar(\r a: 1,\r b: 2\r), c: 3)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "foo(\n bar(\n a: 1,\n b: 2\n ),\n c: 3\n)") + } - #expect(violations.count == 1) - #expect( - violations.first?.reason == MultilineCallArgumentsRule.Reason.singleLineMultipleArgumentsNotAllowed - ) + @Test + func correctionCloseParenHandlesPureCR() throws { + let contents = "foo(\r a: bar(\r 1\r ), b: 2)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + #expect(file.contents == "foo(\r a: bar(\r 1\r ),\n b: 2\n)") + } + + // MARK: - closingTokensThatSkipNewlineAfterComma: rightBrace (multiline) + + @Test + func rightBraceSkipMultilineDoesNotViolate() throws { + let contents = """ + foo( + a: { + x in x + }, b: 2 + ) + """ + let violations = try validate(contents, config: ["allows_single_line": false]) + #expect(violations.isEmpty, "argument ending with `}` must skip newline-after-comma check") } // MARK: - Helper @@ -121,3 +478,29 @@ struct MultilineCallArgumentsRuleTests { return rule.validate(file: SwiftLintFile(contents: contents)) } } + +@Suite(.rulesRegistered) +struct MultilineCallArgumentsLinterTests { + // MARK: - End-to-end: global indentation through Linter pipeline + + private func makeLinter(file: SwiftLintFile, indentation: IndentationStyle) throws -> CollectedLinter { + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let config = Configuration( + rulesMode: .onlyConfiguration(["multiline_call_arguments"]), + allRulesWrapped: [(rule, false)], + indentation: indentation + ) + let storage = RuleStorage() + return Linter(file: file, configuration: config).collect(into: storage) + } + + @Test + func linterCorrectionUsesGlobalTabIndentation() throws { + let file = SwiftLintFile(contents: "foo(a: 1, b: 2)") + let linter = try makeLinter(file: file, indentation: .tabs) + let storage = RuleStorage() + let corrections = linter.correct(using: storage) + #expect(corrections["multiline_call_arguments"] == 1) + #expect(file.contents == "foo(\n\ta: 1,\n\tb: 2\n)") + } +} diff --git a/Tests/FrameworkTests/GlobalConfigurationTests.swift b/Tests/FrameworkTests/GlobalConfigurationTests.swift deleted file mode 100644 index 18cb29313a..0000000000 --- a/Tests/FrameworkTests/GlobalConfigurationTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -import SwiftLintCore -import SwiftLintFramework -import TestHelpers -import Testing - -@Suite(.rulesRegistered) -struct GlobalConfigurationTests { - // MARK: - Bridge: Configuration -> GlobalConfiguration - - @Test(arguments: [ - IndentationStyle.tabs, - IndentationStyle.spaces(count: 2), - IndentationStyle.spaces(count: 4), - ]) - func bridgeMapsIndentation(_ indentation: IndentationStyle) { - let configuration = Configuration(indentation: indentation) - #expect(configuration.globalConfiguration.indentation == indentation) - } - - @Test - func bridgeMapsDefaultIndentationWhenNotSpecified() { - let configuration = Configuration() - #expect(configuration.globalConfiguration.indentation == .default) - } - - @Test - func configurationIsNilOutsideLinterContext() { - #expect(CurrentRule.configuration == nil) - } - - // MARK: - End-to-end: CurrentRule.configuration is propagated to validate - - /// Emits a violation whose reason encodes the indentation observed via `CurrentRule.configuration`. - private struct GlobalIndentationDummyRule: SourceKitFreeRule { - var configuration = SeverityConfiguration(.warning) - var configurationDescription: some Documentable { RuleConfigurationOption.noOptions } - - static let description = RuleDescription( - identifier: "global_indentation_dummy", - name: "", - description: "", - kind: .style - ) - - init() { /* conformance for test */ } - init(configuration _: Any) { self.init() } - - func validate(file: SwiftLintFile) -> [StyleViolation] { - let indentation = CurrentRule.configuration?.indentation - let reason: String - switch indentation { - case .tabs: reason = "tabs" - case .spaces(let count): reason = "spaces(\(count))" - case nil: reason = "nil" - } - return [ - StyleViolation( - ruleDescription: Self.description, - severity: .warning, - location: Location(file: file.path, line: 1, character: 1), - reason: reason - ), - ] - } - } - - private func dummyReason(indentation: IndentationStyle) -> String { - let ruleList = RuleList(rules: GlobalIndentationDummyRule.self) - let configuration = Configuration( - rulesMode: .onlyConfiguration([GlobalIndentationDummyRule.identifier]), - ruleList: ruleList, - indentation: indentation - ) - let file = SwiftLintFile(contents: "let x = 0\n") - let storage = RuleStorage() - let linter = Linter(file: file, configuration: configuration).collect(into: storage) - return linter.styleViolations(using: storage).first?.reason ?? "no violations" - } - - @Test(arguments: [ - (indentation: IndentationStyle.tabs, expected: "tabs"), - (indentation: IndentationStyle.spaces(count: 2), expected: "spaces(2)"), - (indentation: IndentationStyle.spaces(count: 4), expected: "spaces(4)"), - ]) - func ruleObservesIndentation(indentation: IndentationStyle, expected: String) { - #expect(dummyReason(indentation: indentation) == expected) - } - - @Test - func differentConfigurationsDoNotLeakBetweenLints() { - #expect(dummyReason(indentation: .tabs) == "tabs") - #expect(dummyReason(indentation: .spaces(count: 2)) == "spaces(2)") - } - - @Test - func linterConfigurationShadowsOuterTaskLocalAndRestoresAfter() { - let outerConfig = GlobalConfiguration(indentation: .spaces(count: 8)) - - let reason = CurrentRule.$configuration.withValue(outerConfig) { - #expect(CurrentRule.configuration?.indentation == .spaces(count: 8)) - let result = dummyReason(indentation: .tabs) - #expect(CurrentRule.configuration?.indentation == .spaces(count: 8)) - return result - } - - #expect(reason == "tabs") - #expect(CurrentRule.configuration == nil) - } -} diff --git a/Tests/IntegrationTests/Resources/default_rule_configurations.yml b/Tests/IntegrationTests/Resources/default_rule_configurations.yml index 447acc96df..cc2f59c8b2 100644 --- a/Tests/IntegrationTests/Resources/default_rule_configurations.yml +++ b/Tests/IntegrationTests/Resources/default_rule_configurations.yml @@ -709,7 +709,7 @@ multiline_call_arguments: allows_single_line: true meta: opt-in: true - correctable: false + correctable: true multiline_function_chains: severity: warning meta: From 3cd04ac45c07118ce5fa7a6a28bc23db7f7c70c8 Mon Sep 17 00:00:00 2001 From: Rodion Ivashkov Date: Wed, 15 Jul 2026 10:41:26 +0300 Subject: [PATCH 2/4] Remove duplicates --- CHANGELOG.md | 11 +++--- .../Lint/MultilineCallArgumentsRule.swift | 38 +++++++++++-------- .../Models/IndentationStyle.swift | 17 +++++++++ 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index feab80b8c5..6992705d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,11 @@ ### Enhancements -* None. +* Add autocorrection to the `multiline_call_arguments` rule, expanding single-line + and multi-line calls to one-argument-per-line, including nested calls whose closing + `)` would otherwise be stranded with the last argument + (e.g. `foo(bar(\n a: 1\n), for: .normal)` → `foo(\n bar(\n a: 1\n ),\n for: .normal\n)`). + [GandaLF2006](https://github.com/GandaLF2006) ### Bug Fixes @@ -64,11 +68,6 @@ blocks with any whitespace or newlines between the braces. [arimu1](https://github.com/arimu1) [#6839](https://github.com/realm/SwiftLint/issues/6839) -* Add autocorrection to the `multiline_call_arguments` rule, expanding single-line - and multi-line calls to one-argument-per-line, including nested calls whose closing - `)` would otherwise be stranded with the last argument - (e.g. `foo(bar(\n a: 1\n), for: .normal)` → `foo(\n bar(\n a: 1\n ),\n for: .normal\n)`). - [GandaLF2006](https://github.com/GandaLF2006) ### Bug Fixes diff --git a/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRule.swift b/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRule.swift index d161ed792a..43e087e06b 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRule.swift @@ -256,6 +256,15 @@ private extension MultilineCallArgumentsRule { .replacingOccurrences(of: "\r", with: "\n") } + private func reindentedText(argument: LabeledExprSyntax) -> String { + let indentUnit = oneLevel + return normalizedText(argument: argument) + .split(separator: "\n", omittingEmptySubsequences: false) + .enumerated() + .map { $0.offset == 0 ? String($0.element) : indentUnit + $0.element } + .joined(separator: "\n") + } + private func correctCloseParen( comma: TokenSyntax, lastArgument: LabeledExprSyntax, @@ -265,7 +274,7 @@ private extension MultilineCallArgumentsRule { ReasonedRuleViolation.ViolationCorrection( start: comma.endPositionBeforeTrailingTrivia, end: rightParen.positionAfterSkippingLeadingTrivia, - replacement: "\n" + baseIndent + oneLevel + normalizedText(argument: lastArgument) + "\n" + baseIndent + replacement: "\n" + baseIndent + oneLevel + reindentedText(argument: lastArgument) + "\n" + baseIndent ) } @@ -275,18 +284,13 @@ private extension MultilineCallArgumentsRule { rightParen: TokenSyntax, baseIndent: String ) -> ReasonedRuleViolation.ViolationCorrection { - let indentUnit = oneLevel - let indent = baseIndent + indentUnit - let reindented = normalizedText(argument: firstArgument) - .split(separator: "\n", omittingEmptySubsequences: false) - .enumerated() - .map { $0.offset == 0 ? String($0.element) : indentUnit + $0.element } - .joined(separator: "\n") + let indent = baseIndent + oneLevel + let reindented = reindentedText(argument: firstArgument) return ReasonedRuleViolation.ViolationCorrection( start: firstArgument.position, end: rightParen.endPositionBeforeTrailingTrivia, - replacement: "\n" + indent + reindented + "\n" + indent + normalizedText(argument: lastArgument) + replacement: "\n" + indent + reindented + "\n" + indent + reindentedText(argument: lastArgument) + "\n" + baseIndent + ")" ) } @@ -306,11 +310,7 @@ private extension MultilineCallArgumentsRule { let indent = baseIndent + oneLevel let argLines = arguments.enumerated().map { index, arg -> String in - let argText = normalizedText(argument: arg) - .split(separator: "\n", omittingEmptySubsequences: false) - .enumerated() - .map { $0.offset == 0 ? String($0.element) : oneLevel + $0.element } - .joined(separator: "\n") + let argText = reindentedText(argument: arg) let needsComma = index < arguments.count - 1 && arg.trailingComma?.presence == .missing return indent + argText + (needsComma ? "," : "") } @@ -322,8 +322,12 @@ private extension MultilineCallArgumentsRule { ) } + private var indentationStyle: IndentationStyle { + CurrentRule.configuration?.indentation ?? .default + } + private var oneLevel: String { - (CurrentRule.configuration?.indentation ?? .default).indentationString + indentationStyle.indentationString } private func endsWithClosingToken(argument: LabeledExprSyntax) -> Bool { @@ -334,7 +338,9 @@ private extension MultilineCallArgumentsRule { private func baseIndent(for callNode: FunctionCallExprSyntax) -> String { let lineNumber = line(for: callNode.positionAfterSkippingLeadingTrivia) guard lineNumber > 0, lineNumber <= file.lines.count else { return "" } - return String(file.lines[lineNumber - 1].content.prefix(while: { $0.isWhitespace && $0 != "\r" })) + let rawIndent = String(file.lines[lineNumber - 1].content.prefix(while: { $0.isWhitespace && $0 != "\r" })) + let style = indentationStyle + return style.indentation(for: style.levelCount(in: rawIndent)) } private func shouldSuppressSingleLineCorrection(for callNode: FunctionCallExprSyntax) -> Bool { diff --git a/Source/SwiftLintCore/Models/IndentationStyle.swift b/Source/SwiftLintCore/Models/IndentationStyle.swift index 663e1e7f4c..2dccfeab66 100644 --- a/Source/SwiftLintCore/Models/IndentationStyle.swift +++ b/Source/SwiftLintCore/Models/IndentationStyle.swift @@ -16,6 +16,23 @@ public enum IndentationStyle: Hashable, Sendable { } } + /// The indentation string for the given number of levels. + public func indentation(for levels: Int) -> String { + String(repeating: indentationString, count: max(levels, 0)) + } + + /// Counts the indentation level represented by a raw whitespace string. + public func levelCount(in rawIndent: String) -> Int { + let tabs = rawIndent.filter { $0 == "\t" }.count + let spaces = rawIndent.filter { $0 == " " }.count + let spacesPerLevel: Int + switch self { + case .tabs: spacesPerLevel = 4 + case .spaces(let count): spacesPerLevel = count + } + return tabs + spaces / spacesPerLevel + } + /// Creates an indentation style based on an untyped configuration value. /// /// - parameter object: The configuration value. From ffef9652f95b1a22a0e482a0f861065f11461a05 Mon Sep 17 00:00:00 2001 From: Rodion Ivashkov Date: Sat, 29 Aug 2026 23:13:42 +0300 Subject: [PATCH 3/4] Fix comments --- CHANGELOG.md | 3 +- Source/SwiftLintBase/String+SwiftLint.swift | 16 +- .../Lint/MultilineCallArgumentsRule.swift | 14 +- .../MultilineCallArgumentsRuleExamples.swift | 1337 +++++++++-------- ...ilineCallArgumentsConfigurationTests.swift | 33 + .../MultilineCallArgumentsRuleTests.swift | 157 +- .../GlobalConfigurationTests.swift | 109 ++ 7 files changed, 901 insertions(+), 768 deletions(-) create mode 100644 Tests/BuiltInRulesTests/MultilineCallArgumentsConfigurationTests.swift create mode 100644 Tests/FrameworkTests/GlobalConfigurationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6992705d4c..81eaed8a2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,7 @@ * Add autocorrection to the `multiline_call_arguments` rule, expanding single-line and multi-line calls to one-argument-per-line, including nested calls whose closing - `)` would otherwise be stranded with the last argument - (e.g. `foo(bar(\n a: 1\n), for: .normal)` → `foo(\n bar(\n a: 1\n ),\n for: .normal\n)`). + `)` would otherwise be stranded with the last argument. [GandaLF2006](https://github.com/GandaLF2006) ### Bug Fixes diff --git a/Source/SwiftLintBase/String+SwiftLint.swift b/Source/SwiftLintBase/String+SwiftLint.swift index 20e13354d5..5f129cc9d6 100644 --- a/Source/SwiftLintBase/String+SwiftLint.swift +++ b/Source/SwiftLintBase/String+SwiftLint.swift @@ -78,11 +78,17 @@ public extension String { } func indent(by spaces: Int, skipFirst: Bool = false, skipEmptyLines: Bool = true) -> String { + indent(by: String(repeating: " ", count: spaces), skipFirst: skipFirst, skipEmptyLines: skipEmptyLines) + } + + func indent(by indentation: String, skipFirst: Bool = false, skipEmptyLines: Bool = true) -> String { let lines = components(separatedBy: "\n") if skipFirst, let firstLine = lines.first { - return firstLine + "\n" + lines.dropFirst().indent(by: spaces, skipEmptyLines: skipEmptyLines) + let rest = lines.dropFirst() + guard !rest.isEmpty else { return firstLine } + return firstLine + "\n" + rest.indent(by: indentation, skipEmptyLines: skipEmptyLines) } - return lines.indent(by: spaces, skipEmptyLines: skipEmptyLines) + return lines.indent(by: indentation, skipEmptyLines: skipEmptyLines) } func linesPrefixed(with prefix: Self) -> Self { @@ -105,11 +111,15 @@ public extension String { private extension Sequence where Element == String { func indent(by spaces: Int, skipEmptyLines: Bool = true) -> String { + indent(by: String(repeating: " ", count: spaces), skipEmptyLines: skipEmptyLines) + } + + func indent(by indentation: String, skipEmptyLines: Bool = true) -> String { map { line in if skipEmptyLines, line.isEmpty { return line } - return String(repeating: " ", count: spaces) + line + return indentation + line } .joined(separator: "\n") } diff --git a/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRule.swift b/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRule.swift index 43e087e06b..27fb38921c 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRule.swift @@ -1,10 +1,10 @@ -// swiftlint:disable file_length - import Foundation import SourceKittenFramework import SwiftLintCore import SwiftSyntax +// swiftlint:disable file_length + @SwiftSyntaxRule(correctable: true, optIn: true) struct MultilineCallArgumentsRule: Rule { var configuration = MultilineCallArgumentsConfiguration() @@ -257,12 +257,7 @@ private extension MultilineCallArgumentsRule { } private func reindentedText(argument: LabeledExprSyntax) -> String { - let indentUnit = oneLevel - return normalizedText(argument: argument) - .split(separator: "\n", omittingEmptySubsequences: false) - .enumerated() - .map { $0.offset == 0 ? String($0.element) : indentUnit + $0.element } - .joined(separator: "\n") + normalizedText(argument: argument).indent(by: oneLevel, skipFirst: true, skipEmptyLines: false) } private func correctCloseParen( @@ -415,6 +410,9 @@ private extension FunctionCallExprSyntax { node = parent continue } + if parent.is(DeclSyntax.self) { + return false + } return false } return false diff --git a/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRuleExamples.swift b/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRuleExamples.swift index 87052857ef..b398b2f284 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRuleExamples.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRuleExamples.swift @@ -1,343 +1,342 @@ import SwiftLintCore // swiftlint:disable file_length -// swiftlint:disable type_body_length -// swiftlint:disable collection_alignment -struct MultilineCallArgumentsRuleExamples { + +struct MultilineCallArgumentsRuleExamples { // swiftlint:disable:this type_body_length static let nonTriggeringExamples: [Example] = #examples([ // MARK: - All configuration options shown - """ - foo( - param1: 1, - param2: false, - param3: [] - ) - """.asExample(configuration: ["allows_single_line": false]), - """ - foo(param1: 1, param2: false) - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ - foo( - \tparam1: 1, - \tparam2: false - ) - """.asExample(configuration: ["allows_single_line": false]), - - // MARK: - Baseline: multi-line OK - """ - foo(param1: 1, - param2: false, - param3: []) - """.asExample(configuration: ["max_number_of_single_line_parameters": 1]), - """ - func foo(one: [Int], animated: Bool) {} - add(one: [ - 1, - 2, - 3 - ], animated: true) - """, - """ - foo( - param1: 1, - param2: 2, - param3: 3 - ) - """.asExample(configuration: ["allows_single_line": false]), - - // MARK: - Baseline: single-line OK - - "foo(param1: 1, param2: false)".asExample(configuration: ["max_number_of_single_line_parameters": 2] - ), - - "Enum.foo(param1: 1, param2: false)".asExample(configuration: ["max_number_of_single_line_parameters": 2] - ), - - // allows_single_line=false does NOT affect 0/1-arg calls - "foo()".asExample(configuration: ["allows_single_line": false]), - "foo(param1: 1)".asExample(configuration: ["allows_single_line": false]), - "Enum.foo(param1: 1)".asExample(configuration: ["allows_single_line": false]), - - // MARK: - Unlabeled / mixed arguments - "foo(1, 2)".asExample(configuration: ["max_number_of_single_line_parameters": 2]), - "foo(1, b: 2)".asExample(configuration: ["max_number_of_single_line_parameters": 2]), - "foo(1, b: 2, c: 3)".asExample(configuration: ["max_number_of_single_line_parameters": 3]), - - // MARK: - Enum-case constructor calls are normal calls (stable by declaring the enum) - """ - enum EnumCase { - case first(one: Int, two: Int, three: Int, four: Int) - } - EnumCase.first(one: 1, two: 2, three: 3, four: 4) - """.asExample(configuration: ["allows_single_line": true]), - """ - enum EnumCase { - case first(one: Int, two: Int, three: Int, four: Int) - } - let test = EnumCase.first( - one: 1, - two: 2, - three: 3, - four: 4 - ) - """.asExample(configuration: ["allows_single_line": false]), + """ + foo( + param1: 1, + param2: false, + param3: [] + ) + """.asExample(configuration: ["allows_single_line": false]), + """ + foo(param1: 1, param2: false) + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + """ + foo( + \tparam1: 1, + \tparam2: false + ) + """.asExample(configuration: ["allows_single_line": false]), - // MARK: - Trailing closures are ignored by this rule (args-only) - // Single-line args still use max_number_of_single_line_parameters - """ - foo(a: 1, b: 2) { value in - print(value) - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - // Multi-line args remain valid regardless of closure placement - """ - foo( - a: 1, - b: 2 - ) { value in - print(value) - } - """.asExample(configuration: ["allows_single_line": false]), - """ - foo( - a: 1, - b: 2 - ) - { value in - print(value) - } - """.asExample(configuration: ["allows_single_line": false]), - // No-parens form: no arguments list -> never violates - """ - foo { value in - print(value) - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 1]), - // Multiple trailing closures: still args-only - """ - foo(a: 1, b: 2) { _ in - print("main") - } trailing: { _ in - print("extra") - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ - foo(with: { _ in - 9_999 - }, and: { _ in - nil - }) - """, + // MARK: - Baseline: multi-line OK + """ + foo(param1: 1, + param2: false, + param3: []) + """.asExample(configuration: ["max_number_of_single_line_parameters": 1]), + """ + func foo(one: [Int], animated: Bool) {} + add(one: [ + 1, + 2, + 3 + ], animated: true) + """, + """ + foo( + param1: 1, + param2: 2, + param3: 3 + ) + """.asExample(configuration: ["allows_single_line": false]), - // MARK: - Trivia / comments - """ - foo( - a: 1, - // comment - b: 2, - c: 3 - ) - """, - // Note: arguments start on the same line, so this is treated as a single-line-args call; - // the comma-newline check applies only when argument start lines are already split. - """ - foo( - a: (1, 2), b: 3 - ) - """, - """ - foo( - a: (1, 2), - b: 3 - ) - """.asExample(configuration: ["allows_single_line": false]), - """ - foo( - a: 1, // comment - b: 2, - c: 3 - ) - """, - """ - enum EnumCase { - case caseOne(Int, Int, Int, Int) - } - let enumCase: EnumCase = .caseOne( - 1, - 2, - 3, - 4 - ) - if case let .caseOne(_, _, three, _) = enumCase { - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ - enum EnumCase { - case caseOne(one: Int, two: Int, three: Int, four: Int) - } - let enumCase: EnumCase = .caseOne( - one: 1, - two: 2, - three: 3, - four: 4 - ) - switch enumCase { - case let .caseOne(one: _, two: _, three: three, four: _): - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ - enum EnumCase { case caseOne(Int, Int, Int, Int) } - let array: [EnumCase] = [ - .caseOne( - 1, - 2, - 3, - 4 - ) - ] - for case let .caseOne(_, _, three, _) in array { - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ - enum EnumCase { - case caseOne(Int, Int, Int, Int) - } - let enumCase: EnumCase = .caseOne( - 1, - 2, - 3, - 4 - ) - guard case let .caseOne(_, _, three, _) = enumCase else { return } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ - enum EnumCase { - case caseOne(Int, Int, Int, Int) - } - let enumCase: EnumCase = .caseOne( - 1, - 2, - 3, - 4 - ) - while case let .caseOne(_, _, three, _) = enumCase { - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + // MARK: - Baseline: single-line OK - // MARK: - Pattern matching MUST be ignored: catch patterns - """ - enum EnumCase: Error { - case caseOne(Int, Int, Int, Int) - } + "foo(param1: 1, param2: false)".asExample(configuration: ["max_number_of_single_line_parameters": 2] + ), - func mayThrow() throws { - } + "Enum.foo(param1: 1, param2: false)".asExample(configuration: ["max_number_of_single_line_parameters": 2] + ), - do { - try mayThrow() - } catch let EnumCase.caseOne(_, _, three, _) { - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ - enum EnumCase: Error { - case caseOne(one: Int, two: Int, three: Int, four: Int) - } + // allows_single_line=false does NOT affect 0/1-arg calls + "foo()".asExample(configuration: ["allows_single_line": false]), + "foo(param1: 1)".asExample(configuration: ["allows_single_line": false]), + "Enum.foo(param1: 1)".asExample(configuration: ["allows_single_line": false]), - func mayThrow() throws { - } + // MARK: - Unlabeled / mixed arguments + "foo(1, 2)".asExample(configuration: ["max_number_of_single_line_parameters": 2]), + "foo(1, b: 2)".asExample(configuration: ["max_number_of_single_line_parameters": 2]), + "foo(1, b: 2, c: 3)".asExample(configuration: ["max_number_of_single_line_parameters": 3]), - do { - try mayThrow() - } catch let EnumCase.caseOne(one: _, two: _, three: three, four: _) { - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + // MARK: - Enum-case constructor calls are normal calls (stable by declaring the enum) + """ + enum EnumCase { + case first(one: Int, two: Int, three: Int, four: Int) + } + EnumCase.first(one: 1, two: 2, three: 3, four: 4) + """.asExample(configuration: ["allows_single_line": true]), + """ + enum EnumCase { + case first(one: Int, two: Int, three: Int, four: Int) + } + let test = EnumCase.first( + one: 1, + two: 2, + three: 3, + four: 4 + ) + """.asExample(configuration: ["allows_single_line": false]), - // MARK: - Regular calls near patterns are still linted - """ - func foo(a: Int, b: Int, c: Int) -> Int { a + b + c } - enum EnumCase { case caseOne(Int, Int, Int, Int) } + // MARK: - Trailing closures are ignored by this rule (args-only) + // Single-line args still use max_number_of_single_line_parameters + """ + foo(a: 1, b: 2) { value in + print(value) + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + // Multi-line args remain valid regardless of closure placement + """ + foo( + a: 1, + b: 2 + ) { value in + print(value) + } + """.asExample(configuration: ["allows_single_line": false]), + """ + foo( + a: 1, + b: 2 + ) + { value in + print(value) + } + """.asExample(configuration: ["allows_single_line": false]), + // No-parens form: no arguments list -> never violates + """ + foo { value in + print(value) + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 1]), + // Multiple trailing closures: still args-only + """ + foo(a: 1, b: 2) { _ in + print("main") + } trailing: { _ in + print("extra") + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + """ + foo(with: { _ in + 9_999 + }, and: { _ in + nil + }) + """, - if case let .caseOne(_, _, _, _) = EnumCase.caseOne( + // MARK: - Trivia / comments + """ + foo( + a: 1, + // comment + b: 2, + c: 3 + ) + """, + // Note: arguments start on the same line, so this is treated as a single-line-args call; + // the comma-newline check applies only when argument start lines are already split. + """ + foo( + a: (1, 2), b: 3 + ) + """, + """ + foo( + a: (1, 2), + b: 3 + ) + """.asExample(configuration: ["allows_single_line": false]), + """ + foo( + a: 1, // comment + b: 2, + c: 3 + ) + """, + """ + enum EnumCase { + case caseOne(Int, Int, Int, Int) + } + let enumCase: EnumCase = .caseOne( + 1, + 2, + 3, + 4 + ) + if case let .caseOne(_, _, three, _) = enumCase { + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + """ + enum EnumCase { + case caseOne(one: Int, two: Int, three: Int, four: Int) + } + let enumCase: EnumCase = .caseOne( + one: 1, + two: 2, + three: 3, + four: 4 + ) + switch enumCase { + case let .caseOne(one: _, two: _, three: three, four: _): + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + """ + enum EnumCase { case caseOne(Int, Int, Int, Int) } + let array: [EnumCase] = [ + .caseOne( 1, 2, 3, 4 - ) { - _ = foo( - a: 1, - b: 2, - c: 3 - ) - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - // MARK: - Pattern matching MUST be ignored: enum-case patterns with literal subpatterns - """ - enum EnumCase { - case caseOne(Int, Int, Int, Int) - } - - // Real call is written multi-line to avoid noise for max=2 - let enumCase: EnumCase = .caseOne( - 0, - 0, - 0, - 0 ) + ] + for case let .caseOne(_, _, three, _) in array { + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + """ + enum EnumCase { + case caseOne(Int, Int, Int, Int) + } + let enumCase: EnumCase = .caseOne( + 1, + 2, + 3, + 4 + ) + guard case let .caseOne(_, _, three, _) = enumCase else { return } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + """ + enum EnumCase { + case caseOne(Int, Int, Int, Int) + } + let enumCase: EnumCase = .caseOne( + 1, + 2, + 3, + 4 + ) + while case let .caseOne(_, _, three, _) = enumCase { + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - // This is a PATTERN, not a call, and must be ignored even though it looks like `.caseOne(1,2,3,4)` - if case .caseOne(1, 2, 3, 4) = enumCase { - // no-op - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + // MARK: - Pattern matching MUST be ignored: catch patterns + """ + enum EnumCase: Error { + case caseOne(Int, Int, Int, Int) + } + + func mayThrow() throws { + } + + do { + try mayThrow() + } catch let EnumCase.caseOne(_, _, three, _) { + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + """ + enum EnumCase: Error { + case caseOne(one: Int, two: Int, three: Int, four: Int) + } - """ - enum EnumCase { - case caseOne(one: Int, two: Int, three: Int, four: Int) - } + func mayThrow() throws { + } - let enumCase: EnumCase = .caseOne( - one: 0, - two: 0, - three: 0, - four: 0 + do { + try mayThrow() + } catch let EnumCase.caseOne(one: _, two: _, three: three, four: _) { + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + + // MARK: - Regular calls near patterns are still linted + """ + func foo(a: Int, b: Int, c: Int) -> Int { a + b + c } + enum EnumCase { case caseOne(Int, Int, Int, Int) } + + if case let .caseOne(_, _, _, _) = EnumCase.caseOne( + 1, + 2, + 3, + 4 + ) { + _ = foo( + a: 1, + b: 2, + c: 3 ) + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + // MARK: - Pattern matching MUST be ignored: enum-case patterns with literal subpatterns + """ + enum EnumCase { + case caseOne(Int, Int, Int, Int) + } + + // Real call is written multi-line to avoid noise for max=2 + let enumCase: EnumCase = .caseOne( + 0, + 0, + 0, + 0 + ) - switch enumCase { - case .caseOne(one: 1, two: 2, three: 3, four: 4): - break - default: - break - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + // This is a PATTERN, not a call, and must be ignored even though it looks like `.caseOne(1,2,3,4)` + if case .caseOne(1, 2, 3, 4) = enumCase { + // no-op + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - """ - enum EnumCase: Error { - case caseOne(Int, Int, Int, Int) - } + """ + enum EnumCase { + case caseOne(one: Int, two: Int, three: Int, four: Int) + } + + let enumCase: EnumCase = .caseOne( + one: 0, + two: 0, + three: 0, + four: 0 + ) - func mayThrow() throws {} + switch enumCase { + case .caseOne(one: 1, two: 2, three: 3, four: 4): + break + default: + break + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - do { - try mayThrow() - } catch EnumCase.caseOne(1, 2, 3, 4) { - // pattern — must be ignored - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - // MARK: - Nested call inside a pattern constructor must be ignored too - """ - enum Outer { case foo(Inner) } - enum Inner { case bar(Int, Int, Int) } - func test(_ x: Outer) { - if case .foo(.bar(1, 2, 3)) = x {} - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - // MARK: - First arg ending with `]` or `}` skips full expansion - """ - foo([ - 1, - 2 - ], b: 3) - """.asExample(configuration: ["allows_single_line": false]), + """ + enum EnumCase: Error { + case caseOne(Int, Int, Int, Int) + } + + func mayThrow() throws {} + + do { + try mayThrow() + } catch EnumCase.caseOne(1, 2, 3, 4) { + // pattern — must be ignored + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + // MARK: - Nested call inside a pattern constructor must be ignored too + """ + enum Outer { case foo(Inner) } + enum Inner { case bar(Int, Int, Int) } + func test(_ x: Outer) { + if case .foo(.bar(1, 2, 3)) = x {} + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + // MARK: - First arg ending with `]` or `}` skips full expansion + """ + foo([ + 1, + 2 + ], b: 3) + """.asExample(configuration: ["allows_single_line": false]), ]) static let triggeringExamples: [Example] = #examples([ @@ -356,150 +355,150 @@ struct MultilineCallArgumentsRuleExamples { "Enum.foo(param1: 1, ↓param2: false)".asExample(configuration: ["allows_single_line": false]), // MARK: - Multi-line: two args start on the same line - """ - foo( - a: 1, ↓b: 2, - c: 3 - ) - """, - """ - foo( - a: 1, - b: 2, ↓c: 3 - ) - """, - """ - foo( - a: 1, - b: 2, - c: 3, ↓d: 4, - e: 5 - ) - """, - """ - foo( - a: ( - 1, - 2 - ), ↓b: 3 - ) - """.asExample(configuration: ["max_number_of_single_line_parameters": 1] - ), - """ - foo( - a: 1, /* comment */ ↓b: 2, - c: 3 - ) - """, + """ + foo( + a: 1, ↓b: 2, + c: 3 + ) + """, + """ + foo( + a: 1, + b: 2, ↓c: 3 + ) + """, + """ + foo( + a: 1, + b: 2, + c: 3, ↓d: 4, + e: 5 + ) + """, + """ + foo( + a: ( + 1, + 2 + ), ↓b: 3 + ) + """.asExample(configuration: ["max_number_of_single_line_parameters": 1] + ), + """ + foo( + a: 1, /* comment */ ↓b: 2, + c: 3 + ) + """, // MARK: - Multi-line: multiple duplicate argument start lines - """ - foo( - a: 1, ↓b: 2, - c: 3, ↓d: 4 - ) - """, + """ + foo( + a: 1, ↓b: 2, + c: 3, ↓d: 4 + ) + """, // MARK: - Enum-case constructor calls are linted like normal calls - """ - enum EnumCase { - case first(one: Int, two: Int, three: Int, four: Int) - } - EnumCase.first(one: 1, ↓two: 2, three: 3, four: 4) - """.asExample(configuration: ["allows_single_line": false]), - """ - enum EnumCase { - case first(one: Int, two: Int, three: Int, four: Int) - } - let test = EnumCase.first(one: 1, two: 2, ↓three: 3, four: 4) - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + """ + enum EnumCase { + case first(one: Int, two: Int, three: Int, four: Int) + } + EnumCase.first(one: 1, ↓two: 2, three: 3, four: 4) + """.asExample(configuration: ["allows_single_line": false]), + """ + enum EnumCase { + case first(one: Int, two: Int, three: Int, four: Int) + } + let test = EnumCase.first(one: 1, two: 2, ↓three: 3, four: 4) + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), // MARK: - Trailing closure: parentheses args still checked - """ - foo(a: 1, ↓b: 2) { _ in - print("x") - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 1]), + """ + foo(a: 1, ↓b: 2) { _ in + print("x") + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 1]), // MARK: - Targeted tests // Targeted: real `.caseOne(1,2,3,4)` call MUST be linted (not a pattern) - """ - enum EnumCase { case caseOne(Int, Int, Int, Int) } - let x: EnumCase = .caseOne(1, 2, ↓3, 4) - _ = x - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + """ + enum EnumCase { case caseOne(Int, Int, Int, Int) } + let x: EnumCase = .caseOne(1, 2, ↓3, 4) + _ = x + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), // Targeted: labeled enum-case constructor call MUST be linted - """ - enum EnumCase { - case caseOne(one: Int, two: Int, three: Int, four: Int) - } - let x: EnumCase = .caseOne(one: 1, two: 2, ↓three: 3, four: 4) - _ = x - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + """ + enum EnumCase { + case caseOne(one: Int, two: Int, three: Int, four: Int) + } + let x: EnumCase = .caseOne(one: 1, two: 2, ↓three: 3, four: 4) + _ = x + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), // Targeted: pattern-part ignored, RHS call linted - """ - func foo(a: Int, b: Int, c: Int) -> Int { a + b + c } - enum EnumCase { case caseOne(Int, Int, Int, Int) } - let enumCase: EnumCase = .caseOne( - 1, - 2, - 3, - 4 - ) - if case let .caseOne(_, _, _, _) = enumCase { - _ = foo(a: 1, b: 2, ↓c: 3) - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + """ + func foo(a: Int, b: Int, c: Int) -> Int { a + b + c } + enum EnumCase { case caseOne(Int, Int, Int, Int) } + let enumCase: EnumCase = .caseOne( + 1, + 2, + 3, + 4 + ) + if case let .caseOne(_, _, _, _) = enumCase { + _ = foo(a: 1, b: 2, ↓c: 3) + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), // Targeted: switch-where RHS call linted, pattern ignored - """ - func foo(a: Int, b: Int, c: Int) -> Bool { a + b == c } - enum EnumCase { case caseOne(Int, Int, Int, Int) } - let enumCase: EnumCase = .caseOne( + """ + func foo(a: Int, b: Int, c: Int) -> Bool { a + b == c } + enum EnumCase { case caseOne(Int, Int, Int, Int) } + let enumCase: EnumCase = .caseOne( + 1, + 2, + 3, + 4 + ) + switch enumCase { + case .caseOne where foo(a: 1, b: 2, ↓c: 3): + break + default: + break + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + + // Targeted: for-case pattern ignored, body call linted + """ + func foo(a: Int, b: Int, c: Int) -> Int { a + b + c } + enum EnumCase { case caseOne(Int, Int, Int, Int) } + let array: [EnumCase] = [ + .caseOne( 1, 2, 3, 4 ) - switch enumCase { - case .caseOne where foo(a: 1, b: 2, ↓c: 3): - break - default: - break - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), - - // Targeted: for-case pattern ignored, body call linted - """ - func foo(a: Int, b: Int, c: Int) -> Int { a + b + c } - enum EnumCase { case caseOne(Int, Int, Int, Int) } - let array: [EnumCase] = [ - .caseOne( - 1, - 2, - 3, - 4 - ) - ] - for case let .caseOne(_, _, _, _) in array { - _ = foo(a: 1, b: 2, ↓c: 3) - } - """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), + ] + for case let .caseOne(_, _, _, _) in array { + _ = foo(a: 1, b: 2, ↓c: 3) + } + """.asExample(configuration: ["max_number_of_single_line_parameters": 2]), // MARK: - Tab indentation (uses global indentation setting) "foo(param1: 1, ↓param2: false)".asExample(configuration: ["allows_single_line": false]), - """ - class Test { - func method() { - if condition { - foo(param1: 1, ↓param2: false) - } + """ + class Test { + func method() { + if condition { + foo(param1: 1, ↓param2: false) } } - """.asExample(configuration: ["allows_single_line": false]), + } + """.asExample(configuration: ["allows_single_line": false]), // MARK: - Comments between arguments (violation still detected) "foo(param1: 1, /* comment */ ↓param2: false)".asExample(configuration: ["allows_single_line": false]), @@ -508,27 +507,27 @@ struct MultilineCallArgumentsRuleExamples { "foo(bar(1, ↓2), ↓baz: 3)".asExample(configuration: ["allows_single_line": false] ), // MARK: - Nested multiline (closing `)` stranded with last arg) - """ - foo(bar( - a: 1, - b: 2, - c: 3 - ), ↓d: 4) - """, + """ + foo(bar( + a: 1, + b: 2, + c: 3 + ), ↓d: 4) + """, // MARK: - Stranded `)` with first arg on its own line (closeParen branch, 2 args) - """ - foo( - a: bar( - 1 - ), ↓b: 2) - """, - // MARK: - Stranded `)` with 3+ args (no full expansion; converges over passes) - """ - foo(a: bar( + """ + foo( + a: bar( 1 - ), - b: 2, ↓c: 3) - """, + ), ↓b: 2) + """, + // MARK: - Stranded `)` with 3+ args (no full expansion; converges over passes) + """ + foo(a: bar( + 1 + ), + b: 2, ↓c: 3) + """, ]) static let corrections: [Example: Example] = #corrections([ @@ -536,30 +535,30 @@ struct MultilineCallArgumentsRuleExamples { "foo(param1: 1, ↓param2: false)" .asExample(configuration: ["allows_single_line": false]): - """ - foo( - param1: 1, - param2: false - ) - """, + """ + foo( + param1: 1, + param2: false + ) + """, "foo(param1: 1, param2: false, ↓param3: [])" .asExample(configuration: ["max_number_of_single_line_parameters": 2]): - """ - foo( - param1: 1, - param2: false, - param3: [] - ) - """, + """ + foo( + param1: 1, + param2: false, + param3: [] + ) + """, "foo(1, ↓2)".asExample(configuration: ["allows_single_line": false]): - """ - foo( - 1, - 2 - ) - """, + """ + foo( + 1, + 2 + ) + """, "foo(1, b: 2, ↓3)".asExample(configuration: ["max_number_of_single_line_parameters": 2]) : """ @@ -570,60 +569,60 @@ struct MultilineCallArgumentsRuleExamples { ) """, // MARK: - Multi-line: duplicate argument start line - """ - foo( - a: 1, ↓b: 2, - c: 3 - ) - """: """ - foo( - a: 1, - b: 2, - c: 3 - ) - """, + """ + foo( + a: 1, ↓b: 2, + c: 3 + ) + """: """ + foo( + a: 1, + b: 2, + c: 3 + ) + """, // MARK: - Multi-line: four args, two on same line - """ - foo( - a: 1, ↓b: 2, - c: 3, - d: 4 - ) - """: """ - foo( - a: 1, - b: 2, - c: 3, - d: 4 - ) - """, + """ + foo( + a: 1, ↓b: 2, + c: 3, + d: 4 + ) + """: """ + foo( + a: 1, + b: 2, + c: 3, + d: 4 + ) + """, // MARK: - Multi-line: multiple duplicate argument start lines - """ - foo( - a: 1, ↓b: 2, - c: 3, ↓d: 4 - ) - """: """ - foo( - a: 1, - b: 2, - c: 3, - d: 4 - ) - """, + """ + foo( + a: 1, ↓b: 2, + c: 3, ↓d: 4 + ) + """: """ + foo( + a: 1, + b: 2, + c: 3, + d: 4 + ) + """, // MARK: - Multi-line: newline after comma - """ - foo( - a: 1, - b: 2, ↓c: 3 - ) - """: """ - foo( - a: 1, - b: 2, - c: 3 - ) - """, + """ + foo( + a: 1, + b: 2, ↓c: 3 + ) + """: """ + foo( + a: 1, + b: 2, + c: 3 + ) + """, // MARK: - Call with trailing closure "foo(a: 1, ↓b: 2) { _ in }".asExample(configuration: ["allows_single_line": false]) @@ -636,12 +635,12 @@ struct MultilineCallArgumentsRuleExamples { // MARK: - Closure as argument "foo(a: 1, ↓b: { x in x })".asExample(configuration: ["allows_single_line": false]): - """ - foo( - a: 1, - b: { x in x } - ) - """, + """ + foo( + a: 1, + b: { x in x } + ) + """, // MARK: - Enum case call "Enum.foo(param1: 1, ↓param2: false)".asExample(configuration: ["allows_single_line": false]) @@ -670,38 +669,38 @@ struct MultilineCallArgumentsRuleExamples { ) """, // MARK: - Multi-line with tuple argument - """ - foo( - a: (1, 2), ↓b: 3 - ) - """.asExample(configuration: ["max_number_of_single_line_parameters": 1]): """ - foo( - a: (1, 2), - b: 3 - ) - """, + """ + foo( + a: (1, 2), ↓b: 3 + ) + """.asExample(configuration: ["max_number_of_single_line_parameters": 1]): """ + foo( + a: (1, 2), + b: 3 + ) + """, // MARK: - Nested indentation (4 spaces default) - """ - class Test { - func method() { - if condition { - foo(param1: 1, ↓param2: false) - } + """ + class Test { + func method() { + if condition { + foo(param1: 1, ↓param2: false) } } - """.asExample(configuration: ["allows_single_line": false]): - """ - class Test { - func method() { - if condition { - foo( - param1: 1, - param2: false - ) - } + } + """.asExample(configuration: ["allows_single_line": false]): + """ + class Test { + func method() { + if condition { + foo( + param1: 1, + param2: false + ) } } - """, + } + """, // MARK: - Nested calls (inner call already correct, outer has violation) "foo(bar(1), ↓baz: 3)".asExample(configuration: ["allows_single_line": false]) : """ @@ -711,53 +710,53 @@ struct MultilineCallArgumentsRuleExamples { ) """, // MARK: - Nested multiline (inner call already multiline, outer duplicate start line) - """ - foo( - bar( - 1, - 2 - ), ↓baz: 3 - ) - """: """ - foo( - bar( - 1, - 2 - ), - baz: 3 - ) - """, + """ + foo( + bar( + 1, + 2 + ), ↓baz: 3 + ) + """: """ + foo( + bar( + 1, + 2 + ), + baz: 3 + ) + """, // MARK: - Nested multiline (closing `)` stranded with last argument) - """ - foo(bar( + """ + foo(bar( + a: 1, + b: 2, + c: 3 + ), ↓d: 4) + """: """ + foo( + bar( a: 1, b: 2, c: 3 - ), ↓d: 4) - """: """ - foo( - bar( - a: 1, - b: 2, - c: 3 - ), - d: 4 - ) - """, + ), + d: 4 + ) + """, // MARK: - Stranded `)` with first arg on its own line (closeParen branch) - """ - foo( - a: bar( - 1 - ), ↓b: 2) - """: """ - foo( - a: bar( - 1 - ), - b: 2 - ) - """, + """ + foo( + a: bar( + 1 + ), ↓b: 2) + """: """ + foo( + a: bar( + 1 + ), + b: 2 + ) + """, // MARK: - Nested single-line calls (inner correction suppressed) "foo(bar(1, ↓2), ↓baz: 3)".asExample( configuration: ["allows_single_line": false], @@ -773,11 +772,11 @@ struct MultilineCallArgumentsRuleExamples { configuration: ["allows_single_line": false], excludeFromDocumentation: true ): """ - grandFoo( - singleArgFoo(bar(1, 2)), - baz: 3 - ) - """, + grandFoo( + singleArgFoo(bar(1, 2)), + baz: 3 + ) + """, "foo(bar(1, ↓2, ↓3), ↓baz: 4, ↓qux: 5)".asExample( configuration: ["max_number_of_single_line_parameters": 2], excludeFromDocumentation: true @@ -799,18 +798,18 @@ struct MultilineCallArgumentsRuleExamples { ) /* c */, baz: 3) """, // MARK: - Open paren on new line - """ - foo( - a: 1, ↓b: 2) - """.asExample( - configuration: ["allows_single_line": false], - excludeFromDocumentation: true - ): """ - foo( - a: 1, - b: 2 - ) - """, + """ + foo( + a: 1, ↓b: 2) + """.asExample( + configuration: ["allows_single_line": false], + excludeFromDocumentation: true + ): """ + foo( + a: 1, + b: 2 + ) + """, // MARK: - Multi-line closure argument reindentation """ foo(a: 1, ↓b: { @@ -827,8 +826,116 @@ struct MultilineCallArgumentsRuleExamples { } ) """, + // MARK: - Deeply nested calls (inner correction suppressed) + "outer(middle(inner(1, 2), 3), ↓4)".asExample( + configuration: ["allows_single_line": false], + excludeFromDocumentation: true + ): """ + outer( + middle(inner(1, 2), 3), + 4 + ) + """, + "outer(single(middle(1, 2)), ↓3)".asExample( + configuration: ["allows_single_line": false], + excludeFromDocumentation: true + ): """ + outer( + single(middle(1, 2)), + 3 + ) + """, + "outer(middle(1, 2, 3), 4, ↓5)".asExample( + configuration: ["max_number_of_single_line_parameters": 2], + excludeFromDocumentation: true + ): """ + outer( + middle(1, 2, 3), + 4, + 5 + ) + """, + // MARK: - Full expansion variants + """ + foo(bar( + a: 1, + b: 2 + ), ↓c: 3) + """.asExample(excludeFromDocumentation: true): """ + foo( + bar( + a: 1, + b: 2 + ), + c: 3 + ) + """, + """ + foo(bar( + 1, + 2 + ), ↓3) + """.asExample(excludeFromDocumentation: true): """ + foo( + bar( + 1, + 2 + ), + 3 + ) + """, + """ + foo(a: bar( + 1 + ), ↓b: 2) + """.asExample(excludeFromDocumentation: true): """ + foo( + a: bar( + 1 + ), + b: 2 + ) + """, + // MARK: - CloseParen variants (unlabeled, 3+ args) + """ + foo( + bar( + 1 + ), ↓2) + """.asExample(excludeFromDocumentation: true): """ + foo( + bar( + 1 + ), + 2 + ) + """, + """ + foo( + a: 1, + b: bar( + 2 + ), ↓c: 3) + """.asExample(excludeFromDocumentation: true): """ + foo( + a: 1, + b: bar( + 2 + ), + c: 3 + ) + """, + // MARK: - 3+ arguments: multiple corrections in one pass + """ + foo(a: bar( + 1 + ), b: 2, ↓c: 3) + """.asExample(excludeFromDocumentation: true): """ + foo(a: bar( + 1 + ), + b: 2, + c: 3) + """, ]) } -// swiftlint:enable collection_alignment -// swiftlint:enable type_body_length -// swiftlint:enable file_length diff --git a/Tests/BuiltInRulesTests/MultilineCallArgumentsConfigurationTests.swift b/Tests/BuiltInRulesTests/MultilineCallArgumentsConfigurationTests.swift new file mode 100644 index 0000000000..8804bda4bd --- /dev/null +++ b/Tests/BuiltInRulesTests/MultilineCallArgumentsConfigurationTests.swift @@ -0,0 +1,33 @@ +@testable import SwiftLintBuiltInRules +import SwiftLintCore +import TestHelpers +import Testing + +@Suite(.rulesRegistered) +struct MultilineCallArgumentsConfigurationTests { + @Test + func configurationInvalidValuesThrow() { + #expect(throws: Issue.self) { + _ = try MultilineCallArgumentsRule(configuration: ["max_number_of_single_line_parameters": 0]) + } + #expect(throws: Issue.self) { + _ = try MultilineCallArgumentsRule(configuration: ["max_number_of_single_line_parameters": -1]) + } + #expect(throws: Issue.self) { + _ = try MultilineCallArgumentsRule(configuration: [ + "allows_single_line": false, + "max_number_of_single_line_parameters": 2, + ]) + } + } + + @Test + func configurationAllowsSingleLineFalseWithMaxParametersOneIsValid() { + #expect(throws: Never.self) { + _ = try MultilineCallArgumentsRule(configuration: [ + "allows_single_line": false, + "max_number_of_single_line_parameters": 1, + ]) + } + } +} diff --git a/Tests/BuiltInRulesTests/MultilineCallArgumentsRuleTests.swift b/Tests/BuiltInRulesTests/MultilineCallArgumentsRuleTests.swift index 2e90b9bc81..f940306264 100644 --- a/Tests/BuiltInRulesTests/MultilineCallArgumentsRuleTests.swift +++ b/Tests/BuiltInRulesTests/MultilineCallArgumentsRuleTests.swift @@ -1,5 +1,3 @@ -// swiftlint:disable file_length - @testable import SwiftLintBuiltInRules import SwiftLintCore @testable import SwiftLintFramework @@ -117,34 +115,6 @@ struct MultilineCallArgumentsRuleTests { } } - // MARK: - Configuration - - @Test - func configurationInvalidValuesThrow() { - #expect(throws: Issue.self) { - _ = try MultilineCallArgumentsRule(configuration: ["max_number_of_single_line_parameters": 0]) - } - #expect(throws: Issue.self) { - _ = try MultilineCallArgumentsRule(configuration: ["max_number_of_single_line_parameters": -1]) - } - #expect(throws: Issue.self) { - _ = try MultilineCallArgumentsRule(configuration: [ - "allows_single_line": false, - "max_number_of_single_line_parameters": 2, - ]) - } - } - - @Test - func configurationAllowsSingleLineFalseWithMaxParametersOneIsValid() { - #expect(throws: Never.self) { - _ = try MultilineCallArgumentsRule(configuration: [ - "allows_single_line": false, - "max_number_of_single_line_parameters": 1, - ]) - } - } - // MARK: - Line cache: multiple calls on different lines @Test @@ -201,43 +171,8 @@ struct MultilineCallArgumentsRuleTests { #expect(file.contents == "foo(\n a: 1,\n\tb: 2,\n c: 3\n)") } - // MARK: - Deeply nested suppression - - @Test - func correctionDeeplyNestedSuppressesInnermostCorrection() throws { - let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) - let file = SwiftLintFile(contents: "outer(middle(inner(1, 2), 3), 4)") - #expect(rule.correct(file: file) == 1) - #expect(file.contents == "outer(\n middle(inner(1, 2), 3),\n 4\n)") - } - - @Test - func correctionDeeplyNestedThroughNonViolatingMiddleSuppressesInner() throws { - let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) - let file = SwiftLintFile(contents: "outer(single(middle(1, 2)), 3)") - #expect(rule.correct(file: file) == 1) - #expect(file.contents == "outer(\n single(middle(1, 2)),\n 3\n)") - } - - @Test - func correctionDeeplyNestedWithMaxSuppressesInner() throws { - let rule = try MultilineCallArgumentsRule(configuration: ["max_number_of_single_line_parameters": 2]) - let file = SwiftLintFile(contents: "outer(middle(1, 2, 3), 4, 5)") - #expect(rule.correct(file: file) == 1) - #expect(file.contents == "outer(\n middle(1, 2, 3),\n 4,\n 5\n)") - } - // MARK: - Full expansion (first arg glued to `(`, last arg stranded with `)`) - @Test - func correctionFullyExpandsWhenFirstArgGluedAndLastArgStranded() throws { - let contents = "foo(bar(\n a: 1,\n b: 2\n), c: 3)" - let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) - let file = SwiftLintFile(contents: contents) - #expect(rule.correct(file: file) == 1) - #expect(file.contents == "foo(\n bar(\n a: 1,\n b: 2\n ),\n c: 3\n)") - } - @Test func correctionFullyExpandedUsesGlobalTabIndentation() throws { let contents = "foo(bar(\n\ta: 1,\n\tb: 2\n), c: 3)" @@ -260,26 +195,6 @@ struct MultilineCallArgumentsRuleTests { #expect(file.contents == firstPass) } - @Test - func correctionFullyExpandedWorksWithUnlabeledArguments() throws { - let contents = "foo(bar(\n 1,\n 2\n), 3)" - let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) - let file = SwiftLintFile(contents: contents) - #expect(rule.correct(file: file) == 1) - #expect(file.contents == "foo(\n bar(\n 1,\n 2\n ),\n 3\n)") - } - - // MARK: - Stranded `)` with first arg on its own line (closeParen branch) - - @Test - func correctionCloseParenMovesLastArgAndParen() throws { - let contents = "foo(\n a: bar(\n 1\n ), b: 2)" - let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) - let file = SwiftLintFile(contents: contents) - #expect(rule.correct(file: file) == 1) - #expect(file.contents == "foo(\n a: bar(\n 1\n ),\n b: 2\n)") - } - @Test func correctionCloseParenUsesGlobalTabIndentation() throws { let contents = "foo(\n\ta: bar(\n\t\t1\n\t), b: 2)" @@ -291,15 +206,6 @@ struct MultilineCallArgumentsRuleTests { #expect(file.contents == "foo(\n\ta: bar(\n\t\t1\n\t),\n\tb: 2\n)") } - @Test - func correctionCloseParenWithUnlabeledArgs() throws { - let contents = "foo(\n bar(\n 1\n ), 2)" - let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) - let file = SwiftLintFile(contents: contents) - #expect(rule.correct(file: file) == 1) - #expect(file.contents == "foo(\n bar(\n 1\n ),\n 2\n)") - } - @Test func correctionFullyExpandedHandlesCRLF() throws { let contents = "foo(bar(\r\n a: 1,\r\n b: 2\r\n), c: 3)" @@ -318,26 +224,28 @@ struct MultilineCallArgumentsRuleTests { #expect(file.contents == "foo(\r\n a: bar(\r\n 1\r\n ),\n b: 2\n)") } - // MARK: - 3+ arguments: no data loss and idempotent convergence + @Test + func correctionCloseParenIsIdempotent() throws { + let contents = "foo(\n a: bar(\n 1\n ), b: 2)" + let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) + let file = SwiftLintFile(contents: contents) + #expect(rule.correct(file: file) == 1) + let firstPass = file.contents + #expect(rule.correct(file: file) == 0, "second pass must not change a corrected call") + #expect(file.contents == firstPass) + } + + // MARK: - 3+ arguments: idempotent after multiple corrections @Test - func correctionThreeArgsDoesNotDropMiddleArguments() throws { + func correctionThreeArgsIsIdempotent() throws { let contents = "foo(a: bar(\n 1\n), b: 2, c: 3)" let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) let file = SwiftLintFile(contents: contents) - // Correct to convergence: no pass may drop an argument. - var remaining = 5 - while remaining > 0, rule.correct(file: file) > 0 { - remaining -= 1 - } - // All three original argument labels must still be present. - #expect(file.contents.contains("a: bar(")) - #expect(file.contents.contains("b: 2")) - #expect(file.contents.contains("c: 3")) - // Exact converged output. - #expect(file.contents == "foo(a: bar(\n 1\n),\n b: 2,\n c: 3)") - // Converged: another pass produces no corrections. - #expect(rule.correct(file: file) == 0) + #expect(rule.correct(file: file) == 2) + let firstPass = file.contents + #expect(rule.correct(file: file) == 0, "second pass must not change a corrected call") + #expect(file.contents == firstPass) } // MARK: - Pattern matching: nested call inside a pattern constructor @@ -355,28 +263,6 @@ struct MultilineCallArgumentsRuleTests { #expect(violations.isEmpty, "inner `.bar(1, 2, 3)` is part of the pattern, not a call to lint") } - // MARK: - closeParen branch: 3+ arguments and idempotency - - @Test - func correctionCloseParenWithThreeArgsMovesLastArgAndParen() throws { - let contents = "foo(\n a: 1,\n b: bar(\n 2\n ), c: 3)" - let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) - let file = SwiftLintFile(contents: contents) - #expect(rule.correct(file: file) == 1) - #expect(file.contents == "foo(\n a: 1,\n b: bar(\n 2\n ),\n c: 3\n)") - } - - @Test - func correctionCloseParenIsIdempotent() throws { - let contents = "foo(\n a: bar(\n 1\n ), b: 2)" - let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) - let file = SwiftLintFile(contents: contents) - #expect(rule.correct(file: file) == 1) - let firstPass = file.contents - #expect(rule.correct(file: file) == 0, "second pass must not change a corrected call") - #expect(file.contents == firstPass) - } - // MARK: - Idempotency of standard corrections @Test @@ -429,15 +315,6 @@ struct MultilineCallArgumentsRuleTests { // MARK: - Full expansion with labeled first argument - @Test - func correctionFullyExpandedWithLabeledFirstArg() throws { - let contents = "foo(a: bar(\n 1\n), b: 2)" - let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) - let file = SwiftLintFile(contents: contents) - #expect(rule.correct(file: file) == 1) - #expect(file.contents == "foo(\n a: bar(\n 1\n ),\n b: 2\n)") - } - @Test func correctionFullyExpandedHandlesPureCR() throws { let contents = "foo(bar(\r a: 1,\r b: 2\r), c: 3)" diff --git a/Tests/FrameworkTests/GlobalConfigurationTests.swift b/Tests/FrameworkTests/GlobalConfigurationTests.swift new file mode 100644 index 0000000000..18cb29313a --- /dev/null +++ b/Tests/FrameworkTests/GlobalConfigurationTests.swift @@ -0,0 +1,109 @@ +import SwiftLintCore +import SwiftLintFramework +import TestHelpers +import Testing + +@Suite(.rulesRegistered) +struct GlobalConfigurationTests { + // MARK: - Bridge: Configuration -> GlobalConfiguration + + @Test(arguments: [ + IndentationStyle.tabs, + IndentationStyle.spaces(count: 2), + IndentationStyle.spaces(count: 4), + ]) + func bridgeMapsIndentation(_ indentation: IndentationStyle) { + let configuration = Configuration(indentation: indentation) + #expect(configuration.globalConfiguration.indentation == indentation) + } + + @Test + func bridgeMapsDefaultIndentationWhenNotSpecified() { + let configuration = Configuration() + #expect(configuration.globalConfiguration.indentation == .default) + } + + @Test + func configurationIsNilOutsideLinterContext() { + #expect(CurrentRule.configuration == nil) + } + + // MARK: - End-to-end: CurrentRule.configuration is propagated to validate + + /// Emits a violation whose reason encodes the indentation observed via `CurrentRule.configuration`. + private struct GlobalIndentationDummyRule: SourceKitFreeRule { + var configuration = SeverityConfiguration(.warning) + var configurationDescription: some Documentable { RuleConfigurationOption.noOptions } + + static let description = RuleDescription( + identifier: "global_indentation_dummy", + name: "", + description: "", + kind: .style + ) + + init() { /* conformance for test */ } + init(configuration _: Any) { self.init() } + + func validate(file: SwiftLintFile) -> [StyleViolation] { + let indentation = CurrentRule.configuration?.indentation + let reason: String + switch indentation { + case .tabs: reason = "tabs" + case .spaces(let count): reason = "spaces(\(count))" + case nil: reason = "nil" + } + return [ + StyleViolation( + ruleDescription: Self.description, + severity: .warning, + location: Location(file: file.path, line: 1, character: 1), + reason: reason + ), + ] + } + } + + private func dummyReason(indentation: IndentationStyle) -> String { + let ruleList = RuleList(rules: GlobalIndentationDummyRule.self) + let configuration = Configuration( + rulesMode: .onlyConfiguration([GlobalIndentationDummyRule.identifier]), + ruleList: ruleList, + indentation: indentation + ) + let file = SwiftLintFile(contents: "let x = 0\n") + let storage = RuleStorage() + let linter = Linter(file: file, configuration: configuration).collect(into: storage) + return linter.styleViolations(using: storage).first?.reason ?? "no violations" + } + + @Test(arguments: [ + (indentation: IndentationStyle.tabs, expected: "tabs"), + (indentation: IndentationStyle.spaces(count: 2), expected: "spaces(2)"), + (indentation: IndentationStyle.spaces(count: 4), expected: "spaces(4)"), + ]) + func ruleObservesIndentation(indentation: IndentationStyle, expected: String) { + #expect(dummyReason(indentation: indentation) == expected) + } + + @Test + func differentConfigurationsDoNotLeakBetweenLints() { + #expect(dummyReason(indentation: .tabs) == "tabs") + #expect(dummyReason(indentation: .spaces(count: 2)) == "spaces(2)") + } + + @Test + func linterConfigurationShadowsOuterTaskLocalAndRestoresAfter() { + let outerConfig = GlobalConfiguration(indentation: .spaces(count: 8)) + + let reason = CurrentRule.$configuration.withValue(outerConfig) { + #expect(CurrentRule.configuration?.indentation == .spaces(count: 8)) + let result = dummyReason(indentation: .tabs) + #expect(CurrentRule.configuration?.indentation == .spaces(count: 8)) + return result + } + + #expect(reason == "tabs") + #expect(CurrentRule.configuration == nil) + } +} From 69aff63501eeb0f029d167a958e5ad22ba13418d Mon Sep 17 00:00:00 2001 From: Rodion Ivashkov Date: Sun, 30 Aug 2026 15:36:12 +0300 Subject: [PATCH 4/4] Moved --- .../MultilineCallArgumentsRuleExamples.swift | 20 +++++++++++++++++++ .../MultilineCallArgumentsRuleTests.swift | 9 --------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRuleExamples.swift b/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRuleExamples.swift index b398b2f284..191660ed89 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRuleExamples.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Lint/MultilineCallArgumentsRuleExamples.swift @@ -937,5 +937,25 @@ struct MultilineCallArgumentsRuleExamples { // swiftlint:disable:this type_body_ b: 2, c: 3) """, + // MARK: - String literal containing comment-like text is corrected + """ + foo(a: "/* not a comment */", ↓b: 2) + """.asExample(configuration: ["allows_single_line": false]): """ + foo( + a: "/* not a comment */", + b: 2 + ) + """, + """ + foo(a: "/* not a comment */", ↓b: "// also not") + """.asExample( + configuration: ["allows_single_line": false], + excludeFromDocumentation: true + ): """ + foo( + a: "/* not a comment */", + b: "// also not" + ) + """, ]) } diff --git a/Tests/BuiltInRulesTests/MultilineCallArgumentsRuleTests.swift b/Tests/BuiltInRulesTests/MultilineCallArgumentsRuleTests.swift index f940306264..e442804920 100644 --- a/Tests/BuiltInRulesTests/MultilineCallArgumentsRuleTests.swift +++ b/Tests/BuiltInRulesTests/MultilineCallArgumentsRuleTests.swift @@ -90,15 +90,6 @@ struct MultilineCallArgumentsRuleTests { #expect(file.contents == contents) } - @Test - func correctionWithCommentInStringLiteralDoesAutoCorrect() throws { - let rule = try MultilineCallArgumentsRule(configuration: ["allows_single_line": false]) - let file1 = SwiftLintFile(contents: "foo(a: \"/* not a comment */\", b: 2)") - #expect(rule.correct(file: file1) == 1) - let file2 = SwiftLintFile(contents: "foo(a: \"/* not a comment */\", b: \"// also not\")") - #expect(rule.correct(file: file2) == 1) - } - @Test func correctionMultilineWithCommentsDoesNotAutoCorrect() throws { let rule = try MultilineCallArgumentsRule(configuration: [:])