11import Foundation
22import SourceKittenFramework
3+ import SwiftSyntax
34
45@DisabledWithoutSourceKit
56struct 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 \t secondLine \n \t \t thirdLine \n \n \t \t fourthLine " ) ,
3255 Example ( " firstLine \n \t secondLine \n \t \t thirdLine \n \t //test \n \t \t fourthLine " ) ,
3356 Example ( " firstLine \n secondLine \n thirdLine \n fourthLine " ) ,
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