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