SwiftIfConfig library for handling `#if` clauses

Hi all,

Swift has #if to affect to allow different build configurations to impact what code is part of the final program. The conditions in #if are a simplified form of the Swift expression syntax, so you can have code like this:

#if DEBUG
  #if compiler(>=5.7) && hasFeature(ParameterPacks)
  func f<each Arg>(_ args: repeat each Arg) { }
  #endif
#else
  func g() { }
#endif

If DEBUG has been set (e.g., via -D on the command line), the compiler is version 5.7 or greater, and it has the feature ParameterPacks, then the function f will be part of the program. If one of these conditions fails, f will not be part of the program. If DEBUG has not been set, g will be part of the program.

I'm working on a new module for swift-syntax, SwiftIfConfig, which provides APIs for working with #ifs in Swift sources. It's meant to make it easier to reason about #ifs: what they evaluate to, what code is in the program, and so on.

The core protocol of this new module is BuildConfiguration. A build configuration answers queries about the build configuration, e.g.,

  • Is DEBUG defined?
  • Is the architecture x86_64?
  • Can we import the module XYZ?

I expect that there will be a few implementations of BuildConfiguration in practice. I've added one to the compiler itself that reflects the compiler's knowledge of the current build configuration into the form SwiftIfConfig understands, so that we can use SwiftIfConfig directly in the compiler to replace some of the C++ code there. Ideally, we'd also implement a lighter-weight BuildConfiguration that can answer most queries based only on the command-line options provided to the Swift driver, so that IDE clients can use these APIs without spinning up the full compiler.

In SwiftIfConfig, I've included all of the client APIs I could think of uses for:

  • Evaluation of a single #if condition expression via ConfiguredRegionState.init(condition:configuration:).
  • Determining the active condition for a specific #if clause via IfConfigDeclSyntax.activeClause(in:).
  • Walking only the “active” regions of a source tree with the ActiveSyntaxVisitor, which is a SyntaxVisitor that uses the givenBuildConfiguration evaluate any #ifs along the way.
  • Rewriting a syntax tree by removing all inactive regions according to a built configuration, SyntaxProtocol.removingInactive(in:).
  • Querying whether a specific syntax node is part of a build configuration, SyntaxProtocol.isActive(in:).
  • Collecting all the active/inactive #if regions in a source tree according to a configuration with SyntaxProtocol.configuredRegions(in:). This is the same information as the prior API, but in a "batch" form.

All of these APIs are stateless: there are no mutable data structures behind them, it's just a query on the AST. The lower-level APIs like ConfiguredRegionState.init(condition:configuration:) will throw to indicate when they can't make a determination, while the higher-level APIs that expect most clients to use will make a reasonable assumption (invalid condition -> inactive code). All of the APIs can provide diagnostics to indicate any errors, such as ill-formed #if conditions or errors that come from the BuildConfiguration.

The aforementioned integration in the compiler currently uses the configuredRegions(in:) API to replace some existing compiler functionality that backs profiling coverage information and the SourceKit "inactive regions" query. There are a few other obvious places in the compiler to adopt more of these APIs, including outright replacement of the C++ parser's handling of #if.

This library is another step in the Swift'ification of the Swift compiler itself, building little focused libraries that are easy to work with but complete enough to power the compiler itself in the days to come. I'd love your thoughts on the API provided by SwiftIfConfig!

Doug

32 Likes

very interesting! does this mean it would be possible to re-implement lib/SymbolGraphGen as a library by combining SwiftSyntax with IndexStoreDB?

I don't know enough about what shows up in the symbol graph to answer this, but if it's possible I think that would be fantastic.

Doug

SymbolGraph needs type information, so for now there's no way to get that without pulling in the entire frontend.

i haven’t dug into it but i believe this information is available in the IndexStore database? the main thing i am unsure about is how to handle macro expansions, since the expansions don’t have any file system representation.

Producing an indexstore involves a lot more work than SymbolGraphGen does. Symbol graphs only contain information about declarations and can even be generated from already-compiled modules. Generating the indexstore must be done from source files and contains information about both declarations and references.

But even though it's doing more work, the data in indexstore may not be a superset of what's in a symbol graph. Indexstore contains a lot of gaps in areas where something isn't strictly needed for code navigation like jump-to-definition or find-references since that's its primary purpose. I'd be surprised if it was possible to use it to reconstruct a 100% identical symbol graph—and if it is, combining the relevant index data and syntax tree data would be considerably more complex than just running something like swift-symbolgraph-extract.

1 Like

between source mapping and SwiftPM Snippets, we are already eating a lot of the complexity of combining index data and syntax tree data, so it is appealing to me at least to get all symbol information from the same source instead of having to patch together multiple sources of symbol information as we are doing currently.

a lot of the information coming from lib/SymbolGraphGen would eventually need to be cross-referenced against the original syntax tree anyways to detect things like @Sendable, @_spi, etc. this could also be done by changing the compiler itself, but then these improvements would be tightly linked to a specific toolchain version that may be inappropriate to generate docs from (e.g., from a nightly). whereas if this stuff goes through the (somewhat more stable) IndexStore interface, we could iterate on the symbol ingestion logic while still being able to generate documentation using a release toolchain.

Technically the indexstore data is also tied to a specific compiler version. We're just lucky that the version number encoded in it hasn't changed in a very long time.

If that's what you want to do as a workaround now to deal with mismatched compiler versions, sure, but if we're talking about long-term solutions for building compiler-based tooling like the library in this thread is aiming to do, I think the expectation is that more components like the type checker and SymbolGraphGen will eventually move into Swift and be usable as package dependencies so that you have more direct control over what you want to do without having to cobble something together from disparate data sources.