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

Support non-flag attributes in #[builder(on(...))] #152

Open
Veetaha opened this issue Oct 20, 2024 · 0 comments
Open

Support non-flag attributes in #[builder(on(...))] #152

Veetaha opened this issue Oct 20, 2024 · 0 comments
Labels
feature request A new feature is requested

Comments

@Veetaha
Copy link
Contributor

Veetaha commented Oct 20, 2024

Currently, there is a #[builder(on(pattern, attrs))] attribute that supports into configuration. With that one it's possible to enable #[builder(into)] for all members like this:

#[derive(bon::Builder)]
#[builder(on(_, into))]
struct Example {
    // auto #[builder(into)]
    x1: String,
    // auto #[builder(into)]
    x2: PathBuf,
    // auto #[builder(into)]
    x3: Box<str>,
    // auto #[builder(into)]
    x4: Box<Path>,
}

The _ is a pattern that matches all members. However, it's possible to match only specific types like this:

#[derive(bon::Builder)]
#[builder(on(String, into))]
struct Example {
    // auto #[builder(into)]
    x1: String,
    x2: PathBuf,
    x3: Box<str>,
    x4: Box<Path>,
}

This way only x1 will get a #[builder(into)] because its type String matches the pattern. To match any member of Box type the following pattern could be used:

#[derive(bon::Builder)]
#[builder(on(Box<_>, into))]
struct Example {
    x1: String,
    x2: PathBuf,
    // auto #[builder(into)]
    x3: Box<str>,
    // auto #[builder(into)]
    x4: Box<Path>,
}

It's also possible to specify multiple on(...) clauses:

#[derive(bon::Builder)]
#[builder(
    on(Box<_>, into),
    on(String, into),
)]
struct Example {
    // auto #[builder(into)]
    x1: String,
    x2: PathBuf,
    // auto #[builder(into)]
    x3: Box<str>,
    // auto #[builder(into)]
    x4: Box<Path>,
}

This syntax of #[builder(on(...))] works quite well for attributes that are boolean flags.

However, using it for attributes that can have more states than true/false is more complex. For example, #[builder(default)] is not just a boolean. It can accept any arbitrary expression: #[builder(default = 2 + 2)]. So then there is a question of how #[builder(on(...))] should behave when there are multiple on(...) directives that match the same member.

There are several ways to approach the problem.

Prioritization based on pattern specificity

We could obviously say that the pattern _ is more general than Box<_> or String. Therefore we could say that if there is on(String, default = ...) and #[builder(_, default)], then the on(String, default = ...) clause wins and thus its configuration is applied.

However, evaluating specificity is not as easy as that. Suppose, for example, there is this case:

#[derive(bon::Builder)]
#[builder(
    on(BTreeMap<_, u32>, default = BTreeMap::from([(1, 1)])),
    on(BTreeMap<u32, _>, default = BTreeMap::from([(2, 2)])),
)]
struct Example {
    x1: BTreeMap<u32, u32>
}

What default config should be applied in this case? Both of the patterns BTreeMap<_, u32> and BTreeMap<u32, _> seem to be at the same level of specificity as for me. So... there is no one obvious way to resolve this configuration if we specialized based on pattern specificity.

So this approach doesn't work.

Prioritization based on relative ordering

We can say that on(...) directives behave like match arms in a match where the scrutinee is the member. I.e. it semantically works like this:

match member {
    pattern1 => default = ...,
    pattern2 => default = ...,
    ...
}

Then the answer for the problematic BTreeMap<_, u32>/BTreeMap<u32, _> case becomes obvious. The first pattern that we declared syntactically higher in the code wins. This requires the developer to manually arrange the on(...) directives in the order from the most specific to the least specific according to their taste.

However, this becomes inconvenient when multiple attributes are involved. For example:

#[derive(bon::Builder)]
#[builder(
    on(String, into),
    on(_, default),
)]
struct Example {
    x1: String,
    x2: PathBuf,
}

In this case the first on(String, into) will match the member x1. It specifies only into attribute, and thus x1 will get #[builder(into)], but it won't get #[builder(default)], which may or may not be the desired behavior.

Maybe instead, we could say that different into and default attributes are matched separately. So, for example, on(String, into) short circuits for into, but this directive is ignored for default because it doesn't mention it. If the user wants to explicitly disable default for Strings they should write on(String, into, reset(default)).

Extended pattern syntax and attributes that change the member's type and other matched properties

In version 3.0 of bon, there will be a new attribute called #[builder(transparent)]. This attribute applies only to members of type Option<T>. It disables their special handling such that there is only one setter generated that accepts the Option<T> value directly, and that setter is required to call just like for any other member not marked with #[builder(default)].

There was a request for applying such an attribute at the top level with #[builder(on(...))] as well already (#35 (comment)). However, the problem is that this attribute changes the underlying type of the member. It changes it from T to Option<T>.

Currently, the type matching ignores the Option<...> wrapper. This allows the following to work:

#[derive(bon::Builder)]
#[builder(on(String, into))]
struct Example {
    // auto #[builder(into)]
    x1: String,
    // auto #[builder(into)]
    x2: Option<String>,
    // auto #[builder(into)]
    #[builder(default)]
    x3: String,
}

