Workspace+Macro: One binary wants serde derive feature, another doesn't

Hi,

I've got a workspace where two binary crates (bin1, bin2) have a proc-macro library crate (lib) they both depend on. That library uses a proc-macro to generate a type. The library also offers a "derive" feature to enable serde::{Deserialize, Serialize} for that type.

Now, bin1 wants to use that type without serde, but bin2 wants to use it with serde.

Apparently, the proc-macro is only compiled once (because of the workspace, and including the "derive" feature), and inserts #[derive(::serde::Serialize, ::serde::Deserialize)] for that type. However, bin1 doesn't even depend on serde, so therefore the generated type cannot be compiled.

Is there any way to resolve this (other than enabling the derive feature for bin1 as well and also making it depend on serde)?

Repo with a minimal example:

output of cargo check:


Edit: Apparently, if RFC 2523 ever gets implemented, I could probably write something like

#[cfg_attr(accessible(::serde), derive(::serde::Serialize, ::serde::Deserialize))]

But that doesn't exist yet.

1 Like

This is a problem with feature unification. The easiest workaround is to not rely on features for this kind of situation. Features are meant to be additive in ways that if they get enabled it doesn't break anything. For instance, by requiring the dependent to also take a dependency on serde.

The easiest way to address this is by replacing the feature with macro input. I threw together a little demonstration:

extern crate proc_macro;
extern crate syn;
#[macro_use]
extern crate quote;

use proc_macro::{Span, TokenStream};
use syn::parse::{Parse, ParseStream};
use syn::{parse_macro_input, LitBool};

struct HelloInput {
    with_derive: LitBool,
}

impl Parse for HelloInput {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let lookahead = input.lookahead1();
        let with_derive = if lookahead.peek(LitBool) {
            input.parse()?
        } else {
            LitBool::new(false, Span::call_site().into())
        };

        Ok(Self { with_derive })
    }
}

//#[proc_macro_derive(HelloWorld)]
#[proc_macro]
pub fn hello_world(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as HelloInput);

    let serde_derives = if input.with_derive.value {
        quote! { #[derive(::serde::Serialize, ::serde::Deserialize)] }
    } else {
        quote! {}
    };

    quote! {
        #serde_derives
        struct Foo {
            a: i32
        }
    }
    .into()
}

If the caller wants to depend on serde, it can pass true as the macro argument:

lib::hello_world!(true);

This way, the decision is left to the caller, not everyone in the workspace.

Thank you for the suggestion :slight_smile: I'd prefer not having to change the interface of the macro, as it's used all over the place.


I have since found this thread, where @alice shows that out you can check at compile time whether a dependency has a certain feature enabled:

quote! { #[cfg_attr(feature = "serde/derive", derive(::serde::Serialize, ::serde::Deserialize))] }

While this does produce a warning

warning: unexpected `cfg` condition value: `serde/derive`

for both binaries, presumably because neither of them directly depend on serde, they both actually compile.

For some reason this fixes my toy example from the repo above, but not my actual code (where there are two more layers of transitive dependencies), but I'll dig a bit further. If anyone has more suggestions on how to approach this, feel free to comment :slight_smile:


Edit: Ok, it "works" only as long as I don't acutally try to use the derived traits. Because the feature of the dependency is not visible from the topmost crate's cfg_attr's point of view. So the cfg_attr is there, but the feature is never seen and the trait is never derived.