RFC 2957: Cargo: new feature resolver

tools (cargo)

Summary

This RFC is to gather final feedback on stabilizing the new feature resolver in Cargo. This new feature resolver introduces a new algorithm for computing package features that helps to avoid some unwanted unification that happens in the current resolver. This also includes some changes in how features are enabled on the command-line.

These changes have already been implemented and are available on the nightly channel as an unstable feature. See the unstable feature docs for information on how to test out the new resolver, and the unstable package flags for information on the new flag behavior.

Note: The new feature resolver does not address all of the enhancement requests for feature resolution. Some of these are listed below in the Feature resolver enhancements section. These are explicitly deferred for future work.

Motivation

Feature unification

Currently, when features are computed for a package, Cargo takes the union of all requested features in all situations for that package. This is relatively easy to understand, and ensures that packages are only built once during a single build. However, this has problems when features introduce unwanted behavior, dependencies, or other requirements. The following three situations illustrate some of the unwanted feature unification that the new resolver aims to solve:

Command-line feature selection

Cargo has several flags for choosing which features are enabled during a build. --features allows enabling individual features, --all-features enables all features, and --no-default-features ensures the "default" feature is not automatically enabled.

These are fairly straightforward when used with a single package, but in a workspace the current behavior is limited and confusing. There are several problems in a workspace:

See New command-line behavior below for how these problems are solved.

Guide-level explanation

New resolver behavior

When the new feature resolver is enabled, features are not always unified when a dependency appears multiple times in the dependency graph. The new behaviors are described below.

For target dependencies and dev-dependencies, the general rule is, if a dependency is not built, it does not affect feature resolution. For host dependencies, the general rule is that packages used for building (like proc-macros) do not affect the packages being built.

The following three sections describe the new behavior for three difference situations.

Target dependencies

When a package appears multiple times in the build graph, and one of those instances is a target-specific dependency, then the features of the target-specific dependency are only enabled if the target is currently being built. For example:

[dependency.common]
version = "1.0"
features = ["f1"]

[target.'cfg(windows)'.dependencies.common]
version = "1.0"
features = ["f2"]

When building this example for a non-Windows platform, the f2 feature will not be enabled.

dev-dependencies

When a package is shared as a normal dependency and a dev-dependency, the dev-dependency features are only enabled if the current build is including dev-dependencies. For example:

[dependencies]
serde = {version = "1.0", default-features = false}

[dev-dependencies]
serde = {version = "1.0", features = ["std"]}

In this situation, a normal cargo build will build serde without any features. When built with cargo test, Cargo will build serde with its default features plus the "std" feature.

Note that this is a global decision. So a command like cargo build --all-targets will include examples and tests, and thus features from dev-dependencies will be enabled.

Host dependencies

When a package is shared as a normal dependency and a build-dependency or proc-macro, the features for the normal dependency are kept independent of the build-dependency or proc-macro. For example:

[dependencies]
log = "0.4"

[build-dependencies]
log = {version = "0.4", features=['std']}

In this situation, the log package will be built with the default features for the normal dependencies. As a build-dependency, it will have the std feature enabled. This means that log will be built twice, once without std and once with std.

Note that a dependency shared between a build-dependency and proc-macro are still unified. This is intended to help reduce build times, and is expected to be unlikely to cause problems that feature unification usually cause because they are both being built for the host platform, and are only used at build time.

Resolver opt-in

Testing has been performed on various projects. Some were found to fail to compile with the new resolver. This is because some dependencies are written to assume that features are enabled from another part of the graph. Because the new resolver results in a backwards-incompatible change in resolver behavior, the user must opt-in to use the new resolver. This can be done with the resolver field in Cargo.toml:

[package]
name = "my-package"
version = "1.0.0"
resolver = "2"

Setting the resolver to "2" switches Cargo to use the new feature resolver. It also enables backwards-incompatible behavior detailed in New command-line behavior. A value of "1" uses the previous resolver behavior, which is the default if not specified.

