1use 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;
28pub use usage_parser::{SpecArgBuilder, SpecCommandBuilder, SpecFlagBuilder};
32
33#[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 aliases: Vec<String>,
54 hidden_aliases: Vec<String>,
56 spec: Spec,
57}
58
59impl Entry {
60 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#[derive(Debug, Clone, Copy)]
70pub struct App<'a> {
71 catalog: &'a Catalog<'a>,
72 merged: &'a Spec,
73}
74
75#[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 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 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 pub fn is_assembled(&self) -> bool {
117 self.merged.get().is_some()
118 }
119
120 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 merged.restamp();
136 Ok(merged)
137 }
138
139 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 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 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 pub fn spec(&self) -> &'a Spec {
229 self.merged
230 }
231
232 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 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_request(&answer, &request))
256 }
257
258 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 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 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 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 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 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
384fn sort_and_dedup(candidates: &mut Vec<Candidate<'static>>) {
387 candidates.sort_unstable();
388 candidates.dedup_by(|a, b| a.value == b.value);
389}
390
391fn 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
413fn 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
422fn 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 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 let flag_like = flags_possible && prefix.starts_with('-');
445 let mut candidates = Vec::new();
446 let mut files = None;
447 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 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
513fn 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
588fn 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
610fn 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 pub fn root(mut self, spec: Spec) -> Self {
628 self.pending.push((String::new(), spec));
629 self
630 }
631
632 pub fn under(mut self, parent: impl Into<String>, spec: Spec) -> Self {
637 self.pending.push((parent.into(), spec));
638 self
639 }
640
641 pub const fn completions(mut self, overlays: &'a [CompletionOverlay<'a>]) -> Self {
647 self.overlays = overlays;
648 self
649 }
650
651 pub const fn project(mut self, command_path: &'a str) -> Self {
656 self.projection = Some(command_path);
657 self
658 }
659
660 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 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
738fn 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 || 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#[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#[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 {}