Skip to content

Commit bccddb4

Browse files
committed
Handle invalid #if conditions as "unparsed" regions consistently
When determining active regions, treat clauses with invalid conditions as "unparsed" regions, but don't abort the computation by throwing. This provides behavior that is more consistent with the compiler, and is also generally easy for most clients. Those clients that want to report diagnostics can certainly do so, but are not forced to work with throwing APIs for invalid code. While here, improve the active syntax rewriting operation by making it a two-pass operation. The first pass emits diagnostics and determines whether there is any rewriting to do, and the second pass performs the rewriting. This fixes an existing bug where the diagnostic locations were wrong because we were emitting them against partially-rewritten trees.
1 parent 0b0ac13 commit bccddb4

File tree

6 files changed

+101
-124
lines changed

6 files changed

+101
-124
lines changed

Sources/SwiftIfConfig/ConfiguredRegions.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ fileprivate class ConfiguredRegionVisitor<Configuration: BuildConfiguration>: Sy
5959
}
6060

6161
override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind {
62-
// If're not within an
63-
let activeClause = inActiveRegion ? (try? node.activeClause(in: configuration)) : nil
62+
// If we're in an active region, find the active clause. Otherwise,
63+
// there isn't one.
64+
let activeClause = inActiveRegion ? node.activeClause(in: configuration) : nil
6465
for clause in node.clauses {
6566
// If this is the active clause, record it and then recurse into the
6667
// elements.
@@ -81,7 +82,7 @@ fileprivate class ConfiguredRegionVisitor<Configuration: BuildConfiguration>: Sy
8182
(try? clause.isVersioned(
8283
configuration: configuration,
8384
diagnosticHandler: nil
84-
)) ?? false
85+
)) ?? true
8586

8687
// If this is within an active region, or this is an unparsed region,
8788
// record it.

