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//! # Enum form (subcommands)
67//!
68//! Deriving on an **enum of newtype variants** turns each variant into a
69//! subcommand. The derive is a thin dispatcher: it peels the leading
70//! subcommand token and delegates parsing, schema, and help to the
71//! wrapped type, which carries its own `#[derive(FdlArgs)]`. No field
72//! parsing happens at the enum level.
73//!
74//! ```ignore
75//! use flodl_cli::{FdlArgs, parse_or_schema};
76//!
77//! /// flodl letter CLI.
78//! #[derive(FdlArgs)]
79//! enum Cli {
80//!     /// Train a letter model on a dataset
81//!     Train(TrainArgs),
82//!     /// Evaluate a trained letter model on a test split
83//!     Eval(EvalArgs),
84//!     /// Generate samples (subcommand renamed from the variant ident)
85//!     #[command(name = "gen")]
86//!     Generate(GenArgs),
87//! }
88//!
89//! fn main() {
90//!     match parse_or_schema::<Cli>() {
91//!         Cli::Train(a) => { /* ... */ let _ = a; }
92//!         Cli::Eval(a) => { let _ = a; }
93//!         Cli::Generate(a) => { let _ = a; }
94//!     }
95//! }
96//! ```
97//!
98//! - Subcommand name = the variant ident kebab-cased (`TrainSubscan` →
99//!   `train-subscan`), overridable with `#[command(name = "...")]`.
100//! - Variant doc-comments become the per-subcommand descriptions shown in
101//!   the parent `--help` command list.
102//! - `--help` is contextual: `<bin> train --help` shows train's flags,
103//!   `<bin> --help` shows the command list. `--fdl-schema` emits the full
104//!   tree.
105//! - Only single-tuple (newtype) variants are supported — each subcommand
106//!   *is* a struct. Unit, named-field, and multi-field variants are
107//!   rejected at derive time. A variant may itself wrap another
108//!   `FdlArgs` enum for nested subcommands.
109//!
110//! See the [`flodl-cli`](https://docs.rs/flodl-cli) crate for the
111//! user-facing API (`parse_or_schema`, `FdlArgsTrait`, `Schema`) and
112//! the full CLI reference.
113
114use proc_macro::TokenStream;
115use proc_macro2::{Span, TokenStream as TokenStream2};
116use quote::{quote, quote_spanned};
117use syn::{
118    Attribute, Data, DeriveInput, Expr, ExprLit, Fields, GenericArgument, Ident, Lit,
119    PathArguments, Type, TypePath, parse_macro_input,
120};
121
122// ── Reserved flags (kept in sync with flodl-cli/src/config.rs) ─────────
123
124const RESERVED_LONGS: &[&str] = &["help", "version", "quiet", "env"];
125const RESERVED_SHORTS: &[char] = &['h', 'V', 'q', 'v', 'e'];
126
127// ── Entry point ─────────────────────────────────────────────────────────
128
129/// Derive `FdlArgs` to generate an argv parser, `--fdl-schema` JSON
130/// emitter, and ANSI-coloured `--help` renderer.
131///
132/// On a **struct with named fields**, each field is a flag or positional.
133/// On an **enum of newtype variants**, each variant is a subcommand that
134/// delegates to the wrapped type. See the [crate-level docs](crate) for
135/// the attribute reference and worked examples of both forms.
136#[proc_macro_derive(FdlArgs, attributes(option, arg, command))]
137pub fn derive_fdl_args(input: TokenStream) -> TokenStream {
138    let input = parse_macro_input!(input as DeriveInput);
139    match impl_derive(input) {
140        Ok(ts) => ts,
141        Err(e) => e.to_compile_error().into(),
142    }
143}
144
145fn impl_derive(input: DeriveInput) -> syn::Result<TokenStream> {
146    let ident = &input.ident;
147    let description = extract_doc(&input.attrs);
148
149    let fields = match &input.data {
150        Data::Struct(s) => match &s.fields {
151            Fields::Named(n) => &n.named,
152            _ => {
153                return Err(syn::Error::new_spanned(
154                    ident,
155                    "FdlArgs requires a struct with named fields",
156                ));
157            }
158        },
159        // An enum is a variant-shaped CLI: each newtype variant is a
160        // subcommand wrapping a type that itself derives FdlArgs. The
161        // derive here is a thin dispatcher over those inner impls.
162        Data::Enum(e) => return impl_enum_derive(ident, description.as_deref(), e),
163        _ => {
164            return Err(syn::Error::new_spanned(
165                ident,
166                "FdlArgs requires a struct or enum",
167            ));
168        }
169    };
170
171    let mut parsed: Vec<FieldSpec> = Vec::new();
172    for f in fields {
173        parsed.push(parse_field(f)?);
174    }
175
176    validate_collisions(&parsed)?;
177
178    let spec_build = build_spec_expr(&parsed);
179    let schema_build = build_schema_expr(&parsed, description.as_deref());
180    let extract = build_extractor(ident, &parsed)?;
181    let render_help = build_help_expr(&parsed, description.as_deref(), &ident.to_string());
182    let env_injection = build_env_injection(&parsed);
183
184    let expanded = quote! {
185        impl ::flodl_cli::FdlArgsTrait for #ident {
186            fn try_parse_from(args: &[::std::string::String])
187                -> ::std::result::Result<Self, ::std::string::String>
188            {
189                let spec = #spec_build;
190                #env_injection
191                let parsed = ::flodl_cli::args::parser::parse(&spec, args)?;
192                #extract
193            }
194
195            fn schema() -> ::flodl_cli::Schema {
196                #schema_build
197            }
198
199            fn render_help() -> ::std::string::String {
200                #render_help
201            }
202        }
203    };
204    Ok(expanded.into())
205}
206
207// ── Enum (variant-shaped CLI) ───────────────────────────────────────────
208
209/// What we learn from one enum variant: a subcommand.
210struct VariantSpec {
211    /// Variant identifier (used in the generated `Self::Ident(...)` arm).
212    ident: Ident,
213    /// Subcommand name on the command line — kebab of the ident, or the
214    /// `#[command(name = "...")]` override.
215    name: String,
216    /// The wrapped type (`TrainArgs` in `Train(TrainArgs)`); must itself
217    /// implement `FdlArgsTrait` (i.e. derive `FdlArgs`).
218    inner_ty: Type,
219    /// Variant doc-comment → the subcommand's one-line help description.
220    description: Option<String>,
221}
222
223/// Generate the `FdlArgsTrait` impl for an enum of newtype variants. The
224/// impl is a dispatcher: it peels the leading subcommand token and
225/// delegates parse / schema / help to the wrapped type, which carries its
226/// own derived impl. No field parsing happens here.
227fn impl_enum_derive(
228    ident: &Ident,
229    description: Option<&str>,
230    data: &syn::DataEnum,
231) -> syn::Result<TokenStream> {
232    if data.variants.is_empty() {
233        return Err(syn::Error::new_spanned(
234            ident,
235            "FdlArgs enum needs at least one variant (each variant is a subcommand)",
236        ));
237    }
238
239    let mut variants: Vec<VariantSpec> = Vec::new();
240    let mut seen: std::collections::HashMap<String, Span> = std::collections::HashMap::new();
241    for v in &data.variants {
242        let inner_ty = match &v.fields {
243            Fields::Unnamed(f) if f.unnamed.len() == 1 => f.unnamed[0].ty.clone(),
244            _ => {
245                return Err(syn::Error::new_spanned(
246                    v,
247                    "FdlArgs enum variants must be single-tuple (newtype) variants \
248                     wrapping a type that derives FdlArgs, e.g. `Train(TrainArgs)` \
249                     (unit, named-field, and multi-field variants are not supported)",
250                ));
251            }
252        };
253        let name = variant_command_name(v)?;
254        if let Some(prev) = seen.insert(name.clone(), v.span()) {
255            let _ = prev;
256            return Err(syn::Error::new_spanned(
257                v,
258                format!("duplicate subcommand name `{name}`"),
259            ));
260        }
261        variants.push(VariantSpec {
262            ident: v.ident.clone(),
263            name,
264            inner_ty,
265            description: extract_doc(&v.attrs),
266        });
267    }
268
269    // Comma-separated name list for "expected one of" / "did you mean".
270    let names: Vec<&str> = variants.iter().map(|v| v.name.as_str()).collect();
271    let names_csv = names.join(", ");
272    let names_arr = quote! { &[ #( #names ),* ] };
273
274    // try_parse_from: match the subcommand token, delegate the tail.
275    let parse_arms = variants.iter().map(|v| {
276        let name = &v.name;
277        let vident = &v.ident;
278        let inner_ty = &v.inner_ty;
279        quote! {
280            #name => ::std::result::Result::Ok(#ident::#vident(
281                <#inner_ty as ::flodl_cli::FdlArgsTrait>::try_parse_from(&args[1..])?
282            )),
283        }
284    });
285
286    // schema(): build a branch node, one child per subcommand.
287    let schema_inserts = variants.iter().map(|v| {
288        let name = &v.name;
289        let inner_ty = &v.inner_ty;
290        let desc_set = match &v.description {
291            Some(d) => quote! { __child.description = ::std::option::Option::Some(::std::string::String::from(#d)); },
292            None => quote! {},
293        };
294        quote! {
295            {
296                let mut __child = <#inner_ty as ::flodl_cli::FdlArgsTrait>::schema();
297                #desc_set
298                __commands.insert(::std::string::String::from(#name), __child);
299            }
300        }
301    });
302
303    // render_help_path(): peel the first non-flag token, delegate to that
304    // subcommand's (recursive) help; otherwise the command list.
305    let help_path_arms = variants.iter().map(|v| {
306        let name = &v.name;
307        let inner_ty = &v.inner_ty;
308        quote! {
309            #name => return <#inner_ty as ::flodl_cli::FdlArgsTrait>::render_help_path(__tail),
310        }
311    });
312
313    // render_help(): the root command listing.
314    let header = match description {
315        Some(d) => format!("{d}\n\n"),
316        None => format!("{ident}\n\n"),
317    };
318    let command_lines = variants.iter().map(|v| {
319        let label = v.name.clone();
320        let pad = " ".repeat(36usize.saturating_sub(4 + label.chars().count()));
321        let tail = v.description.clone().unwrap_or_default();
322        quote! {
323            out.push_str("    ");
324            out.push_str(&::flodl_cli::style::green(#label));
325            out.push_str(#pad);
326            out.push_str(#tail);
327            out.push('\n');
328        }
329    });
330
331    let expanded = quote! {
332        impl ::flodl_cli::FdlArgsTrait for #ident {
333            fn try_parse_from(args: &[::std::string::String])
334                -> ::std::result::Result<Self, ::std::string::String>
335            {
336                let sub = match args.get(1) {
337                    ::std::option::Option::Some(s) => s.as_str(),
338                    ::std::option::Option::None => {
339                        return ::std::result::Result::Err(::std::format!(
340                            "missing command, expected one of: {}", #names_csv
341                        ));
342                    }
343                };
344                match sub {
345                    #( #parse_arms )*
346                    other => {
347                        match ::flodl_cli::args::parser::suggest(#names_arr, other) {
348                            ::std::option::Option::Some(s) => ::std::result::Result::Err(::std::format!(
349                                "unknown command `{other}`, did you mean `{s}`?"
350                            )),
351                            ::std::option::Option::None => ::std::result::Result::Err(::std::format!(
352                                "unknown command `{other}`, expected one of: {}", #names_csv
353                            )),
354                        }
355                    }
356                }
357            }
358
359            fn schema() -> ::flodl_cli::Schema {
360                let mut __commands: ::std::collections::BTreeMap<::std::string::String, ::flodl_cli::Schema> =
361                    ::std::collections::BTreeMap::new();
362                #( #schema_inserts )*
363                ::flodl_cli::Schema {
364                    commands: __commands,
365                    ..::core::default::Default::default()
366                }
367            }
368
369            fn render_help() -> ::std::string::String {
370                let mut out = ::std::string::String::from(#header);
371                out.push_str(&::flodl_cli::style::yellow("Commands"));
372                out.push_str(":\n");
373                #( #command_lines )*
374                out
375            }
376
377            fn render_help_path(args: &[::std::string::String]) -> ::std::string::String {
378                // The subcommand is args[1] (mirrors `try_parse_from`). A
379                // leading flag or no token falls to the top-level command list.
380                // Scanning past leading flags to the first bare token would
381                // mis-pick an option value (e.g. `42` in `--seed 42 train`) as
382                // the subcommand.
383                if let ::std::option::Option::Some(__sub) =
384                    args.get(1).filter(|s| !s.starts_with('-'))
385                {
386                    let __tail = &args[1..];
387                    match __sub.as_str() {
388                        #( #help_path_arms )*
389                        _ => {}
390                    }
391                }
392                Self::render_help()
393            }
394        }
395    };
396    Ok(expanded.into())
397}
398
399/// Resolve a variant's subcommand name: kebab of the ident by default, or
400/// the `#[command(name = "...")]` override.
401fn variant_command_name(v: &syn::Variant) -> syn::Result<String> {
402    for attr in &v.attrs {
403        if !attr.path().is_ident("command") {
404            continue;
405        }
406        let mut name: Option<String> = None;
407        attr.parse_nested_meta(|meta| {
408            if meta.path.is_ident("name") {
409                let s: syn::LitStr = meta.value()?.parse()?;
410                name = Some(s.value());
411                Ok(())
412            } else {
413                Err(meta.error("unknown #[command] key (valid: name)"))
414            }
415        })?;
416        if let Some(n) = name {
417            if n.trim().is_empty() {
418                return Err(syn::Error::new_spanned(
419                    attr,
420                    "#[command(name = ...)] must be non-empty",
421                ));
422            }
423            return Ok(n);
424        }
425    }
426    Ok(pascal_to_kebab(&v.ident.to_string()))
427}
428
429// ── Field spec (what we learn from each field) ──────────────────────────
430
431#[derive(Clone)]
432enum FieldKind {
433    Option,
434    Arg,
435}
436
437#[derive(Clone)]
438enum TypeShape {
439    /// `bool`
440    Bool,
441    /// `T` — scalar
442    Scalar,
443    /// `Option<T>`
444    Opt,
445    /// `Vec<T>`
446    List,
447}
448
449#[derive(Clone)]
450struct FieldSpec {
451    ident: Ident,
452    kind: FieldKind,
453    shape: TypeShape,
454    /// The "inner" type (for `Option<T>` / `Vec<T>`, the `T`; for `T`, `T` itself).
455    inner_ty: Type,
456    description: Option<String>,
457    // Attribute contents
458    short: Option<char>,
459    default: Option<String>,
460    choices: Option<Vec<String>>,
461    env: Option<String>,
462    completer: Option<String>,
463    variadic: bool,
464    span: Span,
465}
466
467fn parse_field(f: &syn::Field) -> syn::Result<FieldSpec> {
468    let ident = f
469        .ident
470        .clone()
471        .ok_or_else(|| syn::Error::new_spanned(f, "FdlArgs requires named fields"))?;
472    let description = extract_doc(&f.attrs);
473    let (shape, inner_ty) = classify_type(&f.ty);
474
475    // Exactly one of #[option] / #[arg] must be present (plain fields
476    // are NOT auto-treated as options in this MVP — explicit is better
477    // while the contract settles).
478    let mut kind: Option<FieldKind> = None;
479    let mut short: Option<char> = None;
480    let mut default: Option<String> = None;
481    let mut choices: Option<Vec<String>> = None;
482    let mut env: Option<String> = None;
483    let mut completer: Option<String> = None;
484    let mut variadic = false;
485
486    for attr in &f.attrs {
487        if attr.path().is_ident("option") {
488            if kind.is_some() {
489                return Err(syn::Error::new_spanned(
490                    attr,
491                    "field cannot have both #[option] and #[arg]",
492                ));
493            }
494            kind = Some(FieldKind::Option);
495            parse_option_attr(
496                attr,
497                &mut short,
498                &mut default,
499                &mut choices,
500                &mut env,
501                &mut completer,
502            )?;
503        } else if attr.path().is_ident("arg") {
504            if kind.is_some() {
505                return Err(syn::Error::new_spanned(
506                    attr,
507                    "field cannot have both #[option] and #[arg]",
508                ));
509            }
510            kind = Some(FieldKind::Arg);
511            parse_arg_attr(
512                attr,
513                &mut default,
514                &mut choices,
515                &mut variadic,
516                &mut completer,
517            )?;
518        }
519    }
520
521    let kind = kind.ok_or_else(|| {
522        syn::Error::new_spanned(&ident, "field must carry either #[option] or #[arg]")
523    })?;
524
525    // Type + kind + attrs consistency checks.
526    match kind {
527        FieldKind::Option => {
528            if matches!(shape, TypeShape::Bool) && default.is_some() {
529                return Err(syn::Error::new_spanned(
530                    &f.ty,
531                    "#[option(default = ...)] is meaningless on a bool flag (absent=false, present=true)",
532                ));
533            }
534            if matches!(shape, TypeShape::Bool) && env.is_some() {
535                return Err(syn::Error::new_spanned(
536                    &f.ty,
537                    "#[option(env = ...)] is not supported on bare `bool` (truthy/falsy string semantics are ambiguous) — use `Option<bool>` if you need env fallback",
538                ));
539            }
540            if matches!(shape, TypeShape::Scalar)
541                && default.is_none()
542                && !matches!(shape, TypeShape::Bool)
543            {
544                return Err(syn::Error::new_spanned(
545                    &f.ty,
546                    "#[option] on a non-Option, non-bool type requires `default = \"...\"` (the field must always have a value)",
547                ));
548            }
549            if variadic {
550                return Err(syn::Error::new_spanned(
551                    &ident,
552                    "`variadic` only applies to #[arg], not #[option]",
553                ));
554            }
555        }
556        FieldKind::Arg => {
557            if matches!(shape, TypeShape::Bool) {
558                return Err(syn::Error::new_spanned(
559                    &f.ty,
560                    "positional #[arg] cannot be a bool (positionals always carry a value)",
561                ));
562            }
563            if short.is_some() {
564                return Err(syn::Error::new_spanned(
565                    &ident,
566                    "`short` cannot be used on #[arg] (positionals have no short form)",
567                ));
568            }
569            if variadic && !matches!(shape, TypeShape::List) {
570                return Err(syn::Error::new_spanned(
571                    &f.ty,
572                    "#[arg(variadic)] requires a Vec<T> field",
573                ));
574            }
575        }
576    }
577
578    Ok(FieldSpec {
579        ident,
580        kind,
581        shape,
582        inner_ty,
583        description,
584        short,
585        default,
586        choices,
587        env,
588        completer,
589        variadic,
590        span: f.span(),
591    })
592}
593
594// ── Attribute parsing ───────────────────────────────────────────────────
595
596fn parse_option_attr(
597    attr: &Attribute,
598    short: &mut Option<char>,
599    default: &mut Option<String>,
600    choices: &mut Option<Vec<String>>,
601    env: &mut Option<String>,
602    completer: &mut Option<String>,
603) -> syn::Result<()> {
604    if matches!(attr.meta, syn::Meta::Path(_)) {
605        return Ok(()); // bare #[option]
606    }
607    attr.parse_nested_meta(|meta| {
608        let key = meta
609            .path
610            .get_ident()
611            .ok_or_else(|| meta.error("expected identifier key in #[option]"))?;
612        match key.to_string().as_str() {
613            "short" => {
614                let v: syn::LitChar = meta.value()?.parse()?;
615                *short = Some(v.value());
616            }
617            "default" => {
618                let v: syn::LitStr = meta.value()?.parse()?;
619                *default = Some(v.value());
620            }
621            "choices" => {
622                *choices = Some(parse_choices(&meta)?);
623            }
624            "env" => {
625                let v: syn::LitStr = meta.value()?.parse()?;
626                *env = Some(v.value());
627            }
628            "completer" => {
629                let v: syn::LitStr = meta.value()?.parse()?;
630                *completer = Some(v.value());
631            }
632            other => {
633                return Err(meta.error(format!(
634                    "unknown #[option] attribute `{other}` (valid: short, default, choices, env, completer)"
635                )));
636            }
637        }
638        Ok(())
639    })
640}
641
642fn parse_arg_attr(
643    attr: &Attribute,
644    default: &mut Option<String>,
645    choices: &mut Option<Vec<String>>,
646    variadic: &mut bool,
647    completer: &mut Option<String>,
648) -> syn::Result<()> {
649    if matches!(attr.meta, syn::Meta::Path(_)) {
650        return Ok(());
651    }
652    attr.parse_nested_meta(|meta| {
653        let key = meta
654            .path
655            .get_ident()
656            .ok_or_else(|| meta.error("expected identifier key in #[arg]"))?;
657        match key.to_string().as_str() {
658            "default" => {
659                let v: syn::LitStr = meta.value()?.parse()?;
660                *default = Some(v.value());
661            }
662            "choices" => {
663                *choices = Some(parse_choices(&meta)?);
664            }
665            "variadic" => {
666                // Either `variadic` alone or `variadic = true`.
667                *variadic = true;
668                if meta.input.peek(syn::Token![=]) {
669                    let v: syn::LitBool = meta.value()?.parse()?;
670                    *variadic = v.value();
671                }
672            }
673            "completer" => {
674                let v: syn::LitStr = meta.value()?.parse()?;
675                *completer = Some(v.value());
676            }
677            other => {
678                return Err(meta.error(format!(
679                    "unknown #[arg] attribute `{other}` (valid: default, choices, variadic, completer)"
680                )));
681            }
682        }
683        Ok(())
684    })
685}
686
687fn parse_choices(meta: &syn::meta::ParseNestedMeta) -> syn::Result<Vec<String>> {
688    // Accept both `choices = &["a", "b"]` and `choices = ["a", "b"]`.
689    let expr: Expr = meta.value()?.parse()?;
690    let arr = match expr {
691        Expr::Reference(r) => *r.expr,
692        e => e,
693    };
694    match arr {
695        Expr::Array(arr) => {
696            let mut out = Vec::with_capacity(arr.elems.len());
697            for e in arr.elems {
698                if let Expr::Lit(ExprLit {
699                    lit: Lit::Str(s), ..
700                }) = e
701                {
702                    out.push(s.value());
703                } else {
704                    return Err(syn::Error::new_spanned(
705                        e,
706                        "choices must be string literals",
707                    ));
708                }
709            }
710            Ok(out)
711        }
712        other => Err(syn::Error::new_spanned(
713            other,
714            "choices must be an array literal, e.g. `&[\"a\", \"b\"]`",
715        )),
716    }
717}
718
719// ── Type classification ─────────────────────────────────────────────────
720
721fn classify_type(ty: &Type) -> (TypeShape, Type) {
722    if let Type::Path(TypePath { path, .. }) = ty
723        && let Some(seg) = path.segments.last()
724    {
725        let name = seg.ident.to_string();
726        if name == "bool" {
727            return (TypeShape::Bool, ty.clone());
728        }
729        if name == "Option"
730            && let Some(inner) = first_generic(&seg.arguments)
731        {
732            return (TypeShape::Opt, inner);
733        }
734        if name == "Vec"
735            && let Some(inner) = first_generic(&seg.arguments)
736        {
737            return (TypeShape::List, inner);
738        }
739    }
740    (TypeShape::Scalar, ty.clone())
741}
742
743fn first_generic(args: &PathArguments) -> Option<Type> {
744    if let PathArguments::AngleBracketed(a) = args {
745        for arg in &a.args {
746            if let GenericArgument::Type(t) = arg {
747                return Some(t.clone());
748            }
749        }
750    }
751    None
752}
753
754// ── Validation ──────────────────────────────────────────────────────────
755
756fn validate_collisions(fields: &[FieldSpec]) -> syn::Result<()> {
757    let mut seen_long: std::collections::HashMap<String, Span> = std::collections::HashMap::new();
758    let mut seen_short: std::collections::HashMap<char, Span> = std::collections::HashMap::new();
759
760    // Positionals: variadic-last, no-required-after-optional.
761    let mut seen_optional = false;
762    for f in fields {
763        if !matches!(f.kind, FieldKind::Arg) {
764            continue;
765        }
766        let is_optional = matches!(f.shape, TypeShape::Opt) || f.default.is_some() || f.variadic;
767        if seen_optional && !is_optional {
768            return Err(syn::Error::new(
769                f.span,
770                "required positional cannot follow an optional one",
771            ));
772        }
773        if is_optional {
774            seen_optional = true;
775        }
776    }
777    // Variadic may only be the last arg.
778    let mut saw_variadic = false;
779    for f in fields {
780        if !matches!(f.kind, FieldKind::Arg) {
781            continue;
782        }
783        if saw_variadic {
784            return Err(syn::Error::new(
785                f.span,
786                "variadic positional must be the last one",
787            ));
788        }
789        if f.variadic {
790            saw_variadic = true;
791        }
792    }
793
794    for f in fields {
795        if !matches!(f.kind, FieldKind::Option) {
796            continue;
797        }
798        let long = kebab(&f.ident.to_string());
799        if RESERVED_LONGS.contains(&long.as_str()) {
800            return Err(syn::Error::new(
801                f.span,
802                format!("--{long} shadows a reserved fdl-level flag"),
803            ));
804        }
805        if let Some(prev) = seen_long.insert(long.clone(), f.span) {
806            return Err(syn::Error::new(
807                f.span,
808                format!(
809                    "duplicate long flag --{long} (previously declared at {:?})",
810                    prev
811                ),
812            ));
813        }
814        if let Some(s) = f.short {
815            if RESERVED_SHORTS.contains(&s) {
816                return Err(syn::Error::new(
817                    f.span,
818                    format!("-{s} shadows a reserved fdl-level flag"),
819                ));
820            }
821            if let Some(prev) = seen_short.insert(s, f.span) {
822                return Err(syn::Error::new(
823                    f.span,
824                    format!("duplicate short -{s} (previously declared at {:?})", prev),
825                ));
826            }
827        }
828    }
829
830    Ok(())
831}
832
833// ── Code generators ─────────────────────────────────────────────────────
834
835fn build_spec_expr(fields: &[FieldSpec]) -> TokenStream2 {
836    let opts = fields
837        .iter()
838        .filter(|f| matches!(f.kind, FieldKind::Option))
839        .map(build_option_decl);
840    let positionals = fields
841        .iter()
842        .filter(|f| matches!(f.kind, FieldKind::Arg))
843        .map(build_positional_decl);
844
845    quote! {
846        ::flodl_cli::args::parser::ArgsSpec {
847            options: vec![ #( #opts ),* ],
848            positionals: vec![ #( #positionals ),* ],
849            // Derive-parsed CLIs are authoritative about their own
850            // surface — unknown flags are programmer errors, not
851            // legitimate pass-through. Stay strict.
852            lenient_unknowns: false,
853        }
854    }
855}
856
857fn build_option_decl(f: &FieldSpec) -> TokenStream2 {
858    let long = kebab(&f.ident.to_string());
859    let takes_value = !matches!(f.shape, TypeShape::Bool);
860    let allows_bare = match f.shape {
861        TypeShape::Bool => true,
862        _ => f.default.is_some(),
863    };
864    let repeatable = matches!(f.shape, TypeShape::List);
865    let short_expr = match f.short {
866        Some(c) => quote! { ::std::option::Option::Some(#c) },
867        None => quote! { ::std::option::Option::None },
868    };
869    let choices_expr = match &f.choices {
870        Some(list) => {
871            let elems = list.iter();
872            quote! { ::std::option::Option::Some(vec![ #( ::std::string::String::from(#elems) ),* ]) }
873        }
874        None => quote! { ::std::option::Option::None },
875    };
876
877    quote! {
878        ::flodl_cli::args::parser::OptionDecl {
879            long: ::std::string::String::from(#long),
880            short: #short_expr,
881            takes_value: #takes_value,
882            allows_bare: #allows_bare,
883            repeatable: #repeatable,
884            choices: #choices_expr,
885        }
886    }
887}
888
889fn build_positional_decl(f: &FieldSpec) -> TokenStream2 {
890    let name = kebab(&f.ident.to_string());
891    let required = matches!(f.shape, TypeShape::Scalar) && f.default.is_none() && !f.variadic;
892    let variadic = f.variadic;
893    let choices_expr = match &f.choices {
894        Some(list) => {
895            let elems = list.iter();
896            quote! { ::std::option::Option::Some(vec![ #( ::std::string::String::from(#elems) ),* ]) }
897        }
898        None => quote! { ::std::option::Option::None },
899    };
900    quote! {
901        ::flodl_cli::args::parser::PositionalDecl {
902            name: ::std::string::String::from(#name),
903            required: #required,
904            variadic: #variadic,
905            choices: #choices_expr,
906        }
907    }
908}
909
910fn build_schema_expr(fields: &[FieldSpec], description: Option<&str>) -> TokenStream2 {
911    let desc_expr = match description {
912        Some(d) => quote! { ::std::option::Option::Some(::std::string::String::from(#d)) },
913        None => quote! { ::std::option::Option::None },
914    };
915
916    let option_inserts = fields
917        .iter()
918        .filter(|f| matches!(f.kind, FieldKind::Option))
919        .map(|f| {
920            let long = kebab(&f.ident.to_string());
921            let ty = schema_type_str(f);
922            let desc_expr = match &f.description {
923                Some(d) => quote! { ::std::option::Option::Some(::std::string::String::from(#d)) },
924                None => quote! { ::std::option::Option::None },
925            };
926            let default_expr = match &f.default {
927                Some(v) => quote! { ::std::option::Option::Some(::flodl_cli::serde_json::Value::String(::std::string::String::from(#v))) },
928                None => quote! { ::std::option::Option::None },
929            };
930            let choices_expr = match &f.choices {
931                Some(list) => {
932                    let elems = list.iter();
933                    quote! {
934                        ::std::option::Option::Some(vec![
935                            #( ::flodl_cli::serde_json::Value::String(::std::string::String::from(#elems)) ),*
936                        ])
937                    }
938                }
939                None => quote! { ::std::option::Option::None },
940            };
941            let short_expr = match f.short {
942                Some(c) => {
943                    let cs = c.to_string();
944                    quote! { ::std::option::Option::Some(::std::string::String::from(#cs)) }
945                }
946                None => quote! { ::std::option::Option::None },
947            };
948            let env_expr = match &f.env {
949                Some(v) => quote! { ::std::option::Option::Some(::std::string::String::from(#v)) },
950                None => quote! { ::std::option::Option::None },
951            };
952            let completer_expr = match &f.completer {
953                Some(v) => quote! { ::std::option::Option::Some(::std::string::String::from(#v)) },
954                None => quote! { ::std::option::Option::None },
955            };
956            quote! {
957                options.insert(
958                    ::std::string::String::from(#long),
959                    ::flodl_cli::OptionSpec {
960                        ty: ::std::string::String::from(#ty),
961                        description: #desc_expr,
962                        default: #default_expr,
963                        choices: #choices_expr,
964                        short: #short_expr,
965                        env: #env_expr,
966                        completer: #completer_expr,
967                    },
968                );
969            }
970        });
971
972    let arg_pushes = fields
973        .iter()
974        .filter(|f| matches!(f.kind, FieldKind::Arg))
975        .map(|f| {
976            let name = kebab(&f.ident.to_string());
977            let ty = schema_type_str(f);
978            let desc_expr = match &f.description {
979                Some(d) => quote! { ::std::option::Option::Some(::std::string::String::from(#d)) },
980                None => quote! { ::std::option::Option::None },
981            };
982            let required = matches!(f.shape, TypeShape::Scalar) && f.default.is_none() && !f.variadic;
983            let variadic = f.variadic;
984            let default_expr = match &f.default {
985                Some(v) => quote! { ::std::option::Option::Some(::flodl_cli::serde_json::Value::String(::std::string::String::from(#v))) },
986                None => quote! { ::std::option::Option::None },
987            };
988            let choices_expr = match &f.choices {
989                Some(list) => {
990                    let elems = list.iter();
991                    quote! {
992                        ::std::option::Option::Some(vec![
993                            #( ::flodl_cli::serde_json::Value::String(::std::string::String::from(#elems)) ),*
994                        ])
995                    }
996                }
997                None => quote! { ::std::option::Option::None },
998            };
999            let completer_expr = match &f.completer {
1000                Some(v) => quote! { ::std::option::Option::Some(::std::string::String::from(#v)) },
1001                None => quote! { ::std::option::Option::None },
1002            };
1003            quote! {
1004                args.push(::flodl_cli::ArgSpec {
1005                    name: ::std::string::String::from(#name),
1006                    ty: ::std::string::String::from(#ty),
1007                    description: #desc_expr,
1008                    required: #required,
1009                    variadic: #variadic,
1010                    default: #default_expr,
1011                    choices: #choices_expr,
1012                    completer: #completer_expr,
1013                });
1014            }
1015        });
1016
1017    // `desc_expr` is retained for future use (Schema may grow a
1018    // description field). Bind it to `_` only when it has a concrete
1019    // type — interpolating a bare `Option::None` into `let _ = ...;`
1020    // leaves rustc unable to infer the type parameter (E0282) when
1021    // the caller's surrounding context doesn't pin it down, which
1022    // happened inside test modules.
1023    let _ = desc_expr;
1024    quote! {
1025        {
1026            let mut options: ::std::collections::BTreeMap<::std::string::String, ::flodl_cli::OptionSpec> =
1027                ::std::collections::BTreeMap::new();
1028            let mut args: ::std::vec::Vec<::flodl_cli::ArgSpec> = ::std::vec::Vec::new();
1029            #( #option_inserts )*
1030            #( #arg_pushes )*
1031            ::flodl_cli::Schema {
1032                args,
1033                options,
1034                strict: false,
1035                // A `#[derive(FdlArgs)]` struct is always a leaf — no
1036                // subcommand tree. The enum derive builds branches itself.
1037                description: ::std::option::Option::None,
1038                commands: ::std::collections::BTreeMap::new(),
1039            }
1040        }
1041    }
1042}
1043
1044fn schema_type_str(f: &FieldSpec) -> &'static str {
1045    let inner = inner_ty_name(&f.inner_ty);
1046    let base = match inner.as_str() {
1047        "bool" => "bool",
1048        "String" | "&str" => "string",
1049        "PathBuf" | "Path" => "path",
1050        "f32" | "f64" => "float",
1051        // Any integer-ish.
1052        "u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" => "int",
1053        _ => "string",
1054    };
1055    match f.shape {
1056        TypeShape::List => match base {
1057            "string" => "list[string]",
1058            "int" => "list[int]",
1059            "float" => "list[float]",
1060            "path" => "list[path]",
1061            _ => "list[string]",
1062        },
1063        TypeShape::Bool => "bool",
1064        _ => base,
1065    }
1066}
1067
1068fn inner_ty_name(ty: &Type) -> String {
1069    if let Type::Path(TypePath { path, .. }) = ty
1070        && let Some(seg) = path.segments.last()
1071    {
1072        return seg.ident.to_string();
1073    }
1074    String::from("_")
1075}
1076
1077/// Emit an argv pre-processing block that, for each `#[option(env = "...")]`
1078/// field absent from argv, appends `--<long> <value>` sourced from the named
1079/// environment variable. After this runs, the standard parser pipeline
1080/// handles the value exactly like an argv-supplied flag — choices, strict
1081/// unknowns, and `FromStr` all fire unchanged.
1082///
1083/// Precedence (highest wins): argv flag → env var → `default`. Empty env
1084/// vars fall through (consistent with `FDL_ENV` handling in `main.rs`).
1085/// Boolean fields are rejected at derive time elsewhere, so we never
1086/// inject `--foo` without a value.
1087fn build_env_injection(fields: &[FieldSpec]) -> TokenStream2 {
1088    let mut injections: Vec<TokenStream2> = Vec::new();
1089    for f in fields {
1090        let Some(env_name) = f.env.as_deref() else {
1091            continue;
1092        };
1093        // Positional args (#[arg]) don't have an env path in this MVP —
1094        // they're typically required and rarely env-driven. Skip them.
1095        if matches!(f.kind, FieldKind::Arg) {
1096            continue;
1097        }
1098        let long = kebab(&f.ident.to_string());
1099        let long_flag = format!("--{long}");
1100        let long_eq_prefix = format!("--{long}=");
1101        let short_tok = match &f.short {
1102            Some(c) => {
1103                let short_exact = format!("-{c}");
1104                quote! {
1105                    || a.as_str() == #short_exact
1106                }
1107            }
1108            None => quote! {},
1109        };
1110        injections.push(quote! {
1111            {
1112                let has_flag = __env_args.iter().any(|a: &::std::string::String| {
1113                    a.as_str() == #long_flag
1114                        || a.as_str().starts_with(#long_eq_prefix)
1115                        #short_tok
1116                });
1117                if !has_flag {
1118                    if let ::std::result::Result::Ok(v) = ::std::env::var(#env_name) {
1119                        if !v.is_empty() {
1120                            __env_args.push(::std::string::String::from(#long_flag));
1121                            __env_args.push(v);
1122                        }
1123                    }
1124                }
1125            }
1126        });
1127    }
1128    if injections.is_empty() {
1129        return quote! {};
1130    }
1131    quote! {
1132        let __env_args: ::std::vec::Vec<::std::string::String> = {
1133            let mut __env_args: ::std::vec::Vec<::std::string::String> = args.to_vec();
1134            #( #injections )*
1135            __env_args
1136        };
1137        let args: &[::std::string::String] = &__env_args[..];
1138    }
1139}
1140
1141fn build_extractor(ident: &Ident, fields: &[FieldSpec]) -> syn::Result<TokenStream2> {
1142    let mut field_inits: Vec<TokenStream2> = Vec::new();
1143    let mut positional_idx: usize = 0;
1144    for f in fields {
1145        match f.kind {
1146            FieldKind::Option => field_inits.push(option_extraction(f)),
1147            FieldKind::Arg => {
1148                field_inits.push(arg_extraction(f, positional_idx));
1149                if !f.variadic {
1150                    positional_idx += 1;
1151                }
1152            }
1153        }
1154    }
1155    let field_names: Vec<&Ident> = fields.iter().map(|f| &f.ident).collect();
1156    Ok(quote! {
1157        #( #field_inits )*
1158        ::std::result::Result::Ok(#ident {
1159            #( #field_names ),*
1160        })
1161    })
1162}
1163
1164fn option_extraction(f: &FieldSpec) -> TokenStream2 {
1165    let ident = &f.ident;
1166    let long = kebab(&ident.to_string());
1167    let inner_ty = &f.inner_ty;
1168    let span = ident.span();
1169    let parse_one = quote_spanned! { span =>
1170        |s: &::std::string::String| -> ::std::result::Result<#inner_ty, ::std::string::String> {
1171            <#inner_ty as ::std::str::FromStr>::from_str(s)
1172                .map_err(|e| format!("--{}: {}", #long, e))
1173        }
1174    };
1175
1176    match f.shape {
1177        TypeShape::Bool => quote! {
1178            let #ident: bool = matches!(
1179                parsed.options.get(#long),
1180                ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::BarePresent)
1181            );
1182        },
1183        TypeShape::Scalar => {
1184            // Must have a default (validated earlier).
1185            let default_lit = f.default.as_deref().unwrap();
1186            quote! {
1187                let #ident: #inner_ty = match parsed.options.get(#long) {
1188                    ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::WithValues(v)) => {
1189                        let s = &v[0];
1190                        (#parse_one)(s)?
1191                    }
1192                    _ => {
1193                        let s = ::std::string::String::from(#default_lit);
1194                        (#parse_one)(&s).expect("default value must parse")
1195                    }
1196                };
1197            }
1198        }
1199        TypeShape::Opt => {
1200            let default_tok = match &f.default {
1201                Some(v) => quote! { ::std::option::Option::Some({
1202                    let s = ::std::string::String::from(#v);
1203                    (#parse_one)(&s).expect("default value must parse")
1204                }) },
1205                None => quote! { ::std::option::Option::None },
1206            };
1207            quote! {
1208                let #ident: ::std::option::Option<#inner_ty> = match parsed.options.get(#long) {
1209                    ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::WithValues(v)) => {
1210                        ::std::option::Option::Some((#parse_one)(&v[0])?)
1211                    }
1212                    ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::BarePresent) => {
1213                        #default_tok
1214                    }
1215                    ::std::option::Option::None => ::std::option::Option::None,
1216                };
1217            }
1218        }
1219        TypeShape::List => quote! {
1220            let #ident: ::std::vec::Vec<#inner_ty> = match parsed.options.get(#long) {
1221                ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::WithValues(v)) => {
1222                    let mut out: ::std::vec::Vec<#inner_ty> = ::std::vec::Vec::with_capacity(v.len());
1223                    for s in v {
1224                        out.push((#parse_one)(s)?);
1225                    }
1226                    out
1227                }
1228                _ => ::std::vec::Vec::new(),
1229            };
1230        },
1231    }
1232}
1233
1234fn arg_extraction(f: &FieldSpec, idx: usize) -> TokenStream2 {
1235    let ident = &f.ident;
1236    let name = kebab(&ident.to_string());
1237    let inner_ty = &f.inner_ty;
1238    let span = ident.span();
1239    let parse_one = quote_spanned! { span =>
1240        |s: &::std::string::String| -> ::std::result::Result<#inner_ty, ::std::string::String> {
1241            <#inner_ty as ::std::str::FromStr>::from_str(s)
1242                .map_err(|e| format!("<{}>: {}", #name, e))
1243        }
1244    };
1245
1246    match f.shape {
1247        TypeShape::List if f.variadic => quote! {
1248            let #ident: ::std::vec::Vec<#inner_ty> = {
1249                let mut out: ::std::vec::Vec<#inner_ty> = ::std::vec::Vec::new();
1250                for s in &parsed.positionals[#idx..] {
1251                    out.push((#parse_one)(s)?);
1252                }
1253                out
1254            };
1255        },
1256        TypeShape::Opt => quote! {
1257            let #ident: ::std::option::Option<#inner_ty> = match parsed.positionals.get(#idx) {
1258                ::std::option::Option::Some(s) => ::std::option::Option::Some((#parse_one)(s)?),
1259                ::std::option::Option::None => ::std::option::Option::None,
1260            };
1261        },
1262        TypeShape::Scalar => {
1263            let default_tok = match &f.default {
1264                Some(v) => quote! {
1265                    {
1266                        let s = ::std::string::String::from(#v);
1267                        (#parse_one)(&s).expect("default value must parse")
1268                    }
1269                },
1270                None => quote! {
1271                    return ::std::result::Result::Err(
1272                        format!("missing required argument <{}>", #name)
1273                    )
1274                },
1275            };
1276            quote! {
1277                let #ident: #inner_ty = match parsed.positionals.get(#idx) {
1278                    ::std::option::Option::Some(s) => (#parse_one)(s)?,
1279                    ::std::option::Option::None => #default_tok,
1280                };
1281            }
1282        }
1283        _ => quote! {
1284            compile_error!("unsupported positional type shape");
1285        },
1286    }
1287}
1288
1289fn build_help_expr(
1290    fields: &[FieldSpec],
1291    description: Option<&str>,
1292    struct_name: &str,
1293) -> TokenStream2 {
1294    // Prefer the doc-comment description as the banner; fall back to the
1295    // struct ident only when no description is present. The struct name is
1296    // an implementation detail that users shouldn't see in `--help`.
1297    let header = match description {
1298        Some(d) => format!("{d}\n\n"),
1299        None => format!("{struct_name}\n\n"),
1300    };
1301
1302    // The help is assembled at runtime so `::flodl_cli::style::*` can check
1303    // whether stderr is a terminal — piped output stays plain, interactive
1304    // output gets ANSI color to match the hand-rolled helps in run.rs.
1305    // Padding is computed at macro-expand time from the raw label widths;
1306    // ANSI escapes are zero-width on terminal and don't affect alignment
1307    // because they're injected between the label and its trailing spaces.
1308
1309    let mut arg_tokens: Vec<TokenStream2> = Vec::new();
1310    let mut opt_tokens: Vec<TokenStream2> = Vec::new();
1311
1312    for f in fields {
1313        match f.kind {
1314            FieldKind::Option => {
1315                let long = kebab(&f.ident.to_string());
1316                let short_prefix = match f.short {
1317                    Some(c) => format!("-{c}, "),
1318                    None => String::from("    "),
1319                };
1320                let value_part = match f.shape {
1321                    TypeShape::Bool => String::new(),
1322                    TypeShape::List => String::from(" <VALUE>..."),
1323                    _ => format!(" <{}>", value_token(f)),
1324                };
1325                let label = format!("{short_prefix}--{long}{value_part}");
1326                let pad = " ".repeat(36usize.saturating_sub(4 + label.chars().count()));
1327                let mut tail = String::new();
1328                if let Some(d) = &f.description {
1329                    tail.push_str(d);
1330                }
1331                if let Some(d) = &f.default {
1332                    tail.push_str(&format!("  [default: {d}]"));
1333                }
1334                if let Some(choices) = &f.choices {
1335                    tail.push_str(&format!("  [possible: {}]", choices.join(", ")));
1336                }
1337                opt_tokens.push(quote! {
1338                    out.push_str("    ");
1339                    out.push_str(&::flodl_cli::style::green(#label));
1340                    out.push_str(#pad);
1341                    out.push_str(#tail);
1342                    out.push('\n');
1343                });
1344            }
1345            FieldKind::Arg => {
1346                let name = kebab(&f.ident.to_string());
1347                let required = matches!(f.shape, TypeShape::Scalar) && f.default.is_none();
1348                let label = if f.variadic {
1349                    format!("<{name}>...")
1350                } else if required {
1351                    format!("<{name}>")
1352                } else {
1353                    format!("[<{name}>]")
1354                };
1355                let pad = " ".repeat(36usize.saturating_sub(4 + label.chars().count()));
1356                let mut tail = String::new();
1357                if let Some(d) = &f.description {
1358                    tail.push_str(d);
1359                }
1360                if let Some(d) = &f.default {
1361                    tail.push_str(&format!("  [default: {d}]"));
1362                }
1363                arg_tokens.push(quote! {
1364                    out.push_str("    ");
1365                    out.push_str(&::flodl_cli::style::green(#label));
1366                    out.push_str(#pad);
1367                    out.push_str(#tail);
1368                    out.push('\n');
1369                });
1370            }
1371        }
1372    }
1373
1374    let arg_section = if arg_tokens.is_empty() {
1375        quote! {}
1376    } else {
1377        quote! {
1378            out.push_str(&::flodl_cli::style::yellow("Arguments"));
1379            out.push_str(":\n");
1380            #(#arg_tokens)*
1381            out.push('\n');
1382        }
1383    };
1384    let opt_section = if opt_tokens.is_empty() {
1385        quote! {}
1386    } else {
1387        quote! {
1388            out.push_str(&::flodl_cli::style::yellow("Options"));
1389            out.push_str(":\n");
1390            #(#opt_tokens)*
1391            out.push('\n');
1392        }
1393    };
1394
1395    quote! {
1396        {
1397            let mut out = ::std::string::String::from(#header);
1398            #arg_section
1399            #opt_section
1400            out
1401        }
1402    }
1403}
1404
1405fn value_token(f: &FieldSpec) -> &'static str {
1406    let inner = inner_ty_name(&f.inner_ty);
1407    match inner.as_str() {
1408        "u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" => "N",
1409        "f32" | "f64" => "F",
1410        "PathBuf" | "Path" => "PATH",
1411        _ => "VALUE",
1412    }
1413}
1414
1415// ── Utilities ───────────────────────────────────────────────────────────
1416
1417fn extract_doc(attrs: &[Attribute]) -> Option<String> {
1418    let mut lines: Vec<String> = Vec::new();
1419    for a in attrs {
1420        if !a.path().is_ident("doc") {
1421            continue;
1422        }
1423        if let syn::Meta::NameValue(nv) = &a.meta
1424            && let Expr::Lit(ExprLit {
1425                lit: Lit::Str(s), ..
1426            }) = &nv.value
1427        {
1428            let text = s.value();
1429            lines.push(text.trim().to_string());
1430        }
1431    }
1432    if lines.is_empty() {
1433        return None;
1434    }
1435    // Join lines with a space; collapse internal whitespace runs.
1436    let joined = lines
1437        .join(" ")
1438        .split_whitespace()
1439        .collect::<Vec<_>>()
1440        .join(" ");
1441    if joined.is_empty() {
1442        None
1443    } else {
1444        Some(joined)
1445    }
1446}
1447
1448fn kebab(s: &str) -> String {
1449    s.replace('_', "-")
1450}
1451
1452/// PascalCase enum variant ident → kebab-case subcommand name.
1453/// `Train` → `train`, `TrainSubscan` → `train-subscan`,
1454/// `EvalLetterDirect` → `eval-letter-direct`. A leading capital does not
1455/// get a separator; underscores are also treated as boundaries.
1456fn pascal_to_kebab(s: &str) -> String {
1457    let mut out = String::with_capacity(s.len() + 4);
1458    for (i, c) in s.chars().enumerate() {
1459        if c == '_' {
1460            out.push('-');
1461        } else if c.is_uppercase() {
1462            if i != 0 && !out.ends_with('-') {
1463                out.push('-');
1464            }
1465            out.extend(c.to_lowercase());
1466        } else {
1467            out.push(c);
1468        }
1469    }
1470    out
1471}
1472
1473// syn's Span import trick: pull from proc_macro2 above.
1474use syn::spanned::Spanned;
1475
1476#[cfg(test)]
1477mod tests {
1478    use super::pascal_to_kebab;
1479
1480    #[test]
1481    fn pascal_to_kebab_maps_variant_idents() {
1482        assert_eq!(pascal_to_kebab("Train"), "train");
1483        assert_eq!(pascal_to_kebab("Eval"), "eval");
1484        // The fbrl `word` modes — the generalization-stressing cases.
1485        assert_eq!(pascal_to_kebab("TrainSubscan"), "train-subscan");
1486        assert_eq!(pascal_to_kebab("EvalSubscan"), "eval-subscan");
1487        assert_eq!(pascal_to_kebab("EvalLetterDirect"), "eval-letter-direct");
1488        // Underscores are boundaries too; no double separators.
1489        assert_eq!(pascal_to_kebab("Train_Subscan"), "train-subscan");
1490        assert_eq!(pascal_to_kebab("Generate"), "generate");
1491    }
1492}