Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SwiftIfConfig: A library to evaluate #if conditionals within a Swift syntax tree. #1816

Open
wants to merge 22 commits into
base: main
Choose a base branch
from

Conversation

DougGregor
Copy link
Member

@DougGregor DougGregor commented Jun 20, 2023

Swift provides the ability to conditionally compile parts of a source file based on various built-time conditions, including information about the target (operating system, processor architecture, environment), information about the compiler (version, supported attributes and features), and user-supplied conditions specified as part of the build (e.g., DEBUG), which we collectively refer to as the build configuration. These conditions can occur within a #if in the source code, e.g.,

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 doc:BuildConfiguration protocol), and provides a different view on essentially the same information:

  • doc:ActiveSyntaxVisitor and doc:ActiveSyntaxAnyVisitor 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.

There are a few things I'd still like to do before we can call this "complete":

  • Create a build configuration that's populated by swift-driver, so that a client with command-line arguments can get mostly-accurate #if information by only using swift-driver and swift-syntax. (#if canImport(...) effectively requires a compiler).
  • Create a build configuration in the compiler itself, so we can use these utilities from the compiler in lieu of it's C++ implementation.
  • Add a "fixed" or "simple" build configuration instance based on the host, which clients can use if they truly have no information to go on (e.g., because they don't have build settings).
  • Improve error handling and figure out how to deal with warnings. Right now, we either throw if there's an error, or swallow the error and treat that region as inactive.
  • Improve documentation with more examples.
  • Update the SourceKit query for active ranges to use these utilities rather than walking the compiler's semantic AST.
  • Teach the various checks to distinguish "inactive" from "unparsed" regions.

Building on top of the parser and operator-precedence parsing library,
introduce a new library that evaluates `#if` conditions against a
particular build configuration. The build configuration is described
by the aptly named `BuildConfiguration` protocol, which has queries
for various build settings (e.g., configuration flags), compiler
capabilities (features and attributes), and target information (OS,
architecture, endianness, etc.).

At present, the only user-facing operation is the `IfConfigState`
initializer, which takes in an expression (the `#if` condition) and a
build configuration, then evaluates that expression against the build
condition to determine whether code covered by that condition is
active, inactive, or completely unparsed. This is a fairly low-level
API, meant to be a building block for more useful higher-level APIs
that query which `#if` clause is active and whether a particular syntax
node is active.
`IfConfigDeclSyntax.activeClause(in:)` determines which clause is
active within an `#if` syntax node.

`SyntaxProtocol.isActive(in:)` determines whether a given syntax node
is active in the program, based on the nested stack of `#if`
configurations.
This is the last kind of check! Remove the `default` fallthrough from
the main evaluation function.
The `ActiveSyntax(Any)Visitor` visitor classes provide visitors that
only visit the regions of a syntax tree that are active according to
a particular build configuration, meaning that those nodes would be
included in a program that is built with that configuration.
The operation `SyntaxProtocol.removingInactive(in:)` returns a syntax
tree derived from `self` that has removed all inactive syntax nodes based
on the provided configuration.
Postfix `#if` expressions have a different syntactic form than
other `#if` clauses because they don't fit into a list-like position in
the grammar. Implement a separate, recursive folding algorithm to handle
these clauses.
@DougGregor DougGregor requested a review from ahoppen as a code owner June 20, 2023 05:51
@DougGregor
Copy link
Member Author

@swift-ci please test

@harlanhaskins
Copy link
Contributor

I would love to use this in the part of the compiler that strips inactive #if conditions out of module interfaces

Sources/SwiftIfConfig/BuildConfiguration.swift Outdated Show resolved Hide resolved
Sources/SwiftIfConfig/BuildConfiguration.swift Outdated Show resolved Hide resolved
Sources/SwiftIfConfig/IfConfigFunctions.swift Show resolved Hide resolved
Sources/SwiftIfConfig/IfConfigState.swift Show resolved Hide resolved
Sources/SwiftIfConfig/SyntaxLiteralUtils.swift Outdated Show resolved Hide resolved
Tests/SwiftIfConfigTest/EvaluateTests.swift Show resolved Hide resolved
Comment on lines +64 to +66
XCTAssertThrowsError(try ifConfigState("3.14159")) { error in
XCTAssertEqual(String(describing: error), "invalid conditional compilation expression")
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If #if condition evaluation produces diagnostics, it would probably be good to have a assertIfConfigEvaluation that accepts a DiagnosticSpec and location markers.

And honestly, I think it would be a good idea even in the current design to avoid the repeated definition of ifConfigState.

func testCanImport() throws {
let buildConfig = TestingBuildConfiguration()

func ifConfigState(_ condition: ExprSyntax) throws -> IfConfigState {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inside #if expressions this will get parsed as a CanImportExprSyntax. So I think you probably need to do something like

let ifConfigDecl = """
#if \(condition)
#else
let expr = extract the parsed condition again.
"""

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have CanImportExprSyntax at all? It seems like we should leave it as a normal call expression and let SwiftIfConfig deal with it. There are two diagnostics that would have to move into SwiftIfConfig (missing major version, wrong number of arguments), but those are straightforward.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's because the diagnostics(wrong label, wrong number of arguments..) are currently generated in SwiftParserDiagnostics and a call expression node is not expressive enough to generate them.

Tests/SwiftIfConfigTest/VisitorTests.swift Outdated Show resolved Hide resolved
@DougGregor
Copy link
Member Author

@swift-ci please test

@DougGregor
Copy link
Member Author

@swift-ci please test Windows

…lt types

The optional return was used to mean "don't know", but was always
treated as false. Instead, make all of the result types non-optional,
and allow these operations to throw to indicate failure.

While here, drop the "syntax" parameters to all of these functions. We
shouldn't be working with syntax inside the build configuration.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants