Expanding #[cfg()] with another predicate: `one()`

Currently, #[cfg()] provides following predicate options: any(), all() and not(), which is unfortunately not enough when dealing with packages where only single feature must be enabled.

For example, if you have a package that must enable only single feature from following list: ("s112", "s113", "s122", "s132", "s140"), you'll first need check if at least one is enabled, and then create a O(n^2) loop to see if any two features are not enabled in parallel:

#[cfg(not(any(
    feature = "s112",
    feature = "s113",
    feature = "s122",
    feature = "s132",
    feature = "s140"
)))]
compile_error!("No softdevice feature activated. You must activate exactly one of the following features: s112, s113, s122, s132, s140");

#[cfg(any(
    all(feature = "s112", feature = "s113"),
    all(feature = "s112", feature = "s122"),
    all(feature = "s112", feature = "s132"),
    all(feature = "s112", feature = "s140"),
    all(feature = "s113", feature = "s122"),
    all(feature = "s113", feature = "s132"),
    all(feature = "s113", feature = "s140"),
    all(feature = "s122", feature = "s132"),
    all(feature = "s122", feature = "s140"),
    all(feature = "s132", feature = "s140"),
))]
compile_error!("Multiple softdevice features activated. You must activate exactly one of the following features: s112, s113, s122, s132, s140");

This could be alleviated by introducing one() predicate that would return true if single item in the list of features provided is available.

#[cfg(not(one(
    feature = "s112",
    feature = "s113",
    feature = "s122",
    feature = "s132",
    feature = "s140"
)))]
compile_error!("Only one of the following features should be enabled: s112, s113, s122, s132, s140");

For more gruesome examples where having one() would help can be found here: nrf-softdevice/nrf-softdevice/src/lib.rs at 77e4f26b426fa854b878fd27cfb46cb88bfc8b95 · embassy-rs/nrf-softdevice · GitHub

5 Likes

Doesn't having mutually exclusive features violate the principle that all features should be strictly additive? How does this work if Crate A enables one feature, Crate B another, and Crate C attempts to use A and B?

13 Likes

Thanks, I went over the "Mutually exclusive features" paragraph in the Cargo reference.

Seems like for embedded development, especially when dealing with target-specific crates, the requirements are actually opposite:

  1. You want to provide a single API (top-level crate) for a multitude of target devices out of which you will enable single target (for example esp-hal, embassy-nrf or embassy-stm32)
  2. Switching runtime could be doable, but not always and could cause issues with due to code size and in some cases ABI as well (nrf-softdevice for example).
3 Likes

