Discussion/brainstorming: Non-fatal errors
darling has a simple pass-fail approach for turning the AST into receiver structs. All of the darling traits return Result<Self, darling::Error>. This is great for approachability, but it does mean there's not a good place to put non-fatal diagnostics.
Some concrete examples:
- It would be great to support
#[darling(deprecated = "...")], enabling someone to deprecate a field in their macro as easily as they would a regular method. - In
derive_builderthe conflicting visibility declarations don't need to block code generation. Ideally there would be one error for "A field cannot be both public and private" without a second compiler error "LoremBuilder does not exist"
It's possible that these scenarios are best handled outside the existing system - since they don't block parsing, they could be implemented as functions that take the Receiver and return a Vec<Diagnostic>. That "feels" wrong though:
- Someone who's using
darlingfor derivation shouldn't need to remember to also call some other method to avoid losing non-fatal diagnostics. - Composing together types that impl
FromMetashould "just work", including emitting non-fatal diagnostics when appropriate. If the non-fatal diagnostics are gathered in a separate pass, the author will need to remember to visit every field when gathering diagnostics or they'll miss things. - Deferring non-fatal diagnostics requires holding spans in the receiver struct. An examination of crates using
darlingsuggests this rarely happens, withStringandboolbeing very common receiver field types. As a result, non-fatal diagnostics would not have access to good spans.
There seem to be two ways this "could" work:
-
darlingpasses around a context of some sort, andFromMetaimpls can add diagnostics into that context. - The return type for the
darlingtraits is changed to be something like:DarlingResult<T> { value: Option<T>, diagnostics: Vec<Diagnostic> }. A fatal error would causevalueto beNone.
Both of these have problems.
Approach 1 would likely require introduction of parallel traits: FromMetaContext, FromDeriveInputContext, etc. Those wouldn't compose, since existing derivations of FromMeta wouldn't pass the context to their children when they call FromMeta.
Approach 2 would mean an enormous breaking change to the entire darling ecosystem, and despite being a 0.x crate, I'm extremely reluctant - bordering on outright unwilling - to do that.
That leaves the after-the-fact traversal. Maybe there's a way with drop bombs to make that workable: Make #[darling::post_validation] be an attribute macro that implements a trait and injects a drop-bomb field to ensure the trait implementation is called?
Would adding a struct like the one below solve this?
pub struct WithDiagnostics<T> {
pub value: T,
pub diagnostics: Vec<Error>,
}
impl<T: FromMeta> FromMeta for WithDiagnostics<T> {
fn from_meta(meta: &Meta) -> Result<Self> {
Ok(Self {
value: FromMeta::from_meta(meta)?,
diagnostics: vec![],
})
}
}
impl<T: ToTokens> ToTokens for WithDiagnostics<T> {
fn to_tokens(&self, tokens: &mut TokenStream) {
self.value.to_tokens(tokens);
for diagnostic in &self.diagnostics {
// TODO add errors to token stream
}
}
}
Pros
- The change is very localized and opt-in; code that doesn't have non-fatal errors won't need any changes
- The presence of non-fatal diagnostics is represented in the receiver struct's type, which feels more idiomatic than a hidden mutable context.
- This design supports both field-level checks and struct-level post-validation. The
#[darling(with = "...")]attribute lets someone write a function that isolates the field-level checks, while post-validation can be done by pushing diagnostics into the vec after the struct has been built (easily done with#[darling(map = "...")].
Cons
- A lot of
darlingprojects have the receiver struct and the "codegen" struct be different; to avoid losing diagnostics this would require supporting several combinators. That also raises the question of whether this needs to be generic over the type ofErrorso that&Errorcan be used in its stead when creating aWithDiagnosticsthat borrows fromvalue. - This design doesn't help structs that want to derive
FromMetaand have struct-internal non-fatal validations. Those would likely need to do something more involved where they add a field#[darling(skip)] diagnostics: Vec<Error>to themselves, then populate that using#[darling(map = "...")]. - I would currently prefer to use
darling::Errorfor the diagnostics; usingproc_macro::Diagnosticwouldn't work well because right nowdarling_coredoesn't have a hard dependency onproc_macro, and introducing adarling::Diagnostictype feels like it could quickly get confusing. However, I'm hesitant to call this structWithErrorsif it can contain things that aren't errors.
@AlisCode I'm curious to get your input on this one; does your company have situations where you want to add warnings/notes/etc. without adding an error? Would a new util type like WithDiagnostics be a convenient way for you to do that?
In the case of the macro I've been refactoring, we don't exactly separate the concepts of "receiver structs" and "meaningful/stronger-typed structs". We have our "meaningful" structs implement darling traits, namely FromVariant.
Any validation problem on any of the variants of the enum is fatal, so I assume that no, optional diagnostics arent really a thing we need. Not for that specific macro, anyway :eyes: .