Skip to main content

usage_dynamic/
lib.rs

1//! Runtime commands for a derive-generated usage-rs host.
2//!
3//! A CLI that has plugins knows what `host plugin-x` is only after it has read its own configuration,
4//! which is long after the tables describing the rest of its CLI were compiled. Those tables
5//! stay as they are: an application discovers and caches plugin specs itself, and a [`Catalog`]
6//! attaches them beneath a static command that declared an
7//! [`external_subcommand`](usage_parser::SpecCommand::external_subcommand) catch-all.
8//!
9//! What that buys is the three things the static tables cannot answer for a command they have
10//! never seen: help that lists and navigates runtime commands, completion that descends into
11//! them, and a parse of the argv the catch-all captured. Nothing here mutates the derived parse
12//! tables or the KDL they emit, and nothing here runs a subprocess, reads a file, or calls back
13//! into the application.
14
15use std::borrow::Cow;
16use std::collections::{HashMap, HashSet};
17use std::ffi::OsString;
18use std::fmt;
19use std::sync::OnceLock;
20
21use usage_argv::complete::{self, CompletionOverlay, CompletionRequest, Completions, Files, Split};
22use usage_argv::spec::{Candidate, CandidateKind, CommandMeta, SpecView};
23use usage_parser::Parser;
24use usage_parser::error::UsageErr;
25
26pub use usage_parser::Spec;
27pub use usage_parser::parse::ParseOutput;
28// For command trees built in Rust rather than parsed from KDL. `Spec: From<SpecCommand>`
29// wraps a built root; the builders come along so an application defining runtime commands
30// programmatically needs no direct usage-lib dependency.
31pub use usage_parser::{SpecArgBuilder, SpecCommandBuilder, SpecFlagBuilder};
32
33/// A validated collection of caller-supplied runtime command specs.
34///
35/// Building one validates; it does not assemble. The merged tree help and completion navigate
36/// costs a KDL round trip of the whole host spec, and dispatching a plugin command needs none of
37/// it — so it is built when something asks for it, and a host that only ever runs plugins never
38/// pays. See [`app`](Self::app).
39#[derive(Debug)]
40pub struct Catalog<'a> {
41    host: SpecView<'a>,
42    entries: Vec<Entry>,
43    overlays: &'a [CompletionOverlay<'a>],
44    projection: Option<&'a str>,
45    merged: OnceLock<Result<Spec, Error>>,
46}
47
48#[derive(Debug)]
49struct Entry {
50    parent: String,
51    name: String,
52    /// Answered to and advertised.
53    aliases: Vec<String>,
54    /// Answered to and never advertised — an old name kept working.
55    hidden_aliases: Vec<String>,
56    spec: Spec,
57}
58
59impl Entry {
60    /// Whether this is what the user typed, by any name it answers to.
61    fn answers_to(&self, word: &str) -> bool {
62        self.name == word
63            || self.aliases.iter().any(|alias| alias == word)
64            || self.hidden_aliases.iter().any(|alias| alias == word)
65    }
66}
67
68/// Help and completion over the whole command tree, static and runtime alike.
69#[derive(Debug, Clone, Copy)]
70pub struct App<'a> {
71    catalog: &'a Catalog<'a>,
72    merged: &'a Spec,
73}
74
75/// A catalog under construction.
76#[derive(Debug)]
77pub struct Builder<'a> {
78    host: SpecView<'a>,
79    pending: Vec<(String, Spec)>,
80    overlays: &'a [CompletionOverlay<'a>],
81    projection: Option<&'a str>,
82}
83
84impl<'a> Catalog<'a> {
85    /// Begin a catalog over a derive-generated `Cli::app()`.
86    pub fn builder(host: SpecView<'a>) -> Builder<'a> {
87        Builder {
88            host,
89            pending: Vec::new(),
90            overlays: &[],
91            projection: None,
92        }
93    }
94
95    /// Help and completion over the whole tree, static commands and runtime ones alike.
96    ///
97    /// The merged tree is assembled here, on the first call, and kept. Building it is a KDL
98    /// round trip of the host spec — cheap next to rendering a page, and not something
99    /// [`parse_external`](Self::parse_external) should ever pay for, which is why it waits until
100    /// somebody asks.
101    ///
102    /// The error is a disagreement between the derived tables and the spec model that read
103    /// them, which [`Builder::build`] cannot see because it has not lowered the host yet.
104    pub fn app(&self) -> Result<App<'_>, &Error> {
105        let merged = self.merged.get_or_init(|| self.merge()).as_ref()?;
106        Ok(App {
107            catalog: self,
108            merged,
109        })
110    }
111
112    /// Whether the merged tree has been assembled yet.
113    ///
114    /// [`app`](Self::app) builds it once and keeps it, so this answers whether the next call is
115    /// the expensive one. A catalog that is only ever dispatched through never assembles.
116    pub fn is_assembled(&self) -> bool {
117        self.merged.get().is_some()
118    }
119
120    /// Lower the host tables into a spec and graft every entry into it.
121    fn merge(&self) -> Result<Spec, Error> {
122        let mut merged: Spec = self
123            .host
124            .clone()
125            .to_kdl()
126            .parse()
127            .map_err(|error: UsageErr| Error::HostSpec(error.to_string()))?;
128        for entry in &self.entries {
129            insert_plugin(&mut merged, entry)?;
130        }
131        // A grafted command still carries the path it had in the spec it came from, where it was
132        // the root — and so does its parent's usage line, which has just gained a subcommand.
133        // Restamping is what makes the result indistinguishable from a spec that declared these
134        // commands in KDL.
135        merged.restamp();
136        Ok(merged)
137    }
138
139    /// The entry a word beneath a canonical parent path names, by any name it answers to.
140    fn entry(&self, parent: &str, word: &str) -> Option<&Entry> {
141        self.entries
142            .iter()
143            .find(|entry| entry.parent == parent && entry.answers_to(word))
144    }
145
146    /// The static tables' own completion surface, carrying whatever the host registered.
147    fn host_app(&self) -> usage_argv::complete::App<'_> {
148        let app = usage_argv::complete::App::new(self.host.clone()).completions(self.overlays);
149        match self.projection {
150            Some(path) => app.project(path),
151            None => app,
152        }
153    }
154
155    /// Parse the argv an external-subcommand variant captured.
156    ///
157    /// `parent` is the static command path the catch-all sits on, with the empty string meaning
158    /// the root. The first token is the runtime command's name, by any spelling it answers to.
159    ///
160    /// Two of the outcomes here are the caller's to handle rather than errors:
161    ///
162    /// - `Ok(None)` — no catalogued command answers to that name. Whatever the host did with
163    ///   unrecognized words before it had a catalog, it should still do.
164    /// - `Err(Error::NonUtf8)` — a token the portable spec model cannot represent, because it
165    ///   parses `String`s and this one is not one. The derive handed over `OsString`s, which
166    ///   lose nothing, so the argv is still intact: dispatch it the same way as `Ok(None)`
167    ///   rather than failing a command whose argument happens to be an unusual path.
168    ///
169    /// Defaults and environment fallbacks are applied by the plugin's own spec, and `--help` and
170    /// `--version` come back as [`Outcome`]s rather than as errors.
171    pub fn parse_external(
172        &self,
173        parent: &str,
174        argv: &[OsString],
175    ) -> Result<Option<Outcome>, Error> {
176        let canonical_parent = canonical_parent(self.host.spec().root, parent)?;
177        let Some(invoked) = argv.first() else {
178            return Ok(None);
179        };
180        let invoked = invoked.to_str().ok_or(Error::NonUtf8 { index: 0 })?;
181        let Some(entry) = self.entry(&canonical_parent, invoked) else {
182            return Ok(None);
183        };
184        let mut input = Vec::with_capacity(argv.len());
185        for (index, token) in argv.iter().enumerate() {
186            input.push(token.to_str().ok_or(Error::NonUtf8 { index })?.to_owned());
187        }
188        let mut output = Parser::new(&entry.spec)
189            .explain(&input)
190            .map_err(|error| Error::Parse(error.to_string()))?;
191        if let Some(index) = output
192            .errors
193            .iter()
194            .position(|error| matches!(error, UsageErr::Help(_) | UsageErr::Version(_)))
195        {
196            return Ok(Some(match output.errors.swap_remove(index) {
197                UsageErr::Help(page) => Outcome::Help(Help {
198                    parent: entry.parent.clone(),
199                    name: entry.name.clone(),
200                    invoked_as: invoked.to_owned(),
201                    page,
202                }),
203                UsageErr::Version(version) => Outcome::Version(Version {
204                    parent: entry.parent.clone(),
205                    name: entry.name.clone(),
206                    invoked_as: invoked.to_owned(),
207                    version,
208                }),
209                _ => unreachable!(),
210            }));
211        }
212        if !output.errors.is_empty() {
213            return Err(Error::InvalidArgv(
214                output.errors.iter().map(ToString::to_string).collect(),
215            ));
216        }
217        Ok(Some(Outcome::Parsed(Box::new(Parsed {
218            parent: entry.parent.clone(),
219            name: entry.name.clone(),
220            invoked_as: invoked.to_owned(),
221            output,
222        }))))
223    }
224}
225
226impl<'a> App<'a> {
227    /// The merged tree: the host's commands with the catalogued ones grafted in.
228    pub fn spec(&self) -> &'a Spec {
229        self.merged
230    }
231
232    /// Render help for a static or runtime command path. The empty path is the root.
233    ///
234    /// `long` is the `--help` / `-h` distinction: the full page or the summary.
235    pub fn help(self, path: &str, long: bool) -> Option<String> {
236        let command = find_command(self.merged, path)?;
237        Some(usage_parser::docs::cli::render_help(
238            self.merged,
239            command,
240            long,
241        ))
242    }
243
244    /// Answer a hidden `__complete_word__` invocation, or return `None` for ordinary argv.
245    ///
246    /// Two answerers, one line. Up to the catch-all the words are the host's, and the host's own
247    /// tables answer for them — the same engine, so registered completers, multicall
248    /// projections and a `--candidates` request all keep working, and the catalogued names are
249    /// added where a subcommand belongs. Past it the words are a plugin's, and that plugin's
250    /// spec answers alone. A name in neither is a program nobody here can describe, and the
251    /// answer is nothing.
252    pub async fn completion_request(self, argv: &[OsString]) -> Option<String> {
253        let request = CompletionRequest::parse(argv)?;
254        let answer = self.complete_request(&request).await;
255        Some(complete::render(&answer, request.shell))
256    }
257
258    /// The answer to a parsed request, before it is written the way a shell reads it.
259    ///
260    /// For a caller holding a [`Split`] rather than a shell's argv, build the request with
261    /// [`CompletionRequest::for_split`].
262    pub async fn complete_request(self, request: &CompletionRequest) -> Completions<'static> {
263        let host = self.catalog.host_app();
264        let split = host.effective_split(&request.split);
265        let position = complete::walk(self.catalog.host.spec().root.cmd, split.argv());
266        match position.external {
267            None => {
268                let mut answer = own(host.complete_request(request).await);
269                if request.candidates_for.is_none() {
270                    self.add_catalogued_commands(&position, &split, &mut answer);
271                }
272                answer
273            }
274            // A spec's `run=` names a completer to *execute*, and executing one is the thing
275            // this crate does not do. A stale script asking for one gets nothing rather than
276            // the wrong list.
277            Some(_) if request.candidates_for.is_some() => Completions {
278                candidates: Vec::new(),
279                files: None,
280            },
281            Some(index) => self.complete_plugin(&position, index, &split),
282        }
283    }
284
285    /// Add the runtime commands catalogued beneath the command the cursor is at.
286    ///
287    /// The static tables have never heard of them, so this is the one place their names come
288    /// from. Where they belong is wherever a subcommand of that parent would: not in a flag's
289    /// value, not after a dash, and not where a `help` topic is being asked for.
290    fn add_catalogued_commands(
291        self,
292        position: &usage_argv::complete::Position<'_>,
293        split: &Split,
294        answer: &mut Completions<'static>,
295    ) {
296        if position.help_topic
297            || position.awaiting_value.is_some()
298            || (position.flags_possible && split.prefix.starts_with('-'))
299        {
300            return;
301        }
302        let parent = position
303            .path
304            .iter()
305            .skip(1)
306            .map(|(command, _)| command.name)
307            .collect::<Vec<_>>()
308            .join(" ");
309        let before = answer.candidates.len();
310        for entry in self.catalog.entries.iter().filter(|e| e.parent == parent) {
311            let description = entry
312                .spec
313                .cmd
314                .help
315                .clone()
316                .or_else(|| entry.spec.about.clone());
317            for name in std::iter::once(&entry.name).chain(&entry.aliases) {
318                if name.starts_with(&split.prefix) {
319                    answer.candidates.push(Candidate {
320                        value: name.clone(),
321                        kind: CandidateKind::Command,
322                        display: None,
323                        description: description.clone().map(Cow::Owned),
324                    });
325                }
326            }
327        }
328        if answer.candidates.len() == before {
329            return;
330        }
331        sort_and_dedup(&mut answer.candidates);
332
333        // The static tables offered the working directory because *they* had nothing to say
334        // here; a catalogued name is something to say, and the same rule that closes a position
335        // with candidates in it closes this one. Only when no argument is at the cursor, since
336        // an argument may have asked for paths outright — then both belong.
337        if position.next_arg.is_none()
338            && position.awaiting_value.is_none()
339            && matches!(answer.files, Some(Files::Any))
340        {
341            answer.files = None;
342        }
343    }
344
345    /// Answer from the plugin's own spec, for the words that belong to it.
346    fn complete_plugin(
347        self,
348        position: &usage_argv::complete::Position<'_>,
349        index: usize,
350        split: &Split,
351    ) -> Completions<'static> {
352        let nothing = Completions {
353            candidates: Vec::new(),
354            files: None,
355        };
356        let parent = position
357            .path
358            .iter()
359            .skip(1)
360            .map(|(command, _)| command.name)
361            .collect::<Vec<_>>()
362            .join(" ");
363        let words = split.argv();
364        let Some(invoked) = words.get(index) else {
365            return nothing;
366        };
367        let Some(entry) = self.catalog.entry(&parent, invoked) else {
368            return nothing;
369        };
370        // The plugin's spec describes a program of its own, so the words it is given start with
371        // its own name — its `bin`, not whichever alias was typed, so that a spec selecting an
372        // applet or a view by argv0 selects the same one either way.
373        let argv0 = if entry.spec.bin.trim().is_empty() {
374            entry.name.clone()
375        } else {
376            entry.spec.bin.clone()
377        };
378        let mut input = vec![argv0];
379        input.extend(words[index + 1..].iter().cloned());
380        complete_subtree(&entry.spec, &input, &split.prefix).unwrap_or(nothing)
381    }
382}
383
384/// Sorted and deduplicated the way the static engine does it, so an answer with catalogued
385/// names in it reads as one list rather than two concatenated.
386fn sort_and_dedup(candidates: &mut Vec<Candidate<'static>>) {
387    candidates.sort_unstable();
388    candidates.dedup_by(|a, b| a.value == b.value);
389}
390
391/// Take ownership of an answer borrowed from the static tables.
392///
393/// The tables are `'static` in a real program, but a caller's are not required to be, and this
394/// crate's answers say nothing about where they came from.
395fn own(answer: Completions<'_>) -> Completions<'static> {
396    Completions {
397        candidates: answer
398            .candidates
399            .into_iter()
400            .map(|candidate| Candidate {
401                value: candidate.value,
402                kind: candidate.kind,
403                display: candidate.display.map(|text| Cow::Owned(text.into_owned())),
404                description: candidate
405                    .description
406                    .map(|text| Cow::Owned(text.into_owned())),
407            })
408            .collect(),
409        files: answer.files,
410    }
411}
412
413/// The command a space-separated path names, static or runtime alike.
414fn find_command<'a>(spec: &'a Spec, path: &str) -> Option<&'a usage_parser::SpecCommand> {
415    let mut command = &spec.cmd;
416    for component in path.split_ascii_whitespace() {
417        command = command.find_subcommand(component)?;
418    }
419    Some(command)
420}
421
422/// What could go at the cursor, asked of one plugin's own spec.
423///
424/// `input` is the plugin's argv as the plugin would see it — its own name first — and `prefix`
425/// is the word being typed, which is not in `input`: a half-typed word constrains the answer
426/// without being part of the line yet.
427fn complete_subtree(
428    spec: &Spec,
429    input: &[String],
430    prefix: &str,
431) -> Result<Completions<'static>, Error> {
432    let parsed = usage_parser::parse::parse_partial(spec, input)
433        .map_err(|error| Error::Parse(error.to_string()))?;
434    // A plugin may itself forward to a program of its own. One level down, the same rule: these
435    // words describe nothing this spec knows about.
436    if parsed.external.is_some() {
437        return Ok(Completions {
438            candidates: Vec::new(),
439            files: None,
440        });
441    }
442    let flags_possible = !parsed.double_dash_seen;
443    // A dash-prefixed word is a flag or nothing: no path starts with one.
444    let flag_like = flags_possible && prefix.starts_with('-');
445    let mut candidates = Vec::new();
446    let mut files = None;
447    // Whether the position names its own answers. An unmatched prefix against a declared set
448    // means "nothing matched what you typed", not "ask the filesystem" — offering the working
449    // directory for a mistyped choice answers a question nobody asked.
450    let mut closed = false;
451    let mut at_cursor = None;
452
453    if let Some(flag) = parsed.flag_awaiting_value.first() {
454        if let Some(arg) = &flag.arg {
455            candidates.extend(choice_candidates(arg, prefix));
456            closed |= declares_its_own(spec, &parsed.cmd, arg);
457            files = completion_files(spec, &parsed.cmd, arg);
458        }
459    } else if flag_like {
460        for (form, flag) in parsed.completion_flags() {
461            if flag.hide || !form.starts_with(prefix) || hidden_flag_form(&form, &flag) {
462                continue;
463            }
464            candidates.push(Candidate {
465                value: form,
466                kind: CandidateKind::Flag,
467                display: None,
468                description: flag.help.clone().map(Cow::Owned),
469            });
470        }
471    } else {
472        if let Some(arg) = parsed.next_arg.as_deref() {
473            at_cursor = Some(arg);
474            candidates.extend(choice_candidates(arg, prefix));
475            closed |= declares_its_own(spec, &parsed.cmd, arg);
476            files = completion_files(spec, &parsed.cmd, arg);
477        }
478        for command in parsed
479            .cmd
480            .subcommands
481            .values()
482            .filter(|command| !command.hide)
483        {
484            for name in std::iter::once(&command.name).chain(&command.aliases) {
485                if name.starts_with(prefix) {
486                    candidates.push(Candidate {
487                        value: name.clone(),
488                        kind: CandidateKind::Command,
489                        display: None,
490                        description: command.help.clone().map(Cow::Owned),
491                    });
492                }
493            }
494        }
495    }
496    sort_and_dedup(&mut candidates);
497
498    // An argument that requires a `--` is asking for that one word specifically; nothing else
499    // belongs at the cursor until it is there.
500    let needs_separator = parsed.flag_awaiting_value.is_empty()
501        && at_cursor
502            .is_some_and(|arg| arg.double_dash == usage_parser::SpecDoubleDashChoices::Required)
503        && !parsed.double_dash_seen;
504
505    if flag_like || needs_separator {
506        files = None;
507    } else if files.is_none() && candidates.is_empty() && !closed {
508        files = Some(Files::Any);
509    }
510    Ok(Completions { candidates, files })
511}
512
513/// Whether this position states what it accepts, so an unmatched prefix means "no matches".
514///
515/// Choices are the obvious case. A declared `complete` block is one too — unless it says its
516/// values are paths, which is a way of saying the filesystem *is* the answer, or says nothing
517/// at all with `unknown`.
518fn declares_its_own(
519    spec: &Spec,
520    command: &usage_parser::SpecCommand,
521    arg: &usage_parser::SpecArg,
522) -> bool {
523    if arg.choices.is_some() {
524        return true;
525    }
526    completion_for(spec, command, arg).is_some_and(|completion| {
527        completion.type_.as_deref().is_some_and(|type_| {
528            !type_.eq_ignore_ascii_case("unknown") && files_kind(type_).is_none()
529        })
530    })
531}
532
533fn choice_candidates(arg: &usage_parser::SpecArg, prefix: &str) -> Vec<Candidate<'static>> {
534    let Some(choices) = &arg.choices else {
535        return Vec::new();
536    };
537    let details: HashMap<_, _> = choices
538        .details
539        .iter()
540        .map(|choice| (choice.value.as_str(), choice))
541        .collect();
542    let mut candidates = Vec::new();
543    for value in &choices.choices {
544        let detail = details.get(value.as_str()).copied();
545        if detail.is_some_and(|choice| choice.hide) {
546            continue;
547        }
548        for form in std::iter::once(value.as_str()).chain(
549            detail
550                .into_iter()
551                .flat_map(|choice| &choice.aliases)
552                .filter(|alias| !alias.hide)
553                .map(|alias| alias.value.as_str()),
554        ) {
555            if form.starts_with(prefix) {
556                candidates.push(Candidate {
557                    value: form.to_owned(),
558                    kind: CandidateKind::Value,
559                    display: None,
560                    description: detail
561                        .and_then(|choice| choice.help.clone())
562                        .map(Cow::Owned),
563                });
564            }
565        }
566    }
567    candidates
568}
569
570fn hidden_flag_form(form: &str, flag: &usage_parser::SpecFlag) -> bool {
571    form.strip_prefix("--")
572        .is_some_and(|long| flag.hidden_aliases.iter().any(|hidden| hidden == long))
573        || form
574            .strip_prefix('-')
575            .filter(|short| short.len() == 1)
576            .and_then(|short| short.chars().next())
577            .is_some_and(|short| flag.hidden_short_aliases.contains(&short))
578}
579
580fn completion_files(
581    spec: &Spec,
582    command: &usage_parser::SpecCommand,
583    arg: &usage_parser::SpecArg,
584) -> Option<Files> {
585    files_kind(completion_for(spec, command, arg)?.type_.as_deref()?)
586}
587
588/// The path fallback a declared `complete` type asks for, if it asks for one at all.
589fn files_kind(type_: &str) -> Option<Files> {
590    let (kind, filter) = type_
591        .split_once(':')
592        .map_or((type_, None), |(kind, filter)| (kind, Some(filter)));
593    match kind {
594        "path" | "file" => match filter {
595            Some(filter) => Some(Files::Extensions(
596                filter
597                    .split(',')
598                    .map(|extension| extension.trim_start_matches('.').to_owned())
599                    .collect(),
600            )),
601            None => Some(Files::Any),
602        },
603        "dir" | "directory" => Some(Files::Dirs),
604        "executable" | "executable_path" => Some(Files::ExecutablePaths),
605        "command" => Some(Files::Commands),
606        _ => None,
607    }
608}
609
610/// The completer for `arg`: one written inside its own `arg` node first, being attached to the
611/// argument itself, then one naming it.
612fn completion_for<'a>(
613    spec: &'a Spec,
614    command: &'a usage_parser::SpecCommand,
615    arg: &'a usage_parser::SpecArg,
616) -> Option<&'a usage_parser::SpecComplete> {
617    arg.complete.as_ref().or_else(|| {
618        command
619            .complete
620            .get(&arg.name)
621            .or_else(|| spec.complete.get(&arg.name))
622    })
623}
624
625impl<'a> Builder<'a> {
626    /// Attach a supplied plugin spec beneath the static root.
627    pub fn root(mut self, spec: Spec) -> Self {
628        self.pending.push((String::new(), spec));
629        self
630    }
631
632    /// Attach a supplied plugin spec beneath a static command path.
633    ///
634    /// Components may use visible or hidden static aliases; construction stores the canonical
635    /// path. The parent must declare an external-subcommand catch-all.
636    pub fn under(mut self, parent: impl Into<String>, spec: Spec) -> Self {
637        self.pending.push((parent.into(), spec));
638        self
639    }
640
641    /// Register the host's own completion callbacks, as
642    /// [`usage_argv::complete::App::completions`] would.
643    ///
644    /// Words before a runtime command are the host's, and the host's engine answers for them —
645    /// so whatever it was given, it keeps.
646    pub const fn completions(mut self, overlays: &'a [CompletionOverlay<'a>]) -> Self {
647        self.overlays = overlays;
648        self
649    }
650
651    /// Answer as though this command path came after argv0, for a multicall binary.
652    ///
653    /// The same projection [`usage_argv::complete::App::project`] applies, applied once, before
654    /// anything asks where the words are.
655    pub const fn project(mut self, command_path: &'a str) -> Self {
656        self.projection = Some(command_path);
657        self
658    }
659
660    /// Validate every parent and name, and finish the catalog.
661    ///
662    /// What is checked here is what the static tables can answer on their own: that the parent
663    /// exists, that it invited runtime commands, and that no name collides. Assembling the
664    /// merged tree is left to [`Catalog::app`] — dispatching a plugin command never needs it.
665    pub fn build(self) -> Result<Catalog<'a>, Error> {
666        let host = self.host.spec();
667        let mut entries = Vec::with_capacity(self.pending.len());
668        let mut claimed: HashMap<String, HashSet<String>> = HashMap::new();
669        for (requested_parent, mut spec) in self.pending {
670            reject_mounts(&spec.cmd)?;
671            // Parsing stamps a tree; building one in Rust does not. Restamping here makes the
672            // two origins indistinguishable — for a parsed spec it recomputes what is already
673            // there — so nothing downstream has to know where a spec came from.
674            spec.restamp();
675            let (parent, parent_meta) = resolve_parent(host.root, &requested_parent)?;
676            if !parent_meta.cmd.external_subcommand {
677                return Err(Error::ParentNotExternal(parent));
678            }
679            let name = if spec.name.trim().is_empty() {
680                spec.cmd.name.trim().to_owned()
681            } else {
682                spec.name.trim().to_owned()
683            };
684            if name.is_empty() {
685                return Err(Error::EmptyName);
686            }
687            let aliases = spec.cmd.aliases.clone();
688            let hidden_aliases: Vec<String> = spec
689                .cmd
690                .hidden_aliases
691                .iter()
692                .filter(|alias| !aliases.contains(alias))
693                .cloned()
694                .collect();
695            let mut forms = Vec::with_capacity(1 + aliases.len() + hidden_aliases.len());
696            forms.push(name.clone());
697            forms.extend(aliases.iter().cloned());
698            forms.extend(hidden_aliases.iter().cloned());
699            if forms.iter().any(|form| form.is_empty()) {
700                return Err(Error::EmptyName);
701            }
702            let mut static_forms = HashSet::new();
703            for command in parent_meta.subcommands {
704                static_forms.insert(command.cmd.name);
705                static_forms.extend(command.cmd.aliases.iter().copied());
706                static_forms.extend(command.extra.hidden_aliases.iter().copied());
707            }
708            if !parent_meta.cmd.disable_help_subcommand {
709                static_forms.insert("help");
710            }
711            let parent_claimed = claimed.entry(parent.clone()).or_default();
712            for form in &forms {
713                if static_forms.contains(form.as_str()) || !parent_claimed.insert(form.clone()) {
714                    return Err(Error::Collision {
715                        parent,
716                        name: form.clone(),
717                    });
718                }
719            }
720            entries.push(Entry {
721                parent,
722                name,
723                aliases,
724                hidden_aliases,
725                spec,
726            });
727        }
728        Ok(Catalog {
729            host: self.host,
730            entries,
731            overlays: self.overlays,
732            projection: self.projection,
733            merged: OnceLock::new(),
734        })
735    }
736}
737
738/// Graft one entry's spec into the merged tree as a command of its parent.
739///
740/// A spec describes a program, and a command is one thing a program can be. What the two models
741/// keep in different places — a program's `about` is a command's `help` — moves across here.
742fn insert_plugin(merged: &mut Spec, entry: &Entry) -> Result<(), Error> {
743    let plugin = &entry.spec;
744    let mut command = plugin.cmd.clone();
745    command.name = entry.name.clone();
746    command.help = command.help.or_else(|| plugin.about.clone());
747    command.help_long = command.help_long.or_else(|| plugin.about_long.clone());
748    command.before_help = command.before_help.or_else(|| plugin.before_help.clone());
749    command.before_help_long = command
750        .before_help_long
751        .or_else(|| plugin.before_help_long.clone());
752    command.after_help = command.after_help.or_else(|| plugin.after_help.clone());
753    command.after_help_long = command
754        .after_help_long
755        .or_else(|| plugin.after_help_long.clone());
756    for (key, complete) in &plugin.complete {
757        command.complete.insert(key.clone(), complete.clone());
758    }
759    let mut current = &mut merged.cmd;
760    for component in entry.parent.split_ascii_whitespace() {
761        current = current
762            .subcommands
763            .get_mut(component)
764            .ok_or_else(|| Error::MissingParent(entry.parent.clone()))?;
765    }
766    current.subcommands.insert(entry.name.clone(), command);
767    Ok(())
768}
769
770fn canonical_parent(root: &CommandMeta<'_>, requested: &str) -> Result<String, Error> {
771    resolve_parent(root, requested).map(|(path, _)| path)
772}
773
774fn resolve_parent<'a>(
775    root: &'a CommandMeta<'a>,
776    requested: &str,
777) -> Result<(String, &'a CommandMeta<'a>), Error> {
778    let mut current = root;
779    let mut canonical = Vec::new();
780    for component in requested.split_ascii_whitespace() {
781        let Some(next) = current.subcommands.iter().copied().find(|candidate| {
782            candidate.cmd.name == component
783                || candidate.cmd.aliases.contains(&component)
784                // A hidden alias is one the CLI answers to, and an application naming its own
785                // static command is not "a user" being kept from an old spelling.
786                || candidate.extra.hidden_aliases.contains(&component)
787        }) else {
788            return Err(Error::MissingParent(requested.to_owned()));
789        };
790        canonical.push(next.cmd.name);
791        current = next;
792    }
793    Ok((canonical.join(" "), current))
794}
795
796fn reject_mounts(command: &usage_parser::SpecCommand) -> Result<(), Error> {
797    if !command.mounts.is_empty() {
798        return Err(Error::UnresolvedMount(command.name.clone()));
799    }
800    for child in command.subcommands.values() {
801        reject_mounts(child)?;
802    }
803    Ok(())
804}
805
806/// The result of parsing a catalogued external command.
807#[derive(Debug)]
808#[non_exhaustive]
809pub enum Outcome {
810    Parsed(Box<Parsed>),
811    Help(Help),
812    Version(Version),
813}
814
815#[derive(Debug)]
816pub struct Parsed {
817    pub parent: String,
818    pub name: String,
819    pub invoked_as: String,
820    pub output: ParseOutput,
821}
822
823#[derive(Debug, Clone, PartialEq, Eq)]
824pub struct Help {
825    pub parent: String,
826    pub name: String,
827    pub invoked_as: String,
828    pub page: String,
829}
830
831#[derive(Debug, Clone, PartialEq, Eq)]
832pub struct Version {
833    pub parent: String,
834    pub name: String,
835    pub invoked_as: String,
836    pub version: String,
837}
838
839/// A catalog construction or dynamic parse failure.
840#[derive(Debug)]
841#[non_exhaustive]
842pub enum Error {
843    HostSpec(String),
844    MissingParent(String),
845    ParentNotExternal(String),
846    EmptyName,
847    Collision { parent: String, name: String },
848    UnresolvedMount(String),
849    NonUtf8 { index: usize },
850    InvalidArgv(Vec<String>),
851    Parse(String),
852}
853
854impl fmt::Display for Error {
855    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
856        match self {
857            Self::HostSpec(error) => write!(f, "could not construct merged host spec: {error}"),
858            Self::MissingParent(parent) => write!(f, "static parent `{parent}` does not exist"),
859            Self::ParentNotExternal(parent) => {
860                write!(
861                    f,
862                    "static parent `{parent}` has no external-subcommand catch-all"
863                )
864            }
865            Self::EmptyName => f.write_str("dynamic command name cannot be empty"),
866            Self::Collision { parent, name } => {
867                write!(
868                    f,
869                    "dynamic command form `{name}` collides beneath `{parent}`"
870                )
871            }
872            Self::UnresolvedMount(command) => {
873                write!(
874                    f,
875                    "dynamic spec contains an unresolved mount beneath `{command}`"
876                )
877            }
878            Self::NonUtf8 { index } => {
879                write!(f, "dynamic argv token {index} is not valid UTF-8")
880            }
881            Self::InvalidArgv(errors) => f.write_str(&errors.join("\n")),
882            Self::Parse(error) => f.write_str(error),
883        }
884    }
885}
886
887impl std::error::Error for Error {}