usage/spec/
arg.rs

1use kdl::{KdlDocument, KdlEntry, KdlNode};
2use serde::Serialize;
3use std::fmt::Display;
4use std::hash::Hash;
5use std::str::FromStr;
6
7use crate::error::UsageErr;
8use crate::spec::builder::SpecArgBuilder;
9use crate::spec::context::ParsingContext;
10use crate::spec::helpers::NodeHelper;
11use crate::spec::is_false;
12use crate::{string, SpecChoices};
13
14#[derive(Debug, Default, Clone, Serialize, PartialEq, Eq, strum::EnumString, strum::Display)]
15#[strum(serialize_all = "snake_case")]
16pub enum SpecDoubleDashChoices {
17    /// Once an arg is entered, behave as if "--" was passed
18    Automatic,
19    /// Allow "--" to be passed
20    #[default]
21    Optional,
22    /// Require "--" to be passed
23    Required,
24    /// Preserve "--" tokens as values (only for variadic args)
25    Preserve,
26}
27
28#[derive(Debug, Default, Clone, Serialize)]
29pub struct SpecArg {
30    pub name: String,
31    pub usage: String,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub help: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub help_long: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub help_md: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub help_first_line: Option<String>,
40    pub required: bool,
41    pub double_dash: SpecDoubleDashChoices,
42    #[serde(skip_serializing_if = "is_false")]
43    pub var: bool,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub var_min: Option<usize>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub var_max: Option<usize>,
48    pub hide: bool,
49    #[serde(skip_serializing_if = "Vec::is_empty")]
50    pub default: Vec<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub choices: Option<SpecChoices>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub env: Option<String>,
55}
56
57impl SpecArg {
58    /// Create a new builder for SpecArg
59    pub fn builder() -> SpecArgBuilder {
60        SpecArgBuilder::new()
61    }
62
63    pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self, UsageErr> {
64        let mut arg: SpecArg = node.arg(0)?.ensure_string()?.parse()?;
65        for (k, v) in node.props() {
66            match k {
67                "help" => arg.help = Some(v.ensure_string()?),
68                "long_help" => arg.help_long = Some(v.ensure_string()?),
69                "help_long" => arg.help_long = Some(v.ensure_string()?),
70                "help_md" => arg.help_md = Some(v.ensure_string()?),
71                "required" => arg.required = v.ensure_bool()?,
72                "double_dash" => arg.double_dash = v.ensure_string()?.parse()?,
73                "var" => arg.var = v.ensure_bool()?,
74                "hide" => arg.hide = v.ensure_bool()?,
75                "var_min" => arg.var_min = v.ensure_usize().map(Some)?,
76                "var_max" => arg.var_max = v.ensure_usize().map(Some)?,
77                "default" => arg.default = vec![v.ensure_string()?],
78                "env" => arg.env = v.ensure_string().map(Some)?,
79                k => bail_parse!(ctx, v.entry.span(), "unsupported arg key {k}"),
80            }
81        }
82        if !arg.default.is_empty() {
83            arg.required = false;
84        }
85        for child in node.children() {
86            match child.name() {
87                "choices" => arg.choices = Some(SpecChoices::parse(ctx, &child)?),
88                "env" => arg.env = child.arg(0)?.ensure_string().map(Some)?,
89                "default" => {
90                    // Support both single value and multiple values
91                    // default "bar"            -> vec!["bar"]
92                    // default { "xyz"; "bar" } -> vec!["xyz", "bar"]
93                    let children = child.children();
94                    if children.is_empty() {
95                        // Single value: default "bar"
96                        arg.default = vec![child.arg(0)?.ensure_string()?];
97                    } else {
98                        // Multiple values from children: default { "xyz"; "bar" }
99                        // In KDL, these are child nodes where the string is the node name
100                        arg.default = children.iter().map(|c| c.name().to_string()).collect();
101                    }
102                }
103                k => bail_parse!(ctx, child.node.name().span(), "unsupported arg child {k}"),
104            }
105        }
106        arg.usage = arg.usage();
107        if let Some(help) = &arg.help {
108            arg.help_first_line = Some(string::first_line(help));
109        }
110        Ok(arg)
111    }
112}
113
114impl SpecArg {
115    pub fn usage(&self) -> String {
116        let name = if self.double_dash == SpecDoubleDashChoices::Required {
117            format!("-- {}", self.name)
118        } else {
119            self.name.clone()
120        };
121        let mut name = if self.required {
122            format!("<{name}>")
123        } else {
124            format!("[{name}]")
125        };
126        if self.var {
127            name = format!("{name}…");
128        }
129        name
130    }
131}
132
133impl From<&SpecArg> for KdlNode {
134    fn from(arg: &SpecArg) -> Self {
135        let mut node = KdlNode::new("arg");
136        node.push(KdlEntry::new(arg.usage()));
137        if let Some(desc) = &arg.help {
138            node.push(KdlEntry::new_prop("help", desc.clone()));
139        }
140        if let Some(desc) = &arg.help_long {
141            node.push(KdlEntry::new_prop("help_long", desc.clone()));
142        }
143        if let Some(desc) = &arg.help_md {
144            node.push(KdlEntry::new_prop("help_md", desc.clone()));
145        }
146        if !arg.required {
147            node.push(KdlEntry::new_prop("required", false));
148        }
149        if arg.double_dash == SpecDoubleDashChoices::Automatic
150            || arg.double_dash == SpecDoubleDashChoices::Preserve
151        {
152            node.push(KdlEntry::new_prop(
153                "double_dash",
154                arg.double_dash.to_string(),
155            ));
156        }
157        if arg.var {
158            node.push(KdlEntry::new_prop("var", true));
159        }
160        if let Some(min) = arg.var_min {
161            node.push(KdlEntry::new_prop("var_min", min as i128));
162        }
163        if let Some(max) = arg.var_max {
164            node.push(KdlEntry::new_prop("var_max", max as i128));
165        }
166        if arg.hide {
167            node.push(KdlEntry::new_prop("hide", true));
168        }
169        // Serialize default values
170        if !arg.default.is_empty() {
171            if arg.default.len() == 1 {
172                // Single value: use property default="bar"
173                node.push(KdlEntry::new_prop("default", arg.default[0].clone()));
174            } else {
175                // Multiple values: use child node default { "xyz"; "bar" }
176                let children = node.children_mut().get_or_insert_with(KdlDocument::new);
177                let mut default_node = KdlNode::new("default");
178                let default_children = default_node
179                    .children_mut()
180                    .get_or_insert_with(KdlDocument::new);
181                for val in &arg.default {
182                    default_children
183                        .nodes_mut()
184                        .push(KdlNode::new(val.as_str()));
185                }
186                children.nodes_mut().push(default_node);
187            }
188        }
189        if let Some(env) = &arg.env {
190            node.push(KdlEntry::new_prop("env", env.clone()));
191        }
192        if let Some(choices) = &arg.choices {
193            let children = node.children_mut().get_or_insert_with(KdlDocument::new);
194            children.nodes_mut().push(choices.into());
195        }
196        node
197    }
198}
199
200impl From<&str> for SpecArg {
201    fn from(input: &str) -> Self {
202        let mut arg = SpecArg {
203            name: input.to_string(),
204            required: true,
205            ..Default::default()
206        };
207        // Handle trailing ellipsis: "foo..." or "foo…" or "<foo>..." or "[foo]..."
208        if let Some(name) = arg
209            .name
210            .strip_suffix("...")
211            .or_else(|| arg.name.strip_suffix("…"))
212        {
213            arg.var = true;
214            arg.name = name.to_string();
215        }
216        let first = arg.name.chars().next().unwrap_or_default();
217        let last = arg.name.chars().last().unwrap_or_default();
218        match (first, last) {
219            ('[', ']') => {
220                arg.name = arg.name[1..arg.name.len() - 1].to_string();
221                arg.required = false;
222            }
223            ('<', '>') => {
224                arg.name = arg.name[1..arg.name.len() - 1].to_string();
225            }
226            _ => {}
227        }
228        if let Some(name) = arg.name.strip_prefix("-- ") {
229            arg.double_dash = SpecDoubleDashChoices::Required;
230            arg.name = name.to_string();
231        }
232        arg
233    }
234}
235impl FromStr for SpecArg {
236    type Err = UsageErr;
237    fn from_str(input: &str) -> std::result::Result<Self, UsageErr> {
238        Ok(input.into())
239    }
240}
241
242#[cfg(feature = "clap")]
243impl From<&clap::Arg> for SpecArg {
244    fn from(arg: &clap::Arg) -> Self {
245        let required = arg.is_required_set();
246        let help = arg.get_help().map(|s| s.to_string());
247        let help_long = arg.get_long_help().map(|s| s.to_string());
248        let help_first_line = help.as_ref().map(|s| string::first_line(s));
249        let hide = arg.is_hide_set();
250        let var = matches!(
251            arg.get_action(),
252            clap::ArgAction::Count | clap::ArgAction::Append
253        );
254        let choices = arg
255            .get_possible_values()
256            .iter()
257            .flat_map(|v| v.get_name_and_aliases().map(|s| s.to_string()))
258            .collect::<Vec<_>>();
259        let mut arg = Self {
260            name: arg
261                .get_value_names()
262                .unwrap_or_default()
263                .first()
264                .cloned()
265                .unwrap_or_default()
266                .to_string(),
267            usage: "".into(),
268            required,
269            double_dash: if arg.is_last_set() {
270                SpecDoubleDashChoices::Required
271            } else if arg.is_trailing_var_arg_set() {
272                SpecDoubleDashChoices::Automatic
273            } else {
274                SpecDoubleDashChoices::Optional
275            },
276            help,
277            help_long,
278            help_md: None,
279            help_first_line,
280            var,
281            var_max: None,
282            var_min: None,
283            hide,
284            default: arg
285                .get_default_values()
286                .iter()
287                .map(|v| v.to_string_lossy().to_string())
288                .collect(),
289            choices: None,
290            env: None,
291        };
292        if !choices.is_empty() {
293            arg.choices = Some(SpecChoices { choices });
294        }
295
296        arg
297    }
298}
299
300impl Display for SpecArg {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        write!(f, "{}", self.usage())
303    }
304}
305impl PartialEq for SpecArg {
306    fn eq(&self, other: &Self) -> bool {
307        self.name == other.name
308    }
309}
310impl Eq for SpecArg {}
311impl Hash for SpecArg {
312    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
313        self.name.hash(state);
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use crate::Spec;
320    use insta::assert_snapshot;
321
322    #[test]
323    fn test_arg_with_env() {
324        let spec = Spec::parse(
325            &Default::default(),
326            r#"
327arg "<input>" env="MY_INPUT" help="Input file"
328arg "<output>" env="MY_OUTPUT"
329            "#,
330        )
331        .unwrap();
332
333        assert_snapshot!(spec, @r#"
334        arg <input> help="Input file" env=MY_INPUT
335        arg <output> env=MY_OUTPUT
336        "#);
337
338        let input_arg = spec.cmd.args.iter().find(|a| a.name == "input").unwrap();
339        assert_eq!(input_arg.env, Some("MY_INPUT".to_string()));
340
341        let output_arg = spec.cmd.args.iter().find(|a| a.name == "output").unwrap();
342        assert_eq!(output_arg.env, Some("MY_OUTPUT".to_string()));
343    }
344
345    #[test]
346    fn test_arg_with_env_child_node() {
347        let spec = Spec::parse(
348            &Default::default(),
349            r#"
350arg "<input>" help="Input file" {
351    env "MY_INPUT"
352}
353arg "<output>" {
354    env "MY_OUTPUT"
355}
356            "#,
357        )
358        .unwrap();
359
360        assert_snapshot!(spec, @r#"
361        arg <input> help="Input file" env=MY_INPUT
362        arg <output> env=MY_OUTPUT
363        "#);
364
365        let input_arg = spec.cmd.args.iter().find(|a| a.name == "input").unwrap();
366        assert_eq!(input_arg.env, Some("MY_INPUT".to_string()));
367
368        let output_arg = spec.cmd.args.iter().find(|a| a.name == "output").unwrap();
369        assert_eq!(output_arg.env, Some("MY_OUTPUT".to_string()));
370    }
371
372    #[test]
373    fn test_arg_variadic_syntax() {
374        use crate::SpecArg;
375
376        // Trailing ellipsis with required brackets
377        let arg: SpecArg = "<files>...".into();
378        assert_eq!(arg.name, "files");
379        assert!(arg.var);
380        assert!(arg.required);
381
382        // Trailing ellipsis with optional brackets
383        let arg: SpecArg = "[files]...".into();
384        assert_eq!(arg.name, "files");
385        assert!(arg.var);
386        assert!(!arg.required);
387
388        // Unicode ellipsis
389        let arg: SpecArg = "<files>…".into();
390        assert_eq!(arg.name, "files");
391        assert!(arg.var);
392
393        let arg: SpecArg = "[files]…".into();
394        assert_eq!(arg.name, "files");
395        assert!(arg.var);
396        assert!(!arg.required);
397    }
398}