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 if spec.default_subcommand_flags
201 && parsed.cmds.len() == 1
202 && let Some(default) = spec
203 .default_subcommand
204 .as_deref()
205 .and_then(|name| spec.cmd.find_subcommand(name))
206 {
207 for flag in &default.flags {
208 let flag = Arc::new(flag.clone());
209 for key in flag
210 .long
211 .iter()
212 .map(|name| format!("--{name}"))
213 .chain(flag.short.iter().map(|name| format!("-{name}")))
214 .chain(flag.negate.iter().cloned())
215 {
216 flags.entry(key).or_insert_with(|| Arc::clone(&flag));
217 }
218 }
219 }
220 let restart_seen = parsed.tokens.iter().any(|token| {
223 token
224 .roles
225 .iter()
226 .any(|role| matches!(role, usage::parse::TokenRole::Restart))
227 });
228 let automatic_trailing_seen = parsed
229 .tokens
230 .iter()
231 .rev()
232 .take_while(|token| {
233 !token.roles.iter().any(|role| {
234 matches!(
235 role,
236 usage::parse::TokenRole::Restart
237 | usage::parse::TokenRole::ClauseSeparator { .. }
238 )
239 })
240 })
241 .flat_map(|token| &token.roles)
242 .any(|role| match role {
243 usage::parse::TokenRole::Arg { arg, .. }
244 | usage::parse::TokenRole::Sigil { arg, .. } => {
245 arg.double_dash == usage::SpecDoubleDashChoices::Automatic
246 }
247 _ => false,
248 });
249 let flags_possible = !parsed.double_dash_seen && !automatic_trailing_seen;
250 let sigil_arg = (flags_possible
251 && !restart_seen
252 && !after_restart_token
253 && parsed.flag_awaiting_value.is_empty())
254 .then(|| {
255 parsed
256 .cmds
257 .iter()
258 .flat_map(|cmd| cmd.args.iter().map(move |arg| (cmd, arg)))
259 .filter_map(|(cmd, arg)| {
260 let sigil = arg.sigil.as_deref()?;
261 ctoken
262 .strip_prefix(sigil)
263 .map(|prefix| (cmd, arg, sigil, prefix))
264 })
265 .max_by_key(|(_, _, sigil, _)| sigil.len())
266 })
267 .flatten();
268 let attached_long_value = flags_possible
269 .then(|| Self::attached_long_value(&flags, &ctoken))
270 .flatten();
271 let mut used_file_fallback = false;
272 let mut choices = if flags_possible && ctoken == "-" {
273 let shorts = self.complete_short_flag_names(&flags, "");
274 let longs = self.complete_long_flag_names(&flags, "");
275 shorts.into_iter().chain(longs).collect::<Vec<_>>()
276 } else if flags_possible && ctoken.starts_with("--") {
277 if let Some((flag, form, prefix)) = attached_long_value {
278 let arg = flag.arg.as_ref().unwrap();
279 let mut attached_ctx = ctx.clone();
282 let mut attached_words = self.words.clone();
283 if let Some(current) = attached_words.get_mut(cword) {
284 *current = prefix.to_string();
285 }
286 attached_ctx.insert("words", &attached_words);
287 let attached_cx = Ctx {
288 tera: &attached_ctx,
289 spec: cx.spec,
290 parsed: cx.parsed,
291 after_restart_token: cx.after_restart_token,
292 };
293 let (mut found, closed) =
294 self.complete_arg(&attached_cx, &parsed.cmd, arg, prefix)?;
295 has_explicit_choices = closed || arg.choices.is_some();
296 if found.is_empty() && !has_explicit_choices {
297 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
298 found = self
299 .complete_path(&cwd, prefix, |_| true)
300 .into_iter()
301 .map(|name| (name, String::new()))
302 .collect();
303 used_file_fallback = true;
304 }
305 Self::attach_long_value_candidates(&mut found, form);
306 found
307 } else {
308 self.complete_long_flag_names(&flags, &ctoken)
309 }
310 } else if flags_possible && ctoken.starts_with('-') {
311 self.complete_short_flag_names(&flags, &ctoken)
312 } else if after_restart_token {
313 let mut choices = vec![];
317 if let Some(arg) = first_active_arg(&parsed.cmd) {
318 let (found, constrained) = self.complete_positional(
319 &cx,
320 &parsed.cmd,
321 arg,
322 &ctoken,
323 parsed.double_dash_seen,
324 )?;
325 has_explicit_choices = constrained;
326 choices.extend(found);
327 }
328 choices
329 } else if let Some(flag) = parsed.flag_awaiting_value.first() {
330 let arg = flag.arg.as_ref().unwrap();
331 let owner = flag_value_owner(&parsed, flag);
332 let (found, closed) = self.complete_arg(&cx, owner, arg, &ctoken)?;
333 has_explicit_choices = closed || arg.choices.is_some();
334 found
335 } else if let Some((owner, arg, sigil, prefix)) = sigil_arg {
336 let mut sigil_ctx = ctx.clone();
341 let mut sigil_words = self.words.clone();
342 if let Some(current) = sigil_words.get_mut(cword) {
343 *current = prefix.to_string();
344 }
345 sigil_ctx.insert("words", &sigil_words);
346 let sigil_cx = Ctx {
347 tera: &sigil_ctx,
348 spec: cx.spec,
349 parsed: cx.parsed,
350 after_restart_token: cx.after_restart_token,
351 };
352 let (mut found, closed) = self.complete_arg(&sigil_cx, owner, arg, prefix)?;
353 for (candidate, _) in &mut found {
354 candidate.insert_str(0, sigil);
355 }
356 has_explicit_choices = closed || arg.choices.is_some() || found.is_empty();
357 found
358 } else {
359 let mut choices = vec![];
360 if let Some(arg) = parsed.next_arg.as_deref() {
361 let (found, constrained) = self.complete_positional(
362 &cx,
363 &parsed.cmd,
364 arg,
365 &ctoken,
366 parsed.double_dash_seen,
367 )?;
368 has_explicit_choices = constrained;
369 choices.extend(found);
370 }
371 if !parsed.cmd.subcommands.is_empty() {
372 choices.extend(self.complete_subcommands(&parsed.cmd, &ctoken));
373 }
374 if parsed.cmd.name == spec.cmd.name
376 && let Some(default_name) = &spec.default_subcommand
377 && let Some(default_cmd) = spec.cmd.find_subcommand(default_name)
378 {
379 if let Some(arg) = first_active_arg(default_cmd) {
390 let (found, _) = self.complete_positional(
391 &cx,
392 default_cmd,
393 arg,
394 &ctoken,
395 parsed.double_dash_seen,
396 )?;
397 choices.extend(found);
398 }
399 }
400 choices
401 };
402 if flags_possible
403 && attached_long_value.is_none()
404 && sigil_arg.is_none()
405 && let Some(arg) = self.delegated_arg_at_flag(&cx, &ctoken)
406 {
407 let (found, _) = self.complete_arg(&cx, &parsed.cmd, arg, &ctoken)?;
408 for candidate in found {
409 if !choices.iter().any(|(name, _)| *name == candidate.0) {
410 choices.push(candidate);
411 }
412 }
413 }
414 let looks_like_a_flag = flags_possible && ctoken.starts_with('-');
418 let files = used_file_fallback
419 || (choices.is_empty() && !looks_like_a_flag && !has_explicit_choices);
420 if files && choices.is_empty() {
421 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
422 let files = self.complete_path(&cwd, &ctoken, |_| true);
423 choices = files.into_iter().map(|n| (n, String::new())).collect();
424 }
425 trace!("choices: {}", choices.iter().map(|(c, _)| c).join(", "));
426 Ok(CandidateAnswer {
427 candidates: choices,
428 files,
429 })
430 }
431
432 fn complete_subcommands(&self, cmd: &SpecCommand, ctoken: &str) -> Vec<(String, String)> {
433 trace!("complete_subcommands: {ctoken}");
434 let mut choices = vec![];
435 for subcommand in cmd.subcommands.values() {
436 if subcommand.hide {
437 continue;
438 }
439 choices.push((
440 subcommand.name.clone(),
441 subcommand.help.clone().unwrap_or_default(),
442 ));
443 for alias in &subcommand.aliases {
444 choices.push((alias.clone(), subcommand.help.clone().unwrap_or_default()));
445 }
446 }
447 choices
448 .into_iter()
449 .filter(|(c, _)| c.starts_with(ctoken))
450 .sorted()
451 .collect()
452 }
453
454 fn complete_long_flag_names(
455 &self,
456 flags: &BTreeMap<String, Arc<SpecFlag>>,
457 ctoken: &str,
458 ) -> Vec<(String, String)> {
459 debug!("complete_long_flag_names: {ctoken}");
460 trace!("flags: {}", flags.keys().join(", "));
461 flags
462 .values()
463 .filter(|f| !f.hide)
464 .flat_map(|f| {
465 let mut flags = f
466 .long
467 .iter()
468 .filter(|long| !f.hidden_aliases.contains(long))
469 .map(|l| (format!("--{l}"), f.help.clone().unwrap_or_default()))
470 .collect::<Vec<_>>();
471 if let Some(negate) = &f.negate {
472 flags.push((negate.clone(), String::new()))
473 }
474 flags
475 })
476 .unique_by(|(f, _)| f.to_string())
477 .filter(|(f, _)| f.starts_with(ctoken))
478 .sorted()
480 .collect()
481 }
482
483 fn complete_short_flag_names(
484 &self,
485 flags: &BTreeMap<String, Arc<SpecFlag>>,
486 ctoken: &str,
487 ) -> Vec<(String, String)> {
488 debug!("complete_short_flag_names: {ctoken}");
489 let cur = ctoken.chars().nth(1);
490 flags
491 .values()
492 .flat_map(|f| f.short.iter().copied())
493 .unique()
494 .filter(|short| cur.is_none() || cur == Some(*short))
495 .filter_map(|short| {
496 let owner = flags.get(&format!("-{short}"))?;
497 if owner.hide || owner.hidden_short_aliases.contains(&short) {
498 return None;
499 }
500 Some((short, owner.help.clone().unwrap_or_default()))
501 })
502 .map(|(short, help)| (format!("-{short}"), help))
503 .sorted()
504 .collect()
505 }
506
507 fn attached_long_value<'f, 't>(
512 flags: &'f BTreeMap<String, Arc<SpecFlag>>,
513 token: &'t str,
514 ) -> Option<(&'f SpecFlag, &'t str, &'t str)> {
515 let (form, prefix) = token.split_once('=')?;
516 let long = form.strip_prefix("--")?;
517 flags
518 .values()
519 .find(|flag| flag.arg.is_some() && flag.long.iter().any(|candidate| candidate == long))
520 .map(|flag| (flag.as_ref(), form, prefix))
521 }
522
523 fn attach_long_value_candidates(candidates: &mut [(String, String)], form: &str) {
524 for (candidate, _) in candidates {
525 *candidate = format!("{form}={candidate}");
526 }
527 }
528
529 fn complete_builtin(
537 &self,
538 cx: &Ctx<'_>,
539 type_: &str,
540 ctoken: &str,
541 ) -> (Vec<(String, String)>, bool) {
542 if let Some(encoded) = type_
543 .strip_prefix("path:")
544 .or_else(|| type_.strip_prefix("file:"))
545 {
546 let extensions = encoded
547 .split(',')
548 .map(|extension| {
549 extension
550 .trim()
551 .trim_start_matches('.')
552 .to_ascii_lowercase()
553 })
554 .filter(|extension| !extension.is_empty())
555 .collect::<Vec<_>>();
556 if !extensions.is_empty() {
557 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
558 let paths = self.complete_path(&cwd, ctoken, |path| {
559 path.is_dir()
560 || path
561 .file_name()
562 .and_then(|name| name.to_str())
563 .is_some_and(|name| {
564 let name = name.to_ascii_lowercase();
565 extensions
566 .iter()
567 .any(|wanted| name.ends_with(&format!(".{wanted}")))
568 })
569 });
570 return (
571 paths
572 .into_iter()
573 .map(|value| (value, String::new()))
574 .collect(),
575 true,
576 );
577 }
578 }
579 match type_ {
582 "config_keys" => return (self.complete_config_keys(cx.spec, ctoken), true),
583 "config_values" => return self.complete_config_values(cx, ctoken),
584 "executable" => {
585 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
586 return (
587 self.complete_path(&cwd, ctoken, |path| path.is_dir() || is_executable(path))
588 .into_iter()
589 .map(|value| (value, String::new()))
590 .collect(),
591 true,
592 );
593 }
594 "command" => return (self.complete_commands(ctoken), true),
595 "username" => return (complete_usernames(ctoken), true),
596 "hostname" => return (complete_hostnames(ctoken), true),
597 "none" | "url" | "email" => return (vec![], true),
598 "unknown" => {}
600 "command_args" => {
601 let command_was_bound = cx.parsed.next_arg.as_ref().is_some_and(|next| {
602 cx.parsed
607 .args
608 .keys()
609 .any(|bound| bound.as_ref() == next.as_ref())
610 });
611 if cx.after_restart_token || !command_was_bound {
612 return (self.complete_commands(ctoken), true);
613 }
614 }
615 _ => {}
616 }
617 let names = match (type_, env::current_dir()) {
618 ("path" | "file", Ok(cwd)) => self.complete_path(&cwd, ctoken, |_| true),
619 ("dir", Ok(cwd)) => self.complete_path(&cwd, ctoken, |p| p.is_dir()),
620 _ => vec![],
622 };
623 (
624 names.into_iter().map(|n| (n, String::new())).collect(),
625 false,
626 )
627 }
628
629 fn complete_config_keys(&self, spec: &Spec, ctoken: &str) -> Vec<(String, String)> {
638 spec.config
639 .props
640 .iter()
641 .filter(|(_, prop)| !prop.hide)
642 .filter(|(key, _)| key.starts_with(ctoken))
643 .map(|(key, prop)| {
644 let help = one_line(prop.help.as_deref());
645 let help = help.as_str();
646 let description = match &prop.deprecated {
649 Some(_) if help.is_empty() => "deprecated".to_string(),
650 Some(_) => format!("deprecated — {help}"),
651 None => help.to_string(),
652 };
653 (key.clone(), description)
654 })
655 .collect()
656 }
657
658 fn complete_config_values(&self, cx: &Ctx<'_>, ctoken: &str) -> (Vec<(String, String)>, bool) {
664 let Some(prop) = self.config_key_before_cursor(cx) else {
665 return (vec![], false);
668 };
669 if !prop.choices.is_empty() {
670 return (
671 prop.choices
672 .iter()
673 .map(|choice| (choice.value.display(), one_line(choice.help.as_deref())))
674 .filter(|(value, _)| value.starts_with(ctoken))
675 .collect(),
676 true,
680 );
681 }
682 if holds_a_boolean(&prop.value_type.clone().unwrap_or_default()) {
686 let declared = prop.value_type.clone().unwrap_or_default();
687 return (
688 ["false", "true"]
689 .into_iter()
690 .filter(|value| value.starts_with(ctoken))
691 .map(|value| (value.to_string(), String::new()))
692 .collect(),
693 !accepts_unenumerable_values(&declared),
697 );
698 }
699 (vec![], false)
702 }
703
704 fn config_key_before_cursor<'a>(&self, cx: &Ctx<'a>) -> Option<&'a SpecConfigProp> {
714 cx.parsed
721 .args
722 .iter()
723 .filter(|(arg, _)| self.completer_type(cx, arg) == Some("config_keys"))
724 .filter_map(|(_, value)| match value {
725 ParseValue::String(word) => Some(word.as_str()),
726 ParseValue::MultiString(words) => words.last().map(String::as_str),
727 ParseValue::Bool(_) | ParseValue::MultiBool(_) => None,
728 })
729 .next_back()
746 .and_then(|word| resolve_config_key(&cx.spec.config, word))
747 }
748
749 fn completer_type<'b>(&self, cx: &'b Ctx<'_>, arg: &'b SpecArg) -> Option<&'b str> {
751 cx.spec
752 .completer(&cx.parsed.cmd, arg)
753 .and_then(|complete| complete.type_.as_deref())
754 }
755
756 fn complete_positional(
767 &self,
768 cx: &Ctx<'_>,
769 cmd: &SpecCommand,
770 arg: &SpecArg,
771 ctoken: &str,
772 double_dash_seen: bool,
773 ) -> usage::miette::Result<(Vec<(String, String)>, bool)> {
774 if arg.double_dash == SpecDoubleDashChoices::Required && !double_dash_seen {
775 let separator = ctoken.is_empty().then(|| ("--".to_string(), String::new()));
777 return Ok((separator.into_iter().collect(), true));
778 }
779 let (found, closed) = self.complete_arg(cx, cmd, arg, ctoken)?;
780 Ok((found, closed || arg.choices.is_some()))
781 }
782
783 fn complete_arg(
784 &self,
785 cx: &Ctx<'_>,
786 cmd: &SpecCommand,
787 arg: &SpecArg,
788 ctoken: &str,
789 ) -> usage::miette::Result<(Vec<(String, String)>, bool)> {
790 static EMPTY_COMPL: LazyLock<SpecComplete> = LazyLock::new(SpecComplete::default);
791
792 trace!("complete_arg: {arg} {ctoken}");
793 let complete = cx.spec.completer(cmd, arg).unwrap_or(&EMPTY_COMPL);
794 if let Some(type_) = complete.type_.as_deref() {
795 let (builtin, closed) = self.complete_builtin(cx, type_, ctoken);
799 if !builtin.is_empty() || closed {
800 return Ok((builtin, closed));
801 }
802 }
803
804 if let Some(choices) = &arg.choices {
805 let values = choices.resolved_values(None).unwrap_or_else(|err| {
810 trace!("choices run= failed: {err}");
811 choices.values()
812 });
813 return Ok((
814 values
815 .into_iter()
816 .filter(|c| c.starts_with(ctoken))
817 .map(|value| {
818 let help = choices
823 .details
824 .iter()
825 .find(|detail| detail.value == value)
826 .and_then(|detail| detail.help.clone())
827 .unwrap_or_default();
828 (value, help)
829 })
830 .collect(),
831 true,
832 ));
833 }
834 if let Some(delegate) = &complete.delegate {
835 let found = self
836 .complete_delegated(cx, arg, delegate, ctoken)
837 .into_iter()
838 .filter_map(|candidate| whole_word_candidate(candidate, ctoken))
839 .collect();
840 return Ok((found, false));
843 }
844 if let Some(run) = &complete.run {
845 let run = render_completer_run(run, cx.tera).into_diagnostic()?;
846 trace!("run: {run}");
847 let stdout = sh(&run)?;
848 static DESCRIPTION_SEPARATOR: LazyLock<Regex> =
850 LazyLock::new(|| Regex::new(r"[^\\]:").unwrap());
851 let re = &*DESCRIPTION_SEPARATOR;
852 return Ok((
853 stdout
854 .lines()
855 .map(|l| {
856 if complete.descriptions {
857 match re.find(l).map(|m| l.split_at(m.end() - 1)) {
858 Some((l, d)) if d.len() <= 1 => {
859 (l.trim().replace("\\:", ":"), String::new())
860 }
861 Some((l, d)) => (
862 l.trim().replace("\\:", ":"),
863 d[1..].trim().replace("\\:", ":"),
864 ),
865 None => (l.trim().replace("\\:", ":"), String::new()),
866 }
867 } else {
868 (l.trim().to_string(), String::new())
869 }
870 })
871 .filter(|(name, _)| name.starts_with(ctoken))
872 .collect(),
873 false,
876 ));
877 }
878
879 if complete.type_.is_none() {
885 let (builtin, closed) = self.complete_builtin(cx, &arg.name.to_lowercase(), ctoken);
886 if !builtin.is_empty() || closed {
887 return Ok((builtin, closed));
888 }
889 }
890
891 Ok((vec![], false))
892 }
893
894 fn complete_delegated(
896 &self,
897 cx: &Ctx<'_>,
898 arg: &SpecArg,
899 delegate: &str,
900 ctoken: &str,
901 ) -> Vec<(String, String)> {
902 let Ok(mut words) = usage::shell_words::split(delegate) else {
904 return vec![];
905 };
906 words.extend(delegated_words(cx.parsed, arg));
907 words.push(ctoken.to_string());
908 trace!("delegate: {}", words.iter().join(" "));
909 complete_delegate::complete(&self.shell, &words)
910 }
911
912 fn delegated_arg_at_flag<'a>(&self, cx: &Ctx<'a>, ctoken: &str) -> Option<&'a SpecArg> {
920 if !ctoken.starts_with('-') || !cx.parsed.flag_awaiting_value.is_empty() {
921 return None;
922 }
923 let unknown_flags = cx
924 .parsed
925 .cmds
926 .iter()
927 .rev()
928 .find_map(|cmd| cmd.unknown_flags)
929 .or(cx.spec.unknown_flags)
930 .unwrap_or_default();
931 if unknown_flags != UnknownFlags::Value {
932 return None;
933 }
934 let arg = cx.parsed.next_arg.as_deref()?;
935 let complete = cx.spec.completer(&cx.parsed.cmd, arg)?;
936 (complete.delegate.is_some() && !delegated_words(cx.parsed, arg).is_empty()).then_some(arg)
937 }
938
939 fn complete_path(
940 &self,
941 base: &Path,
942 ctoken: &str,
943 filter: impl Fn(&Path) -> bool,
944 ) -> Vec<String> {
945 trace!("complete_path: {ctoken}");
946 let separator = rendered_separator(ctoken);
947 let path = PathBuf::from(ctoken);
948 let exact = if path.is_absolute() {
949 path.clone()
950 } else {
951 base.join(&path)
952 };
953 let trailing_separator = (ctoken.ends_with(std::path::MAIN_SEPARATOR)
957 || (cfg!(windows) && ctoken.ends_with('/')))
958 && exact.is_dir();
959 let (parent, prefix) = if trailing_separator {
960 (path.as_path(), "")
961 } else {
962 (
963 path.parent().unwrap_or_else(|| Path::new("")),
964 path.file_name()
965 .unwrap_or_default()
966 .to_str()
967 .unwrap_or_default(),
968 )
969 };
970
971 resolve_path_dirs(base, parent)
972 .into_iter()
973 .flat_map(|dir| std::fs::read_dir(dir).ok().into_iter().flatten())
974 .filter_map(Result::ok)
975 .filter(|de| {
976 let name = de.file_name();
977 let name = name.to_string_lossy();
978 !name.starts_with('.') && name.starts_with(prefix)
979 })
980 .filter(|de| filter(&de.path()))
981 .map(|de| {
982 let p = de.path();
983 let is_dir = de
984 .file_type()
985 .map(|ft| ft.is_dir())
986 .unwrap_or_else(|_| p.is_dir());
987 let mut s = p
988 .strip_prefix(base)
989 .unwrap_or(&p)
990 .to_string_lossy()
991 .replace(std::path::MAIN_SEPARATOR, separator);
992 if is_dir {
993 s.push_str(separator);
994 }
995 s
996 })
997 .sorted()
998 .collect()
999 }
1000
1001 fn complete_commands(&self, ctoken: &str) -> Vec<(String, String)> {
1002 if ctoken.contains(std::path::MAIN_SEPARATOR) || (cfg!(windows) && ctoken.contains('/')) {
1003 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1004 return self
1005 .complete_path(&cwd, ctoken, |path| path.is_dir() || is_executable(path))
1006 .into_iter()
1007 .map(|value| (value, String::new()))
1008 .collect();
1009 }
1010
1011 let mut found = BTreeSet::new();
1012 if let Some(path) = env::var_os("PATH") {
1013 for dir in env::split_paths(&path) {
1014 for entry in std::fs::read_dir(dir).ok().into_iter().flatten().flatten() {
1015 let path = entry.path();
1016 let name = entry.file_name().to_string_lossy().into_owned();
1017 if command_name_starts_with(&name, ctoken, cfg!(windows))
1018 && is_executable(&path)
1019 {
1020 found.insert(name);
1021 }
1022 }
1023 }
1024 }
1025 found
1026 .into_iter()
1027 .map(|value| (value, String::new()))
1028 .collect()
1029 }
1030}
1031
1032impl usage_rs::Run for CompleteWord {
1033 type Output = usage::miette::Result<()>;
1034
1035 fn run(self) -> Self::Output {
1036 let spec = generate::file_or_spec(&self.file, &self.spec)?;
1037 let choices = self.complete_word(&spec)?;
1038 let shell = self.shell.as_ref();
1039 let any_descriptions = choices.iter().any(|(_, d)| !d.is_empty());
1040 for (c, description) in choices {
1041 let description = one_line(Some(&description));
1044 match shell {
1045 "bash" => println!("{c}"),
1046 "fish" | "nu" | "powershell" => {
1047 if any_descriptions {
1048 println!("{c}\t{description}")
1049 } else {
1050 println!("{c}")
1051 }
1052 }
1053 "zsh" => {
1054 let insert = zsh_shell_quote(&c);
1067 println!("{c}\t{description}\t{insert}")
1068 }
1069 _ => {
1070 usage::miette::bail!("unsupported shell: {}", shell);
1071 }
1072 }
1073 }
1074
1075 Ok(())
1076 }
1077}
1078
1079fn rendered_separator(ctoken: &str) -> &'static str {
1093 if cfg!(windows) && ctoken.contains('\\') && !ctoken.contains('/') {
1094 "\\"
1095 } else {
1096 "/"
1097 }
1098}
1099
1100fn resolve_path_dirs(base: &Path, path: &Path) -> Vec<PathBuf> {
1106 let exact = if path.is_absolute() {
1107 path.to_path_buf()
1108 } else {
1109 base.join(path)
1110 };
1111 if exact.is_dir() {
1112 return vec![exact];
1113 }
1114
1115 let Some(prefix) = path.file_name().and_then(|name| name.to_str()) else {
1116 return Vec::new();
1117 };
1118 let parent = path.parent().unwrap_or_else(|| Path::new(""));
1119 resolve_path_dirs(base, parent)
1120 .into_iter()
1121 .flat_map(|dir| std::fs::read_dir(dir).ok().into_iter().flatten())
1122 .filter_map(Result::ok)
1123 .filter(|entry| {
1124 entry.file_type().is_ok_and(|kind| kind.is_dir())
1125 && entry
1126 .file_name()
1127 .to_str()
1128 .is_some_and(|name| !name.starts_with('.') && name.starts_with(prefix))
1129 })
1130 .map(|entry| entry.path())
1131 .collect()
1132}
1133
1134fn flag_value_owner<'a>(parsed: &'a ParseOutput, flag: &Arc<SpecFlag>) -> &'a SpecCommand {
1145 let Some(boundary) = parsed.cmds.iter().position(|cmd| cmd.mounted) else {
1146 return &parsed.cmd;
1147 };
1148 if parsed.is_mounted_flag(flag) {
1149 return &parsed.cmd;
1150 }
1151 parsed.cmds[..boundary].last().unwrap_or(&parsed.cmd)
1152}
1153
1154fn complete_usernames(prefix: &str) -> Vec<(String, String)> {
1155 let mut found = BTreeSet::new();
1156 for key in ["USER", "USERNAME"] {
1157 if let Ok(value) = env::var(key)
1158 && value.starts_with(prefix)
1159 {
1160 found.insert(value);
1161 }
1162 }
1163 if let Ok(passwd) = std::fs::read_to_string("/etc/passwd") {
1164 for line in passwd.lines() {
1165 if let Some(name) = line
1166 .split(':')
1167 .next()
1168 .filter(|name| name.starts_with(prefix))
1169 {
1170 found.insert(name.to_string());
1171 }
1172 }
1173 }
1174 found
1175 .into_iter()
1176 .map(|value| (value, String::new()))
1177 .collect()
1178}
1179
1180fn complete_hostnames(prefix: &str) -> Vec<(String, String)> {
1181 let mut found = BTreeSet::new();
1182 for key in ["HOSTNAME", "COMPUTERNAME"] {
1183 if let Ok(value) = env::var(key)
1184 && value.starts_with(prefix)
1185 {
1186 found.insert(value);
1187 }
1188 }
1189 if let Ok(hosts) = std::fs::read_to_string("/etc/hosts") {
1190 for line in hosts.lines() {
1191 let line = line.split('#').next().unwrap_or_default();
1192 for name in line.split_whitespace().skip(1) {
1193 if name.starts_with(prefix) {
1194 found.insert(name.to_string());
1195 }
1196 }
1197 }
1198 }
1199 found
1200 .into_iter()
1201 .map(|value| (value, String::new()))
1202 .collect()
1203}
1204
1205fn command_name_starts_with(name: &str, prefix: &str, case_insensitive: bool) -> bool {
1206 if case_insensitive {
1207 name.get(..prefix.len())
1208 .is_some_and(|start| start.eq_ignore_ascii_case(prefix))
1209 } else {
1210 name.starts_with(prefix)
1211 }
1212}
1213
1214#[cfg(unix)]
1215fn is_executable(path: &Path) -> bool {
1216 use std::os::unix::fs::PermissionsExt;
1217 path.metadata()
1218 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
1219}
1220
1221#[cfg(windows)]
1222fn is_executable(path: &Path) -> bool {
1223 let extensions = env::var_os("PATHEXT").unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".into());
1224 path.is_file()
1225 && path.extension().is_some_and(|extension| {
1226 let extension = format!(".{}", extension.to_string_lossy());
1227 extensions
1228 .to_string_lossy()
1229 .split(';')
1230 .any(|candidate| candidate.eq_ignore_ascii_case(&extension))
1231 })
1232}
1233
1234struct Ctx<'a> {
1244 tera: &'a tera::Context,
1245 spec: &'a Spec,
1246 parsed: &'a ParseOutput,
1247 after_restart_token: bool,
1248}
1249
1250fn whole_word_candidate(
1259 (name, description): (String, String),
1260 ctoken: &str,
1261) -> Option<(String, String)> {
1262 if name.starts_with(ctoken) {
1263 return Some((name, description));
1264 }
1265 let (head, _) = ctoken.rsplit_once('=')?;
1266 let name = format!("{head}={name}");
1267 name.starts_with(ctoken).then_some((name, description))
1268}
1269
1270fn delegated_words(parsed: &ParseOutput, arg: &SpecArg) -> Vec<String> {
1277 let mut words = vec![];
1278 for token in &parsed.tokens {
1279 for role in &token.roles {
1280 match role {
1281 TokenRole::Restart | TokenRole::ClauseSeparator { .. } => words.clear(),
1282 TokenRole::Arg { arg: bound, .. }
1283 | TokenRole::UnknownFlag {
1284 bound_as: Some(bound),
1285 } if bound.name == arg.name => words.push(token.word.clone()),
1286 _ => {}
1287 }
1288 }
1289 }
1290 words
1291}
1292
1293fn first_ordinary_arg(cmd: &SpecCommand) -> Option<&SpecArg> {
1295 cmd.args.iter().find(|arg| arg.sigil.is_none())
1296}
1297
1298fn first_active_arg(cmd: &SpecCommand) -> Option<&SpecArg> {
1300 cmd.clause
1301 .as_ref()
1302 .and_then(|clause| clause.args.first())
1303 .or_else(|| first_ordinary_arg(cmd))
1304}
1305
1306fn one_line(text: Option<&str>) -> String {
1312 text.unwrap_or_default()
1313 .lines()
1314 .next()
1315 .unwrap_or_default()
1316 .trim()
1317 .to_string()
1318}
1319
1320fn resolve_config_key<'a>(
1338 config: &'a usage::spec::config::SpecConfig,
1339 key: &str,
1340) -> Option<&'a SpecConfigProp> {
1341 let (_, mut prop) = config
1342 .props
1343 .iter()
1344 .find(|(name, prop)| name.as_str() == key || prop.aliases.iter().any(|a| a == key))?;
1345 for _ in 0..config.props.len() {
1346 let Some(target) = &prop.renamed_to else {
1347 return Some(prop);
1348 };
1349 let Some(next) = config.props.get(target) else {
1353 return Some(prop);
1354 };
1355 prop = next;
1356 }
1357 Some(prop)
1358}
1359
1360fn accepts_unenumerable_values(ty: &SpecConfigType) -> bool {
1366 match ty {
1367 SpecConfigType::Base(Base::Bool) => false,
1368 SpecConfigType::Option(inner) => accepts_unenumerable_values(inner),
1369 SpecConfigType::Union(members) => members.iter().any(accepts_unenumerable_values),
1370 _ => true,
1373 }
1374}
1375
1376fn holds_a_boolean(ty: &SpecConfigType) -> bool {
1382 match ty {
1383 SpecConfigType::Base(Base::Bool) => true,
1384 SpecConfigType::Option(inner) => holds_a_boolean(inner),
1385 SpecConfigType::Union(members) => members.iter().any(holds_a_boolean),
1386 _ => false,
1389 }
1390}
1391
1392fn zsh_shell_quote(s: &str) -> String {
1393 fn safe(c: char) -> bool {
1394 matches!(c,
1395 'a'..='z' | 'A'..='Z' | '0'..='9'
1396 | '_' | '-' | '.' | '/' | ':' | '@' | '+' | '=' | '%' | ','
1397 )
1398 }
1399 if !s.is_empty() && s.chars().all(safe) {
1400 return s.to_string();
1401 }
1402 let escaped = s.replace('\'', "'\\''");
1404 format!("'{escaped}'")
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use super::{command_name_starts_with, render_completer_run, rendered_separator};
1410
1411 #[test]
1412 fn a_slash_in_the_token_is_kept() {
1413 assert_eq!(rendered_separator("target/de"), "/");
1415 assert_eq!(rendered_separator("/abs/path"), "/");
1416 }
1417
1418 #[test]
1419 fn a_token_with_no_separator_yet_gets_a_slash() {
1420 assert_eq!(rendered_separator(""), "/");
1422 assert_eq!(rendered_separator("target"), "/");
1423 }
1424
1425 #[test]
1426 fn a_backslash_is_a_separator_only_on_windows() {
1427 let expected = if cfg!(windows) { "\\" } else { "/" };
1431 assert_eq!(rendered_separator(r"target\de"), expected);
1432 assert_eq!(rendered_separator(r"C:\Users\me"), expected);
1433 }
1434
1435 #[test]
1436 fn a_mixed_token_settles_on_the_slash() {
1437 assert_eq!(rendered_separator(r"target\de/inc"), "/");
1440 }
1441
1442 #[test]
1443 fn windows_command_prefixes_ignore_ascii_case() {
1444 assert!(command_name_starts_with("Cargo.EXE", "car", true));
1445 assert!(!command_name_starts_with("Cargo.EXE", "car", false));
1446 }
1447
1448 #[test]
1449 fn completer_templates_can_shell_quote_typed_words() {
1450 let mut ctx = tera::Context::new();
1451 ctx.insert("word", "a'b; echo injected");
1452 let rendered = render_completer_run("printf '%s\\n' {{ word | shell_quote }}", &ctx)
1453 .expect("the filter should render");
1454 assert_eq!(rendered, "printf '%s\\n' 'a'\\''b; echo injected'");
1455 }
1456
1457 #[cfg(unix)]
1458 #[test]
1459 fn shell_quoted_template_values_remain_one_literal_argument() {
1460 let mut ctx = tera::Context::new();
1461 ctx.insert("word", "$(printf injected); a'b");
1462 let rendered = render_completer_run("printf '%s\\n' {{ word | shell_quote }}", &ctx)
1463 .expect("the filter should render");
1464 let stdout = usage::sh::sh(&rendered).expect("the rendered command should run");
1465 assert_eq!(stdout, "$(printf injected); a'b\n");
1466 }
1467
1468 #[test]
1469 fn shell_quote_rejects_non_strings() {
1470 let mut ctx = tera::Context::new();
1471 ctx.insert("word", &42);
1472 let err = render_completer_run("{{ word | shell_quote }}", &ctx).unwrap_err();
1473 assert!(
1474 err.to_string().contains("shell_quote expects a string"),
1475 "{err}"
1476 );
1477 }
1478
1479 #[cfg(unix)]
1480 #[test]
1481 fn shell_join_preserves_the_argv_vector_when_forwarded_as_one_argument() {
1482 let expected = ["ex", "two words", "a'b"];
1483 let mut ctx = tera::Context::new();
1484 ctx.insert("words", &expected);
1485 let rendered = render_completer_run(
1486 "printf '%s\\n' {{ words | shell_join | shell_quote }}",
1487 &ctx,
1488 )
1489 .expect("the filters should render");
1490 let stdout = usage::sh::sh(&rendered).expect("the rendered command should run");
1491 let reparsed = shell_words::split(stdout.trim()).expect("the joined value should parse");
1492 assert_eq!(reparsed, expected);
1493 }
1494}