While it's true that these sorts of Cargo features don't really work well (which is why there's some work towards providing an alternative kind of crate-feature toggle which is designed to be set to one of a set of values, https://internals.rust-lang.org/t/pre-rfc-mutually-excusive-global-features/19618 I believe is the latest on this), there's maybe some situations with direct --cfg where this sort of exclusivity is also useful?

4 Likes

Interesting. Another name for this could also be xor(), though I think one() is more consistent in this case.

As a curiosity, this can be done in a boolean formula of size O(n log n) rather than O(n^2), using a divide-and-conquer strategy, for example: one(a, b, c, d) = (one(a, b) and zero(c, d)) or (zero(a, b) and one(c, d))

It would be nice if cfg expressions allowed arbitrary user-defined functions.

3 Likes

It would be nice in the sense that it is nice to get hit in the head with a hammer (if you ask me). I can't imagine how difficult it would be for tooling as well as us mere mortals to keep track of what is going on.

3 Likes

In practice, many crates already depend on mutually exclusive features, despite any principle or official recommendation. This is just current practice and is unlikely to go away, because it solves an important need.

Cargo should have a way to encode that features are mutually exclusive, so that any tooling can deal with this, but in meantime people will continue to use them regardless

4 Likes

Agree, this is quite minimal and clear in use predicate, that sometimes significantly reduce config boilerplate

The specific case of mutually exclusive features is better served by mutually exclusive features (i.e. features/configuration that's an enum instead of a bool). Is there a use case other than emulating "feature enums" that wants a one() predicate?

There are already "enum" cfg predicates, and they're exposed as e.g. cfg(target_arch = "arch") with only one value set for the key (although multiple are allowed to be set simultaneously).

If the only use case is to use features in a way that's actively discouraged, then I don't think we should add it, we should address the underlying need rather than the symptom. The exception would be if it's significantly easier to implement and stabilize the cfg predicate than the cargo functionality, and the predicate is significantly better than the status quo. Or, of course, that there's a reason to use the one() predicate for other cfgs, or that you have multiple intersecting sets of mutual feature incompatibility (thus aren't fully served by a proper solution for feature enums).

If you already have a buildscript, adding in an extra bit of code to set a cfg flag for compilation (or even just panic and fail the build that way) if incompatible flags are used isn't significantly worse than a #[cfg(one(…))] compile_error! imho (and both are O(n), just with a big constant for the buildscript if it isn't already paid for). But there's definitely room for a #[cfg_exactly_one] proc macro implementing the O(n log n) approach. It should even be simple enough to write that it wouldn't even need to use syn or quote. (I don't think there's any use case that wouldn't put the one predicate at the top level.)

6 Likes

The mutually exclusive global features RFC looks great and I hope it lands, but in the meantime you can just use cfg attributes passed via RUSTFLAGS to achieve something similar, e.g.:

RUSTFLAGS='--cfg my_custom_backend="s112"' cargo test

Or passed via .cargo/config:

[build]
rustflags = ['--cfg=my_custom_backend="s112"']

...which in your code maps to:

#[cfg(my_custom_backend = "s112")]
...

This achieves largely the same goals: selection is naturally 1-of-n can only be made by the toplevel binary and not (potentially transitive) dependencies, which eliminates the possibility that dependencies might select mutually incompatible backends, which can happen with features (which are supposed to be purely additive anyway).

This has some drawbacks which are why the mutually exclusive global features RFC would be nice: it's hard to detect if the attribute hasn't been set, to specify a default, or lint that it's been set to a valid value. As has already been mentioned, you can use a build script to work around these ergonomic issues for now.

4 Likes

Adding compiler support to implement #[cfg(one(...))] seems to be quite easy, here's a branch:

1 Like

Note that any push back isn't about how hard it is to implement or that people aren't doing this today but about making sure we design things in a cohesive way. The more we add to the language/cargo, the more complicated things can get. There are fundamental, unaddressable flaws with using features in a mutually exclusive way. Making the experience better for mutually exclusive features would also cause people to use it more, making things worse in the ecosystem. This means that adding one would still require a different way of solving this problem and then we'd be stuck with two implementations.

What would be helpful is for someone to pick up the work on Mutually-Exclusive Global Features. I posted that proposal to write down ideas I had so I wouldn't forget and to help move the conversations forward but I do not have time to focus on it and without someone taking it on, its not going anywhere.

3 Likes

Note that you can "implement" cfg(one(...)) in linear space like this:

#[cfg(feature = "foo")]
static _AT_MOST_ONE: () = ();
#[cfg(feature = "bar")]
static _AT_MOST_ONE: () = ();
#[cfg(feature = "qux")]
static _AT_MOST_ONE: () = ();

static _AT_LEAST_ONE: () = _AT_MOST_ONE;

You can name the variables _AT_MOST_ONE_SOFT_DEVICE and _AT_LEAST_ONE_SOFT_DEVICE (or omit that variable if you don't need exactly one soft-device but only at most one). The error messages are pretty intelligible although hacky.

EDIT: Oops, I realize I replied to the wrong message. I meant to reply to the original post (to which I sympathize because I hit the same issue in embedded Rust too). Not sure how to fix this mistake.

2 Likes

Another option using const panic:

const EXACTLY_ONE: () = {
    let mut n = 0;
    #[cfg(feature = "a")]
    { n += 1; }
    #[cfg(feature = "b")]
    { n += 1; }

    if n != 1 {
        panic!("must have exactly one of the features active");
    }
};
9 Likes