1pub mod arg;
2pub mod builder;
3pub mod choices;
4pub mod cmd;
5pub mod complete;
6pub mod config;
7pub mod config_type;
8mod context;
9pub mod data_types;
10pub mod effect;
11pub mod flag;
12pub mod flagset;
13pub mod group;
14pub mod helpers;
15pub mod mount;
16pub mod unknown_flags;
17pub mod view;
18
19use indexmap::IndexMap;
20use kdl::{KdlDocument, KdlEntry, KdlNode};
21use log::{info, warn};
22use regex::Regex;
23use serde::Serialize;
24use std::collections::HashMap;
25use std::fmt::{Display, Formatter};
26use std::iter::once;
27use std::path::{Path, PathBuf};
28use std::str::FromStr;
29use std::sync::LazyLock;
30
31use crate::error::UsageErr;
32use crate::spec::cmd::{SpecCommand, SpecExample};
33use crate::spec::config::SpecConfig;
34use crate::spec::context::ParsingContext;
35use crate::spec::flagset::{SpecFlagSet, SpecUse};
36use crate::spec::helpers::{string_entry, NodeHelper};
37use crate::{SpecArg, SpecComplete, SpecFlag};
38use view::SpecView;
39
40#[derive(Debug, Default, Clone, Serialize)]
41#[non_exhaustive]
42pub struct Spec {
43 pub name: String,
44 pub bin: String,
45 pub cmd: SpecCommand,
46 pub config: SpecConfig,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub version: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub long_version: Option<String>,
51 pub usage: String,
52 pub complete: IndexMap<String, SpecComplete>,
53 #[serde(skip_serializing_if = "IndexMap::is_empty")]
55 pub views: IndexMap<String, SpecView>,
56 #[serde(skip)]
62 pub flagsets: IndexMap<String, SpecFlagSet>,
63 #[serde(skip)]
72 pub sources: Vec<PathBuf>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub source_code_link_template: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
83 pub repository: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub author: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub about: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub about_long: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub about_md: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub license: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub before_help: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub after_help: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub before_help_long: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub after_help_long: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none")]
113 pub help_template: Option<String>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub disable_help: Option<bool>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub min_usage_version: Option<String>,
118 #[serde(skip_serializing_if = "Vec::is_empty")]
119 pub examples: Vec<SpecExample>,
120 #[serde(skip_serializing_if = "Option::is_none")]
123 pub default_subcommand: Option<String>,
124 #[serde(default, skip_serializing_if = "is_false")]
131 pub multicall: bool,
132 #[doc(hidden)]
137 #[serde(skip)]
138 pub multicall_set: bool,
139 pub unknown_flags: Option<crate::spec::unknown_flags::UnknownFlags>,
142}
143
144impl Spec {
145 pub fn resolve_mount_outputs(
151 &mut self,
152 outputs: &HashMap<String, String>,
153 ) -> Result<(), UsageErr> {
154 self.resolve_mount_outputs_at_root(outputs, true)
155 }
156
157 pub(crate) fn resolve_mount_outputs_at_root(
158 &mut self,
159 outputs: &HashMap<String, String>,
160 apply_default_subcommand: bool,
161 ) -> Result<(), UsageErr> {
162 fn resolve(
163 cmd: &mut SpecCommand,
164 outputs: &HashMap<String, String>,
165 skip_mounts: bool,
166 ) -> Result<(), UsageErr> {
167 if !skip_mounts && !cmd.mounts.is_empty() {
168 cmd.mount(&[], Some(outputs))?;
169 cmd.mounts.clear();
170 }
171 for subcommand in cmd.subcommands.values_mut() {
172 resolve(subcommand, outputs, false)?;
173 }
174 Ok(())
175 }
176
177 let default_outranks_root_mounts = apply_default_subcommand
178 && self.default_subcommand.is_some()
179 && !self.cmd.mounts.iter().any(|mount| mount.overrides_default);
180 resolve(&mut self.cmd, outputs, default_outranks_root_mounts)
181 }
182
183 #[must_use = "parsing result should be used"]
191 pub fn parse_file(file: &Path) -> Result<Spec, UsageErr> {
192 Self::parse_file_with_metadata_inference(file, true)
193 }
194
195 fn parse_file_with_metadata_inference(
196 file: &Path,
197 infer_metadata_from_filename: bool,
198 ) -> Result<Spec, UsageErr> {
199 let spec = split_script(file)?;
200 let ctx = ParsingContext::new(file, &spec);
201 let mut schema = Self::parse(&ctx, &spec)?;
202 if infer_metadata_from_filename && schema.bin.is_empty() {
203 schema.bin = file
204 .file_name()
205 .and_then(|n| n.to_str())
206 .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
207 .to_string();
208 }
209 if schema.name.is_empty() {
210 schema.name.clone_from(&schema.bin);
211 }
212 Ok(schema)
213 }
214 #[must_use = "parsing result should be used"]
220 pub fn parse_script(file: &Path) -> Result<Spec, UsageErr> {
221 let mut spec = Self::parse_script_with_path(&read_to_string(file)?, file)?;
222 if spec.bin.is_empty() {
223 spec.bin = file
224 .file_name()
225 .and_then(|n| n.to_str())
226 .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
227 .to_string();
228 }
229 if spec.name.is_empty() {
230 spec.name.clone_from(&spec.bin);
231 }
232 Ok(spec)
233 }
234
235 #[must_use = "parsing result should be used"]
243 pub fn parse_script_str(input: &str) -> Result<Spec, UsageErr> {
244 Self::parse_script_with_path(input, Path::new(""))
245 }
246
247 fn parse_script_with_path(input: &str, file: &Path) -> Result<Spec, UsageErr> {
248 let raw = extract_usage_from_comments(input);
249 let ctx = ParsingContext::new(file, &raw);
250 Self::parse(&ctx, &raw)
251 }
252
253 #[deprecated]
254 pub fn parse_spec(input: &str) -> Result<Spec, UsageErr> {
255 Self::parse(&Default::default(), input)
256 }
257
258 pub fn is_empty(&self) -> bool {
259 self.name.is_empty()
260 && self.bin.is_empty()
261 && self.usage.is_empty()
262 && self.cmd.is_empty()
263 && self.config.is_empty()
264 && self.complete.is_empty()
265 && self.views.is_empty()
266 && self.examples.is_empty()
267 }
268
269 pub fn for_view(&self, id: &str) -> Result<Spec, UsageErr> {
275 let view = self
276 .views
277 .get(id)
278 .ok_or_else(|| UsageErr::InvalidView(format!("spec declares no view named `{id}`")))?;
279 let mut command = &self.cmd;
280 for segment in view.root.split_whitespace() {
281 command = command.subcommands.get(segment).ok_or_else(|| {
282 UsageErr::InvalidView(format!(
283 "view `{id}` promotes `{}`, but `{segment}` is not a command on that path",
284 view.root
285 ))
286 })?;
287 }
288 let mut promoted = command.clone();
289 let matches_selector = |flag: &SpecFlag, selector: &str| {
290 selector
291 .strip_prefix("--")
292 .is_some_and(|name| flag.long.iter().any(|long| long == name))
293 || selector
294 .strip_prefix('-')
295 .filter(|short| short.len() == 1)
296 .and_then(|short| short.chars().next())
297 .is_some_and(|short| flag.short.contains(&short))
298 };
299 let carries = |flag: &SpecFlag| {
300 if !flag.global {
301 return false;
302 }
303 view.all_globals
304 || view
305 .globals
306 .iter()
307 .any(|selector| matches_selector(flag, selector))
308 };
309 for selector in &view.globals {
310 if !self
311 .cmd
312 .flags
313 .iter()
314 .any(|flag| flag.global && matches_selector(flag, selector))
315 {
316 return Err(UsageErr::InvalidView(format!(
317 "view `{id}` carries `{selector}`, but it is not a root global flag"
318 )));
319 }
320 }
321 let globals: Vec<SpecFlag> = self
322 .cmd
323 .flags
324 .iter()
325 .filter(|flag| carries(flag) || flag.action == crate::SpecFlagAction::Version)
328 .cloned()
329 .collect();
330 let mut globals: Vec<SpecFlag> = globals
333 .into_iter()
334 .filter(|global| {
335 !promoted
336 .flags
337 .iter()
338 .any(|local| spec_flag_forms_overlap(global, local))
339 })
340 .collect();
341 let mut complete = IndexMap::new();
346 for flag in &globals {
347 if let Some(arg) = &flag.arg {
348 let name = arg.name.to_lowercase();
349 if let Some(completer) = self.complete.get(&name) {
350 complete.insert(name, completer.clone());
351 }
352 }
353 }
354 complete.extend(std::mem::take(&mut promoted.complete));
355 let mut carried_groups = Vec::new();
359 for group in &self.cmd.groups {
360 let members: Vec<String> = group
361 .members
362 .iter()
363 .filter(|selector| {
364 globals
365 .iter()
366 .any(|flag| flag_matches_selector(flag, selector))
367 })
368 .cloned()
369 .collect();
370 match members.as_slice() {
371 [only] if group.required => {
372 if let Some(flag) = globals
373 .iter_mut()
374 .find(|flag| flag_matches_selector(flag, only))
375 {
376 flag.required = true;
377 }
378 }
379 [_, _, ..] => {
380 let mut projected = group.clone();
381 projected.members = members;
382 carried_groups.push(projected);
383 }
384 _ => {}
385 }
386 }
387 promoted.flags.splice(0..0, globals);
388 promoted.groups.splice(0..0, carried_groups);
389 promoted.name.clone_from(&view.bin);
390 promoted.full_cmd.clear();
391 promoted.aliases.clear();
392 promoted.hidden_aliases.clear();
393 promoted.disable_version_flag = self.cmd.disable_version_flag;
397 set_subcommand_ancestors(&mut promoted, &[]);
398 promoted.usage = promoted.usage();
399
400 let mut spec = self.clone();
401 spec.name.clone_from(&view.name);
402 spec.bin.clone_from(&view.bin);
403 spec.about = promoted.help.clone();
404 spec.about_long = promoted.help_long.clone();
405 spec.about_md = promoted.help_md.clone();
406 spec.before_help = promoted.before_help.clone();
407 spec.before_help_long = promoted.before_help_long.clone();
408 spec.after_help = promoted.after_help.clone();
409 spec.after_help_long = promoted.after_help_long.clone();
410 spec.examples.clone_from(&promoted.examples);
411 spec.usage = promoted.usage.clone();
412 spec.complete = complete;
413 spec.cmd = promoted;
414 spec.default_subcommand = None;
415 spec.multicall = false;
416 spec.multicall_set = false;
417 spec.views.clear();
418 Ok(spec)
419 }
420
421 pub fn view_for_program(&self, program: &str) -> Option<&str> {
423 let basename = crate::parse::multicall_basename(program);
424 if basename == crate::parse::multicall_basename(&self.name)
425 || (!self.bin.is_empty() && basename == crate::parse::multicall_basename(&self.bin))
426 {
427 return None;
428 }
429 self.views.values().find_map(|view| {
430 (basename == crate::parse::multicall_basename(&view.bin)
431 || basename == crate::parse::multicall_basename(&view.id))
432 .then_some(view.id.as_str())
433 })
434 }
435
436 pub(crate) fn parse(ctx: &ParsingContext, input: &str) -> Result<Spec, UsageErr> {
437 let kdl: KdlDocument = input
438 .parse()
439 .map_err(|err: kdl::KdlError| UsageErr::KdlError(err))?;
440 let mut schema = Self {
441 ..Default::default()
442 };
443 if !ctx.file.as_os_str().is_empty() {
446 schema.sources.push(ctx.file.clone());
447 }
448 for node in kdl.nodes().iter().map(|n| NodeHelper::new(ctx, n)) {
449 match node.name() {
450 "name" => schema.name = node.arg(0)?.ensure_string()?,
451 "bin" => {
452 schema.bin = node.arg(0)?.ensure_string()?;
453 if schema.name.is_empty() {
454 schema.name.clone_from(&schema.bin);
455 }
456 }
457 "version" => schema.version = Some(node.arg(0)?.ensure_string()?),
458 "long_version" => schema.long_version = Some(node.arg(0)?.ensure_string()?),
459 "author" => schema.author = Some(node.arg(0)?.ensure_string()?),
460 "source_code_link_template" => {
461 schema.source_code_link_template = Some(node.arg(0)?.ensure_string()?)
462 }
463 "repository" => schema.repository = Some(node.arg(0)?.ensure_string()?),
464 "about" => schema.about = Some(node.arg(0)?.ensure_string()?),
465 "long_about" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
466 "about_long" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
467 "about_md" => schema.about_md = Some(node.arg(0)?.ensure_string()?),
468 "license" => schema.license = Some(node.arg(0)?.ensure_string()?),
469 "before_help" => schema.before_help = Some(node.arg(0)?.ensure_string()?),
470 "after_help" => schema.after_help = Some(node.arg(0)?.ensure_string()?),
471 "before_long_help" | "before_help_long" => {
472 schema.before_help_long = Some(node.arg(0)?.ensure_string()?)
473 }
474 "after_long_help" | "after_help_long" => {
475 schema.after_help_long = Some(node.arg(0)?.ensure_string()?)
476 }
477 "usage" => schema.usage = node.arg(0)?.ensure_string()?,
478 "help_template" => {
482 let template = node.arg(0)?.ensure_string()?;
483 if let Err(problem) = crate::help_template::check(&template) {
484 bail_parse!(ctx, node.span(), "{problem}");
485 }
486 schema.help_template =
489 crate::help_template::is_set(&template).then_some(template);
490 }
491 "arg" => {
492 let arg = SpecArg::parse(ctx, &node)?;
493 if arg.delimiter.is_some() && !arg.var {
496 bail_parse!(
497 ctx,
498 node.node.name().span(),
499 "argument <{}> has a delimiter and holds one value; add \
500 `var=#true` for the values it splits into",
501 arg.name
502 );
503 }
504 schema.cmd.args.push(arg);
505 }
506 "flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?),
507 "group" => schema.cmd.groups.push(crate::SpecGroup::parse(ctx, &node)?),
510 "mount" => schema.cmd.mounts.push(crate::SpecMount::parse(ctx, &node)?),
514 "cmd" => {
515 let node: SpecCommand = SpecCommand::parse(ctx, &node)?;
516 schema.cmd.subcommands.insert(node.name.to_string(), node);
517 }
518 "flagset" => {
519 let set = SpecFlagSet::parse(ctx, &node)?;
520 if schema.flagsets.insert(set.name.clone(), set).is_some() {
521 bail_parse!(ctx, node.span(), "a flagset may be declared only once");
522 }
523 }
524 "use" => {
527 let at = schema.cmd.flags.len();
528 schema.cmd.uses.push(SpecUse::parse(ctx, &node, at)?);
529 }
530 "config" => schema.config = SpecConfig::parse(ctx, &node)?,
531 "complete" => {
532 let complete = SpecComplete::parse(ctx, &node)?;
533 schema.complete.insert(complete.name.clone(), complete);
534 }
535 "view" => {
536 let view = SpecView::parse(ctx, &node)?;
537 if schema.views.insert(view.id.clone(), view).is_some() {
538 bail_parse!(
539 ctx,
540 node.span(),
541 "a view identifier may be declared only once"
542 );
543 }
544 }
545 "disable_help" => schema.disable_help = Some(node.arg(0)?.ensure_bool()?),
546 "min_usage_version" => {
547 let v = node.arg(0)?.ensure_string()?;
548 check_usage_version(&v);
549 schema.min_usage_version = Some(v);
550 }
551 "unknown_flags" => {
552 let raw = node.arg(0)?.ensure_string()?;
553 match raw.parse() {
554 Ok(mode) => schema.unknown_flags = Some(mode),
555 Err(_) => bail_parse!(
556 ctx,
557 node.span(),
558 "unsupported unknown_flags {raw}, expected one of: {}",
559 crate::spec::unknown_flags::UNKNOWN_FLAGS_VALUES
560 ),
561 }
562 }
563 "default_subcommand" => {
564 schema.default_subcommand = Some(node.arg(0)?.ensure_string()?)
565 }
566 "multicall" => {
567 schema.multicall = node.arg(0)?.ensure_bool()?;
568 schema.multicall_set = true;
569 }
570 "external_subcommand" => {
571 schema.cmd.external_subcommand = node.arg(0)?.ensure_bool()?;
572 }
573 "arg_required_else_help" => {
574 schema.cmd.arg_required_else_help = node.arg(0)?.ensure_bool()?;
575 }
576 "disable_help_flag" => {
577 schema.cmd.disable_help_flag = node.arg(0)?.ensure_bool()?;
578 }
579 "disable_help_subcommand" => {
580 schema.cmd.disable_help_subcommand = node.arg(0)?.ensure_bool()?;
581 }
582 "disable_version_flag" => {
583 schema.cmd.disable_version_flag = node.arg(0)?.ensure_bool()?;
584 }
585 "dont_delimit_trailing_values" => {
586 schema.cmd.dont_delimit_trailing_values = node.arg(0)?.ensure_bool()?;
587 }
588 "args_override_self" => {
589 schema.cmd.args_override_self = node.arg(0)?.ensure_bool()?;
590 }
591 "subcommand_negates_reqs" => {
592 schema.cmd.subcommand_negates_reqs = node.arg(0)?.ensure_bool()?;
593 }
594 "args_conflicts_with_subcommands" => {
595 schema.cmd.args_conflicts_with_subcommands = node.arg(0)?.ensure_bool()?;
596 }
597 "subcommand_precedence_over_arg" => {
598 schema.cmd.subcommand_precedence_over_arg = node.arg(0)?.ensure_bool()?;
599 }
600 "allow_missing_positional" => {
601 schema.cmd.allow_missing_positional = node.arg(0)?.ensure_bool()?;
602 }
603 "deprecated" => schema.cmd.deprecated = Some(node.arg(0)?.ensure_string()?),
604 "deprecated_warn_at" => {
605 schema.cmd.deprecated_warn_at = Some(node.arg(0)?.ensure_string()?);
606 }
607 "deprecated_remove_at" => {
608 schema.cmd.deprecated_remove_at = Some(node.arg(0)?.ensure_string()?);
609 }
610 "subcommand_required" => {
611 schema.cmd.subcommand_required = node.arg(0)?.ensure_bool()?;
612 }
613 "subcommand_help_heading" => {
614 schema.cmd.subcommand_help_heading = Some(node.arg(0)?.ensure_string()?);
615 }
616 "subcommand_value_name" => {
617 schema.cmd.subcommand_value_name = Some(node.arg(0)?.ensure_string()?);
618 }
619 "next_line_help" => {
620 schema.cmd.next_line_help = node.arg(0)?.ensure_bool()?;
621 }
622 "flatten_help" => {
623 schema.cmd.flatten_help = node.arg(0)?.ensure_bool()?;
624 }
625 "term_width" => {
626 schema.cmd.term_width = Some(node.arg(0)?.ensure_usize()?);
627 }
628 "max_term_width" => {
629 schema.cmd.max_term_width = Some(node.arg(0)?.ensure_usize()?);
630 }
631 "example" => {
632 let code = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
633 let mut example = SpecExample::new(code.trim().to_string());
634 for (k, v) in node.props() {
635 match k {
636 "header" => example.header = Some(v.ensure_string()?),
637 "help" => example.help = Some(v.ensure_string()?),
638 "lang" => example.lang = v.ensure_string()?,
639 k => bail_parse!(ctx, v.entry.span(), "unsupported example key {k}"),
640 }
641 }
642 schema.examples.push(example);
643 }
644 "include" => {
645 let file = node
646 .props()
647 .get("file")
648 .map(|v| v.ensure_string())
649 .transpose()?
650 .ok_or_else(|| ctx.build_err("missing file".into(), node.span()))?;
651 let file = Path::new(&file);
652 let file = match file.is_relative() {
653 true => ctx
654 .file
655 .parent()
656 .ok_or_else(|| {
657 let msg = if ctx.file.as_os_str().is_empty() {
658 "relative includes require a source file".to_string()
659 } else {
660 format!("cannot get parent of {}", ctx.file.display())
661 };
662 ctx.build_err(msg, node.span())
663 })?
664 .join(file),
665 false => file.to_path_buf(),
666 };
667 info!("include: {}", file.display());
668 let other = Self::parse_file_with_metadata_inference(&file, false)?;
669 let clash = other.flagsets.values().find(|incoming| {
680 schema
681 .flagsets
682 .get(&incoming.name)
683 .is_some_and(|own| own.declared_in != incoming.declared_in)
684 });
685 if let Some(incoming) = clash {
686 let name = &incoming.name;
687 let owner = schema.flagsets[name].declared_in.clone();
688 let owner = match owner.as_os_str().is_empty() {
689 true => "this spec".to_string(),
690 false => owner.display().to_string(),
691 };
692 bail_parse!(
693 ctx,
694 node.span(),
695 "a flagset may be declared only once: \"{name}\" is declared in \
696 {} and in {owner}",
697 incoming.declared_in.display()
698 );
699 }
700 schema.merge(other);
701 }
702 k => bail_parse!(ctx, node.node.name().span(), "unsupported spec key {k}"),
703 }
704 }
705 schema.cmd.name = if schema.bin.is_empty() {
706 schema.name.clone()
707 } else {
708 schema.bin.clone()
709 };
710 flagset::expand(ctx, &mut schema.cmd, &mut schema.flagsets)?;
712 set_subcommand_ancestors(&mut schema.cmd, &[]);
713 Ok(schema)
714 }
715
716 pub fn merge(&mut self, other: Spec) {
717 macro_rules! merge_str {
718 ($field:ident) => {
719 if !other.$field.is_empty() {
720 self.$field = other.$field;
721 }
722 };
723 }
724 macro_rules! merge_opt {
725 ($field:ident) => {
726 if other.$field.is_some() {
727 self.$field = other.$field;
728 }
729 };
730 }
731 macro_rules! merge_extend {
732 ($field:ident) => {
733 if !other.$field.is_empty() {
734 self.$field.extend(other.$field);
735 }
736 };
737 }
738
739 merge_str!(name);
740 merge_str!(bin);
741 merge_str!(usage);
742 merge_opt!(about);
743 merge_opt!(source_code_link_template);
744 merge_opt!(repository);
745 merge_opt!(version);
746 merge_opt!(long_version);
747 merge_opt!(author);
748 merge_opt!(about_long);
749 merge_opt!(about_md);
750 merge_opt!(license);
751 merge_opt!(before_help);
752 merge_opt!(after_help);
753 merge_opt!(before_help_long);
754 merge_opt!(after_help_long);
755 merge_opt!(help_template);
756 merge_opt!(disable_help);
757 merge_opt!(min_usage_version);
758 merge_opt!(default_subcommand);
759 if other.multicall_set {
760 self.multicall = other.multicall;
761 self.multicall_set = true;
762 }
763 merge_opt!(unknown_flags);
764 merge_extend!(complete);
765 merge_extend!(views);
766 merge_extend!(flagsets);
773 merge_extend!(examples);
774 merge_extend!(sources);
776
777 if !other.config.is_empty() {
778 self.config.merge(&other.config);
779 }
780 self.cmd.merge(other.cmd);
781 }
782}
783
784pub(crate) fn spec_flag_forms_overlap(a: &SpecFlag, b: &SpecFlag) -> bool {
785 fn long_forms(flag: &SpecFlag) -> impl Iterator<Item = &str> {
786 flag.long
787 .iter()
788 .chain(&flag.hidden_aliases)
789 .map(String::as_str)
790 .chain(
791 flag.negate
792 .as_deref()
793 .map(|name| name.strip_prefix("--").unwrap_or(name)),
794 )
795 }
796 fn short_forms(flag: &SpecFlag) -> impl Iterator<Item = &char> {
797 flag.short.iter().chain(&flag.hidden_short_aliases)
798 }
799
800 long_forms(a).any(|name| long_forms(b).any(|other| other == name))
801 || short_forms(a).any(|name| short_forms(b).any(|other| other == name))
802}
803
804fn flag_matches_selector(flag: &SpecFlag, selector: &str) -> bool {
805 selector.strip_prefix("--").is_some_and(|name| {
806 flag.long
807 .iter()
808 .chain(&flag.hidden_aliases)
809 .any(|long| long == name)
810 || flag
811 .negate
812 .as_deref()
813 .is_some_and(|negate| negate.strip_prefix("--").unwrap_or(negate) == name)
814 }) || selector
815 .strip_prefix('-')
816 .filter(|short| short.len() == 1)
817 .and_then(|short| short.chars().next())
818 .is_some_and(|short| {
819 flag.short
820 .iter()
821 .chain(&flag.hidden_short_aliases)
822 .any(|candidate| *candidate == short)
823 })
824}
825
826fn check_usage_version(version: &str) {
827 let cur = versions::Versioning::new(env!("CARGO_PKG_VERSION")).unwrap();
828 match versions::Versioning::new(version) {
829 Some(v) => {
830 if cur < v {
831 warn!(
832 "This usage spec requires at least version {version}, but you are using version {cur} of usage"
833 );
834 }
835 }
836 _ => warn!("Invalid version: {version}"),
837 }
838}
839
840fn read_to_string(file: &Path) -> Result<String, UsageErr> {
845 std::fs::read_to_string(file).map_err(|err| UsageErr::FileError(err, file.to_path_buf()))
846}
847
848static USAGE_COMMENT: LazyLock<Regex> =
851 LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])(.*)$").unwrap());
852static HAS_USAGE_COMMENT: LazyLock<Regex> =
855 LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])").unwrap());
856static BLANK_COMMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(?:#|//|::)\s*$").unwrap());
858
859fn split_script(file: &Path) -> Result<String, UsageErr> {
860 let full = read_to_string(file)?;
861 if full.starts_with("#!") && full.lines().any(|l| HAS_USAGE_COMMENT.is_match(l)) {
863 return Ok(extract_usage_from_comments(&full));
864 }
865 Ok(full)
867}
868
869fn extract_usage_from_comments(full: &str) -> String {
870 let mut usage = vec![];
871 let mut found = false;
872 for line in full.lines() {
873 if let Some(captures) = USAGE_COMMENT.captures(line) {
874 found = true;
875 let content = captures.get(1).map_or("", |m| m.as_str());
876 usage.push(content.trim());
877 } else if found {
878 if BLANK_COMMENT.is_match(line) {
880 continue;
881 }
882 break;
884 }
885 }
886 usage.join("\n")
887}
888
889fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) {
890 for subcmd in cmd.subcommands.values_mut() {
891 subcmd.full_cmd = ancestors
892 .iter()
893 .cloned()
894 .chain(once(subcmd.name.clone()))
895 .collect();
896 let child_ancestors = subcmd.full_cmd.clone();
897 set_subcommand_ancestors(subcmd, &child_ancestors);
898 }
899 if cmd.usage.is_empty() {
900 cmd.usage = cmd.usage();
901 }
902}
903
904impl Display for Spec {
905 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
906 let mut doc = KdlDocument::new();
907 let nodes = &mut doc.nodes_mut();
908 if !self.name.is_empty() {
909 let mut node = KdlNode::new("name");
910 node.push(string_entry(None, &self.name));
911 nodes.push(node);
912 }
913 if !self.bin.is_empty() {
914 let mut node = KdlNode::new("bin");
915 node.push(string_entry(None, &self.bin));
916 nodes.push(node);
917 }
918 if let Some(version) = &self.version {
919 let mut node = KdlNode::new("version");
920 node.push(string_entry(None, version));
921 nodes.push(node);
922 }
923 if let Some(version) = &self.long_version {
924 let mut node = KdlNode::new("long_version");
925 node.push(string_entry(None, version));
926 nodes.push(node);
927 }
928 if let Some(author) = &self.author {
929 let mut node = KdlNode::new("author");
930 node.push(string_entry(None, author));
931 nodes.push(node);
932 }
933 if let Some(about) = &self.about {
934 let mut node = KdlNode::new("about");
935 node.push(string_entry(None, about));
936 nodes.push(node);
937 }
938 if let Some(source_code_link_template) = &self.source_code_link_template {
939 let mut node = KdlNode::new("source_code_link_template");
940 node.push(string_entry(None, source_code_link_template));
941 nodes.push(node);
942 }
943 if let Some(repository) = &self.repository {
944 let mut node = KdlNode::new("repository");
945 node.push(string_entry(None, repository));
946 nodes.push(node);
947 }
948 if let Some(about_md) = &self.about_md {
949 let mut node = KdlNode::new("about_md");
950 node.push(string_entry(None, about_md));
951 nodes.push(node);
952 }
953 if let Some(long_about) = &self.about_long {
954 let mut node = KdlNode::new("long_about");
955 node.push(string_entry(None, long_about));
956 nodes.push(node);
957 }
958 if let Some(license) = &self.license {
959 let mut node = KdlNode::new("license");
960 node.push(string_entry(None, license));
961 nodes.push(node);
962 }
963 if let Some(before_help) = &self.before_help {
964 let mut node = KdlNode::new("before_help");
965 node.push(string_entry(None, before_help));
966 nodes.push(node);
967 }
968 if let Some(after_help) = &self.after_help {
969 let mut node = KdlNode::new("after_help");
970 node.push(string_entry(None, after_help));
971 nodes.push(node);
972 }
973 if let Some(before_help_long) = &self.before_help_long {
974 let mut node = KdlNode::new("before_long_help");
975 node.push(string_entry(None, before_help_long));
976 nodes.push(node);
977 }
978 if let Some(after_help_long) = &self.after_help_long {
979 let mut node = KdlNode::new("after_long_help");
980 node.push(string_entry(None, after_help_long));
981 nodes.push(node);
982 }
983 if let Some(help_template) = &self.help_template {
984 let mut node = KdlNode::new("help_template");
985 node.push(string_entry(None, help_template));
986 nodes.push(node);
987 }
988 if let Some(disable_help) = self.disable_help {
989 let mut node = KdlNode::new("disable_help");
990 node.push(KdlEntry::new(disable_help));
991 nodes.push(node);
992 }
993 if let Some(min_usage_version) = &self.min_usage_version {
994 let mut node = KdlNode::new("min_usage_version");
995 node.push(string_entry(None, min_usage_version));
996 nodes.push(node);
997 }
998 if let Some(unknown_flags) = &self.unknown_flags {
999 let mut node = KdlNode::new("unknown_flags");
1000 node.push(string_entry(None, unknown_flags.as_str()));
1001 nodes.push(node);
1002 }
1003 if let Some(default_subcommand) = &self.default_subcommand {
1004 let mut node = KdlNode::new("default_subcommand");
1005 node.push(string_entry(None, default_subcommand));
1006 nodes.push(node);
1007 }
1008 if self.multicall_set {
1009 let mut node = KdlNode::new("multicall");
1010 node.push(KdlEntry::new(self.multicall));
1011 nodes.push(node);
1012 }
1013 if self.cmd.external_subcommand {
1014 let mut node = KdlNode::new("external_subcommand");
1015 node.push(KdlEntry::new(true));
1016 nodes.push(node);
1017 }
1018 if self.cmd.arg_required_else_help {
1019 let mut node = KdlNode::new("arg_required_else_help");
1020 node.push(KdlEntry::new(true));
1021 nodes.push(node);
1022 }
1023 if self.cmd.disable_help_flag {
1024 let mut node = KdlNode::new("disable_help_flag");
1025 node.push(KdlEntry::new(true));
1026 nodes.push(node);
1027 }
1028 if self.cmd.disable_help_subcommand {
1029 let mut node = KdlNode::new("disable_help_subcommand");
1030 node.push(KdlEntry::new(true));
1031 nodes.push(node);
1032 }
1033 if self.cmd.disable_version_flag {
1034 let mut node = KdlNode::new("disable_version_flag");
1035 node.push(KdlEntry::new(true));
1036 nodes.push(node);
1037 }
1038 if self.cmd.dont_delimit_trailing_values {
1039 let mut node = KdlNode::new("dont_delimit_trailing_values");
1040 node.push(true);
1041 nodes.push(node);
1042 }
1043 if !self.cmd.args_override_self {
1044 let mut node = KdlNode::new("args_override_self");
1045 node.push(false);
1046 nodes.push(node);
1047 }
1048 if self.cmd.subcommand_negates_reqs {
1049 let mut node = KdlNode::new("subcommand_negates_reqs");
1050 node.push(true);
1051 nodes.push(node);
1052 }
1053 if self.cmd.args_conflicts_with_subcommands {
1054 let mut node = KdlNode::new("args_conflicts_with_subcommands");
1055 node.push(true);
1056 nodes.push(node);
1057 }
1058 if self.cmd.subcommand_precedence_over_arg {
1059 let mut node = KdlNode::new("subcommand_precedence_over_arg");
1060 node.push(true);
1061 nodes.push(node);
1062 }
1063 if self.cmd.allow_missing_positional {
1064 let mut node = KdlNode::new("allow_missing_positional");
1065 node.push(true);
1066 nodes.push(node);
1067 }
1068 if let Some(message) = &self.cmd.deprecated {
1069 let mut node = KdlNode::new("deprecated");
1070 node.push(string_entry(None, message));
1071 nodes.push(node);
1072 }
1073 if let Some(at) = &self.cmd.deprecated_warn_at {
1074 let mut node = KdlNode::new("deprecated_warn_at");
1075 node.push(string_entry(None, at));
1076 nodes.push(node);
1077 }
1078 if let Some(at) = &self.cmd.deprecated_remove_at {
1079 let mut node = KdlNode::new("deprecated_remove_at");
1080 node.push(string_entry(None, at));
1081 nodes.push(node);
1082 }
1083 if self.cmd.subcommand_required && !self.cmd.subcommands.is_empty() {
1084 let mut node = KdlNode::new("subcommand_required");
1085 node.push(true);
1086 nodes.push(node);
1087 }
1088 if let Some(heading) = &self.cmd.subcommand_help_heading {
1089 let mut node = KdlNode::new("subcommand_help_heading");
1090 node.push(string_entry(None, heading));
1091 nodes.push(node);
1092 }
1093 if let Some(name) = &self.cmd.subcommand_value_name {
1094 let mut node = KdlNode::new("subcommand_value_name");
1095 node.push(string_entry(None, name));
1096 nodes.push(node);
1097 }
1098 if self.cmd.next_line_help {
1099 let mut node = KdlNode::new("next_line_help");
1100 node.push(true);
1101 nodes.push(node);
1102 }
1103 if self.cmd.flatten_help {
1104 let mut node = KdlNode::new("flatten_help");
1105 node.push(true);
1106 nodes.push(node);
1107 }
1108 if let Some(width) = self.cmd.term_width {
1109 let mut node = KdlNode::new("term_width");
1110 node.push(width as i128);
1111 nodes.push(node);
1112 }
1113 if let Some(width) = self.cmd.max_term_width {
1114 let mut node = KdlNode::new("max_term_width");
1115 node.push(width as i128);
1116 nodes.push(node);
1117 }
1118 if !self.usage.is_empty() {
1119 let mut node = KdlNode::new("usage");
1120 node.push(string_entry(None, &self.usage));
1121 nodes.push(node);
1122 }
1123 for flag in self.cmd.flags.iter() {
1124 nodes.push(flag.into());
1125 }
1126 for arg in self.cmd.args.iter() {
1127 nodes.push(arg.into());
1128 }
1129 for mount in self.cmd.mounts.iter() {
1132 nodes.push(mount.into());
1133 }
1134 for group in self.cmd.groups.iter() {
1135 nodes.push(group.into());
1136 }
1137 for example in self.examples.iter() {
1138 nodes.push(example.into());
1139 }
1140 for complete in self.complete.values() {
1141 nodes.push(complete.into());
1142 }
1143 for complete in self.cmd.complete.values() {
1144 nodes.push(complete.into());
1145 }
1146 for view in self.views.values() {
1147 let rendered: KdlDocument = view
1148 .to_string()
1149 .parse()
1150 .expect("a view always renders valid KDL");
1151 nodes.extend(rendered.nodes().iter().cloned());
1152 }
1153 for cmd in self.cmd.subcommands.values() {
1154 nodes.push(cmd.into())
1155 }
1156 if !self.config.is_empty() {
1157 nodes.push((&self.config).into());
1158 }
1159 doc.autoformat_config(&kdl::FormatConfigBuilder::new().build());
1160 write!(f, "{doc}")
1161 }
1162}
1163
1164impl FromStr for Spec {
1165 type Err = UsageErr;
1166
1167 fn from_str(s: &str) -> Result<Self, Self::Err> {
1168 Self::parse(&Default::default(), s)
1169 }
1170}
1171
1172#[cfg(feature = "clap")]
1173impl From<&clap::Command> for Spec {
1174 fn from(cmd: &clap::Command) -> Self {
1175 let mut spec = Spec {
1176 name: cmd.get_name().to_string(),
1177 bin: cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string(),
1178 cmd: cmd.into(),
1179 version: cmd.get_version().map(|v| v.to_string()),
1180 long_version: cmd.get_long_version().map(|v| v.to_string()),
1181 about: cmd.get_about().map(|a| a.to_string()),
1182 about_long: cmd.get_long_about().map(|a| a.to_string()),
1183 usage: cmd.clone().render_usage().to_string(),
1184 unknown_flags: crate::spec::cmd::SpecCommand::from(cmd).unknown_flags,
1187 multicall: cmd.is_multicall_set(),
1188 multicall_set: cmd.is_multicall_set(),
1189 ..Default::default()
1190 };
1191 set_subcommand_ancestors(&mut spec.cmd, &[]);
1196 spec
1197 }
1198}
1199
1200#[inline]
1201pub fn is_true(b: &bool) -> bool {
1202 *b
1203}
1204
1205#[inline]
1206pub fn is_false(b: &bool) -> bool {
1207 !is_true(b)
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212 use super::*;
1213 use insta::assert_snapshot;
1214
1215 #[test]
1216 fn test_display() {
1217 let spec = Spec::parse(
1218 &Default::default(),
1219 r#"
1220name "Usage CLI"
1221bin "usage"
1222arg "arg1"
1223flag "-f --force" global=#true
1224cmd "config" {
1225 cmd "set" {
1226 arg "key" help="Key to set"
1227 arg "value"
1228 }
1229}
1230complete "file" run="ls" descriptions=#true
1231 "#,
1232 )
1233 .unwrap();
1234 assert_snapshot!(spec, @r#"
1235 name "Usage CLI"
1236 bin usage
1237 flag "-f --force" global=#true
1238 arg <arg1>
1239 complete file run=ls descriptions=#true
1240 cmd config {
1241 cmd set {
1242 arg <key> help="Key to set"
1243 arg <value>
1244 }
1245 }
1246 "#);
1247 }
1248
1249 #[test]
1250 fn test_repository_round_trips() {
1251 let spec = Spec::parse(
1252 &Default::default(),
1253 r#"
1254bin "mise"
1255repository "https://github.com/jdx/mise"
1256source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1257 "#,
1258 )
1259 .unwrap();
1260 assert_eq!(
1261 spec.repository.as_deref(),
1262 Some("https://github.com/jdx/mise")
1263 );
1264 assert_snapshot!(spec, @r#"
1267 name mise
1268 bin mise
1269 source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1270 repository "https://github.com/jdx/mise"
1271 "#);
1272 }
1273
1274 #[test]
1275 fn test_repository_merges_like_the_other_optionals() {
1276 let mut generated = Spec::parse(&Default::default(), r#"bin "mise""#).unwrap();
1279 let extra = Spec::parse(
1280 &Default::default(),
1281 r#"repository "https://github.com/jdx/mise""#,
1282 )
1283 .unwrap();
1284 generated.merge(extra);
1285 assert_eq!(
1286 generated.repository.as_deref(),
1287 Some("https://github.com/jdx/mise")
1288 );
1289 }
1290
1291 #[test]
1292 #[cfg(feature = "clap")]
1293 fn test_clap() {
1294 let cmd = clap::Command::new("test");
1295 assert_snapshot!(Spec::from(&cmd), @r#"
1296 name test
1297 bin test
1298 unknown_flags error
1299 args_override_self #false
1300 usage "Usage: test"
1301 "#);
1302 }
1303
1304 #[test]
1305 #[cfg(feature = "clap")]
1306 fn a_clap_subcommand_knows_where_it_sits() {
1307 let cmd = clap::Command::new("ex").subcommand(
1312 clap::Command::new("go")
1313 .about("Go somewhere")
1314 .subcommand(clap::Command::new("fast").about("Quickly")),
1315 );
1316 let spec = Spec::from(&cmd);
1317
1318 let go = spec.cmd.subcommands.get("go").expect("go");
1319 assert_eq!(go.full_cmd, ["go"]);
1320 assert_eq!(go.usage, "go <SUBCOMMAND>");
1323
1324 let fast = go.subcommands.get("fast").expect("fast");
1326 assert_eq!(fast.full_cmd, ["go", "fast"]);
1327 assert_eq!(fast.usage, "go fast");
1328 }
1329
1330 #[test]
1331 #[cfg(feature = "clap")]
1332 fn a_delimited_default_becomes_the_values_clap_would_split_it_into() {
1333 let cmd = clap::Command::new("test").arg(
1341 clap::Arg::new("events")
1342 .long("events")
1343 .value_delimiter(',')
1344 .action(clap::ArgAction::Append)
1345 .value_parser(["a", "b", "c"])
1346 .default_value("a,b"),
1347 );
1348 let spec = Spec::from(&cmd);
1349 let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1350 assert_eq!(flag.default, ["a", "b"]);
1351
1352 let cmd = clap::Command::new("test")
1355 .arg(clap::Arg::new("events").long("events").default_value("a,b"));
1356 let spec = Spec::from(&cmd);
1357 let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1358 assert_eq!(flag.default, ["a,b"]);
1359 }
1360
1361 #[test]
1362 fn multicall_round_trips() {
1363 let spec = Spec::parse(
1364 &Default::default(),
1365 r#"
1366name "busybox"
1367bin "busybox"
1368multicall #true
1369cmd "ls"
1370cmd "cat"
1371 "#,
1372 )
1373 .unwrap();
1374 assert!(spec.multicall);
1375 let emitted = spec.to_string();
1376 assert!(
1377 emitted.contains("multicall #true"),
1378 "lost on the way out: {emitted}"
1379 );
1380 let again: Spec = emitted.parse().unwrap();
1381 assert!(again.multicall);
1382 }
1383
1384 #[test]
1385 fn a_declared_view_promotes_a_command_and_selected_globals() {
1386 let spec: Spec = r#"
1387name "aube"
1388bin "aube"
1389about_md "Host **markdown**"
1390before_help "host before"
1391before_long_help "host long before"
1392after_help "host after"
1393after_long_help "host long after"
1394example "aube host" header="host example"
1395flag "-v --verbose" global=#true
1396flag "--config <FILE>" global=#true
1397view "aubr" root="run" {
1398 global "--verbose"
1399}
1400cmd "run" help="Run a package script" {
1401 before_help "run before"
1402 before_long_help "run long before"
1403 after_help "run after"
1404 after_long_help "run long after"
1405 example "aubr task" header="run example"
1406 flag "--if-present"
1407 arg "[SCRIPT]"
1408 cmd "nested"
1409}
1410"#
1411 .parse()
1412 .unwrap();
1413
1414 let rendered = spec.to_string();
1415 assert!(rendered.contains("view aubr root=run"), "{rendered}");
1416 let reparsed: Spec = rendered.parse().unwrap();
1417 let applet = reparsed.for_view("aubr").unwrap();
1418 assert_eq!(applet.name, "aubr");
1419 assert_eq!(applet.bin, "aubr");
1420 assert_eq!(applet.about.as_deref(), Some("Run a package script"));
1421 assert_eq!(applet.about_md, None);
1422 assert_eq!(applet.before_help.as_deref(), Some("run before"));
1423 assert_eq!(applet.before_help_long.as_deref(), Some("run long before"));
1424 assert_eq!(applet.after_help.as_deref(), Some("run after"));
1425 assert_eq!(applet.after_help_long.as_deref(), Some("run long after"));
1426 assert_eq!(applet.examples.len(), 1);
1427 assert_eq!(applet.examples[0].header.as_deref(), Some("run example"));
1428 assert!(applet.cmd.flags.iter().any(|flag| flag.name == "verbose"));
1429 assert!(applet
1430 .cmd
1431 .flags
1432 .iter()
1433 .any(|flag| flag.name == "if-present"));
1434 assert!(!applet.cmd.flags.iter().any(|flag| flag.name == "config"));
1435 assert!(applet.cmd.subcommands.contains_key("nested"));
1436 assert!(applet.views.is_empty());
1437 assert!(applet.to_string().contains("bin aubr"));
1438 }
1439
1440 #[test]
1441 fn a_view_preserves_the_host_version_entry_policy() {
1442 let spec: Spec = r#"
1443bin "host"
1444version "1.2.3"
1445disable_version_flag #true
1446view "runner" root=run
1447cmd "run"
1448"#
1449 .parse()
1450 .unwrap();
1451
1452 let view = spec.for_view("runner").unwrap();
1453 assert!(view.cmd.disable_version_flag);
1454 let emitted = view.to_string();
1455 assert!(emitted.contains("disable_version_flag #true"), "{emitted}");
1456 let reparsed: Spec = emitted.parse().unwrap();
1457 assert!(reparsed.cmd.disable_version_flag);
1458 }
1459
1460 #[test]
1461 fn a_promoted_flag_shadows_every_carried_global_spelling() {
1462 let spec: Spec = r#"
1463bin "host"
1464flag "--color" global=#true negate="--no-color"
1465view "runner" root=run {
1466 global "--color"
1467}
1468cmd "run" {
1469 flag "--no-color"
1470}
1471"#
1472 .parse()
1473 .unwrap();
1474
1475 let view = spec.for_view("runner").unwrap();
1476 assert_eq!(view.cmd.flags.len(), 1);
1477 assert_eq!(view.cmd.flags[0].long, ["no-color"]);
1478 }
1479
1480 #[test]
1481 fn a_view_keeps_only_its_own_and_carried_global_completers() {
1482 let spec: Spec = r#"
1483bin "host"
1484flag "--host <HOST>" global=#true
1485flag "--carried <CARRIED>" global=#true
1486complete "host" run="host candidates"
1487complete "carried" run="carried candidates"
1488view "runner" root=run {
1489 global "--carried"
1490}
1491cmd "run" {
1492 arg "<HOST>"
1493 complete "host" run="view candidates"
1494}
1495"#
1496 .parse()
1497 .unwrap();
1498
1499 let view = spec.for_view("runner").unwrap();
1500 assert_eq!(
1501 view.complete.get("host").unwrap().run.as_deref(),
1502 Some("view candidates")
1503 );
1504 assert_eq!(
1505 view.complete.get("carried").unwrap().run.as_deref(),
1506 Some("carried candidates")
1507 );
1508 assert!(view.cmd.complete.is_empty());
1509 assert_eq!(view.complete.len(), 2);
1510 assert_eq!(view.to_string().matches("complete ").count(), 2);
1511 }
1512
1513 #[test]
1514 fn a_view_projects_groups_of_carried_globals() {
1515 let spec: Spec = r#"
1516bin "host"
1517flag "--json" global=#true
1518flag "--yaml" global=#true
1519flag "--toml" global=#true
1520group "format" "--json" "--yaml" "--toml" required=#true
1521view "all" root=run globals=#true
1522view "json" root=run {
1523 global "--json"
1524}
1525cmd "run"
1526"#
1527 .parse()
1528 .unwrap();
1529
1530 let all = spec.for_view("all").unwrap();
1531 assert_eq!(all.cmd.groups.len(), 1);
1532 assert_eq!(all.cmd.groups[0].members, ["--json", "--yaml", "--toml"]);
1533 assert!(all.to_string().parse::<Spec>().is_ok());
1534
1535 let json = spec.for_view("json").unwrap();
1536 assert!(json.cmd.groups.is_empty());
1537 assert!(
1538 json.cmd
1539 .flags
1540 .iter()
1541 .find(|flag| flag.name == "json")
1542 .unwrap()
1543 .required
1544 );
1545 assert!(json.to_string().parse::<Spec>().is_ok());
1546 }
1547
1548 #[test]
1549 fn a_view_refuses_unknown_commands_and_non_global_carryovers() {
1550 let missing: Spec = "bin \"ex\"\nview \"x\" root=missing\n".parse().unwrap();
1551 assert!(missing
1552 .for_view("x")
1553 .unwrap_err()
1554 .to_string()
1555 .contains("missing"));
1556
1557 let local: Spec =
1558 "bin \"ex\"\nflag \"--local\"\nview \"x\" root=go { global \"--local\" }\ncmd go\n"
1559 .parse()
1560 .unwrap();
1561 assert!(local
1562 .for_view("x")
1563 .unwrap_err()
1564 .to_string()
1565 .contains("not a root global"));
1566 }
1567
1568 #[test]
1569 fn an_included_spec_can_enable_or_disable_multicall() {
1570 let dir = tempfile::tempdir().unwrap();
1571 let included = dir.path().join("included.usage.kdl");
1572 let root = dir.path().join("root.usage.kdl");
1573
1574 std::fs::write(&included, "multicall #false\n").unwrap();
1575 std::fs::write(
1576 &root,
1577 "multicall #true\ninclude file=\"./included.usage.kdl\"\n",
1578 )
1579 .unwrap();
1580 let spec = Spec::parse_file(&root).unwrap();
1581 assert!(!spec.multicall);
1582 assert!(spec.to_string().contains("multicall #false"));
1583
1584 std::fs::write(&included, "multicall #true\n").unwrap();
1585 std::fs::write(
1586 &root,
1587 "multicall #false\ninclude file=\"./included.usage.kdl\"\n",
1588 )
1589 .unwrap();
1590 let spec = Spec::parse_file(&root).unwrap();
1591 assert!(spec.multicall);
1592 assert!(spec.to_string().contains("multicall #true"));
1593 }
1594
1595 #[test]
1596 #[cfg(feature = "clap")]
1597 fn multicall_comes_across_from_clap() {
1598 let cmd = clap::Command::new("busybox")
1599 .multicall(true)
1600 .subcommand(clap::Command::new("ls"))
1601 .subcommand(clap::Command::new("cat"));
1602 let spec = Spec::from(&cmd);
1603 assert!(spec.multicall);
1604 assert!(
1605 spec.to_string().contains("multicall #true"),
1606 "{}",
1607 spec.to_string()
1608 );
1609
1610 let plain = clap::Command::new("ex").subcommand(clap::Command::new("ls"));
1611 assert!(!Spec::from(&plain).multicall);
1612 }
1613
1614 macro_rules! extract_usage_tests {
1615 ($($name:ident: $input:expr, $expected:expr,)*) => {
1616 $(
1617 #[test]
1618 fn $name() {
1619 let result = extract_usage_from_comments($input);
1620 let expected = $expected.trim_start_matches('\n').trim_end();
1621 assert_eq!(result, expected);
1622 }
1623 )*
1624 }
1625 }
1626
1627 extract_usage_tests! {
1628 test_extract_usage_from_comments_original_hash:
1629 r#"
1630#!/bin/bash
1631#USAGE bin "test"
1632#USAGE flag "--foo" help="test"
1633echo "hello"
1634 "#,
1635 r#"
1636bin "test"
1637flag "--foo" help="test"
1638 "#,
1639
1640 test_extract_usage_from_comments_original_double_slash:
1641 r#"
1642#!/usr/bin/env node
1643//USAGE bin "test"
1644//USAGE flag "--foo" help="test"
1645console.log("hello");
1646 "#,
1647 r#"
1648bin "test"
1649flag "--foo" help="test"
1650 "#,
1651
1652 test_extract_usage_from_comments_bracket_with_space:
1653 r#"
1654#!/bin/bash
1655# [USAGE] bin "test"
1656# [USAGE] flag "--foo" help="test"
1657echo "hello"
1658 "#,
1659 r#"
1660bin "test"
1661flag "--foo" help="test"
1662 "#,
1663
1664 test_extract_usage_from_comments_bracket_no_space:
1665 r#"
1666#!/bin/bash
1667#[USAGE] bin "test"
1668#[USAGE] flag "--foo" help="test"
1669echo "hello"
1670 "#,
1671 r#"
1672bin "test"
1673flag "--foo" help="test"
1674 "#,
1675
1676 test_extract_usage_from_comments_double_slash_bracket_with_space:
1677 r#"
1678#!/usr/bin/env node
1679// [USAGE] bin "test"
1680// [USAGE] flag "--foo" help="test"
1681console.log("hello");
1682 "#,
1683 r#"
1684bin "test"
1685flag "--foo" help="test"
1686 "#,
1687
1688 test_extract_usage_from_comments_double_slash_bracket_no_space:
1689 r#"
1690#!/usr/bin/env node
1691//[USAGE] bin "test"
1692//[USAGE] flag "--foo" help="test"
1693console.log("hello");
1694 "#,
1695 r#"
1696bin "test"
1697flag "--foo" help="test"
1698 "#,
1699
1700 test_extract_usage_from_comments_stops_at_gap:
1701 r#"
1702#!/bin/bash
1703#USAGE bin "test"
1704#USAGE flag "--foo" help="test"
1705
1706#USAGE flag "--bar" help="should not be included"
1707echo "hello"
1708 "#,
1709 r#"
1710bin "test"
1711flag "--foo" help="test"
1712 "#,
1713
1714 test_extract_usage_from_comments_with_content_after_marker:
1715 r#"
1716#!/bin/bash
1717# [USAGE] bin "test"
1718# [USAGE] flag "--verbose" help="verbose mode"
1719# [USAGE] arg "input" help="input file"
1720echo "hello"
1721 "#,
1722 r#"
1723bin "test"
1724flag "--verbose" help="verbose mode"
1725arg "input" help="input file"
1726 "#,
1727
1728 test_extract_usage_from_comments_double_colon_original:
1729 r#"
1730::USAGE bin "test"
1731::USAGE flag "--foo" help="test"
1732echo "hello"
1733 "#,
1734 r#"
1735bin "test"
1736flag "--foo" help="test"
1737 "#,
1738
1739 test_extract_usage_from_comments_double_colon_bracket_with_space:
1740 r#"
1741:: [USAGE] bin "test"
1742:: [USAGE] flag "--foo" help="test"
1743echo "hello"
1744 "#,
1745 r#"
1746bin "test"
1747flag "--foo" help="test"
1748 "#,
1749
1750 test_extract_usage_from_comments_double_colon_bracket_no_space:
1751 r#"
1752::[USAGE] bin "test"
1753::[USAGE] flag "--foo" help="test"
1754echo "hello"
1755 "#,
1756 r#"
1757bin "test"
1758flag "--foo" help="test"
1759 "#,
1760
1761 test_extract_usage_from_comments_double_colon_stops_at_gap:
1762 r#"
1763::USAGE bin "test"
1764::USAGE flag "--foo" help="test"
1765
1766::USAGE flag "--bar" help="should not be included"
1767echo "hello"
1768 "#,
1769 r#"
1770bin "test"
1771flag "--foo" help="test"
1772 "#,
1773
1774 test_extract_usage_from_comments_double_colon_with_content_after_marker:
1775 r#"
1776::USAGE bin "test"
1777::USAGE flag "--verbose" help="verbose mode"
1778::USAGE arg "input" help="input file"
1779echo "hello"
1780 "#,
1781 r#"
1782bin "test"
1783flag "--verbose" help="verbose mode"
1784arg "input" help="input file"
1785 "#,
1786
1787 test_extract_usage_from_comments_double_colon_bracket_with_space_multiple_lines:
1788 r#"
1789:: [USAGE] bin "myapp"
1790:: [USAGE] flag "--config <file>" help="config file"
1791:: [USAGE] flag "--verbose" help="verbose output"
1792:: [USAGE] arg "input" help="input file"
1793:: [USAGE] arg "[output]" help="output file" required=#false
1794echo "done"
1795 "#,
1796 r#"
1797bin "myapp"
1798flag "--config <file>" help="config file"
1799flag "--verbose" help="verbose output"
1800arg "input" help="input file"
1801arg "[output]" help="output file" required=#false
1802 "#,
1803
1804 test_extract_usage_from_comments_empty:
1805 r#"
1806#!/bin/bash
1807echo "hello"
1808 "#,
1809 "",
1810
1811 test_extract_usage_from_comments_lowercase_usage:
1812 r#"
1813#!/bin/bash
1814#usage bin "test"
1815#usage flag "--foo" help="test"
1816echo "hello"
1817 "#,
1818 "",
1819
1820 test_extract_usage_from_comments_mixed_case_usage:
1821 r#"
1822#!/bin/bash
1823#Usage bin "test"
1824#Usage flag "--foo" help="test"
1825echo "hello"
1826 "#,
1827 "",
1828
1829 test_extract_usage_from_comments_space_before_usage:
1830 r#"
1831#!/bin/bash
1832# USAGE bin "test"
1833# USAGE flag "--foo" help="test"
1834echo "hello"
1835 "#,
1836 "",
1837
1838 test_extract_usage_from_comments_double_slash_lowercase:
1839 r#"
1840#!/usr/bin/env node
1841//usage bin "test"
1842//usage flag "--foo" help="test"
1843console.log("hello");
1844 "#,
1845 "",
1846
1847 test_extract_usage_from_comments_double_slash_mixed_case:
1848 r#"
1849#!/usr/bin/env node
1850//Usage bin "test"
1851//Usage flag "--foo" help="test"
1852console.log("hello");
1853 "#,
1854 "",
1855
1856 test_extract_usage_from_comments_double_slash_space_before_usage:
1857 r#"
1858#!/usr/bin/env node
1859// USAGE bin "test"
1860// USAGE flag "--foo" help="test"
1861console.log("hello");
1862 "#,
1863 "",
1864
1865 test_extract_usage_from_comments_bracket_lowercase:
1866 r#"
1867#!/bin/bash
1868#[usage] bin "test"
1869#[usage] flag "--foo" help="test"
1870echo "hello"
1871 "#,
1872 "",
1873
1874 test_extract_usage_from_comments_bracket_mixed_case:
1875 r#"
1876#!/bin/bash
1877#[Usage] bin "test"
1878#[Usage] flag "--foo" help="test"
1879echo "hello"
1880 "#,
1881 "",
1882
1883 test_extract_usage_from_comments_bracket_space_lowercase:
1884 r#"
1885#!/bin/bash
1886# [usage] bin "test"
1887# [usage] flag "--foo" help="test"
1888echo "hello"
1889 "#,
1890 "",
1891
1892 test_extract_usage_from_comments_double_colon_lowercase:
1893 r#"
1894::usage bin "test"
1895::usage flag "--foo" help="test"
1896echo "hello"
1897 "#,
1898 "",
1899
1900 test_extract_usage_from_comments_double_colon_mixed_case:
1901 r#"
1902::Usage bin "test"
1903::Usage flag "--foo" help="test"
1904echo "hello"
1905 "#,
1906 "",
1907
1908 test_extract_usage_from_comments_double_colon_space_before_usage:
1909 r#"
1910:: USAGE bin "test"
1911:: USAGE flag "--foo" help="test"
1912echo "hello"
1913 "#,
1914 "",
1915
1916 test_extract_usage_from_comments_double_colon_bracket_lowercase:
1917 r#"
1918::[usage] bin "test"
1919::[usage] flag "--foo" help="test"
1920echo "hello"
1921 "#,
1922 "",
1923
1924 test_extract_usage_from_comments_double_colon_bracket_mixed_case:
1925 r#"
1926::[Usage] bin "test"
1927::[Usage] flag "--foo" help="test"
1928echo "hello"
1929 "#,
1930 "",
1931
1932 test_extract_usage_from_comments_double_colon_bracket_space_lowercase:
1933 r#"
1934:: [usage] bin "test"
1935:: [usage] flag "--foo" help="test"
1936echo "hello"
1937 "#,
1938 "",
1939 }
1940
1941 #[test]
1942 fn test_spec_with_examples() {
1943 let spec = Spec::parse(
1944 &Default::default(),
1945 r#"
1946name "demo"
1947bin "demo"
1948example "demo --help" header="Getting help" help="Display help information"
1949example "demo --version" header="Check version"
1950 "#,
1951 )
1952 .unwrap();
1953
1954 assert_eq!(spec.examples.len(), 2);
1955
1956 assert_eq!(spec.examples[0].code, "demo --help");
1957 assert_eq!(spec.examples[0].header, Some("Getting help".to_string()));
1958 assert_eq!(
1959 spec.examples[0].help,
1960 Some("Display help information".to_string())
1961 );
1962
1963 assert_eq!(spec.examples[1].code, "demo --version");
1964 assert_eq!(spec.examples[1].header, Some("Check version".to_string()));
1965 assert_eq!(spec.examples[1].help, None);
1966 }
1967
1968 #[test]
1969 fn test_spec_examples_display() {
1970 let spec = Spec::parse(
1971 &Default::default(),
1972 r#"
1973name "demo"
1974bin "demo"
1975example "demo --help" header="Getting help" help="Show help"
1976example "demo --version"
1977 "#,
1978 )
1979 .unwrap();
1980
1981 let output = format!("{}", spec);
1982 assert!(
1983 output.contains("example \"demo --help\" header=\"Getting help\" help=\"Show help\"")
1984 );
1985 assert!(output.contains("example \"demo --version\""));
1986 }
1987
1988 #[test]
1989 fn test_parse_script_str() {
1990 let spec = Spec::parse_script_str(
1991 r#"
1992#!/bin/bash
1993#USAGE bin "test"
1994#USAGE flag "--foo" help="test"
1995echo "hello"
1996 "#,
1997 )
1998 .unwrap();
1999
2000 assert_eq!(spec.bin, "test");
2001 assert_eq!(spec.name, "test");
2002 assert_eq!(spec.cmd.flags.len(), 1);
2003 assert_eq!(spec.cmd.flags[0].long, ["foo"]);
2004 }
2005
2006 #[test]
2007 fn test_parse_script_str_rejects_relative_includes() {
2008 let err = Spec::parse_script_str(r#"#USAGE include file="relative.usage.kdl""#)
2009 .expect_err("relative includes need a source path");
2010
2011 match err {
2012 UsageErr::InvalidInput(msg, _, _) => {
2013 assert_eq!(msg, "relative includes require a source file");
2014 }
2015 err => panic!("unexpected error: {err:?}"),
2016 }
2017 }
2018
2019 #[test]
2020 fn test_include_does_not_infer_metadata_from_included_filename() {
2021 let dir = tempfile::tempdir().unwrap();
2022 let included = dir.path().join("overrides.usage.kdl");
2023 let root = dir.path().join("my-script.usage.kdl");
2024 std::fs::write(&included, "").unwrap();
2025 std::fs::write(&root, "include file=\"./overrides.usage.kdl\"\n").unwrap();
2026
2027 let spec = Spec::parse_file(&root).unwrap();
2028
2029 assert_eq!(spec.name, "my-script.usage.kdl");
2030 assert_eq!(spec.bin, "my-script.usage.kdl");
2031 assert!(spec.cmd.name.is_empty());
2032 }
2033
2034 #[test]
2035 fn injected_nested_mounts_ignore_the_mounted_specs_root_default() {
2036 let mut spec: Spec = "mount run=outer".parse().unwrap();
2037 let outputs = HashMap::from([
2038 (
2039 "outer".to_string(),
2040 "default_subcommand run\nmount run=nested\ncmd run".to_string(),
2041 ),
2042 ("nested".to_string(), "cmd leaf".to_string()),
2043 ]);
2044
2045 spec.resolve_mount_outputs(&outputs).unwrap();
2046
2047 assert!(spec.cmd.subcommands.contains_key("leaf"));
2048 }
2049}