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    parse_macro_input, Attribute, Data, DeriveInput, Expr, ExprLit, Fields, GenericArgument,
119    Ident, Lit, PathArguments, Type, TypePath,
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.ident.clone().ok_or_else(|| {
469        syn::Error::new_spanned(f, "FdlArgs requires named fields")
470    })?;
471    let description = extract_doc(&f.attrs);
472    let (shape, inner_ty) = classify_type(&f.ty);
473
474    // Exactly one of #[option] / #[arg] must be present (plain fields
475    // are NOT auto-treated as options in this MVP — explicit is better
476    // while the contract settles).
477    let mut kind: Option<FieldKind> = None;
478    let mut short: Option<char> = None;
479    let mut default: Option<String> = None;
480    let mut choices: Option<Vec<String>> = None;
481    let mut env: Option<String> = None;
482    let mut completer: Option<String> = None;
483    let mut variadic = false;
484
485    for attr in &f.attrs {
486        if attr.path().is_ident("option") {
487            if kind.is_some() {
488                return Err(syn::Error::new_spanned(
489                    attr,
490                    "field cannot have both #[option] and #[arg]",
491                ));
492            }
493            kind = Some(FieldKind::Option);
494            parse_option_attr(attr, &mut short, &mut default, &mut choices, &mut env, &mut completer)?;
495        } else if attr.path().is_ident("arg") {
496            if kind.is_some() {
497                return Err(syn::Error::new_spanned(
498                    attr,
499                    "field cannot have both #[option] and #[arg]",
500                ));
501            }
502            kind = Some(FieldKind::Arg);
503            parse_arg_attr(attr, &mut default, &mut choices, &mut variadic, &mut completer)?;
504        }
505    }
506
507    let kind = kind.ok_or_else(|| {
508        syn::Error::new_spanned(
509            &ident,
510            "field must carry either #[option] or #[arg]",
511        )
512    })?;
513
514    // Type + kind + attrs consistency checks.
515    match kind {
516        FieldKind::Option => {
517            if matches!(shape, TypeShape::Bool) && default.is_some() {
518                return Err(syn::Error::new_spanned(
519                    &f.ty,
520                    "#[option(default = ...)] is meaningless on a bool flag (absent=false, present=true)",
521                ));
522            }
523            if matches!(shape, TypeShape::Bool) && env.is_some() {
524                return Err(syn::Error::new_spanned(
525                    &f.ty,
526                    "#[option(env = ...)] is not supported on bare `bool` (truthy/falsy string semantics are ambiguous) — use `Option<bool>` if you need env fallback",
527                ));
528            }
529            if matches!(shape, TypeShape::Scalar) && default.is_none() && !matches!(shape, TypeShape::Bool) {
530                return Err(syn::Error::new_spanned(
531                    &f.ty,
532                    "#[option] on a non-Option, non-bool type requires `default = \"...\"` (the field must always have a value)",
533                ));
534            }
535            if variadic {
536                return Err(syn::Error::new_spanned(
537                    &ident,
538                    "`variadic` only applies to #[arg], not #[option]",
539                ));
540            }
541        }
542        FieldKind::Arg => {
543            if matches!(shape, TypeShape::Bool) {
544                return Err(syn::Error::new_spanned(
545                    &f.ty,
546                    "positional #[arg] cannot be a bool (positionals always carry a value)",
547                ));
548            }
549            if short.is_some() {
550                return Err(syn::Error::new_spanned(
551                    &ident,
552                    "`short` cannot be used on #[arg] (positionals have no short form)",
553                ));
554            }
555            if variadic && !matches!(shape, TypeShape::List) {
556                return Err(syn::Error::new_spanned(
557                    &f.ty,
558                    "#[arg(variadic)] requires a Vec<T> field",
559                ));
560            }
561        }
562    }
563
564    Ok(FieldSpec {
565        ident,
566        kind,
567        shape,
568        inner_ty,
569        description,
570        short,
571        default,
572        choices,
573        env,
574        completer,
575        variadic,
576        span: f.span(),
577    })
578}
579
580// ── Attribute parsing ───────────────────────────────────────────────────
581
582fn parse_option_attr(
583    attr: &Attribute,
584    short: &mut Option<char>,
585    default: &mut Option<String>,
586    choices: &mut Option<Vec<String>>,
587    env: &mut Option<String>,
588    completer: &mut Option<String>,
589) -> syn::Result<()> {
590    if matches!(attr.meta, syn::Meta::Path(_)) {
591        return Ok(()); // bare #[option]
592    }
593    attr.parse_nested_meta(|meta| {
594        let key = meta
595            .path
596            .get_ident()
597            .ok_or_else(|| meta.error("expected identifier key in #[option]"))?;
598        match key.to_string().as_str() {
599            "short" => {
600                let v: syn::LitChar = meta.value()?.parse()?;
601                *short = Some(v.value());
602            }
603            "default" => {
604                let v: syn::LitStr = meta.value()?.parse()?;
605                *default = Some(v.value());
606            }
607            "choices" => {
608                *choices = Some(parse_choices(&meta)?);
609            }
610            "env" => {
611                let v: syn::LitStr = meta.value()?.parse()?;
612                *env = Some(v.value());
613            }
614            "completer" => {
615                let v: syn::LitStr = meta.value()?.parse()?;
616                *completer = Some(v.value());
617            }
618            other => {
619                return Err(meta.error(format!(
620                    "unknown #[option] attribute `{other}` (valid: short, default, choices, env, completer)"
621                )));
622            }
623        }
624        Ok(())
625    })
626}
627
628fn parse_arg_attr(
629    attr: &Attribute,
630    default: &mut Option<String>,
631    choices: &mut Option<Vec<String>>,
632    variadic: &mut bool,
633    completer: &mut Option<String>,
634) -> syn::Result<()> {
635    if matches!(attr.meta, syn::Meta::Path(_)) {
636        return Ok(());
637    }
638    attr.parse_nested_meta(|meta| {
639        let key = meta
640            .path
641            .get_ident()
642            .ok_or_else(|| meta.error("expected identifier key in #[arg]"))?;
643        match key.to_string().as_str() {
644            "default" => {
645                let v: syn::LitStr = meta.value()?.parse()?;
646                *default = Some(v.value());
647            }
648            "choices" => {
649                *choices = Some(parse_choices(&meta)?);
650            }
651            "variadic" => {
652                // Either `variadic` alone or `variadic = true`.
653                *variadic = true;
654                if meta.input.peek(syn::Token![=]) {
655                    let v: syn::LitBool = meta.value()?.parse()?;
656                    *variadic = v.value();
657                }
658            }
659            "completer" => {
660                let v: syn::LitStr = meta.value()?.parse()?;
661                *completer = Some(v.value());
662            }
663            other => {
664                return Err(meta.error(format!(
665                    "unknown #[arg] attribute `{other}` (valid: default, choices, variadic, completer)"
666                )));
667            }
668        }
669        Ok(())
670    })
671}
672
673fn parse_choices(meta: &syn::meta::ParseNestedMeta) -> syn::Result<Vec<String>> {
674    // Accept both `choices = &["a", "b"]` and `choices = ["a", "b"]`.
675    let expr: Expr = meta.value()?.parse()?;
676    let arr = match expr {
677        Expr::Reference(r) => *r.expr,
678        e => e,
679    };
680    match arr {
681        Expr::Array(arr) => {
682            let mut out = Vec::with_capacity(arr.elems.len());
683            for e in arr.elems {
684                if let Expr::Lit(ExprLit {
685                    lit: Lit::Str(s), ..
686                }) = e
687                {
688                    out.push(s.value());
689                } else {
690                    return Err(syn::Error::new_spanned(
691                        e,
692                        "choices must be string literals",
693                    ));
694                }
695            }
696            Ok(out)
697        }
698        other => Err(syn::Error::new_spanned(
699            other,
700            "choices must be an array literal, e.g. `&[\"a\", \"b\"]`",
701        )),
702    }
703}
704
705// ── Type classification ─────────────────────────────────────────────────
706
707fn classify_type(ty: &Type) -> (TypeShape, Type) {
708    if let Type::Path(TypePath { path, .. }) = ty {
709        if let Some(seg) = path.segments.last() {
710            let name = seg.ident.to_string();
711            if name == "bool" {
712                return (TypeShape::Bool, ty.clone());
713            }
714            if name == "Option" {
715                if let Some(inner) = first_generic(&seg.arguments) {
716                    return (TypeShape::Opt, inner);
717                }
718            }
719            if name == "Vec" {
720                if let Some(inner) = first_generic(&seg.arguments) {
721                    return (TypeShape::List, inner);
722                }
723            }
724        }
725    }
726    (TypeShape::Scalar, ty.clone())
727}
728
729fn first_generic(args: &PathArguments) -> Option<Type> {
730    if let PathArguments::AngleBracketed(a) = args {
731        for arg in &a.args {
732            if let GenericArgument::Type(t) = arg {
733                return Some(t.clone());
734            }
735        }
736    }
737    None
738}
739
740// ── Validation ──────────────────────────────────────────────────────────
741
742fn validate_collisions(fields: &[FieldSpec]) -> syn::Result<()> {
743    let mut seen_long: std::collections::HashMap<String, Span> =
744        std::collections::HashMap::new();
745    let mut seen_short: std::collections::HashMap<char, Span> =
746        std::collections::HashMap::new();
747
748    // Positionals: variadic-last, no-required-after-optional.
749    let mut seen_optional = false;
750    for f in fields {
751        if !matches!(f.kind, FieldKind::Arg) {
752            continue;
753        }
754        let is_optional =
755            matches!(f.shape, TypeShape::Opt) || f.default.is_some() || f.variadic;
756        if seen_optional && !is_optional {
757            return Err(syn::Error::new(
758                f.span,
759                "required positional cannot follow an optional one",
760            ));
761        }
762        if is_optional {
763            seen_optional = true;
764        }
765    }
766    // Variadic may only be the last arg.
767    let mut saw_variadic = false;
768    for f in fields {
769        if !matches!(f.kind, FieldKind::Arg) {
770            continue;
771        }
772        if saw_variadic {
773            return Err(syn::Error::new(
774                f.span,
775                "variadic positional must be the last one",
776            ));
777        }
778        if f.variadic {
779            saw_variadic = true;
780        }
781    }
782
783    for f in fields {
784        if !matches!(f.kind, FieldKind::Option) {
785            continue;
786        }
787        let long = kebab(&f.ident.to_string());
788        if RESERVED_LONGS.contains(&long.as_str()) {
789            return Err(syn::Error::new(
790                f.span,
791                format!("--{long} shadows a reserved fdl-level flag"),
792            ));
793        }
794        if let Some(prev) = seen_long.insert(long.clone(), f.span) {
795            return Err(syn::Error::new(
796                f.span,
797                format!("duplicate long flag --{long} (previously declared at {:?})", prev),
798            ));
799        }
800        if let Some(s) = f.short {
801            if RESERVED_SHORTS.contains(&s) {
802                return Err(syn::Error::new(
803                    f.span,
804                    format!("-{s} shadows a reserved fdl-level flag"),
805                ));
806            }
807            if let Some(prev) = seen_short.insert(s, f.span) {
808                return Err(syn::Error::new(
809                    f.span,
810                    format!("duplicate short -{s} (previously declared at {:?})", prev),
811                ));
812            }
813        }
814    }
815
816    Ok(())
817}
818
819// ── Code generators ─────────────────────────────────────────────────────
820
821fn build_spec_expr(fields: &[FieldSpec]) -> TokenStream2 {
822    let opts = fields
823        .iter()
824        .filter(|f| matches!(f.kind, FieldKind::Option))
825        .map(build_option_decl);
826    let positionals = fields
827        .iter()
828        .filter(|f| matches!(f.kind, FieldKind::Arg))
829        .map(build_positional_decl);
830
831    quote! {
832        ::flodl_cli::args::parser::ArgsSpec {
833            options: vec![ #( #opts ),* ],
834            positionals: vec![ #( #positionals ),* ],
835            // Derive-parsed CLIs are authoritative about their own
836            // surface — unknown flags are programmer errors, not
837            // legitimate pass-through. Stay strict.
838            lenient_unknowns: false,
839        }
840    }
841}
842
843fn build_option_decl(f: &FieldSpec) -> TokenStream2 {
844    let long = kebab(&f.ident.to_string());
845    let takes_value = !matches!(f.shape, TypeShape::Bool);
846    let allows_bare = match f.shape {
847        TypeShape::Bool => true,
848        _ => f.default.is_some(),
849    };
850    let repeatable = matches!(f.shape, TypeShape::List);
851    let short_expr = match f.short {
852        Some(c) => quote! { ::std::option::Option::Some(#c) },
853        None => quote! { ::std::option::Option::None },
854    };
855    let choices_expr = match &f.choices {
856        Some(list) => {
857            let elems = list.iter();
858            quote! { ::std::option::Option::Some(vec![ #( ::std::string::String::from(#elems) ),* ]) }
859        }
860        None => quote! { ::std::option::Option::None },
861    };
862
863    quote! {
864        ::flodl_cli::args::parser::OptionDecl {
865            long: ::std::string::String::from(#long),
866            short: #short_expr,
867            takes_value: #takes_value,
868            allows_bare: #allows_bare,
869            repeatable: #repeatable,
870            choices: #choices_expr,
871        }
872    }
873}
874
875fn build_positional_decl(f: &FieldSpec) -> TokenStream2 {
876    let name = kebab(&f.ident.to_string());
877    let required = matches!(f.shape, TypeShape::Scalar) && f.default.is_none() && !f.variadic;
878    let variadic = f.variadic;
879    let choices_expr = match &f.choices {
880        Some(list) => {
881            let elems = list.iter();
882            quote! { ::std::option::Option::Some(vec![ #( ::std::string::String::from(#elems) ),* ]) }
883        }
884        None => quote! { ::std::option::Option::None },
885    };
886    quote! {
887        ::flodl_cli::args::parser::PositionalDecl {
888            name: ::std::string::String::from(#name),
889            required: #required,
890            variadic: #variadic,
891            choices: #choices_expr,
892        }
893    }
894}
895
896fn build_schema_expr(fields: &[FieldSpec], description: Option<&str>) -> TokenStream2 {
897    let desc_expr = match description {
898        Some(d) => quote! { ::std::option::Option::Some(::std::string::String::from(#d)) },
899        None => quote! { ::std::option::Option::None },
900    };
901
902    let option_inserts = fields
903        .iter()
904        .filter(|f| matches!(f.kind, FieldKind::Option))
905        .map(|f| {
906            let long = kebab(&f.ident.to_string());
907            let ty = schema_type_str(f);
908            let desc_expr = match &f.description {
909                Some(d) => quote! { ::std::option::Option::Some(::std::string::String::from(#d)) },
910                None => quote! { ::std::option::Option::None },
911            };
912            let default_expr = match &f.default {
913                Some(v) => quote! { ::std::option::Option::Some(::flodl_cli::serde_json::Value::String(::std::string::String::from(#v))) },
914                None => quote! { ::std::option::Option::None },
915            };
916            let choices_expr = match &f.choices {
917                Some(list) => {
918                    let elems = list.iter();
919                    quote! {
920                        ::std::option::Option::Some(vec![
921                            #( ::flodl_cli::serde_json::Value::String(::std::string::String::from(#elems)) ),*
922                        ])
923                    }
924                }
925                None => quote! { ::std::option::Option::None },
926            };
927            let short_expr = match f.short {
928                Some(c) => {
929                    let cs = c.to_string();
930                    quote! { ::std::option::Option::Some(::std::string::String::from(#cs)) }
931                }
932                None => quote! { ::std::option::Option::None },
933            };
934            let env_expr = match &f.env {
935                Some(v) => quote! { ::std::option::Option::Some(::std::string::String::from(#v)) },
936                None => quote! { ::std::option::Option::None },
937            };
938            let completer_expr = match &f.completer {
939                Some(v) => quote! { ::std::option::Option::Some(::std::string::String::from(#v)) },
940                None => quote! { ::std::option::Option::None },
941            };
942            quote! {
943                options.insert(
944                    ::std::string::String::from(#long),
945                    ::flodl_cli::OptionSpec {
946                        ty: ::std::string::String::from(#ty),
947                        description: #desc_expr,
948                        default: #default_expr,
949                        choices: #choices_expr,
950                        short: #short_expr,
951                        env: #env_expr,
952                        completer: #completer_expr,
953                    },
954                );
955            }
956        });
957
958    let arg_pushes = fields
959        .iter()
960        .filter(|f| matches!(f.kind, FieldKind::Arg))
961        .map(|f| {
962            let name = kebab(&f.ident.to_string());
963            let ty = schema_type_str(f);
964            let desc_expr = match &f.description {
965                Some(d) => quote! { ::std::option::Option::Some(::std::string::String::from(#d)) },
966                None => quote! { ::std::option::Option::None },
967            };
968            let required = matches!(f.shape, TypeShape::Scalar) && f.default.is_none() && !f.variadic;
969            let variadic = f.variadic;
970            let default_expr = match &f.default {
971                Some(v) => quote! { ::std::option::Option::Some(::flodl_cli::serde_json::Value::String(::std::string::String::from(#v))) },
972                None => quote! { ::std::option::Option::None },
973            };
974            let choices_expr = match &f.choices {
975                Some(list) => {
976                    let elems = list.iter();
977                    quote! {
978                        ::std::option::Option::Some(vec![
979                            #( ::flodl_cli::serde_json::Value::String(::std::string::String::from(#elems)) ),*
980                        ])
981                    }
982                }
983                None => quote! { ::std::option::Option::None },
984            };
985            let completer_expr = match &f.completer {
986                Some(v) => quote! { ::std::option::Option::Some(::std::string::String::from(#v)) },
987                None => quote! { ::std::option::Option::None },
988            };
989            quote! {
990                args.push(::flodl_cli::ArgSpec {
991                    name: ::std::string::String::from(#name),
992                    ty: ::std::string::String::from(#ty),
993                    description: #desc_expr,
994                    required: #required,
995                    variadic: #variadic,
996                    default: #default_expr,
997                    choices: #choices_expr,
998                    completer: #completer_expr,
999                });
1000            }
1001        });
1002
1003    // `desc_expr` is retained for future use (Schema may grow a
1004    // description field). Bind it to `_` only when it has a concrete
1005    // type — interpolating a bare `Option::None` into `let _ = ...;`
1006    // leaves rustc unable to infer the type parameter (E0282) when
1007    // the caller's surrounding context doesn't pin it down, which
1008    // happened inside test modules.
1009    let _ = desc_expr;
1010    quote! {
1011        {
1012            let mut options: ::std::collections::BTreeMap<::std::string::String, ::flodl_cli::OptionSpec> =
1013                ::std::collections::BTreeMap::new();
1014            let mut args: ::std::vec::Vec<::flodl_cli::ArgSpec> = ::std::vec::Vec::new();
1015            #( #option_inserts )*
1016            #( #arg_pushes )*
1017            ::flodl_cli::Schema {
1018                args,
1019                options,
1020                strict: false,
1021                // A `#[derive(FdlArgs)]` struct is always a leaf — no
1022                // subcommand tree. The enum derive builds branches itself.
1023                description: ::std::option::Option::None,
1024                commands: ::std::collections::BTreeMap::new(),
1025            }
1026        }
1027    }
1028}
1029
1030fn schema_type_str(f: &FieldSpec) -> &'static str {
1031    let inner = inner_ty_name(&f.inner_ty);
1032    let base = match inner.as_str() {
1033        "bool" => "bool",
1034        "String" | "&str" => "string",
1035        "PathBuf" | "Path" => "path",
1036        "f32" | "f64" => "float",
1037        // Any integer-ish.
1038        "u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" => "int",
1039        _ => "string",
1040    };
1041    match f.shape {
1042        TypeShape::List => match base {
1043            "string" => "list[string]",
1044            "int" => "list[int]",
1045            "float" => "list[float]",
1046            "path" => "list[path]",
1047            _ => "list[string]",
1048        },
1049        TypeShape::Bool => "bool",
1050        _ => base,
1051    }
1052}
1053
1054fn inner_ty_name(ty: &Type) -> String {
1055    if let Type::Path(TypePath { path, .. }) = ty {
1056        if let Some(seg) = path.segments.last() {
1057            return seg.ident.to_string();
1058        }
1059    }
1060    String::from("_")
1061}
1062
1063/// Emit an argv pre-processing block that, for each `#[option(env = "...")]`
1064/// field absent from argv, appends `--<long> <value>` sourced from the named
1065/// environment variable. After this runs, the standard parser pipeline
1066/// handles the value exactly like an argv-supplied flag — choices, strict
1067/// unknowns, and `FromStr` all fire unchanged.
1068///
1069/// Precedence (highest wins): argv flag → env var → `default`. Empty env
1070/// vars fall through (consistent with `FDL_ENV` handling in `main.rs`).
1071/// Boolean fields are rejected at derive time elsewhere, so we never
1072/// inject `--foo` without a value.
1073fn build_env_injection(fields: &[FieldSpec]) -> TokenStream2 {
1074    let mut injections: Vec<TokenStream2> = Vec::new();
1075    for f in fields {
1076        let Some(env_name) = f.env.as_deref() else {
1077            continue;
1078        };
1079        // Positional args (#[arg]) don't have an env path in this MVP —
1080        // they're typically required and rarely env-driven. Skip them.
1081        if matches!(f.kind, FieldKind::Arg) {
1082            continue;
1083        }
1084        let long = kebab(&f.ident.to_string());
1085        let long_flag = format!("--{long}");
1086        let long_eq_prefix = format!("--{long}=");
1087        let short_tok = match &f.short {
1088            Some(c) => {
1089                let short_exact = format!("-{c}");
1090                quote! {
1091                    || a.as_str() == #short_exact
1092                }
1093            }
1094            None => quote! {},
1095        };
1096        injections.push(quote! {
1097            {
1098                let has_flag = __env_args.iter().any(|a: &::std::string::String| {
1099                    a.as_str() == #long_flag
1100                        || a.as_str().starts_with(#long_eq_prefix)
1101                        #short_tok
1102                });
1103                if !has_flag {
1104                    if let ::std::result::Result::Ok(v) = ::std::env::var(#env_name) {
1105                        if !v.is_empty() {
1106                            __env_args.push(::std::string::String::from(#long_flag));
1107                            __env_args.push(v);
1108                        }
1109                    }
1110                }
1111            }
1112        });
1113    }
1114    if injections.is_empty() {
1115        return quote! {};
1116    }
1117    quote! {
1118        let __env_args: ::std::vec::Vec<::std::string::String> = {
1119            let mut __env_args: ::std::vec::Vec<::std::string::String> = args.to_vec();
1120            #( #injections )*
1121            __env_args
1122        };
1123        let args: &[::std::string::String] = &__env_args[..];
1124    }
1125}
1126
1127fn build_extractor(ident: &Ident, fields: &[FieldSpec]) -> syn::Result<TokenStream2> {
1128    let mut field_inits: Vec<TokenStream2> = Vec::new();
1129    let mut positional_idx: usize = 0;
1130    for f in fields {
1131        match f.kind {
1132            FieldKind::Option => field_inits.push(option_extraction(f)),
1133            FieldKind::Arg => {
1134                field_inits.push(arg_extraction(f, positional_idx));
1135                if !f.variadic {
1136                    positional_idx += 1;
1137                }
1138            }
1139        }
1140    }
1141    let field_names: Vec<&Ident> = fields.iter().map(|f| &f.ident).collect();
1142    Ok(quote! {
1143        #( #field_inits )*
1144        ::std::result::Result::Ok(#ident {
1145            #( #field_names ),*
1146        })
1147    })
1148}
1149
1150fn option_extraction(f: &FieldSpec) -> TokenStream2 {
1151    let ident = &f.ident;
1152    let long = kebab(&ident.to_string());
1153    let inner_ty = &f.inner_ty;
1154    let span = ident.span();
1155    let parse_one = quote_spanned! { span =>
1156        |s: &::std::string::String| -> ::std::result::Result<#inner_ty, ::std::string::String> {
1157            <#inner_ty as ::std::str::FromStr>::from_str(s)
1158                .map_err(|e| format!("--{}: {}", #long, e))
1159        }
1160    };
1161
1162    match f.shape {
1163        TypeShape::Bool => quote! {
1164            let #ident: bool = matches!(
1165                parsed.options.get(#long),
1166                ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::BarePresent)
1167            );
1168        },
1169        TypeShape::Scalar => {
1170            // Must have a default (validated earlier).
1171            let default_lit = f.default.as_deref().unwrap();
1172            quote! {
1173                let #ident: #inner_ty = match parsed.options.get(#long) {
1174                    ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::WithValues(v)) => {
1175                        let s = &v[0];
1176                        (#parse_one)(s)?
1177                    }
1178                    _ => {
1179                        let s = ::std::string::String::from(#default_lit);
1180                        (#parse_one)(&s).expect("default value must parse")
1181                    }
1182                };
1183            }
1184        }
1185        TypeShape::Opt => {
1186            let default_tok = match &f.default {
1187                Some(v) => quote! { ::std::option::Option::Some({
1188                    let s = ::std::string::String::from(#v);
1189                    (#parse_one)(&s).expect("default value must parse")
1190                }) },
1191                None => quote! { ::std::option::Option::None },
1192            };
1193            quote! {
1194                let #ident: ::std::option::Option<#inner_ty> = match parsed.options.get(#long) {
1195                    ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::WithValues(v)) => {
1196                        ::std::option::Option::Some((#parse_one)(&v[0])?)
1197                    }
1198                    ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::BarePresent) => {
1199                        #default_tok
1200                    }
1201                    ::std::option::Option::None => ::std::option::Option::None,
1202                };
1203            }
1204        }
1205        TypeShape::List => quote! {
1206            let #ident: ::std::vec::Vec<#inner_ty> = match parsed.options.get(#long) {
1207                ::std::option::Option::Some(::flodl_cli::args::parser::OptionState::WithValues(v)) => {
1208                    let mut out: ::std::vec::Vec<#inner_ty> = ::std::vec::Vec::with_capacity(v.len());
1209                    for s in v {
1210                        out.push((#parse_one)(s)?);
1211                    }
1212                    out
1213                }
1214                _ => ::std::vec::Vec::new(),
1215            };
1216        },
1217    }
1218}
1219
1220fn arg_extraction(f: &FieldSpec, idx: usize) -> TokenStream2 {
1221    let ident = &f.ident;
1222    let name = kebab(&ident.to_string());
1223    let inner_ty = &f.inner_ty;
1224    let span = ident.span();
1225    let parse_one = quote_spanned! { span =>
1226        |s: &::std::string::String| -> ::std::result::Result<#inner_ty, ::std::string::String> {
1227            <#inner_ty as ::std::str::FromStr>::from_str(s)
1228                .map_err(|e| format!("<{}>: {}", #name, e))
1229        }
1230    };
1231
1232    match f.shape {
1233        TypeShape::List if f.variadic => quote! {
1234            let #ident: ::std::vec::Vec<#inner_ty> = {
1235                let mut out: ::std::vec::Vec<#inner_ty> = ::std::vec::Vec::new();
1236                for s in &parsed.positionals[#idx..] {
1237                    out.push((#parse_one)(s)?);
1238                }
1239                out
1240            };
1241        },
1242        TypeShape::Opt => quote! {
1243            let #ident: ::std::option::Option<#inner_ty> = match parsed.positionals.get(#idx) {
1244                ::std::option::Option::Some(s) => ::std::option::Option::Some((#parse_one)(s)?),
1245                ::std::option::Option::None => ::std::option::Option::None,
1246            };
1247        },
1248        TypeShape::Scalar => {
1249            let default_tok = match &f.default {
1250                Some(v) => quote! {
1251                    {
1252                        let s = ::std::string::String::from(#v);
1253                        (#parse_one)(&s).expect("default value must parse")
1254                    }
1255                },
1256                None => quote! {
1257                    return ::std::result::Result::Err(
1258                        format!("missing required argument <{}>", #name)
1259                    )
1260                },
1261            };
1262            quote! {
1263                let #ident: #inner_ty = match parsed.positionals.get(#idx) {
1264                    ::std::option::Option::Some(s) => (#parse_one)(s)?,
1265                    ::std::option::Option::None => #default_tok,
1266                };
1267            }
1268        }
1269        _ => quote! {
1270            compile_error!("unsupported positional type shape");
1271        },
1272    }
1273}
1274
1275fn build_help_expr(fields: &[FieldSpec], description: Option<&str>, struct_name: &str) -> TokenStream2 {
1276    // Prefer the doc-comment description as the banner; fall back to the
1277    // struct ident only when no description is present. The struct name is
1278    // an implementation detail that users shouldn't see in `--help`.
1279    let header = match description {
1280        Some(d) => format!("{d}\n\n"),
1281        None => format!("{struct_name}\n\n"),
1282    };
1283
1284    // The help is assembled at runtime so `::flodl_cli::style::*` can check
1285    // whether stderr is a terminal — piped output stays plain, interactive
1286    // output gets ANSI color to match the hand-rolled helps in run.rs.
1287    // Padding is computed at macro-expand time from the raw label widths;
1288    // ANSI escapes are zero-width on terminal and don't affect alignment
1289    // because they're injected between the label and its trailing spaces.
1290
1291    let mut arg_tokens: Vec<TokenStream2> = Vec::new();
1292    let mut opt_tokens: Vec<TokenStream2> = Vec::new();
1293
1294    for f in fields {
1295        match f.kind {
1296            FieldKind::Option => {
1297                let long = kebab(&f.ident.to_string());
1298                let short_prefix = match f.short {
1299                    Some(c) => format!("-{c}, "),
1300                    None => String::from("    "),
1301                };
1302                let value_part = match f.shape {
1303                    TypeShape::Bool => String::new(),
1304                    TypeShape::List => String::from(" <VALUE>..."),
1305                    _ => format!(" <{}>", value_token(f)),
1306                };
1307                let label = format!("{short_prefix}--{long}{value_part}");
1308                let pad = " ".repeat(36usize.saturating_sub(4 + label.chars().count()));
1309                let mut tail = String::new();
1310                if let Some(d) = &f.description {
1311                    tail.push_str(d);
1312                }
1313                if let Some(d) = &f.default {
1314                    tail.push_str(&format!("  [default: {d}]"));
1315                }
1316                if let Some(choices) = &f.choices {
1317                    tail.push_str(&format!("  [possible: {}]", choices.join(", ")));
1318                }
1319                opt_tokens.push(quote! {
1320                    out.push_str("    ");
1321                    out.push_str(&::flodl_cli::style::green(#label));
1322                    out.push_str(#pad);
1323                    out.push_str(#tail);
1324                    out.push('\n');
1325                });
1326            }
1327            FieldKind::Arg => {
1328                let name = kebab(&f.ident.to_string());
1329                let required = matches!(f.shape, TypeShape::Scalar) && f.default.is_none();
1330                let label = if f.variadic {
1331                    format!("<{name}>...")
1332                } else if required {
1333                    format!("<{name}>")
1334                } else {
1335                    format!("[<{name}>]")
1336                };
1337                let pad = " ".repeat(36usize.saturating_sub(4 + label.chars().count()));
1338                let mut tail = String::new();
1339                if let Some(d) = &f.description {
1340                    tail.push_str(d);
1341                }
1342                if let Some(d) = &f.default {
1343                    tail.push_str(&format!("  [default: {d}]"));
1344                }
1345                arg_tokens.push(quote! {
1346                    out.push_str("    ");
1347                    out.push_str(&::flodl_cli::style::green(#label));
1348                    out.push_str(#pad);
1349                    out.push_str(#tail);
1350                    out.push('\n');
1351                });
1352            }
1353        }
1354    }
1355
1356    let arg_section = if arg_tokens.is_empty() {
1357        quote! {}
1358    } else {
1359        quote! {
1360            out.push_str(&::flodl_cli::style::yellow("Arguments"));
1361            out.push_str(":\n");
1362            #(#arg_tokens)*
1363            out.push('\n');
1364        }
1365    };
1366    let opt_section = if opt_tokens.is_empty() {
1367        quote! {}
1368    } else {
1369        quote! {
1370            out.push_str(&::flodl_cli::style::yellow("Options"));
1371            out.push_str(":\n");
1372            #(#opt_tokens)*
1373            out.push('\n');
1374        }
1375    };
1376
1377    quote! {
1378        {
1379            let mut out = ::std::string::String::from(#header);
1380            #arg_section
1381            #opt_section
1382            out
1383        }
1384    }
1385}
1386
1387fn value_token(f: &FieldSpec) -> &'static str {
1388    let inner = inner_ty_name(&f.inner_ty);
1389    match inner.as_str() {
1390        "u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" => "N",
1391        "f32" | "f64" => "F",
1392        "PathBuf" | "Path" => "PATH",
1393        _ => "VALUE",
1394    }
1395}
1396
1397// ── Utilities ───────────────────────────────────────────────────────────
1398
1399fn extract_doc(attrs: &[Attribute]) -> Option<String> {
1400    let mut lines: Vec<String> = Vec::new();
1401    for a in attrs {
1402        if !a.path().is_ident("doc") {
1403            continue;
1404        }
1405        if let syn::Meta::NameValue(nv) = &a.meta {
1406            if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = &nv.value {
1407                let text = s.value();
1408                lines.push(text.trim().to_string());
1409            }
1410        }
1411    }
1412    if lines.is_empty() {
1413        return None;
1414    }
1415    // Join lines with a space; collapse internal whitespace runs.
1416    let joined = lines.join(" ").split_whitespace().collect::<Vec<_>>().join(" ");
1417    if joined.is_empty() {
1418        None
1419    } else {
1420        Some(joined)
1421    }
1422}
1423
1424fn kebab(s: &str) -> String {
1425    s.replace('_', "-")
1426}
1427
1428/// PascalCase enum variant ident → kebab-case subcommand name.
1429/// `Train` → `train`, `TrainSubscan` → `train-subscan`,
1430/// `EvalLetterDirect` → `eval-letter-direct`. A leading capital does not
1431/// get a separator; underscores are also treated as boundaries.
1432fn pascal_to_kebab(s: &str) -> String {
1433    let mut out = String::with_capacity(s.len() + 4);
1434    for (i, c) in s.chars().enumerate() {
1435        if c == '_' {
1436            out.push('-');
1437        } else if c.is_uppercase() {
1438            if i != 0 && !out.ends_with('-') {
1439                out.push('-');
1440            }
1441            out.extend(c.to_lowercase());
1442        } else {
1443            out.push(c);
1444        }
1445    }
1446    out
1447}
1448
1449// syn's Span import trick: pull from proc_macro2 above.
1450use syn::spanned::Spanned;
1451
1452#[cfg(test)]
1453mod tests {
1454    use super::pascal_to_kebab;
1455
1456    #[test]
1457    fn pascal_to_kebab_maps_variant_idents() {
1458        assert_eq!(pascal_to_kebab("Train"), "train");
1459        assert_eq!(pascal_to_kebab("Eval"), "eval");
1460        // The fbrl `word` modes — the generalization-stressing cases.
1461        assert_eq!(pascal_to_kebab("TrainSubscan"), "train-subscan");
1462        assert_eq!(pascal_to_kebab("EvalSubscan"), "eval-subscan");
1463        assert_eq!(pascal_to_kebab("EvalLetterDirect"), "eval-letter-direct");
1464        // Underscores are boundaries too; no double separators.
1465        assert_eq!(pascal_to_kebab("Train_Subscan"), "train-subscan");
1466        assert_eq!(pascal_to_kebab("Generate"), "generate");
1467    }
1468}