1use 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
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.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 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 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
580fn 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(()); }
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 *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 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
705fn 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
740fn 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 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 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
819fn 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 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 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 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 "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
1063fn 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 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 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 let header = match description {
1280 Some(d) => format!("{d}\n\n"),
1281 None => format!("{struct_name}\n\n"),
1282 };
1283
1284 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
1397fn 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 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
1428fn 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
1449use 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 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 assert_eq!(pascal_to_kebab("Train_Subscan"), "train-subscan");
1466 assert_eq!(pascal_to_kebab("Generate"), "generate");
1467 }
1468}