1use std::collections::{BTreeMap, HashMap};
66
67use mlua::prelude::*;
68
69#[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 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 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
289fn 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
310fn 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
324struct 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 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 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
507fn dashed(name: &str) -> String {
509 name.replace('_', "-")
510}
511
512fn 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
626pub 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}