Skip to content

Commit 475313f

Browse files
authored
Merge pull request #400 from allevato/fix-case-merging
Fix multiple issues in `switch/case` merging.
2 parents b48ae1c + 1886a77 commit 475313f

File tree

2 files changed

+198
-53
lines changed

2 files changed

+198
-53
lines changed

Sources/SwiftFormatRules/NoCasesWithOnlyFallthrough.swift

Lines changed: 85 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,8 @@ public final class NoCasesWithOnlyFallthrough: SyntaxFormatRule {
4343
continue
4444
}
4545

46-
if isFallthroughOnly(switchCase), let label = switchCase.label.as(SwitchCaseLabelSyntax.self) {
47-
// If the case is fallthrough-only, store it as a violation that we will merge later.
48-
diagnose(
49-
.collapseCase(name: label.caseItems.withoutTrivia().description), on: switchCase)
46+
if isMergeableFallthroughOnly(switchCase) {
47+
// Keep track of `fallthrough`-only cases so we can merge and diagnose them later.
5048
fallthroughOnlyCases.append(switchCase)
5149
} else {
5250
guard !fallthroughOnlyCases.isEmpty else {
@@ -56,33 +54,27 @@ public final class NoCasesWithOnlyFallthrough: SyntaxFormatRule {
5654
continue
5755
}
5856

59-
// If the case is not a `case ...`, then it must be a `default`. Under *most* circumstances,
60-
// we could simply remove the immediately preceding `fallthrough`-only cases because they
61-
// would end up falling through to the `default`, which would match them anyway. However,
62-
// if any of the patterns in those cases have side effects, removing those cases would
63-
// change the program's behavior. Nobody should ever write code like this, but we don't want
64-
// to risk changing behavior just by reformatting.
65-
guard switchCase.label.is(SwitchCaseLabelSyntax.self) else {
66-
flushViolations()
57+
if canMergeWithPreviousCases(switchCase) {
58+
// If the current case can be merged with the ones before it, merge them all, leaving no
59+
// `fallthrough`-only cases behind.
60+
newChildren.append(visit(mergedCases(fallthroughOnlyCases + [switchCase])))
61+
} else {
62+
// If the current case can't be merged with the ones before it, merge the previous ones
63+
// into a single `fallthrough`-only case and then append the current one. This could
64+
// happen in one of two situations:
65+
//
66+
// 1. The current case has a value binding pattern.
67+
// 2. The current case is the `default`. Under most circumstances, we could simply remove
68+
// the immediately preceding `fallthrough`-only cases because they would end up
69+
// falling through to the `default` which would match them anyway. However, if any of
70+
// the patterns in those cases have side effects, removing those cases would change
71+
// the program's behavior.
72+
// 3. The current case is `@unknown default`, which can't be merged notwithstanding the
73+
// side-effect issues discussed above.
74+
newChildren.append(visit(mergedCases(fallthroughOnlyCases)))
6775
newChildren.append(visit(switchCase))
68-
continue
69-
}
70-
71-
// We have a case that's not fallthrough-only, and a list of fallthrough-only cases before
72-
// it. Merge them and add the result to the new list.
73-
var newCase = mergedCase(violations: fallthroughOnlyCases, validCase: switchCase)
74-
75-
// Only the first violation case can have displaced trivia, because any non-whitespace
76-
// trivia in the other violation cases would've prevented collapsing.
77-
if let displacedLeadingTrivia =
78-
fallthroughOnlyCases.first?.leadingTrivia?.withoutLastLine()
79-
{
80-
let existingLeadingTrivia = newCase.leadingTrivia ?? []
81-
let mergedLeadingTrivia = displacedLeadingTrivia + existingLeadingTrivia
82-
newCase = newCase.withLeadingTrivia(mergedLeadingTrivia)
8376
}
8477

85-
newChildren.append(visit(newCase))
8678
fallthroughOnlyCases.removeAll()
8779
}
8880
}
@@ -94,17 +86,48 @@ public final class NoCasesWithOnlyFallthrough: SyntaxFormatRule {
9486
return Syntax(SwitchCaseListSyntax(newChildren))
9587
}
9688

97-
/// Returns whether the given `SwitchCaseSyntax` contains only a fallthrough statement.
89+
/// Returns true if this case can definitely be merged with any that come before it.
90+
private func canMergeWithPreviousCases(_ node: SwitchCaseSyntax) -> Bool {
91+
return node.label.is(SwitchCaseLabelSyntax.self) && !containsValueBindingPattern(node.label)
92+
}
93+
94+
/// Returns true if this node or one of its descendents is a `ValueBindingPatternSyntax`.
95+
private func containsValueBindingPattern(_ node: Syntax) -> Bool {
96+
if node.is(ValueBindingPatternSyntax.self) {
97+
return true
98+
}
99+
for child in node.children(viewMode: .sourceAccurate) {
100+
if containsValueBindingPattern(child) {
101+
return true
102+
}
103+
}
104+
return false
105+
}
106+
107+
/// Returns whether the given `SwitchCaseSyntax` contains only a fallthrough statement and is
108+
/// able to be merged with other cases.
98109
///
99110
/// - Parameter switchCase: A syntax node describing a case in a switch statement.
100-
private func isFallthroughOnly(_ switchCase: SwitchCaseSyntax) -> Bool {
111+
private func isMergeableFallthroughOnly(_ switchCase: SwitchCaseSyntax) -> Bool {
112+
// Ignore anything that isn't a `SwitchCaseLabelSyntax`, like a `default`.
113+
guard switchCase.label.is(SwitchCaseLabelSyntax.self) else {
114+
return false
115+
}
116+
101117
// When there are any additional or non-fallthrough statements, it isn't only a fallthrough.
102118
guard let onlyStatement = switchCase.statements.firstAndOnly,
103119
onlyStatement.item.is(FallthroughStmtSyntax.self)
104120
else {
105121
return false
106122
}
107123

124+
// We cannot merge cases that contain a value pattern binding, even if the body is `fallthrough`
125+
// only. For example, `case .foo(let x)` cannot be combined with other cases unless they all
126+
// bind the same variables and types.
127+
if containsValueBindingPattern(switchCase.label) {
128+
return false
129+
}
130+
108131
// Check for any comments that are adjacent to the case or fallthrough statement.
109132
if let leadingTrivia = switchCase.leadingTrivia,
110133
leadingTrivia.drop(while: { !$0.isNewline }).contains(where: { $0.isComment })
@@ -127,31 +150,46 @@ public final class NoCasesWithOnlyFallthrough: SyntaxFormatRule {
127150
return true
128151
}
129152

130-
/// Returns a copy of the given valid case (and its statements) but with the case items from the
131-
/// violations merged with its own case items.
132-
private func mergedCase(violations: [SwitchCaseSyntax], validCase: SwitchCaseSyntax)
133-
-> SwitchCaseSyntax
134-
{
135-
var newCaseItems: [CaseItemSyntax] = []
153+
/// Returns a merged case whose body is derived from the last case in the array, and the labels
154+
/// of all the cases are merged into a single comma-delimited list.
155+
private func mergedCases(_ cases: [SwitchCaseSyntax]) -> SwitchCaseSyntax {
156+
precondition(!cases.isEmpty, "Must have at least one case to merge")
136157

137-
for label in violations.lazy.compactMap({ $0.label.as(SwitchCaseLabelSyntax.self) }) {
138-
let caseItems = Array(label.caseItems)
158+
// If there's only one case, just return it.
159+
if cases.count == 1 {
160+
return cases.first!
161+
}
139162

163+
var newCaseItems: [CaseItemSyntax] = []
164+
let labels = cases.lazy.compactMap({ $0.label.as(SwitchCaseLabelSyntax.self) })
165+
for label in labels.dropLast() {
140166
// We can blindly append all but the last case item because they must already have a trailing
141167
// comma. Then, we need to add a trailing comma to the last one, since it will be followed by
142168
// more items.
143-
newCaseItems.append(contentsOf: caseItems.dropLast())
169+
newCaseItems.append(contentsOf: label.caseItems.dropLast())
144170
newCaseItems.append(
145-
caseItems.last!.withTrailingComma(
171+
label.caseItems.last!.withTrailingComma(
146172
TokenSyntax.commaToken(trailingTrivia: .spaces(1))))
147-
}
148173

149-
let validCaseLabel = validCase.label.as(SwitchCaseLabelSyntax.self)!
150-
newCaseItems.append(contentsOf: validCaseLabel.caseItems)
151-
152-
let label = validCaseLabel.withCaseItems(
153-
CaseItemListSyntax(newCaseItems))
154-
return validCase.withLabel(Syntax(label))
174+
// Diagnose the cases being collapsed. We do this for all but the last one in the array; the
175+
// last one isn't diagnosed because it will contain the body that applies to all the previous
176+
// cases.
177+
diagnose(.collapseCase(name: label.caseItems.withoutTrivia().description), on: label)
178+
}
179+
newCaseItems.append(contentsOf: labels.last!.caseItems)
180+
181+
let newCase = cases.last!.withLabel(
182+
Syntax(labels.last!.withCaseItems(CaseItemListSyntax(newCaseItems))))
183+
184+
// Only the first violation case can have displaced trivia, because any non-whitespace
185+
// trivia in the other violation cases would've prevented collapsing.
186+
if let displacedLeadingTrivia = cases.first!.leadingTrivia?.withoutLastLine() {
187+
let existingLeadingTrivia = newCase.leadingTrivia ?? []
188+
let mergedLeadingTrivia = displacedLeadingTrivia + existingLeadingTrivia
189+
return newCase.withLeadingTrivia(mergedLeadingTrivia)
190+
} else {
191+
return newCase
192+
}
155193
}
156194
}
157195

Tests/SwiftFormatRulesTests/NoCasesWithOnlyFallthroughTests.swift

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase {
6464
XCTAssertDiagnosed(.collapseCase(name: "\"f\""), line: 15, column: 1)
6565
XCTAssertDiagnosed(.collapseCase(name: ".rightBrace"), line: 21, column: 1)
6666
XCTAssertDiagnosed(.collapseCase(name: ".leftBrace"), line: 22, column: 1)
67-
XCTAssertDiagnosed(.collapseCase(name: ".empty"), line: 25, column: 1)
6867
}
6968

7069
func testFallthroughCasesWithCommentsAreNotCombined() {
@@ -102,7 +101,11 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase {
102101
// This case has a descriptive comment.
103102
case 6, 7: print("got here")
104103
}
105-
""")
104+
""",
105+
checkForUnassertedDiagnostics: true)
106+
107+
XCTAssertDiagnosed(.collapseCase(name: "2"), line: 4, column: 1)
108+
XCTAssertDiagnosed(.collapseCase(name: "6"), line: 12, column: 1)
106109
}
107110

108111
func testCommentsAroundCombinedCasesStayInPlace() {
@@ -131,7 +134,11 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase {
131134
// This case has an extra leading newline for emphasis.
132135
case 8, 9: print("8 to 9")
133136
}
134-
""")
137+
""",
138+
checkForUnassertedDiagnostics: true)
139+
140+
XCTAssertDiagnosed(.collapseCase(name: "6"), line: 4, column: 1)
141+
XCTAssertDiagnosed(.collapseCase(name: "8"), line: 7, column: 1)
135142
}
136143

137144
func testNestedSwitches() {
@@ -164,7 +171,14 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase {
164171
case 1, 2: print(2)
165172
}
166173
}
167-
""")
174+
""",
175+
checkForUnassertedDiagnostics: true)
176+
177+
XCTAssertDiagnosed(.collapseCase(name: "1"), line: 2, column: 1)
178+
XCTAssertDiagnosed(.collapseCase(name: "2"), line: 3, column: 1)
179+
// TODO: Column 9 seems wrong here; it should be 3. Look into this.
180+
XCTAssertDiagnosed(.collapseCase(name: "1"), line: 6, column: 9)
181+
XCTAssertDiagnosed(.collapseCase(name: "1"), line: 11, column: 3)
168182
}
169183

170184
func testCasesInsideConditionalCompilationBlock() {
@@ -207,7 +221,11 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase {
207221
#endif
208222
case 10: print(10)
209223
}
210-
""")
224+
""",
225+
checkForUnassertedDiagnostics: true)
226+
227+
XCTAssertDiagnosed(.collapseCase(name: "2"), line: 4, column: 1)
228+
XCTAssertDiagnosed(.collapseCase(name: "5"), line: 8, column: 1)
211229
}
212230

