use proc_macro::TokenStream;
use proc_macro2::{Span, TokenStream as TokenStream2};
use quote::{quote, quote_spanned};
use syn::{
parse_macro_input, Attribute, Data, DeriveInput, Expr, ExprLit, Fields, GenericArgument,
Ident, Lit, PathArguments, Type, TypePath,
};
const RESERVED_LONGS: &[&str] = &["help", "version", "quiet", "env"];
const RESERVED_SHORTS: &[char] = &['h', 'V', 'q', 'v', 'e'];
#[proc_macro_derive(FdlArgs, attributes(option, arg))]
pub fn derive_fdl_args(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match impl_derive(input) {
Ok(ts) => ts,
Err(e) => e.to_compile_error().into(),
}
}
fn impl_derive(input: DeriveInput) -> syn::Result<TokenStream> {
let ident = &input.ident;
let description = extract_doc(&input.attrs);
let fields = match &input.data {
Data::Struct(s) => match &s.fields {
Fields::Named(n) => &n.named,
_ => {
return Err(syn::Error::new_spanned(
ident,
"FdlArgs requires a struct with named fields",
));
}
},
_ => {
return Err(syn::Error::new_spanned(
ident,
"FdlArgs requires a struct",
));
}
};
let mut parsed: Vec<FieldSpec> = Vec::new();
for f in fields {
parsed.push(parse_field(f)?);
}
validate_collisions(&parsed)?;
let spec_build = build_spec_expr(&parsed);
let schema_build = build_schema_expr(&parsed, description.as_deref());
let extract = build_extractor(ident, &parsed)?;
let render_help = build_help_expr(&parsed, description.as_deref(), &ident.to_string());
let env_injection = build_env_injection(&parsed);
let expanded = quote! {
impl ::flodl_cli::FdlArgsTrait for #ident {
fn try_parse_from(args: &[::std::string::String])
-> ::std::result::Result<Self, ::std::string::String>
{
let spec = #spec_build;
#env_injection
let parsed = ::flodl_cli::args::parser::parse(&spec, args)?;
#extract
}
fn schema() -> ::flodl_cli::Schema {
#schema_build
}
fn render_help() -> ::std::string::String {
#render_help
}
}
};
Ok(expanded.into())
}
#[derive(Clone)]
enum FieldKind {
Option,
Arg,
}
#[derive(Clone)]
enum TypeShape {
Bool,
Scalar,
Opt,
List,
}
#[derive(Clone)]
struct FieldSpec {
ident: Ident,
kind: FieldKind,
shape: TypeShape,
inner_ty: Type,
description: Option<String>,
short: Option<char>,
default: Option<String>,
choices: Option<Vec<String>>,
env: Option<String>,
completer: Option<String>,
variadic: bool,
span: Span,
}
fn parse_field(f: &syn::Field) -> syn::Result<FieldSpec> {
let ident = f.ident.clone().ok_or_else(|| {
syn::Error::new_spanned(f, "FdlArgs requires named fields")
})?;
let description = extract_doc(&f.attrs);
let (shape, inner_ty) = classify_type(&f.ty);
let mut kind: Option<FieldKind> = None;
let mut short: Option<char> = None;
let mut default: Option<String> = None;
let mut choices: Option<Vec<String>> = None;
let mut env: Option<String> = None;
let mut completer: Option<String> = None;
let mut variadic = false;
for attr in &f.attrs {
if attr.path().is_ident("option") {
if kind.is_some() {
return Err(syn::Error::new_spanned(
attr,
"field cannot have both #[option] and #[arg]",
));
}
kind = Some(FieldKind::Option);
parse_option_attr(attr, &mut short, &mut default, &mut choices, &mut env, &mut completer)?;
} else if attr.path().is_ident("arg") {
if kind.is_some() {
return Err(syn::Error::new_spanned(
attr,
"field cannot have both #[option] and #[arg]",
));
}
kind = Some(FieldKind::Arg);
parse_arg_attr(attr, &mut default, &mut choices, &mut variadic, &mut completer)?;
}
}
let kind = kind.ok_or_else(|| {
syn::Error::new_spanned(
&ident,
"field must carry either #[option] or #[arg]",
)
})?;
match kind {
FieldKind::Option => {
if matches!(shape, TypeShape::Bool) && default.is_some() {
return Err(syn::Error::new_spanned(
&f.ty,
"#[option(default = ...)] is meaningless on a bool flag (absent=false, present=true)",
));
}
if matches!(shape, TypeShape::Bool) && env.is_some() {
return Err(syn::Error::new_spanned(
&f.ty,
"#[option(env = ...)] is not supported on bare `bool` (truthy/falsy string semantics are ambiguous) — use `Option<bool>` if you need env fallback",
));
}
if matches!(shape, TypeShape::Scalar) && default.is_none() && !matches!(shape, TypeShape::Bool) {
return Err(syn::Error::new_spanned(
&f.ty,
"#[option] on a non-Option, non-bool type requires `default = \"...\"` (the field must always have a value)",
));
}
if variadic {
return Err(syn::Error::new_spanned(
&ident,
"`variadic` only applies to #[arg], not #[option]",
));
}
}
FieldKind::Arg => {
if matches!(shape, TypeShape::Bool) {
return Err(syn::Error::new_spanned(
&f.ty,
"positional #[arg] cannot be a bool (positionals always carry a value)",
));
}
if short.is_some() {
return Err(syn::Error::new_spanned(
&ident,
"`short` cannot be used on #[arg] (positionals have no short form)",
));
}
if variadic && !matches!(shape, TypeShape::List) {
return Err(syn::Error::new_spanned(
&f.ty,
"#[arg(variadic)] requires a Vec<T> field",
));
}
}
}
Ok(FieldSpec {
ident,
kind,
shape,
inner_ty,
description,
short,
default,
choices,
env,
completer,
variadic,
span: f.span(),
})
}
fn parse_option_attr(
attr: &Attribute,
short: &mut Option<char>,
default: &mut Option<String>,
choices: &mut Option<Vec<String>>,
env: &mut Option<String>,
completer: &mut Option<String>,
) -> syn::Result<()> {
if matches!(attr.meta, syn::Meta::Path(_)) {
return Ok(()); }
attr.parse_nested_meta(|meta| {
let key = meta
.path
.get_ident()
.ok_or_else(|| meta.error("expected identifier key in #[option]"))?;
match key.to_string().as_str() {
"short" => {
let v: syn::LitChar = meta.value()?.parse()?;
*short = Some(v.value());
}
"default" => {
let v: syn::LitStr = meta.value()?.parse()?;
*default = Some(v.value());
}
"choices" => {
*choices = Some(parse_choices(&meta)?);
}
"env" => {
let v: syn::LitStr = meta.value()?.parse()?;
*env = Some(v.value());
}
"completer" => {
let v: syn::LitStr = meta.value()?.parse()?;
*completer = Some(v.value());
}
other => {
return Err(meta.error(format!(
"unknown #[option] attribute `{other}` (valid: short, default, choices, env, completer)"
)));
}
}
Ok(())
})
}
fn parse_arg_attr(
attr: &Attribute,
default: &mut Option<String>,
choices: &mut Option<Vec<String>>,
variadic: &mut bool,
completer: &mut Option<String>,
) -> syn::Result<()> {
if matches!(attr.meta, syn::Meta::Path(_)) {
return Ok(());
}
attr.parse_nested_meta(|meta| {
let key = meta
.path
.get_ident()
.ok_or_else(|| meta.error("expected identifier key in #[arg]"))?;
match key.to_string().as_str() {
"default" => {
let v: syn::LitStr = meta.value()?.parse()?;
*default = Some(v.value());
}
"choices" => {
*choices = Some(parse_choices(&meta)?);
}
"variadic" => {
*variadic = true;
if meta.input.peek(syn::Token![=]) {
let v: syn::LitBool = meta.value()?.parse()?;
*variadic = v.value();
}
}
"completer" => {
let v: syn::LitStr = meta.value()?.parse()?;
*completer = Some(v.value());
}
other => {
return Err(meta.error(format!(
"unknown #[arg] attribute `{other}` (valid: default, choices, variadic, completer)"
)));
}
}
Ok(())
})
}
fn parse_choices(meta: &syn::meta::ParseNestedMeta) -> syn::Result<Vec<String>> {
let expr: Expr = meta.value()?.parse()?;
let arr = match expr {
Expr::Reference(r) => *r.expr,
e => e,
};
match arr {
Expr::Array(arr) => {
let mut out = Vec::with_capacity(arr.elems.len());
for e in arr.elems {
if let Expr::Lit(ExprLit {
lit: Lit::Str(s), ..
}) = e
{
out.push(s.value());
} else {
return Err(syn::Error::new_spanned(
e,
"choices must be string literals",
));
}
}
Ok(out)
}
other => Err(syn::Error::new_spanned(
other,
"choices must be an array literal, e.g. `&[\"a\", \"b\"]`",
)),
}
}
fn classify_type(ty: &Type) -> (TypeShape, Type) {
if let Type::Path(TypePath { path, .. }) = ty {
if let Some(seg) = path.segments.last() {
let name = seg.ident.to_string();
if name == "bool" {
return (TypeShape::Bool, ty.clone());
}
if name == "Option" {
if let Some(inner) = first_generic(&seg.arguments) {
return (TypeShape::Opt, inner);
}
}
if name == "Vec" {
if let Some(inner) = first_generic(&seg.arguments) {
return (TypeShape::List, inner);
}
}
}
}
(TypeShape::Scalar, ty.clone())
}
fn first_generic(args: &PathArguments) -> Option<Type> {
if let PathArguments::AngleBracketed(a) = args {
for arg in &a.args {
if let GenericArgument::Type(t) = arg {
return Some(t.clone());
}
}
}
None
}
fn validate_collisions(fields: &[FieldSpec]) -> syn::Result<()> {
let mut seen_long: std::collections::HashMap<String, Span> =
std::collections::HashMap::new();
let mut seen_short: std::collections::HashMap<char, Span> =
std::collections::HashMap::new();
let mut seen_optional = false;
for f in fields {
if !matches!(f.kind, FieldKind::Arg) {
continue;
}
let is_optional =
matches!(f.shape, TypeShape::Opt) || f.default.is_some() || f.variadic;
if seen_optional && !is_optional {
return Err(syn::Error::new(
f.span,
"required positional cannot follow an optional one",
));
}
if is_optional {
seen_optional = true;
}
}
let mut saw_variadic = false;
for f in fields {
if !matches!(f.kind, FieldKind::Arg) {
continue;
}
if saw_variadic {
return Err(syn::Error::new(
f.span,
"variadic positional must be the last one",
));
}
if f.variadic {
saw_variadic = true;
}
}
for f in fields {
if !matches!(f.kind, FieldKind::Option) {
continue;
}
let long = kebab(&f.ident.to_string());
if RESERVED_LONGS.contains(&long.as_str()) {
return Err(syn::Error::new(
f.span,
format!("--{long} shadows a reserved fdl-level flag"),
));
}
if let Some(prev) = seen_long.insert(long.clone(), f.span) {
return Err(syn::Error::new(
f.span,
format!("duplicate long flag --{long} (previously declared at {:?})", prev),
));
}
if let Some(s) = f.short {
if RESERVED_SHORTS.contains(&s) {
return Err(syn::Error::new(
f.span,
format!("-{s} shadows a reserved fdl-level flag"),
));
}
if let Some(prev) = seen_short.insert(s, f.span) {
return Err(syn::Error::new(
f.span,
format!("duplicate short -{s} (previously declared at {:?})", prev),
));
}
}
}
Ok(())
}
fn build_spec_expr(fields: &[FieldSpec]) -> TokenStream2 {
let opts = fields
.iter()
.filter(|f| matches!(f.kind, FieldKind::Option))
.map(build_option_decl);
let positionals = fields
.iter()
.filter(|f| matches!(f.kind, FieldKind::Arg))
.map(build_positional_decl);
quote! {
::flodl_cli::args::parser::ArgsSpec {
options: vec![ #( #opts ),* ],
positionals: vec![ #( #positionals ),* ],
lenient_unknowns: false,
}
}
}
fn build_option_decl(f: &FieldSpec) -> TokenStream2 {
let long = kebab(&f.ident.to_string());
let takes_value = !matches!(f.shape, TypeShape::Bool);
let allows_bare = match f.shape {
TypeShape::Bool => true,
_ => f.default.is_some(),
};
let repeatable = matches!(f.shape, TypeShape::List);
let short_expr = match f.short {
Some(c) => quote! { ::std::option::Option::Some(#c) },
None => quote! { ::std::option::Option::None },
};
let choices_expr = match &f.choices {
Some(list) => {
let elems = list.iter();
quote! { ::std::option::Option::Some(vec![ #( ::std::string::String::from(#elems) ),* ]) }
}
None => quote! { ::std::option::Option::None },
};
quote! {
::flodl_cli::args::parser::OptionDecl {
long: ::std::string::String::from(#long),
short: #short_expr,
takes_value: #takes_value,
allows_bare: #allows_bare,
repeatable: #repeatable,
choices: #choices_expr,
}
}
}
fn build_positional_decl(f: &FieldSpec) -> TokenStream2 {
let name = kebab(&f.ident.to_string());
let required = matches!(f.shape, TypeShape::Scalar) && f.default.is_none() && !f.variadic;
let variadic = f.variadic;
let choices_expr = match &f.choices {
Some(list) => {
let elems = list.iter();
quote! { ::std::option::Option::Some(vec![ #( ::std::string::String::from(#elems) ),* ]) }
}
None => quote! { ::std::option::Option::None },
};
quote! {
::flodl_cli::args::parser::PositionalDecl {
name: ::std::string::String::from(#name),
required: #required,
variadic: #variadic,
choices: #choices_expr,
}
}
}
fn build_schema_expr(fields: &[FieldSpec], description: Option<&str>) -> TokenStream2 {
let desc_expr = match description {
Some(d) => quote! { ::std::option::Option::Some(::std::string::String::from(#d)) },
None => quote! { ::std::option::Option::None },
};
let option_inserts = fields
.iter()
.filter(|f| matches!(f.kind, FieldKind::Option))
.map(|f| {
let long = kebab(&f.ident.to_string());
let ty = schema_type_str(f);
let desc_expr = match &f.description {
Some(d) => quote! { ::std::option::Option::Some(::std::string::String::from(#d)) },
None => quote! { ::std::option::Option::None },
};
let default_expr = match &f.default {
Some(v) => quote! { ::std::option::Option::Some(::flodl_cli::serde_json::Value::String(::std::string::String::from(#v))) },
None => quote! { ::std::option::Option::None },
};
let choices_expr = match &f.choices {
Some(list) => {
let elems = list.iter();
quote! {
::std::option::Option::Some(vec![
#( ::flodl_cli::serde_json::Value::String(::std::string::String::from(#elems)) ),*
])
}
}
None => quote! { ::std::option::Option::None },
};
let short_expr = match f.short {
Some(c) => {
let cs = c.to_string();
quote! { ::std::option::Option::Some(::std::string::String::from(#cs)) }
}
None => quote! { ::std::option::Option::None },
};
let env_expr = match &f.env {
Some(v) => quote! { ::std::option::Option::Some(::std::string::String::from(#v)) },
None => quote! { ::std::option::Option::None },
};
let completer_expr = match &f.completer {
Some(v) => quote! { ::std::option::Option::Some(::std::string::String::from(#v)) },
None => quote! { ::std::option::Option::None },
};
quote! {
options.insert(
::std::string::String::from(#long),
::flodl_cli::OptionSpec {
ty: ::std::string::String::from(#ty),
description: #desc_expr,
default: #default_expr,
choices: #choices_expr,
short: #short_expr,
env: #env_expr,
completer: #completer_expr,
},
);
}
});
let arg_pushes = fields
.iter()
.filter(|f| matches!(f.kind, FieldKind::Arg))
.map(|f| {
let name = kebab(&f.ident.to_string());
let ty = schema_type_str(f);
let desc_expr = match &f.description {
Some(d) => quote! { ::std::option::Option::Some(::std::string::String::from(#d)) },
None => quote! { ::std::option::Option::None },
};
let required = matches!(f.shape, TypeShape::Scalar) && f.default.is_none() && !f.variadic;
let variadic = f.variadic;
let default_expr = match &f.default {
Some(v) => quote! { ::std::option::Option::Some(::flodl_cli::serde_json::Value::String(::std::string::String::from(#v))) },
None => quote! { ::std::option::Option::None },
};
let choices_expr = match &f.choices {
Some(list) => {
let elems = list.iter();
quote! {
::std::option::Option::Some(vec![
#( ::flodl_cli::serde_json::Value::String(::std::string::String::from(#elems)) ),*
])
}
}
None => quote! { ::std::option::Option::None },
};
let completer_expr = match &f.completer {
Some(v) => quote! { ::std::option::Option::Some(::std::string::String::from(#v)) },
None => quote! { ::std::option::Option::None },
};
quote! {
args.push(::flodl_cli::ArgSpec {
name: ::std::string::String::from(#name),
ty: ::std::string::String::from(#ty),
description: #desc_expr,
required: #required,
variadic: #variadic,
default: #default_expr,
choices: #choices_expr,
completer: #completer_expr,
});
}
});
let _ = desc_expr;
quote! {
{
let mut options: ::std::collections::BTreeMap<::std::string::String, ::flodl_cli::OptionSpec> =
::std::collections::BTreeMap::new();
let mut args: ::std::vec::Vec<::flodl_cli::ArgSpec> = ::std::vec::Vec::new();
#( #option_inserts )*
#( #arg_pushes )*
::flodl_cli::Schema {
args,
options,
strict: false,
}
}
}
}
fn schema_type_str(f: &FieldSpec) -> &'static str {
let inner = inner_ty_name(&f.inner_ty);
let base = match inner.as_str() {
"bool" => "bool",
"String" | "&str" => "string",
"PathBuf" | "Path" => "path",
"f32" | "f64" => "float",
"u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" => "int",
_ => "string",
};
match f.shape {
TypeShape::List => match base {
"string" => "list[string]",
"int" => "list[int]",
"float" => "list[float]",
"path" => "list[path]",
_ => "list[string]",
},
TypeShape::Bool => "bool",
_ => base,
}
}
fn inner_ty_name(ty: &Type) -> String {
if let Type::Path(TypePath { path, .. }) = ty {
if let Some(seg) = path.segments.last() {
return seg.ident.to_string();
}
}
String::from("_")
}
fn build_env_injection(fields: &[FieldSpec]) -> TokenStream2 {
let mut injections: Vec<TokenStream2> = Vec::new();
for f in fields {
let Some(env_name) = f.env.as_deref() else {
continue;
};
if matches!(f.kind, FieldKind::Arg) {
continue;
}
let long = kebab(&f.ident.to_string());
let long_flag = format!("--{long}");
let long_eq_prefix = format!("--{long}=");
let short_tok = match &f.short {
Some(c) => {
let short_exact = format!("-{c}");
quote! {
|| a.as_str() == #short_exact
}
}
None => quote! {},
};
injections.push(quote! {
{
let has_flag = __env_args.iter().any(|a: &::std::string::String| {
a.as_str() == #long_flag
|| a.as_str().starts_with(#long_eq_prefix)
#short_tok
});
if !has_flag {
if let ::std::result::Result::Ok(v) = ::std::env::var(#env_name) {
if !v.is_empty() {
__env_args.push(::std::string::String::from(#long_flag));
__env_args.push(v);
}
}
}
}
});
}
if injections.is_empty() {
return quote! {};
}
quote! {
let __env_args: ::std::vec::Vec<::std::string::String> = {
let mut __env_args: ::std::vec::Vec<::std::string::String> = args.to_vec();
#( #injections )*
__env_args
};
let args: &[::std::string::String] = &__env_args[..];
}
}
fn build_extractor(ident: &Ident, fields: &[FieldSpec]) -> syn::Result<TokenStream2> {
let mut field_inits: Vec<TokenStream2> = Vec::new();
let mut positional_idx: usize = 0;
for f in fields {
match f.kind {
FieldKind::Option => field_inits.push(option_extraction(f)),
FieldKind::Arg => {
field_inits.push(arg_extraction(f, positional_idx));
if !f.variadic {
positional_idx += 1;
}
}
}
}
let field_names: Vec<&Ident> = fields.iter().map(|f| &f.ident).collect();
Ok(quote! {
#( #field_inits )*
::std::result::Result::Ok(#ident {
#( #field_names ),*
})
})
}
fn option_extraction(f: &FieldSpec) -> TokenStream2 {
let ident = &f.ident;
let long = kebab(&ident.to_string());
let inner_ty = &f.inner_ty;
let span = ident.span();
let parse_one = quote_spanned! { span =>
|s: &::std::string::String| -> ::std::result::Result<#inner_ty, ::std::string::String> {
<#inner_ty as ::std::str::FromStr>::from_str(s)
.map_err(|e| format!("--{}: {}", #long, e))
}
};
match f.shape {
TypeShape::Bool => quote! {
let #ident: bool = matches!(
parsed.options.get(#long),
::std::option::Option::Some(::flodl_cli::args::parser::OptionState::BarePresent)
);
},
TypeShape::Scalar => {
let default_lit = f.default.as_deref().unwrap();
quote! {
let #ident: #inner_ty = match parsed.options.get(#long) {
::std::option::Option::Some(::flodl_cli::args::parser::OptionState::WithValues(v)) => {
let s = &v[0];
(#parse_one)(s)?
}
_ => {
let s = ::std::string::String::from(#default_lit);
(#parse_one)(&s).expect("default value must parse")
}
};
}
}
TypeShape::Opt => {
let default_tok = match &f.default {
Some(v) => quote! { ::std::option::Option::Some({
let s = ::std::string::String::from(#v);
(#parse_one)(&s).expect("default value must parse")
}) },
None => quote! { ::std::option::Option::None },
};
quote! {
let #ident: ::std::option::Option<#inner_ty> = match parsed.options.get(#long) {
::std::option::Option::Some(::flodl_cli::args::parser::OptionState::WithValues(v)) => {
::std::option::Option::Some((#parse_one)(&v[0])?)
}
::std::option::Option::Some(::flodl_cli::args::parser::OptionState::BarePresent) => {
#default_tok
}
::std::option::Option::None => ::std::option::Option::None,
};
}
}
TypeShape::List => quote! {
let #ident: ::std::vec::Vec<#inner_ty> = match parsed.options.get(#long) {
::std::option::Option::Some(::flodl_cli::args::parser::OptionState::WithValues(v)) => {
let mut out: ::std::vec::Vec<#inner_ty> = ::std::vec::Vec::with_capacity(v.len());
for s in v {
out.push((#parse_one)(s)?);
}
out
}
_ => ::std::vec::Vec::new(),
};
},
}
}
fn arg_extraction(f: &FieldSpec, idx: usize) -> TokenStream2 {
let ident = &f.ident;
let name = kebab(&ident.to_string());
let inner_ty = &f.inner_ty;
let span = ident.span();
let parse_one = quote_spanned! { span =>
|s: &::std::string::String| -> ::std::result::Result<#inner_ty, ::std::string::String> {
<#inner_ty as ::std::str::FromStr>::from_str(s)
.map_err(|e| format!("<{}>: {}", #name, e))
}
};
match f.shape {
TypeShape::List if f.variadic => quote! {
let #ident: ::std::vec::Vec<#inner_ty> = {
let mut out: ::std::vec::Vec<#inner_ty> = ::std::vec::Vec::new();
for s in &parsed.positionals[#idx..] {
out.push((#parse_one)(s)?);
}
out
};
},
TypeShape::Opt => quote! {
let #ident: ::std::option::Option<#inner_ty> = match parsed.positionals.get(#idx) {
::std::option::Option::Some(s) => ::std::option::Option::Some((#parse_one)(s)?),
::std::option::Option::None => ::std::option::Option::None,
};
},
TypeShape::Scalar => {
let default_tok = match &f.default {
Some(v) => quote! {
{
let s = ::std::string::String::from(#v);
(#parse_one)(&s).expect("default value must parse")
}
},
None => quote! {
return ::std::result::Result::Err(
format!("missing required argument <{}>", #name)
)
},
};
quote! {
let #ident: #inner_ty = match parsed.positionals.get(#idx) {
::std::option::Option::Some(s) => (#parse_one)(s)?,
::std::option::Option::None => #default_tok,
};
}
}
_ => quote! {
compile_error!("unsupported positional type shape");
},
}
}
fn build_help_expr(fields: &[FieldSpec], description: Option<&str>, struct_name: &str) -> TokenStream2 {
let header = match description {
Some(d) => format!("{d}\n\n"),
None => format!("{struct_name}\n\n"),
};
let mut arg_tokens: Vec<TokenStream2> = Vec::new();
let mut opt_tokens: Vec<TokenStream2> = Vec::new();
for f in fields {
match f.kind {
FieldKind::Option => {
let long = kebab(&f.ident.to_string());
let short_prefix = match f.short {
Some(c) => format!("-{c}, "),
None => String::from(" "),
};
let value_part = match f.shape {
TypeShape::Bool => String::new(),
TypeShape::List => String::from(" <VALUE>..."),
_ => format!(" <{}>", value_token(f)),
};
let label = format!("{short_prefix}--{long}{value_part}");
let pad = " ".repeat(36usize.saturating_sub(4 + label.chars().count()));
let mut tail = String::new();
if let Some(d) = &f.description {
tail.push_str(d);
}
if let Some(d) = &f.default {
tail.push_str(&format!(" [default: {d}]"));
}
if let Some(choices) = &f.choices {
tail.push_str(&format!(" [possible: {}]", choices.join(", ")));
}
opt_tokens.push(quote! {
out.push_str(" ");
out.push_str(&::flodl_cli::style::green(#label));
out.push_str(#pad);
out.push_str(#tail);
out.push('\n');
});
}
FieldKind::Arg => {
let name = kebab(&f.ident.to_string());
let required = matches!(f.shape, TypeShape::Scalar) && f.default.is_none();
let label = if f.variadic {
format!("<{name}>...")
} else if required {
format!("<{name}>")
} else {
format!("[<{name}>]")
};
let pad = " ".repeat(36usize.saturating_sub(4 + label.chars().count()));
let mut tail = String::new();
if let Some(d) = &f.description {
tail.push_str(d);
}
if let Some(d) = &f.default {
tail.push_str(&format!(" [default: {d}]"));
}
arg_tokens.push(quote! {
out.push_str(" ");
out.push_str(&::flodl_cli::style::green(#label));
out.push_str(#pad);
out.push_str(#tail);
out.push('\n');
});
}
}
}
let arg_section = if arg_tokens.is_empty() {
quote! {}
} else {
quote! {
out.push_str(&::flodl_cli::style::yellow("Arguments"));
out.push_str(":\n");
#(#arg_tokens)*
out.push('\n');
}
};
let opt_section = if opt_tokens.is_empty() {
quote! {}
} else {
quote! {
out.push_str(&::flodl_cli::style::yellow("Options"));
out.push_str(":\n");
#(#opt_tokens)*
out.push('\n');
}
};
quote! {
{
let mut out = ::std::string::String::from(#header);
#arg_section
#opt_section
out
}
}
}
fn value_token(f: &FieldSpec) -> &'static str {
let inner = inner_ty_name(&f.inner_ty);
match inner.as_str() {
"u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" => "N",
"f32" | "f64" => "F",
"PathBuf" | "Path" => "PATH",
_ => "VALUE",
}
}
fn extract_doc(attrs: &[Attribute]) -> Option<String> {
let mut lines: Vec<String> = Vec::new();
for a in attrs {
if !a.path().is_ident("doc") {
continue;
}
if let syn::Meta::NameValue(nv) = &a.meta {
if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = &nv.value {
let text = s.value();
lines.push(text.trim().to_string());
}
}
}
if lines.is_empty() {
return None;
}
let joined = lines.join(" ").split_whitespace().collect::<Vec<_>>().join(" ");
if joined.is_empty() {
None
} else {
Some(joined)
}
}
fn kebab(s: &str) -> String {
s.replace('_', "-")
}
use syn::spanned::Spanned;