All the members x1, x2, x3 have #[builder(into)] automatically configured for them. We didn't have to write on(String, into), on(Option<String>, into). The "underlying" type of the member is matched instead. What are the underlying types in this case? Here they are:

  • x1 - this is a required member and its underlying type is the type of the field itself i.e. String
  • x2 - this is an optional member and its underlying type is the type under the Option<...> i.e. String.
  • x3 - this is an optional member with a default and its underlying type is the type of the fieeld itself i.e. String.

This way the "optional", "default", "required" behaviors are treated as separate parameters of the member. Member's underlying type is always independent of those parameters. This makes the match against the underlying type of the member stable. So if the user changes a required member of type String to Option<String> the match still occurs.

However, if we add #[builder(transparent)] into this, then the reasoning becomes more complex:

#[derive(bon::Builder)]
#[builder(on(String, into))]
struct Example {
    // auto #[builder(into)]
    x1: String,
    #[builder(transparent)]
    x2: Option<String>,
    // auto #[builder(into)]
    #[builder(default)]
    x3: String,
}

Now, the member x2 becomes a required member with the underlying type of Option<String>. Therefore, the directive on(String, into) no longer matches it.

Then, if we support transparent in the on(...) directive itself, then the order in which we evaluate the on(...) directives becomes even more important. For example:

#[derive(bon::Builder)]
#[builder(
    on(_, transparent),
    on(String, into),
)]
struct Example {
    // auto #[builder(into)]
    x1: String,
    x2: Option<String>,
    // auto #[builder(into)]
    #[builder(default)]
    x3: String,
}

In this case, we assume that we first apply transparent to all members, and only after that do we evaluate the on(String, into). So after the first pass of applying transparent the x2 member's underlying type changes to Option<String>, and thus it no longer matches the on(String, into).

Then, we could say that if we change the order of on(...) directives here, things change like this:

#[derive(bon::Builder)]
#[builder(
    on(String, into),
    on(_, transparent),
)]
struct Example {
    // auto #[builder(into)]
    x1: String,
    // auto #[builder(into, transparent)]
    x2: Option<String>,
    // auto #[builder(into)]
    #[builder(default)]
    x3: String,
}

The first pass applies into to all members with the underlying String type. The second pass applies transparent to the member x2.

Summing up

We need to generalize all of this into a consistent simple algorithm that could be explained and understood by the developers. Here is how it could be described:

  • All on(...) directives are evaluated in order of their declaration.

  • Once the first matching on(...) directive sets a config for a specific parameter of the member all other configs for this parameter in the remaining on(...) directives will be ignored. But... member-level configuration always takes precedence.
    Example:

    #[derive(bon::Builder)]
    #[builder(
      // use `true` as the default value for booleans
      on(bool, default = true),
      // for any other types use whatever the `Default` trait returns for them
      on(_, default)
    )]
    struct Example {
        // auto #[builder(default = true)]
        // this is because the first matching `on(bool, ...)` directive
        // specified the config for `default` first.
        x1: bool,
    
        // auto #[builder(default)]
        x2: String,
    
        // member-level config always wins
        #[builder(default = "custom default".to_owned())]
        x3: String,
    }
  • The reset(...) directive can be used to explicitly request the "factory reset" for the config parameter so that on(...) directives below don't override its value. Example:

    #[derive(bon::Builder)]
    #[builder(
      // explicit config for `u32` that asks to use the "factory settings" for `default`,
      // which is that it doesn't have a default value (the member is required)
      on(u32, reset(default)),
      //
      on(_, default)
    )]
    struct Example {
        // non-default required member
        // this is because the first matching `on(u32, ...)` directive
        // specified the "reset" config for `default` first
        x1: u32
    
        // Member-level config always wins
        #[builder(default)]
        x2: u32,
    
        // "Factory-reset" the `default` config for this specific member.
        // So this member will be required (as it is when no config is applied)
        #[builder(reset(default))]
        x3: String,
    }
  • The on(...) directives that are declared earlier (higher in code) change the properties of the member and these changes influence the matching for the on(...) directives that come later (lower in code).
    Example:

    #[derive(bon::Builder)]
    #[builder(
        on(_, transparent),
        on(String, into),
    )]
    struct Example {
        // auto #[builder(into)]
        x1: String,
    
        // Doesn't get `#[builder(into)]` because `on(_, transparent)`
        // changed the underlying type of this member from `String` to `Option<String>`,
        // so `on(String, into)` no longer matches it.
        x2: Option<String>,
    
        // auto #[builder(into)]
        #[builder(default)]
        x3: String,
    }

Future posibilities

In the future, there can be more complex syntax for patterns in on(pattern, attrs). We could allow selecting members not only by their type, but also by other properites:

  • on(prefix = foo, ...) select members with names that start with foo
  • on(required(), ...) select all reequired members
  • on(has(into), ...) select members that have #[buider(into)] applied to them

A note for the community from the maintainers

Please vote on this issue by adding a 👍 reaction to help the maintainers with prioritizing it. You may add a comment describing your real use case related to this issue for us to better understand the problem domain.

@Veetaha Veetaha added the feature request A new feature is requested label Oct 20, 2024
@Veetaha Veetaha changed the title #[builder(on(...))] extensions #[builder(on(...))] extended design Oct 20, 2024
@Veetaha Veetaha changed the title #[builder(on(...))] extended design Extend #[builder(on(...))] to support non-flag attributes Nov 10, 2024
@Veetaha Veetaha changed the title Extend #[builder(on(...))] to support non-flag attributes Support non-flag attributes in #[builder(on(...))] Nov 10, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature request A new feature is requested
Projects
None yet
Development

No branches or pull requests

1 participant