Skip to main content

usage/spec/
mod.rs

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::{info, 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    /// Named executable surfaces promoted from commands in this canonical spec.
61    #[serde(skip_serializing_if = "IndexMap::is_empty")]
62    pub views: IndexMap<String, SpecView>,
63    /// Reusable flag declarations, by name.
64    ///
65    /// Not serialized, and not re-emitted: a `use` is resolved while the file is read, so by
66    /// the time anything reads this spec the flags are on the commands that use them and these
67    /// entries only record where they came from.
68    #[serde(skip)]
69    pub flagsets: IndexMap<String, SpecFlagSet>,
70    /// Every file this spec was read from: its own path, each `include`, and external output
71    /// schema files, recursively.
72    ///
73    /// What a build script has to watch. A generator that watches only the file it was pointed at
74    /// rebuilds nothing when an included KDL or JSON schema changes.
75    ///
76    /// Not serialized: it is where the spec came from rather than part of what it says, and `usage g
77    /// json` describes the latter.
78    #[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    /// Where the CLI's source lives, e.g. `https://github.com/jdx/mise`.
84    ///
85    /// Distinct from [`Self::source_code_link_template`], which is a per-command
86    /// deep link with a `{{path}}` placeholder and is only usable for building
87    /// "view source" links in generated docs. Scraping a repository out of it
88    /// works for one forge and one URL layout and fails everywhere else.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub repository: Option<String>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub author: Option<String>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub about: Option<String>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub about_long: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub about_md: Option<String>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub license: Option<String>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub before_help: Option<String>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub after_help: Option<String>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub before_help_long: Option<String>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub after_help_long: Option<String>,
109    /// How every page in this CLI is laid out, as named sections.
110    ///
111    /// One template for the whole tree, holding the pre-rendered sections an author may reorder,
112    /// omit, wrap or colour. Nothing else is substituted: the closed section and style
113    /// vocabularies let an interpreter, a compiled parser and generated Go agree without
114    /// exposing the metadata behind a section.
115    ///
116    /// A placeholder naming no section is refused when the spec is read, so a page is never
117    /// rendered from a template one of whose sections cannot be filled.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub help_template: Option<String>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub disable_help: Option<bool>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub min_usage_version: Option<String>,
124    #[serde(skip_serializing_if = "Vec::is_empty")]
125    pub examples: Vec<SpecExample>,
126    /// CLI-wide outputs, inherited by every command that does not say otherwise.
127    #[serde(skip_serializing_if = "Vec::is_empty")]
128    pub outputs: Vec<SpecOutput>,
129    /// The CLI-wide flag whose value picks among [`Self::outputs`].
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub select: Option<String>,
132    /// CLI-wide exit codes, refined per command rather than replaced.
133    #[serde(skip_serializing_if = "Vec::is_empty")]
134    pub exit_codes: Vec<SpecExitCode>,
135    /// Default subcommand to use when first non-flag argument is not a known subcommand.
136    /// This enables "naked" command syntax like `mise foo` instead of `mise run foo`.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub default_subcommand: Option<String>,
139    /// Opt in to routing flags of the default command before its first positional.
140    #[serde(skip_serializing_if = "is_false")]
141    pub default_subcommand_flags: bool,
142    #[serde(skip)]
143    pub default_subcommand_flags_set: bool,
144    /// Whether argv[0]'s basename selects a subcommand (busybox-style applets).
145    ///
146    /// clap's `multicall`. The dispatcher names ([`Self::name`] and [`Self::bin`])
147    /// are skipped; any other basename is parsed as the first word, so a symlink
148    /// `ls -> busybox` runs the `ls` applet. Path components and a trailing `.exe`
149    /// are stripped.
150    #[serde(default, skip_serializing_if = "is_false")]
151    pub multicall: bool,
152    /// Whether the source explicitly declared [`Self::multicall`].
153    ///
154    /// This distinguishes an omitted node from `multicall #false` while includes
155    /// are merged. It is parsing bookkeeping rather than part of the JSON model.
156    #[doc(hidden)]
157    #[serde(skip)]
158    pub multicall_set: bool,
159    /// What to do with a flag-like token that names no declared flag, for the whole
160    /// CLI. A command may override it; see [`SpecCommand::unknown_flags`].
161    pub unknown_flags: Option<crate::spec::unknown_flags::UnknownFlags>,
162}
163
164impl Spec {
165    /// Recompute everything a command's position in this tree decides, after building one by
166    /// hand: each command's path, its usage line, and the memoized subcommand lookup.
167    ///
168    /// A `SpecCommand` inserted into `subcommands` carries the path it had wherever it came
169    /// from — a spec of its own, most likely, where it was the root and its path was empty. Its
170    /// help page would say so. So would its parent's, which gains a `<SUBCOMMAND>` placeholder
171    /// the first time it acquires a child. Call this after grafting, and the tree is
172    /// indistinguishable from one that declared the same commands in KDL.
173    ///
174    /// The alternative — writing the tree out and parsing it back, which is how this was reached
175    /// before — costs a KDL round trip of the whole spec. For a large one that is a quarter of a
176    /// second to fix up strings.
177    pub fn restamp(&mut self) {
178        restamp_paths(&mut self.cmd, &[], true);
179    }
180
181    /// Resolve every mount from supplied command outputs without spawning processes.
182    ///
183    /// This is intended for deterministic generators and conformance harnesses. The
184    /// map is keyed by each mount's exact `run` declaration. Missing entries are an
185    /// error, so injecting a partial view cannot silently execute the remainder.
186    pub fn resolve_mount_outputs(
187        &mut self,
188        outputs: &HashMap<String, String>,
189    ) -> Result<(), UsageErr> {
190        self.resolve_mount_outputs_at_root(outputs, true)
191    }
192
193    pub(crate) fn resolve_mount_outputs_at_root(
194        &mut self,
195        outputs: &HashMap<String, String>,
196        apply_default_subcommand: bool,
197    ) -> Result<(), UsageErr> {
198        fn resolve(
199            cmd: &mut SpecCommand,
200            outputs: &HashMap<String, String>,
201            skip_mounts: bool,
202        ) -> Result<(), UsageErr> {
203            if !skip_mounts && !cmd.mounts.is_empty() {
204                cmd.mount(&[], Some(outputs))?;
205                cmd.mounts.clear();
206            }
207            for subcommand in cmd.subcommands.values_mut() {
208                resolve(subcommand, outputs, false)?;
209            }
210            Ok(())
211        }
212
213        let default_outranks_root_mounts = apply_default_subcommand
214            && self.default_subcommand.is_some()
215            && !self.cmd.mounts.iter().any(|mount| mount.overrides_default);
216        resolve(&mut self.cmd, outputs, default_outranks_root_mounts)?;
217        self.restamp();
218        self.cmd
219            .validate_clause_flag_spellings()
220            .map_err(UsageErr::InvalidSpec)
221    }
222
223    /// Parse a spec from a file.
224    ///
225    /// Automatically detects whether the file is:
226    /// - A `.kdl` or `.usage.kdl` file containing a raw spec
227    /// - A script file with embedded `#USAGE` comments
228    ///
229    /// If `bin` is not specified in the spec, it defaults to the filename.
230    #[must_use = "parsing result should be used"]
231    pub fn parse_file(file: &Path) -> Result<Spec, UsageErr> {
232        Self::parse_file_with_metadata_inference(file, true, true)
233    }
234
235    fn parse_file_with_metadata_inference(
236        file: &Path,
237        infer_metadata_from_filename: bool,
238        resolve_outputs: bool,
239    ) -> Result<Spec, UsageErr> {
240        let spec = split_script(file)?;
241        let ctx = ParsingContext::new(file, &spec);
242        let mut schema = Self::parse_with_output_resolution(&ctx, &spec, resolve_outputs)?;
243        if infer_metadata_from_filename && schema.bin.is_empty() {
244            schema.bin = file
245                .file_name()
246                .and_then(|n| n.to_str())
247                .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
248                .to_string();
249        }
250        if schema.name.is_empty() {
251            schema.name.clone_from(&schema.bin);
252        }
253        Ok(schema)
254    }
255    /// Parse a spec from a script file's embedded USAGE comments.
256    ///
257    /// Extracts the spec from comment lines marked with `#USAGE`, `//USAGE`,
258    /// `::USAGE`, or their `[USAGE]` variants.
259    /// If `bin` is not specified in the spec, it defaults to the filename.
260    #[must_use = "parsing result should be used"]
261    pub fn parse_script(file: &Path) -> Result<Spec, UsageErr> {
262        let mut spec = Self::parse_script_with_path(&read_to_string(file)?, file)?;
263        if spec.bin.is_empty() {
264            spec.bin = file
265                .file_name()
266                .and_then(|n| n.to_str())
267                .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
268                .to_string();
269        }
270        if spec.name.is_empty() {
271            spec.name.clone_from(&spec.bin);
272        }
273        Ok(spec)
274    }
275
276    /// Parse a spec from a script string's embedded USAGE comments.
277    ///
278    /// Extracts the spec from comment lines marked with `#USAGE`, `//USAGE`,
279    /// `::USAGE`, or their `[USAGE]` variants. Unlike [`Self::parse_script`],
280    /// this function cannot infer `bin` or `name` from a filename. Relative
281    /// `include` paths are rejected because there is no source path to resolve
282    /// them against; absolute `include` paths remain supported.
283    #[must_use = "parsing result should be used"]
284    pub fn parse_script_str(input: &str) -> Result<Spec, UsageErr> {
285        Self::parse_script_with_path(input, Path::new(""))
286    }
287
288    fn parse_script_with_path(input: &str, file: &Path) -> Result<Spec, UsageErr> {
289        let raw = extract_usage_from_comments(input);
290        let ctx = ParsingContext::new(file, &raw);
291        Self::parse(&ctx, &raw)
292    }
293
294    #[deprecated]
295    pub fn parse_spec(input: &str) -> Result<Spec, UsageErr> {
296        Self::parse(&Default::default(), input)
297    }
298
299    pub fn is_empty(&self) -> bool {
300        self.name.is_empty()
301            && self.bin.is_empty()
302            && self.usage.is_empty()
303            && self.cmd.is_empty()
304            && self.config.is_empty()
305            && self.complete.is_empty()
306            && self.views.is_empty()
307            && self.examples.is_empty()
308    }
309
310    /// Materialize one declared executable view.
311    ///
312    /// This is a cold-path operation for documentation and completion generation. The canonical
313    /// spec remains unchanged; the returned spec promotes the view's command to the root and
314    /// carries only the root globals the view declares.
315    pub fn for_view(&self, id: &str) -> Result<Spec, UsageErr> {
316        let view = self
317            .views
318            .get(id)
319            .ok_or_else(|| UsageErr::InvalidView(format!("spec declares no view named `{id}`")))?;
320        let mut command = &self.cmd;
321        for segment in view.root.split_whitespace() {
322            command = command.subcommands.get(segment).ok_or_else(|| {
323                UsageErr::InvalidView(format!(
324                    "view `{id}` promotes `{}`, but `{segment}` is not a command on that path",
325                    view.root
326                ))
327            })?;
328        }
329        let mut promoted = command.clone();
330        let matches_selector = |flag: &SpecFlag, selector: &str| {
331            selector
332                .strip_prefix("--")
333                .is_some_and(|name| flag.long.iter().any(|long| long == name))
334                || selector
335                    .strip_prefix('-')
336                    .filter(|short| short.len() == 1)
337                    .and_then(|short| short.chars().next())
338                    .is_some_and(|short| flag.short.contains(&short))
339        };
340        let carries = |flag: &SpecFlag| {
341            if !flag.global {
342                return false;
343            }
344            view.all_globals
345                || view
346                    .globals
347                    .iter()
348                    .any(|selector| matches_selector(flag, selector))
349        };
350        for selector in &view.globals {
351            if !self
352                .cmd
353                .flags
354                .iter()
355                .any(|flag| flag.global && matches_selector(flag, selector))
356            {
357                return Err(UsageErr::InvalidView(format!(
358                    "view `{id}` carries `{selector}`, but it is not a root global flag"
359                )));
360            }
361        }
362        let globals: Vec<SpecFlag> = self
363            .cmd
364            .flags
365            .iter()
366            // A view is another executable surface of this package. Keep the host's
367            // version actions in addition to the globals explicitly carried by the view.
368            .filter(|flag| carries(flag) || flag.action == crate::SpecFlagAction::Version)
369            .cloned()
370            .collect();
371        // A promoted command may redeclare a global spelling. The nearer declaration owns it,
372        // matching ordinary parsing, so do not create a duplicate root flag.
373        let mut globals: Vec<SpecFlag> = globals
374            .into_iter()
375            .filter(|global| {
376                !promoted
377                    .flags
378                    .iter()
379                    .any(|local| spec_flag_forms_overlap(global, local))
380            })
381            .collect();
382        // Root completers belong to the host's fields, not to every executable view. Carry the
383        // ones for selected globals, then let the promoted command's own completers win on a
384        // shared name. A promoted command is the new root, so its command-scoped entries become
385        // the materialized spec's root entries rather than remaining in both places.
386        let mut complete = IndexMap::new();
387        for flag in &globals {
388            if let Some(arg) = &flag.arg {
389                let name = arg.name.to_lowercase();
390                if let Some(completer) = self.complete.get(&name) {
391                    complete.insert(name, completer.clone());
392                }
393            }
394        }
395        complete.extend(std::mem::take(&mut promoted.complete));
396        // Root groups are relationships between the root fields, so project them along with
397        // the carried globals. A group reduced to one required member is ordinary requiredness;
398        // keeping it as a one-member group would emit KDL the spec reader deliberately refuses.
399        let mut carried_groups = Vec::new();
400        for group in &self.cmd.groups {
401            let members: Vec<String> = group
402                .members
403                .iter()
404                .filter(|selector| {
405                    globals
406                        .iter()
407                        .any(|flag| flag_matches_selector(flag, selector))
408                })
409                .cloned()
410                .collect();
411            match members.as_slice() {
412                [only] if group.required => {
413                    if let Some(flag) = globals
414                        .iter_mut()
415                        .find(|flag| flag_matches_selector(flag, only))
416                    {
417                        flag.required = true;
418                    }
419                }
420                [_, _, ..] => {
421                    let mut projected = group.clone();
422                    projected.members = members;
423                    carried_groups.push(projected);
424                }
425                _ => {}
426            }
427        }
428        promoted.flags.splice(0..0, globals);
429        promoted.groups.splice(0..0, carried_groups);
430        promoted.name.clone_from(&view.bin);
431        promoted.full_cmd.clear();
432        promoted.aliases.clear();
433        promoted.hidden_aliases.clear();
434        // A view is another executable surface of the host package. Keep the host policy that
435        // governs its synthesized version entry along with the host version strings retained on
436        // `spec`; otherwise materializing a promoted command silently re-enables `--version`.
437        promoted.disable_version_flag = self.cmd.disable_version_flag;
438        set_subcommand_ancestors(&mut promoted, &[]);
439        promoted.usage = promoted.usage();
440
441        let mut spec = self.clone();
442        spec.name.clone_from(&view.name);
443        spec.bin.clone_from(&view.bin);
444        spec.about = promoted.help.clone();
445        spec.about_long = promoted.help_long.clone();
446        spec.about_md = promoted.help_md.clone();
447        spec.before_help = promoted.before_help.clone();
448        spec.before_help_long = promoted.before_help_long.clone();
449        spec.after_help = promoted.after_help.clone();
450        spec.after_help_long = promoted.after_help_long.clone();
451        spec.examples.clone_from(&promoted.examples);
452        spec.usage = promoted.usage.clone();
453        spec.complete = complete;
454        spec.cmd = promoted;
455        spec.default_subcommand = None;
456        spec.default_subcommand_flags = false;
457        spec.default_subcommand_flags_set = false;
458        spec.multicall = false;
459        spec.multicall_set = false;
460        spec.views.clear();
461        Ok(spec)
462    }
463
464    /// The stable identifier of the executable view selected by a program name.
465    pub fn view_for_program(&self, program: &str) -> Option<&str> {
466        let basename = crate::parse::multicall_basename(program);
467        if basename == crate::parse::multicall_basename(&self.name)
468            || (!self.bin.is_empty() && basename == crate::parse::multicall_basename(&self.bin))
469        {
470            return None;
471        }
472        self.views.values().find_map(|view| {
473            (basename == crate::parse::multicall_basename(&view.bin)
474                || basename == crate::parse::multicall_basename(&view.id))
475            .then_some(view.id.as_str())
476        })
477    }
478
479    pub(crate) fn parse(ctx: &ParsingContext, input: &str) -> Result<Spec, UsageErr> {
480        Self::parse_with_output_resolution(ctx, input, true)
481    }
482
483    fn parse_with_output_resolution(
484        ctx: &ParsingContext,
485        input: &str,
486        resolve_outputs: bool,
487    ) -> Result<Spec, UsageErr> {
488        let kdl: KdlDocument = input.parse().map_err(|err: kdl::KdlError| {
489            UsageErr::KdlError(err.with_source_name(ctx.file.to_string_lossy()))
490        })?;
491        let mut schema = Self {
492            ..Default::default()
493        };
494        // The file being read, before anything in it can fail: a build script that watches this list
495        // should watch a spec that does not parse too, or the next build is a stale success.
496        if !ctx.file.as_os_str().is_empty() {
497            schema.sources.push(ctx.file.clone());
498        }
499        for node in kdl.nodes().iter().map(|n| NodeHelper::new(ctx, n)) {
500            match node.name() {
501                "name" => schema.name = node.arg(0)?.ensure_string()?,
502                "bin" => {
503                    schema.bin = node.arg(0)?.ensure_string()?;
504                    if schema.name.is_empty() {
505                        schema.name.clone_from(&schema.bin);
506                    }
507                }
508                "version" => schema.version = Some(node.arg(0)?.ensure_string()?),
509                "long_version" => schema.long_version = Some(node.arg(0)?.ensure_string()?),
510                "author" => schema.author = Some(node.arg(0)?.ensure_string()?),
511                "source_code_link_template" => {
512                    schema.source_code_link_template = Some(node.arg(0)?.ensure_string()?)
513                }
514                "repository" => schema.repository = Some(node.arg(0)?.ensure_string()?),
515                "about" => schema.about = Some(node.arg(0)?.ensure_string()?),
516                "long_about" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
517                "about_long" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
518                "about_md" => schema.about_md = Some(node.arg(0)?.ensure_string()?),
519                "surface" => schema.cmd.surface = Some(node.arg(0)?.ensure_string()?),
520                "available_if" => {
521                    schema.cmd.available_if = node
522                        .ensure_arg_len(1..)?
523                        .args()
524                        .map(|entry| entry.ensure_string())
525                        .collect::<Result<Vec<_>, _>>()?;
526                }
527                "license" => schema.license = Some(node.arg(0)?.ensure_string()?),
528                "before_help" => schema.before_help = Some(node.arg(0)?.ensure_string()?),
529                "after_help" => schema.after_help = Some(node.arg(0)?.ensure_string()?),
530                "before_long_help" | "before_help_long" => {
531                    schema.before_help_long = Some(node.arg(0)?.ensure_string()?)
532                }
533                "after_long_help" | "after_help_long" => {
534                    schema.after_help_long = Some(node.arg(0)?.ensure_string()?)
535                }
536                "usage" => schema.usage = node.arg(0)?.ensure_string()?,
537                // Refused here rather than at render time, and for the reason every other
538                // unsupported word is: a page laid out by a template is read by people, and a
539                // placeholder naming no section would reach them as the braces somebody typed.
540                "help_template" => {
541                    let template = node.arg(0)?.ensure_string()?;
542                    if let Err(problem) = crate::help_template::check(&template) {
543                        bail_parse!(ctx, node.span(), "{problem}");
544                    }
545                    // Whitespace-only is no layout: store it as unset so a round trip does
546                    // not emit a node that would then render three different empty pages.
547                    schema.help_template =
548                        crate::help_template::is_set(&template).then_some(template);
549                }
550                "arg" => {
551                    let arg = SpecArg::parse(ctx, &node)?;
552                    // The same rule the `cmd` block applies: a delimiter with nowhere to
553                    // put what it splits drops everything after the first separator.
554                    if arg.delimiter.is_some() && !arg.var {
555                        bail_parse!(
556                            ctx,
557                            node.node.name().span(),
558                            "argument <{}> has a delimiter and holds one value; add \
559                             `var=#true` for the values it splits into",
560                            arg.name
561                        );
562                    }
563                    schema.cmd.args.push(arg);
564                }
565                "clause" => {
566                    if schema.cmd.clause.is_some() {
567                        bail_parse!(ctx, node.span(), "a command may declare at most one clause");
568                    }
569                    schema.cmd.clause = Some(crate::SpecClause::parse(ctx, &node)?);
570                }
571                "flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?),
572                // The root command's groups, as its flags and arguments are: a spec
573                // whose top level declares flags can group them there too.
574                "group" => schema.cmd.groups.push(crate::SpecGroup::parse(ctx, &node)?),
575                // The root is a command like any other, so it can discover its own
576                // subcommands by running something. A CLI whose top-level commands
577                // come from plugins has no other way to say so.
578                "mount" => schema.cmd.mounts.push(crate::SpecMount::parse(ctx, &node)?),
579                "cmd" => {
580                    let node: SpecCommand = SpecCommand::parse(ctx, &node)?;
581                    schema.cmd.subcommands.insert(node.name.to_string(), node);
582                }
583                "flagset" => {
584                    let set = SpecFlagSet::parse(ctx, &node)?;
585                    if schema.flagsets.insert(set.name.clone(), set).is_some() {
586                        bail_parse!(ctx, node.span(), "a flagset may be declared only once");
587                    }
588                }
589                // The root is a command like any other: if its own flags repeat a set, it
590                // says so the same way a subcommand does.
591                "use" => {
592                    let at = schema.cmd.flags.len();
593                    schema.cmd.uses.push(SpecUse::parse(ctx, &node, at)?);
594                }
595                "config" => schema.config = SpecConfig::parse(ctx, &node)?,
596                "complete" => {
597                    let complete = SpecComplete::parse(ctx, &node)?;
598                    schema.complete.insert(complete.name.clone(), complete);
599                }
600                "view" => {
601                    let view = SpecView::parse(ctx, &node)?;
602                    if schema.views.insert(view.id.clone(), view).is_some() {
603                        bail_parse!(
604                            ctx,
605                            node.span(),
606                            "a view identifier may be declared only once"
607                        );
608                    }
609                }
610                "disable_help" => schema.disable_help = Some(node.arg(0)?.ensure_bool()?),
611                "min_usage_version" => {
612                    let v = node.arg(0)?.ensure_string()?;
613                    check_usage_version(&v);
614                    schema.min_usage_version = Some(v);
615                }
616                "unknown_flags" => {
617                    let raw = node.arg(0)?.ensure_string()?;
618                    match raw.parse() {
619                        Ok(mode) => schema.unknown_flags = Some(mode),
620                        Err(_) => bail_parse!(
621                            ctx,
622                            node.span(),
623                            "unsupported unknown_flags {raw}, expected one of: {}",
624                            crate::spec::unknown_flags::UNKNOWN_FLAGS_VALUES
625                        ),
626                    }
627                }
628                "default_subcommand" => {
629                    schema.default_subcommand = Some(node.arg(0)?.ensure_string()?)
630                }
631                "default_subcommand_flags" => {
632                    schema.default_subcommand_flags = node.arg(0)?.ensure_bool()?;
633                    schema.default_subcommand_flags_set = true;
634                }
635                "multicall" => {
636                    schema.multicall = node.arg(0)?.ensure_bool()?;
637                    schema.multicall_set = true;
638                }
639                "external_subcommand" => {
640                    schema.cmd.external_subcommand = node.arg(0)?.ensure_bool()?;
641                }
642                "arg_required_else_help" => {
643                    schema.cmd.arg_required_else_help = node.arg(0)?.ensure_bool()?;
644                }
645                "disable_help_flag" => {
646                    schema.cmd.disable_help_flag = node.arg(0)?.ensure_bool()?;
647                }
648                "disable_help_subcommand" => {
649                    schema.cmd.disable_help_subcommand = node.arg(0)?.ensure_bool()?;
650                }
651                "disable_version_flag" => {
652                    schema.cmd.disable_version_flag = node.arg(0)?.ensure_bool()?;
653                }
654                "dont_delimit_trailing_values" => {
655                    schema.cmd.dont_delimit_trailing_values = node.arg(0)?.ensure_bool()?;
656                }
657                "args_override_self" => {
658                    schema.cmd.args_override_self = node.arg(0)?.ensure_bool()?;
659                }
660                "subcommand_negates_reqs" => {
661                    schema.cmd.subcommand_negates_reqs = node.arg(0)?.ensure_bool()?;
662                }
663                "args_conflicts_with_subcommands" => {
664                    schema.cmd.args_conflicts_with_subcommands = node.arg(0)?.ensure_bool()?;
665                }
666                "subcommand_precedence_over_arg" => {
667                    schema.cmd.subcommand_precedence_over_arg = node.arg(0)?.ensure_bool()?;
668                }
669                "allow_missing_positional" => {
670                    schema.cmd.allow_missing_positional = node.arg(0)?.ensure_bool()?;
671                }
672                "deprecated" => schema.cmd.deprecated = Some(node.arg(0)?.ensure_string()?),
673                "deprecated_warn_at" => {
674                    schema.cmd.deprecated_warn_at = Some(node.arg(0)?.ensure_string()?);
675                }
676                "deprecated_remove_at" => {
677                    schema.cmd.deprecated_remove_at = Some(node.arg(0)?.ensure_string()?);
678                }
679                "subcommand_required" => {
680                    schema.cmd.subcommand_required = node.arg(0)?.ensure_bool()?;
681                }
682                "subcommand_help_heading" => {
683                    schema.cmd.subcommand_help_heading = Some(node.arg(0)?.ensure_string()?);
684                }
685                "subcommand_value_name" => {
686                    schema.cmd.subcommand_value_name = Some(node.arg(0)?.ensure_string()?);
687                }
688                "next_line_help" => {
689                    schema.cmd.next_line_help = node.arg(0)?.ensure_bool()?;
690                }
691                "flatten_help" => {
692                    schema.cmd.flatten_help = node.arg(0)?.ensure_bool()?;
693                }
694                "term_width" => {
695                    schema.cmd.term_width = Some(node.arg(0)?.ensure_usize()?);
696                }
697                "max_term_width" => {
698                    schema.cmd.max_term_width = Some(node.arg(0)?.ensure_usize()?);
699                }
700                "example" => {
701                    let code = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
702                    let mut example = SpecExample::new(code.trim().to_string());
703                    for (k, v) in node.props() {
704                        match k {
705                            "header" => example.header = Some(v.ensure_string()?),
706                            "help" => example.help = Some(v.ensure_string()?),
707                            "lang" => example.lang = v.ensure_string()?,
708                            k => bail_parse!(ctx, v.entry.span(), "unsupported example key {k}"),
709                        }
710                    }
711                    schema.examples.push(example);
712                }
713                "heading" => {
714                    let title = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
715                    let mut help = None;
716                    for (k, v) in node.props() {
717                        match k {
718                            "help" => help = Some(v.ensure_string()?),
719                            k => bail_parse!(ctx, v.entry.span(), "unsupported heading key {k}"),
720                        }
721                    }
722                    let Some(help) = help else {
723                        bail_parse!(ctx, node.node.span(), "heading {title} needs help text");
724                    };
725                    schema.cmd.headings.push(SpecHeading::new(title, help));
726                }
727                "output" => schema.outputs.push(SpecOutput::parse(ctx, &node)?),
728                "exit_code" => schema.exit_codes.push(SpecExitCode::parse(ctx, &node)?),
729                "select" => {
730                    schema.select = Some(node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?);
731                }
732                "include" => {
733                    let file = node
734                        .props()
735                        .get("file")
736                        .map(|v| v.ensure_string())
737                        .transpose()?
738                        .ok_or_else(|| ctx.build_err("missing file".into(), node.span()))?;
739                    let file = Path::new(&file);
740                    let file = match file.is_relative() {
741                        true => ctx
742                            .file
743                            .parent()
744                            .ok_or_else(|| {
745                                let msg = if ctx.file.as_os_str().is_empty() {
746                                    "relative includes require a source file".to_string()
747                                } else {
748                                    format!("cannot get parent of {}", ctx.file.display())
749                                };
750                                ctx.build_err(msg, node.span())
751                            })?
752                            .join(file),
753                        false => file.to_path_buf(),
754                    };
755                    info!("include: {}", file.display());
756                    let other = Self::parse_file_with_metadata_inference(&file, false, false)?;
757                    // Two *declarations* of one name are refused, the same as two in a single
758                    // file. Letting the incoming set win would make which declaration a
759                    // `use` gets depend on whether the `include` stands above or below it —
760                    // and only in that direction, since a `flagset` written after an
761                    // `include` already fails here.
762                    //
763                    // Which declaration, not which name: a file of shared sets is included
764                    // by every file whose `use` nodes name them, since each file resolves
765                    // its own. A spec that includes two of those files sees the shared set
766                    // arrive twice, and that is one declaration by two routes.
767                    let clash = other.flagsets.values().find(|incoming| {
768                        schema
769                            .flagsets
770                            .get(&incoming.name)
771                            .is_some_and(|own| own.declared_in != incoming.declared_in)
772                    });
773                    if let Some(incoming) = clash {
774                        let name = &incoming.name;
775                        let owner = schema.flagsets[name].declared_in.clone();
776                        let owner = match owner.as_os_str().is_empty() {
777                            true => "this spec".to_string(),
778                            false => owner.display().to_string(),
779                        };
780                        bail_parse!(
781                            ctx,
782                            node.span(),
783                            "a flagset may be declared only once: \"{name}\" is declared in \
784                             {} and in {owner}",
785                            incoming.declared_in.display()
786                        );
787                    }
788                    schema.merge(other);
789                }
790                k => bail_parse!(ctx, node.node.name().span(), "unsupported spec key {k}"),
791            }
792        }
793        schema.cmd.name = if schema.bin.is_empty() {
794            schema.name.clone()
795        } else {
796            schema.bin.clone()
797        };
798        if let Some(clause) = &schema.cmd.clause {
799            if !schema.cmd.args.is_empty() {
800                bail_parse!(
801                    ctx,
802                    kdl.span(),
803                    "a command cannot declare both top-level arguments and a clause"
804                );
805            }
806            if schema.cmd.restart_token.is_some() {
807                bail_parse!(
808                    ctx,
809                    kdl.span(),
810                    "a command cannot declare both restart_token and a clause"
811                );
812            }
813            if clause.args.iter().any(|arg| arg.sigil.is_some()) {
814                bail_parse!(
815                    ctx,
816                    kdl.span(),
817                    "sigil arguments are not supported inside clauses"
818                );
819            }
820            if let Some(spelling) = clause.conflicting_flag_spelling(&schema.cmd.flags) {
821                bail_parse!(
822                    ctx,
823                    kdl.span(),
824                    "clause flag spelling {spelling:?} conflicts with another flag on this command"
825                );
826            }
827        }
828        // Before ancestors are stamped, because expanding a flagset or narrowing a selector can
829        // add a flag to a command and the usage strings are computed from the flag list.
830        flagset::expand(ctx, &mut schema.cmd, &mut schema.flagsets)?;
831        schema
832            .cmd
833            .validate_clause_flag_spellings()
834            .map_err(|message| ctx.build_err(message, (0, ctx.spec.len()).into()))?;
835        if resolve_outputs {
836            output::resolve_selectors(&mut schema)?;
837        }
838        schema
839            .cmd
840            .validate_sigil_prefixes()
841            .map_err(|message| ctx.build_err(message, (0, ctx.spec.len()).into()))?;
842        schema.sources.extend(ctx.sources());
843        set_subcommand_ancestors(&mut schema.cmd, &[]);
844        Ok(schema)
845    }
846
847    pub fn merge(&mut self, other: Spec) {
848        macro_rules! merge_str {
849            ($field:ident) => {
850                if !other.$field.is_empty() {
851                    self.$field = other.$field;
852                }
853            };
854        }
855        macro_rules! merge_opt {
856            ($field:ident) => {
857                if other.$field.is_some() {
858                    self.$field = other.$field;
859                }
860            };
861        }
862        macro_rules! merge_extend {
863            ($field:ident) => {
864                if !other.$field.is_empty() {
865                    self.$field.extend(other.$field);
866                }
867            };
868        }
869
870        merge_str!(name);
871        merge_str!(bin);
872        merge_str!(usage);
873        merge_opt!(about);
874        merge_opt!(source_code_link_template);
875        merge_opt!(repository);
876        merge_opt!(version);
877        merge_opt!(long_version);
878        merge_opt!(author);
879        merge_opt!(about_long);
880        merge_opt!(about_md);
881        merge_opt!(license);
882        merge_opt!(before_help);
883        merge_opt!(after_help);
884        merge_opt!(before_help_long);
885        merge_opt!(after_help_long);
886        merge_opt!(help_template);
887        merge_opt!(disable_help);
888        merge_opt!(min_usage_version);
889        merge_opt!(default_subcommand);
890        if other.default_subcommand_flags_set {
891            self.default_subcommand_flags = other.default_subcommand_flags;
892            self.default_subcommand_flags_set = true;
893        }
894        if other.multicall_set {
895            self.multicall = other.multicall;
896            self.multicall_set = true;
897        }
898        merge_opt!(unknown_flags);
899        merge_extend!(complete);
900        merge_extend!(views);
901        // An included file's sets are visible to the file that includes it, which is how a
902        // spec keeps its shared declarations in a file of their own. Its own `use` nodes are
903        // already resolved by the time it gets here, so nothing is expanded twice. Two files
904        // declaring one name never reach this extend: the `include` refuses them, rather
905        // than one silently taking the other's name. What does reach it is the same shared
906        // file arriving by two routes, which overwrites an entry with itself.
907        merge_extend!(flagsets);
908        merge_extend!(examples);
909        merge_extend!(outputs);
910        merge_extend!(exit_codes);
911        merge_opt!(select);
912        // An included spec brings the files *it* read, which is how a nested include is watched.
913        merge_extend!(sources);
914
915        if !other.config.is_empty() {
916            self.config.merge(&other.config);
917        }
918        self.cmd.merge(other.cmd);
919    }
920}
921
922pub(crate) fn spec_flag_forms_overlap(a: &SpecFlag, b: &SpecFlag) -> bool {
923    fn long_forms(flag: &SpecFlag) -> impl Iterator<Item = &str> {
924        flag.long
925            .iter()
926            .chain(&flag.hidden_aliases)
927            .map(String::as_str)
928            .chain(
929                flag.negate
930                    .as_deref()
931                    .map(|name| name.strip_prefix("--").unwrap_or(name)),
932            )
933    }
934    fn short_forms(flag: &SpecFlag) -> impl Iterator<Item = &char> {
935        flag.short.iter().chain(&flag.hidden_short_aliases)
936    }
937
938    long_forms(a).any(|name| long_forms(b).any(|other| other == name))
939        || short_forms(a).any(|name| short_forms(b).any(|other| other == name))
940}
941
942fn flag_matches_selector(flag: &SpecFlag, selector: &str) -> bool {
943    selector.strip_prefix("--").is_some_and(|name| {
944        flag.long
945            .iter()
946            .chain(&flag.hidden_aliases)
947            .any(|long| long == name)
948            || flag
949                .negate
950                .as_deref()
951                .is_some_and(|negate| negate.strip_prefix("--").unwrap_or(negate) == name)
952    }) || selector
953        .strip_prefix('-')
954        .filter(|short| short.len() == 1)
955        .and_then(|short| short.chars().next())
956        .is_some_and(|short| {
957            flag.short
958                .iter()
959                .chain(&flag.hidden_short_aliases)
960                .any(|candidate| *candidate == short)
961        })
962}
963
964fn check_usage_version(version: &str) {
965    let cur = semver::Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
966    match parse_usage_version(version) {
967        Ok(v) => {
968            if cur < v {
969                warn!(
970                    "This usage spec requires at least version {version}, but you are using version {cur} of usage"
971                );
972            }
973        }
974        Err(_) => warn!("Invalid version: {version}"),
975    }
976}
977
978/// Parse the relaxed SemVer spelling accepted by `min_usage_version`.
979///
980/// Specs have historically used versions such as `4.0`, while the SemVer crate correctly
981/// requires all three numeric components. Fill in omitted components before parsing without
982/// accepting any other non-SemVer syntax.
983fn parse_usage_version(version: &str) -> Result<semver::Version, semver::Error> {
984    let core_end = version.find(['-', '+']).unwrap_or(version.len());
985    let (core, suffix) = version.split_at(core_end);
986    let missing = match core.matches('.').count() {
987        0 => ".0.0",
988        1 => ".0",
989        _ => "",
990    };
991
992    semver::Version::parse(&format!("{core}{missing}{suffix}"))
993}
994
995/// Read a file, keeping its path in the error.
996///
997/// `std::fs::read_to_string` reports "No such file or directory" and nothing about which
998/// file, and these paths come from a command line.
999fn read_to_string(file: &Path) -> Result<String, UsageErr> {
1000    std::fs::read_to_string(file).map_err(|err| UsageErr::FileError(err, file.to_path_buf()))
1001}
1002
1003/// A comment line that opens or continues an embedded spec: `#USAGE`, `//USAGE`, `::USAGE`,
1004/// or their `[USAGE]` spellings.
1005static USAGE_COMMENT: LazyLock<Regex> =
1006    LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])(.*)$").unwrap());
1007/// The same, without capturing the rest of the line: used only to answer whether a script
1008/// carries an embedded spec at all.
1009static HAS_USAGE_COMMENT: LazyLock<Regex> =
1010    LazyLock::new(|| Regex::new(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])").unwrap());
1011/// A comment line with nothing on it, which continues a spec rather than ending it.
1012static BLANK_COMMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(?:#|//|::)\s*$").unwrap());
1013
1014fn split_script(file: &Path) -> Result<String, UsageErr> {
1015    let full = read_to_string(file)?;
1016    // If file has a shebang and USAGE comments, extract the spec from comments
1017    if full.starts_with("#!") && full.lines().any(|l| HAS_USAGE_COMMENT.is_match(l)) {
1018        return Ok(extract_usage_from_comments(&full));
1019    }
1020    // Otherwise treat the whole file as a KDL spec (e.g., .usage.kdl files)
1021    Ok(full)
1022}
1023
1024fn extract_usage_from_comments(full: &str) -> String {
1025    let mut usage = vec![];
1026    let mut found = false;
1027    for line in full.lines() {
1028        if let Some(captures) = USAGE_COMMENT.captures(line) {
1029            found = true;
1030            let content = captures.get(1).map_or("", |m| m.as_str());
1031            usage.push(content.trim());
1032        } else if found {
1033            // Allow blank comment lines to continue parsing
1034            if BLANK_COMMENT.is_match(line) {
1035                continue;
1036            }
1037            // if there is a non-blank non-USAGE line, stop reading
1038            break;
1039        }
1040    }
1041    usage.join("\n")
1042}
1043
1044fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) {
1045    restamp_paths(cmd, ancestors, false);
1046}
1047
1048/// Recompute what a command's position in the tree decides: its path, its usage line, and the
1049/// lookup that answers to its subcommands' names and aliases.
1050///
1051/// A command does not know where it sits — `full_cmd` is stamped onto it by whoever assembled
1052/// the tree, `usage` is rendered from that path, and `find_subcommand` memoizes what it found.
1053/// Move a command, or graft one in, and all three describe where it used to be. This is the pass
1054/// that fixes them, and the only one: a tree built by hand is otherwise as stale as one built by
1055/// hand always was, which is why building one used to mean writing it out as KDL and parsing it
1056/// back.
1057///
1058/// `force` says whether a usage line that is already there is trusted. Parsing writes them as it
1059/// goes and only wants the blanks filled; a graft brings a subtree whose lines are all correct
1060/// for the spec it came from and all wrong here.
1061fn restamp_paths(cmd: &mut SpecCommand, ancestors: &[String], force: bool) {
1062    for subcmd in cmd.subcommands.values_mut() {
1063        subcmd.full_cmd = ancestors
1064            .iter()
1065            .cloned()
1066            .chain(once(subcmd.name.clone()))
1067            .collect();
1068        let child_ancestors = subcmd.full_cmd.clone();
1069        restamp_paths(subcmd, &child_ancestors, force);
1070    }
1071    if force {
1072        // The lookup memoizes names *and* aliases, so a grafted sibling is missing from it and a
1073        // removed one is still in it. Dropping it costs the next lookup and nothing else.
1074        cmd.reset_subcommand_lookup();
1075        cmd.usage = cmd.usage();
1076    } else if cmd.usage.is_empty() {
1077        cmd.usage = cmd.usage();
1078    }
1079}
1080
1081impl Display for Spec {
1082    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1083        let mut doc = KdlDocument::new();
1084        let nodes = &mut doc.nodes_mut();
1085        if !self.name.is_empty() {
1086            let mut node = KdlNode::new("name");
1087            node.push(string_entry(None, &self.name));
1088            nodes.push(node);
1089        }
1090        if !self.bin.is_empty() {
1091            let mut node = KdlNode::new("bin");
1092            node.push(string_entry(None, &self.bin));
1093            nodes.push(node);
1094        }
1095        if let Some(version) = &self.version {
1096            let mut node = KdlNode::new("version");
1097            node.push(string_entry(None, version));
1098            nodes.push(node);
1099        }
1100        if let Some(version) = &self.long_version {
1101            let mut node = KdlNode::new("long_version");
1102            node.push(string_entry(None, version));
1103            nodes.push(node);
1104        }
1105        if let Some(author) = &self.author {
1106            let mut node = KdlNode::new("author");
1107            node.push(string_entry(None, author));
1108            nodes.push(node);
1109        }
1110        if let Some(about) = &self.about {
1111            let mut node = KdlNode::new("about");
1112            node.push(string_entry(None, about));
1113            nodes.push(node);
1114        }
1115        if let Some(source_code_link_template) = &self.source_code_link_template {
1116            let mut node = KdlNode::new("source_code_link_template");
1117            node.push(string_entry(None, source_code_link_template));
1118            nodes.push(node);
1119        }
1120        if let Some(repository) = &self.repository {
1121            let mut node = KdlNode::new("repository");
1122            node.push(string_entry(None, repository));
1123            nodes.push(node);
1124        }
1125        if let Some(about_md) = &self.about_md {
1126            let mut node = KdlNode::new("about_md");
1127            node.push(string_entry(None, about_md));
1128            nodes.push(node);
1129        }
1130        if let Some(long_about) = &self.about_long {
1131            let mut node = KdlNode::new("long_about");
1132            node.push(string_entry(None, long_about));
1133            nodes.push(node);
1134        }
1135        if let Some(license) = &self.license {
1136            let mut node = KdlNode::new("license");
1137            node.push(string_entry(None, license));
1138            nodes.push(node);
1139        }
1140        if let Some(before_help) = &self.before_help {
1141            let mut node = KdlNode::new("before_help");
1142            node.push(string_entry(None, before_help));
1143            nodes.push(node);
1144        }
1145        if let Some(after_help) = &self.after_help {
1146            let mut node = KdlNode::new("after_help");
1147            node.push(string_entry(None, after_help));
1148            nodes.push(node);
1149        }
1150        if let Some(before_help_long) = &self.before_help_long {
1151            let mut node = KdlNode::new("before_long_help");
1152            node.push(string_entry(None, before_help_long));
1153            nodes.push(node);
1154        }
1155        if let Some(after_help_long) = &self.after_help_long {
1156            let mut node = KdlNode::new("after_long_help");
1157            node.push(string_entry(None, after_help_long));
1158            nodes.push(node);
1159        }
1160        if let Some(help_template) = &self.help_template {
1161            let mut node = KdlNode::new("help_template");
1162            node.push(string_entry(None, help_template));
1163            nodes.push(node);
1164        }
1165        if let Some(disable_help) = self.disable_help {
1166            let mut node = KdlNode::new("disable_help");
1167            node.push(KdlEntry::new(disable_help));
1168            nodes.push(node);
1169        }
1170        if let Some(min_usage_version) = &self.min_usage_version {
1171            let mut node = KdlNode::new("min_usage_version");
1172            node.push(string_entry(None, min_usage_version));
1173            nodes.push(node);
1174        }
1175        if let Some(unknown_flags) = &self.unknown_flags {
1176            let mut node = KdlNode::new("unknown_flags");
1177            node.push(string_entry(None, unknown_flags.as_str()));
1178            nodes.push(node);
1179        }
1180        if let Some(default_subcommand) = &self.default_subcommand {
1181            let mut node = KdlNode::new("default_subcommand");
1182            node.push(string_entry(None, default_subcommand));
1183            nodes.push(node);
1184        }
1185        if self.default_subcommand_flags_set || self.default_subcommand_flags {
1186            let mut node = KdlNode::new("default_subcommand_flags");
1187            node.push(KdlEntry::new(self.default_subcommand_flags));
1188            nodes.push(node);
1189        }
1190        if self.multicall_set {
1191            let mut node = KdlNode::new("multicall");
1192            node.push(KdlEntry::new(self.multicall));
1193            nodes.push(node);
1194        }
1195        if self.cmd.external_subcommand {
1196            let mut node = KdlNode::new("external_subcommand");
1197            node.push(KdlEntry::new(true));
1198            nodes.push(node);
1199        }
1200        if self.cmd.arg_required_else_help {
1201            let mut node = KdlNode::new("arg_required_else_help");
1202            node.push(KdlEntry::new(true));
1203            nodes.push(node);
1204        }
1205        if self.cmd.disable_help_flag {
1206            let mut node = KdlNode::new("disable_help_flag");
1207            node.push(KdlEntry::new(true));
1208            nodes.push(node);
1209        }
1210        if self.cmd.disable_help_subcommand {
1211            let mut node = KdlNode::new("disable_help_subcommand");
1212            node.push(KdlEntry::new(true));
1213            nodes.push(node);
1214        }
1215        if self.cmd.disable_version_flag {
1216            let mut node = KdlNode::new("disable_version_flag");
1217            node.push(KdlEntry::new(true));
1218            nodes.push(node);
1219        }
1220        if self.cmd.dont_delimit_trailing_values {
1221            let mut node = KdlNode::new("dont_delimit_trailing_values");
1222            node.push(true);
1223            nodes.push(node);
1224        }
1225        if !self.cmd.args_override_self {
1226            let mut node = KdlNode::new("args_override_self");
1227            node.push(false);
1228            nodes.push(node);
1229        }
1230        if self.cmd.subcommand_negates_reqs {
1231            let mut node = KdlNode::new("subcommand_negates_reqs");
1232            node.push(true);
1233            nodes.push(node);
1234        }
1235        if self.cmd.args_conflicts_with_subcommands {
1236            let mut node = KdlNode::new("args_conflicts_with_subcommands");
1237            node.push(true);
1238            nodes.push(node);
1239        }
1240        if self.cmd.subcommand_precedence_over_arg {
1241            let mut node = KdlNode::new("subcommand_precedence_over_arg");
1242            node.push(true);
1243            nodes.push(node);
1244        }
1245        if self.cmd.allow_missing_positional {
1246            let mut node = KdlNode::new("allow_missing_positional");
1247            node.push(true);
1248            nodes.push(node);
1249        }
1250        if let Some(message) = &self.cmd.deprecated {
1251            let mut node = KdlNode::new("deprecated");
1252            node.push(string_entry(None, message));
1253            nodes.push(node);
1254        }
1255        if let Some(surface) = &self.cmd.surface {
1256            let mut node = KdlNode::new("surface");
1257            node.push(string_entry(None, surface));
1258            nodes.push(node);
1259        }
1260        if !self.cmd.available_if.is_empty() {
1261            let mut node = KdlNode::new("available_if");
1262            for condition in &self.cmd.available_if {
1263                node.push(string_entry(None, condition));
1264            }
1265            nodes.push(node);
1266        }
1267        if let Some(at) = &self.cmd.deprecated_warn_at {
1268            let mut node = KdlNode::new("deprecated_warn_at");
1269            node.push(string_entry(None, at));
1270            nodes.push(node);
1271        }
1272        if let Some(at) = &self.cmd.deprecated_remove_at {
1273            let mut node = KdlNode::new("deprecated_remove_at");
1274            node.push(string_entry(None, at));
1275            nodes.push(node);
1276        }
1277        if self.cmd.subcommand_required && !self.cmd.subcommands.is_empty() {
1278            let mut node = KdlNode::new("subcommand_required");
1279            node.push(true);
1280            nodes.push(node);
1281        }
1282        if let Some(heading) = &self.cmd.subcommand_help_heading {
1283            let mut node = KdlNode::new("subcommand_help_heading");
1284            node.push(string_entry(None, heading));
1285            nodes.push(node);
1286        }
1287        if let Some(name) = &self.cmd.subcommand_value_name {
1288            let mut node = KdlNode::new("subcommand_value_name");
1289            node.push(string_entry(None, name));
1290            nodes.push(node);
1291        }
1292        if self.cmd.next_line_help {
1293            let mut node = KdlNode::new("next_line_help");
1294            node.push(true);
1295            nodes.push(node);
1296        }
1297        if self.cmd.flatten_help {
1298            let mut node = KdlNode::new("flatten_help");
1299            node.push(true);
1300            nodes.push(node);
1301        }
1302        if let Some(width) = self.cmd.term_width {
1303            let mut node = KdlNode::new("term_width");
1304            node.push(width as i128);
1305            nodes.push(node);
1306        }
1307        if let Some(width) = self.cmd.max_term_width {
1308            let mut node = KdlNode::new("max_term_width");
1309            node.push(width as i128);
1310            nodes.push(node);
1311        }
1312        if !self.usage.is_empty() {
1313            let mut node = KdlNode::new("usage");
1314            node.push(string_entry(None, &self.usage));
1315            nodes.push(node);
1316        }
1317        for flag in self.cmd.flags.iter() {
1318            nodes.push(flag.into());
1319        }
1320        for arg in self.cmd.args.iter() {
1321            nodes.push(arg.into());
1322        }
1323        if let Some(clause) = &self.cmd.clause {
1324            nodes.push(clause.into());
1325        }
1326        // Written here rather than by SpecCommand, because the root's own nodes
1327        // live at the top level of the document instead of inside a `cmd` block.
1328        for mount in self.cmd.mounts.iter() {
1329            nodes.push(mount.into());
1330        }
1331        for group in self.cmd.groups.iter() {
1332            nodes.push(group.into());
1333        }
1334        for example in self.examples.iter() {
1335            nodes.push(example.into());
1336        }
1337        for heading in self.cmd.headings.iter() {
1338            nodes.push(heading.into());
1339        }
1340        for output in self.outputs.iter() {
1341            nodes.push(output.into());
1342        }
1343        if let Some(select) = &self.select {
1344            let mut node = KdlNode::new("select");
1345            node.push(string_entry(None, select));
1346            nodes.push(node);
1347        }
1348        for exit_code in self.exit_codes.iter() {
1349            nodes.push(exit_code.into());
1350        }
1351        for complete in self.complete.values() {
1352            nodes.push(complete.into());
1353        }
1354        for complete in self.cmd.complete.values() {
1355            nodes.push(complete.into());
1356        }
1357        for view in self.views.values() {
1358            let rendered: KdlDocument = view
1359                .to_string()
1360                .parse()
1361                .expect("a view always renders valid KDL");
1362            nodes.extend(rendered.nodes().iter().cloned());
1363        }
1364        for cmd in self.cmd.subcommands.values() {
1365            nodes.push(cmd.into())
1366        }
1367        if !self.config.is_empty() {
1368            nodes.push((&self.config).into());
1369        }
1370        doc.autoformat_config(&kdl::FormatConfig::default());
1371        write!(f, "{doc}")
1372    }
1373}
1374
1375impl FromStr for Spec {
1376    type Err = UsageErr;
1377
1378    fn from_str(s: &str) -> Result<Self, Self::Err> {
1379        Self::parse(&Default::default(), s)
1380    }
1381}
1382
1383#[cfg(feature = "clap")]
1384impl From<&clap::Command> for Spec {
1385    fn from(cmd: &clap::Command) -> Self {
1386        let mut spec = Spec {
1387            name: cmd.get_name().to_string(),
1388            bin: cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string(),
1389            cmd: cmd.into(),
1390            version: cmd.get_version().map(|v| v.to_string()),
1391            long_version: cmd.get_long_version().map(|v| v.to_string()),
1392            about: cmd.get_about().map(|a| a.to_string()),
1393            about_long: cmd.get_long_about().map(|a| a.to_string()),
1394            usage: cmd.clone().render_usage().to_string(),
1395            // The root is a command too, and its own answer has nowhere else to go: a spec says
1396            // this at the top level, which is the field a reader puts it back into.
1397            unknown_flags: crate::spec::cmd::SpecCommand::from(cmd).unknown_flags,
1398            multicall: cmd.is_multicall_set(),
1399            multicall_set: cmd.is_multicall_set(),
1400            ..Default::default()
1401        };
1402        // The same pass the KDL parser makes, and for the same reason: a command has to know
1403        // where it sits. Without it every subcommand of a clap-derived spec had `full_cmd`
1404        // empty — and `SpecCommand::usage()` joins `full_cmd`, so their usage lines came out
1405        // blank. Now they say what a user would type.
1406        set_subcommand_ancestors(&mut spec.cmd, &[]);
1407        spec
1408    }
1409}
1410
1411/// A spec wrapping one command, for command trees built in Rust rather than parsed from KDL.
1412///
1413/// The command's own `name` becomes the spec's `name` and `bin`, and its `help` becomes the
1414/// spec's `about` — the same correspondence `usage-dynamic` applies in the other direction when
1415/// it grafts a spec into a host as a command.
1416impl From<SpecCommand> for Spec {
1417    fn from(cmd: SpecCommand) -> Self {
1418        let mut spec = Self {
1419            name: cmd.name.clone(),
1420            bin: cmd.name.clone(),
1421            about: cmd.help.clone(),
1422            about_long: cmd.help_long.clone(),
1423            cmd,
1424            ..Self::default()
1425        };
1426        // A built tree has never been stamped: nested commands do not know their paths, so
1427        // their usage lines are missing the words a user would type.
1428        spec.restamp();
1429        spec
1430    }
1431}
1432
1433#[inline]
1434pub fn is_true(b: &bool) -> bool {
1435    *b
1436}
1437
1438#[inline]
1439pub fn is_false(b: &bool) -> bool {
1440    !is_true(b)
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445    use super::*;
1446    use insta::assert_snapshot;
1447
1448    #[test]
1449    fn parses_relaxed_usage_versions_as_semver() {
1450        assert_eq!(
1451            parse_usage_version("6").unwrap(),
1452            semver::Version::new(6, 0, 0)
1453        );
1454        assert_eq!(
1455            parse_usage_version("6.1").unwrap(),
1456            semver::Version::new(6, 1, 0)
1457        );
1458        assert_eq!(
1459            parse_usage_version("6.1.2-beta.1+build").unwrap(),
1460            semver::Version::parse("6.1.2-beta.1+build").unwrap()
1461        );
1462        assert!(parse_usage_version("not-a-version").is_err());
1463        assert!(parse_usage_version("6.1.2.3").is_err());
1464    }
1465
1466    #[test]
1467    fn test_display() {
1468        let spec = Spec::parse(
1469            &Default::default(),
1470            r#"
1471name "Usage CLI"
1472bin "usage"
1473arg "arg1"
1474flag "-f --force" global=#true
1475cmd "config" {
1476  cmd "set" {
1477    arg "key" help="Key to set"
1478    arg "value"
1479  }
1480}
1481complete "file" run="ls" descriptions=#true
1482        "#,
1483        )
1484        .unwrap();
1485        assert_snapshot!(spec, @r#"
1486        name "Usage CLI"
1487        bin usage
1488        flag "-f --force" global=#true
1489        arg <arg1>
1490        complete file run=ls descriptions=#true
1491        cmd config {
1492            cmd set {
1493                arg <key> help="Key to set"
1494                arg <value>
1495            }
1496        }
1497        "#);
1498    }
1499
1500    #[test]
1501    fn test_repository_round_trips() {
1502        let spec = Spec::parse(
1503            &Default::default(),
1504            r#"
1505bin "mise"
1506repository "https://github.com/jdx/mise"
1507source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1508        "#,
1509        )
1510        .unwrap();
1511        assert_eq!(
1512            spec.repository.as_deref(),
1513            Some("https://github.com/jdx/mise")
1514        );
1515        // A spec that is parsed and re-emitted must not lose it, which is the
1516        // failure mode for every field added to this struct.
1517        assert_snapshot!(spec, @r#"
1518        name mise
1519        bin mise
1520        source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
1521        repository "https://github.com/jdx/mise"
1522        "#);
1523    }
1524
1525    #[test]
1526    fn test_repository_merges_like_the_other_optionals() {
1527        // Extra specs are merged over a generated one, which is how a clap CLI
1528        // declares anything clap has no concept of.
1529        let mut generated = Spec::parse(&Default::default(), r#"bin "mise""#).unwrap();
1530        let extra = Spec::parse(
1531            &Default::default(),
1532            r#"repository "https://github.com/jdx/mise""#,
1533        )
1534        .unwrap();
1535        generated.merge(extra);
1536        assert_eq!(
1537            generated.repository.as_deref(),
1538            Some("https://github.com/jdx/mise")
1539        );
1540    }
1541
1542    #[test]
1543    #[cfg(feature = "clap")]
1544    fn test_clap() {
1545        let cmd = clap::Command::new("test");
1546        assert_snapshot!(Spec::from(&cmd), @r#"
1547        name test
1548        bin test
1549        unknown_flags error
1550        args_override_self #false
1551        usage "Usage: test"
1552        "#);
1553    }
1554
1555    #[test]
1556    #[cfg(feature = "clap")]
1557    fn a_clap_subcommand_knows_where_it_sits() {
1558        // The KDL parser makes this pass; the clap conversion did not, so every subcommand of
1559        // a clap-derived spec had an empty `full_cmd`. Two things read it: `usage()`, which
1560        // joins it and so produced a usage line with no command in it, and help rendering,
1561        // which uses it to tell a subcommand's page from the program's.
1562        let cmd = clap::Command::new("ex").subcommand(
1563            clap::Command::new("go")
1564                .about("Go somewhere")
1565                .subcommand(clap::Command::new("fast").about("Quickly")),
1566        );
1567        let spec = Spec::from(&cmd);
1568
1569        let go = spec.cmd.subcommands.get("go").expect("go");
1570        assert_eq!(go.full_cmd, ["go"]);
1571        // `usage()` names the command and then what it takes — `go` has a subcommand, so it
1572        // says so. The point is that the command's own name is in there at all.
1573        assert_eq!(go.usage, "go [SUBCOMMAND]");
1574
1575        // And all the way down, which is what makes it a walk rather than one level.
1576        let fast = go.subcommands.get("fast").expect("fast");
1577        assert_eq!(fast.full_cmd, ["go", "fast"]);
1578        assert_eq!(fast.usage, "go fast");
1579    }
1580
1581    #[test]
1582    #[cfg(feature = "clap")]
1583    fn a_delimited_default_becomes_the_values_clap_would_split_it_into() {
1584        // clap splits by the delimiter before anyone sees a value, defaults included, so the
1585        // joined string is not something the CLI ever holds. The spec has no delimiter — it has
1586        // a list, which says the same thing.
1587        //
1588        // mise's `--fs-events` is why: `default_value = "create,remove,rename,modify,metadata"`
1589        // beside `value_parser` listing those as its choices, so the recorded default was a
1590        // single value its own spec forbade.
1591        let cmd = clap::Command::new("test").arg(
1592            clap::Arg::new("events")
1593                .long("events")
1594                .value_delimiter(',')
1595                .action(clap::ArgAction::Append)
1596                .value_parser(["a", "b", "c"])
1597                .default_value("a,b"),
1598        );
1599        let spec = Spec::from(&cmd);
1600        let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1601        assert_eq!(flag.default, ["a", "b"]);
1602
1603        // And without a delimiter the value is whatever was written, commas and all: a path list
1604        // is not every CLI's idea of a separator, so splitting on speculation would be worse.
1605        let cmd = clap::Command::new("test")
1606            .arg(clap::Arg::new("events").long("events").default_value("a,b"));
1607        let spec = Spec::from(&cmd);
1608        let flag = spec.cmd.flags.iter().find(|f| f.name == "events").unwrap();
1609        assert_eq!(flag.default, ["a,b"]);
1610    }
1611
1612    #[test]
1613    fn multicall_round_trips() {
1614        let spec = Spec::parse(
1615            &Default::default(),
1616            r#"
1617name "busybox"
1618bin "busybox"
1619multicall #true
1620cmd "ls"
1621cmd "cat"
1622        "#,
1623        )
1624        .unwrap();
1625        assert!(spec.multicall);
1626        let emitted = spec.to_string();
1627        assert!(
1628            emitted.contains("multicall #true"),
1629            "lost on the way out: {emitted}"
1630        );
1631        let again: Spec = emitted.parse().unwrap();
1632        assert!(again.multicall);
1633    }
1634
1635    #[test]
1636    fn a_declared_view_promotes_a_command_and_selected_globals() {
1637        let spec: Spec = r#"
1638name "aube"
1639bin "aube"
1640about_md "Host **markdown**"
1641before_help "host before"
1642before_long_help "host long before"
1643after_help "host after"
1644after_long_help "host long after"
1645example "aube host" header="host example"
1646flag "-v --verbose" global=#true
1647flag "--config <FILE>" global=#true
1648view "aubr" root="run" {
1649  global "--verbose"
1650}
1651cmd "run" help="Run a package script" {
1652  before_help "run before"
1653  before_long_help "run long before"
1654  after_help "run after"
1655  after_long_help "run long after"
1656  example "aubr task" header="run example"
1657  flag "--if-present"
1658  arg "[SCRIPT]"
1659  cmd "nested"
1660}
1661"#
1662        .parse()
1663        .unwrap();
1664
1665        let rendered = spec.to_string();
1666        assert!(rendered.contains("view aubr root=run"), "{rendered}");
1667        let reparsed: Spec = rendered.parse().unwrap();
1668        let applet = reparsed.for_view("aubr").unwrap();
1669        assert_eq!(applet.name, "aubr");
1670        assert_eq!(applet.bin, "aubr");
1671        assert_eq!(applet.about.as_deref(), Some("Run a package script"));
1672        assert_eq!(applet.about_md, None);
1673        assert_eq!(applet.before_help.as_deref(), Some("run before"));
1674        assert_eq!(applet.before_help_long.as_deref(), Some("run long before"));
1675        assert_eq!(applet.after_help.as_deref(), Some("run after"));
1676        assert_eq!(applet.after_help_long.as_deref(), Some("run long after"));
1677        assert_eq!(applet.examples.len(), 1);
1678        assert_eq!(applet.examples[0].header.as_deref(), Some("run example"));
1679        assert!(applet.cmd.flags.iter().any(|flag| flag.name == "verbose"));
1680        assert!(applet
1681            .cmd
1682            .flags
1683            .iter()
1684            .any(|flag| flag.name == "if-present"));
1685        assert!(!applet.cmd.flags.iter().any(|flag| flag.name == "config"));
1686        assert!(applet.cmd.subcommands.contains_key("nested"));
1687        assert!(applet.views.is_empty());
1688        assert!(applet.to_string().contains("bin aubr"));
1689    }
1690
1691    #[test]
1692    fn a_view_preserves_the_host_version_entry_policy() {
1693        let spec: Spec = r#"
1694bin "host"
1695version "1.2.3"
1696disable_version_flag #true
1697view "runner" root=run
1698cmd "run"
1699"#
1700        .parse()
1701        .unwrap();
1702
1703        let view = spec.for_view("runner").unwrap();
1704        assert!(view.cmd.disable_version_flag);
1705        let emitted = view.to_string();
1706        assert!(emitted.contains("disable_version_flag #true"), "{emitted}");
1707        let reparsed: Spec = emitted.parse().unwrap();
1708        assert!(reparsed.cmd.disable_version_flag);
1709    }
1710
1711    #[test]
1712    fn a_promoted_flag_shadows_every_carried_global_spelling() {
1713        let spec: Spec = r#"
1714bin "host"
1715flag "--color" global=#true negate="--no-color"
1716view "runner" root=run {
1717  global "--color"
1718}
1719cmd "run" {
1720  flag "--no-color"
1721}
1722"#
1723        .parse()
1724        .unwrap();
1725
1726        let view = spec.for_view("runner").unwrap();
1727        assert_eq!(view.cmd.flags.len(), 1);
1728        assert_eq!(view.cmd.flags[0].long, ["no-color"]);
1729    }
1730
1731    #[test]
1732    fn a_view_keeps_only_its_own_and_carried_global_completers() {
1733        let spec: Spec = r#"
1734bin "host"
1735flag "--host <HOST>" global=#true
1736flag "--carried <CARRIED>" global=#true
1737complete "host" run="host candidates"
1738complete "carried" run="carried candidates"
1739view "runner" root=run {
1740  global "--carried"
1741}
1742cmd "run" {
1743  arg "<HOST>"
1744  complete "host" run="view candidates"
1745}
1746"#
1747        .parse()
1748        .unwrap();
1749
1750        let view = spec.for_view("runner").unwrap();
1751        assert_eq!(
1752            view.complete.get("host").unwrap().run.as_deref(),
1753            Some("view candidates")
1754        );
1755        assert_eq!(
1756            view.complete.get("carried").unwrap().run.as_deref(),
1757            Some("carried candidates")
1758        );
1759        assert!(view.cmd.complete.is_empty());
1760        assert_eq!(view.complete.len(), 2);
1761        assert_eq!(view.to_string().matches("complete ").count(), 2);
1762    }
1763
1764    #[test]
1765    fn a_view_projects_groups_of_carried_globals() {
1766        let spec: Spec = r#"
1767bin "host"
1768flag "--json" global=#true
1769flag "--yaml" global=#true
1770flag "--toml" global=#true
1771group "format" "--json" "--yaml" "--toml" required=#true
1772view "all" root=run globals=#true
1773view "json" root=run {
1774  global "--json"
1775}
1776cmd "run"
1777"#
1778        .parse()
1779        .unwrap();
1780
1781        let all = spec.for_view("all").unwrap();
1782        assert_eq!(all.cmd.groups.len(), 1);
1783        assert_eq!(all.cmd.groups[0].members, ["--json", "--yaml", "--toml"]);
1784        assert!(all.to_string().parse::<Spec>().is_ok());
1785
1786        let json = spec.for_view("json").unwrap();
1787        assert!(json.cmd.groups.is_empty());
1788        assert!(
1789            json.cmd
1790                .flags
1791                .iter()
1792                .find(|flag| flag.name == "json")
1793                .unwrap()
1794                .required
1795        );
1796        assert!(json.to_string().parse::<Spec>().is_ok());
1797    }
1798
1799    #[test]
1800    fn a_view_refuses_unknown_commands_and_non_global_carryovers() {
1801        let missing: Spec = "bin \"ex\"\nview \"x\" root=missing\n".parse().unwrap();
1802        assert!(missing
1803            .for_view("x")
1804            .unwrap_err()
1805            .to_string()
1806            .contains("missing"));
1807
1808        let local: Spec =
1809            "bin \"ex\"\nflag \"--local\"\nview \"x\" root=go { global \"--local\" }\ncmd go\n"
1810                .parse()
1811                .unwrap();
1812        assert!(local
1813            .for_view("x")
1814            .unwrap_err()
1815            .to_string()
1816            .contains("not a root global"));
1817    }
1818
1819    #[test]
1820    fn an_included_spec_can_enable_or_disable_multicall() {
1821        let dir = tempfile::tempdir().unwrap();
1822        let included = dir.path().join("included.usage.kdl");
1823        let root = dir.path().join("root.usage.kdl");
1824
1825        std::fs::write(&included, "multicall #false\n").unwrap();
1826        std::fs::write(
1827            &root,
1828            "multicall #true\ninclude file=\"./included.usage.kdl\"\n",
1829        )
1830        .unwrap();
1831        let spec = Spec::parse_file(&root).unwrap();
1832        assert!(!spec.multicall);
1833        assert!(spec.to_string().contains("multicall #false"));
1834
1835        std::fs::write(&included, "multicall #true\n").unwrap();
1836        std::fs::write(
1837            &root,
1838            "multicall #false\ninclude file=\"./included.usage.kdl\"\n",
1839        )
1840        .unwrap();
1841        let spec = Spec::parse_file(&root).unwrap();
1842        assert!(spec.multicall);
1843        assert!(spec.to_string().contains("multicall #true"));
1844    }
1845
1846    #[test]
1847    #[cfg(feature = "clap")]
1848    fn multicall_comes_across_from_clap() {
1849        let cmd = clap::Command::new("busybox")
1850            .multicall(true)
1851            .subcommand(clap::Command::new("ls"))
1852            .subcommand(clap::Command::new("cat"));
1853        let spec = Spec::from(&cmd);
1854        assert!(spec.multicall);
1855        assert!(
1856            spec.to_string().contains("multicall #true"),
1857            "{}",
1858            spec.to_string()
1859        );
1860
1861        let plain = clap::Command::new("ex").subcommand(clap::Command::new("ls"));
1862        assert!(!Spec::from(&plain).multicall);
1863    }
1864
1865    macro_rules! extract_usage_tests {
1866        ($($name:ident: $input:expr, $expected:expr,)*) => {
1867        $(
1868            #[test]
1869            fn $name() {
1870                let result = extract_usage_from_comments($input);
1871                let expected = $expected.trim_start_matches('\n').trim_end();
1872                assert_eq!(result, expected);
1873            }
1874        )*
1875        }
1876    }
1877
1878    extract_usage_tests! {
1879        test_extract_usage_from_comments_original_hash:
1880            r#"
1881#!/bin/bash
1882#USAGE bin "test"
1883#USAGE flag "--foo" help="test"
1884echo "hello"
1885            "#,
1886            r#"
1887bin "test"
1888flag "--foo" help="test"
1889            "#,
1890
1891        test_extract_usage_from_comments_original_double_slash:
1892            r#"
1893#!/usr/bin/env node
1894//USAGE bin "test"
1895//USAGE flag "--foo" help="test"
1896console.log("hello");
1897            "#,
1898            r#"
1899bin "test"
1900flag "--foo" help="test"
1901            "#,
1902
1903        test_extract_usage_from_comments_bracket_with_space:
1904            r#"
1905#!/bin/bash
1906# [USAGE] bin "test"
1907# [USAGE] flag "--foo" help="test"
1908echo "hello"
1909            "#,
1910            r#"
1911bin "test"
1912flag "--foo" help="test"
1913            "#,
1914
1915        test_extract_usage_from_comments_bracket_no_space:
1916            r#"
1917#!/bin/bash
1918#[USAGE] bin "test"
1919#[USAGE] flag "--foo" help="test"
1920echo "hello"
1921            "#,
1922            r#"
1923bin "test"
1924flag "--foo" help="test"
1925            "#,
1926
1927        test_extract_usage_from_comments_double_slash_bracket_with_space:
1928            r#"
1929#!/usr/bin/env node
1930// [USAGE] bin "test"
1931// [USAGE] flag "--foo" help="test"
1932console.log("hello");
1933            "#,
1934            r#"
1935bin "test"
1936flag "--foo" help="test"
1937            "#,
1938
1939        test_extract_usage_from_comments_double_slash_bracket_no_space:
1940            r#"
1941#!/usr/bin/env node
1942//[USAGE] bin "test"
1943//[USAGE] flag "--foo" help="test"
1944console.log("hello");
1945            "#,
1946            r#"
1947bin "test"
1948flag "--foo" help="test"
1949            "#,
1950
1951        test_extract_usage_from_comments_stops_at_gap:
1952            r#"
1953#!/bin/bash
1954#USAGE bin "test"
1955#USAGE flag "--foo" help="test"
1956
1957#USAGE flag "--bar" help="should not be included"
1958echo "hello"
1959            "#,
1960            r#"
1961bin "test"
1962flag "--foo" help="test"
1963            "#,
1964
1965        test_extract_usage_from_comments_with_content_after_marker:
1966            r#"
1967#!/bin/bash
1968# [USAGE] bin "test"
1969# [USAGE] flag "--verbose" help="verbose mode"
1970# [USAGE] arg "input" help="input file"
1971echo "hello"
1972            "#,
1973            r#"
1974bin "test"
1975flag "--verbose" help="verbose mode"
1976arg "input" help="input file"
1977            "#,
1978
1979        test_extract_usage_from_comments_double_colon_original:
1980            r#"
1981::USAGE bin "test"
1982::USAGE flag "--foo" help="test"
1983echo "hello"
1984            "#,
1985            r#"
1986bin "test"
1987flag "--foo" help="test"
1988            "#,
1989
1990        test_extract_usage_from_comments_double_colon_bracket_with_space:
1991            r#"
1992:: [USAGE] bin "test"
1993:: [USAGE] flag "--foo" help="test"
1994echo "hello"
1995            "#,
1996            r#"
1997bin "test"
1998flag "--foo" help="test"
1999            "#,
2000
2001        test_extract_usage_from_comments_double_colon_bracket_no_space:
2002            r#"
2003::[USAGE] bin "test"
2004::[USAGE] flag "--foo" help="test"
2005echo "hello"
2006            "#,
2007            r#"
2008bin "test"
2009flag "--foo" help="test"
2010            "#,
2011
2012        test_extract_usage_from_comments_double_colon_stops_at_gap:
2013            r#"
2014::USAGE bin "test"
2015::USAGE flag "--foo" help="test"
2016
2017::USAGE flag "--bar" help="should not be included"
2018echo "hello"
2019            "#,
2020            r#"
2021bin "test"
2022flag "--foo" help="test"
2023            "#,
2024
2025        test_extract_usage_from_comments_double_colon_with_content_after_marker:
2026            r#"
2027::USAGE bin "test"
2028::USAGE flag "--verbose" help="verbose mode"
2029::USAGE arg "input" help="input file"
2030echo "hello"
2031            "#,
2032            r#"
2033bin "test"
2034flag "--verbose" help="verbose mode"
2035arg "input" help="input file"
2036            "#,
2037
2038        test_extract_usage_from_comments_double_colon_bracket_with_space_multiple_lines:
2039            r#"
2040:: [USAGE] bin "myapp"
2041:: [USAGE] flag "--config <file>" help="config file"
2042:: [USAGE] flag "--verbose" help="verbose output"
2043:: [USAGE] arg "input" help="input file"
2044:: [USAGE] arg "[output]" help="output file" required=#false
2045echo "done"
2046            "#,
2047            r#"
2048bin "myapp"
2049flag "--config <file>" help="config file"
2050flag "--verbose" help="verbose output"
2051arg "input" help="input file"
2052arg "[output]" help="output file" required=#false
2053            "#,
2054
2055        test_extract_usage_from_comments_empty:
2056            r#"
2057#!/bin/bash
2058echo "hello"
2059            "#,
2060            "",
2061
2062        test_extract_usage_from_comments_lowercase_usage:
2063            r#"
2064#!/bin/bash
2065#usage bin "test"
2066#usage flag "--foo" help="test"
2067echo "hello"
2068            "#,
2069            "",
2070
2071        test_extract_usage_from_comments_mixed_case_usage:
2072            r#"
2073#!/bin/bash
2074#Usage bin "test"
2075#Usage flag "--foo" help="test"
2076echo "hello"
2077            "#,
2078            "",
2079
2080        test_extract_usage_from_comments_space_before_usage:
2081            r#"
2082#!/bin/bash
2083# USAGE bin "test"
2084# USAGE flag "--foo" help="test"
2085echo "hello"
2086            "#,
2087            "",
2088
2089        test_extract_usage_from_comments_double_slash_lowercase:
2090            r#"
2091#!/usr/bin/env node
2092//usage bin "test"
2093//usage flag "--foo" help="test"
2094console.log("hello");
2095            "#,
2096            "",
2097
2098        test_extract_usage_from_comments_double_slash_mixed_case:
2099            r#"
2100#!/usr/bin/env node
2101//Usage bin "test"
2102//Usage flag "--foo" help="test"
2103console.log("hello");
2104            "#,
2105            "",
2106
2107        test_extract_usage_from_comments_double_slash_space_before_usage:
2108            r#"
2109#!/usr/bin/env node
2110// USAGE bin "test"
2111// USAGE flag "--foo" help="test"
2112console.log("hello");
2113            "#,
2114            "",
2115
2116        test_extract_usage_from_comments_bracket_lowercase:
2117            r#"
2118#!/bin/bash
2119#[usage] bin "test"
2120#[usage] flag "--foo" help="test"
2121echo "hello"
2122            "#,
2123            "",
2124
2125        test_extract_usage_from_comments_bracket_mixed_case:
2126            r#"
2127#!/bin/bash
2128#[Usage] bin "test"
2129#[Usage] flag "--foo" help="test"
2130echo "hello"
2131            "#,
2132            "",
2133
2134        test_extract_usage_from_comments_bracket_space_lowercase:
2135            r#"
2136#!/bin/bash
2137# [usage] bin "test"
2138# [usage] flag "--foo" help="test"
2139echo "hello"
2140            "#,
2141            "",
2142
2143        test_extract_usage_from_comments_double_colon_lowercase:
2144            r#"
2145::usage bin "test"
2146::usage flag "--foo" help="test"
2147echo "hello"
2148            "#,
2149            "",
2150
2151        test_extract_usage_from_comments_double_colon_mixed_case:
2152            r#"
2153::Usage bin "test"
2154::Usage flag "--foo" help="test"
2155echo "hello"
2156            "#,
2157            "",
2158
2159        test_extract_usage_from_comments_double_colon_space_before_usage:
2160            r#"
2161:: USAGE bin "test"
2162:: USAGE flag "--foo" help="test"
2163echo "hello"
2164            "#,
2165            "",
2166
2167        test_extract_usage_from_comments_double_colon_bracket_lowercase:
2168            r#"
2169::[usage] bin "test"
2170::[usage] flag "--foo" help="test"
2171echo "hello"
2172            "#,
2173            "",
2174
2175        test_extract_usage_from_comments_double_colon_bracket_mixed_case:
2176            r#"
2177::[Usage] bin "test"
2178::[Usage] flag "--foo" help="test"
2179echo "hello"
2180            "#,
2181            "",
2182
2183        test_extract_usage_from_comments_double_colon_bracket_space_lowercase:
2184            r#"
2185:: [usage] bin "test"
2186:: [usage] flag "--foo" help="test"
2187echo "hello"
2188            "#,
2189            "",
2190    }
2191
2192    #[test]
2193    fn test_spec_with_examples() {
2194        let spec = Spec::parse(
2195            &Default::default(),
2196            r#"
2197name "demo"
2198bin "demo"
2199example "demo --help" header="Getting help" help="Display help information"
2200example "demo --version" header="Check version"
2201        "#,
2202        )
2203        .unwrap();
2204
2205        assert_eq!(spec.examples.len(), 2);
2206
2207        assert_eq!(spec.examples[0].code, "demo --help");
2208        assert_eq!(spec.examples[0].header, Some("Getting help".to_string()));
2209        assert_eq!(
2210            spec.examples[0].help,
2211            Some("Display help information".to_string())
2212        );
2213
2214        assert_eq!(spec.examples[1].code, "demo --version");
2215        assert_eq!(spec.examples[1].header, Some("Check version".to_string()));
2216        assert_eq!(spec.examples[1].help, None);
2217    }
2218
2219    #[test]
2220    fn test_spec_examples_display() {
2221        let spec = Spec::parse(
2222            &Default::default(),
2223            r#"
2224name "demo"
2225bin "demo"
2226example "demo --help" header="Getting help" help="Show help"
2227example "demo --version"
2228        "#,
2229        )
2230        .unwrap();
2231
2232        let output = format!("{}", spec);
2233        assert!(
2234            output.contains("example \"demo --help\" header=\"Getting help\" help=\"Show help\"")
2235        );
2236        assert!(output.contains("example \"demo --version\""));
2237    }
2238
2239    #[test]
2240    fn test_parse_script_str() {
2241        let spec = Spec::parse_script_str(
2242            r#"
2243#!/bin/bash
2244#USAGE bin "test"
2245#USAGE flag "--foo" help="test"
2246echo "hello"
2247            "#,
2248        )
2249        .unwrap();
2250
2251        assert_eq!(spec.bin, "test");
2252        assert_eq!(spec.name, "test");
2253        assert_eq!(spec.cmd.flags.len(), 1);
2254        assert_eq!(spec.cmd.flags[0].long, ["foo"]);
2255    }
2256
2257    #[test]
2258    fn test_parse_script_str_rejects_relative_includes() {
2259        let err = Spec::parse_script_str(r#"#USAGE include file="relative.usage.kdl""#)
2260            .expect_err("relative includes need a source path");
2261
2262        match err {
2263            UsageErr::InvalidInput(msg, _, _) => {
2264                assert_eq!(msg, "relative includes require a source file");
2265            }
2266            err => panic!("unexpected error: {err:?}"),
2267        }
2268    }
2269
2270    #[test]
2271    fn test_include_does_not_infer_metadata_from_included_filename() {
2272        let dir = tempfile::tempdir().unwrap();
2273        let included = dir.path().join("overrides.usage.kdl");
2274        let root = dir.path().join("my-script.usage.kdl");
2275        std::fs::write(&included, "").unwrap();
2276        std::fs::write(&root, "include file=\"./overrides.usage.kdl\"\n").unwrap();
2277
2278        let spec = Spec::parse_file(&root).unwrap();
2279
2280        assert_eq!(spec.name, "my-script.usage.kdl");
2281        assert_eq!(spec.bin, "my-script.usage.kdl");
2282        assert!(spec.cmd.name.is_empty());
2283    }
2284
2285    #[test]
2286    fn resolving_mounts_replaces_the_unresolved_synopsis() {
2287        let mut spec: Spec = "bin ex\nmount run=tasks synopsis=\"[TASK] [ARGS]…\""
2288            .parse()
2289            .unwrap();
2290        assert!(spec.cmd.usage.contains("[TASK] [ARGS]…"));
2291        spec.resolve_mount_outputs(&HashMap::from([(
2292            "tasks".to_string(),
2293            "cmd build".to_string(),
2294        )]))
2295        .unwrap();
2296        assert!(spec.cmd.mounts.is_empty());
2297        assert_eq!(spec.cmd.usage, "[SUBCOMMAND]");
2298        assert_eq!(spec.cmd.subcommands["build"].usage, "build");
2299        let json = serde_json::to_value(&spec).unwrap();
2300        assert_eq!(json["cmd"]["usage"], "[SUBCOMMAND]");
2301    }
2302
2303    #[test]
2304    fn injected_nested_mounts_ignore_the_mounted_specs_root_default() {
2305        let mut spec: Spec = "mount run=outer".parse().unwrap();
2306        let outputs = HashMap::from([
2307            (
2308                "outer".to_string(),
2309                "default_subcommand run\nmount run=nested\ncmd run".to_string(),
2310            ),
2311            ("nested".to_string(), "cmd leaf".to_string()),
2312        ]);
2313
2314        spec.resolve_mount_outputs(&outputs).unwrap();
2315
2316        assert!(spec.cmd.subcommands.contains_key("leaf"));
2317    }
2318
2319    #[test]
2320    fn restamping_a_grafted_tree_matches_writing_it_out_and_reading_it_back() {
2321        // The claim the whole thing rests on: grafting a command in and restamping produces the
2322        // tree a spec declaring the same commands would have parsed to. If it ever stops being
2323        // true, the cheap path is silently wrong rather than slow.
2324        let host: Spec =
2325            "name \"host\"\nbin \"host\"\ncmd \"plugins\" {\n  external_subcommand #true\n}\n"
2326                .parse()
2327                .unwrap();
2328        let plugin: Spec =
2329            "name \"formatter\"\nbin \"formatter\"\narg \"[path]\"\ncmd \"check\" {\n  flag \"--fix\"\n}\n"
2330                .parse()
2331                .unwrap();
2332
2333        let mut grafted = host.clone();
2334        grafted
2335            .cmd
2336            .subcommands
2337            .get_mut("plugins")
2338            .unwrap()
2339            .subcommands
2340            .insert("formatter".to_string(), plugin.cmd.clone());
2341        grafted.restamp();
2342
2343        let reparsed: Spec = grafted.to_string().parse().unwrap();
2344        let path = |spec: &Spec, words: &[&str]| {
2345            let mut cmd = &spec.cmd;
2346            for word in words {
2347                cmd = cmd.find_subcommand(word).unwrap();
2348            }
2349            (cmd.full_cmd.clone(), cmd.usage.clone())
2350        };
2351        for words in [
2352            &[][..],
2353            &["plugins"],
2354            &["plugins", "formatter"],
2355            &["plugins", "formatter", "check"],
2356        ] {
2357            assert_eq!(
2358                path(&grafted, words),
2359                path(&reparsed, words),
2360                "at {words:?}"
2361            );
2362        }
2363
2364        // Specifically: the plugin no longer claims to be a root, and its new parent has
2365        // learned that it takes a subcommand at all.
2366        assert_eq!(
2367            path(&grafted, &["plugins", "formatter"]).0,
2368            ["plugins", "formatter"]
2369        );
2370        assert!(
2371            path(&grafted, &["plugins"]).1.contains("[SUBCOMMAND]"),
2372            "{:?}",
2373            path(&grafted, &["plugins"]).1
2374        );
2375        // And the memoized lookup answers for what is there now, aliases included.
2376        assert!(grafted
2377            .cmd
2378            .find_subcommand("plugins")
2379            .unwrap()
2380            .find_subcommand("formatter")
2381            .is_some());
2382    }
2383}