Skip to content

Commit 9bc16b5

Browse files
tanaevSimplyDanny
andauthored
Fix indentation_width false positives for multi-line conditions (#6505)
Co-authored-by: Danny Mösch <danny.moesch@icloud.com>
1 parent 98554ca commit 9bc16b5

5 files changed

Lines changed: 239 additions & 84 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@
115115
[SimplyDanny](https://github.com/SimplyDanny)
116116
[#6080](https://github.com/realm/SwiftLint/issues/6080)
117117

118+
* Fix false positives in `indentation_width` rule for continuation lines
119+
of multi-line `guard`/`if`/`while` conditions. A new option
120+
`include_multiline_conditions` (default: `false`) skips these lines by
121+
default. When enabled, it validates that continuation lines are aligned
122+
with the first condition after the keyword.
123+
[tanaev](https://github.com/tanaev)
124+
[#4961](https://github.com/realm/SwiftLint/issues/4961)
125+
118126
* `multiline_call_arguments` no longer reports violations for enum-case patterns in
119127
pattern matching (e.g. if case, switch case, for case, catch).
120128
[GandaLF2006](https://github.com/GandaLF2006)

Source/SwiftLintBuiltInRules/Rules/RuleConfigurations/IndentationWidthConfiguration.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,6 @@ struct IndentationWidthConfiguration: SeverityBasedRuleConfiguration {
2222
private(set) var includeCompilerDirectives = true
2323
@ConfigurationElement(key: "include_multiline_strings")
2424
private(set) var includeMultilineStrings = true
25+
@ConfigurationElement(key: "include_multiline_conditions")
26+
private(set) var includeMultilineConditions = false
2527
}

Source/SwiftLintBuiltInRules/Rules/Style/IndentationWidthRule.swift

Lines changed: 183 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import SourceKittenFramework
3+
import SwiftSyntax
34

45
@DisabledWithoutSourceKit
56
struct IndentationWidthRule: OptInRule {
@@ -16,6 +17,28 @@ struct IndentationWidthRule: OptInRule {
1617
}
1718
}
1819

20+
/// Parsed information about a line's leading whitespace.
21+
private struct IndentationPrefix {
22+
let tabCount: Int
23+
let spaceCount: Int
24+
25+
var combinedCount: Int { tabCount + spaceCount }
26+
27+
init(line: Line, length: Int) {
28+
var tabs = 0
29+
var spaces = 0
30+
for char in line.content.prefix(length) {
31+
if char == "\t" { tabs += 1 } else if char == " " { spaces += 1 }
32+
}
33+
self.tabCount = tabs
34+
self.spaceCount = spaces
35+
}
36+
37+
func spacesEquivalent(indentationWidth: Int) -> Int {
38+
spaceCount + tabCount * indentationWidth
39+
}
40+
}
41+
1942
// MARK: - Properties
2043
var configuration = IndentationWidthConfiguration()
2144

@@ -31,6 +54,31 @@ struct IndentationWidthRule: OptInRule {
3154
Example("firstLine\n\tsecondLine\n\t\tthirdLine\n\n\t\tfourthLine"),
3255
Example("firstLine\n\tsecondLine\n\t\tthirdLine\n\t//test\n\t\tfourthLine"),
3356
Example("firstLine\n secondLine\n thirdLine\nfourthLine"),
57+
Example("""
58+
guard let x = foo(),
59+
let y = bar() else {
60+
return
61+
}
62+
"""),
63+
Example("""
64+
if let x = foo(),
65+
let y = bar() {
66+
doSomething()
67+
}
68+
"""),
69+
Example("""
70+
while let x = foo(),
71+
let y = bar() {
72+
doSomething()
73+
}
74+
"""),
75+
Example("""
76+
if let x = foo(),
77+
let y = bar(),
78+
let z = baz() {
79+
doSomething()
80+
}
81+
"""),
3482
],
3583
triggeringExamples: [
3684
Example("↓ firstLine", testMultiByteOffsets: false, testDisableCommand: false),
@@ -42,105 +90,134 @@ struct IndentationWidthRule: OptInRule {
4290

4391
// MARK: - Initializers
4492
// MARK: - Methods: Validation
45-
func validate(file: SwiftLintFile) -> [StyleViolation] { // swiftlint:disable:this function_body_length
93+
func validate(file: SwiftLintFile) -> [StyleViolation] {
4694
var violations: [StyleViolation] = []
4795
var previousLineIndentations: [Indentation] = []
4896

49-
for line in file.lines {
50-
if ignoreCompilerDirective(line: line, in: file) { continue }
97+
let conditionContinuationInfo = multilineConditionInfo(in: file)
5198

52-
// Skip line if it's a whitespace-only line
99+
for line in file.lines {
100+
// Skip whitespace-only lines, comments, compiler directives, multiline strings
53101
let indentationCharacterCount = line.content.countOfLeadingCharacters(in: CharacterSet(charactersIn: " \t"))
54-
if line.content.count == indentationCharacterCount { continue }
55-
56-
if ignoreComment(line: line, in: file) || ignoreMultilineStrings(line: line, in: file) { continue }
57-
58-
// Get space and tab count in prefix
59-
let prefix = String(line.content.prefix(indentationCharacterCount))
60-
let tabCount = prefix.filter { $0 == "\t" }.count
61-
let spaceCount = prefix.filter { $0 == " " }.count
62-
63-
// Determine indentation
64-
let indentation: Indentation
65-
if tabCount != 0, spaceCount != 0 {
66-
// Catch mixed indentation
67-
violations.append(
68-
StyleViolation(
69-
ruleDescription: Self.description,
70-
severity: configuration.severityConfiguration.severity,
71-
location: Location(file: file, characterOffset: line.range.location),
72-
reason: "Code should be indented with tabs or " +
73-
"\(configuration.indentationWidth) spaces, but not both in the same line"
74-
)
75-
)
102+
if shouldSkipLine(line: line, indentationCharacterCount: indentationCharacterCount, in: file) { continue }
76103

77-
// Model this line's indentation using spaces (although it's tabs & spaces) to let parsing continue
78-
indentation = .spaces(spaceCount + tabCount * configuration.indentationWidth)
79-
} else if tabCount != 0 {
80-
indentation = .tabs(tabCount)
81-
} else {
82-
indentation = .spaces(spaceCount)
104+
let prefix = IndentationPrefix(line: line, length: indentationCharacterCount)
105+
106+
if let expectedColumn = conditionContinuationInfo[line.index] {
107+
if let violation = checkMultilineConditionAlignment(
108+
line: line, expectedColumn: expectedColumn, prefix: prefix, file: file
109+
) {
110+
violations.append(violation)
111+
}
112+
continue
83113
}
84114

115+
// Determine indentation from prefix
116+
let (indentation, mixedViolation) = parseIndentation(line: line, prefix: prefix, file: file)
117+
if let mixedViolation { violations.append(mixedViolation) }
118+
85119
// Catch indented first line
86120
guard previousLineIndentations.isNotEmpty else {
87121
previousLineIndentations = [indentation]
88-
89122
if indentation != .spaces(0) {
90-
// There's an indentation although this is the first line!
91123
violations.append(
92-
StyleViolation(
93-
ruleDescription: Self.description,
94-
severity: configuration.severityConfiguration.severity,
95-
location: Location(file: file, characterOffset: line.range.location),
96-
reason: "The first line shall not be indented"
97-
)
124+
makeViolation(file: file, line: line, reason: "The first line shall not be indented")
98125
)
99126
}
100-
101127
continue
102128
}
103129

104-
let linesValidationResult = previousLineIndentations.map {
105-
validate(indentation: indentation, comparingTo: $0)
130+
if let violation = checkIndentationChange(
131+
indentation: indentation, previousLineIndentations: previousLineIndentations, line: line, file: file
132+
) {
133+
violations.append(violation)
106134
}
107135

108-
// Catch wrong indentation or wrong unindentation
109-
if !linesValidationResult.contains(true) {
110-
let isIndentation = previousLineIndentations.last.map {
111-
indentation.spacesEquivalent(indentationWidth: configuration.indentationWidth) >=
112-
$0.spacesEquivalent(indentationWidth: configuration.indentationWidth)
113-
} ?? true
114-
115-
let indentWidth = configuration.indentationWidth
116-
violations.append(
117-
StyleViolation(
118-
ruleDescription: Self.description,
119-
severity: configuration.severityConfiguration.severity,
120-
location: Location(file: file, characterOffset: line.range.location),
121-
reason: isIndentation ?
122-
"Code should be indented using one tab or \(indentWidth) spaces" :
123-
"Code should be unindented by multiples of one tab or multiples of \(indentWidth) spaces"
124-
)
125-
)
126-
}
127-
128-
if linesValidationResult.first == true {
129-
// Reset previousLineIndentations to this line only
130-
// if this line's indentation matches the last valid line's indentation (first in the array)
136+
if validate(indentation: indentation, comparingTo: previousLineIndentations[0]) {
131137
previousLineIndentations = [indentation]
132138
} else {
133-
// We not only store this line's indentation, but also keep what was stored before.
134-
// Therefore, the next line can be indented either according to the last valid line
135-
// or any of the succeeding, failing lines.
136-
// This mechanism avoids duplicate warnings.
137139
previousLineIndentations.append(indentation)
138140
}
139141
}
140142

141143
return violations
142144
}
143145

146+
private func shouldSkipLine(line: Line, indentationCharacterCount: Int, in file: SwiftLintFile) -> Bool {
147+
line.content.count == indentationCharacterCount ||
148+
ignoreCompilerDirective(line: line, in: file) ||
149+
ignoreComment(line: line, in: file) ||
150+
ignoreMultilineStrings(line: line, in: file)
151+
}
152+
153+
private func checkIndentationChange(
154+
indentation: Indentation, previousLineIndentations: [Indentation], line: Line, file: SwiftLintFile
155+
) -> StyleViolation? {
156+
let isValid = previousLineIndentations.contains { validate(indentation: indentation, comparingTo: $0) }
157+
guard !isValid else { return nil }
158+
let isIndentation = previousLineIndentations.last.map {
159+
indentation.spacesEquivalent(indentationWidth: configuration.indentationWidth) >=
160+
$0.spacesEquivalent(indentationWidth: configuration.indentationWidth)
161+
} ?? true
162+
let indentWidth = configuration.indentationWidth
163+
return makeViolation(
164+
file: file,
165+
line: line,
166+
reason: isIndentation ?
167+
"Code should be indented using one tab or \(indentWidth) spaces" :
168+
"Code should be unindented by multiples of one tab or multiples of \(indentWidth) spaces"
169+
)
170+
}
171+
172+
private func makeViolation(file: SwiftLintFile, line: Line, reason: String) -> StyleViolation {
173+
StyleViolation(
174+
ruleDescription: Self.description,
175+
severity: configuration.severityConfiguration.severity,
176+
location: Location(file: file, characterOffset: line.range.location),
177+
reason: reason
178+
)
179+
}
180+
181+
private func parseIndentation(
182+
line: Line, prefix: IndentationPrefix, file: SwiftLintFile
183+
) -> (Indentation, StyleViolation?) {
184+
if prefix.tabCount != 0, prefix.spaceCount != 0 {
185+
let violation = makeViolation(
186+
file: file,
187+
line: line,
188+
reason: "Code should be indented with tabs or " +
189+
"\(configuration.indentationWidth) spaces, but not both in the same line"
190+
)
191+
return (.spaces(prefix.spacesEquivalent(indentationWidth: configuration.indentationWidth)), violation)
192+
}
193+
if prefix.tabCount != 0 {
194+
return (.tabs(prefix.tabCount), nil)
195+
}
196+
return (.spaces(prefix.spaceCount), nil)
197+
}
198+
199+
private func checkMultilineConditionAlignment(
200+
line: Line, expectedColumn: Int, prefix: IndentationPrefix, file: SwiftLintFile
201+
) -> StyleViolation? {
202+
if !configuration.includeMultilineConditions { return nil }
203+
let actualColumn = prefix.spacesEquivalent(indentationWidth: configuration.indentationWidth)
204+
guard actualColumn != expectedColumn else { return nil }
205+
return makeViolation(
206+
file: file,
207+
line: line,
208+
reason: "Multi-line condition should be aligned with the first condition " +
209+
"(expected \(expectedColumn) spaces, got \(actualColumn))"
210+
)
211+
}
212+
213+
/// Returns a mapping from line index to expected indentation column for continuation lines
214+
/// of multi-line conditions. When `include_multiline_conditions` is false, these lines are
215+
/// skipped entirely (expected column is still stored so the line is recognized as a continuation).
216+
private func multilineConditionInfo(in file: SwiftLintFile) -> [Int: Int] {
217+
let visitor = MultilineConditionLineVisitor(locationConverter: file.locationConverter)
218+
return visitor.walk(tree: file.syntaxTree, handler: \.continuationLines)
219+
}
220+
144221
private func ignoreCompilerDirective(line: Line, in file: SwiftLintFile) -> Bool {
145222
if configuration.includeCompilerDirectives {
146223
return false
@@ -203,3 +280,39 @@ struct IndentationWidthRule: OptInRule {
203280
)
204281
}
205282
}
283+
284+
private final class MultilineConditionLineVisitor: SyntaxVisitor {
285+
private let locationConverter: SourceLocationConverter
286+
/// Maps line index → expected indentation column for continuation lines.
287+
private(set) var continuationLines = [Int: Int]()
288+
289+
init(locationConverter: SourceLocationConverter) {
290+
self.locationConverter = locationConverter
291+
super.init(viewMode: .sourceAccurate)
292+
}
293+
294+
override func visitPost(_ node: GuardStmtSyntax) {
295+
collectContinuationLines(keyword: node.guardKeyword, conditions: node.conditions)
296+
}
297+
298+
override func visitPost(_ node: IfExprSyntax) {
299+
collectContinuationLines(keyword: node.ifKeyword, conditions: node.conditions)
300+
}
301+
302+
override func visitPost(_ node: WhileStmtSyntax) {
303+
collectContinuationLines(keyword: node.whileKeyword, conditions: node.conditions)
304+
}
305+
306+
private func collectContinuationLines(keyword: TokenSyntax, conditions: ConditionElementListSyntax) {
307+
guard conditions.count > 1 else { return }
308+
let keywordLine = locationConverter.location(for: keyword.positionAfterSkippingLeadingTrivia).line
309+
let firstConditionLoc = locationConverter.location(for: conditions.positionAfterSkippingLeadingTrivia)
310+
let conditionsEndLine = locationConverter.location(for: conditions.endPositionBeforeTrailingTrivia).line
311+
guard keywordLine < conditionsEndLine else { return }
312+
// Expected column is where the first condition starts (0-based → subtract 1)
313+
let expectedColumn = firstConditionLoc.column - 1
314+
for lineIndex in (keywordLine + 1)...conditionsEndLine {
315+
continuationLines[lineIndex] = expectedColumn
316+
}
317+
}
318+
}

0 commit comments

Comments
 (0)