diff --git a/Sources/SwiftIfConfig/ActiveClauseEvaluator.swift b/Sources/SwiftIfConfig/ActiveClauseEvaluator.swift new file mode 100644 index 00000000000..8e7b9e2f5d9 --- /dev/null +++ b/Sources/SwiftIfConfig/ActiveClauseEvaluator.swift @@ -0,0 +1,50 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// Captures sufficient information to determine the active clause for an `#if` +/// either by querying existing configured regions or by evaluating the +/// clause's conditions against a build configuration. +enum ActiveClauseEvaluator { + case configuredRegions(ConfiguredRegions) + case configuration(any BuildConfiguration) + + /// Previously-known diagnostics. + var priorDiagnostics: [Diagnostic] { + switch self { + case .configuredRegions(let configuredRegions): + return configuredRegions.diagnostics + case .configuration: + return [] + } + } + + /// Determine which clause of an `#if` declaration is active, if any. + /// + /// If this evaluation produced any diagnostics, they will be appended to + /// the diagnostics parameter. + func activeClause( + for node: IfConfigDeclSyntax, + diagnostics: inout [Diagnostic] + ) -> IfConfigClauseSyntax? { + switch self { + case .configuredRegions(let configuredRegions): + return configuredRegions.activeClause(for: node) + case .configuration(let configuration): + let (activeClause, localDiagnostics) = node.activeClause(in: configuration) + diagnostics.append(contentsOf: localDiagnostics) + return activeClause + } + } +} diff --git a/Sources/SwiftIfConfig/ActiveSyntaxRewriter.swift b/Sources/SwiftIfConfig/ActiveSyntaxRewriter.swift index 0cdeb97e4e7..f4ac3b4c695 100644 --- a/Sources/SwiftIfConfig/ActiveSyntaxRewriter.swift +++ b/Sources/SwiftIfConfig/ActiveSyntaxRewriter.swift @@ -18,34 +18,100 @@ extension SyntaxProtocol { /// 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. + /// - Parameters: + /// - configuration: the configuration to apply. + /// - Returns: the syntax node with all inactive regions removed, along with + /// an array containing any diagnostics produced along the way. public func removingInactive( in configuration: some BuildConfiguration ) -> (result: Syntax, diagnostics: [Diagnostic]) { - // First pass: Find all of the active clauses for the #ifs we need to - // visit, along with any diagnostics produced along the way. This process - // does not change the tree in any way. - let visitor = ActiveSyntaxVisitor(viewMode: .sourceAccurate, configuration: configuration) - visitor.walk(self) - - // If there were no active clauses to visit, we're done! - if !visitor.visitedAnyIfClauses { - return (Syntax(self), visitor.diagnostics) - } + return removingInactive(in: configuration, retainFeatureCheckIfConfigs: false) + } - // Second pass: Rewrite the syntax tree by removing the inactive clauses + /// 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. + /// + /// 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. + /// - Parameters: + /// - configuration: the configuration to apply. + /// - retainFeatureCheckIfConfigs: whether to retain `#if` blocks involving + /// compiler version checks (e.g., `compiler(>=6.0)`) and `$`-based + /// feature checks. + /// - Returns: the syntax node with all inactive regions removed, along with + /// an array containing any diagnostics produced along the way. + @_spi(Compiler) + public func removingInactive( + in configuration: some BuildConfiguration, + retainFeatureCheckIfConfigs: Bool + ) -> (result: Syntax, diagnostics: [Diagnostic]) { + // Rewrite the syntax tree by removing the inactive clauses // from each #if (along with the #ifs themselves). - let rewriter = ActiveSyntaxRewriter(configuration: configuration) + let rewriter = ActiveSyntaxRewriter( + configuration: configuration, + retainFeatureCheckIfConfigs: retainFeatureCheckIfConfigs + ) return ( rewriter.rewrite(Syntax(self)), - visitor.diagnostics + rewriter.diagnostics + ) + } +} + +extension ConfiguredRegions { + /// Produce a copy of some syntax node in the configured region that removes + /// all syntax regions that are inactive according to the build configuration, + /// leaving only the code that is active within that build configuration. + /// + /// 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. + /// - Parameters: + /// - node: the stnrax node from which inactive regions will be removed. + /// - Returns: the syntax node with all inactive regions removed. + public func removingInactive(from node: some SyntaxProtocol) -> Syntax { + return removingInactive(from: node, retainFeatureCheckIfConfigs: false) + } + + /// Produce a copy of some syntax node in the configured region that removes + /// all syntax regions that are inactive according to the build configuration, + /// leaving only the code that is active within that build configuration. + /// + /// 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. + /// - Parameters: + /// - node: the stnrax node from which inactive regions will be removed. + /// - retainFeatureCheckIfConfigs: whether to retain `#if` blocks involving + /// compiler version checks (e.g., `compiler(>=6.0)`) and `$`-based + /// feature checks. + /// - Returns: the syntax node with all inactive regions removed. + @_spi(Compiler) + public func removingInactive( + from node: some SyntaxProtocol, + retainFeatureCheckIfConfigs: Bool + ) -> Syntax { + // If there are no inactive regions, there's nothing to do. + if regions.isEmpty { + return Syntax(node) + } + + // Rewrite the syntax tree by removing the inactive clauses + // from each #if (along with the #ifs themselves). + let rewriter = ActiveSyntaxRewriter( + configuredRegions: self, + retainFeatureCheckIfConfigs: retainFeatureCheckIfConfigs ) + return rewriter.rewrite(Syntax(node)) } } @@ -79,12 +145,23 @@ extension SyntaxProtocol { /// /// For any other target platforms, the resulting tree will be empty (other /// than trivia). -class ActiveSyntaxRewriter: SyntaxRewriter { - let configuration: Configuration - var diagnostics: [Diagnostic] = [] +class ActiveSyntaxRewriter: SyntaxRewriter { + let activeClauses: ActiveClauseEvaluator + var diagnostics: [Diagnostic] + + /// Whether to retain `#if` blocks containing compiler and feature checks. + var retainFeatureCheckIfConfigs: Bool + + init(configuredRegions: ConfiguredRegions, retainFeatureCheckIfConfigs: Bool) { + self.activeClauses = .configuredRegions(configuredRegions) + self.diagnostics = activeClauses.priorDiagnostics + self.retainFeatureCheckIfConfigs = retainFeatureCheckIfConfigs + } - init(configuration: Configuration) { - self.configuration = configuration + init(configuration: some BuildConfiguration, retainFeatureCheckIfConfigs: Bool) { + self.activeClauses = .configuration(configuration) + self.diagnostics = activeClauses.priorDiagnostics + self.retainFeatureCheckIfConfigs = retainFeatureCheckIfConfigs } private func dropInactive( @@ -93,23 +170,30 @@ class ActiveSyntaxRewriter: SyntaxRewriter { ) -> List { var newElements: [List.Element] = [] var anyChanged = false + + // Note that an element changed at the given index. + func noteElementChanged(at elementIndex: List.Index) { + if anyChanged { + return + } + + // This is the first element that changed, so note that we have + // changes and add all prior elements to the list of new elements. + anyChanged = true + newElements.append(contentsOf: node[..: SyntaxRewriter { continue } + // Transform the element directly. If it changed, note the changes. + if let transformedElement = rewrite(Syntax(element)).as(List.Element.self), + transformedElement.id != element.id + { + noteElementChanged(at: elementIndex) + newElements.append(transformedElement) + continue + } + if anyChanged { newElements.append(element) } @@ -141,47 +234,39 @@ class ActiveSyntaxRewriter: SyntaxRewriter { } override func visit(_ node: CodeBlockItemListSyntax) -> CodeBlockItemListSyntax { - let rewrittenNode = dropInactive(node) { element in + return 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 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 + return 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 + return 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. @@ -201,7 +286,7 @@ class ActiveSyntaxRewriter: SyntaxRewriter { return nil } - let newExpr = applyBaseToPostfixExpression(base: base, postfix: node[keyPath: keyPath]) + let newExpr = applyBaseToPostfixExpression(base: base, postfix: visit(node[keyPath: keyPath])) return ExprSyntax(node.with(keyPath, newExpr)) } @@ -210,7 +295,7 @@ class ActiveSyntaxRewriter: SyntaxRewriter { 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)) + return ExprSyntax(memberAccess.with(\.base, visit(base))) } let newBase = applyBaseToPostfixExpression(base: base, postfix: memberBase) @@ -262,11 +347,14 @@ class ActiveSyntaxRewriter: SyntaxRewriter { outerBase: ExprSyntax?, postfixIfConfig: PostfixIfConfigExprSyntax ) -> ExprSyntax { - // Retrieve the active `if` clause. - let (activeClause, localDiagnostics) = postfixIfConfig.config.activeClause(in: configuration) + // If we're supposed to retain #if configs that are feature checks, and + // this configuration has one, do so. + if retainFeatureCheckIfConfigs && postfixIfConfig.config.containsFeatureCheck { + return ExprSyntax(postfixIfConfig) + } - // Record these diagnostics. - diagnostics.append(contentsOf: localDiagnostics) + // Retrieve the active `if` clause. + let activeClause = activeClauses.activeClause(for: postfixIfConfig.config, diagnostics: &diagnostics) guard case .postfixExpression(let postfixExpr) = activeClause?.elements else { @@ -276,7 +364,7 @@ class ActiveSyntaxRewriter: SyntaxRewriter { // only have both in an ill-formed syntax tree that was manually // created. if let base = postfixIfConfig.base ?? outerBase { - return base + return visit(base) } // If there was no base, we're in an erroneous syntax tree that would @@ -291,7 +379,7 @@ class ActiveSyntaxRewriter: SyntaxRewriter { // If there is no base, return the postfix expression. guard let base = postfixIfConfig.base ?? outerBase else { - return postfixExpr + return visit(postfixExpr) } // Apply the base to the postfix expression. @@ -299,11 +387,107 @@ class ActiveSyntaxRewriter: SyntaxRewriter { } override func visit(_ node: PostfixIfConfigExprSyntax) -> ExprSyntax { - let rewrittenNode = dropInactive(outerBase: nil, postfixIfConfig: node) - if rewrittenNode == ExprSyntax(node) { - return rewrittenNode + return dropInactive(outerBase: nil, postfixIfConfig: node) + } +} + +/// Helper class to find a feature or compiler check. +fileprivate class FindFeatureCheckVisitor: SyntaxVisitor { + var foundFeatureCheck = false + + override func visit(_ node: DeclReferenceExprSyntax) -> SyntaxVisitorContinueKind { + // Checks that start with $ are feature checks that should be retained. + if let identifier = node.simpleIdentifier, + let initialChar = identifier.name.first, + initialChar == "$" + { + foundFeatureCheck = true + return .skipChildren + } + + return .visitChildren + } + + override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { + if let calleeDeclRef = node.calledExpression.as(DeclReferenceExprSyntax.self), + let calleeName = calleeDeclRef.simpleIdentifier?.name, + (calleeName == "compiler" || calleeName == "_compiler_version") + { + foundFeatureCheck = true } - return visit(rewrittenNode) + return .skipChildren + } +} + +extension ExprSyntaxProtocol { + /// Whether any of the nodes in this expression involve compiler or feature + /// checks. + fileprivate var containsFeatureCheck: Bool { + let visitor = FindFeatureCheckVisitor(viewMode: .fixedUp) + visitor.walk(self) + return visitor.foundFeatureCheck + } +} + +extension IfConfigDeclSyntax { + /// Whether any of the clauses in this #if contain a feature check. + var containsFeatureCheck: Bool { + return clauses.contains { clause in + if let condition = clause.condition { + return condition.containsFeatureCheck + } else { + return false + } + } + } +} + +extension SyntaxProtocol { + // Produce the source code for this syntax node with all of the comments + // and #sourceLocations removed. Each comment will be replaced with either + // a newline or a space, depending on whether the comment involved a newline. + @_spi(Compiler) + public var descriptionWithoutCommentsAndSourceLocations: String { + var result = "" + var skipUntilRParen = false + for token in tokens(viewMode: .sourceAccurate) { + // Skip #sourceLocation(...). + if token.tokenKind == .poundSourceLocation { + skipUntilRParen = true + continue + } + + if skipUntilRParen { + if token.tokenKind == .rightParen { + skipUntilRParen = false + } + continue + } + + token.leadingTrivia.writeWithoutComments(to: &result) + token.text.write(to: &result) + token.trailingTrivia.writeWithoutComments(to: &result) + } + return result + } +} + +extension Trivia { + fileprivate func writeWithoutComments(to stream: inout some TextOutputStream) { + for piece in pieces { + switch piece { + case .backslashes, .carriageReturnLineFeeds, .carriageReturns, .formfeeds, .newlines, .pounds, .spaces, .tabs, + .unexpectedText, .verticalTabs: + piece.write(to: &stream) + + case .blockComment(let text), .docBlockComment(let text), .docLineComment(let text), .lineComment(let text): + if text.contains(where: \.isNewline) { + stream.write("\n") + } else { + stream.write(" ") + } + } + } } } diff --git a/Sources/SwiftIfConfig/ActiveSyntaxVisitor.swift b/Sources/SwiftIfConfig/ActiveSyntaxVisitor.swift index e202f11809f..ba1d3ccfe46 100644 --- a/Sources/SwiftIfConfig/ActiveSyntaxVisitor.swift +++ b/Sources/SwiftIfConfig/ActiveSyntaxVisitor.swift @@ -39,28 +39,30 @@ import SwiftSyntax /// All notes visited by this visitor will have the "active" state, i.e., /// `node.isActive(in: configuration)` will have evaluated to `.active`. /// When errors occur, they will be recorded in the array of diagnostics. -open class ActiveSyntaxVisitor: SyntaxVisitor { - /// The build configuration, which will be queried for each relevant `#if`. - public let configuration: Configuration +open class ActiveSyntaxVisitor: SyntaxVisitor { + /// The abstracted build configuration, which will be queried for each + /// relevant `#if`. + let activeClauses: ActiveClauseEvaluator /// The diagnostics accumulated during this walk of active syntax. public private(set) var diagnostics: [Diagnostic] = [] - /// Whether we visited any "#if" clauses. - var visitedAnyIfClauses: Bool = false + public init(viewMode: SyntaxTreeViewMode, configuration: some BuildConfiguration) { + self.activeClauses = .configuration(configuration) + self.diagnostics = activeClauses.priorDiagnostics + super.init(viewMode: viewMode) + } - public init(viewMode: SyntaxTreeViewMode, configuration: Configuration) { - self.configuration = configuration + public init(viewMode: SyntaxTreeViewMode, configuredRegions: ConfiguredRegions) { + self.activeClauses = .configuredRegions(configuredRegions) + self.diagnostics = activeClauses.priorDiagnostics super.init(viewMode: viewMode) } open override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { // Note: there is a clone of this code in ActiveSyntaxAnyVisitor. If you // change one, please also change the other. - let (activeClause, localDiagnostics) = node.activeClause(in: configuration) - diagnostics.append(contentsOf: localDiagnostics) - - visitedAnyIfClauses = true + let activeClause = activeClauses.activeClause(for: node, diagnostics: &diagnostics) // If there is an active clause, visit it's children. if let activeClause, let elements = activeClause.elements { @@ -98,15 +100,23 @@ open class ActiveSyntaxVisitor: SyntaxVisitor /// All notes visited by this visitor will have the "active" state, i.e., /// `node.isActive(in: configuration)` will have evaluated to `.active`. /// When errors occur, they will be recorded in the array of diagnostics. -open class ActiveSyntaxAnyVisitor: SyntaxAnyVisitor { - /// The build configuration, which will be queried for each relevant `#if`. - public let configuration: Configuration +open class ActiveSyntaxAnyVisitor: SyntaxAnyVisitor { + /// The abstracted build configuration, which will be queried for each + /// relevant `#if`. + let activeClauses: ActiveClauseEvaluator /// The diagnostics accumulated during this walk of active syntax. public private(set) var diagnostics: [Diagnostic] = [] - public init(viewMode: SyntaxTreeViewMode, configuration: Configuration) { - self.configuration = configuration + public init(viewMode: SyntaxTreeViewMode, configuration: some BuildConfiguration) { + self.activeClauses = .configuration(configuration) + self.diagnostics = activeClauses.priorDiagnostics + super.init(viewMode: viewMode) + } + + public init(viewMode: SyntaxTreeViewMode, configuredRegions: ConfiguredRegions) { + self.activeClauses = .configuredRegions(configuredRegions) + self.diagnostics = activeClauses.priorDiagnostics super.init(viewMode: viewMode) } @@ -115,8 +125,7 @@ open class ActiveSyntaxAnyVisitor: SyntaxAnyV // change one, please also change the other. // If there is an active clause, visit it's children. - let (activeClause, localDiagnostics) = node.activeClause(in: configuration) - diagnostics.append(contentsOf: localDiagnostics) + let activeClause = activeClauses.activeClause(for: node, diagnostics: &diagnostics) if let activeClause, let elements = activeClause.elements { walk(elements) diff --git a/Sources/SwiftIfConfig/CMakeLists.txt b/Sources/SwiftIfConfig/CMakeLists.txt index 422577e474b..2df83517c5b 100644 --- a/Sources/SwiftIfConfig/CMakeLists.txt +++ b/Sources/SwiftIfConfig/CMakeLists.txt @@ -7,6 +7,7 @@ # See http://swift.org/CONTRIBUTORS.txt for Swift project authors add_swift_syntax_library(SwiftIfConfig + ActiveClauseEvaluator.swift ActiveSyntaxVisitor.swift ActiveSyntaxRewriter.swift BuildConfiguration.swift diff --git a/Sources/SwiftIfConfig/ConfiguredRegions.swift b/Sources/SwiftIfConfig/ConfiguredRegions.swift index bd83252c399..67d1afb29c6 100644 --- a/Sources/SwiftIfConfig/ConfiguredRegions.swift +++ b/Sources/SwiftIfConfig/ConfiguredRegions.swift @@ -39,6 +39,12 @@ import SwiftSyntax public struct ConfiguredRegions { let regions: [(ifClause: IfConfigClauseSyntax, state: IfConfigRegionState)] + // A mapping from each of the #if declarations that have been evaluated to + // the active clause. Absence from this map means that there is no active + // clause, either because every clause failed or because the entire #if + // itself is inactive. + var activeClauses: [IfConfigDeclSyntax: IfConfigClauseSyntax] + /// The set of diagnostics produced when evaluating the configured regions. public let diagnostics: [Diagnostic] @@ -80,6 +86,17 @@ public struct ConfiguredRegions { (region.ifClause.regionStart...region.ifClause.endPosition).contains(node.position) }?.state ?? .active } + + /// Determine which clause of an `#if` declaration was active within this + /// set of configured regions. + /// + /// A particular `#if` declaration might have no active clause (e.g., this + /// operation will return a `nil`) if either none of the clauses had + /// conditions that succeeded, or the `#if` declaration itself is within an + /// inactive (or unparsed) region and therefore cannot have an active clause. + public func activeClause(for node: IfConfigDeclSyntax) -> IfConfigClauseSyntax? { + return activeClauses[node] + } } extension ConfiguredRegions: RandomAccessCollection { @@ -150,6 +167,7 @@ extension SyntaxProtocol { visitor.walk(self) return ConfiguredRegions( regions: visitor.regions, + activeClauses: visitor.activeClauses, diagnostics: visitor.diagnostics ) } @@ -171,6 +189,12 @@ fileprivate class ConfiguredRegionVisitor: Sy // All diagnostics encountered along the way. var diagnostics: [Diagnostic] = [] + // A mapping from each of the #if declarations that have been evaluated to + // the active clause. Absence from this map means that there is no active + // clause, either because every clause failed or because the entire #if + // itself is inactive. + var activeClauses: [IfConfigDeclSyntax: IfConfigClauseSyntax] = [:] + init(configuration: Configuration) { self.configuration = configuration super.init(viewMode: .sourceAccurate) @@ -242,6 +266,11 @@ fileprivate class ConfiguredRegionVisitor: Sy isActive = !foundActive && inActiveRegion } + // If this is the active clause, record it as such. + if isActive { + activeClauses[node] = clause + } + // Determine and record the current state. let currentState: IfConfigRegionState switch (isActive, syntaxErrorsAllowed) { diff --git a/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md index e2a4636650e..77292721c0f 100644 --- a/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md +++ b/Sources/SwiftIfConfig/SwiftIfConfig.docc/SwiftIfConfig.md @@ -29,4 +29,5 @@ The `SwiftIfConfig` library provides utilities to determine which syntax nodes a * and are visitor types that only visit the syntax nodes that are included ("active") for a given build configuration, implicitly skipping any nodes within inactive `#if` clauses. * `SyntaxProtocol.removingInactive(in:)` produces a syntax node that removes all inactive regions (and their corresponding `IfConfigDeclSyntax` nodes) from the given syntax tree, returning a new tree that is free of `#if` conditions. * `IfConfigDeclSyntax.activeClause(in:)` determines which of the clauses of an `#if` is active for the given build configuration, returning the active clause. -* `SyntaxProtocol.configuredRegions(in:)` produces a `ConfiguredRegions` value that can be used to efficiently test whether a given syntax node is in an active, inactive, or unparsed region (via `isActive`). +* `SyntaxProtocol.configuredRegions(in:)` produces a `ConfiguredRegions` value that can be used to efficiently test whether a given syntax node is in an active, inactive, or unparsed region, remove inactive syntax, or determine the + active clause for a given `#if`. Use `ConfiguredRegions` for repeated queries. diff --git a/Tests/SwiftIfConfigTest/VisitorTests.swift b/Tests/SwiftIfConfigTest/VisitorTests.swift index 4e669e2b33a..0a4ea2fbd3c 100644 --- a/Tests/SwiftIfConfigTest/VisitorTests.swift +++ b/Tests/SwiftIfConfigTest/VisitorTests.swift @@ -11,7 +11,7 @@ //===----------------------------------------------------------------------===// import SwiftDiagnostics -import SwiftIfConfig +@_spi(Compiler) import SwiftIfConfig import SwiftParser import SwiftSyntax @_spi(XCTestFailureLocation) @_spi(Testing) import SwiftSyntaxMacrosGenericTestSupport @@ -23,26 +23,49 @@ import _SwiftSyntaxTestSupport /// /// This cross-checks the visitor itself with the `SyntaxProtocol.isActive(in:)` /// API. -class AllActiveVisitor: ActiveSyntaxAnyVisitor { - init(configuration: TestingBuildConfiguration) { - super.init(viewMode: .sourceAccurate, configuration: configuration) +class AllActiveVisitor: ActiveSyntaxAnyVisitor { + let configuration: TestingBuildConfiguration + + init( + configuration: TestingBuildConfiguration, + configuredRegions: ConfiguredRegions? = nil + ) { + self.configuration = configuration + + if let configuredRegions { + super.init(viewMode: .sourceAccurate, configuredRegions: configuredRegions) + } else { + super.init(viewMode: .sourceAccurate, configuration: configuration) + } } + open override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind { XCTAssertEqual(node.isActive(in: configuration).state, .active) return .visitChildren } } -class NameCheckingVisitor: ActiveSyntaxAnyVisitor { +class NameCheckingVisitor: ActiveSyntaxAnyVisitor { + let configuration: TestingBuildConfiguration + /// 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) { + init( + configuration: TestingBuildConfiguration, + expectedNames: Set, + configuredRegions: ConfiguredRegions? = nil + ) { + self.configuration = configuration self.expectedNames = expectedNames - super.init(viewMode: .sourceAccurate, configuration: configuration) + if let configuredRegions { + super.init(viewMode: .sourceAccurate, configuredRegions: configuredRegions) + } else { + super.init(viewMode: .sourceAccurate, configuration: configuration) + } } deinit { @@ -145,32 +168,31 @@ public class VisitorTests: XCTestCase { func testAnyVisitorVisitsOnlyActive() throws { // Make sure that all visited nodes are active nodes. - AllActiveVisitor(configuration: linuxBuildConfig).walk(inputSource) - AllActiveVisitor(configuration: iosBuildConfig).walk(inputSource) + assertVisitedAllActive(linuxBuildConfig) + assertVisitedAllActive(iosBuildConfig) } func testVisitsExpectedNodes() throws { // Check that the right set of names is visited. - NameCheckingVisitor( - configuration: linuxBuildConfig, + assertVisitedExpectedNames( + linuxBuildConfig, expectedNames: ["f", "h", "i", "S", "generationCount", "value", "withAvail"] - ).walk(inputSource) + ) - NameCheckingVisitor( - configuration: iosBuildConfig, + assertVisitedExpectedNames( + 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"] + assertVisitedExpectedNames( + configuration, + expectedNames: ["f", "h", "i", "S", "generationCount", "value", "notAvail"], + diagnosticCount: 3 ) - visitor.walk(inputSource) - XCTAssertEqual(visitor.diagnostics.count, 3) } func testRemoveInactive() { @@ -253,13 +275,134 @@ public class VisitorTests: XCTestCase { """ ) } + + func testRemoveInactiveRetainingFeatureChecks() { + assertRemoveInactive( + """ + public func hasIfCompilerCheck(_ x: () -> Bool = { + #if compiler(>=5.3) + return true + #else + return false + #endif + + #if $Blah + return 0 + #else + return 1 + #endif + + #if NOT_SET + return 3.14159 + #else + return 2.71828 + #endif + }) { + } + """, + configuration: linuxBuildConfig, + retainFeatureCheckIfConfigs: true, + expectedSource: """ + public func hasIfCompilerCheck(_ x: () -> Bool = { + #if compiler(>=5.3) + return true + #else + return false + #endif + + #if $Blah + return 0 + #else + return 1 + #endif + return 2.71828 + }) { + } + """ + ) + } + + func testRemoveCommentsAndSourceLocations() { + let original: SourceFileSyntax = """ + + /// This is a documentation comment + func f() { } + + #sourceLocation(file: "if-configs.swift", line: 200) + /** Another documentation comment + that is split across + multiple lines */ + func g() { } + + func h() { + x +/*comment*/y + // foo + } + """ + + assertStringsEqualWithDiff( + original.descriptionWithoutCommentsAndSourceLocations, + """ + + + func f() { } + + + func g() { } + + func h() { + x + y + + } + """ + ) + } } -/// Assert that applying the given build configuration to the source code -/// returns the expected source and diagnostics. +extension VisitorTests { + /// Ensure that all visited nodes are active nodes according to the given + /// build configuration. + fileprivate func assertVisitedAllActive(_ configuration: TestingBuildConfiguration) { + AllActiveVisitor(configuration: configuration).walk(inputSource) + + let configuredRegions = inputSource.configuredRegions(in: configuration) + AllActiveVisitor( + configuration: configuration, + configuredRegions: configuredRegions + ).walk(inputSource) + } + + /// Ensure that we visit nodes with the set of names we were expecting to + /// visit. + fileprivate func assertVisitedExpectedNames( + _ configuration: TestingBuildConfiguration, + expectedNames: Set, + diagnosticCount: Int = 0 + ) { + let firstVisitor = NameCheckingVisitor( + configuration: configuration, + expectedNames: expectedNames + ) + firstVisitor.walk(inputSource) + XCTAssertEqual(firstVisitor.diagnostics.count, diagnosticCount) + + let configuredRegions = inputSource.configuredRegions(in: configuration) + let secondVisitor = NameCheckingVisitor( + configuration: configuration, + expectedNames: expectedNames, + configuredRegions: configuredRegions + ) + secondVisitor.walk(inputSource) + XCTAssertEqual(secondVisitor.diagnostics.count, diagnosticCount) + } +} + +/// Assert that removing any inactive code according to the given build +/// configuration returns the expected source and diagnostics. fileprivate func assertRemoveInactive( _ source: String, configuration: some BuildConfiguration, + retainFeatureCheckIfConfigs: Bool = false, diagnostics expectedDiagnostics: [DiagnosticSpec] = [], expectedSource: String, file: StaticString = #filePath, @@ -268,36 +411,59 @@ fileprivate func assertRemoveInactive( 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 - ) + for useConfiguredRegions in [false, true] { + let fromDescription = useConfiguredRegions ? "configured regions" : "build configuration" + let treeWithoutInactive: Syntax + let actualDiagnostics: [Diagnostic] + + if useConfiguredRegions { + let configuredRegions = tree.configuredRegions(in: configuration) + actualDiagnostics = configuredRegions.diagnostics + treeWithoutInactive = configuredRegions.removingInactive( + from: tree, + retainFeatureCheckIfConfigs: retainFeatureCheckIfConfigs + ) + } else { + (treeWithoutInactive, actualDiagnostics) = tree.removingInactive( + in: configuration, + retainFeatureCheckIfConfigs: retainFeatureCheckIfConfigs + ) + } - // Check the diagnostics. - if actualDiagnostics.count != expectedDiagnostics.count { - XCTFail( - """ - Expected \(expectedDiagnostics.count) diagnostics, but got \(actualDiagnostics.count): - \(actualDiagnostics.map(\.debugDescription).joined(separator: "\n")) - """, + // Check the resulting tree. + assertStringsEqualWithDiff( + treeWithoutInactive.description, + expectedSource, + "Active code (\(fromDescription))", 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) - } + + // Check the diagnostics. + if actualDiagnostics.count != expectedDiagnostics.count { + XCTFail( + """ + Expected \(expectedDiagnostics.count) diagnostics, but got \(actualDiagnostics.count) via \(fromDescription): + \(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 + " via \(fromDescription)", + file: $0.location.staticFilePath, + line: $0.location.unsignedLine + ) + } + ) + } } } }