diff --git a/Sources/SwiftIfConfig/BuildConfiguration.swift b/Sources/SwiftIfConfig/BuildConfiguration.swift index d94158801c3..d15b35f4fca 100644 --- a/Sources/SwiftIfConfig/BuildConfiguration.swift +++ b/Sources/SwiftIfConfig/BuildConfiguration.swift @@ -9,6 +9,7 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// +import SwiftSyntax /// Describes the ordering of a sequence of bytes that make up a word of /// storage for a particular architecture. @@ -114,15 +115,15 @@ public protocol BuildConfiguration { /// 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"]`. + /// - importPath: A nonempty sequence of (token, identifier) pairs + /// 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 `[(token, "UIKit"), (token, "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 + func canImport(importPath: [(TokenSyntax, String)], version: CanImportVersion) throws -> Bool /// Determine whether the given name is the active target OS (e.g., Linux, iOS). /// diff --git a/Sources/SwiftIfConfig/ConfiguredRegions.swift b/Sources/SwiftIfConfig/ConfiguredRegions.swift index f4553e09a7b..393d0e25f6b 100644 --- a/Sources/SwiftIfConfig/ConfiguredRegions.swift +++ b/Sources/SwiftIfConfig/ConfiguredRegions.swift @@ -13,6 +13,90 @@ import SwiftDiagnostics import SwiftSyntax +/// Describes all of the #if/#elseif/#else clauses within the given syntax node, +/// indicating their active state. This operation will recurse into all +/// clauses to indicate regions of active / inactive / unparsed code. +/// +/// For example, given code like the following: +/// #if DEBUG +/// #if A +/// func f() +/// #elseif B +/// func g() +/// #elseif compiler(>= 12.0) +/// please print the number after 41 +/// #endif +/// #else +/// #endif +/// +/// If the configuration options `DEBUG` and `B` are provided, but `A` is not, +/// and the compiler version is less than 12.0, the results will be contain: +/// - Active region for the `#if DEBUG`. +/// - Inactive region for the `#if A`. +/// - Active region for the `#elseif B`. +/// - Unparsed region for the `#elseif compiler(>= 12.0)`. +/// - Inactive region for the final `#else`. +public struct ConfiguredRegions { + let regions: [Element] + + /// The set of diagnostics produced when evaluating the configured regions. + public let diagnostics: [Diagnostic] + + /// Determine whether the given syntax node is active within the configured + /// regions. + public func isActive(_ node: some SyntaxProtocol) -> IfConfigRegionState { + var currentState: IfConfigRegionState = .active + for (ifClause, state) in regions { + if node.position < ifClause.position { + return currentState + } + + if node.position >= ifClause.regionStart && node.position <= ifClause.endPosition { + currentState = state + } + } + + return currentState + } +} + +extension ConfiguredRegions: RandomAccessCollection { + public typealias Element = (IfConfigClauseSyntax, IfConfigRegionState) + public var startIndex: Int { regions.startIndex } + public var endIndex: Int { regions.endIndex } + + public subscript(index: Int) -> Element { + regions[index] + } +} + +extension ConfiguredRegions: CustomDebugStringConvertible { + /// Provides source ranges for each of the configured regions. + public var debugDescription: String { + guard let firstRegion = first else { + return "[]" + } + + let root = firstRegion.0.root + let converter = SourceLocationConverter(fileName: "", tree: root) + let regionDescriptions = regions.map { (ifClause, state) in + let startPosition = converter.location(for: ifClause.position) + let endPosition = converter.location(for: ifClause.endPosition) + return "[\(startPosition.line):\(startPosition.column) - \(endPosition.line):\(endPosition.column)] = \(state)" + } + + return "[\(regionDescriptions.joined(separator: ", ")))]" + } +} + +extension IfConfigClauseSyntax { + /// The effective start of the region after which code is subject to its + /// condition. + fileprivate var regionStart: AbsolutePosition { + condition?.endPosition ?? elements?._syntaxNode.position ?? poundKeyword.endPosition + } +} + extension SyntaxProtocol { /// Find all of the #if/#elseif/#else clauses within the given syntax node, /// indicating their active state. This operation will recurse into all @@ -39,10 +123,13 @@ extension SyntaxProtocol { /// - Inactive region for the final `#else`. public func configuredRegions( in configuration: some BuildConfiguration - ) -> [(IfConfigClauseSyntax, IfConfigRegionState)] { + ) -> ConfiguredRegions { let visitor = ConfiguredRegionVisitor(configuration: configuration) visitor.walk(self) - return visitor.regions + return ConfiguredRegions( + regions: visitor.regions, + diagnostics: visitor.diagnostics + ) } } @@ -56,58 +143,111 @@ fileprivate class ConfiguredRegionVisitor: Sy /// Whether we are currently within an active region. var inActiveRegion = true + /// Whether we are currently within an #if at all. + var inAnyIfConfig = false + + // All diagnostics encountered along the way. + var diagnostics: [Diagnostic] = [] + 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).clause : nil + // We are in an #if. + let priorInAnyIfConfig = inAnyIfConfig + inAnyIfConfig = true + defer { + inAnyIfConfig = priorInAnyIfConfig + } + + // Walk through the clauses to find the active one. var foundActive = false var syntaxErrorsAllowed = false + let outerState: IfConfigRegionState = inActiveRegion ? .active : .inactive for clause in node.clauses { - // If we haven't found the active clause yet, syntax errors are allowed - // depending on this clause. - if !foundActive { - syntaxErrorsAllowed = - clause.condition.map { - IfConfigClauseSyntax.syntaxErrorsAllowed($0).syntaxErrorsAllowed - } ?? false - } + let isActive: Bool + if let condition = clause.condition { + if !foundActive { + // Fold operators so we can evaluate this #if condition. + let (foldedCondition, foldDiagnostics) = IfConfigClauseSyntax.foldOperators(condition) + diagnostics.append(contentsOf: foldDiagnostics) + + // In an active region, evaluate the condition to determine whether + // this clause is active. Otherwise, this clause is inactive. + // inactive. + if inActiveRegion { + let (thisIsActive, _, evalDiagnostics) = evaluateIfConfig( + condition: foldedCondition, + configuration: configuration + ) + diagnostics.append(contentsOf: evalDiagnostics) - // If this is the active clause, record it and then recurse into the - // elements. - if clause == activeClause { - assert(inActiveRegion) + // Determine if there was an error that prevented us from + // evaluating the condition. If so, we'll allow syntax errors + // from here on out. + let hadError = + foldDiagnostics.contains { diag in + diag.diagMessage.severity == .error + } + || evalDiagnostics.contains { diag in + diag.diagMessage.severity == .error + } - regions.append((clause, .active)) + if hadError { + isActive = false + syntaxErrorsAllowed = true + } else { + isActive = thisIsActive - if let elements = clause.elements { - walk(elements) + // Determine whether syntax errors are allowed. + syntaxErrorsAllowed = foldedCondition.allowsSyntaxErrorsFolded + } + } else { + isActive = false + + // Determine whether syntax errors are allowed, even though we + // skipped evaluation of the actual condition. + syntaxErrorsAllowed = foldedCondition.allowsSyntaxErrorsFolded + } + } else { + // We already found an active condition, so this is inactive. + isActive = false } + } else { + // This is an #else. It's active if we haven't found an active clause + // yet and are in an active region. + isActive = !foundActive && inActiveRegion + } - foundActive = true - continue + // Determine and record the current state. + let currentState: IfConfigRegionState + switch (isActive, syntaxErrorsAllowed) { + case (true, _): currentState = .active + case (false, false): currentState = .inactive + case (false, true): currentState = .unparsed } - // If this is within an active region, or this is an unparsed region, - // record it. - if inActiveRegion || syntaxErrorsAllowed { - regions.append((clause, syntaxErrorsAllowed ? .unparsed : .inactive)) + // If there is a state change, record it. + if !priorInAnyIfConfig || currentState != .inactive || currentState != outerState { + regions.append((clause, currentState)) } - // Recurse into inactive (but not unparsed) regions to find any - // unparsed regions below. - if !syntaxErrorsAllowed, let elements = clause.elements { + // If this is a parsed region, recurse into it. + if currentState != .unparsed, let elements = clause.elements { let priorInActiveRegion = inActiveRegion - inActiveRegion = false + inActiveRegion = isActive defer { inActiveRegion = priorInActiveRegion } walk(elements) } + + // Note when we found an active clause. + if isActive { + foundActive = true + } } return .skipChildren diff --git a/Sources/SwiftIfConfig/IfConfigEvaluation.swift b/Sources/SwiftIfConfig/IfConfigEvaluation.swift index 4102dfae922..fe60df79edc 100644 --- a/Sources/SwiftIfConfig/IfConfigEvaluation.swift +++ b/Sources/SwiftIfConfig/IfConfigEvaluation.swift @@ -443,7 +443,7 @@ func evaluateIfConfig( } // Extract the import path. - let importPath: [String] + let importPath: [(TokenSyntax, String)] do { importPath = try extractImportPath(firstArg.expression) } catch { @@ -502,7 +502,7 @@ func evaluateIfConfig( return checkConfiguration(at: call) { ( active: try configuration.canImport( - importPath: importPath.map { String($0) }, + importPath: importPath, version: version ), syntaxErrorsAllowed: fn.syntaxErrorsAllowed @@ -540,22 +540,22 @@ extension SyntaxProtocol { } /// Given an expression with the expected form A.B.C, extract the import path -/// ["A", "B", "C"] from it. Throws an error if the expression doesn't match -/// this form. -private func extractImportPath(_ expression: some ExprSyntaxProtocol) throws -> [String] { +/// ["A", "B", "C"] from it with the token syntax nodes for each name. +/// Throws an error if the expression doesn't match this form. +private func extractImportPath(_ expression: some ExprSyntaxProtocol) throws -> [(TokenSyntax, String)] { // Member access. if let memberAccess = expression.as(MemberAccessExprSyntax.self), let base = memberAccess.base, let memberName = memberAccess.declName.simpleIdentifier?.name { - return try extractImportPath(base) + [memberName] + return try extractImportPath(base) + [(memberAccess.declName.baseName, memberName)] } // Declaration reference. if let declRef = expression.as(DeclReferenceExprSyntax.self), let name = declRef.simpleIdentifier?.name { - return [name] + return [(declRef.baseName, name)] } throw IfConfigDiagnostic.expectedModuleName(syntax: ExprSyntax(expression)) @@ -794,7 +794,7 @@ private struct CanImportSuppressingBuildConfiguration return try other.hasAttribute(name: name) } - func canImport(importPath: [String], version: CanImportVersion) throws -> Bool { + func canImport(importPath: [(TokenSyntax, String)], version: CanImportVersion) throws -> Bool { return false } diff --git a/Sources/SwiftIfConfig/SyntaxProtocol+IfConfig.swift b/Sources/SwiftIfConfig/SyntaxProtocol+IfConfig.swift index 4f87ad40877..801e626ac15 100644 --- a/Sources/SwiftIfConfig/SyntaxProtocol+IfConfig.swift +++ b/Sources/SwiftIfConfig/SyntaxProtocol+IfConfig.swift @@ -36,44 +36,16 @@ extension SyntaxProtocol { /// If the compiler version is smaller than 12.0, then `isActive` on any of the tokens within /// that `#elseif` block would return "unparsed", because that syntax should not (conceptually) /// be parsed. + /// + /// Note that this function requires processing all #ifs from the root node + /// of the syntax tree down to the current node. If performing more than a + /// small number of `isActive(_:)` queries, please form a `ConfiguredRegions` + /// instance and use `ConfiguredRegions.isActive(_:)` instead. public func isActive( in configuration: some BuildConfiguration ) -> (state: IfConfigRegionState, diagnostics: [Diagnostic]) { - var currentNode: Syntax = Syntax(self) - var currentState: IfConfigRegionState = .active - var diagnostics: [Diagnostic] = [] - - while let parent = currentNode.parent { - // If the parent is an `#if` configuration, check whether our current - // clause is active. If not, we're in an inactive region. We also - // need to determine whether an inactive region should be parsed or not. - if let ifConfigClause = currentNode.as(IfConfigClauseSyntax.self), - let ifConfigDecl = ifConfigClause.parent?.parent?.as(IfConfigDeclSyntax.self) - { - let (activeClause, localDiagnostics) = ifConfigDecl.activeClause(in: configuration) - diagnostics.append(contentsOf: localDiagnostics) - - if activeClause != ifConfigClause { - // This was not the active clause, so we know that we're in an - // inactive block. If syntax errors aren't allowable, this is an - // unparsed region. - let syntaxErrorsAllowed = - ifConfigClause.condition.map { - IfConfigClauseSyntax.syntaxErrorsAllowed($0).syntaxErrorsAllowed - } ?? false - - if syntaxErrorsAllowed { - return (.unparsed, diagnostics) - } - - currentState = .inactive - } - } - - currentNode = parent - } - - return (currentState, diagnostics) + let configuredRegions = root.configuredRegions(in: configuration) + return (configuredRegions.isActive(self), configuredRegions.diagnostics) } /// Determine whether the given syntax node is active given a set of @@ -82,20 +54,10 @@ extension SyntaxProtocol { /// If you are querying whether many syntax nodes in a particular file are /// active, consider calling `configuredRegions(in:)` once and using /// this function. For occasional queries, use `isActive(in:)`. + @available(*, deprecated, message: "Please use ConfiguredRegions.isActive(_:)") public func isActive( - inConfiguredRegions regions: [(IfConfigClauseSyntax, IfConfigRegionState)] + inConfiguredRegions regions: ConfiguredRegions ) -> IfConfigRegionState { - var currentState: IfConfigRegionState = .active - for (ifClause, state) in regions { - if self.position < ifClause.position { - return currentState - } - - if self.position <= ifClause.endPosition { - currentState = state - } - } - - return currentState + regions.isActive(self) } } diff --git a/Tests/SwiftIfConfigTest/ActiveRegionTests.swift b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift index 33fd7c1b7f8..997b15b3735 100644 --- a/Tests/SwiftIfConfigTest/ActiveRegionTests.swift +++ b/Tests/SwiftIfConfigTest/ActiveRegionTests.swift @@ -49,7 +49,7 @@ public class ActiveRegionTests: XCTestCase { "2️⃣": .active, "3️⃣": .inactive, "4️⃣": .unparsed, - "5️⃣": .inactive, + "5️⃣": .unparsed, "6️⃣": .active, ] ) @@ -77,9 +77,11 @@ public class ActiveRegionTests: XCTestCase { "2️⃣": .active, "3️⃣": .inactive, "4️⃣": .unparsed, - "5️⃣": .inactive, + "5️⃣": .unparsed, "6️⃣": .active, - ] + ], + configuredRegionDescription: + "[[1:6 - 3:5] = active, [3:5 - 10:7] = inactive, [5:5 - 7:5] = unparsed, [7:5 - 9:5] = unparsed)]" ) } @@ -100,6 +102,25 @@ public class ActiveRegionTests: XCTestCase { ] ) } + + func testActiveRegionUnparsed() throws { + try assertActiveCode( + """ + #if false + #if compiler(>=4.1) + 1️⃣let _: Int = 1 + #else + // There should be no error here. + 2️⃣foo bar + #endif + #endif + """, + states: [ + "1️⃣": .unparsed, + "2️⃣": .unparsed, + ] + ) + } } /// Assert that the various marked positions in the source code have the @@ -108,6 +129,7 @@ fileprivate func assertActiveCode( _ markedSource: String, configuration: some BuildConfiguration = TestingBuildConfiguration(), states: [String: IfConfigRegionState], + configuredRegionDescription: String? = nil, file: StaticString = #filePath, line: UInt = #line ) throws { @@ -133,7 +155,7 @@ fileprivate func assertActiveCode( let (actualState, _) = token.isActive(in: configuration) XCTAssertEqual(actualState, expectedState, "isActive(in:) at marker \(marker)", file: file, line: line) - let actualViaRegions = token.isActive(inConfiguredRegions: configuredRegions) + let actualViaRegions = configuredRegions.isActive(token) XCTAssertEqual( actualViaRegions, expectedState, @@ -141,5 +163,15 @@ fileprivate func assertActiveCode( file: file, line: line ) + + if let configuredRegionDescription { + XCTAssertEqual( + configuredRegions.debugDescription, + configuredRegionDescription, + "configured region descsription", + file: file, + line: line + ) + } } } diff --git a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift index 6d6beb7caee..a902f825fe0 100644 --- a/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift +++ b/Tests/SwiftIfConfigTest/TestingBuildConfiguration.swift @@ -54,10 +54,10 @@ struct TestingBuildConfiguration: BuildConfiguration { } func canImport( - importPath: [String], + importPath: [(TokenSyntax, String)], version: CanImportVersion ) throws -> Bool { - guard let moduleName = importPath.first else { + guard let moduleName = importPath.first?.1 else { return false }