Skip to content

Commit 85e4260

Browse files
Pavlo TanaievPavlo Tanaiev
authored andcommitted
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.
1 parent a09d152 commit 85e4260

File tree

3 files changed

+162
-118
lines changed

3 files changed

+162
-118
lines changed

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,9 @@
9696
[#6080](https://github.com/realm/SwiftLint/issues/6080)
9797
* Fix false positives in `indentation_width` rule for continuation lines
9898
of multi-line `guard`/`if`/`while` conditions. A new option
99-
`include_multiline_conditions` (default: `false`) controls whether
100-
these lines are checked for indentation.
99+
`include_multiline_conditions` (default: `false`) skips these lines by
100+
default. When enabled, it validates that continuation lines are aligned
101+
with the first condition after the keyword.
101102
[tanaev](https://github.com/tanaev)
102103
[#4961](https://github.com/realm/SwiftLint/issues/4961)
103104

Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift

Lines changed: 124 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,19 @@ struct IndentationWidthRule: OptInRule {
4444
doSomething()
4545
}
4646
"""),
47+
Example("""
48+
while let x = foo(),
49+
let y = bar() {
50+
doSomething()
51+
}
52+
"""),
53+
Example("""
54+
if let x = foo(),
55+
let y = bar(),
56+
let z = baz() {
57+
doSomething()
58+
}
59+
"""),
4760
],
4861
triggeringExamples: [
4962
Example("↓ firstLine", testMultiByteOffsets: false, testDisableCommand: false),
@@ -55,112 +68,138 @@ struct IndentationWidthRule: OptInRule {
5568

5669
// MARK: - Initializers
5770
// MARK: - Methods: Validation
58-
func validate(file: SwiftLintFile) -> [StyleViolation] { // swiftlint:disable:this function_body_length
71+
func validate(file: SwiftLintFile) -> [StyleViolation] {
5972
var violations: [StyleViolation] = []
6073
var previousLineIndentations: [Indentation] = []
6174

62-
let conditionContinuationLines = multilineConditionLines(in: file)
75+
let conditionContinuationInfo = multilineConditionInfo(in: file)
6376

6477
for line in file.lines {
65-
if ignoreCompilerDirective(line: line, in: file) { continue }
66-
67-
// Skip line if it's a whitespace-only line
78+
// Skip whitespace-only lines, comments, compiler directives, multiline strings
6879
let indentationCharacterCount = line.content.countOfLeadingCharacters(in: CharacterSet(charactersIn: " \t"))
69-
if line.content.count == indentationCharacterCount { continue }
70-
71-
if ignoreComment(line: line, in: file) || ignoreMultilineStrings(line: line, in: file)
72-
|| conditionContinuationLines.contains(line.index) { continue }
73-
74-
// Get space and tab count in prefix
75-
let prefix = String(line.content.prefix(indentationCharacterCount))
76-
let tabCount = prefix.filter { $0 == "\t" }.count
77-
let spaceCount = prefix.filter { $0 == " " }.count
78-
79-
// Determine indentation
80-
let indentation: Indentation
81-
if tabCount != 0, spaceCount != 0 {
82-
// Catch mixed indentation
83-
violations.append(
84-
StyleViolation(
85-
ruleDescription: Self.description,
86-
severity: configuration.severityConfiguration.severity,
87-
location: Location(file: file, characterOffset: line.range.location),
88-
reason: "Code should be indented with tabs or " +
89-
"\(configuration.indentationWidth) spaces, but not both in the same line"
90-
)
91-
)
92-
93-
// Model this line's indentation using spaces (although it's tabs & spaces) to let parsing continue
94-
indentation = .spaces(spaceCount + tabCount * configuration.indentationWidth)
95-
} else if tabCount != 0 {
96-
indentation = .tabs(tabCount)
97-
} else {
98-
indentation = .spaces(spaceCount)
80+
if shouldSkipLine(line: line, indentationCharacterCount: indentationCharacterCount, in: file) { continue }
81+
82+
if let expectedColumn = conditionContinuationInfo[line.index] {
83+
if let violation = checkMultilineConditionAlignment(
84+
line: line, expectedColumn: expectedColumn,
85+
indentationCharacterCount: indentationCharacterCount, file: file
86+
) {
87+
violations.append(violation)
88+
}
89+
continue
9990
}
10091

92+
// Determine indentation from prefix
93+
let (indentation, mixedViolation) = parseIndentation(
94+
line: line, indentationCharacterCount: indentationCharacterCount, file: file
95+
)
96+
if let mixedViolation { violations.append(mixedViolation) }
97+
10198
// Catch indented first line
10299
guard previousLineIndentations.isNotEmpty else {
103100
previousLineIndentations = [indentation]
104-
105101
if indentation != .spaces(0) {
106-
// There's an indentation although this is the first line!
107102
violations.append(
108-
StyleViolation(
109-
ruleDescription: Self.description,
110-
severity: configuration.severityConfiguration.severity,
111-
location: Location(file: file, characterOffset: line.range.location),
112-
reason: "The first line shall not be indented"
113-
)
103+
makeViolation(file: file, line: line, reason: "The first line shall not be indented")
114104
)
115105
}
116-
117106
continue
118107
}
119108

120-
let linesValidationResult = previousLineIndentations.map {
121-
validate(indentation: indentation, comparingTo: $0)
109+
if let violation = checkIndentationChange(
110+
indentation: indentation, previousLineIndentations: previousLineIndentations, line: line, file: file
111+
) {
112+
violations.append(violation)
122113
}
123114

124-
// Catch wrong indentation or wrong unindentation
125-
if !linesValidationResult.contains(true) {
126-
let isIndentation = previousLineIndentations.last.map {
127-
indentation.spacesEquivalent(indentationWidth: configuration.indentationWidth) >=
128-
$0.spacesEquivalent(indentationWidth: configuration.indentationWidth)
129-
} ?? true
130-
131-
let indentWidth = configuration.indentationWidth
132-
violations.append(
133-
StyleViolation(
134-
ruleDescription: Self.description,
135-
severity: configuration.severityConfiguration.severity,
136-
location: Location(file: file, characterOffset: line.range.location),
137-
reason: isIndentation ?
138-
"Code should be indented using one tab or \(indentWidth) spaces" :
139-
"Code should be unindented by multiples of one tab or multiples of \(indentWidth) spaces"
140-
)
141-
)
142-
}
143-
144-
if linesValidationResult.first == true {
145-
// Reset previousLineIndentations to this line only
146-
// if this line's indentation matches the last valid line's indentation (first in the array)
115+
if validate(indentation: indentation, comparingTo: previousLineIndentations[0]) {
147116
previousLineIndentations = [indentation]
148117
} else {
149-
// We not only store this line's indentation, but also keep what was stored before.
150-
// Therefore, the next line can be indented either according to the last valid line
151-
// or any of the succeeding, failing lines.
152-
// This mechanism avoids duplicate warnings.
153118
previousLineIndentations.append(indentation)
154119
}
155120
}
156121

157122
return violations
158123
}
159124

160-
private func multilineConditionLines(in file: SwiftLintFile) -> Set<Int> {
161-
if configuration.includeMultilineConditions {
162-
return []
125+
private func shouldSkipLine(line: Line, indentationCharacterCount: Int, in file: SwiftLintFile) -> Bool {
126+
line.content.count == indentationCharacterCount ||
127+
ignoreCompilerDirective(line: line, in: file) ||
128+
ignoreComment(line: line, in: file) ||
129+
ignoreMultilineStrings(line: line, in: file)
130+
}
131+
132+
private func checkIndentationChange(
133+
indentation: Indentation, previousLineIndentations: [Indentation], line: Line, file: SwiftLintFile
134+
) -> StyleViolation? {
135+
let isValid = previousLineIndentations.contains { validate(indentation: indentation, comparingTo: $0) }
136+
guard !isValid else { return nil }
137+
let isIndentation = previousLineIndentations.last.map {
138+
indentation.spacesEquivalent(indentationWidth: configuration.indentationWidth) >=
139+
$0.spacesEquivalent(indentationWidth: configuration.indentationWidth)
140+
} ?? true
141+
let indentWidth = configuration.indentationWidth
142+
return makeViolation(
143+
file: file, line: line,
144+
reason: isIndentation ?
145+
"Code should be indented using one tab or \(indentWidth) spaces" :
146+
"Code should be unindented by multiples of one tab or multiples of \(indentWidth) spaces"
147+
)
148+
}
149+
150+
private func makeViolation(file: SwiftLintFile, line: Line, reason: String) -> StyleViolation {
151+
StyleViolation(
152+
ruleDescription: Self.description,
153+
severity: configuration.severityConfiguration.severity,
154+
location: Location(file: file, characterOffset: line.range.location),
155+
reason: reason
156+
)
157+
}
158+
159+
private func parseIndentation(
160+
line: Line, indentationCharacterCount: Int, file: SwiftLintFile
161+
) -> (Indentation, StyleViolation?) {
162+
let prefix = String(line.content.prefix(indentationCharacterCount))
163+
let tabCount = prefix.filter { $0 == "\t" }.count
164+
let spaceCount = prefix.filter { $0 == " " }.count
165+
if tabCount != 0, spaceCount != 0 {
166+
let violation = StyleViolation(
167+
ruleDescription: Self.description,
168+
severity: configuration.severityConfiguration.severity,
169+
location: Location(file: file, characterOffset: line.range.location),
170+
reason: "Code should be indented with tabs or " +
171+
"\(configuration.indentationWidth) spaces, but not both in the same line"
172+
)
173+
return (.spaces(spaceCount + tabCount * configuration.indentationWidth), violation)
174+
}
175+
if tabCount != 0 {
176+
return (.tabs(tabCount), nil)
163177
}
178+
return (.spaces(spaceCount), nil)
179+
}
180+
181+
private func checkMultilineConditionAlignment(
182+
line: Line, expectedColumn: Int, indentationCharacterCount: Int, file: SwiftLintFile
183+
) -> StyleViolation? {
184+
if !configuration.includeMultilineConditions { return nil }
185+
let prefix = String(line.content.prefix(indentationCharacterCount))
186+
let spaceCount = prefix.filter { $0 == " " }.count
187+
let tabCount = prefix.filter { $0 == "\t" }.count
188+
let actualColumn = spaceCount + tabCount * configuration.indentationWidth
189+
guard actualColumn != expectedColumn else { return nil }
190+
return StyleViolation(
191+
ruleDescription: Self.description,
192+
severity: configuration.severityConfiguration.severity,
193+
location: Location(file: file, characterOffset: line.range.location),
194+
reason: "Multi-line condition should be aligned with the first condition " +
195+
"(expected \(expectedColumn) spaces, got \(actualColumn))"
196+
)
197+
}
198+
199+
/// Returns a mapping from line index to expected indentation column for continuation lines
200+
/// of multi-line conditions. When `include_multiline_conditions` is false, these lines are
201+
/// skipped entirely (expected column is still stored so the line is recognized as a continuation).
202+
private func multilineConditionInfo(in file: SwiftLintFile) -> [Int: Int] {
164203
let visitor = MultilineConditionLineVisitor(locationConverter: file.locationConverter)
165204
return visitor.walk(tree: file.syntaxTree, handler: \.continuationLines)
166205
}
@@ -230,7 +269,8 @@ struct IndentationWidthRule: OptInRule {
230269

231270
private final class MultilineConditionLineVisitor: SyntaxVisitor {
232271
private let locationConverter: SourceLocationConverter
233-
private(set) var continuationLines = Set<Int>()
272+
/// Maps line index → expected indentation column for continuation lines.
273+
private(set) var continuationLines = [Int: Int]()
234274

235275
init(locationConverter: SourceLocationConverter) {
236276
self.locationConverter = locationConverter
@@ -250,9 +290,15 @@ private final class MultilineConditionLineVisitor: SyntaxVisitor {
250290
}
251291

252292
private func collectContinuationLines(keyword: TokenSyntax, conditions: ConditionElementListSyntax) {
293+
guard conditions.count > 1 else { return }
253294
let keywordLine = locationConverter.location(for: keyword.positionAfterSkippingLeadingTrivia).line
295+
let firstConditionLoc = locationConverter.location(for: conditions.positionAfterSkippingLeadingTrivia)
254296
let conditionsEndLine = locationConverter.location(for: conditions.endPositionBeforeTrailingTrivia).line
255297
guard keywordLine < conditionsEndLine else { return }
256-
continuationLines.formUnion((keywordLine + 1)...conditionsEndLine)
298+
// Expected column is where the first condition starts (0-based → subtract 1)
299+
let expectedColumn = firstConditionLoc.column - 1
300+
for lineIndex in (keywordLine + 1)...conditionsEndLine {
301+
continuationLines[lineIndex] = expectedColumn
302+
}
257303
}
258304
}

Tests/BuiltInRulesTests/IndentationWidthRuleTests.swift

Lines changed: 35 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -268,30 +268,37 @@ final class IndentationWidthRuleTests: SwiftLintTestCase {
268268
assert1Violation(in: example4, includeMultilineStrings: true)
269269
}
270270

271-
func testIncludeMultilineConditions() {
272-
let guardExample = """
273-
guard let x = foo(),
274-
let y = bar() else {
275-
return
276-
}
277-
"""
278-
let ifExample = """
279-
if let x = foo(),
280-
let y = bar() {
281-
doSomething()
282-
}
283-
"""
284-
285-
assertNoViolation(in: guardExample)
286-
assertNoViolation(in: ifExample)
271+
func testMultilineConditionsSkippedByDefault() {
272+
assertNoViolation(in: "guard let x = foo(),\n let y = bar() else {\n return\n}")
273+
assertNoViolation(in: "if let x = foo(),\n let y = bar() {\n doSomething()\n}")
287274
assertNoViolation(in: "while let x = foo(),\n let y = bar() {\n doSomething()\n}")
288-
assert1Violation(in: guardExample, includeMultilineConditions: true)
289-
assert1Violation(in: ifExample, includeMultilineConditions: true)
290275
assertNoViolation(in: "guard let x = foo() else {\n return\n}")
291-
assertNoViolation(
292-
in: "guard\n let x = foo(),\n let y = bar()\nelse {\n return\n}",
293-
includeMultilineConditions: true
294-
)
276+
// Misaligned but skipped when include_multiline_conditions: false
277+
assertNoViolation(in: "if let x = foo(),\n let y = bar() {\n doSomething()\n}")
278+
}
279+
280+
func testMultilineConditionsAlignmentChecked() {
281+
// Properly aligned — no violations
282+
let guardAligned = "guard let x = foo(),\n let y = bar() else {\n return\n}"
283+
let ifAligned = "if let x = foo(),\n let y = bar() {\n doSomething()\n}"
284+
let whileAligned = "while let x = foo(),\n let y = bar() {\n doSomething()\n}"
285+
let guardNextLine = "guard\n let x = foo(),\n let y = bar()\nelse {\n return\n}"
286+
let ifThreeAligned = "if let a = foo(),\n let b = bar(),\n let c = baz() {\n doSomething()\n}"
287+
assertNoViolation(in: guardAligned, includeMultilineConditions: true)
288+
assertNoViolation(in: ifAligned, includeMultilineConditions: true)
289+
assertNoViolation(in: whileAligned, includeMultilineConditions: true)
290+
assertNoViolation(in: guardNextLine, includeMultilineConditions: true)
291+
assertNoViolation(in: ifThreeAligned, includeMultilineConditions: true)
292+
}
293+
294+
func testMultilineConditionsMisaligned() {
295+
let ifMisaligned = "if let x = foo(),\n let y = bar() {\n doSomething()\n}"
296+
let guardMisaligned = "guard let x = foo(),\n let y = bar() else {\n return\n}"
297+
let ifThreeMisaligned =
298+
"if let a = foo(),\n let b = bar(),\n let c = baz() {\n doSomething()\n}"
299+
assert1Violation(in: ifMisaligned, includeMultilineConditions: true)
300+
assert1Violation(in: guardMisaligned, includeMultilineConditions: true)
301+
assertViolations(in: ifThreeMisaligned, equals: 2, includeMultilineConditions: true)
295302
}
296303

297304
// MARK: Helpers
@@ -361,15 +368,10 @@ final class IndentationWidthRuleTests: SwiftLintTestCase {
361368
line: UInt = #line
362369
) {
363370
assertViolations(
364-
in: string,
365-
equals: 0,
366-
indentationWidth: indentationWidth,
367-
includeComments: includeComments,
368-
includeCompilerDirectives: includeCompilerDirectives,
371+
in: string, equals: 0, indentationWidth: indentationWidth,
372+
includeComments: includeComments, includeCompilerDirectives: includeCompilerDirectives,
369373
includeMultilineStrings: includeMultilineStrings,
370-
includeMultilineConditions: includeMultilineConditions,
371-
file: file,
372-
line: line
374+
includeMultilineConditions: includeMultilineConditions, file: file, line: line
373375
)
374376
}
375377

@@ -384,15 +386,10 @@ final class IndentationWidthRuleTests: SwiftLintTestCase {
384386
line: UInt = #line
385387
) {
386388
assertViolations(
387-
in: string,
388-
equals: 1,
389-
indentationWidth: indentationWidth,
390-
includeComments: includeComments,
391-
includeCompilerDirectives: includeCompilerDirectives,
389+
in: string, equals: 1, indentationWidth: indentationWidth,
390+
includeComments: includeComments, includeCompilerDirectives: includeCompilerDirectives,
392391
includeMultilineStrings: includeMultilineStrings,
393-
includeMultilineConditions: includeMultilineConditions,
394-
file: file,
395-
line: line
392+
includeMultilineConditions: includeMultilineConditions, file: file, line: line
396393
)
397394
}
398395
}

0 commit comments

Comments
 (0)