From 746a64a98966ed227fbc098f45f89b9af0de474f Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Mon, 5 Jun 2023 04:22:28 -0700 Subject: [PATCH 01/42] [SwiftIfConfig] Add a new library for evaluating `#if` conditions. Building on top of the parser and operator-precedence parsing library, introduce a new library that evaluates `#if` conditions against a particular build configuration. The build configuration is described by the aptly named `BuildConfiguration` protocol, which has queries for various build settings (e.g., configuration flags), compiler capabilities (features and attributes), and target information (OS, architecture, endianness, etc.). At present, the only user-facing operation is the `IfConfigState` initializer, which takes in an expression (the `#if` condition) and a build configuration, then evaluates that expression against the build condition to determine whether code covered by that condition is active, inactive, or completely unparsed. This is a fairly low-level API, meant to be a building block for more useful higher-level APIs that query which `#if` clause is active and whether a particular syntax node is active. --- Package.swift | 17 ++ .../SwiftIfConfig/BuildConfiguration.swift | 69 +++++++ .../SwiftIfConfig/IfConfigEvaluation.swift | 193 ++++++++++++++++++ Sources/SwiftIfConfig/IfConfigFunctions.swift | 28 +++ Sources/SwiftIfConfig/IfConfigState.swift | 21 ++ .../SwiftIfConfig/SyntaxLiteralUtils.swift | 48 +++++ Tests/SwiftIfConfigTest/EvaluateTests.swift | 155 ++++++++++++++ 7 files changed, 531 insertions(+) create mode 100644 Sources/SwiftIfConfig/BuildConfiguration.swift create mode 100644 Sources/SwiftIfConfig/IfConfigEvaluation.swift create mode 100644 Sources/SwiftIfConfig/IfConfigFunctions.swift create mode 100644 Sources/SwiftIfConfig/IfConfigState.swift create mode 100644 Sources/SwiftIfConfig/SyntaxLiteralUtils.swift create mode 100644 Tests/SwiftIfConfigTest/EvaluateTests.swift diff --git a/Package.swift b/Package.swift index 99a11c22bfa..2bdb6cc3b44 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,11 @@ let package = Package( .library(name: "SwiftCompilerPlugin", targets: ["SwiftCompilerPlugin"]), .library(name: "SwiftDiagnostics", targets: ["SwiftDiagnostics"]), .library(name: "SwiftIDEUtils", targets: ["SwiftIDEUtils"]), +<<<<<<< HEAD .library(name: "SwiftLexicalLookup", targets: ["SwiftLexicalLookup"]), +======= + .library(name: "SwiftIfConfig", targets: ["SwiftIfConfig"]), +>>>>>>> d8c920b49 ([SwiftIfConfig] Add a new library for evaluating `#if` conditions.) .library(name: "SwiftOperators", targets: ["SwiftOperators"]), .library(name: "SwiftParser", targets: ["SwiftParser"]), .library(name: "SwiftParserDiagnostics", targets: ["SwiftParserDiagnostics"]), @@ -138,6 +142,19 @@ let package = Package( dependencies: ["_SwiftSyntaxTestSupport", "SwiftIDEUtils", "SwiftParser", "SwiftSyntax"] ), + // MARK: SwiftIfConfig + + .target( + name: "SwiftIfConfig", + dependencies: ["SwiftSyntax", "SwiftOperators"], + exclude: ["CMakeLists.txt"] + ), + + .testTarget( + name: "SwiftIfConfigTest", + dependencies: ["_SwiftSyntaxTestSupport", "SwiftIfConfig", "SwiftParser"] + ), + // MARK: SwiftLexicalLookup .target( diff --git a/Sources/SwiftIfConfig/BuildConfiguration.swift b/Sources/SwiftIfConfig/BuildConfiguration.swift new file mode 100644 index 00000000000..d83e537a750 --- /dev/null +++ b/Sources/SwiftIfConfig/BuildConfiguration.swift @@ -0,0 +1,69 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import SwiftSyntax + +public enum Endianness: String { + case little + case big +} + +/// Captures information about the build configuration that can be +/// queried in a `#if` expression, including OS, compiler version, +/// enabled language features, and available modules. +/// +/// Providing complete build configuration information effectively requires +/// a Swift compiler, because (for example) determining whether a module can +/// be imported is a complicated task only implemented in the Swift compiler. +/// Therefore, many of the queries return `Bool?`, where `nil` indicates +/// that the answer is not known. Clients that don't have a lot of context +/// (such as an IDE that does not have access to the compiler command line) +/// can return `nil`. +public protocol BuildConfiguration { + /// Determine whether a given custom build condition has been set. + /// + /// Custom build conditions can be set by the `-D` command line option to + /// the Swift compiler. For example, `-DDEBUG` sets the custom condition + /// named `DEBUG`. + func isCustomConditionSet(name: String, syntax: TokenSyntax) -> Bool? + + /// Determine whether the given feature is enabled. + /// + /// Features are determined by the Swift compiler, language mode, and other + /// options such as `--enable-upcoming-feature`. + func hasFeature(name: String, syntax: ExprSyntax) -> Bool? + + /// Determine whether the given attribute is available. + /// + /// Attributes are determined by the Swift compiler. + func hasAttribute(name: String, syntax: ExprSyntax) -> Bool? + + /// Determine whether the given name is the active target OS (e.g., Linux, iOS). + func isActiveTargetOS(name: String, syntax: ExprSyntax) -> Bool? + + /// Determine whether the given name is the active target architecture (e.g., x86_64, arm64) + func isActiveTargetArchitecture(name: String, syntax: ExprSyntax) -> Bool? + + /// Determine whether the given name is the active target environment (e.g., simulator) + func isActiveTargetEnvironment(name: String, syntax: ExprSyntax) -> Bool? + + /// Determine whether the given name is the active target runtime (e.g., _ObjC vs. _Native) + func isActiveTargetRuntime(name: String, syntax: ExprSyntax) -> Bool? + + /// Determine whether the given name is the active target pointer authentication scheme (e.g., arm64e). + func isActiveTargetPointerAuthentication(name: String, syntax: ExprSyntax) -> Bool? + + /// The bit width of a data pointer for the target architecture. + var targetPointerBitWidth: Int? { get } + + /// The endianness of the target architecture. + var endianness: Endianness? { get } +} diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift new file mode 100644 index 00000000000..356818df043 --- /dev/null +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -0,0 +1,193 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import SwiftSyntax +import SwiftOperators + +enum IfConfigError: Error, CustomStringConvertible { + case unknownExpression(ExprSyntax) + case unhandledCustomCondition(name: String, syntax: TokenSyntax) + case unhandledFunction(name: String, syntax: ExprSyntax) + + var description: String { + switch self { + case .unknownExpression: + return "invalid conditional compilation expression" + + case .unhandledCustomCondition(name: let name, syntax: _): + return "build configuration cannot handle custom condition '\(name)'" + + case .unhandledFunction(name: let name, syntax: _): + return "build configuration cannot handle '\(name)'" + } + } +} + +/// Evaluate the condition of an `#if`. +private func evaluateIfConfig( + condition: ExprSyntax, + configuration: some BuildConfiguration +) throws -> Bool { + // Boolean literals evaluate as-is + if let boolLiteral = condition.as(BooleanLiteralExprSyntax.self) { + return boolLiteral.literalValue + } + + // Integer literals evaluate true if that are not "0". + if let intLiteral = condition.as(IntegerLiteralExprSyntax.self) { + return intLiteral.digits.text != "0" + } + + // Declaration references are for custom compilation flags. + if let identExpr = condition.as(IdentifierExprSyntax.self) { + // FIXME: Need a real notion of an identifier. + let ident = identExpr.identifier.text + + // Evaluate the custom condition. If the build configuration cannot answer this query, fail. + guard let result = configuration.isCustomConditionSet(name: ident, syntax: identExpr.identifier) else { + throw IfConfigError.unhandledCustomCondition(name: ident, syntax: identExpr.identifier) + } + return result + } + + // Logical '!'. + if let prefixOp = condition.as(PrefixOperatorExprSyntax.self), + prefixOp.operatorToken?.text == "!" { + return try !evaluateIfConfig(condition: prefixOp.postfixExpression, configuration: configuration) + } + + // Logical '&&' and '||'. + if let binOp = condition.as(InfixOperatorExprSyntax.self), + let op = binOp.operatorOperand.as(BinaryOperatorExprSyntax.self), + (op.operatorToken.text == "&&" || op.operatorToken.text == "||") { + // Evaluate the left-hand side. + let lhsResult = try evaluateIfConfig(condition: binOp.leftOperand, configuration: configuration) + + // Short-circuit evaluation if we know the answer. + switch (lhsResult, op.operatorToken.text) { + case (true, "||"): return true + case (false, "&&"): return false + default: break + } + + // Evaluate the right-hand side and use that result. + return try evaluateIfConfig(condition: binOp.rightOperand, configuration: configuration) + } + + // Look through parentheses. + if let tuple = condition.as(TupleExprSyntax.self), tuple.isParentheses, + let element = tuple.elements.first { + return try evaluateIfConfig(condition: element.expression, configuration: configuration) + } + + // Calls syntax is for operations. + if let call = condition.as(FunctionCallExprSyntax.self), + let fnName = call.calledExpression.simpleIdentifierExpr, + let fn = IfConfigFunctions(rawValue: fnName) { + + /// Perform a check for an operation that takes a single identifier argument. + func doSingleIdentifierArgumentCheck(_ body: (String, ExprSyntax) -> Bool?) throws -> Bool? { + // Ensure that we have a single argument that is a simple identifier. + guard let argExpr = call.argumentList.singleUnlabeledExpression, + let arg = argExpr.simpleIdentifierExpr else { return nil } + + guard let result = body(arg, ExprSyntax(argExpr)) else { + throw IfConfigError.unhandledFunction(name: fnName, syntax: ExprSyntax(call)) + } + + return result + } + + let result: Bool? + switch fn { + case .hasAttribute: + result = try doSingleIdentifierArgumentCheck(configuration.hasAttribute) + + case .hasFeature: + result = try doSingleIdentifierArgumentCheck(configuration.hasFeature) + + case .os: + result = try doSingleIdentifierArgumentCheck(configuration.isActiveTargetOS) + + case .arch: + result = try doSingleIdentifierArgumentCheck(configuration.isActiveTargetArchitecture) + + case .targetEnvironment: + result = try doSingleIdentifierArgumentCheck(configuration.isActiveTargetEnvironment) + + case ._runtime: + result = try doSingleIdentifierArgumentCheck(configuration.isActiveTargetRuntime) + + case ._ptrauth: + result = try doSingleIdentifierArgumentCheck(configuration.isActiveTargetPointerAuthentication) + + case ._endian: + // Ensure that we have a single argument that is a simple identifier, + // either "little" or "big". + guard let argExpr = call.argumentList.singleUnlabeledExpression, + let arg = argExpr.simpleIdentifierExpr, + let expectedEndianness = Endianness(rawValue: arg) else { + result = nil + break + } + + // If the build configuration doesn't know the endianness, fail. + guard let targetEndianness = configuration.endianness else { + throw IfConfigError.unhandledFunction(name: fnName, syntax: ExprSyntax(call)) + } + + result = targetEndianness == expectedEndianness + + case ._pointerBitWidth: + // Ensure that we have a single argument that is a simple identifier, which + // is an underscore followed by an integer. + guard let argExpr = call.argumentList.singleUnlabeledExpression, + let arg = argExpr.simpleIdentifierExpr, + let argFirst = arg.first, + argFirst == "_", + let expectedPointerBitWidth = Int(arg.dropFirst()) else { + result = nil + break + } + + // If the build configuration doesn't know the pointer bit width, fail. + guard let targetPointerBitWidth = configuration.targetPointerBitWidth else { + throw IfConfigError.unhandledFunction(name: fnName, syntax: ExprSyntax(call)) + } + + result = targetPointerBitWidth == expectedPointerBitWidth + + default: + // FIXME: Deal with all of the other kinds of checks we can perform. + result = nil + break + } + + // If we found a result, return it. + if let result { return result } + + // Otherwise, fall through to diagnose the error + } + + throw IfConfigError.unknownExpression(condition) +} + +extension IfConfigState { + /// Evaluate the given `#if` condition using the given build configuration, throwing an error if there is + /// insufficient information to make a determination. + public init(condition: some ExprSyntaxProtocol, configuration: some BuildConfiguration) throws { + // Apply operator folding for !/&&/||. + let foldedCondition = try OperatorTable.logicalOperators.foldAll(condition).as(ExprSyntax.self)! + + let result = try evaluateIfConfig(condition: foldedCondition, configuration: configuration) + self = result ? .active : .inactive + } +} diff --git a/Sources/SwiftIfConfig/IfConfigFunctions.swift b/Sources/SwiftIfConfig/IfConfigFunctions.swift new file mode 100644 index 00000000000..21056775a3f --- /dev/null +++ b/Sources/SwiftIfConfig/IfConfigFunctions.swift @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Enum capturing all of the functions that can be used in an `#if` condition. +enum IfConfigFunctions: String { + case hasAttribute + case hasFeature + case _compiler_version + case swift + case compiler + case canImport + case os + case arch + case _endian + case _pointerBitWidth + case _runtime + case targetEnvironment + case _ptrauth +} diff --git a/Sources/SwiftIfConfig/IfConfigState.swift b/Sources/SwiftIfConfig/IfConfigState.swift new file mode 100644 index 00000000000..5cfd535ce63 --- /dev/null +++ b/Sources/SwiftIfConfig/IfConfigState.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Describes the state of a particular region guarded by `#if` or similar. +public enum IfConfigState { + /// The region is not parsed, and may contain syntax that is invalid. + case unparsed + /// The region is parsed but is not part of the compiled program. + case inactive + /// The region is active and is part of the compiled program. + case active +} diff --git a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift new file mode 100644 index 00000000000..dd7ba78983e --- /dev/null +++ b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift @@ -0,0 +1,48 @@ + //===----------------------------------------------------------------------===// + // + // This source file is part of the Swift.org open source project + // + // Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors + // Licensed under Apache License v2.0 with Runtime Library Exception + // + // See https://swift.org/LICENSE.txt for license information + // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors + // + //===----------------------------------------------------------------------===// + import SwiftSyntax + +extension BooleanLiteralExprSyntax { + var literalValue: Bool { + return booleanLiteral.tokenKind == .keyword(.true) + } +} + +extension TupleExprSyntax { + /// Whether this tuple is a parenthesized expression, e.g., (x). + var isParentheses: Bool { + guard elements.count == 1, let element = elements.first else { return false } + return element.label == nil + } +} + +extension TupleExprElementListSyntax { + /// If this list is a single, unlabeled expression, return it. + var singleUnlabeledExpression: ExprSyntax? { + guard count == 1, let element = first else { return nil } + return element.expression + } +} + +extension ExprSyntax { + /// Whether this is a simple identifier expression and, if so, what the identifier string is. + var simpleIdentifierExpr: String? { + guard let identExpr = self.as(IdentifierExprSyntax.self), + identExpr.declNameArguments == nil + else { + return nil + } + + // FIXME: Handle escaping here. + return identExpr.identifier.text + } +} diff --git a/Tests/SwiftIfConfigTest/EvaluateTests.swift b/Tests/SwiftIfConfigTest/EvaluateTests.swift new file mode 100644 index 00000000000..e97df8509eb --- /dev/null +++ b/Tests/SwiftIfConfigTest/EvaluateTests.swift @@ -0,0 +1,155 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import XCTest +import SwiftSyntax +import SwiftParser +import SwiftIfConfig +import _SwiftSyntaxTestSupport + +struct TestingBuildConfiguration : BuildConfiguration { + var customConditions: Set = [] + var features: Set = [] + var attributes: Set = [] + + func isCustomConditionSet(name: String, syntax: TokenSyntax) -> Bool? { + customConditions.contains(name) + } + + func hasFeature(name: String, syntax: ExprSyntax) -> Bool? { + features.contains(name) + } + + func hasAttribute(name: String, syntax: ExprSyntax) -> Bool? { + attributes.contains(name) + } + + func isActiveTargetOS(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + name == "Linux" + } + + func isActiveTargetArchitecture(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + name == "arm64" + } + + func isActiveTargetEnvironment(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + name == "simulator" + } + + func isActiveTargetRuntime(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + name == "_Native" + } + + func isActiveTargetPointerAuthentication(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + name == "arm64e" + } + + var targetPointerBitWidth: Int? { 64 } + + var endianness: SwiftIfConfig.Endianness? { .little } +} + +public class EvaluateTests: XCTestCase { + func testLiterals() throws { + let buildConfig = TestingBuildConfiguration(customConditions: ["DEBUG", "ASSERTS"]) + + func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { + try IfConfigState(condition: condition, configuration: buildConfig) + } + + XCTAssertEqual(try ifConfigState("true"), .active) + XCTAssertEqual(try ifConfigState("false"), .inactive) + + // FIXME: How can we produce warnings from this code? + XCTAssertEqual(try ifConfigState("1"), .active) + XCTAssertEqual(try ifConfigState("0"), .inactive) + } + + func testCustomConfigs() throws { + let buildConfig = TestingBuildConfiguration(customConditions: ["DEBUG", "ASSERTS"]) + + func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { + try IfConfigState(condition: condition, configuration: buildConfig) + } + + XCTAssertEqual(try ifConfigState("DEBUG"), .active) + XCTAssertEqual(try ifConfigState("NODEBUG"), .inactive) + XCTAssertEqual(try ifConfigState("!DEBUG"), .inactive) + XCTAssertEqual(try ifConfigState("!NODEBUG"), .active) + XCTAssertEqual(try ifConfigState("DEBUG && ASSERTS"), .active) + XCTAssertEqual(try ifConfigState("DEBUG && nope"), .inactive) + XCTAssertEqual(try ifConfigState("nope && DEBUG"), .inactive) + XCTAssertEqual(try ifConfigState("nope && 3.14159"), .inactive) + XCTAssertEqual(try ifConfigState("DEBUG || ASSERTS"), .active) + XCTAssertEqual(try ifConfigState("DEBUG || nope"), .active) + XCTAssertEqual(try ifConfigState("nope || DEBUG"), .active) + XCTAssertEqual(try ifConfigState("nope || !DEBUG"), .inactive) + XCTAssertEqual(try ifConfigState("DEBUG || 3.14159"), .active) + XCTAssertEqual(try ifConfigState("(DEBUG) || 3.14159"), .active) + } + + func testBadExpressions() throws { + let buildConfig = TestingBuildConfiguration(customConditions: ["DEBUG", "ASSERTS"]) + + func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { + try IfConfigState(condition: condition, configuration: buildConfig) + } + + XCTAssertThrowsError(try ifConfigState("3.14159")) { error in + XCTAssertEqual(String(describing: error), "invalid conditional compilation expression") + } + } + + func testFeatures() throws { + let buildConfig = TestingBuildConfiguration(features: ["ParameterPacks"]) + + func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { + try IfConfigState(condition: condition, configuration: buildConfig) + } + + XCTAssertEqual(try ifConfigState("hasFeature(ParameterPacks)"), .active) + XCTAssertEqual(try ifConfigState("hasFeature(HigherKindedGenerics)"), .inactive) + } + + func testAttributes() throws { + let buildConfig = TestingBuildConfiguration(attributes: ["available"]) + + func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { + try IfConfigState(condition: condition, configuration: buildConfig) + } + + XCTAssertEqual(try ifConfigState("hasAttribute(available)"), .active) + XCTAssertEqual(try ifConfigState("hasAttribute(unsafeUnavailable)"), .inactive) + } + + func testPlatform() throws { + let buildConfig = TestingBuildConfiguration() + + func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { + try IfConfigState(condition: condition, configuration: buildConfig) + } + + XCTAssertEqual(try ifConfigState("os(Linux)"), .active) + XCTAssertEqual(try ifConfigState("os(BeOS)"), .inactive) + XCTAssertEqual(try ifConfigState("arch(arm64)"), .active) + XCTAssertEqual(try ifConfigState("arch(x86_64)"), .inactive) + XCTAssertEqual(try ifConfigState("targetEnvironment(simulator)"), .active) + XCTAssertEqual(try ifConfigState("targetEnvironment(blargh)"), .inactive) + XCTAssertEqual(try ifConfigState("_endian(little)"), .active) + XCTAssertEqual(try ifConfigState("_endian(big)"), .inactive) + XCTAssertEqual(try ifConfigState("_runtime(_Native)"), .active) + XCTAssertEqual(try ifConfigState("_runtime(_ObjC)"), .inactive) + XCTAssertEqual(try ifConfigState("_ptrauth(arm64e)"), .active) + XCTAssertEqual(try ifConfigState("_ptrauth(none)"), .inactive) + XCTAssertEqual(try ifConfigState("_pointerBitWidth(_64)"), .active) + XCTAssertEqual(try ifConfigState("_pointerBitWidth(_32)"), .inactive) + } +} From 719e0dadbd6adfda744368903223a0b34d0dd0ac Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Mon, 5 Jun 2023 04:55:53 -0700 Subject: [PATCH 02/42] Add higher-level APIs for querying active code state `IfConfigDeclSyntax.activeClause(in:)` determines which clause is active within an `#if` syntax node. `SyntaxProtocol.isActive(in:)` determines whether a given syntax node is active in the program, based on the nested stack of `#if` configurations. --- .../SwiftIfConfig/IfConfigEvaluation.swift | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index 356818df043..f0c88237664 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -191,3 +191,83 @@ extension IfConfigState { self = result ? .active : .inactive } } + +extension IfConfigDeclSyntax { + /// Given a particular build configuration, determine which clause (if any) is the "active" clause. + /// + /// For example, for code like the following: + /// ``` + /// #if A + /// func f() + /// #elseif B + /// func g() + /// #endif + /// ``` + /// + /// If the `A` configuration option was passed on the command line (e.g. via `-DA`), the first clause + /// (containing `func f()`) would be returned. If not, and if the `B`configuration was passed on the + /// command line, the second clause (containing `func g()`) would be returned. If neither was + /// passed, this function will return `nil` to indicate that none of the regions are active. + /// + /// If an error occurred while processing any of the `#if` clauses, this function will throw that error. + public func activeClause(in configuration: some BuildConfiguration) throws -> IfConfigClauseSyntax? { + for clause in clauses { + // If there is no condition, we have reached an unconditional clause. Return it. + guard let condition = clause.condition else { + return clause + } + + // If this condition evaluates true, return this clause. + if try evaluateIfConfig(condition: condition, configuration: configuration) { + return clause + } + } + + return nil + } +} + +extension SyntaxProtocol { + /// Determine whether the given syntax node is active within the given build configuration. + /// + /// This function evaluates the enclosing stack of `#if` conditions to determine whether the + /// given node is active in the program when it is compiled with the given build configuration. + /// + /// For example, given code like the following: + /// #if DEBUG + /// #if A + /// func f() + /// #elseif B + /// func g() + /// #endif + /// #endif + /// + /// a call to `isActive` on the syntax node for the function `g` would return `true` when the + /// configuration options `DEBUG` and `B` are provided, but `A` is not. + public func isActive(in configuration: some BuildConfiguration) throws -> Bool { + var currentNode: Syntax = Syntax(self) + var currentClause = currentNode.as(IfConfigClauseSyntax.self) + + while let parent = currentNode.parent { + // If the parent is an `#if` configuration, check whether our current + // clause is active. If not, we're in an inactive region. + if let parentIfConfig = parent.as(IfConfigDeclSyntax.self) { + if try currentClause != nil && parentIfConfig.activeClause(in: configuration) != currentClause { + return false + } + + currentClause = nil + } + + // If the parent node is an if configuration clause, store it. + if let parentClause = parent.as(IfConfigClauseSyntax.self) { + currentClause = parentClause + } + + currentNode = parent + } + + // No more enclosing nodes; this code is active. + return true + } +} From cb7123f2d228810f954e043a7e8201f693bdc643 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Mon, 12 Jun 2023 23:43:49 -0700 Subject: [PATCH 03/42] Add support for evaluating `swift` and `compiler` conditionals These were introduced by SE-0212. --- .../SwiftIfConfig/BuildConfiguration.swift | 6 ++ .../SwiftIfConfig/IfConfigEvaluation.swift | 42 ++++++++++++++ Sources/SwiftIfConfig/VersionTuple.swift | 58 +++++++++++++++++++ Tests/SwiftIfConfigTest/EvaluateTests.swift | 19 ++++++ 4 files changed, 125 insertions(+) create mode 100644 Sources/SwiftIfConfig/VersionTuple.swift diff --git a/Sources/SwiftIfConfig/BuildConfiguration.swift b/Sources/SwiftIfConfig/BuildConfiguration.swift index d83e537a750..8657b1e9966 100644 --- a/Sources/SwiftIfConfig/BuildConfiguration.swift +++ b/Sources/SwiftIfConfig/BuildConfiguration.swift @@ -66,4 +66,10 @@ public protocol BuildConfiguration { /// The endianness of the target architecture. var endianness: Endianness? { get } + + /// The effective language version, which can be set by the user (e.g., 5.0). + var languageVersion: VersionTuple? { get } + + /// The version of the compiler (e.g., 5.9). + var compilerVersion: VersionTuple? { get } } diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index f0c88237664..c11f264fca2 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -16,6 +16,8 @@ enum IfConfigError: Error, CustomStringConvertible { case unknownExpression(ExprSyntax) case unhandledCustomCondition(name: String, syntax: TokenSyntax) case unhandledFunction(name: String, syntax: ExprSyntax) + case unsupportedVersionOperator(name: String, operator: TokenSyntax) + case invalidVersionOperand(name: String, syntax: ExprSyntax) var description: String { switch self { @@ -27,6 +29,12 @@ enum IfConfigError: Error, CustomStringConvertible { case .unhandledFunction(name: let name, syntax: _): return "build configuration cannot handle '\(name)'" + + case .unsupportedVersionOperator(name: let name, operator: let op): + return "'\(name)' version check does not support operator '\(op.trimmedDescription)'" + + case .invalidVersionOperand(name: let name, syntax: let version): + return "'\(name)' version check has invalid version '\(version.trimmedDescription)'" } } } @@ -106,6 +114,34 @@ private func evaluateIfConfig( return result } + /// Perform a check for a version constraint as used in the "swift" or "compiler" version checks. + func doVersionComparisonCheck(_ actualVersion: VersionTuple?) throws -> Bool? { + // Ensure that we have a single unlabeled argument that is either >= or < as a prefix + // operator applied to a version. + guard let argExpr = call.argumentList.singleUnlabeledExpression, + let unaryArg = argExpr.as(PrefixOperatorExprSyntax.self), + let opToken = unaryArg.operatorToken else { + return nil + } + + guard let version = VersionTuple(parsing: unaryArg.postfixExpression.trimmedDescription) else { + throw IfConfigError.invalidVersionOperand(name: fnName, syntax: unaryArg.postfixExpression) + } + + guard let actualVersion else { + throw IfConfigError.unhandledFunction(name: fnName, syntax: argExpr) + } + + switch opToken.text { + case ">=": + return actualVersion >= version + case "<": + return actualVersion < version + default: + throw IfConfigError.unsupportedVersionOperator(name: fnName, operator: opToken) + } + } + let result: Bool? switch fn { case .hasAttribute: @@ -165,6 +201,12 @@ private func evaluateIfConfig( result = targetPointerBitWidth == expectedPointerBitWidth + case .swift: + result = try doVersionComparisonCheck(configuration.languageVersion) + + case .compiler: + result = try doVersionComparisonCheck(configuration.compilerVersion) + default: // FIXME: Deal with all of the other kinds of checks we can perform. result = nil diff --git a/Sources/SwiftIfConfig/VersionTuple.swift b/Sources/SwiftIfConfig/VersionTuple.swift new file mode 100644 index 00000000000..6fe80ad7072 --- /dev/null +++ b/Sources/SwiftIfConfig/VersionTuple.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Describes a version such as `5.9`. +public struct VersionTuple { + /// The components of the version tuple, start with the major version. + public var components: [Int] +} + +extension VersionTuple { + /// Create a version tuple from its components. + public init(_ components: Int...) { + self.components = components + } + + /// Parse a string into a version tuple, returning `nil` if any errors were + /// present. + public init?(parsing string: String) { + self.components = [] + + for componentText in string.split(separator: ".") { + guard let component = Int(componentText) else { + return nil + } + + components.append(component) + } + } +} + +extension VersionTuple { + /// Normalize the version tuple by removing trailing zeroes. + var normalized: VersionTuple { + var newComponents = components + while newComponents.count > 1 && newComponents.last == 0 { + newComponents.removeLast() + } + + return VersionTuple(components: newComponents) + } +} + +extension VersionTuple: Equatable, Hashable { } + +extension VersionTuple: Comparable { + public static func <(lhs: VersionTuple, rhs: VersionTuple) -> Bool { + return lhs.normalized.components.lexicographicallyPrecedes(rhs.normalized.components) + } +} diff --git a/Tests/SwiftIfConfigTest/EvaluateTests.swift b/Tests/SwiftIfConfigTest/EvaluateTests.swift index e97df8509eb..b4e7871e019 100644 --- a/Tests/SwiftIfConfigTest/EvaluateTests.swift +++ b/Tests/SwiftIfConfigTest/EvaluateTests.swift @@ -55,6 +55,10 @@ struct TestingBuildConfiguration : BuildConfiguration { var targetPointerBitWidth: Int? { 64 } var endianness: SwiftIfConfig.Endianness? { .little } + + var languageVersion: VersionTuple? { VersionTuple(5, 5) } + + var compilerVersion: VersionTuple? { VersionTuple(5, 9) } } public class EvaluateTests: XCTestCase { @@ -152,4 +156,19 @@ public class EvaluateTests: XCTestCase { XCTAssertEqual(try ifConfigState("_pointerBitWidth(_64)"), .active) XCTAssertEqual(try ifConfigState("_pointerBitWidth(_32)"), .inactive) } + + func testVersions() throws { + let buildConfig = TestingBuildConfiguration() + + func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { + try IfConfigState(condition: condition, configuration: buildConfig) + } + + XCTAssertEqual(try ifConfigState("swift(>=5.5"), .active) + XCTAssertEqual(try ifConfigState("swift(<6"), .active) + XCTAssertEqual(try ifConfigState("swift(>=6"), .inactive) + XCTAssertEqual(try ifConfigState("compiler(>=5.8"), .active) + XCTAssertEqual(try ifConfigState("compiler(>=5.9"), .active) + XCTAssertEqual(try ifConfigState("compiler(>=5.10"), .inactive) + } } From 979ebc08c4924cdf969e0fde1786c4768caa8d0c Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Sat, 17 Jun 2023 23:25:09 -0700 Subject: [PATCH 04/42] Implement support for archaic `_compiler_version("X.Y.Z.W.V")` check --- .../SwiftIfConfig/BuildConfiguration.swift | 2 +- .../SwiftIfConfig/IfConfigEvaluation.swift | 131 +++++++++++++++++- Tests/SwiftIfConfigTest/EvaluateTests.swift | 7 +- 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftIfConfig/BuildConfiguration.swift b/Sources/SwiftIfConfig/BuildConfiguration.swift index 8657b1e9966..ecfe7400120 100644 --- a/Sources/SwiftIfConfig/BuildConfiguration.swift +++ b/Sources/SwiftIfConfig/BuildConfiguration.swift @@ -33,7 +33,7 @@ public protocol BuildConfiguration { /// Custom build conditions can be set by the `-D` command line option to /// the Swift compiler. For example, `-DDEBUG` sets the custom condition /// named `DEBUG`. - func isCustomConditionSet(name: String, syntax: TokenSyntax) -> Bool? + func isCustomConditionSet(name: String, syntax: ExprSyntax) -> Bool? /// Determine whether the given feature is enabled. /// diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index c11f264fca2..43df33b6667 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -18,6 +18,10 @@ enum IfConfigError: Error, CustomStringConvertible { case unhandledFunction(name: String, syntax: ExprSyntax) case unsupportedVersionOperator(name: String, operator: TokenSyntax) case invalidVersionOperand(name: String, syntax: ExprSyntax) + case emptyVersionComponent(syntax: ExprSyntax) + case compilerVersionOutOfRange(value: Int, upperLimit: Int, syntax: ExprSyntax) + case compilerVersionSecondComponentNotWildcard(syntax: ExprSyntax) + case compilerVersionTooManyComponents(syntax: ExprSyntax) var description: String { switch self { @@ -35,10 +39,114 @@ enum IfConfigError: Error, CustomStringConvertible { case .invalidVersionOperand(name: let name, syntax: let version): return "'\(name)' version check has invalid version '\(version.trimmedDescription)'" + + case .emptyVersionComponent(syntax: _): + return "found empty version component" + + case .compilerVersionOutOfRange(value: _, upperLimit: let upperLimit, syntax: _): + // FIXME: This matches the C++ implementation, but it would be more useful to + // provide the actual value as-written and avoid the mathy [0, N] syntax. + return "compiler version component out of range: must be in [0, \(upperLimit)]" + + case .compilerVersionSecondComponentNotWildcard(syntax: _): + return "the second version component is not used for comparison in legacy compiler versions" + + case .compilerVersionTooManyComponents(syntax: _): + return "compiler version must not have more than five components" } } } +extension VersionTuple { + /// Parse a compiler build version of the form "5007.*.1.2.3*", which is + /// used by an older if configuration form `_compiler_version("...")`. + fileprivate init( + parsingCompilerBuildVersion versionString: String, + _ syntax: ExprSyntax + ) throws { + components = [] + + // Version value are separated by periods. + let componentStrings = versionString.split(separator: ".") + + /// Record a component after checking its value. + func recordComponent(_ value: Int) throws { + let limit = components.isEmpty ? 9223371 : 999 + if value < 0 || value > limit { + // FIXME: Can we provide a more precise location here? + throw IfConfigError.compilerVersionOutOfRange(value: value, upperLimit: limit, syntax: syntax) + } + + components.append(value) + } + + // Parse the components into version values. + for (index, componentString) in componentStrings.enumerated() { + // Check ahead of time for empty version components + if componentString.isEmpty { + throw IfConfigError.emptyVersionComponent(syntax: syntax) + } + + // The second component is always "*", and is never used for comparison. + if index == 1 { + if componentString != "*" { + throw IfConfigError.compilerVersionSecondComponentNotWildcard(syntax: syntax) + } + try recordComponent(0) + continue + } + + // Every other component must be an integer value. + guard let component = Int(componentString) else { + throw IfConfigError.invalidVersionOperand(name: "_compiler_version", syntax: syntax) + } + + try recordComponent(component) + } + + // Only allowed to specify up to 5 version components. + if components.count > 5 { + throw IfConfigError.compilerVersionTooManyComponents(syntax: syntax) + } + + // In the beginning, '_compiler_version(string-literal)' was designed for a + // different version scheme where the major was fairly large and the minor + // was ignored; now we use one where the minor is significant and major and + // minor match the Swift language version. Specifically, majors 600-1300 + // were used for Swift 1.0-5.5 (based on clang versions), but then we reset + // the numbering based on Swift versions, so 5.6 had major 5. We assume + // that majors below 600 use the new scheme and equal/above it use the old + // scheme. + // + // However, we want the string literal variant of '_compiler_version' to + // maintain source compatibility with old checks; that means checks for new + // versions have to be written so that old compilers will think they represent + // newer versions, while new compilers have to interpret old version number + // strings in a way that will compare correctly to the new versions compiled + // into them. + // + // To achieve this, modern compilers divide the major by 1000 and overwrite + // the wildcard component with the remainder, effectively shifting the last + // three digits of the major into the minor, before comparing it to the + // compiler version: + // + // _compiler_version("5007.*.1.2.3") -> 5.7.1.2.3 + // _compiler_version("1300.*.1.2.3") -> 1.300.1.2.3 (smaller than 5.6) + // _compiler_version( "600.*.1.2.3") -> 0.600.1.2.3 (smaller than 5.6) + // + // So if you want to specify a 5.7.z.a.b version, we ask users to either + // write it as 5007.*.z.a.b, or to use the new 'compiler(>= version)' + // syntax instead, which does not perform this conversion. + if !components.isEmpty { + if components.count > 1 { + components[1] = components[0] % 1000 + } + components[0] = components[0] / 1000 + } + } +} + + /// Evaluate the condition of an `#if`. private func evaluateIfConfig( condition: ExprSyntax, @@ -60,7 +168,7 @@ private func evaluateIfConfig( let ident = identExpr.identifier.text // Evaluate the custom condition. If the build configuration cannot answer this query, fail. - guard let result = configuration.isCustomConditionSet(name: ident, syntax: identExpr.identifier) else { + guard let result = configuration.isCustomConditionSet(name: ident, syntax: ExprSyntax(identExpr)) else { throw IfConfigError.unhandledCustomCondition(name: ident, syntax: identExpr.identifier) } return result @@ -207,6 +315,27 @@ private func evaluateIfConfig( case .compiler: result = try doVersionComparisonCheck(configuration.compilerVersion) + case ._compiler_version: + // Argument is a single unlabeled argument containing a string + // literal. + guard let argExpr = call.argumentList.singleUnlabeledExpression, + let stringLiteral = argExpr.as(StringLiteralExprSyntax.self), + stringLiteral.segments.count == 1, + let segment = stringLiteral.segments.first, + case .stringSegment(let stringSegment) = segment else { + // FIXME: better diagnostic here + throw IfConfigError.unknownExpression(condition) + } + + let versionString = stringSegment.content.text + let expectedVersion = try VersionTuple(parsingCompilerBuildVersion: versionString, argExpr) + + guard let actualVersion = configuration.compilerVersion else { + throw IfConfigError.unhandledFunction(name: fnName, syntax: argExpr) + } + + return actualVersion >= expectedVersion + default: // FIXME: Deal with all of the other kinds of checks we can perform. result = nil diff --git a/Tests/SwiftIfConfigTest/EvaluateTests.swift b/Tests/SwiftIfConfigTest/EvaluateTests.swift index b4e7871e019..e0fd44c9232 100644 --- a/Tests/SwiftIfConfigTest/EvaluateTests.swift +++ b/Tests/SwiftIfConfigTest/EvaluateTests.swift @@ -20,7 +20,7 @@ struct TestingBuildConfiguration : BuildConfiguration { var features: Set = [] var attributes: Set = [] - func isCustomConditionSet(name: String, syntax: TokenSyntax) -> Bool? { + func isCustomConditionSet(name: String, syntax: ExprSyntax) -> Bool? { customConditions.contains(name) } @@ -58,7 +58,7 @@ struct TestingBuildConfiguration : BuildConfiguration { var languageVersion: VersionTuple? { VersionTuple(5, 5) } - var compilerVersion: VersionTuple? { VersionTuple(5, 9) } + var compilerVersion: VersionTuple? { VersionTuple(5, 9, 1) } } public class EvaluateTests: XCTestCase { @@ -170,5 +170,8 @@ public class EvaluateTests: XCTestCase { XCTAssertEqual(try ifConfigState("compiler(>=5.8"), .active) XCTAssertEqual(try ifConfigState("compiler(>=5.9"), .active) XCTAssertEqual(try ifConfigState("compiler(>=5.10"), .inactive) + XCTAssertEqual(try ifConfigState(#"_compiler_version("5009.*.1")"#), .active) + XCTAssertEqual(try ifConfigState(#"_compiler_version("5009.*.3.2.3")"#), .inactive) + XCTAssertEqual(try ifConfigState(#"_compiler_version("5010.*.0")"#), .inactive) } } From 4987cbcbf09e9ec18a38a57777c4334d188fbdce Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Sat, 17 Jun 2023 23:55:27 -0700 Subject: [PATCH 05/42] Add support for `canImport` configuration checks. This is the last kind of check! Remove the `default` fallthrough from the main evaluation function. --- .../SwiftIfConfig/BuildConfiguration.swift | 17 +++++ .../SwiftIfConfig/IfConfigEvaluation.swift | 70 +++++++++++++++++-- .../SwiftIfConfig/SyntaxLiteralUtils.swift | 24 +++---- Tests/SwiftIfConfigTest/EvaluateTests.swift | 41 +++++++++++ 4 files changed, 136 insertions(+), 16 deletions(-) diff --git a/Sources/SwiftIfConfig/BuildConfiguration.swift b/Sources/SwiftIfConfig/BuildConfiguration.swift index ecfe7400120..83cd77ff6ef 100644 --- a/Sources/SwiftIfConfig/BuildConfiguration.swift +++ b/Sources/SwiftIfConfig/BuildConfiguration.swift @@ -16,6 +16,19 @@ public enum Endianness: String { case big } +/// Describes the requested version of a module +public enum CanImportVersion { + /// Any version of the module will suffice. + case unversioned + + /// Only modules with the given version or higher will match. + case version(VersionTuple) + + /// Only modules where the underlying Clang module has the given version or + /// higher will match. + case underlyingVersion(VersionTuple) +} + /// Captures information about the build configuration that can be /// queried in a `#if` expression, including OS, compiler version, /// enabled language features, and available modules. @@ -46,6 +59,10 @@ public protocol BuildConfiguration { /// Attributes are determined by the Swift compiler. func hasAttribute(name: String, syntax: ExprSyntax) -> Bool? + /// Determine whether a module with the given import path can be imported, + /// with additional version information. + func canImport(importPath: [String], version: CanImportVersion, syntax: ExprSyntax) -> Bool? + /// Determine whether the given name is the active target OS (e.g., Linux, iOS). func isActiveTargetOS(name: String, syntax: ExprSyntax) -> Bool? diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index 43df33b6667..8f6765db791 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -22,6 +22,9 @@ enum IfConfigError: Error, CustomStringConvertible { case compilerVersionOutOfRange(value: Int, upperLimit: Int, syntax: ExprSyntax) case compilerVersionSecondComponentNotWildcard(syntax: ExprSyntax) case compilerVersionTooManyComponents(syntax: ExprSyntax) + case canImportMissingModule(syntax: ExprSyntax) + case canImportLabel(syntax: ExprSyntax) + case canImportTwoParameters(syntax: ExprSyntax) var description: String { switch self { @@ -53,6 +56,15 @@ enum IfConfigError: Error, CustomStringConvertible { case .compilerVersionTooManyComponents(syntax: _): return "compiler version must not have more than five components" + + case .canImportMissingModule(syntax: _): + return "canImport requires a module name" + + case .canImportLabel(syntax: _): + return "2nd parameter of canImport should be labeled as _version or _underlyingVersion" + + case .canImportTwoParameters(syntax: _): + return "canImport can take only two parameters" } } } @@ -336,10 +348,60 @@ private func evaluateIfConfig( return actualVersion >= expectedVersion - default: - // FIXME: Deal with all of the other kinds of checks we can perform. - result = nil - break + case .canImport: + // Retrieve the first argument, which must not have a label. This is + // the module import path. + guard let firstArg = call.argumentList.first, + firstArg.label == nil else { + throw IfConfigError.canImportMissingModule(syntax: ExprSyntax(call)) + } + + let importPath = firstArg.expression.trimmedDescription.split(separator: ".") + // FIXME: Check to make sure we have all identifiers here. + + // If there is a second argument, it shall have the label _version or + // _underlyingVersion. + let version: CanImportVersion + if let secondArg = call.argumentList.dropFirst().first { + if secondArg.label?.text != "_version" && + secondArg.label?.text != "_underlyingVersion" { + throw IfConfigError.canImportLabel(syntax: secondArg.expression) + } + + let versionText: String + if let stringLiteral = secondArg.expression.as(StringLiteralExprSyntax.self), + stringLiteral.segments.count == 1, + let firstSegment = stringLiteral.segments.first, + case .stringSegment(let stringSegment) = firstSegment { + versionText = stringSegment.content.text + } else { + versionText = secondArg.expression.trimmedDescription + } + + guard let versionTuple = VersionTuple(parsing: versionText) else { + throw IfConfigError.invalidVersionOperand(name: "canImport", syntax: secondArg.expression) + } + + // FIXME: Warning that the version can only have at most 4 components. + + if secondArg.label?.text == "_version" { + version = .version(versionTuple) + } else { + assert(secondArg.label?.text == "_underlyingVersion") + version = .underlyingVersion(versionTuple) + } + + if call.argumentList.count > 2 { + throw IfConfigError.canImportTwoParameters(syntax: ExprSyntax(call)) + } + } else { + version = .unversioned + } + + result = configuration.canImport( + importPath: importPath.map { String($0) }, + version: version, syntax: ExprSyntax(call) + ) } // If we found a result, return it. diff --git a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift index dd7ba78983e..dda370a50ed 100644 --- a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift +++ b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift @@ -1,15 +1,15 @@ - //===----------------------------------------------------------------------===// - // - // This source file is part of the Swift.org open source project - // - // Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors - // Licensed under Apache License v2.0 with Runtime Library Exception - // - // See https://swift.org/LICENSE.txt for license information - // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors - // - //===----------------------------------------------------------------------===// - import SwiftSyntax +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import SwiftSyntax extension BooleanLiteralExprSyntax { var literalValue: Bool { diff --git a/Tests/SwiftIfConfigTest/EvaluateTests.swift b/Tests/SwiftIfConfigTest/EvaluateTests.swift index e0fd44c9232..72a8238065a 100644 --- a/Tests/SwiftIfConfigTest/EvaluateTests.swift +++ b/Tests/SwiftIfConfigTest/EvaluateTests.swift @@ -32,6 +32,29 @@ struct TestingBuildConfiguration : BuildConfiguration { attributes.contains(name) } + func canImport( + importPath: [String], + version: CanImportVersion, + syntax: ExprSyntax + ) -> Bool? { + guard let moduleName = importPath.first else { + return false + } + + guard moduleName == "SwiftSyntax" else { return false } + + switch version { + case .unversioned: + return true + + case .version(let expectedVersion): + return expectedVersion <= VersionTuple(5, 9, 2) + + case .underlyingVersion(let expectedVersion): + return expectedVersion <= VersionTuple(5009, 2) + } + } + func isActiveTargetOS(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { name == "Linux" } @@ -174,4 +197,22 @@ public class EvaluateTests: XCTestCase { XCTAssertEqual(try ifConfigState(#"_compiler_version("5009.*.3.2.3")"#), .inactive) XCTAssertEqual(try ifConfigState(#"_compiler_version("5010.*.0")"#), .inactive) } + + func testCanImport() throws { + let buildConfig = TestingBuildConfiguration() + + func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { + try IfConfigState(condition: condition, configuration: buildConfig) + } + + XCTAssertEqual(try ifConfigState("canImport(SwiftSyntax)"), .active) + XCTAssertEqual(try ifConfigState("canImport(SwiftSyntax.Sub)"), .active) + XCTAssertEqual(try ifConfigState("canImport(SwiftParser)"), .inactive) + XCTAssertEqual(try ifConfigState("canImport(SwiftSyntax, _version: 5.9)"), .active) + XCTAssertEqual(try ifConfigState("canImport(SwiftSyntax, _version: 5.10)"), .inactive) + XCTAssertEqual(try ifConfigState(#"canImport(SwiftSyntax, _version: "5.9")"#), .active) + XCTAssertEqual(try ifConfigState("canImport(SwiftSyntax, _underlyingVersion: 5009)"), .active) + XCTAssertEqual(try ifConfigState("canImport(SwiftSyntax, _underlyingVersion: 5009.10)"), .inactive) + + } } From 89c1f6fb114955db349f36ba40c2b981bc832b9d Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Mon, 19 Jun 2023 10:55:02 -0700 Subject: [PATCH 06/42] Improve documentation for `#if` configuration functions --- Sources/SwiftIfConfig/IfConfigFunctions.swift | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigFunctions.swift b/Sources/SwiftIfConfig/IfConfigFunctions.swift index 21056775a3f..95cf6cdc0f1 100644 --- a/Sources/SwiftIfConfig/IfConfigFunctions.swift +++ b/Sources/SwiftIfConfig/IfConfigFunctions.swift @@ -12,17 +12,42 @@ /// Enum capturing all of the functions that can be used in an `#if` condition. enum IfConfigFunctions: String { + /// A check for a specific attribute. case hasAttribute + + /// A check for a specific named feature. case hasFeature - case _compiler_version + + /// A check for the Swift language version. case swift + + /// A check for the Swift compiler version. case compiler + + /// A check to determine whether a given module can be imported. case canImport + + /// A check for the target Operating System kind (e.g., Linux, iOS). case os + + /// A check for the target architecture (e.g., arm64, x86_64). case arch + + /// A check for the target environment (e.g., simulator). + case targetEnvironment + + /// A historical check against a specific compiler build version. + case _compiler_version + + /// A check for the target endianness (e.g., big or little) case _endian + + /// A check for the target bit width of a pointer (e.g., _64) case _pointerBitWidth + + /// A check for the target runtime paired with the Swift runtime (e.g., _ObjC). case _runtime - case targetEnvironment + + /// A check for the target's pointer authentication scheme (e.g., _arm64e). case _ptrauth } From 59720656f5ef512106df8d63ec7f43e4f9b2dfc1 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Mon, 19 Jun 2023 11:52:10 -0700 Subject: [PATCH 07/42] Add `ActiveSyntax(Any)Visitor` visitor classes. The `ActiveSyntax(Any)Visitor` visitor classes provide visitors that only visit the regions of a syntax tree that are active according to a particular build configuration, meaning that those nodes would be included in a program that is built with that configuration. --- .../SwiftIfConfig/IfConfigEvaluation.swift | 2 +- Sources/SwiftIfConfig/IfConfigVisitor.swift | 112 +++++++++++++++ Tests/SwiftIfConfigTest/EvaluateTests.swift | 69 ---------- .../TestingBuildConfiguration.swift | 83 +++++++++++ Tests/SwiftIfConfigTest/VisitorTests.swift | 130 ++++++++++++++++++ 5 files changed, 326 insertions(+), 70 deletions(-) create mode 100644 Sources/SwiftIfConfig/IfConfigVisitor.swift create mode 100644 Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift create mode 100644 Tests/SwiftIfConfigTest/VisitorTests.swift diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index 8f6765db791..9bd6fc6e329 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -472,7 +472,7 @@ extension SyntaxProtocol { /// func f() /// #elseif B /// func g() - /// #endif + /// #endif /// #endif /// /// a call to `isActive` on the syntax node for the function `g` would return `true` when the diff --git a/Sources/SwiftIfConfig/IfConfigVisitor.swift b/Sources/SwiftIfConfig/IfConfigVisitor.swift new file mode 100644 index 00000000000..b188e33dfe9 --- /dev/null +++ b/Sources/SwiftIfConfig/IfConfigVisitor.swift @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import SwiftSyntax + +/// A syntax visitor that only visits the syntax nodes that are active +/// according to a particular build configuration. +/// +/// This subclass of `SyntaxVisitor` walks all of the syntax nodes in a given +/// tree that are "active" according to a particular build configuration, +/// meaning that the syntax would contribute to the resulting program when +/// when compiled with that configuration. For example, given: +/// +/// ``` +/// #if DEBUG +/// #if os(Linux) +/// func f() +/// #elseif os(iOS) +/// func g() +/// #endif +/// #endif +/// ``` +/// +/// And a build targeting Linux with the custom condition `DEBUG` set, a +/// complete walk via this visitor would visit `func f` but not `func g`. If +/// the build configuration instead targted macOS (but still had `DEBUG` set), +/// it would not visit either `f` or `g`. +/// +/// All notes visited by this visitor will have the "active" state, i.e., +/// `node.isActive(in: configuration)` will evaluate to `.active` or will +/// throw. +/// +/// TBD: This visitor currently swallows errors uncovered while checking `#if` +/// conditions, which is deeply unfortunate. We need a better answer here. +open class ActiveSyntaxVisitor : SyntaxVisitor { + /// The build configuration, which will be queried for each relevant `#if`. + public let configuration: BuildConfiguration + + public init(viewMode: SyntaxTreeViewMode, configuration: BuildConfiguration) { + self.configuration = configuration + super.init(viewMode: viewMode) + } + + open override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { + // If there is an active clause, visit it's children. + // FIXME: try? suppresses errors here. How shall we report them? + if let activeClause = try? node.activeClause(in: configuration), + let elements = activeClause.elements { + walk(Syntax(elements)) + } + + return .skipChildren + } +} + +/// A syntax visitor that only visits the syntax nodes that are active +/// according to a particular build configuration. +/// +/// This subclass of `SyntaxVisitor` walks all of the syntax nodes in a given +/// tree that are "active" according to a particular build configuration, +/// meaning that the syntax would contribute to the resulting program when +/// when compiled with that configuration. For example, given: +/// +/// ``` +/// #if DEBUG +/// #if os(Linux) +/// func f() +/// #elseif os(iOS) +/// func g() +/// #endif +/// #endif +/// ``` +/// +/// And a build targeting Linux with the custom condition `DEBUG` set, a +/// complete walk via this visitor would visit `func f` but not `func g`. If +/// the build configuration instead targted macOS (but still had `DEBUG` set), +/// it would not visit either `f` or `g`. +/// +/// All notes visited by this visitor will have the "active" state, i.e., +/// `node.isActive(in: configuration)` will evaluate to `.active` or will +/// throw. +/// +/// TBD: This visitor currently swallows errors uncovered while checking `#if` +/// conditions, which is deeply unfortunate. We need a better answer here. +open class ActiveSyntaxAnyVisitor : SyntaxAnyVisitor { + /// The build configuration, which will be queried for each relevant `#if`. + public let configuration: BuildConfiguration + + public init(viewMode: SyntaxTreeViewMode, configuration: BuildConfiguration) { + self.configuration = configuration + super.init(viewMode: viewMode) + } + + open override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { + // If there is an active clause, visit it's children. + // FIXME: try? suppresses errors here. How shall we report them? + if let activeClause = try? node.activeClause(in: configuration), + let elements = activeClause.elements { + walk(Syntax(elements)) + } + + return .skipChildren + } +} diff --git a/Tests/SwiftIfConfigTest/EvaluateTests.swift b/Tests/SwiftIfConfigTest/EvaluateTests.swift index 72a8238065a..96fdb97bc8e 100644 --- a/Tests/SwiftIfConfigTest/EvaluateTests.swift +++ b/Tests/SwiftIfConfigTest/EvaluateTests.swift @@ -15,75 +15,6 @@ import SwiftParser import SwiftIfConfig import _SwiftSyntaxTestSupport -struct TestingBuildConfiguration : BuildConfiguration { - var customConditions: Set = [] - var features: Set = [] - var attributes: Set = [] - - func isCustomConditionSet(name: String, syntax: ExprSyntax) -> Bool? { - customConditions.contains(name) - } - - func hasFeature(name: String, syntax: ExprSyntax) -> Bool? { - features.contains(name) - } - - func hasAttribute(name: String, syntax: ExprSyntax) -> Bool? { - attributes.contains(name) - } - - func canImport( - importPath: [String], - version: CanImportVersion, - syntax: ExprSyntax - ) -> Bool? { - guard let moduleName = importPath.first else { - return false - } - - guard moduleName == "SwiftSyntax" else { return false } - - switch version { - case .unversioned: - return true - - case .version(let expectedVersion): - return expectedVersion <= VersionTuple(5, 9, 2) - - case .underlyingVersion(let expectedVersion): - return expectedVersion <= VersionTuple(5009, 2) - } - } - - func isActiveTargetOS(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { - name == "Linux" - } - - func isActiveTargetArchitecture(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { - name == "arm64" - } - - func isActiveTargetEnvironment(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { - name == "simulator" - } - - func isActiveTargetRuntime(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { - name == "_Native" - } - - func isActiveTargetPointerAuthentication(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { - name == "arm64e" - } - - var targetPointerBitWidth: Int? { 64 } - - var endianness: SwiftIfConfig.Endianness? { .little } - - var languageVersion: VersionTuple? { VersionTuple(5, 5) } - - var compilerVersion: VersionTuple? { VersionTuple(5, 9, 1) } -} - public class EvaluateTests: XCTestCase { func testLiterals() throws { let buildConfig = TestingBuildConfiguration(customConditions: ["DEBUG", "ASSERTS"]) diff --git a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift new file mode 100644 index 00000000000..899e5b33be7 --- /dev/null +++ b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import SwiftIfConfig +import SwiftSyntax + +struct TestingBuildConfiguration : BuildConfiguration { + var platformName: String = "Linux" + var customConditions: Set = [] + var features: Set = [] + var attributes: Set = [] + + func isCustomConditionSet(name: String, syntax: ExprSyntax) -> Bool? { + customConditions.contains(name) + } + + func hasFeature(name: String, syntax: ExprSyntax) -> Bool? { + features.contains(name) + } + + func hasAttribute(name: String, syntax: ExprSyntax) -> Bool? { + attributes.contains(name) + } + + func canImport( + importPath: [String], + version: CanImportVersion, + syntax: ExprSyntax + ) -> Bool? { + guard let moduleName = importPath.first else { + return false + } + + guard moduleName == "SwiftSyntax" else { return false } + + switch version { + case .unversioned: + return true + + case .version(let expectedVersion): + return expectedVersion <= VersionTuple(5, 9, 2) + + case .underlyingVersion(let expectedVersion): + return expectedVersion <= VersionTuple(5009, 2) + } + } + + func isActiveTargetOS(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + name == platformName + } + + func isActiveTargetArchitecture(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + name == "arm64" + } + + func isActiveTargetEnvironment(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + name == "simulator" + } + + func isActiveTargetRuntime(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + name == "_Native" + } + + func isActiveTargetPointerAuthentication(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + name == "arm64e" + } + + var targetPointerBitWidth: Int? { 64 } + + var endianness: SwiftIfConfig.Endianness? { .little } + + var languageVersion: VersionTuple? { VersionTuple(5, 5) } + + var compilerVersion: VersionTuple? { VersionTuple(5, 9, 1) } +} diff --git a/Tests/SwiftIfConfigTest/VisitorTests.swift b/Tests/SwiftIfConfigTest/VisitorTests.swift new file mode 100644 index 00000000000..2a1269b2728 --- /dev/null +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -0,0 +1,130 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import XCTest +import SwiftSyntax +import SwiftParser +import SwiftIfConfig +import _SwiftSyntaxTestSupport + +/// Visitor that ensures that all of the nodes we visit are active. +/// +/// This cross-checks the visitor itself with the `SyntaxProtocol.isActive(in:)` +/// API. +class AllActiveVisitor: ActiveSyntaxAnyVisitor { + init(configuration: TestingBuildConfiguration) { + super.init(viewMode: .sourceAccurate, configuration: configuration) + } + open override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { + XCTAssertTrue(try! node.isActive(in: configuration)) + return .visitChildren + } +} + +public class VisitorTests: XCTestCase { + let linuxBuildConfig = TestingBuildConfiguration( + customConditions: ["DEBUG", "ASSERTS"], + features: ["ParameterPacks"], + attributes: ["available"] + ) + + let iosBuildConfig = TestingBuildConfiguration( + platformName: "iOS", + customConditions: ["DEBUG", "ASSERTS"], + features: ["ParameterPacks"], + attributes: ["available"] + ) + + let inputSource: SourceFileSyntax = """ + #if DEBUG + #if os(Linux) + #if hasAttribute(attribute) + @available(*, deprecated, message: "use something else") + #else + @MainActor + #endif + func f() { + } + #elseif os(iOS) + func g() { + let a = foo + #if hasFeature(ParameterPacks) + .b + #endif + .c + } + #endif + + struct S { + #if DEBUG + var generationCount = 0 + #endif + } + #endif + """ + + func testAnyVisitorVisitsOnlyActive() throws { + // Make sure that all visited nodes are active nodes. + AllActiveVisitor(configuration: linuxBuildConfig).walk(inputSource) + AllActiveVisitor(configuration: iosBuildConfig).walk(inputSource) + } + + func testVisitsExpectedNodes() throws { + class NameCheckingVisitor: ActiveSyntaxAnyVisitor { + /// The set of names we are expected to visit. Any syntax nodes with + /// names that aren't here will be rejected, and each of the names listed + /// here must occur exactly once. + var expectedNames: Set + + init(configuration: TestingBuildConfiguration, expectedNames: Set) { + self.expectedNames = expectedNames + + super.init(viewMode: .sourceAccurate, configuration: configuration) + } + + deinit { + if !expectedNames.isEmpty { + XCTFail("No nodes with expected names visited: \(expectedNames)") + } + } + + func checkName(name: String, node: Syntax) { + if !expectedNames.contains(name) { + XCTFail("syntax node with unexpected name \(name) found: \(node.debugDescription)") + } + + expectedNames.remove(name) + } + + open override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { + if let identified = node.asProtocol(IdentifiedDeclSyntax.self) { + checkName(name: identified.identifier.text, node: node) + } else if let identPattern = node.as(IdentifierPatternSyntax.self) { + // FIXME: Should the above be an IdentifiedDeclSyntax? + checkName(name: identPattern.identifier.text, node: node) + } + + return .visitChildren + } + } + + // Check that the right set of names is visited. + NameCheckingVisitor( + configuration: linuxBuildConfig, + expectedNames: ["f", "S", "generationCount"] + ).walk(inputSource) + + NameCheckingVisitor( + configuration: iosBuildConfig, + expectedNames: ["g", "a", "S", "generationCount"] + ).walk(inputSource) + } +} From c115be66c53f4c4f9851926d49e6419d4f725e87 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Mon, 19 Jun 2023 11:57:23 -0700 Subject: [PATCH 08/42] Add/cleanup some TODO comments --- Sources/SwiftIfConfig/IfConfigState.swift | 3 +++ Sources/SwiftIfConfig/IfConfigVisitor.swift | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigState.swift b/Sources/SwiftIfConfig/IfConfigState.swift index 5cfd535ce63..b1eea6b3939 100644 --- a/Sources/SwiftIfConfig/IfConfigState.swift +++ b/Sources/SwiftIfConfig/IfConfigState.swift @@ -13,6 +13,9 @@ /// Describes the state of a particular region guarded by `#if` or similar. public enum IfConfigState { /// The region is not parsed, and may contain syntax that is invalid. + /// + /// TODO: For now, the IfConfig library does not distinguish between + /// inactive and unparsed regions, so this case is never used. case unparsed /// The region is parsed but is not part of the compiled program. case inactive diff --git a/Sources/SwiftIfConfig/IfConfigVisitor.swift b/Sources/SwiftIfConfig/IfConfigVisitor.swift index b188e33dfe9..90aa8c0669a 100644 --- a/Sources/SwiftIfConfig/IfConfigVisitor.swift +++ b/Sources/SwiftIfConfig/IfConfigVisitor.swift @@ -38,7 +38,7 @@ import SwiftSyntax /// `node.isActive(in: configuration)` will evaluate to `.active` or will /// throw. /// -/// TBD: This visitor currently swallows errors uncovered while checking `#if` +/// TODO: This visitor currently swallows errors uncovered while checking `#if` /// conditions, which is deeply unfortunate. We need a better answer here. open class ActiveSyntaxVisitor : SyntaxVisitor { /// The build configuration, which will be queried for each relevant `#if`. @@ -88,7 +88,7 @@ open class ActiveSyntaxVisitor : SyntaxVisito /// `node.isActive(in: configuration)` will evaluate to `.active` or will /// throw. /// -/// TBD: This visitor currently swallows errors uncovered while checking `#if` +/// TODO: This visitor currently swallows errors uncovered while checking `#if` /// conditions, which is deeply unfortunate. We need a better answer here. open class ActiveSyntaxAnyVisitor : SyntaxAnyVisitor { /// The build configuration, which will be queried for each relevant `#if`. From 623ffc79202f71f3a0d55c2a8b8c2265115d6714 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Mon, 19 Jun 2023 15:09:42 -0700 Subject: [PATCH 09/42] Add an API to rewrite a syntax tree by removing inactive regions. The operation `SyntaxProtocol.removingInactive(in:)` returns a syntax tree derived from `self` that has removed all inactive syntax nodes based on the provided configuration. --- Sources/SwiftIfConfig/IfConfigRewriter.swift | 160 +++++++++++++++++++ Sources/SwiftIfConfig/IfConfigVisitor.swift | 8 +- Tests/SwiftIfConfigTest/VisitorTests.swift | 75 ++++++--- 3 files changed, 218 insertions(+), 25 deletions(-) create mode 100644 Sources/SwiftIfConfig/IfConfigRewriter.swift diff --git a/Sources/SwiftIfConfig/IfConfigRewriter.swift b/Sources/SwiftIfConfig/IfConfigRewriter.swift new file mode 100644 index 00000000000..5e2b8b01142 --- /dev/null +++ b/Sources/SwiftIfConfig/IfConfigRewriter.swift @@ -0,0 +1,160 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +// +// This file defines the SyntaxRewriter, a class that performs a standard walk +// and tree-rebuilding pattern. +// +// Subclassers of this class can override the walking behavior for any syntax +// node and transform nodes however they like. +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// Syntax rewriter that only visits syntax nodes that are active according +/// to a particular build configuration build configuration. +/// +/// Given an example such as +/// ``` +/// #if os(Linux) +/// func f() { } +/// #elseif os(iOS) +/// func g() { } +/// #endif +/// ``` +/// +/// the rewriter will eliminate nodes for inactive clauses, leaving only +/// those nodes that are in active clauses. When rewriting the above given +/// a build configuration for Linux, the resulting tree will be +/// +/// ``` +/// func f() { } +/// ``` +/// +/// When rewriting the above given a build configuration for iOS, the resulting +/// tree will be +/// +/// ``` +/// func g() { } +/// ``` +/// +/// For any other target platforms, the resulting tree will be empty (other +/// than trivia). +class ActiveSyntaxRewriter : SyntaxRewriter { + let configuration: Configuration + + init(configuration: Configuration) { + self.configuration = configuration + } + + private func dropInactive( + _ node: List, + elementAsIfConfig: (List.Element) -> IfConfigDeclSyntax? + ) -> List { + var newElements: [List.Element] = [] + var anyChanged = false + for elementIndex in node.indices { + let element = node[elementIndex] + + // Find #ifs within the list. + if let ifConfigDecl = elementAsIfConfig(element) { + // If this is the first element that changed, note that we have + // changes and add all prior elements to the list of new elements. + if !anyChanged { + anyChanged = true + newElements.append(contentsOf: node[.. CodeBlockItemListSyntax { + let rewrittenNode = dropInactive(node) { element in + guard case .decl(let declElement) = element.item else { + return nil + } + + return declElement.as(IfConfigDeclSyntax.self) + } + + return super.visit(rewrittenNode) + } + + override func visit(_ node: MemberDeclListSyntax) -> MemberDeclListSyntax { + let rewrittenNode = dropInactive(node) { element in + return element.decl.as(IfConfigDeclSyntax.self) + } + + return super.visit(rewrittenNode) + } + + override func visit(_ node: SwitchCaseListSyntax) -> SwitchCaseListSyntax { + let rewrittenNode = dropInactive(node) { element in + if case .ifConfigDecl(let ifConfigDecl) = element { + return ifConfigDecl + } + + return nil + } + + return super.visit(rewrittenNode) + } + + override func visit(_ node: AttributeListSyntax) -> AttributeListSyntax { + let rewrittenNode = dropInactive(node) { element in + if case .ifConfigDecl(let ifConfigDecl) = element { + return ifConfigDecl + } + + return nil + } + + return super.visit(rewrittenNode) + } +} + + +extension SyntaxProtocol { + /// Produce a copy of this syntax node that removes all syntax regions that + /// are inactive according to the given build configuration, leaving only + /// the code that is active within that build configuration. + public func removingInactive(in configuration: some BuildConfiguration) -> Syntax { + let visitor = ActiveSyntaxRewriter(configuration: configuration) + return visitor.rewrite(Syntax(self)) + } +} diff --git a/Sources/SwiftIfConfig/IfConfigVisitor.swift b/Sources/SwiftIfConfig/IfConfigVisitor.swift index 90aa8c0669a..a5d7dee56b8 100644 --- a/Sources/SwiftIfConfig/IfConfigVisitor.swift +++ b/Sources/SwiftIfConfig/IfConfigVisitor.swift @@ -42,9 +42,9 @@ import SwiftSyntax /// conditions, which is deeply unfortunate. We need a better answer here. open class ActiveSyntaxVisitor : SyntaxVisitor { /// The build configuration, which will be queried for each relevant `#if`. - public let configuration: BuildConfiguration + public let configuration: Configuration - public init(viewMode: SyntaxTreeViewMode, configuration: BuildConfiguration) { + public init(viewMode: SyntaxTreeViewMode, configuration: Configuration) { self.configuration = configuration super.init(viewMode: viewMode) } @@ -92,9 +92,9 @@ open class ActiveSyntaxVisitor : SyntaxVisito /// conditions, which is deeply unfortunate. We need a better answer here. open class ActiveSyntaxAnyVisitor : SyntaxAnyVisitor { /// The build configuration, which will be queried for each relevant `#if`. - public let configuration: BuildConfiguration + public let configuration: Configuration - public init(viewMode: SyntaxTreeViewMode, configuration: BuildConfiguration) { + public init(viewMode: SyntaxTreeViewMode, configuration: Configuration) { self.configuration = configuration super.init(viewMode: viewMode) } diff --git a/Tests/SwiftIfConfigTest/VisitorTests.swift b/Tests/SwiftIfConfigTest/VisitorTests.swift index 2a1269b2728..39304ea303d 100644 --- a/Tests/SwiftIfConfigTest/VisitorTests.swift +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -45,29 +45,40 @@ public class VisitorTests: XCTestCase { let inputSource: SourceFileSyntax = """ #if DEBUG - #if os(Linux) - #if hasAttribute(attribute) - @available(*, deprecated, message: "use something else") - #else - @MainActor - #endif - func f() { - } - #elseif os(iOS) - func g() { - let a = foo - #if hasFeature(ParameterPacks) - .b - #endif - .c - } + #if os(Linux) + #if hasAttribute(available) + @available(*, deprecated, message: "use something else") + #else + @MainActor + #endif + func f() { + } + #elseif os(iOS) + func g() { + let a = foo + #if hasFeature(ParameterPacks) + .b + #endif + .c + } + #endif + + struct S { + #if DEBUG + var generationCount = 0 #endif + } - struct S { - #if DEBUG - var generationCount = 0 + func h() { + switch result { + case .success(let value): + break + #if os(iOS) + case .failure(let error): + break #endif } + } #endif """ @@ -119,12 +130,34 @@ public class VisitorTests: XCTestCase { // Check that the right set of names is visited. NameCheckingVisitor( configuration: linuxBuildConfig, - expectedNames: ["f", "S", "generationCount"] + expectedNames: ["f", "h", "S", "generationCount", "value"] ).walk(inputSource) NameCheckingVisitor( configuration: iosBuildConfig, - expectedNames: ["g", "a", "S", "generationCount"] + expectedNames: ["g", "h", "a", "S", "generationCount", "value", "error"] ).walk(inputSource) } + + func testRemoveInactive() { + assertStringsEqualWithDiff( + inputSource.removingInactive(in: linuxBuildConfig).description, + """ + + @available(*, deprecated, message: "use something else") + func f() { + } + + struct S { + var generationCount = 0 + } + + func h() { + switch result { + case .success(let value): + break + } + } + """) + } } From 2d2021ff1dc9e38fe35a533c43a3584abac463bd Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Mon, 19 Jun 2023 16:23:37 -0700 Subject: [PATCH 10/42] Implement inactive clause rewriting support for postfix `#if` Postfix `#if` expressions have a different syntactic form than other `#if` clauses because they don't fit into a list-like position in the grammar. Implement a separate, recursive folding algorithm to handle these clauses. --- Sources/SwiftIfConfig/IfConfigRewriter.swift | 116 +++++++++++++++++++ Tests/SwiftIfConfigTest/VisitorTests.swift | 23 +++- 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigRewriter.swift b/Sources/SwiftIfConfig/IfConfigRewriter.swift index 5e2b8b01142..13f1ea3e4b8 100644 --- a/Sources/SwiftIfConfig/IfConfigRewriter.swift +++ b/Sources/SwiftIfConfig/IfConfigRewriter.swift @@ -146,6 +146,122 @@ class ActiveSyntaxRewriter : SyntaxRewriter { return super.visit(rewrittenNode) } + + /// Apply the given base to the postfix expression. + private func applyBaseToPostfixExpression( + base: ExprSyntax, postfix: ExprSyntax + ) -> ExprSyntax { + /// Try to apply the base to the postfix expression using the given + /// keypath into a specific node type. + /// + /// Returns the new expression node on success, `nil` when the node kind + /// didn't match. + func tryApply( + _ keyPath: WritableKeyPath + ) -> ExprSyntax? { + guard let node = postfix.as(Node.self) else { + return nil + } + + let newExpr = applyBaseToPostfixExpression(base: base, postfix: node[keyPath: keyPath]) + return ExprSyntax(node.with(keyPath, newExpr)) + } + + // Member access + if let memberAccess = postfix.as(MemberAccessExprSyntax.self) { + guard let memberBase = memberAccess.base else { + // If this member access has no base, this is the base we are + // replacing, terminating the recursion. Do so now. + return ExprSyntax(memberAccess.with(\.base, base)) + } + + let newBase = applyBaseToPostfixExpression(base: base, postfix: memberBase) + return ExprSyntax(memberAccess.with(\.base, newBase)) + } + + // Generic arguments <...> + if let result = tryApply(\SpecializeExprSyntax.expression) { + return result + } + + // Call (...) + if let result = tryApply(\FunctionCallExprSyntax.calledExpression) { + return result + } + + // Subscript [...] + if let result = tryApply(\SubscriptExprSyntax.calledExpression) { + return result + } + + // Optional chaining ? + if let result = tryApply(\OptionalChainingExprSyntax.expression) { + return result + } + + // Forced optional value ! + if let result = tryApply(\ForcedValueExprSyntax.expression) { + return result + } + + // Postfix unary operator. + if let result = tryApply(\PostfixUnaryExprSyntax.expression) { + return result + } + + // #if + if let postfixIfConfig = postfix.as(PostfixIfConfigExprSyntax.self) { + return dropInactive(outerBase: base, postfixIfConfig: postfixIfConfig) + } + + assert(false, "Unhandled postfix expression in #if elimination") + return base + } + + /// Drop inactive regions from a postfix `#if` configuration, applying the + /// outer "base" expression to the rewritten node. + private func dropInactive( + outerBase: ExprSyntax?, + postfixIfConfig: PostfixIfConfigExprSyntax + ) -> ExprSyntax { + // Determine the active clause within this syntax node. + // TODO: Swallows errors + guard let activeClause = try? postfixIfConfig.config.activeClause(in: configuration), + case .`postfixExpression`(let postfixExpr) = activeClause.elements else { + // If there is no active clause, return the base. + + // Prefer the base we have and, if not, use the outer base. + // TODO: Can we have both? If so, then what? + if let base = postfixIfConfig.base ?? outerBase { + return base + } + + // If there was no base, we're in an erroneous syntax tree that would + // never be produced by the parser. Synthesize a missing expression + // syntax node so clients can recover more gracefully. + return ExprSyntax( + MissingExprSyntax( + placeholder: .init(.identifier("_"), presence: .missing) + ) + ) + } + + // If there is no base, return the postfix expression. + guard let base = postfixIfConfig.base ?? outerBase else { + return postfixExpr + } + + // Apply the base to the postfix expression. + return applyBaseToPostfixExpression(base: base, postfix: postfixExpr) + } + + // TODO: PostfixIfConfigExprSyntax has a different form that doesn't work + // well with the way dropInactive is written. We essentially need to + // thread a the "base" into the active clause. + override func visit(_ node: PostfixIfConfigExprSyntax) -> ExprSyntax { + let rewrittenNode = dropInactive(outerBase: nil, postfixIfConfig: node) + return visit(rewrittenNode) + } } diff --git a/Tests/SwiftIfConfigTest/VisitorTests.swift b/Tests/SwiftIfConfigTest/VisitorTests.swift index 39304ea303d..96fbb2bad61 100644 --- a/Tests/SwiftIfConfigTest/VisitorTests.swift +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -79,6 +79,19 @@ public class VisitorTests: XCTestCase { #endif } } + + func i() { + a.b + #if DEBUG + .c + #endif + #if hasAttribute(available) + .d() + #endif + #if os(iOS) + .e[] + #endif + } #endif """ @@ -130,12 +143,12 @@ public class VisitorTests: XCTestCase { // Check that the right set of names is visited. NameCheckingVisitor( configuration: linuxBuildConfig, - expectedNames: ["f", "h", "S", "generationCount", "value"] + expectedNames: ["f", "h", "i", "S", "generationCount", "value"] ).walk(inputSource) NameCheckingVisitor( configuration: iosBuildConfig, - expectedNames: ["g", "h", "a", "S", "generationCount", "value", "error"] + expectedNames: ["g", "h", "i", "a", "S", "generationCount", "value", "error"] ).walk(inputSource) } @@ -158,6 +171,12 @@ public class VisitorTests: XCTestCase { break } } + + func i() { + a.b + .c + .d() + } """) } } From fc41db41b0ab6ebacb162f8a7e77f18244325aa5 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Mon, 19 Jun 2023 22:39:58 -0700 Subject: [PATCH 11/42] Add overview documentation for the SwiftIfConfig library --- .../SwiftIfConfig.docc/Info.plist | 38 +++++++++++++++++++ .../SwiftIfConfig.docc/SwiftIfConfig.md | 34 +++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 Sources/SwiftIfConfig/SwiftIfConfig.docc/Info.plist create mode 100644 Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md diff --git a/Sources/SwiftIfConfig/SwiftIfConfig.docc/Info.plist b/Sources/SwiftIfConfig/SwiftIfConfig.docc/Info.plist new file mode 100644 index 00000000000..f69f2f1a65c --- /dev/null +++ b/Sources/SwiftIfConfig/SwiftIfConfig.docc/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleName + SwiftIfConfig + CFBundleDisplayName + SwiftIfConfig + CFBundleIdentifier + com.apple.swift-if-config + CFBundleDevelopmentRegion + en + CFBundleIconFile + DocumentationIcon + CFBundleIconName + DocumentationIcon + CFBundlePackageType + DOCS + CFBundleShortVersionString + 0.1.0 + CDDefaultCodeListingLanguage + swift + CFBundleVersion + 0.1.0 + CDAppleDefaultAvailability + + SwiftIfConfig + + + name + macOS + version + 10.15 + + + + + diff --git a/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md new file mode 100644 index 00000000000..05641597332 --- /dev/null +++ b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md @@ -0,0 +1,34 @@ +# `SwiftIfConfig` + + + +A library to evaluate `#if` conditionals within a Swift syntax tree. + +## Overview + +Swift provides the ability to conditionally compile parts of a source file based on various built-time conditions, including information about the target (operating system, processor architecture, environment), information about the compiler (version, supported attributes and features), and user-supplied conditions specified as part of the build (e.g., `DEBUG`), which we collectively refer to as the *build configuration*. These conditions can occur within a `#if` in the source code, e.g., + +```swift +func f() { +#if DEBUG + log("called f") +#endif + +#if os(Linux) + // use Linux API +#elseif os(iOS) || os(macOS) + // use iOS/macOS API +#else + #error("unsupported platform") +#endif +} +``` + +The syntax tree and its parser do not reason about the build configuration. Rather, the syntax tree produced by parsing this code will include `IfConfigDeclSyntax` nodes wherever there is a `#if`, and each such node contains the a list of clauses, each with a condition to check (e.g., `os(Linux)`) and a list of syntax nodes that are conditionally part of the program. Therefore, the syntax tree captures all the information needed to process the source file for any build configuration. + +The `SwiftIfConfig` library provides utilities to determine which syntax nodes are part of a particular build configuration. Each utility requires that one provide a specific build configuration (i.e., an instance of a type that conforms to the protocol), and provides a different view on essentially the same information: + +* and are visitor types that only visit the syntax nodes that are included ("active") for a given build configuration, implicitly skipping any nodes within inactive `#if` clauses. +* `SyntaxProtocol.removingInactive(in:)` produces a syntax node that removes all inactive regions (and their corresponding `IfConfigDeclSyntax` nodes) from the given syntax tree, returning a new tree that is free of `#if` conditions. +* `IfConfigDeclSyntax.activeClause(in:)` determines which of the clauses of an `#if` is active for the given build configuration, returning the active clause. +* `SyntaxProtocol.isActive(in:)` determines whether the given syntax node is active for the given build configuration. \ No newline at end of file From f004511263f16676a2066588fc59d6979844d5ef Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Mon, 19 Jun 2023 22:43:43 -0700 Subject: [PATCH 12/42] Add CMake build system for SwiftIfConfig --- Sources/CMakeLists.txt | 1 + Sources/SwiftIfConfig/CMakeLists.txt | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 Sources/SwiftIfConfig/CMakeLists.txt diff --git a/Sources/CMakeLists.txt b/Sources/CMakeLists.txt index f9e6c3edf11..5c2b0520b92 100644 --- a/Sources/CMakeLists.txt +++ b/Sources/CMakeLists.txt @@ -16,6 +16,7 @@ add_subdirectory(SwiftParser) add_subdirectory(SwiftParserDiagnostics) add_subdirectory(SwiftRefactor) add_subdirectory(SwiftOperators) +add_subdirectory(SwiftIfConfig) add_subdirectory(SwiftSyntaxBuilder) add_subdirectory(SwiftSyntaxMacros) add_subdirectory(SwiftSyntaxMacroExpansion) diff --git a/Sources/SwiftIfConfig/CMakeLists.txt b/Sources/SwiftIfConfig/CMakeLists.txt new file mode 100644 index 00000000000..028a801e1fc --- /dev/null +++ b/Sources/SwiftIfConfig/CMakeLists.txt @@ -0,0 +1,24 @@ +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +# Licensed under Apache License v2.0 with Runtime Library Exception +# +# See http://swift.org/LICENSE.txt for license information +# See http://swift.org/CONTRIBUTORS.txt for Swift project authors + +add_swift_host_library(SwiftIfConfig + BuildConfiguration.swift + IfConfigEvaluation.swift + IfConfigFunctions.swift + IfConfigRewriter.swift + IfConfigState.swift + IfConfigVisitor.swift + SyntaxLiteralUtils.swift + VersionTuple.swift +) + +target_link_libraries(SwiftIfConfig PUBLIC + SwiftSyntax + SwiftDiagnostics + SwiftOperators + SwiftParser) From 14b67186fbd19f19a69ced9015d7a5898255e2d4 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Mon, 19 Jun 2023 22:53:39 -0700 Subject: [PATCH 13/42] Format source --- .../SwiftIfConfig/IfConfigEvaluation.swift | 66 +++++++++++-------- Sources/SwiftIfConfig/IfConfigRewriter.swift | 45 ++++++------- Sources/SwiftIfConfig/IfConfigVisitor.swift | 10 +-- .../SwiftIfConfig/SyntaxLiteralUtils.swift | 2 +- Sources/SwiftIfConfig/VersionTuple.swift | 4 +- .../TestingBuildConfiguration.swift | 2 +- Tests/SwiftIfConfigTest/VisitorTests.swift | 3 +- 7 files changed, 73 insertions(+), 59 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index 9bd6fc6e329..81bb22908ec 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -158,7 +158,6 @@ extension VersionTuple { } } - /// Evaluate the condition of an `#if`. private func evaluateIfConfig( condition: ExprSyntax, @@ -188,14 +187,16 @@ private func evaluateIfConfig( // Logical '!'. if let prefixOp = condition.as(PrefixOperatorExprSyntax.self), - prefixOp.operatorToken?.text == "!" { + prefixOp.operatorToken?.text == "!" + { return try !evaluateIfConfig(condition: prefixOp.postfixExpression, configuration: configuration) } // Logical '&&' and '||'. if let binOp = condition.as(InfixOperatorExprSyntax.self), - let op = binOp.operatorOperand.as(BinaryOperatorExprSyntax.self), - (op.operatorToken.text == "&&" || op.operatorToken.text == "||") { + let op = binOp.operatorOperand.as(BinaryOperatorExprSyntax.self), + (op.operatorToken.text == "&&" || op.operatorToken.text == "||") + { // Evaluate the left-hand side. let lhsResult = try evaluateIfConfig(condition: binOp.leftOperand, configuration: configuration) @@ -212,20 +213,23 @@ private func evaluateIfConfig( // Look through parentheses. if let tuple = condition.as(TupleExprSyntax.self), tuple.isParentheses, - let element = tuple.elements.first { + let element = tuple.elements.first + { return try evaluateIfConfig(condition: element.expression, configuration: configuration) } // Calls syntax is for operations. if let call = condition.as(FunctionCallExprSyntax.self), - let fnName = call.calledExpression.simpleIdentifierExpr, - let fn = IfConfigFunctions(rawValue: fnName) { + let fnName = call.calledExpression.simpleIdentifierExpr, + let fn = IfConfigFunctions(rawValue: fnName) + { /// Perform a check for an operation that takes a single identifier argument. func doSingleIdentifierArgumentCheck(_ body: (String, ExprSyntax) -> Bool?) throws -> Bool? { // Ensure that we have a single argument that is a simple identifier. guard let argExpr = call.argumentList.singleUnlabeledExpression, - let arg = argExpr.simpleIdentifierExpr else { return nil } + let arg = argExpr.simpleIdentifierExpr + else { return nil } guard let result = body(arg, ExprSyntax(argExpr)) else { throw IfConfigError.unhandledFunction(name: fnName, syntax: ExprSyntax(call)) @@ -239,8 +243,9 @@ private func evaluateIfConfig( // Ensure that we have a single unlabeled argument that is either >= or < as a prefix // operator applied to a version. guard let argExpr = call.argumentList.singleUnlabeledExpression, - let unaryArg = argExpr.as(PrefixOperatorExprSyntax.self), - let opToken = unaryArg.operatorToken else { + let unaryArg = argExpr.as(PrefixOperatorExprSyntax.self), + let opToken = unaryArg.operatorToken + else { return nil } @@ -289,8 +294,9 @@ private func evaluateIfConfig( // Ensure that we have a single argument that is a simple identifier, // either "little" or "big". guard let argExpr = call.argumentList.singleUnlabeledExpression, - let arg = argExpr.simpleIdentifierExpr, - let expectedEndianness = Endianness(rawValue: arg) else { + let arg = argExpr.simpleIdentifierExpr, + let expectedEndianness = Endianness(rawValue: arg) + else { result = nil break } @@ -306,10 +312,11 @@ private func evaluateIfConfig( // Ensure that we have a single argument that is a simple identifier, which // is an underscore followed by an integer. guard let argExpr = call.argumentList.singleUnlabeledExpression, - let arg = argExpr.simpleIdentifierExpr, - let argFirst = arg.first, - argFirst == "_", - let expectedPointerBitWidth = Int(arg.dropFirst()) else { + let arg = argExpr.simpleIdentifierExpr, + let argFirst = arg.first, + argFirst == "_", + let expectedPointerBitWidth = Int(arg.dropFirst()) + else { result = nil break } @@ -331,10 +338,11 @@ private func evaluateIfConfig( // Argument is a single unlabeled argument containing a string // literal. guard let argExpr = call.argumentList.singleUnlabeledExpression, - let stringLiteral = argExpr.as(StringLiteralExprSyntax.self), - stringLiteral.segments.count == 1, - let segment = stringLiteral.segments.first, - case .stringSegment(let stringSegment) = segment else { + let stringLiteral = argExpr.as(StringLiteralExprSyntax.self), + stringLiteral.segments.count == 1, + let segment = stringLiteral.segments.first, + case .stringSegment(let stringSegment) = segment + else { // FIXME: better diagnostic here throw IfConfigError.unknownExpression(condition) } @@ -352,7 +360,8 @@ private func evaluateIfConfig( // Retrieve the first argument, which must not have a label. This is // the module import path. guard let firstArg = call.argumentList.first, - firstArg.label == nil else { + firstArg.label == nil + else { throw IfConfigError.canImportMissingModule(syntax: ExprSyntax(call)) } @@ -363,16 +372,16 @@ private func evaluateIfConfig( // _underlyingVersion. let version: CanImportVersion if let secondArg = call.argumentList.dropFirst().first { - if secondArg.label?.text != "_version" && - secondArg.label?.text != "_underlyingVersion" { + if secondArg.label?.text != "_version" && secondArg.label?.text != "_underlyingVersion" { throw IfConfigError.canImportLabel(syntax: secondArg.expression) } let versionText: String if let stringLiteral = secondArg.expression.as(StringLiteralExprSyntax.self), - stringLiteral.segments.count == 1, - let firstSegment = stringLiteral.segments.first, - case .stringSegment(let stringSegment) = firstSegment { + stringLiteral.segments.count == 1, + let firstSegment = stringLiteral.segments.first, + case .stringSegment(let stringSegment) = firstSegment + { versionText = stringSegment.content.text } else { versionText = secondArg.expression.trimmedDescription @@ -386,7 +395,7 @@ private func evaluateIfConfig( if secondArg.label?.text == "_version" { version = .version(versionTuple) - } else { + } else { assert(secondArg.label?.text == "_underlyingVersion") version = .underlyingVersion(versionTuple) } @@ -400,7 +409,8 @@ private func evaluateIfConfig( result = configuration.canImport( importPath: importPath.map { String($0) }, - version: version, syntax: ExprSyntax(call) + version: version, + syntax: ExprSyntax(call) ) } diff --git a/Sources/SwiftIfConfig/IfConfigRewriter.swift b/Sources/SwiftIfConfig/IfConfigRewriter.swift index 13f1ea3e4b8..c59f602f20d 100644 --- a/Sources/SwiftIfConfig/IfConfigRewriter.swift +++ b/Sources/SwiftIfConfig/IfConfigRewriter.swift @@ -50,13 +50,13 @@ import SwiftSyntax /// /// For any other target platforms, the resulting tree will be empty (other /// than trivia). -class ActiveSyntaxRewriter : SyntaxRewriter { +class ActiveSyntaxRewriter: SyntaxRewriter { let configuration: Configuration - + init(configuration: Configuration) { self.configuration = configuration } - + private func dropInactive( _ node: List, elementAsIfConfig: (List.Element) -> IfConfigDeclSyntax? @@ -65,7 +65,7 @@ class ActiveSyntaxRewriter : SyntaxRewriter { var anyChanged = false for elementIndex in node.indices { let element = node[elementIndex] - + // Find #ifs within the list. if let ifConfigDecl = elementAsIfConfig(element) { // If this is the first element that changed, note that we have @@ -74,64 +74,64 @@ class ActiveSyntaxRewriter : SyntaxRewriter { anyChanged = true newElements.append(contentsOf: node[.. CodeBlockItemListSyntax { let rewrittenNode = dropInactive(node) { element in guard case .decl(let declElement) = element.item else { return nil } - + return declElement.as(IfConfigDeclSyntax.self) } - + return super.visit(rewrittenNode) } - + override func visit(_ node: MemberDeclListSyntax) -> MemberDeclListSyntax { let rewrittenNode = dropInactive(node) { element in return element.decl.as(IfConfigDeclSyntax.self) } - + return super.visit(rewrittenNode) } - + override func visit(_ node: SwitchCaseListSyntax) -> SwitchCaseListSyntax { let rewrittenNode = dropInactive(node) { element in if case .ifConfigDecl(let ifConfigDecl) = element { return ifConfigDecl } - + return nil } - + return super.visit(rewrittenNode) } @@ -149,7 +149,8 @@ class ActiveSyntaxRewriter : SyntaxRewriter { /// Apply the given base to the postfix expression. private func applyBaseToPostfixExpression( - base: ExprSyntax, postfix: ExprSyntax + base: ExprSyntax, + postfix: ExprSyntax ) -> ExprSyntax { /// Try to apply the base to the postfix expression using the given /// keypath into a specific node type. @@ -227,7 +228,8 @@ class ActiveSyntaxRewriter : SyntaxRewriter { // Determine the active clause within this syntax node. // TODO: Swallows errors guard let activeClause = try? postfixIfConfig.config.activeClause(in: configuration), - case .`postfixExpression`(let postfixExpr) = activeClause.elements else { + case .`postfixExpression`(let postfixExpr) = activeClause.elements + else { // If there is no active clause, return the base. // Prefer the base we have and, if not, use the outer base. @@ -264,7 +266,6 @@ class ActiveSyntaxRewriter : SyntaxRewriter { } } - extension SyntaxProtocol { /// Produce a copy of this syntax node that removes all syntax regions that /// are inactive according to the given build configuration, leaving only diff --git a/Sources/SwiftIfConfig/IfConfigVisitor.swift b/Sources/SwiftIfConfig/IfConfigVisitor.swift index a5d7dee56b8..897e81101c6 100644 --- a/Sources/SwiftIfConfig/IfConfigVisitor.swift +++ b/Sources/SwiftIfConfig/IfConfigVisitor.swift @@ -40,7 +40,7 @@ import SwiftSyntax /// /// TODO: This visitor currently swallows errors uncovered while checking `#if` /// conditions, which is deeply unfortunate. We need a better answer here. -open class ActiveSyntaxVisitor : SyntaxVisitor { +open class ActiveSyntaxVisitor: SyntaxVisitor { /// The build configuration, which will be queried for each relevant `#if`. public let configuration: Configuration @@ -53,7 +53,8 @@ open class ActiveSyntaxVisitor : SyntaxVisito // If there is an active clause, visit it's children. // FIXME: try? suppresses errors here. How shall we report them? if let activeClause = try? node.activeClause(in: configuration), - let elements = activeClause.elements { + let elements = activeClause.elements + { walk(Syntax(elements)) } @@ -90,7 +91,7 @@ open class ActiveSyntaxVisitor : SyntaxVisito /// /// TODO: This visitor currently swallows errors uncovered while checking `#if` /// conditions, which is deeply unfortunate. We need a better answer here. -open class ActiveSyntaxAnyVisitor : SyntaxAnyVisitor { +open class ActiveSyntaxAnyVisitor: SyntaxAnyVisitor { /// The build configuration, which will be queried for each relevant `#if`. public let configuration: Configuration @@ -103,7 +104,8 @@ open class ActiveSyntaxAnyVisitor : SyntaxAny // If there is an active clause, visit it's children. // FIXME: try? suppresses errors here. How shall we report them? if let activeClause = try? node.activeClause(in: configuration), - let elements = activeClause.elements { + let elements = activeClause.elements + { walk(Syntax(elements)) } diff --git a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift index dda370a50ed..544cc40641e 100644 --- a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift +++ b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift @@ -37,7 +37,7 @@ extension ExprSyntax { /// Whether this is a simple identifier expression and, if so, what the identifier string is. var simpleIdentifierExpr: String? { guard let identExpr = self.as(IdentifierExprSyntax.self), - identExpr.declNameArguments == nil + identExpr.declNameArguments == nil else { return nil } diff --git a/Sources/SwiftIfConfig/VersionTuple.swift b/Sources/SwiftIfConfig/VersionTuple.swift index 6fe80ad7072..2edc8649229 100644 --- a/Sources/SwiftIfConfig/VersionTuple.swift +++ b/Sources/SwiftIfConfig/VersionTuple.swift @@ -49,10 +49,10 @@ extension VersionTuple { } } -extension VersionTuple: Equatable, Hashable { } +extension VersionTuple: Equatable, Hashable {} extension VersionTuple: Comparable { - public static func <(lhs: VersionTuple, rhs: VersionTuple) -> Bool { + public static func < (lhs: VersionTuple, rhs: VersionTuple) -> Bool { return lhs.normalized.components.lexicographicallyPrecedes(rhs.normalized.components) } } diff --git a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift index 899e5b33be7..5e7a85aba9c 100644 --- a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift +++ b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift @@ -12,7 +12,7 @@ import SwiftIfConfig import SwiftSyntax -struct TestingBuildConfiguration : BuildConfiguration { +struct TestingBuildConfiguration: BuildConfiguration { var platformName: String = "Linux" var customConditions: Set = [] var features: Set = [] diff --git a/Tests/SwiftIfConfigTest/VisitorTests.swift b/Tests/SwiftIfConfigTest/VisitorTests.swift index 96fbb2bad61..69bcdd47fb4 100644 --- a/Tests/SwiftIfConfigTest/VisitorTests.swift +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -177,6 +177,7 @@ public class VisitorTests: XCTestCase { .c .d() } - """) + """ + ) } } From 19ed1e8a6c1c2aa740fbff47b8372c3beab75854 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Wed, 21 Jun 2023 23:13:51 -0700 Subject: [PATCH 14/42] Improve documentation for BuildConfiguration --- .../SwiftIfConfig/BuildConfiguration.swift | 185 +++++++++++++++++- 1 file changed, 180 insertions(+), 5 deletions(-) diff --git a/Sources/SwiftIfConfig/BuildConfiguration.swift b/Sources/SwiftIfConfig/BuildConfiguration.swift index 83cd77ff6ef..66e0987adb2 100644 --- a/Sources/SwiftIfConfig/BuildConfiguration.swift +++ b/Sources/SwiftIfConfig/BuildConfiguration.swift @@ -11,12 +11,19 @@ //===----------------------------------------------------------------------===// import SwiftSyntax +/// Describes the ordering of a sequence of bytes that make up a word of +/// storage for a particular architecture. public enum Endianness: String { + /// Little endian, meaning that the least significant byte of a word is + /// stored at the lowest address. case little + + /// Big endian, meaning that the most significant byte of a word is stored + /// at the lowest address. case big } -/// Describes the requested version of a module +/// Describes the requested version of a module. public enum CanImportVersion { /// Any version of the module will suffice. case unversioned @@ -45,48 +52,216 @@ public protocol BuildConfiguration { /// /// Custom build conditions can be set by the `-D` command line option to /// the Swift compiler. For example, `-DDEBUG` sets the custom condition - /// named `DEBUG`. + /// named `DEBUG`, which could be checked with, e.g., + /// + /// ``` + /// #if DEBUG + /// // ... + /// #endif + /// ``` + /// + /// - Parameters: + /// - name: The name of the custom build condition being checked (e.g., + /// `DEBUG`. + /// - syntax: The syntax node for the name of the custom build + /// configuration. + /// - Returns: Whether the custom condition is set. func isCustomConditionSet(name: String, syntax: ExprSyntax) -> Bool? /// Determine whether the given feature is enabled. /// /// Features are determined by the Swift compiler, language mode, and other - /// options such as `--enable-upcoming-feature`. + /// options such as `--enable-upcoming-feature`, and can be checked with + /// the `hasFeature` syntax, e.g., + /// + /// ``` + /// #if hasFeature(VariadicGenerics) + /// // ... + /// #endif + /// ``` + /// + /// - Parameters: + /// - name: The name of the feature being checked. + /// - syntax: The syntax node for the `hasFeature()`. + /// - Returns: Whether the requested feature is available. func hasFeature(name: String, syntax: ExprSyntax) -> Bool? /// Determine whether the given attribute is available. /// - /// Attributes are determined by the Swift compiler. + /// Attributes are determined by the Swift compiler. They can be checked + /// with `hasAttribute` syntax, e.g., + /// + /// ``` + /// #if hasAttribute(available) + /// // ... + /// #endif + /// ``` + /// + /// - Parameters: + /// - name: The name of the attribute being queried. + /// - syntax: The syntax node for the `hasAttribute()`. + /// - Returns: Whether the requested attribute is supported. func hasAttribute(name: String, syntax: ExprSyntax) -> Bool? /// Determine whether a module with the given import path can be imported, /// with additional version information. + /// + /// The availability of a module for import can be checked with `canImport`, + /// e.g., + /// + /// ``` + /// #if canImport(UIKit) + /// // ... + /// #endif + /// ``` + /// + /// There is an experimental syntax for providing required module version + /// information, which will translate into the `version` argument. + /// + /// - Parameters: + /// - importPath: A nonempty sequence of identifiers describing the + /// imported module, which was written in source as a dotted sequence, + /// e.g., `UIKit.UIViewController` will be passed in as the import path + /// array `["UIKit", "UIViewController"]`. + /// - version: The version restriction on the imported module. For the + /// normal `canImport()` syntax, this will always be + /// `CanImportVersion.unversioned`. + /// - syntax: The syntax node for the `canImport` expression. + /// - Returns: Whether the module can be imported. func canImport(importPath: [String], version: CanImportVersion, syntax: ExprSyntax) -> Bool? /// Determine whether the given name is the active target OS (e.g., Linux, iOS). + /// + /// The target operating system can be queried with `os()`, e.g., + /// + /// ``` + /// #if os(Linux) + /// // Linux-specific implementation + /// #endif + /// + /// - Parameters: + /// - name: The name of the operating system being queried, such as `Linux`, + /// `Windows`, `macOS`, etc. + /// - syntax: The syntax node for the `os()` expression. + /// - Returns: Whether the given operating system name is the target operating + /// system, i.e., the operating system for which code is being generated. func isActiveTargetOS(name: String, syntax: ExprSyntax) -> Bool? - /// Determine whether the given name is the active target architecture (e.g., x86_64, arm64) + /// Determine whether the given name is the active target architecture + /// (e.g., x86_64, arm64). + /// + /// The target processor architecture can be queried with `arch()`, e.g., + /// + /// ``` + /// #if arch(x86_64) + /// // 64-bit x86 Intel-specific code + /// #endif + /// + /// - Parameters: + /// - name: The name of the target architecture to check. + /// - syntax: The syntax node for the `arch()` expression. + /// - Returns: Whether the given processor architecture is the target + /// architecture. func isActiveTargetArchitecture(name: String, syntax: ExprSyntax) -> Bool? /// Determine whether the given name is the active target environment (e.g., simulator) + /// + /// The target environment can be queried with `targetEnvironment()`, + /// e.g., + /// + /// ``` + /// #if targetEnvironment(simulator) + /// // Simulator-specific code + /// #endif + /// + /// - Parameters: + /// - name: The name of the target environment to check. + /// - syntax: The syntax node for the `targetEnvironment()` + /// expression. + /// - Returns: Whether the target platform is for a specific environment, + /// such as a simulator or emulator. func isActiveTargetEnvironment(name: String, syntax: ExprSyntax) -> Bool? /// Determine whether the given name is the active target runtime (e.g., _ObjC vs. _Native) + /// + /// The target runtime can only be queried by an experimental syntax + /// `_runtime()`, e.g., + /// + /// #if _runtime(_ObjC) + /// // Code that depends on Swift being built for use with the Objective-C + /// // runtime, e.g., on Apple platforms. + /// #endif + /// + /// The only other runtime is "none", when Swift isn't tying into any other + /// specific runtime. + /// + /// - Parameters: + /// - name: The name of the runtime. + /// - syntax: The syntax node for the `_runtime()` expression. + /// - Returns: Whether the target runtime matches the given name. func isActiveTargetRuntime(name: String, syntax: ExprSyntax) -> Bool? /// Determine whether the given name is the active target pointer authentication scheme (e.g., arm64e). + /// + /// The target pointer authentication scheme describes how pointers are + /// signed, as a security mitigation. This scheme can only be queried by + /// an experimental syntax `_ptrath()`, e.g., + /// + /// ``` + /// #if _ptrauth(arm64e) + /// // Special logic for arm64e pointer signing + /// #endif + /// + /// - Parameters: + /// - name: The name of the pointer authentication scheme to check. + /// - syntax: The syntax node for the `_ptrauth()` expression. + /// - Returns: Whether the code generated for the target will use the given + /// pointer authentication scheme. func isActiveTargetPointerAuthentication(name: String, syntax: ExprSyntax) -> Bool? /// The bit width of a data pointer for the target architecture. + /// + /// The target's pointer bit with (which also corresponds to the number of + /// bits in `Int`/`UInt`) can only be queried with the experimental syntax + /// `_pointerBitWidth(_)`, e.g., + /// + /// ``` + /// #if _pointerBitWidth(32) + /// // 32-bit system + /// #endif var targetPointerBitWidth: Int? { get } /// The endianness of the target architecture. + /// + /// The target's endianness can onyl be queried with the experimental syntax + /// `_endian()`, where `` can be either "big" or "little", e.g., + /// + /// #if _endian(little) + /// // Swap some bytes around for network byte order + /// #endif var endianness: Endianness? { get } /// The effective language version, which can be set by the user (e.g., 5.0). + /// + /// The language version can be queried with the `swift` directive that checks + /// how the supported language version compares, as described by + /// [SE-0212](https://github.com/apple/swift-evolution/blob/main/proposals/0212-compiler-version-directive.md). For example: + /// + /// ``` + /// #if swift(>=5.5) + /// // Hooray, we can use tasks! + /// ``` var languageVersion: VersionTuple? { get } /// The version of the compiler (e.g., 5.9). + /// + /// The compiler version can be queried with the `compiler` directive that + /// checks the specific version of the compiler being used to process the + /// code, e.g., + /// + /// ``` + /// #if compiler(>=5.7) + /// // Hoorway, we can implicitly open existentials! + /// #endif var compilerVersion: VersionTuple? { get } } From 15073e8f53efd2132680068448755019b7070b96 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Wed, 21 Jun 2023 23:16:42 -0700 Subject: [PATCH 15/42] Improve documentation for internal `IfConfigFunctions`. --- Sources/SwiftIfConfig/IfConfigFunctions.swift | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigFunctions.swift b/Sources/SwiftIfConfig/IfConfigFunctions.swift index 95cf6cdc0f1..12ee69bf6ae 100644 --- a/Sources/SwiftIfConfig/IfConfigFunctions.swift +++ b/Sources/SwiftIfConfig/IfConfigFunctions.swift @@ -12,42 +12,44 @@ /// Enum capturing all of the functions that can be used in an `#if` condition. enum IfConfigFunctions: String { - /// A check for a specific attribute. + /// A check for a specific attribute via `hasAttribute()`. case hasAttribute - /// A check for a specific named feature. + /// A check for a specific named feature via `hasFeature()`. case hasFeature - /// A check for the Swift language version. + /// A check for the Swift language version via `swift(>=version).` case swift - /// A check for the Swift compiler version. + /// A check for the Swift compiler version via `compiler(>=version)`. case compiler - /// A check to determine whether a given module can be imported. + /// A check to determine whether a given module can be imported via `canImport()`. case canImport - /// A check for the target Operating System kind (e.g., Linux, iOS). + /// A check for the target Operating System kind (e.g., Linux, iOS) via `os()` case os - /// A check for the target architecture (e.g., arm64, x86_64). + /// A check for the target architecture (e.g., arm64, x86_64) via `arch()`. case arch - /// A check for the target environment (e.g., simulator). + /// A check for the target environment (e.g., simulator) via `targetEnvironment()`. case targetEnvironment - /// A historical check against a specific compiler build version. + /// A historical check against a specific compiler build version via `_compiler_version("")`. case _compiler_version - /// A check for the target endianness (e.g., big or little) + /// A check for the target endianness (e.g., big or little) via `_endian(big|little)`. case _endian /// A check for the target bit width of a pointer (e.g., _64) case _pointerBitWidth - /// A check for the target runtime paired with the Swift runtime (e.g., _ObjC). + /// A check for the target runtime paired with the Swift runtime (e.g., _ObjC) + /// via `_runtime()`. case _runtime - /// A check for the target's pointer authentication scheme (e.g., _arm64e). + /// A check for the target's pointer authentication scheme (e.g., _arm64e) + /// via `_ptrauth()`. case _ptrauth } From 431fe8a381f3d35fc760a6ac9fa688483ab9117a Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 22 Jun 2023 00:05:22 -0700 Subject: [PATCH 16/42] Address a number of review comments (thanks, Alex!) --- .../SwiftIfConfig/IfConfigEvaluation.swift | 32 +++++++++++++------ .../SwiftIfConfig.docc/SwiftIfConfig.md | 8 ++--- .../SwiftIfConfig/SyntaxLiteralUtils.swift | 3 +- Sources/SwiftIfConfig/VersionTuple.swift | 18 +++++++---- Tests/SwiftIfConfigTest/VisitorTests.swift | 4 ++- 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index 81bb22908ec..f0ed56ddb19 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -72,9 +72,14 @@ enum IfConfigError: Error, CustomStringConvertible { extension VersionTuple { /// Parse a compiler build version of the form "5007.*.1.2.3*", which is /// used by an older if configuration form `_compiler_version("...")`. + /// - Parameters: + /// - versionString: The version string for the compiler build version that + /// we are parsing. + /// - versionSyntax: The syntax node that contains the version string, used + /// only for diagnostic purposes. fileprivate init( parsingCompilerBuildVersion versionString: String, - _ syntax: ExprSyntax + _ versionSyntax: ExprSyntax ) throws { components = [] @@ -86,7 +91,7 @@ extension VersionTuple { let limit = components.isEmpty ? 9223371 : 999 if value < 0 || value > limit { // FIXME: Can we provide a more precise location here? - throw IfConfigError.compilerVersionOutOfRange(value: value, upperLimit: limit, syntax: syntax) + throw IfConfigError.compilerVersionOutOfRange(value: value, upperLimit: limit, syntax: versionSyntax) } components.append(value) @@ -96,13 +101,13 @@ extension VersionTuple { for (index, componentString) in componentStrings.enumerated() { // Check ahead of time for empty version components if componentString.isEmpty { - throw IfConfigError.emptyVersionComponent(syntax: syntax) + throw IfConfigError.emptyVersionComponent(syntax: versionSyntax) } // The second component is always "*", and is never used for comparison. if index == 1 { if componentString != "*" { - throw IfConfigError.compilerVersionSecondComponentNotWildcard(syntax: syntax) + throw IfConfigError.compilerVersionSecondComponentNotWildcard(syntax: versionSyntax) } try recordComponent(0) continue @@ -110,7 +115,7 @@ extension VersionTuple { // Every other component must be an integer value. guard let component = Int(componentString) else { - throw IfConfigError.invalidVersionOperand(name: "_compiler_version", syntax: syntax) + throw IfConfigError.invalidVersionOperand(name: "_compiler_version", syntax: versionSyntax) } try recordComponent(component) @@ -118,7 +123,7 @@ extension VersionTuple { // Only allowed to specify up to 5 version components. if components.count > 5 { - throw IfConfigError.compilerVersionTooManyComponents(syntax: syntax) + throw IfConfigError.compilerVersionTooManyComponents(syntax: versionSyntax) } // In the beginning, '_compiler_version(string-literal)' was designed for a @@ -159,6 +164,13 @@ extension VersionTuple { } /// Evaluate the condition of an `#if`. +/// - Parameters: +/// - condition: The condition to evaluate, which we assume has already been +/// folded according to the logical operators table. +/// - configuration: The configuration against which the condition will be +/// evaluated. +/// - Throws: Throws if an errors occur during evaluation. +/// - Returns: Whether the condition holds with the given build configuration. private func evaluateIfConfig( condition: ExprSyntax, configuration: some BuildConfiguration @@ -223,7 +235,6 @@ private func evaluateIfConfig( let fnName = call.calledExpression.simpleIdentifierExpr, let fn = IfConfigFunctions(rawValue: fnName) { - /// Perform a check for an operation that takes a single identifier argument. func doSingleIdentifierArgumentCheck(_ body: (String, ExprSyntax) -> Bool?) throws -> Bool? { // Ensure that we have a single argument that is a simple identifier. @@ -297,6 +308,8 @@ private func evaluateIfConfig( let arg = argExpr.simpleIdentifierExpr, let expectedEndianness = Endianness(rawValue: arg) else { + // FIXME: Custom error message when the endianness doesn't match any + // case. result = nil break } @@ -365,8 +378,9 @@ private func evaluateIfConfig( throw IfConfigError.canImportMissingModule(syntax: ExprSyntax(call)) } + // FIXME: This is a gross hack. Actually look at the sequence of + // `MemberAccessExprSyntax` nodes and pull out the identifiers. let importPath = firstArg.expression.trimmedDescription.split(separator: ".") - // FIXME: Check to make sure we have all identifiers here. // If there is a second argument, it shall have the label _version or // _underlyingVersion. @@ -428,7 +442,7 @@ extension IfConfigState { /// insufficient information to make a determination. public init(condition: some ExprSyntaxProtocol, configuration: some BuildConfiguration) throws { // Apply operator folding for !/&&/||. - let foldedCondition = try OperatorTable.logicalOperators.foldAll(condition).as(ExprSyntax.self)! + let foldedCondition = try OperatorTable.logicalOperators.foldAll(condition).cast(ExprSyntax.self) let result = try evaluateIfConfig(condition: foldedCondition, configuration: configuration) self = result ? .active : .inactive diff --git a/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md index 05641597332..963315c5290 100644 --- a/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md +++ b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md @@ -1,7 +1,5 @@ # `SwiftIfConfig` - - A library to evaluate `#if` conditionals within a Swift syntax tree. ## Overview @@ -29,6 +27,6 @@ The syntax tree and its parser do not reason about the build configuration. Rath The `SwiftIfConfig` library provides utilities to determine which syntax nodes are part of a particular build configuration. Each utility requires that one provide a specific build configuration (i.e., an instance of a type that conforms to the protocol), and provides a different view on essentially the same information: * and are visitor types that only visit the syntax nodes that are included ("active") for a given build configuration, implicitly skipping any nodes within inactive `#if` clauses. -* `SyntaxProtocol.removingInactive(in:)` produces a syntax node that removes all inactive regions (and their corresponding `IfConfigDeclSyntax` nodes) from the given syntax tree, returning a new tree that is free of `#if` conditions. -* `IfConfigDeclSyntax.activeClause(in:)` determines which of the clauses of an `#if` is active for the given build configuration, returning the active clause. -* `SyntaxProtocol.isActive(in:)` determines whether the given syntax node is active for the given build configuration. \ No newline at end of file +* ``SyntaxProtocol/removingInactive(in:)`` produces a syntax node that removes all inactive regions (and their corresponding `IfConfigDeclSyntax` nodes) from the given syntax tree, returning a new tree that is free of `#if` conditions. +* ``IfConfigDeclSyntax.activeClause(in:)`` determines which of the clauses of an `#if` is active for the given build configuration, returning the active clause. +* ``SyntaxProtocol.isActive(in:)`` determines whether the given syntax node is active for the given build configuration. diff --git a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift index 544cc40641e..0ceb17b5416 100644 --- a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift +++ b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift @@ -20,8 +20,7 @@ extension BooleanLiteralExprSyntax { extension TupleExprSyntax { /// Whether this tuple is a parenthesized expression, e.g., (x). var isParentheses: Bool { - guard elements.count == 1, let element = elements.first else { return false } - return element.label == nil + return elements.singleUnlabeledExpression != nil } } diff --git a/Sources/SwiftIfConfig/VersionTuple.swift b/Sources/SwiftIfConfig/VersionTuple.swift index 2edc8649229..6e37d736095 100644 --- a/Sources/SwiftIfConfig/VersionTuple.swift +++ b/Sources/SwiftIfConfig/VersionTuple.swift @@ -14,14 +14,20 @@ public struct VersionTuple { /// The components of the version tuple, start with the major version. public var components: [Int] -} -extension VersionTuple { - /// Create a version tuple from its components. - public init(_ components: Int...) { + /// Create a version tuple from a non-empty array of components. + public init(components: [Int]) { + precondition(!components.isEmpty) self.components = components } + /// Create a version tuple from its components. + public init(_ firstComponent: Int, _ remainingComponents: Int...) { + self.components = [] + self.components.append(firstComponent) + self.components.append(contentsOf: remainingComponents) + } + /// Parse a string into a version tuple, returning `nil` if any errors were /// present. public init?(parsing string: String) { @@ -34,10 +40,10 @@ extension VersionTuple { components.append(component) } + + if components.isEmpty { return nil } } -} -extension VersionTuple { /// Normalize the version tuple by removing trailing zeroes. var normalized: VersionTuple { var newComponents = components diff --git a/Tests/SwiftIfConfigTest/VisitorTests.swift b/Tests/SwiftIfConfigTest/VisitorTests.swift index 69bcdd47fb4..9514db67cfd 100644 --- a/Tests/SwiftIfConfigTest/VisitorTests.swift +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -24,7 +24,9 @@ class AllActiveVisitor: ActiveSyntaxAnyVisitor { super.init(viewMode: .sourceAccurate, configuration: configuration) } open override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { - XCTAssertTrue(try! node.isActive(in: configuration)) + var active: Bool = false + XCTAssertNoThrow(try active = node.isActive(in: configuration)) + XCTAssertTrue(active) return .visitChildren } } From f35c9e8e23547b38182890730d1c5c9d678a27ac Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Sat, 24 Jun 2023 23:05:07 -0700 Subject: [PATCH 17/42] [Build configuration] Drop optionality from protocol requirement result types The optional return was used to mean "don't know", but was always treated as false. Instead, make all of the result types non-optional, and allow these operations to throw to indicate failure. While here, drop the "syntax" parameters to all of these functions. We shouldn't be working with syntax inside the build configuration. --- .../SwiftIfConfig/BuildConfiguration.swift | 77 ++++++++-------- .../SwiftIfConfig/IfConfigEvaluation.swift | 89 ++++++------------- .../TestingBuildConfiguration.swift | 29 +++--- 3 files changed, 78 insertions(+), 117 deletions(-) diff --git a/Sources/SwiftIfConfig/BuildConfiguration.swift b/Sources/SwiftIfConfig/BuildConfiguration.swift index 66e0987adb2..4e1b6be441c 100644 --- a/Sources/SwiftIfConfig/BuildConfiguration.swift +++ b/Sources/SwiftIfConfig/BuildConfiguration.swift @@ -9,7 +9,6 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// -import SwiftSyntax /// Describes the ordering of a sequence of bytes that make up a word of /// storage for a particular architecture. @@ -43,10 +42,9 @@ public enum CanImportVersion { /// Providing complete build configuration information effectively requires /// a Swift compiler, because (for example) determining whether a module can /// be imported is a complicated task only implemented in the Swift compiler. -/// Therefore, many of the queries return `Bool?`, where `nil` indicates -/// that the answer is not known. Clients that don't have a lot of context -/// (such as an IDE that does not have access to the compiler command line) -/// can return `nil`. +/// Therefore, queries are permitted to throw an error to report when they +/// cannot answer a query, in which case this error will be reported to +/// the caller. public protocol BuildConfiguration { /// Determine whether a given custom build condition has been set. /// @@ -54,7 +52,7 @@ public protocol BuildConfiguration { /// the Swift compiler. For example, `-DDEBUG` sets the custom condition /// named `DEBUG`, which could be checked with, e.g., /// - /// ``` + /// ```swift /// #if DEBUG /// // ... /// #endif @@ -63,10 +61,8 @@ public protocol BuildConfiguration { /// - Parameters: /// - name: The name of the custom build condition being checked (e.g., /// `DEBUG`. - /// - syntax: The syntax node for the name of the custom build - /// configuration. /// - Returns: Whether the custom condition is set. - func isCustomConditionSet(name: String, syntax: ExprSyntax) -> Bool? + func isCustomConditionSet(name: String) throws -> Bool /// Determine whether the given feature is enabled. /// @@ -74,7 +70,7 @@ public protocol BuildConfiguration { /// options such as `--enable-upcoming-feature`, and can be checked with /// the `hasFeature` syntax, e.g., /// - /// ``` + /// ```swift /// #if hasFeature(VariadicGenerics) /// // ... /// #endif @@ -82,16 +78,15 @@ public protocol BuildConfiguration { /// /// - Parameters: /// - name: The name of the feature being checked. - /// - syntax: The syntax node for the `hasFeature()`. /// - Returns: Whether the requested feature is available. - func hasFeature(name: String, syntax: ExprSyntax) -> Bool? + func hasFeature(name: String) throws -> Bool /// Determine whether the given attribute is available. /// /// Attributes are determined by the Swift compiler. They can be checked /// with `hasAttribute` syntax, e.g., /// - /// ``` + /// ```swift /// #if hasAttribute(available) /// // ... /// #endif @@ -99,9 +94,8 @@ public protocol BuildConfiguration { /// /// - Parameters: /// - name: The name of the attribute being queried. - /// - syntax: The syntax node for the `hasAttribute()`. /// - Returns: Whether the requested attribute is supported. - func hasAttribute(name: String, syntax: ExprSyntax) -> Bool? + func hasAttribute(name: String) throws -> Bool /// Determine whether a module with the given import path can be imported, /// with additional version information. @@ -109,7 +103,7 @@ public protocol BuildConfiguration { /// The availability of a module for import can be checked with `canImport`, /// e.g., /// - /// ``` + /// ```swift /// #if canImport(UIKit) /// // ... /// #endif @@ -126,80 +120,79 @@ public protocol BuildConfiguration { /// - version: The version restriction on the imported module. For the /// normal `canImport()` syntax, this will always be /// `CanImportVersion.unversioned`. - /// - syntax: The syntax node for the `canImport` expression. /// - Returns: Whether the module can be imported. - func canImport(importPath: [String], version: CanImportVersion, syntax: ExprSyntax) -> Bool? + func canImport(importPath: [String], version: CanImportVersion) throws -> Bool /// Determine whether the given name is the active target OS (e.g., Linux, iOS). /// /// The target operating system can be queried with `os()`, e.g., /// - /// ``` + /// ```swift /// #if os(Linux) /// // Linux-specific implementation /// #endif + /// ``` /// /// - Parameters: /// - name: The name of the operating system being queried, such as `Linux`, /// `Windows`, `macOS`, etc. - /// - syntax: The syntax node for the `os()` expression. /// - Returns: Whether the given operating system name is the target operating /// system, i.e., the operating system for which code is being generated. - func isActiveTargetOS(name: String, syntax: ExprSyntax) -> Bool? + func isActiveTargetOS(name: String) throws -> Bool /// Determine whether the given name is the active target architecture /// (e.g., x86_64, arm64). /// /// The target processor architecture can be queried with `arch()`, e.g., /// - /// ``` + /// ```swift /// #if arch(x86_64) /// // 64-bit x86 Intel-specific code /// #endif + /// ``` /// /// - Parameters: /// - name: The name of the target architecture to check. - /// - syntax: The syntax node for the `arch()` expression. /// - Returns: Whether the given processor architecture is the target /// architecture. - func isActiveTargetArchitecture(name: String, syntax: ExprSyntax) -> Bool? + func isActiveTargetArchitecture(name: String) throws -> Bool /// Determine whether the given name is the active target environment (e.g., simulator) /// /// The target environment can be queried with `targetEnvironment()`, /// e.g., /// - /// ``` + /// ```swift /// #if targetEnvironment(simulator) /// // Simulator-specific code /// #endif + /// ``` /// /// - Parameters: /// - name: The name of the target environment to check. - /// - syntax: The syntax node for the `targetEnvironment()` - /// expression. /// - Returns: Whether the target platform is for a specific environment, /// such as a simulator or emulator. - func isActiveTargetEnvironment(name: String, syntax: ExprSyntax) -> Bool? + func isActiveTargetEnvironment(name: String) throws -> Bool /// Determine whether the given name is the active target runtime (e.g., _ObjC vs. _Native) /// /// The target runtime can only be queried by an experimental syntax /// `_runtime()`, e.g., /// + /// ```swift /// #if _runtime(_ObjC) /// // Code that depends on Swift being built for use with the Objective-C /// // runtime, e.g., on Apple platforms. /// #endif + /// ``` /// /// The only other runtime is "none", when Swift isn't tying into any other /// specific runtime. /// /// - Parameters: /// - name: The name of the runtime. - /// - syntax: The syntax node for the `_runtime()` expression. /// - Returns: Whether the target runtime matches the given name. - func isActiveTargetRuntime(name: String, syntax: ExprSyntax) -> Bool? + func isActiveTargetRuntime(name: String) throws -> Bool /// Determine whether the given name is the active target pointer authentication scheme (e.g., arm64e). /// @@ -207,17 +200,16 @@ public protocol BuildConfiguration { /// signed, as a security mitigation. This scheme can only be queried by /// an experimental syntax `_ptrath()`, e.g., /// - /// ``` + /// ```swift /// #if _ptrauth(arm64e) /// // Special logic for arm64e pointer signing /// #endif - /// + /// ``` /// - Parameters: /// - name: The name of the pointer authentication scheme to check. - /// - syntax: The syntax node for the `_ptrauth()` expression. /// - Returns: Whether the code generated for the target will use the given /// pointer authentication scheme. - func isActiveTargetPointerAuthentication(name: String, syntax: ExprSyntax) -> Bool? + func isActiveTargetPointerAuthentication(name: String) throws -> Bool /// The bit width of a data pointer for the target architecture. /// @@ -225,21 +217,24 @@ public protocol BuildConfiguration { /// bits in `Int`/`UInt`) can only be queried with the experimental syntax /// `_pointerBitWidth(_)`, e.g., /// - /// ``` + /// ```swift /// #if _pointerBitWidth(32) /// // 32-bit system /// #endif - var targetPointerBitWidth: Int? { get } + /// ``` + var targetPointerBitWidth: Int { get } /// The endianness of the target architecture. /// /// The target's endianness can onyl be queried with the experimental syntax /// `_endian()`, where `` can be either "big" or "little", e.g., /// + /// ```swift /// #if _endian(little) /// // Swap some bytes around for network byte order /// #endif - var endianness: Endianness? { get } + /// ``` + var endianness: Endianness { get } /// The effective language version, which can be set by the user (e.g., 5.0). /// @@ -247,11 +242,11 @@ public protocol BuildConfiguration { /// how the supported language version compares, as described by /// [SE-0212](https://github.com/apple/swift-evolution/blob/main/proposals/0212-compiler-version-directive.md). For example: /// - /// ``` + /// ```swift /// #if swift(>=5.5) /// // Hooray, we can use tasks! /// ``` - var languageVersion: VersionTuple? { get } + var languageVersion: VersionTuple { get } /// The version of the compiler (e.g., 5.9). /// @@ -259,9 +254,9 @@ public protocol BuildConfiguration { /// checks the specific version of the compiler being used to process the /// code, e.g., /// - /// ``` + /// ```swift /// #if compiler(>=5.7) /// // Hoorway, we can implicitly open existentials! /// #endif - var compilerVersion: VersionTuple? { get } + var compilerVersion: VersionTuple { get } } diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index f0ed56ddb19..f2529d6726d 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -14,8 +14,8 @@ import SwiftOperators enum IfConfigError: Error, CustomStringConvertible { case unknownExpression(ExprSyntax) - case unhandledCustomCondition(name: String, syntax: TokenSyntax) case unhandledFunction(name: String, syntax: ExprSyntax) + case requiresUnlabeledArgument(name: String, role: String) case unsupportedVersionOperator(name: String, operator: TokenSyntax) case invalidVersionOperand(name: String, syntax: ExprSyntax) case emptyVersionComponent(syntax: ExprSyntax) @@ -31,12 +31,12 @@ enum IfConfigError: Error, CustomStringConvertible { case .unknownExpression: return "invalid conditional compilation expression" - case .unhandledCustomCondition(name: let name, syntax: _): - return "build configuration cannot handle custom condition '\(name)'" - case .unhandledFunction(name: let name, syntax: _): return "build configuration cannot handle '\(name)'" + case .requiresUnlabeledArgument(name: let name, role: let role): + return "\(name) requires a single unlabeled argument for the \(role)" + case .unsupportedVersionOperator(name: let name, operator: let op): return "'\(name)' version check does not support operator '\(op.trimmedDescription)'" @@ -191,10 +191,7 @@ private func evaluateIfConfig( let ident = identExpr.identifier.text // Evaluate the custom condition. If the build configuration cannot answer this query, fail. - guard let result = configuration.isCustomConditionSet(name: ident, syntax: ExprSyntax(identExpr)) else { - throw IfConfigError.unhandledCustomCondition(name: ident, syntax: identExpr.identifier) - } - return result + return try configuration.isCustomConditionSet(name: ident) } // Logical '!'. @@ -236,38 +233,33 @@ private func evaluateIfConfig( let fn = IfConfigFunctions(rawValue: fnName) { /// Perform a check for an operation that takes a single identifier argument. - func doSingleIdentifierArgumentCheck(_ body: (String, ExprSyntax) -> Bool?) throws -> Bool? { + func doSingleIdentifierArgumentCheck(_ body: (String) throws -> Bool, role: String) throws -> Bool { // Ensure that we have a single argument that is a simple identifier. guard let argExpr = call.argumentList.singleUnlabeledExpression, let arg = argExpr.simpleIdentifierExpr - else { return nil } - - guard let result = body(arg, ExprSyntax(argExpr)) else { - throw IfConfigError.unhandledFunction(name: fnName, syntax: ExprSyntax(call)) + else { + throw IfConfigError.requiresUnlabeledArgument(name: fnName, role: role) } - return result + return try body(arg) } /// Perform a check for a version constraint as used in the "swift" or "compiler" version checks. - func doVersionComparisonCheck(_ actualVersion: VersionTuple?) throws -> Bool? { + func doVersionComparisonCheck(_ actualVersion: VersionTuple) throws -> Bool { // Ensure that we have a single unlabeled argument that is either >= or < as a prefix // operator applied to a version. guard let argExpr = call.argumentList.singleUnlabeledExpression, let unaryArg = argExpr.as(PrefixOperatorExprSyntax.self), let opToken = unaryArg.operatorToken else { - return nil + throw IfConfigError.requiresUnlabeledArgument(name: fnName, role: "version comparison (>= or <= a version)") } + // Parse the version. guard let version = VersionTuple(parsing: unaryArg.postfixExpression.trimmedDescription) else { throw IfConfigError.invalidVersionOperand(name: fnName, syntax: unaryArg.postfixExpression) } - guard let actualVersion else { - throw IfConfigError.unhandledFunction(name: fnName, syntax: argExpr) - } - switch opToken.text { case ">=": return actualVersion >= version @@ -278,28 +270,27 @@ private func evaluateIfConfig( } } - let result: Bool? switch fn { case .hasAttribute: - result = try doSingleIdentifierArgumentCheck(configuration.hasAttribute) + return try doSingleIdentifierArgumentCheck(configuration.hasAttribute, role: "attribute") case .hasFeature: - result = try doSingleIdentifierArgumentCheck(configuration.hasFeature) + return try doSingleIdentifierArgumentCheck(configuration.hasFeature, role: "feature") case .os: - result = try doSingleIdentifierArgumentCheck(configuration.isActiveTargetOS) + return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetOS, role: "operating system") case .arch: - result = try doSingleIdentifierArgumentCheck(configuration.isActiveTargetArchitecture) + return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetArchitecture, role: "architecture") case .targetEnvironment: - result = try doSingleIdentifierArgumentCheck(configuration.isActiveTargetEnvironment) + return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetEnvironment, role: "environment") case ._runtime: - result = try doSingleIdentifierArgumentCheck(configuration.isActiveTargetRuntime) + return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetRuntime, role: "runtime") case ._ptrauth: - result = try doSingleIdentifierArgumentCheck(configuration.isActiveTargetPointerAuthentication) + return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetPointerAuthentication, role: "pointer authentication scheme") case ._endian: // Ensure that we have a single argument that is a simple identifier, @@ -308,18 +299,10 @@ private func evaluateIfConfig( let arg = argExpr.simpleIdentifierExpr, let expectedEndianness = Endianness(rawValue: arg) else { - // FIXME: Custom error message when the endianness doesn't match any - // case. - result = nil - break + throw IfConfigError.requiresUnlabeledArgument(name: fnName, role: "endiannes ('big' or 'little')") } - // If the build configuration doesn't know the endianness, fail. - guard let targetEndianness = configuration.endianness else { - throw IfConfigError.unhandledFunction(name: fnName, syntax: ExprSyntax(call)) - } - - result = targetEndianness == expectedEndianness + return configuration.endianness == expectedEndianness case ._pointerBitWidth: // Ensure that we have a single argument that is a simple identifier, which @@ -330,22 +313,16 @@ private func evaluateIfConfig( argFirst == "_", let expectedPointerBitWidth = Int(arg.dropFirst()) else { - result = nil - break - } - - // If the build configuration doesn't know the pointer bit width, fail. - guard let targetPointerBitWidth = configuration.targetPointerBitWidth else { - throw IfConfigError.unhandledFunction(name: fnName, syntax: ExprSyntax(call)) + throw IfConfigError.requiresUnlabeledArgument(name: fnName, role: "pointer bit with ('_' followed by an integer)") } - result = targetPointerBitWidth == expectedPointerBitWidth + return configuration.targetPointerBitWidth == expectedPointerBitWidth case .swift: - result = try doVersionComparisonCheck(configuration.languageVersion) + return try doVersionComparisonCheck(configuration.languageVersion) case .compiler: - result = try doVersionComparisonCheck(configuration.compilerVersion) + return try doVersionComparisonCheck(configuration.compilerVersion) case ._compiler_version: // Argument is a single unlabeled argument containing a string @@ -363,11 +340,7 @@ private func evaluateIfConfig( let versionString = stringSegment.content.text let expectedVersion = try VersionTuple(parsingCompilerBuildVersion: versionString, argExpr) - guard let actualVersion = configuration.compilerVersion else { - throw IfConfigError.unhandledFunction(name: fnName, syntax: argExpr) - } - - return actualVersion >= expectedVersion + return configuration.compilerVersion >= expectedVersion case .canImport: // Retrieve the first argument, which must not have a label. This is @@ -421,17 +394,11 @@ private func evaluateIfConfig( version = .unversioned } - result = configuration.canImport( + return try configuration.canImport( importPath: importPath.map { String($0) }, - version: version, - syntax: ExprSyntax(call) + version: version ) } - - // If we found a result, return it. - if let result { return result } - - // Otherwise, fall through to diagnose the error } throw IfConfigError.unknownExpression(condition) diff --git a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift index 5e7a85aba9c..943cf0e9df2 100644 --- a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift +++ b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift @@ -18,23 +18,22 @@ struct TestingBuildConfiguration: BuildConfiguration { var features: Set = [] var attributes: Set = [] - func isCustomConditionSet(name: String, syntax: ExprSyntax) -> Bool? { + func isCustomConditionSet(name: String) -> Bool { customConditions.contains(name) } - func hasFeature(name: String, syntax: ExprSyntax) -> Bool? { + func hasFeature(name: String) -> Bool { features.contains(name) } - func hasAttribute(name: String, syntax: ExprSyntax) -> Bool? { + func hasAttribute(name: String) -> Bool { attributes.contains(name) } func canImport( importPath: [String], - version: CanImportVersion, - syntax: ExprSyntax - ) -> Bool? { + version: CanImportVersion + ) -> Bool { guard let moduleName = importPath.first else { return false } @@ -53,31 +52,31 @@ struct TestingBuildConfiguration: BuildConfiguration { } } - func isActiveTargetOS(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + func isActiveTargetOS(name: String) -> Bool { name == platformName } - func isActiveTargetArchitecture(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + func isActiveTargetArchitecture(name: String) -> Bool { name == "arm64" } - func isActiveTargetEnvironment(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + func isActiveTargetEnvironment(name: String) -> Bool { name == "simulator" } - func isActiveTargetRuntime(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + func isActiveTargetRuntime(name: String) -> Bool { name == "_Native" } - func isActiveTargetPointerAuthentication(name: String, syntax: SwiftSyntax.ExprSyntax) -> Bool? { + func isActiveTargetPointerAuthentication(name: String) -> Bool { name == "arm64e" } - var targetPointerBitWidth: Int? { 64 } + var targetPointerBitWidth: Int { 64 } - var endianness: SwiftIfConfig.Endianness? { .little } + var endianness: SwiftIfConfig.Endianness { .little } - var languageVersion: VersionTuple? { VersionTuple(5, 5) } + var languageVersion: VersionTuple { VersionTuple(5, 5) } - var compilerVersion: VersionTuple? { VersionTuple(5, 9, 1) } + var compilerVersion: VersionTuple { VersionTuple(5, 9, 1) } } From b6502e5b39826e95f133b6d69d47362dc4d9dfe4 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Sat, 24 Jun 2023 23:14:57 -0700 Subject: [PATCH 18/42] Simplify `isActive(in:)` using Alex Hoppen's suggestion --- Sources/SwiftIfConfig/IfConfigEvaluation.swift | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index f2529d6726d..674eea33935 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -470,22 +470,15 @@ extension SyntaxProtocol { /// configuration options `DEBUG` and `B` are provided, but `A` is not. public func isActive(in configuration: some BuildConfiguration) throws -> Bool { var currentNode: Syntax = Syntax(self) - var currentClause = currentNode.as(IfConfigClauseSyntax.self) - while let parent = currentNode.parent { // If the parent is an `#if` configuration, check whether our current // clause is active. If not, we're in an inactive region. - if let parentIfConfig = parent.as(IfConfigDeclSyntax.self) { - if try currentClause != nil && parentIfConfig.activeClause(in: configuration) != currentClause { + if let ifConfigClause = currentNode.as(IfConfigClauseSyntax.self), + let ifConfigDecl = ifConfigClause.parent?.as(IfConfigDeclSyntax.self) + { + if try ifConfigDecl.activeClause(in: configuration) != ifConfigClause { return false } - - currentClause = nil - } - - // If the parent node is an if configuration clause, store it. - if let parentClause = parent.as(IfConfigClauseSyntax.self) { - currentClause = parentClause } currentNode = parent From a21bb5ef4f261ad4c5232414ca5f5d11b4aa6f1e Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Sat, 24 Jun 2023 23:16:09 -0700 Subject: [PATCH 19/42] Use preconditionFailure --- Sources/SwiftIfConfig/IfConfigRewriter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftIfConfig/IfConfigRewriter.swift b/Sources/SwiftIfConfig/IfConfigRewriter.swift index c59f602f20d..7dfb00a2f29 100644 --- a/Sources/SwiftIfConfig/IfConfigRewriter.swift +++ b/Sources/SwiftIfConfig/IfConfigRewriter.swift @@ -215,7 +215,7 @@ class ActiveSyntaxRewriter: SyntaxRewriter { return dropInactive(outerBase: base, postfixIfConfig: postfixIfConfig) } - assert(false, "Unhandled postfix expression in #if elimination") + preconditionFailure("Unhandled postfix expression in #if elimination") return base } From 39961b0999092c42bbacd307a63dd4498b81b822 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Sat, 24 Jun 2023 23:16:54 -0700 Subject: [PATCH 20/42] Use editor placeholder for placeholder expression --- Sources/SwiftIfConfig/IfConfigRewriter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftIfConfig/IfConfigRewriter.swift b/Sources/SwiftIfConfig/IfConfigRewriter.swift index 7dfb00a2f29..ba4882a8727 100644 --- a/Sources/SwiftIfConfig/IfConfigRewriter.swift +++ b/Sources/SwiftIfConfig/IfConfigRewriter.swift @@ -243,7 +243,7 @@ class ActiveSyntaxRewriter: SyntaxRewriter { // syntax node so clients can recover more gracefully. return ExprSyntax( MissingExprSyntax( - placeholder: .init(.identifier("_"), presence: .missing) + placeholder: .init(.identifier("<#expression#>"), presence: .missing) ) ) } From 3ef7aa3e8b672546079c260a19d3c0c534ba60a5 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Sat, 24 Jun 2023 23:18:23 -0700 Subject: [PATCH 21/42] Minor cleanups --- Sources/SwiftIfConfig/IfConfigRewriter.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigRewriter.swift b/Sources/SwiftIfConfig/IfConfigRewriter.swift index ba4882a8727..d9f5f8a70d9 100644 --- a/Sources/SwiftIfConfig/IfConfigRewriter.swift +++ b/Sources/SwiftIfConfig/IfConfigRewriter.swift @@ -216,7 +216,6 @@ class ActiveSyntaxRewriter: SyntaxRewriter { } preconditionFailure("Unhandled postfix expression in #if elimination") - return base } /// Drop inactive regions from a postfix `#if` configuration, applying the @@ -228,7 +227,7 @@ class ActiveSyntaxRewriter: SyntaxRewriter { // Determine the active clause within this syntax node. // TODO: Swallows errors guard let activeClause = try? postfixIfConfig.config.activeClause(in: configuration), - case .`postfixExpression`(let postfixExpr) = activeClause.elements + case .postfixExpression(let postfixExpr) = activeClause.elements else { // If there is no active clause, return the base. From 83f38249dec659bcc692316acce8b45a3ad354c5 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Sat, 24 Jun 2023 23:24:05 -0700 Subject: [PATCH 22/42] Minor cleanups to the #if rewriter --- Sources/SwiftIfConfig/IfConfigRewriter.swift | 23 +++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigRewriter.swift b/Sources/SwiftIfConfig/IfConfigRewriter.swift index d9f5f8a70d9..b03d71cafb1 100644 --- a/Sources/SwiftIfConfig/IfConfigRewriter.swift +++ b/Sources/SwiftIfConfig/IfConfigRewriter.swift @@ -25,7 +25,8 @@ import SwiftSyntax /// to a particular build configuration build configuration. /// /// Given an example such as -/// ``` +/// +/// ```swift /// #if os(Linux) /// func f() { } /// #elseif os(iOS) @@ -37,14 +38,14 @@ import SwiftSyntax /// those nodes that are in active clauses. When rewriting the above given /// a build configuration for Linux, the resulting tree will be /// -/// ``` +/// ```swift /// func f() { } /// ``` /// /// When rewriting the above given a build configuration for iOS, the resulting /// tree will be /// -/// ``` +/// ```swift /// func g() { } /// ``` /// @@ -84,9 +85,14 @@ class ActiveSyntaxRewriter: SyntaxRewriter { continue } - let innerElements = Syntax(elements).cast(List.self) - let newInnerElements = dropInactive(innerElements, elementAsIfConfig: elementAsIfConfig) - newElements.append(contentsOf: newInnerElements) + // In a well-formed syntax tree, the element list is always the + // same type as List. However, handle a manually-constructed, + // ill-formed syntax tree gracefully by dropping the inner elements + // as well. + if let innerElements = Syntax(elements).as(List.self) { + let newInnerElements = dropInactive(innerElements, elementAsIfConfig: elementAsIfConfig) + newElements.append(contentsOf: newInnerElements) + } continue } @@ -231,8 +237,9 @@ class ActiveSyntaxRewriter: SyntaxRewriter { else { // If there is no active clause, return the base. - // Prefer the base we have and, if not, use the outer base. - // TODO: Can we have both? If so, then what? + // Prefer the base we have and, if not, use the outer base. We can + // only have both in an ill-formed syntax tree that was manually + // created. if let base = postfixIfConfig.base ?? outerBase { return base } From 7ce49811c2ce5c58f09b60b7b6f3b2d1d5f067c8 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Sun, 6 Aug 2023 14:01:54 -0700 Subject: [PATCH 23/42] Rework use of deprecated APIs --- .../SwiftIfConfig/IfConfigEvaluation.swift | 38 +++++++++---------- Sources/SwiftIfConfig/IfConfigRewriter.swift | 2 +- .../SwiftIfConfig/SyntaxLiteralUtils.swift | 10 ++--- Tests/SwiftIfConfigTest/VisitorTests.swift | 4 +- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index 674eea33935..fef2a1cff26 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -182,13 +182,13 @@ private func evaluateIfConfig( // Integer literals evaluate true if that are not "0". if let intLiteral = condition.as(IntegerLiteralExprSyntax.self) { - return intLiteral.digits.text != "0" + return intLiteral.literal.text != "0" } // Declaration references are for custom compilation flags. - if let identExpr = condition.as(IdentifierExprSyntax.self) { + if let identExpr = condition.as(DeclReferenceExprSyntax.self) { // FIXME: Need a real notion of an identifier. - let ident = identExpr.identifier.text + let ident = identExpr.baseName.text // Evaluate the custom condition. If the build configuration cannot answer this query, fail. return try configuration.isCustomConditionSet(name: ident) @@ -196,21 +196,21 @@ private func evaluateIfConfig( // Logical '!'. if let prefixOp = condition.as(PrefixOperatorExprSyntax.self), - prefixOp.operatorToken?.text == "!" + prefixOp.operator?.text == "!" { - return try !evaluateIfConfig(condition: prefixOp.postfixExpression, configuration: configuration) + return try !evaluateIfConfig(condition: prefixOp.expression, configuration: configuration) } // Logical '&&' and '||'. if let binOp = condition.as(InfixOperatorExprSyntax.self), - let op = binOp.operatorOperand.as(BinaryOperatorExprSyntax.self), - (op.operatorToken.text == "&&" || op.operatorToken.text == "||") + let op = binOp.operator.as(BinaryOperatorExprSyntax.self), + (op.operator.text == "&&" || op.operator.text == "||") { // Evaluate the left-hand side. let lhsResult = try evaluateIfConfig(condition: binOp.leftOperand, configuration: configuration) // Short-circuit evaluation if we know the answer. - switch (lhsResult, op.operatorToken.text) { + switch (lhsResult, op.operator.text) { case (true, "||"): return true case (false, "&&"): return false default: break @@ -235,7 +235,7 @@ private func evaluateIfConfig( /// Perform a check for an operation that takes a single identifier argument. func doSingleIdentifierArgumentCheck(_ body: (String) throws -> Bool, role: String) throws -> Bool { // Ensure that we have a single argument that is a simple identifier. - guard let argExpr = call.argumentList.singleUnlabeledExpression, + guard let argExpr = call.arguments.singleUnlabeledExpression, let arg = argExpr.simpleIdentifierExpr else { throw IfConfigError.requiresUnlabeledArgument(name: fnName, role: role) @@ -248,16 +248,16 @@ private func evaluateIfConfig( func doVersionComparisonCheck(_ actualVersion: VersionTuple) throws -> Bool { // Ensure that we have a single unlabeled argument that is either >= or < as a prefix // operator applied to a version. - guard let argExpr = call.argumentList.singleUnlabeledExpression, + guard let argExpr = call.arguments.singleUnlabeledExpression, let unaryArg = argExpr.as(PrefixOperatorExprSyntax.self), - let opToken = unaryArg.operatorToken + let opToken = unaryArg.operator else { throw IfConfigError.requiresUnlabeledArgument(name: fnName, role: "version comparison (>= or <= a version)") } // Parse the version. - guard let version = VersionTuple(parsing: unaryArg.postfixExpression.trimmedDescription) else { - throw IfConfigError.invalidVersionOperand(name: fnName, syntax: unaryArg.postfixExpression) + guard let version = VersionTuple(parsing: unaryArg.expression.trimmedDescription) else { + throw IfConfigError.invalidVersionOperand(name: fnName, syntax: unaryArg.expression) } switch opToken.text { @@ -295,7 +295,7 @@ private func evaluateIfConfig( case ._endian: // Ensure that we have a single argument that is a simple identifier, // either "little" or "big". - guard let argExpr = call.argumentList.singleUnlabeledExpression, + guard let argExpr = call.arguments.singleUnlabeledExpression, let arg = argExpr.simpleIdentifierExpr, let expectedEndianness = Endianness(rawValue: arg) else { @@ -307,7 +307,7 @@ private func evaluateIfConfig( case ._pointerBitWidth: // Ensure that we have a single argument that is a simple identifier, which // is an underscore followed by an integer. - guard let argExpr = call.argumentList.singleUnlabeledExpression, + guard let argExpr = call.arguments.singleUnlabeledExpression, let arg = argExpr.simpleIdentifierExpr, let argFirst = arg.first, argFirst == "_", @@ -327,7 +327,7 @@ private func evaluateIfConfig( case ._compiler_version: // Argument is a single unlabeled argument containing a string // literal. - guard let argExpr = call.argumentList.singleUnlabeledExpression, + guard let argExpr = call.arguments.singleUnlabeledExpression, let stringLiteral = argExpr.as(StringLiteralExprSyntax.self), stringLiteral.segments.count == 1, let segment = stringLiteral.segments.first, @@ -345,7 +345,7 @@ private func evaluateIfConfig( case .canImport: // Retrieve the first argument, which must not have a label. This is // the module import path. - guard let firstArg = call.argumentList.first, + guard let firstArg = call.arguments.first, firstArg.label == nil else { throw IfConfigError.canImportMissingModule(syntax: ExprSyntax(call)) @@ -358,7 +358,7 @@ private func evaluateIfConfig( // If there is a second argument, it shall have the label _version or // _underlyingVersion. let version: CanImportVersion - if let secondArg = call.argumentList.dropFirst().first { + if let secondArg = call.arguments.dropFirst().first { if secondArg.label?.text != "_version" && secondArg.label?.text != "_underlyingVersion" { throw IfConfigError.canImportLabel(syntax: secondArg.expression) } @@ -387,7 +387,7 @@ private func evaluateIfConfig( version = .underlyingVersion(versionTuple) } - if call.argumentList.count > 2 { + if call.arguments.count > 2 { throw IfConfigError.canImportTwoParameters(syntax: ExprSyntax(call)) } } else { diff --git a/Sources/SwiftIfConfig/IfConfigRewriter.swift b/Sources/SwiftIfConfig/IfConfigRewriter.swift index b03d71cafb1..4c8de987116 100644 --- a/Sources/SwiftIfConfig/IfConfigRewriter.swift +++ b/Sources/SwiftIfConfig/IfConfigRewriter.swift @@ -121,7 +121,7 @@ class ActiveSyntaxRewriter: SyntaxRewriter { return super.visit(rewrittenNode) } - override func visit(_ node: MemberDeclListSyntax) -> MemberDeclListSyntax { + override func visit(_ node: MemberBlockItemListSyntax) -> MemberBlockItemListSyntax { let rewrittenNode = dropInactive(node) { element in return element.decl.as(IfConfigDeclSyntax.self) } diff --git a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift index 0ceb17b5416..eb64ae149a9 100644 --- a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift +++ b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift @@ -13,7 +13,7 @@ import SwiftSyntax extension BooleanLiteralExprSyntax { var literalValue: Bool { - return booleanLiteral.tokenKind == .keyword(.true) + return literal.tokenKind == .keyword(.true) } } @@ -24,7 +24,7 @@ extension TupleExprSyntax { } } -extension TupleExprElementListSyntax { +extension LabeledExprListSyntax { /// If this list is a single, unlabeled expression, return it. var singleUnlabeledExpression: ExprSyntax? { guard count == 1, let element = first else { return nil } @@ -35,13 +35,13 @@ extension TupleExprElementListSyntax { extension ExprSyntax { /// Whether this is a simple identifier expression and, if so, what the identifier string is. var simpleIdentifierExpr: String? { - guard let identExpr = self.as(IdentifierExprSyntax.self), - identExpr.declNameArguments == nil + guard let identExpr = self.as(DeclReferenceExprSyntax.self), + identExpr.argumentNames == nil else { return nil } // FIXME: Handle escaping here. - return identExpr.identifier.text + return identExpr.baseName.text } } diff --git a/Tests/SwiftIfConfigTest/VisitorTests.swift b/Tests/SwiftIfConfigTest/VisitorTests.swift index 9514db67cfd..8d063ab49aa 100644 --- a/Tests/SwiftIfConfigTest/VisitorTests.swift +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -131,8 +131,8 @@ public class VisitorTests: XCTestCase { } open override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { - if let identified = node.asProtocol(IdentifiedDeclSyntax.self) { - checkName(name: identified.identifier.text, node: node) + if let identified = node.asProtocol(NamedDeclSyntax.self) { + checkName(name: identified.name.text, node: node) } else if let identPattern = node.as(IdentifierPatternSyntax.self) { // FIXME: Should the above be an IdentifiedDeclSyntax? checkName(name: identPattern.identifier.text, node: node) From f5e35403e4cb718683eb99f080e14695256b72ce Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Fri, 11 Aug 2023 09:17:39 -0700 Subject: [PATCH 24/42] Teach active-clause visitors to handle diagnostics When the active-clause visitors encounter an error during evaluation of the `#if` conditions, translate the errors into diagnostics and and visit all of the clauses (because we don't know which is active). This behavior is configurable by ActiveSyntax(Any)Visitor subclasses. --- Sources/SwiftIfConfig/IfConfigVisitor.swift | 87 ++++++++++++----- .../TestingBuildConfiguration.swift | 23 ++++- Tests/SwiftIfConfigTest/VisitorTests.swift | 97 +++++++++++-------- 3 files changed, 143 insertions(+), 64 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigVisitor.swift b/Sources/SwiftIfConfig/IfConfigVisitor.swift index 897e81101c6..269fc066bcd 100644 --- a/Sources/SwiftIfConfig/IfConfigVisitor.swift +++ b/Sources/SwiftIfConfig/IfConfigVisitor.swift @@ -9,6 +9,7 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// +import SwiftDiagnostics import SwiftSyntax /// A syntax visitor that only visits the syntax nodes that are active @@ -36,29 +37,48 @@ import SwiftSyntax /// /// All notes visited by this visitor will have the "active" state, i.e., /// `node.isActive(in: configuration)` will evaluate to `.active` or will -/// throw. -/// -/// TODO: This visitor currently swallows errors uncovered while checking `#if` -/// conditions, which is deeply unfortunate. We need a better answer here. +/// throw. When errors occur, they will be reported via a call to +/// `reportEvaluationError`, which can report the errors (the default is to +/// turn them into diagnostics that go into the `diagnostics` array) and then +/// choose whether to visit all of the `#if` clauses (the default) or skip them. open class ActiveSyntaxVisitor: SyntaxVisitor { /// The build configuration, which will be queried for each relevant `#if`. public let configuration: Configuration + /// The set of diagnostics accumulated during this walk of active syntax. + public var diagnostics: [Diagnostic] = [] + public init(viewMode: SyntaxTreeViewMode, configuration: Configuration) { self.configuration = configuration super.init(viewMode: viewMode) } + /// Called when the evaluation of an `#if` condition produces an error. + /// + /// By default, this records diagnostics from the error into the `diagnostics` + /// array. + /// + /// - Returns: Whether to visit the children of the `#if` or not after the + /// error. By default, this function returns `.visitChildren`. + open func reportEvaluationError(at node: IfConfigDeclSyntax, error: Error) -> SyntaxVisitorContinueKind { + let newDiagnostics = error.asDiagnostics(at: node) + diagnostics.append(contentsOf: newDiagnostics) + return .visitChildren + } + open override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { - // If there is an active clause, visit it's children. - // FIXME: try? suppresses errors here. How shall we report them? - if let activeClause = try? node.activeClause(in: configuration), - let elements = activeClause.elements - { - walk(Syntax(elements)) - } + do { + // If there is an active clause, visit it's children. + if let activeClause = try node.activeClause(in: configuration), + let elements = activeClause.elements { + walk(Syntax(elements)) + } - return .skipChildren + // Skip everything else in the + return .skipChildren + } catch { + return reportEvaluationError(at: node, error: error) + } } } @@ -89,26 +109,49 @@ open class ActiveSyntaxVisitor: SyntaxVisitor /// `node.isActive(in: configuration)` will evaluate to `.active` or will /// throw. /// -/// TODO: This visitor currently swallows errors uncovered while checking `#if` -/// conditions, which is deeply unfortunate. We need a better answer here. +/// All notes visited by this visitor will have the "active" state, i.e., +/// `node.isActive(in: configuration)` will evaluate to `.active` or will +/// throw. When errors occur, they will be reported via a call to +/// `reportEvaluationError`, which can report the errors (the default is to +/// turn them into diagnostics that go into the `diagnostics` array) and then +/// choose whether to visit all of the `#if` clauses (the default) or skip them. open class ActiveSyntaxAnyVisitor: SyntaxAnyVisitor { /// The build configuration, which will be queried for each relevant `#if`. public let configuration: Configuration + /// The set of diagnostics accumulated during this walk of active syntax. + public var diagnostics: [Diagnostic] = [] + public init(viewMode: SyntaxTreeViewMode, configuration: Configuration) { self.configuration = configuration super.init(viewMode: viewMode) } + /// Called when the evaluation of an `#if` condition produces an error. + /// + /// By default, this records diagnostics from the error into the `diagnostics` + /// array. + /// + /// - Returns: Whether to visit the children of the `#if` or not after the + /// error. By default, this function returns `.visitChildren`. + open func reportEvaluationError(at node: IfConfigDeclSyntax, error: Error) -> SyntaxVisitorContinueKind { + let newDiagnostics = error.asDiagnostics(at: node) + diagnostics.append(contentsOf: newDiagnostics) + return .visitChildren + } + open override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { - // If there is an active clause, visit it's children. - // FIXME: try? suppresses errors here. How shall we report them? - if let activeClause = try? node.activeClause(in: configuration), - let elements = activeClause.elements - { - walk(Syntax(elements)) - } + do { + // If there is an active clause, visit it's children. + if let activeClause = try node.activeClause(in: configuration), + let elements = activeClause.elements { + walk(Syntax(elements)) + } - return .skipChildren + // Skip everything else in the + return .skipChildren + } catch { + return reportEvaluationError(at: node, error: error) + } } } diff --git a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift index 943cf0e9df2..d3c2dfd2304 100644 --- a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift +++ b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift @@ -12,12 +12,27 @@ import SwiftIfConfig import SwiftSyntax +enum BuildConfigurationError: Error, CustomStringConvertible { + case badAttribute(String) + + var description: String { + switch self { + case .badAttribute(let attribute): + return "unacceptable attribute \(attribute)" + } + } +} + struct TestingBuildConfiguration: BuildConfiguration { var platformName: String = "Linux" var customConditions: Set = [] var features: Set = [] var attributes: Set = [] + /// A set of attribute names that are "bad", causing the build configuration + /// to throw an error if queried. + var badAttributes: Set = [] + func isCustomConditionSet(name: String) -> Bool { customConditions.contains(name) } @@ -26,8 +41,12 @@ struct TestingBuildConfiguration: BuildConfiguration { features.contains(name) } - func hasAttribute(name: String) -> Bool { - attributes.contains(name) + func hasAttribute(name: String) throws -> Bool { + if badAttributes.contains(name) { + throw BuildConfigurationError.badAttribute(name) + } + + return attributes.contains(name) } func canImport( diff --git a/Tests/SwiftIfConfigTest/VisitorTests.swift b/Tests/SwiftIfConfigTest/VisitorTests.swift index 8d063ab49aa..49c8a1adc15 100644 --- a/Tests/SwiftIfConfigTest/VisitorTests.swift +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -31,6 +31,43 @@ class AllActiveVisitor: ActiveSyntaxAnyVisitor { } } +class NameCheckingVisitor: ActiveSyntaxAnyVisitor { + /// The set of names we are expected to visit. Any syntax nodes with + /// names that aren't here will be rejected, and each of the names listed + /// here must occur exactly once. + var expectedNames: Set + + init(configuration: TestingBuildConfiguration, expectedNames: Set) { + self.expectedNames = expectedNames + + super.init(viewMode: .sourceAccurate, configuration: configuration) + } + + deinit { + if !expectedNames.isEmpty { + XCTFail("No nodes with expected names visited: \(expectedNames)") + } + } + + func checkName(name: String, node: Syntax) { + if !expectedNames.contains(name) { + XCTFail("syntax node with unexpected name \(name) found: \(node.debugDescription)") + } + + expectedNames.remove(name) + } + + open override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { + if let identified = node.asProtocol(NamedDeclSyntax.self) { + checkName(name: identified.name.text, node: node) + } else if let identPattern = node.as(IdentifierPatternSyntax.self) { + // FIXME: Should the above be an IdentifiedDeclSyntax? + checkName(name: identPattern.identifier.text, node: node) + } + + return .visitChildren + } +} public class VisitorTests: XCTestCase { let linuxBuildConfig = TestingBuildConfiguration( customConditions: ["DEBUG", "ASSERTS"], @@ -95,6 +132,12 @@ public class VisitorTests: XCTestCase { #endif } #endif + + #if hasAttribute(available) + func withAvail() { } + #else + func notAvail() { } + #endif """ func testAnyVisitorVisitsOnlyActive() throws { @@ -104,56 +147,29 @@ public class VisitorTests: XCTestCase { } func testVisitsExpectedNodes() throws { - class NameCheckingVisitor: ActiveSyntaxAnyVisitor { - /// The set of names we are expected to visit. Any syntax nodes with - /// names that aren't here will be rejected, and each of the names listed - /// here must occur exactly once. - var expectedNames: Set - - init(configuration: TestingBuildConfiguration, expectedNames: Set) { - self.expectedNames = expectedNames - - super.init(viewMode: .sourceAccurate, configuration: configuration) - } - - deinit { - if !expectedNames.isEmpty { - XCTFail("No nodes with expected names visited: \(expectedNames)") - } - } - - func checkName(name: String, node: Syntax) { - if !expectedNames.contains(name) { - XCTFail("syntax node with unexpected name \(name) found: \(node.debugDescription)") - } - - expectedNames.remove(name) - } - - open override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { - if let identified = node.asProtocol(NamedDeclSyntax.self) { - checkName(name: identified.name.text, node: node) - } else if let identPattern = node.as(IdentifierPatternSyntax.self) { - // FIXME: Should the above be an IdentifiedDeclSyntax? - checkName(name: identPattern.identifier.text, node: node) - } - - return .visitChildren - } - } - // Check that the right set of names is visited. NameCheckingVisitor( configuration: linuxBuildConfig, - expectedNames: ["f", "h", "i", "S", "generationCount", "value"] + expectedNames: ["f", "h", "i", "S", "generationCount", "value", "withAvail"] ).walk(inputSource) NameCheckingVisitor( configuration: iosBuildConfig, - expectedNames: ["g", "h", "i", "a", "S", "generationCount", "value", "error"] + expectedNames: ["g", "h", "i", "a", "S", "generationCount", "value", "error", "withAvail"] ).walk(inputSource) } + func testVisitorWithErrors() throws { + var configuration = linuxBuildConfig + configuration.badAttributes.insert("available") + let visitor = NameCheckingVisitor( + configuration: configuration, + expectedNames: ["f", "h", "i", "S", "generationCount", "value", "withAvail", "notAvail"] + ) + visitor.walk(inputSource) + XCTAssertEqual(visitor.diagnostics.count, 3) + } + func testRemoveInactive() { assertStringsEqualWithDiff( inputSource.removingInactive(in: linuxBuildConfig).description, @@ -179,6 +195,7 @@ public class VisitorTests: XCTestCase { .c .d() } + func withAvail() { } """ ) } From 2590ebbd25dc755b848472ef4890d75dc284d569 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 4 Jul 2024 11:38:55 -0700 Subject: [PATCH 25/42] Add SwiftIfConfig to the set of documentation targets --- .spi.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.spi.yml b/.spi.yml index 5b4aaf55d5b..5ed74fe1c95 100644 --- a/.spi.yml +++ b/.spi.yml @@ -12,6 +12,7 @@ builder: - SwiftCompilerPlugin - SwiftDiagnostics - SwiftIDEUtils + - SwiftIfConfig - SwiftLexicalLookup - SwiftOperators - SwiftParser From 1b0f44aa574e9d2d5071ee3615b4f3d401e3983d Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 4 Jul 2024 11:47:54 -0700 Subject: [PATCH 26/42] Fix typo in the release notes --- Release Notes/600.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Release Notes/600.md b/Release Notes/600.md index e670d013267..05538cf7c43 100644 --- a/Release Notes/600.md +++ b/Release Notes/600.md @@ -133,7 +133,7 @@ - Pull request: https://github.com/swiftlang/swift-syntax/pull/2433 - `CanImportExprSyntax` and `CanImportVersionInfoSyntax` - - Description: Instead of parsing `canImport` inside `#if` directives as a special expression node, parse it as a functionc call expression. This is in-line with how the `swift(>=6.0)` and `compiler(>=6.0)` directives are parsed. + - Description: Instead of parsing `canImport` inside `#if` directives as a special expression node, parse it as a function call expression. This is in-line with how the `swift(>=6.0)` and `compiler(>=6.0)` directives are parsed. - Pull request: https://github.com/swiftlang/swift-syntax/pull/2025 - `SyntaxClassifiedRange.offset`, `length` and `endOffset` From 00fb7a0f37f5527b7af06858499aa268ec101b6d Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 4 Jul 2024 11:48:11 -0700 Subject: [PATCH 27/42] Adapt to optionality changes on main --- Sources/SwiftIfConfig/IfConfigEvaluation.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index fef2a1cff26..2d427031175 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -196,7 +196,7 @@ private func evaluateIfConfig( // Logical '!'. if let prefixOp = condition.as(PrefixOperatorExprSyntax.self), - prefixOp.operator?.text == "!" + prefixOp.operator.text == "!" { return try !evaluateIfConfig(condition: prefixOp.expression, configuration: configuration) } @@ -249,13 +249,13 @@ private func evaluateIfConfig( // Ensure that we have a single unlabeled argument that is either >= or < as a prefix // operator applied to a version. guard let argExpr = call.arguments.singleUnlabeledExpression, - let unaryArg = argExpr.as(PrefixOperatorExprSyntax.self), - let opToken = unaryArg.operator + let unaryArg = argExpr.as(PrefixOperatorExprSyntax.self) else { throw IfConfigError.requiresUnlabeledArgument(name: fnName, role: "version comparison (>= or <= a version)") } // Parse the version. + let opToken = unaryArg.operator guard let version = VersionTuple(parsing: unaryArg.expression.trimmedDescription) else { throw IfConfigError.invalidVersionOperand(name: fnName, syntax: unaryArg.expression) } From 5428bbc8f737dbcdd1f842da5330590d3bfc85fc Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 4 Jul 2024 12:10:13 -0700 Subject: [PATCH 28/42] Add a method `Error.asDiagnostics(at:)` to unpack errors into diagnostics This is a useful convenience function to handle cases where we have a thrown error from some API and we want to translate it into a diagnostic that we need to emit at a particular location. --- Release Notes/601.md | 4 +++ Sources/SwiftDiagnostics/Diagnostic.swift | 35 +++++++++++++++++++ .../MacroExpansionContext.swift | 32 +++++++++-------- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/Release Notes/601.md b/Release Notes/601.md index 26499144348..7036e503b33 100644 --- a/Release Notes/601.md +++ b/Release Notes/601.md @@ -11,6 +11,10 @@ - Description: Returns the node or the first ancestor that satisfies `condition`. - Pull Request: https://github.com/swiftlang/swift-syntax/pull/2696 +- `Error` protocol now has an `asDiagnostics(at:)` method. + - Description: This method translates an error into one or more diagnostics, recognizing `DiagnosticsError` and `DiagnosticMessage` instances or providing its own `Diagnostic` as needed. + - Pull Request: https://github.com/swiftlang/swift-syntax/pull/1816 + ## API Behavior Changes - `SyntaxProtocol.trimmed` detaches the node diff --git a/Sources/SwiftDiagnostics/Diagnostic.swift b/Sources/SwiftDiagnostics/Diagnostic.swift index 7963e150dc9..12c35ff7e1b 100644 --- a/Sources/SwiftDiagnostics/Diagnostic.swift +++ b/Sources/SwiftDiagnostics/Diagnostic.swift @@ -92,3 +92,38 @@ public struct DiagnosticsError: Error, Sendable { ) } } + +/// Diagnostic message used for thrown errors. +private struct DiagnosticFromError: DiagnosticMessage { + let error: Error + let severity: DiagnosticSeverity = .error + + var message: String { + return String(describing: error) + } + + var diagnosticID: MessageID { + .init(domain: "SwiftDiagnostics", id: "\(type(of: error))") + } +} + +extension Error { + /// Given an error, produce an array of diagnostics reporting the error, + /// using the given syntax node as the location if it wasn't otherwise known. + /// + /// This operation will look for diagnostics of known type, such as + /// `DiagnosticsError` and `DiagnosticMessage` to retain information. If + /// none of those apply, it will produce an `error` diagnostic whose message + /// comes from rendering the error as a string. + public func asDiagnostics(at node: some SyntaxProtocol) -> [Diagnostic] { + if let diagnosticsError = self as? DiagnosticsError { + return diagnosticsError.diagnostics + } + + if let message = self as? DiagnosticMessage { + return [Diagnostic(node: Syntax(node), message: message)] + } + + return [Diagnostic(node: Syntax(node), message: DiagnosticFromError(error: self))] + } +} diff --git a/Sources/SwiftSyntaxMacros/MacroExpansionContext.swift b/Sources/SwiftSyntaxMacros/MacroExpansionContext.swift index 450e3a5d037..c06886507f0 100644 --- a/Sources/SwiftSyntaxMacros/MacroExpansionContext.swift +++ b/Sources/SwiftSyntaxMacros/MacroExpansionContext.swift @@ -102,14 +102,23 @@ extension MacroExpansionContext { #endif } -/// Diagnostic message used for thrown errors. -private struct ThrownErrorDiagnostic: DiagnosticMessage { - let message: String +private enum MacroExpansionContextError: DiagnosticMessage { + case internalError(SyntaxStringInterpolationInvalidNodeTypeError) + case missingError + + var message: String { + switch self { + case .internalError(let error): + return "Internal macro error: \(error.description)" + case .missingError: + return "macro expansion failed without generating an error" + } + } var severity: DiagnosticSeverity { .error } var diagnosticID: MessageID { - .init(domain: "SwiftSyntaxMacros", id: "ThrownErrorDiagnostic") + .init(domain: "SwiftDiagnostics", id: "MacroExpansionContextError") } } @@ -118,18 +127,15 @@ extension MacroExpansionContext { public func addDiagnostics(from error: Error, node: some SyntaxProtocol) { // Inspect the error to form an appropriate set of diagnostics. var diagnostics: [Diagnostic] - if let diagnosticsError = error as? DiagnosticsError { - diagnostics = diagnosticsError.diagnostics - } else if let message = error as? DiagnosticMessage { - diagnostics = [Diagnostic(node: Syntax(node), message: message)] - } else if let error = error as? SyntaxStringInterpolationInvalidNodeTypeError { + + if let error = error as? SyntaxStringInterpolationInvalidNodeTypeError { let diagnostic = Diagnostic( node: Syntax(node), - message: ThrownErrorDiagnostic(message: "Internal macro error: \(error.description)") + message: MacroExpansionContextError.internalError(error) ) diagnostics = [diagnostic] } else { - diagnostics = [Diagnostic(node: Syntax(node), message: ThrownErrorDiagnostic(message: String(describing: error)))] + diagnostics = error.asDiagnostics(at: node) } // Emit the diagnostics. @@ -144,9 +150,7 @@ extension MacroExpansionContext { diagnose( Diagnostic( node: Syntax(node), - message: ThrownErrorDiagnostic( - message: "macro expansion failed without generating an error" - ) + message: MacroExpansionContextError.missingError ) ) } From 65fd3b9a044348d1bf660603491b387845f15eae Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 4 Jul 2024 13:35:55 -0700 Subject: [PATCH 29/42] Handle errors while removing inactive regions When removing inactive regions via a visitor, accumulate any diagnostics produced during this process so they can be reported. Introduce a test harness so we can test these diagnostics more easily. --- Package.swift | 7 +- .../SwiftIfConfig/IfConfigEvaluation.swift | 18 ++- Sources/SwiftIfConfig/IfConfigRewriter.swift | 55 ++++++-- Sources/SwiftIfConfig/IfConfigVisitor.swift | 6 +- .../SwiftIfConfig/SyntaxLiteralUtils.swift | 2 +- .../Assertions.swift | 40 +++++- Tests/SwiftIfConfigTest/Assertions.swift | 66 ++++++++++ Tests/SwiftIfConfigTest/EvaluateTests.swift | 6 +- .../TestingBuildConfiguration.swift | 2 +- Tests/SwiftIfConfigTest/VisitorTests.swift | 117 ++++++++++++++---- 10 files changed, 264 insertions(+), 55 deletions(-) create mode 100644 Tests/SwiftIfConfigTest/Assertions.swift diff --git a/Package.swift b/Package.swift index 2bdb6cc3b44..e9cf50c25b1 100644 --- a/Package.swift +++ b/Package.swift @@ -152,7 +152,12 @@ let package = Package( .testTarget( name: "SwiftIfConfigTest", - dependencies: ["_SwiftSyntaxTestSupport", "SwiftIfConfig", "SwiftParser"] + dependencies: [ + "_SwiftSyntaxTestSupport", + "SwiftIfConfig", + "SwiftParser", + "SwiftSyntaxMacrosGenericTestSupport", + ] ), // MARK: SwiftLexicalLookup diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index 2d427031175..58b598ed68b 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -1,3 +1,4 @@ +import SwiftOperators //===----------------------------------------------------------------------===// // // This source file is part of the Swift.org open source project @@ -10,7 +11,6 @@ // //===----------------------------------------------------------------------===// import SwiftSyntax -import SwiftOperators enum IfConfigError: Error, CustomStringConvertible { case unknownExpression(ExprSyntax) @@ -196,15 +196,15 @@ private func evaluateIfConfig( // Logical '!'. if let prefixOp = condition.as(PrefixOperatorExprSyntax.self), - prefixOp.operator.text == "!" + prefixOp.operator.text == "!" { return try !evaluateIfConfig(condition: prefixOp.expression, configuration: configuration) } // Logical '&&' and '||'. if let binOp = condition.as(InfixOperatorExprSyntax.self), - let op = binOp.operator.as(BinaryOperatorExprSyntax.self), - (op.operator.text == "&&" || op.operator.text == "||") + let op = binOp.operator.as(BinaryOperatorExprSyntax.self), + (op.operator.text == "&&" || op.operator.text == "||") { // Evaluate the left-hand side. let lhsResult = try evaluateIfConfig(condition: binOp.leftOperand, configuration: configuration) @@ -290,7 +290,10 @@ private func evaluateIfConfig( return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetRuntime, role: "runtime") case ._ptrauth: - return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetPointerAuthentication, role: "pointer authentication scheme") + return try doSingleIdentifierArgumentCheck( + configuration.isActiveTargetPointerAuthentication, + role: "pointer authentication scheme" + ) case ._endian: // Ensure that we have a single argument that is a simple identifier, @@ -313,7 +316,10 @@ private func evaluateIfConfig( argFirst == "_", let expectedPointerBitWidth = Int(arg.dropFirst()) else { - throw IfConfigError.requiresUnlabeledArgument(name: fnName, role: "pointer bit with ('_' followed by an integer)") + throw IfConfigError.requiresUnlabeledArgument( + name: fnName, + role: "pointer bit with ('_' followed by an integer)" + ) } return configuration.targetPointerBitWidth == expectedPointerBitWidth diff --git a/Sources/SwiftIfConfig/IfConfigRewriter.swift b/Sources/SwiftIfConfig/IfConfigRewriter.swift index 4c8de987116..5f4ec4667f7 100644 --- a/Sources/SwiftIfConfig/IfConfigRewriter.swift +++ b/Sources/SwiftIfConfig/IfConfigRewriter.swift @@ -19,6 +19,7 @@ // //===----------------------------------------------------------------------===// +import SwiftDiagnostics import SwiftSyntax /// Syntax rewriter that only visits syntax nodes that are active according @@ -53,11 +54,17 @@ import SwiftSyntax /// than trivia). class ActiveSyntaxRewriter: SyntaxRewriter { let configuration: Configuration + var diagnostics: [Diagnostic] = [] init(configuration: Configuration) { self.configuration = configuration } + private func reportEvaluationError(at node: some SyntaxProtocol, error: Error) { + let newDiagnostics = error.asDiagnostics(at: node) + diagnostics.append(contentsOf: newDiagnostics) + } + private func dropInactive( _ node: List, elementAsIfConfig: (List.Element) -> IfConfigDeclSyntax? @@ -69,6 +76,21 @@ class ActiveSyntaxRewriter: SyntaxRewriter { // Find #ifs within the list. if let ifConfigDecl = elementAsIfConfig(element) { + // Evaluate the `#if` condition. + let activeClause: IfConfigClauseSyntax? + do { + activeClause = try ifConfigDecl.activeClause(in: configuration) + } catch { + // When an error occurs in the evaluation of the condition, + // keep the entire `#if`. + if anyChanged { + newElements.append(element) + } + + reportEvaluationError(at: element, error: error) + continue + } + // If this is the first element that changed, note that we have // changes and add all prior elements to the list of new elements. if !anyChanged { @@ -76,12 +98,8 @@ class ActiveSyntaxRewriter: SyntaxRewriter { newElements.append(contentsOf: node[..: SyntaxRewriter { postfixIfConfig: PostfixIfConfigExprSyntax ) -> ExprSyntax { // Determine the active clause within this syntax node. - // TODO: Swallows errors - guard let activeClause = try? postfixIfConfig.config.activeClause(in: configuration), - case .postfixExpression(let postfixExpr) = activeClause.elements + let activeClause: IfConfigClauseSyntax? + do { + activeClause = try postfixIfConfig.config.activeClause(in: configuration) + } catch { + reportEvaluationError(at: postfixIfConfig, error: error) + return ExprSyntax(postfixIfConfig) + } + + guard case .postfixExpression(let postfixExpr) = activeClause?.elements else { // If there is no active clause, return the base. @@ -263,11 +287,15 @@ class ActiveSyntaxRewriter: SyntaxRewriter { return applyBaseToPostfixExpression(base: base, postfix: postfixExpr) } - // TODO: PostfixIfConfigExprSyntax has a different form that doesn't work + // FIXME: PostfixIfConfigExprSyntax has a different form that doesn't work // well with the way dropInactive is written. We essentially need to // thread a the "base" into the active clause. override func visit(_ node: PostfixIfConfigExprSyntax) -> ExprSyntax { let rewrittenNode = dropInactive(outerBase: nil, postfixIfConfig: node) + if rewrittenNode == ExprSyntax(node) { + return rewrittenNode + } + return visit(rewrittenNode) } } @@ -276,8 +304,11 @@ extension SyntaxProtocol { /// Produce a copy of this syntax node that removes all syntax regions that /// are inactive according to the given build configuration, leaving only /// the code that is active within that build configuration. - public func removingInactive(in configuration: some BuildConfiguration) -> Syntax { + /// + /// Returns the syntax node with all inactive regions removed, along with an + /// array containing any diagnostics produced along the way. + public func removingInactive(in configuration: some BuildConfiguration) -> (Syntax, [Diagnostic]) { let visitor = ActiveSyntaxRewriter(configuration: configuration) - return visitor.rewrite(Syntax(self)) + return (visitor.rewrite(Syntax(self)), visitor.diagnostics) } } diff --git a/Sources/SwiftIfConfig/IfConfigVisitor.swift b/Sources/SwiftIfConfig/IfConfigVisitor.swift index 269fc066bcd..e07aa99608d 100644 --- a/Sources/SwiftIfConfig/IfConfigVisitor.swift +++ b/Sources/SwiftIfConfig/IfConfigVisitor.swift @@ -70,7 +70,8 @@ open class ActiveSyntaxVisitor: SyntaxVisitor do { // If there is an active clause, visit it's children. if let activeClause = try node.activeClause(in: configuration), - let elements = activeClause.elements { + let elements = activeClause.elements + { walk(Syntax(elements)) } @@ -144,7 +145,8 @@ open class ActiveSyntaxAnyVisitor: SyntaxAnyV do { // If there is an active clause, visit it's children. if let activeClause = try node.activeClause(in: configuration), - let elements = activeClause.elements { + let elements = activeClause.elements + { walk(Syntax(elements)) } diff --git a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift index eb64ae149a9..85b946066e7 100644 --- a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift +++ b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift @@ -36,7 +36,7 @@ extension ExprSyntax { /// Whether this is a simple identifier expression and, if so, what the identifier string is. var simpleIdentifierExpr: String? { guard let identExpr = self.as(DeclReferenceExprSyntax.self), - identExpr.argumentNames == nil + identExpr.argumentNames == nil else { return nil } diff --git a/Sources/SwiftSyntaxMacrosGenericTestSupport/Assertions.swift b/Sources/SwiftSyntaxMacrosGenericTestSupport/Assertions.swift index 8f204aafbf3..2023ed6d55f 100644 --- a/Sources/SwiftSyntaxMacrosGenericTestSupport/Assertions.swift +++ b/Sources/SwiftSyntaxMacrosGenericTestSupport/Assertions.swift @@ -146,7 +146,7 @@ public struct NoteSpec { func assertNote( _ note: Note, - in expansionContext: BasicMacroExpansionContext, + in expansionContext: DiagnosticAssertionContext, expected spec: NoteSpec, failureHandler: (TestFailureSpec) -> Void ) { @@ -338,9 +338,36 @@ extension DiagnosticSpec { } } -func assertDiagnostic( +/// Describes the context in which we are asserting diagnostic correctness. +/// +/// This is used to map source locations. +public enum DiagnosticAssertionContext { + case macroExpansion(BasicMacroExpansionContext) + case tree(any SyntaxProtocol) + + func location( + for position: AbsolutePosition, + anchoredAt node: Syntax, + fileName: String + ) -> SourceLocation { + switch self { + case .macroExpansion(let expansionContext): + return expansionContext.location( + for: position, + anchoredAt: node, + fileName: fileName + ) + + case .tree(let syntax): + return SourceLocationConverter(fileName: fileName, tree: syntax) + .location(for: position) + } + } +} + +public func assertDiagnostic( _ diag: Diagnostic, - in expansionContext: BasicMacroExpansionContext, + in expansionContext: DiagnosticAssertionContext, expected spec: DiagnosticSpec, failureHandler: (TestFailureSpec) -> Void ) { @@ -533,7 +560,12 @@ public func assertMacroExpansion( ) } else { for (actualDiag, expectedDiag) in zip(context.diagnostics, diagnostics) { - assertDiagnostic(actualDiag, in: context, expected: expectedDiag, failureHandler: failureHandler) + assertDiagnostic( + actualDiag, + in: .macroExpansion(context), + expected: expectedDiag, + failureHandler: failureHandler + ) } } diff --git a/Tests/SwiftIfConfigTest/Assertions.swift b/Tests/SwiftIfConfigTest/Assertions.swift new file mode 100644 index 00000000000..e235014a897 --- /dev/null +++ b/Tests/SwiftIfConfigTest/Assertions.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import SwiftDiagnostics +import SwiftIfConfig +import SwiftParser +import SwiftSyntax +@_spi(XCTestFailureLocation) import SwiftSyntaxMacrosGenericTestSupport +import XCTest +import _SwiftSyntaxGenericTestSupport +import _SwiftSyntaxTestSupport + +/// Assert that applying the given build configuration to the source code +/// returns the expected source and diagnostics. +func assertRemoveInactive( + _ source: String, + configuration: any BuildConfiguration, + diagnostics expectedDiagnostics: [DiagnosticSpec] = [], + expectedSource: String, + file: StaticString = #filePath, + line: UInt = #line +) { + var parser = Parser(source) + let tree = SourceFileSyntax.parse(from: &parser) + + let (treeWithoutInactive, actualDiagnostics) = tree.removingInactive(in: configuration) + + // Check the resulting tree. + assertStringsEqualWithDiff( + treeWithoutInactive.description, + expectedSource, + file: file, + line: line + ) + + // Check the diagnostics. + if actualDiagnostics.count != expectedDiagnostics.count { + XCTFail( + """ + Expected \(expectedDiagnostics.count) diagnostics, but got \(actualDiagnostics.count): + \(actualDiagnostics.map(\.debugDescription).joined(separator: "\n")) + """, + file: file, + line: line + ) + } else { + for (actualDiag, expectedDiag) in zip(actualDiagnostics, expectedDiagnostics) { + assertDiagnostic( + actualDiag, + in: .tree(tree), + expected: expectedDiag, + failureHandler: { + XCTFail($0.message, file: $0.location.staticFilePath, line: $0.location.unsignedLine) + } + ) + } + } +} diff --git a/Tests/SwiftIfConfigTest/EvaluateTests.swift b/Tests/SwiftIfConfigTest/EvaluateTests.swift index 96fdb97bc8e..33cbaefc6a5 100644 --- a/Tests/SwiftIfConfigTest/EvaluateTests.swift +++ b/Tests/SwiftIfConfigTest/EvaluateTests.swift @@ -1,3 +1,6 @@ +import SwiftIfConfig +import SwiftParser +import SwiftSyntax //===----------------------------------------------------------------------===// // // This source file is part of the Swift.org open source project @@ -10,9 +13,6 @@ // //===----------------------------------------------------------------------===// import XCTest -import SwiftSyntax -import SwiftParser -import SwiftIfConfig import _SwiftSyntaxTestSupport public class EvaluateTests: XCTestCase { diff --git a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift index d3c2dfd2304..a20913ac12f 100644 --- a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift +++ b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift @@ -18,7 +18,7 @@ enum BuildConfigurationError: Error, CustomStringConvertible { var description: String { switch self { case .badAttribute(let attribute): - return "unacceptable attribute \(attribute)" + return "unacceptable attribute '\(attribute)'" } } } diff --git a/Tests/SwiftIfConfigTest/VisitorTests.swift b/Tests/SwiftIfConfigTest/VisitorTests.swift index 49c8a1adc15..f7d3c29e3e6 100644 --- a/Tests/SwiftIfConfigTest/VisitorTests.swift +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -9,11 +9,12 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// -import XCTest -import SwiftSyntax -import SwiftParser +import SwiftDiagnostics import SwiftIfConfig -import _SwiftSyntaxTestSupport +import SwiftParser +import SwiftSyntax +import SwiftSyntaxMacrosGenericTestSupport +import XCTest /// Visitor that ensures that all of the nodes we visit are active. /// @@ -68,6 +69,7 @@ class NameCheckingVisitor: ActiveSyntaxAnyVisitor { return .visitChildren } } + public class VisitorTests: XCTestCase { let linuxBuildConfig = TestingBuildConfiguration( customConditions: ["DEBUG", "ASSERTS"], @@ -171,32 +173,97 @@ public class VisitorTests: XCTestCase { } func testRemoveInactive() { - assertStringsEqualWithDiff( - inputSource.removingInactive(in: linuxBuildConfig).description, - """ + assertRemoveInactive( + inputSource.description, + configuration: linuxBuildConfig, + expectedSource: """ - @available(*, deprecated, message: "use something else") - func f() { - } + @available(*, deprecated, message: "use something else") + func f() { + } - struct S { - var generationCount = 0 - } + struct S { + var generationCount = 0 + } - func h() { - switch result { - case .success(let value): - break + func h() { + switch result { + case .success(let value): + break + } } - } - func i() { - a.b - .c - .d() - } - func withAvail() { } - """ + func i() { + a.b + .c + .d() + } + func withAvail() { } + """ + ) + } + + func testRemoveInactiveWithErrors() { + var configuration = linuxBuildConfig + configuration.badAttributes.insert("available") + + assertRemoveInactive( + inputSource.description, + configuration: configuration, + diagnostics: [ + DiagnosticSpec( + message: "unacceptable attribute 'available'", + line: 51, + column: 1 + ), + DiagnosticSpec( + message: "unacceptable attribute 'available'", + line: 1, + column: 2 + ), + DiagnosticSpec( + message: "unacceptable attribute 'available'", + line: 27, + column: 17 + ), + ], + expectedSource: """ + + #if hasAttribute(available) + @available(*, deprecated, message: "use something else") + #else + @MainActor + #endif + func f() { + } + + struct S { + var generationCount = 0 + } + + func h() { + switch result { + case .success(let value): + break + } + } + + func i() { + a.b + #if DEBUG + .c + #endif + #if hasAttribute(available) + .d() + #endif + } + + #if hasAttribute(available) + func withAvail() { } + #else + func notAvail() { } + #endif + """ ) } } From 7cb5189fbbff8da2641ee4bfb8d7fbb27d7fa1a9 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 4 Jul 2024 18:13:20 -0700 Subject: [PATCH 30/42] Handle diagnostics through #if condition evaluation Allow #if condition evaluation to produce proper diagnostics, including warnings, while performing the evaluation. This covers both warnings/errors directly coming from #if handling and also those thrown as errors by the build configuration. Introduce assertion functions to make it a lot easier to properly test the tests with calls to these assertions. Add the remaining diagnostics that were FIXME'd because we couldn't report them properly before. --- .../SwiftIfConfig/IfConfigEvaluation.swift | 282 +++++++++++++++--- Sources/SwiftIfConfig/VersionTuple.swift | 8 +- Tests/SwiftIfConfigTest/Assertions.swift | 48 ++- Tests/SwiftIfConfigTest/EvaluateTests.swift | 222 +++++++------- 4 files changed, 415 insertions(+), 145 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index 58b598ed68b..fb1b453022b 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -1,4 +1,3 @@ -import SwiftOperators //===----------------------------------------------------------------------===// // // This source file is part of the Swift.org open source project @@ -10,12 +9,14 @@ import SwiftOperators // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// +import SwiftDiagnostics +import SwiftOperators import SwiftSyntax enum IfConfigError: Error, CustomStringConvertible { case unknownExpression(ExprSyntax) case unhandledFunction(name: String, syntax: ExprSyntax) - case requiresUnlabeledArgument(name: String, role: String) + case requiresUnlabeledArgument(name: String, role: String, syntax: ExprSyntax) case unsupportedVersionOperator(name: String, operator: TokenSyntax) case invalidVersionOperand(name: String, syntax: ExprSyntax) case emptyVersionComponent(syntax: ExprSyntax) @@ -25,6 +26,8 @@ enum IfConfigError: Error, CustomStringConvertible { case canImportMissingModule(syntax: ExprSyntax) case canImportLabel(syntax: ExprSyntax) case canImportTwoParameters(syntax: ExprSyntax) + case ignoredTrailingComponents(version: VersionTuple, syntax: ExprSyntax) + case integerLiteralCondition(syntax: ExprSyntax, replacement: Bool) var description: String { switch self { @@ -34,7 +37,7 @@ enum IfConfigError: Error, CustomStringConvertible { case .unhandledFunction(name: let name, syntax: _): return "build configuration cannot handle '\(name)'" - case .requiresUnlabeledArgument(name: let name, role: let role): + case .requiresUnlabeledArgument(name: let name, role: let role, syntax: _): return "\(name) requires a single unlabeled argument for the \(role)" case .unsupportedVersionOperator(name: let name, operator: let op): @@ -65,7 +68,80 @@ enum IfConfigError: Error, CustomStringConvertible { case .canImportTwoParameters(syntax: _): return "canImport can take only two parameters" + + case .ignoredTrailingComponents(version: let version, syntax: _): + return "trailing components of version '\(version.description)' are ignored" + + case .integerLiteralCondition(syntax: let syntax, replacement: let replacement): + return "'\(syntax.trimmedDescription)' is not a valid conditional compilation expression, use '\(replacement)'" + } + } + + /// Retrieve the syntax node associated with this error. + var syntax: Syntax { + switch self { + case .unknownExpression(let syntax), + .unhandledFunction(name: _, syntax: let syntax), + .requiresUnlabeledArgument(name: _, role: _, syntax: let syntax), + .invalidVersionOperand(name: _, syntax: let syntax), + .emptyVersionComponent(syntax: let syntax), + .compilerVersionOutOfRange(value: _, upperLimit: _, syntax: let syntax), + .compilerVersionTooManyComponents(syntax: let syntax), + .compilerVersionSecondComponentNotWildcard(syntax: let syntax), + .canImportMissingModule(syntax: let syntax), + .canImportLabel(syntax: let syntax), + .canImportTwoParameters(syntax: let syntax), + .ignoredTrailingComponents(version: _, syntax: let syntax), + .integerLiteralCondition(syntax: let syntax, replacement: _): + return Syntax(syntax) + + case .unsupportedVersionOperator(name: _, operator: let op): + return Syntax(op) + } + } +} + +extension IfConfigError: DiagnosticMessage { + var message: String { description } + + var diagnosticID: MessageID { + .init(domain: "SwiftIfConfig", id: "IfConfigError") + } + + var severity: SwiftDiagnostics.DiagnosticSeverity { + switch self { + case .ignoredTrailingComponents: return .warning + default: return .error + } + } + + private struct SimpleFixItMessage: FixItMessage { + var message: String + + var fixItID: MessageID { + .init(domain: "SwiftIfConfig", id: "IfConfigFixIt") + } + } + + var asDiagnostic: Diagnostic { + // For the integer literal condition we have a Fix-It. + if case .integerLiteralCondition(let syntax, let replacement) = self { + return Diagnostic( + node: syntax, + message: self, + fixIt: .replace( + message: SimpleFixItMessage( + message: "replace with Boolean literal '\(replacement)'" + ), + oldNode: syntax, + newNode: BooleanLiteralExprSyntax( + literal: .keyword(replacement ? .true : .false) + ) + ) + ) } + + return Diagnostic(node: syntax, message: self) } } @@ -90,7 +166,6 @@ extension VersionTuple { func recordComponent(_ value: Int) throws { let limit = components.isEmpty ? 9223371 : 999 if value < 0 || value > limit { - // FIXME: Can we provide a more precise location here? throw IfConfigError.compilerVersionOutOfRange(value: value, upperLimit: limit, syntax: versionSyntax) } @@ -169,20 +244,65 @@ extension VersionTuple { /// folded according to the logical operators table. /// - configuration: The configuration against which the condition will be /// evaluated. -/// - Throws: Throws if an errors occur during evaluation. +/// - diagnosticHandler: Receives any diagnostics that are produced by the +/// evaluation, whether from errors in the source code or produced by the +/// build configuration itself. +/// - Throws: Throws if an error occurs occur during evaluation. The error will +/// also be provided to the diagnostic handler before doing so. /// - Returns: Whether the condition holds with the given build configuration. private func evaluateIfConfig( condition: ExprSyntax, - configuration: some BuildConfiguration + configuration: some BuildConfiguration, + diagnosticHandler: ((Diagnostic) -> Void)? ) throws -> Bool { + /// Record the error before returning it. Use this for every 'throw' site + /// in this evaluation. + func recordedError(_ error: any Error, at node: some SyntaxProtocol) -> any Error { + if let diagnosticHandler { + error.asDiagnostics(at: node).forEach { diagnosticHandler($0) } + } + + return error + } + + /// Record an if-config evaluation error before returning it. Use this for + /// every 'throw' site in this evaluation. + func recordedError(_ error: IfConfigError) -> any Error { + return recordedError(error, at: error.syntax) + } + + /// Check a configuration condition, translating any thrown error into an + /// appropriate diagnostic for the handler before rethrowing it. + func checkConfiguration( + at node: some SyntaxProtocol, + body: () throws -> Bool + ) throws -> Bool { + do { + return try body() + } catch let error { + throw recordedError(error, at: node) + } + } + // Boolean literals evaluate as-is if let boolLiteral = condition.as(BooleanLiteralExprSyntax.self) { return boolLiteral.literalValue } - // Integer literals evaluate true if that are not "0". - if let intLiteral = condition.as(IntegerLiteralExprSyntax.self) { - return intLiteral.literal.text != "0" + // Integer literals aren't allowed, but we recognize them. + if let intLiteral = condition.as(IntegerLiteralExprSyntax.self), + (intLiteral.literal.text == "0" || intLiteral.literal.text == "1") + { + let result = intLiteral.literal.text == "1" + + diagnosticHandler?( + IfConfigError.integerLiteralCondition( + syntax: condition, + replacement: result + ).asDiagnostic + ) + + return result } // Declaration references are for custom compilation flags. @@ -191,14 +311,20 @@ private func evaluateIfConfig( let ident = identExpr.baseName.text // Evaluate the custom condition. If the build configuration cannot answer this query, fail. - return try configuration.isCustomConditionSet(name: ident) + return try checkConfiguration(at: identExpr) { + try configuration.isCustomConditionSet(name: ident) + } } // Logical '!'. if let prefixOp = condition.as(PrefixOperatorExprSyntax.self), prefixOp.operator.text == "!" { - return try !evaluateIfConfig(condition: prefixOp.expression, configuration: configuration) + return try !evaluateIfConfig( + condition: prefixOp.expression, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) } // Logical '&&' and '||'. @@ -207,7 +333,11 @@ private func evaluateIfConfig( (op.operator.text == "&&" || op.operator.text == "||") { // Evaluate the left-hand side. - let lhsResult = try evaluateIfConfig(condition: binOp.leftOperand, configuration: configuration) + let lhsResult = try evaluateIfConfig( + condition: binOp.leftOperand, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) // Short-circuit evaluation if we know the answer. switch (lhsResult, op.operator.text) { @@ -217,17 +347,25 @@ private func evaluateIfConfig( } // Evaluate the right-hand side and use that result. - return try evaluateIfConfig(condition: binOp.rightOperand, configuration: configuration) + return try evaluateIfConfig( + condition: binOp.rightOperand, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) } // Look through parentheses. if let tuple = condition.as(TupleExprSyntax.self), tuple.isParentheses, let element = tuple.elements.first { - return try evaluateIfConfig(condition: element.expression, configuration: configuration) + return try evaluateIfConfig( + condition: element.expression, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) } - // Calls syntax is for operations. + // Call syntax is for operations. if let call = condition.as(FunctionCallExprSyntax.self), let fnName = call.calledExpression.simpleIdentifierExpr, let fn = IfConfigFunctions(rawValue: fnName) @@ -238,10 +376,14 @@ private func evaluateIfConfig( guard let argExpr = call.arguments.singleUnlabeledExpression, let arg = argExpr.simpleIdentifierExpr else { - throw IfConfigError.requiresUnlabeledArgument(name: fnName, role: role) + throw recordedError( + .requiresUnlabeledArgument(name: fnName, role: role, syntax: ExprSyntax(call)) + ) } - return try body(arg) + return try checkConfiguration(at: argExpr) { + try body(arg) + } } /// Perform a check for a version constraint as used in the "swift" or "compiler" version checks. @@ -251,13 +393,19 @@ private func evaluateIfConfig( guard let argExpr = call.arguments.singleUnlabeledExpression, let unaryArg = argExpr.as(PrefixOperatorExprSyntax.self) else { - throw IfConfigError.requiresUnlabeledArgument(name: fnName, role: "version comparison (>= or <= a version)") + throw recordedError( + .requiresUnlabeledArgument( + name: fnName, + role: "version comparison (>= or <= a version)", + syntax: ExprSyntax(call) + ) + ) } // Parse the version. let opToken = unaryArg.operator guard let version = VersionTuple(parsing: unaryArg.expression.trimmedDescription) else { - throw IfConfigError.invalidVersionOperand(name: fnName, syntax: unaryArg.expression) + throw recordedError(.invalidVersionOperand(name: fnName, syntax: unaryArg.expression)) } switch opToken.text { @@ -266,7 +414,7 @@ private func evaluateIfConfig( case "<": return actualVersion < version default: - throw IfConfigError.unsupportedVersionOperator(name: fnName, operator: opToken) + throw recordedError(.unsupportedVersionOperator(name: fnName, operator: opToken)) } } @@ -302,7 +450,13 @@ private func evaluateIfConfig( let arg = argExpr.simpleIdentifierExpr, let expectedEndianness = Endianness(rawValue: arg) else { - throw IfConfigError.requiresUnlabeledArgument(name: fnName, role: "endiannes ('big' or 'little')") + throw recordedError( + .requiresUnlabeledArgument( + name: fnName, + role: "endiannes ('big' or 'little')", + syntax: ExprSyntax(call) + ) + ) } return configuration.endianness == expectedEndianness @@ -316,9 +470,12 @@ private func evaluateIfConfig( argFirst == "_", let expectedPointerBitWidth = Int(arg.dropFirst()) else { - throw IfConfigError.requiresUnlabeledArgument( - name: fnName, - role: "pointer bit with ('_' followed by an integer)" + throw recordedError( + .requiresUnlabeledArgument( + name: fnName, + role: "pointer bit with ('_' followed by an integer)", + syntax: ExprSyntax(call) + ) ) } @@ -339,8 +496,13 @@ private func evaluateIfConfig( let segment = stringLiteral.segments.first, case .stringSegment(let stringSegment) = segment else { - // FIXME: better diagnostic here - throw IfConfigError.unknownExpression(condition) + throw recordedError( + .requiresUnlabeledArgument( + name: "_compiler_version", + role: "version", + syntax: ExprSyntax(call) + ) + ) } let versionString = stringSegment.content.text @@ -354,7 +516,7 @@ private func evaluateIfConfig( guard let firstArg = call.arguments.first, firstArg.label == nil else { - throw IfConfigError.canImportMissingModule(syntax: ExprSyntax(call)) + throw recordedError(.canImportMissingModule(syntax: ExprSyntax(call))) } // FIXME: This is a gross hack. Actually look at the sequence of @@ -366,7 +528,7 @@ private func evaluateIfConfig( let version: CanImportVersion if let secondArg = call.arguments.dropFirst().first { if secondArg.label?.text != "_version" && secondArg.label?.text != "_underlyingVersion" { - throw IfConfigError.canImportLabel(syntax: secondArg.expression) + throw recordedError(.canImportLabel(syntax: secondArg.expression)) } let versionText: String @@ -380,11 +542,25 @@ private func evaluateIfConfig( versionText = secondArg.expression.trimmedDescription } - guard let versionTuple = VersionTuple(parsing: versionText) else { - throw IfConfigError.invalidVersionOperand(name: "canImport", syntax: secondArg.expression) + guard var versionTuple = VersionTuple(parsing: versionText) else { + throw recordedError( + .invalidVersionOperand(name: "canImport", syntax: secondArg.expression) + ) } - // FIXME: Warning that the version can only have at most 4 components. + // Remove excess components from the version, + if versionTuple.components.count > 4 { + // Remove excess components. + versionTuple.components.removeSubrange(4...) + + // Warn that we did this. + diagnosticHandler?( + IfConfigError.ignoredTrailingComponents( + version: versionTuple, + syntax: secondArg.expression + ).asDiagnostic + ) + } if secondArg.label?.text == "_version" { version = .version(versionTuple) @@ -394,30 +570,43 @@ private func evaluateIfConfig( } if call.arguments.count > 2 { - throw IfConfigError.canImportTwoParameters(syntax: ExprSyntax(call)) + throw recordedError(.canImportTwoParameters(syntax: ExprSyntax(call))) } } else { version = .unversioned } - return try configuration.canImport( - importPath: importPath.map { String($0) }, - version: version - ) + return try checkConfiguration(at: call) { + try configuration.canImport( + importPath: importPath.map { String($0) }, + version: version + ) + } } } - throw IfConfigError.unknownExpression(condition) + throw recordedError(.unknownExpression(condition)) } extension IfConfigState { /// Evaluate the given `#if` condition using the given build configuration, throwing an error if there is /// insufficient information to make a determination. - public init(condition: some ExprSyntaxProtocol, configuration: some BuildConfiguration) throws { + public init( + condition: some ExprSyntaxProtocol, + configuration: some BuildConfiguration, + diagnosticHandler: ((Diagnostic) -> Void)? = nil + ) throws { // Apply operator folding for !/&&/||. - let foldedCondition = try OperatorTable.logicalOperators.foldAll(condition).cast(ExprSyntax.self) - - let result = try evaluateIfConfig(condition: foldedCondition, configuration: configuration) + let foldedCondition = try OperatorTable.logicalOperators.foldAll(condition) { error in + diagnosticHandler?(error.asDiagnostic) + throw error + }.cast(ExprSyntax.self) + + let result = try evaluateIfConfig( + condition: foldedCondition, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) self = result ? .active : .inactive } } @@ -440,7 +629,10 @@ extension IfConfigDeclSyntax { /// passed, this function will return `nil` to indicate that none of the regions are active. /// /// If an error occurred while processing any of the `#if` clauses, this function will throw that error. - public func activeClause(in configuration: some BuildConfiguration) throws -> IfConfigClauseSyntax? { + public func activeClause( + in configuration: some BuildConfiguration, + diagnosticHandler: ((Diagnostic) -> Void)? = nil + ) throws -> IfConfigClauseSyntax? { for clause in clauses { // If there is no condition, we have reached an unconditional clause. Return it. guard let condition = clause.condition else { @@ -448,7 +640,11 @@ extension IfConfigDeclSyntax { } // If this condition evaluates true, return this clause. - if try evaluateIfConfig(condition: condition, configuration: configuration) { + if try evaluateIfConfig( + condition: condition, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) { return clause } } diff --git a/Sources/SwiftIfConfig/VersionTuple.swift b/Sources/SwiftIfConfig/VersionTuple.swift index 6e37d736095..53a5eb1d034 100644 --- a/Sources/SwiftIfConfig/VersionTuple.swift +++ b/Sources/SwiftIfConfig/VersionTuple.swift @@ -11,7 +11,7 @@ //===----------------------------------------------------------------------===// /// Describes a version such as `5.9`. -public struct VersionTuple { +public struct VersionTuple: Sendable { /// The components of the version tuple, start with the major version. public var components: [Int] @@ -62,3 +62,9 @@ extension VersionTuple: Comparable { return lhs.normalized.components.lexicographicallyPrecedes(rhs.normalized.components) } } + +extension VersionTuple: CustomStringConvertible { + public var description: String { + return components.map { String($0) }.joined(separator: ".") + } +} diff --git a/Tests/SwiftIfConfigTest/Assertions.swift b/Tests/SwiftIfConfigTest/Assertions.swift index e235014a897..4034cfd3de1 100644 --- a/Tests/SwiftIfConfigTest/Assertions.swift +++ b/Tests/SwiftIfConfigTest/Assertions.swift @@ -18,11 +18,57 @@ import XCTest import _SwiftSyntaxGenericTestSupport import _SwiftSyntaxTestSupport +/// Assert the results of evaluating the condition within an `#if` against the +/// given build configuration. +func assertIfConfig( + _ condition: ExprSyntax, + _ expectedState: IfConfigState?, + configuration: some BuildConfiguration = TestingBuildConfiguration(), + diagnostics expectedDiagnostics: [DiagnosticSpec] = [], + file: StaticString = #filePath, + line: UInt = #line +) { + // Evaluate the condition to check the state. + var actualDiagnostics: [Diagnostic] = [] + do { + let actualState = try IfConfigState(condition: condition, configuration: configuration) { diag in + actualDiagnostics.append(diag) + } + XCTAssertEqual(actualState, expectedState, file: file, line: line) + } catch { + XCTAssertNil(expectedState, file: file, line: line) + } + + // Check the diagnostics. + if actualDiagnostics.count != expectedDiagnostics.count { + XCTFail( + """ + Expected \(expectedDiagnostics.count) diagnostics, but got \(actualDiagnostics.count): + \(actualDiagnostics.map(\.debugDescription).joined(separator: "\n")) + """, + file: file, + line: line + ) + } else { + for (actualDiag, expectedDiag) in zip(actualDiagnostics, expectedDiagnostics) { + assertDiagnostic( + actualDiag, + in: .tree(condition), + expected: expectedDiag, + failureHandler: { + XCTFail($0.message, file: $0.location.staticFilePath, line: $0.location.unsignedLine) + } + ) + } + } + +} + /// Assert that applying the given build configuration to the source code /// returns the expected source and diagnostics. func assertRemoveInactive( _ source: String, - configuration: any BuildConfiguration, + configuration: some BuildConfiguration, diagnostics expectedDiagnostics: [DiagnosticSpec] = [], expectedSource: String, file: StaticString = #filePath, diff --git a/Tests/SwiftIfConfigTest/EvaluateTests.swift b/Tests/SwiftIfConfigTest/EvaluateTests.swift index 33cbaefc6a5..849f74ad99e 100644 --- a/Tests/SwiftIfConfigTest/EvaluateTests.swift +++ b/Tests/SwiftIfConfigTest/EvaluateTests.swift @@ -1,6 +1,3 @@ -import SwiftIfConfig -import SwiftParser -import SwiftSyntax //===----------------------------------------------------------------------===// // // This source file is part of the Swift.org open source project @@ -12,6 +9,10 @@ import SwiftSyntax // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// +import SwiftIfConfig +import SwiftParser +import SwiftSyntax +import SwiftSyntaxMacrosGenericTestSupport import XCTest import _SwiftSyntaxTestSupport @@ -19,131 +20,152 @@ public class EvaluateTests: XCTestCase { func testLiterals() throws { let buildConfig = TestingBuildConfiguration(customConditions: ["DEBUG", "ASSERTS"]) - func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { - try IfConfigState(condition: condition, configuration: buildConfig) - } - - XCTAssertEqual(try ifConfigState("true"), .active) - XCTAssertEqual(try ifConfigState("false"), .inactive) - - // FIXME: How can we produce warnings from this code? - XCTAssertEqual(try ifConfigState("1"), .active) - XCTAssertEqual(try ifConfigState("0"), .inactive) + assertIfConfig("true", .active, configuration: buildConfig) + assertIfConfig("false", .inactive, configuration: buildConfig) + + assertIfConfig( + "1", + .active, + configuration: buildConfig, + diagnostics: [ + DiagnosticSpec( + message: "'1' is not a valid conditional compilation expression, use 'true'", + line: 1, + column: 1, + fixIts: [ + FixItSpec(message: "replace with Boolean literal 'true'") + ] + ) + ] + ) + assertIfConfig( + "0", + .inactive, + configuration: buildConfig, + diagnostics: [ + DiagnosticSpec( + message: "'0' is not a valid conditional compilation expression, use 'false'", + line: 1, + column: 1, + fixIts: [ + FixItSpec(message: "replace with Boolean literal 'false'") + ] + ) + ] + ) + assertIfConfig( + "2", + nil, + configuration: buildConfig, + diagnostics: [ + DiagnosticSpec( + message: "invalid conditional compilation expression", + line: 1, + column: 1 + ) + ] + ) } func testCustomConfigs() throws { let buildConfig = TestingBuildConfiguration(customConditions: ["DEBUG", "ASSERTS"]) - func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { - try IfConfigState(condition: condition, configuration: buildConfig) - } - - XCTAssertEqual(try ifConfigState("DEBUG"), .active) - XCTAssertEqual(try ifConfigState("NODEBUG"), .inactive) - XCTAssertEqual(try ifConfigState("!DEBUG"), .inactive) - XCTAssertEqual(try ifConfigState("!NODEBUG"), .active) - XCTAssertEqual(try ifConfigState("DEBUG && ASSERTS"), .active) - XCTAssertEqual(try ifConfigState("DEBUG && nope"), .inactive) - XCTAssertEqual(try ifConfigState("nope && DEBUG"), .inactive) - XCTAssertEqual(try ifConfigState("nope && 3.14159"), .inactive) - XCTAssertEqual(try ifConfigState("DEBUG || ASSERTS"), .active) - XCTAssertEqual(try ifConfigState("DEBUG || nope"), .active) - XCTAssertEqual(try ifConfigState("nope || DEBUG"), .active) - XCTAssertEqual(try ifConfigState("nope || !DEBUG"), .inactive) - XCTAssertEqual(try ifConfigState("DEBUG || 3.14159"), .active) - XCTAssertEqual(try ifConfigState("(DEBUG) || 3.14159"), .active) + assertIfConfig("DEBUG", .active, configuration: buildConfig) + assertIfConfig("NODEBUG", .inactive, configuration: buildConfig) + assertIfConfig("!DEBUG", .inactive, configuration: buildConfig) + assertIfConfig("!NODEBUG", .active, configuration: buildConfig) + assertIfConfig("DEBUG && ASSERTS", .active, configuration: buildConfig) + assertIfConfig("DEBUG && nope", .inactive, configuration: buildConfig) + assertIfConfig("nope && DEBUG", .inactive, configuration: buildConfig) + assertIfConfig("nope && 3.14159", .inactive, configuration: buildConfig) + assertIfConfig("DEBUG || ASSERTS", .active, configuration: buildConfig) + assertIfConfig("DEBUG || nope", .active, configuration: buildConfig) + assertIfConfig("nope || DEBUG", .active, configuration: buildConfig) + assertIfConfig("nope || !DEBUG", .inactive, configuration: buildConfig) + assertIfConfig("DEBUG || 3.14159", .active, configuration: buildConfig) + assertIfConfig("(DEBUG) || 3.14159", .active, configuration: buildConfig) } func testBadExpressions() throws { let buildConfig = TestingBuildConfiguration(customConditions: ["DEBUG", "ASSERTS"]) - func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { - try IfConfigState(condition: condition, configuration: buildConfig) - } - - XCTAssertThrowsError(try ifConfigState("3.14159")) { error in - XCTAssertEqual(String(describing: error), "invalid conditional compilation expression") - } + assertIfConfig( + "3.14159", + nil, + configuration: buildConfig, + diagnostics: [ + DiagnosticSpec( + message: "invalid conditional compilation expression", + line: 1, + column: 1 + ) + ] + ) } func testFeatures() throws { let buildConfig = TestingBuildConfiguration(features: ["ParameterPacks"]) - func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { - try IfConfigState(condition: condition, configuration: buildConfig) - } - - XCTAssertEqual(try ifConfigState("hasFeature(ParameterPacks)"), .active) - XCTAssertEqual(try ifConfigState("hasFeature(HigherKindedGenerics)"), .inactive) + assertIfConfig("hasFeature(ParameterPacks)", .active, configuration: buildConfig) + assertIfConfig("hasFeature(HigherKindedGenerics)", .inactive, configuration: buildConfig) } func testAttributes() throws { let buildConfig = TestingBuildConfiguration(attributes: ["available"]) - func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { - try IfConfigState(condition: condition, configuration: buildConfig) - } - - XCTAssertEqual(try ifConfigState("hasAttribute(available)"), .active) - XCTAssertEqual(try ifConfigState("hasAttribute(unsafeUnavailable)"), .inactive) + assertIfConfig("hasAttribute(available)", .active, configuration: buildConfig) + assertIfConfig("hasAttribute(unsafeUnavailable)", .inactive, configuration: buildConfig) } func testPlatform() throws { - let buildConfig = TestingBuildConfiguration() - - func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { - try IfConfigState(condition: condition, configuration: buildConfig) - } - - XCTAssertEqual(try ifConfigState("os(Linux)"), .active) - XCTAssertEqual(try ifConfigState("os(BeOS)"), .inactive) - XCTAssertEqual(try ifConfigState("arch(arm64)"), .active) - XCTAssertEqual(try ifConfigState("arch(x86_64)"), .inactive) - XCTAssertEqual(try ifConfigState("targetEnvironment(simulator)"), .active) - XCTAssertEqual(try ifConfigState("targetEnvironment(blargh)"), .inactive) - XCTAssertEqual(try ifConfigState("_endian(little)"), .active) - XCTAssertEqual(try ifConfigState("_endian(big)"), .inactive) - XCTAssertEqual(try ifConfigState("_runtime(_Native)"), .active) - XCTAssertEqual(try ifConfigState("_runtime(_ObjC)"), .inactive) - XCTAssertEqual(try ifConfigState("_ptrauth(arm64e)"), .active) - XCTAssertEqual(try ifConfigState("_ptrauth(none)"), .inactive) - XCTAssertEqual(try ifConfigState("_pointerBitWidth(_64)"), .active) - XCTAssertEqual(try ifConfigState("_pointerBitWidth(_32)"), .inactive) + assertIfConfig("os(Linux)", .active) + assertIfConfig("os(BeOS)", .inactive) + assertIfConfig("arch(arm64)", .active) + assertIfConfig("arch(x86_64)", .inactive) + assertIfConfig("targetEnvironment(simulator)", .active) + assertIfConfig("targetEnvironment(blargh)", .inactive) + assertIfConfig("_endian(little)", .active) + assertIfConfig("_endian(big)", .inactive) + assertIfConfig("_runtime(_Native)", .active) + assertIfConfig("_runtime(_ObjC)", .inactive) + assertIfConfig("_ptrauth(arm64e)", .active) + assertIfConfig("_ptrauth(none)", .inactive) + assertIfConfig("_pointerBitWidth(_64)", .active) + assertIfConfig("_pointerBitWidth(_32)", .inactive) } func testVersions() throws { - let buildConfig = TestingBuildConfiguration() - - func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { - try IfConfigState(condition: condition, configuration: buildConfig) - } - - XCTAssertEqual(try ifConfigState("swift(>=5.5"), .active) - XCTAssertEqual(try ifConfigState("swift(<6"), .active) - XCTAssertEqual(try ifConfigState("swift(>=6"), .inactive) - XCTAssertEqual(try ifConfigState("compiler(>=5.8"), .active) - XCTAssertEqual(try ifConfigState("compiler(>=5.9"), .active) - XCTAssertEqual(try ifConfigState("compiler(>=5.10"), .inactive) - XCTAssertEqual(try ifConfigState(#"_compiler_version("5009.*.1")"#), .active) - XCTAssertEqual(try ifConfigState(#"_compiler_version("5009.*.3.2.3")"#), .inactive) - XCTAssertEqual(try ifConfigState(#"_compiler_version("5010.*.0")"#), .inactive) + assertIfConfig("swift(>=5.5", .active) + assertIfConfig("swift(<6", .active) + assertIfConfig("swift(>=6", .inactive) + assertIfConfig("compiler(>=5.8", .active) + assertIfConfig("compiler(>=5.9", .active) + assertIfConfig("compiler(>=5.10", .inactive) + assertIfConfig(#"_compiler_version("5009.*.1")"#, .active) + assertIfConfig(#"_compiler_version("5009.*.3.2.3")"#, .inactive) + assertIfConfig(#"_compiler_version("5010.*.0")"#, .inactive) } func testCanImport() throws { - let buildConfig = TestingBuildConfiguration() - - func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState { - try IfConfigState(condition: condition, configuration: buildConfig) - } - - XCTAssertEqual(try ifConfigState("canImport(SwiftSyntax)"), .active) - XCTAssertEqual(try ifConfigState("canImport(SwiftSyntax.Sub)"), .active) - XCTAssertEqual(try ifConfigState("canImport(SwiftParser)"), .inactive) - XCTAssertEqual(try ifConfigState("canImport(SwiftSyntax, _version: 5.9)"), .active) - XCTAssertEqual(try ifConfigState("canImport(SwiftSyntax, _version: 5.10)"), .inactive) - XCTAssertEqual(try ifConfigState(#"canImport(SwiftSyntax, _version: "5.9")"#), .active) - XCTAssertEqual(try ifConfigState("canImport(SwiftSyntax, _underlyingVersion: 5009)"), .active) - XCTAssertEqual(try ifConfigState("canImport(SwiftSyntax, _underlyingVersion: 5009.10)"), .inactive) - + assertIfConfig("canImport(SwiftSyntax)", .active) + assertIfConfig("canImport(SwiftSyntax.Sub)", .active) + assertIfConfig("canImport(SwiftParser)", .inactive) + assertIfConfig("canImport(SwiftSyntax, _version: 5.9)", .active) + assertIfConfig("canImport(SwiftSyntax, _version: 5.10)", .inactive) + assertIfConfig(#"canImport(SwiftSyntax, _version: "5.9")"#, .active) + assertIfConfig("canImport(SwiftSyntax, _underlyingVersion: 5009)", .active) + assertIfConfig("canImport(SwiftSyntax, _underlyingVersion: 5009.10", .inactive) + assertIfConfig( + "canImport(SwiftSyntax, _underlyingVersion: 5009.10.5.4.2.3.5", + .inactive, + diagnostics: [ + DiagnosticSpec( + message: "trailing components of version '5009.10.5.4' are ignored", + line: 1, + column: 44, + severity: .warning + ) + ] + ) } } From 96569a7642e770a2bc505f4104b1cfdbd881323b Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 4 Jul 2024 19:10:19 -0700 Subject: [PATCH 31/42] 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. --- .../SwiftIfConfig/IfConfigEvaluation.swift | 107 +++++++++++++----- Sources/SwiftIfConfig/IfConfigFunctions.swift | 13 +++ Sources/SwiftIfConfig/IfConfigState.swift | 6 +- Tests/SwiftIfConfigTest/EvaluateTests.swift | 79 +++++++++++-- 4 files changed, 160 insertions(+), 45 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index fb1b453022b..df72c2fb5b7 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -249,12 +249,15 @@ extension VersionTuple { /// build configuration itself. /// - Throws: Throws if an error occurs occur during evaluation. The error will /// also be provided to the diagnostic handler before doing so. -/// - Returns: Whether the condition holds with the given build configuration. +/// - Returns: A pair of Boolean values. The first describes whether the +/// condition holds with the given build configuration. The second whether +/// the build condition is a "versioned" check that implies that we shouldn't +/// diagnose syntax errors in blocks where the check fails. private func evaluateIfConfig( condition: ExprSyntax, configuration: some BuildConfiguration, diagnosticHandler: ((Diagnostic) -> Void)? -) throws -> Bool { +) throws -> (active: Bool, versioned: Bool) { /// Record the error before returning it. Use this for every 'throw' site /// in this evaluation. func recordedError(_ error: any Error, at node: some SyntaxProtocol) -> any Error { @@ -275,8 +278,8 @@ private func evaluateIfConfig( /// appropriate diagnostic for the handler before rethrowing it. func checkConfiguration( at node: some SyntaxProtocol, - body: () throws -> Bool - ) throws -> Bool { + body: () throws -> (Bool, Bool) + ) throws -> (active: Bool, versioned: Bool) { do { return try body() } catch let error { @@ -286,7 +289,7 @@ private func evaluateIfConfig( // Boolean literals evaluate as-is if let boolLiteral = condition.as(BooleanLiteralExprSyntax.self) { - return boolLiteral.literalValue + return (active: boolLiteral.literalValue, versioned: false) } // Integer literals aren't allowed, but we recognize them. @@ -302,7 +305,7 @@ private func evaluateIfConfig( ).asDiagnostic ) - return result + return (active: result, versioned: false) } // Declaration references are for custom compilation flags. @@ -312,7 +315,7 @@ private func evaluateIfConfig( // Evaluate the custom condition. If the build configuration cannot answer this query, fail. return try checkConfiguration(at: identExpr) { - try configuration.isCustomConditionSet(name: ident) + (active: try configuration.isCustomConditionSet(name: ident), versioned: false) } } @@ -320,11 +323,13 @@ private func evaluateIfConfig( if let prefixOp = condition.as(PrefixOperatorExprSyntax.self), prefixOp.operator.text == "!" { - return try !evaluateIfConfig( + let (innerActive, innerVersioned) = try evaluateIfConfig( condition: prefixOp.expression, configuration: configuration, diagnosticHandler: diagnosticHandler ) + + return (active: !innerActive, versioned: innerVersioned) } // Logical '&&' and '||'. @@ -333,25 +338,45 @@ private func evaluateIfConfig( (op.operator.text == "&&" || op.operator.text == "||") { // Evaluate the left-hand side. - let lhsResult = try evaluateIfConfig( + let (lhsActive, lhsVersioned) = try evaluateIfConfig( condition: binOp.leftOperand, configuration: configuration, diagnosticHandler: diagnosticHandler ) - // Short-circuit evaluation if we know the answer. - switch (lhsResult, op.operator.text) { - case (true, "||"): return true - case (false, "&&"): return false - default: break + // Short-circuit evaluation if we know the answer and the left-hand side + // was versioned. + if lhsVersioned { + switch (lhsActive, op.operator.text) { + case (true, "||"): return (active: true, versioned: lhsVersioned) + case (false, "&&"): return (active: false, versioned: lhsVersioned) + default: break + } } - // Evaluate the right-hand side and use that result. - return try evaluateIfConfig( + // Evaluate the right-hand side. + let (rhsActive, rhsVersioned) = try evaluateIfConfig( condition: binOp.rightOperand, configuration: configuration, diagnosticHandler: diagnosticHandler ) + + switch op.operator.text { + case "||": + return ( + active: lhsActive || rhsActive, + versioned: lhsVersioned && rhsVersioned + ) + + case "&&": + return ( + active: lhsActive && rhsActive, + versioned: lhsVersioned || rhsVersioned + ) + + default: + fatalError("prevented by condition for getting here") + } } // Look through parentheses. @@ -371,7 +396,10 @@ private func evaluateIfConfig( let fn = IfConfigFunctions(rawValue: fnName) { /// Perform a check for an operation that takes a single identifier argument. - func doSingleIdentifierArgumentCheck(_ body: (String) throws -> Bool, role: String) throws -> Bool { + func doSingleIdentifierArgumentCheck( + _ body: (String) throws -> Bool, + role: String + ) throws -> (active: Bool, versioned: Bool) { // Ensure that we have a single argument that is a simple identifier. guard let argExpr = call.arguments.singleUnlabeledExpression, let arg = argExpr.simpleIdentifierExpr @@ -382,12 +410,14 @@ private func evaluateIfConfig( } return try checkConfiguration(at: argExpr) { - try body(arg) + (active: try body(arg), versioned: fn.isVersioned) } } /// Perform a check for a version constraint as used in the "swift" or "compiler" version checks. - func doVersionComparisonCheck(_ actualVersion: VersionTuple) throws -> Bool { + func doVersionComparisonCheck( + _ actualVersion: VersionTuple + ) throws -> (active: Bool, versioned: Bool) { // Ensure that we have a single unlabeled argument that is either >= or < as a prefix // operator applied to a version. guard let argExpr = call.arguments.singleUnlabeledExpression, @@ -410,9 +440,9 @@ private func evaluateIfConfig( switch opToken.text { case ">=": - return actualVersion >= version + return (active: actualVersion >= version, versioned: fn.isVersioned) case "<": - return actualVersion < version + return (active: actualVersion < version, versioned: fn.isVersioned) default: throw recordedError(.unsupportedVersionOperator(name: fnName, operator: opToken)) } @@ -459,7 +489,10 @@ private func evaluateIfConfig( ) } - return configuration.endianness == expectedEndianness + return ( + active: configuration.endianness == expectedEndianness, + versioned: fn.isVersioned + ) case ._pointerBitWidth: // Ensure that we have a single argument that is a simple identifier, which @@ -479,7 +512,10 @@ private func evaluateIfConfig( ) } - return configuration.targetPointerBitWidth == expectedPointerBitWidth + return ( + active: configuration.targetPointerBitWidth == expectedPointerBitWidth, + versioned: fn.isVersioned + ) case .swift: return try doVersionComparisonCheck(configuration.languageVersion) @@ -508,7 +544,10 @@ private func evaluateIfConfig( let versionString = stringSegment.content.text let expectedVersion = try VersionTuple(parsingCompilerBuildVersion: versionString, argExpr) - return configuration.compilerVersion >= expectedVersion + return ( + active: configuration.compilerVersion >= expectedVersion, + versioned: fn.isVersioned + ) case .canImport: // Retrieve the first argument, which must not have a label. This is @@ -577,9 +616,12 @@ private func evaluateIfConfig( } return try checkConfiguration(at: call) { - try configuration.canImport( - importPath: importPath.map { String($0) }, - version: version + ( + active: try configuration.canImport( + importPath: importPath.map { String($0) }, + version: version + ), + versioned: fn.isVersioned ) } } @@ -602,12 +644,17 @@ extension IfConfigState { throw error }.cast(ExprSyntax.self) - let result = try evaluateIfConfig( + let (active, versioned) = try evaluateIfConfig( condition: foldedCondition, configuration: configuration, diagnosticHandler: diagnosticHandler ) - self = result ? .active : .inactive + + switch (active, versioned) { + case (true, _): self = .active + case (false, false): self = .inactive + case (false, true): self = .unparsed + } } } @@ -644,7 +691,7 @@ extension IfConfigDeclSyntax { condition: condition, configuration: configuration, diagnosticHandler: diagnosticHandler - ) { + ).active { return clause } } diff --git a/Sources/SwiftIfConfig/IfConfigFunctions.swift b/Sources/SwiftIfConfig/IfConfigFunctions.swift index 12ee69bf6ae..86bf6790f4e 100644 --- a/Sources/SwiftIfConfig/IfConfigFunctions.swift +++ b/Sources/SwiftIfConfig/IfConfigFunctions.swift @@ -52,4 +52,17 @@ enum IfConfigFunctions: String { /// A check for the target's pointer authentication scheme (e.g., _arm64e) /// via `_ptrauth()`. case _ptrauth + + /// Whether uses of this function consistute a "versioned" check. Such checks + /// suppress parser diagnostics if the block failed. + var isVersioned: Bool { + switch self { + case .swift, .compiler, ._compiler_version: + return true + + case .hasAttribute, .hasFeature, .canImport, .os, .arch, .targetEnvironment, + ._endian, ._pointerBitWidth, ._runtime, ._ptrauth: + return false + } + } } diff --git a/Sources/SwiftIfConfig/IfConfigState.swift b/Sources/SwiftIfConfig/IfConfigState.swift index b1eea6b3939..0fd5520d230 100644 --- a/Sources/SwiftIfConfig/IfConfigState.swift +++ b/Sources/SwiftIfConfig/IfConfigState.swift @@ -12,10 +12,8 @@ /// Describes the state of a particular region guarded by `#if` or similar. public enum IfConfigState { - /// The region is not parsed, and may contain syntax that is invalid. - /// - /// TODO: For now, the IfConfig library does not distinguish between - /// inactive and unparsed regions, so this case is never used. + /// The region is not part of the compiled program and is not even parsed, + /// and therefore many contain syntax that is invalid. case unparsed /// The region is parsed but is not part of the compiled program. case inactive diff --git a/Tests/SwiftIfConfigTest/EvaluateTests.swift b/Tests/SwiftIfConfigTest/EvaluateTests.swift index 849f74ad99e..153c41f8f4a 100644 --- a/Tests/SwiftIfConfigTest/EvaluateTests.swift +++ b/Tests/SwiftIfConfigTest/EvaluateTests.swift @@ -77,13 +77,46 @@ public class EvaluateTests: XCTestCase { assertIfConfig("DEBUG && ASSERTS", .active, configuration: buildConfig) assertIfConfig("DEBUG && nope", .inactive, configuration: buildConfig) assertIfConfig("nope && DEBUG", .inactive, configuration: buildConfig) - assertIfConfig("nope && 3.14159", .inactive, configuration: buildConfig) + assertIfConfig( + "nope && 3.14159", + nil, + configuration: buildConfig, + diagnostics: [ + DiagnosticSpec( + message: "invalid conditional compilation expression", + line: 1, + column: 9 + ) + ] + ) assertIfConfig("DEBUG || ASSERTS", .active, configuration: buildConfig) assertIfConfig("DEBUG || nope", .active, configuration: buildConfig) assertIfConfig("nope || DEBUG", .active, configuration: buildConfig) assertIfConfig("nope || !DEBUG", .inactive, configuration: buildConfig) - assertIfConfig("DEBUG || 3.14159", .active, configuration: buildConfig) - assertIfConfig("(DEBUG) || 3.14159", .active, configuration: buildConfig) + assertIfConfig( + "DEBUG || 3.14159", + nil, + configuration: buildConfig, + diagnostics: [ + DiagnosticSpec( + message: "invalid conditional compilation expression", + line: 1, + column: 10 + ) + ] + ) + assertIfConfig( + "(DEBUG) || 3.14159", + nil, + configuration: buildConfig, + diagnostics: [ + DiagnosticSpec( + message: "invalid conditional compilation expression", + line: 1, + column: 12 + ) + ] + ) } func testBadExpressions() throws { @@ -135,15 +168,39 @@ public class EvaluateTests: XCTestCase { } func testVersions() throws { - assertIfConfig("swift(>=5.5", .active) - assertIfConfig("swift(<6", .active) - assertIfConfig("swift(>=6", .inactive) - assertIfConfig("compiler(>=5.8", .active) - assertIfConfig("compiler(>=5.9", .active) - assertIfConfig("compiler(>=5.10", .inactive) + assertIfConfig("swift(>=5.5)", .active) + assertIfConfig("swift(<6)", .active) + assertIfConfig("swift(>=6)", .unparsed) + assertIfConfig("compiler(>=5.8)", .active) + assertIfConfig("compiler(>=5.9)", .active) + assertIfConfig("compiler(>=5.10)", .unparsed) assertIfConfig(#"_compiler_version("5009.*.1")"#, .active) - assertIfConfig(#"_compiler_version("5009.*.3.2.3")"#, .inactive) - assertIfConfig(#"_compiler_version("5010.*.0")"#, .inactive) + assertIfConfig(#"_compiler_version("5009.*.3.2.3")"#, .unparsed) + assertIfConfig(#"_compiler_version("5010.*.0")"#, .unparsed) + assertIfConfig("compiler(>=5.10) && 3.14159", .unparsed) + assertIfConfig( + "compiler(>=5.10) || 3.14159", + nil, + diagnostics: [ + DiagnosticSpec( + message: "invalid conditional compilation expression", + line: 1, + column: 21 + ) + ] + ) + assertIfConfig("compiler(>=5.9) || 3.14159", .active) + assertIfConfig( + "compiler(>=5.9) && 3.14159", + nil, + diagnostics: [ + DiagnosticSpec( + message: "invalid conditional compilation expression", + line: 1, + column: 20 + ) + ] + ) } func testCanImport() throws { From e6497999487a2432b2837eb84910c37f38b8ba4b Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 4 Jul 2024 20:03:56 -0700 Subject: [PATCH 32/42] Rework `isActive(in: configuration)` to distinguish inactive vs. unparsed Update the interface of this function to return an `IfConfigState` rather than just a `Bool`. Then, check the enclosing versioned conditions to distinguish between inactive vs. unparsed. Finally, add a marker-based assertion function that makes it easy to test the active state of any location in the source code. Use the new test to flush out an obvious bug in my original implementation of `isActive(in: configuration)`. --- .../SwiftIfConfig/IfConfigEvaluation.swift | 46 ++++++++++++++--- Sources/SwiftIfConfig/IfConfigVisitor.swift | 2 +- .../SwiftIfConfigTest/ActiveRegionTests.swift | 50 +++++++++++++++++++ Tests/SwiftIfConfigTest/Assertions.swift | 30 +++++++++++ Tests/SwiftIfConfigTest/VisitorTests.swift | 6 +-- 5 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 Tests/SwiftIfConfigTest/ActiveRegionTests.swift diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index df72c2fb5b7..c0dfef2dd69 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -715,25 +715,55 @@ extension SyntaxProtocol { /// #endif /// #endif /// - /// a call to `isActive` on the syntax node for the function `g` would return `true` when the + /// a call to `isActive` on the syntax node for the function `g` would return `active` when the /// configuration options `DEBUG` and `B` are provided, but `A` is not. - public func isActive(in configuration: some BuildConfiguration) throws -> Bool { + public func isActive( + in configuration: some BuildConfiguration, + diagnosticHandler: ((Diagnostic) -> Void)? = nil + ) throws -> IfConfigState { var currentNode: Syntax = Syntax(self) + var currentState: IfConfigState = .active + while let parent = currentNode.parent { // If the parent is an `#if` configuration, check whether our current - // clause is active. If not, we're in an inactive region. + // clause is active. If not, we're in an inactive region. We also + // need to determine whether if let ifConfigClause = currentNode.as(IfConfigClauseSyntax.self), - let ifConfigDecl = ifConfigClause.parent?.as(IfConfigDeclSyntax.self) + let ifConfigDecl = ifConfigClause.parent?.parent?.as(IfConfigDeclSyntax.self) { - if try ifConfigDecl.activeClause(in: configuration) != ifConfigClause { - return false + let activeClause = try ifConfigDecl.activeClause( + in: configuration, + diagnosticHandler: diagnosticHandler + ) + + if activeClause != ifConfigClause { + // This was not the active clause, so we know that we're in an + // inactive block. However, depending on the condition of this + // clause and any enclosing clauses, it might be an unparsed block. + + // Check this condition. + if let condition = ifConfigClause.condition { + // Evaluate this condition against the build configuration. + let (_, versioned) = try evaluateIfConfig( + condition: condition, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) + + // If the condition is versioned, this is an unparsed region. + // We already know that it is inactive, or we wouldn't be here. + if versioned { + return .unparsed + } + } + + currentState = .inactive } } currentNode = parent } - // No more enclosing nodes; this code is active. - return true + return currentState } } diff --git a/Sources/SwiftIfConfig/IfConfigVisitor.swift b/Sources/SwiftIfConfig/IfConfigVisitor.swift index e07aa99608d..d8ebf671686 100644 --- a/Sources/SwiftIfConfig/IfConfigVisitor.swift +++ b/Sources/SwiftIfConfig/IfConfigVisitor.swift @@ -75,7 +75,7 @@ open class ActiveSyntaxVisitor: SyntaxVisitor walk(Syntax(elements)) } - // Skip everything else in the + // Skip everything else in the #if. return .skipChildren } catch { return reportEvaluationError(at: node, error: error) diff --git a/Tests/SwiftIfConfigTest/ActiveRegionTests.swift b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift new file mode 100644 index 00000000000..58246416cb9 --- /dev/null +++ b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift @@ -0,0 +1,50 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import SwiftDiagnostics +import SwiftIfConfig +import SwiftParser +import SwiftSyntax +import SwiftSyntaxMacrosGenericTestSupport +import XCTest + +public class ActiveRegionTests: XCTestCase { + let linuxBuildConfig = TestingBuildConfiguration( + customConditions: ["DEBUG", "ASSERTS"], + features: ["ParameterPacks"], + attributes: ["available"] + ) + + func testActiveRegions() throws { + try assertActiveCode( + """ + #if DEBUG + 0️⃣func f() + #elseif ASSERTS + 1️⃣func g() + + #if compiler(>=8.0) + 2️⃣func h() + #else + 3️⃣var i + #endif + #endif + """, + configuration: linuxBuildConfig, + states: [ + "0️⃣": .active, + "1️⃣": .inactive, + "2️⃣": .unparsed, + "3️⃣": .inactive, + ] + ) + } +} diff --git a/Tests/SwiftIfConfigTest/Assertions.swift b/Tests/SwiftIfConfigTest/Assertions.swift index 4034cfd3de1..ed82a4e7207 100644 --- a/Tests/SwiftIfConfigTest/Assertions.swift +++ b/Tests/SwiftIfConfigTest/Assertions.swift @@ -61,7 +61,37 @@ func assertIfConfig( ) } } +} +/// Assert that the various marked positions in the source code have the +/// expected active states. +func assertActiveCode( + _ markedSource: String, + configuration: some BuildConfiguration = TestingBuildConfiguration(), + states: [String: IfConfigState], + file: StaticString = #filePath, + line: UInt = #line +) throws { + // Pull out the markers that we'll use to dig out nodes to query. + let (markerLocations, source) = extractMarkers(markedSource) + + var parser = Parser(source) + let tree = SourceFileSyntax.parse(from: &parser) + + for (marker, location) in markerLocations { + guard let expectedState = states[marker] else { + XCTFail("Missing marker \(marker) in expected states", file: file, line: line) + continue + } + + guard let token = tree.token(at: AbsolutePosition(utf8Offset: location)) else { + XCTFail("Unable to find token at location \(location)", file: file, line: line) + continue + } + + let actualState = try token.isActive(in: configuration) + XCTAssertEqual(actualState, expectedState, "at marker \(marker)", file: file, line: line) + } } /// Assert that applying the given build configuration to the source code diff --git a/Tests/SwiftIfConfigTest/VisitorTests.swift b/Tests/SwiftIfConfigTest/VisitorTests.swift index f7d3c29e3e6..12daa528327 100644 --- a/Tests/SwiftIfConfigTest/VisitorTests.swift +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -25,9 +25,9 @@ class AllActiveVisitor: ActiveSyntaxAnyVisitor { super.init(viewMode: .sourceAccurate, configuration: configuration) } open override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { - var active: Bool = false + var active: IfConfigState = .inactive XCTAssertNoThrow(try active = node.isActive(in: configuration)) - XCTAssertTrue(active) + XCTAssertEqual(active, .active) return .visitChildren } } From e1e67f1ad728f6fdcf492d486580b60a91156fe8 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 4 Jul 2024 20:14:58 -0700 Subject: [PATCH 33/42] NFC: Split out IfConfigError and the VersionTuple parsing into their own files --- Sources/SwiftIfConfig/IfConfigError.swift | 146 +++++++++++ .../SwiftIfConfig/IfConfigEvaluation.swift | 230 +----------------- .../SwiftIfConfig/VersionTuple+Parsing.swift | 105 ++++++++ 3 files changed, 254 insertions(+), 227 deletions(-) create mode 100644 Sources/SwiftIfConfig/IfConfigError.swift create mode 100644 Sources/SwiftIfConfig/VersionTuple+Parsing.swift diff --git a/Sources/SwiftIfConfig/IfConfigError.swift b/Sources/SwiftIfConfig/IfConfigError.swift new file mode 100644 index 00000000000..47256debcbb --- /dev/null +++ b/Sources/SwiftIfConfig/IfConfigError.swift @@ -0,0 +1,146 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import SwiftDiagnostics +import SwiftSyntax + +/// Describes the kinds of errors that can occur when processing #if conditions. +enum IfConfigError: Error, CustomStringConvertible { + case unknownExpression(ExprSyntax) + case unhandledFunction(name: String, syntax: ExprSyntax) + case requiresUnlabeledArgument(name: String, role: String, syntax: ExprSyntax) + case unsupportedVersionOperator(name: String, operator: TokenSyntax) + case invalidVersionOperand(name: String, syntax: ExprSyntax) + case emptyVersionComponent(syntax: ExprSyntax) + case compilerVersionOutOfRange(value: Int, upperLimit: Int, syntax: ExprSyntax) + case compilerVersionSecondComponentNotWildcard(syntax: ExprSyntax) + case compilerVersionTooManyComponents(syntax: ExprSyntax) + case canImportMissingModule(syntax: ExprSyntax) + case canImportLabel(syntax: ExprSyntax) + case canImportTwoParameters(syntax: ExprSyntax) + case ignoredTrailingComponents(version: VersionTuple, syntax: ExprSyntax) + case integerLiteralCondition(syntax: ExprSyntax, replacement: Bool) + + var description: String { + switch self { + case .unknownExpression: + return "invalid conditional compilation expression" + + case .unhandledFunction(name: let name, syntax: _): + return "build configuration cannot handle '\(name)'" + + case .requiresUnlabeledArgument(name: let name, role: let role, syntax: _): + return "\(name) requires a single unlabeled argument for the \(role)" + + case .unsupportedVersionOperator(name: let name, operator: let op): + return "'\(name)' version check does not support operator '\(op.trimmedDescription)'" + + case .invalidVersionOperand(name: let name, syntax: let version): + return "'\(name)' version check has invalid version '\(version.trimmedDescription)'" + + case .emptyVersionComponent(syntax: _): + return "found empty version component" + + case .compilerVersionOutOfRange(value: _, upperLimit: let upperLimit, syntax: _): + // FIXME: This matches the C++ implementation, but it would be more useful to + // provide the actual value as-written and avoid the mathy [0, N] syntax. + return "compiler version component out of range: must be in [0, \(upperLimit)]" + + case .compilerVersionSecondComponentNotWildcard(syntax: _): + return "the second version component is not used for comparison in legacy compiler versions" + + case .compilerVersionTooManyComponents(syntax: _): + return "compiler version must not have more than five components" + + case .canImportMissingModule(syntax: _): + return "canImport requires a module name" + + case .canImportLabel(syntax: _): + return "2nd parameter of canImport should be labeled as _version or _underlyingVersion" + + case .canImportTwoParameters(syntax: _): + return "canImport can take only two parameters" + + case .ignoredTrailingComponents(version: let version, syntax: _): + return "trailing components of version '\(version.description)' are ignored" + + case .integerLiteralCondition(syntax: let syntax, replacement: let replacement): + return "'\(syntax.trimmedDescription)' is not a valid conditional compilation expression, use '\(replacement)'" + } + } + + /// Retrieve the syntax node associated with this error. + var syntax: Syntax { + switch self { + case .unknownExpression(let syntax), + .unhandledFunction(name: _, syntax: let syntax), + .requiresUnlabeledArgument(name: _, role: _, syntax: let syntax), + .invalidVersionOperand(name: _, syntax: let syntax), + .emptyVersionComponent(syntax: let syntax), + .compilerVersionOutOfRange(value: _, upperLimit: _, syntax: let syntax), + .compilerVersionTooManyComponents(syntax: let syntax), + .compilerVersionSecondComponentNotWildcard(syntax: let syntax), + .canImportMissingModule(syntax: let syntax), + .canImportLabel(syntax: let syntax), + .canImportTwoParameters(syntax: let syntax), + .ignoredTrailingComponents(version: _, syntax: let syntax), + .integerLiteralCondition(syntax: let syntax, replacement: _): + return Syntax(syntax) + + case .unsupportedVersionOperator(name: _, operator: let op): + return Syntax(op) + } + } +} + +extension IfConfigError: DiagnosticMessage { + var message: String { description } + + var diagnosticID: MessageID { + .init(domain: "SwiftIfConfig", id: "IfConfigError") + } + + var severity: SwiftDiagnostics.DiagnosticSeverity { + switch self { + case .ignoredTrailingComponents: return .warning + default: return .error + } + } + + private struct SimpleFixItMessage: FixItMessage { + var message: String + + var fixItID: MessageID { + .init(domain: "SwiftIfConfig", id: "IfConfigFixIt") + } + } + + var asDiagnostic: Diagnostic { + // For the integer literal condition we have a Fix-It. + if case .integerLiteralCondition(let syntax, let replacement) = self { + return Diagnostic( + node: syntax, + message: self, + fixIt: .replace( + message: SimpleFixItMessage( + message: "replace with Boolean literal '\(replacement)'" + ), + oldNode: syntax, + newNode: BooleanLiteralExprSyntax( + literal: .keyword(replacement ? .true : .false) + ) + ) + ) + } + + return Diagnostic(node: syntax, message: self) + } +} diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index c0dfef2dd69..ae85e51374c 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information @@ -13,231 +13,6 @@ import SwiftDiagnostics import SwiftOperators import SwiftSyntax -enum IfConfigError: Error, CustomStringConvertible { - case unknownExpression(ExprSyntax) - case unhandledFunction(name: String, syntax: ExprSyntax) - case requiresUnlabeledArgument(name: String, role: String, syntax: ExprSyntax) - case unsupportedVersionOperator(name: String, operator: TokenSyntax) - case invalidVersionOperand(name: String, syntax: ExprSyntax) - case emptyVersionComponent(syntax: ExprSyntax) - case compilerVersionOutOfRange(value: Int, upperLimit: Int, syntax: ExprSyntax) - case compilerVersionSecondComponentNotWildcard(syntax: ExprSyntax) - case compilerVersionTooManyComponents(syntax: ExprSyntax) - case canImportMissingModule(syntax: ExprSyntax) - case canImportLabel(syntax: ExprSyntax) - case canImportTwoParameters(syntax: ExprSyntax) - case ignoredTrailingComponents(version: VersionTuple, syntax: ExprSyntax) - case integerLiteralCondition(syntax: ExprSyntax, replacement: Bool) - - var description: String { - switch self { - case .unknownExpression: - return "invalid conditional compilation expression" - - case .unhandledFunction(name: let name, syntax: _): - return "build configuration cannot handle '\(name)'" - - case .requiresUnlabeledArgument(name: let name, role: let role, syntax: _): - return "\(name) requires a single unlabeled argument for the \(role)" - - case .unsupportedVersionOperator(name: let name, operator: let op): - return "'\(name)' version check does not support operator '\(op.trimmedDescription)'" - - case .invalidVersionOperand(name: let name, syntax: let version): - return "'\(name)' version check has invalid version '\(version.trimmedDescription)'" - - case .emptyVersionComponent(syntax: _): - return "found empty version component" - - case .compilerVersionOutOfRange(value: _, upperLimit: let upperLimit, syntax: _): - // FIXME: This matches the C++ implementation, but it would be more useful to - // provide the actual value as-written and avoid the mathy [0, N] syntax. - return "compiler version component out of range: must be in [0, \(upperLimit)]" - - case .compilerVersionSecondComponentNotWildcard(syntax: _): - return "the second version component is not used for comparison in legacy compiler versions" - - case .compilerVersionTooManyComponents(syntax: _): - return "compiler version must not have more than five components" - - case .canImportMissingModule(syntax: _): - return "canImport requires a module name" - - case .canImportLabel(syntax: _): - return "2nd parameter of canImport should be labeled as _version or _underlyingVersion" - - case .canImportTwoParameters(syntax: _): - return "canImport can take only two parameters" - - case .ignoredTrailingComponents(version: let version, syntax: _): - return "trailing components of version '\(version.description)' are ignored" - - case .integerLiteralCondition(syntax: let syntax, replacement: let replacement): - return "'\(syntax.trimmedDescription)' is not a valid conditional compilation expression, use '\(replacement)'" - } - } - - /// Retrieve the syntax node associated with this error. - var syntax: Syntax { - switch self { - case .unknownExpression(let syntax), - .unhandledFunction(name: _, syntax: let syntax), - .requiresUnlabeledArgument(name: _, role: _, syntax: let syntax), - .invalidVersionOperand(name: _, syntax: let syntax), - .emptyVersionComponent(syntax: let syntax), - .compilerVersionOutOfRange(value: _, upperLimit: _, syntax: let syntax), - .compilerVersionTooManyComponents(syntax: let syntax), - .compilerVersionSecondComponentNotWildcard(syntax: let syntax), - .canImportMissingModule(syntax: let syntax), - .canImportLabel(syntax: let syntax), - .canImportTwoParameters(syntax: let syntax), - .ignoredTrailingComponents(version: _, syntax: let syntax), - .integerLiteralCondition(syntax: let syntax, replacement: _): - return Syntax(syntax) - - case .unsupportedVersionOperator(name: _, operator: let op): - return Syntax(op) - } - } -} - -extension IfConfigError: DiagnosticMessage { - var message: String { description } - - var diagnosticID: MessageID { - .init(domain: "SwiftIfConfig", id: "IfConfigError") - } - - var severity: SwiftDiagnostics.DiagnosticSeverity { - switch self { - case .ignoredTrailingComponents: return .warning - default: return .error - } - } - - private struct SimpleFixItMessage: FixItMessage { - var message: String - - var fixItID: MessageID { - .init(domain: "SwiftIfConfig", id: "IfConfigFixIt") - } - } - - var asDiagnostic: Diagnostic { - // For the integer literal condition we have a Fix-It. - if case .integerLiteralCondition(let syntax, let replacement) = self { - return Diagnostic( - node: syntax, - message: self, - fixIt: .replace( - message: SimpleFixItMessage( - message: "replace with Boolean literal '\(replacement)'" - ), - oldNode: syntax, - newNode: BooleanLiteralExprSyntax( - literal: .keyword(replacement ? .true : .false) - ) - ) - ) - } - - return Diagnostic(node: syntax, message: self) - } -} - -extension VersionTuple { - /// Parse a compiler build version of the form "5007.*.1.2.3*", which is - /// used by an older if configuration form `_compiler_version("...")`. - /// - Parameters: - /// - versionString: The version string for the compiler build version that - /// we are parsing. - /// - versionSyntax: The syntax node that contains the version string, used - /// only for diagnostic purposes. - fileprivate init( - parsingCompilerBuildVersion versionString: String, - _ versionSyntax: ExprSyntax - ) throws { - components = [] - - // Version value are separated by periods. - let componentStrings = versionString.split(separator: ".") - - /// Record a component after checking its value. - func recordComponent(_ value: Int) throws { - let limit = components.isEmpty ? 9223371 : 999 - if value < 0 || value > limit { - throw IfConfigError.compilerVersionOutOfRange(value: value, upperLimit: limit, syntax: versionSyntax) - } - - components.append(value) - } - - // Parse the components into version values. - for (index, componentString) in componentStrings.enumerated() { - // Check ahead of time for empty version components - if componentString.isEmpty { - throw IfConfigError.emptyVersionComponent(syntax: versionSyntax) - } - - // The second component is always "*", and is never used for comparison. - if index == 1 { - if componentString != "*" { - throw IfConfigError.compilerVersionSecondComponentNotWildcard(syntax: versionSyntax) - } - try recordComponent(0) - continue - } - - // Every other component must be an integer value. - guard let component = Int(componentString) else { - throw IfConfigError.invalidVersionOperand(name: "_compiler_version", syntax: versionSyntax) - } - - try recordComponent(component) - } - - // Only allowed to specify up to 5 version components. - if components.count > 5 { - throw IfConfigError.compilerVersionTooManyComponents(syntax: versionSyntax) - } - - // In the beginning, '_compiler_version(string-literal)' was designed for a - // different version scheme where the major was fairly large and the minor - // was ignored; now we use one where the minor is significant and major and - // minor match the Swift language version. Specifically, majors 600-1300 - // were used for Swift 1.0-5.5 (based on clang versions), but then we reset - // the numbering based on Swift versions, so 5.6 had major 5. We assume - // that majors below 600 use the new scheme and equal/above it use the old - // scheme. - // - // However, we want the string literal variant of '_compiler_version' to - // maintain source compatibility with old checks; that means checks for new - // versions have to be written so that old compilers will think they represent - // newer versions, while new compilers have to interpret old version number - // strings in a way that will compare correctly to the new versions compiled - // into them. - // - // To achieve this, modern compilers divide the major by 1000 and overwrite - // the wildcard component with the remainder, effectively shifting the last - // three digits of the major into the minor, before comparing it to the - // compiler version: - // - // _compiler_version("5007.*.1.2.3") -> 5.7.1.2.3 - // _compiler_version("1300.*.1.2.3") -> 1.300.1.2.3 (smaller than 5.6) - // _compiler_version( "600.*.1.2.3") -> 0.600.1.2.3 (smaller than 5.6) - // - // So if you want to specify a 5.7.z.a.b version, we ask users to either - // write it as 5007.*.z.a.b, or to use the new 'compiler(>= version)' - // syntax instead, which does not perform this conversion. - if !components.isEmpty { - if components.count > 1 { - components[1] = components[0] % 1000 - } - components[0] = components[0] / 1000 - } - } -} - /// Evaluate the condition of an `#if`. /// - Parameters: /// - condition: The condition to evaluate, which we assume has already been @@ -247,7 +22,8 @@ extension VersionTuple { /// - diagnosticHandler: Receives any diagnostics that are produced by the /// evaluation, whether from errors in the source code or produced by the /// build configuration itself. -/// - Throws: Throws if an error occurs occur during evaluation. The error will +/// - Throws: Throws if an error occurs occur during evaluation that prevents +/// this function from forming a valid result. The error will /// also be provided to the diagnostic handler before doing so. /// - Returns: A pair of Boolean values. The first describes whether the /// condition holds with the given build configuration. The second whether diff --git a/Sources/SwiftIfConfig/VersionTuple+Parsing.swift b/Sources/SwiftIfConfig/VersionTuple+Parsing.swift new file mode 100644 index 00000000000..03cad60653b --- /dev/null +++ b/Sources/SwiftIfConfig/VersionTuple+Parsing.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import SwiftSyntax + +extension VersionTuple { + /// Parse a compiler build version of the form "5007.*.1.2.3*", which is + /// used by an older if configuration form `_compiler_version("...")`. + /// - Parameters: + /// - versionString: The version string for the compiler build version that + /// we are parsing. + /// - versionSyntax: The syntax node that contains the version string, used + /// only for diagnostic purposes. + init( + parsingCompilerBuildVersion versionString: String, + _ versionSyntax: ExprSyntax + ) throws { + components = [] + + // Version value are separated by periods. + let componentStrings = versionString.split(separator: ".") + + /// Record a component after checking its value. + func recordComponent(_ value: Int) throws { + let limit = components.isEmpty ? 9223371 : 999 + if value < 0 || value > limit { + throw IfConfigError.compilerVersionOutOfRange(value: value, upperLimit: limit, syntax: versionSyntax) + } + + components.append(value) + } + + // Parse the components into version values. + for (index, componentString) in componentStrings.enumerated() { + // Check ahead of time for empty version components + if componentString.isEmpty { + throw IfConfigError.emptyVersionComponent(syntax: versionSyntax) + } + + // The second component is always "*", and is never used for comparison. + if index == 1 { + if componentString != "*" { + throw IfConfigError.compilerVersionSecondComponentNotWildcard(syntax: versionSyntax) + } + try recordComponent(0) + continue + } + + // Every other component must be an integer value. + guard let component = Int(componentString) else { + throw IfConfigError.invalidVersionOperand(name: "_compiler_version", syntax: versionSyntax) + } + + try recordComponent(component) + } + + // Only allowed to specify up to 5 version components. + if components.count > 5 { + throw IfConfigError.compilerVersionTooManyComponents(syntax: versionSyntax) + } + + // In the beginning, '_compiler_version(string-literal)' was designed for a + // different version scheme where the major was fairly large and the minor + // was ignored; now we use one where the minor is significant and major and + // minor match the Swift language version. Specifically, majors 600-1300 + // were used for Swift 1.0-5.5 (based on clang versions), but then we reset + // the numbering based on Swift versions, so 5.6 had major 5. We assume + // that majors below 600 use the new scheme and equal/above it use the old + // scheme. + // + // However, we want the string literal variant of '_compiler_version' to + // maintain source compatibility with old checks; that means checks for new + // versions have to be written so that old compilers will think they represent + // newer versions, while new compilers have to interpret old version number + // strings in a way that will compare correctly to the new versions compiled + // into them. + // + // To achieve this, modern compilers divide the major by 1000 and overwrite + // the wildcard component with the remainder, effectively shifting the last + // three digits of the major into the minor, before comparing it to the + // compiler version: + // + // _compiler_version("5007.*.1.2.3") -> 5.7.1.2.3 + // _compiler_version("1300.*.1.2.3") -> 1.300.1.2.3 (smaller than 5.6) + // _compiler_version( "600.*.1.2.3") -> 0.600.1.2.3 (smaller than 5.6) + // + // So if you want to specify a 5.7.z.a.b version, we ask users to either + // write it as 5007.*.z.a.b, or to use the new 'compiler(>= version)' + // syntax instead, which does not perform this conversion. + if !components.isEmpty { + if components.count > 1 { + components[1] = components[0] % 1000 + } + components[0] = components[0] / 1000 + } + } +} From b7e9a4d84819f954677a3d3d7b2c455335baa7b1 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 4 Jul 2024 20:17:42 -0700 Subject: [PATCH 34/42] Clarify documentation a little bit --- Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md index 963315c5290..7e45523442c 100644 --- a/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md +++ b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md @@ -4,7 +4,7 @@ A library to evaluate `#if` conditionals within a Swift syntax tree. ## Overview -Swift provides the ability to conditionally compile parts of a source file based on various built-time conditions, including information about the target (operating system, processor architecture, environment), information about the compiler (version, supported attributes and features), and user-supplied conditions specified as part of the build (e.g., `DEBUG`), which we collectively refer to as the *build configuration*. These conditions can occur within a `#if` in the source code, e.g., +Swift provides the ability to conditionally compile parts of a source file based on various build-time conditions, including information about the target (operating system, processor architecture, environment), information about the compiler (version, supported attributes and features), and user-supplied conditions specified as part of the build (e.g., `DEBUG`), which we collectively refer to as the *build configuration*. These conditions can occur within a `#if` in the source code, e.g., ```swift func f() { @@ -29,4 +29,7 @@ The `SwiftIfConfig` library provides utilities to determine which syntax nodes a * and are visitor types that only visit the syntax nodes that are included ("active") for a given build configuration, implicitly skipping any nodes within inactive `#if` clauses. * ``SyntaxProtocol/removingInactive(in:)`` produces a syntax node that removes all inactive regions (and their corresponding `IfConfigDeclSyntax` nodes) from the given syntax tree, returning a new tree that is free of `#if` conditions. * ``IfConfigDeclSyntax.activeClause(in:)`` determines which of the clauses of an `#if` is active for the given build configuration, returning the active clause. -* ``SyntaxProtocol.isActive(in:)`` determines whether the given syntax node is active for the given build configuration. +* ``SyntaxProtocol.isActive(in:)`` determines whether the given syntax node is active for the given build configuration. The result is one of "active" + (the node is included in the program), "inactive" (the node is not included + in the program), or "unparsed" (the node is not included in the program and + is also allowed to have syntax errors). From d8dcb8236322dd8892de93643eb9403783d92612 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 4 Jul 2024 20:25:35 -0700 Subject: [PATCH 35/42] Update CMake build for SwiftIfConfig changes --- Sources/SwiftIfConfig/CMakeLists.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftIfConfig/CMakeLists.txt b/Sources/SwiftIfConfig/CMakeLists.txt index 028a801e1fc..b0bb99445bf 100644 --- a/Sources/SwiftIfConfig/CMakeLists.txt +++ b/Sources/SwiftIfConfig/CMakeLists.txt @@ -6,18 +6,20 @@ # See http://swift.org/LICENSE.txt for license information # See http://swift.org/CONTRIBUTORS.txt for Swift project authors -add_swift_host_library(SwiftIfConfig +add_swift_syntax_library(SwiftIfConfig BuildConfiguration.swift + IfConfigError.swift IfConfigEvaluation.swift IfConfigFunctions.swift IfConfigRewriter.swift IfConfigState.swift IfConfigVisitor.swift SyntaxLiteralUtils.swift + VersionTuple+Parsing.swift VersionTuple.swift ) -target_link_libraries(SwiftIfConfig PUBLIC +target_link_swift_syntax_libraries(SwiftIfConfig PUBLIC SwiftSyntax SwiftDiagnostics SwiftOperators From b6c8a1b3215d931eebf79337499cc2e53a5f8c29 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 4 Jul 2024 22:17:23 -0700 Subject: [PATCH 36/42] Add a new API to compute the "configured regions" of a syntax tree This API produces information similar to the "active regions" API used within the compiler and by SourceKit, a sorted array that indicates the #if clauses that are active or inactive (including distinguishing inactive vs. unparsed). When this array has already been computed for a syntax tree, one can then use the new `SyntaxProtocol.isActive(inConfiguredRegions:)` function to determine whether a given node is active. This can be more efficient than the existing `SyntaxProtocol.isActive(in:)` when querying for many nodes. Test the new functionality by cross-checking the two `isActive` implementations against each other on existing tests. --- Sources/SwiftIfConfig/CMakeLists.txt | 1 + Sources/SwiftIfConfig/ConfiguredRegions.swift | 106 ++++++++++++++++++ .../SwiftIfConfig/IfConfigEvaluation.swift | 65 ++++++++--- .../SwiftIfConfig.docc/SwiftIfConfig.md | 1 + .../SwiftIfConfigTest/ActiveRegionTests.swift | 4 + Tests/SwiftIfConfigTest/Assertions.swift | 13 ++- 6 files changed, 172 insertions(+), 18 deletions(-) create mode 100644 Sources/SwiftIfConfig/ConfiguredRegions.swift diff --git a/Sources/SwiftIfConfig/CMakeLists.txt b/Sources/SwiftIfConfig/CMakeLists.txt index b0bb99445bf..f3a7dd43a72 100644 --- a/Sources/SwiftIfConfig/CMakeLists.txt +++ b/Sources/SwiftIfConfig/CMakeLists.txt @@ -8,6 +8,7 @@ add_swift_syntax_library(SwiftIfConfig BuildConfiguration.swift + ConfiguredRegions.swift IfConfigError.swift IfConfigEvaluation.swift IfConfigFunctions.swift diff --git a/Sources/SwiftIfConfig/ConfiguredRegions.swift b/Sources/SwiftIfConfig/ConfiguredRegions.swift new file mode 100644 index 00000000000..151d5bf1b00 --- /dev/null +++ b/Sources/SwiftIfConfig/ConfiguredRegions.swift @@ -0,0 +1,106 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import SwiftDiagnostics +import SwiftSyntax + +extension SyntaxProtocol { + /// Find all of the #if/#elseif/#else clauses within the given syntax node, + /// indicating their active state. This operation will recurse into active + /// clauses to represent the flattened nested structure, while nonactive + /// clauses need no recursion (because there is no relevant structure in + /// them). + /// + /// For example, given code like the following: + /// #if DEBUG + /// #if A + /// func f() + /// #elseif B + /// func g() + /// #endif + /// #else + /// #endif + /// + /// If the configuration options `DEBUG` and `B` are provided, but `A` is not, + /// the results will be contain: + /// - Active region for the `#if DEBUG` + /// - Inactive region for the `#if A` + /// - Active region for the `#elseif B` + /// - Inactive region for the final `#else`. + public func configuredRegions( + in configuration: some BuildConfiguration + ) -> [(IfConfigClauseSyntax, IfConfigState)] { + let visitor = ConfiguredRegionVisitor(configuration: configuration) + visitor.walk(self) + return visitor.regions + } +} + +fileprivate class ConfiguredRegionVisitor: SyntaxVisitor { + let configuration: Configuration + + /// The regions we've found so far. + var regions: [(IfConfigClauseSyntax, IfConfigState)] = [] + + /// Whether we are currently within an active region. + var inActiveRegion = true + + init(configuration: Configuration) { + self.configuration = configuration + super.init(viewMode: .sourceAccurate) + } + + override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { + // If're not within an + let activeClause = inActiveRegion ? (try? node.activeClause(in: configuration)) : nil + for clause in node.clauses { + // If this is the active clause, record it and then recurse into the + // elements. + if clause == activeClause { + assert(inActiveRegion) + + regions.append((clause, .active)) + + if let elements = clause.elements { + walk(elements) + } + + continue + } + + // For inactive clauses, distinguish between inactive and unparsed. + let isVersioned = + (try? clause.isVersioned( + configuration: configuration, + diagnosticHandler: nil + )) ?? false + + // If this is within an active region, or this is an unparsed region, + // record it. + if inActiveRegion || isVersioned { + regions.append((clause, isVersioned ? .unparsed : .inactive)) + } + + // Recurse into inactive (but not unparsed) regions to find any + // unparsed regions below. + if !isVersioned, let elements = clause.elements { + let priorInActiveRegion = inActiveRegion + inActiveRegion = false + defer { + inActiveRegion = priorInActiveRegion + } + walk(elements) + } + } + + return .skipChildren + } +} diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index ae85e51374c..720d82b2656 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -514,23 +514,13 @@ extension SyntaxProtocol { if activeClause != ifConfigClause { // This was not the active clause, so we know that we're in an - // inactive block. However, depending on the condition of this - // clause and any enclosing clauses, it might be an unparsed block. - - // Check this condition. - if let condition = ifConfigClause.condition { - // Evaluate this condition against the build configuration. - let (_, versioned) = try evaluateIfConfig( - condition: condition, - configuration: configuration, - diagnosticHandler: diagnosticHandler - ) - - // If the condition is versioned, this is an unparsed region. - // We already know that it is inactive, or we wouldn't be here. - if versioned { - return .unparsed - } + // inactive block. However, if the condition is versioned, this is an + // unparsed region. + if try ifConfigClause.isVersioned( + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) { + return .unparsed } currentState = .inactive @@ -542,4 +532,45 @@ extension SyntaxProtocol { return currentState } + + /// Determine whether the given syntax node is active given a set of + /// configured regions as produced by `configuredRegions(in:)`. + /// + /// This is + /// an approximation + public func isActive( + inConfiguredRegions regions: [(IfConfigClauseSyntax, IfConfigState)] + ) -> IfConfigState { + var currentState: IfConfigState = .active + for (ifClause, state) in regions { + if self.position < ifClause.position { + return currentState + } + + if self.position <= ifClause.endPosition { + currentState = state + } + } + + return currentState + } +} + +extension IfConfigClauseSyntax { + /// Determine whether this condition is "versioned". + func isVersioned( + configuration: some BuildConfiguration, + diagnosticHandler: ((Diagnostic) -> Void)? + ) throws -> Bool { + guard let condition else { return false } + + // Evaluate this condition against the build configuration. + let (_, versioned) = try evaluateIfConfig( + condition: condition, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) + + return versioned + } } diff --git a/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md index 7e45523442c..31b80c35885 100644 --- a/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md +++ b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md @@ -33,3 +33,4 @@ The `SwiftIfConfig` library provides utilities to determine which syntax nodes a (the node is included in the program), "inactive" (the node is not included in the program), or "unparsed" (the node is not included in the program and is also allowed to have syntax errors). +* ``SyntaxProtocol.configuredRegions(in:)`` produces an array describing the various regions in which a configuration has an effect, indicating active, inactive, and unparsed regions in the source tree. The array can be used as an input to `SyntaxProtocol.isActive(inConfiguredRegions:)`` to determine whether a given syntax node is active. diff --git a/Tests/SwiftIfConfigTest/ActiveRegionTests.swift b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift index 58246416cb9..6977fa5aa14 100644 --- a/Tests/SwiftIfConfigTest/ActiveRegionTests.swift +++ b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift @@ -26,6 +26,7 @@ public class ActiveRegionTests: XCTestCase { func testActiveRegions() throws { try assertActiveCode( """ + 4️⃣ #if DEBUG 0️⃣func f() #elseif ASSERTS @@ -37,6 +38,7 @@ public class ActiveRegionTests: XCTestCase { 3️⃣var i #endif #endif + 5️⃣token """, configuration: linuxBuildConfig, states: [ @@ -44,6 +46,8 @@ public class ActiveRegionTests: XCTestCase { "1️⃣": .inactive, "2️⃣": .unparsed, "3️⃣": .inactive, + "4️⃣": .active, + "5️⃣": .active, ] ) } diff --git a/Tests/SwiftIfConfigTest/Assertions.swift b/Tests/SwiftIfConfigTest/Assertions.swift index ed82a4e7207..bef2af3428d 100644 --- a/Tests/SwiftIfConfigTest/Assertions.swift +++ b/Tests/SwiftIfConfigTest/Assertions.swift @@ -78,6 +78,8 @@ func assertActiveCode( var parser = Parser(source) let tree = SourceFileSyntax.parse(from: &parser) + let configuredRegions = tree.configuredRegions(in: configuration) + for (marker, location) in markerLocations { guard let expectedState = states[marker] else { XCTFail("Missing marker \(marker) in expected states", file: file, line: line) @@ -90,7 +92,16 @@ func assertActiveCode( } let actualState = try token.isActive(in: configuration) - XCTAssertEqual(actualState, expectedState, "at marker \(marker)", file: file, line: line) + XCTAssertEqual(actualState, expectedState, "isActive(in:) at marker \(marker)", file: file, line: line) + + let actualViaRegions = token.isActive(inConfiguredRegions: configuredRegions) + XCTAssertEqual( + actualViaRegions, + expectedState, + "isActive(inConfiguredRegions:) at marker \(marker)", + file: file, + line: line + ) } } From e07f68a5e0ec107e80e0e1d29562b91e6f00b0e6 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Thu, 4 Jul 2024 22:35:22 -0700 Subject: [PATCH 37/42] DocC cleanups --- Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md index 31b80c35885..15e3922b0cf 100644 --- a/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md +++ b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md @@ -27,10 +27,10 @@ The syntax tree and its parser do not reason about the build configuration. Rath The `SwiftIfConfig` library provides utilities to determine which syntax nodes are part of a particular build configuration. Each utility requires that one provide a specific build configuration (i.e., an instance of a type that conforms to the protocol), and provides a different view on essentially the same information: * and are visitor types that only visit the syntax nodes that are included ("active") for a given build configuration, implicitly skipping any nodes within inactive `#if` clauses. -* ``SyntaxProtocol/removingInactive(in:)`` produces a syntax node that removes all inactive regions (and their corresponding `IfConfigDeclSyntax` nodes) from the given syntax tree, returning a new tree that is free of `#if` conditions. -* ``IfConfigDeclSyntax.activeClause(in:)`` determines which of the clauses of an `#if` is active for the given build configuration, returning the active clause. -* ``SyntaxProtocol.isActive(in:)`` determines whether the given syntax node is active for the given build configuration. The result is one of "active" +* `SyntaxProtocol/removingInactive(in:)` produces a syntax node that removes all inactive regions (and their corresponding `IfConfigDeclSyntax` nodes) from the given syntax tree, returning a new tree that is free of `#if` conditions. +* `IfConfigDeclSyntax.activeClause(in:)` determines which of the clauses of an `#if` is active for the given build configuration, returning the active clause. +* `SyntaxProtocol.isActive(in:)` determines whether the given syntax node is active for the given build configuration. The result is one of "active" (the node is included in the program), "inactive" (the node is not included in the program), or "unparsed" (the node is not included in the program and is also allowed to have syntax errors). -* ``SyntaxProtocol.configuredRegions(in:)`` produces an array describing the various regions in which a configuration has an effect, indicating active, inactive, and unparsed regions in the source tree. The array can be used as an input to `SyntaxProtocol.isActive(inConfiguredRegions:)`` to determine whether a given syntax node is active. +* `SyntaxProtocol.configuredRegions(in:)` produces an array describing the various regions in which a configuration has an effect, indicating active, inactive, and unparsed regions in the source tree. The array can be used as an input to `SyntaxProtocol.isActive(inConfiguredRegions:)` to determine whether a given syntax node is active. From abe00a80c9a595772aedf5fc80690194d50ed9e0 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Fri, 5 Jul 2024 21:00:42 -0700 Subject: [PATCH 38/42] Test active code regions with postfix #if --- Sources/SwiftIfConfig/IfConfigRewriter.swift | 3 -- .../SwiftIfConfigTest/ActiveRegionTests.swift | 29 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftIfConfig/IfConfigRewriter.swift b/Sources/SwiftIfConfig/IfConfigRewriter.swift index 5f4ec4667f7..8bb7861f3f6 100644 --- a/Sources/SwiftIfConfig/IfConfigRewriter.swift +++ b/Sources/SwiftIfConfig/IfConfigRewriter.swift @@ -287,9 +287,6 @@ class ActiveSyntaxRewriter: SyntaxRewriter { return applyBaseToPostfixExpression(base: base, postfix: postfixExpr) } - // FIXME: PostfixIfConfigExprSyntax has a different form that doesn't work - // well with the way dropInactive is written. We essentially need to - // thread a the "base" into the active clause. override func visit(_ node: PostfixIfConfigExprSyntax) -> ExprSyntax { let rewrittenNode = dropInactive(outerBase: nil, postfixIfConfig: node) if rewrittenNode == ExprSyntax(node) { diff --git a/Tests/SwiftIfConfigTest/ActiveRegionTests.swift b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift index 6977fa5aa14..23b281ba031 100644 --- a/Tests/SwiftIfConfigTest/ActiveRegionTests.swift +++ b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift @@ -51,4 +51,33 @@ public class ActiveRegionTests: XCTestCase { ] ) } + + func testActiveRegionsInPostfix() throws { + try assertActiveCode( + """ + 4️⃣a.b() + #if DEBUG + 0️⃣.c() + #elseif ASSERTS + 1️⃣.d() + #if compiler(>=8.0) + 2️⃣.e() + #else + 3️⃣.f() + #endif + #endif + 5️⃣.g() + """, + configuration: linuxBuildConfig, + states: [ + "0️⃣": .active, + "1️⃣": .inactive, + "2️⃣": .unparsed, + "3️⃣": .inactive, + "4️⃣": .active, + "5️⃣": .active, + ] + ) + + } } From 0b0ac13434233576e6a0c169639f8f2c5e91dcf6 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Fri, 5 Jul 2024 21:04:38 -0700 Subject: [PATCH 39/42] Rename IfConfigState -> ConfiguredRegionState The new name better describes that we're talking about regions that have to do with the build configuration. --- Package.swift | 5 +---- Sources/SwiftIfConfig/CMakeLists.txt | 2 +- ...ConfigState.swift => ConfiguredRegionState.swift} | 2 +- Sources/SwiftIfConfig/ConfiguredRegions.swift | 4 ++-- Sources/SwiftIfConfig/IfConfigEvaluation.swift | 12 ++++++------ Tests/SwiftIfConfigTest/Assertions.swift | 6 +++--- Tests/SwiftIfConfigTest/VisitorTests.swift | 2 +- 7 files changed, 15 insertions(+), 18 deletions(-) rename Sources/SwiftIfConfig/{IfConfigState.swift => ConfiguredRegionState.swift} (96%) diff --git a/Package.swift b/Package.swift index e9cf50c25b1..4d4a4a96a14 100644 --- a/Package.swift +++ b/Package.swift @@ -17,11 +17,8 @@ let package = Package( .library(name: "SwiftCompilerPlugin", targets: ["SwiftCompilerPlugin"]), .library(name: "SwiftDiagnostics", targets: ["SwiftDiagnostics"]), .library(name: "SwiftIDEUtils", targets: ["SwiftIDEUtils"]), -<<<<<<< HEAD - .library(name: "SwiftLexicalLookup", targets: ["SwiftLexicalLookup"]), -======= .library(name: "SwiftIfConfig", targets: ["SwiftIfConfig"]), ->>>>>>> d8c920b49 ([SwiftIfConfig] Add a new library for evaluating `#if` conditions.) + .library(name: "SwiftLexicalLookup", targets: ["SwiftLexicalLookup"]), .library(name: "SwiftOperators", targets: ["SwiftOperators"]), .library(name: "SwiftParser", targets: ["SwiftParser"]), .library(name: "SwiftParserDiagnostics", targets: ["SwiftParserDiagnostics"]), diff --git a/Sources/SwiftIfConfig/CMakeLists.txt b/Sources/SwiftIfConfig/CMakeLists.txt index f3a7dd43a72..471362fc840 100644 --- a/Sources/SwiftIfConfig/CMakeLists.txt +++ b/Sources/SwiftIfConfig/CMakeLists.txt @@ -9,11 +9,11 @@ add_swift_syntax_library(SwiftIfConfig BuildConfiguration.swift ConfiguredRegions.swift + ConfiguredRegionState.swift IfConfigError.swift IfConfigEvaluation.swift IfConfigFunctions.swift IfConfigRewriter.swift - IfConfigState.swift IfConfigVisitor.swift SyntaxLiteralUtils.swift VersionTuple+Parsing.swift diff --git a/Sources/SwiftIfConfig/IfConfigState.swift b/Sources/SwiftIfConfig/ConfiguredRegionState.swift similarity index 96% rename from Sources/SwiftIfConfig/IfConfigState.swift rename to Sources/SwiftIfConfig/ConfiguredRegionState.swift index 0fd5520d230..a6d9e043e2a 100644 --- a/Sources/SwiftIfConfig/IfConfigState.swift +++ b/Sources/SwiftIfConfig/ConfiguredRegionState.swift @@ -11,7 +11,7 @@ //===----------------------------------------------------------------------===// /// Describes the state of a particular region guarded by `#if` or similar. -public enum IfConfigState { +public enum ConfiguredRegionState { /// The region is not part of the compiled program and is not even parsed, /// and therefore many contain syntax that is invalid. case unparsed diff --git a/Sources/SwiftIfConfig/ConfiguredRegions.swift b/Sources/SwiftIfConfig/ConfiguredRegions.swift index 151d5bf1b00..419879f963a 100644 --- a/Sources/SwiftIfConfig/ConfiguredRegions.swift +++ b/Sources/SwiftIfConfig/ConfiguredRegions.swift @@ -37,7 +37,7 @@ extension SyntaxProtocol { /// - Inactive region for the final `#else`. public func configuredRegions( in configuration: some BuildConfiguration - ) -> [(IfConfigClauseSyntax, IfConfigState)] { + ) -> [(IfConfigClauseSyntax, ConfiguredRegionState)] { let visitor = ConfiguredRegionVisitor(configuration: configuration) visitor.walk(self) return visitor.regions @@ -48,7 +48,7 @@ fileprivate class ConfiguredRegionVisitor: Sy let configuration: Configuration /// The regions we've found so far. - var regions: [(IfConfigClauseSyntax, IfConfigState)] = [] + var regions: [(IfConfigClauseSyntax, ConfiguredRegionState)] = [] /// Whether we are currently within an active region. var inActiveRegion = true diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index 720d82b2656..727953b7f6a 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -406,7 +406,7 @@ private func evaluateIfConfig( throw recordedError(.unknownExpression(condition)) } -extension IfConfigState { +extension ConfiguredRegionState { /// Evaluate the given `#if` condition using the given build configuration, throwing an error if there is /// insufficient information to make a determination. public init( @@ -496,9 +496,9 @@ extension SyntaxProtocol { public func isActive( in configuration: some BuildConfiguration, diagnosticHandler: ((Diagnostic) -> Void)? = nil - ) throws -> IfConfigState { + ) throws -> ConfiguredRegionState { var currentNode: Syntax = Syntax(self) - var currentState: IfConfigState = .active + var currentState: ConfiguredRegionState = .active while let parent = currentNode.parent { // If the parent is an `#if` configuration, check whether our current @@ -539,9 +539,9 @@ extension SyntaxProtocol { /// This is /// an approximation public func isActive( - inConfiguredRegions regions: [(IfConfigClauseSyntax, IfConfigState)] - ) -> IfConfigState { - var currentState: IfConfigState = .active + inConfiguredRegions regions: [(IfConfigClauseSyntax, ConfiguredRegionState)] + ) -> ConfiguredRegionState { + var currentState: ConfiguredRegionState = .active for (ifClause, state) in regions { if self.position < ifClause.position { return currentState diff --git a/Tests/SwiftIfConfigTest/Assertions.swift b/Tests/SwiftIfConfigTest/Assertions.swift index bef2af3428d..00c3a442848 100644 --- a/Tests/SwiftIfConfigTest/Assertions.swift +++ b/Tests/SwiftIfConfigTest/Assertions.swift @@ -22,7 +22,7 @@ import _SwiftSyntaxTestSupport /// given build configuration. func assertIfConfig( _ condition: ExprSyntax, - _ expectedState: IfConfigState?, + _ expectedState: ConfiguredRegionState?, configuration: some BuildConfiguration = TestingBuildConfiguration(), diagnostics expectedDiagnostics: [DiagnosticSpec] = [], file: StaticString = #filePath, @@ -31,7 +31,7 @@ func assertIfConfig( // Evaluate the condition to check the state. var actualDiagnostics: [Diagnostic] = [] do { - let actualState = try IfConfigState(condition: condition, configuration: configuration) { diag in + let actualState = try ConfiguredRegionState(condition: condition, configuration: configuration) { diag in actualDiagnostics.append(diag) } XCTAssertEqual(actualState, expectedState, file: file, line: line) @@ -68,7 +68,7 @@ func assertIfConfig( func assertActiveCode( _ markedSource: String, configuration: some BuildConfiguration = TestingBuildConfiguration(), - states: [String: IfConfigState], + states: [String: ConfiguredRegionState], file: StaticString = #filePath, line: UInt = #line ) throws { diff --git a/Tests/SwiftIfConfigTest/VisitorTests.swift b/Tests/SwiftIfConfigTest/VisitorTests.swift index 12daa528327..ca5628994bc 100644 --- a/Tests/SwiftIfConfigTest/VisitorTests.swift +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -25,7 +25,7 @@ class AllActiveVisitor: ActiveSyntaxAnyVisitor { super.init(viewMode: .sourceAccurate, configuration: configuration) } open override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { - var active: IfConfigState = .inactive + var active: ConfiguredRegionState = .inactive XCTAssertNoThrow(try active = node.isActive(in: configuration)) XCTAssertEqual(active, .active) return .visitChildren From bccddb4e87403e2955b85b955baa54088a0a3a71 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Sat, 6 Jul 2024 17:09:06 -0700 Subject: [PATCH 40/42] 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. --- Sources/SwiftIfConfig/ConfiguredRegions.swift | 7 +- .../SwiftIfConfig/IfConfigEvaluation.swift | 30 ++++--- Sources/SwiftIfConfig/IfConfigRewriter.swift | 59 +++++++------ Sources/SwiftIfConfig/IfConfigVisitor.swift | 84 ++++++------------- .../SwiftIfConfigTest/ActiveRegionTests.swift | 17 ++++ Tests/SwiftIfConfigTest/VisitorTests.swift | 28 ++----- 6 files changed, 101 insertions(+), 124 deletions(-) diff --git a/Sources/SwiftIfConfig/ConfiguredRegions.swift b/Sources/SwiftIfConfig/ConfiguredRegions.swift index 419879f963a..3c4143e7930 100644 --- a/Sources/SwiftIfConfig/ConfiguredRegions.swift +++ b/Sources/SwiftIfConfig/ConfiguredRegions.swift @@ -59,8 +59,9 @@ fileprivate class ConfiguredRegionVisitor: Sy } override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { - // If're not within an - let activeClause = inActiveRegion ? (try? node.activeClause(in: configuration)) : nil + // If we're in an active region, find the active clause. Otherwise, + // there isn't one. + let activeClause = inActiveRegion ? node.activeClause(in: configuration) : nil for clause in node.clauses { // If this is the active clause, record it and then recurse into the // elements. @@ -81,7 +82,7 @@ fileprivate class ConfiguredRegionVisitor: Sy (try? clause.isVersioned( configuration: configuration, diagnosticHandler: nil - )) ?? false + )) ?? true // If this is within an active region, or this is an unparsed region, // record it. diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index 727953b7f6a..adc75979542 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -451,11 +451,13 @@ extension IfConfigDeclSyntax { /// command line, the second clause (containing `func g()`) would be returned. If neither was /// passed, this function will return `nil` to indicate that none of the regions are active. /// - /// If an error occurred while processing any of the `#if` clauses, this function will throw that error. + /// If an error occurrs while processing any of the `#if` clauses, + /// that clause will be considered inactive and this operation will + /// continue to evaluate later clauses. public func activeClause( in configuration: some BuildConfiguration, diagnosticHandler: ((Diagnostic) -> Void)? = nil - ) throws -> IfConfigClauseSyntax? { + ) -> IfConfigClauseSyntax? { for clause in clauses { // If there is no condition, we have reached an unconditional clause. Return it. guard let condition = clause.condition else { @@ -463,11 +465,13 @@ extension IfConfigDeclSyntax { } // If this condition evaluates true, return this clause. - if try evaluateIfConfig( - condition: condition, - configuration: configuration, - diagnosticHandler: diagnosticHandler - ).active { + let isActive = + (try? evaluateIfConfig( + condition: condition, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ))?.active ?? false + if isActive { return clause } } @@ -507,7 +511,7 @@ extension SyntaxProtocol { if let ifConfigClause = currentNode.as(IfConfigClauseSyntax.self), let ifConfigDecl = ifConfigClause.parent?.parent?.as(IfConfigDeclSyntax.self) { - let activeClause = try ifConfigDecl.activeClause( + let activeClause = ifConfigDecl.activeClause( in: configuration, diagnosticHandler: diagnosticHandler ) @@ -516,10 +520,12 @@ extension SyntaxProtocol { // This was not the active clause, so we know that we're in an // inactive block. However, if the condition is versioned, this is an // unparsed region. - if try ifConfigClause.isVersioned( - configuration: configuration, - diagnosticHandler: diagnosticHandler - ) { + let isVersioned = + (try? ifConfigClause.isVersioned( + configuration: configuration, + diagnosticHandler: diagnosticHandler + )) ?? true + if isVersioned { return .unparsed } diff --git a/Sources/SwiftIfConfig/IfConfigRewriter.swift b/Sources/SwiftIfConfig/IfConfigRewriter.swift index 8bb7861f3f6..a7b8eb14385 100644 --- a/Sources/SwiftIfConfig/IfConfigRewriter.swift +++ b/Sources/SwiftIfConfig/IfConfigRewriter.swift @@ -23,7 +23,7 @@ import SwiftDiagnostics import SwiftSyntax /// Syntax rewriter that only visits syntax nodes that are active according -/// to a particular build configuration build configuration. +/// to a particular build configuration. /// /// Given an example such as /// @@ -54,17 +54,11 @@ import SwiftSyntax /// than trivia). class ActiveSyntaxRewriter: SyntaxRewriter { let configuration: Configuration - var diagnostics: [Diagnostic] = [] init(configuration: Configuration) { self.configuration = configuration } - private func reportEvaluationError(at node: some SyntaxProtocol, error: Error) { - let newDiagnostics = error.asDiagnostics(at: node) - diagnostics.append(contentsOf: newDiagnostics) - } - private func dropInactive( _ node: List, elementAsIfConfig: (List.Element) -> IfConfigDeclSyntax? @@ -76,20 +70,8 @@ class ActiveSyntaxRewriter: SyntaxRewriter { // Find #ifs within the list. if let ifConfigDecl = elementAsIfConfig(element) { - // Evaluate the `#if` condition. - let activeClause: IfConfigClauseSyntax? - do { - activeClause = try ifConfigDecl.activeClause(in: configuration) - } catch { - // When an error occurs in the evaluation of the condition, - // keep the entire `#if`. - if anyChanged { - newElements.append(element) - } - - reportEvaluationError(at: element, error: error) - continue - } + // Retrieve the active `#if` clause + let activeClause = ifConfigDecl.activeClause(in: configuration) // If this is the first element that changed, note that we have // changes and add all prior elements to the list of new elements. @@ -248,14 +230,8 @@ class ActiveSyntaxRewriter: SyntaxRewriter { outerBase: ExprSyntax?, postfixIfConfig: PostfixIfConfigExprSyntax ) -> ExprSyntax { - // Determine the active clause within this syntax node. - let activeClause: IfConfigClauseSyntax? - do { - activeClause = try postfixIfConfig.config.activeClause(in: configuration) - } catch { - reportEvaluationError(at: postfixIfConfig, error: error) - return ExprSyntax(postfixIfConfig) - } + // Retrieve the active `if` clause. + let activeClause = postfixIfConfig.config.activeClause(in: configuration) guard case .postfixExpression(let postfixExpr) = activeClause?.elements else { @@ -304,8 +280,29 @@ extension SyntaxProtocol { /// /// Returns the syntax node with all inactive regions removed, along with an /// array containing any diagnostics produced along the way. + /// + /// If there are errors in the conditions of any configuration + /// clauses, e.g., `#if FOO > 10`, then the condition will be + /// considered to have failed and the clauses's elements will be + /// removed. public func removingInactive(in configuration: some BuildConfiguration) -> (Syntax, [Diagnostic]) { - let visitor = ActiveSyntaxRewriter(configuration: configuration) - return (visitor.rewrite(Syntax(self)), visitor.diagnostics) + // First pass: Find all of the active clauses for the #ifs we need to + // visit, along with any diagnostics produced along the way. This process + // does not change the tree in any way. + let visitor = ActiveSyntaxVisitor(viewMode: .sourceAccurate, configuration: configuration) + visitor.walk(self) + + // If there were no active clauses to visit, we're done! + if visitor.numIfClausesVisited == 0 { + return (Syntax(self), visitor.diagnostics) + } + + // Second pass: Rewrite the syntax tree by removing the inactive clauses + // from each #if (along with the #ifs themselves). + let rewriter = ActiveSyntaxRewriter(configuration: configuration) + return ( + rewriter.rewrite(Syntax(self)), + visitor.diagnostics + ) } } diff --git a/Sources/SwiftIfConfig/IfConfigVisitor.swift b/Sources/SwiftIfConfig/IfConfigVisitor.swift index d8ebf671686..4caba69c733 100644 --- a/Sources/SwiftIfConfig/IfConfigVisitor.swift +++ b/Sources/SwiftIfConfig/IfConfigVisitor.swift @@ -37,10 +37,8 @@ import SwiftSyntax /// /// All notes visited by this visitor will have the "active" state, i.e., /// `node.isActive(in: configuration)` will evaluate to `.active` or will -/// throw. When errors occur, they will be reported via a call to -/// `reportEvaluationError`, which can report the errors (the default is to -/// turn them into diagnostics that go into the `diagnostics` array) and then -/// choose whether to visit all of the `#if` clauses (the default) or skip them. +/// throw. When errors occur, they will be recorded in the set of +/// diagnostics. open class ActiveSyntaxVisitor: SyntaxVisitor { /// The build configuration, which will be queried for each relevant `#if`. public let configuration: Configuration @@ -48,38 +46,28 @@ open class ActiveSyntaxVisitor: SyntaxVisitor /// The set of diagnostics accumulated during this walk of active syntax. public var diagnostics: [Diagnostic] = [] + /// The number of "#if" clauses that were visited. + var numIfClausesVisited: Int = 0 + public init(viewMode: SyntaxTreeViewMode, configuration: Configuration) { self.configuration = configuration super.init(viewMode: viewMode) } - /// Called when the evaluation of an `#if` condition produces an error. - /// - /// By default, this records diagnostics from the error into the `diagnostics` - /// array. - /// - /// - Returns: Whether to visit the children of the `#if` or not after the - /// error. By default, this function returns `.visitChildren`. - open func reportEvaluationError(at node: IfConfigDeclSyntax, error: Error) -> SyntaxVisitorContinueKind { - let newDiagnostics = error.asDiagnostics(at: node) - diagnostics.append(contentsOf: newDiagnostics) - return .visitChildren - } - open override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { - do { - // If there is an active clause, visit it's children. - if let activeClause = try node.activeClause(in: configuration), - let elements = activeClause.elements - { - walk(Syntax(elements)) - } + let activeClause = node.activeClause(in: configuration) { diag in + self.diagnostics.append(diag) + } + + numIfClausesVisited += 1 - // Skip everything else in the #if. - return .skipChildren - } catch { - return reportEvaluationError(at: node, error: error) + // If there is an active clause, visit it's children. + if let activeClause, let elements = activeClause.elements { + walk(Syntax(elements)) } + + // Skip everything else in the #if. + return .skipChildren } } @@ -112,10 +100,8 @@ open class ActiveSyntaxVisitor: SyntaxVisitor /// /// All notes visited by this visitor will have the "active" state, i.e., /// `node.isActive(in: configuration)` will evaluate to `.active` or will -/// throw. When errors occur, they will be reported via a call to -/// `reportEvaluationError`, which can report the errors (the default is to -/// turn them into diagnostics that go into the `diagnostics` array) and then -/// choose whether to visit all of the `#if` clauses (the default) or skip them. +/// throw. When errors occur, they will be recorded in the set of +/// diagnostivs. open class ActiveSyntaxAnyVisitor: SyntaxAnyVisitor { /// The build configuration, which will be queried for each relevant `#if`. public let configuration: Configuration @@ -128,32 +114,16 @@ open class ActiveSyntaxAnyVisitor: SyntaxAnyV super.init(viewMode: viewMode) } - /// Called when the evaluation of an `#if` condition produces an error. - /// - /// By default, this records diagnostics from the error into the `diagnostics` - /// array. - /// - /// - Returns: Whether to visit the children of the `#if` or not after the - /// error. By default, this function returns `.visitChildren`. - open func reportEvaluationError(at node: IfConfigDeclSyntax, error: Error) -> SyntaxVisitorContinueKind { - let newDiagnostics = error.asDiagnostics(at: node) - diagnostics.append(contentsOf: newDiagnostics) - return .visitChildren - } - open override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { - do { - // If there is an active clause, visit it's children. - if let activeClause = try node.activeClause(in: configuration), - let elements = activeClause.elements - { - walk(Syntax(elements)) - } - - // Skip everything else in the - return .skipChildren - } catch { - return reportEvaluationError(at: node, error: error) + // If there is an active clause, visit it's children. + let activeClause = node.activeClause(in: configuration) { diag in + self.diagnostics.append(diag) } + if let activeClause, let elements = activeClause.elements { + walk(Syntax(elements)) + } + + // Skip everything else in the #if. + return .skipChildren } } diff --git a/Tests/SwiftIfConfigTest/ActiveRegionTests.swift b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift index 23b281ba031..8e553a8f032 100644 --- a/Tests/SwiftIfConfigTest/ActiveRegionTests.swift +++ b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift @@ -78,6 +78,23 @@ public class ActiveRegionTests: XCTestCase { "5️⃣": .active, ] ) + } + func testActiveRegionsWithErrors() throws { + try assertActiveCode( + """ + #if FOO > 10 + 0️⃣class Foo { + } + #else + 1️⃣class Fallback { + } + #endif + """, + states: [ + "0️⃣": .unparsed, + "1️⃣": .active, + ] + ) } } diff --git a/Tests/SwiftIfConfigTest/VisitorTests.swift b/Tests/SwiftIfConfigTest/VisitorTests.swift index ca5628994bc..eddb8c1d3d7 100644 --- a/Tests/SwiftIfConfigTest/VisitorTests.swift +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -166,7 +166,7 @@ public class VisitorTests: XCTestCase { configuration.badAttributes.insert("available") let visitor = NameCheckingVisitor( configuration: configuration, - expectedNames: ["f", "h", "i", "S", "generationCount", "value", "withAvail", "notAvail"] + expectedNames: ["f", "h", "i", "S", "generationCount", "value", "notAvail"] ) visitor.walk(inputSource) XCTAssertEqual(visitor.diagnostics.count, 3) @@ -213,27 +213,23 @@ public class VisitorTests: XCTestCase { diagnostics: [ DiagnosticSpec( message: "unacceptable attribute 'available'", - line: 51, - column: 1 + line: 3, + column: 18 ), DiagnosticSpec( message: "unacceptable attribute 'available'", - line: 1, - column: 2 + line: 42, + column: 20 ), DiagnosticSpec( message: "unacceptable attribute 'available'", - line: 27, - column: 17 + line: 51, + column: 18 ), ], expectedSource: """ - #if hasAttribute(available) - @available(*, deprecated, message: "use something else") - #else @MainActor - #endif func f() { } @@ -250,19 +246,9 @@ public class VisitorTests: XCTestCase { func i() { a.b - #if DEBUG .c - #endif - #if hasAttribute(available) - .d() - #endif } - - #if hasAttribute(available) - func withAvail() { } - #else func notAvail() { } - #endif """ ) } From aa97fbd35b039c9cc46018b801b4453cc0e0b1b1 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Sat, 6 Jul 2024 17:21:04 -0700 Subject: [PATCH 41/42] Reshuffle and rename some source files in SwiftIfConfig Rename source files in SwiftIfConfig to better reflect what they do, move the public APIs up to the tops of files, and split the massive IfConfigEvaluation.swift into several files. The file itself defines the core logic for doing the evaluation (which is internal to the library), and other source files provide public APIs on top of it. --- ...riter.swift => ActiveSyntaxRewriter.swift} | 68 ++++---- ...isitor.swift => ActiveSyntaxVisitor.swift} | 0 Sources/SwiftIfConfig/CMakeLists.txt | 6 +- .../SwiftIfConfig/ConfiguredRegionState.swift | 29 ++++ Sources/SwiftIfConfig/ConfiguredRegions.swift | 1 + .../SwiftIfConfig/IfConfigDecl+IfConfig.swift | 59 +++++++ .../SwiftIfConfig/IfConfigEvaluation.swift | 159 +----------------- .../SyntaxProtocol+IfConfig.swift | 95 +++++++++++ 8 files changed, 223 insertions(+), 194 deletions(-) rename Sources/SwiftIfConfig/{IfConfigRewriter.swift => ActiveSyntaxRewriter.swift} (100%) rename Sources/SwiftIfConfig/{IfConfigVisitor.swift => ActiveSyntaxVisitor.swift} (100%) create mode 100644 Sources/SwiftIfConfig/IfConfigDecl+IfConfig.swift create mode 100644 Sources/SwiftIfConfig/SyntaxProtocol+IfConfig.swift diff --git a/Sources/SwiftIfConfig/IfConfigRewriter.swift b/Sources/SwiftIfConfig/ActiveSyntaxRewriter.swift similarity index 100% rename from Sources/SwiftIfConfig/IfConfigRewriter.swift rename to Sources/SwiftIfConfig/ActiveSyntaxRewriter.swift index a7b8eb14385..a863c6776b2 100644 --- a/Sources/SwiftIfConfig/IfConfigRewriter.swift +++ b/Sources/SwiftIfConfig/ActiveSyntaxRewriter.swift @@ -22,6 +22,40 @@ import SwiftDiagnostics import SwiftSyntax +extension SyntaxProtocol { + /// Produce a copy of this syntax node that removes all syntax regions that + /// are inactive according to the given build configuration, leaving only + /// the code that is active within that build configuration. + /// + /// Returns the syntax node with all inactive regions removed, along with an + /// array containing any diagnostics produced along the way. + /// + /// If there are errors in the conditions of any configuration + /// clauses, e.g., `#if FOO > 10`, then the condition will be + /// considered to have failed and the clauses's elements will be + /// removed. + public func removingInactive(in configuration: some BuildConfiguration) -> (Syntax, [Diagnostic]) { + // First pass: Find all of the active clauses for the #ifs we need to + // visit, along with any diagnostics produced along the way. This process + // does not change the tree in any way. + let visitor = ActiveSyntaxVisitor(viewMode: .sourceAccurate, configuration: configuration) + visitor.walk(self) + + // If there were no active clauses to visit, we're done! + if visitor.numIfClausesVisited == 0 { + return (Syntax(self), visitor.diagnostics) + } + + // Second pass: Rewrite the syntax tree by removing the inactive clauses + // from each #if (along with the #ifs themselves). + let rewriter = ActiveSyntaxRewriter(configuration: configuration) + return ( + rewriter.rewrite(Syntax(self)), + visitor.diagnostics + ) + } +} + /// Syntax rewriter that only visits syntax nodes that are active according /// to a particular build configuration. /// @@ -272,37 +306,3 @@ class ActiveSyntaxRewriter: SyntaxRewriter { return visit(rewrittenNode) } } - -extension SyntaxProtocol { - /// Produce a copy of this syntax node that removes all syntax regions that - /// are inactive according to the given build configuration, leaving only - /// the code that is active within that build configuration. - /// - /// Returns the syntax node with all inactive regions removed, along with an - /// array containing any diagnostics produced along the way. - /// - /// If there are errors in the conditions of any configuration - /// clauses, e.g., `#if FOO > 10`, then the condition will be - /// considered to have failed and the clauses's elements will be - /// removed. - public func removingInactive(in configuration: some BuildConfiguration) -> (Syntax, [Diagnostic]) { - // First pass: Find all of the active clauses for the #ifs we need to - // visit, along with any diagnostics produced along the way. This process - // does not change the tree in any way. - let visitor = ActiveSyntaxVisitor(viewMode: .sourceAccurate, configuration: configuration) - visitor.walk(self) - - // If there were no active clauses to visit, we're done! - if visitor.numIfClausesVisited == 0 { - return (Syntax(self), visitor.diagnostics) - } - - // Second pass: Rewrite the syntax tree by removing the inactive clauses - // from each #if (along with the #ifs themselves). - let rewriter = ActiveSyntaxRewriter(configuration: configuration) - return ( - rewriter.rewrite(Syntax(self)), - visitor.diagnostics - ) - } -} diff --git a/Sources/SwiftIfConfig/IfConfigVisitor.swift b/Sources/SwiftIfConfig/ActiveSyntaxVisitor.swift similarity index 100% rename from Sources/SwiftIfConfig/IfConfigVisitor.swift rename to Sources/SwiftIfConfig/ActiveSyntaxVisitor.swift diff --git a/Sources/SwiftIfConfig/CMakeLists.txt b/Sources/SwiftIfConfig/CMakeLists.txt index 471362fc840..3a5d6ece0c1 100644 --- a/Sources/SwiftIfConfig/CMakeLists.txt +++ b/Sources/SwiftIfConfig/CMakeLists.txt @@ -7,15 +7,17 @@ # See http://swift.org/CONTRIBUTORS.txt for Swift project authors add_swift_syntax_library(SwiftIfConfig + ActiveSyntaxVisitor.swift + ActiveSyntaxRewriter.swift BuildConfiguration.swift ConfiguredRegions.swift ConfiguredRegionState.swift + IfConfigDecl+IfConfig.swift IfConfigError.swift IfConfigEvaluation.swift IfConfigFunctions.swift - IfConfigRewriter.swift - IfConfigVisitor.swift SyntaxLiteralUtils.swift + SyntaxProtocol+IfConfig.swift VersionTuple+Parsing.swift VersionTuple.swift ) diff --git a/Sources/SwiftIfConfig/ConfiguredRegionState.swift b/Sources/SwiftIfConfig/ConfiguredRegionState.swift index a6d9e043e2a..5596ea6e1d0 100644 --- a/Sources/SwiftIfConfig/ConfiguredRegionState.swift +++ b/Sources/SwiftIfConfig/ConfiguredRegionState.swift @@ -9,6 +9,9 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// +import SwiftDiagnostics +import SwiftOperators +import SwiftSyntax /// Describes the state of a particular region guarded by `#if` or similar. public enum ConfiguredRegionState { @@ -19,4 +22,30 @@ public enum ConfiguredRegionState { case inactive /// The region is active and is part of the compiled program. case active + + /// Evaluate the given `#if` condition using the given build configuration, throwing an error if there is + /// insufficient information to make a determination. + public init( + condition: some ExprSyntaxProtocol, + configuration: some BuildConfiguration, + diagnosticHandler: ((Diagnostic) -> Void)? = nil + ) throws { + // Apply operator folding for !/&&/||. + let foldedCondition = try OperatorTable.logicalOperators.foldAll(condition) { error in + diagnosticHandler?(error.asDiagnostic) + throw error + }.cast(ExprSyntax.self) + + let (active, versioned) = try evaluateIfConfig( + condition: foldedCondition, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) + + switch (active, versioned) { + case (true, _): self = .active + case (false, false): self = .inactive + case (false, true): self = .unparsed + } + } } diff --git a/Sources/SwiftIfConfig/ConfiguredRegions.swift b/Sources/SwiftIfConfig/ConfiguredRegions.swift index 3c4143e7930..f47bb5a33c7 100644 --- a/Sources/SwiftIfConfig/ConfiguredRegions.swift +++ b/Sources/SwiftIfConfig/ConfiguredRegions.swift @@ -44,6 +44,7 @@ extension SyntaxProtocol { } } +/// Helper class that walks a syntax tree looking for configured regions. fileprivate class ConfiguredRegionVisitor: SyntaxVisitor { let configuration: Configuration diff --git a/Sources/SwiftIfConfig/IfConfigDecl+IfConfig.swift b/Sources/SwiftIfConfig/IfConfigDecl+IfConfig.swift new file mode 100644 index 00000000000..823724ee965 --- /dev/null +++ b/Sources/SwiftIfConfig/IfConfigDecl+IfConfig.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import SwiftDiagnostics +import SwiftSyntax + +extension IfConfigDeclSyntax { + /// Given a particular build configuration, determine which clause (if any) is the "active" clause. + /// + /// For example, for code like the following: + /// ``` + /// #if A + /// func f() + /// #elseif B + /// func g() + /// #endif + /// ``` + /// + /// If the `A` configuration option was passed on the command line (e.g. via `-DA`), the first clause + /// (containing `func f()`) would be returned. If not, and if the `B`configuration was passed on the + /// command line, the second clause (containing `func g()`) would be returned. If neither was + /// passed, this function will return `nil` to indicate that none of the regions are active. + /// + /// If an error occurrs while processing any of the `#if` clauses, + /// that clause will be considered inactive and this operation will + /// continue to evaluate later clauses. + public func activeClause( + in configuration: some BuildConfiguration, + diagnosticHandler: ((Diagnostic) -> Void)? = nil + ) -> IfConfigClauseSyntax? { + for clause in clauses { + // If there is no condition, we have reached an unconditional clause. Return it. + guard let condition = clause.condition else { + return clause + } + + // If this condition evaluates true, return this clause. + let isActive = + (try? evaluateIfConfig( + condition: condition, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ))?.active ?? false + if isActive { + return clause + } + } + + return nil + } +} diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index adc75979542..70f596aee29 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// import SwiftDiagnostics -import SwiftOperators import SwiftSyntax /// Evaluate the condition of an `#if`. @@ -29,7 +28,7 @@ import SwiftSyntax /// condition holds with the given build configuration. The second whether /// the build condition is a "versioned" check that implies that we shouldn't /// diagnose syntax errors in blocks where the check fails. -private func evaluateIfConfig( +func evaluateIfConfig( condition: ExprSyntax, configuration: some BuildConfiguration, diagnosticHandler: ((Diagnostic) -> Void)? @@ -406,162 +405,6 @@ private func evaluateIfConfig( throw recordedError(.unknownExpression(condition)) } -extension ConfiguredRegionState { - /// Evaluate the given `#if` condition using the given build configuration, throwing an error if there is - /// insufficient information to make a determination. - public init( - condition: some ExprSyntaxProtocol, - configuration: some BuildConfiguration, - diagnosticHandler: ((Diagnostic) -> Void)? = nil - ) throws { - // Apply operator folding for !/&&/||. - let foldedCondition = try OperatorTable.logicalOperators.foldAll(condition) { error in - diagnosticHandler?(error.asDiagnostic) - throw error - }.cast(ExprSyntax.self) - - let (active, versioned) = try evaluateIfConfig( - condition: foldedCondition, - configuration: configuration, - diagnosticHandler: diagnosticHandler - ) - - switch (active, versioned) { - case (true, _): self = .active - case (false, false): self = .inactive - case (false, true): self = .unparsed - } - } -} - -extension IfConfigDeclSyntax { - /// Given a particular build configuration, determine which clause (if any) is the "active" clause. - /// - /// For example, for code like the following: - /// ``` - /// #if A - /// func f() - /// #elseif B - /// func g() - /// #endif - /// ``` - /// - /// If the `A` configuration option was passed on the command line (e.g. via `-DA`), the first clause - /// (containing `func f()`) would be returned. If not, and if the `B`configuration was passed on the - /// command line, the second clause (containing `func g()`) would be returned. If neither was - /// passed, this function will return `nil` to indicate that none of the regions are active. - /// - /// If an error occurrs while processing any of the `#if` clauses, - /// that clause will be considered inactive and this operation will - /// continue to evaluate later clauses. - public func activeClause( - in configuration: some BuildConfiguration, - diagnosticHandler: ((Diagnostic) -> Void)? = nil - ) -> IfConfigClauseSyntax? { - for clause in clauses { - // If there is no condition, we have reached an unconditional clause. Return it. - guard let condition = clause.condition else { - return clause - } - - // If this condition evaluates true, return this clause. - let isActive = - (try? evaluateIfConfig( - condition: condition, - configuration: configuration, - diagnosticHandler: diagnosticHandler - ))?.active ?? false - if isActive { - return clause - } - } - - return nil - } -} - -extension SyntaxProtocol { - /// Determine whether the given syntax node is active within the given build configuration. - /// - /// This function evaluates the enclosing stack of `#if` conditions to determine whether the - /// given node is active in the program when it is compiled with the given build configuration. - /// - /// For example, given code like the following: - /// #if DEBUG - /// #if A - /// func f() - /// #elseif B - /// func g() - /// #endif - /// #endif - /// - /// a call to `isActive` on the syntax node for the function `g` would return `active` when the - /// configuration options `DEBUG` and `B` are provided, but `A` is not. - public func isActive( - in configuration: some BuildConfiguration, - diagnosticHandler: ((Diagnostic) -> Void)? = nil - ) throws -> ConfiguredRegionState { - var currentNode: Syntax = Syntax(self) - var currentState: ConfiguredRegionState = .active - - while let parent = currentNode.parent { - // If the parent is an `#if` configuration, check whether our current - // clause is active. If not, we're in an inactive region. We also - // need to determine whether - if let ifConfigClause = currentNode.as(IfConfigClauseSyntax.self), - let ifConfigDecl = ifConfigClause.parent?.parent?.as(IfConfigDeclSyntax.self) - { - let activeClause = ifConfigDecl.activeClause( - in: configuration, - diagnosticHandler: diagnosticHandler - ) - - if activeClause != ifConfigClause { - // This was not the active clause, so we know that we're in an - // inactive block. However, if the condition is versioned, this is an - // unparsed region. - let isVersioned = - (try? ifConfigClause.isVersioned( - configuration: configuration, - diagnosticHandler: diagnosticHandler - )) ?? true - if isVersioned { - return .unparsed - } - - currentState = .inactive - } - } - - currentNode = parent - } - - return currentState - } - - /// Determine whether the given syntax node is active given a set of - /// configured regions as produced by `configuredRegions(in:)`. - /// - /// This is - /// an approximation - public func isActive( - inConfiguredRegions regions: [(IfConfigClauseSyntax, ConfiguredRegionState)] - ) -> ConfiguredRegionState { - var currentState: ConfiguredRegionState = .active - for (ifClause, state) in regions { - if self.position < ifClause.position { - return currentState - } - - if self.position <= ifClause.endPosition { - currentState = state - } - } - - return currentState - } -} - extension IfConfigClauseSyntax { /// Determine whether this condition is "versioned". func isVersioned( diff --git a/Sources/SwiftIfConfig/SyntaxProtocol+IfConfig.swift b/Sources/SwiftIfConfig/SyntaxProtocol+IfConfig.swift new file mode 100644 index 00000000000..e2b012dfd68 --- /dev/null +++ b/Sources/SwiftIfConfig/SyntaxProtocol+IfConfig.swift @@ -0,0 +1,95 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// +import SwiftDiagnostics +import SwiftSyntax + +extension SyntaxProtocol { + /// Determine whether the given syntax node is active within the given build configuration. + /// + /// This function evaluates the enclosing stack of `#if` conditions to determine whether the + /// given node is active in the program when it is compiled with the given build configuration. + /// + /// For example, given code like the following: + /// #if DEBUG + /// #if A + /// func f() + /// #elseif B + /// func g() + /// #endif + /// #endif + /// + /// a call to `isActive` on the syntax node for the function `g` would return `active` when the + /// configuration options `DEBUG` and `B` are provided, but `A` is not. + public func isActive( + in configuration: some BuildConfiguration, + diagnosticHandler: ((Diagnostic) -> Void)? = nil + ) throws -> ConfiguredRegionState { + var currentNode: Syntax = Syntax(self) + var currentState: ConfiguredRegionState = .active + + while let parent = currentNode.parent { + // If the parent is an `#if` configuration, check whether our current + // clause is active. If not, we're in an inactive region. We also + // need to determine whether + if let ifConfigClause = currentNode.as(IfConfigClauseSyntax.self), + let ifConfigDecl = ifConfigClause.parent?.parent?.as(IfConfigDeclSyntax.self) + { + let activeClause = ifConfigDecl.activeClause( + in: configuration, + diagnosticHandler: diagnosticHandler + ) + + if activeClause != ifConfigClause { + // This was not the active clause, so we know that we're in an + // inactive block. However, if the condition is versioned, this is an + // unparsed region. + let isVersioned = + (try? ifConfigClause.isVersioned( + configuration: configuration, + diagnosticHandler: diagnosticHandler + )) ?? true + if isVersioned { + return .unparsed + } + + currentState = .inactive + } + } + + currentNode = parent + } + + return currentState + } + + /// Determine whether the given syntax node is active given a set of + /// configured regions as produced by `configuredRegions(in:)`. + /// + /// This is + /// an approximation + public func isActive( + inConfiguredRegions regions: [(IfConfigClauseSyntax, ConfiguredRegionState)] + ) -> ConfiguredRegionState { + var currentState: ConfiguredRegionState = .active + for (ifClause, state) in regions { + if self.position < ifClause.position { + return currentState + } + + if self.position <= ifClause.endPosition { + currentState = state + } + } + + return currentState + } +} From 87790740a7861f9685d677cea1112762117519a2 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Sat, 6 Jul 2024 17:50:02 -0700 Subject: [PATCH 42/42] Add support for _hasAtomicBitWidth to evaluation --- .../SwiftIfConfig/BuildConfiguration.swift | 17 ++++++++++++++-- .../SwiftIfConfig/IfConfigEvaluation.swift | 20 ++++++++++++------- Sources/SwiftIfConfig/IfConfigFunctions.swift | 6 +++++- Tests/SwiftIfConfigTest/EvaluateTests.swift | 2 ++ .../TestingBuildConfiguration.swift | 2 ++ 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/Sources/SwiftIfConfig/BuildConfiguration.swift b/Sources/SwiftIfConfig/BuildConfiguration.swift index 4e1b6be441c..30cdac9c63d 100644 --- a/Sources/SwiftIfConfig/BuildConfiguration.swift +++ b/Sources/SwiftIfConfig/BuildConfiguration.swift @@ -213,17 +213,30 @@ public protocol BuildConfiguration { /// The bit width of a data pointer for the target architecture. /// - /// The target's pointer bit with (which also corresponds to the number of + /// The target's pointer bit width (which also corresponds to the number of /// bits in `Int`/`UInt`) can only be queried with the experimental syntax /// `_pointerBitWidth(_)`, e.g., /// /// ```swift - /// #if _pointerBitWidth(32) + /// #if _pointerBitWidth(_32) /// // 32-bit system /// #endif /// ``` var targetPointerBitWidth: Int { get } + /// The atomic bit widths that are natively supported by the target + /// architecture. + /// + /// This lists all of the bit widths for which the target provides support + /// for atomic operations. It can be queried with + /// `_hasAtomicBitWidth(_)`, e.g. + /// + /// ```swift + /// #if _hasAtomicBitWidth(_64) + /// // 64-bit atomics are available + /// #endif + var targetAtomicBitWidths: [Int] { get } + /// The endianness of the target architecture. /// /// The target's endianness can onyl be queried with the experimental syntax diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index 70f596aee29..bc9bb17b9ae 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -269,28 +269,34 @@ func evaluateIfConfig( versioned: fn.isVersioned ) - case ._pointerBitWidth: + case ._pointerBitWidth, ._hasAtomicBitWidth: // Ensure that we have a single argument that is a simple identifier, which // is an underscore followed by an integer. guard let argExpr = call.arguments.singleUnlabeledExpression, let arg = argExpr.simpleIdentifierExpr, let argFirst = arg.first, argFirst == "_", - let expectedPointerBitWidth = Int(arg.dropFirst()) + let expectedBitWidth = Int(arg.dropFirst()) else { throw recordedError( .requiresUnlabeledArgument( name: fnName, - role: "pointer bit with ('_' followed by an integer)", + role: "bit width ('_' followed by an integer)", syntax: ExprSyntax(call) ) ) } - return ( - active: configuration.targetPointerBitWidth == expectedPointerBitWidth, - versioned: fn.isVersioned - ) + let active: Bool + if fn == ._pointerBitWidth { + active = configuration.targetPointerBitWidth == expectedBitWidth + } else if fn == ._hasAtomicBitWidth { + active = configuration.targetAtomicBitWidths.contains(expectedBitWidth) + } else { + fatalError("extraneous case above not handled") + } + + return (active: active, versioned: fn.isVersioned) case .swift: return try doVersionComparisonCheck(configuration.languageVersion) diff --git a/Sources/SwiftIfConfig/IfConfigFunctions.swift b/Sources/SwiftIfConfig/IfConfigFunctions.swift index 86bf6790f4e..8b42ddb910a 100644 --- a/Sources/SwiftIfConfig/IfConfigFunctions.swift +++ b/Sources/SwiftIfConfig/IfConfigFunctions.swift @@ -36,6 +36,10 @@ enum IfConfigFunctions: String { /// A check for the target environment (e.g., simulator) via `targetEnvironment()`. case targetEnvironment + /// A check to determine whether the platform supports atomic operations + /// with the given bitwidth, e.g., `_hasAtomicBitWidth(_64)`. + case _hasAtomicBitWidth + /// A historical check against a specific compiler build version via `_compiler_version("")`. case _compiler_version @@ -61,7 +65,7 @@ enum IfConfigFunctions: String { return true case .hasAttribute, .hasFeature, .canImport, .os, .arch, .targetEnvironment, - ._endian, ._pointerBitWidth, ._runtime, ._ptrauth: + ._hasAtomicBitWidth, ._endian, ._pointerBitWidth, ._runtime, ._ptrauth: return false } } diff --git a/Tests/SwiftIfConfigTest/EvaluateTests.swift b/Tests/SwiftIfConfigTest/EvaluateTests.swift index 153c41f8f4a..3fe5c9c1c6c 100644 --- a/Tests/SwiftIfConfigTest/EvaluateTests.swift +++ b/Tests/SwiftIfConfigTest/EvaluateTests.swift @@ -165,6 +165,8 @@ public class EvaluateTests: XCTestCase { assertIfConfig("_ptrauth(none)", .inactive) assertIfConfig("_pointerBitWidth(_64)", .active) assertIfConfig("_pointerBitWidth(_32)", .inactive) + assertIfConfig("_hasAtomicBitWidth(_64)", .active) + assertIfConfig("_hasAtomicBitWidth(_128)", .inactive) } func testVersions() throws { diff --git a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift index a20913ac12f..39a5702f71d 100644 --- a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift +++ b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift @@ -93,6 +93,8 @@ struct TestingBuildConfiguration: BuildConfiguration { var targetPointerBitWidth: Int { 64 } + var targetAtomicBitWidths: [Int] { [32, 64] } + var endianness: SwiftIfConfig.Endianness { .little } var languageVersion: VersionTuple { VersionTuple(5, 5) }