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