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>(
388 &'a self,
389 cmd: &'a SpecCommand,
390 arg: &'a SpecArg,
391 ) -> Option<&'a SpecComplete> {
392 arg.complete.as_ref().or_else(|| {
393 let name = arg.name.to_lowercase();
394 self.complete.get(&name).or_else(|| cmd.complete.get(&name))
395 })
396 }
397
398 pub fn for_view(&self, id: &str) -> Result<Spec, UsageErr> {
404 let view = self
405 .views
406 .get(id)
407 .ok_or_else(|| UsageErr::InvalidView(format!("spec declares no view named `{id}`")))?;
408 let mut command = &self.cmd;
409 for segment in view.root.split_whitespace() {
410 command = command.subcommands.get(segment).ok_or_else(|| {
411 UsageErr::InvalidView(format!(
412 "view `{id}` promotes `{}`, but `{segment}` is not a command on that path",
413 view.root
414 ))
415 })?;
416 }
417 let mut promoted = command.clone();
418 let matches_selector = |flag: &SpecFlag, selector: &str| {
419 selector
420 .strip_prefix("--")
421 .is_some_and(|name| flag.long.iter().any(|long| long == name))
422 || selector
423 .strip_prefix('-')
424 .filter(|short| short.len() == 1)
425 .and_then(|short| short.chars().next())
426 .is_some_and(|short| flag.short.contains(&short))
427 };
428 let carries = |flag: &SpecFlag| {
429 if !flag.global {
430 return false;
431 }
432 view.all_globals
433 || view
434 .globals
435 .iter()
436 .any(|selector| matches_selector(flag, selector))
437 };
438 for selector in &view.globals {
439 if !self
440 .cmd
441 .flags
442 .iter()
443 .any(|flag| flag.global && matches_selector(flag, selector))
444 {
445 return Err(UsageErr::InvalidView(format!(
446 "view `{id}` carries `{selector}`, but it is not a root global flag"
447 )));
448 }
449 }
450 let globals: Vec<SpecFlag> = self
451 .cmd
452 .flags
453 .iter()
454 .filter(|flag| carries(flag) || flag.action == crate::SpecFlagAction::Version)
457 .cloned()
458 .collect();
459 let mut globals: Vec<SpecFlag> = globals
462 .into_iter()
463 .filter(|global| {
464 !promoted
465 .flags
466 .iter()
467 .any(|local| spec_flag_forms_overlap(global, local))
468 })
469 .collect();
470 let mut complete = IndexMap::new();
475 for flag in &globals {
476 if let Some(arg) = &flag.arg {
477 let name = arg.name.to_lowercase();
478 if let Some(completer) = self.complete.get(&name) {
479 complete.insert(name, completer.clone());
480 }
481 }
482 }
483 complete.extend(std::mem::take(&mut promoted.complete));
484 let mut carried_groups = Vec::new();
488 for group in &self.cmd.groups {
489 let members: Vec<String> = group
490 .members
491 .iter()
492 .filter(|selector| {
493 globals
494 .iter()
495 .any(|flag| flag_matches_selector(flag, selector))
496 })
497 .cloned()
498 .collect();
499 match members.as_slice() {
500 [only] if group.required => {
501 if let Some(flag) = globals
502 .iter_mut()
503 .find(|flag| flag_matches_selector(flag, only))
504 {
505 flag.required = true;
506 }
507 }
508 [_, _, ..] => {
509 let mut projected = group.clone();
510 projected.members = members;
511 carried_groups.push(projected);
512 }
513 _ => {}
514 }
515 }
516 promoted.flags.splice(0..0, globals);
517 promoted.groups.splice(0..0, carried_groups);
518 promoted.name.clone_from(&view.bin);
519 promoted.full_cmd.clear();
520 promoted.aliases.clear();
521 promoted.hidden_aliases.clear();
522 promoted.disable_version_flag = self.cmd.disable_version_flag;
526 set_subcommand_ancestors(&mut promoted, &[]);
527 promoted.usage = promoted.usage();
528
529 let mut spec = self.clone();
530 spec.name.clone_from(&view.name);
531 spec.bin.clone_from(&view.bin);
532 spec.about = promoted.help.clone();
533 spec.about_long = promoted.help_long.clone();
534 spec.about_md = promoted.help_md.clone();
535 spec.before_help = promoted.before_help.clone();
536 spec.before_help_long = promoted.before_help_long.clone();
537 spec.after_help = promoted.after_help.clone();
538 spec.after_help_long = promoted.after_help_long.clone();
539 spec.examples.clone_from(&promoted.examples);
540 spec.usage = promoted.usage.clone();
541 spec.complete = complete;
542 spec.cmd = promoted;
543 spec.default_subcommand = None;
544 spec.default_subcommand_flags = false;
545 spec.default_subcommand_flags_set = false;
546 spec.default_subcommand_on_empty = false;
547 spec.default_subcommand_on_empty_set = false;
548 spec.default_subcommand_help = false;
549 spec.default_subcommand_help_set = false;
550 spec.multicall = false;
551 spec.multicall_set = false;
552 spec.views.clear();
553 Ok(spec)
554 }
555
556 pub fn view_for_program(&self, program: &str) -> Option<&str> {
558 let basename = crate::parse::multicall_basename(program);
559 if basename == crate::parse::multicall_basename(&self.name)
560 || (!self.bin.is_empty() && basename == crate::parse::multicall_basename(&self.bin))
561 {
562 return None;
563 }
564 self.views.values().find_map(|view| {
565 (basename == crate::parse::multicall_basename(&view.bin)
566 || basename == crate::parse::multicall_basename(&view.id))
567 .then_some(view.id.as_str())
568 })
569 }
570
571 pub(crate) fn parse(ctx: &ParsingContext, input: &str) -> Result<Spec, UsageErr> {
572 Self::parse_with_output_resolution(ctx, input, true)
573 }
574
575 fn parse_with_output_resolution(
576 ctx: &ParsingContext,
577 input: &str,
578 resolve_outputs: bool,
579 ) -> Result<Spec, UsageErr> {
580 let kdl: KdlDocument = input.parse().map_err(|err: kdl::KdlError| {
581 UsageErr::KdlError(err.with_source_name(ctx.file.to_string_lossy()))
582 })?;
583 let mut schema = Self {
584 ..Default::default()
585 };
586 if !ctx.file.as_os_str().is_empty() {
589 schema.sources.push(ctx.file.clone());
590 }
591 for node in kdl.nodes().iter().map(|n| NodeHelper::new(ctx, n)) {
592 match node.name() {
593 "name" => schema.name = node.arg(0)?.ensure_string()?,
594 "bin" => {
595 schema.bin = node.arg(0)?.ensure_string()?;
596 if schema.name.is_empty() {
597 schema.name.clone_from(&schema.bin);
598 }
599 }
600 "version" => schema.version = Some(node.arg(0)?.ensure_string()?),
601 "long_version" => schema.long_version = Some(node.arg(0)?.ensure_string()?),
602 "author" => schema.author = Some(node.arg(0)?.ensure_string()?),
603 "source_code_link_template" => {
604 schema.source_code_link_template = Some(node.arg(0)?.ensure_string()?)
605 }
606 "repository" => schema.repository = Some(node.arg(0)?.ensure_string()?),
607 "about" => schema.about = Some(node.arg(0)?.ensure_string()?),
608 "long_about" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
609 "about_long" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
610 "about_md" => schema.about_md = Some(node.arg(0)?.ensure_string()?),
611 "surface" => schema.cmd.surface = Some(node.arg(0)?.ensure_string()?),
612 "available_if" => {
613 schema.cmd.available_if = node
614 .ensure_arg_len(1..)?
615 .args()
616 .map(|entry| entry.ensure_string())
617 .collect::<Result<Vec<_>, _>>()?;
618 }
619 "license" => schema.license = Some(node.arg(0)?.ensure_string()?),
620 "logo" => {
621 schema.logo = Some(node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
622 for (key, value) in node.props() {
623 match key {
624 "style" => {
628 let style = value.ensure_string()?;
629 if let Err(problem) = crate::help_template::check_style(&style) {
630 bail_parse!(ctx, value.entry.span(), "{problem}");
631 }
632 schema.logo_style = Some(style);
633 }
634 key => {
635 bail_parse!(ctx, value.entry.span(), "unsupported logo key {key}")
636 }
637 }
638 }
639 }
640 "before_help" => schema.before_help = Some(node.arg(0)?.ensure_string()?),
641 "after_help" => schema.after_help = Some(node.arg(0)?.ensure_string()?),
642 "before_long_help" | "before_help_long" => {
643 schema.before_help_long = Some(node.arg(0)?.ensure_string()?)
644 }
645 "after_long_help" | "after_help_long" => {
646 schema.after_help_long = Some(node.arg(0)?.ensure_string()?)
647 }
648 "usage" => schema.usage = node.arg(0)?.ensure_string()?,
649 "help_template" => {
653 let template = node.arg(0)?.ensure_string()?;
654 if let Err(problem) = crate::help_template::check(&template) {
655 bail_parse!(ctx, node.span(), "{problem}");
656 }
657 schema.help_template =
660 crate::help_template::is_set(&template).then_some(template);
661 }
662 "arg" => {
663 let arg = SpecArg::parse(ctx, &node)?;
664 if arg.delimiter.is_some() && !arg.var {
667 bail_parse!(
668 ctx,
669 node.node.name().span(),
670 "argument <{}> has a delimiter and holds one value; add \
671 `var=#true` for the values it splits into",
672 arg.name
673 );
674 }
675 schema.cmd.args.push(arg);
676 }
677 "clause" => {
678 if schema.cmd.clause.is_some() {
679 bail_parse!(ctx, node.span(), "a command may declare at most one clause");
680 }
681 schema.cmd.clause = Some(crate::SpecClause::parse(ctx, &node)?);
682 }
683 "flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?),
684 "group" => schema.cmd.groups.push(crate::SpecGroup::parse(ctx, &node)?),
687 "mount" => schema.cmd.mounts.push(crate::SpecMount::parse(ctx, &node)?),
691 "cmd" => {
692 let node: SpecCommand = SpecCommand::parse(ctx, &node)?;
693 schema.cmd.subcommands.insert(node.name.to_string(), node);
694 }
695 "flagset" => {
696 let set = SpecFlagSet::parse(ctx, &node)?;
697 if schema.flagsets.insert(set.name.clone(), set).is_some() {
698 bail_parse!(ctx, node.span(), "a flagset may be declared only once");
699 }
700 }
701 "use" => {
704 let at = schema.cmd.flags.len();
705 schema.cmd.uses.push(SpecUse::parse(ctx, &node, at)?);
706 }
707 "config" => schema.config = SpecConfig::parse(ctx, &node)?,
708 "complete" => {
709 let complete = SpecComplete::parse(ctx, &node)?;
710 schema.complete.insert(complete.name.clone(), complete);
711 }
712 "view" => {
713 let view = SpecView::parse(ctx, &node)?;
714 if schema.views.insert(view.id.clone(), view).is_some() {
715 bail_parse!(
716 ctx,
717 node.span(),
718 "a view identifier may be declared only once"
719 );
720 }
721 }
722 "disable_help" => schema.disable_help = Some(node.arg(0)?.ensure_bool()?),
723 "min_usage_version" => {
724 let v = node.arg(0)?.ensure_string()?;
725 check_usage_version(&v);
726 schema.min_usage_version = Some(v);
727 }
728 "unknown_flags" => {
729 let raw = node.arg(0)?.ensure_string()?;
730 match raw.parse() {
731 Ok(mode) => schema.unknown_flags = Some(mode),
732 Err(_) => bail_parse!(
733 ctx,
734 node.span(),
735 "unsupported unknown_flags {raw}, expected one of: {}",
736 crate::spec::unknown_flags::UNKNOWN_FLAGS_VALUES
737 ),
738 }
739 }
740 "default_subcommand" => {
741 schema.default_subcommand = Some(node.arg(0)?.ensure_string()?)
742 }
743 "default_subcommand_flags" => {
744 schema.default_subcommand_flags = node.arg(0)?.ensure_bool()?;
745 schema.default_subcommand_flags_set = true;
746 }
747 "default_subcommand_on_empty" => {
748 schema.default_subcommand_on_empty = node.arg(0)?.ensure_bool()?;
749 schema.default_subcommand_on_empty_set = true;
750 }
751 "default_subcommand_help" => {
752 schema.default_subcommand_help = node.arg(0)?.ensure_bool()?;
753 schema.default_subcommand_help_set = true;
754 }
755 "multicall" => {
756 schema.multicall = node.arg(0)?.ensure_bool()?;
757 schema.multicall_set = true;
758 }
759 "external_subcommand" => {
760 schema.cmd.external_subcommand = node.arg(0)?.ensure_bool()?;
761 }
762 "arg_required_else_help" => {
763 schema.cmd.arg_required_else_help = node.arg(0)?.ensure_bool()?;
764 }
765 "disable_help_flag" => {
766 schema.cmd.disable_help_flag = node.arg(0)?.ensure_bool()?;
767 }
768 "disable_help_subcommand" => {
769 schema.cmd.disable_help_subcommand = node.arg(0)?.ensure_bool()?;
770 }
771 "disable_version_flag" => {
772 schema.cmd.disable_version_flag = node.arg(0)?.ensure_bool()?;
773 }
774 "dont_delimit_trailing_values" => {
775 schema.cmd.dont_delimit_trailing_values = node.arg(0)?.ensure_bool()?;
776 }
777 "args_override_self" => {
778 schema.cmd.args_override_self = node.arg(0)?.ensure_bool()?;
779 }
780 "subcommand_negates_reqs" => {
781 schema.cmd.subcommand_negates_reqs = node.arg(0)?.ensure_bool()?;
782 }
783 "args_conflicts_with_subcommands" => {
784 schema.cmd.args_conflicts_with_subcommands = node.arg(0)?.ensure_bool()?;
785 }
786 "subcommand_precedence_over_arg" => {
787 schema.cmd.subcommand_precedence_over_arg = node.arg(0)?.ensure_bool()?;
788 }
789 "allow_missing_positional" => {
790 schema.cmd.allow_missing_positional = node.arg(0)?.ensure_bool()?;
791 }
792 "deprecated" => schema.cmd.deprecated = Some(node.arg(0)?.ensure_string()?),
793 "deprecated_warn_at" => {
794 schema.cmd.deprecated_warn_at = Some(node.arg(0)?.ensure_string()?);
795 }
796 "deprecated_remove_at" => {
797 schema.cmd.deprecated_remove_at = Some(node.arg(0)?.ensure_string()?);
798 }
799 "subcommand_required" => {
800 schema.cmd.subcommand_required = node.arg(0)?.ensure_bool()?;
801 }
802 "subcommand_help_heading" => {
803 schema.cmd.subcommand_help_heading = Some(node.arg(0)?.ensure_string()?);
804 }
805 "subcommand_value_name" => {
806 schema.cmd.subcommand_value_name = Some(node.arg(0)?.ensure_string()?);
807 }
808 "next_line_help" => {
809 schema.cmd.next_line_help = node.arg(0)?.ensure_bool()?;
810 }
811 "flatten_help" => {
812 schema.cmd.flatten_help = node.arg(0)?.ensure_bool()?;
813 }
814 "term_width" => {
815 schema.cmd.term_width = Some(node.arg(0)?.ensure_usize()?);
816 }
817 "max_term_width" => {
818 schema.cmd.max_term_width = Some(node.arg(0)?.ensure_usize()?);
819 }
820 "example" => {
821 let code = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
822 let mut example = SpecExample::new(code.trim().to_string());
823 for (k, v) in node.props() {
824 match k {
825 "header" => example.header = Some(v.ensure_string()?),
826 "help" => example.help = Some(v.ensure_string()?),
827 "lang" => example.lang = v.ensure_string()?,
828 k => bail_parse!(ctx, v.entry.span(), "unsupported example key {k}"),
829 }
830 }
831 schema.examples.push(example);
832 }
833 "heading" => {
834 let title = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
835 let mut help = None;
836 for (k, v) in node.props() {
837 match k {
838 "help" => help = Some(v.ensure_string()?),
839 k => bail_parse!(ctx, v.entry.span(), "unsupported heading key {k}"),
840 }
841 }
842 let Some(help) = help else {
843 bail_parse!(ctx, node.node.span(), "heading {title} needs help text");
844 };
845 schema.cmd.headings.push(SpecHeading::new(title, help));
846 }
847 "output" => schema.outputs.push(SpecOutput::parse(ctx, &node)?),
848 "exit_code" => schema.exit_codes.push(SpecExitCode::parse(ctx, &node)?),
849 "select" => {
850 schema.select = Some(node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
851 }
852 "include" => {
853 let file = node
854 .props()
855 .get("file")
856 .map(|v| v.ensure_string())
857 .transpose()?
858 .ok_or_else(|| ctx.build_err("missing file".into(), node.span()))?;
859 let file = match &ctx.include_env {
860 Some(env) => expand_include_env(&file, env)
861 .map_err(|msg| ctx.build_err(msg, node.span()))?,
862 None => file,
863 };
864 let file = Path::new(&file);
865 let file = match file.is_relative() {
866 true => ctx
867 .file
868 .parent()
869 .ok_or_else(|| {
870 let msg = if ctx.file.as_os_str().is_empty() {
871 "relative includes require a source file".to_string()
872 } else {
873 format!("cannot get parent of {}", ctx.file.display())
874 };
875 ctx.build_err(msg, node.span())
876 })?
877 .join(file),
878 false => file.to_path_buf(),
879 };
880 debug!("include: {}", file.display());
885 let other = Self::parse_file_with_metadata_inference_and_env(
886 &file,
887 false,
888 false,
889 ctx.include_env.as_ref(),
890 )?;
891 let clash = other.flagsets.values().find(|incoming| {
902 schema
903 .flagsets
904 .get(&incoming.name)
905 .is_some_and(|own| own.declared_in != incoming.declared_in)
906 });
907 if let Some(incoming) = clash {
908 let name = &incoming.name;
909 let owner = schema.flagsets[name].declared_in.clone();
910 let owner = match owner.as_os_str().is_empty() {
911 true => "this spec".to_string(),
912 false => owner.display().to_string(),
913 };
914 bail_parse!(
915 ctx,
916 node.span(),
917 "a flagset may be declared only once: \"{name}\" is declared in \
918 {} and in {owner}",
919 incoming.declared_in.display()
920 );
921 }
922 schema.merge(other);
923 }
924 k => bail_parse!(ctx, node.node.name().span(), "unsupported spec key {k}"),
925 }
926 }
927 schema.cmd.name = if schema.bin.is_empty() {
928 schema.name.clone()
929 } else {
930 schema.bin.clone()
931 };
932 if let Some(clause) = &schema.cmd.clause {
933 if !schema.cmd.args.is_empty() {
934 bail_parse!(
935 ctx,
936 kdl.span(),
937 "a command cannot declare both top-level arguments and a clause"
938 );
939 }
940 if schema.cmd.restart_token.is_some() {
941 bail_parse!(
942 ctx,
943 kdl.span(),
944 "a command cannot declare both restart_token and a clause"
945 );
946 }
947 if clause.args.iter().any(|arg| arg.sigil.is_some()) {
948 bail_parse!(
949 ctx,
950 kdl.span(),
951 "sigil arguments are not supported inside clauses"
952 );
953 }
954 if let Some(spelling) = clause.conflicting_flag_spelling(&schema.cmd.flags) {
955 bail_parse!(
956 ctx,
957 kdl.span(),
958 "clause flag spelling {spelling:?} conflicts with another flag on this command"
959 );
960 }
961 }
962 flagset::expand(ctx, &mut schema.cmd, &mut schema.flagsets)?;
965 schema
966 .cmd
967 .validate_clause_flag_spellings()
968 .map_err(|message| ctx.build_err(message, (0, ctx.spec.len()).into()))?;
969 if resolve_outputs {
970 output::resolve_selectors(&mut schema)?;
971 }
972 schema
973 .cmd
974 .validate_sigil_prefixes()
975 .map_err(|message| ctx.build_err(message, (0, ctx.spec.len()).into()))?;
976 schema.sources.extend(ctx.sources());
977 set_subcommand_ancestors(&mut schema.cmd, &[]);
978 Ok(schema)
979 }
980
981 pub fn merge(&mut self, other: Spec) {
982 macro_rules! merge_str {
983 ($field:ident) => {
984 if !other.$field.is_empty() {
985 self.$field = other.$field;
986 }
987 };
988 }
989 macro_rules! merge_opt {
990 ($field:ident) => {
991 if other.$field.is_some() {
992 self.$field = other.$field;
993 }
994 };
995 }
996 macro_rules! merge_extend {
997 ($field:ident) => {
998 if !other.$field.is_empty() {
999 self.$field.extend(other.$field);
1000 }
1001 };
1002 }
1003
1004 merge_str!(name);
1005 merge_str!(bin);
1006 merge_str!(usage);
1007 merge_opt!(about);
1008 merge_opt!(source_code_link_template);
1009 merge_opt!(repository);
1010 merge_opt!(version);
1011 merge_opt!(long_version);
1012 merge_opt!(author);
1013 merge_opt!(about_long);
1014 merge_opt!(about_md);
1015 merge_opt!(license);
1016 merge_opt!(logo);
1017 merge_opt!(logo_style);
1018 merge_opt!(before_help);
1019 merge_opt!(after_help);
1020 merge_opt!(before_help_long);
1021 merge_opt!(after_help_long);
1022 merge_opt!(help_template);
1023 merge_opt!(disable_help);
1024 merge_opt!(min_usage_version);
1025 merge_opt!(default_subcommand);
1026 if other.default_subcommand_flags_set {
1027 self.default_subcommand_flags = other.default_subcommand_flags;
1028 self.default_subcommand_flags_set = true;
1029 }
1030 if other.default_subcommand_on_empty_set {
1031 self.default_subcommand_on_empty = other.default_subcommand_on_empty;
1032 self.default_subcommand_on_empty_set = true;
1033 }
1034 if other.default_subcommand_help_set {
1035 self.default_subcommand_help = other.default_subcommand_help;
1036 self.default_subcommand_help_set = true;
1037 }
1038 if other.multicall_set {
1039 self.multicall = other.multicall;
1040 self.multicall_set = true;
1041 }
1042 merge_opt!(unknown_flags);
1043 merge_extend!(complete);
1044 merge_extend!(views);
1045 merge_extend!(flagsets);
1052 merge_extend!(examples);
1053 merge_extend!(outputs);
1054 merge_extend!(exit_codes);
1055 merge_opt!(select);
1056 merge_extend!(sources);
1058
1059 if !other.config.is_empty() {
1060 self.config.merge(&other.config);
1061 }
1062 self.cmd.merge(other.cmd);
1063 }
1064}
1065
1066pub(crate) fn spec_flag_forms_overlap(a: &SpecFlag, b: &SpecFlag) -> bool {
1067 fn long_forms(flag: &SpecFlag) -> impl Iterator<Item = &str> {
1068 flag.long
1069 .iter()
1070 .chain(&flag.hidden_aliases)
1071 .map(String::as_str)
1072 .chain(
1073 flag.negate
1074 .as_deref()
1075 .map(|name| name.strip_prefix("--").unwrap_or(name)),
1076 )
1077 }
1078 fn short_forms(flag: &SpecFlag) -> impl Iterator<Item = &char> {
1079 flag.short.iter().chain(&flag.hidden_short_aliases)
1080 }
1081
1082 long_forms(a).any(|name| long_forms(b).any(|other| other == name))
1083 || short_forms(a).any(|name| short_forms(b).any(|other| other == name))
1084}
1085
1086fn flag_matches_selector(flag: &SpecFlag, selector: &str) -> bool {
1087 selector.strip_prefix("--").is_some_and(|name| {
1088 flag.long
1089 .iter()
1090 .chain(&flag.hidden_aliases)
1091 .any(|long| long == name)
1092 || flag
1093 .negate
1094 .as_deref()
1095 .is_some_and(|negate| negate.strip_prefix("--").unwrap_or(negate) == name)
1096 }) || selector
1097 .strip_prefix('-')
1098 .filter(|short| short.len() == 1)
1099 .and_then(|short| short.chars().next())
1100 .is_some_and(|short| {
1101 flag.short
1102 .iter()
1103 .chain(&flag.hidden_short_aliases)
1104 .any(|candidate| *candidate == short)
1105 })
1106}
1107
1108fn check_usage_version(version: &str) {
1109 let cur = semver::Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
1110 match parse_usage_version(version) {
1111 Ok(v) => {
1112 if cur < v {
1113 warn!(
1114 "This usage spec requires at least version {version}, but you are using version {cur} of usage"
1115 );
1116 }
1117 }
1118 Err(_) => warn!("Invalid version: {version}"),
1119 }
1120}
1121
1122fn parse_usage_version(version: &str) -> Result<semver::Version, semver::Error> {
1128 let core_end = version.find(['-', '+']).unwrap_or(version.len());
1129 let (core, suffix) = version.split_at(core_end);
1130 let missing = match core.matches('.').count() {
1131 0 => ".0.0",
1132 1 => ".0",
1133 _ => "",
1134 };
1135
1136 semver::Version::parse(&format!("{core}{missing}{suffix}"))
1137}
1138
1139fn read_to_string(file: &Path) -> Result<String, UsageErr> {
1144 std::fs::read_to_string(file).map_err(|err| UsageErr::FileError(err, file.to_path_buf()))
1145}
1146
1147static USAGE_COMMENT: LazyLock<Regex> =
1150 LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])(.*)$").unwrap());
1151static HAS_USAGE_COMMENT: LazyLock<Regex> =
1154 LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])").unwrap());
1155static BLANK_COMMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(?:#|//|::)\s*$").unwrap());
1157
1158fn split_script(file: &Path) -> Result<String, UsageErr> {
1159 let full = read_to_string(file)?;
1160 if full.starts_with("#!") && full.lines().any(|l| HAS_USAGE_COMMENT.is_match(l)) {
1162 return Ok(extract_usage_from_comments(&full));
1163 }
1164 Ok(full)
1166}
1167
1168fn extract_usage_from_comments(full: &str) -> String {
1169 let mut usage = vec![];
1170 let mut found = false;
1171 for line in full.lines() {
1172 if let Some(captures) = USAGE_COMMENT.captures(line) {
1173 found = true;
1174 let content = captures.get(1).map_or("", |m| m.as_str());
1175 usage.push(content.trim());
1176 } else if found {
1177 if BLANK_COMMENT.is_match(line) {
1179 continue;
1180 }
1181 break;
1183 }
1184 }
1185 usage.join("\n")
1186}
1187
1188fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) {
1189 restamp_paths(cmd, ancestors, false);
1190}
1191
1192fn restamp_paths(cmd: &mut SpecCommand, ancestors: &[String], force: bool) {
1206 for subcmd in cmd.subcommands.values_mut() {
1207 subcmd.full_cmd = ancestors
1208 .iter()
1209 .cloned()
1210 .chain(once(subcmd.name.clone()))
1211 .collect();
1212 let child_ancestors = subcmd.full_cmd.clone();
1213 restamp_paths(subcmd, &child_ancestors, force);
1214 }
1215 if force {
1216 cmd.reset_subcommand_lookup();
1219 cmd.usage = cmd.usage();
1220 } else if cmd.usage.is_empty() {
1221 cmd.usage = cmd.usage();
1222 }
1223}
1224
1225impl Display for Spec {
1226 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1227 let mut doc = KdlDocument::new();
1228 let nodes = &mut doc.nodes_mut();
1229 if !self.name.is_empty() {
1230 let mut node = KdlNode::new("name");
1231 node.push(string_entry(None, &self.name));
1232 nodes.push(node);
1233 }
1234 if !self.bin.is_empty() {
1235 let mut node = KdlNode::new("bin");
1236 node.push(string_entry(None, &self.bin));
1237 nodes.push(node);
1238 }
1239 if let Some(version) = &self.version {
1240 let mut node = KdlNode::new("version");
1241 node.push(string_entry(None, version));
1242 nodes.push(node);
1243 }
1244 if let Some(version) = &self.long_version {
1245 let mut node = KdlNode::new("long_version");
1246 node.push(string_entry(None, version));
1247 nodes.push(node);
1248 }
1249 if let Some(author) = &self.author {
1250 let mut node = KdlNode::new("author");
1251 node.push(string_entry(None, author));
1252 nodes.push(node);
1253 }
1254 if let Some(about) = &self.about {
1255 let mut node = KdlNode::new("about");
1256 node.push(string_entry(None, about));
1257 nodes.push(node);
1258 }
1259 if let Some(source_code_link_template) = &self.source_code_link_template {
1260 let mut node = KdlNode::new("source_code_link_template");
1261 node.push(string_entry(None, source_code_link_template));
1262 nodes.push(node);
1263 }
1264 if let Some(repository) = &self.repository {
1265 let mut node = KdlNode::new("repository");
1266 node.push(string_entry(None, repository));
1267 nodes.push(node);
1268 }
1269 if let Some(about_md) = &self.about_md {
1270 let mut node = KdlNode::new("about_md");
1271 node.push(string_entry(None, about_md));
1272 nodes.push(node);
1273 }
1274 if let Some(long_about) = &self.about_long {
1275 let mut node = KdlNode::new("long_about");
1276 node.push(string_entry(None, long_about));
1277 nodes.push(node);
1278 }
1279 if let Some(license) = &self.license {
1280 let mut node = KdlNode::new("license");
1281 node.push(string_entry(None, license));
1282 nodes.push(node);
1283 }
1284 if let Some(logo) = &self.logo {
1285 let mut node = KdlNode::new("logo");
1286 node.push(string_entry(None, logo));
1287 if let Some(style) = &self.logo_style {
1288 node.push(string_entry(Some("style"), style));
1289 }
1290 nodes.push(node);
1291 }
1292 if let Some(before_help) = &self.before_help {
1293 let mut node = KdlNode::new("before_help");
1294 node.push(string_entry(None, before_help));
1295 nodes.push(node);
1296 }
1297 if let Some(after_help) = &self.after_help {
1298 let mut node = KdlNode::new("after_help");
1299 node.push(string_entry(None, after_help));
1300 nodes.push(node);
1301 }
1302 if let Some(before_help_long) = &self.before_help_long {
1303 let mut node = KdlNode::new("before_long_help");
1304 node.push(string_entry(None, before_help_long));
1305 nodes.push(node);
1306 }
1307 if let Some(after_help_long) = &self.after_help_long {
1308 let mut node = KdlNode::new("after_long_help");
1309 node.push(string_entry(None, after_help_long));
1310 nodes.push(node);
1311 }
1312 if let Some(help_template) = &self.help_template {
1313 let mut node = KdlNode::new("help_template");
1314 node.push(string_entry(None, help_template));
1315 nodes.push(node);
1316 }
1317 if let Some(disable_help) = self.disable_help {
1318 let mut node = KdlNode::new("disable_help");
1319 node.push(KdlEntry::new(disable_help));
1320 nodes.push(node);
1321 }
1322 if let Some(min_usage_version) = &self.min_usage_version {
1323 let mut node = KdlNode::new("min_usage_version");
1324 node.push(string_entry(None, min_usage_version));
1325 nodes.push(node);
1326 }
1327 if let Some(unknown_flags) = &self.unknown_flags {
1328 let mut node = KdlNode::new("unknown_flags");
1329 node.push(string_entry(None, unknown_flags.as_str()));
1330 nodes.push(node);
1331 }
1332 if let Some(default_subcommand) = &self.default_subcommand {
1333 let mut node = KdlNode::new("default_subcommand");
1334 node.push(string_entry(None, default_subcommand));
1335 nodes.push(node);
1336 }
1337 if self.default_subcommand_flags_set || self.default_subcommand_flags {
1338 let mut node = KdlNode::new("default_subcommand_flags");
1339 node.push(KdlEntry::new(self.default_subcommand_flags));
1340 nodes.push(node);
1341 }
1342 if self.default_subcommand_on_empty_set || self.default_subcommand_on_empty {
1343 let mut node = KdlNode::new("default_subcommand_on_empty");
1344 node.push(KdlEntry::new(self.default_subcommand_on_empty));
1345 nodes.push(node);
1346 }
1347 if self.default_subcommand_help_set || self.default_subcommand_help {
1348 let mut node = KdlNode::new("default_subcommand_help");
1349 node.push(KdlEntry::new(self.default_subcommand_help));
1350 nodes.push(node);
1351 }
1352 if self.multicall_set {
1353 let mut node = KdlNode::new("multicall");
1354 node.push(KdlEntry::new(self.multicall));
1355 nodes.push(node);
1356 }
1357 if self.cmd.external_subcommand {
1358 let mut node = KdlNode::new("external_subcommand");
1359 node.push(KdlEntry::new(true));
1360 nodes.push(node);
1361 }
1362 if self.cmd.arg_required_else_help {
1363 let mut node = KdlNode::new("arg_required_else_help");
1364 node.push(KdlEntry::new(true));
1365 nodes.push(node);
1366 }
1367 if self.cmd.disable_help_flag {
1368 let mut node = KdlNode::new("disable_help_flag");
1369 node.push(KdlEntry::new(true));
1370 nodes.push(node);
1371 }
1372 if self.cmd.disable_help_subcommand {
1373 let mut node = KdlNode::new("disable_help_subcommand");
1374 node.push(KdlEntry::new(true));
1375 nodes.push(node);
1376 }
1377 if self.cmd.disable_version_flag {
1378 let mut node = KdlNode::new("disable_version_flag");
1379 node.push(KdlEntry::new(true));
1380 nodes.push(node);
1381 }
1382 if self.cmd.dont_delimit_trailing_values {
1383 let mut node = KdlNode::new("dont_delimit_trailing_values");
1384 node.push(true);
1385 nodes.push(node);
1386 }
1387 if !self.cmd.args_override_self {
1388 let mut node = KdlNode::new("args_override_self");
1389 node.push(false);
1390 nodes.push(node);
1391 }
1392 if self.cmd.subcommand_negates_reqs {
1393 let mut node = KdlNode::new("subcommand_negates_reqs");
1394 node.push(true);
1395 nodes.push(node);
1396 }
1397 if self.cmd.args_conflicts_with_subcommands {
1398 let mut node = KdlNode::new("args_conflicts_with_subcommands");
1399 node.push(true);
1400 nodes.push(node);
1401 }
1402 if self.cmd.subcommand_precedence_over_arg {
1403 let mut node = KdlNode::new("subcommand_precedence_over_arg");
1404 node.push(true);
1405 nodes.push(node);
1406 }
1407 if self.cmd.allow_missing_positional {
1408 let mut node = KdlNode::new("allow_missing_positional");
1409 node.push(true);
1410 nodes.push(node);
1411 }
1412 if let Some(message) = &self.cmd.deprecated {
1413 let mut node = KdlNode::new("deprecated");
1414 node.push(string_entry(None, message));
1415 nodes.push(node);
1416 }
1417 if let Some(surface) = &self.cmd.surface {
1418 let mut node = KdlNode::new("surface");
1419 node.push(string_entry(None, surface));
1420 nodes.push(node);
1421 }
1422 if !self.cmd.available_if.is_empty() {
1423 let mut node = KdlNode::new("available_if");
1424 for condition in &self.cmd.available_if {
1425 node.push(string_entry(None, condition));
1426 }
1427 nodes.push(node);
1428 }
1429 if let Some(at) = &self.cmd.deprecated_warn_at {
1430 let mut node = KdlNode::new("deprecated_warn_at");
1431 node.push(string_entry(None, at));
1432 nodes.push(node);
1433 }
1434 if let Some(at) = &self.cmd.deprecated_remove_at {
1435 let mut node = KdlNode::new("deprecated_remove_at");
1436 node.push(string_entry(None, at));
1437 nodes.push(node);
1438 }
1439 if self.cmd.subcommand_required && !self.cmd.subcommands.is_empty() {
1440 let mut node = KdlNode::new("subcommand_required");
1441 node.push(true);
1442 nodes.push(node);
1443 }
1444 if let Some(heading) = &self.cmd.subcommand_help_heading {
1445 let mut node = KdlNode::new("subcommand_help_heading");
1446 node.push(string_entry(None, heading));
1447 nodes.push(node);
1448 }
1449 if let Some(name) = &self.cmd.subcommand_value_name {
1450 let mut node = KdlNode::new("subcommand_value_name");
1451 node.push(string_entry(None, name));
1452 nodes.push(node);
1453 }
1454 if self.cmd.next_line_help {
1455 let mut node = KdlNode::new("next_line_help");
1456 node.push(true);
1457 nodes.push(node);
1458 }
1459 if self.cmd.flatten_help {
1460 let mut node = KdlNode::new("flatten_help");
1461 node.push(true);
1462 nodes.push(node);
1463 }
1464 if let Some(width) = self.cmd.term_width {
1465 let mut node = KdlNode::new("term_width");
1466 node.push(width as i128);
1467 nodes.push(node);
1468 }
1469 if let Some(width) = self.cmd.max_term_width {
1470 let mut node = KdlNode::new("max_term_width");
1471 node.push(width as i128);
1472 nodes.push(node);
1473 }
1474 if !self.usage.is_empty() {
1475 let mut node = KdlNode::new("usage");
1476 node.push(string_entry(None, &self.usage));
1477 nodes.push(node);
1478 }
1479 for flag in self.cmd.flags.iter() {
1480 nodes.push(flag.into());
1481 }
1482 for arg in self.cmd.args.iter() {
1483 nodes.push(arg.into());
1484 }
1485 if let Some(clause) = &self.cmd.clause {
1486 nodes.push(clause.into());
1487 }
1488 for mount in self.cmd.mounts.iter() {
1491 nodes.push(mount.into());
1492 }
1493 for group in self.cmd.groups.iter() {
1494 nodes.push(group.into());
1495 }
1496 for example in self.examples.iter() {
1497 nodes.push(example.into());
1498 }
1499 for heading in self.cmd.headings.iter() {
1500 nodes.push(heading.into());
1501 }
1502 for output in self.outputs.iter() {
1503 nodes.push(output.into());
1504 }
1505 if let Some(select) = &self.select {
1506 let mut node = KdlNode::new("select");
1507 node.push(string_entry(None, select));
1508 nodes.push(node);
1509 }
1510 for exit_code in self.exit_codes.iter() {
1511 nodes.push(exit_code.into());
1512 }
1513 for complete in self.complete.values() {
1514 nodes.push(complete.into());
1515 }
1516 for complete in self.cmd.complete.values() {
1517 nodes.push(complete.into());
1518 }
1519 for view in self.views.values() {
1520 let rendered: KdlDocument = view
1521 .to_string()
1522 .parse()
1523 .expect("a view always renders valid KDL");
1524 nodes.extend(rendered.nodes().iter().cloned());
1525 }
1526 for cmd in self.cmd.subcommands.values() {
1527 nodes.push(cmd.into())
1528 }
1529 if !self.config.is_empty() {
1530 nodes.push((&self.config).into());
1531 }
1532 doc.autoformat_config(&kdl::FormatConfig::default());
1533 write!(f, "{doc}")
1534 }
1535}
1536
1537fn expand_include_env(input: &str, env: &HashMap<String, String>) -> Result<String, String> {
1538 let mut output = String::with_capacity(input.len());
1539 let mut chars = input.chars().peekable();
1540 while let Some(ch) = chars.next() {
1541 if ch != '$' {
1542 output.push(ch);
1543 continue;
1544 }
1545 if chars.next_if_eq(&'$').is_some() {
1546 output.push('$');
1547 continue;
1548 }
1549
1550 let (name, braced) = if chars.next_if_eq(&'{').is_some() {
1551 let mut name = String::new();
1552 let mut closed = false;
1553 for ch in chars.by_ref() {
1554 if ch == '}' {
1555 closed = true;
1556 break;
1557 }
1558 name.push(ch);
1559 }
1560 if !closed {
1561 return Err("include path contains an unterminated environment reference".into());
1562 }
1563 (name, true)
1564 } else {
1565 let mut name = String::new();
1566 while chars
1567 .peek()
1568 .is_some_and(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
1569 {
1570 name.push(chars.next().expect("peeked character exists"));
1571 }
1572 (name, false)
1573 };
1574 if name.is_empty() {
1575 output.push_str(if braced { "${}" } else { "$" });
1576 continue;
1577 }
1578 let value = env.get(&name).ok_or_else(|| {
1579 format!("include path references undefined environment variable {name}")
1580 })?;
1581 output.push_str(value);
1582 }
1583 Ok(output)
1584}
1585
1586impl FromStr for Spec {
1587 type Err = UsageErr;
1588
1589 fn from_str(s: &str) -> Result<Self, Self::Err> {
1590 Self::parse(&ParsingContext::new(Path::new(""), s), s)
1596 }
1597}
1598
1599#[cfg(feature = "clap")]
1600impl From<&clap::Command> for Spec {
1601 fn from(cmd: &clap::Command) -> Self {
1602 let mut spec = Spec {
1603 name: cmd.get_name().to_string(),
1604 bin: cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string(),
1605 cmd: cmd.into(),
1606 version: cmd.get_version().map(|v| v.to_string()),
1607 long_version: cmd.get_long_version().map(|v| v.to_string()),
1608 about: cmd.get_about().map(|a| a.to_string()),
1609 about_long: cmd.get_long_about().map(|a| a.to_string()),
1610 usage: cmd.clone().render_usage().to_string(),
1611 unknown_flags: crate::spec::cmd::SpecCommand::from(cmd).unknown_flags,
1614 multicall: cmd.is_multicall_set(),
1615 multicall_set: cmd.is_multicall_set(),
1616 ..Default::default()
1617 };
1618 set_subcommand_ancestors(&mut spec.cmd, &[]);
1623 spec
1624 }
1625}
1626
1627impl From<SpecCommand> for Spec {
1633 fn from(cmd: SpecCommand) -> Self {
1634 let mut spec = Self {
1635 name: cmd.name.clone(),
1636 bin: cmd.name.clone(),
1637 about: cmd.help.clone(),
1638 about_long: cmd.help_long.clone(),
1639 cmd,
1640 ..Self::default()
1641 };
1642 spec.restamp();
1645 spec
1646 }
1647}
1648
1649#[inline]
1650pub fn is_true(b: &bool) -> bool {
1651 *b
1652}
1653
1654#[inline]
1655pub fn is_false(b: &bool) -> bool {
1656 !is_true(b)
1657}
1658
1659#[cfg(test)]
1660mod tests {
1661 use super::*;
1662 use insta::assert_snapshot;
1663
1664 #[test]
1665 fn parses_relaxed_usage_versions_as_semver() {
1666 assert_eq!(
1667 parse_usage_version("6").unwrap(),
1668 semver::Version::new(6, 0, 0)
1669 );
1670 assert_eq!(
1671 parse_usage_version("6.1").unwrap(),
1672 semver::Version::new(6, 1, 0)
1673 );
1674 assert_eq!(
1675 parse_usage_version("6.1.2-beta.1+build").unwrap(),
1676 semver::Version::parse("6.1.2-beta.1+build").unwrap()
1677 );
1678 assert!(parse_usage_version("not-a-version").is_err());
1679 assert!(parse_usage_version("6.1.2.3").is_err());
1680 }
1681
1682 #[test]
1683 fn test_display() {
1684 let spec = Spec::parse(
1685 &Default::default(),
1686 r#"
1687name "Usage CLI"
1688bin "usage"
1689arg "arg1"
1690flag "-f --force" global=#true
1691cmd "config" {
1692 cmd "set" {
1693 arg "key" help="Key to set"
1694 arg "value"
1695 }
1696}
1697complete "file" run="ls" descriptions=#true
1698 "#,
1699 )
1700 .unwrap();
1701 assert_snapshot!(spec, @r#"
1702 name "Usage CLI"
1703 bin usage
1704 flag "-f --force" global=#true
1705 arg <arg1>
1706 complete file run=ls descriptions=#true
1707 cmd config {
1708 cmd set {
1709 arg <key> help="Key to set"
1710 arg <value>
1711 }
1712 }
1713 "#);
1714 }
1715
1716 #[test]
1717 fn test_repository_round_trips() {
1718 let spec = Spec::parse(
1719 &Default::default(),
1720 r#"
1721bin "mise"
1722repository "https://github.com/jdx/mise"
1723source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1724 "#,
1725 )
1726 .unwrap();
1727 assert_eq!(
1728 spec.repository.as_deref(),
1729 Some("https://github.com/jdx/mise")
1730 );
1731 assert_snapshot!(spec, @r#"
1734 name mise
1735 bin mise
1736 source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1737 repository "https://github.com/jdx/mise"
1738 "#);
1739 }
1740
1741 #[test]
1742 fn test_repository_merges_like_the_other_optionals() {
1743 let mut generated = Spec::parse(&Default::default(), r#"bin "mise""#).unwrap();
1746 let extra = Spec::parse(
1747 &Default::default(),
1748 r#"repository "https://github.com/jdx/mise""#,
1749 )
1750 .unwrap();
1751 generated.merge(extra);
1752 assert_eq!(
1753 generated.repository.as_deref(),
1754 Some("https://github.com/jdx/mise")
1755 );
1756 }
1757
1758 #[test]
1759 #[cfg(feature = "clap")]
1760 fn test_clap() {
1761 let cmd = clap::Command::new("test");
1762 assert_snapshot!(Spec::from(&cmd), @r#"
1763 name test
1764 bin test
1765 unknown_flags error
1766 args_override_self #false
1767 usage "Usage: test"
1768 "#);
1769 }
1770
1771 #[test]
1772 #[cfg(feature = "clap")]
1773 fn a_clap_subcommand_knows_where_it_sits() {
1774 let cmd = clap::Command::new("ex").subcommand(
1779 clap::Command::new("go")
1780 .about("Go somewhere")
1781 .subcommand(clap::Command::new("fast").about("Quickly")),
1782 );
1783 let spec = Spec::from(&cmd);
1784
1785 let go = spec.cmd.subcommands.get("go").expect("go");
1786 assert_eq!(go.full_cmd, ["go"]);
1787 assert_eq!(go.usage, "go [SUBCOMMAND]");
1790
1791 let fast = go.subcommands.get("fast").expect("fast");
1793 assert_eq!(fast.full_cmd, ["go", "fast"]);
1794 assert_eq!(fast.usage, "go fast");
1795 }
1796
1797 #[test]
1798 #[cfg(feature = "clap")]
1799 fn a_delimited_default_becomes_the_values_clap_would_split_it_into() {
1800 let cmd = clap::Command::new("test").arg(
1808 clap::Arg::new("events")
1809 .long("events")
1810 .value_delimiter(',')
1811 .action(clap::ArgAction::Append)
1812 .value_parser(["a", "b", "c"])
1813 .default_value("a,b"),
1814 );
1815 let spec = Spec::from(&cmd);
1816 let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1817 assert_eq!(flag.default, ["a", "b"]);
1818
1819 let cmd = clap::Command::new("test")
1822 .arg(clap::Arg::new("events").long("events").default_value("a,b"));
1823 let spec = Spec::from(&cmd);
1824 let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1825 assert_eq!(flag.default, ["a,b"]);
1826 }
1827
1828 #[test]
1829 fn multicall_round_trips() {
1830 let spec = Spec::parse(
1831 &Default::default(),
1832 r#"
1833name "busybox"
1834bin "busybox"
1835multicall #true
1836cmd "ls"
1837cmd "cat"
1838 "#,
1839 )
1840 .unwrap();
1841 assert!(spec.multicall);
1842 let emitted = spec.to_string();
1843 assert!(
1844 emitted.contains("multicall #true"),
1845 "lost on the way out: {emitted}"
1846 );
1847 let again: Spec = emitted.parse().unwrap();
1848 assert!(again.multicall);
1849 }
1850
1851 #[test]
1852 fn a_declared_view_promotes_a_command_and_selected_globals() {
1853 let spec: Spec = r#"
1854name "aube"
1855bin "aube"
1856about_md "Host **markdown**"
1857before_help "host before"
1858before_long_help "host long before"
1859after_help "host after"
1860after_long_help "host long after"
1861example "aube host" header="host example"
1862flag "-v --verbose" global=#true
1863flag "--config <FILE>" global=#true
1864view "aubr" root="run" {
1865 global "--verbose"
1866}
1867cmd "run" help="Run a package script" {
1868 before_help "run before"
1869 before_long_help "run long before"
1870 after_help "run after"
1871 after_long_help "run long after"
1872 example "aubr task" header="run example"
1873 flag "--if-present"
1874 arg "[SCRIPT]"
1875 cmd "nested"
1876}
1877"#
1878 .parse()
1879 .unwrap();
1880
1881 let rendered = spec.to_string();
1882 assert!(rendered.contains("view aubr root=run"), "{rendered}");
1883 let reparsed: Spec = rendered.parse().unwrap();
1884 let applet = reparsed.for_view("aubr").unwrap();
1885 assert_eq!(applet.name, "aubr");
1886 assert_eq!(applet.bin, "aubr");
1887 assert_eq!(applet.about.as_deref(), Some("Run a package script"));
1888 assert_eq!(applet.about_md, None);
1889 assert_eq!(applet.before_help.as_deref(), Some("run before"));
1890 assert_eq!(applet.before_help_long.as_deref(), Some("run long before"));
1891 assert_eq!(applet.after_help.as_deref(), Some("run after"));
1892 assert_eq!(applet.after_help_long.as_deref(), Some("run long after"));
1893 assert_eq!(applet.examples.len(), 1);
1894 assert_eq!(applet.examples[0].header.as_deref(), Some("run example"));
1895 assert!(applet.cmd.flags.iter().any(|flag| flag.name == "verbose"));
1896 assert!(
1897 applet
1898 .cmd
1899 .flags
1900 .iter()
1901 .any(|flag| flag.name == "if-present")
1902 );
1903 assert!(!applet.cmd.flags.iter().any(|flag| flag.name == "config"));
1904 assert!(applet.cmd.subcommands.contains_key("nested"));
1905 assert!(applet.views.is_empty());
1906 assert!(applet.to_string().contains("bin aubr"));
1907 }
1908
1909 #[test]
1910 fn a_view_preserves_the_host_version_entry_policy() {
1911 let spec: Spec = r#"
1912bin "host"
1913version "1.2.3"
1914disable_version_flag #true
1915view "runner" root=run
1916cmd "run"
1917"#
1918 .parse()
1919 .unwrap();
1920
1921 let view = spec.for_view("runner").unwrap();
1922 assert!(view.cmd.disable_version_flag);
1923 let emitted = view.to_string();
1924 assert!(emitted.contains("disable_version_flag #true"), "{emitted}");
1925 let reparsed: Spec = emitted.parse().unwrap();
1926 assert!(reparsed.cmd.disable_version_flag);
1927 }
1928
1929 #[test]
1930 fn a_promoted_flag_shadows_every_carried_global_spelling() {
1931 let spec: Spec = r#"
1932bin "host"
1933flag "--color" global=#true negate="--no-color"
1934view "runner" root=run {
1935 global "--color"
1936}
1937cmd "run" {
1938 flag "--no-color"
1939}
1940"#
1941 .parse()
1942 .unwrap();
1943
1944 let view = spec.for_view("runner").unwrap();
1945 assert_eq!(view.cmd.flags.len(), 1);
1946 assert_eq!(view.cmd.flags[0].long, ["no-color"]);
1947 }
1948
1949 #[test]
1950 fn a_view_keeps_only_its_own_and_carried_global_completers() {
1951 let spec: Spec = r#"
1952bin "host"
1953flag "--host <HOST>" global=#true
1954flag "--carried <CARRIED>" global=#true
1955complete "host" run="host candidates"
1956complete "carried" run="carried candidates"
1957view "runner" root=run {
1958 global "--carried"
1959}
1960cmd "run" {
1961 arg "<HOST>"
1962 complete "host" run="view candidates"
1963}
1964"#
1965 .parse()
1966 .unwrap();
1967
1968 let view = spec.for_view("runner").unwrap();
1969 assert_eq!(
1970 view.complete.get("host").unwrap().run.as_deref(),
1971 Some("view candidates")
1972 );
1973 assert_eq!(
1974 view.complete.get("carried").unwrap().run.as_deref(),
1975 Some("carried candidates")
1976 );
1977 assert!(view.cmd.complete.is_empty());
1978 assert_eq!(view.complete.len(), 2);
1979 assert_eq!(view.to_string().matches("complete ").count(), 2);
1980 }
1981
1982 #[test]
1983 fn a_view_projects_groups_of_carried_globals() {
1984 let spec: Spec = r#"
1985bin "host"
1986flag "--json" global=#true
1987flag "--yaml" global=#true
1988flag "--toml" global=#true
1989group "format" "--json" "--yaml" "--toml" required=#true
1990view "all" root=run globals=#true
1991view "json" root=run {
1992 global "--json"
1993}
1994cmd "run"
1995"#
1996 .parse()
1997 .unwrap();
1998
1999 let all = spec.for_view("all").unwrap();
2000 assert_eq!(all.cmd.groups.len(), 1);
2001 assert_eq!(all.cmd.groups[0].members, ["--json", "--yaml", "--toml"]);
2002 assert!(all.to_string().parse::<Spec>().is_ok());
2003
2004 let json = spec.for_view("json").unwrap();
2005 assert!(json.cmd.groups.is_empty());
2006 assert!(
2007 json.cmd
2008 .flags
2009 .iter()
2010 .find(|flag| flag.name == "json")
2011 .unwrap()
2012 .required
2013 );
2014 assert!(json.to_string().parse::<Spec>().is_ok());
2015 }
2016
2017 #[test]
2018 fn a_view_refuses_unknown_commands_and_non_global_carryovers() {
2019 let missing: Spec = "bin \"ex\"\nview \"x\" root=missing\n".parse().unwrap();
2020 assert!(
2021 missing
2022 .for_view("x")
2023 .unwrap_err()
2024 .to_string()
2025 .contains("missing")
2026 );
2027
2028 let local: Spec =
2029 "bin \"ex\"\nflag \"--local\"\nview \"x\" root=go { global \"--local\" }\ncmd go\n"
2030 .parse()
2031 .unwrap();
2032 assert!(
2033 local
2034 .for_view("x")
2035 .unwrap_err()
2036 .to_string()
2037 .contains("not a root global")
2038 );
2039 }
2040
2041 #[test]
2042 fn a_string_parsed_spec_reports_the_offending_line() {
2043 let err = "flag \"--verbose\"\nflag \"--json\" bogus_key=\"nope\"\n"
2044 .parse::<Spec>()
2045 .unwrap_err();
2046 let rendered = format!("{:?}", crate::miette::Error::from(err));
2047 assert!(
2048 rendered.contains("unsupported flag key bogus_key"),
2049 "{rendered}"
2050 );
2051 assert!(rendered.contains("flag \"--json\""), "{rendered}");
2054 }
2055
2056 #[test]
2057 fn parse_str_with_path_resolves_relative_includes() {
2058 let dir = tempfile::tempdir().unwrap();
2059 let included = dir.path().join("shared.usage.kdl");
2060 std::fs::write(&included, "flagset \"common\" {\n flag \"--shared\"\n}\n").unwrap();
2061
2062 let spec_text = "include file=\"./shared.usage.kdl\"\nuse \"common\"\nflag \"--own\"\n";
2063
2064 assert!(spec_text.parse::<Spec>().is_err());
2066
2067 let spec =
2068 Spec::parse_str_with_path(spec_text, &dir.path().join("root.usage.kdl")).unwrap();
2069 let flags: Vec<_> = spec.cmd.flags.iter().map(|f| f.name.clone()).collect();
2070 assert_eq!(flags, vec!["shared".to_string(), "own".to_string()]);
2071 }
2072
2073 #[test]
2074 fn parse_str_with_path_and_env_expands_nested_include_paths() {
2075 let dir = tempfile::tempdir().unwrap();
2076 let shared = dir.path().join("shared");
2077 std::fs::create_dir(&shared).unwrap();
2078 std::fs::write(
2079 shared.join("common.usage.kdl"),
2080 "include file=\"${SHARED_ROOT}/flags.usage.kdl\"\nuse \"common\"\n",
2081 )
2082 .unwrap();
2083 std::fs::write(
2084 shared.join("flags.usage.kdl"),
2085 "flagset \"common\" {\n flag \"--shared\"\n}\n",
2086 )
2087 .unwrap();
2088 let input = "include file=\"$CONFIG_ROOT/shared/common.usage.kdl\"\n";
2089 let env = HashMap::from([
2090 (
2091 "CONFIG_ROOT".to_string(),
2092 dir.path().to_string_lossy().into_owned(),
2093 ),
2094 (
2095 "SHARED_ROOT".to_string(),
2096 shared.to_string_lossy().into_owned(),
2097 ),
2098 ]);
2099
2100 let spec =
2101 Spec::parse_str_with_path_and_env(input, &dir.path().join("mise-tasks/build"), &env)
2102 .unwrap();
2103
2104 assert_eq!(spec.cmd.flags.len(), 1);
2105 assert_eq!(spec.cmd.flags[0].long, ["shared"]);
2106 }
2107
2108 #[test]
2109 fn parse_str_with_path_and_env_rejects_an_undefined_include_variable() {
2110 let err = Spec::parse_str_with_path_and_env(
2111 "include file=\"$MISSING/shared.usage.kdl\"\n",
2112 Path::new("mise-tasks/build"),
2113 &HashMap::new(),
2114 )
2115 .unwrap_err();
2116 let rendered = format!("{:?}", crate::miette::Error::from(err));
2117
2118 assert!(
2119 rendered.contains("undefined environment variable MISSING"),
2120 "{rendered}"
2121 );
2122 }
2123
2124 #[test]
2125 fn include_environment_expansion_supports_braces_and_literal_dollars() {
2126 let env = HashMap::from([("ROOT".to_string(), "/project".to_string())]);
2127
2128 assert_eq!(
2129 expand_include_env("$ROOT/${ROOT}/$$ROOT/assets/${}", &env).unwrap(),
2130 "/project//project/$ROOT/assets/${}"
2131 );
2132 assert!(expand_include_env("${ROOT", &env).is_err());
2133 }
2134
2135 #[test]
2136 fn an_included_spec_can_enable_or_disable_multicall() {
2137 let dir = tempfile::tempdir().unwrap();
2138 let included = dir.path().join("included.usage.kdl");
2139 let root = dir.path().join("root.usage.kdl");
2140
2141 std::fs::write(&included, "multicall #false\n").unwrap();
2142 std::fs::write(
2143 &root,
2144 "multicall #true\ninclude file=\"./included.usage.kdl\"\n",
2145 )
2146 .unwrap();
2147 let spec = Spec::parse_file(&root).unwrap();
2148 assert!(!spec.multicall);
2149 assert!(spec.to_string().contains("multicall #false"));
2150
2151 std::fs::write(&included, "multicall #true\n").unwrap();
2152 std::fs::write(
2153 &root,
2154 "multicall #false\ninclude file=\"./included.usage.kdl\"\n",
2155 )
2156 .unwrap();
2157 let spec = Spec::parse_file(&root).unwrap();
2158 assert!(spec.multicall);
2159 assert!(spec.to_string().contains("multicall #true"));
2160 }
2161
2162 #[test]
2163 #[cfg(feature = "clap")]
2164 fn multicall_comes_across_from_clap() {
2165 let cmd = clap::Command::new("busybox")
2166 .multicall(true)
2167 .subcommand(clap::Command::new("ls"))
2168 .subcommand(clap::Command::new("cat"));
2169 let spec = Spec::from(&cmd);
2170 assert!(spec.multicall);
2171 assert!(
2172 spec.to_string().contains("multicall #true"),
2173 "{}",
2174 spec.to_string()
2175 );
2176
2177 let plain = clap::Command::new("ex").subcommand(clap::Command::new("ls"));
2178 assert!(!Spec::from(&plain).multicall);
2179 }
2180
2181 macro_rules! extract_usage_tests {
2182 ($($name:ident: $input:expr, $expected:expr,)*) => {
2183 $(
2184 #[test]
2185 fn $name() {
2186 let result = extract_usage_from_comments($input);
2187 let expected = $expected.trim_start_matches('\n').trim_end();
2188 assert_eq!(result, expected);
2189 }
2190 )*
2191 }
2192 }
2193
2194 extract_usage_tests! {
2195 test_extract_usage_from_comments_original_hash:
2196 r#"
2197#!/bin/bash
2198#USAGE bin "test"
2199#USAGE flag "--foo" help="test"
2200echo "hello"
2201 "#,
2202 r#"
2203bin "test"
2204flag "--foo" help="test"
2205 "#,
2206
2207 test_extract_usage_from_comments_original_double_slash:
2208 r#"
2209#!/usr/bin/env node
2210//USAGE bin "test"
2211//USAGE flag "--foo" help="test"
2212console.log("hello");
2213 "#,
2214 r#"
2215bin "test"
2216flag "--foo" help="test"
2217 "#,
2218
2219 test_extract_usage_from_comments_bracket_with_space:
2220 r#"
2221#!/bin/bash
2222# [USAGE] bin "test"
2223# [USAGE] flag "--foo" help="test"
2224echo "hello"
2225 "#,
2226 r#"
2227bin "test"
2228flag "--foo" help="test"
2229 "#,
2230
2231 test_extract_usage_from_comments_bracket_no_space:
2232 r#"
2233#!/bin/bash
2234#[USAGE] bin "test"
2235#[USAGE] flag "--foo" help="test"
2236echo "hello"
2237 "#,
2238 r#"
2239bin "test"
2240flag "--foo" help="test"
2241 "#,
2242
2243 test_extract_usage_from_comments_double_slash_bracket_with_space:
2244 r#"
2245#!/usr/bin/env node
2246// [USAGE] bin "test"
2247// [USAGE] flag "--foo" help="test"
2248console.log("hello");
2249 "#,
2250 r#"
2251bin "test"
2252flag "--foo" help="test"
2253 "#,
2254
2255 test_extract_usage_from_comments_double_slash_bracket_no_space:
2256 r#"
2257#!/usr/bin/env node
2258//[USAGE] bin "test"
2259//[USAGE] flag "--foo" help="test"
2260console.log("hello");
2261 "#,
2262 r#"
2263bin "test"
2264flag "--foo" help="test"
2265 "#,
2266
2267 test_extract_usage_from_comments_stops_at_gap:
2268 r#"
2269#!/bin/bash
2270#USAGE bin "test"
2271#USAGE flag "--foo" help="test"
2272
2273#USAGE flag "--bar" help="should not be included"
2274echo "hello"
2275 "#,
2276 r#"
2277bin "test"
2278flag "--foo" help="test"
2279 "#,
2280
2281 test_extract_usage_from_comments_with_content_after_marker:
2282 r#"
2283#!/bin/bash
2284# [USAGE] bin "test"
2285# [USAGE] flag "--verbose" help="verbose mode"
2286# [USAGE] arg "input" help="input file"
2287echo "hello"
2288 "#,
2289 r#"
2290bin "test"
2291flag "--verbose" help="verbose mode"
2292arg "input" help="input file"
2293 "#,
2294
2295 test_extract_usage_from_comments_double_colon_original:
2296 r#"
2297::USAGE bin "test"
2298::USAGE flag "--foo" help="test"
2299echo "hello"
2300 "#,
2301 r#"
2302bin "test"
2303flag "--foo" help="test"
2304 "#,
2305
2306 test_extract_usage_from_comments_double_colon_bracket_with_space:
2307 r#"
2308:: [USAGE] bin "test"
2309:: [USAGE] flag "--foo" help="test"
2310echo "hello"
2311 "#,
2312 r#"
2313bin "test"
2314flag "--foo" help="test"
2315 "#,
2316
2317 test_extract_usage_from_comments_double_colon_bracket_no_space:
2318 r#"
2319::[USAGE] bin "test"
2320::[USAGE] flag "--foo" help="test"
2321echo "hello"
2322 "#,
2323 r#"
2324bin "test"
2325flag "--foo" help="test"
2326 "#,
2327
2328 test_extract_usage_from_comments_double_colon_stops_at_gap:
2329 r#"
2330::USAGE bin "test"
2331::USAGE flag "--foo" help="test"
2332
2333::USAGE flag "--bar" help="should not be included"
2334echo "hello"
2335 "#,
2336 r#"
2337bin "test"
2338flag "--foo" help="test"
2339 "#,
2340
2341 test_extract_usage_from_comments_double_colon_with_content_after_marker:
2342 r#"
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_bracket_with_space_multiple_lines:
2355 r#"
2356:: [USAGE] bin "myapp"
2357:: [USAGE] flag "--config <file>" help="config file"
2358:: [USAGE] flag "--verbose" help="verbose output"
2359:: [USAGE] arg "input" help="input file"
2360:: [USAGE] arg "[output]" help="output file" required=#false
2361echo "done"
2362 "#,
2363 r#"
2364bin "myapp"
2365flag "--config <file>" help="config file"
2366flag "--verbose" help="verbose output"
2367arg "input" help="input file"
2368arg "[output]" help="output file" required=#false
2369 "#,
2370
2371 test_extract_usage_from_comments_empty:
2372 r#"
2373#!/bin/bash
2374echo "hello"
2375 "#,
2376 "",
2377
2378 test_extract_usage_from_comments_lowercase_usage:
2379 r#"
2380#!/bin/bash
2381#usage bin "test"
2382#usage flag "--foo" help="test"
2383echo "hello"
2384 "#,
2385 "",
2386
2387 test_extract_usage_from_comments_mixed_case_usage:
2388 r#"
2389#!/bin/bash
2390#Usage bin "test"
2391#Usage flag "--foo" help="test"
2392echo "hello"
2393 "#,
2394 "",
2395
2396 test_extract_usage_from_comments_space_before_usage:
2397 r#"
2398#!/bin/bash
2399# USAGE bin "test"
2400# USAGE flag "--foo" help="test"
2401echo "hello"
2402 "#,
2403 "",
2404
2405 test_extract_usage_from_comments_double_slash_lowercase:
2406 r#"
2407#!/usr/bin/env node
2408//usage bin "test"
2409//usage flag "--foo" help="test"
2410console.log("hello");
2411 "#,
2412 "",
2413
2414 test_extract_usage_from_comments_double_slash_mixed_case:
2415 r#"
2416#!/usr/bin/env node
2417//Usage bin "test"
2418//Usage flag "--foo" help="test"
2419console.log("hello");
2420 "#,
2421 "",
2422
2423 test_extract_usage_from_comments_double_slash_space_before_usage:
2424 r#"
2425#!/usr/bin/env node
2426// USAGE bin "test"
2427// USAGE flag "--foo" help="test"
2428console.log("hello");
2429 "#,
2430 "",
2431
2432 test_extract_usage_from_comments_bracket_lowercase:
2433 r#"
2434#!/bin/bash
2435#[usage] bin "test"
2436#[usage] flag "--foo" help="test"
2437echo "hello"
2438 "#,
2439 "",
2440
2441 test_extract_usage_from_comments_bracket_mixed_case:
2442 r#"
2443#!/bin/bash
2444#[Usage] bin "test"
2445#[Usage] flag "--foo" help="test"
2446echo "hello"
2447 "#,
2448 "",
2449
2450 test_extract_usage_from_comments_bracket_space_lowercase:
2451 r#"
2452#!/bin/bash
2453# [usage] bin "test"
2454# [usage] flag "--foo" help="test"
2455echo "hello"
2456 "#,
2457 "",
2458
2459 test_extract_usage_from_comments_double_colon_lowercase:
2460 r#"
2461::usage bin "test"
2462::usage flag "--foo" help="test"
2463echo "hello"
2464 "#,
2465 "",
2466
2467 test_extract_usage_from_comments_double_colon_mixed_case:
2468 r#"
2469::Usage bin "test"
2470::Usage flag "--foo" help="test"
2471echo "hello"
2472 "#,
2473 "",
2474
2475 test_extract_usage_from_comments_double_colon_space_before_usage:
2476 r#"
2477:: USAGE bin "test"
2478:: USAGE flag "--foo" help="test"
2479echo "hello"
2480 "#,
2481 "",
2482
2483 test_extract_usage_from_comments_double_colon_bracket_lowercase:
2484 r#"
2485::[usage] bin "test"
2486::[usage] flag "--foo" help="test"
2487echo "hello"
2488 "#,
2489 "",
2490
2491 test_extract_usage_from_comments_double_colon_bracket_mixed_case:
2492 r#"
2493::[Usage] bin "test"
2494::[Usage] flag "--foo" help="test"
2495echo "hello"
2496 "#,
2497 "",
2498
2499 test_extract_usage_from_comments_double_colon_bracket_space_lowercase:
2500 r#"
2501:: [usage] bin "test"
2502:: [usage] flag "--foo" help="test"
2503echo "hello"
2504 "#,
2505 "",
2506 }
2507
2508 #[test]
2509 fn test_spec_with_examples() {
2510 let spec = Spec::parse(
2511 &Default::default(),
2512 r#"
2513name "demo"
2514bin "demo"
2515example "demo --help" header="Getting help" help="Display help information"
2516example "demo --version" header="Check version"
2517 "#,
2518 )
2519 .unwrap();
2520
2521 assert_eq!(spec.examples.len(), 2);
2522
2523 assert_eq!(spec.examples[0].code, "demo --help");
2524 assert_eq!(spec.examples[0].header, Some("Getting help".to_string()));
2525 assert_eq!(
2526 spec.examples[0].help,
2527 Some("Display help information".to_string())
2528 );
2529
2530 assert_eq!(spec.examples[1].code, "demo --version");
2531 assert_eq!(spec.examples[1].header, Some("Check version".to_string()));
2532 assert_eq!(spec.examples[1].help, None);
2533 }
2534
2535 #[test]
2536 fn test_spec_examples_display() {
2537 let spec = Spec::parse(
2538 &Default::default(),
2539 r#"
2540name "demo"
2541bin "demo"
2542example "demo --help" header="Getting help" help="Show help"
2543example "demo --version"
2544 "#,
2545 )
2546 .unwrap();
2547
2548 let output = format!("{}", spec);
2549 assert!(
2550 output.contains("example \"demo --help\" header=\"Getting help\" help=\"Show help\"")
2551 );
2552 assert!(output.contains("example \"demo --version\""));
2553 }
2554
2555 #[test]
2556 fn test_parse_script_str() {
2557 let spec = Spec::parse_script_str(
2558 r#"
2559#!/bin/bash
2560#USAGE bin "test"
2561#USAGE flag "--foo" help="test"
2562echo "hello"
2563 "#,
2564 )
2565 .unwrap();
2566
2567 assert_eq!(spec.bin, "test");
2568 assert_eq!(spec.name, "test");
2569 assert_eq!(spec.cmd.flags.len(), 1);
2570 assert_eq!(spec.cmd.flags[0].long, ["foo"]);
2571 }
2572
2573 #[test]
2574 fn test_parse_script_str_rejects_relative_includes() {
2575 let err = Spec::parse_script_str(r#"#USAGE include file="relative.usage.kdl""#)
2576 .expect_err("relative includes need a source path");
2577
2578 match err {
2579 UsageErr::InvalidInput(msg, _, _) => {
2580 assert_eq!(msg, "relative includes require a source file");
2581 }
2582 err => panic!("unexpected error: {err:?}"),
2583 }
2584 }
2585
2586 #[test]
2587 fn test_include_does_not_infer_metadata_from_included_filename() {
2588 let dir = tempfile::tempdir().unwrap();
2589 let included = dir.path().join("overrides.usage.kdl");
2590 let root = dir.path().join("my-script.usage.kdl");
2591 std::fs::write(&included, "").unwrap();
2592 std::fs::write(&root, "include file=\"./overrides.usage.kdl\"\n").unwrap();
2593
2594 let spec = Spec::parse_file(&root).unwrap();
2595
2596 assert_eq!(spec.name, "my-script.usage.kdl");
2597 assert_eq!(spec.bin, "my-script.usage.kdl");
2598 assert!(spec.cmd.name.is_empty());
2599 }
2600
2601 #[test]
2602 fn resolving_mounts_replaces_the_unresolved_synopsis() {
2603 let mut spec: Spec = "bin ex\nmount run=tasks synopsis=\"[TASK] [ARGS]…\""
2604 .parse()
2605 .unwrap();
2606 assert!(spec.cmd.usage.contains("[TASK] [ARGS]…"));
2607 spec.resolve_mount_outputs(&HashMap::from([(
2608 "tasks".to_string(),
2609 "cmd build".to_string(),
2610 )]))
2611 .unwrap();
2612 assert!(spec.cmd.mounts.is_empty());
2613 assert_eq!(spec.cmd.usage, "[SUBCOMMAND]");
2614 assert_eq!(spec.cmd.subcommands["build"].usage, "build");
2615 let json = serde_json::to_value(&spec).unwrap();
2616 assert_eq!(json["cmd"]["usage"], "[SUBCOMMAND]");
2617 }
2618
2619 #[test]
2620 fn injected_nested_mounts_ignore_the_mounted_specs_root_default() {
2621 let mut spec: Spec = "mount run=outer".parse().unwrap();
2622 let outputs = HashMap::from([
2623 (
2624 "outer".to_string(),
2625 "default_subcommand run\nmount run=nested\ncmd run".to_string(),
2626 ),
2627 ("nested".to_string(), "cmd leaf".to_string()),
2628 ]);
2629
2630 spec.resolve_mount_outputs(&outputs).unwrap();
2631
2632 assert!(spec.cmd.subcommands.contains_key("leaf"));
2633 }
2634
2635 #[test]
2636 fn restamping_a_grafted_tree_matches_writing_it_out_and_reading_it_back() {
2637 let host: Spec =
2641 "name \"host\"\nbin \"host\"\ncmd \"plugins\" {\n external_subcommand #true\n}\n"
2642 .parse()
2643 .unwrap();
2644 let plugin: Spec =
2645 "name \"formatter\"\nbin \"formatter\"\narg \"[path]\"\ncmd \"check\" {\n flag \"--fix\"\n}\n"
2646 .parse()
2647 .unwrap();
2648
2649 let mut grafted = host.clone();
2650 grafted
2651 .cmd
2652 .subcommands
2653 .get_mut("plugins")
2654 .unwrap()
2655 .subcommands
2656 .insert("formatter".to_string(), plugin.cmd.clone());
2657 grafted.restamp();
2658
2659 let reparsed: Spec = grafted.to_string().parse().unwrap();
2660 let path = |spec: &Spec, words: &[&str]| {
2661 let mut cmd = &spec.cmd;
2662 for word in words {
2663 cmd = cmd.find_subcommand(word).unwrap();
2664 }
2665 (cmd.full_cmd.clone(), cmd.usage.clone())
2666 };
2667 for words in [
2668 &[][..],
2669 &["plugins"],
2670 &["plugins", "formatter"],
2671 &["plugins", "formatter", "check"],
2672 ] {
2673 assert_eq!(
2674 path(&grafted, words),
2675 path(&reparsed, words),
2676 "at {words:?}"
2677 );
2678 }
2679
2680 assert_eq!(
2683 path(&grafted, &["plugins", "formatter"]).0,
2684 ["plugins", "formatter"]
2685 );
2686 assert!(
2687 path(&grafted, &["plugins"]).1.contains("[SUBCOMMAND]"),
2688 "{:?}",
2689 path(&grafted, &["plugins"]).1
2690 );
2691 assert!(
2693 grafted
2694 .cmd
2695 .find_subcommand("plugins")
2696 .unwrap()
2697 .find_subcommand("formatter")
2698 .is_some()
2699 );
2700 }
2701}