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