The value is a string (instead of an integer) to allow for possible extensions in the future.

The resolver field is only honored in the top-level package or workspace, it is ignored in dependencies. This is because feature-unification is an inherently global decision.

If using a virtual workspace, the root definition should be in the [workspace] table like this:

[workspace]
members = ["member1", "member2"]
resolver = "2"

For packages that encounter a problem due to missing feature declarations, it is backwards-compatible to add the missing features. Adding those missing features should not affect projects using the old resolver.

It is intended that resolver = "2" will likely become the default setting in a future Rust Edition. See "Default opt-in" below for more details.

New command-line behavior

The following changes are made to the behavior of selecting features on the command-line.

The ability to set features for non-workspace members is not allowed, as the resolver fundamentally does not support that ability.

The first change is only enabled if the resolver = "2" value is set in the workspace manifest because it is a backwards-incompatible change. The other changes are intended to be stabilized for everyone, as they only extend previously invalid usage.

cargo metadata

At this time, the cargo metadata command will not be changed to expose the new feature resolver. The "features" field will continue to display the features as computed by the original dependency resolver.

Properly expressing the dependency graph with features would require a number of changes to cargo metadata that can add complexity to the interface. For example, the following flags would need to be added to properly show how features are selected:

Additionally, the current graph structure does not expose the host-vs-target dependency relationship, among other issues.

It is intended that this will be addressed at some point in the future. Feedback on desired use cases for feature information will help define the solution. A possible alternative is to stabilize the --unit-graph flag, which exposes Cargo's internal graph structure, which accurately indicates the actual dependency relationships and uses the new feature resolver.

For non-parseable output, cargo tree will show features from the new resolver.

Drawbacks

There are a number of drawbacks to this approach:

Subtle behaviors

The following are behaviors that may be confusing or surprising, and are highlighted here as potential concerns.

Optional dependency feature names

Code that needs to have a cfg expression for a dependency of this kind should use a cfg that matches the condition (like cfg(windows)) or use cfg(accessible(dep_name)) when that syntax is stabilized.

This is somewhat intertwined with the upcoming namespaced features. For an optional dependency, the feature is decoupled from the activating of the dependency itself.

Proc-macro unification in a workspace

If there is a proc-macro in a workspace, and the proc-macro is included as a "root" package along with other packages in a workspace (for example with cargo build --workspace), then there can be some potentially surprising feature unification between the proc-macro and the other members of the workspace. This is because proc-macros may have normal targets such as binaries or tests, which need feature unification with the rest of the workspace.

This issue is detailed in issue #8312.

At this time, there isn't a clear solution to this problem. If this is an issue, projects are encouraged to avoid using --workspace or use --exclude or otherwise avoid building multiple workspace members together. This is also related to the workspace unification issue.

Rationale and alternatives

Prior art

Other tools have various ways of controlling conditional compilation, but none are quite exactly like Cargo to our knowledge. The following is a survey of a few tools with similar capabilities.

Unresolved questions

None at this time.

Motivating issues

The Cargo issue tracker contains historical context for some of the requests that have motivated these changes:

Future possibilities

Feature resolver enhancements

The following changes are things we are thinking about, but are not in a fully-baked state. It is uncertain if they will require backwards-incompatible changes or not.

Default opt-in

We are planning to make it so that in the next Rust Edition, Cargo will automatically use the new resolver. It will assume you specify resolver = "2" when a workspace specifies the next edition. This may help reduce the boilerplate in the manifest, and make the preferred behavior the default for new projects. Cargo has some precedent for this, as in the 2018 edition several defaults were changed. It is unclear how this would work in a virtual workspace, or if this will cause additional confusion, so this is left as a possibility to be explored in the future.

Default cargo new

In the short term, cargo new (and init) will not set the resolver field. After this feature has had some time on stable and more projects have some experience with it, the default manifest for cargo new will be modified to set resolver = "2".