1use std::collections::{BTreeMap, BTreeSet};
2use std::env;
3use std::fmt::Debug;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use itertools::Itertools;
8use regex::Regex;
9use std::sync::LazyLock;
10use usage::miette::IntoDiagnostic;
11use usage_rs::Args;
12
13use usage::parse::{ParseOutput, ParseValue, TokenRole};
14use usage::sh::sh;
15use usage::spec::config::SpecConfigProp;
16use usage::spec::config_type::{Base, SpecConfigType};
17use usage::{
18 Spec, SpecArg, SpecCommand, SpecComplete, SpecDoubleDashChoices, SpecFlag, UnknownFlags,
19};
20
21use crate::cli::{complete_delegate, generate};
22
23static COMPLETER_TERA: LazyLock<tera::Tera> = LazyLock::new(|| {
24 let mut tera = tera::Tera::default();
25 tera.register_filter(
26 "shell_quote",
27 |value: &tera::Value, _: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
28 let value = value
29 .as_str()
30 .ok_or_else(|| tera::Error::message("shell_quote expects a string"))?;
31 Ok(shell_words::quote(value).into_owned())
32 },
33 );
34 tera.register_filter(
35 "shell_join",
36 |value: &tera::Value, _: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
37 let values = value
38 .as_array()
39 .ok_or_else(|| tera::Error::message("shell_join expects a list of strings"))?;
40 let words = values
41 .iter()
42 .map(|value| {
43 value
44 .as_str()
45 .ok_or_else(|| tera::Error::message("shell_join expects a list of strings"))
46 })
47 .collect::<tera::TeraResult<Vec<_>>>()?;
48 Ok(shell_words::join(words))
49 },
50 );
51 tera
52});
53
54fn render_completer_run(run: &str, ctx: &tera::Context) -> tera::TeraResult<String> {
55 COMPLETER_TERA.render_str(run, ctx, false)
56}
57
58#[derive(Debug, Args)]
64#[usage(alias = "cw", effect = "read")]
65pub struct CompleteWord {
66 words: Vec<String>,
68
69 #[usage(short, long)]
71 file: Option<PathBuf>,
72
73 #[usage(short, long, required_unless = "--file", overrides = "--file")]
75 spec: Option<String>,
76
77 #[usage(long)]
79 cword: Option<usize>,
80
81 #[usage(
83 long,
84 default = "bash",
85 choices("bash", "fish", "nu", "powershell", "zsh")
86 )]
87 shell: String,
88}
89
90pub fn candidates(
96 spec: &Spec,
97 words: &[String],
98 cword: usize,
99 shell: &str,
100) -> usage::miette::Result<Vec<(String, String)>> {
101 Ok(answer(spec, words, cword, shell)?.candidates)
102}
103
104#[derive(Debug, PartialEq, Eq)]
110pub struct CandidateAnswer {
111 pub candidates: Vec<(String, String)>,
113 pub files: bool,
115}
116
117pub fn answer(
119 spec: &Spec,
120 words: &[String],
121 cword: usize,
122 shell: &str,
123) -> usage::miette::Result<CandidateAnswer> {
124 CompleteWord {
125 words: words.to_vec(),
126 file: None,
127 spec: None,
128 cword: Some(cword),
129 shell: shell.to_string(),
130 }
131 .complete_word_answer(spec)
132}
133
134impl CompleteWord {
135 pub fn complete_word(&self, spec: &Spec) -> usage::miette::Result<Vec<(String, String)>> {
136 Ok(self.complete_word_answer(spec)?.candidates)
137 }
138
139 fn complete_word_answer(&self, spec: &Spec) -> usage::miette::Result<CandidateAnswer> {
140 let cword = self.cword.unwrap_or(self.words.len().max(1) - 1);
141 let ctoken = self.words.get(cword).cloned().unwrap_or_default();
142 let words: Vec<_> = self.words.iter().take(cword).cloned().collect();
143
144 trace!(
145 "cword: {cword} ctoken: {ctoken} words: {}",
146 words.iter().join(" ")
147 );
148
149 let mut ctx = tera::Context::new();
150 ctx.insert("words", &self.words);
151 ctx.insert("CURRENT", &cword);
152 if cword > 0 {
153 ctx.insert("PREV", &(cword - 1));
154 }
155
156 let parsed = usage::parse::parse_partial(spec, &words)?;
157 debug!("parsed cmd: {}", parsed.cmd.full_cmd.join(" "));
158
159 if parsed.external.is_some() {
165 return Ok(CandidateAnswer {
166 candidates: vec![],
167 files: false,
168 });
169 }
170
171 let prev_token = if cword > 0 {
174 self.words.get(cword - 1).map(|s| s.as_str())
175 } else {
176 None
177 };
178 let after_restart_token = parsed
179 .cmd
180 .restart_token
181 .as_ref()
182 .is_some_and(|rt| prev_token == Some(rt.as_str()))
183 || parsed
184 .cmd
185 .clause
186 .as_ref()
187 .and_then(|clause| clause.separator.as_deref())
188 .is_some_and(|separator| prev_token == Some(separator));
189
190 let cx = Ctx {
191 tera: &ctx,
192 spec,
193 parsed: &parsed,
194 after_restart_token,
195 };
196 let mut has_explicit_choices = false;
197 let mut flags = parsed.completion_flags();
200 Self::add_supplied_flags(spec, &parsed, &mut flags);
203 if spec.default_subcommand_flags
204 && parsed.cmds.len() == 1
205 && let Some(default) = spec
206 .default_subcommand
207 .as_deref()
208 .and_then(|name| spec.cmd.find_subcommand(name))
209 {
210 for flag in &default.flags {
211 let flag = Arc::new(flag.clone());
212 for key in flag
213 .long
214 .iter()
215 .map(|name| format!("--{name}"))
216 .chain(flag.short.iter().map(|name| format!("-{name}")))
217 .chain(flag.negate.iter().cloned())
218 {
219 flags.entry(key).or_insert_with(|| Arc::clone(&flag));
220 }
221 }
222 }
223 let restart_seen = parsed.tokens.iter().any(|token| {
226 token
227 .roles
228 .iter()
229 .any(|role| matches!(role, usage::parse::TokenRole::Restart))
230 });
231 let automatic_trailing_seen = parsed
232 .tokens
233 .iter()
234 .rev()
235 .take_while(|token| {
236 !token.roles.iter().any(|role| {
237 matches!(
238 role,
239 usage::parse::TokenRole::Restart
240 | usage::parse::TokenRole::ClauseSeparator { .. }
241 )
242 })
243 })
244 .flat_map(|token| &token.roles)
245 .any(|role| match role {
246 usage::parse::TokenRole::Arg { arg, .. }
247 | usage::parse::TokenRole::Sigil { arg, .. } => {
248 arg.double_dash == usage::SpecDoubleDashChoices::Automatic
249 }
250 _ => false,
251 });
252 let flags_possible = !parsed.double_dash_seen && !automatic_trailing_seen;
253 let sigil_arg = (flags_possible
254 && !restart_seen
255 && !after_restart_token
256 && parsed.flag_awaiting_value.is_empty())
257 .then(|| {
258 parsed
259 .cmds
260 .iter()
261 .flat_map(|cmd| cmd.args.iter().map(move |arg| (cmd, arg)))
262 .filter_map(|(cmd, arg)| {
263 let sigil = arg.sigil.as_deref()?;
264 ctoken
265 .strip_prefix(sigil)
266 .map(|prefix| (cmd, arg, sigil, prefix))
267 })
268 .max_by_key(|(_, _, sigil, _)| sigil.len())
269 })
270 .flatten();
271 let attached_long_value = flags_possible
272 .then(|| Self::attached_long_value(&flags, &ctoken))
273 .flatten();
274 let mut used_file_fallback = false;
275 let mut choices = if flags_possible && ctoken == "-" {
276 let shorts = self.complete_short_flag_names(&flags, "");
277 let longs = self.complete_long_flag_names(&flags, "");
278 shorts.into_iter().chain(longs).collect::<Vec<_>>()
279 } else if flags_possible && ctoken.starts_with("--") {
280 if let Some((flag, form, prefix)) = attached_long_value {
281 let arg = flag.arg.as_ref().unwrap();
282 let mut attached_ctx = ctx.clone();
285 let mut attached_words = self.words.clone();
286 if let Some(current) = attached_words.get_mut(cword) {
287 *current = prefix.to_string();
288 }
289 attached_ctx.insert("words", &attached_words);
290 let attached_cx = Ctx {
291 tera: &attached_ctx,
292 spec: cx.spec,
293 parsed: cx.parsed,
294 after_restart_token: cx.after_restart_token,
295 };
296 let (mut found, closed) =
297 self.complete_arg(&attached_cx, &parsed.cmd, arg, prefix)?;
298 has_explicit_choices = closed || arg.choices.is_some();
299 if found.is_empty() && !has_explicit_choices {
300 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
301 found = self
302 .complete_path(&cwd, prefix, |_| true)
303 .into_iter()
304 .map(|name| (name, String::new()))
305 .collect();
306 used_file_fallback = true;
307 }
308 Self::attach_long_value_candidates(&mut found, form);
309 found
310 } else {
311 self.complete_long_flag_names(&flags, &ctoken)
312 }
313 } else if flags_possible && ctoken.starts_with('-') {
314 self.complete_short_flag_names(&flags, &ctoken)
315 } else if after_restart_token {
316 let mut choices = vec![];
320 if let Some(arg) = first_active_arg(&parsed.cmd) {
321 let (found, constrained) = self.complete_positional(
322 &cx,
323 &parsed.cmd,
324 arg,
325 &ctoken,
326 parsed.double_dash_seen,
327 )?;
328 has_explicit_choices = constrained;
329 choices.extend(found);
330 }
331 choices
332 } else if let Some(flag) = parsed.flag_awaiting_value.first() {
333 let arg = flag.arg.as_ref().unwrap();
334 let owner = flag_value_owner(&parsed, flag);
335 let (found, closed) = self.complete_arg(&cx, owner, arg, &ctoken)?;
336 has_explicit_choices = closed || arg.choices.is_some();
337 found
338 } else if let Some((owner, arg, sigil, prefix)) = sigil_arg {
339 let mut sigil_ctx = ctx.clone();
344 let mut sigil_words = self.words.clone();
345 if let Some(current) = sigil_words.get_mut(cword) {
346 *current = prefix.to_string();
347 }
348 sigil_ctx.insert("words", &sigil_words);
349 let sigil_cx = Ctx {
350 tera: &sigil_ctx,
351 spec: cx.spec,
352 parsed: cx.parsed,
353 after_restart_token: cx.after_restart_token,
354 };
355 let (mut found, closed) = self.complete_arg(&sigil_cx, owner, arg, prefix)?;
356 for (candidate, _) in &mut found {
357 candidate.insert_str(0, sigil);
358 }
359 has_explicit_choices = closed || arg.choices.is_some() || found.is_empty();
360 found
361 } else {
362 let mut choices = vec![];
363 if let Some(arg) = parsed.next_arg.as_deref() {
364 let (found, constrained) = self.complete_positional(
365 &cx,
366 &parsed.cmd,
367 arg,
368 &ctoken,
369 parsed.double_dash_seen,
370 )?;
371 has_explicit_choices = constrained;
372 choices.extend(found);
373 }
374 if !parsed.cmd.subcommands.is_empty() {
375 choices.extend(self.complete_subcommands(&parsed.cmd, &ctoken));
376 }
377 if parsed.cmd.name == spec.cmd.name
379 && let Some(default_name) = &spec.default_subcommand
380 && let Some(default_cmd) = spec.cmd.find_subcommand(default_name)
381 {
382 if let Some(arg) = first_active_arg(default_cmd) {
393 let (found, _) = self.complete_positional(
394 &cx,
395 default_cmd,
396 arg,
397 &ctoken,
398 parsed.double_dash_seen,
399 )?;
400 choices.extend(found);
401 }
402 }
403 choices
404 };
405 if flags_possible
406 && attached_long_value.is_none()
407 && sigil_arg.is_none()
408 && let Some(arg) = self.delegated_arg_at_flag(&cx, &ctoken)
409 {
410 let (found, _) = self.complete_arg(&cx, &parsed.cmd, arg, &ctoken)?;
411 for candidate in found {
412 if !choices.iter().any(|(name, _)| *name == candidate.0) {
413 choices.push(candidate);
414 }
415 }
416 }
417 let looks_like_a_flag = flags_possible && ctoken.starts_with('-');
421 let files = used_file_fallback
422 || (choices.is_empty() && !looks_like_a_flag && !has_explicit_choices);
423 if files && choices.is_empty() {
424 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
425 let files = self.complete_path(&cwd, &ctoken, |_| true);
426 choices = files.into_iter().map(|n| (n, String::new())).collect();
427 }
428 trace!("choices: {}", choices.iter().map(|(c, _)| c).join(", "));
429 Ok(CandidateAnswer {
430 candidates: choices,
431 files,
432 })
433 }
434
435 fn complete_subcommands(&self, cmd: &SpecCommand, ctoken: &str) -> Vec<(String, String)> {
436 trace!("complete_subcommands: {ctoken}");
437 let mut choices = vec![];
438 for subcommand in cmd.subcommands.values() {
439 if subcommand.hide {
440 continue;
441 }
442 choices.push((
443 subcommand.name.clone(),
444 subcommand.help.clone().unwrap_or_default(),
445 ));
446 for alias in &subcommand.aliases {
447 choices.push((alias.clone(), subcommand.help.clone().unwrap_or_default()));
448 }
449 }
450 choices
451 .into_iter()
452 .filter(|(c, _)| c.starts_with(ctoken))
453 .sorted()
454 .collect()
455 }
456
457 fn add_supplied_flags(
464 spec: &Spec,
465 parsed: &usage::parse::ParseOutput,
466 flags: &mut BTreeMap<String, Arc<SpecFlag>>,
467 ) {
468 let mut supply = |name: &str, help: &str, long: Option<&str>, short: Option<char>| {
469 let key = match (long, short) {
470 (Some(long), _) => format!("--{long}"),
471 (_, Some(short)) => format!("-{short}"),
472 _ => return,
473 };
474 if flags.contains_key(&key) || parsed.available_flags.contains_key(&key) {
477 return;
478 }
479 let mut flag = SpecFlag::default();
480 flag.name = name.to_string();
481 flag.help = Some(help.to_string());
482 flag.long = long.map(str::to_string).into_iter().collect();
483 flag.short = short.into_iter().collect();
484 flag.usage = flag.usage();
485 flags.insert(key, Arc::new(flag));
486 };
487 if spec.disable_help != Some(true) && !parsed.cmd.disable_help_flag {
488 supply("help", "Print help", Some("help"), None);
489 supply("help", "Print help", None, Some('h'));
490 }
491 if (spec.version.is_some() || spec.long_version.is_some())
492 && parsed.cmds.len() == 1
493 && !spec.cmd.disable_version_flag
494 {
495 supply("version", "Print version", Some("version"), None);
496 supply("version", "Print version", None, Some('V'));
497 }
498 }
499
500 fn complete_long_flag_names(
501 &self,
502 flags: &BTreeMap<String, Arc<SpecFlag>>,
503 ctoken: &str,
504 ) -> Vec<(String, String)> {
505 debug!("complete_long_flag_names: {ctoken}");
506 trace!("flags: {}", flags.keys().join(", "));
507 flags
508 .values()
509 .filter(|f| !f.hide)
510 .flat_map(|f| {
511 let mut flags = f
512 .long
513 .iter()
514 .filter(|long| !f.hidden_aliases.contains(long))
515 .map(|l| (format!("--{l}"), f.help.clone().unwrap_or_default()))
516 .collect::<Vec<_>>();
517 if let Some(negate) = &f.negate {
518 flags.push((negate.clone(), String::new()))
519 }
520 flags
521 })
522 .unique_by(|(f, _)| f.to_string())
523 .filter(|(f, _)| f.starts_with(ctoken))
524 .sorted()
526 .collect()
527 }
528
529 fn complete_short_flag_names(
530 &self,
531 flags: &BTreeMap<String, Arc<SpecFlag>>,
532 ctoken: &str,
533 ) -> Vec<(String, String)> {
534 debug!("complete_short_flag_names: {ctoken}");
535 let cur = ctoken.chars().nth(1);
536 flags
537 .values()
538 .flat_map(|f| f.short.iter().copied())
539 .unique()
540 .filter(|short| cur.is_none() || cur == Some(*short))
541 .filter_map(|short| {
542 let owner = flags.get(&format!("-{short}"))?;
543 if owner.hide || owner.hidden_short_aliases.contains(&short) {
544 return None;
545 }
546 Some((short, owner.help.clone().unwrap_or_default()))
547 })
548 .map(|(short, help)| (format!("-{short}"), help))
549 .sorted()
550 .collect()
551 }
552
553 fn attached_long_value<'f, 't>(
558 flags: &'f BTreeMap<String, Arc<SpecFlag>>,
559 token: &'t str,
560 ) -> Option<(&'f SpecFlag, &'t str, &'t str)> {
561 let (form, prefix) = token.split_once('=')?;
562 let long = form.strip_prefix("--")?;
563 flags
564 .values()
565 .find(|flag| flag.arg.is_some() && flag.long.iter().any(|candidate| candidate == long))
566 .map(|flag| (flag.as_ref(), form, prefix))
567 }
568
569 fn attach_long_value_candidates(candidates: &mut [(String, String)], form: &str) {
570 for (candidate, _) in candidates {
571 *candidate = format!("{form}={candidate}");
572 }
573 }
574
575 fn complete_builtin(
583 &self,
584 cx: &Ctx<'_>,
585 type_: &str,
586 ctoken: &str,
587 ) -> (Vec<(String, String)>, bool) {
588 if let Some(encoded) = type_
589 .strip_prefix("path:")
590 .or_else(|| type_.strip_prefix("file:"))
591 {
592 let extensions = encoded
593 .split(',')
594 .map(|extension| {
595 extension
596 .trim()
597 .trim_start_matches('.')
598 .to_ascii_lowercase()
599 })
600 .filter(|extension| !extension.is_empty())
601 .collect::<Vec<_>>();
602 if !extensions.is_empty() {
603 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
604 let paths = self.complete_path(&cwd, ctoken, |path| {
605 path.is_dir()
606 || path
607 .file_name()
608 .and_then(|name| name.to_str())
609 .is_some_and(|name| {
610 let name = name.to_ascii_lowercase();
611 extensions
612 .iter()
613 .any(|wanted| name.ends_with(&format!(".{wanted}")))
614 })
615 });
616 return (
617 paths
618 .into_iter()
619 .map(|value| (value, String::new()))
620 .collect(),
621 true,
622 );
623 }
624 }
625 match type_ {
628 "config_keys" => return (self.complete_config_keys(cx.spec, ctoken), true),
629 "config_values" => return self.complete_config_values(cx, ctoken),
630 "executable" => {
631 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
632 return (
633 self.complete_path(&cwd, ctoken, |path| path.is_dir() || is_executable(path))
634 .into_iter()
635 .map(|value| (value, String::new()))
636 .collect(),
637 true,
638 );
639 }
640 "command" => return (self.complete_commands(ctoken), true),
641 "username" => return (complete_usernames(ctoken), true),
642 "hostname" => return (complete_hostnames(ctoken), true),
643 "none" | "url" | "email" => return (vec![], true),
644 "unknown" => {}
646 "command_args" => {
647 let command_was_bound = cx.parsed.next_arg.as_ref().is_some_and(|next| {
648 cx.parsed
653 .args
654 .keys()
655 .any(|bound| bound.as_ref() == next.as_ref())
656 });
657 if cx.after_restart_token || !command_was_bound {
658 return (self.complete_commands(ctoken), true);
659 }
660 }
661 _ => {}
662 }
663 let names = match (type_, env::current_dir()) {
664 ("path" | "file", Ok(cwd)) => self.complete_path(&cwd, ctoken, |_| true),
665 ("dir", Ok(cwd)) => self.complete_path(&cwd, ctoken, |p| p.is_dir()),
666 _ => vec![],
668 };
669 (
670 names.into_iter().map(|n| (n, String::new())).collect(),
671 false,
672 )
673 }
674
675 fn complete_config_keys(&self, spec: &Spec, ctoken: &str) -> Vec<(String, String)> {
684 spec.config
685 .props
686 .iter()
687 .filter(|(_, prop)| !prop.hide)
688 .filter(|(key, _)| key.starts_with(ctoken))
689 .map(|(key, prop)| {
690 let help = one_line(prop.help.as_deref());
691 let help = help.as_str();
692 let description = match &prop.deprecated {
695 Some(_) if help.is_empty() => "deprecated".to_string(),
696 Some(_) => format!("deprecated — {help}"),
697 None => help.to_string(),
698 };
699 (key.clone(), description)
700 })
701 .collect()
702 }
703
704 fn complete_config_values(&self, cx: &Ctx<'_>, ctoken: &str) -> (Vec<(String, String)>, bool) {
710 let Some(prop) = self.config_key_before_cursor(cx) else {
711 return (vec![], false);
714 };
715 if !prop.choices.is_empty() {
716 return (
717 prop.choices
718 .iter()
719 .map(|choice| (choice.value.display(), one_line(choice.help.as_deref())))
720 .filter(|(value, _)| value.starts_with(ctoken))
721 .collect(),
722 true,
726 );
727 }
728 if holds_a_boolean(&prop.value_type.clone().unwrap_or_default()) {
732 let declared = prop.value_type.clone().unwrap_or_default();
733 return (
734 ["false", "true"]
735 .into_iter()
736 .filter(|value| value.starts_with(ctoken))
737 .map(|value| (value.to_string(), String::new()))
738 .collect(),
739 !accepts_unenumerable_values(&declared),
743 );
744 }
745 (vec![], false)
748 }
749
750 fn config_key_before_cursor<'a>(&self, cx: &Ctx<'a>) -> Option<&'a SpecConfigProp> {
760 cx.parsed
767 .args
768 .iter()
769 .filter(|(arg, _)| self.completer_type(cx, arg) == Some("config_keys"))
770 .filter_map(|(_, value)| match value {
771 ParseValue::String(word) => Some(word.as_str()),
772 ParseValue::MultiString(words) => words.last().map(String::as_str),
773 ParseValue::Bool(_) | ParseValue::MultiBool(_) => None,
774 })
775 .next_back()
792 .and_then(|word| resolve_config_key(&cx.spec.config, word))
793 }
794
795 fn completer_type<'b>(&self, cx: &'b Ctx<'_>, arg: &'b SpecArg) -> Option<&'b str> {
797 cx.spec
798 .completer(&cx.parsed.cmd, arg)
799 .and_then(|complete| complete.type_.as_deref())
800 }
801
802 fn complete_positional(
813 &self,
814 cx: &Ctx<'_>,
815 cmd: &SpecCommand,
816 arg: &SpecArg,
817 ctoken: &str,
818 double_dash_seen: bool,
819 ) -> usage::miette::Result<(Vec<(String, String)>, bool)> {
820 if arg.double_dash == SpecDoubleDashChoices::Required && !double_dash_seen {
821 let separator = ctoken.is_empty().then(|| ("--".to_string(), String::new()));
823 return Ok((separator.into_iter().collect(), true));
824 }
825 let (found, closed) = self.complete_arg(cx, cmd, arg, ctoken)?;
826 Ok((found, closed || arg.choices.is_some()))
827 }
828
829 fn complete_arg(
830 &self,
831 cx: &Ctx<'_>,
832 cmd: &SpecCommand,
833 arg: &SpecArg,
834 ctoken: &str,
835 ) -> usage::miette::Result<(Vec<(String, String)>, bool)> {
836 static EMPTY_COMPL: LazyLock<SpecComplete> = LazyLock::new(SpecComplete::default);
837
838 trace!("complete_arg: {arg} {ctoken}");
839 let complete = cx.spec.completer(cmd, arg).unwrap_or(&EMPTY_COMPL);
840 if let Some(type_) = complete.type_.as_deref() {
841 let (builtin, closed) = self.complete_builtin(cx, type_, ctoken);
845 if !builtin.is_empty() || closed {
846 return Ok((builtin, closed));
847 }
848 }
849
850 if let Some(choices) = &arg.choices {
851 let values = choices.resolved_values(None).unwrap_or_else(|err| {
856 trace!("choices run= failed: {err}");
857 choices.values()
858 });
859 return Ok((
860 values
861 .into_iter()
862 .filter(|c| c.starts_with(ctoken))
863 .map(|value| {
864 let help = choices
869 .details
870 .iter()
871 .find(|detail| detail.value == value)
872 .and_then(|detail| detail.help.clone())
873 .unwrap_or_default();
874 (value, help)
875 })
876 .collect(),
877 true,
878 ));
879 }
880 if let Some(delegate) = &complete.delegate {
881 let found = self
882 .complete_delegated(cx, arg, delegate, ctoken)
883 .into_iter()
884 .filter_map(|candidate| whole_word_candidate(candidate, ctoken))
885 .collect();
886 return Ok((found, false));
889 }
890 if let Some(run) = &complete.run {
891 let run = render_completer_run(run, cx.tera).into_diagnostic()?;
892 trace!("run: {run}");
893 let stdout = sh(&run)?;
894 static DESCRIPTION_SEPARATOR: LazyLock<Regex> =
896 LazyLock::new(|| Regex::new(r"[^\\]:").unwrap());
897 let re = &*DESCRIPTION_SEPARATOR;
898 return Ok((
899 stdout
900 .lines()
901 .map(|l| {
902 if complete.descriptions {
903 match re.find(l).map(|m| l.split_at(m.end() - 1)) {
904 Some((l, d)) if d.len() <= 1 => {
905 (l.trim().replace("\\:", ":"), String::new())
906 }
907 Some((l, d)) => (
908 l.trim().replace("\\:", ":"),
909 d[1..].trim().replace("\\:", ":"),
910 ),
911 None => (l.trim().replace("\\:", ":"), String::new()),
912 }
913 } else {
914 (l.trim().to_string(), String::new())
915 }
916 })
917 .filter(|(name, _)| name.starts_with(ctoken))
918 .collect(),
919 false,
922 ));
923 }
924
925 if complete.type_.is_none() {
931 let (builtin, closed) = self.complete_builtin(cx, &arg.name.to_lowercase(), ctoken);
932 if !builtin.is_empty() || closed {
933 return Ok((builtin, closed));
934 }
935 }
936
937 Ok((vec![], false))
938 }
939
940 fn complete_delegated(
942 &self,
943 cx: &Ctx<'_>,
944 arg: &SpecArg,
945 delegate: &str,
946 ctoken: &str,
947 ) -> Vec<(String, String)> {
948 let Ok(mut words) = usage::shell_words::split(delegate) else {
950 return vec![];
951 };
952 words.extend(delegated_words(cx.parsed, arg));
953 words.push(ctoken.to_string());
954 trace!("delegate: {}", words.iter().join(" "));
955 complete_delegate::complete(&self.shell, &words)
956 }
957
958 fn delegated_arg_at_flag<'a>(&self, cx: &Ctx<'a>, ctoken: &str) -> Option<&'a SpecArg> {
966 if !ctoken.starts_with('-') || !cx.parsed.flag_awaiting_value.is_empty() {
967 return None;
968 }
969 let unknown_flags = cx
970 .parsed
971 .cmds
972 .iter()
973 .rev()
974 .find_map(|cmd| cmd.unknown_flags)
975 .or(cx.spec.unknown_flags)
976 .unwrap_or_default();
977 if unknown_flags != UnknownFlags::Value {
978 return None;
979 }
980 let arg = cx.parsed.next_arg.as_deref()?;
981 let complete = cx.spec.completer(&cx.parsed.cmd, arg)?;
982 (complete.delegate.is_some() && !delegated_words(cx.parsed, arg).is_empty()).then_some(arg)
983 }
984
985 fn complete_path(
986 &self,
987 base: &Path,
988 ctoken: &str,
989 filter: impl Fn(&Path) -> bool,
990 ) -> Vec<String> {
991 trace!("complete_path: {ctoken}");
992 let separator = rendered_separator(ctoken);
993 let path = PathBuf::from(ctoken);
994 let exact = if path.is_absolute() {
995 path.clone()
996 } else {
997 base.join(&path)
998 };
999 let trailing_separator = (ctoken.ends_with(std::path::MAIN_SEPARATOR)
1003 || (cfg!(windows) && ctoken.ends_with('/')))
1004 && exact.is_dir();
1005 let (parent, prefix) = if trailing_separator {
1006 (path.as_path(), "")
1007 } else {
1008 (
1009 path.parent().unwrap_or_else(|| Path::new("")),
1010 path.file_name()
1011 .unwrap_or_default()
1012 .to_str()
1013 .unwrap_or_default(),
1014 )
1015 };
1016
1017 resolve_path_dirs(base, parent)
1018 .into_iter()
1019 .flat_map(|dir| std::fs::read_dir(dir).ok().into_iter().flatten())
1020 .filter_map(Result::ok)
1021 .filter(|de| {
1022 let name = de.file_name();
1023 let name = name.to_string_lossy();
1024 !name.starts_with('.') && name.starts_with(prefix)
1025 })
1026 .filter(|de| filter(&de.path()))
1027 .map(|de| {
1028 let p = de.path();
1029 let is_dir = de
1030 .file_type()
1031 .map(|ft| ft.is_dir())
1032 .unwrap_or_else(|_| p.is_dir());
1033 let mut s = p
1034 .strip_prefix(base)
1035 .unwrap_or(&p)
1036 .to_string_lossy()
1037 .replace(std::path::MAIN_SEPARATOR, separator);
1038 if is_dir {
1039 s.push_str(separator);
1040 }
1041 s
1042 })
1043 .sorted()
1044 .collect()
1045 }
1046
1047 fn complete_commands(&self, ctoken: &str) -> Vec<(String, String)> {
1048 if ctoken.contains(std::path::MAIN_SEPARATOR) || (cfg!(windows) && ctoken.contains('/')) {
1049 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1050 return self
1051 .complete_path(&cwd, ctoken, |path| path.is_dir() || is_executable(path))
1052 .into_iter()
1053 .map(|value| (value, String::new()))
1054 .collect();
1055 }
1056
1057 let mut found = BTreeSet::new();
1058 if let Some(path) = env::var_os("PATH") {
1059 for dir in env::split_paths(&path) {
1060 for entry in std::fs::read_dir(dir).ok().into_iter().flatten().flatten() {
1061 let path = entry.path();
1062 let name = entry.file_name().to_string_lossy().into_owned();
1063 if command_name_starts_with(&name, ctoken, cfg!(windows))
1064 && is_executable(&path)
1065 {
1066 found.insert(name);
1067 }
1068 }
1069 }
1070 }
1071 found
1072 .into_iter()
1073 .map(|value| (value, String::new()))
1074 .collect()
1075 }
1076}
1077
1078impl usage_rs::Run for CompleteWord {
1079 type Output = usage::miette::Result<()>;
1080
1081 fn run(self) -> Self::Output {
1082 let spec = generate::file_or_spec(&self.file, &self.spec)?;
1083 let choices = self.complete_word(&spec)?;
1084 let shell = self.shell.as_ref();
1085 let any_descriptions = choices.iter().any(|(_, d)| !d.is_empty());
1086 for (c, description) in choices {
1087 let description = one_line(Some(&description));
1090 match shell {
1091 "bash" => println!("{c}"),
1092 "fish" | "nu" | "powershell" => {
1093 if any_descriptions {
1094 println!("{c}\t{description}")
1095 } else {
1096 println!("{c}")
1097 }
1098 }
1099 "zsh" => {
1100 let insert = zsh_shell_quote(&c);
1113 println!("{c}\t{description}\t{insert}")
1114 }
1115 _ => {
1116 usage::miette::bail!("unsupported shell: {}", shell);
1117 }
1118 }
1119 }
1120
1121 Ok(())
1122 }
1123}
1124
1125fn rendered_separator(ctoken: &str) -> &'static str {
1139 if cfg!(windows) && ctoken.contains('\\') && !ctoken.contains('/') {
1140 "\\"
1141 } else {
1142 "/"
1143 }
1144}
1145
1146fn resolve_path_dirs(base: &Path, path: &Path) -> Vec<PathBuf> {
1152 let exact = if path.is_absolute() {
1153 path.to_path_buf()
1154 } else {
1155 base.join(path)
1156 };
1157 if exact.is_dir() {
1158 return vec![exact];
1159 }
1160
1161 let Some(prefix) = path.file_name().and_then(|name| name.to_str()) else {
1162 return Vec::new();
1163 };
1164 let parent = path.parent().unwrap_or_else(|| Path::new(""));
1165 resolve_path_dirs(base, parent)
1166 .into_iter()
1167 .flat_map(|dir| std::fs::read_dir(dir).ok().into_iter().flatten())
1168 .filter_map(Result::ok)
1169 .filter(|entry| {
1170 entry.file_type().is_ok_and(|kind| kind.is_dir())
1171 && entry
1172 .file_name()
1173 .to_str()
1174 .is_some_and(|name| !name.starts_with('.') && name.starts_with(prefix))
1175 })
1176 .map(|entry| entry.path())
1177 .collect()
1178}
1179
1180fn flag_value_owner<'a>(parsed: &'a ParseOutput, flag: &Arc<SpecFlag>) -> &'a SpecCommand {
1191 let Some(boundary) = parsed.cmds.iter().position(|cmd| cmd.mounted) else {
1192 return &parsed.cmd;
1193 };
1194 if parsed.is_mounted_flag(flag) {
1195 return &parsed.cmd;
1196 }
1197 parsed.cmds[..boundary].last().unwrap_or(&parsed.cmd)
1198}
1199
1200fn complete_usernames(prefix: &str) -> Vec<(String, String)> {
1201 let mut found = BTreeSet::new();
1202 for key in ["USER", "USERNAME"] {
1203 if let Ok(value) = env::var(key)
1204 && value.starts_with(prefix)
1205 {
1206 found.insert(value);
1207 }
1208 }
1209 if let Ok(passwd) = std::fs::read_to_string("/etc/passwd") {
1210 for line in passwd.lines() {
1211 if let Some(name) = line
1212 .split(':')
1213 .next()
1214 .filter(|name| name.starts_with(prefix))
1215 {
1216 found.insert(name.to_string());
1217 }
1218 }
1219 }
1220 found
1221 .into_iter()
1222 .map(|value| (value, String::new()))
1223 .collect()
1224}
1225
1226fn complete_hostnames(prefix: &str) -> Vec<(String, String)> {
1227 let mut found = BTreeSet::new();
1228 for key in ["HOSTNAME", "COMPUTERNAME"] {
1229 if let Ok(value) = env::var(key)
1230 && value.starts_with(prefix)
1231 {
1232 found.insert(value);
1233 }
1234 }
1235 if let Ok(hosts) = std::fs::read_to_string("/etc/hosts") {
1236 for line in hosts.lines() {
1237 let line = line.split('#').next().unwrap_or_default();
1238 for name in line.split_whitespace().skip(1) {
1239 if name.starts_with(prefix) {
1240 found.insert(name.to_string());
1241 }
1242 }
1243 }
1244 }
1245 found
1246 .into_iter()
1247 .map(|value| (value, String::new()))
1248 .collect()
1249}
1250
1251fn command_name_starts_with(name: &str, prefix: &str, case_insensitive: bool) -> bool {
1252 if case_insensitive {
1253 name.get(..prefix.len())
1254 .is_some_and(|start| start.eq_ignore_ascii_case(prefix))
1255 } else {
1256 name.starts_with(prefix)
1257 }
1258}
1259
1260#[cfg(unix)]
1261fn is_executable(path: &Path) -> bool {
1262 use std::os::unix::fs::PermissionsExt;
1263 path.metadata()
1264 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
1265}
1266
1267#[cfg(windows)]
1268fn is_executable(path: &Path) -> bool {
1269 let extensions = env::var_os("PATHEXT").unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".into());
1270 path.is_file()
1271 && path.extension().is_some_and(|extension| {
1272 let extension = format!(".{}", extension.to_string_lossy());
1273 extensions
1274 .to_string_lossy()
1275 .split(';')
1276 .any(|candidate| candidate.eq_ignore_ascii_case(&extension))
1277 })
1278}
1279
1280struct Ctx<'a> {
1290 tera: &'a tera::Context,
1291 spec: &'a Spec,
1292 parsed: &'a ParseOutput,
1293 after_restart_token: bool,
1294}
1295
1296fn whole_word_candidate(
1305 (name, description): (String, String),
1306 ctoken: &str,
1307) -> Option<(String, String)> {
1308 if name.starts_with(ctoken) {
1309 return Some((name, description));
1310 }
1311 let (head, _) = ctoken.rsplit_once('=')?;
1312 let name = format!("{head}={name}");
1313 name.starts_with(ctoken).then_some((name, description))
1314}
1315
1316fn delegated_words(parsed: &ParseOutput, arg: &SpecArg) -> Vec<String> {
1323 let mut words = vec![];
1324 for token in &parsed.tokens {
1325 for role in &token.roles {
1326 match role {
1327 TokenRole::Restart | TokenRole::ClauseSeparator { .. } => words.clear(),
1328 TokenRole::Arg { arg: bound, .. }
1329 | TokenRole::UnknownFlag {
1330 bound_as: Some(bound),
1331 } if bound.name == arg.name => words.push(token.word.clone()),
1332 _ => {}
1333 }
1334 }
1335 }
1336 words
1337}
1338
1339fn first_ordinary_arg(cmd: &SpecCommand) -> Option<&SpecArg> {
1341 cmd.args.iter().find(|arg| arg.sigil.is_none())
1342}
1343
1344fn first_active_arg(cmd: &SpecCommand) -> Option<&SpecArg> {
1346 cmd.clause
1347 .as_ref()
1348 .and_then(|clause| clause.args.first())
1349 .or_else(|| first_ordinary_arg(cmd))
1350}
1351
1352fn one_line(text: Option<&str>) -> String {
1358 text.unwrap_or_default()
1359 .lines()
1360 .next()
1361 .unwrap_or_default()
1362 .trim()
1363 .to_string()
1364}
1365
1366fn resolve_config_key<'a>(
1384 config: &'a usage::spec::config::SpecConfig,
1385 key: &str,
1386) -> Option<&'a SpecConfigProp> {
1387 let (_, mut prop) = config
1388 .props
1389 .iter()
1390 .find(|(name, prop)| name.as_str() == key || prop.aliases.iter().any(|a| a == key))?;
1391 for _ in 0..config.props.len() {
1392 let Some(target) = &prop.renamed_to else {
1393 return Some(prop);
1394 };
1395 let Some(next) = config.props.get(target) else {
1399 return Some(prop);
1400 };
1401 prop = next;
1402 }
1403 Some(prop)
1404}
1405
1406fn accepts_unenumerable_values(ty: &SpecConfigType) -> bool {
1412 match ty {
1413 SpecConfigType::Base(Base::Bool) => false,
1414 SpecConfigType::Option(inner) => accepts_unenumerable_values(inner),
1415 SpecConfigType::Union(members) => members.iter().any(accepts_unenumerable_values),
1416 _ => true,
1419 }
1420}
1421
1422fn holds_a_boolean(ty: &SpecConfigType) -> bool {
1428 match ty {
1429 SpecConfigType::Base(Base::Bool) => true,
1430 SpecConfigType::Option(inner) => holds_a_boolean(inner),
1431 SpecConfigType::Union(members) => members.iter().any(holds_a_boolean),
1432 _ => false,
1435 }
1436}
1437
1438fn zsh_shell_quote(s: &str) -> String {
1439 fn safe(c: char) -> bool {
1440 matches!(c,
1441 'a'..='z' | 'A'..='Z' | '0'..='9'
1442 | '_' | '-' | '.' | '/' | ':' | '@' | '+' | '=' | '%' | ','
1443 )
1444 }
1445 if !s.is_empty() && s.chars().all(safe) {
1446 return s.to_string();
1447 }
1448 let escaped = s.replace('\'', "'\\''");
1450 format!("'{escaped}'")
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use super::{command_name_starts_with, render_completer_run, rendered_separator};
1456
1457 #[test]
1458 fn a_slash_in_the_token_is_kept() {
1459 assert_eq!(rendered_separator("target/de"), "/");
1461 assert_eq!(rendered_separator("/abs/path"), "/");
1462 }
1463
1464 #[test]
1465 fn a_token_with_no_separator_yet_gets_a_slash() {
1466 assert_eq!(rendered_separator(""), "/");
1468 assert_eq!(rendered_separator("target"), "/");
1469 }
1470
1471 #[test]
1472 fn a_backslash_is_a_separator_only_on_windows() {
1473 let expected = if cfg!(windows) { "\\" } else { "/" };
1477 assert_eq!(rendered_separator(r"target\de"), expected);
1478 assert_eq!(rendered_separator(r"C:\Users\me"), expected);
1479 }
1480
1481 #[test]
1482 fn a_mixed_token_settles_on_the_slash() {
1483 assert_eq!(rendered_separator(r"target\de/inc"), "/");
1486 }
1487
1488 #[test]
1489 fn windows_command_prefixes_ignore_ascii_case() {
1490 assert!(command_name_starts_with("Cargo.EXE", "car", true));
1491 assert!(!command_name_starts_with("Cargo.EXE", "car", false));
1492 }
1493
1494 #[test]
1495 fn completer_templates_can_shell_quote_typed_words() {
1496 let mut ctx = tera::Context::new();
1497 ctx.insert("word", "a'b; echo injected");
1498 let rendered = render_completer_run("printf '%s\\n' {{ word | shell_quote }}", &ctx)
1499 .expect("the filter should render");
1500 assert_eq!(rendered, "printf '%s\\n' 'a'\\''b; echo injected'");
1501 }
1502
1503 #[cfg(unix)]
1504 #[test]
1505 fn shell_quoted_template_values_remain_one_literal_argument() {
1506 let mut ctx = tera::Context::new();
1507 ctx.insert("word", "$(printf injected); a'b");
1508 let rendered = render_completer_run("printf '%s\\n' {{ word | shell_quote }}", &ctx)
1509 .expect("the filter should render");
1510 let stdout = usage::sh::sh(&rendered).expect("the rendered command should run");
1511 assert_eq!(stdout, "$(printf injected); a'b\n");
1512 }
1513
1514 #[test]
1515 fn shell_quote_rejects_non_strings() {
1516 let mut ctx = tera::Context::new();
1517 ctx.insert("word", &42);
1518 let err = render_completer_run("{{ word | shell_quote }}", &ctx).unwrap_err();
1519 assert!(
1520 err.to_string().contains("shell_quote expects a string"),
1521 "{err}"
1522 );
1523 }
1524
1525 #[cfg(unix)]
1526 #[test]
1527 fn shell_join_preserves_the_argv_vector_when_forwarded_as_one_argument() {
1528 let expected = ["ex", "two words", "a'b"];
1529 let mut ctx = tera::Context::new();
1530 ctx.insert("words", &expected);
1531 let rendered = render_completer_run(
1532 "printf '%s\\n' {{ words | shell_join | shell_quote }}",
1533 &ctx,
1534 )
1535 .expect("the filters should render");
1536 let stdout = usage::sh::sh(&rendered).expect("the rendered command should run");
1537 let reparsed = shell_words::split(stdout.trim()).expect("the joined value should parse");
1538 assert_eq!(reparsed, expected);
1539 }
1540}