Skip to content

Commit 96569a7

Browse files
committed
Teach #if condition evaluation to reason about "versioned" checks
When a check is "versioned" and fails, we allow the failed #if block to have syntactic errors in it. Start to reflect this distinction when determining the `IfConfigState` for a particular condition, so that we finally use the "unparsed" case.
1 parent 7cb5189 commit 96569a7

File tree

4 files changed

+160
-45
lines changed

4 files changed

+160
-45
lines changed

Sources/SwiftIfConfig/IfConfigEvaluation.swift

Lines changed: 77 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -249,12 +249,15 @@ extension VersionTuple {
249249
/// build configuration itself.
250250
/// - Throws: Throws if an error occurs occur during evaluation. The error will
251251
/// also be provided to the diagnostic handler before doing so.
252-
/// - Returns: Whether the condition holds with the given build configuration.
252+
/// - Returns: A pair of Boolean values. The first describes whether the
253+
/// condition holds with the given build configuration. The second whether
254+
/// the build condition is a "versioned" check that implies that we shouldn't
255+
/// diagnose syntax errors in blocks where the check fails.
253256
private func evaluateIfConfig(
254257
condition: ExprSyntax,
255258
configuration: some BuildConfiguration,
256259
diagnosticHandler: ((Diagnostic) -> Void)?
257-
) throws -> Bool {
260+
) throws -> (active: Bool, versioned: Bool) {
258261
/// Record the error before returning it. Use this for every 'throw' site
259262
/// in this evaluation.
260263
func recordedError(_ error: any Error, at node: some SyntaxProtocol) -> any Error {
@@ -275,8 +278,8 @@ private func evaluateIfConfig(
275278
/// appropriate diagnostic for the handler before rethrowing it.
276279
func checkConfiguration(
277280
at node: some SyntaxProtocol,
278-
body: () throws -> Bool
279-
) throws -> Bool {
281+
body: () throws -> (Bool, Bool)
282+
) throws -> (active: Bool, versioned: Bool) {
280283
do {
281284
return try body()
282285
} catch let error {
@@ -286,7 +289,7 @@ private func evaluateIfConfig(
286289

287290
// Boolean literals evaluate as-is
288291
if let boolLiteral = condition.as(BooleanLiteralExprSyntax.self) {
289-
return boolLiteral.literalValue
292+
return (active: boolLiteral.literalValue, versioned: false)
290293
}
291294

292295
// Integer literals aren't allowed, but we recognize them.
@@ -302,7 +305,7 @@ private func evaluateIfConfig(
302305
).asDiagnostic
303306
)
304307

305-
return result
308+
return (active: result, versioned: false)
306309
}
307310

308311
// Declaration references are for custom compilation flags.
@@ -312,19 +315,21 @@ private func evaluateIfConfig(
312315

313316
// Evaluate the custom condition. If the build configuration cannot answer this query, fail.
314317
return try checkConfiguration(at: identExpr) {
315-
try configuration.isCustomConditionSet(name: ident)
318+
(active: try configuration.isCustomConditionSet(name: ident), versioned: false)
316319
}
317320
}
318321

319322
// Logical '!'.
320323
if let prefixOp = condition.as(PrefixOperatorExprSyntax.self),
321324
prefixOp.operator.text == "!"
322325
{
323-
return try !evaluateIfConfig(
326+
let (innerActive, innerVersioned) = try evaluateIfConfig(
324327
condition: prefixOp.expression,
325328
configuration: configuration,
326329
diagnosticHandler: diagnosticHandler
327330
)
331+
332+
return (active: !innerActive, versioned: innerVersioned)
328333
}
329334

330335
// Logical '&&' and '||'.
@@ -333,25 +338,45 @@ private func evaluateIfConfig(
333338
(op.operator.text == "&&" || op.operator.text == "||")
334339
{
335340
// Evaluate the left-hand side.
336-
let lhsResult = try evaluateIfConfig(
341+
let (lhsActive, lhsVersioned) = try evaluateIfConfig(
337342
condition: binOp.leftOperand,
338343
configuration: configuration,
339344
diagnosticHandler: diagnosticHandler
340345
)
341346

342-
// Short-circuit evaluation if we know the answer.
343-
switch (lhsResult, op.operator.text) {
344-
case (true, "||"): return true
345-
case (false, "&&"): return false
346-
default: break
347+
// Short-circuit evaluation if we know the answer and the left-hand side
348+
// was versioned.
349+
if lhsVersioned {
350+
switch (lhsActive, op.operator.text) {
351+
case (true, "||"): return (active: true, versioned: lhsVersioned)
352+
case (false, "&&"): return (active: false, versioned: lhsVersioned)
353+
default: break
354+
}
347355
}
348356

349-
// Evaluate the right-hand side and use that result.
350-
return try evaluateIfConfig(
357+
// Evaluate the right-hand side.
358+
let (rhsActive, rhsVersioned) = try evaluateIfConfig(
351359
condition: binOp.rightOperand,
352360
configuration: configuration,
353361
diagnosticHandler: diagnosticHandler
354362
)
363+
364+
switch op.operator.text {
365+
case "||":
366+
return (
367+
active: lhsActive || rhsActive,
368+
versioned: lhsVersioned && rhsVersioned
369+
)
370+
371+
case "&&":
372+
return (
373+
active: lhsActive && rhsActive,
374+
versioned: lhsVersioned || rhsVersioned
375+
)
376+
377+
default:
378+
fatalError("prevented by condition for getting here")
379+
}
355380
}
356381

357382
// Look through parentheses.
@@ -371,7 +396,10 @@ private func evaluateIfConfig(
371396
let fn = IfConfigFunctions(rawValue: fnName)
372397
{
373398
/// Perform a check for an operation that takes a single identifier argument.
374-
func doSingleIdentifierArgumentCheck(_ body: (String) throws -> Bool, role: String) throws -> Bool {
399+
func doSingleIdentifierArgumentCheck(
400+
_ body: (String) throws -> Bool,
401+
role: String
402+
) throws -> (active: Bool, versioned: Bool) {
375403
// Ensure that we have a single argument that is a simple identifier.
376404
guard let argExpr = call.arguments.singleUnlabeledExpression,
377405
let arg = argExpr.simpleIdentifierExpr
@@ -382,12 +410,14 @@ private func evaluateIfConfig(
382410
}
383411

384412
return try checkConfiguration(at: argExpr) {
385-
try body(arg)
413+
(active: try body(arg), versioned: fn.isVersioned)
386414
}
387415
}
388416

389417
/// Perform a check for a version constraint as used in the "swift" or "compiler" version checks.
390-
func doVersionComparisonCheck(_ actualVersion: VersionTuple) throws -> Bool {
418+
func doVersionComparisonCheck(
419+
_ actualVersion: VersionTuple
420+
) throws -> (active: Bool, versioned: Bool) {
391421
// Ensure that we have a single unlabeled argument that is either >= or < as a prefix
392422
// operator applied to a version.
393423
guard let argExpr = call.arguments.singleUnlabeledExpression,
@@ -410,9 +440,9 @@ private func evaluateIfConfig(
410440

411441
switch opToken.text {
412442
case ">=":
413-
return actualVersion >= version
443+
return (active: actualVersion >= version, versioned: fn.isVersioned)
414444
case "<":
415-
return actualVersion < version
445+
return (active: actualVersion < version, versioned: fn.isVersioned)
416446
default:
417447
throw recordedError(.unsupportedVersionOperator(name: fnName, operator: opToken))
418448
}
@@ -459,7 +489,10 @@ private func evaluateIfConfig(
459489
)
460490
}
461491

462-
return configuration.endianness == expectedEndianness
492+
return (
493+
active: configuration.endianness == expectedEndianness,
494+
versioned: fn.isVersioned
495+
)
463496

464497
case ._pointerBitWidth:
465498
// Ensure that we have a single argument that is a simple identifier, which
@@ -479,7 +512,10 @@ private func evaluateIfConfig(
479512
)
480513
}
481514

482-
return configuration.targetPointerBitWidth == expectedPointerBitWidth
515+
return (
516+
active: configuration.targetPointerBitWidth == expectedPointerBitWidth,
517+
versioned: fn.isVersioned
518+
)
483519

484520
case .swift:
485521
return try doVersionComparisonCheck(configuration.languageVersion)
@@ -508,7 +544,10 @@ private func evaluateIfConfig(
508544
let versionString = stringSegment.content.text
509545
let expectedVersion = try VersionTuple(parsingCompilerBuildVersion: versionString, argExpr)
510546

511-
return configuration.compilerVersion >= expectedVersion
547+
return (
548+
active: configuration.compilerVersion >= expectedVersion,
549+
versioned: fn.isVersioned
550+
)
512551

513552
case .canImport:
514553
// Retrieve the first argument, which must not have a label. This is
@@ -577,9 +616,12 @@ private func evaluateIfConfig(
577616
}
578617

579618
return try checkConfiguration(at: call) {
580-
try configuration.canImport(
581-
importPath: importPath.map { String($0) },
582-
version: version
619+
(
620+
active: try configuration.canImport(
621+
importPath: importPath.map { String($0) },
622+
version: version
623+
),
624+
versioned: fn.isVersioned
583625
)
584626
}
585627
}
@@ -602,12 +644,17 @@ extension IfConfigState {
602644
throw error
603645
}.cast(ExprSyntax.self)
604646

605-
let result = try evaluateIfConfig(
647+
let (active, versioned) = try evaluateIfConfig(
606648
condition: foldedCondition,
607649
configuration: configuration,
608650
diagnosticHandler: diagnosticHandler
609651
)
610-
self = result ? .active : .inactive
652+
653+
switch (active, versioned) {
654+
case (true, _): self = .active
655+
case (false, false): self = .inactive
656+
case (false, true): self = .unparsed
657+
}
611658
}
612659
}
613660

@@ -644,7 +691,7 @@ extension IfConfigDeclSyntax {
644691
condition: condition,
645692
configuration: configuration,
646693
diagnosticHandler: diagnosticHandler
647-
) {
694+
).active {
648695
return clause
649696
}
650697
}

Sources/SwiftIfConfig/IfConfigFunctions.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,17 @@ enum IfConfigFunctions: String {
5252
/// A check for the target's pointer authentication scheme (e.g., _arm64e)
5353
/// via `_ptrauth(<name>)`.
5454
case _ptrauth
55+
56+
/// Whether uses of this function consistute a "versioned" check. Such checks
57+
/// suppress parser diagnostics if the block failed.
58+
var isVersioned: Bool {
59+
switch self {
60+
case .swift, .compiler, ._compiler_version:
61+
return true
62+
63+
case .hasAttribute, .hasFeature, .canImport, .os, .arch, .targetEnvironment,
64+
._endian, ._pointerBitWidth, ._runtime, ._ptrauth:
65+
return false
66+
}
67+
}
5568
}

Sources/SwiftIfConfig/IfConfigState.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,8 @@
1212

1313
/// Describes the state of a particular region guarded by `#if` or similar.
1414
public enum IfConfigState {
15-
/// The region is not parsed, and may contain syntax that is invalid.
16-
///
17-
/// TODO: For now, the IfConfig library does not distinguish between
18-
/// inactive and unparsed regions, so this case is never used.
15+
/// The region is not part of the compiled program and is not even parsed,
16+
/// and therefore many contain syntax that is invalid.
1917
case unparsed
2018
/// The region is parsed but is not part of the compiled program.
2119
case inactive

Tests/SwiftIfConfigTest/EvaluateTests.swift

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,46 @@ public class EvaluateTests: XCTestCase {
7777
assertIfConfig("DEBUG && ASSERTS", .active, configuration: buildConfig)
7878
assertIfConfig("DEBUG && nope", .inactive, configuration: buildConfig)
7979
assertIfConfig("nope && DEBUG", .inactive, configuration: buildConfig)
80-
assertIfConfig("nope && 3.14159", .inactive, configuration: buildConfig)
80+
assertIfConfig(
81+
"nope && 3.14159",
82+
nil,
83+
configuration: buildConfig,
84+
diagnostics: [
85+
DiagnosticSpec(
86+
message: "invalid conditional compilation expression",
87+
line: 1,
88+
column: 9
89+
)
90+
]
91+
)
8192
assertIfConfig("DEBUG || ASSERTS", .active, configuration: buildConfig)
8293
assertIfConfig("DEBUG || nope", .active, configuration: buildConfig)
8394
assertIfConfig("nope || DEBUG", .active, configuration: buildConfig)
8495
assertIfConfig("nope || !DEBUG", .inactive, configuration: buildConfig)
85-
assertIfConfig("DEBUG || 3.14159", .active, configuration: buildConfig)
86-
assertIfConfig("(DEBUG) || 3.14159", .active, configuration: buildConfig)
96+
assertIfConfig(
97+
"DEBUG || 3.14159",
98+
nil,
99+
configuration: buildConfig,
100+
diagnostics: [
101+
DiagnosticSpec(
102+
message: "invalid conditional compilation expression",
103+
line: 1,
104+
column: 10
105+
)
106+
]
107+
)
108+
assertIfConfig(
109+
"(DEBUG) || 3.14159",
110+
nil,
111+
configuration: buildConfig,
112+
diagnostics: [
113+
DiagnosticSpec(
114+
message: "invalid conditional compilation expression",
115+
line: 1,
116+
column: 12
117+
)
118+
]
119+
)
87120
}
88121

89122
func testBadExpressions() throws {
@@ -135,15 +168,39 @@ public class EvaluateTests: XCTestCase {
135168
}
136169

137170
func testVersions() throws {
138-
assertIfConfig("swift(>=5.5", .active)
139-
assertIfConfig("swift(<6", .active)
140-
assertIfConfig("swift(>=6", .inactive)
141-
assertIfConfig("compiler(>=5.8", .active)
142-
assertIfConfig("compiler(>=5.9", .active)
143-
assertIfConfig("compiler(>=5.10", .inactive)
171+
assertIfConfig("swift(>=5.5)", .active)
172+
assertIfConfig("swift(<6)", .active)
173+
assertIfConfig("swift(>=6)", .unparsed)
174+
assertIfConfig("compiler(>=5.8)", .active)
175+
assertIfConfig("compiler(>=5.9)", .active)
176+
assertIfConfig("compiler(>=5.10)", .unparsed)
144177
assertIfConfig(#"_compiler_version("5009.*.1")"#, .active)
145-
assertIfConfig(#"_compiler_version("5009.*.3.2.3")"#, .inactive)
146-
assertIfConfig(#"_compiler_version("5010.*.0")"#, .inactive)
178+
assertIfConfig(#"_compiler_version("5009.*.3.2.3")"#, .unparsed)
179+
assertIfConfig(#"_compiler_version("5010.*.0")"#, .unparsed)
180+
assertIfConfig("compiler(>=5.10) && 3.14159", .unparsed)
181+
assertIfConfig(
182+
"compiler(>=5.10) || 3.14159",
183+
nil,
184+
diagnostics: [
185+
DiagnosticSpec(
186+
message: "invalid conditional compilation expression",
187+
line: 1,
188+
column: 21
189+
)
190+
]
191+
)
192+
assertIfConfig("compiler(>=5.9) || 3.14159", .active)
193+
assertIfConfig(
194+
"compiler(>=5.9) && 3.14159",
195+
nil,
196+
diagnostics: [
197+
DiagnosticSpec(
198+
message: "invalid conditional compilation expression",
199+
line: 1,
200+
column: 20
201+
)
202+
]
203+
)
147204
}
148205

149206
func testCanImport() throws {

0 commit comments

Comments
 (0)