1use 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
122const RESERVED_LONGS: &[&str] = &["help", "version", "quiet", "env"];
125const RESERVED_SHORTS: &[char] = &['h', 'V', 'q', 'v', 'e'];
126
127#[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 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
207struct VariantSpec {
211 ident: Ident,
213 name: String,
216 inner_ty: Type,
219 description: Option<String>,
221}
222
223fn 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 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 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 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 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 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 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
399fn 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#[derive(Clone)]
432enum FieldKind {
433 Option,
434 Arg,
435}
436
437#[derive(Clone)]
438enum TypeShape {
439 Bool,
441 Scalar,
443 Opt,
445 List,
447}
448
449#[derive(Clone)]
450struct FieldSpec {
451 ident: Ident,
452 kind: FieldKind,
453 shape: TypeShape,
454 inner_ty: Type,
456 description: Option<String>,
457 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 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 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
594fn 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(()); }
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 *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 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
719fn 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
754fn 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 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 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
833fn 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 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 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 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 "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
1077fn 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 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 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 let header = match description {
1298 Some(d) => format!("{d}\n\n"),
1299 None => format!("{struct_name}\n\n"),
1300 };
1301
1302 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
1415fn 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 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
1452fn 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
1473use 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 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 assert_eq!(pascal_to_kebab("Train_Subscan"), "train-subscan");
1490 assert_eq!(pascal_to_kebab("Generate"), "generate");
1491 }
1492}