213231
func testCasesWithWhereClauses() {
@@ -237,6 +255,95 @@ final class NoCasesWithOnlyFallthroughTests: LintOrFormatRuleTestCase {
237255
case 5, 6, 7, 8, 9, 10 where y == 0: print(10)
238256
default: print("?")
239257
}
240-
""")
258+
""",
259+
checkForUnassertedDiagnostics: true)
260+
261+
XCTAssertDiagnosed(.collapseCase(name: "1 where y < 0"), line: 2, column: 1)
262+
XCTAssertDiagnosed(.collapseCase(name: "2 where y == 0"), line: 3, column: 1)
263+
XCTAssertDiagnosed(.collapseCase(name: "3 where y < 0"), line: 4, column: 1)
264+
XCTAssertDiagnosed(.collapseCase(name: "5"), line: 6, column: 1)
265+
XCTAssertDiagnosed(.collapseCase(name: "6"), line: 7, column: 1)
266+
XCTAssertDiagnosed(.collapseCase(name: "7"), line: 8, column: 1)
267+
XCTAssertDiagnosed(.collapseCase(name: "8"), line: 9, column: 1)
268+
XCTAssertDiagnosed(.collapseCase(name: "9"), line: 10, column: 1)
269+
}
270+
271+
func testCasesWithValueBindingsAreNotMerged() {
272+
XCTAssertFormatting(
273+
NoCasesWithOnlyFallthrough.self,
274+
input: """
275+
switch x {
276+
case .a: fallthrough
277+
case .b: fallthrough
278+
case .c(let x): fallthrough
279+
case .d(let y): fallthrough
280+
case .e: fallthrough
281+
case .f: fallthrough
282+
case (let g, let h): fallthrough
283+
case .i: fallthrough
284+
case .j?: fallthrough
285+
case let k as K: fallthrough
286+
case .l: break
287+
}
288+
""",
289+
expected: """
290+
switch x {
291+
case .a, .b: fallthrough
292+
case .c(let x): fallthrough
293+
case .d(let y): fallthrough
294+
case .e, .f: fallthrough
295+
case (let g, let h): fallthrough
296+
case .i, .j?: fallthrough
297+
case let k as K: fallthrough
298+
case .l: break
299+
}
300+
""",
301+
checkForUnassertedDiagnostics: true)
302+
303+
XCTAssertDiagnosed(.collapseCase(name: ".a"), line: 2, column: 1)
304+
XCTAssertDiagnosed(.collapseCase(name: ".e"), line: 6, column: 1)
305+
XCTAssertDiagnosed(.collapseCase(name: ".i"), line: 9, column: 1)
306+
}
307+
308+
func testFallthroughOnlyCasesAreNotMergedWithDefault() {
309+
XCTAssertFormatting(
310+
NoCasesWithOnlyFallthrough.self,
311+
input: """
312+
switch x {
313+
case .a: fallthrough
314+
case .b: fallthrough
315+
default: print("got here")
316+
}
317+
""",
318+
expected: """
319+
switch x {
320+
case .a, .b: fallthrough
321+
default: print("got here")
322+
}
323+
""",
324+
checkForUnassertedDiagnostics: true)
325+
326+
XCTAssertDiagnosed(.collapseCase(name: ".a"), line: 2, column: 1)
327+
}
328+
329+
func testFallthroughOnlyCasesAreNotMergedWithUnknownDefault() {
330+
XCTAssertFormatting(
331+
NoCasesWithOnlyFallthrough.self,
332+
input: """
333+
switch x {
334+
case .a: fallthrough
335+
case .b: fallthrough
336+
@unknown default: print("got here")
337+
}
338+
""",
339+
expected: """
340+
switch x {
341+
case .a, .b: fallthrough
342+
@unknown default: print("got here")
343+
}
344+
""",
345+
checkForUnassertedDiagnostics: true)
346+
347+
XCTAssertDiagnosed(.collapseCase(name: ".a"), line: 2, column: 1)
241348
}
242349
}

0 commit comments

Comments
 (0)