Skip to main content

flodl_cli_macros/
lib.rs

1//! `#[derive(FdlArgs)]` -- proc-macro derive for flodl-cli's argv parser.
2//!
3//! This crate is re-exported by [`flodl-cli`](https://crates.io/crates/flodl-cli)
4//! as `flodl_cli::FdlArgs`, so downstream binaries depend on `flodl-cli`,
5//! not on this crate directly.
6//!
7//! The derive turns a plain struct with named fields into an argv parser
8//! plus JSON schema emitter plus ANSI-coloured help renderer. One struct
9//! is the single source of truth: doc-comments become help text,
10//! attribute metadata becomes schema, field types become typed values.
11//!
12//! # Field attributes
13//!
14//! Each field carries exactly one of `#[option(...)]` (named flag,
15//! kebab-cased from the field ident) or `#[arg(...)]` (positional).
16//! The field type determines cardinality:
17//!
18//! - `bool` -- absent = `false`, present = `true`. `#[option]` only.
19//! - `T` -- scalar, required. `#[option]` must supply `default = "..."`.
20//! - `Option<T>` -- scalar, optional. Absent = `None`.
21//! - `Vec<T>` -- `#[option]`: repeatable. `#[arg]`: variadic, last.
22//!
23//! Supported keys for `#[option]`: `short`, `default`, `choices`, `env`,
24//! `completer`. For `#[arg]`: `default`, `choices`, `variadic`,
25//! `completer`. Reserved flags (`--help`, `--version`, `--quiet`,
26//! `--env`, and their shorts) cannot be shadowed; collisions error at
27//! derive time.
28//!
29//! # Example
30//!
31//! The example below depends on the `flodl-cli` crate; it is marked
32//! `ignore` because this crate is a proc-macro and doesn't depend on
33//! `flodl-cli` itself. Copy the snippet into a `flodl-cli`-depending
34//! binary to try it.
35//!
36//! ```ignore
37//! use flodl_cli::{FdlArgs, parse_or_schema};
38//!
39//! /// Train a model.
40//! #[derive(FdlArgs, Debug)]
41//! struct TrainArgs {
42//!     /// Model architecture to use.
43//!     #[option(short = 'm', choices = &["mlp", "resnet"], default = "mlp")]
44//!     model: String,
45//!
46//!     /// Number of epochs.
47//!     #[option(short = 'e', default = "10")]
48//!     epochs: u32,
49//!
50//!     /// API key, read from env if flag is absent.
51//!     #[option(env = "WANDB_API_KEY")]
52//!     wandb_key: Option<String>,
53//!
54//!     /// Extra dataset paths.
55//!     #[arg(variadic)]
56//!     datasets: Vec<String>,
57//! }
58//!
59//! fn main() {
60//!     let args: TrainArgs = parse_or_schema();
61//!     // `--help` and `--fdl-schema` are intercepted by parse_or_schema.
62//!     let _ = args;
63//! }
64//! ```
65//!
66//! See the [`flodl-cli`](https://docs.rs/flodl-cli) crate for the
67//! user-facing API (`parse_or_schema`, `FdlArgsTrait`, `Schema`) and
68//! the full CLI reference.
69
70use proc_macro::TokenStream;
71use proc_macro2::{Span, TokenStream as TokenStream2};
72use quote::{quote, quote_spanned};
73use syn::{
74    parse_macro_input, Attribute, Data, DeriveInput, Expr, ExprLit, Fields, GenericArgument,
75    Ident, Lit, PathArguments, Type, TypePath,
76};
77
78// ── Reserved flags (kept in sync with flodl-cli/src/config.rs) ─────────
79
80const RESERVED_LONGS: &[&str] = &["help", "version", "quiet", "env"];
81const RESERVED_SHORTS: &[char] = &['h', 'V', 'q', 'v', 'e'];
82
83// ── Entry point ─────────────────────────────────────────────────────────
84
85/// Derive `FdlArgs` on a struct with named fields to generate an argv
86/// parser, `--fdl-schema` JSON emitter, and ANSI-coloured `--help`
87/// renderer. See the [crate-level docs](crate) for the attribute
88/// reference and a worked example.
89#[proc_macro_derive(FdlArgs, attributes(option, arg))]
90pub fn derive_fdl_args(input: TokenStream) -> TokenStream {
91    let input = parse_macro_input!(input as DeriveInput);
92    match impl_derive(input) {
93        Ok(ts) => ts,
94        Err(e) => e.to_compile_error().into(),
95    }
96}
97
98fn impl_derive(input: DeriveInput) -> syn::Result<TokenStream> {
99    let ident = &input.ident;
100    let description = extract_doc(&input.attrs);
101
102    let fields = match &input.data {
103        Data::Struct(s) => match &s.fields {
104            Fields::Named(n) => &n.named,
105            _ => {
106                return Err(syn::Error::new_spanned(
107                    ident,
108                    "FdlArgs requires a struct with named fields",
109                ));
110            }
111        },
112        _ => {
113            return Err(syn::Error::new_spanned(
114                ident,
115                "FdlArgs requires a struct",
116            ));
117        }
118    };
119
120    let mut parsed: Vec<FieldSpec> = Vec::new();
121    for f in fields {
122        parsed.push(parse_field(f)?);
123    }
124
125    validate_collisions(&parsed)?;
126
127    let spec_build = build_spec_expr(&parsed);
128    let schema_build = build_schema_expr(&parsed, description.as_deref());
129    let extract = build_extractor(ident, &parsed)?;
130    let render_help = build_help_expr(&parsed, description.as_deref(), &ident.to_string());
131    let env_injection = build_env_injection(&parsed);
132
133    let expanded = quote! {
134        impl ::flodl_cli::FdlArgsTrait for #ident {
135            fn try_parse_from(args: &[::std::string::String])
136                -> ::std::result::Result<Self, ::std::string::String>
137            {
138                let spec = #spec_build;
139                #env_injection
140                let parsed = ::flodl_cli::args::parser::parse(&spec, args)?;
141                #extract
142            }
143
144            fn schema() -> ::flodl_cli::Schema {
145                #schema_build
146            }
147
148            fn render_help() -> ::std::string::String {
149                #render_help
150            }
151        }
152    };
153    Ok(expanded.into())
154}
155
156// ── Field spec (what we learn from each field) ──────────────────────────
157
158#[derive(Clone)]
159enum FieldKind {
160    Option,
161    Arg,
162}
163
164#[derive(Clone)]
165enum TypeShape {
166    /// `bool`
167    Bool,
168    /// `T` — scalar
169    Scalar,
170    /// `Option<T>`
171    Opt,
172    /// `Vec<T>`
173    List,
174}
175
176#[derive(Clone)]
177struct FieldSpec {
178    ident: Ident,
179    kind: FieldKind,
180    shape: TypeShape,
181    /// The "inner" type (for `Option<T>` / `Vec<T>`, the `T`; for `T`, `T` itself).
182    inner_ty: Type,
183    description: Option<String>,
184    // Attribute contents
185    short: Option<char>,
186    default: Option<String>,
187    choices: Option<Vec<String>>,
188    env: Option<String>,
189    completer: Option<String>,
190    variadic: bool,
191    span: Span,
192}
193
194fn parse_field(f: &syn::Field) -> syn::Result<FieldSpec> {
195    let ident = f.ident.clone().ok_or_else(|| {
196        syn::Error::new_spanned(f, "FdlArgs requires named fields")
197    })?;
198    let description = extract_doc(&f.attrs);
199    let (shape, inner_ty) = classify_type(&f.ty);
200
201    // Exactly one of #[option] / #[arg] must be present (plain fields
202    // are NOT auto-treated as options in this MVP — explicit is better
203    // while the contract settles).
204    let mut kind: Option<FieldKind> = None;
205    let mut short: Option<char> = None;
206    let mut default: Option<String> = None;
207    let mut choices: Option<Vec<String>> = None;
208    let mut env: Option<String> = None;
209    let mut completer: Option<String> = None;
210    let mut variadic = false;
211
212    for attr in &f.attrs {
213        if attr.path().is_ident("option") {
214            if kind.is_some() {
215                return Err(syn::Error::new_spanned(
216                    attr,
217                    "field cannot have both #[option] and #[arg]",
218                ));
219            }
220            kind = Some(FieldKind::Option);
221            parse_option_attr(attr, &mut short, &mut default, &mut choices, &mut env, &mut completer)?;
222        } else if attr.path().is_ident("arg") {
223            if kind.is_some() {
224                return Err(syn::Error::new_spanned(
225                    attr,
226                    "field cannot have both #[option] and #[arg]",
227                ));
228            }
229            kind = Some(FieldKind::Arg);
230            parse_arg_attr(attr, &mut default, &mut choices, &mut variadic, &mut completer)?;
231        }
232    }
233
234    let kind = kind.ok_or_else(|| {
235        syn::Error::new_spanned(
236            &ident,
237            "field must carry either #[option] or #[arg]",
238        )
239    })?;
240
241    // Type + kind + attrs consistency checks.
242    match kind {
243        FieldKind::Option => {
244            if matches!(shape, TypeShape::Bool) && default.is_some() {
245                return Err(syn::Error::new_spanned(
246                    &f.ty,
247                    "#[option(default = ...)] is meaningless on a bool flag (absent=false, present=true)",
248                ));
249            }
250            if matches!(shape, TypeShape::Bool) && env.is_some() {
251                return Err(syn::Error::new_spanned(
252                    &f.ty,
253                    "#[option(env = ...)] is not supported on bare `bool` (truthy/falsy string semantics are ambiguous) — use `Option<bool>` if you need env fallback",
254                ));
255            }
256            if matches!(shape, TypeShape::Scalar) && default.is_none() && !matches!(shape, TypeShape::Bool) {
257                return Err(syn::Error::new_spanned(
258                    &f.ty,
259                    "#[option] on a non-Option, non-bool type requires `default = \"...\"` (the field must always have a value)",
260                ));
261            }
262            if variadic {
263                return Err(syn::Error::new_spanned(
264                    &ident,
265                    "`variadic` only applies to #[arg], not #[option]",
266                ));
267            }
268        }
269        FieldKind::Arg => {
270            if matches!(shape, TypeShape::Bool) {
271                return Err(syn::Error::new_spanned(
272                    &f.ty,
273                    "positional #[arg] cannot be a bool (positionals always carry a value)",
274                ));
275            }
276            if short.is_some() {
277                return Err(syn::Error::new_spanned(
278                    &ident,
279                    "`short` cannot be used on #[arg] (positionals have no short form)",
280                ));
281            }
282            if variadic && !matches!(shape, TypeShape::List) {
283                return Err(syn::Error::new_spanned(
284                    &f.ty,
285                    "#[arg(variadic)] requires a Vec<T> field",
286                ));
287            }
288        }
289    }
290
291    Ok(FieldSpec {
292        ident,
293        kind,
294        shape,
295        inner_ty,
296        description,
297        short,
298        default,
299        choices,
300        env,
301        completer,
302        variadic,
303        span: f.span(),
304    })
305}
306
307// ── Attribute parsing ───────────────────────────────────────────────────
308
309fn parse_option_attr(
310    attr: &Attribute,
311    short: &mut Option<char>,
312    default: &mut Option<String>,
313    choices: &mut Option<Vec<String>>,
314    env: &mut Option<String>,
315    completer: &mut Option<String>,
316) -> syn::Result<()> {
317    if matches!(attr.meta, syn::Meta::Path(_)) {
318        return Ok(()); // bare #[option]
319    }
320    attr.parse_nested_meta(|meta| {
321        let key = meta
322            .path
323            .get_ident()
324            .ok_or_else(|| meta.error("expected identifier key in #[option]"))?;
325        match key.to_string().as_str() {
326            "short" => {
327                let v: syn::LitChar = meta.value()?.parse()?;
328                *short = Some(v.value());
329            }
330            "default" => {
331                let v: syn::LitStr = meta.value()?.parse()?;
332                *default = Some(v.value());
333            }
334            "choices" => {
335                *choices = Some(parse_choices(&meta)?);
336            }
337            "env" => {
338                let v: syn::LitStr = meta.value()?.parse()?;
339                *env = Some(v.value());
340            }
341            "completer" => {
342                let v: syn::LitStr = meta.value()?.parse()?;
343                *completer = Some(v.value());
344            }
345            other => {
346                return Err(meta.error(format!(
347                    "unknown #[option] attribute `{other}` (valid: short, default, choices, env, completer)"
348                )));
349            }
350        }
351        Ok(())
352    })
353}
354
355fn parse_arg_attr(
356    attr: &Attribute,
357    default: &mut Option<String>,
358    choices: &mut Option<Vec<String>>,
359    variadic: &mut bool,
360    completer: &mut Option<String>,
361) -> syn::Result<()> {
362    if matches!(attr.meta, syn::Meta::Path(_)) {
363        return Ok(());
364    }
365    attr.parse_nested_meta(|meta| {
366        let key = meta
367            .path
368            .get_ident()
369            .ok_or_else(|| meta.error("expected identifier key in #[arg]"))?;
370        match key.to_string().as_str() {
371            "default" => {
372                let v: syn::LitStr = meta.value()?.parse()?;
373                *default = Some(v.value());
374            }
375            "choices" => {
376                *choices = Some(parse_choices(&meta)?);
377            }
378            "variadic" => {
379                // Either `variadic` alone or `variadic = true`.
380                *variadic = true;
381                if meta.input.peek(syn::Token![=]) {
382                    let v: syn::LitBool = meta.value()?.parse()?;
383                    *variadic = v.value();
384                }
385            }
386            "completer" => {
387                let v: syn::LitStr = meta.value()?.parse()?;
388                *completer = Some(v.value());
389            }
390            other => {
391                return Err(meta.error(format!(
392                    "unknown #[arg] attribute `{other}` (valid: default, choices, variadic, completer)"
393                )));
394            }
395        }
396        Ok(())
397    })
398}
399
400fn parse_choices(meta: &syn::meta::ParseNestedMeta) -> syn::Result<Vec<String>> {
401    // Accept both `choices = &["a", "b"]` and `choices = ["a", "b"]`.
402    let expr: Expr = meta.value()?.parse()?;
403    let arr = match expr {
404        Expr::Reference(r) => *r.expr,
405        e => e,
406    };
407    match arr {
408        Expr::Array(arr) => {
409            let mut out = Vec::with_capacity(arr.elems.len());
410            for e in arr.elems {
411                if let Expr::Lit(ExprLit {
412                    lit: Lit::Str(s), ..
413                }) = e
414                {
415                    out.push(s.value());
416                } else {
417                    return Err(syn::Error::new_spanned(
418                        e,
419                        "choices must be string literals",
420                    ));
421                }
422            }
423            Ok(out)
424        }
425        other => Err(syn::Error::new_spanned(
426            other,
427            "choices must be an array literal, e.g. `&[\"a\", \"b\"]`",
428        )),
429    }
430}
431
432// ── Type classification ─────────────────────────────────────────────────
433
434fn classify_type(ty: &Type) -> (TypeShape, Type) {
435    if let Type::Path(TypePath { path, .. }) = ty {
436        if let Some(seg) = path.segments.last() {
437            let name = seg.ident.to_string();
438            if name == "bool" {
439                return (TypeShape::Bool, ty.clone());
440            }
441            if name == "Option" {
442                if let Some(inner) = first_generic(&seg.arguments) {
443                    return (TypeShape::Opt, inner);
444                }
445            }
446            if name == "Vec" {
447                if let Some(inner) = first_generic(&seg.arguments) {
448                    return (TypeShape::List, inner);
449                }
450            }
451        }
452    }
453    (TypeShape::Scalar, ty.clone())
454}
455
456fn first_generic(args: &PathArguments) -> Option<Type> {
457    if let PathArguments::AngleBracketed(a) = args {
458        for arg in &a.args {
459            if let GenericArgument::Type(t) = arg {
460                return Some(t.clone());
461            }
462        }
463    }
464    None
465}
466
467// ── Validation ──────────────────────────────────────────────────────────
468
469fn validate_collisions(fields: &[FieldSpec]) -> syn::Result<()> {
470    let mut seen_long: std::collections::HashMap<String, Span> =
471        std::collections::HashMap::new();
472    let mut seen_short: std::collections::HashMap<char, Span> =
473        std::collections::HashMap::new();
474
475    // Positionals: variadic-last, no-required-after-optional.
476    let mut seen_optional = false;
477    for f in fields {
478        if !matches!(f.kind, FieldKind::Arg) {
479            continue;
480        }
481        let is_optional =
482            matches!(f.shape, TypeShape::Opt) || f.default.is_some() || f.variadic;
483        if seen_optional && !is_optional {
484            return Err(syn::Error::new(
485                f.span,
486                "required positional cannot follow an optional one",
487            ));
488        }
489        if is_optional {
490            seen_optional = true;
491        }
492    }
493    // Variadic may only be the last arg.
494    let mut saw_variadic = false;
495    for f in fields {
496        if !matches!(f.kind, FieldKind::Arg) {
497            continue;
498        }
499        if saw_variadic {
500            return Err(syn::Error::new(
501                f.span,
502                "variadic positional must be the last one",
503            ));
504        }
505        if f.variadic {
506            saw_variadic = true;
507        }
508    }
509
510    for f in fields {
511        if !matches!(f.kind, FieldKind::Option) {
512            continue;
513        }
514        let long = kebab(&f.ident.to_string());
515        if RESERVED_LONGS.contains(&long.as_str()) {
516            return Err(syn::Error::new(
517                f.span,
518                format!("--{long} shadows a reserved fdl-level flag"),
519            ));
520        }
521        if let Some(prev) = seen_long.insert(long.clone(), f.span) {
522            return Err(syn::Error::new(
523                f.span,
524                format!("duplicate long flag --{long} (previously declared at {:?})", prev),
525            ));
526        }
527        if let Some(s) = f.short {
528            if RESERVED_SHORTS.contains(&s) {
529                return Err(syn::Error::new(
530                    f.span,
531                    format!("-{s} shadows a reserved fdl-level flag"),
532                ));
533            }
534            if let Some(prev) = seen_short.insert(s, f.span) {
535                return Err(syn::Error::new(
536                    f.span,
537                    format!("duplicate short -{s} (previously declared at {:?})", prev),
538                ));
539            }
540        }
541    }
542
543    Ok(())
544}
545
546// ── Code generators ─────────────────────────────────────────────────────
547
548fn build_spec_expr(fields: &[FieldSpec]) -> TokenStream2 {
549    let opts = fields
550        .iter()
551        .filter(|f| matches!(f.kind, FieldKind::Option))
552        .map(build_option_decl);
553    let positionals = fields
554        .iter()
555        .filter(|f| matches!(f.kind, FieldKind::Arg))
556        .map(build_positional_decl);
557
558    quote! {
559        ::flodl_cli::args::parser::ArgsSpec {
560            options: vec![ #( #opts ),* ],
561            positionals: vec![ #( #positionals ),* ],
562            // Derive-parsed CLIs are authoritative about their own
563            // surface — unknown flags are programmer errors, not
564            // legitimate pass-through. Stay strict.
565            lenient_unknowns: false,
566        }
567    }
568}
569
570fn build_option_decl(f: &FieldSpec) -> TokenStream2 {
571    let long = kebab(&f.ident.to_string());
572    let takes_value = !matches!(f.shape, TypeShape::Bool);
573    let allows_bare = match f.shape {
574        TypeShape::Bool => true,
575        _ => f.default.is_some(),
576    };
577    let repeatable = matches!(f.shape, TypeShape::List);
578    let short_expr = match f.short {
579        Some(c) => quote! { ::std::option::Option::Some(#c) },
580        None => quote! { ::std::option::Option::None },
581    };
582    let choices_expr = match &f.choices {
583        Some(list) => {
584            let elems = list.iter();
585            quote! { ::std::option::Option::Some(vec![ #( ::std::string::String::from(#elems) ),* ]) }
586        }
587        None => quote! { ::std::option::Option::None },
588    };
589
590    quote! {
591        ::flodl_cli::args::parser::OptionDecl {
592            long: ::std::string::String::from(#long),
593            short: #short_expr,
594            takes_value: #takes_value,
595            allows_bare: #allows_bare,
596            repeatable: #repeatable,
597            choices: #choices_expr,
598        }
599    }
600}
601
602fn build_positional_decl(f: &FieldSpec) -> TokenStream2 {
603    let name = kebab(&f.ident.to_string());
604    let required = matches!(f.shape, TypeShape::Scalar) && f.default.is_none() && !f.variadic;
605    let variadic = f.variadic;
606    let choices_expr = match &f.choices {
607        Some(list) => {
608            let elems = list.iter();
609            quote! { ::std::option::Option::Some(vec![ #( ::std::string::String::from(#elems) ),* ]) }
610        }
611        None => quote! { ::std::option::Option::None },
612    };
613    quote! {
614        ::flodl_cli::args::parser::PositionalDecl {
615            name: ::std::string::String::from(#name),
616            required: #required,
617            variadic: #variadic,
618            choices: #choices_expr,
619        }
620    }
621}
622
623fn build_schema_expr(fields: &[FieldSpec], description: Option<&str>) -> TokenStream2 {
624    let desc_expr = match description {
625        Some(d) => quote! { ::std::option::Option::Some(::std::string::String::from(#d)) },
626        None => quote! { ::std::option::Option::None },
627    };
628
629    let option_inserts = fields
630        .iter()
631        .filter(|f| matches!(f.kind, FieldKind::Option))
632        .map(|f| {
633            let long = kebab(&f.ident.to_string());
634            let ty = schema_type_str(f);
635            let desc_expr = match &f.description {
636                Some(d) => quote! { ::std::option::Option::Some(::std::string::String::from(#d)) },
637                None => quote! { ::std::option::Option::None },
638            };
639            let default_expr = match &f.default {
640                Some(v) => quote! { ::std::option::Option::Some(::flodl_cli::serde_json::Value::String(::std::string::String::from(#v))) },
641                None => quote! { ::std::option::Option::None },
642            };
643            let choices_expr = match &f.choices {
644                Some(list) => {
645                    let elems = list.iter();
646                    quote! {
647                        ::std::option::Option::Some(vec![
648                            #( ::flodl_cli::serde_json::Value::String(::std::string::String::from(#elems)) ),*
649                        ])
650                    }
651                }
652                None => quote! { ::std::option::Option::None },
653            };
654            let short_expr = match f.short {
655                Some(c) => {
656                    let cs = c.to_string();
657                    quote! { ::std::option::Option::Some(::std::string::String::from(#cs)) }
658                }
659                None => quote! { ::std::option::Option::None },
660            };
661            let env_expr = match &f.env {
662                Some(v) => quote! { ::std::option::Option::Some(::std::string::String::from(#v)) },
663                None => quote! { ::std::option::Option::None },
664            };
665            let completer_expr = match &f.completer {
666                Some(v) => quote! { ::std::option::Option::Some(::std::string::String::from(#v)) },
667                None => quote! { ::std::option::Option::None },
668            };
669            quote! {
670                options.insert(
671                    ::std::string::String::from(#long),
672                    ::flodl_cli::OptionSpec {
673                        ty: ::std::string::String::from(#ty),
674                        description: #desc_expr,
675                        default: #default_expr,
676                        choices: #choices_expr,
677                        short: #short_expr,
678                        env: #env_expr,
679                        completer: #completer_expr,
680                    },
681                );
682            }
683        });
684
685    let arg_pushes = fields
686        .iter()
687        .filter(|f| matches!(f.kind, FieldKind::Arg))
688        .map(|f| {
689            let name = kebab(&f.ident.to_string());
690            let ty = schema_type_str(f);
691            let desc_expr = match &f.description {
692                Some(d) => quote! { ::std::option::Option::Some(::std::string::String::from(#d)) },
693                None => quote! { ::std::option::Option::None },
694            };
695            let required = matches!(f.shape, TypeShape::Scalar) && f.default.is_none() && !f.variadic;
696            let variadic = f.variadic;
697            let default_expr = match &f.default {
698                Some(v) => quote! { ::std::option::Option::Some(::flodl_cli::serde_json::Value::String(::std::string::String::from(#v))) },
699                None => quote! { ::std::option::Option::None },
700            };
701            let choices_expr = match &f.choices {
702                Some(list) => {
703                    let elems = list.iter();
704                    quote! {
705                        ::std::option::Option::Some(vec![
706                            #( ::flodl_cli::serde_json::Value::String(::std::string::String::from(#elems)) ),*
707                        ])
708                    }
709                }
710                None => quote! { ::std::option::Option::None },
711            };
712            let completer_expr = match &f.completer {
713                Some(v) => quote! { ::std::option::Option::Some(::std::string::String::from(#v)) },
714                None => quote! { ::std::option::Option::None },
715            };
716            quote! {
717                args.push(::flodl_cli::ArgSpec {
718                    name: ::std::string::String::from(#name),
719                    ty: ::std::string::String::from(#ty),
720                    description: #desc_expr,
721                    required: #required,
722                    variadic: #variadic,
723                    default: #default_expr,
724                    choices: #choices_expr,
725                    completer: #completer_expr,
726                });
727            }
728        });
729
730    // `desc_expr` is retained for future use (Schema may grow a
731    // description field). Bind it to `_` only when it has a concrete
732    // type — interpolating a bare `Option::None` into `let _ = ...;`
733    // leaves rustc unable to infer the type parameter (E0282) when
734    // the caller's surrounding context doesn't pin it down, which
735    // happened inside test modules.
736    let _ = desc_expr;
737    quote! {
738        {
739            let mut options: ::std::collections::BTreeMap<::std::string::String, ::flodl_cli::OptionSpec> =
740                ::std::collections::BTreeMap::new();
741            let mut args: ::std::vec::Vec<::flodl_cli::ArgSpec> = ::std::vec::Vec::new();
742            #( #option_inserts )*
743            #( #arg_pushes )*
744            ::flodl_cli::Schema {
745                args,
746                options,
747                strict: false,
748            }
749        }
750    }
751}
752
753fn schema_type_str(f: &FieldSpec) -> &'static str {
754    let inner = inner_ty_name(&f.inner_ty);
755    let base = match inner.as_str() {
756        "bool" => "bool",
757        "String" | "&str" => "string",
758        "PathBuf" | "Path" => "path",
759        "f32" | "f64" => "float",
760        // Any integer-ish.
761        "u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" => "int",
762        _ => "string",
763    };
764    match f.shape {
765        TypeShape::List => match base {
766            "string" => "list[string]",
767            "int" => "list[int]",
768            "float" => "list[float]",
769            "path" => "list[path]",
770            _ => "list[string]",
771        },
772        TypeShape::Bool => "bool",
773        _ => base,
774    }
775}
776
777fn inner_ty_name(ty: &Type) -> String {
778    if let Type::Path(TypePath { path, .. }) = ty {
779        if let Some(seg) = path.segments.last() {
780            return seg.ident.to_string();
781        }
782    }
783    String::from("_")
784}
785
786/// Emit an argv pre-processing block that, for each `#[option(env = "...")]`
787/// field absent from argv, appends `--<long> <value>` sourced from the named
788/// environment variable. After this runs, the standard parser pipeline
789/// handles the value exactly like an argv-supplied flag — choices, strict
790/// unknowns, and `FromStr` all fire unchanged.
791///
792/// Precedence (highest wins): argv flag → env var → `default`. Empty env
793/// vars fall through (consistent with `FDL_ENV` handling in `main.rs`).
794/// Boolean fields are rejected at derive time elsewhere, so we never
795/// inject `--foo` without a value.
796fn build_env_injection(fields: &[FieldSpec]) -> TokenStream2 {
797    let mut injections: Vec<TokenStream2> = Vec::new();
798    for f in fields {
799        let Some(env_name) = f.env.as_deref() else {
800            continue;
801        };
802        // Positional args (#[arg]) don't have an env path in this MVP —
803        // they're typically required and rarely env-driven. Skip them.
804        if matches!(f.kind, FieldKind::Arg) {
805            continue;
806        }
807        let long = kebab(&f.ident.to_string());
808        let long_flag = format!("--{long}");
809        let long_eq_prefix = format!("--{long}=");
810        let short_tok = match &f.short {
811            Some(c) => {
812                let short_exact = format!("-{c}");
813                quote! {
814                    || a.as_str() == #short_exact
815                }
816            }
817            None => quote! {},
818        };
819        injections.push(quote! {
820            {
821                let has_flag = __env_args.iter().any(|a: &::std::string::String| {
822                    a.as_str() == #long_flag
823                        || a.as_str().starts_with(#long_eq_prefix)
824                        #short_tok
825                });
826                if !has_flag {
827                    if let ::std::result::Result::Ok(v) = ::std::env::var(#env_name) {
828                        if !v.is_empty() {
829                            __env_args.push(::std::string::String::from(#long_flag));
830                            __env_args.push(v);
831                        }
832                    }
833                }
834            }
835        });
836    }
837    if injections.is_empty() {
838        return quote! {};
839    }
840    quote! {
841        let __env_args: ::std::vec::Vec<::std::string::String> = {
842            let mut __env_args: ::std::vec::Vec<::std::string::String> = args.to_vec();
843            #( #injections )*
844            __env_args
845        };
846        let args: &[::std::string::String] = &__env_args[..];
847    }
848}
849
850fn build_extractor(ident: &Ident, fields: &[FieldSpec]) -> syn::Result<TokenStream2> {
851    let mut field_inits: Vec<TokenStream2> = Vec::new();
852    let mut positional_idx: usize = 0;
853    for f in fields {
854        match f.kind {
855            FieldKind::Option => field_inits.push(option_extraction(f)),
856            FieldKind::Arg => {
857                field_inits.push(arg_extraction(f, positional_idx));
858                if !f.variadic {
859                    positional_idx += 1;
860                }
861            }
862        }
863    }
864    let field_names: Vec<&Ident> = fields.iter().map(|f| &f.ident).collect();
865    Ok(quote! {
866        #( #field_inits )*
867        ::std::result::Result::Ok(#ident {
868            #( #field_names ),*
869        })
870    })
871}
872
873fn option_extraction(f: &FieldSpec) -> TokenStream2 {
874    let ident = &f.ident;
875    let long = kebab(&ident.to_string());
876    let inner_ty = &f.inner_ty;
877    let span = ident.span();
878    let parse_one = quote_spanned! { span =>
879        |s: &::std::string::String| -> ::std::result::Result<#inner_ty, ::std::string::String> {
880            <#inner_ty as ::std::str::FromStr>::from_str(s)
881                .map_err(|e| format!("--{}: {}", #long, e))
882        }
883    };
884
885    match f.shape {
886        TypeShape::Bool => quote! {
887            let #ident: bool = matches!(
888                parsed.options.get(#long),
889                ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::BarePresent)
890            );
891        },
892        TypeShape::Scalar => {
893            // Must have a default (validated earlier).
894            let default_lit = f.default.as_deref().unwrap();
895            quote! {
896                let #ident: #inner_ty = match parsed.options.get(#long) {
897                    ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::WithValues(v)) => {
898                        let s = &v[0];
899                        (#parse_one)(s)?
900                    }
901                    _ => {
902                        let s = ::std::string::String::from(#default_lit);
903                        (#parse_one)(&s).expect("default value must parse")
904                    }
905                };
906            }
907        }
908        TypeShape::Opt => {
909            let default_tok = match &f.default {
910                Some(v) => quote! { ::std::option::Option::Some({
911                    let s = ::std::string::String::from(#v);
912                    (#parse_one)(&s).expect("default value must parse")
913                }) },
914                None => quote! { ::std::option::Option::None },
915            };
916            quote! {
917                let #ident: ::std::option::Option<#inner_ty> = match parsed.options.get(#long) {
918                    ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::WithValues(v)) => {
919                        ::std::option::Option::Some((#parse_one)(&v[0])?)
920                    }
921                    ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::BarePresent) => {
922                        #default_tok
923                    }
924                    ::std::option::Option::None => ::std::option::Option::None,
925                };
926            }
927        }
928        TypeShape::List => quote! {
929            let #ident: ::std::vec::Vec<#inner_ty> = match parsed.options.get(#long) {
930                ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::WithValues(v)) => {
931                    let mut out: ::std::vec::Vec<#inner_ty> = ::std::vec::Vec::with_capacity(v.len());
932                    for s in v {
933                        out.push((#parse_one)(s)?);
934                    }
935                    out
936                }
937                _ => ::std::vec::Vec::new(),
938            };
939        },
940    }
941}
942
943fn arg_extraction(f: &FieldSpec, idx: usize) -> TokenStream2 {
944    let ident = &f.ident;
945    let name = kebab(&ident.to_string());
946    let inner_ty = &f.inner_ty;
947    let span = ident.span();
948    let parse_one = quote_spanned! { span =>
949        |s: &::std::string::String| -> ::std::result::Result<#inner_ty, ::std::string::String> {
950            <#inner_ty as ::std::str::FromStr>::from_str(s)
951                .map_err(|e| format!("<{}>: {}", #name, e))
952        }
953    };
954
955    match f.shape {
956        TypeShape::List if f.variadic => quote! {
957            let #ident: ::std::vec::Vec<#inner_ty> = {
958                let mut out: ::std::vec::Vec<#inner_ty> = ::std::vec::Vec::new();
959                for s in &parsed.positionals[#idx..] {
960                    out.push((#parse_one)(s)?);
961                }
962                out
963            };
964        },
965        TypeShape::Opt => quote! {
966            let #ident: ::std::option::Option<#inner_ty> = match parsed.positionals.get(#idx) {
967                ::std::option::Option::Some(s) => ::std::option::Option::Some((#parse_one)(s)?),
968                ::std::option::Option::None => ::std::option::Option::None,
969            };
970        },
971        TypeShape::Scalar => {
972            let default_tok = match &f.default {
973                Some(v) => quote! {
974                    {
975                        let s = ::std::string::String::from(#v);
976                        (#parse_one)(&s).expect("default value must parse")
977                    }
978                },
979                None => quote! {
980                    return ::std::result::Result::Err(
981                        format!("missing required argument <{}>", #name)
982                    )
983                },
984            };
985            quote! {
986                let #ident: #inner_ty = match parsed.positionals.get(#idx) {
987                    ::std::option::Option::Some(s) => (#parse_one)(s)?,
988                    ::std::option::Option::None => #default_tok,
989                };
990            }
991        }
992        _ => quote! {
993            compile_error!("unsupported positional type shape");
994        },
995    }
996}
997
998fn build_help_expr(fields: &[FieldSpec], description: Option<&str>, struct_name: &str) -> TokenStream2 {
999    // Prefer the doc-comment description as the banner; fall back to the
1000    // struct ident only when no description is present. The struct name is
1001    // an implementation detail that users shouldn't see in `--help`.
1002    let header = match description {
1003        Some(d) => format!("{d}\n\n"),
1004        None => format!("{struct_name}\n\n"),
1005    };
1006
1007    // The help is assembled at runtime so `::flodl_cli::style::*` can check
1008    // whether stderr is a terminal — piped output stays plain, interactive
1009    // output gets ANSI color to match the hand-rolled helps in run.rs.
1010    // Padding is computed at macro-expand time from the raw label widths;
1011    // ANSI escapes are zero-width on terminal and don't affect alignment
1012    // because they're injected between the label and its trailing spaces.
1013
1014    let mut arg_tokens: Vec<TokenStream2> = Vec::new();
1015    let mut opt_tokens: Vec<TokenStream2> = Vec::new();
1016
1017    for f in fields {
1018        match f.kind {
1019            FieldKind::Option => {
1020                let long = kebab(&f.ident.to_string());
1021                let short_prefix = match f.short {
1022                    Some(c) => format!("-{c}, "),
1023                    None => String::from("    "),
1024                };
1025                let value_part = match f.shape {
1026                    TypeShape::Bool => String::new(),
1027                    TypeShape::List => String::from(" <VALUE>..."),
1028                    _ => format!(" <{}>", value_token(f)),
1029                };
1030                let label = format!("{short_prefix}--{long}{value_part}");
1031                let pad = " ".repeat(36usize.saturating_sub(4 + label.chars().count()));
1032                let mut tail = String::new();
1033                if let Some(d) = &f.description {
1034                    tail.push_str(d);
1035                }
1036                if let Some(d) = &f.default {
1037                    tail.push_str(&format!("  [default: {d}]"));
1038                }
1039                if let Some(choices) = &f.choices {
1040                    tail.push_str(&format!("  [possible: {}]", choices.join(", ")));
1041                }
1042                opt_tokens.push(quote! {
1043                    out.push_str("    ");
1044                    out.push_str(&::flodl_cli::style::green(#label));
1045                    out.push_str(#pad);
1046                    out.push_str(#tail);
1047                    out.push('\n');
1048                });
1049            }
1050            FieldKind::Arg => {
1051                let name = kebab(&f.ident.to_string());
1052                let required = matches!(f.shape, TypeShape::Scalar) && f.default.is_none();
1053                let label = if f.variadic {
1054                    format!("<{name}>...")
1055                } else if required {
1056                    format!("<{name}>")
1057                } else {
1058                    format!("[<{name}>]")
1059                };
1060                let pad = " ".repeat(36usize.saturating_sub(4 + label.chars().count()));
1061                let mut tail = String::new();
1062                if let Some(d) = &f.description {
1063                    tail.push_str(d);
1064                }
1065                if let Some(d) = &f.default {
1066                    tail.push_str(&format!("  [default: {d}]"));
1067                }
1068                arg_tokens.push(quote! {
1069                    out.push_str("    ");
1070                    out.push_str(&::flodl_cli::style::green(#label));
1071                    out.push_str(#pad);
1072                    out.push_str(#tail);
1073                    out.push('\n');
1074                });
1075            }
1076        }
1077    }
1078
1079    let arg_section = if arg_tokens.is_empty() {
1080        quote! {}
1081    } else {
1082        quote! {
1083            out.push_str(&::flodl_cli::style::yellow("Arguments"));
1084            out.push_str(":\n");
1085            #(#arg_tokens)*
1086            out.push('\n');
1087        }
1088    };
1089    let opt_section = if opt_tokens.is_empty() {
1090        quote! {}
1091    } else {
1092        quote! {
1093            out.push_str(&::flodl_cli::style::yellow("Options"));
1094            out.push_str(":\n");
1095            #(#opt_tokens)*
1096            out.push('\n');
1097        }
1098    };
1099
1100    quote! {
1101        {
1102            let mut out = ::std::string::String::from(#header);
1103            #arg_section
1104            #opt_section
1105            out
1106        }
1107    }
1108}
1109
1110fn value_token(f: &FieldSpec) -> &'static str {
1111    let inner = inner_ty_name(&f.inner_ty);
1112    match inner.as_str() {
1113        "u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" => "N",
1114        "f32" | "f64" => "F",
1115        "PathBuf" | "Path" => "PATH",
1116        _ => "VALUE",
1117    }
1118}
1119
1120// ── Utilities ───────────────────────────────────────────────────────────
1121
1122fn extract_doc(attrs: &[Attribute]) -> Option<String> {
1123    let mut lines: Vec<String> = Vec::new();
1124    for a in attrs {
1125        if !a.path().is_ident("doc") {
1126            continue;
1127        }
1128        if let syn::Meta::NameValue(nv) = &a.meta {
1129            if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = &nv.value {
1130                let text = s.value();
1131                lines.push(text.trim().to_string());
1132            }
1133        }
1134    }
1135    if lines.is_empty() {
1136        return None;
1137    }
1138    // Join lines with a space; collapse internal whitespace runs.
1139    let joined = lines.join(" ").split_whitespace().collect::<Vec<_>>().join(" ");
1140    if joined.is_empty() {
1141        None
1142    } else {
1143        Some(joined)
1144    }
1145}
1146
1147fn kebab(s: &str) -> String {
1148    s.replace('_', "-")
1149}
1150
1151// syn's Span import trick: pull from proc_macro2 above.
1152use syn::spanned::Spanned;