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 (found, closed) = self.complete_arg(&cx, &parsed.cmd, arg, &ctoken)?;
332 has_explicit_choices = closed || arg.choices.is_some();
333 found
334 } else if let Some((owner, arg, sigil, prefix)) = sigil_arg {
335 let mut sigil_ctx = ctx.clone();
340 let mut sigil_words = self.words.clone();
341 if let Some(current) = sigil_words.get_mut(cword) {
342 *current = prefix.to_string();
343 }
344 sigil_ctx.insert("words", &sigil_words);
345 let sigil_cx = Ctx {
346 tera: &sigil_ctx,
347 spec: cx.spec,
348 parsed: cx.parsed,
349 after_restart_token: cx.after_restart_token,
350 };
351 let (mut found, closed) = self.complete_arg(&sigil_cx, owner, arg, prefix)?;
352 for (candidate, _) in &mut found {
353 candidate.insert_str(0, sigil);
354 }
355 has_explicit_choices = closed || arg.choices.is_some() || found.is_empty();
356 found
357 } else {
358 let mut choices = vec![];
359 if let Some(arg) = parsed.next_arg.as_deref() {
360 let (found, constrained) = self.complete_positional(
361 &cx,
362 &parsed.cmd,
363 arg,
364 &ctoken,
365 parsed.double_dash_seen,
366 )?;
367 has_explicit_choices = constrained;
368 choices.extend(found);
369 }
370 if !parsed.cmd.subcommands.is_empty() {
371 choices.extend(self.complete_subcommands(&parsed.cmd, &ctoken));
372 }
373 if parsed.cmd.name == spec.cmd.name
375 && let Some(default_name) = &spec.default_subcommand
376 && let Some(default_cmd) = spec.cmd.find_subcommand(default_name)
377 {
378 if let Some(arg) = first_active_arg(default_cmd) {
389 let (found, _) = self.complete_positional(
390 &cx,
391 default_cmd,
392 arg,
393 &ctoken,
394 parsed.double_dash_seen,
395 )?;
396 choices.extend(found);
397 }
398 }
399 choices
400 };
401 if flags_possible
402 && attached_long_value.is_none()
403 && sigil_arg.is_none()
404 && let Some(arg) = self.delegated_arg_at_flag(&cx, &ctoken)
405 {
406 let (found, _) = self.complete_arg(&cx, &parsed.cmd, arg, &ctoken)?;
407 for candidate in found {
408 if !choices.iter().any(|(name, _)| *name == candidate.0) {
409 choices.push(candidate);
410 }
411 }
412 }
413 let looks_like_a_flag = flags_possible && ctoken.starts_with('-');
417 let files = used_file_fallback
418 || (choices.is_empty() && !looks_like_a_flag && !has_explicit_choices);
419 if files && choices.is_empty() {
420 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
421 let files = self.complete_path(&cwd, &ctoken, |_| true);
422 choices = files.into_iter().map(|n| (n, String::new())).collect();
423 }
424 trace!("choices: {}", choices.iter().map(|(c, _)| c).join(", "));
425 Ok(CandidateAnswer {
426 candidates: choices,
427 files,
428 })
429 }
430
431 fn complete_subcommands(&self, cmd: &SpecCommand, ctoken: &str) -> Vec<(String, String)> {
432 trace!("complete_subcommands: {ctoken}");
433 let mut choices = vec![];
434 for subcommand in cmd.subcommands.values() {
435 if subcommand.hide {
436 continue;
437 }
438 choices.push((
439 subcommand.name.clone(),
440 subcommand.help.clone().unwrap_or_default(),
441 ));
442 for alias in &subcommand.aliases {
443 choices.push((alias.clone(), subcommand.help.clone().unwrap_or_default()));
444 }
445 }
446 choices
447 .into_iter()
448 .filter(|(c, _)| c.starts_with(ctoken))
449 .sorted()
450 .collect()
451 }
452
453 fn complete_long_flag_names(
454 &self,
455 flags: &BTreeMap<String, Arc<SpecFlag>>,
456 ctoken: &str,
457 ) -> Vec<(String, String)> {
458 debug!("complete_long_flag_names: {ctoken}");
459 trace!("flags: {}", flags.keys().join(", "));
460 flags
461 .values()
462 .filter(|f| !f.hide)
463 .flat_map(|f| {
464 let mut flags = f
465 .long
466 .iter()
467 .filter(|long| !f.hidden_aliases.contains(long))
468 .map(|l| (format!("--{l}"), f.help.clone().unwrap_or_default()))
469 .collect::<Vec<_>>();
470 if let Some(negate) = &f.negate {
471 flags.push((negate.clone(), String::new()))
472 }
473 flags
474 })
475 .unique_by(|(f, _)| f.to_string())
476 .filter(|(f, _)| f.starts_with(ctoken))
477 .sorted()
479 .collect()
480 }
481
482 fn complete_short_flag_names(
483 &self,
484 flags: &BTreeMap<String, Arc<SpecFlag>>,
485 ctoken: &str,
486 ) -> Vec<(String, String)> {
487 debug!("complete_short_flag_names: {ctoken}");
488 let cur = ctoken.chars().nth(1);
489 flags
490 .values()
491 .filter(|f| !f.hide)
492 .flat_map(|f| {
493 f.short
494 .iter()
495 .filter(|short| !f.hidden_short_aliases.contains(short))
496 })
497 .unique()
498 .filter(|c| cur.is_none() || cur == Some(**c))
499 .map(|c| (format!("-{c}"), String::new()))
501 .sorted()
502 .collect()
503 }
504
505 fn attached_long_value<'f, 't>(
510 flags: &'f BTreeMap<String, Arc<SpecFlag>>,
511 token: &'t str,
512 ) -> Option<(&'f SpecFlag, &'t str, &'t str)> {
513 let (form, prefix) = token.split_once('=')?;
514 let long = form.strip_prefix("--")?;
515 flags
516 .values()
517 .find(|flag| flag.arg.is_some() && flag.long.iter().any(|candidate| candidate == long))
518 .map(|flag| (flag.as_ref(), form, prefix))
519 }
520
521 fn attach_long_value_candidates(candidates: &mut [(String, String)], form: &str) {
522 for (candidate, _) in candidates {
523 *candidate = format!("{form}={candidate}");
524 }
525 }
526
527 fn complete_builtin(
535 &self,
536 cx: &Ctx<'_>,
537 type_: &str,
538 ctoken: &str,
539 ) -> (Vec<(String, String)>, bool) {
540 if let Some(encoded) = type_
541 .strip_prefix("path:")
542 .or_else(|| type_.strip_prefix("file:"))
543 {
544 let extensions = encoded
545 .split(',')
546 .map(|extension| {
547 extension
548 .trim()
549 .trim_start_matches('.')
550 .to_ascii_lowercase()
551 })
552 .filter(|extension| !extension.is_empty())
553 .collect::<Vec<_>>();
554 if !extensions.is_empty() {
555 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
556 let paths = self.complete_path(&cwd, ctoken, |path| {
557 path.is_dir()
558 || path
559 .file_name()
560 .and_then(|name| name.to_str())
561 .is_some_and(|name| {
562 let name = name.to_ascii_lowercase();
563 extensions
564 .iter()
565 .any(|wanted| name.ends_with(&format!(".{wanted}")))
566 })
567 });
568 return (
569 paths
570 .into_iter()
571 .map(|value| (value, String::new()))
572 .collect(),
573 true,
574 );
575 }
576 }
577 match type_ {
580 "config_keys" => return (self.complete_config_keys(cx.spec, ctoken), true),
581 "config_values" => return self.complete_config_values(cx, ctoken),
582 "executable" => {
583 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
584 return (
585 self.complete_path(&cwd, ctoken, |path| path.is_dir() || is_executable(path))
586 .into_iter()
587 .map(|value| (value, String::new()))
588 .collect(),
589 true,
590 );
591 }
592 "command" => return (self.complete_commands(ctoken), true),
593 "username" => return (complete_usernames(ctoken), true),
594 "hostname" => return (complete_hostnames(ctoken), true),
595 "none" | "url" | "email" => return (vec![], true),
596 "unknown" => {}
598 "command_args" => {
599 let command_was_bound = cx.parsed.next_arg.as_ref().is_some_and(|next| {
600 cx.parsed
605 .args
606 .keys()
607 .any(|bound| bound.as_ref() == next.as_ref())
608 });
609 if cx.after_restart_token || !command_was_bound {
610 return (self.complete_commands(ctoken), true);
611 }
612 }
613 _ => {}
614 }
615 let names = match (type_, env::current_dir()) {
616 ("path" | "file", Ok(cwd)) => self.complete_path(&cwd, ctoken, |_| true),
617 ("dir", Ok(cwd)) => self.complete_path(&cwd, ctoken, |p| p.is_dir()),
618 _ => vec![],
620 };
621 (
622 names.into_iter().map(|n| (n, String::new())).collect(),
623 false,
624 )
625 }
626
627 fn complete_config_keys(&self, spec: &Spec, ctoken: &str) -> Vec<(String, String)> {
636 spec.config
637 .props
638 .iter()
639 .filter(|(_, prop)| !prop.hide)
640 .filter(|(key, _)| key.starts_with(ctoken))
641 .map(|(key, prop)| {
642 let help = one_line(prop.help.as_deref());
643 let help = help.as_str();
644 let description = match &prop.deprecated {
647 Some(_) if help.is_empty() => "deprecated".to_string(),
648 Some(_) => format!("deprecated — {help}"),
649 None => help.to_string(),
650 };
651 (key.clone(), description)
652 })
653 .collect()
654 }
655
656 fn complete_config_values(&self, cx: &Ctx<'_>, ctoken: &str) -> (Vec<(String, String)>, bool) {
662 let Some(prop) = self.config_key_before_cursor(cx) else {
663 return (vec![], false);
666 };
667 if !prop.choices.is_empty() {
668 return (
669 prop.choices
670 .iter()
671 .map(|choice| (choice.value.display(), one_line(choice.help.as_deref())))
672 .filter(|(value, _)| value.starts_with(ctoken))
673 .collect(),
674 true,
678 );
679 }
680 if holds_a_boolean(&prop.value_type.clone().unwrap_or_default()) {
684 let declared = prop.value_type.clone().unwrap_or_default();
685 return (
686 ["false", "true"]
687 .into_iter()
688 .filter(|value| value.starts_with(ctoken))
689 .map(|value| (value.to_string(), String::new()))
690 .collect(),
691 !accepts_unenumerable_values(&declared),
695 );
696 }
697 (vec![], false)
700 }
701
702 fn config_key_before_cursor<'a>(&self, cx: &Ctx<'a>) -> Option<&'a SpecConfigProp> {
712 cx.parsed
719 .args
720 .iter()
721 .filter(|(arg, _)| self.completer_type(cx, arg) == Some("config_keys"))
722 .filter_map(|(_, value)| match value {
723 ParseValue::String(word) => Some(word.as_str()),
724 ParseValue::MultiString(words) => words.last().map(String::as_str),
725 ParseValue::Bool(_) | ParseValue::MultiBool(_) => None,
726 })
727 .next_back()
744 .and_then(|word| resolve_config_key(&cx.spec.config, word))
745 }
746
747 fn completer_type<'b>(&self, cx: &'b Ctx<'_>, arg: &'b SpecArg) -> Option<&'b str> {
749 cx.spec
750 .completer(&cx.parsed.cmd, arg)
751 .and_then(|complete| complete.type_.as_deref())
752 }
753
754 fn complete_positional(
765 &self,
766 cx: &Ctx<'_>,
767 cmd: &SpecCommand,
768 arg: &SpecArg,
769 ctoken: &str,
770 double_dash_seen: bool,
771 ) -> usage::miette::Result<(Vec<(String, String)>, bool)> {
772 if arg.double_dash == SpecDoubleDashChoices::Required && !double_dash_seen {
773 let separator = ctoken.is_empty().then(|| ("--".to_string(), String::new()));
775 return Ok((separator.into_iter().collect(), true));
776 }
777 let (found, closed) = self.complete_arg(cx, cmd, arg, ctoken)?;
778 Ok((found, closed || arg.choices.is_some()))
779 }
780
781 fn complete_arg(
782 &self,
783 cx: &Ctx<'_>,
784 cmd: &SpecCommand,
785 arg: &SpecArg,
786 ctoken: &str,
787 ) -> usage::miette::Result<(Vec<(String, String)>, bool)> {
788 static EMPTY_COMPL: LazyLock<SpecComplete> = LazyLock::new(SpecComplete::default);
789
790 trace!("complete_arg: {arg} {ctoken}");
791 let complete = cx.spec.completer(cmd, arg).unwrap_or(&EMPTY_COMPL);
792 if let Some(type_) = complete.type_.as_deref() {
793 let (builtin, closed) = self.complete_builtin(cx, type_, ctoken);
797 if !builtin.is_empty() || closed {
798 return Ok((builtin, closed));
799 }
800 }
801
802 if let Some(choices) = &arg.choices {
803 let values = choices.resolved_values(None).unwrap_or_else(|err| {
808 trace!("choices run= failed: {err}");
809 choices.values()
810 });
811 return Ok((
812 values
813 .into_iter()
814 .filter(|c| c.starts_with(ctoken))
815 .map(|value| {
816 let help = choices
821 .details
822 .iter()
823 .find(|detail| detail.value == value)
824 .and_then(|detail| detail.help.clone())
825 .unwrap_or_default();
826 (value, help)
827 })
828 .collect(),
829 true,
830 ));
831 }
832 if let Some(delegate) = &complete.delegate {
833 let found = self
834 .complete_delegated(cx, arg, delegate, ctoken)
835 .into_iter()
836 .filter_map(|candidate| whole_word_candidate(candidate, ctoken))
837 .collect();
838 return Ok((found, false));
841 }
842 if let Some(run) = &complete.run {
843 let run = render_completer_run(run, cx.tera).into_diagnostic()?;
844 trace!("run: {run}");
845 let stdout = sh(&run)?;
846 static DESCRIPTION_SEPARATOR: LazyLock<Regex> =
848 LazyLock::new(|| Regex::new(r"[^\\]:").unwrap());
849 let re = &*DESCRIPTION_SEPARATOR;
850 return Ok((
851 stdout
852 .lines()
853 .map(|l| {
854 if complete.descriptions {
855 match re.find(l).map(|m| l.split_at(m.end() - 1)) {
856 Some((l, d)) if d.len() <= 1 => {
857 (l.trim().replace("\\:", ":"), String::new())
858 }
859 Some((l, d)) => (
860 l.trim().replace("\\:", ":"),
861 d[1..].trim().replace("\\:", ":"),
862 ),
863 None => (l.trim().replace("\\:", ":"), String::new()),
864 }
865 } else {
866 (l.trim().to_string(), String::new())
867 }
868 })
869 .filter(|(name, _)| name.starts_with(ctoken))
870 .collect(),
871 false,
874 ));
875 }
876
877 if complete.type_.is_none() {
883 let (builtin, closed) = self.complete_builtin(cx, &arg.name.to_lowercase(), ctoken);
884 if !builtin.is_empty() || closed {
885 return Ok((builtin, closed));
886 }
887 }
888
889 Ok((vec![], false))
890 }
891
892 fn complete_delegated(
894 &self,
895 cx: &Ctx<'_>,
896 arg: &SpecArg,
897 delegate: &str,
898 ctoken: &str,
899 ) -> Vec<(String, String)> {
900 let Ok(mut words) = usage::shell_words::split(delegate) else {
902 return vec![];
903 };
904 words.extend(delegated_words(cx.parsed, arg));
905 words.push(ctoken.to_string());
906 trace!("delegate: {}", words.iter().join(" "));
907 complete_delegate::complete(&self.shell, &words)
908 }
909
910 fn delegated_arg_at_flag<'a>(&self, cx: &Ctx<'a>, ctoken: &str) -> Option<&'a SpecArg> {
918 if !ctoken.starts_with('-') || !cx.parsed.flag_awaiting_value.is_empty() {
919 return None;
920 }
921 let unknown_flags = cx
922 .parsed
923 .cmds
924 .iter()
925 .rev()
926 .find_map(|cmd| cmd.unknown_flags)
927 .or(cx.spec.unknown_flags)
928 .unwrap_or_default();
929 if unknown_flags != UnknownFlags::Value {
930 return None;
931 }
932 let arg = cx.parsed.next_arg.as_deref()?;
933 let complete = cx.spec.completer(&cx.parsed.cmd, arg)?;
934 (complete.delegate.is_some() && !delegated_words(cx.parsed, arg).is_empty()).then_some(arg)
935 }
936
937 fn complete_path(
938 &self,
939 base: &Path,
940 ctoken: &str,
941 filter: impl Fn(&Path) -> bool,
942 ) -> Vec<String> {
943 trace!("complete_path: {ctoken}");
944 let separator = rendered_separator(ctoken);
945 let path = PathBuf::from(ctoken);
946 let exact = if path.is_absolute() {
947 path.clone()
948 } else {
949 base.join(&path)
950 };
951 let trailing_separator = (ctoken.ends_with(std::path::MAIN_SEPARATOR)
955 || (cfg!(windows) && ctoken.ends_with('/')))
956 && exact.is_dir();
957 let (parent, prefix) = if trailing_separator {
958 (path.as_path(), "")
959 } else {
960 (
961 path.parent().unwrap_or_else(|| Path::new("")),
962 path.file_name()
963 .unwrap_or_default()
964 .to_str()
965 .unwrap_or_default(),
966 )
967 };
968
969 resolve_path_dirs(base, parent)
970 .into_iter()
971 .flat_map(|dir| std::fs::read_dir(dir).ok().into_iter().flatten())
972 .filter_map(Result::ok)
973 .filter(|de| {
974 let name = de.file_name();
975 let name = name.to_string_lossy();
976 !name.starts_with('.') && name.starts_with(prefix)
977 })
978 .filter(|de| filter(&de.path()))
979 .map(|de| {
980 let p = de.path();
981 let is_dir = de
982 .file_type()
983 .map(|ft| ft.is_dir())
984 .unwrap_or_else(|_| p.is_dir());
985 let mut s = p
986 .strip_prefix(base)
987 .unwrap_or(&p)
988 .to_string_lossy()
989 .replace(std::path::MAIN_SEPARATOR, separator);
990 if is_dir {
991 s.push_str(separator);
992 }
993 s
994 })
995 .sorted()
996 .collect()
997 }
998
999 fn complete_commands(&self, ctoken: &str) -> Vec<(String, String)> {
1000 if ctoken.contains(std::path::MAIN_SEPARATOR) || (cfg!(windows) && ctoken.contains('/')) {
1001 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1002 return self
1003 .complete_path(&cwd, ctoken, |path| path.is_dir() || is_executable(path))
1004 .into_iter()
1005 .map(|value| (value, String::new()))
1006 .collect();
1007 }
1008
1009 let mut found = BTreeSet::new();
1010 if let Some(path) = env::var_os("PATH") {
1011 for dir in env::split_paths(&path) {
1012 for entry in std::fs::read_dir(dir).ok().into_iter().flatten().flatten() {
1013 let path = entry.path();
1014 let name = entry.file_name().to_string_lossy().into_owned();
1015 if command_name_starts_with(&name, ctoken, cfg!(windows))
1016 && is_executable(&path)
1017 {
1018 found.insert(name);
1019 }
1020 }
1021 }
1022 }
1023 found
1024 .into_iter()
1025 .map(|value| (value, String::new()))
1026 .collect()
1027 }
1028}
1029
1030impl usage_rs::Run for CompleteWord {
1031 type Output = usage::miette::Result<()>;
1032
1033 fn run(self) -> Self::Output {
1034 let spec = generate::file_or_spec(&self.file, &self.spec)?;
1035 let choices = self.complete_word(&spec)?;
1036 let shell = self.shell.as_ref();
1037 let any_descriptions = choices.iter().any(|(_, d)| !d.is_empty());
1038 for (c, description) in choices {
1039 let description = one_line(Some(&description));
1042 match shell {
1043 "bash" => println!("{c}"),
1044 "fish" | "nu" | "powershell" => {
1045 if any_descriptions {
1046 println!("{c}\t{description}")
1047 } else {
1048 println!("{c}")
1049 }
1050 }
1051 "zsh" => {
1052 let insert = zsh_shell_quote(&c);
1065 println!("{c}\t{description}\t{insert}")
1066 }
1067 _ => {
1068 usage::miette::bail!("unsupported shell: {}", shell);
1069 }
1070 }
1071 }
1072
1073 Ok(())
1074 }
1075}
1076
1077fn rendered_separator(ctoken: &str) -> &'static str {
1091 if cfg!(windows) && ctoken.contains('\\') && !ctoken.contains('/') {
1092 "\\"
1093 } else {
1094 "/"
1095 }
1096}
1097
1098fn resolve_path_dirs(base: &Path, path: &Path) -> Vec<PathBuf> {
1104 let exact = if path.is_absolute() {
1105 path.to_path_buf()
1106 } else {
1107 base.join(path)
1108 };
1109 if exact.is_dir() {
1110 return vec![exact];
1111 }
1112
1113 let Some(prefix) = path.file_name().and_then(|name| name.to_str()) else {
1114 return Vec::new();
1115 };
1116 let parent = path.parent().unwrap_or_else(|| Path::new(""));
1117 resolve_path_dirs(base, parent)
1118 .into_iter()
1119 .flat_map(|dir| std::fs::read_dir(dir).ok().into_iter().flatten())
1120 .filter_map(Result::ok)
1121 .filter(|entry| {
1122 entry.file_type().is_ok_and(|kind| kind.is_dir())
1123 && entry
1124 .file_name()
1125 .to_str()
1126 .is_some_and(|name| !name.starts_with('.') && name.starts_with(prefix))
1127 })
1128 .map(|entry| entry.path())
1129 .collect()
1130}
1131
1132fn complete_usernames(prefix: &str) -> Vec<(String, String)> {
1133 let mut found = BTreeSet::new();
1134 for key in ["USER", "USERNAME"] {
1135 if let Ok(value) = env::var(key)
1136 && value.starts_with(prefix)
1137 {
1138 found.insert(value);
1139 }
1140 }
1141 if let Ok(passwd) = std::fs::read_to_string("/etc/passwd") {
1142 for line in passwd.lines() {
1143 if let Some(name) = line
1144 .split(':')
1145 .next()
1146 .filter(|name| name.starts_with(prefix))
1147 {
1148 found.insert(name.to_string());
1149 }
1150 }
1151 }
1152 found
1153 .into_iter()
1154 .map(|value| (value, String::new()))
1155 .collect()
1156}
1157
1158fn complete_hostnames(prefix: &str) -> Vec<(String, String)> {
1159 let mut found = BTreeSet::new();
1160 for key in ["HOSTNAME", "COMPUTERNAME"] {
1161 if let Ok(value) = env::var(key)
1162 && value.starts_with(prefix)
1163 {
1164 found.insert(value);
1165 }
1166 }
1167 if let Ok(hosts) = std::fs::read_to_string("/etc/hosts") {
1168 for line in hosts.lines() {
1169 let line = line.split('#').next().unwrap_or_default();
1170 for name in line.split_whitespace().skip(1) {
1171 if name.starts_with(prefix) {
1172 found.insert(name.to_string());
1173 }
1174 }
1175 }
1176 }
1177 found
1178 .into_iter()
1179 .map(|value| (value, String::new()))
1180 .collect()
1181}
1182
1183fn command_name_starts_with(name: &str, prefix: &str, case_insensitive: bool) -> bool {
1184 if case_insensitive {
1185 name.get(..prefix.len())
1186 .is_some_and(|start| start.eq_ignore_ascii_case(prefix))
1187 } else {
1188 name.starts_with(prefix)
1189 }
1190}
1191
1192#[cfg(unix)]
1193fn is_executable(path: &Path) -> bool {
1194 use std::os::unix::fs::PermissionsExt;
1195 path.metadata()
1196 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
1197}
1198
1199#[cfg(windows)]
1200fn is_executable(path: &Path) -> bool {
1201 let extensions = env::var_os("PATHEXT").unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".into());
1202 path.is_file()
1203 && path.extension().is_some_and(|extension| {
1204 let extension = format!(".{}", extension.to_string_lossy());
1205 extensions
1206 .to_string_lossy()
1207 .split(';')
1208 .any(|candidate| candidate.eq_ignore_ascii_case(&extension))
1209 })
1210}
1211
1212struct Ctx<'a> {
1222 tera: &'a tera::Context,
1223 spec: &'a Spec,
1224 parsed: &'a ParseOutput,
1225 after_restart_token: bool,
1226}
1227
1228fn whole_word_candidate(
1237 (name, description): (String, String),
1238 ctoken: &str,
1239) -> Option<(String, String)> {
1240 if name.starts_with(ctoken) {
1241 return Some((name, description));
1242 }
1243 let (head, _) = ctoken.rsplit_once('=')?;
1244 let name = format!("{head}={name}");
1245 name.starts_with(ctoken).then_some((name, description))
1246}
1247
1248fn delegated_words(parsed: &ParseOutput, arg: &SpecArg) -> Vec<String> {
1255 let mut words = vec![];
1256 for token in &parsed.tokens {
1257 for role in &token.roles {
1258 match role {
1259 TokenRole::Restart | TokenRole::ClauseSeparator { .. } => words.clear(),
1260 TokenRole::Arg { arg: bound, .. }
1261 | TokenRole::UnknownFlag {
1262 bound_as: Some(bound),
1263 } if bound.name == arg.name => words.push(token.word.clone()),
1264 _ => {}
1265 }
1266 }
1267 }
1268 words
1269}
1270
1271fn first_ordinary_arg(cmd: &SpecCommand) -> Option<&SpecArg> {
1273 cmd.args.iter().find(|arg| arg.sigil.is_none())
1274}
1275
1276fn first_active_arg(cmd: &SpecCommand) -> Option<&SpecArg> {
1278 cmd.clause
1279 .as_ref()
1280 .and_then(|clause| clause.args.first())
1281 .or_else(|| first_ordinary_arg(cmd))
1282}
1283
1284fn one_line(text: Option<&str>) -> String {
1290 text.unwrap_or_default()
1291 .lines()
1292 .next()
1293 .unwrap_or_default()
1294 .trim()
1295 .to_string()
1296}
1297
1298fn resolve_config_key<'a>(
1316 config: &'a usage::spec::config::SpecConfig,
1317 key: &str,
1318) -> Option<&'a SpecConfigProp> {
1319 let (_, mut prop) = config
1320 .props
1321 .iter()
1322 .find(|(name, prop)| name.as_str() == key || prop.aliases.iter().any(|a| a == key))?;
1323 for _ in 0..config.props.len() {
1324 let Some(target) = &prop.renamed_to else {
1325 return Some(prop);
1326 };
1327 let Some(next) = config.props.get(target) else {
1331 return Some(prop);
1332 };
1333 prop = next;
1334 }
1335 Some(prop)
1336}
1337
1338fn accepts_unenumerable_values(ty: &SpecConfigType) -> bool {
1344 match ty {
1345 SpecConfigType::Base(Base::Bool) => false,
1346 SpecConfigType::Option(inner) => accepts_unenumerable_values(inner),
1347 SpecConfigType::Union(members) => members.iter().any(accepts_unenumerable_values),
1348 _ => true,
1351 }
1352}
1353
1354fn holds_a_boolean(ty: &SpecConfigType) -> bool {
1360 match ty {
1361 SpecConfigType::Base(Base::Bool) => true,
1362 SpecConfigType::Option(inner) => holds_a_boolean(inner),
1363 SpecConfigType::Union(members) => members.iter().any(holds_a_boolean),
1364 _ => false,
1367 }
1368}
1369
1370fn zsh_shell_quote(s: &str) -> String {
1371 fn safe(c: char) -> bool {
1372 matches!(c,
1373 'a'..='z' | 'A'..='Z' | '0'..='9'
1374 | '_' | '-' | '.' | '/' | ':' | '@' | '+' | '=' | '%' | ','
1375 )
1376 }
1377 if !s.is_empty() && s.chars().all(safe) {
1378 return s.to_string();
1379 }
1380 let escaped = s.replace('\'', "'\\''");
1382 format!("'{escaped}'")
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387 use super::{command_name_starts_with, render_completer_run, rendered_separator};
1388
1389 #[test]
1390 fn a_slash_in_the_token_is_kept() {
1391 assert_eq!(rendered_separator("target/de"), "/");
1393 assert_eq!(rendered_separator("/abs/path"), "/");
1394 }
1395
1396 #[test]
1397 fn a_token_with_no_separator_yet_gets_a_slash() {
1398 assert_eq!(rendered_separator(""), "/");
1400 assert_eq!(rendered_separator("target"), "/");
1401 }
1402
1403 #[test]
1404 fn a_backslash_is_a_separator_only_on_windows() {
1405 let expected = if cfg!(windows) { "\\" } else { "/" };
1409 assert_eq!(rendered_separator(r"target\de"), expected);
1410 assert_eq!(rendered_separator(r"C:\Users\me"), expected);
1411 }
1412
1413 #[test]
1414 fn a_mixed_token_settles_on_the_slash() {
1415 assert_eq!(rendered_separator(r"target\de/inc"), "/");
1418 }
1419
1420 #[test]
1421 fn windows_command_prefixes_ignore_ascii_case() {
1422 assert!(command_name_starts_with("Cargo.EXE", "car", true));
1423 assert!(!command_name_starts_with("Cargo.EXE", "car", false));
1424 }
1425
1426 #[test]
1427 fn completer_templates_can_shell_quote_typed_words() {
1428 let mut ctx = tera::Context::new();
1429 ctx.insert("word", "a'b; echo injected");
1430 let rendered = render_completer_run("printf '%s\\n' {{ word | shell_quote }}", &ctx)
1431 .expect("the filter should render");
1432 assert_eq!(rendered, "printf '%s\\n' 'a'\\''b; echo injected'");
1433 }
1434
1435 #[cfg(unix)]
1436 #[test]
1437 fn shell_quoted_template_values_remain_one_literal_argument() {
1438 let mut ctx = tera::Context::new();
1439 ctx.insert("word", "$(printf injected); a'b");
1440 let rendered = render_completer_run("printf '%s\\n' {{ word | shell_quote }}", &ctx)
1441 .expect("the filter should render");
1442 let stdout = usage::sh::sh(&rendered).expect("the rendered command should run");
1443 assert_eq!(stdout, "$(printf injected); a'b\n");
1444 }
1445
1446 #[test]
1447 fn shell_quote_rejects_non_strings() {
1448 let mut ctx = tera::Context::new();
1449 ctx.insert("word", &42);
1450 let err = render_completer_run("{{ word | shell_quote }}", &ctx).unwrap_err();
1451 assert!(
1452 err.to_string().contains("shell_quote expects a string"),
1453 "{err}"
1454 );
1455 }
1456
1457 #[cfg(unix)]
1458 #[test]
1459 fn shell_join_preserves_the_argv_vector_when_forwarded_as_one_argument() {
1460 let expected = ["ex", "two words", "a'b"];
1461 let mut ctx = tera::Context::new();
1462 ctx.insert("words", &expected);
1463 let rendered = render_completer_run(
1464 "printf '%s\\n' {{ words | shell_join | shell_quote }}",
1465 &ctx,
1466 )
1467 .expect("the filters should render");
1468 let stdout = usage::sh::sh(&rendered).expect("the rendered command should run");
1469 let reparsed = shell_words::split(stdout.trim()).expect("the joined value should parse");
1470 assert_eq!(reparsed, expected);
1471 }
1472}