diff --git a/Package.swift b/Package.swift index 4d4a4a96a14..2d626ac9ab5 100644 --- a/Package.swift +++ b/Package.swift @@ -143,7 +143,7 @@ let package = Package( .target( name: "SwiftIfConfig", - dependencies: ["SwiftSyntax", "SwiftOperators"], + dependencies: ["SwiftSyntax", "SwiftDiagnostics", "SwiftOperators"], exclude: ["CMakeLists.txt"] ), diff --git a/Release Notes/601.md b/Release Notes/601.md index 7036e503b33..cb704e26814 100644 --- a/Release Notes/601.md +++ b/Release Notes/601.md @@ -15,6 +15,10 @@ - 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 +- Added a new library `SwiftIfConfig`. + - Description: This new library provides facilities for evaluating `#if` conditions and determining which regions of a syntax tree are active according to a given build configuration. + - Pull Request: https://github.com/swiftlang/swift-syntax/pull/1816 + ## API Behavior Changes - `SyntaxProtocol.trimmed` detaches the node diff --git a/Sources/SwiftIfConfig/ActiveSyntaxRewriter.swift b/Sources/SwiftIfConfig/ActiveSyntaxRewriter.swift index a863c6776b2..0cdeb97e4e7 100644 --- a/Sources/SwiftIfConfig/ActiveSyntaxRewriter.swift +++ b/Sources/SwiftIfConfig/ActiveSyntaxRewriter.swift @@ -10,15 +10,6 @@ // //===----------------------------------------------------------------------===// -// -// 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 SwiftDiagnostics import SwiftSyntax @@ -34,7 +25,9 @@ extension SyntaxProtocol { /// 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]) { + public func removingInactive( + in configuration: some BuildConfiguration + ) -> (result: Syntax, diagnostics: [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. @@ -42,7 +35,7 @@ extension SyntaxProtocol { visitor.walk(self) // If there were no active clauses to visit, we're done! - if visitor.numIfClausesVisited == 0 { + if !visitor.visitedAnyIfClauses { return (Syntax(self), visitor.diagnostics) } @@ -88,12 +81,13 @@ extension SyntaxProtocol { /// than trivia). class ActiveSyntaxRewriter: SyntaxRewriter { let configuration: Configuration + var diagnostics: [Diagnostic] = [] init(configuration: Configuration) { self.configuration = configuration } - private func dropInactive( + private func dropInactive( _ node: List, elementAsIfConfig: (List.Element) -> IfConfigDeclSyntax? ) -> List { @@ -105,7 +99,10 @@ class ActiveSyntaxRewriter: SyntaxRewriter { // Find #ifs within the list. if let ifConfigDecl = elementAsIfConfig(element) { // Retrieve the active `#if` clause - let activeClause = ifConfigDecl.activeClause(in: configuration) + let (activeClause, localDiagnostics) = ifConfigDecl.activeClause(in: configuration) + + // Add these diagnostics. + diagnostics.append(contentsOf: localDiagnostics) // If this is the first element that changed, note that we have // changes and add all prior elements to the list of new elements. @@ -255,7 +252,8 @@ class ActiveSyntaxRewriter: SyntaxRewriter { return dropInactive(outerBase: base, postfixIfConfig: postfixIfConfig) } - preconditionFailure("Unhandled postfix expression in #if elimination") + assertionFailure("Unhandled postfix expression in #if elimination") + return postfix } /// Drop inactive regions from a postfix `#if` configuration, applying the @@ -265,7 +263,10 @@ class ActiveSyntaxRewriter: SyntaxRewriter { postfixIfConfig: PostfixIfConfigExprSyntax ) -> ExprSyntax { // Retrieve the active `if` clause. - let activeClause = postfixIfConfig.config.activeClause(in: configuration) + let (activeClause, localDiagnostics) = postfixIfConfig.config.activeClause(in: configuration) + + // Record these diagnostics. + diagnostics.append(contentsOf: localDiagnostics) guard case .postfixExpression(let postfixExpr) = activeClause?.elements else { diff --git a/Sources/SwiftIfConfig/ActiveSyntaxVisitor.swift b/Sources/SwiftIfConfig/ActiveSyntaxVisitor.swift index 4caba69c733..e27e237b4a6 100644 --- a/Sources/SwiftIfConfig/ActiveSyntaxVisitor.swift +++ b/Sources/SwiftIfConfig/ActiveSyntaxVisitor.swift @@ -9,6 +9,7 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// + import SwiftDiagnostics import SwiftSyntax @@ -36,18 +37,18 @@ import SwiftSyntax /// 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. When errors occur, they will be recorded in the set of +/// `node.isActive(in: configuration)` will have evaluated to `.active` +/// 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 - /// The set of diagnostics accumulated during this walk of active syntax. - public var diagnostics: [Diagnostic] = [] + /// The diagnostics accumulated during this walk of active syntax. + public private(set) var diagnostics: [Diagnostic] = [] - /// The number of "#if" clauses that were visited. - var numIfClausesVisited: Int = 0 + /// Whether we visited any "#if" clauses. + var visitedAnyIfClauses: Bool = false public init(viewMode: SyntaxTreeViewMode, configuration: Configuration) { self.configuration = configuration @@ -55,15 +56,16 @@ open class ActiveSyntaxVisitor: SyntaxVisitor } open override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { - let activeClause = node.activeClause(in: configuration) { diag in - self.diagnostics.append(diag) - } + // Note: there is a clone of this code in ActiveSyntaxAnyVisitor. If you + // change one, please also change the other. + let (activeClause, localDiagnostics) = node.activeClause(in: configuration) + diagnostics.append(contentsOf: localDiagnostics) - numIfClausesVisited += 1 + visitedAnyIfClauses = true // If there is an active clause, visit it's children. if let activeClause, let elements = activeClause.elements { - walk(Syntax(elements)) + walk(elements) } // Skip everything else in the #if. @@ -95,19 +97,14 @@ open class ActiveSyntaxVisitor: SyntaxVisitor /// 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. -/// -/// 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 recorded in the set of -/// diagnostivs. +/// `node.isActive(in: configuration)` will have evaluated to `.active`. +/// When errors occur, they will be recorded in the array of diagnostics. 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] = [] + /// The diagnostics accumulated during this walk of active syntax. + public private(set) var diagnostics: [Diagnostic] = [] public init(viewMode: SyntaxTreeViewMode, configuration: Configuration) { self.configuration = configuration @@ -115,12 +112,15 @@ open class ActiveSyntaxAnyVisitor: SyntaxAnyV } open override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { + // Note: there is a clone of this code in ActiveSyntaxVisitor. If you + // change one, please also change the other. + // If there is an active clause, visit it's children. - let activeClause = node.activeClause(in: configuration) { diag in - self.diagnostics.append(diag) - } + let (activeClause, localDiagnostics) = node.activeClause(in: configuration) + diagnostics.append(contentsOf: localDiagnostics) + if let activeClause, let elements = activeClause.elements { - walk(Syntax(elements)) + walk(elements) } // Skip everything else in the #if. diff --git a/Sources/SwiftIfConfig/BuildConfiguration.swift b/Sources/SwiftIfConfig/BuildConfiguration.swift index 30cdac9c63d..a3a62a95da8 100644 --- a/Sources/SwiftIfConfig/BuildConfiguration.swift +++ b/Sources/SwiftIfConfig/BuildConfiguration.swift @@ -253,7 +253,7 @@ public protocol BuildConfiguration { /// /// 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: + /// [SE-0212](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0212-compiler-version-directive.md). For example: /// /// ```swift /// #if swift(>=5.5) diff --git a/Sources/SwiftIfConfig/CMakeLists.txt b/Sources/SwiftIfConfig/CMakeLists.txt index 3a5d6ece0c1..350c134b9f9 100644 --- a/Sources/SwiftIfConfig/CMakeLists.txt +++ b/Sources/SwiftIfConfig/CMakeLists.txt @@ -11,7 +11,7 @@ add_swift_syntax_library(SwiftIfConfig ActiveSyntaxRewriter.swift BuildConfiguration.swift ConfiguredRegions.swift - ConfiguredRegionState.swift + IfConfigRegionState.swift IfConfigDecl+IfConfig.swift IfConfigError.swift IfConfigEvaluation.swift @@ -25,5 +25,4 @@ add_swift_syntax_library(SwiftIfConfig target_link_swift_syntax_libraries(SwiftIfConfig PUBLIC SwiftSyntax SwiftDiagnostics - SwiftOperators - SwiftParser) + SwiftOperators) diff --git a/Sources/SwiftIfConfig/ConfiguredRegions.swift b/Sources/SwiftIfConfig/ConfiguredRegions.swift index f47bb5a33c7..0706a7ba372 100644 --- a/Sources/SwiftIfConfig/ConfiguredRegions.swift +++ b/Sources/SwiftIfConfig/ConfiguredRegions.swift @@ -9,15 +9,14 @@ // 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). + /// indicating their active state. This operation will recurse into all + /// clauses to indicate regions of active / inactive / unparsed code. /// /// For example, given code like the following: /// #if DEBUG @@ -37,7 +36,7 @@ extension SyntaxProtocol { /// - Inactive region for the final `#else`. public func configuredRegions( in configuration: some BuildConfiguration - ) -> [(IfConfigClauseSyntax, ConfiguredRegionState)] { + ) -> [(IfConfigClauseSyntax, IfConfigRegionState)] { let visitor = ConfiguredRegionVisitor(configuration: configuration) visitor.walk(self) return visitor.regions @@ -49,7 +48,7 @@ fileprivate class ConfiguredRegionVisitor: Sy let configuration: Configuration /// The regions we've found so far. - var regions: [(IfConfigClauseSyntax, ConfiguredRegionState)] = [] + var regions: [(IfConfigClauseSyntax, IfConfigRegionState)] = [] /// Whether we are currently within an active region. var inActiveRegion = true @@ -62,7 +61,7 @@ fileprivate class ConfiguredRegionVisitor: Sy override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { // If we're in an active region, find the active clause. Otherwise, // there isn't one. - let activeClause = inActiveRegion ? node.activeClause(in: configuration) : nil + let activeClause = inActiveRegion ? node.activeClause(in: configuration).clause : nil for clause in node.clauses { // If this is the active clause, record it and then recurse into the // elements. @@ -79,11 +78,9 @@ fileprivate class ConfiguredRegionVisitor: Sy } // For inactive clauses, distinguish between inactive and unparsed. - let isVersioned = - (try? clause.isVersioned( - configuration: configuration, - diagnosticHandler: nil - )) ?? true + let isVersioned = clause.isVersioned( + configuration: configuration + ).versioned // If this is within an active region, or this is an unparsed region, // record it. diff --git a/Sources/SwiftIfConfig/IfConfigDecl+IfConfig.swift b/Sources/SwiftIfConfig/IfConfigDecl+IfConfig.swift index 823724ee965..68e9e6b7b48 100644 --- a/Sources/SwiftIfConfig/IfConfigDecl+IfConfig.swift +++ b/Sources/SwiftIfConfig/IfConfigDecl+IfConfig.swift @@ -9,6 +9,7 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// + import SwiftDiagnostics import SwiftSyntax @@ -25,35 +26,35 @@ extension IfConfigDeclSyntax { /// ``` /// /// 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 + /// (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, + /// If an error occurs 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? { + in configuration: some BuildConfiguration + ) -> (clause: IfConfigClauseSyntax?, diagnostics: [Diagnostic]) { + var diagnostics: [Diagnostic] = [] 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 + return (clause, diagnostics: diagnostics) } // If this condition evaluates true, return this clause. - let isActive = - (try? evaluateIfConfig( - condition: condition, - configuration: configuration, - diagnosticHandler: diagnosticHandler - ))?.active ?? false + let (isActive, _, localDiagnostics) = evaluateIfConfig( + condition: condition, + configuration: configuration + ) + diagnostics.append(contentsOf: localDiagnostics) + if isActive { - return clause + return (clause, diagnostics: diagnostics) } } - return nil + return (nil, diagnostics: diagnostics) } } diff --git a/Sources/SwiftIfConfig/IfConfigError.swift b/Sources/SwiftIfConfig/IfConfigError.swift index 47256debcbb..a0e33506c68 100644 --- a/Sources/SwiftIfConfig/IfConfigError.swift +++ b/Sources/SwiftIfConfig/IfConfigError.swift @@ -9,6 +9,7 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// + import SwiftDiagnostics import SwiftSyntax diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index bc9bb17b9ae..8faec1a7c31 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -9,6 +9,7 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// + import SwiftDiagnostics import SwiftSyntax @@ -18,35 +19,38 @@ import SwiftSyntax /// folded according to the logical operators table. /// - configuration: The configuration against which the condition will be /// evaluated. -/// - 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 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 +/// - Returns: A pair of Boolean values and any diagnostics produced during the +/// evaluation. The first Boolean describes whether the condition holds with +/// the given build configuration. The second Boolean described whether /// the build condition is a "versioned" check that implies that we shouldn't /// diagnose syntax errors in blocks where the check fails. func evaluateIfConfig( condition: ExprSyntax, - configuration: some BuildConfiguration, - diagnosticHandler: ((Diagnostic) -> Void)? -) 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 { - if let diagnosticHandler { - error.asDiagnostics(at: node).forEach { diagnosticHandler($0) } - } - - return error + configuration: some BuildConfiguration +) -> (active: Bool, versioned: Bool, diagnostics: [Diagnostic]) { + var extraDiagnostics: [Diagnostic] = [] + + /// Record the error before returning the given value. + func recordError( + _ error: any Error, + at node: some SyntaxProtocol + ) -> (active: Bool, versioned: Bool, diagnostics: [Diagnostic]) { + return ( + active: false, + versioned: true, + diagnostics: extraDiagnostics + error.asDiagnostics(at: node) + ) } /// 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) + func recordError( + _ error: IfConfigError + ) -> (active: Bool, versioned: Bool, diagnostics: [Diagnostic]) { + return recordError(error, at: error.syntax) } /// Check a configuration condition, translating any thrown error into an @@ -54,17 +58,22 @@ func evaluateIfConfig( func checkConfiguration( at node: some SyntaxProtocol, body: () throws -> (Bool, Bool) - ) throws -> (active: Bool, versioned: Bool) { + ) -> (active: Bool, versioned: Bool, diagnostics: [Diagnostic]) { do { - return try body() + let (active, versioned) = try body() + return (active, versioned, extraDiagnostics) } catch let error { - throw recordedError(error, at: node) + return recordError(error, at: node) } } // Boolean literals evaluate as-is if let boolLiteral = condition.as(BooleanLiteralExprSyntax.self) { - return (active: boolLiteral.literalValue, versioned: false) + return ( + active: boolLiteral.literalValue, + versioned: false, + diagnostics: extraDiagnostics + ) } // Integer literals aren't allowed, but we recognize them. @@ -73,14 +82,16 @@ func evaluateIfConfig( { let result = intLiteral.literal.text == "1" - diagnosticHandler?( - IfConfigError.integerLiteralCondition( - syntax: condition, - replacement: result - ).asDiagnostic + return ( + active: result, + versioned: false, + diagnostics: [ + IfConfigError.integerLiteralCondition( + syntax: condition, + replacement: result + ).asDiagnostic + ] ) - - return (active: result, versioned: false) } // Declaration references are for custom compilation flags. @@ -89,7 +100,7 @@ func evaluateIfConfig( let ident = identExpr.baseName.text // Evaluate the custom condition. If the build configuration cannot answer this query, fail. - return try checkConfiguration(at: identExpr) { + return checkConfiguration(at: identExpr) { (active: try configuration.isCustomConditionSet(name: ident), versioned: false) } } @@ -98,13 +109,12 @@ func evaluateIfConfig( if let prefixOp = condition.as(PrefixOperatorExprSyntax.self), prefixOp.operator.text == "!" { - let (innerActive, innerVersioned) = try evaluateIfConfig( + let (innerActive, innerVersioned, innerDiagnostics) = evaluateIfConfig( condition: prefixOp.expression, - configuration: configuration, - diagnosticHandler: diagnosticHandler + configuration: configuration ) - return (active: !innerActive, versioned: innerVersioned) + return (active: !innerActive, versioned: innerVersioned, diagnostics: innerDiagnostics) } // Logical '&&' and '||'. @@ -113,40 +123,43 @@ func evaluateIfConfig( (op.operator.text == "&&" || op.operator.text == "||") { // Evaluate the left-hand side. - let (lhsActive, lhsVersioned) = try evaluateIfConfig( + let (lhsActive, lhsVersioned, lhsDiagnostics) = evaluateIfConfig( condition: binOp.leftOperand, - configuration: configuration, - diagnosticHandler: diagnosticHandler + configuration: configuration ) // 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 + case (true, "||"): + return (active: true, versioned: lhsVersioned, diagnostics: lhsDiagnostics) + case (false, "&&"): + return (active: false, versioned: lhsVersioned, diagnostics: lhsDiagnostics) + default: + break } } // Evaluate the right-hand side. - let (rhsActive, rhsVersioned) = try evaluateIfConfig( + let (rhsActive, rhsVersioned, rhsDiagnostics) = evaluateIfConfig( condition: binOp.rightOperand, - configuration: configuration, - diagnosticHandler: diagnosticHandler + configuration: configuration ) switch op.operator.text { case "||": return ( active: lhsActive || rhsActive, - versioned: lhsVersioned && rhsVersioned + versioned: lhsVersioned && rhsVersioned, + diagnostics: lhsDiagnostics + rhsDiagnostics ) case "&&": return ( active: lhsActive && rhsActive, - versioned: lhsVersioned || rhsVersioned + versioned: lhsVersioned || rhsVersioned, + diagnostics: lhsDiagnostics + rhsDiagnostics ) default: @@ -158,10 +171,9 @@ func evaluateIfConfig( if let tuple = condition.as(TupleExprSyntax.self), tuple.isParentheses, let element = tuple.elements.first { - return try evaluateIfConfig( + return evaluateIfConfig( condition: element.expression, - configuration: configuration, - diagnosticHandler: diagnosticHandler + configuration: configuration ) } @@ -174,17 +186,17 @@ func evaluateIfConfig( func doSingleIdentifierArgumentCheck( _ body: (String) throws -> Bool, role: String - ) throws -> (active: Bool, versioned: Bool) { + ) -> (active: Bool, versioned: Bool, diagnostics: [Diagnostic]) { // Ensure that we have a single argument that is a simple identifier. guard let argExpr = call.arguments.singleUnlabeledExpression, let arg = argExpr.simpleIdentifierExpr else { - throw recordedError( + return recordError( .requiresUnlabeledArgument(name: fnName, role: role, syntax: ExprSyntax(call)) ) } - return try checkConfiguration(at: argExpr) { + return checkConfiguration(at: argExpr) { (active: try body(arg), versioned: fn.isVersioned) } } @@ -192,13 +204,13 @@ func evaluateIfConfig( /// Perform a check for a version constraint as used in the "swift" or "compiler" version checks. func doVersionComparisonCheck( _ actualVersion: VersionTuple - ) throws -> (active: Bool, versioned: Bool) { + ) -> (active: Bool, versioned: Bool, diagnostics: [Diagnostic]) { // 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) else { - throw recordedError( + return recordError( .requiresUnlabeledArgument( name: fnName, role: "version comparison (>= or <= a version)", @@ -210,40 +222,48 @@ func evaluateIfConfig( // Parse the version. let opToken = unaryArg.operator guard let version = VersionTuple(parsing: unaryArg.expression.trimmedDescription) else { - throw recordedError(.invalidVersionOperand(name: fnName, syntax: unaryArg.expression)) + return recordError(.invalidVersionOperand(name: fnName, syntax: unaryArg.expression)) } switch opToken.text { case ">=": - return (active: actualVersion >= version, versioned: fn.isVersioned) + return ( + active: actualVersion >= version, + versioned: fn.isVersioned, + diagnostics: extraDiagnostics + ) case "<": - return (active: actualVersion < version, versioned: fn.isVersioned) + return ( + active: actualVersion < version, + versioned: fn.isVersioned, + diagnostics: extraDiagnostics + ) default: - throw recordedError(.unsupportedVersionOperator(name: fnName, operator: opToken)) + return recordError(.unsupportedVersionOperator(name: fnName, operator: opToken)) } } switch fn { case .hasAttribute: - return try doSingleIdentifierArgumentCheck(configuration.hasAttribute, role: "attribute") + return doSingleIdentifierArgumentCheck(configuration.hasAttribute, role: "attribute") case .hasFeature: - return try doSingleIdentifierArgumentCheck(configuration.hasFeature, role: "feature") + return doSingleIdentifierArgumentCheck(configuration.hasFeature, role: "feature") case .os: - return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetOS, role: "operating system") + return doSingleIdentifierArgumentCheck(configuration.isActiveTargetOS, role: "operating system") case .arch: - return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetArchitecture, role: "architecture") + return doSingleIdentifierArgumentCheck(configuration.isActiveTargetArchitecture, role: "architecture") case .targetEnvironment: - return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetEnvironment, role: "environment") + return doSingleIdentifierArgumentCheck(configuration.isActiveTargetEnvironment, role: "environment") case ._runtime: - return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetRuntime, role: "runtime") + return doSingleIdentifierArgumentCheck(configuration.isActiveTargetRuntime, role: "runtime") case ._ptrauth: - return try doSingleIdentifierArgumentCheck( + return doSingleIdentifierArgumentCheck( configuration.isActiveTargetPointerAuthentication, role: "pointer authentication scheme" ) @@ -255,7 +275,7 @@ func evaluateIfConfig( let arg = argExpr.simpleIdentifierExpr, let expectedEndianness = Endianness(rawValue: arg) else { - throw recordedError( + return recordError( .requiresUnlabeledArgument( name: fnName, role: "endiannes ('big' or 'little')", @@ -266,7 +286,8 @@ func evaluateIfConfig( return ( active: configuration.endianness == expectedEndianness, - versioned: fn.isVersioned + versioned: fn.isVersioned, + diagnostics: extraDiagnostics ) case ._pointerBitWidth, ._hasAtomicBitWidth: @@ -278,7 +299,7 @@ func evaluateIfConfig( argFirst == "_", let expectedBitWidth = Int(arg.dropFirst()) else { - throw recordedError( + return recordError( .requiresUnlabeledArgument( name: fnName, role: "bit width ('_' followed by an integer)", @@ -296,13 +317,13 @@ func evaluateIfConfig( fatalError("extraneous case above not handled") } - return (active: active, versioned: fn.isVersioned) + return (active: active, versioned: fn.isVersioned, diagnostics: extraDiagnostics) case .swift: - return try doVersionComparisonCheck(configuration.languageVersion) + return doVersionComparisonCheck(configuration.languageVersion) case .compiler: - return try doVersionComparisonCheck(configuration.compilerVersion) + return doVersionComparisonCheck(configuration.compilerVersion) case ._compiler_version: // Argument is a single unlabeled argument containing a string @@ -313,7 +334,7 @@ func evaluateIfConfig( let segment = stringLiteral.segments.first, case .stringSegment(let stringSegment) = segment else { - throw recordedError( + return recordError( .requiresUnlabeledArgument( name: "_compiler_version", role: "version", @@ -323,11 +344,17 @@ func evaluateIfConfig( } let versionString = stringSegment.content.text - let expectedVersion = try VersionTuple(parsingCompilerBuildVersion: versionString, argExpr) + let expectedVersion: VersionTuple + do { + expectedVersion = try VersionTuple(parsingCompilerBuildVersion: versionString, argExpr) + } catch { + return recordError(error, at: stringSegment.content) + } return ( active: configuration.compilerVersion >= expectedVersion, - versioned: fn.isVersioned + versioned: fn.isVersioned, + diagnostics: extraDiagnostics ) case .canImport: @@ -336,7 +363,7 @@ func evaluateIfConfig( guard let firstArg = call.arguments.first, firstArg.label == nil else { - throw recordedError(.canImportMissingModule(syntax: ExprSyntax(call))) + return recordError(.canImportMissingModule(syntax: ExprSyntax(call))) } // FIXME: This is a gross hack. Actually look at the sequence of @@ -348,7 +375,7 @@ func evaluateIfConfig( let version: CanImportVersion if let secondArg = call.arguments.dropFirst().first { if secondArg.label?.text != "_version" && secondArg.label?.text != "_underlyingVersion" { - throw recordedError(.canImportLabel(syntax: secondArg.expression)) + return recordError(.canImportLabel(syntax: secondArg.expression)) } let versionText: String @@ -363,7 +390,7 @@ func evaluateIfConfig( } guard var versionTuple = VersionTuple(parsing: versionText) else { - throw recordedError( + return recordError( .invalidVersionOperand(name: "canImport", syntax: secondArg.expression) ) } @@ -374,7 +401,7 @@ func evaluateIfConfig( versionTuple.components.removeSubrange(4...) // Warn that we did this. - diagnosticHandler?( + extraDiagnostics.append( IfConfigError.ignoredTrailingComponents( version: versionTuple, syntax: secondArg.expression @@ -390,13 +417,13 @@ func evaluateIfConfig( } if call.arguments.count > 2 { - throw recordedError(.canImportTwoParameters(syntax: ExprSyntax(call))) + return recordError(.canImportTwoParameters(syntax: ExprSyntax(call))) } } else { version = .unversioned } - return try checkConfiguration(at: call) { + return checkConfiguration(at: call) { ( active: try configuration.canImport( importPath: importPath.map { String($0) }, @@ -408,24 +435,24 @@ func evaluateIfConfig( } } - throw recordedError(.unknownExpression(condition)) + return recordError(.unknownExpression(condition)) } extension IfConfigClauseSyntax { /// Determine whether this condition is "versioned". func isVersioned( - configuration: some BuildConfiguration, - diagnosticHandler: ((Diagnostic) -> Void)? - ) throws -> Bool { - guard let condition else { return false } + configuration: some BuildConfiguration + ) -> (versioned: Bool, diagnostics: [Diagnostic]) { + guard let condition else { + return (versioned: false, diagnostics: []) + } // Evaluate this condition against the build configuration. - let (_, versioned) = try evaluateIfConfig( + let (_, versioned, diagnostics) = evaluateIfConfig( condition: condition, - configuration: configuration, - diagnosticHandler: diagnosticHandler + configuration: configuration ) - return versioned + return (versioned, diagnostics) } } diff --git a/Sources/SwiftIfConfig/ConfiguredRegionState.swift b/Sources/SwiftIfConfig/IfConfigRegionState.swift similarity index 58% rename from Sources/SwiftIfConfig/ConfiguredRegionState.swift rename to Sources/SwiftIfConfig/IfConfigRegionState.swift index 5596ea6e1d0..af6965de5ed 100644 --- a/Sources/SwiftIfConfig/ConfiguredRegionState.swift +++ b/Sources/SwiftIfConfig/IfConfigRegionState.swift @@ -9,12 +9,13 @@ // 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 { +public enum IfConfigRegionState { /// The region is not part of the compiled program and is not even parsed, /// and therefore many contain syntax that is invalid. case unparsed @@ -23,29 +24,29 @@ public enum ConfiguredRegionState { /// 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 { + /// Evaluate the given `#if` condition using the given build configuration + /// to determine its state and identify any problems encountered along the + /// way. + public static func evaluating( + _ condition: some ExprSyntaxProtocol, + in configuration: some BuildConfiguration + ) -> (state: IfConfigRegionState, diagnostics: [Diagnostic]) { // Apply operator folding for !/&&/||. - let foldedCondition = try OperatorTable.logicalOperators.foldAll(condition) { error in - diagnosticHandler?(error.asDiagnostic) - throw error + var foldingDiagnostics: [Diagnostic] = [] + let foldedCondition = OperatorTable.logicalOperators.foldAll(condition) { error in + foldingDiagnostics.append(contentsOf: error.asDiagnostics(at: condition)) }.cast(ExprSyntax.self) - let (active, versioned) = try evaluateIfConfig( + let (active, versioned, evalDiagnostics) = evaluateIfConfig( condition: foldedCondition, - configuration: configuration, - diagnosticHandler: diagnosticHandler + configuration: configuration ) + let diagnostics = foldingDiagnostics + evalDiagnostics switch (active, versioned) { - case (true, _): self = .active - case (false, false): self = .inactive - case (false, true): self = .unparsed + case (true, _): return (.active, diagnostics) + case (false, false): return (.inactive, diagnostics) + case (false, true): return (.unparsed, diagnostics) } } } diff --git a/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md index 15e3922b0cf..3763309642a 100644 --- a/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md +++ b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md @@ -1,4 +1,4 @@ -# `SwiftIfConfig` +# SwiftIfConfig A library to evaluate `#if` conditionals within a Swift syntax tree. diff --git a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift index 85b946066e7..ebe6ec9af69 100644 --- a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift +++ b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift @@ -9,6 +9,7 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// + import SwiftSyntax extension BooleanLiteralExprSyntax { diff --git a/Sources/SwiftIfConfig/SyntaxProtocol+IfConfig.swift b/Sources/SwiftIfConfig/SyntaxProtocol+IfConfig.swift index e2b012dfd68..12c6dc2c5d2 100644 --- a/Sources/SwiftIfConfig/SyntaxProtocol+IfConfig.swift +++ b/Sources/SwiftIfConfig/SyntaxProtocol+IfConfig.swift @@ -9,6 +9,7 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// + import SwiftDiagnostics import SwiftSyntax @@ -30,35 +31,33 @@ extension SyntaxProtocol { /// 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 { + in configuration: some BuildConfiguration + ) -> (state: IfConfigRegionState, diagnostics: [Diagnostic]) { var currentNode: Syntax = Syntax(self) - var currentState: ConfiguredRegionState = .active + var currentState: IfConfigRegionState = .active + var diagnostics: [Diagnostic] = [] 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 + // need to determine whether an inactive region should be parsed or not. if let ifConfigClause = currentNode.as(IfConfigClauseSyntax.self), let ifConfigDecl = ifConfigClause.parent?.parent?.as(IfConfigDeclSyntax.self) { - let activeClause = ifConfigDecl.activeClause( - in: configuration, - diagnosticHandler: diagnosticHandler - ) + let (activeClause, localDiagnostics) = ifConfigDecl.activeClause(in: configuration) + diagnostics.append(contentsOf: localDiagnostics) 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 + let (isVersioned, localDiagnostics) = ifConfigClause.isVersioned( + configuration: configuration + ) + diagnostics.append(contentsOf: localDiagnostics) + if isVersioned { - return .unparsed + return (.unparsed, diagnostics) } currentState = .inactive @@ -68,18 +67,19 @@ extension SyntaxProtocol { currentNode = parent } - return currentState + return (currentState, diagnostics) } /// Determine whether the given syntax node is active given a set of /// configured regions as produced by `configuredRegions(in:)`. /// - /// This is - /// an approximation + /// If you are querying whether many syntax nodes in a particular file are + /// active, consider calling `configuredRegions(in:)` once and using + /// this function. For occasional queries, use `isActive(in:)`. public func isActive( - inConfiguredRegions regions: [(IfConfigClauseSyntax, ConfiguredRegionState)] - ) -> ConfiguredRegionState { - var currentState: ConfiguredRegionState = .active + inConfiguredRegions regions: [(IfConfigClauseSyntax, IfConfigRegionState)] + ) -> IfConfigRegionState { + var currentState: IfConfigRegionState = .active for (ifClause, state) in regions { if self.position < ifClause.position { return currentState diff --git a/Sources/SwiftIfConfig/VersionTuple+Parsing.swift b/Sources/SwiftIfConfig/VersionTuple+Parsing.swift index 03cad60653b..c8dc16eb8ec 100644 --- a/Sources/SwiftIfConfig/VersionTuple+Parsing.swift +++ b/Sources/SwiftIfConfig/VersionTuple+Parsing.swift @@ -9,6 +9,7 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// + import SwiftSyntax extension VersionTuple { diff --git a/Sources/SwiftSyntaxMacrosGenericTestSupport/Assertions.swift b/Sources/SwiftSyntaxMacrosGenericTestSupport/Assertions.swift index 2023ed6d55f..0ab97de0785 100644 --- a/Sources/SwiftSyntaxMacrosGenericTestSupport/Assertions.swift +++ b/Sources/SwiftSyntaxMacrosGenericTestSupport/Assertions.swift @@ -365,6 +365,7 @@ public enum DiagnosticAssertionContext { } } +@_spi(Testing) public func assertDiagnostic( _ diag: Diagnostic, in expansionContext: DiagnosticAssertionContext, diff --git a/Tests/SwiftIfConfigTest/ActiveRegionTests.swift b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift index 8e553a8f032..9059edfda4b 100644 --- a/Tests/SwiftIfConfigTest/ActiveRegionTests.swift +++ b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift @@ -9,12 +9,15 @@ // 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 +import _SwiftSyntaxGenericTestSupport +import _SwiftSyntaxTestSupport public class ActiveRegionTests: XCTestCase { let linuxBuildConfig = TestingBuildConfiguration( @@ -98,3 +101,45 @@ public class ActiveRegionTests: XCTestCase { ) } } + +/// 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: IfConfigRegionState], + 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) + + 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) + 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, _) = token.isActive(in: configuration) + 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 + ) + } +} diff --git a/Tests/SwiftIfConfigTest/Assertions.swift b/Tests/SwiftIfConfigTest/Assertions.swift deleted file mode 100644 index 00c3a442848..00000000000 --- a/Tests/SwiftIfConfigTest/Assertions.swift +++ /dev/null @@ -1,153 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 the results of evaluating the condition within an `#if` against the -/// given build configuration. -func assertIfConfig( - _ condition: ExprSyntax, - _ expectedState: ConfiguredRegionState?, - 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 ConfiguredRegionState(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 the various marked positions in the source code have the -/// expected active states. -func assertActiveCode( - _ markedSource: String, - configuration: some BuildConfiguration = TestingBuildConfiguration(), - states: [String: ConfiguredRegionState], - 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) - - 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) - 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, "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 - ) - } -} - -/// Assert that applying the given build configuration to the source code -/// returns the expected source and diagnostics. -func assertRemoveInactive( - _ source: String, - configuration: some 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 3fe5c9c1c6c..e7e5ad19a0a 100644 --- a/Tests/SwiftIfConfigTest/EvaluateTests.swift +++ b/Tests/SwiftIfConfigTest/EvaluateTests.swift @@ -9,11 +9,14 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// + +import SwiftDiagnostics import SwiftIfConfig import SwiftParser import SwiftSyntax -import SwiftSyntaxMacrosGenericTestSupport +@_spi(XCTestFailureLocation) @_spi(Testing) import SwiftSyntaxMacrosGenericTestSupport import XCTest +import _SwiftSyntaxGenericTestSupport import _SwiftSyntaxTestSupport public class EvaluateTests: XCTestCase { @@ -55,7 +58,7 @@ public class EvaluateTests: XCTestCase { ) assertIfConfig( "2", - nil, + .unparsed, configuration: buildConfig, diagnostics: [ DiagnosticSpec( @@ -79,7 +82,7 @@ public class EvaluateTests: XCTestCase { assertIfConfig("nope && DEBUG", .inactive, configuration: buildConfig) assertIfConfig( "nope && 3.14159", - nil, + .unparsed, configuration: buildConfig, diagnostics: [ DiagnosticSpec( @@ -95,7 +98,7 @@ public class EvaluateTests: XCTestCase { assertIfConfig("nope || !DEBUG", .inactive, configuration: buildConfig) assertIfConfig( "DEBUG || 3.14159", - nil, + .active, configuration: buildConfig, diagnostics: [ DiagnosticSpec( @@ -107,7 +110,7 @@ public class EvaluateTests: XCTestCase { ) assertIfConfig( "(DEBUG) || 3.14159", - nil, + .active, configuration: buildConfig, diagnostics: [ DiagnosticSpec( @@ -124,7 +127,7 @@ public class EvaluateTests: XCTestCase { assertIfConfig( "3.14159", - nil, + .unparsed, configuration: buildConfig, diagnostics: [ DiagnosticSpec( @@ -182,7 +185,7 @@ public class EvaluateTests: XCTestCase { assertIfConfig("compiler(>=5.10) && 3.14159", .unparsed) assertIfConfig( "compiler(>=5.10) || 3.14159", - nil, + .unparsed, diagnostics: [ DiagnosticSpec( message: "invalid conditional compilation expression", @@ -194,7 +197,7 @@ public class EvaluateTests: XCTestCase { assertIfConfig("compiler(>=5.9) || 3.14159", .active) assertIfConfig( "compiler(>=5.9) && 3.14159", - nil, + .unparsed, diagnostics: [ DiagnosticSpec( message: "invalid conditional compilation expression", @@ -228,3 +231,43 @@ public class EvaluateTests: XCTestCase { ) } } + +/// Assert the results of evaluating the condition within an `#if` against the +/// given build configuration. +func assertIfConfig( + _ condition: ExprSyntax, + _ expectedState: IfConfigRegionState, + configuration: some BuildConfiguration = TestingBuildConfiguration(), + diagnostics expectedDiagnostics: [DiagnosticSpec] = [], + file: StaticString = #filePath, + line: UInt = #line +) { + // Evaluate the condition to check the state. + let actualDiagnostics: [Diagnostic] + let actualState: IfConfigRegionState + (actualState, actualDiagnostics) = IfConfigRegionState.evaluating(condition, in: configuration) + XCTAssertEqual(actualState, 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) + } + ) + } + } +} diff --git a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift index 39a5702f71d..5f807fe0b1e 100644 --- a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift +++ b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift @@ -9,6 +9,7 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// + import SwiftIfConfig import SwiftSyntax diff --git a/Tests/SwiftIfConfigTest/VisitorTests.swift b/Tests/SwiftIfConfigTest/VisitorTests.swift index eddb8c1d3d7..ae3afd5acf2 100644 --- a/Tests/SwiftIfConfigTest/VisitorTests.swift +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -9,12 +9,15 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// + import SwiftDiagnostics import SwiftIfConfig import SwiftParser import SwiftSyntax -import SwiftSyntaxMacrosGenericTestSupport +@_spi(XCTestFailureLocation) @_spi(Testing) import SwiftSyntaxMacrosGenericTestSupport import XCTest +import _SwiftSyntaxGenericTestSupport +import _SwiftSyntaxTestSupport /// Visitor that ensures that all of the nodes we visit are active. /// @@ -25,9 +28,7 @@ class AllActiveVisitor: ActiveSyntaxAnyVisitor { super.init(viewMode: .sourceAccurate, configuration: configuration) } open override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { - var active: ConfiguredRegionState = .inactive - XCTAssertNoThrow(try active = node.isActive(in: configuration)) - XCTAssertEqual(active, .active) + XCTAssertEqual(node.isActive(in: configuration).state, .active) return .visitChildren } } @@ -253,3 +254,50 @@ public class VisitorTests: XCTestCase { ) } } + +/// Assert that applying the given build configuration to the source code +/// returns the expected source and diagnostics. +func assertRemoveInactive( + _ source: String, + configuration: some 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) + } + ) + } + } +}