1pub mod admonition;
2pub mod arg;
3pub mod builder;
4pub mod choices;
5pub mod cmd;
6pub mod complete;
7pub mod config;
8pub mod config_type;
9mod context;
10pub mod data_types;
11pub mod effect;
12pub mod exit_code;
13pub mod flag;
14pub mod flagset;
15pub mod group;
16pub mod helpers;
17pub mod mount;
18pub mod output;
19pub mod unknown_flags;
20pub mod view;
21
22use indexmap::IndexMap;
23use kdl::{KdlDocument, KdlEntry, KdlNode};
24use log::{info, warn};
25use regex::Regex;
26use serde::Serialize;
27use std::collections::HashMap;
28use std::fmt::{Display, Formatter};
29use std::iter::once;
30use std::path::{Path, PathBuf};
31use std::str::FromStr;
32use std::sync::LazyLock;
33
34use crate::error::UsageErr;
35use crate::spec::cmd::{SpecCommand, SpecExample, SpecHeading};
36use crate::spec::config::SpecConfig;
37use crate::spec::context::ParsingContext;
38use crate::spec::exit_code::SpecExitCode;
39use crate::spec::flagset::{SpecFlagSet, SpecUse};
40use crate::spec::helpers::{string_entry, NodeHelper};
41use crate::spec::output::SpecOutput;
42use crate::{SpecArg, SpecComplete, SpecFlag};
43use view::SpecView;
44
45#[derive(Debug, Default, Clone, Serialize)]
46#[non_exhaustive]
47pub struct Spec {
48 pub name: String,
49 pub bin: String,
50 pub cmd: SpecCommand,
51 pub config: SpecConfig,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub version: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub long_version: Option<String>,
56 pub usage: String,
57 pub complete: IndexMap<String, SpecComplete>,
58 #[serde(skip_serializing_if = "IndexMap::is_empty")]
60 pub views: IndexMap<String, SpecView>,
61 #[serde(skip)]
67 pub flagsets: IndexMap<String, SpecFlagSet>,
68 #[serde(skip)]
77 pub sources: Vec<PathBuf>,
78
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub source_code_link_template: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
88 pub repository: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub author: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub about: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub about_long: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub about_md: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub license: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub before_help: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub after_help: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub before_help_long: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub after_help_long: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
118 pub help_template: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub disable_help: Option<bool>,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub min_usage_version: Option<String>,
123 #[serde(skip_serializing_if = "Vec::is_empty")]
124 pub examples: Vec<SpecExample>,
125 #[serde(skip_serializing_if = "Vec::is_empty")]
127 pub outputs: Vec<SpecOutput>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub select: Option<String>,
131 #[serde(skip_serializing_if = "Vec::is_empty")]
133 pub exit_codes: Vec<SpecExitCode>,
134 #[serde(skip_serializing_if = "Option::is_none")]
137 pub default_subcommand: Option<String>,
138 #[serde(default, skip_serializing_if = "is_false")]
145 pub multicall: bool,
146 #[doc(hidden)]
151 #[serde(skip)]
152 pub multicall_set: bool,
153 pub unknown_flags: Option<crate::spec::unknown_flags::UnknownFlags>,
156}
157
158impl Spec {
159 pub fn restamp(&mut self) {
172 restamp_paths(&mut self.cmd, &[], true);
173 }
174
175 pub fn resolve_mount_outputs(
181 &mut self,
182 outputs: &HashMap<String, String>,
183 ) -> Result<(), UsageErr> {
184 self.resolve_mount_outputs_at_root(outputs, true)
185 }
186
187 pub(crate) fn resolve_mount_outputs_at_root(
188 &mut self,
189 outputs: &HashMap<String, String>,
190 apply_default_subcommand: bool,
191 ) -> Result<(), UsageErr> {
192 fn resolve(
193 cmd: &mut SpecCommand,
194 outputs: &HashMap<String, String>,
195 skip_mounts: bool,
196 ) -> Result<(), UsageErr> {
197 if !skip_mounts && !cmd.mounts.is_empty() {
198 cmd.mount(&[], Some(outputs))?;
199 cmd.mounts.clear();
200 }
201 for subcommand in cmd.subcommands.values_mut() {
202 resolve(subcommand, outputs, false)?;
203 }
204 Ok(())
205 }
206
207 let default_outranks_root_mounts = apply_default_subcommand
208 && self.default_subcommand.is_some()
209 && !self.cmd.mounts.iter().any(|mount| mount.overrides_default);
210 resolve(&mut self.cmd, outputs, default_outranks_root_mounts)
211 }
212
213 #[must_use = "parsing result should be used"]
221 pub fn parse_file(file: &Path) -> Result<Spec, UsageErr> {
222 Self::parse_file_with_metadata_inference(file, true, true)
223 }
224
225 fn parse_file_with_metadata_inference(
226 file: &Path,
227 infer_metadata_from_filename: bool,
228 resolve_outputs: bool,
229 ) -> Result<Spec, UsageErr> {
230 let spec = split_script(file)?;
231 let ctx = ParsingContext::new(file, &spec);
232 let mut schema = Self::parse_with_output_resolution(&ctx, &spec, resolve_outputs)?;
233 if infer_metadata_from_filename && schema.bin.is_empty() {
234 schema.bin = file
235 .file_name()
236 .and_then(|n| n.to_str())
237 .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
238 .to_string();
239 }
240 if schema.name.is_empty() {
241 schema.name.clone_from(&schema.bin);
242 }
243 Ok(schema)
244 }
245 #[must_use = "parsing result should be used"]
251 pub fn parse_script(file: &Path) -> Result<Spec, UsageErr> {
252 let mut spec = Self::parse_script_with_path(&read_to_string(file)?, file)?;
253 if spec.bin.is_empty() {
254 spec.bin = file
255 .file_name()
256 .and_then(|n| n.to_str())
257 .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
258 .to_string();
259 }
260 if spec.name.is_empty() {
261 spec.name.clone_from(&spec.bin);
262 }
263 Ok(spec)
264 }
265
266 #[must_use = "parsing result should be used"]
274 pub fn parse_script_str(input: &str) -> Result<Spec, UsageErr> {
275 Self::parse_script_with_path(input, Path::new(""))
276 }
277
278 fn parse_script_with_path(input: &str, file: &Path) -> Result<Spec, UsageErr> {
279 let raw = extract_usage_from_comments(input);
280 let ctx = ParsingContext::new(file, &raw);
281 Self::parse(&ctx, &raw)
282 }
283
284 #[deprecated]
285 pub fn parse_spec(input: &str) -> Result<Spec, UsageErr> {
286 Self::parse(&Default::default(), input)
287 }
288
289 pub fn is_empty(&self) -> bool {
290 self.name.is_empty()
291 && self.bin.is_empty()
292 && self.usage.is_empty()
293 && self.cmd.is_empty()
294 && self.config.is_empty()
295 && self.complete.is_empty()
296 && self.views.is_empty()
297 && self.examples.is_empty()
298 }
299
300 pub fn for_view(&self, id: &str) -> Result<Spec, UsageErr> {
306 let view = self
307 .views
308 .get(id)
309 .ok_or_else(|| UsageErr::InvalidView(format!("spec declares no view named `{id}`")))?;
310 let mut command = &self.cmd;
311 for segment in view.root.split_whitespace() {
312 command = command.subcommands.get(segment).ok_or_else(|| {
313 UsageErr::InvalidView(format!(
314 "view `{id}` promotes `{}`, but `{segment}` is not a command on that path",
315 view.root
316 ))
317 })?;
318 }
319 let mut promoted = command.clone();
320 let matches_selector = |flag: &SpecFlag, selector: &str| {
321 selector
322 .strip_prefix("--")
323 .is_some_and(|name| flag.long.iter().any(|long| long == name))
324 || selector
325 .strip_prefix('-')
326 .filter(|short| short.len() == 1)
327 .and_then(|short| short.chars().next())
328 .is_some_and(|short| flag.short.contains(&short))
329 };
330 let carries = |flag: &SpecFlag| {
331 if !flag.global {
332 return false;
333 }
334 view.all_globals
335 || view
336 .globals
337 .iter()
338 .any(|selector| matches_selector(flag, selector))
339 };
340 for selector in &view.globals {
341 if !self
342 .cmd
343 .flags
344 .iter()
345 .any(|flag| flag.global && matches_selector(flag, selector))
346 {
347 return Err(UsageErr::InvalidView(format!(
348 "view `{id}` carries `{selector}`, but it is not a root global flag"
349 )));
350 }
351 }
352 let globals: Vec<SpecFlag> = self
353 .cmd
354 .flags
355 .iter()
356 .filter(|flag| carries(flag) || flag.action == crate::SpecFlagAction::Version)
359 .cloned()
360 .collect();
361 let mut globals: Vec<SpecFlag> = globals
364 .into_iter()
365 .filter(|global| {
366 !promoted
367 .flags
368 .iter()
369 .any(|local| spec_flag_forms_overlap(global, local))
370 })
371 .collect();
372 let mut complete = IndexMap::new();
377 for flag in &globals {
378 if let Some(arg) = &flag.arg {
379 let name = arg.name.to_lowercase();
380 if let Some(completer) = self.complete.get(&name) {
381 complete.insert(name, completer.clone());
382 }
383 }
384 }
385 complete.extend(std::mem::take(&mut promoted.complete));
386 let mut carried_groups = Vec::new();
390 for group in &self.cmd.groups {
391 let members: Vec<String> = group
392 .members
393 .iter()
394 .filter(|selector| {
395 globals
396 .iter()
397 .any(|flag| flag_matches_selector(flag, selector))
398 })
399 .cloned()
400 .collect();
401 match members.as_slice() {
402 [only] if group.required => {
403 if let Some(flag) = globals
404 .iter_mut()
405 .find(|flag| flag_matches_selector(flag, only))
406 {
407 flag.required = true;
408 }
409 }
410 [_, _, ..] => {
411 let mut projected = group.clone();
412 projected.members = members;
413 carried_groups.push(projected);
414 }
415 _ => {}
416 }
417 }
418 promoted.flags.splice(0..0, globals);
419 promoted.groups.splice(0..0, carried_groups);
420 promoted.name.clone_from(&view.bin);
421 promoted.full_cmd.clear();
422 promoted.aliases.clear();
423 promoted.hidden_aliases.clear();
424 promoted.disable_version_flag = self.cmd.disable_version_flag;
428 set_subcommand_ancestors(&mut promoted, &[]);
429 promoted.usage = promoted.usage();
430
431 let mut spec = self.clone();
432 spec.name.clone_from(&view.name);
433 spec.bin.clone_from(&view.bin);
434 spec.about = promoted.help.clone();
435 spec.about_long = promoted.help_long.clone();
436 spec.about_md = promoted.help_md.clone();
437 spec.before_help = promoted.before_help.clone();
438 spec.before_help_long = promoted.before_help_long.clone();
439 spec.after_help = promoted.after_help.clone();
440 spec.after_help_long = promoted.after_help_long.clone();
441 spec.examples.clone_from(&promoted.examples);
442 spec.usage = promoted.usage.clone();
443 spec.complete = complete;
444 spec.cmd = promoted;
445 spec.default_subcommand = None;
446 spec.multicall = false;
447 spec.multicall_set = false;
448 spec.views.clear();
449 Ok(spec)
450 }
451
452 pub fn view_for_program(&self, program: &str) -> Option<&str> {
454 let basename = crate::parse::multicall_basename(program);
455 if basename == crate::parse::multicall_basename(&self.name)
456 || (!self.bin.is_empty() && basename == crate::parse::multicall_basename(&self.bin))
457 {
458 return None;
459 }
460 self.views.values().find_map(|view| {
461 (basename == crate::parse::multicall_basename(&view.bin)
462 || basename == crate::parse::multicall_basename(&view.id))
463 .then_some(view.id.as_str())
464 })
465 }
466
467 pub(crate) fn parse(ctx: &ParsingContext, input: &str) -> Result<Spec, UsageErr> {
468 Self::parse_with_output_resolution(ctx, input, true)
469 }
470
471 fn parse_with_output_resolution(
472 ctx: &ParsingContext,
473 input: &str,
474 resolve_outputs: bool,
475 ) -> Result<Spec, UsageErr> {
476 let kdl: KdlDocument = input
477 .parse()
478 .map_err(|err: kdl::KdlError| UsageErr::KdlError(err))?;
479 let mut schema = Self {
480 ..Default::default()
481 };
482 if !ctx.file.as_os_str().is_empty() {
485 schema.sources.push(ctx.file.clone());
486 }
487 for node in kdl.nodes().iter().map(|n| NodeHelper::new(ctx, n)) {
488 match node.name() {
489 "name" => schema.name = node.arg(0)?.ensure_string()?,
490 "bin" => {
491 schema.bin = node.arg(0)?.ensure_string()?;
492 if schema.name.is_empty() {
493 schema.name.clone_from(&schema.bin);
494 }
495 }
496 "version" => schema.version = Some(node.arg(0)?.ensure_string()?),
497 "long_version" => schema.long_version = Some(node.arg(0)?.ensure_string()?),
498 "author" => schema.author = Some(node.arg(0)?.ensure_string()?),
499 "source_code_link_template" => {
500 schema.source_code_link_template = Some(node.arg(0)?.ensure_string()?)
501 }
502 "repository" => schema.repository = Some(node.arg(0)?.ensure_string()?),
503 "about" => schema.about = Some(node.arg(0)?.ensure_string()?),
504 "long_about" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
505 "about_long" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
506 "about_md" => schema.about_md = Some(node.arg(0)?.ensure_string()?),
507 "surface" => schema.cmd.surface = Some(node.arg(0)?.ensure_string()?),
508 "available_if" => {
509 schema.cmd.available_if = node
510 .ensure_arg_len(1..)?
511 .args()
512 .map(|entry| entry.ensure_string())
513 .collect::<Result<Vec<_>, _>>()?;
514 }
515 "license" => schema.license = Some(node.arg(0)?.ensure_string()?),
516 "before_help" => schema.before_help = Some(node.arg(0)?.ensure_string()?),
517 "after_help" => schema.after_help = Some(node.arg(0)?.ensure_string()?),
518 "before_long_help" | "before_help_long" => {
519 schema.before_help_long = Some(node.arg(0)?.ensure_string()?)
520 }
521 "after_long_help" | "after_help_long" => {
522 schema.after_help_long = Some(node.arg(0)?.ensure_string()?)
523 }
524 "usage" => schema.usage = node.arg(0)?.ensure_string()?,
525 "help_template" => {
529 let template = node.arg(0)?.ensure_string()?;
530 if let Err(problem) = crate::help_template::check(&template) {
531 bail_parse!(ctx, node.span(), "{problem}");
532 }
533 schema.help_template =
536 crate::help_template::is_set(&template).then_some(template);
537 }
538 "arg" => {
539 let arg = SpecArg::parse(ctx, &node)?;
540 if arg.delimiter.is_some() && !arg.var {
543 bail_parse!(
544 ctx,
545 node.node.name().span(),
546 "argument <{}> has a delimiter and holds one value; add \
547 `var=#true` for the values it splits into",
548 arg.name
549 );
550 }
551 schema.cmd.args.push(arg);
552 }
553 "flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?),
554 "group" => schema.cmd.groups.push(crate::SpecGroup::parse(ctx, &node)?),
557 "mount" => schema.cmd.mounts.push(crate::SpecMount::parse(ctx, &node)?),
561 "cmd" => {
562 let node: SpecCommand = SpecCommand::parse(ctx, &node)?;
563 schema.cmd.subcommands.insert(node.name.to_string(), node);
564 }
565 "flagset" => {
566 let set = SpecFlagSet::parse(ctx, &node)?;
567 if schema.flagsets.insert(set.name.clone(), set).is_some() {
568 bail_parse!(ctx, node.span(), "a flagset may be declared only once");
569 }
570 }
571 "use" => {
574 let at = schema.cmd.flags.len();
575 schema.cmd.uses.push(SpecUse::parse(ctx, &node, at)?);
576 }
577 "config" => schema.config = SpecConfig::parse(ctx, &node)?,
578 "complete" => {
579 let complete = SpecComplete::parse(ctx, &node)?;
580 schema.complete.insert(complete.name.clone(), complete);
581 }
582 "view" => {
583 let view = SpecView::parse(ctx, &node)?;
584 if schema.views.insert(view.id.clone(), view).is_some() {
585 bail_parse!(
586 ctx,
587 node.span(),
588 "a view identifier may be declared only once"
589 );
590 }
591 }
592 "disable_help" => schema.disable_help = Some(node.arg(0)?.ensure_bool()?),
593 "min_usage_version" => {
594 let v = node.arg(0)?.ensure_string()?;
595 check_usage_version(&v);
596 schema.min_usage_version = Some(v);
597 }
598 "unknown_flags" => {
599 let raw = node.arg(0)?.ensure_string()?;
600 match raw.parse() {
601 Ok(mode) => schema.unknown_flags = Some(mode),
602 Err(_) => bail_parse!(
603 ctx,
604 node.span(),
605 "unsupported unknown_flags {raw}, expected one of: {}",
606 crate::spec::unknown_flags::UNKNOWN_FLAGS_VALUES
607 ),
608 }
609 }
610 "default_subcommand" => {
611 schema.default_subcommand = Some(node.arg(0)?.ensure_string()?)
612 }
613 "multicall" => {
614 schema.multicall = node.arg(0)?.ensure_bool()?;
615 schema.multicall_set = true;
616 }
617 "external_subcommand" => {
618 schema.cmd.external_subcommand = node.arg(0)?.ensure_bool()?;
619 }
620 "arg_required_else_help" => {
621 schema.cmd.arg_required_else_help = node.arg(0)?.ensure_bool()?;
622 }
623 "disable_help_flag" => {
624 schema.cmd.disable_help_flag = node.arg(0)?.ensure_bool()?;
625 }
626 "disable_help_subcommand" => {
627 schema.cmd.disable_help_subcommand = node.arg(0)?.ensure_bool()?;
628 }
629 "disable_version_flag" => {
630 schema.cmd.disable_version_flag = node.arg(0)?.ensure_bool()?;
631 }
632 "dont_delimit_trailing_values" => {
633 schema.cmd.dont_delimit_trailing_values = node.arg(0)?.ensure_bool()?;
634 }
635 "args_override_self" => {
636 schema.cmd.args_override_self = node.arg(0)?.ensure_bool()?;
637 }
638 "subcommand_negates_reqs" => {
639 schema.cmd.subcommand_negates_reqs = node.arg(0)?.ensure_bool()?;
640 }
641 "args_conflicts_with_subcommands" => {
642 schema.cmd.args_conflicts_with_subcommands = node.arg(0)?.ensure_bool()?;
643 }
644 "subcommand_precedence_over_arg" => {
645 schema.cmd.subcommand_precedence_over_arg = node.arg(0)?.ensure_bool()?;
646 }
647 "allow_missing_positional" => {
648 schema.cmd.allow_missing_positional = node.arg(0)?.ensure_bool()?;
649 }
650 "deprecated" => schema.cmd.deprecated = Some(node.arg(0)?.ensure_string()?),
651 "deprecated_warn_at" => {
652 schema.cmd.deprecated_warn_at = Some(node.arg(0)?.ensure_string()?);
653 }
654 "deprecated_remove_at" => {
655 schema.cmd.deprecated_remove_at = Some(node.arg(0)?.ensure_string()?);
656 }
657 "subcommand_required" => {
658 schema.cmd.subcommand_required = node.arg(0)?.ensure_bool()?;
659 }
660 "subcommand_help_heading" => {
661 schema.cmd.subcommand_help_heading = Some(node.arg(0)?.ensure_string()?);
662 }
663 "subcommand_value_name" => {
664 schema.cmd.subcommand_value_name = Some(node.arg(0)?.ensure_string()?);
665 }
666 "next_line_help" => {
667 schema.cmd.next_line_help = node.arg(0)?.ensure_bool()?;
668 }
669 "flatten_help" => {
670 schema.cmd.flatten_help = node.arg(0)?.ensure_bool()?;
671 }
672 "term_width" => {
673 schema.cmd.term_width = Some(node.arg(0)?.ensure_usize()?);
674 }
675 "max_term_width" => {
676 schema.cmd.max_term_width = Some(node.arg(0)?.ensure_usize()?);
677 }
678 "example" => {
679 let code = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
680 let mut example = SpecExample::new(code.trim().to_string());
681 for (k, v) in node.props() {
682 match k {
683 "header" => example.header = Some(v.ensure_string()?),
684 "help" => example.help = Some(v.ensure_string()?),
685 "lang" => example.lang = v.ensure_string()?,
686 k => bail_parse!(ctx, v.entry.span(), "unsupported example key {k}"),
687 }
688 }
689 schema.examples.push(example);
690 }
691 "heading" => {
692 let title = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
693 let mut help = None;
694 for (k, v) in node.props() {
695 match k {
696 "help" => help = Some(v.ensure_string()?),
697 k => bail_parse!(ctx, v.entry.span(), "unsupported heading key {k}"),
698 }
699 }
700 let Some(help) = help else {
701 bail_parse!(ctx, node.node.span(), "heading {title} needs help text");
702 };
703 schema.cmd.headings.push(SpecHeading::new(title, help));
704 }
705 "output" => schema.outputs.push(SpecOutput::parse(ctx, &node)?),
706 "exit_code" => schema.exit_codes.push(SpecExitCode::parse(ctx, &node)?),
707 "select" => {
708 schema.select = Some(node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
709 }
710 "include" => {
711 let file = node
712 .props()
713 .get("file")
714 .map(|v| v.ensure_string())
715 .transpose()?
716 .ok_or_else(|| ctx.build_err("missing file".into(), node.span()))?;
717 let file = Path::new(&file);
718 let file = match file.is_relative() {
719 true => ctx
720 .file
721 .parent()
722 .ok_or_else(|| {
723 let msg = if ctx.file.as_os_str().is_empty() {
724 "relative includes require a source file".to_string()
725 } else {
726 format!("cannot get parent of {}", ctx.file.display())
727 };
728 ctx.build_err(msg, node.span())
729 })?
730 .join(file),
731 false => file.to_path_buf(),
732 };
733 info!("include: {}", file.display());
734 let other = Self::parse_file_with_metadata_inference(&file, false, false)?;
735 let clash = other.flagsets.values().find(|incoming| {
746 schema
747 .flagsets
748 .get(&incoming.name)
749 .is_some_and(|own| own.declared_in != incoming.declared_in)
750 });
751 if let Some(incoming) = clash {
752 let name = &incoming.name;
753 let owner = schema.flagsets[name].declared_in.clone();
754 let owner = match owner.as_os_str().is_empty() {
755 true => "this spec".to_string(),
756 false => owner.display().to_string(),
757 };
758 bail_parse!(
759 ctx,
760 node.span(),
761 "a flagset may be declared only once: \"{name}\" is declared in \
762 {} and in {owner}",
763 incoming.declared_in.display()
764 );
765 }
766 schema.merge(other);
767 }
768 k => bail_parse!(ctx, node.node.name().span(), "unsupported spec key {k}"),
769 }
770 }
771 schema.cmd.name = if schema.bin.is_empty() {
772 schema.name.clone()
773 } else {
774 schema.bin.clone()
775 };
776 flagset::expand(ctx, &mut schema.cmd, &mut schema.flagsets)?;
779 if resolve_outputs {
780 output::resolve_selectors(&mut schema)?;
781 }
782 schema.sources.extend(ctx.sources());
783 set_subcommand_ancestors(&mut schema.cmd, &[]);
784 Ok(schema)
785 }
786
787 pub fn merge(&mut self, other: Spec) {
788 macro_rules! merge_str {
789 ($field:ident) => {
790 if !other.$field.is_empty() {
791 self.$field = other.$field;
792 }
793 };
794 }
795 macro_rules! merge_opt {
796 ($field:ident) => {
797 if other.$field.is_some() {
798 self.$field = other.$field;
799 }
800 };
801 }
802 macro_rules! merge_extend {
803 ($field:ident) => {
804 if !other.$field.is_empty() {
805 self.$field.extend(other.$field);
806 }
807 };
808 }
809
810 merge_str!(name);
811 merge_str!(bin);
812 merge_str!(usage);
813 merge_opt!(about);
814 merge_opt!(source_code_link_template);
815 merge_opt!(repository);
816 merge_opt!(version);
817 merge_opt!(long_version);
818 merge_opt!(author);
819 merge_opt!(about_long);
820 merge_opt!(about_md);
821 merge_opt!(license);
822 merge_opt!(before_help);
823 merge_opt!(after_help);
824 merge_opt!(before_help_long);
825 merge_opt!(after_help_long);
826 merge_opt!(help_template);
827 merge_opt!(disable_help);
828 merge_opt!(min_usage_version);
829 merge_opt!(default_subcommand);
830 if other.multicall_set {
831 self.multicall = other.multicall;
832 self.multicall_set = true;
833 }
834 merge_opt!(unknown_flags);
835 merge_extend!(complete);
836 merge_extend!(views);
837 merge_extend!(flagsets);
844 merge_extend!(examples);
845 merge_extend!(outputs);
846 merge_extend!(exit_codes);
847 merge_opt!(select);
848 merge_extend!(sources);
850
851 if !other.config.is_empty() {
852 self.config.merge(&other.config);
853 }
854 self.cmd.merge(other.cmd);
855 }
856}
857
858pub(crate) fn spec_flag_forms_overlap(a: &SpecFlag, b: &SpecFlag) -> bool {
859 fn long_forms(flag: &SpecFlag) -> impl Iterator<Item = &str> {
860 flag.long
861 .iter()
862 .chain(&flag.hidden_aliases)
863 .map(String::as_str)
864 .chain(
865 flag.negate
866 .as_deref()
867 .map(|name| name.strip_prefix("--").unwrap_or(name)),
868 )
869 }
870 fn short_forms(flag: &SpecFlag) -> impl Iterator<Item = &char> {
871 flag.short.iter().chain(&flag.hidden_short_aliases)
872 }
873
874 long_forms(a).any(|name| long_forms(b).any(|other| other == name))
875 || short_forms(a).any(|name| short_forms(b).any(|other| other == name))
876}
877
878fn flag_matches_selector(flag: &SpecFlag, selector: &str) -> bool {
879 selector.strip_prefix("--").is_some_and(|name| {
880 flag.long
881 .iter()
882 .chain(&flag.hidden_aliases)
883 .any(|long| long == name)
884 || flag
885 .negate
886 .as_deref()
887 .is_some_and(|negate| negate.strip_prefix("--").unwrap_or(negate) == name)
888 }) || selector
889 .strip_prefix('-')
890 .filter(|short| short.len() == 1)
891 .and_then(|short| short.chars().next())
892 .is_some_and(|short| {
893 flag.short
894 .iter()
895 .chain(&flag.hidden_short_aliases)
896 .any(|candidate| *candidate == short)
897 })
898}
899
900fn check_usage_version(version: &str) {
901 let cur = semver::Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
902 match parse_usage_version(version) {
903 Ok(v) => {
904 if cur < v {
905 warn!(
906 "This usage spec requires at least version {version}, but you are using version {cur} of usage"
907 );
908 }
909 }
910 Err(_) => warn!("Invalid version: {version}"),
911 }
912}
913
914fn parse_usage_version(version: &str) -> Result<semver::Version, semver::Error> {
920 let core_end = version.find(['-', '+']).unwrap_or(version.len());
921 let (core, suffix) = version.split_at(core_end);
922 let missing = match core.matches('.').count() {
923 0 => ".0.0",
924 1 => ".0",
925 _ => "",
926 };
927
928 semver::Version::parse(&format!("{core}{missing}{suffix}"))
929}
930
931fn read_to_string(file: &Path) -> Result<String, UsageErr> {
936 std::fs::read_to_string(file).map_err(|err| UsageErr::FileError(err, file.to_path_buf()))
937}
938
939static USAGE_COMMENT: LazyLock<Regex> =
942 LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])(.*)$").unwrap());
943static HAS_USAGE_COMMENT: LazyLock<Regex> =
946 LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])").unwrap());
947static BLANK_COMMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(?:#|//|::)\s*$").unwrap());
949
950fn split_script(file: &Path) -> Result<String, UsageErr> {
951 let full = read_to_string(file)?;
952 if full.starts_with("#!") && full.lines().any(|l| HAS_USAGE_COMMENT.is_match(l)) {
954 return Ok(extract_usage_from_comments(&full));
955 }
956 Ok(full)
958}
959
960fn extract_usage_from_comments(full: &str) -> String {
961 let mut usage = vec![];
962 let mut found = false;
963 for line in full.lines() {
964 if let Some(captures) = USAGE_COMMENT.captures(line) {
965 found = true;
966 let content = captures.get(1).map_or("", |m| m.as_str());
967 usage.push(content.trim());
968 } else if found {
969 if BLANK_COMMENT.is_match(line) {
971 continue;
972 }
973 break;
975 }
976 }
977 usage.join("\n")
978}
979
980fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) {
981 restamp_paths(cmd, ancestors, false);
982}
983
984fn restamp_paths(cmd: &mut SpecCommand, ancestors: &[String], force: bool) {
998 for subcmd in cmd.subcommands.values_mut() {
999 subcmd.full_cmd = ancestors
1000 .iter()
1001 .cloned()
1002 .chain(once(subcmd.name.clone()))
1003 .collect();
1004 let child_ancestors = subcmd.full_cmd.clone();
1005 restamp_paths(subcmd, &child_ancestors, force);
1006 }
1007 if force {
1008 cmd.reset_subcommand_lookup();
1011 cmd.usage = cmd.usage();
1012 } else if cmd.usage.is_empty() {
1013 cmd.usage = cmd.usage();
1014 }
1015}
1016
1017impl Display for Spec {
1018 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1019 let mut doc = KdlDocument::new();
1020 let nodes = &mut doc.nodes_mut();
1021 if !self.name.is_empty() {
1022 let mut node = KdlNode::new("name");
1023 node.push(string_entry(None, &self.name));
1024 nodes.push(node);
1025 }
1026 if !self.bin.is_empty() {
1027 let mut node = KdlNode::new("bin");
1028 node.push(string_entry(None, &self.bin));
1029 nodes.push(node);
1030 }
1031 if let Some(version) = &self.version {
1032 let mut node = KdlNode::new("version");
1033 node.push(string_entry(None, version));
1034 nodes.push(node);
1035 }
1036 if let Some(version) = &self.long_version {
1037 let mut node = KdlNode::new("long_version");
1038 node.push(string_entry(None, version));
1039 nodes.push(node);
1040 }
1041 if let Some(author) = &self.author {
1042 let mut node = KdlNode::new("author");
1043 node.push(string_entry(None, author));
1044 nodes.push(node);
1045 }
1046 if let Some(about) = &self.about {
1047 let mut node = KdlNode::new("about");
1048 node.push(string_entry(None, about));
1049 nodes.push(node);
1050 }
1051 if let Some(source_code_link_template) = &self.source_code_link_template {
1052 let mut node = KdlNode::new("source_code_link_template");
1053 node.push(string_entry(None, source_code_link_template));
1054 nodes.push(node);
1055 }
1056 if let Some(repository) = &self.repository {
1057 let mut node = KdlNode::new("repository");
1058 node.push(string_entry(None, repository));
1059 nodes.push(node);
1060 }
1061 if let Some(about_md) = &self.about_md {
1062 let mut node = KdlNode::new("about_md");
1063 node.push(string_entry(None, about_md));
1064 nodes.push(node);
1065 }
1066 if let Some(long_about) = &self.about_long {
1067 let mut node = KdlNode::new("long_about");
1068 node.push(string_entry(None, long_about));
1069 nodes.push(node);
1070 }
1071 if let Some(license) = &self.license {
1072 let mut node = KdlNode::new("license");
1073 node.push(string_entry(None, license));
1074 nodes.push(node);
1075 }
1076 if let Some(before_help) = &self.before_help {
1077 let mut node = KdlNode::new("before_help");
1078 node.push(string_entry(None, before_help));
1079 nodes.push(node);
1080 }
1081 if let Some(after_help) = &self.after_help {
1082 let mut node = KdlNode::new("after_help");
1083 node.push(string_entry(None, after_help));
1084 nodes.push(node);
1085 }
1086 if let Some(before_help_long) = &self.before_help_long {
1087 let mut node = KdlNode::new("before_long_help");
1088 node.push(string_entry(None, before_help_long));
1089 nodes.push(node);
1090 }
1091 if let Some(after_help_long) = &self.after_help_long {
1092 let mut node = KdlNode::new("after_long_help");
1093 node.push(string_entry(None, after_help_long));
1094 nodes.push(node);
1095 }
1096 if let Some(help_template) = &self.help_template {
1097 let mut node = KdlNode::new("help_template");
1098 node.push(string_entry(None, help_template));
1099 nodes.push(node);
1100 }
1101 if let Some(disable_help) = self.disable_help {
1102 let mut node = KdlNode::new("disable_help");
1103 node.push(KdlEntry::new(disable_help));
1104 nodes.push(node);
1105 }
1106 if let Some(min_usage_version) = &self.min_usage_version {
1107 let mut node = KdlNode::new("min_usage_version");
1108 node.push(string_entry(None, min_usage_version));
1109 nodes.push(node);
1110 }
1111 if let Some(unknown_flags) = &self.unknown_flags {
1112 let mut node = KdlNode::new("unknown_flags");
1113 node.push(string_entry(None, unknown_flags.as_str()));
1114 nodes.push(node);
1115 }
1116 if let Some(default_subcommand) = &self.default_subcommand {
1117 let mut node = KdlNode::new("default_subcommand");
1118 node.push(string_entry(None, default_subcommand));
1119 nodes.push(node);
1120 }
1121 if self.multicall_set {
1122 let mut node = KdlNode::new("multicall");
1123 node.push(KdlEntry::new(self.multicall));
1124 nodes.push(node);
1125 }
1126 if self.cmd.external_subcommand {
1127 let mut node = KdlNode::new("external_subcommand");
1128 node.push(KdlEntry::new(true));
1129 nodes.push(node);
1130 }
1131 if self.cmd.arg_required_else_help {
1132 let mut node = KdlNode::new("arg_required_else_help");
1133 node.push(KdlEntry::new(true));
1134 nodes.push(node);
1135 }
1136 if self.cmd.disable_help_flag {
1137 let mut node = KdlNode::new("disable_help_flag");
1138 node.push(KdlEntry::new(true));
1139 nodes.push(node);
1140 }
1141 if self.cmd.disable_help_subcommand {
1142 let mut node = KdlNode::new("disable_help_subcommand");
1143 node.push(KdlEntry::new(true));
1144 nodes.push(node);
1145 }
1146 if self.cmd.disable_version_flag {
1147 let mut node = KdlNode::new("disable_version_flag");
1148 node.push(KdlEntry::new(true));
1149 nodes.push(node);
1150 }
1151 if self.cmd.dont_delimit_trailing_values {
1152 let mut node = KdlNode::new("dont_delimit_trailing_values");
1153 node.push(true);
1154 nodes.push(node);
1155 }
1156 if !self.cmd.args_override_self {
1157 let mut node = KdlNode::new("args_override_self");
1158 node.push(false);
1159 nodes.push(node);
1160 }
1161 if self.cmd.subcommand_negates_reqs {
1162 let mut node = KdlNode::new("subcommand_negates_reqs");
1163 node.push(true);
1164 nodes.push(node);
1165 }
1166 if self.cmd.args_conflicts_with_subcommands {
1167 let mut node = KdlNode::new("args_conflicts_with_subcommands");
1168 node.push(true);
1169 nodes.push(node);
1170 }
1171 if self.cmd.subcommand_precedence_over_arg {
1172 let mut node = KdlNode::new("subcommand_precedence_over_arg");
1173 node.push(true);
1174 nodes.push(node);
1175 }
1176 if self.cmd.allow_missing_positional {
1177 let mut node = KdlNode::new("allow_missing_positional");
1178 node.push(true);
1179 nodes.push(node);
1180 }
1181 if let Some(message) = &self.cmd.deprecated {
1182 let mut node = KdlNode::new("deprecated");
1183 node.push(string_entry(None, message));
1184 nodes.push(node);
1185 }
1186 if let Some(surface) = &self.cmd.surface {
1187 let mut node = KdlNode::new("surface");
1188 node.push(string_entry(None, surface));
1189 nodes.push(node);
1190 }
1191 if !self.cmd.available_if.is_empty() {
1192 let mut node = KdlNode::new("available_if");
1193 for condition in &self.cmd.available_if {
1194 node.push(string_entry(None, condition));
1195 }
1196 nodes.push(node);
1197 }
1198 if let Some(at) = &self.cmd.deprecated_warn_at {
1199 let mut node = KdlNode::new("deprecated_warn_at");
1200 node.push(string_entry(None, at));
1201 nodes.push(node);
1202 }
1203 if let Some(at) = &self.cmd.deprecated_remove_at {
1204 let mut node = KdlNode::new("deprecated_remove_at");
1205 node.push(string_entry(None, at));
1206 nodes.push(node);
1207 }
1208 if self.cmd.subcommand_required && !self.cmd.subcommands.is_empty() {
1209 let mut node = KdlNode::new("subcommand_required");
1210 node.push(true);
1211 nodes.push(node);
1212 }
1213 if let Some(heading) = &self.cmd.subcommand_help_heading {
1214 let mut node = KdlNode::new("subcommand_help_heading");
1215 node.push(string_entry(None, heading));
1216 nodes.push(node);
1217 }
1218 if let Some(name) = &self.cmd.subcommand_value_name {
1219 let mut node = KdlNode::new("subcommand_value_name");
1220 node.push(string_entry(None, name));
1221 nodes.push(node);
1222 }
1223 if self.cmd.next_line_help {
1224 let mut node = KdlNode::new("next_line_help");
1225 node.push(true);
1226 nodes.push(node);
1227 }
1228 if self.cmd.flatten_help {
1229 let mut node = KdlNode::new("flatten_help");
1230 node.push(true);
1231 nodes.push(node);
1232 }
1233 if let Some(width) = self.cmd.term_width {
1234 let mut node = KdlNode::new("term_width");
1235 node.push(width as i128);
1236 nodes.push(node);
1237 }
1238 if let Some(width) = self.cmd.max_term_width {
1239 let mut node = KdlNode::new("max_term_width");
1240 node.push(width as i128);
1241 nodes.push(node);
1242 }
1243 if !self.usage.is_empty() {
1244 let mut node = KdlNode::new("usage");
1245 node.push(string_entry(None, &self.usage));
1246 nodes.push(node);
1247 }
1248 for flag in self.cmd.flags.iter() {
1249 nodes.push(flag.into());
1250 }
1251 for arg in self.cmd.args.iter() {
1252 nodes.push(arg.into());
1253 }
1254 for mount in self.cmd.mounts.iter() {
1257 nodes.push(mount.into());
1258 }
1259 for group in self.cmd.groups.iter() {
1260 nodes.push(group.into());
1261 }
1262 for example in self.examples.iter() {
1263 nodes.push(example.into());
1264 }
1265 for heading in self.cmd.headings.iter() {
1266 nodes.push(heading.into());
1267 }
1268 for output in self.outputs.iter() {
1269 nodes.push(output.into());
1270 }
1271 if let Some(select) = &self.select {
1272 let mut node = KdlNode::new("select");
1273 node.push(string_entry(None, select));
1274 nodes.push(node);
1275 }
1276 for exit_code in self.exit_codes.iter() {
1277 nodes.push(exit_code.into());
1278 }
1279 for complete in self.complete.values() {
1280 nodes.push(complete.into());
1281 }
1282 for complete in self.cmd.complete.values() {
1283 nodes.push(complete.into());
1284 }
1285 for view in self.views.values() {
1286 let rendered: KdlDocument = view
1287 .to_string()
1288 .parse()
1289 .expect("a view always renders valid KDL");
1290 nodes.extend(rendered.nodes().iter().cloned());
1291 }
1292 for cmd in self.cmd.subcommands.values() {
1293 nodes.push(cmd.into())
1294 }
1295 if !self.config.is_empty() {
1296 nodes.push((&self.config).into());
1297 }
1298 doc.autoformat_config(&kdl::FormatConfigBuilder::new().build());
1299 write!(f, "{doc}")
1300 }
1301}
1302
1303impl FromStr for Spec {
1304 type Err = UsageErr;
1305
1306 fn from_str(s: &str) -> Result<Self, Self::Err> {
1307 Self::parse(&Default::default(), s)
1308 }
1309}
1310
1311#[cfg(feature = "clap")]
1312impl From<&clap::Command> for Spec {
1313 fn from(cmd: &clap::Command) -> Self {
1314 let mut spec = Spec {
1315 name: cmd.get_name().to_string(),
1316 bin: cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string(),
1317 cmd: cmd.into(),
1318 version: cmd.get_version().map(|v| v.to_string()),
1319 long_version: cmd.get_long_version().map(|v| v.to_string()),
1320 about: cmd.get_about().map(|a| a.to_string()),
1321 about_long: cmd.get_long_about().map(|a| a.to_string()),
1322 usage: cmd.clone().render_usage().to_string(),
1323 unknown_flags: crate::spec::cmd::SpecCommand::from(cmd).unknown_flags,
1326 multicall: cmd.is_multicall_set(),
1327 multicall_set: cmd.is_multicall_set(),
1328 ..Default::default()
1329 };
1330 set_subcommand_ancestors(&mut spec.cmd, &[]);
1335 spec
1336 }
1337}
1338
1339impl From<SpecCommand> for Spec {
1345 fn from(cmd: SpecCommand) -> Self {
1346 let mut spec = Self {
1347 name: cmd.name.clone(),
1348 bin: cmd.name.clone(),
1349 about: cmd.help.clone(),
1350 about_long: cmd.help_long.clone(),
1351 cmd,
1352 ..Self::default()
1353 };
1354 spec.restamp();
1357 spec
1358 }
1359}
1360
1361#[inline]
1362pub fn is_true(b: &bool) -> bool {
1363 *b
1364}
1365
1366#[inline]
1367pub fn is_false(b: &bool) -> bool {
1368 !is_true(b)
1369}
1370
1371#[cfg(test)]
1372mod tests {
1373 use super::*;
1374 use insta::assert_snapshot;
1375
1376 #[test]
1377 fn parses_relaxed_usage_versions_as_semver() {
1378 assert_eq!(
1379 parse_usage_version("6").unwrap(),
1380 semver::Version::new(6, 0, 0)
1381 );
1382 assert_eq!(
1383 parse_usage_version("6.1").unwrap(),
1384 semver::Version::new(6, 1, 0)
1385 );
1386 assert_eq!(
1387 parse_usage_version("6.1.2-beta.1+build").unwrap(),
1388 semver::Version::parse("6.1.2-beta.1+build").unwrap()
1389 );
1390 assert!(parse_usage_version("not-a-version").is_err());
1391 assert!(parse_usage_version("6.1.2.3").is_err());
1392 }
1393
1394 #[test]
1395 fn test_display() {
1396 let spec = Spec::parse(
1397 &Default::default(),
1398 r#"
1399name "Usage CLI"
1400bin "usage"
1401arg "arg1"
1402flag "-f --force" global=#true
1403cmd "config" {
1404 cmd "set" {
1405 arg "key" help="Key to set"
1406 arg "value"
1407 }
1408}
1409complete "file" run="ls" descriptions=#true
1410 "#,
1411 )
1412 .unwrap();
1413 assert_snapshot!(spec, @r#"
1414 name "Usage CLI"
1415 bin usage
1416 flag "-f --force" global=#true
1417 arg <arg1>
1418 complete file run=ls descriptions=#true
1419 cmd config {
1420 cmd set {
1421 arg <key> help="Key to set"
1422 arg <value>
1423 }
1424 }
1425 "#);
1426 }
1427
1428 #[test]
1429 fn test_repository_round_trips() {
1430 let spec = Spec::parse(
1431 &Default::default(),
1432 r#"
1433bin "mise"
1434repository "https://github.com/jdx/mise"
1435source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1436 "#,
1437 )
1438 .unwrap();
1439 assert_eq!(
1440 spec.repository.as_deref(),
1441 Some("https://github.com/jdx/mise")
1442 );
1443 assert_snapshot!(spec, @r#"
1446 name mise
1447 bin mise
1448 source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1449 repository "https://github.com/jdx/mise"
1450 "#);
1451 }
1452
1453 #[test]
1454 fn test_repository_merges_like_the_other_optionals() {
1455 let mut generated = Spec::parse(&Default::default(), r#"bin "mise""#).unwrap();
1458 let extra = Spec::parse(
1459 &Default::default(),
1460 r#"repository "https://github.com/jdx/mise""#,
1461 )
1462 .unwrap();
1463 generated.merge(extra);
1464 assert_eq!(
1465 generated.repository.as_deref(),
1466 Some("https://github.com/jdx/mise")
1467 );
1468 }
1469
1470 #[test]
1471 #[cfg(feature = "clap")]
1472 fn test_clap() {
1473 let cmd = clap::Command::new("test");
1474 assert_snapshot!(Spec::from(&cmd), @r#"
1475 name test
1476 bin test
1477 unknown_flags error
1478 args_override_self #false
1479 usage "Usage: test"
1480 "#);
1481 }
1482
1483 #[test]
1484 #[cfg(feature = "clap")]
1485 fn a_clap_subcommand_knows_where_it_sits() {
1486 let cmd = clap::Command::new("ex").subcommand(
1491 clap::Command::new("go")
1492 .about("Go somewhere")
1493 .subcommand(clap::Command::new("fast").about("Quickly")),
1494 );
1495 let spec = Spec::from(&cmd);
1496
1497 let go = spec.cmd.subcommands.get("go").expect("go");
1498 assert_eq!(go.full_cmd, ["go"]);
1499 assert_eq!(go.usage, "go <SUBCOMMAND>");
1502
1503 let fast = go.subcommands.get("fast").expect("fast");
1505 assert_eq!(fast.full_cmd, ["go", "fast"]);
1506 assert_eq!(fast.usage, "go fast");
1507 }
1508
1509 #[test]
1510 #[cfg(feature = "clap")]
1511 fn a_delimited_default_becomes_the_values_clap_would_split_it_into() {
1512 let cmd = clap::Command::new("test").arg(
1520 clap::Arg::new("events")
1521 .long("events")
1522 .value_delimiter(',')
1523 .action(clap::ArgAction::Append)
1524 .value_parser(["a", "b", "c"])
1525 .default_value("a,b"),
1526 );
1527 let spec = Spec::from(&cmd);
1528 let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1529 assert_eq!(flag.default, ["a", "b"]);
1530
1531 let cmd = clap::Command::new("test")
1534 .arg(clap::Arg::new("events").long("events").default_value("a,b"));
1535 let spec = Spec::from(&cmd);
1536 let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1537 assert_eq!(flag.default, ["a,b"]);
1538 }
1539
1540 #[test]
1541 fn multicall_round_trips() {
1542 let spec = Spec::parse(
1543 &Default::default(),
1544 r#"
1545name "busybox"
1546bin "busybox"
1547multicall #true
1548cmd "ls"
1549cmd "cat"
1550 "#,
1551 )
1552 .unwrap();
1553 assert!(spec.multicall);
1554 let emitted = spec.to_string();
1555 assert!(
1556 emitted.contains("multicall #true"),
1557 "lost on the way out: {emitted}"
1558 );
1559 let again: Spec = emitted.parse().unwrap();
1560 assert!(again.multicall);
1561 }
1562
1563 #[test]
1564 fn a_declared_view_promotes_a_command_and_selected_globals() {
1565 let spec: Spec = r#"
1566name "aube"
1567bin "aube"
1568about_md "Host **markdown**"
1569before_help "host before"
1570before_long_help "host long before"
1571after_help "host after"
1572after_long_help "host long after"
1573example "aube host" header="host example"
1574flag "-v --verbose" global=#true
1575flag "--config <FILE>" global=#true
1576view "aubr" root="run" {
1577 global "--verbose"
1578}
1579cmd "run" help="Run a package script" {
1580 before_help "run before"
1581 before_long_help "run long before"
1582 after_help "run after"
1583 after_long_help "run long after"
1584 example "aubr task" header="run example"
1585 flag "--if-present"
1586 arg "[SCRIPT]"
1587 cmd "nested"
1588}
1589"#
1590 .parse()
1591 .unwrap();
1592
1593 let rendered = spec.to_string();
1594 assert!(rendered.contains("view aubr root=run"), "{rendered}");
1595 let reparsed: Spec = rendered.parse().unwrap();
1596 let applet = reparsed.for_view("aubr").unwrap();
1597 assert_eq!(applet.name, "aubr");
1598 assert_eq!(applet.bin, "aubr");
1599 assert_eq!(applet.about.as_deref(), Some("Run a package script"));
1600 assert_eq!(applet.about_md, None);
1601 assert_eq!(applet.before_help.as_deref(), Some("run before"));
1602 assert_eq!(applet.before_help_long.as_deref(), Some("run long before"));
1603 assert_eq!(applet.after_help.as_deref(), Some("run after"));
1604 assert_eq!(applet.after_help_long.as_deref(), Some("run long after"));
1605 assert_eq!(applet.examples.len(), 1);
1606 assert_eq!(applet.examples[0].header.as_deref(), Some("run example"));
1607 assert!(applet.cmd.flags.iter().any(|flag| flag.name == "verbose"));
1608 assert!(applet
1609 .cmd
1610 .flags
1611 .iter()
1612 .any(|flag| flag.name == "if-present"));
1613 assert!(!applet.cmd.flags.iter().any(|flag| flag.name == "config"));
1614 assert!(applet.cmd.subcommands.contains_key("nested"));
1615 assert!(applet.views.is_empty());
1616 assert!(applet.to_string().contains("bin aubr"));
1617 }
1618
1619 #[test]
1620 fn a_view_preserves_the_host_version_entry_policy() {
1621 let spec: Spec = r#"
1622bin "host"
1623version "1.2.3"
1624disable_version_flag #true
1625view "runner" root=run
1626cmd "run"
1627"#
1628 .parse()
1629 .unwrap();
1630
1631 let view = spec.for_view("runner").unwrap();
1632 assert!(view.cmd.disable_version_flag);
1633 let emitted = view.to_string();
1634 assert!(emitted.contains("disable_version_flag #true"), "{emitted}");
1635 let reparsed: Spec = emitted.parse().unwrap();
1636 assert!(reparsed.cmd.disable_version_flag);
1637 }
1638
1639 #[test]
1640 fn a_promoted_flag_shadows_every_carried_global_spelling() {
1641 let spec: Spec = r#"
1642bin "host"
1643flag "--color" global=#true negate="--no-color"
1644view "runner" root=run {
1645 global "--color"
1646}
1647cmd "run" {
1648 flag "--no-color"
1649}
1650"#
1651 .parse()
1652 .unwrap();
1653
1654 let view = spec.for_view("runner").unwrap();
1655 assert_eq!(view.cmd.flags.len(), 1);
1656 assert_eq!(view.cmd.flags[0].long, ["no-color"]);
1657 }
1658
1659 #[test]
1660 fn a_view_keeps_only_its_own_and_carried_global_completers() {
1661 let spec: Spec = r#"
1662bin "host"
1663flag "--host <HOST>" global=#true
1664flag "--carried <CARRIED>" global=#true
1665complete "host" run="host candidates"
1666complete "carried" run="carried candidates"
1667view "runner" root=run {
1668 global "--carried"
1669}
1670cmd "run" {
1671 arg "<HOST>"
1672 complete "host" run="view candidates"
1673}
1674"#
1675 .parse()
1676 .unwrap();
1677
1678 let view = spec.for_view("runner").unwrap();
1679 assert_eq!(
1680 view.complete.get("host").unwrap().run.as_deref(),
1681 Some("view candidates")
1682 );
1683 assert_eq!(
1684 view.complete.get("carried").unwrap().run.as_deref(),
1685 Some("carried candidates")
1686 );
1687 assert!(view.cmd.complete.is_empty());
1688 assert_eq!(view.complete.len(), 2);
1689 assert_eq!(view.to_string().matches("complete ").count(), 2);
1690 }
1691
1692 #[test]
1693 fn a_view_projects_groups_of_carried_globals() {
1694 let spec: Spec = r#"
1695bin "host"
1696flag "--json" global=#true
1697flag "--yaml" global=#true
1698flag "--toml" global=#true
1699group "format" "--json" "--yaml" "--toml" required=#true
1700view "all" root=run globals=#true
1701view "json" root=run {
1702 global "--json"
1703}
1704cmd "run"
1705"#
1706 .parse()
1707 .unwrap();
1708
1709 let all = spec.for_view("all").unwrap();
1710 assert_eq!(all.cmd.groups.len(), 1);
1711 assert_eq!(all.cmd.groups[0].members, ["--json", "--yaml", "--toml"]);
1712 assert!(all.to_string().parse::<Spec>().is_ok());
1713
1714 let json = spec.for_view("json").unwrap();
1715 assert!(json.cmd.groups.is_empty());
1716 assert!(
1717 json.cmd
1718 .flags
1719 .iter()
1720 .find(|flag| flag.name == "json")
1721 .unwrap()
1722 .required
1723 );
1724 assert!(json.to_string().parse::<Spec>().is_ok());
1725 }
1726
1727 #[test]
1728 fn a_view_refuses_unknown_commands_and_non_global_carryovers() {
1729 let missing: Spec = "bin \"ex\"\nview \"x\" root=missing\n".parse().unwrap();
1730 assert!(missing
1731 .for_view("x")
1732 .unwrap_err()
1733 .to_string()
1734 .contains("missing"));
1735
1736 let local: Spec =
1737 "bin \"ex\"\nflag \"--local\"\nview \"x\" root=go { global \"--local\" }\ncmd go\n"
1738 .parse()
1739 .unwrap();
1740 assert!(local
1741 .for_view("x")
1742 .unwrap_err()
1743 .to_string()
1744 .contains("not a root global"));
1745 }
1746
1747 #[test]
1748 fn an_included_spec_can_enable_or_disable_multicall() {
1749 let dir = tempfile::tempdir().unwrap();
1750 let included = dir.path().join("included.usage.kdl");
1751 let root = dir.path().join("root.usage.kdl");
1752
1753 std::fs::write(&included, "multicall #false\n").unwrap();
1754 std::fs::write(
1755 &root,
1756 "multicall #true\ninclude file=\"./included.usage.kdl\"\n",
1757 )
1758 .unwrap();
1759 let spec = Spec::parse_file(&root).unwrap();
1760 assert!(!spec.multicall);
1761 assert!(spec.to_string().contains("multicall #false"));
1762
1763 std::fs::write(&included, "multicall #true\n").unwrap();
1764 std::fs::write(
1765 &root,
1766 "multicall #false\ninclude file=\"./included.usage.kdl\"\n",
1767 )
1768 .unwrap();
1769 let spec = Spec::parse_file(&root).unwrap();
1770 assert!(spec.multicall);
1771 assert!(spec.to_string().contains("multicall #true"));
1772 }
1773
1774 #[test]
1775 #[cfg(feature = "clap")]
1776 fn multicall_comes_across_from_clap() {
1777 let cmd = clap::Command::new("busybox")
1778 .multicall(true)
1779 .subcommand(clap::Command::new("ls"))
1780 .subcommand(clap::Command::new("cat"));
1781 let spec = Spec::from(&cmd);
1782 assert!(spec.multicall);
1783 assert!(
1784 spec.to_string().contains("multicall #true"),
1785 "{}",
1786 spec.to_string()
1787 );
1788
1789 let plain = clap::Command::new("ex").subcommand(clap::Command::new("ls"));
1790 assert!(!Spec::from(&plain).multicall);
1791 }
1792
1793 macro_rules! extract_usage_tests {
1794 ($($name:ident: $input:expr, $expected:expr,)*) => {
1795 $(
1796 #[test]
1797 fn $name() {
1798 let result = extract_usage_from_comments($input);
1799 let expected = $expected.trim_start_matches('\n').trim_end();
1800 assert_eq!(result, expected);
1801 }
1802 )*
1803 }
1804 }
1805
1806 extract_usage_tests! {
1807 test_extract_usage_from_comments_original_hash:
1808 r#"
1809#!/bin/bash
1810#USAGE bin "test"
1811#USAGE flag "--foo" help="test"
1812echo "hello"
1813 "#,
1814 r#"
1815bin "test"
1816flag "--foo" help="test"
1817 "#,
1818
1819 test_extract_usage_from_comments_original_double_slash:
1820 r#"
1821#!/usr/bin/env node
1822//USAGE bin "test"
1823//USAGE flag "--foo" help="test"
1824console.log("hello");
1825 "#,
1826 r#"
1827bin "test"
1828flag "--foo" help="test"
1829 "#,
1830
1831 test_extract_usage_from_comments_bracket_with_space:
1832 r#"
1833#!/bin/bash
1834# [USAGE] bin "test"
1835# [USAGE] flag "--foo" help="test"
1836echo "hello"
1837 "#,
1838 r#"
1839bin "test"
1840flag "--foo" help="test"
1841 "#,
1842
1843 test_extract_usage_from_comments_bracket_no_space:
1844 r#"
1845#!/bin/bash
1846#[USAGE] bin "test"
1847#[USAGE] flag "--foo" help="test"
1848echo "hello"
1849 "#,
1850 r#"
1851bin "test"
1852flag "--foo" help="test"
1853 "#,
1854
1855 test_extract_usage_from_comments_double_slash_bracket_with_space:
1856 r#"
1857#!/usr/bin/env node
1858// [USAGE] bin "test"
1859// [USAGE] flag "--foo" help="test"
1860console.log("hello");
1861 "#,
1862 r#"
1863bin "test"
1864flag "--foo" help="test"
1865 "#,
1866
1867 test_extract_usage_from_comments_double_slash_bracket_no_space:
1868 r#"
1869#!/usr/bin/env node
1870//[USAGE] bin "test"
1871//[USAGE] flag "--foo" help="test"
1872console.log("hello");
1873 "#,
1874 r#"
1875bin "test"
1876flag "--foo" help="test"
1877 "#,
1878
1879 test_extract_usage_from_comments_stops_at_gap:
1880 r#"
1881#!/bin/bash
1882#USAGE bin "test"
1883#USAGE flag "--foo" help="test"
1884
1885#USAGE flag "--bar" help="should not be included"
1886echo "hello"
1887 "#,
1888 r#"
1889bin "test"
1890flag "--foo" help="test"
1891 "#,
1892
1893 test_extract_usage_from_comments_with_content_after_marker:
1894 r#"
1895#!/bin/bash
1896# [USAGE] bin "test"
1897# [USAGE] flag "--verbose" help="verbose mode"
1898# [USAGE] arg "input" help="input file"
1899echo "hello"
1900 "#,
1901 r#"
1902bin "test"
1903flag "--verbose" help="verbose mode"
1904arg "input" help="input file"
1905 "#,
1906
1907 test_extract_usage_from_comments_double_colon_original:
1908 r#"
1909::USAGE bin "test"
1910::USAGE flag "--foo" help="test"
1911echo "hello"
1912 "#,
1913 r#"
1914bin "test"
1915flag "--foo" help="test"
1916 "#,
1917
1918 test_extract_usage_from_comments_double_colon_bracket_with_space:
1919 r#"
1920:: [USAGE] bin "test"
1921:: [USAGE] flag "--foo" help="test"
1922echo "hello"
1923 "#,
1924 r#"
1925bin "test"
1926flag "--foo" help="test"
1927 "#,
1928
1929 test_extract_usage_from_comments_double_colon_bracket_no_space:
1930 r#"
1931::[USAGE] bin "test"
1932::[USAGE] flag "--foo" help="test"
1933echo "hello"
1934 "#,
1935 r#"
1936bin "test"
1937flag "--foo" help="test"
1938 "#,
1939
1940 test_extract_usage_from_comments_double_colon_stops_at_gap:
1941 r#"
1942::USAGE bin "test"
1943::USAGE flag "--foo" help="test"
1944
1945::USAGE flag "--bar" help="should not be included"
1946echo "hello"
1947 "#,
1948 r#"
1949bin "test"
1950flag "--foo" help="test"
1951 "#,
1952
1953 test_extract_usage_from_comments_double_colon_with_content_after_marker:
1954 r#"
1955::USAGE bin "test"
1956::USAGE flag "--verbose" help="verbose mode"
1957::USAGE arg "input" help="input file"
1958echo "hello"
1959 "#,
1960 r#"
1961bin "test"
1962flag "--verbose" help="verbose mode"
1963arg "input" help="input file"
1964 "#,
1965
1966 test_extract_usage_from_comments_double_colon_bracket_with_space_multiple_lines:
1967 r#"
1968:: [USAGE] bin "myapp"
1969:: [USAGE] flag "--config <file>" help="config file"
1970:: [USAGE] flag "--verbose" help="verbose output"
1971:: [USAGE] arg "input" help="input file"
1972:: [USAGE] arg "[output]" help="output file" required=#false
1973echo "done"
1974 "#,
1975 r#"
1976bin "myapp"
1977flag "--config <file>" help="config file"
1978flag "--verbose" help="verbose output"
1979arg "input" help="input file"
1980arg "[output]" help="output file" required=#false
1981 "#,
1982
1983 test_extract_usage_from_comments_empty:
1984 r#"
1985#!/bin/bash
1986echo "hello"
1987 "#,
1988 "",
1989
1990 test_extract_usage_from_comments_lowercase_usage:
1991 r#"
1992#!/bin/bash
1993#usage bin "test"
1994#usage flag "--foo" help="test"
1995echo "hello"
1996 "#,
1997 "",
1998
1999 test_extract_usage_from_comments_mixed_case_usage:
2000 r#"
2001#!/bin/bash
2002#Usage bin "test"
2003#Usage flag "--foo" help="test"
2004echo "hello"
2005 "#,
2006 "",
2007
2008 test_extract_usage_from_comments_space_before_usage:
2009 r#"
2010#!/bin/bash
2011# USAGE bin "test"
2012# USAGE flag "--foo" help="test"
2013echo "hello"
2014 "#,
2015 "",
2016
2017 test_extract_usage_from_comments_double_slash_lowercase:
2018 r#"
2019#!/usr/bin/env node
2020//usage bin "test"
2021//usage flag "--foo" help="test"
2022console.log("hello");
2023 "#,
2024 "",
2025
2026 test_extract_usage_from_comments_double_slash_mixed_case:
2027 r#"
2028#!/usr/bin/env node
2029//Usage bin "test"
2030//Usage flag "--foo" help="test"
2031console.log("hello");
2032 "#,
2033 "",
2034
2035 test_extract_usage_from_comments_double_slash_space_before_usage:
2036 r#"
2037#!/usr/bin/env node
2038// USAGE bin "test"
2039// USAGE flag "--foo" help="test"
2040console.log("hello");
2041 "#,
2042 "",
2043
2044 test_extract_usage_from_comments_bracket_lowercase:
2045 r#"
2046#!/bin/bash
2047#[usage] bin "test"
2048#[usage] flag "--foo" help="test"
2049echo "hello"
2050 "#,
2051 "",
2052
2053 test_extract_usage_from_comments_bracket_mixed_case:
2054 r#"
2055#!/bin/bash
2056#[Usage] bin "test"
2057#[Usage] flag "--foo" help="test"
2058echo "hello"
2059 "#,
2060 "",
2061
2062 test_extract_usage_from_comments_bracket_space_lowercase:
2063 r#"
2064#!/bin/bash
2065# [usage] bin "test"
2066# [usage] flag "--foo" help="test"
2067echo "hello"
2068 "#,
2069 "",
2070
2071 test_extract_usage_from_comments_double_colon_lowercase:
2072 r#"
2073::usage bin "test"
2074::usage flag "--foo" help="test"
2075echo "hello"
2076 "#,
2077 "",
2078
2079 test_extract_usage_from_comments_double_colon_mixed_case:
2080 r#"
2081::Usage bin "test"
2082::Usage flag "--foo" help="test"
2083echo "hello"
2084 "#,
2085 "",
2086
2087 test_extract_usage_from_comments_double_colon_space_before_usage:
2088 r#"
2089:: USAGE bin "test"
2090:: USAGE flag "--foo" help="test"
2091echo "hello"
2092 "#,
2093 "",
2094
2095 test_extract_usage_from_comments_double_colon_bracket_lowercase:
2096 r#"
2097::[usage] bin "test"
2098::[usage] flag "--foo" help="test"
2099echo "hello"
2100 "#,
2101 "",
2102
2103 test_extract_usage_from_comments_double_colon_bracket_mixed_case:
2104 r#"
2105::[Usage] bin "test"
2106::[Usage] flag "--foo" help="test"
2107echo "hello"
2108 "#,
2109 "",
2110
2111 test_extract_usage_from_comments_double_colon_bracket_space_lowercase:
2112 r#"
2113:: [usage] bin "test"
2114:: [usage] flag "--foo" help="test"
2115echo "hello"
2116 "#,
2117 "",
2118 }
2119
2120 #[test]
2121 fn test_spec_with_examples() {
2122 let spec = Spec::parse(
2123 &Default::default(),
2124 r#"
2125name "demo"
2126bin "demo"
2127example "demo --help" header="Getting help" help="Display help information"
2128example "demo --version" header="Check version"
2129 "#,
2130 )
2131 .unwrap();
2132
2133 assert_eq!(spec.examples.len(), 2);
2134
2135 assert_eq!(spec.examples[0].code, "demo --help");
2136 assert_eq!(spec.examples[0].header, Some("Getting help".to_string()));
2137 assert_eq!(
2138 spec.examples[0].help,
2139 Some("Display help information".to_string())
2140 );
2141
2142 assert_eq!(spec.examples[1].code, "demo --version");
2143 assert_eq!(spec.examples[1].header, Some("Check version".to_string()));
2144 assert_eq!(spec.examples[1].help, None);
2145 }
2146
2147 #[test]
2148 fn test_spec_examples_display() {
2149 let spec = Spec::parse(
2150 &Default::default(),
2151 r#"
2152name "demo"
2153bin "demo"
2154example "demo --help" header="Getting help" help="Show help"
2155example "demo --version"
2156 "#,
2157 )
2158 .unwrap();
2159
2160 let output = format!("{}", spec);
2161 assert!(
2162 output.contains("example \"demo --help\" header=\"Getting help\" help=\"Show help\"")
2163 );
2164 assert!(output.contains("example \"demo --version\""));
2165 }
2166
2167 #[test]
2168 fn test_parse_script_str() {
2169 let spec = Spec::parse_script_str(
2170 r#"
2171#!/bin/bash
2172#USAGE bin "test"
2173#USAGE flag "--foo" help="test"
2174echo "hello"
2175 "#,
2176 )
2177 .unwrap();
2178
2179 assert_eq!(spec.bin, "test");
2180 assert_eq!(spec.name, "test");
2181 assert_eq!(spec.cmd.flags.len(), 1);
2182 assert_eq!(spec.cmd.flags[0].long, ["foo"]);
2183 }
2184
2185 #[test]
2186 fn test_parse_script_str_rejects_relative_includes() {
2187 let err = Spec::parse_script_str(r#"#USAGE include file="relative.usage.kdl""#)
2188 .expect_err("relative includes need a source path");
2189
2190 match err {
2191 UsageErr::InvalidInput(msg, _, _) => {
2192 assert_eq!(msg, "relative includes require a source file");
2193 }
2194 err => panic!("unexpected error: {err:?}"),
2195 }
2196 }
2197
2198 #[test]
2199 fn test_include_does_not_infer_metadata_from_included_filename() {
2200 let dir = tempfile::tempdir().unwrap();
2201 let included = dir.path().join("overrides.usage.kdl");
2202 let root = dir.path().join("my-script.usage.kdl");
2203 std::fs::write(&included, "").unwrap();
2204 std::fs::write(&root, "include file=\"./overrides.usage.kdl\"\n").unwrap();
2205
2206 let spec = Spec::parse_file(&root).unwrap();
2207
2208 assert_eq!(spec.name, "my-script.usage.kdl");
2209 assert_eq!(spec.bin, "my-script.usage.kdl");
2210 assert!(spec.cmd.name.is_empty());
2211 }
2212
2213 #[test]
2214 fn injected_nested_mounts_ignore_the_mounted_specs_root_default() {
2215 let mut spec: Spec = "mount run=outer".parse().unwrap();
2216 let outputs = HashMap::from([
2217 (
2218 "outer".to_string(),
2219 "default_subcommand run\nmount run=nested\ncmd run".to_string(),
2220 ),
2221 ("nested".to_string(), "cmd leaf".to_string()),
2222 ]);
2223
2224 spec.resolve_mount_outputs(&outputs).unwrap();
2225
2226 assert!(spec.cmd.subcommands.contains_key("leaf"));
2227 }
2228
2229 #[test]
2230 fn restamping_a_grafted_tree_matches_writing_it_out_and_reading_it_back() {
2231 let host: Spec =
2235 "name \"host\"\nbin \"host\"\ncmd \"plugins\" {\n external_subcommand #true\n}\n"
2236 .parse()
2237 .unwrap();
2238 let plugin: Spec =
2239 "name \"formatter\"\nbin \"formatter\"\narg \"[path]\"\ncmd \"check\" {\n flag \"--fix\"\n}\n"
2240 .parse()
2241 .unwrap();
2242
2243 let mut grafted = host.clone();
2244 grafted
2245 .cmd
2246 .subcommands
2247 .get_mut("plugins")
2248 .unwrap()
2249 .subcommands
2250 .insert("formatter".to_string(), plugin.cmd.clone());
2251 grafted.restamp();
2252
2253 let reparsed: Spec = grafted.to_string().parse().unwrap();
2254 let path = |spec: &Spec, words: &[&str]| {
2255 let mut cmd = &spec.cmd;
2256 for word in words {
2257 cmd = cmd.find_subcommand(word).unwrap();
2258 }
2259 (cmd.full_cmd.clone(), cmd.usage.clone())
2260 };
2261 for words in [
2262 &[][..],
2263 &["plugins"],
2264 &["plugins", "formatter"],
2265 &["plugins", "formatter", "check"],
2266 ] {
2267 assert_eq!(
2268 path(&grafted, words),
2269 path(&reparsed, words),
2270 "at {words:?}"
2271 );
2272 }
2273
2274 assert_eq!(
2277 path(&grafted, &["plugins", "formatter"]).0,
2278 ["plugins", "formatter"]
2279 );
2280 assert!(
2281 path(&grafted, &["plugins"]).1.contains("<SUBCOMMAND>"),
2282 "{:?}",
2283 path(&grafted, &["plugins"]).1
2284 );
2285 assert!(grafted
2287 .cmd
2288 .find_subcommand("plugins")
2289 .unwrap()
2290 .find_subcommand("formatter")
2291 .is_some());
2292 }
2293}