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 diff --git a/Package.swift b/Package.swift index 99a11c22bfa..4d4a4a96a14 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,7 @@ let package = Package( .library(name: "SwiftCompilerPlugin", targets: ["SwiftCompilerPlugin"]), .library(name: "SwiftDiagnostics", targets: ["SwiftDiagnostics"]), .library(name: "SwiftIDEUtils", targets: ["SwiftIDEUtils"]), + .library(name: "SwiftIfConfig", targets: ["SwiftIfConfig"]), .library(name: "SwiftLexicalLookup", targets: ["SwiftLexicalLookup"]), .library(name: "SwiftOperators", targets: ["SwiftOperators"]), .library(name: "SwiftParser", targets: ["SwiftParser"]), @@ -138,6 +139,24 @@ 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", + "SwiftSyntaxMacrosGenericTestSupport", + ] + ), + // MARK: SwiftLexicalLookup .target( 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` 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/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/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/SwiftIfConfig/ActiveSyntaxRewriter.swift b/Sources/SwiftIfConfig/ActiveSyntaxRewriter.swift new file mode 100644 index 00000000000..a863c6776b2 --- /dev/null +++ b/Sources/SwiftIfConfig/ActiveSyntaxRewriter.swift @@ -0,0 +1,308 @@ +//===----------------------------------------------------------------------===// +// +// 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 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. +/// +/// Given an example such as +/// +/// ```swift +/// #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 +/// +/// ```swift +/// func f() { } +/// ``` +/// +/// When rewriting the above given a build configuration for iOS, the resulting +/// tree will be +/// +/// ```swift +/// 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) { + // 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. + 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: MemberBlockItemListSyntax) -> MemberBlockItemListSyntax { + 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) + } + + /// 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) + } + + preconditionFailure("Unhandled postfix expression in #if elimination") + } + + /// 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 { + // Retrieve the active `if` clause. + let activeClause = postfixIfConfig.config.activeClause(in: configuration) + + guard 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. We can + // only have both in an ill-formed syntax tree that was manually + // created. + 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("<#expression#>"), 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) + } + + override func visit(_ node: PostfixIfConfigExprSyntax) -> ExprSyntax { + let rewrittenNode = dropInactive(outerBase: nil, postfixIfConfig: node) + if rewrittenNode == ExprSyntax(node) { + return rewrittenNode + } + + return visit(rewrittenNode) + } +} diff --git a/Sources/SwiftIfConfig/ActiveSyntaxVisitor.swift b/Sources/SwiftIfConfig/ActiveSyntaxVisitor.swift new file mode 100644 index 00000000000..4caba69c733 --- /dev/null +++ b/Sources/SwiftIfConfig/ActiveSyntaxVisitor.swift @@ -0,0 +1,129 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftDiagnostics +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. 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 number of "#if" clauses that were visited. + var numIfClausesVisited: Int = 0 + + public init(viewMode: SyntaxTreeViewMode, configuration: Configuration) { + self.configuration = configuration + super.init(viewMode: viewMode) + } + + open override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { + let activeClause = node.activeClause(in: configuration) { diag in + self.diagnostics.append(diag) + } + + numIfClausesVisited += 1 + + // 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 + } +} + +/// 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. +/// +/// 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. +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) + } + + open override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { + // 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/Sources/SwiftIfConfig/BuildConfiguration.swift b/Sources/SwiftIfConfig/BuildConfiguration.swift new file mode 100644 index 00000000000..30cdac9c63d --- /dev/null +++ b/Sources/SwiftIfConfig/BuildConfiguration.swift @@ -0,0 +1,275 @@ +//===----------------------------------------------------------------------===// +// +// 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 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. +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. +/// +/// 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, 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. + /// + /// 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`, which could be checked with, e.g., + /// + /// ```swift + /// #if DEBUG + /// // ... + /// #endif + /// ``` + /// + /// - Parameters: + /// - name: The name of the custom build condition being checked (e.g., + /// `DEBUG`. + /// - Returns: Whether the custom condition is set. + func isCustomConditionSet(name: String) throws -> 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`, and can be checked with + /// the `hasFeature` syntax, e.g., + /// + /// ```swift + /// #if hasFeature(VariadicGenerics) + /// // ... + /// #endif + /// ``` + /// + /// - Parameters: + /// - name: The name of the feature being checked. + /// - Returns: Whether the requested feature is available. + 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 + /// ``` + /// + /// - Parameters: + /// - name: The name of the attribute being queried. + /// - Returns: Whether the requested attribute is supported. + func hasAttribute(name: String) throws -> 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., + /// + /// ```swift + /// #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`. + /// - Returns: Whether the module can be imported. + 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. + /// - 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) 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. + /// - Returns: Whether the given processor architecture is the target + /// architecture. + 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. + /// - Returns: Whether the target platform is for a specific environment, + /// such as a simulator or emulator. + 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. + /// - Returns: Whether the target runtime matches the given name. + func isActiveTargetRuntime(name: String) throws -> 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., + /// + /// ```swift + /// #if _ptrauth(arm64e) + /// // Special logic for arm64e pointer signing + /// #endif + /// ``` + /// - Parameters: + /// - name: The name of the pointer authentication scheme to check. + /// - Returns: Whether the code generated for the target will use the given + /// pointer authentication scheme. + func isActiveTargetPointerAuthentication(name: String) throws -> Bool + + /// The bit width of a data pointer for the target architecture. + /// + /// 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) + /// // 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 + /// `_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 } + + /// 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: + /// + /// ```swift + /// #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., + /// + /// ```swift + /// #if compiler(>=5.7) + /// // Hoorway, we can implicitly open existentials! + /// #endif + var compilerVersion: VersionTuple { get } +} diff --git a/Sources/SwiftIfConfig/CMakeLists.txt b/Sources/SwiftIfConfig/CMakeLists.txt new file mode 100644 index 00000000000..3a5d6ece0c1 --- /dev/null +++ b/Sources/SwiftIfConfig/CMakeLists.txt @@ -0,0 +1,29 @@ +# 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_syntax_library(SwiftIfConfig + ActiveSyntaxVisitor.swift + ActiveSyntaxRewriter.swift + BuildConfiguration.swift + ConfiguredRegions.swift + ConfiguredRegionState.swift + IfConfigDecl+IfConfig.swift + IfConfigError.swift + IfConfigEvaluation.swift + IfConfigFunctions.swift + SyntaxLiteralUtils.swift + SyntaxProtocol+IfConfig.swift + VersionTuple+Parsing.swift + VersionTuple.swift +) + +target_link_swift_syntax_libraries(SwiftIfConfig PUBLIC + SwiftSyntax + SwiftDiagnostics + SwiftOperators + SwiftParser) diff --git a/Sources/SwiftIfConfig/ConfiguredRegionState.swift b/Sources/SwiftIfConfig/ConfiguredRegionState.swift new file mode 100644 index 00000000000..5596ea6e1d0 --- /dev/null +++ b/Sources/SwiftIfConfig/ConfiguredRegionState.swift @@ -0,0 +1,51 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftDiagnostics +import SwiftOperators +import SwiftSyntax + +/// Describes the state of a particular region guarded by `#if` or similar. +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 + /// 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 + + /// 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 new file mode 100644 index 00000000000..f47bb5a33c7 --- /dev/null +++ b/Sources/SwiftIfConfig/ConfiguredRegions.swift @@ -0,0 +1,108 @@ +//===----------------------------------------------------------------------===// +// +// 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, ConfiguredRegionState)] { + let visitor = ConfiguredRegionVisitor(configuration: configuration) + visitor.walk(self) + return visitor.regions + } +} + +/// Helper class that walks a syntax tree looking for configured regions. +fileprivate class ConfiguredRegionVisitor: SyntaxVisitor { + let configuration: Configuration + + /// The regions we've found so far. + var regions: [(IfConfigClauseSyntax, ConfiguredRegionState)] = [] + + /// 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 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. + 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 + )) ?? true + + // 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/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/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 new file mode 100644 index 00000000000..bc9bb17b9ae --- /dev/null +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -0,0 +1,431 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// 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. +/// - 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 +/// 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 + } + + /// 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, Bool) + ) throws -> (active: Bool, versioned: 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 (active: boolLiteral.literalValue, versioned: false) + } + + // 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 (active: result, versioned: false) + } + + // Declaration references are for custom compilation flags. + if let identExpr = condition.as(DeclReferenceExprSyntax.self) { + // FIXME: Need a real notion of an identifier. + let ident = identExpr.baseName.text + + // Evaluate the custom condition. If the build configuration cannot answer this query, fail. + return try checkConfiguration(at: identExpr) { + (active: try configuration.isCustomConditionSet(name: ident), versioned: false) + } + } + + // Logical '!'. + if let prefixOp = condition.as(PrefixOperatorExprSyntax.self), + prefixOp.operator.text == "!" + { + let (innerActive, innerVersioned) = try evaluateIfConfig( + condition: prefixOp.expression, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) + + return (active: !innerActive, versioned: innerVersioned) + } + + // Logical '&&' and '||'. + if let binOp = condition.as(InfixOperatorExprSyntax.self), + let op = binOp.operator.as(BinaryOperatorExprSyntax.self), + (op.operator.text == "&&" || op.operator.text == "||") + { + // Evaluate the left-hand side. + let (lhsActive, lhsVersioned) = try evaluateIfConfig( + condition: binOp.leftOperand, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) + + // 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. + 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. + if let tuple = condition.as(TupleExprSyntax.self), tuple.isParentheses, + let element = tuple.elements.first + { + return try evaluateIfConfig( + condition: element.expression, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) + } + + // Call 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) 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 + else { + throw recordedError( + .requiresUnlabeledArgument(name: fnName, role: role, syntax: ExprSyntax(call)) + ) + } + + return try checkConfiguration(at: argExpr) { + (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 -> (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, + let unaryArg = argExpr.as(PrefixOperatorExprSyntax.self) + else { + 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 recordedError(.invalidVersionOperand(name: fnName, syntax: unaryArg.expression)) + } + + switch opToken.text { + case ">=": + return (active: actualVersion >= version, versioned: fn.isVersioned) + case "<": + return (active: actualVersion < version, versioned: fn.isVersioned) + default: + throw recordedError(.unsupportedVersionOperator(name: fnName, operator: opToken)) + } + } + + switch fn { + case .hasAttribute: + return try doSingleIdentifierArgumentCheck(configuration.hasAttribute, role: "attribute") + + case .hasFeature: + return try doSingleIdentifierArgumentCheck(configuration.hasFeature, role: "feature") + + case .os: + return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetOS, role: "operating system") + + case .arch: + return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetArchitecture, role: "architecture") + + case .targetEnvironment: + return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetEnvironment, role: "environment") + + case ._runtime: + return try doSingleIdentifierArgumentCheck(configuration.isActiveTargetRuntime, role: "runtime") + + case ._ptrauth: + return try doSingleIdentifierArgumentCheck( + configuration.isActiveTargetPointerAuthentication, + role: "pointer authentication scheme" + ) + + case ._endian: + // Ensure that we have a single argument that is a simple identifier, + // either "little" or "big". + guard let argExpr = call.arguments.singleUnlabeledExpression, + let arg = argExpr.simpleIdentifierExpr, + let expectedEndianness = Endianness(rawValue: arg) + else { + throw recordedError( + .requiresUnlabeledArgument( + name: fnName, + role: "endiannes ('big' or 'little')", + syntax: ExprSyntax(call) + ) + ) + } + + return ( + active: configuration.endianness == expectedEndianness, + versioned: fn.isVersioned + ) + + 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 expectedBitWidth = Int(arg.dropFirst()) + else { + throw recordedError( + .requiresUnlabeledArgument( + name: fnName, + role: "bit width ('_' followed by an integer)", + syntax: ExprSyntax(call) + ) + ) + } + + 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) + + case .compiler: + return try doVersionComparisonCheck(configuration.compilerVersion) + + case ._compiler_version: + // Argument is a single unlabeled argument containing a string + // literal. + guard let argExpr = call.arguments.singleUnlabeledExpression, + let stringLiteral = argExpr.as(StringLiteralExprSyntax.self), + stringLiteral.segments.count == 1, + let segment = stringLiteral.segments.first, + case .stringSegment(let stringSegment) = segment + else { + throw recordedError( + .requiresUnlabeledArgument( + name: "_compiler_version", + role: "version", + syntax: ExprSyntax(call) + ) + ) + } + + let versionString = stringSegment.content.text + let expectedVersion = try VersionTuple(parsingCompilerBuildVersion: versionString, argExpr) + + return ( + active: configuration.compilerVersion >= expectedVersion, + versioned: fn.isVersioned + ) + + case .canImport: + // Retrieve the first argument, which must not have a label. This is + // the module import path. + guard let firstArg = call.arguments.first, + firstArg.label == nil + else { + throw recordedError(.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: ".") + + // If there is a second argument, it shall have the label _version or + // _underlyingVersion. + 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)) + } + + 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 var versionTuple = VersionTuple(parsing: versionText) else { + throw recordedError( + .invalidVersionOperand(name: "canImport", syntax: secondArg.expression) + ) + } + + // 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) + } else { + assert(secondArg.label?.text == "_underlyingVersion") + version = .underlyingVersion(versionTuple) + } + + if call.arguments.count > 2 { + throw recordedError(.canImportTwoParameters(syntax: ExprSyntax(call))) + } + } else { + version = .unversioned + } + + return try checkConfiguration(at: call) { + ( + active: try configuration.canImport( + importPath: importPath.map { String($0) }, + version: version + ), + versioned: fn.isVersioned + ) + } + } + } + + throw recordedError(.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 } + + // Evaluate this condition against the build configuration. + let (_, versioned) = try evaluateIfConfig( + condition: condition, + configuration: configuration, + diagnosticHandler: diagnosticHandler + ) + + return versioned + } +} diff --git a/Sources/SwiftIfConfig/IfConfigFunctions.swift b/Sources/SwiftIfConfig/IfConfigFunctions.swift new file mode 100644 index 00000000000..8b42ddb910a --- /dev/null +++ b/Sources/SwiftIfConfig/IfConfigFunctions.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// +// 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 { + /// A check for a specific attribute via `hasAttribute()`. + case hasAttribute + + /// A check for a specific named feature via `hasFeature()`. + case hasFeature + + /// A check for the Swift language version via `swift(>=version).` + case swift + + /// A check for the Swift compiler version via `compiler(>=version)`. + case compiler + + /// 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) via `os()` + case os + + /// A check for the target architecture (e.g., arm64, x86_64) via `arch()`. + case arch + + /// 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 + + /// 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) + /// via `_runtime()`. + case _runtime + + /// 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, + ._hasAtomicBitWidth, ._endian, ._pointerBitWidth, ._runtime, ._ptrauth: + return false + } + } +} 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..15e3922b0cf --- /dev/null +++ b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md @@ -0,0 +1,36 @@ +# `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 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() { +#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. 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. diff --git a/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift new file mode 100644 index 00000000000..85b946066e7 --- /dev/null +++ b/Sources/SwiftIfConfig/SyntaxLiteralUtils.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// 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 literal.tokenKind == .keyword(.true) + } +} + +extension TupleExprSyntax { + /// Whether this tuple is a parenthesized expression, e.g., (x). + var isParentheses: Bool { + return elements.singleUnlabeledExpression != nil + } +} + +extension LabeledExprListSyntax { + /// 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(DeclReferenceExprSyntax.self), + identExpr.argumentNames == nil + else { + return nil + } + + // FIXME: Handle escaping here. + return identExpr.baseName.text + } +} 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 + } +} 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 + } + } +} diff --git a/Sources/SwiftIfConfig/VersionTuple.swift b/Sources/SwiftIfConfig/VersionTuple.swift new file mode 100644 index 00000000000..53a5eb1d034 --- /dev/null +++ b/Sources/SwiftIfConfig/VersionTuple.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// +// 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: Sendable { + /// The components of the version tuple, start with the major version. + public var 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) { + self.components = [] + + for componentText in string.split(separator: ".") { + guard let component = Int(componentText) else { + return nil + } + + components.append(component) + } + + if components.isEmpty { return nil } + } + + /// 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) + } +} + +extension VersionTuple: CustomStringConvertible { + public var description: String { + return components.map { String($0) }.joined(separator: ".") + } +} 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 ) ) } 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/ActiveRegionTests.swift b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift new file mode 100644 index 00000000000..8e553a8f032 --- /dev/null +++ b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// +// 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( + """ + 4️⃣ + #if DEBUG + 0️⃣func f() + #elseif ASSERTS + 1️⃣func g() + + #if compiler(>=8.0) + 2️⃣func h() + #else + 3️⃣var i + #endif + #endif + 5️⃣token + """, + configuration: linuxBuildConfig, + states: [ + "0️⃣": .active, + "1️⃣": .inactive, + "2️⃣": .unparsed, + "3️⃣": .inactive, + "4️⃣": .active, + "5️⃣": .active, + ] + ) + } + + 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, + ] + ) + } + + 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/Assertions.swift b/Tests/SwiftIfConfigTest/Assertions.swift new file mode 100644 index 00000000000..00c3a442848 --- /dev/null +++ b/Tests/SwiftIfConfigTest/Assertions.swift @@ -0,0 +1,153 @@ +//===----------------------------------------------------------------------===// +// +// 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 new file mode 100644 index 00000000000..3fe5c9c1c6c --- /dev/null +++ b/Tests/SwiftIfConfigTest/EvaluateTests.swift @@ -0,0 +1,230 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftParser +import SwiftSyntax +import SwiftSyntaxMacrosGenericTestSupport +import XCTest +import _SwiftSyntaxTestSupport + +public class EvaluateTests: XCTestCase { + func testLiterals() throws { + let buildConfig = TestingBuildConfiguration(customConditions: ["DEBUG", "ASSERTS"]) + + 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"]) + + 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", + 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", + 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 { + let buildConfig = TestingBuildConfiguration(customConditions: ["DEBUG", "ASSERTS"]) + + 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"]) + + assertIfConfig("hasFeature(ParameterPacks)", .active, configuration: buildConfig) + assertIfConfig("hasFeature(HigherKindedGenerics)", .inactive, configuration: buildConfig) + } + + func testAttributes() throws { + let buildConfig = TestingBuildConfiguration(attributes: ["available"]) + + assertIfConfig("hasAttribute(available)", .active, configuration: buildConfig) + assertIfConfig("hasAttribute(unsafeUnavailable)", .inactive, configuration: buildConfig) + } + + func testPlatform() throws { + 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) + assertIfConfig("_hasAtomicBitWidth(_64)", .active) + assertIfConfig("_hasAtomicBitWidth(_128)", .inactive) + } + + func testVersions() throws { + 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")"#, .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 { + 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 + ) + ] + ) + } +} diff --git a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift new file mode 100644 index 00000000000..39a5702f71d --- /dev/null +++ b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift @@ -0,0 +1,103 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +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) + } + + func hasFeature(name: String) -> Bool { + features.contains(name) + } + + func hasAttribute(name: String) throws -> Bool { + if badAttributes.contains(name) { + throw BuildConfigurationError.badAttribute(name) + } + + return attributes.contains(name) + } + + func canImport( + importPath: [String], + version: CanImportVersion + ) -> 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) -> Bool { + name == platformName + } + + func isActiveTargetArchitecture(name: String) -> Bool { + name == "arm64" + } + + func isActiveTargetEnvironment(name: String) -> Bool { + name == "simulator" + } + + func isActiveTargetRuntime(name: String) -> Bool { + name == "_Native" + } + + func isActiveTargetPointerAuthentication(name: String) -> Bool { + name == "arm64e" + } + + var targetPointerBitWidth: Int { 64 } + + var targetAtomicBitWidths: [Int] { [32, 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..eddb8c1d3d7 --- /dev/null +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -0,0 +1,255 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// 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 { + var active: ConfiguredRegionState = .inactive + XCTAssertNoThrow(try active = node.isActive(in: configuration)) + XCTAssertEqual(active, .active) + return .visitChildren + } +} + +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"], + 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(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 + } + + func h() { + switch result { + case .success(let value): + break + #if os(iOS) + case .failure(let error): + break + #endif + } + } + + func i() { + a.b + #if DEBUG + .c + #endif + #if hasAttribute(available) + .d() + #endif + #if os(iOS) + .e[] + #endif + } + #endif + + #if hasAttribute(available) + func withAvail() { } + #else + func notAvail() { } + #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 { + // Check that the right set of names is visited. + NameCheckingVisitor( + configuration: linuxBuildConfig, + expectedNames: ["f", "h", "i", "S", "generationCount", "value", "withAvail"] + ).walk(inputSource) + + NameCheckingVisitor( + configuration: iosBuildConfig, + 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", "notAvail"] + ) + visitor.walk(inputSource) + XCTAssertEqual(visitor.diagnostics.count, 3) + } + + func testRemoveInactive() { + assertRemoveInactive( + inputSource.description, + configuration: linuxBuildConfig, + expectedSource: """ + + @available(*, deprecated, message: "use something else") + func f() { + } + + struct S { + var generationCount = 0 + } + + func h() { + switch result { + case .success(let value): + break + } + } + + 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: 3, + column: 18 + ), + DiagnosticSpec( + message: "unacceptable attribute 'available'", + line: 42, + column: 20 + ), + DiagnosticSpec( + message: "unacceptable attribute 'available'", + line: 51, + column: 18 + ), + ], + expectedSource: """ + + @MainActor + func f() { + } + + struct S { + var generationCount = 0 + } + + func h() { + switch result { + case .success(let value): + break + } + } + + func i() { + a.b + .c + } + func notAvail() { } + """ + ) + } +}