Sources/SwiftIfConfig/IfConfigEvaluation.swift

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -451,23 +451,27 @@ extension IfConfigDeclSyntax {
451451
/// command line, the second clause (containing `func g()`) would be returned. If neither was
452452
/// passed, this function will return `nil` to indicate that none of the regions are active.
453453
///
454-
/// If an error occurred while processing any of the `#if` clauses, this function will throw that error.
454+
/// If an error occurrs while processing any of the `#if` clauses,
455+
/// that clause will be considered inactive and this operation will
456+
/// continue to evaluate later clauses.
455457
public func activeClause(
456458
in configuration: some BuildConfiguration,
457459
diagnosticHandler: ((Diagnostic) -> Void)? = nil
458-
) throws -> IfConfigClauseSyntax? {
460+
) -> IfConfigClauseSyntax? {
459461
for clause in clauses {
460462
// If there is no condition, we have reached an unconditional clause. Return it.
461463
guard let condition = clause.condition else {
462464
return clause
463465
}
464466

465467
// If this condition evaluates true, return this clause.
466-
if try evaluateIfConfig(
467-
condition: condition,
468-
configuration: configuration,
469-
diagnosticHandler: diagnosticHandler
470-
).active {
468+
let isActive =
469+
(try? evaluateIfConfig(
470+
condition: condition,
471+
configuration: configuration,
472+
diagnosticHandler: diagnosticHandler
473+
))?.active ?? false
474+
if isActive {
471475
return clause
472476
}
473477
}
@@ -507,7 +511,7 @@ extension SyntaxProtocol {
507511
if let ifConfigClause = currentNode.as(IfConfigClauseSyntax.self),
508512
let ifConfigDecl = ifConfigClause.parent?.parent?.as(IfConfigDeclSyntax.self)
509513
{
510-
let activeClause = try ifConfigDecl.activeClause(
514+
let activeClause = ifConfigDecl.activeClause(
511515
in: configuration,
512516
diagnosticHandler: diagnosticHandler
513517
)
@@ -516,10 +520,12 @@ extension SyntaxProtocol {
516520
// This was not the active clause, so we know that we're in an
517521
// inactive block. However, if the condition is versioned, this is an
518522
// unparsed region.
519-
if try ifConfigClause.isVersioned(
520-
configuration: configuration,
521-
diagnosticHandler: diagnosticHandler
522-
) {
523+
let isVersioned =
524+
(try? ifConfigClause.isVersioned(
525+
configuration: configuration,
526+
diagnosticHandler: diagnosticHandler
527+
)) ?? true
528+
if isVersioned {
523529
return .unparsed
524530
}
525531

Sources/SwiftIfConfig/IfConfigRewriter.swift

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import SwiftDiagnostics
2323
import SwiftSyntax
2424

2525
/// Syntax rewriter that only visits syntax nodes that are active according
26-
/// to a particular build configuration build configuration.
26+
/// to a particular build configuration.
2727
///
2828
/// Given an example such as
2929
///
@@ -54,17 +54,11 @@ import SwiftSyntax
5454
/// than trivia).
5555
class ActiveSyntaxRewriter<Configuration: BuildConfiguration>: SyntaxRewriter {
5656
let configuration: Configuration
57-
var diagnostics: [Diagnostic] = []
5857

5958
init(configuration: Configuration) {
6059
self.configuration = configuration
6160
}
6261

63-
private func reportEvaluationError(at node: some SyntaxProtocol, error: Error) {
64-
let newDiagnostics = error.asDiagnostics(at: node)
65-
diagnostics.append(contentsOf: newDiagnostics)
66-
}
67-
6862
private func dropInactive<List: Collection & SyntaxCollection>(
6963
_ node: List,
7064
elementAsIfConfig: (List.Element) -> IfConfigDeclSyntax?
@@ -76,20 +70,8 @@ class ActiveSyntaxRewriter<Configuration: BuildConfiguration>: SyntaxRewriter {
7670

7771
// Find #ifs within the list.
7872
if let ifConfigDecl = elementAsIfConfig(element) {
79-
// Evaluate the `#if` condition.
80-
let activeClause: IfConfigClauseSyntax?
81-
do {
82-
activeClause = try ifConfigDecl.activeClause(in: configuration)
83-
} catch {
84-
// When an error occurs in the evaluation of the condition,
85-
// keep the entire `#if`.
86-
if anyChanged {
87-
newElements.append(element)
88-
}
89-
90-
reportEvaluationError(at: element, error: error)
91-
continue
92-
}
73+
// Retrieve the active `#if` clause
74+
let activeClause = ifConfigDecl.activeClause(in: configuration)
9375

9476
// If this is the first element that changed, note that we have
9577
// changes and add all prior elements to the list of new elements.
@@ -248,14 +230,8 @@ class ActiveSyntaxRewriter<Configuration: BuildConfiguration>: SyntaxRewriter {
248230
outerBase: ExprSyntax?,
249231
postfixIfConfig: PostfixIfConfigExprSyntax
250232
) -> ExprSyntax {
251-
// Determine the active clause within this syntax node.
252-
let activeClause: IfConfigClauseSyntax?
253-
do {
254-
activeClause = try postfixIfConfig.config.activeClause(in: configuration)
255-
} catch {
256-
reportEvaluationError(at: postfixIfConfig, error: error)
257-
return ExprSyntax(postfixIfConfig)
258-
}
233+
// Retrieve the active `if` clause.
234+
let activeClause = postfixIfConfig.config.activeClause(in: configuration)
259235

260236
guard case .postfixExpression(let postfixExpr) = activeClause?.elements
261237
else {
@@ -304,8 +280,29 @@ extension SyntaxProtocol {
304280
///
305281
/// Returns the syntax node with all inactive regions removed, along with an
306282
/// array containing any diagnostics produced along the way.
283+
///
284+
/// If there are errors in the conditions of any configuration
285+
/// clauses, e.g., `#if FOO > 10`, then the condition will be
286+
/// considered to have failed and the clauses's elements will be
287+
/// removed.
307288
public func removingInactive(in configuration: some BuildConfiguration) -> (Syntax, [Diagnostic]) {
308-
let visitor = ActiveSyntaxRewriter(configuration: configuration)
309-
return (visitor.rewrite(Syntax(self)), visitor.diagnostics)
289+
// First pass: Find all of the active clauses for the #ifs we need to
290+
// visit, along with any diagnostics produced along the way. This process
291+
// does not change the tree in any way.
292+
let visitor = ActiveSyntaxVisitor(viewMode: .sourceAccurate, configuration: configuration)
293+
visitor.walk(self)
294+
295+
// If there were no active clauses to visit, we're done!
296+
if visitor.numIfClausesVisited == 0 {
297+
return (Syntax(self), visitor.diagnostics)
298+
}
299+
300+
// Second pass: Rewrite the syntax tree by removing the inactive clauses
301+
// from each #if (along with the #ifs themselves).
302+
let rewriter = ActiveSyntaxRewriter(configuration: configuration)
303+
return (
304+
rewriter.rewrite(Syntax(self)),
305+
visitor.diagnostics
306+
)
310307
}
311308
}

Sources/SwiftIfConfig/IfConfigVisitor.swift

Lines changed: 27 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -37,49 +37,37 @@ import SwiftSyntax
3737
///
3838
/// All notes visited by this visitor will have the "active" state, i.e.,
3939
/// `node.isActive(in: configuration)` will evaluate to `.active` or will
40-
/// throw. When errors occur, they will be reported via a call to
41-
/// `reportEvaluationError`, which can report the errors (the default is to
42-
/// turn them into diagnostics that go into the `diagnostics` array) and then
43-
/// choose whether to visit all of the `#if` clauses (the default) or skip them.
40+
/// throw. When errors occur, they will be recorded in the set of
41+
/// diagnostics.
4442
open class ActiveSyntaxVisitor<Configuration: BuildConfiguration>: SyntaxVisitor {
4543
/// The build configuration, which will be queried for each relevant `#if`.
4644
public let configuration: Configuration
4745

4846
/// The set of diagnostics accumulated during this walk of active syntax.
4947
public var diagnostics: [Diagnostic] = []
5048

49+
/// The number of "#if" clauses that were visited.
50+
var numIfClausesVisited: Int = 0
51+
5152
public init(viewMode: SyntaxTreeViewMode, configuration: Configuration) {
5253
self.configuration = configuration
5354
super.init(viewMode: viewMode)
5455
}
5556

56-
/// Called when the evaluation of an `#if` condition produces an error.
57-
///
58-
/// By default, this records diagnostics from the error into the `diagnostics`
59-
/// array.
60-
///
61-
/// - Returns: Whether to visit the children of the `#if` or not after the
62-
/// error. By default, this function returns `.visitChildren`.
63-
open func reportEvaluationError(at node: IfConfigDeclSyntax, error: Error) -> SyntaxVisitorContinueKind {
64-
let newDiagnostics = error.asDiagnostics(at: node)
65-
diagnostics.append(contentsOf: newDiagnostics)
66-
return .visitChildren
67-
}
68-
6957
open override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind {
70-
do {
71-
// If there is an active clause, visit it's children.
72-
if let activeClause = try node.activeClause(in: configuration),
73-
let elements = activeClause.elements
74-
{
75-
walk(Syntax(elements))
76-
}
58+
let activeClause = node.activeClause(in: configuration) { diag in
59+
self.diagnostics.append(diag)
60+
}
61+
62+
numIfClausesVisited += 1
7763

78-
// Skip everything else in the #if.
79-
return .skipChildren
80-
} catch {
81-
return reportEvaluationError(at: node, error: error)
64+
// If there is an active clause, visit it's children.
65+
if let activeClause, let elements = activeClause.elements {
66+
walk(Syntax(elements))
8267
}
68+
69+
// Skip everything else in the #if.
70+
return .skipChildren
8371
}
8472
}
8573

@@ -112,10 +100,8 @@ open class ActiveSyntaxVisitor<Configuration: BuildConfiguration>: SyntaxVisitor
112100
///
113101
/// All notes visited by this visitor will have the "active" state, i.e.,
114102
/// `node.isActive(in: configuration)` will evaluate to `.active` or will
115-
/// throw. When errors occur, they will be reported via a call to
116-
/// `reportEvaluationError`, which can report the errors (the default is to
117-
/// turn them into diagnostics that go into the `diagnostics` array) and then
118-
/// choose whether to visit all of the `#if` clauses (the default) or skip them.
103+
/// throw. When errors occur, they will be recorded in the set of
104+
/// diagnostivs.
119105
open class ActiveSyntaxAnyVisitor<Configuration: BuildConfiguration>: SyntaxAnyVisitor {
120106
/// The build configuration, which will be queried for each relevant `#if`.
121107
public let configuration: Configuration
@@ -128,32 +114,16 @@ open class ActiveSyntaxAnyVisitor<Configuration: BuildConfiguration>: SyntaxAnyV
128114
super.init(viewMode: viewMode)
129115
}
130116

131-
/// Called when the evaluation of an `#if` condition produces an error.
132-
///
133-
/// By default, this records diagnostics from the error into the `diagnostics`
134-
/// array.
135-
///
136-
/// - Returns: Whether to visit the children of the `#if` or not after the
137-
/// error. By default, this function returns `.visitChildren`.
138-
open func reportEvaluationError(at node: IfConfigDeclSyntax, error: Error) -> SyntaxVisitorContinueKind {
139-
let newDiagnostics = error.asDiagnostics(at: node)
140-
diagnostics.append(contentsOf: newDiagnostics)
141-
return .visitChildren
142-
}
143-
144117
open override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind {
145-
do {
146-
// If there is an active clause, visit it's children.
147-
if let activeClause = try node.activeClause(in: configuration),
148-
let elements = activeClause.elements
149-
{
150-
walk(Syntax(elements))
151-
}
152-
153-
// Skip everything else in the
154-
return .skipChildren
155-
} catch {
156-
return reportEvaluationError(at: node, error: error)
118+
// If there is an active clause, visit it's children.
119+
let activeClause = node.activeClause(in: configuration) { diag in
120+
self.diagnostics.append(diag)
157121
}
122+
if let activeClause, let elements = activeClause.elements {
123+
walk(Syntax(elements))
124+
}
125+
126+
// Skip everything else in the #if.
127+
return .skipChildren
158128
}
159129
}

Tests/SwiftIfConfigTest/ActiveRegionTests.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,23 @@ public class ActiveRegionTests: XCTestCase {
7878
"5️⃣": .active,
7979
]
8080
)
81+
}
8182

83+
func testActiveRegionsWithErrors() throws {
84+
try assertActiveCode(
85+
"""
86+
#if FOO > 10
87+
0️⃣class Foo {
88+
}
89+
#else
90+
1️⃣class Fallback {
91+
}
92+
#endif
93+
""",
94+
states: [
95+
"0️⃣": .unparsed,
96+
"1️⃣": .active,
97+
]
98+
)
8299
}
83100
}

Tests/SwiftIfConfigTest/VisitorTests.swift

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ public class VisitorTests: XCTestCase {
166166
configuration.badAttributes.insert("available")
167167
let visitor = NameCheckingVisitor(
168168
configuration: configuration,
169-
expectedNames: ["f", "h", "i", "S", "generationCount", "value", "withAvail", "notAvail"]
169+
expectedNames: ["f", "h", "i", "S", "generationCount", "value", "notAvail"]
170170
)
171171
visitor.walk(inputSource)
172172
XCTAssertEqual(visitor.diagnostics.count, 3)
@@ -213,27 +213,23 @@ public class VisitorTests: XCTestCase {
213213
diagnostics: [
214214
DiagnosticSpec(
215215
message: "unacceptable attribute 'available'",
216-
line: 51,
217-
column: 1
216+
line: 3,
217+
column: 18
218218
),
219219
DiagnosticSpec(
220220
message: "unacceptable attribute 'available'",
221-
line: 1,
222-
column: 2
221+
line: 42,
222+
column: 20
223223
),
224224
DiagnosticSpec(
225225
message: "unacceptable attribute 'available'",
226-
line: 27,
227-
column: 17
226+
line: 51,
227+
column: 18
228228
),
229229
],
230230
expectedSource: """
231231
232-
#if hasAttribute(available)
233-
@available(*, deprecated, message: "use something else")
234-
#else
235232
@MainActor
236-
#endif
237233
func f() {
238234
}
239235
@@ -250,19 +246,9 @@ public class VisitorTests: XCTestCase {
250246
251247
func i() {
252248
a.b
253-
#if DEBUG
254249
.c
255-
#endif
256-
#if hasAttribute(available)
257-
.d()
258-
#endif
259250
}
260-
261-
#if hasAttribute(available)
262-
func withAvail() { }
263-
#else
264251
func notAvail() { }
265-
#endif
266252
"""
267253
)
268254
}

0 commit comments

Comments
 (0)