1pub mod admonition;
2pub mod arg;
3pub mod builder;
4pub mod choices;
5pub mod clause;
6pub mod cmd;
7pub mod complete;
8pub mod config;
9pub mod config_type;
10mod context;
11pub mod data_types;
12pub mod effect;
13pub mod exit_code;
14pub mod flag;
15pub mod flagset;
16pub mod group;
17pub mod helpers;
18pub mod mount;
19pub mod output;
20pub mod unknown_flags;
21pub mod view;
22
23use crate::kdl;
24use indexmap::IndexMap;
25use kdl::{KdlDocument, KdlEntry, KdlNode};
26use log::{debug, warn};
27use regex::Regex;
28use serde::Serialize;
29use std::collections::HashMap;
30use std::fmt::{Display, Formatter};
31use std::iter::once;
32use std::path::{Path, PathBuf};
33use std::str::FromStr;
34use std::sync::LazyLock;
35
36use crate::error::UsageErr;
37use crate::spec::cmd::{SpecCommand, SpecExample, SpecHeading};
38use crate::spec::config::SpecConfig;
39use crate::spec::context::ParsingContext;
40use crate::spec::exit_code::SpecExitCode;
41use crate::spec::flagset::{SpecFlagSet, SpecUse};
42use crate::spec::helpers::{NodeHelper, string_entry};
43use crate::spec::output::SpecOutput;
44use crate::{SpecArg, SpecComplete, SpecFlag};
45use view::SpecView;
46
47#[derive(Debug, Default, Clone, Serialize)]
48#[non_exhaustive]
49pub struct Spec {
50 pub name: String,
51 pub bin: String,
52 pub cmd: SpecCommand,
53 pub config: SpecConfig,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub version: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub long_version: Option<String>,
58 pub usage: String,
59 pub complete: IndexMap<String, SpecComplete>,
60 #[serde(skip_serializing_if = "IndexMap::is_empty")]
62 pub views: IndexMap<String, SpecView>,
63 #[serde(skip)]
69 pub flagsets: IndexMap<String, SpecFlagSet>,
70 #[serde(skip)]
79 pub sources: Vec<PathBuf>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub source_code_link_template: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
90 pub repository: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub author: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub about: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub about_long: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub about_md: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub license: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
110 pub logo: Option<String>,
111 #[serde(skip_serializing_if = "Option::is_none")]
117 pub logo_style: Option<String>,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub before_help: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub after_help: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub before_help_long: Option<String>,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub after_help_long: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
136 pub help_template: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub disable_help: Option<bool>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub min_usage_version: Option<String>,
141 #[serde(skip_serializing_if = "Vec::is_empty")]
142 pub examples: Vec<SpecExample>,
143 #[serde(skip_serializing_if = "Vec::is_empty")]
145 pub outputs: Vec<SpecOutput>,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub select: Option<String>,
149 #[serde(skip_serializing_if = "Vec::is_empty")]
151 pub exit_codes: Vec<SpecExitCode>,
152 #[serde(skip_serializing_if = "Option::is_none")]
155 pub default_subcommand: Option<String>,
156 #[serde(skip_serializing_if = "is_false")]
158 pub default_subcommand_flags: bool,
159 #[serde(skip)]
160 pub default_subcommand_flags_set: bool,
161 #[serde(skip_serializing_if = "is_false")]
163 pub default_subcommand_on_empty: bool,
164 #[serde(skip)]
165 pub default_subcommand_on_empty_set: bool,
166 #[serde(skip_serializing_if = "is_false")]
168 pub default_subcommand_help: bool,
169 #[serde(skip)]
170 pub default_subcommand_help_set: bool,
171 #[serde(default, skip_serializing_if = "is_false")]
178 pub multicall: bool,
179 #[doc(hidden)]
184 #[serde(skip)]
185 pub multicall_set: bool,
186 pub unknown_flags: Option<crate::spec::unknown_flags::UnknownFlags>,
189}
190
191impl Spec {
192 pub fn restamp(&mut self) {
205 restamp_paths(&mut self.cmd, &[], true);
206 }
207
208 pub fn resolve_mount_outputs(
214 &mut self,
215 outputs: &HashMap<String, String>,
216 ) -> Result<(), UsageErr> {
217 self.resolve_mount_outputs_at_root(outputs, true)
218 }
219
220 pub(crate) fn resolve_mount_outputs_at_root(
221 &mut self,
222 outputs: &HashMap<String, String>,
223 apply_default_subcommand: bool,
224 ) -> Result<(), UsageErr> {
225 fn resolve(
226 cmd: &mut SpecCommand,
227 outputs: &HashMap<String, String>,
228 skip_mounts: bool,
229 ) -> Result<(), UsageErr> {
230 if !skip_mounts && !cmd.mounts.is_empty() {
231 cmd.mount(&[], Some(outputs))?;
232 cmd.mounts.clear();
233 }
234 for subcommand in cmd.subcommands.values_mut() {
235 resolve(subcommand, outputs, false)?;
236 }
237 Ok(())
238 }
239
240 let default_outranks_root_mounts = apply_default_subcommand
241 && self.default_subcommand.is_some()
242 && !self.cmd.mounts.iter().any(|mount| mount.overrides_default);
243 resolve(&mut self.cmd, outputs, default_outranks_root_mounts)?;
244 self.restamp();
245 self.cmd
246 .validate_clause_flag_spellings()
247 .map_err(UsageErr::InvalidSpec)
248 }
249
250 #[must_use = "parsing result should be used"]
258 pub fn parse_file(file: &Path) -> Result<Spec, UsageErr> {
259 Self::parse_file_with_metadata_inference(file, true, true)
260 }
261
262 fn parse_file_with_metadata_inference(
263 file: &Path,
264 infer_metadata_from_filename: bool,
265 resolve_outputs: bool,
266 ) -> Result<Spec, UsageErr> {
267 Self::parse_file_with_metadata_inference_and_env(
268 file,
269 infer_metadata_from_filename,
270 resolve_outputs,
271 None,
272 )
273 }
274
275 fn parse_file_with_metadata_inference_and_env(
276 file: &Path,
277 infer_metadata_from_filename: bool,
278 resolve_outputs: bool,
279 include_env: Option<&HashMap<String, String>>,
280 ) -> Result<Spec, UsageErr> {
281 let spec = split_script(file)?;
282 let mut ctx = ParsingContext::new(file, &spec);
283 if let Some(env) = include_env {
284 ctx = ctx.with_include_env(env);
285 }
286 let mut schema = Self::parse_with_output_resolution(&ctx, &spec, resolve_outputs)?;
287 if infer_metadata_from_filename && schema.bin.is_empty() {
288 schema.bin = file
289 .file_name()
290 .and_then(|n| n.to_str())
291 .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
292 .to_string();
293 }
294 if schema.name.is_empty() {
295 schema.name.clone_from(&schema.bin);
296 }
297 Ok(schema)
298 }
299 #[must_use = "parsing result should be used"]
305 pub fn parse_script(file: &Path) -> Result<Spec, UsageErr> {
306 let mut spec = Self::parse_script_with_path(&read_to_string(file)?, file)?;
307 if spec.bin.is_empty() {
308 spec.bin = file
309 .file_name()
310 .and_then(|n| n.to_str())
311 .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
312 .to_string();
313 }
314 if spec.name.is_empty() {
315 spec.name.clone_from(&spec.bin);
316 }
317 Ok(spec)
318 }
319
320 #[must_use = "parsing result should be used"]
328 pub fn parse_script_str(input: &str) -> Result<Spec, UsageErr> {
329 Self::parse_script_with_path(input, Path::new(""))
330 }
331
332 #[must_use = "parsing result should be used"]
340 pub fn parse_str_with_path(input: &str, file: &Path) -> Result<Spec, UsageErr> {
341 Self::parse(&ParsingContext::new(file, input), input)
342 }
343
344 #[must_use = "parsing result should be used"]
351 pub fn parse_str_with_path_and_env(
352 input: &str,
353 file: &Path,
354 env: &HashMap<String, String>,
355 ) -> Result<Spec, UsageErr> {
356 let ctx = ParsingContext::new(file, input).with_include_env(env);
357 Self::parse(&ctx, input)
358 }
359
360 fn parse_script_with_path(input: &str, file: &Path) -> Result<Spec, UsageErr> {
361 let raw = extract_usage_from_comments(input);
362 let ctx = ParsingContext::new(file, &raw);
363 Self::parse(&ctx, &raw)
364 }
365
366 #[deprecated]
367 pub fn parse_spec(input: &str) -> Result<Spec, UsageErr> {
368 Self::parse(&Default::default(), input)
369 }
370
371 pub fn is_empty(&self) -> bool {
372 self.name.is_empty()
373 && self.bin.is_empty()
374 && self.usage.is_empty()
375 && self.cmd.is_empty()
376 && self.config.is_empty()
377 && self.complete.is_empty()
378 && self.views.is_empty()
379 && self.examples.is_empty()
380 }
381
382 pub fn completer<'a>(
393 &'a self,
394 cmd: &'a SpecCommand,
395 arg: &'a SpecArg,
396 ) -> Option<&'a SpecComplete> {
397 arg.complete.as_ref().or_else(|| {
398 let name = arg.name.to_lowercase();
399 if cmd.mounted {
400 return cmd.complete.get(&name);
401 }
402 self.complete.get(&name).or_else(|| cmd.complete.get(&name))
403 })
404 }
405
406 pub fn for_view(&self, id: &str) -> Result<Spec, UsageErr> {
412 let view = self
413 .views
414 .get(id)
415 .ok_or_else(|| UsageErr::InvalidView(format!("spec declares no view named `{id}`")))?;
416 let mut command = &self.cmd;
417 for segment in view.root.split_whitespace() {
418 command = command.subcommands.get(segment).ok_or_else(|| {
419 UsageErr::InvalidView(format!(
420 "view `{id}` promotes `{}`, but `{segment}` is not a command on that path",
421 view.root
422 ))
423 })?;
424 }
425 let mut promoted = command.clone();
426 let matches_selector = |flag: &SpecFlag, selector: &str| {
427 selector
428 .strip_prefix("--")
429 .is_some_and(|name| flag.long.iter().any(|long| long == name))
430 || selector
431 .strip_prefix('-')
432 .filter(|short| short.len() == 1)
433 .and_then(|short| short.chars().next())
434 .is_some_and(|short| flag.short.contains(&short))
435 };
436 let carries = |flag: &SpecFlag| {
437 if !flag.global {
438 return false;
439 }
440 view.all_globals
441 || view
442 .globals
443 .iter()
444 .any(|selector| matches_selector(flag, selector))
445 };
446 for selector in &view.globals {
447 if !self
448 .cmd
449 .flags
450 .iter()
451 .any(|flag| flag.global && matches_selector(flag, selector))
452 {
453 return Err(UsageErr::InvalidView(format!(
454 "view `{id}` carries `{selector}`, but it is not a root global flag"
455 )));
456 }
457 }
458 let globals: Vec<SpecFlag> = self
459 .cmd
460 .flags
461 .iter()
462 .filter(|flag| carries(flag) || flag.action == crate::SpecFlagAction::Version)
465 .cloned()
466 .collect();
467 let mut globals: Vec<SpecFlag> = globals
470 .into_iter()
471 .filter(|global| {
472 !promoted
473 .flags
474 .iter()
475 .any(|local| spec_flag_forms_overlap(global, local))
476 })
477 .collect();
478 let mut complete = IndexMap::new();
489 for flag in &mut globals {
490 if let Some(arg) = &mut flag.arg {
491 let name = arg.name.to_lowercase();
492 if let Some(completer) = self.complete.get(&name) {
493 if !promoted.mounted {
494 complete.insert(name, completer.clone());
495 } else if arg.complete.is_none() && !promoted.complete.contains_key(&name) {
496 arg.complete = Some(completer.clone());
497 }
498 }
499 }
500 }
501 if !promoted.mounted {
502 complete.extend(std::mem::take(&mut promoted.complete));
503 }
504 let mut carried_groups = Vec::new();
508 for group in &self.cmd.groups {
509 let members: Vec<String> = group
510 .members
511 .iter()
512 .filter(|selector| {
513 globals
514 .iter()
515 .any(|flag| flag_matches_selector(flag, selector))
516 })
517 .cloned()
518 .collect();
519 match members.as_slice() {
520 [only] if group.required => {
521 if let Some(flag) = globals
522 .iter_mut()
523 .find(|flag| flag_matches_selector(flag, only))
524 {
525 flag.required = true;
526 }
527 }
528 [_, _, ..] => {
529 let mut projected = group.clone();
530 projected.members = members;
531 carried_groups.push(projected);
532 }
533 _ => {}
534 }
535 }
536 promoted.flags.splice(0..0, globals);
537 promoted.groups.splice(0..0, carried_groups);
538 promoted.name.clone_from(&view.bin);
539 promoted.full_cmd.clear();
540 promoted.aliases.clear();
541 promoted.hidden_aliases.clear();
542 promoted.disable_version_flag = self.cmd.disable_version_flag;
546 set_subcommand_ancestors(&mut promoted, &[]);
547 promoted.usage = promoted.usage();
548
549 let mut spec = self.clone();
550 spec.name.clone_from(&view.name);
551 spec.bin.clone_from(&view.bin);
552 spec.about = promoted.help.clone();
553 spec.about_long = promoted.help_long.clone();
554 spec.about_md = promoted.help_md.clone();
555 spec.before_help = promoted.before_help.clone();
556 spec.before_help_long = promoted.before_help_long.clone();
557 spec.after_help = promoted.after_help.clone();
558 spec.after_help_long = promoted.after_help_long.clone();
559 spec.examples.clone_from(&promoted.examples);
560 spec.usage = promoted.usage.clone();
561 spec.complete = complete;
562 spec.cmd = promoted;
563 spec.default_subcommand = None;
564 spec.default_subcommand_flags = false;
565 spec.default_subcommand_flags_set = false;
566 spec.default_subcommand_on_empty = false;
567 spec.default_subcommand_on_empty_set = false;
568 spec.default_subcommand_help = false;
569 spec.default_subcommand_help_set = false;
570 spec.multicall = false;
571 spec.multicall_set = false;
572 spec.views.clear();
573 Ok(spec)
574 }
575
576 pub fn view_for_program(&self, program: &str) -> Option<&str> {
578 let basename = crate::parse::multicall_basename(program);
579 if basename == crate::parse::multicall_basename(&self.name)
580 || (!self.bin.is_empty() && basename == crate::parse::multicall_basename(&self.bin))
581 {
582 return None;
583 }
584 self.views.values().find_map(|view| {
585 (basename == crate::parse::multicall_basename(&view.bin)
586 || basename == crate::parse::multicall_basename(&view.id))
587 .then_some(view.id.as_str())
588 })
589 }
590
591 pub(crate) fn parse(ctx: &ParsingContext, input: &str) -> Result<Spec, UsageErr> {
592 Self::parse_with_output_resolution(ctx, input, true)
593 }
594
595 fn parse_with_output_resolution(
596 ctx: &ParsingContext,
597 input: &str,
598 resolve_outputs: bool,
599 ) -> Result<Spec, UsageErr> {
600 let kdl: KdlDocument = input.parse().map_err(|err: kdl::KdlError| {
601 UsageErr::KdlError(err.with_source_name(ctx.file.to_string_lossy()))
602 })?;
603 let mut schema = Self {
604 ..Default::default()
605 };
606 if !ctx.file.as_os_str().is_empty() {
609 schema.sources.push(ctx.file.clone());
610 }
611 for node in kdl.nodes().iter().map(|n| NodeHelper::new(ctx, n)) {
612 match node.name() {
613 "name" => schema.name = node.arg(0)?.ensure_string()?,
614 "bin" => {
615 schema.bin = node.arg(0)?.ensure_string()?;
616 if schema.name.is_empty() {
617 schema.name.clone_from(&schema.bin);
618 }
619 }
620 "version" => schema.version = Some(node.arg(0)?.ensure_string()?),
621 "long_version" => schema.long_version = Some(node.arg(0)?.ensure_string()?),
622 "author" => schema.author = Some(node.arg(0)?.ensure_string()?),
623 "source_code_link_template" => {
624 schema.source_code_link_template = Some(node.arg(0)?.ensure_string()?)
625 }
626 "repository" => schema.repository = Some(node.arg(0)?.ensure_string()?),
627 "about" => schema.about = Some(node.arg(0)?.ensure_string()?),
628 "long_about" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
629 "about_long" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
630 "about_md" => schema.about_md = Some(node.arg(0)?.ensure_string()?),
631 "surface" => schema.cmd.surface = Some(node.arg(0)?.ensure_string()?),
632 "available_if" => {
633 schema.cmd.available_if = node
634 .ensure_arg_len(1..)?
635 .args()
636 .map(|entry| entry.ensure_string())
637 .collect::<Result<Vec<_>, _>>()?;
638 }
639 "license" => schema.license = Some(node.arg(0)?.ensure_string()?),
640 "logo" => {
641 schema.logo = Some(node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
642 for (key, value) in node.props() {
643 match key {
644 "style" => {
648 let style = value.ensure_string()?;
649 if let Err(problem) = crate::help_template::check_style(&style) {
650 bail_parse!(ctx, value.entry.span(), "{problem}");
651 }
652 schema.logo_style = Some(style);
653 }
654 key => {
655 bail_parse!(ctx, value.entry.span(), "unsupported logo key {key}")
656 }
657 }
658 }
659 }
660 "before_help" => schema.before_help = Some(node.arg(0)?.ensure_string()?),
661 "after_help" => schema.after_help = Some(node.arg(0)?.ensure_string()?),
662 "before_long_help" | "before_help_long" => {
663 schema.before_help_long = Some(node.arg(0)?.ensure_string()?)
664 }
665 "after_long_help" | "after_help_long" => {
666 schema.after_help_long = Some(node.arg(0)?.ensure_string()?)
667 }
668 "usage" => schema.usage = node.arg(0)?.ensure_string()?,
669 "help_template" => {
673 let template = node.arg(0)?.ensure_string()?;
674 if let Err(problem) = crate::help_template::check(&template) {
675 bail_parse!(ctx, node.span(), "{problem}");
676 }
677 schema.help_template =
680 crate::help_template::is_set(&template).then_some(template);
681 }
682 "arg" => {
683 let arg = SpecArg::parse(ctx, &node)?;
684 if arg.delimiter.is_some() && !arg.var {
687 bail_parse!(
688 ctx,
689 node.node.name().span(),
690 "argument <{}> has a delimiter and holds one value; add \
691 `var=#true` for the values it splits into",
692 arg.name
693 );
694 }
695 schema.cmd.args.push(arg);
696 }
697 "clause" => {
698 if schema.cmd.clause.is_some() {
699 bail_parse!(ctx, node.span(), "a command may declare at most one clause");
700 }
701 schema.cmd.clause = Some(crate::SpecClause::parse(ctx, &node)?);
702 }
703 "flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?),
704 "group" => schema.cmd.groups.push(crate::SpecGroup::parse(ctx, &node)?),
707 "mount" => schema.cmd.mounts.push(crate::SpecMount::parse(ctx, &node)?),
711 "cmd" => {
712 let node: SpecCommand = SpecCommand::parse(ctx, &node)?;
713 schema.cmd.subcommands.insert(node.name.to_string(), node);
714 }
715 "flagset" => {
716 let set = SpecFlagSet::parse(ctx, &node)?;
717 if schema.flagsets.insert(set.name.clone(), set).is_some() {
718 bail_parse!(ctx, node.span(), "a flagset may be declared only once");
719 }
720 }
721 "use" => {
724 let at = schema.cmd.flags.len();
725 schema.cmd.uses.push(SpecUse::parse(ctx, &node, at)?);
726 }
727 "config" => schema.config = SpecConfig::parse(ctx, &node)?,
728 "complete" => {
729 let complete = SpecComplete::parse(ctx, &node)?;
730 schema.complete.insert(complete.name.clone(), complete);
731 }
732 "view" => {
733 let view = SpecView::parse(ctx, &node)?;
734 if schema.views.insert(view.id.clone(), view).is_some() {
735 bail_parse!(
736 ctx,
737 node.span(),
738 "a view identifier may be declared only once"
739 );
740 }
741 }
742 "disable_help" => schema.disable_help = Some(node.arg(0)?.ensure_bool()?),
743 "min_usage_version" => {
744 let v = node.arg(0)?.ensure_string()?;
745 check_usage_version(&v);
746 schema.min_usage_version = Some(v);
747 }
748 "unknown_flags" => {
749 let raw = node.arg(0)?.ensure_string()?;
750 match raw.parse() {
751 Ok(mode) => schema.unknown_flags = Some(mode),
752 Err(_) => bail_parse!(
753 ctx,
754 node.span(),
755 "unsupported unknown_flags {raw}, expected one of: {}",
756 crate::spec::unknown_flags::UNKNOWN_FLAGS_VALUES
757 ),
758 }
759 }
760 "default_subcommand" => {
761 schema.default_subcommand = Some(node.arg(0)?.ensure_string()?)
762 }
763 "default_subcommand_flags" => {
764 schema.default_subcommand_flags = node.arg(0)?.ensure_bool()?;
765 schema.default_subcommand_flags_set = true;
766 }
767 "default_subcommand_on_empty" => {
768 schema.default_subcommand_on_empty = node.arg(0)?.ensure_bool()?;
769 schema.default_subcommand_on_empty_set = true;
770 }
771 "default_subcommand_help" => {
772 schema.default_subcommand_help = node.arg(0)?.ensure_bool()?;
773 schema.default_subcommand_help_set = true;
774 }
775 "multicall" => {
776 schema.multicall = node.arg(0)?.ensure_bool()?;
777 schema.multicall_set = true;
778 }
779 "external_subcommand" => {
780 schema.cmd.external_subcommand = node.arg(0)?.ensure_bool()?;
781 }
782 "arg_required_else_help" => {
783 schema.cmd.arg_required_else_help = node.arg(0)?.ensure_bool()?;
784 }
785 "disable_help_flag" => {
786 schema.cmd.disable_help_flag = node.arg(0)?.ensure_bool()?;
787 }
788 "disable_help_subcommand" => {
789 schema.cmd.disable_help_subcommand = node.arg(0)?.ensure_bool()?;
790 }
791 "disable_version_flag" => {
792 schema.cmd.disable_version_flag = node.arg(0)?.ensure_bool()?;
793 }
794 "dont_delimit_trailing_values" => {
795 schema.cmd.dont_delimit_trailing_values = node.arg(0)?.ensure_bool()?;
796 }
797 "args_override_self" => {
798 schema.cmd.args_override_self = node.arg(0)?.ensure_bool()?;
799 }
800 "subcommand_negates_reqs" => {
801 schema.cmd.subcommand_negates_reqs = node.arg(0)?.ensure_bool()?;
802 }
803 "args_conflicts_with_subcommands" => {
804 schema.cmd.args_conflicts_with_subcommands = node.arg(0)?.ensure_bool()?;
805 }
806 "subcommand_precedence_over_arg" => {
807 schema.cmd.subcommand_precedence_over_arg = node.arg(0)?.ensure_bool()?;
808 }
809 "allow_missing_positional" => {
810 schema.cmd.allow_missing_positional = node.arg(0)?.ensure_bool()?;
811 }
812 "deprecated" => schema.cmd.deprecated = Some(node.arg(0)?.ensure_string()?),
813 "deprecated_warn_at" => {
814 schema.cmd.deprecated_warn_at = Some(node.arg(0)?.ensure_string()?);
815 }
816 "deprecated_remove_at" => {
817 schema.cmd.deprecated_remove_at = Some(node.arg(0)?.ensure_string()?);
818 }
819 "subcommand_required" => {
820 schema.cmd.subcommand_required = node.arg(0)?.ensure_bool()?;
821 }
822 "subcommand_help_heading" => {
823 schema.cmd.subcommand_help_heading = Some(node.arg(0)?.ensure_string()?);
824 }
825 "subcommand_value_name" => {
826 schema.cmd.subcommand_value_name = Some(node.arg(0)?.ensure_string()?);
827 }
828 "next_line_help" => {
829 schema.cmd.next_line_help = node.arg(0)?.ensure_bool()?;
830 }
831 "flatten_help" => {
832 schema.cmd.flatten_help = node.arg(0)?.ensure_bool()?;
833 }
834 "term_width" => {
835 schema.cmd.term_width = Some(node.arg(0)?.ensure_usize()?);
836 }
837 "max_term_width" => {
838 schema.cmd.max_term_width = Some(node.arg(0)?.ensure_usize()?);
839 }
840 "example" => {
841 let code = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
842 let mut example = SpecExample::new(code.trim().to_string());
843 for (k, v) in node.props() {
844 match k {
845 "header" => example.header = Some(v.ensure_string()?),
846 "help" => example.help = Some(v.ensure_string()?),
847 "lang" => example.lang = v.ensure_string()?,
848 k => bail_parse!(ctx, v.entry.span(), "unsupported example key {k}"),
849 }
850 }
851 schema.examples.push(example);
852 }
853 "heading" => {
854 let title = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
855 let mut help = None;
856 for (k, v) in node.props() {
857 match k {
858 "help" => help = Some(v.ensure_string()?),
859 k => bail_parse!(ctx, v.entry.span(), "unsupported heading key {k}"),
860 }
861 }
862 let Some(help) = help else {
863 bail_parse!(ctx, node.node.span(), "heading {title} needs help text");
864 };
865 schema.cmd.headings.push(SpecHeading::new(title, help));
866 }
867 "output" => schema.outputs.push(SpecOutput::parse(ctx, &node)?),
868 "exit_code" => schema.exit_codes.push(SpecExitCode::parse(ctx, &node)?),
869 "select" => {
870 schema.select = Some(node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
871 }
872 "include" => {
873 let file = node
874 .props()
875 .get("file")
876 .map(|v| v.ensure_string())
877 .transpose()?
878 .ok_or_else(|| ctx.build_err("missing file".into(), node.span()))?;
879 let file = match &ctx.include_env {
880 Some(env) => expand_include_env(&file, env)
881 .map_err(|msg| ctx.build_err(msg, node.span()))?,
882 None => file,
883 };
884 let file = Path::new(&file);
885 let file = match file.is_relative() {
886 true => ctx
887 .file
888 .parent()
889 .ok_or_else(|| {
890 let msg = if ctx.file.as_os_str().is_empty() {
891 "relative includes require a source file".to_string()
892 } else {
893 format!("cannot get parent of {}", ctx.file.display())
894 };
895 ctx.build_err(msg, node.span())
896 })?
897 .join(file),
898 false => file.to_path_buf(),
899 };
900 debug!("include: {}", file.display());
905 let other = Self::parse_file_with_metadata_inference_and_env(
906 &file,
907 false,
908 false,
909 ctx.include_env.as_ref(),
910 )?;
911 let clash = other.flagsets.values().find(|incoming| {
922 schema
923 .flagsets
924 .get(&incoming.name)
925 .is_some_and(|own| own.declared_in != incoming.declared_in)
926 });
927 if let Some(incoming) = clash {
928 let name = &incoming.name;
929 let owner = schema.flagsets[name].declared_in.clone();
930 let owner = match owner.as_os_str().is_empty() {
931 true => "this spec".to_string(),
932 false => owner.display().to_string(),
933 };
934 bail_parse!(
935 ctx,
936 node.span(),
937 "a flagset may be declared only once: \"{name}\" is declared in \
938 {} and in {owner}",
939 incoming.declared_in.display()
940 );
941 }
942 schema.merge(other);
943 }
944 k => bail_parse!(ctx, node.node.name().span(), "unsupported spec key {k}"),
945 }
946 }
947 schema.cmd.name = if schema.bin.is_empty() {
948 schema.name.clone()
949 } else {
950 schema.bin.clone()
951 };
952 if let Some(clause) = &schema.cmd.clause {
953 if !schema.cmd.args.is_empty() {
954 bail_parse!(
955 ctx,
956 kdl.span(),
957 "a command cannot declare both top-level arguments and a clause"
958 );
959 }
960 if schema.cmd.restart_token.is_some() {
961 bail_parse!(
962 ctx,
963 kdl.span(),
964 "a command cannot declare both restart_token and a clause"
965 );
966 }
967 if clause.args.iter().any(|arg| arg.sigil.is_some()) {
968 bail_parse!(
969 ctx,
970 kdl.span(),
971 "sigil arguments are not supported inside clauses"
972 );
973 }
974 if let Some(spelling) = clause.conflicting_flag_spelling(&schema.cmd.flags) {
975 bail_parse!(
976 ctx,
977 kdl.span(),
978 "clause flag spelling {spelling:?} conflicts with another flag on this command"
979 );
980 }
981 }
982 flagset::expand(ctx, &mut schema.cmd, &mut schema.flagsets)?;
985 schema
986 .cmd
987 .validate_clause_flag_spellings()
988 .map_err(|message| ctx.build_err(message, (0, ctx.spec.len()).into()))?;
989 if resolve_outputs {
990 output::resolve_selectors(&mut schema)?;
991 }
992 schema
993 .cmd
994 .validate_sigil_prefixes()
995 .map_err(|message| ctx.build_err(message, (0, ctx.spec.len()).into()))?;
996 schema.sources.extend(ctx.sources());
997 set_subcommand_ancestors(&mut schema.cmd, &[]);
998 Ok(schema)
999 }
1000
1001 pub fn merge(&mut self, other: Spec) {
1002 macro_rules! merge_str {
1003 ($field:ident) => {
1004 if !other.$field.is_empty() {
1005 self.$field = other.$field;
1006 }
1007 };
1008 }
1009 macro_rules! merge_opt {
1010 ($field:ident) => {
1011 if other.$field.is_some() {
1012 self.$field = other.$field;
1013 }
1014 };
1015 }
1016 macro_rules! merge_extend {
1017 ($field:ident) => {
1018 if !other.$field.is_empty() {
1019 self.$field.extend(other.$field);
1020 }
1021 };
1022 }
1023
1024 merge_str!(name);
1025 merge_str!(bin);
1026 merge_str!(usage);
1027 merge_opt!(about);
1028 merge_opt!(source_code_link_template);
1029 merge_opt!(repository);
1030 merge_opt!(version);
1031 merge_opt!(long_version);
1032 merge_opt!(author);
1033 merge_opt!(about_long);
1034 merge_opt!(about_md);
1035 merge_opt!(license);
1036 merge_opt!(logo);
1037 merge_opt!(logo_style);
1038 merge_opt!(before_help);
1039 merge_opt!(after_help);
1040 merge_opt!(before_help_long);
1041 merge_opt!(after_help_long);
1042 merge_opt!(help_template);
1043 merge_opt!(disable_help);
1044 merge_opt!(min_usage_version);
1045 merge_opt!(default_subcommand);
1046 if other.default_subcommand_flags_set {
1047 self.default_subcommand_flags = other.default_subcommand_flags;
1048 self.default_subcommand_flags_set = true;
1049 }
1050 if other.default_subcommand_on_empty_set {
1051 self.default_subcommand_on_empty = other.default_subcommand_on_empty;
1052 self.default_subcommand_on_empty_set = true;
1053 }
1054 if other.default_subcommand_help_set {
1055 self.default_subcommand_help = other.default_subcommand_help;
1056 self.default_subcommand_help_set = true;
1057 }
1058 if other.multicall_set {
1059 self.multicall = other.multicall;
1060 self.multicall_set = true;
1061 }
1062 merge_opt!(unknown_flags);
1063 merge_extend!(complete);
1064 merge_extend!(views);
1065 merge_extend!(flagsets);
1072 merge_extend!(examples);
1073 merge_extend!(outputs);
1074 merge_extend!(exit_codes);
1075 merge_opt!(select);
1076 merge_extend!(sources);
1078
1079 if !other.config.is_empty() {
1080 self.config.merge(&other.config);
1081 }
1082 self.cmd.merge(other.cmd);
1083 }
1084}
1085
1086pub(crate) fn spec_flag_forms_overlap(a: &SpecFlag, b: &SpecFlag) -> bool {
1087 fn long_forms(flag: &SpecFlag) -> impl Iterator<Item = &str> {
1088 flag.long
1089 .iter()
1090 .chain(&flag.hidden_aliases)
1091 .map(String::as_str)
1092 .chain(
1093 flag.negate
1094 .as_deref()
1095 .map(|name| name.strip_prefix("--").unwrap_or(name)),
1096 )
1097 }
1098 fn short_forms(flag: &SpecFlag) -> impl Iterator<Item = &char> {
1099 flag.short.iter().chain(&flag.hidden_short_aliases)
1100 }
1101
1102 long_forms(a).any(|name| long_forms(b).any(|other| other == name))
1103 || short_forms(a).any(|name| short_forms(b).any(|other| other == name))
1104}
1105
1106fn flag_matches_selector(flag: &SpecFlag, selector: &str) -> bool {
1107 selector.strip_prefix("--").is_some_and(|name| {
1108 flag.long
1109 .iter()
1110 .chain(&flag.hidden_aliases)
1111 .any(|long| long == name)
1112 || flag
1113 .negate
1114 .as_deref()
1115 .is_some_and(|negate| negate.strip_prefix("--").unwrap_or(negate) == name)
1116 }) || selector
1117 .strip_prefix('-')
1118 .filter(|short| short.len() == 1)
1119 .and_then(|short| short.chars().next())
1120 .is_some_and(|short| {
1121 flag.short
1122 .iter()
1123 .chain(&flag.hidden_short_aliases)
1124 .any(|candidate| *candidate == short)
1125 })
1126}
1127
1128fn check_usage_version(version: &str) {
1129 let cur = semver::Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
1130 match parse_usage_version(version) {
1131 Ok(v) => {
1132 if cur < v {
1133 warn!(
1134 "This usage spec requires at least version {version}, but you are using version {cur} of usage"
1135 );
1136 }
1137 }
1138 Err(_) => warn!("Invalid version: {version}"),
1139 }
1140}
1141
1142fn parse_usage_version(version: &str) -> Result<semver::Version, semver::Error> {
1148 let core_end = version.find(['-', '+']).unwrap_or(version.len());
1149 let (core, suffix) = version.split_at(core_end);
1150 let missing = match core.matches('.').count() {
1151 0 => ".0.0",
1152 1 => ".0",
1153 _ => "",
1154 };
1155
1156 semver::Version::parse(&format!("{core}{missing}{suffix}"))
1157}
1158
1159fn read_to_string(file: &Path) -> Result<String, UsageErr> {
1164 std::fs::read_to_string(file).map_err(|err| UsageErr::FileError(err, file.to_path_buf()))
1165}
1166
1167static USAGE_COMMENT: LazyLock<Regex> =
1170 LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])(.*)$").unwrap());
1171static HAS_USAGE_COMMENT: LazyLock<Regex> =
1174 LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])").unwrap());
1175static BLANK_COMMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(?:#|//|::)\s*$").unwrap());
1177
1178fn split_script(file: &Path) -> Result<String, UsageErr> {
1179 let full = read_to_string(file)?;
1180 if full.starts_with("#!") && full.lines().any(|l| HAS_USAGE_COMMENT.is_match(l)) {
1182 return Ok(extract_usage_from_comments(&full));
1183 }
1184 Ok(full)
1186}
1187
1188fn extract_usage_from_comments(full: &str) -> String {
1189 let mut usage = vec![];
1190 let mut found = false;
1191 for line in full.lines() {
1192 if let Some(captures) = USAGE_COMMENT.captures(line) {
1193 found = true;
1194 let content = captures.get(1).map_or("", |m| m.as_str());
1195 usage.push(content.trim());
1196 } else if found {
1197 if BLANK_COMMENT.is_match(line) {
1199 continue;
1200 }
1201 break;
1203 }
1204 }
1205 usage.join("\n")
1206}
1207
1208fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) {
1209 restamp_paths(cmd, ancestors, false);
1210}
1211
1212fn restamp_paths(cmd: &mut SpecCommand, ancestors: &[String], force: bool) {
1226 for subcmd in cmd.subcommands.values_mut() {
1227 subcmd.full_cmd = ancestors
1228 .iter()
1229 .cloned()
1230 .chain(once(subcmd.name.clone()))
1231 .collect();
1232 let child_ancestors = subcmd.full_cmd.clone();
1233 restamp_paths(subcmd, &child_ancestors, force);
1234 }
1235 if force {
1236 cmd.reset_subcommand_lookup();
1239 cmd.usage = cmd.usage();
1240 } else if cmd.usage.is_empty() {
1241 cmd.usage = cmd.usage();
1242 }
1243}
1244
1245impl Display for Spec {
1246 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1247 let mut doc = KdlDocument::new();
1248 let nodes = &mut doc.nodes_mut();
1249 if !self.name.is_empty() {
1250 let mut node = KdlNode::new("name");
1251 node.push(string_entry(None, &self.name));
1252 nodes.push(node);
1253 }
1254 if !self.bin.is_empty() {
1255 let mut node = KdlNode::new("bin");
1256 node.push(string_entry(None, &self.bin));
1257 nodes.push(node);
1258 }
1259 if let Some(version) = &self.version {
1260 let mut node = KdlNode::new("version");
1261 node.push(string_entry(None, version));
1262 nodes.push(node);
1263 }
1264 if let Some(version) = &self.long_version {
1265 let mut node = KdlNode::new("long_version");
1266 node.push(string_entry(None, version));
1267 nodes.push(node);
1268 }
1269 if let Some(author) = &self.author {
1270 let mut node = KdlNode::new("author");
1271 node.push(string_entry(None, author));
1272 nodes.push(node);
1273 }
1274 if let Some(about) = &self.about {
1275 let mut node = KdlNode::new("about");
1276 node.push(string_entry(None, about));
1277 nodes.push(node);
1278 }
1279 if let Some(source_code_link_template) = &self.source_code_link_template {
1280 let mut node = KdlNode::new("source_code_link_template");
1281 node.push(string_entry(None, source_code_link_template));
1282 nodes.push(node);
1283 }
1284 if let Some(repository) = &self.repository {
1285 let mut node = KdlNode::new("repository");
1286 node.push(string_entry(None, repository));
1287 nodes.push(node);
1288 }
1289 if let Some(about_md) = &self.about_md {
1290 let mut node = KdlNode::new("about_md");
1291 node.push(string_entry(None, about_md));
1292 nodes.push(node);
1293 }
1294 if let Some(long_about) = &self.about_long {
1295 let mut node = KdlNode::new("long_about");
1296 node.push(string_entry(None, long_about));
1297 nodes.push(node);
1298 }
1299 if let Some(license) = &self.license {
1300 let mut node = KdlNode::new("license");
1301 node.push(string_entry(None, license));
1302 nodes.push(node);
1303 }
1304 if let Some(logo) = &self.logo {
1305 let mut node = KdlNode::new("logo");
1306 node.push(string_entry(None, logo));
1307 if let Some(style) = &self.logo_style {
1308 node.push(string_entry(Some("style"), style));
1309 }
1310 nodes.push(node);
1311 }
1312 if let Some(before_help) = &self.before_help {
1313 let mut node = KdlNode::new("before_help");
1314 node.push(string_entry(None, before_help));
1315 nodes.push(node);
1316 }
1317 if let Some(after_help) = &self.after_help {
1318 let mut node = KdlNode::new("after_help");
1319 node.push(string_entry(None, after_help));
1320 nodes.push(node);
1321 }
1322 if let Some(before_help_long) = &self.before_help_long {
1323 let mut node = KdlNode::new("before_long_help");
1324 node.push(string_entry(None, before_help_long));
1325 nodes.push(node);
1326 }
1327 if let Some(after_help_long) = &self.after_help_long {
1328 let mut node = KdlNode::new("after_long_help");
1329 node.push(string_entry(None, after_help_long));
1330 nodes.push(node);
1331 }
1332 if let Some(help_template) = &self.help_template {
1333 let mut node = KdlNode::new("help_template");
1334 node.push(string_entry(None, help_template));
1335 nodes.push(node);
1336 }
1337 if let Some(disable_help) = self.disable_help {
1338 let mut node = KdlNode::new("disable_help");
1339 node.push(KdlEntry::new(disable_help));
1340 nodes.push(node);
1341 }
1342 if let Some(min_usage_version) = &self.min_usage_version {
1343 let mut node = KdlNode::new("min_usage_version");
1344 node.push(string_entry(None, min_usage_version));
1345 nodes.push(node);
1346 }
1347 if let Some(unknown_flags) = &self.unknown_flags {
1348 let mut node = KdlNode::new("unknown_flags");
1349 node.push(string_entry(None, unknown_flags.as_str()));
1350 nodes.push(node);
1351 }
1352 if let Some(default_subcommand) = &self.default_subcommand {
1353 let mut node = KdlNode::new("default_subcommand");
1354 node.push(string_entry(None, default_subcommand));
1355 nodes.push(node);
1356 }
1357 if self.default_subcommand_flags_set || self.default_subcommand_flags {
1358 let mut node = KdlNode::new("default_subcommand_flags");
1359 node.push(KdlEntry::new(self.default_subcommand_flags));
1360 nodes.push(node);
1361 }
1362 if self.default_subcommand_on_empty_set || self.default_subcommand_on_empty {
1363 let mut node = KdlNode::new("default_subcommand_on_empty");
1364 node.push(KdlEntry::new(self.default_subcommand_on_empty));
1365 nodes.push(node);
1366 }
1367 if self.default_subcommand_help_set || self.default_subcommand_help {
1368 let mut node = KdlNode::new("default_subcommand_help");
1369 node.push(KdlEntry::new(self.default_subcommand_help));
1370 nodes.push(node);
1371 }
1372 if self.multicall_set {
1373 let mut node = KdlNode::new("multicall");
1374 node.push(KdlEntry::new(self.multicall));
1375 nodes.push(node);
1376 }
1377 if self.cmd.external_subcommand {
1378 let mut node = KdlNode::new("external_subcommand");
1379 node.push(KdlEntry::new(true));
1380 nodes.push(node);
1381 }
1382 if self.cmd.arg_required_else_help {
1383 let mut node = KdlNode::new("arg_required_else_help");
1384 node.push(KdlEntry::new(true));
1385 nodes.push(node);
1386 }
1387 if self.cmd.disable_help_flag {
1388 let mut node = KdlNode::new("disable_help_flag");
1389 node.push(KdlEntry::new(true));
1390 nodes.push(node);
1391 }
1392 if self.cmd.disable_help_subcommand {
1393 let mut node = KdlNode::new("disable_help_subcommand");
1394 node.push(KdlEntry::new(true));
1395 nodes.push(node);
1396 }
1397 if self.cmd.disable_version_flag {
1398 let mut node = KdlNode::new("disable_version_flag");
1399 node.push(KdlEntry::new(true));
1400 nodes.push(node);
1401 }
1402 if self.cmd.dont_delimit_trailing_values {
1403 let mut node = KdlNode::new("dont_delimit_trailing_values");
1404 node.push(true);
1405 nodes.push(node);
1406 }
1407 if !self.cmd.args_override_self {
1408 let mut node = KdlNode::new("args_override_self");
1409 node.push(false);
1410 nodes.push(node);
1411 }
1412 if self.cmd.subcommand_negates_reqs {
1413 let mut node = KdlNode::new("subcommand_negates_reqs");
1414 node.push(true);
1415 nodes.push(node);
1416 }
1417 if self.cmd.args_conflicts_with_subcommands {
1418 let mut node = KdlNode::new("args_conflicts_with_subcommands");
1419 node.push(true);
1420 nodes.push(node);
1421 }
1422 if self.cmd.subcommand_precedence_over_arg {
1423 let mut node = KdlNode::new("subcommand_precedence_over_arg");
1424 node.push(true);
1425 nodes.push(node);
1426 }
1427 if self.cmd.allow_missing_positional {
1428 let mut node = KdlNode::new("allow_missing_positional");
1429 node.push(true);
1430 nodes.push(node);
1431 }
1432 if let Some(message) = &self.cmd.deprecated {
1433 let mut node = KdlNode::new("deprecated");
1434 node.push(string_entry(None, message));
1435 nodes.push(node);
1436 }
1437 if let Some(surface) = &self.cmd.surface {
1438 let mut node = KdlNode::new("surface");
1439 node.push(string_entry(None, surface));
1440 nodes.push(node);
1441 }
1442 if !self.cmd.available_if.is_empty() {
1443 let mut node = KdlNode::new("available_if");
1444 for condition in &self.cmd.available_if {
1445 node.push(string_entry(None, condition));
1446 }
1447 nodes.push(node);
1448 }
1449 if let Some(at) = &self.cmd.deprecated_warn_at {
1450 let mut node = KdlNode::new("deprecated_warn_at");
1451 node.push(string_entry(None, at));
1452 nodes.push(node);
1453 }
1454 if let Some(at) = &self.cmd.deprecated_remove_at {
1455 let mut node = KdlNode::new("deprecated_remove_at");
1456 node.push(string_entry(None, at));
1457 nodes.push(node);
1458 }
1459 if self.cmd.subcommand_required && !self.cmd.subcommands.is_empty() {
1460 let mut node = KdlNode::new("subcommand_required");
1461 node.push(true);
1462 nodes.push(node);
1463 }
1464 if let Some(heading) = &self.cmd.subcommand_help_heading {
1465 let mut node = KdlNode::new("subcommand_help_heading");
1466 node.push(string_entry(None, heading));
1467 nodes.push(node);
1468 }
1469 if let Some(name) = &self.cmd.subcommand_value_name {
1470 let mut node = KdlNode::new("subcommand_value_name");
1471 node.push(string_entry(None, name));
1472 nodes.push(node);
1473 }
1474 if self.cmd.next_line_help {
1475 let mut node = KdlNode::new("next_line_help");
1476 node.push(true);
1477 nodes.push(node);
1478 }
1479 if self.cmd.flatten_help {
1480 let mut node = KdlNode::new("flatten_help");
1481 node.push(true);
1482 nodes.push(node);
1483 }
1484 if let Some(width) = self.cmd.term_width {
1485 let mut node = KdlNode::new("term_width");
1486 node.push(width as i128);
1487 nodes.push(node);
1488 }
1489 if let Some(width) = self.cmd.max_term_width {
1490 let mut node = KdlNode::new("max_term_width");
1491 node.push(width as i128);
1492 nodes.push(node);
1493 }
1494 if !self.usage.is_empty() {
1495 let mut node = KdlNode::new("usage");
1496 node.push(string_entry(None, &self.usage));
1497 nodes.push(node);
1498 }
1499 for flag in self.cmd.flags.iter() {
1500 nodes.push(flag.into());
1501 }
1502 for arg in self.cmd.args.iter() {
1503 nodes.push(arg.into());
1504 }
1505 if let Some(clause) = &self.cmd.clause {
1506 nodes.push(clause.into());
1507 }
1508 for mount in self.cmd.mounts.iter() {
1511 nodes.push(mount.into());
1512 }
1513 for group in self.cmd.groups.iter() {
1514 nodes.push(group.into());
1515 }
1516 for example in self.examples.iter() {
1517 nodes.push(example.into());
1518 }
1519 for heading in self.cmd.headings.iter() {
1520 nodes.push(heading.into());
1521 }
1522 for output in self.outputs.iter() {
1523 nodes.push(output.into());
1524 }
1525 if let Some(select) = &self.select {
1526 let mut node = KdlNode::new("select");
1527 node.push(string_entry(None, select));
1528 nodes.push(node);
1529 }
1530 for exit_code in self.exit_codes.iter() {
1531 nodes.push(exit_code.into());
1532 }
1533 for complete in self.complete.values() {
1534 nodes.push(complete.into());
1535 }
1536 for complete in self.cmd.complete.values() {
1537 nodes.push(complete.into());
1538 }
1539 for view in self.views.values() {
1540 let rendered: KdlDocument = view
1541 .to_string()
1542 .parse()
1543 .expect("a view always renders valid KDL");
1544 nodes.extend(rendered.nodes().iter().cloned());
1545 }
1546 for cmd in self.cmd.subcommands.values() {
1547 nodes.push(cmd.into())
1548 }
1549 if !self.config.is_empty() {
1550 nodes.push((&self.config).into());
1551 }
1552 doc.autoformat_config(&kdl::FormatConfig::default());
1553 write!(f, "{doc}")
1554 }
1555}
1556
1557fn expand_include_env(input: &str, env: &HashMap<String, String>) -> Result<String, String> {
1558 let mut output = String::with_capacity(input.len());
1559 let mut chars = input.chars().peekable();
1560 while let Some(ch) = chars.next() {
1561 if ch != '$' {
1562 output.push(ch);
1563 continue;
1564 }
1565 if chars.next_if_eq(&'$').is_some() {
1566 output.push('$');
1567 continue;
1568 }
1569
1570 let (name, braced) = if chars.next_if_eq(&'{').is_some() {
1571 let mut name = String::new();
1572 let mut closed = false;
1573 for ch in chars.by_ref() {
1574 if ch == '}' {
1575 closed = true;
1576 break;
1577 }
1578 name.push(ch);
1579 }
1580 if !closed {
1581 return Err("include path contains an unterminated environment reference".into());
1582 }
1583 (name, true)
1584 } else {
1585 let mut name = String::new();
1586 while chars
1587 .peek()
1588 .is_some_and(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
1589 {
1590 name.push(chars.next().expect("peeked character exists"));
1591 }
1592 (name, false)
1593 };
1594 if name.is_empty() {
1595 output.push_str(if braced { "${}" } else { "$" });
1596 continue;
1597 }
1598 let value = env.get(&name).ok_or_else(|| {
1599 format!("include path references undefined environment variable {name}")
1600 })?;
1601 output.push_str(value);
1602 }
1603 Ok(output)
1604}
1605
1606impl FromStr for Spec {
1607 type Err = UsageErr;
1608
1609 fn from_str(s: &str) -> Result<Self, Self::Err> {
1610 Self::parse(&ParsingContext::new(Path::new(""), s), s)
1616 }
1617}
1618
1619#[cfg(feature = "clap")]
1620impl From<&clap::Command> for Spec {
1621 fn from(cmd: &clap::Command) -> Self {
1622 let mut spec = Spec {
1623 name: cmd.get_name().to_string(),
1624 bin: cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string(),
1625 cmd: cmd.into(),
1626 version: cmd.get_version().map(|v| v.to_string()),
1627 long_version: cmd.get_long_version().map(|v| v.to_string()),
1628 about: cmd.get_about().map(|a| a.to_string()),
1629 about_long: cmd.get_long_about().map(|a| a.to_string()),
1630 usage: cmd.clone().render_usage().to_string(),
1631 unknown_flags: crate::spec::cmd::SpecCommand::from(cmd).unknown_flags,
1634 multicall: cmd.is_multicall_set(),
1635 multicall_set: cmd.is_multicall_set(),
1636 ..Default::default()
1637 };
1638 set_subcommand_ancestors(&mut spec.cmd, &[]);
1643 spec
1644 }
1645}
1646
1647impl From<SpecCommand> for Spec {
1653 fn from(cmd: SpecCommand) -> Self {
1654 let mut spec = Self {
1655 name: cmd.name.clone(),
1656 bin: cmd.name.clone(),
1657 about: cmd.help.clone(),
1658 about_long: cmd.help_long.clone(),
1659 cmd,
1660 ..Self::default()
1661 };
1662 spec.restamp();
1665 spec
1666 }
1667}
1668
1669#[inline]
1670pub fn is_true(b: &bool) -> bool {
1671 *b
1672}
1673
1674#[inline]
1675pub fn is_false(b: &bool) -> bool {
1676 !is_true(b)
1677}
1678
1679#[cfg(test)]
1680mod tests {
1681 use super::*;
1682 use insta::assert_snapshot;
1683
1684 #[test]
1685 fn parses_relaxed_usage_versions_as_semver() {
1686 assert_eq!(
1687 parse_usage_version("6").unwrap(),
1688 semver::Version::new(6, 0, 0)
1689 );
1690 assert_eq!(
1691 parse_usage_version("6.1").unwrap(),
1692 semver::Version::new(6, 1, 0)
1693 );
1694 assert_eq!(
1695 parse_usage_version("6.1.2-beta.1+build").unwrap(),
1696 semver::Version::parse("6.1.2-beta.1+build").unwrap()
1697 );
1698 assert!(parse_usage_version("not-a-version").is_err());
1699 assert!(parse_usage_version("6.1.2.3").is_err());
1700 }
1701
1702 #[test]
1703 fn test_display() {
1704 let spec = Spec::parse(
1705 &Default::default(),
1706 r#"
1707name "Usage CLI"
1708bin "usage"
1709arg "arg1"
1710flag "-f --force" global=#true
1711cmd "config" {
1712 cmd "set" {
1713 arg "key" help="Key to set"
1714 arg "value"
1715 }
1716}
1717complete "file" run="ls" descriptions=#true
1718 "#,
1719 )
1720 .unwrap();
1721 assert_snapshot!(spec, @r#"
1722 name "Usage CLI"
1723 bin usage
1724 flag "-f --force" global=#true
1725 arg <arg1>
1726 complete file run=ls descriptions=#true
1727 cmd config {
1728 cmd set {
1729 arg <key> help="Key to set"
1730 arg <value>
1731 }
1732 }
1733 "#);
1734 }
1735
1736 #[test]
1737 fn test_repository_round_trips() {
1738 let spec = Spec::parse(
1739 &Default::default(),
1740 r#"
1741bin "mise"
1742repository "https://github.com/jdx/mise"
1743source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1744 "#,
1745 )
1746 .unwrap();
1747 assert_eq!(
1748 spec.repository.as_deref(),
1749 Some("https://github.com/jdx/mise")
1750 );
1751 assert_snapshot!(spec, @r#"
1754 name mise
1755 bin mise
1756 source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1757 repository "https://github.com/jdx/mise"
1758 "#);
1759 }
1760
1761 #[test]
1762 fn test_repository_merges_like_the_other_optionals() {
1763 let mut generated = Spec::parse(&Default::default(), r#"bin "mise""#).unwrap();
1766 let extra = Spec::parse(
1767 &Default::default(),
1768 r#"repository "https://github.com/jdx/mise""#,
1769 )
1770 .unwrap();
1771 generated.merge(extra);
1772 assert_eq!(
1773 generated.repository.as_deref(),
1774 Some("https://github.com/jdx/mise")
1775 );
1776 }
1777
1778 #[test]
1779 #[cfg(feature = "clap")]
1780 fn test_clap() {
1781 let cmd = clap::Command::new("test");
1782 assert_snapshot!(Spec::from(&cmd), @r#"
1783 name test
1784 bin test
1785 unknown_flags error
1786 args_override_self #false
1787 usage "Usage: test"
1788 "#);
1789 }
1790
1791 #[test]
1792 #[cfg(feature = "clap")]
1793 fn a_clap_subcommand_knows_where_it_sits() {
1794 let cmd = clap::Command::new("ex").subcommand(
1799 clap::Command::new("go")
1800 .about("Go somewhere")
1801 .subcommand(clap::Command::new("fast").about("Quickly")),
1802 );
1803 let spec = Spec::from(&cmd);
1804
1805 let go = spec.cmd.subcommands.get("go").expect("go");
1806 assert_eq!(go.full_cmd, ["go"]);
1807 assert_eq!(go.usage, "go [SUBCOMMAND]");
1810
1811 let fast = go.subcommands.get("fast").expect("fast");
1813 assert_eq!(fast.full_cmd, ["go", "fast"]);
1814 assert_eq!(fast.usage, "go fast");
1815 }
1816
1817 #[test]
1818 #[cfg(feature = "clap")]
1819 fn a_delimited_default_becomes_the_values_clap_would_split_it_into() {
1820 let cmd = clap::Command::new("test").arg(
1828 clap::Arg::new("events")
1829 .long("events")
1830 .value_delimiter(',')
1831 .action(clap::ArgAction::Append)
1832 .value_parser(["a", "b", "c"])
1833 .default_value("a,b"),
1834 );
1835 let spec = Spec::from(&cmd);
1836 let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1837 assert_eq!(flag.default, ["a", "b"]);
1838
1839 let cmd = clap::Command::new("test")
1842 .arg(clap::Arg::new("events").long("events").default_value("a,b"));
1843 let spec = Spec::from(&cmd);
1844 let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1845 assert_eq!(flag.default, ["a,b"]);
1846 }
1847
1848 #[test]
1849 fn multicall_round_trips() {
1850 let spec = Spec::parse(
1851 &Default::default(),
1852 r#"
1853name "busybox"
1854bin "busybox"
1855multicall #true
1856cmd "ls"
1857cmd "cat"
1858 "#,
1859 )
1860 .unwrap();
1861 assert!(spec.multicall);
1862 let emitted = spec.to_string();
1863 assert!(
1864 emitted.contains("multicall #true"),
1865 "lost on the way out: {emitted}"
1866 );
1867 let again: Spec = emitted.parse().unwrap();
1868 assert!(again.multicall);
1869 }
1870
1871 #[test]
1872 fn a_declared_view_promotes_a_command_and_selected_globals() {
1873 let spec: Spec = r#"
1874name "aube"
1875bin "aube"
1876about_md "Host **markdown**"
1877before_help "host before"
1878before_long_help "host long before"
1879after_help "host after"
1880after_long_help "host long after"
1881example "aube host" header="host example"
1882flag "-v --verbose" global=#true
1883flag "--config <FILE>" global=#true
1884view "aubr" root="run" {
1885 global "--verbose"
1886}
1887cmd "run" help="Run a package script" {
1888 before_help "run before"
1889 before_long_help "run long before"
1890 after_help "run after"
1891 after_long_help "run long after"
1892 example "aubr task" header="run example"
1893 flag "--if-present"
1894 arg "[SCRIPT]"
1895 cmd "nested"
1896}
1897"#
1898 .parse()
1899 .unwrap();
1900
1901 let rendered = spec.to_string();
1902 assert!(rendered.contains("view aubr root=run"), "{rendered}");
1903 let reparsed: Spec = rendered.parse().unwrap();
1904 let applet = reparsed.for_view("aubr").unwrap();
1905 assert_eq!(applet.name, "aubr");
1906 assert_eq!(applet.bin, "aubr");
1907 assert_eq!(applet.about.as_deref(), Some("Run a package script"));
1908 assert_eq!(applet.about_md, None);
1909 assert_eq!(applet.before_help.as_deref(), Some("run before"));
1910 assert_eq!(applet.before_help_long.as_deref(), Some("run long before"));
1911 assert_eq!(applet.after_help.as_deref(), Some("run after"));
1912 assert_eq!(applet.after_help_long.as_deref(), Some("run long after"));
1913 assert_eq!(applet.examples.len(), 1);
1914 assert_eq!(applet.examples[0].header.as_deref(), Some("run example"));
1915 assert!(applet.cmd.flags.iter().any(|flag| flag.name == "verbose"));
1916 assert!(
1917 applet
1918 .cmd
1919 .flags
1920 .iter()
1921 .any(|flag| flag.name == "if-present")
1922 );
1923 assert!(!applet.cmd.flags.iter().any(|flag| flag.name == "config"));
1924 assert!(applet.cmd.subcommands.contains_key("nested"));
1925 assert!(applet.views.is_empty());
1926 assert!(applet.to_string().contains("bin aubr"));
1927 }
1928
1929 #[test]
1930 fn a_view_preserves_the_host_version_entry_policy() {
1931 let spec: Spec = r#"
1932bin "host"
1933version "1.2.3"
1934disable_version_flag #true
1935view "runner" root=run
1936cmd "run"
1937"#
1938 .parse()
1939 .unwrap();
1940
1941 let view = spec.for_view("runner").unwrap();
1942 assert!(view.cmd.disable_version_flag);
1943 let emitted = view.to_string();
1944 assert!(emitted.contains("disable_version_flag #true"), "{emitted}");
1945 let reparsed: Spec = emitted.parse().unwrap();
1946 assert!(reparsed.cmd.disable_version_flag);
1947 }
1948
1949 #[test]
1950 fn a_promoted_flag_shadows_every_carried_global_spelling() {
1951 let spec: Spec = r#"
1952bin "host"
1953flag "--color" global=#true negate="--no-color"
1954view "runner" root=run {
1955 global "--color"
1956}
1957cmd "run" {
1958 flag "--no-color"
1959}
1960"#
1961 .parse()
1962 .unwrap();
1963
1964 let view = spec.for_view("runner").unwrap();
1965 assert_eq!(view.cmd.flags.len(), 1);
1966 assert_eq!(view.cmd.flags[0].long, ["no-color"]);
1967 }
1968
1969 #[test]
1970 fn a_view_keeps_only_its_own_and_carried_global_completers() {
1971 let spec: Spec = r#"
1972bin "host"
1973flag "--host <HOST>" global=#true
1974flag "--carried <CARRIED>" global=#true
1975complete "host" run="host candidates"
1976complete "carried" run="carried candidates"
1977view "runner" root=run {
1978 global "--carried"
1979}
1980cmd "run" {
1981 arg "<HOST>"
1982 complete "host" run="view candidates"
1983}
1984"#
1985 .parse()
1986 .unwrap();
1987
1988 let view = spec.for_view("runner").unwrap();
1989 assert_eq!(
1990 view.complete.get("host").unwrap().run.as_deref(),
1991 Some("view candidates")
1992 );
1993 assert_eq!(
1994 view.complete.get("carried").unwrap().run.as_deref(),
1995 Some("carried candidates")
1996 );
1997 assert!(view.cmd.complete.is_empty());
1998 assert_eq!(view.complete.len(), 2);
1999 assert_eq!(view.to_string().matches("complete ").count(), 2);
2000 }
2001
2002 #[test]
2003 fn a_view_projects_groups_of_carried_globals() {
2004 let spec: Spec = r#"
2005bin "host"
2006flag "--json" global=#true
2007flag "--yaml" global=#true
2008flag "--toml" global=#true
2009group "format" "--json" "--yaml" "--toml" required=#true
2010view "all" root=run globals=#true
2011view "json" root=run {
2012 global "--json"
2013}
2014cmd "run"
2015"#
2016 .parse()
2017 .unwrap();
2018
2019 let all = spec.for_view("all").unwrap();
2020 assert_eq!(all.cmd.groups.len(), 1);
2021 assert_eq!(all.cmd.groups[0].members, ["--json", "--yaml", "--toml"]);
2022 assert!(all.to_string().parse::<Spec>().is_ok());
2023
2024 let json = spec.for_view("json").unwrap();
2025 assert!(json.cmd.groups.is_empty());
2026 assert!(
2027 json.cmd
2028 .flags
2029 .iter()
2030 .find(|flag| flag.name == "json")
2031 .unwrap()
2032 .required
2033 );
2034 assert!(json.to_string().parse::<Spec>().is_ok());
2035 }
2036
2037 #[test]
2038 fn a_view_refuses_unknown_commands_and_non_global_carryovers() {
2039 let missing: Spec = "bin \"ex\"\nview \"x\" root=missing\n".parse().unwrap();
2040 assert!(
2041 missing
2042 .for_view("x")
2043 .unwrap_err()
2044 .to_string()
2045 .contains("missing")
2046 );
2047
2048 let local: Spec =
2049 "bin \"ex\"\nflag \"--local\"\nview \"x\" root=go { global \"--local\" }\ncmd go\n"
2050 .parse()
2051 .unwrap();
2052 assert!(
2053 local
2054 .for_view("x")
2055 .unwrap_err()
2056 .to_string()
2057 .contains("not a root global")
2058 );
2059 }
2060
2061 #[test]
2062 fn a_string_parsed_spec_reports_the_offending_line() {
2063 let err = "flag \"--verbose\"\nflag \"--json\" bogus_key=\"nope\"\n"
2064 .parse::<Spec>()
2065 .unwrap_err();
2066 let rendered = format!("{:?}", crate::miette::Error::from(err));
2067 assert!(
2068 rendered.contains("unsupported flag key bogus_key"),
2069 "{rendered}"
2070 );
2071 assert!(rendered.contains("flag \"--json\""), "{rendered}");
2074 }
2075
2076 #[test]
2077 fn parse_str_with_path_resolves_relative_includes() {
2078 let dir = tempfile::tempdir().unwrap();
2079 let included = dir.path().join("shared.usage.kdl");
2080 std::fs::write(&included, "flagset \"common\" {\n flag \"--shared\"\n}\n").unwrap();
2081
2082 let spec_text = "include file=\"./shared.usage.kdl\"\nuse \"common\"\nflag \"--own\"\n";
2083
2084 assert!(spec_text.parse::<Spec>().is_err());
2086
2087 let spec =
2088 Spec::parse_str_with_path(spec_text, &dir.path().join("root.usage.kdl")).unwrap();
2089 let flags: Vec<_> = spec.cmd.flags.iter().map(|f| f.name.clone()).collect();
2090 assert_eq!(flags, vec!["shared".to_string(), "own".to_string()]);
2091 }
2092
2093 #[test]
2094 fn parse_str_with_path_and_env_expands_nested_include_paths() {
2095 let dir = tempfile::tempdir().unwrap();
2096 let shared = dir.path().join("shared");
2097 std::fs::create_dir(&shared).unwrap();
2098 std::fs::write(
2099 shared.join("common.usage.kdl"),
2100 "include file=\"${SHARED_ROOT}/flags.usage.kdl\"\nuse \"common\"\n",
2101 )
2102 .unwrap();
2103 std::fs::write(
2104 shared.join("flags.usage.kdl"),
2105 "flagset \"common\" {\n flag \"--shared\"\n}\n",
2106 )
2107 .unwrap();
2108 let input = "include file=\"$CONFIG_ROOT/shared/common.usage.kdl\"\n";
2109 let env = HashMap::from([
2110 (
2111 "CONFIG_ROOT".to_string(),
2112 dir.path().to_string_lossy().into_owned(),
2113 ),
2114 (
2115 "SHARED_ROOT".to_string(),
2116 shared.to_string_lossy().into_owned(),
2117 ),
2118 ]);
2119
2120 let spec =
2121 Spec::parse_str_with_path_and_env(input, &dir.path().join("mise-tasks/build"), &env)
2122 .unwrap();
2123
2124 assert_eq!(spec.cmd.flags.len(), 1);
2125 assert_eq!(spec.cmd.flags[0].long, ["shared"]);
2126 }
2127
2128 #[test]
2129 fn parse_str_with_path_and_env_rejects_an_undefined_include_variable() {
2130 let err = Spec::parse_str_with_path_and_env(
2131 "include file=\"$MISSING/shared.usage.kdl\"\n",
2132 Path::new("mise-tasks/build"),
2133 &HashMap::new(),
2134 )
2135 .unwrap_err();
2136 let rendered = format!("{:?}", crate::miette::Error::from(err));
2137
2138 assert!(
2139 rendered.contains("undefined environment variable MISSING"),
2140 "{rendered}"
2141 );
2142 }
2143
2144 #[test]
2145 fn include_environment_expansion_supports_braces_and_literal_dollars() {
2146 let env = HashMap::from([("ROOT".to_string(), "/project".to_string())]);
2147
2148 assert_eq!(
2149 expand_include_env("$ROOT/${ROOT}/$$ROOT/assets/${}", &env).unwrap(),
2150 "/project//project/$ROOT/assets/${}"
2151 );
2152 assert!(expand_include_env("${ROOT", &env).is_err());
2153 }
2154
2155 #[test]
2156 fn an_included_spec_can_enable_or_disable_multicall() {
2157 let dir = tempfile::tempdir().unwrap();
2158 let included = dir.path().join("included.usage.kdl");
2159 let root = dir.path().join("root.usage.kdl");
2160
2161 std::fs::write(&included, "multicall #false\n").unwrap();
2162 std::fs::write(
2163 &root,
2164 "multicall #true\ninclude file=\"./included.usage.kdl\"\n",
2165 )
2166 .unwrap();
2167 let spec = Spec::parse_file(&root).unwrap();
2168 assert!(!spec.multicall);
2169 assert!(spec.to_string().contains("multicall #false"));
2170
2171 std::fs::write(&included, "multicall #true\n").unwrap();
2172 std::fs::write(
2173 &root,
2174 "multicall #false\ninclude file=\"./included.usage.kdl\"\n",
2175 )
2176 .unwrap();
2177 let spec = Spec::parse_file(&root).unwrap();
2178 assert!(spec.multicall);
2179 assert!(spec.to_string().contains("multicall #true"));
2180 }
2181
2182 #[test]
2183 #[cfg(feature = "clap")]
2184 fn multicall_comes_across_from_clap() {
2185 let cmd = clap::Command::new("busybox")
2186 .multicall(true)
2187 .subcommand(clap::Command::new("ls"))
2188 .subcommand(clap::Command::new("cat"));
2189 let spec = Spec::from(&cmd);
2190 assert!(spec.multicall);
2191 assert!(
2192 spec.to_string().contains("multicall #true"),
2193 "{}",
2194 spec.to_string()
2195 );
2196
2197 let plain = clap::Command::new("ex").subcommand(clap::Command::new("ls"));
2198 assert!(!Spec::from(&plain).multicall);
2199 }
2200
2201 macro_rules! extract_usage_tests {
2202 ($($name:ident: $input:expr, $expected:expr,)*) => {
2203 $(
2204 #[test]
2205 fn $name() {
2206 let result = extract_usage_from_comments($input);
2207 let expected = $expected.trim_start_matches('\n').trim_end();
2208 assert_eq!(result, expected);
2209 }
2210 )*
2211 }
2212 }
2213
2214 extract_usage_tests! {
2215 test_extract_usage_from_comments_original_hash:
2216 r#"
2217#!/bin/bash
2218#USAGE bin "test"
2219#USAGE flag "--foo" help="test"
2220echo "hello"
2221 "#,
2222 r#"
2223bin "test"
2224flag "--foo" help="test"
2225 "#,
2226
2227 test_extract_usage_from_comments_original_double_slash:
2228 r#"
2229#!/usr/bin/env node
2230//USAGE bin "test"
2231//USAGE flag "--foo" help="test"
2232console.log("hello");
2233 "#,
2234 r#"
2235bin "test"
2236flag "--foo" help="test"
2237 "#,
2238
2239 test_extract_usage_from_comments_bracket_with_space:
2240 r#"
2241#!/bin/bash
2242# [USAGE] bin "test"
2243# [USAGE] flag "--foo" help="test"
2244echo "hello"
2245 "#,
2246 r#"
2247bin "test"
2248flag "--foo" help="test"
2249 "#,
2250
2251 test_extract_usage_from_comments_bracket_no_space:
2252 r#"
2253#!/bin/bash
2254#[USAGE] bin "test"
2255#[USAGE] flag "--foo" help="test"
2256echo "hello"
2257 "#,
2258 r#"
2259bin "test"
2260flag "--foo" help="test"
2261 "#,
2262
2263 test_extract_usage_from_comments_double_slash_bracket_with_space:
2264 r#"
2265#!/usr/bin/env node
2266// [USAGE] bin "test"
2267// [USAGE] flag "--foo" help="test"
2268console.log("hello");
2269 "#,
2270 r#"
2271bin "test"
2272flag "--foo" help="test"
2273 "#,
2274
2275 test_extract_usage_from_comments_double_slash_bracket_no_space:
2276 r#"
2277#!/usr/bin/env node
2278//[USAGE] bin "test"
2279//[USAGE] flag "--foo" help="test"
2280console.log("hello");
2281 "#,
2282 r#"
2283bin "test"
2284flag "--foo" help="test"
2285 "#,
2286
2287 test_extract_usage_from_comments_stops_at_gap:
2288 r#"
2289#!/bin/bash
2290#USAGE bin "test"
2291#USAGE flag "--foo" help="test"
2292
2293#USAGE flag "--bar" help="should not be included"
2294echo "hello"
2295 "#,
2296 r#"
2297bin "test"
2298flag "--foo" help="test"
2299 "#,
2300
2301 test_extract_usage_from_comments_with_content_after_marker:
2302 r#"
2303#!/bin/bash
2304# [USAGE] bin "test"
2305# [USAGE] flag "--verbose" help="verbose mode"
2306# [USAGE] arg "input" help="input file"
2307echo "hello"
2308 "#,
2309 r#"
2310bin "test"
2311flag "--verbose" help="verbose mode"
2312arg "input" help="input file"
2313 "#,
2314
2315 test_extract_usage_from_comments_double_colon_original:
2316 r#"
2317::USAGE bin "test"
2318::USAGE flag "--foo" help="test"
2319echo "hello"
2320 "#,
2321 r#"
2322bin "test"
2323flag "--foo" help="test"
2324 "#,
2325
2326 test_extract_usage_from_comments_double_colon_bracket_with_space:
2327 r#"
2328:: [USAGE] bin "test"
2329:: [USAGE] flag "--foo" help="test"
2330echo "hello"
2331 "#,
2332 r#"
2333bin "test"
2334flag "--foo" help="test"
2335 "#,
2336
2337 test_extract_usage_from_comments_double_colon_bracket_no_space:
2338 r#"
2339::[USAGE] bin "test"
2340::[USAGE] flag "--foo" help="test"
2341echo "hello"
2342 "#,
2343 r#"
2344bin "test"
2345flag "--foo" help="test"
2346 "#,
2347
2348 test_extract_usage_from_comments_double_colon_stops_at_gap:
2349 r#"
2350::USAGE bin "test"
2351::USAGE flag "--foo" help="test"
2352
2353::USAGE flag "--bar" help="should not be included"
2354echo "hello"
2355 "#,
2356 r#"
2357bin "test"
2358flag "--foo" help="test"
2359 "#,
2360
2361 test_extract_usage_from_comments_double_colon_with_content_after_marker:
2362 r#"
2363::USAGE bin "test"
2364::USAGE flag "--verbose" help="verbose mode"
2365::USAGE arg "input" help="input file"
2366echo "hello"
2367 "#,
2368 r#"
2369bin "test"
2370flag "--verbose" help="verbose mode"
2371arg "input" help="input file"
2372 "#,
2373
2374 test_extract_usage_from_comments_double_colon_bracket_with_space_multiple_lines:
2375 r#"
2376:: [USAGE] bin "myapp"
2377:: [USAGE] flag "--config <file>" help="config file"
2378:: [USAGE] flag "--verbose" help="verbose output"
2379:: [USAGE] arg "input" help="input file"
2380:: [USAGE] arg "[output]" help="output file" required=#false
2381echo "done"
2382 "#,
2383 r#"
2384bin "myapp"
2385flag "--config <file>" help="config file"
2386flag "--verbose" help="verbose output"
2387arg "input" help="input file"
2388arg "[output]" help="output file" required=#false
2389 "#,
2390
2391 test_extract_usage_from_comments_empty:
2392 r#"
2393#!/bin/bash
2394echo "hello"
2395 "#,
2396 "",
2397
2398 test_extract_usage_from_comments_lowercase_usage:
2399 r#"
2400#!/bin/bash
2401#usage bin "test"
2402#usage flag "--foo" help="test"
2403echo "hello"
2404 "#,
2405 "",
2406
2407 test_extract_usage_from_comments_mixed_case_usage:
2408 r#"
2409#!/bin/bash
2410#Usage bin "test"
2411#Usage flag "--foo" help="test"
2412echo "hello"
2413 "#,
2414 "",
2415
2416 test_extract_usage_from_comments_space_before_usage:
2417 r#"
2418#!/bin/bash
2419# USAGE bin "test"
2420# USAGE flag "--foo" help="test"
2421echo "hello"
2422 "#,
2423 "",
2424
2425 test_extract_usage_from_comments_double_slash_lowercase:
2426 r#"
2427#!/usr/bin/env node
2428//usage bin "test"
2429//usage flag "--foo" help="test"
2430console.log("hello");
2431 "#,
2432 "",
2433
2434 test_extract_usage_from_comments_double_slash_mixed_case:
2435 r#"
2436#!/usr/bin/env node
2437//Usage bin "test"
2438//Usage flag "--foo" help="test"
2439console.log("hello");
2440 "#,
2441 "",
2442
2443 test_extract_usage_from_comments_double_slash_space_before_usage:
2444 r#"
2445#!/usr/bin/env node
2446// USAGE bin "test"
2447// USAGE flag "--foo" help="test"
2448console.log("hello");
2449 "#,
2450 "",
2451
2452 test_extract_usage_from_comments_bracket_lowercase:
2453 r#"
2454#!/bin/bash
2455#[usage] bin "test"
2456#[usage] flag "--foo" help="test"
2457echo "hello"
2458 "#,
2459 "",
2460
2461 test_extract_usage_from_comments_bracket_mixed_case:
2462 r#"
2463#!/bin/bash
2464#[Usage] bin "test"
2465#[Usage] flag "--foo" help="test"
2466echo "hello"
2467 "#,
2468 "",
2469
2470 test_extract_usage_from_comments_bracket_space_lowercase:
2471 r#"
2472#!/bin/bash
2473# [usage] bin "test"
2474# [usage] flag "--foo" help="test"
2475echo "hello"
2476 "#,
2477 "",
2478
2479 test_extract_usage_from_comments_double_colon_lowercase:
2480 r#"
2481::usage bin "test"
2482::usage flag "--foo" help="test"
2483echo "hello"
2484 "#,
2485 "",
2486
2487 test_extract_usage_from_comments_double_colon_mixed_case:
2488 r#"
2489::Usage bin "test"
2490::Usage flag "--foo" help="test"
2491echo "hello"
2492 "#,
2493 "",
2494
2495 test_extract_usage_from_comments_double_colon_space_before_usage:
2496 r#"
2497:: USAGE bin "test"
2498:: USAGE flag "--foo" help="test"
2499echo "hello"
2500 "#,
2501 "",
2502
2503 test_extract_usage_from_comments_double_colon_bracket_lowercase:
2504 r#"
2505::[usage] bin "test"
2506::[usage] flag "--foo" help="test"
2507echo "hello"
2508 "#,
2509 "",
2510
2511 test_extract_usage_from_comments_double_colon_bracket_mixed_case:
2512 r#"
2513::[Usage] bin "test"
2514::[Usage] flag "--foo" help="test"
2515echo "hello"
2516 "#,
2517 "",
2518
2519 test_extract_usage_from_comments_double_colon_bracket_space_lowercase:
2520 r#"
2521:: [usage] bin "test"
2522:: [usage] flag "--foo" help="test"
2523echo "hello"
2524 "#,
2525 "",
2526 }
2527
2528 #[test]
2529 fn test_spec_with_examples() {
2530 let spec = Spec::parse(
2531 &Default::default(),
2532 r#"
2533name "demo"
2534bin "demo"
2535example "demo --help" header="Getting help" help="Display help information"
2536example "demo --version" header="Check version"
2537 "#,
2538 )
2539 .unwrap();
2540
2541 assert_eq!(spec.examples.len(), 2);
2542
2543 assert_eq!(spec.examples[0].code, "demo --help");
2544 assert_eq!(spec.examples[0].header, Some("Getting help".to_string()));
2545 assert_eq!(
2546 spec.examples[0].help,
2547 Some("Display help information".to_string())
2548 );
2549
2550 assert_eq!(spec.examples[1].code, "demo --version");
2551 assert_eq!(spec.examples[1].header, Some("Check version".to_string()));
2552 assert_eq!(spec.examples[1].help, None);
2553 }
2554
2555 #[test]
2556 fn test_spec_examples_display() {
2557 let spec = Spec::parse(
2558 &Default::default(),
2559 r#"
2560name "demo"
2561bin "demo"
2562example "demo --help" header="Getting help" help="Show help"
2563example "demo --version"
2564 "#,
2565 )
2566 .unwrap();
2567
2568 let output = format!("{}", spec);
2569 assert!(
2570 output.contains("example \"demo --help\" header=\"Getting help\" help=\"Show help\"")
2571 );
2572 assert!(output.contains("example \"demo --version\""));
2573 }
2574
2575 #[test]
2576 fn test_parse_script_str() {
2577 let spec = Spec::parse_script_str(
2578 r#"
2579#!/bin/bash
2580#USAGE bin "test"
2581#USAGE flag "--foo" help="test"
2582echo "hello"
2583 "#,
2584 )
2585 .unwrap();
2586
2587 assert_eq!(spec.bin, "test");
2588 assert_eq!(spec.name, "test");
2589 assert_eq!(spec.cmd.flags.len(), 1);
2590 assert_eq!(spec.cmd.flags[0].long, ["foo"]);
2591 }
2592
2593 #[test]
2594 fn test_parse_script_str_rejects_relative_includes() {
2595 let err = Spec::parse_script_str(r#"#USAGE include file="relative.usage.kdl""#)
2596 .expect_err("relative includes need a source path");
2597
2598 match err {
2599 UsageErr::InvalidInput(msg, _, _) => {
2600 assert_eq!(msg, "relative includes require a source file");
2601 }
2602 err => panic!("unexpected error: {err:?}"),
2603 }
2604 }
2605
2606 #[test]
2607 fn test_include_does_not_infer_metadata_from_included_filename() {
2608 let dir = tempfile::tempdir().unwrap();
2609 let included = dir.path().join("overrides.usage.kdl");
2610 let root = dir.path().join("my-script.usage.kdl");
2611 std::fs::write(&included, "").unwrap();
2612 std::fs::write(&root, "include file=\"./overrides.usage.kdl\"\n").unwrap();
2613
2614 let spec = Spec::parse_file(&root).unwrap();
2615
2616 assert_eq!(spec.name, "my-script.usage.kdl");
2617 assert_eq!(spec.bin, "my-script.usage.kdl");
2618 assert!(spec.cmd.name.is_empty());
2619 }
2620
2621 #[test]
2622 fn resolving_mounts_replaces_the_unresolved_synopsis() {
2623 let mut spec: Spec = "bin ex\nmount run=tasks synopsis=\"[TASK] [ARGS]…\""
2624 .parse()
2625 .unwrap();
2626 assert!(spec.cmd.usage.contains("[TASK] [ARGS]…"));
2627 spec.resolve_mount_outputs(&HashMap::from([(
2628 "tasks".to_string(),
2629 "cmd build".to_string(),
2630 )]))
2631 .unwrap();
2632 assert!(spec.cmd.mounts.is_empty());
2633 assert_eq!(spec.cmd.usage, "[SUBCOMMAND]");
2634 assert_eq!(spec.cmd.subcommands["build"].usage, "build");
2635 let json = serde_json::to_value(&spec).unwrap();
2636 assert_eq!(json["cmd"]["usage"], "[SUBCOMMAND]");
2637 }
2638
2639 #[test]
2640 fn a_view_of_a_mounted_command_keeps_its_completers() {
2641 let mut spec: Spec = r#"
2642bin ex
2643flag "--carried <CARRIED>" global=#true
2644flag "--pick <tool>" global=#true
2645complete "carried" run="carried candidates"
2646complete "tool" run="host-tools"
2647view "owner" root="run own" {
2648 global "--carried"
2649}
2650view "bare" root="run bare" {
2651 global "--carried"
2652 global "--pick"
2653}
2654cmd run {
2655 mount run=tasks
2656}
2657"#
2658 .parse()
2659 .unwrap();
2660 let mounted = r#"
2661cmd own {
2662 arg "<tool>"
2663 complete "tool" run="task-tools"
2664}
2665cmd bare {
2666 arg "<tool>"
2667}
2668"#;
2669 spec.resolve_mount_outputs(&HashMap::from([("tasks".to_string(), mounted.to_string())]))
2670 .unwrap();
2671 fn run_of(view: &Spec, arg: &SpecArg) -> Option<String> {
2672 view.completer(&view.cmd, arg)
2673 .and_then(|complete| complete.run.clone())
2674 }
2675 fn flag_arg<'a>(view: &'a Spec, long: &str) -> &'a SpecArg {
2676 let flag = view.cmd.flags.iter().find(|flag| flag.long == [long]);
2677 flag.and_then(|flag| flag.arg.as_ref()).unwrap()
2678 }
2679
2680 let view = spec.for_view("owner").unwrap();
2682 assert_eq!(
2683 run_of(&view, &view.cmd.args[0]).as_deref(),
2684 Some("task-tools")
2685 );
2686 let carried = flag_arg(&view, "carried");
2687 assert_eq!(
2688 run_of(&view, carried).as_deref(),
2689 Some("carried candidates")
2690 );
2691 let emitted = view.to_string();
2692 assert_eq!(emitted.matches("complete tool").count(), 1, "{emitted}");
2693 emitted.parse::<Spec>().unwrap();
2694
2695 let view = spec.for_view("bare").unwrap();
2698 assert_eq!(run_of(&view, &view.cmd.args[0]), None);
2699 assert_eq!(
2700 run_of(&view, flag_arg(&view, "pick")).as_deref(),
2701 Some("host-tools")
2702 );
2703 assert_eq!(
2704 run_of(&view, flag_arg(&view, "carried")).as_deref(),
2705 Some("carried candidates")
2706 );
2707 view.to_string().parse::<Spec>().unwrap();
2708 }
2709
2710 #[test]
2711 fn mounted_commands_do_not_inherit_top_level_completers_by_name() {
2712 let mut spec: Spec = r#"
2713bin ex
2714complete "tool" run="host-tools"
2715cmd install {
2716 arg "<tool>"
2717}
2718cmd run {
2719 mount run=tasks
2720}
2721"#
2722 .parse()
2723 .unwrap();
2724 let mounted = r#"
2725cmd plain {
2726 arg "<tool>"
2727 cmd nested {
2728 arg "<tool>"
2729 }
2730}
2731cmd own {
2732 arg "<tool>"
2733 complete "tool" run="task-tools"
2734}
2735cmd inline {
2736 arg "<tool>" {
2737 complete run="inline-tools"
2738 }
2739}
2740"#;
2741 spec.resolve_mount_outputs(&HashMap::from([("tasks".to_string(), mounted.to_string())]))
2742 .unwrap();
2743 let run_of = |path: &[&str]| {
2744 let mut cmd = &spec.cmd;
2745 for name in path {
2746 cmd = &cmd.subcommands[*name];
2747 }
2748 spec.completer(cmd, &cmd.args[0])
2749 .and_then(|complete| complete.run.as_deref())
2750 };
2751
2752 assert_eq!(run_of(&["install"]), Some("host-tools"));
2754 assert_eq!(run_of(&["run", "plain"]), None);
2756 assert_eq!(run_of(&["run", "plain", "nested"]), None);
2757 assert_eq!(run_of(&["run", "own"]), Some("task-tools"));
2759 assert_eq!(run_of(&["run", "inline"]), Some("inline-tools"));
2760 }
2761
2762 #[test]
2763 fn injected_nested_mounts_ignore_the_mounted_specs_root_default() {
2764 let mut spec: Spec = "mount run=outer".parse().unwrap();
2765 let outputs = HashMap::from([
2766 (
2767 "outer".to_string(),
2768 "default_subcommand run\nmount run=nested\ncmd run".to_string(),
2769 ),
2770 ("nested".to_string(), "cmd leaf".to_string()),
2771 ]);
2772
2773 spec.resolve_mount_outputs(&outputs).unwrap();
2774
2775 assert!(spec.cmd.subcommands.contains_key("leaf"));
2776 }
2777
2778 #[test]
2779 fn restamping_a_grafted_tree_matches_writing_it_out_and_reading_it_back() {
2780 let host: Spec =
2784 "name \"host\"\nbin \"host\"\ncmd \"plugins\" {\n external_subcommand #true\n}\n"
2785 .parse()
2786 .unwrap();
2787 let plugin: Spec =
2788 "name \"formatter\"\nbin \"formatter\"\narg \"[path]\"\ncmd \"check\" {\n flag \"--fix\"\n}\n"
2789 .parse()
2790 .unwrap();
2791
2792 let mut grafted = host.clone();
2793 grafted
2794 .cmd
2795 .subcommands
2796 .get_mut("plugins")
2797 .unwrap()
2798 .subcommands
2799 .insert("formatter".to_string(), plugin.cmd.clone());
2800 grafted.restamp();
2801
2802 let reparsed: Spec = grafted.to_string().parse().unwrap();
2803 let path = |spec: &Spec, words: &[&str]| {
2804 let mut cmd = &spec.cmd;
2805 for word in words {
2806 cmd = cmd.find_subcommand(word).unwrap();
2807 }
2808 (cmd.full_cmd.clone(), cmd.usage.clone())
2809 };
2810 for words in [
2811 &[][..],
2812 &["plugins"],
2813 &["plugins", "formatter"],
2814 &["plugins", "formatter", "check"],
2815 ] {
2816 assert_eq!(
2817 path(&grafted, words),
2818 path(&reparsed, words),
2819 "at {words:?}"
2820 );
2821 }
2822
2823 assert_eq!(
2826 path(&grafted, &["plugins", "formatter"]).0,
2827 ["plugins", "formatter"]
2828 );
2829 assert!(
2830 path(&grafted, &["plugins"]).1.contains("[SUBCOMMAND]"),
2831 "{:?}",
2832 path(&grafted, &["plugins"]).1
2833 );
2834 assert!(
2836 grafted
2837 .cmd
2838 .find_subcommand("plugins")
2839 .unwrap()
2840 .find_subcommand("formatter")
2841 .is_some()
2842 );
2843 }
2844}