Skip to main content

mlua_batteries/
argparse.rs

1//! Command-line argument parsing from a spec table — `std.argparse`.
2//!
3//! ```lua
4//! local argparse = std.argparse
5//! local spec = {
6//!     name = "cardbox",
7//!     flags = {
8//!         json    = { type = "boolean", short = "j", help = "print JSON" },
9//!         port    = { type = "integer", default = 8080 },
10//!         include = { type = "string", multiple = true, short = "I" },
11//!     },
12//!     positionals = {
13//!         { name = "command", required = true },
14//!         { name = "files", rest = true },
15//!     },
16//! }
17//! local r = argparse.parse(arg, spec)
18//! -- r.opts.json      true / false / nil
19//! -- r.opts.port      8080 unless --port was given
20//! -- r.opts.include   { "a", "b" } for -I a -I b (empty table when absent)
21//! -- r.args.command   "list"
22//! -- r.args.files     { "x.md", "y.md" }
23//! print(argparse.usage(spec))
24//! ```
25//!
26//! The spec is data, so a host-specific convention (a `--json` every
27//! command accepts, say) is one entry in the host's spec rather than a
28//! feature of this module.
29//!
30//! # Accepted forms
31//!
32//! - `--name value`, `--name=value`, `-n value`, `-nvalue`
33//! - booleans: `--flag`, `--no-flag`, `-f`; bundled shorts `-vq` when
34//!   every letter is a boolean flag; `--flag=true|false` also works
35//! - `--some-name` and `--some_name` both address the flag `some_name`
36//! - `--` ends option parsing; everything after it is positional
37//! - a value that starts with `-` is only taken for an `integer` /
38//!   `number` flag when it parses as one (`--offset -3`); a string flag
39//!   given `--name --other` raises instead of swallowing the next option
40//!
41//! # Results
42//!
43//! `parse` returns `{ opts, args, rest }`.  `opts` holds every flag by
44//! name: the parsed value, the `default` when absent, `{}` for an absent
45//! `multiple` flag, and nil otherwise (so `if r.opts.verbose then` reads
46//! naturally).  `args` holds the declared positionals by name, the `rest`
47//! positional as a list.  `rest` is what the spec did not claim — unknown
48//! options and surplus positionals — and is only ever non-empty when
49//! `allow_unknown = true`; without it those raise.
50//!
51//! # Errors
52//!
53//! Everything raises, with an `argparse:` prefix: an unknown option, a
54//! value of the wrong type, a missing required flag or positional, a
55//! surplus positional, and a malformed spec (unknown `type`, a `short`
56//! that is not one character, two flags sharing a short, `rest` on a
57//! positional that is not last, `required` together with `default`).
58//!
59//! # Not covered
60//!
61//! Subcommands (parse the first positional and dispatch to a second
62//! spec), environment-variable fallbacks, and an automatic `--help`
63//! (declare a boolean flag and print `usage(spec)` yourself).
64
65use std::collections::{BTreeMap, HashMap};
66
67use mlua::prelude::*;
68
69// ─── Spec ─────────────────────────────────────────────
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72enum Kind {
73    String,
74    Integer,
75    Number,
76    Boolean,
77}
78
79impl Kind {
80    fn parse(name: &str, ctx: &str) -> LuaResult<Self> {
81        Ok(match name {
82            "string" => Kind::String,
83            "integer" => Kind::Integer,
84            "number" => Kind::Number,
85            "boolean" => Kind::Boolean,
86            other => {
87                return Err(err(format!(
88                    "{ctx}: unknown type \"{other}\" (expected string, integer, number or boolean)"
89                )))
90            }
91        })
92    }
93
94    fn label(self) -> &'static str {
95        match self {
96            Kind::String => "string",
97            Kind::Integer => "integer",
98            Kind::Number => "number",
99            Kind::Boolean => "boolean",
100        }
101    }
102}
103
104#[derive(Debug)]
105struct Flag {
106    name: String,
107    kind: Kind,
108    short: Option<char>,
109    default: LuaValue,
110    required: bool,
111    multiple: bool,
112    help: Option<String>,
113    metavar: Option<String>,
114}
115
116#[derive(Debug)]
117struct Positional {
118    name: String,
119    kind: Kind,
120    required: bool,
121    rest: bool,
122    help: Option<String>,
123}
124
125#[derive(Debug)]
126struct Spec {
127    name: Option<String>,
128    description: Option<String>,
129    /// Sorted by name, so usage output and error messages are stable.
130    flags: BTreeMap<String, Flag>,
131    shorts: HashMap<char, String>,
132    positionals: Vec<Positional>,
133    allow_unknown: bool,
134}
135
136fn err(msg: impl Into<String>) -> LuaError {
137    LuaError::external(format!("argparse: {}", msg.into()))
138}
139
140fn opt_string(t: &LuaTable, key: &str, ctx: &str) -> LuaResult<Option<String>> {
141    match t.get::<LuaValue>(key)? {
142        LuaValue::Nil => Ok(None),
143        LuaValue::String(s) => Ok(Some(s.to_str()?.to_string())),
144        other => Err(err(format!(
145            "{ctx}: {key} must be a string, got {}",
146            other.type_name()
147        ))),
148    }
149}
150
151fn opt_bool(t: &LuaTable, key: &str, ctx: &str) -> LuaResult<bool> {
152    match t.get::<LuaValue>(key)? {
153        LuaValue::Nil => Ok(false),
154        LuaValue::Boolean(b) => Ok(b),
155        other => Err(err(format!(
156            "{ctx}: {key} must be a boolean, got {}",
157            other.type_name()
158        ))),
159    }
160}
161
162impl Spec {
163    fn from_lua(t: &LuaTable) -> LuaResult<Self> {
164        let name = opt_string(t, "name", "spec")?;
165        let description = opt_string(t, "description", "spec")?;
166        let allow_unknown = opt_bool(t, "allow_unknown", "spec")?;
167
168        let mut flags = BTreeMap::new();
169        let mut shorts = HashMap::new();
170        if let Some(ft) = t.get::<Option<LuaTable>>("flags")? {
171            for pair in ft.pairs::<LuaValue, LuaTable>() {
172                let (key, def) = pair.map_err(|e| err(format!("spec.flags: {e}")))?;
173                let LuaValue::String(key) = key else {
174                    return Err(err(format!(
175                        "spec.flags: keys must be flag names, got {}",
176                        key.type_name()
177                    )));
178                };
179                let name = key.to_str()?.to_string();
180                let ctx = format!("spec.flags.{name}");
181                if !is_flag_name(&name) {
182                    return Err(err(format!(
183                        "{ctx}: flag names are letters, digits and _ (not starting with a digit)"
184                    )));
185                }
186                let kind = Kind::parse(
187                    &opt_string(&def, "type", &ctx)?.unwrap_or_else(|| "string".into()),
188                    &ctx,
189                )?;
190                let short = match opt_string(&def, "short", &ctx)? {
191                    None => None,
192                    Some(s) => {
193                        let mut it = s.chars();
194                        match (it.next(), it.next()) {
195                            (Some(c), None) if c.is_ascii_alphanumeric() => Some(c),
196                            _ => {
197                                return Err(err(format!(
198                                    "{ctx}: short must be one letter or digit, got \"{s}\""
199                                )))
200                            }
201                        }
202                    }
203                };
204                if let Some(c) = short {
205                    if let Some(prev) = shorts.insert(c, name.clone()) {
206                        return Err(err(format!("{ctx}: short -{c} is already used by {prev}")));
207                    }
208                }
209                let default: LuaValue = def.get("default")?;
210                let required = opt_bool(&def, "required", &ctx)?;
211                let multiple = opt_bool(&def, "multiple", &ctx)?;
212                if required && !default.is_nil() {
213                    return Err(err(format!("{ctx}: required and default are exclusive")));
214                }
215                if kind == Kind::Boolean && multiple {
216                    return Err(err(format!("{ctx}: a boolean flag cannot be multiple")));
217                }
218                flags.insert(
219                    name.clone(),
220                    Flag {
221                        name,
222                        kind,
223                        short,
224                        default,
225                        required,
226                        multiple,
227                        help: opt_string(&def, "help", &ctx)?,
228                        metavar: opt_string(&def, "metavar", &ctx)?,
229                    },
230                );
231            }
232        }
233
234        let mut positionals = Vec::new();
235        if let Some(pt) = t.get::<Option<LuaTable>>("positionals")? {
236            let n = pt.raw_len();
237            for i in 1..=n {
238                let def: LuaTable = pt.raw_get(i)?;
239                let ctx = format!("spec.positionals[{i}]");
240                let name = opt_string(&def, "name", &ctx)?
241                    .ok_or_else(|| err(format!("{ctx}: name is required")))?;
242                let kind = Kind::parse(
243                    &opt_string(&def, "type", &ctx)?.unwrap_or_else(|| "string".into()),
244                    &ctx,
245                )?;
246                if kind == Kind::Boolean {
247                    return Err(err(format!("{ctx}: a positional cannot be boolean")));
248                }
249                let rest = opt_bool(&def, "rest", &ctx)?;
250                if rest && i != n {
251                    return Err(err(format!("{ctx}: rest must be the last positional")));
252                }
253                positionals.push(Positional {
254                    name,
255                    kind,
256                    required: opt_bool(&def, "required", &ctx)?,
257                    rest,
258                    help: opt_string(&def, "help", &ctx)?,
259                });
260            }
261        }
262
263        Ok(Spec {
264            name,
265            description,
266            flags,
267            shorts,
268            positionals,
269            allow_unknown,
270        })
271    }
272
273    /// `--some-name` and `--some_name` both address `some_name`.
274    fn flag_by_long(&self, long: &str) -> Option<&Flag> {
275        self.flags.get(&long.replace('-', "_"))
276    }
277
278    fn flag_by_short(&self, c: char) -> Option<&Flag> {
279        self.shorts.get(&c).and_then(|n| self.flags.get(n))
280    }
281}
282
283fn is_flag_name(s: &str) -> bool {
284    let mut chars = s.chars();
285    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
286        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
287}
288
289// ─── Values ───────────────────────────────────────────
290
291fn convert(lua: &Lua, kind: Kind, raw: &str, what: &str) -> LuaResult<LuaValue> {
292    Ok(match kind {
293        Kind::String => LuaValue::String(lua.create_string(raw)?),
294        Kind::Integer => match raw.parse::<i64>() {
295            Ok(i) => LuaValue::Integer(i),
296            Err(_) => return Err(err(format!("{what} expects an integer, got \"{raw}\""))),
297        },
298        Kind::Number => match raw.parse::<f64>() {
299            Ok(n) if n.is_finite() => LuaValue::Number(n),
300            _ => return Err(err(format!("{what} expects a number, got \"{raw}\""))),
301        },
302        Kind::Boolean => match raw {
303            "true" | "yes" | "1" | "on" => LuaValue::Boolean(true),
304            "false" | "no" | "0" | "off" => LuaValue::Boolean(false),
305            _ => return Err(err(format!("{what} expects true or false, got \"{raw}\""))),
306        },
307    })
308}
309
310/// Can `token` be taken as the value of a flag of this kind?  A token
311/// that starts with `-` is only a value when the flag is numeric and the
312/// token parses as a number.
313fn takes_as_value(kind: Kind, token: &str) -> bool {
314    if !token.starts_with('-') || token == "-" {
315        return true;
316    }
317    match kind {
318        Kind::Integer => token.parse::<i64>().is_ok(),
319        Kind::Number => token.parse::<f64>().is_ok(),
320        _ => false,
321    }
322}
323
324// ─── Parse ────────────────────────────────────────────
325
326struct Parsed {
327    opts: HashMap<String, LuaValue>,
328    positional: Vec<String>,
329    rest: Vec<String>,
330}
331
332impl Parsed {
333    fn set(&mut self, lua: &Lua, flag: &Flag, value: LuaValue) -> LuaResult<()> {
334        if flag.multiple {
335            let list = match self.opts.get(&flag.name) {
336                Some(LuaValue::Table(t)) => t.clone(),
337                _ => {
338                    let t = lua.create_table()?;
339                    self.opts
340                        .insert(flag.name.clone(), LuaValue::Table(t.clone()));
341                    t
342                }
343            };
344            list.raw_push(value)?;
345        } else {
346            self.opts.insert(flag.name.clone(), value);
347        }
348        Ok(())
349    }
350}
351
352fn parse_argv(lua: &Lua, spec: &Spec, argv: &[String]) -> LuaResult<Parsed> {
353    let mut out = Parsed {
354        opts: HashMap::new(),
355        positional: Vec::new(),
356        rest: Vec::new(),
357    };
358    let mut i = 0;
359    let mut only_positional = false;
360
361    while i < argv.len() {
362        let tok = argv[i].as_str();
363        i += 1;
364
365        if only_positional || tok == "-" || !tok.starts_with('-') {
366            out.positional.push(tok.to_string());
367            continue;
368        }
369        if tok == "--" {
370            only_positional = true;
371            continue;
372        }
373
374        if let Some(long) = tok.strip_prefix("--") {
375            let (key, inline) = match long.split_once('=') {
376                Some((k, v)) => (k, Some(v)),
377                None => (long, None),
378            };
379            // --no-flag for booleans.
380            if inline.is_none() {
381                if let Some(base) = key.strip_prefix("no-") {
382                    if let Some(flag) = spec.flag_by_long(base) {
383                        if flag.kind == Kind::Boolean {
384                            out.set(lua, flag, LuaValue::Boolean(false))?;
385                            continue;
386                        }
387                    }
388                }
389            }
390            let Some(flag) = spec.flag_by_long(key) else {
391                if spec.allow_unknown {
392                    out.rest.push(tok.to_string());
393                    continue;
394                }
395                return Err(err(format!("unknown option --{key}")));
396            };
397            let what = format!("--{key}");
398            let value = match (flag.kind, inline) {
399                (Kind::Boolean, None) => LuaValue::Boolean(true),
400                (kind, Some(v)) => convert(lua, kind, v, &what)?,
401                (kind, None) => {
402                    let next = argv.get(i).map(String::as_str);
403                    match next {
404                        Some(v) if takes_as_value(kind, v) => {
405                            i += 1;
406                            convert(lua, kind, v, &what)?
407                        }
408                        _ => return Err(err(format!("{what} expects a value"))),
409                    }
410                }
411            };
412            out.set(lua, flag, value)?;
413            continue;
414        }
415
416        // Short option(s): -v, -vq, -n value, -nvalue.
417        let body = &tok[1..];
418        for (pos, c) in body.char_indices() {
419            let Some(flag) = spec.flag_by_short(c) else {
420                if spec.allow_unknown && pos == 0 {
421                    out.rest.push(tok.to_string());
422                    break;
423                }
424                return Err(err(format!("unknown option -{c}")));
425            };
426            let what = format!("-{c}");
427            if flag.kind == Kind::Boolean {
428                out.set(lua, flag, LuaValue::Boolean(true))?;
429                continue;
430            }
431            let attached = &body[pos + c.len_utf8()..];
432            let value = if !attached.is_empty() {
433                convert(lua, flag.kind, attached, &what)?
434            } else {
435                match argv.get(i).map(String::as_str) {
436                    Some(v) if takes_as_value(flag.kind, v) => {
437                        i += 1;
438                        convert(lua, flag.kind, v, &what)?
439                    }
440                    _ => return Err(err(format!("{what} expects a value"))),
441                }
442            };
443            out.set(lua, flag, value)?;
444            break;
445        }
446    }
447
448    Ok(out)
449}
450
451fn build_result(lua: &Lua, spec: &Spec, mut parsed: Parsed) -> LuaResult<LuaTable> {
452    let opts = lua.create_table()?;
453    for flag in spec.flags.values() {
454        match parsed.opts.remove(&flag.name) {
455            Some(v) => opts.set(flag.name.as_str(), v)?,
456            None if flag.required => {
457                return Err(err(format!(
458                    "missing required option --{}",
459                    dashed(&flag.name)
460                )))
461            }
462            None if !flag.default.is_nil() => opts.set(flag.name.as_str(), flag.default.clone())?,
463            None if flag.multiple => opts.set(flag.name.as_str(), lua.create_table()?)?,
464            None => {}
465        }
466    }
467
468    let args = lua.create_table()?;
469    let mut tokens = parsed.positional.into_iter();
470    for pos in &spec.positionals {
471        if pos.rest {
472            let list = lua.create_table()?;
473            for tok in tokens.by_ref() {
474                list.raw_push(convert(lua, pos.kind, &tok, &pos.name)?)?;
475            }
476            args.set(pos.name.as_str(), list)?;
477            continue;
478        }
479        match tokens.next() {
480            Some(tok) => args.set(pos.name.as_str(), convert(lua, pos.kind, &tok, &pos.name)?)?,
481            None if pos.required => {
482                return Err(err(format!("missing required argument <{}>", pos.name)))
483            }
484            None => {}
485        }
486    }
487    let surplus: Vec<String> = tokens.collect();
488    if !surplus.is_empty() {
489        if !spec.allow_unknown {
490            return Err(err(format!("unexpected argument \"{}\"", surplus[0])));
491        }
492        parsed.rest.extend(surplus);
493    }
494
495    let rest = lua.create_table()?;
496    for tok in parsed.rest {
497        rest.raw_push(tok)?;
498    }
499
500    let result = lua.create_table()?;
501    result.set("opts", opts)?;
502    result.set("args", args)?;
503    result.set("rest", rest)?;
504    Ok(result)
505}
506
507/// `some_name` → `some-name`, the spelling usage shows.
508fn dashed(name: &str) -> String {
509    name.replace('_', "-")
510}
511
512// ─── Usage ────────────────────────────────────────────
513
514fn usage(spec: &Spec) -> String {
515    let mut out = String::new();
516    out.push_str("Usage: ");
517    out.push_str(spec.name.as_deref().unwrap_or("<program>"));
518    if !spec.flags.is_empty() {
519        out.push_str(" [options]");
520    }
521    for pos in &spec.positionals {
522        out.push(' ');
523        let inner = if pos.rest {
524            format!("{}...", pos.name)
525        } else {
526            pos.name.clone()
527        };
528        if pos.required {
529            out.push_str(&format!("<{inner}>"));
530        } else {
531            out.push_str(&format!("[{inner}]"));
532        }
533    }
534    out.push('\n');
535    if let Some(d) = &spec.description {
536        out.push('\n');
537        out.push_str(d);
538        out.push('\n');
539    }
540
541    let positional_rows: Vec<(String, String)> = spec
542        .positionals
543        .iter()
544        .map(|p| {
545            let mut help = p.help.clone().unwrap_or_default();
546            if p.kind != Kind::String {
547                help = append_note(help, p.kind.label());
548            }
549            (p.name.clone(), help)
550        })
551        .collect();
552    let flag_rows: Vec<(String, String)> = spec
553        .flags
554        .values()
555        .map(|f| {
556            let short = match f.short {
557                Some(c) => format!("-{c}, "),
558                None => "    ".to_string(),
559            };
560            let mut left = format!("{short}--{}", dashed(&f.name));
561            if f.kind != Kind::Boolean {
562                let metavar = f
563                    .metavar
564                    .clone()
565                    .unwrap_or_else(|| f.kind.label().to_string());
566                left.push_str(&format!(" <{metavar}>"));
567            }
568            let mut help = f.help.clone().unwrap_or_default();
569            if f.required {
570                help = append_note(help, "required");
571            } else if !f.default.is_nil() {
572                let shown = match &f.default {
573                    LuaValue::String(s) => s.to_string_lossy(),
574                    other => other.to_string().unwrap_or_default(),
575                };
576                help = append_note(help, &format!("default: {shown}"));
577            }
578            if f.multiple {
579                help = append_note(help, "repeatable");
580            }
581            (left, help)
582        })
583        .collect();
584
585    let width = positional_rows
586        .iter()
587        .chain(flag_rows.iter())
588        .map(|(l, _)| l.chars().count())
589        .max()
590        .unwrap_or(0);
591
592    if !positional_rows.is_empty() {
593        out.push_str("\nArguments:\n");
594        for (l, h) in &positional_rows {
595            push_row(&mut out, l, h, width);
596        }
597    }
598    if !flag_rows.is_empty() {
599        out.push_str("\nOptions:\n");
600        for (l, h) in &flag_rows {
601            push_row(&mut out, l, h, width);
602        }
603    }
604    out
605}
606
607fn append_note(help: String, note: &str) -> String {
608    if help.is_empty() {
609        format!("({note})")
610    } else {
611        format!("{help} ({note})")
612    }
613}
614
615fn push_row(out: &mut String, left: &str, help: &str, width: usize) {
616    out.push_str("  ");
617    out.push_str(left);
618    if !help.is_empty() {
619        let pad = width - left.chars().count() + 2;
620        out.extend(std::iter::repeat_n(' ', pad));
621        out.push_str(help);
622    }
623    out.push('\n');
624}
625
626// ─── Module ───────────────────────────────────────────
627
628pub fn module(lua: &Lua) -> LuaResult<LuaTable> {
629    let t = lua.create_table()?;
630
631    t.set(
632        "parse",
633        lua.create_function(|lua, (argv, spec): (LuaTable, LuaTable)| {
634            let spec = Spec::from_lua(&spec)?;
635            let mut args = Vec::with_capacity(argv.raw_len());
636            for i in 1..=argv.raw_len() {
637                match argv.raw_get::<LuaValue>(i)? {
638                    LuaValue::String(s) => args.push(s.to_str()?.to_string()),
639                    other => {
640                        return Err(err(format!(
641                            "argv[{i}] must be a string, got {}",
642                            other.type_name()
643                        )))
644                    }
645                }
646            }
647            let parsed = parse_argv(lua, &spec, &args)?;
648            build_result(lua, &spec, parsed)
649        })?,
650    )?;
651
652    t.set(
653        "usage",
654        lua.create_function(|_, spec: LuaTable| {
655            let spec = Spec::from_lua(&spec)?;
656            Ok(usage(&spec))
657        })?,
658    )?;
659
660    Ok(t)
661}
662
663#[cfg(test)]
664mod tests {
665    use mlua::prelude::*;
666
667    fn run(code: &str) -> LuaResult<String> {
668        let lua = Lua::new();
669        crate::register_all(&lua, "std").unwrap();
670        lua.load(format!(
671            r#"
672            local argparse = std.argparse
673            local spec = {{
674                name = "tool",
675                flags = {{
676                    json    = {{ type = "boolean", short = "j", help = "print JSON" }},
677                    verbose = {{ type = "boolean", short = "v" }},
678                    quiet   = {{ type = "boolean", short = "q" }},
679                    port    = {{ type = "integer", default = 8080, short = "p" }},
680                    ratio   = {{ type = "number" }},
681                    name    = {{ type = "string", required = true, short = "n" }},
682                    include = {{ type = "string", multiple = true, short = "I", metavar = "DIR" }},
683                    dry_run = {{ type = "boolean" }},
684                }},
685                positionals = {{
686                    {{ name = "command", required = true }},
687                    {{ name = "count", type = "integer" }},
688                    {{ name = "files", rest = true }},
689                }},
690            }}
691            {code}
692            "#
693        ))
694        .eval::<String>()
695    }
696
697    fn ok(code: &str) -> String {
698        run(code).unwrap()
699    }
700
701    fn fails(code: &str) -> String {
702        run(code).unwrap_err().to_string()
703    }
704
705    #[test]
706    fn long_short_inline_and_attached_values() {
707        let s = ok(r#"
708            local r = argparse.parse({ "-n", "x", "list", "--port=9", "-I", "a", "-Ib", "--ratio", "0.5", "--dry-run", "--json" }, spec)
709            return std.pretty.dump(r, { indent = 0 })
710        "#);
711        assert_eq!(
712            s,
713            r#"{ args = { command = "list", files = {} }, opts = { dry_run = true, include = { "a", "b" }, json = true, name = "x", port = 9, ratio = 0.5 }, rest = {} }"#
714        );
715    }
716
717    #[test]
718    fn defaults_absent_multiple_and_positionals() {
719        let s = ok(r#"
720            local r = argparse.parse({ "run", "-n", "x", "3", "f1", "f2" }, spec)
721            return std.pretty.dump(r, { indent = 0 })
722        "#);
723        assert_eq!(
724            s,
725            r#"{ args = { command = "run", count = 3, files = { "f1", "f2" } }, opts = { include = {}, name = "x", port = 8080 }, rest = {} }"#
726        );
727    }
728
729    #[test]
730    fn booleans_no_prefix_bundle_and_explicit_value() {
731        let s = ok(r#"
732            local r = argparse.parse({ "-vq", "--no-json", "--dry_run=false", "-n", "x", "c" }, spec)
733            return tostring(r.opts.verbose) .. tostring(r.opts.quiet) .. tostring(r.opts.json) .. tostring(r.opts.dry_run)
734        "#);
735        assert_eq!(s, "truetruefalsefalse");
736    }
737
738    #[test]
739    fn double_dash_ends_options_and_negative_numbers_are_values() {
740        let s = ok(r#"
741            local r = argparse.parse({ "-n", "x", "--port", "-1", "c", "2", "--", "--not-a-flag", "-v" }, spec)
742            return std.pretty.dump({ r.opts.port, r.args.files }, { indent = 0 })
743        "#);
744        assert_eq!(s, r#"{ -1, { "--not-a-flag", "-v" } }"#);
745    }
746
747    #[test]
748    fn errors_name_the_problem() {
749        let cases: &[(&str, &str)] = &[
750            (
751                r#"argparse.parse({ "--nope", "c" }, spec)"#,
752                "unknown option --nope",
753            ),
754            (
755                r#"argparse.parse({ "-z", "c" }, spec)"#,
756                "unknown option -z",
757            ),
758            (
759                r#"argparse.parse({ "-n", "x", "--port", "abc", "c" }, spec)"#,
760                "--port expects an integer, got \"abc\"",
761            ),
762            (
763                r#"argparse.parse({ "-n", "x", "--port", "c" }, spec)"#,
764                "--port expects an integer, got \"c\"",
765            ),
766            (
767                r#"argparse.parse({ "-n", "--json", "c" }, spec)"#,
768                "-n expects a value",
769            ),
770            (
771                r#"argparse.parse({ "-n", "x", "--ratio" }, spec)"#,
772                "--ratio expects a value",
773            ),
774            (
775                r#"argparse.parse({ "c" }, spec)"#,
776                "missing required option --name",
777            ),
778            (
779                r#"argparse.parse({ "-n", "x" }, spec)"#,
780                "missing required argument <command>",
781            ),
782            (
783                r#"argparse.parse({ "-n", "x", "c", "zz" }, spec)"#,
784                "count expects an integer, got \"zz\"",
785            ),
786            (
787                r#"argparse.parse({ "-n", "x", "c", "-j", 1 }, spec)"#,
788                "argv[5] must be a string",
789            ),
790        ];
791        for (code, expected) in cases {
792            let msg = fails(&format!("return tostring({code})"));
793            assert!(
794                msg.contains(expected),
795                "{code}\n  got: {msg}\n  want: {expected}"
796            );
797        }
798    }
799
800    #[test]
801    fn surplus_positional_raises_unless_allow_unknown() {
802        let lua = Lua::new();
803        crate::register_all(&lua, "std").unwrap();
804        let strict: LuaResult<LuaValue> = lua
805            .load(r#"return std.argparse.parse({ "a", "b" }, { positionals = { { name = "one" } } })"#)
806            .eval();
807        assert!(strict
808            .unwrap_err()
809            .to_string()
810            .contains("unexpected argument \"b\""));
811        let lenient: String = lua
812            .load(
813                r#"
814                local r = std.argparse.parse({ "a", "--x", "b", "-y" }, { allow_unknown = true, positionals = { { name = "one" } } })
815                return std.pretty.dump(r, { indent = 0 })
816            "#,
817            )
818            .eval()
819            .unwrap();
820        assert_eq!(
821            s(lenient),
822            r#"{ args = { one = "a" }, opts = {}, rest = { "--x", "-y", "b" } }"#
823        );
824        fn s(x: String) -> String {
825            x
826        }
827    }
828
829    #[test]
830    fn spec_validation() {
831        let cases: &[(&str, &str)] = &[
832            (
833                r#"{ flags = { a = { type = "list" } } }"#,
834                "unknown type \"list\"",
835            ),
836            (
837                r#"{ flags = { a = { short = "ab" } } }"#,
838                "short must be one letter",
839            ),
840            (
841                r#"{ flags = { a = { short = "x" }, b = { short = "x" } } }"#,
842                "short -x is already used by",
843            ),
844            (
845                r#"{ flags = { a = { required = true, default = 1 } } }"#,
846                "required and default are exclusive",
847            ),
848            (
849                r#"{ flags = { a = { type = "boolean", multiple = true } } }"#,
850                "cannot be multiple",
851            ),
852            (
853                r#"{ flags = { ["bad-name"] = {} } }"#,
854                "flag names are letters",
855            ),
856            (
857                r#"{ positionals = { { name = "a", rest = true }, { name = "b" } } }"#,
858                "rest must be the last",
859            ),
860            (
861                r#"{ positionals = { { rest = true } } }"#,
862                "name is required",
863            ),
864            (
865                r#"{ positionals = { { name = "a", type = "boolean" } } }"#,
866                "cannot be boolean",
867            ),
868        ];
869        let lua = Lua::new();
870        crate::register_all(&lua, "std").unwrap();
871        for (spec, expected) in cases {
872            let r: LuaResult<LuaValue> = lua
873                .load(format!("return std.argparse.usage({spec})"))
874                .eval();
875            let msg = r.unwrap_err().to_string();
876            assert!(msg.contains(expected), "{spec}\n  got: {msg}");
877        }
878    }
879
880    #[test]
881    fn usage_layout() {
882        let s = ok("return argparse.usage(spec)");
883        let expected = "\
884Usage: tool [options] <command> [count] [files...]
885
886Arguments:
887  command
888  count                 (integer)
889  files
890
891Options:
892      --dry-run
893  -I, --include <DIR>   (repeatable)
894  -j, --json            print JSON
895  -n, --name <string>   (required)
896  -p, --port <integer>  (default: 8080)
897  -q, --quiet
898      --ratio <number>
899  -v, --verbose
900";
901        assert_eq!(s, expected);
902    }
903}