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