From 863fde355c82daeaba4903dfaa877828059e58c5 Mon Sep 17 00:00:00 2001 From: Pavlo Tanaiev Date: Thu, 19 Feb 2026 23:25:29 +0200 Subject: [PATCH 1/6] Fix `indentation_width` false positives for multi-line conditions Skip continuation lines of multi-line `guard`/`if`/`while` condition lists that are aligned to the keyword rather than following the `indentation_width` grid. Add `include_multiline_conditions` option (default: `false`) to control this behavior. Fixes #4961 --- CHANGELOG.md | 6 ++ .../IndentationWidthConfiguration.swift | 2 + .../Rules/Style/IndentationWidthRule.swift | 55 ++++++++++++++++++- .../IndentationWidthRuleTests.swift | 34 ++++++++++++ .../Resources/default_rule_configurations.yml | 1 + 5 files changed, 97 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4e3d43b1..e9d08e55cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,12 @@ in build tool plugins. [SimplyDanny](https://github.com/SimplyDanny) [#6080](https://github.com/realm/SwiftLint/issues/6080) +* Fix false positives in `indentation_width` rule for continuation lines + of multi-line `guard`/`if`/`while` conditions. A new option + `include_multiline_conditions` (default: `false`) controls whether + these lines are checked for indentation. + [tanaev](https://github.com/tanaev) + [#4961](https://github.com/realm/SwiftLint/issues/4961) * `multiline_call_arguments` no longer reports violations for enum-case patterns in pattern matching (e.g. if case, switch case, for case, catch). diff --git a/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/IndentationWidthConfiguration.swift b/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/IndentationWidthConfiguration.swift index 4133e2fc87..7e1b56d5f5 100644 --- a/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/IndentationWidthConfiguration.swift +++ b/Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/IndentationWidthConfiguration.swift @@ -22,4 +22,6 @@ struct IndentationWidthConfiguration: SeverityBasedRuleConfiguration { private(set) var includeCompilerDirectives = true @ConfigurationElement(key: "include_multiline_strings") private(set) var includeMultilineStrings = true + @ConfigurationElement(key: "include_multiline_conditions") + private(set) var includeMultilineConditions = false } diff --git a/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift b/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift index 2c8f304de7..46c42f939d 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift @@ -1,5 +1,6 @@ import Foundation import SourceKittenFramework +import SwiftSyntax @DisabledWithoutSourceKit struct IndentationWidthRule: OptInRule { @@ -31,6 +32,18 @@ struct IndentationWidthRule: OptInRule { Example("firstLine\n\tsecondLine\n\t\tthirdLine\n\n\t\tfourthLine"), Example("firstLine\n\tsecondLine\n\t\tthirdLine\n\t//test\n\t\tfourthLine"), Example("firstLine\n secondLine\n thirdLine\nfourthLine"), + Example(""" + guard let x = foo(), + let y = bar() else { + return + } + """), + Example(""" + if let x = foo(), + let y = bar() { + doSomething() + } + """), ], triggeringExamples: [ Example("↓ firstLine", testMultiByteOffsets: false, testDisableCommand: false), @@ -46,6 +59,8 @@ struct IndentationWidthRule: OptInRule { var violations: [StyleViolation] = [] var previousLineIndentations: [Indentation] = [] + let conditionContinuationLines = multilineConditionLines(in: file) + for line in file.lines { if ignoreCompilerDirective(line: line, in: file) { continue } @@ -53,7 +68,8 @@ struct IndentationWidthRule: OptInRule { let indentationCharacterCount = line.content.countOfLeadingCharacters(in: CharacterSet(charactersIn: " \t")) if line.content.count == indentationCharacterCount { continue } - if ignoreComment(line: line, in: file) || ignoreMultilineStrings(line: line, in: file) { continue } + if ignoreComment(line: line, in: file) || ignoreMultilineStrings(line: line, in: file) + || conditionContinuationLines.contains(line.index) { continue } // Get space and tab count in prefix let prefix = String(line.content.prefix(indentationCharacterCount)) @@ -141,6 +157,14 @@ struct IndentationWidthRule: OptInRule { return violations } + private func multilineConditionLines(in file: SwiftLintFile) -> Set { + if configuration.includeMultilineConditions { + return [] + } + let visitor = MultilineConditionLineVisitor(locationConverter: file.locationConverter) + return visitor.walk(tree: file.syntaxTree, handler: \.continuationLines) + } + private func ignoreCompilerDirective(line: Line, in file: SwiftLintFile) -> Bool { if configuration.includeCompilerDirectives { return false @@ -203,3 +227,32 @@ struct IndentationWidthRule: OptInRule { ) } } + +private final class MultilineConditionLineVisitor: SyntaxVisitor { + private let locationConverter: SourceLocationConverter + private(set) var continuationLines = Set() + + init(locationConverter: SourceLocationConverter) { + self.locationConverter = locationConverter + super.init(viewMode: .sourceAccurate) + } + + override func visitPost(_ node: GuardStmtSyntax) { + collectContinuationLines(keyword: node.guardKeyword, conditions: node.conditions) + } + + override func visitPost(_ node: IfExprSyntax) { + collectContinuationLines(keyword: node.ifKeyword, conditions: node.conditions) + } + + override func visitPost(_ node: WhileStmtSyntax) { + collectContinuationLines(keyword: node.whileKeyword, conditions: node.conditions) + } + + private func collectContinuationLines(keyword: TokenSyntax, conditions: ConditionElementListSyntax) { + let keywordLine = locationConverter.location(for: keyword.positionAfterSkippingLeadingTrivia).line + let conditionsEndLine = locationConverter.location(for: conditions.endPositionBeforeTrailingTrivia).line + guard keywordLine < conditionsEndLine else { return } + continuationLines.formUnion((keywordLine + 1)...conditionsEndLine) + } +} diff --git a/Tests/BuiltInRulesTests/IndentationWidthRuleTests.swift b/Tests/BuiltInRulesTests/IndentationWidthRuleTests.swift index 936dd77bfc..ef21772451 100644 --- a/Tests/BuiltInRulesTests/IndentationWidthRuleTests.swift +++ b/Tests/BuiltInRulesTests/IndentationWidthRuleTests.swift @@ -268,6 +268,32 @@ final class IndentationWidthRuleTests: SwiftLintTestCase { assert1Violation(in: example4, includeMultilineStrings: true) } + func testIncludeMultilineConditions() { + let guardExample = """ + guard let x = foo(), + let y = bar() else { + return + } + """ + let ifExample = """ + if let x = foo(), + let y = bar() { + doSomething() + } + """ + + assertNoViolation(in: guardExample) + assertNoViolation(in: ifExample) + assertNoViolation(in: "while let x = foo(),\n let y = bar() {\n doSomething()\n}") + assert1Violation(in: guardExample, includeMultilineConditions: true) + assert1Violation(in: ifExample, includeMultilineConditions: true) + assertNoViolation(in: "guard let x = foo() else {\n return\n}") + assertNoViolation( + in: "guard\n let x = foo(),\n let y = bar()\nelse {\n return\n}", + includeMultilineConditions: true + ) + } + // MARK: Helpers private func countViolations( in example: Example, @@ -275,6 +301,7 @@ final class IndentationWidthRuleTests: SwiftLintTestCase { includeComments: Bool = true, includeCompilerDirectives: Bool = true, includeMultilineStrings: Bool = true, + includeMultilineConditions: Bool = false, file: StaticString = #filePath, line: UInt = #line ) -> Int { @@ -285,6 +312,7 @@ final class IndentationWidthRuleTests: SwiftLintTestCase { configDict["include_comments"] = includeComments configDict["include_compiler_directives"] = includeCompilerDirectives configDict["include_multiline_strings"] = includeMultilineStrings + configDict["include_multiline_conditions"] = includeMultilineConditions guard let config = makeConfig(configDict, IndentationWidthRule.identifier) else { XCTFail("Unable to create rule configuration.", file: (file), line: line) @@ -301,6 +329,7 @@ final class IndentationWidthRuleTests: SwiftLintTestCase { includeComments: Bool = true, includeCompilerDirectives: Bool = true, includeMultilineStrings: Bool = true, + includeMultilineConditions: Bool = false, file: StaticString = #filePath, line: UInt = #line ) { @@ -311,6 +340,7 @@ final class IndentationWidthRuleTests: SwiftLintTestCase { includeComments: includeComments, includeCompilerDirectives: includeCompilerDirectives, includeMultilineStrings: includeMultilineStrings, + includeMultilineConditions: includeMultilineConditions, file: file, line: line ), @@ -326,6 +356,7 @@ final class IndentationWidthRuleTests: SwiftLintTestCase { includeComments: Bool = true, includeCompilerDirectives: Bool = true, includeMultilineStrings: Bool = true, + includeMultilineConditions: Bool = false, file: StaticString = #filePath, line: UInt = #line ) { @@ -336,6 +367,7 @@ final class IndentationWidthRuleTests: SwiftLintTestCase { includeComments: includeComments, includeCompilerDirectives: includeCompilerDirectives, includeMultilineStrings: includeMultilineStrings, + includeMultilineConditions: includeMultilineConditions, file: file, line: line ) @@ -347,6 +379,7 @@ final class IndentationWidthRuleTests: SwiftLintTestCase { includeComments: Bool = true, includeCompilerDirectives: Bool = true, includeMultilineStrings: Bool = true, + includeMultilineConditions: Bool = false, file: StaticString = #filePath, line: UInt = #line ) { @@ -357,6 +390,7 @@ final class IndentationWidthRuleTests: SwiftLintTestCase { includeComments: includeComments, includeCompilerDirectives: includeCompilerDirectives, includeMultilineStrings: includeMultilineStrings, + includeMultilineConditions: includeMultilineConditions, file: file, line: line ) diff --git a/Tests/IntegrationTests/Resources/default_rule_configurations.yml b/Tests/IntegrationTests/Resources/default_rule_configurations.yml index 33a7c57dae..06d509314f 100644 --- a/Tests/IntegrationTests/Resources/default_rule_configurations.yml +++ b/Tests/IntegrationTests/Resources/default_rule_configurations.yml @@ -546,6 +546,7 @@ indentation_width: include_comments: true include_compiler_directives: true include_multiline_strings: true + include_multiline_conditions: false meta: opt-in: true correctable: false From cdafb30df9776b261424d053975e4c4443120d80 Mon Sep 17 00:00:00 2001 From: Pavlo Tanaiev Date: Sat, 11 Apr 2026 00:11:56 +0300 Subject: [PATCH 2/6] Validate alignment of multi-line condition continuation lines When `include_multiline_conditions` is enabled, continuation lines of multi-line `guard`/`if`/`while` conditions are now checked to ensure they are aligned with the first condition after the keyword, rather than simply being skipped. Also refactor `validate(file:)` to reduce cyclomatic complexity by extracting helper methods. --- CHANGELOG.md | 5 +- .../Rules/Style/IndentationWidthRule.swift | 202 +++++++++++------- .../IndentationWidthRuleTests.swift | 73 +++---- 3 files changed, 162 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d08e55cf..a356cc4d0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,8 +109,9 @@ [#6080](https://github.com/realm/SwiftLint/issues/6080) * Fix false positives in `indentation_width` rule for continuation lines of multi-line `guard`/`if`/`while` conditions. A new option - `include_multiline_conditions` (default: `false`) controls whether - these lines are checked for indentation. + `include_multiline_conditions` (default: `false`) skips these lines by + default. When enabled, it validates that continuation lines are aligned + with the first condition after the keyword. [tanaev](https://github.com/tanaev) [#4961](https://github.com/realm/SwiftLint/issues/4961) diff --git a/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift b/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift index 46c42f939d..5f46874874 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift @@ -44,6 +44,19 @@ struct IndentationWidthRule: OptInRule { doSomething() } """), + Example(""" + while let x = foo(), + let y = bar() { + doSomething() + } + """), + Example(""" + if let x = foo(), + let y = bar(), + let z = baz() { + doSomething() + } + """), ], triggeringExamples: [ Example("↓ firstLine", testMultiByteOffsets: false, testDisableCommand: false), @@ -55,101 +68,53 @@ struct IndentationWidthRule: OptInRule { // MARK: - Initializers // MARK: - Methods: Validation - func validate(file: SwiftLintFile) -> [StyleViolation] { // swiftlint:disable:this function_body_length + func validate(file: SwiftLintFile) -> [StyleViolation] { var violations: [StyleViolation] = [] var previousLineIndentations: [Indentation] = [] - let conditionContinuationLines = multilineConditionLines(in: file) + let conditionContinuationInfo = multilineConditionInfo(in: file) for line in file.lines { - if ignoreCompilerDirective(line: line, in: file) { continue } - - // Skip line if it's a whitespace-only line + // Skip whitespace-only lines, comments, compiler directives, multiline strings let indentationCharacterCount = line.content.countOfLeadingCharacters(in: CharacterSet(charactersIn: " \t")) - if line.content.count == indentationCharacterCount { continue } - - if ignoreComment(line: line, in: file) || ignoreMultilineStrings(line: line, in: file) - || conditionContinuationLines.contains(line.index) { continue } - - // Get space and tab count in prefix - let prefix = String(line.content.prefix(indentationCharacterCount)) - let tabCount = prefix.filter { $0 == "\t" }.count - let spaceCount = prefix.filter { $0 == " " }.count - - // Determine indentation - let indentation: Indentation - if tabCount != 0, spaceCount != 0 { - // Catch mixed indentation - violations.append( - StyleViolation( - ruleDescription: Self.description, - severity: configuration.severityConfiguration.severity, - location: Location(file: file, characterOffset: line.range.location), - reason: "Code should be indented with tabs or " + - "\(configuration.indentationWidth) spaces, but not both in the same line" - ) - ) - - // Model this line's indentation using spaces (although it's tabs & spaces) to let parsing continue - indentation = .spaces(spaceCount + tabCount * configuration.indentationWidth) - } else if tabCount != 0 { - indentation = .tabs(tabCount) - } else { - indentation = .spaces(spaceCount) + if shouldSkipLine(line: line, indentationCharacterCount: indentationCharacterCount, in: file) { continue } + + if let expectedColumn = conditionContinuationInfo[line.index] { + if let violation = checkMultilineConditionAlignment( + line: line, expectedColumn: expectedColumn, + indentationCharacterCount: indentationCharacterCount, file: file + ) { + violations.append(violation) + } + continue } + // Determine indentation from prefix + let (indentation, mixedViolation) = parseIndentation( + line: line, indentationCharacterCount: indentationCharacterCount, file: file + ) + if let mixedViolation { violations.append(mixedViolation) } + // Catch indented first line guard previousLineIndentations.isNotEmpty else { previousLineIndentations = [indentation] - if indentation != .spaces(0) { - // There's an indentation although this is the first line! violations.append( - StyleViolation( - ruleDescription: Self.description, - severity: configuration.severityConfiguration.severity, - location: Location(file: file, characterOffset: line.range.location), - reason: "The first line shall not be indented" - ) + makeViolation(file: file, line: line, reason: "The first line shall not be indented") ) } - continue } - let linesValidationResult = previousLineIndentations.map { - validate(indentation: indentation, comparingTo: $0) + if let violation = checkIndentationChange( + indentation: indentation, previousLineIndentations: previousLineIndentations, line: line, file: file + ) { + violations.append(violation) } - // Catch wrong indentation or wrong unindentation - if !linesValidationResult.contains(true) { - let isIndentation = previousLineIndentations.last.map { - indentation.spacesEquivalent(indentationWidth: configuration.indentationWidth) >= - $0.spacesEquivalent(indentationWidth: configuration.indentationWidth) - } ?? true - - let indentWidth = configuration.indentationWidth - violations.append( - StyleViolation( - ruleDescription: Self.description, - severity: configuration.severityConfiguration.severity, - location: Location(file: file, characterOffset: line.range.location), - reason: isIndentation ? - "Code should be indented using one tab or \(indentWidth) spaces" : - "Code should be unindented by multiples of one tab or multiples of \(indentWidth) spaces" - ) - ) - } - - if linesValidationResult.first == true { - // Reset previousLineIndentations to this line only - // if this line's indentation matches the last valid line's indentation (first in the array) + if validate(indentation: indentation, comparingTo: previousLineIndentations[0]) { previousLineIndentations = [indentation] } else { - // We not only store this line's indentation, but also keep what was stored before. - // Therefore, the next line can be indented either according to the last valid line - // or any of the succeeding, failing lines. - // This mechanism avoids duplicate warnings. previousLineIndentations.append(indentation) } } @@ -157,10 +122,84 @@ struct IndentationWidthRule: OptInRule { return violations } - private func multilineConditionLines(in file: SwiftLintFile) -> Set { - if configuration.includeMultilineConditions { - return [] + private func shouldSkipLine(line: Line, indentationCharacterCount: Int, in file: SwiftLintFile) -> Bool { + line.content.count == indentationCharacterCount || + ignoreCompilerDirective(line: line, in: file) || + ignoreComment(line: line, in: file) || + ignoreMultilineStrings(line: line, in: file) + } + + private func checkIndentationChange( + indentation: Indentation, previousLineIndentations: [Indentation], line: Line, file: SwiftLintFile + ) -> StyleViolation? { + let isValid = previousLineIndentations.contains { validate(indentation: indentation, comparingTo: $0) } + guard !isValid else { return nil } + let isIndentation = previousLineIndentations.last.map { + indentation.spacesEquivalent(indentationWidth: configuration.indentationWidth) >= + $0.spacesEquivalent(indentationWidth: configuration.indentationWidth) + } ?? true + let indentWidth = configuration.indentationWidth + return makeViolation( + file: file, line: line, + reason: isIndentation ? + "Code should be indented using one tab or \(indentWidth) spaces" : + "Code should be unindented by multiples of one tab or multiples of \(indentWidth) spaces" + ) + } + + private func makeViolation(file: SwiftLintFile, line: Line, reason: String) -> StyleViolation { + StyleViolation( + ruleDescription: Self.description, + severity: configuration.severityConfiguration.severity, + location: Location(file: file, characterOffset: line.range.location), + reason: reason + ) + } + + private func parseIndentation( + line: Line, indentationCharacterCount: Int, file: SwiftLintFile + ) -> (Indentation, StyleViolation?) { + let prefix = String(line.content.prefix(indentationCharacterCount)) + let tabCount = prefix.filter { $0 == "\t" }.count + let spaceCount = prefix.filter { $0 == " " }.count + if tabCount != 0, spaceCount != 0 { + let violation = StyleViolation( + ruleDescription: Self.description, + severity: configuration.severityConfiguration.severity, + location: Location(file: file, characterOffset: line.range.location), + reason: "Code should be indented with tabs or " + + "\(configuration.indentationWidth) spaces, but not both in the same line" + ) + return (.spaces(spaceCount + tabCount * configuration.indentationWidth), violation) + } + if tabCount != 0 { + return (.tabs(tabCount), nil) } + return (.spaces(spaceCount), nil) + } + + private func checkMultilineConditionAlignment( + line: Line, expectedColumn: Int, indentationCharacterCount: Int, file: SwiftLintFile + ) -> StyleViolation? { + if !configuration.includeMultilineConditions { return nil } + let prefix = String(line.content.prefix(indentationCharacterCount)) + let spaceCount = prefix.filter { $0 == " " }.count + let tabCount = prefix.filter { $0 == "\t" }.count + let actualColumn = spaceCount + tabCount * configuration.indentationWidth + guard actualColumn != expectedColumn else { return nil } + return StyleViolation( + ruleDescription: Self.description, + severity: configuration.severityConfiguration.severity, + location: Location(file: file, characterOffset: line.range.location), + reason: "Multi-line condition should be aligned with the first condition " + + "(expected \(expectedColumn) spaces, got \(actualColumn))" + ) + } + + /// Returns a mapping from line index to expected indentation column for continuation lines + /// of multi-line conditions. When `include_multiline_conditions` is false, these lines are + /// skipped entirely (expected column is still stored so the line is recognized as a continuation). + private func multilineConditionInfo(in file: SwiftLintFile) -> [Int: Int] { let visitor = MultilineConditionLineVisitor(locationConverter: file.locationConverter) return visitor.walk(tree: file.syntaxTree, handler: \.continuationLines) } @@ -230,7 +269,8 @@ struct IndentationWidthRule: OptInRule { private final class MultilineConditionLineVisitor: SyntaxVisitor { private let locationConverter: SourceLocationConverter - private(set) var continuationLines = Set() + /// Maps line index → expected indentation column for continuation lines. + private(set) var continuationLines = [Int: Int]() init(locationConverter: SourceLocationConverter) { self.locationConverter = locationConverter @@ -250,9 +290,15 @@ private final class MultilineConditionLineVisitor: SyntaxVisitor { } private func collectContinuationLines(keyword: TokenSyntax, conditions: ConditionElementListSyntax) { + guard conditions.count > 1 else { return } let keywordLine = locationConverter.location(for: keyword.positionAfterSkippingLeadingTrivia).line + let firstConditionLoc = locationConverter.location(for: conditions.positionAfterSkippingLeadingTrivia) let conditionsEndLine = locationConverter.location(for: conditions.endPositionBeforeTrailingTrivia).line guard keywordLine < conditionsEndLine else { return } - continuationLines.formUnion((keywordLine + 1)...conditionsEndLine) + // Expected column is where the first condition starts (0-based → subtract 1) + let expectedColumn = firstConditionLoc.column - 1 + for lineIndex in (keywordLine + 1)...conditionsEndLine { + continuationLines[lineIndex] = expectedColumn + } } } diff --git a/Tests/BuiltInRulesTests/IndentationWidthRuleTests.swift b/Tests/BuiltInRulesTests/IndentationWidthRuleTests.swift index ef21772451..3e21dad803 100644 --- a/Tests/BuiltInRulesTests/IndentationWidthRuleTests.swift +++ b/Tests/BuiltInRulesTests/IndentationWidthRuleTests.swift @@ -268,30 +268,37 @@ final class IndentationWidthRuleTests: SwiftLintTestCase { assert1Violation(in: example4, includeMultilineStrings: true) } - func testIncludeMultilineConditions() { - let guardExample = """ - guard let x = foo(), - let y = bar() else { - return - } - """ - let ifExample = """ - if let x = foo(), - let y = bar() { - doSomething() - } - """ - - assertNoViolation(in: guardExample) - assertNoViolation(in: ifExample) + func testMultilineConditionsSkippedByDefault() { + assertNoViolation(in: "guard let x = foo(),\n let y = bar() else {\n return\n}") + assertNoViolation(in: "if let x = foo(),\n let y = bar() {\n doSomething()\n}") assertNoViolation(in: "while let x = foo(),\n let y = bar() {\n doSomething()\n}") - assert1Violation(in: guardExample, includeMultilineConditions: true) - assert1Violation(in: ifExample, includeMultilineConditions: true) assertNoViolation(in: "guard let x = foo() else {\n return\n}") - assertNoViolation( - in: "guard\n let x = foo(),\n let y = bar()\nelse {\n return\n}", - includeMultilineConditions: true - ) + // Misaligned but skipped when include_multiline_conditions: false + assertNoViolation(in: "if let x = foo(),\n let y = bar() {\n doSomething()\n}") + } + + func testMultilineConditionsAlignmentChecked() { + // Properly aligned — no violations + let guardAligned = "guard let x = foo(),\n let y = bar() else {\n return\n}" + let ifAligned = "if let x = foo(),\n let y = bar() {\n doSomething()\n}" + let whileAligned = "while let x = foo(),\n let y = bar() {\n doSomething()\n}" + let guardNextLine = "guard\n let x = foo(),\n let y = bar()\nelse {\n return\n}" + let ifThreeAligned = "if let a = foo(),\n let b = bar(),\n let c = baz() {\n doSomething()\n}" + assertNoViolation(in: guardAligned, includeMultilineConditions: true) + assertNoViolation(in: ifAligned, includeMultilineConditions: true) + assertNoViolation(in: whileAligned, includeMultilineConditions: true) + assertNoViolation(in: guardNextLine, includeMultilineConditions: true) + assertNoViolation(in: ifThreeAligned, includeMultilineConditions: true) + } + + func testMultilineConditionsMisaligned() { + let ifMisaligned = "if let x = foo(),\n let y = bar() {\n doSomething()\n}" + let guardMisaligned = "guard let x = foo(),\n let y = bar() else {\n return\n}" + let ifThreeMisaligned = + "if let a = foo(),\n let b = bar(),\n let c = baz() {\n doSomething()\n}" + assert1Violation(in: ifMisaligned, includeMultilineConditions: true) + assert1Violation(in: guardMisaligned, includeMultilineConditions: true) + assertViolations(in: ifThreeMisaligned, equals: 2, includeMultilineConditions: true) } // MARK: Helpers @@ -361,15 +368,10 @@ final class IndentationWidthRuleTests: SwiftLintTestCase { line: UInt = #line ) { assertViolations( - in: string, - equals: 0, - indentationWidth: indentationWidth, - includeComments: includeComments, - includeCompilerDirectives: includeCompilerDirectives, + in: string, equals: 0, indentationWidth: indentationWidth, + includeComments: includeComments, includeCompilerDirectives: includeCompilerDirectives, includeMultilineStrings: includeMultilineStrings, - includeMultilineConditions: includeMultilineConditions, - file: file, - line: line + includeMultilineConditions: includeMultilineConditions, file: file, line: line ) } @@ -384,15 +386,10 @@ final class IndentationWidthRuleTests: SwiftLintTestCase { line: UInt = #line ) { assertViolations( - in: string, - equals: 1, - indentationWidth: indentationWidth, - includeComments: includeComments, - includeCompilerDirectives: includeCompilerDirectives, + in: string, equals: 1, indentationWidth: indentationWidth, + includeComments: includeComments, includeCompilerDirectives: includeCompilerDirectives, includeMultilineStrings: includeMultilineStrings, - includeMultilineConditions: includeMultilineConditions, - file: file, - line: line + includeMultilineConditions: includeMultilineConditions, file: file, line: line ) } } From 9933bf3168ff7b5f95b76a139d3ccf76929c3729 Mon Sep 17 00:00:00 2001 From: Pavlo Tanaiev Date: Thu, 16 Apr 2026 00:11:06 +0300 Subject: [PATCH 3/6] Update CHANGELOG.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Danny Mösch --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a356cc4d0e..e4df36d251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,11 +107,12 @@ in build tool plugins. [SimplyDanny](https://github.com/SimplyDanny) [#6080](https://github.com/realm/SwiftLint/issues/6080) + * Fix false positives in `indentation_width` rule for continuation lines of multi-line `guard`/`if`/`while` conditions. A new option `include_multiline_conditions` (default: `false`) skips these lines by default. When enabled, it validates that continuation lines are aligned - with the first condition after the keyword. + with the first condition after the keyword. [tanaev](https://github.com/tanaev) [#4961](https://github.com/realm/SwiftLint/issues/4961) From 644bfbb6ba91cae2418ae21e0c84942382c3c8aa Mon Sep 17 00:00:00 2001 From: Pavlo Tanaiev Date: Thu, 16 Apr 2026 00:11:39 +0300 Subject: [PATCH 4/6] Update Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Danny Mösch --- .../Rules/Style/IndentationWidthRule.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift b/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift index 5f46874874..813a9d1eff 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift @@ -140,7 +140,8 @@ struct IndentationWidthRule: OptInRule { } ?? true let indentWidth = configuration.indentationWidth return makeViolation( - file: file, line: line, + file: file, + line: line, reason: isIndentation ? "Code should be indented using one tab or \(indentWidth) spaces" : "Code should be unindented by multiples of one tab or multiples of \(indentWidth) spaces" From 47d2fd29a965adcc50cde3b011c9837a5966006d Mon Sep 17 00:00:00 2001 From: Pavlo Tanaiev Date: Thu, 16 Apr 2026 00:14:55 +0300 Subject: [PATCH 5/6] Use makeViolation helper and IndentationPrefix struct Address review feedback: - Introduce IndentationPrefix struct to parse a line's leading whitespace once and expose tab/space/combined counts for reuse. - Use the existing makeViolation helper in parseIndentation and checkMultilineConditionAlignment instead of constructing StyleViolation manually. --- .../Rules/Style/IndentationWidthRule.swift | 67 +++++++++++-------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift b/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift index 813a9d1eff..ec3e30d0a6 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift @@ -17,6 +17,28 @@ struct IndentationWidthRule: OptInRule { } } + /// Parsed information about a line's leading whitespace. + private struct IndentationPrefix { + let tabCount: Int + let spaceCount: Int + + var combinedCount: Int { tabCount + spaceCount } + + init(line: Line, length: Int) { + var tabs = 0 + var spaces = 0 + for char in line.content.prefix(length) { + if char == "\t" { tabs += 1 } else if char == " " { spaces += 1 } + } + self.tabCount = tabs + self.spaceCount = spaces + } + + func spacesEquivalent(indentationWidth: Int) -> Int { + spaceCount + tabCount * indentationWidth + } + } + // MARK: - Properties var configuration = IndentationWidthConfiguration() @@ -79,10 +101,11 @@ struct IndentationWidthRule: OptInRule { let indentationCharacterCount = line.content.countOfLeadingCharacters(in: CharacterSet(charactersIn: " \t")) if shouldSkipLine(line: line, indentationCharacterCount: indentationCharacterCount, in: file) { continue } + let prefix = IndentationPrefix(line: line, length: indentationCharacterCount) + if let expectedColumn = conditionContinuationInfo[line.index] { if let violation = checkMultilineConditionAlignment( - line: line, expectedColumn: expectedColumn, - indentationCharacterCount: indentationCharacterCount, file: file + line: line, expectedColumn: expectedColumn, prefix: prefix, file: file ) { violations.append(violation) } @@ -90,9 +113,7 @@ struct IndentationWidthRule: OptInRule { } // Determine indentation from prefix - let (indentation, mixedViolation) = parseIndentation( - line: line, indentationCharacterCount: indentationCharacterCount, file: file - ) + let (indentation, mixedViolation) = parseIndentation(line: line, prefix: prefix, file: file) if let mixedViolation { violations.append(mixedViolation) } // Catch indented first line @@ -158,40 +179,32 @@ struct IndentationWidthRule: OptInRule { } private func parseIndentation( - line: Line, indentationCharacterCount: Int, file: SwiftLintFile + line: Line, prefix: IndentationPrefix, file: SwiftLintFile ) -> (Indentation, StyleViolation?) { - let prefix = String(line.content.prefix(indentationCharacterCount)) - let tabCount = prefix.filter { $0 == "\t" }.count - let spaceCount = prefix.filter { $0 == " " }.count - if tabCount != 0, spaceCount != 0 { - let violation = StyleViolation( - ruleDescription: Self.description, - severity: configuration.severityConfiguration.severity, - location: Location(file: file, characterOffset: line.range.location), + if prefix.tabCount != 0, prefix.spaceCount != 0 { + let violation = makeViolation( + file: file, + line: line, reason: "Code should be indented with tabs or " + "\(configuration.indentationWidth) spaces, but not both in the same line" ) - return (.spaces(spaceCount + tabCount * configuration.indentationWidth), violation) + return (.spaces(prefix.spacesEquivalent(indentationWidth: configuration.indentationWidth)), violation) } - if tabCount != 0 { - return (.tabs(tabCount), nil) + if prefix.tabCount != 0 { + return (.tabs(prefix.tabCount), nil) } - return (.spaces(spaceCount), nil) + return (.spaces(prefix.spaceCount), nil) } private func checkMultilineConditionAlignment( - line: Line, expectedColumn: Int, indentationCharacterCount: Int, file: SwiftLintFile + line: Line, expectedColumn: Int, prefix: IndentationPrefix, file: SwiftLintFile ) -> StyleViolation? { if !configuration.includeMultilineConditions { return nil } - let prefix = String(line.content.prefix(indentationCharacterCount)) - let spaceCount = prefix.filter { $0 == " " }.count - let tabCount = prefix.filter { $0 == "\t" }.count - let actualColumn = spaceCount + tabCount * configuration.indentationWidth + let actualColumn = prefix.spacesEquivalent(indentationWidth: configuration.indentationWidth) guard actualColumn != expectedColumn else { return nil } - return StyleViolation( - ruleDescription: Self.description, - severity: configuration.severityConfiguration.severity, - location: Location(file: file, characterOffset: line.range.location), + return makeViolation( + file: file, + line: line, reason: "Multi-line condition should be aligned with the first condition " + "(expected \(expectedColumn) spaces, got \(actualColumn))" ) From 2c64278fbe9ad8827a72e1749b3eadedb139373f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danny=20M=C3=B6sch?= Date: Sun, 19 Apr 2026 13:30:39 +0200 Subject: [PATCH 6/6] Remove trailing space --- .../Rules/Style/IndentationWidthRule.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift b/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift index ec3e30d0a6..bc8b11ace0 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift @@ -161,7 +161,7 @@ struct IndentationWidthRule: OptInRule { } ?? true let indentWidth = configuration.indentationWidth return makeViolation( - file: file, + file: file, line: line, reason: isIndentation ? "Code should be indented using one tab or \(indentWidth) spaces" :