@@ -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
231270private 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}
0 commit comments