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};
14use usage::sh::sh;
15use usage::spec::config::SpecConfigProp;
16use usage::spec::config_type::{Base, SpecConfigType};
17use usage::{Spec, SpecArg, SpecCommand, SpecComplete, SpecDoubleDashChoices, SpecFlag};
18
19use crate::cli::generate;
20
21static COMPLETER_TERA: LazyLock<tera::Tera> = LazyLock::new(|| {
22 let mut tera = tera::Tera::default();
23 tera.register_filter(
24 "shell_quote",
25 |value: &tera::Value, _: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
26 let value = value
27 .as_str()
28 .ok_or_else(|| tera::Error::message("shell_quote expects a string"))?;
29 Ok(shell_words::quote(value).into_owned())
30 },
31 );
32 tera.register_filter(
33 "shell_join",
34 |value: &tera::Value, _: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
35 let values = value
36 .as_array()
37 .ok_or_else(|| tera::Error::message("shell_join expects a list of strings"))?;
38 let words = values
39 .iter()
40 .map(|value| {
41 value
42 .as_str()
43 .ok_or_else(|| tera::Error::message("shell_join expects a list of strings"))
44 })
45 .collect::<tera::TeraResult<Vec<_>>>()?;
46 Ok(shell_words::join(words))
47 },
48 );
49 tera
50});
51
52fn render_completer_run(run: &str, ctx: &tera::Context) -> tera::TeraResult<String> {
53 COMPLETER_TERA.render_str(run, ctx, false)
54}
55
56#[derive(Debug, Args)]
62#[usage(alias = "cw", effect = "read")]
63pub struct CompleteWord {
64 words: Vec<String>,
66
67 #[usage(short, long)]
69 file: Option<PathBuf>,
70
71 #[usage(short, long, required_unless = "--file", overrides = "--file")]
73 spec: Option<String>,
74
75 #[usage(long)]
77 cword: Option<usize>,
78
79 #[usage(
81 long,
82 default = "bash",
83 choices("bash", "fish", "nu", "powershell", "zsh")
84 )]
85 shell: String,
86}
87
88pub fn candidates(
94 spec: &Spec,
95 words: &[String],
96 cword: usize,
97 shell: &str,
98) -> usage::miette::Result<Vec<(String, String)>> {
99 Ok(answer(spec, words, cword, shell)?.candidates)
100}
101
102#[derive(Debug, PartialEq, Eq)]
108pub struct CandidateAnswer {
109 pub candidates: Vec<(String, String)>,
111 pub files: bool,
113}
114
115pub fn answer(
117 spec: &Spec,
118 words: &[String],
119 cword: usize,
120 shell: &str,
121) -> usage::miette::Result<CandidateAnswer> {
122 CompleteWord {
123 words: words.to_vec(),
124 file: None,
125 spec: None,
126 cword: Some(cword),
127 shell: shell.to_string(),
128 }
129 .complete_word_answer(spec)
130}
131
132impl CompleteWord {
133 pub fn complete_word(&self, spec: &Spec) -> usage::miette::Result<Vec<(String, String)>> {
134 Ok(self.complete_word_answer(spec)?.candidates)
135 }
136
137 fn complete_word_answer(&self, spec: &Spec) -> usage::miette::Result<CandidateAnswer> {
138 let cword = self.cword.unwrap_or(self.words.len().max(1) - 1);
139 let ctoken = self.words.get(cword).cloned().unwrap_or_default();
140 let words: Vec<_> = self.words.iter().take(cword).cloned().collect();
141
142 trace!(
143 "cword: {cword} ctoken: {ctoken} words: {}",
144 words.iter().join(" ")
145 );
146
147 let mut ctx = tera::Context::new();
148 ctx.insert("words", &self.words);
149 ctx.insert("CURRENT", &cword);
150 if cword > 0 {
151 ctx.insert("PREV", &(cword - 1));
152 }
153
154 let parsed = usage::parse::parse_partial(spec, &words)?;
155 debug!("parsed cmd: {}", parsed.cmd.full_cmd.join(" "));
156
157 if parsed.external.is_some() {
163 return Ok(CandidateAnswer {
164 candidates: vec![],
165 files: false,
166 });
167 }
168
169 let prev_token = if cword > 0 {
172 self.words.get(cword - 1).map(|s| s.as_str())
173 } else {
174 None
175 };
176 let after_restart_token = parsed
177 .cmd
178 .restart_token
179 .as_ref()
180 .is_some_and(|rt| prev_token == Some(rt.as_str()))
181 || parsed
182 .cmd
183 .clause
184 .as_ref()
185 .and_then(|clause| clause.separator.as_deref())
186 .is_some_and(|separator| prev_token == Some(separator));
187
188 let cx = Ctx {
189 tera: &ctx,
190 spec,
191 parsed: &parsed,
192 after_restart_token,
193 };
194 let mut has_explicit_choices = false;
195 let mut flags = parsed.completion_flags();
198 if spec.default_subcommand_flags && parsed.cmds.len() == 1 {
199 if let Some(default) = spec
200 .default_subcommand
201 .as_deref()
202 .and_then(|name| spec.cmd.find_subcommand(name))
203 {
204 for flag in &default.flags {
205 let flag = Arc::new(flag.clone());
206 for key in flag
207 .long
208 .iter()
209 .map(|name| format!("--{name}"))
210 .chain(flag.short.iter().map(|name| format!("-{name}")))
211 .chain(flag.negate.iter().cloned())
212 {
213 flags.entry(key).or_insert_with(|| Arc::clone(&flag));
214 }
215 }
216 }
217 }
218 let restart_seen = parsed.tokens.iter().any(|token| {
221 token
222 .roles
223 .iter()
224 .any(|role| matches!(role, usage::parse::TokenRole::Restart))
225 });
226 let automatic_trailing_seen = parsed
227 .tokens
228 .iter()
229 .rev()
230 .take_while(|token| {
231 !token.roles.iter().any(|role| {
232 matches!(
233 role,
234 usage::parse::TokenRole::Restart
235 | usage::parse::TokenRole::ClauseSeparator { .. }
236 )
237 })
238 })
239 .flat_map(|token| &token.roles)
240 .any(|role| match role {
241 usage::parse::TokenRole::Arg { arg, .. }
242 | usage::parse::TokenRole::Sigil { arg, .. } => {
243 arg.double_dash == usage::SpecDoubleDashChoices::Automatic
244 }
245 _ => false,
246 });
247 let flags_possible = !parsed.double_dash_seen && !automatic_trailing_seen;
248 let sigil_arg = (flags_possible
249 && !restart_seen
250 && !after_restart_token
251 && parsed.flag_awaiting_value.is_empty())
252 .then(|| {
253 parsed
254 .cmds
255 .iter()
256 .flat_map(|cmd| cmd.args.iter().map(move |arg| (cmd, arg)))
257 .filter_map(|(cmd, arg)| {
258 let sigil = arg.sigil.as_deref()?;
259 ctoken
260 .strip_prefix(sigil)
261 .map(|prefix| (cmd, arg, sigil, prefix))
262 })
263 .max_by_key(|(_, _, sigil, _)| sigil.len())
264 })
265 .flatten();
266 let attached_long_value = flags_possible
267 .then(|| Self::attached_long_value(&flags, &ctoken))
268 .flatten();
269 let mut used_file_fallback = false;
270 let mut choices = if flags_possible && ctoken == "-" {
271 let shorts = self.complete_short_flag_names(&flags, "");
272 let longs = self.complete_long_flag_names(&flags, "");
273 shorts.into_iter().chain(longs).collect::<Vec<_>>()
274 } else if flags_possible && ctoken.starts_with("--") {
275 if let Some((flag, form, prefix)) = attached_long_value {
276 let arg = flag.arg.as_ref().unwrap();
277 let mut attached_ctx = ctx.clone();
280 let mut attached_words = self.words.clone();
281 if let Some(current) = attached_words.get_mut(cword) {
282 *current = prefix.to_string();
283 }
284 attached_ctx.insert("words", &attached_words);
285 let attached_cx = Ctx {
286 tera: &attached_ctx,
287 spec: cx.spec,
288 parsed: cx.parsed,
289 after_restart_token: cx.after_restart_token,
290 };
291 let (mut found, closed) =
292 self.complete_arg(&attached_cx, &parsed.cmd, arg, prefix)?;
293 has_explicit_choices = closed || arg.choices.is_some();
294 if found.is_empty() && !has_explicit_choices {
295 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
296 found = self
297 .complete_path(&cwd, prefix, |_| true)
298 .into_iter()
299 .map(|name| (name, String::new()))
300 .collect();
301 used_file_fallback = true;
302 }
303 Self::attach_long_value_candidates(&mut found, form);
304 found
305 } else {
306 self.complete_long_flag_names(&flags, &ctoken)
307 }
308 } else if flags_possible && ctoken.starts_with('-') {
309 self.complete_short_flag_names(&flags, &ctoken)
310 } else if after_restart_token {
311 let mut choices = vec![];
315 if let Some(arg) = first_active_arg(&parsed.cmd) {
316 let (found, constrained) = self.complete_positional(
317 &cx,
318 &parsed.cmd,
319 arg,
320 &ctoken,
321 parsed.double_dash_seen,
322 )?;
323 has_explicit_choices = constrained;
324 choices.extend(found);
325 }
326 choices
327 } else if let Some(flag) = parsed.flag_awaiting_value.first() {
328 let arg = flag.arg.as_ref().unwrap();
329 let (found, closed) = self.complete_arg(&cx, &parsed.cmd, arg, &ctoken)?;
330 has_explicit_choices = closed || arg.choices.is_some();
331 found
332 } else if let Some((owner, arg, sigil, prefix)) = sigil_arg {
333 let mut sigil_ctx = ctx.clone();
338 let mut sigil_words = self.words.clone();
339 if let Some(current) = sigil_words.get_mut(cword) {
340 *current = prefix.to_string();
341 }
342 sigil_ctx.insert("words", &sigil_words);
343 let sigil_cx = Ctx {
344 tera: &sigil_ctx,
345 spec: cx.spec,
346 parsed: cx.parsed,
347 after_restart_token: cx.after_restart_token,
348 };
349 let (mut found, closed) = self.complete_arg(&sigil_cx, owner, arg, prefix)?;
350 for (candidate, _) in &mut found {
351 candidate.insert_str(0, sigil);
352 }
353 has_explicit_choices = closed || arg.choices.is_some() || found.is_empty();
354 found
355 } else {
356 let mut choices = vec![];
357 if let Some(arg) = parsed.next_arg.as_deref() {
358 let (found, constrained) = self.complete_positional(
359 &cx,
360 &parsed.cmd,
361 arg,
362 &ctoken,
363 parsed.double_dash_seen,
364 )?;
365 has_explicit_choices = constrained;
366 choices.extend(found);
367 }
368 if !parsed.cmd.subcommands.is_empty() {
369 choices.extend(self.complete_subcommands(&parsed.cmd, &ctoken));
370 }
371 if parsed.cmd.name == spec.cmd.name {
373 if let Some(default_name) = &spec.default_subcommand {
374 if let Some(default_cmd) = spec.cmd.find_subcommand(default_name) {
375 if let Some(arg) = first_active_arg(default_cmd) {
386 let (found, _) = self.complete_positional(
387 &cx,
388 default_cmd,
389 arg,
390 &ctoken,
391 parsed.double_dash_seen,
392 )?;
393 choices.extend(found);
394 }
395 }
396 }
397 }
398 choices
399 };
400 let looks_like_a_flag = flags_possible && ctoken.starts_with('-');
404 let files = used_file_fallback
405 || (choices.is_empty() && !looks_like_a_flag && !has_explicit_choices);
406 if files && choices.is_empty() {
407 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
408 let files = self.complete_path(&cwd, &ctoken, |_| true);
409 choices = files.into_iter().map(|n| (n, String::new())).collect();
410 }
411 trace!("choices: {}", choices.iter().map(|(c, _)| c).join(", "));
412 Ok(CandidateAnswer {
413 candidates: choices,
414 files,
415 })
416 }
417
418 fn complete_subcommands(&self, cmd: &SpecCommand, ctoken: &str) -> Vec<(String, String)> {
419 trace!("complete_subcommands: {ctoken}");
420 let mut choices = vec![];
421 for subcommand in cmd.subcommands.values() {
422 if subcommand.hide {
423 continue;
424 }
425 choices.push((
426 subcommand.name.clone(),
427 subcommand.help.clone().unwrap_or_default(),
428 ));
429 for alias in &subcommand.aliases {
430 choices.push((alias.clone(), subcommand.help.clone().unwrap_or_default()));
431 }
432 }
433 choices
434 .into_iter()
435 .filter(|(c, _)| c.starts_with(ctoken))
436 .sorted()
437 .collect()
438 }
439
440 fn complete_long_flag_names(
441 &self,
442 flags: &BTreeMap<String, Arc<SpecFlag>>,
443 ctoken: &str,
444 ) -> Vec<(String, String)> {
445 debug!("complete_long_flag_names: {ctoken}");
446 trace!("flags: {}", flags.keys().join(", "));
447 flags
448 .values()
449 .filter(|f| !f.hide)
450 .flat_map(|f| {
451 let mut flags = f
452 .long
453 .iter()
454 .filter(|long| !f.hidden_aliases.contains(long))
455 .map(|l| (format!("--{l}"), f.help.clone().unwrap_or_default()))
456 .collect::<Vec<_>>();
457 if let Some(negate) = &f.negate {
458 flags.push((negate.clone(), String::new()))
459 }
460 flags
461 })
462 .unique_by(|(f, _)| f.to_string())
463 .filter(|(f, _)| f.starts_with(ctoken))
464 .sorted()
466 .collect()
467 }
468
469 fn complete_short_flag_names(
470 &self,
471 flags: &BTreeMap<String, Arc<SpecFlag>>,
472 ctoken: &str,
473 ) -> Vec<(String, String)> {
474 debug!("complete_short_flag_names: {ctoken}");
475 let cur = ctoken.chars().nth(1);
476 flags
477 .values()
478 .filter(|f| !f.hide)
479 .flat_map(|f| {
480 f.short
481 .iter()
482 .filter(|short| !f.hidden_short_aliases.contains(short))
483 })
484 .unique()
485 .filter(|c| cur.is_none() || cur == Some(**c))
486 .map(|c| (format!("-{c}"), String::new()))
488 .sorted()
489 .collect()
490 }
491
492 fn attached_long_value<'f, 't>(
497 flags: &'f BTreeMap<String, Arc<SpecFlag>>,
498 token: &'t str,
499 ) -> Option<(&'f SpecFlag, &'t str, &'t str)> {
500 let (form, prefix) = token.split_once('=')?;
501 let long = form.strip_prefix("--")?;
502 flags
503 .values()
504 .find(|flag| flag.arg.is_some() && flag.long.iter().any(|candidate| candidate == long))
505 .map(|flag| (flag.as_ref(), form, prefix))
506 }
507
508 fn attach_long_value_candidates(candidates: &mut [(String, String)], form: &str) {
509 for (candidate, _) in candidates {
510 *candidate = format!("{form}={candidate}");
511 }
512 }
513
514 fn complete_builtin(
522 &self,
523 cx: &Ctx<'_>,
524 type_: &str,
525 ctoken: &str,
526 ) -> (Vec<(String, String)>, bool) {
527 if let Some(encoded) = type_
528 .strip_prefix("path:")
529 .or_else(|| type_.strip_prefix("file:"))
530 {
531 let extensions = encoded
532 .split(',')
533 .map(|extension| {
534 extension
535 .trim()
536 .trim_start_matches('.')
537 .to_ascii_lowercase()
538 })
539 .filter(|extension| !extension.is_empty())
540 .collect::<Vec<_>>();
541 if !extensions.is_empty() {
542 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
543 let paths = self.complete_path(&cwd, ctoken, |path| {
544 path.is_dir()
545 || path
546 .file_name()
547 .and_then(|name| name.to_str())
548 .is_some_and(|name| {
549 let name = name.to_ascii_lowercase();
550 extensions
551 .iter()
552 .any(|wanted| name.ends_with(&format!(".{wanted}")))
553 })
554 });
555 return (
556 paths
557 .into_iter()
558 .map(|value| (value, String::new()))
559 .collect(),
560 true,
561 );
562 }
563 }
564 match type_ {
567 "config_keys" => return (self.complete_config_keys(cx.spec, ctoken), true),
568 "config_values" => return self.complete_config_values(cx, ctoken),
569 "executable" => {
570 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
571 return (
572 self.complete_path(&cwd, ctoken, |path| path.is_dir() || is_executable(path))
573 .into_iter()
574 .map(|value| (value, String::new()))
575 .collect(),
576 true,
577 );
578 }
579 "command" => return (self.complete_commands(ctoken), true),
580 "username" => return (complete_usernames(ctoken), true),
581 "hostname" => return (complete_hostnames(ctoken), true),
582 "none" | "url" | "email" => return (vec![], true),
583 "unknown" => {}
585 "command_args" => {
586 let command_was_bound = cx.parsed.next_arg.as_ref().is_some_and(|next| {
587 cx.parsed
592 .args
593 .keys()
594 .any(|bound| bound.as_ref() == next.as_ref())
595 });
596 if cx.after_restart_token || !command_was_bound {
597 return (self.complete_commands(ctoken), true);
598 }
599 }
600 _ => {}
601 }
602 let names = match (type_, env::current_dir()) {
603 ("path" | "file", Ok(cwd)) => self.complete_path(&cwd, ctoken, |_| true),
604 ("dir", Ok(cwd)) => self.complete_path(&cwd, ctoken, |p| p.is_dir()),
605 _ => vec![],
607 };
608 (
609 names.into_iter().map(|n| (n, String::new())).collect(),
610 false,
611 )
612 }
613
614 fn complete_config_keys(&self, spec: &Spec, ctoken: &str) -> Vec<(String, String)> {
623 spec.config
624 .props
625 .iter()
626 .filter(|(_, prop)| !prop.hide)
627 .filter(|(key, _)| key.starts_with(ctoken))
628 .map(|(key, prop)| {
629 let help = one_line(prop.help.as_deref());
630 let help = help.as_str();
631 let description = match &prop.deprecated {
634 Some(_) if help.is_empty() => "deprecated".to_string(),
635 Some(_) => format!("deprecated — {help}"),
636 None => help.to_string(),
637 };
638 (key.clone(), description)
639 })
640 .collect()
641 }
642
643 fn complete_config_values(&self, cx: &Ctx<'_>, ctoken: &str) -> (Vec<(String, String)>, bool) {
649 let Some(prop) = self.config_key_before_cursor(cx) else {
650 return (vec![], false);
653 };
654 if !prop.choices.is_empty() {
655 return (
656 prop.choices
657 .iter()
658 .map(|choice| (choice.value.display(), one_line(choice.help.as_deref())))
659 .filter(|(value, _)| value.starts_with(ctoken))
660 .collect(),
661 true,
665 );
666 }
667 if holds_a_boolean(&prop.value_type.clone().unwrap_or_default()) {
671 let declared = prop.value_type.clone().unwrap_or_default();
672 return (
673 ["false", "true"]
674 .into_iter()
675 .filter(|value| value.starts_with(ctoken))
676 .map(|value| (value.to_string(), String::new()))
677 .collect(),
678 !accepts_unenumerable_values(&declared),
682 );
683 }
684 (vec![], false)
687 }
688
689 fn config_key_before_cursor<'a>(&self, cx: &Ctx<'a>) -> Option<&'a SpecConfigProp> {
699 cx.parsed
706 .args
707 .iter()
708 .filter(|(arg, _)| self.completer_type(cx, arg) == Some("config_keys"))
709 .filter_map(|(_, value)| match value {
710 ParseValue::String(word) => Some(word.as_str()),
711 ParseValue::MultiString(words) => words.last().map(String::as_str),
712 ParseValue::Bool(_) | ParseValue::MultiBool(_) => None,
713 })
714 .next_back()
731 .and_then(|word| resolve_config_key(&cx.spec.config, word))
732 }
733
734 fn completer_type<'a>(&self, cx: &Ctx<'a>, arg: &SpecArg) -> Option<&'a str> {
736 let name = arg.name.to_lowercase();
737 cx.spec
738 .complete
739 .get(&name)
740 .or_else(|| cx.parsed.cmd.complete.get(&name))
741 .and_then(|complete| complete.type_.as_deref())
742 }
743
744 fn complete_positional(
755 &self,
756 cx: &Ctx<'_>,
757 cmd: &SpecCommand,
758 arg: &SpecArg,
759 ctoken: &str,
760 double_dash_seen: bool,
761 ) -> usage::miette::Result<(Vec<(String, String)>, bool)> {
762 if arg.double_dash == SpecDoubleDashChoices::Required && !double_dash_seen {
763 let separator = ctoken.is_empty().then(|| ("--".to_string(), String::new()));
765 return Ok((separator.into_iter().collect(), true));
766 }
767 let (found, closed) = self.complete_arg(cx, cmd, arg, ctoken)?;
768 Ok((found, closed || arg.choices.is_some()))
769 }
770
771 fn complete_arg(
772 &self,
773 cx: &Ctx<'_>,
774 cmd: &SpecCommand,
775 arg: &SpecArg,
776 ctoken: &str,
777 ) -> usage::miette::Result<(Vec<(String, String)>, bool)> {
778 static EMPTY_COMPL: LazyLock<SpecComplete> = LazyLock::new(SpecComplete::default);
779
780 trace!("complete_arg: {arg} {ctoken}");
781 let name = arg.name.to_lowercase();
782 let complete = cx
783 .spec
784 .complete
785 .get(&name)
786 .or(cmd.complete.get(&name))
787 .unwrap_or(&EMPTY_COMPL);
788 if let Some(type_) = complete.type_.as_deref() {
789 let (builtin, closed) = self.complete_builtin(cx, type_, ctoken);
793 if !builtin.is_empty() || closed {
794 return Ok((builtin, closed));
795 }
796 }
797
798 if let Some(choices) = &arg.choices {
799 return Ok((
800 choices
801 .values()
802 .into_iter()
803 .filter(|c| c.starts_with(ctoken))
804 .map(|value| {
805 let help = choices
810 .details
811 .iter()
812 .find(|detail| detail.value == value)
813 .and_then(|detail| detail.help.clone())
814 .unwrap_or_default();
815 (value, help)
816 })
817 .collect(),
818 true,
819 ));
820 }
821 if let Some(run) = &complete.run {
822 let run = render_completer_run(run, cx.tera).into_diagnostic()?;
823 trace!("run: {run}");
824 let stdout = sh(&run)?;
825 static DESCRIPTION_SEPARATOR: LazyLock<Regex> =
827 LazyLock::new(|| Regex::new(r"[^\\]:").unwrap());
828 let re = &*DESCRIPTION_SEPARATOR;
829 return Ok((
830 stdout
831 .lines()
832 .map(|l| {
833 if complete.descriptions {
834 match re.find(l).map(|m| l.split_at(m.end() - 1)) {
835 Some((l, d)) if d.len() <= 1 => {
836 (l.trim().replace("\\:", ":"), String::new())
837 }
838 Some((l, d)) => (
839 l.trim().replace("\\:", ":"),
840 d[1..].trim().replace("\\:", ":"),
841 ),
842 None => (l.trim().replace("\\:", ":"), String::new()),
843 }
844 } else {
845 (l.trim().to_string(), String::new())
846 }
847 })
848 .filter(|(name, _)| name.starts_with(ctoken))
849 .collect(),
850 false,
853 ));
854 }
855
856 if complete.type_.is_none() {
862 let (builtin, closed) = self.complete_builtin(cx, &name, ctoken);
863 if !builtin.is_empty() || closed {
864 return Ok((builtin, closed));
865 }
866 }
867
868 Ok((vec![], false))
869 }
870
871 fn complete_path(
872 &self,
873 base: &Path,
874 ctoken: &str,
875 filter: impl Fn(&Path) -> bool,
876 ) -> Vec<String> {
877 trace!("complete_path: {ctoken}");
878 let separator = rendered_separator(ctoken);
879 let path = PathBuf::from(ctoken);
880 let exact = if path.is_absolute() {
881 path.clone()
882 } else {
883 base.join(&path)
884 };
885 let trailing_separator = (ctoken.ends_with(std::path::MAIN_SEPARATOR)
889 || (cfg!(windows) && ctoken.ends_with('/')))
890 && exact.is_dir();
891 let (parent, prefix) = if trailing_separator {
892 (path.as_path(), "")
893 } else {
894 (
895 path.parent().unwrap_or_else(|| Path::new("")),
896 path.file_name()
897 .unwrap_or_default()
898 .to_str()
899 .unwrap_or_default(),
900 )
901 };
902
903 resolve_path_dirs(base, parent)
904 .into_iter()
905 .flat_map(|dir| std::fs::read_dir(dir).ok().into_iter().flatten())
906 .filter_map(Result::ok)
907 .filter(|de| {
908 let name = de.file_name();
909 let name = name.to_string_lossy();
910 !name.starts_with('.') && name.starts_with(prefix)
911 })
912 .filter(|de| filter(&de.path()))
913 .map(|de| {
914 let p = de.path();
915 let is_dir = de
916 .file_type()
917 .map(|ft| ft.is_dir())
918 .unwrap_or_else(|_| p.is_dir());
919 let mut s = p
920 .strip_prefix(base)
921 .unwrap_or(&p)
922 .to_string_lossy()
923 .replace(std::path::MAIN_SEPARATOR, separator);
924 if is_dir {
925 s.push_str(separator);
926 }
927 s
928 })
929 .sorted()
930 .collect()
931 }
932
933 fn complete_commands(&self, ctoken: &str) -> Vec<(String, String)> {
934 if ctoken.contains(std::path::MAIN_SEPARATOR) || (cfg!(windows) && ctoken.contains('/')) {
935 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
936 return self
937 .complete_path(&cwd, ctoken, |path| path.is_dir() || is_executable(path))
938 .into_iter()
939 .map(|value| (value, String::new()))
940 .collect();
941 }
942
943 let mut found = BTreeSet::new();
944 if let Some(path) = env::var_os("PATH") {
945 for dir in env::split_paths(&path) {
946 for entry in std::fs::read_dir(dir).ok().into_iter().flatten().flatten() {
947 let path = entry.path();
948 let name = entry.file_name().to_string_lossy().into_owned();
949 if command_name_starts_with(&name, ctoken, cfg!(windows))
950 && is_executable(&path)
951 {
952 found.insert(name);
953 }
954 }
955 }
956 }
957 found
958 .into_iter()
959 .map(|value| (value, String::new()))
960 .collect()
961 }
962}
963
964impl usage_rs::Run for CompleteWord {
965 type Output = usage::miette::Result<()>;
966
967 fn run(self) -> Self::Output {
968 let spec = generate::file_or_spec(&self.file, &self.spec)?;
969 let choices = self.complete_word(&spec)?;
970 let shell = self.shell.as_ref();
971 let any_descriptions = choices.iter().any(|(_, d)| !d.is_empty());
972 for (c, description) in choices {
973 match shell {
974 "bash" => println!("{c}"),
975 "fish" | "nu" | "powershell" => {
976 if any_descriptions {
977 println!("{c}\t{description}")
978 } else {
979 println!("{c}")
980 }
981 }
982 "zsh" => {
983 let insert = zsh_shell_quote(&c);
996 println!("{c}\t{description}\t{insert}")
997 }
998 _ => {
999 usage::miette::bail!("unsupported shell: {}", shell);
1000 }
1001 }
1002 }
1003
1004 Ok(())
1005 }
1006}
1007
1008fn rendered_separator(ctoken: &str) -> &'static str {
1022 if cfg!(windows) && ctoken.contains('\\') && !ctoken.contains('/') {
1023 "\\"
1024 } else {
1025 "/"
1026 }
1027}
1028
1029fn resolve_path_dirs(base: &Path, path: &Path) -> Vec<PathBuf> {
1035 let exact = if path.is_absolute() {
1036 path.to_path_buf()
1037 } else {
1038 base.join(path)
1039 };
1040 if exact.is_dir() {
1041 return vec![exact];
1042 }
1043
1044 let Some(prefix) = path.file_name().and_then(|name| name.to_str()) else {
1045 return Vec::new();
1046 };
1047 let parent = path.parent().unwrap_or_else(|| Path::new(""));
1048 resolve_path_dirs(base, parent)
1049 .into_iter()
1050 .flat_map(|dir| std::fs::read_dir(dir).ok().into_iter().flatten())
1051 .filter_map(Result::ok)
1052 .filter(|entry| {
1053 entry.file_type().is_ok_and(|kind| kind.is_dir())
1054 && entry
1055 .file_name()
1056 .to_str()
1057 .is_some_and(|name| !name.starts_with('.') && name.starts_with(prefix))
1058 })
1059 .map(|entry| entry.path())
1060 .collect()
1061}
1062
1063fn complete_usernames(prefix: &str) -> Vec<(String, String)> {
1064 let mut found = BTreeSet::new();
1065 for key in ["USER", "USERNAME"] {
1066 if let Ok(value) = env::var(key) {
1067 if value.starts_with(prefix) {
1068 found.insert(value);
1069 }
1070 }
1071 }
1072 if let Ok(passwd) = std::fs::read_to_string("/etc/passwd") {
1073 for line in passwd.lines() {
1074 if let Some(name) = line
1075 .split(':')
1076 .next()
1077 .filter(|name| name.starts_with(prefix))
1078 {
1079 found.insert(name.to_string());
1080 }
1081 }
1082 }
1083 found
1084 .into_iter()
1085 .map(|value| (value, String::new()))
1086 .collect()
1087}
1088
1089fn complete_hostnames(prefix: &str) -> Vec<(String, String)> {
1090 let mut found = BTreeSet::new();
1091 for key in ["HOSTNAME", "COMPUTERNAME"] {
1092 if let Ok(value) = env::var(key) {
1093 if value.starts_with(prefix) {
1094 found.insert(value);
1095 }
1096 }
1097 }
1098 if let Ok(hosts) = std::fs::read_to_string("/etc/hosts") {
1099 for line in hosts.lines() {
1100 let line = line.split('#').next().unwrap_or_default();
1101 for name in line.split_whitespace().skip(1) {
1102 if name.starts_with(prefix) {
1103 found.insert(name.to_string());
1104 }
1105 }
1106 }
1107 }
1108 found
1109 .into_iter()
1110 .map(|value| (value, String::new()))
1111 .collect()
1112}
1113
1114fn command_name_starts_with(name: &str, prefix: &str, case_insensitive: bool) -> bool {
1115 if case_insensitive {
1116 name.get(..prefix.len())
1117 .is_some_and(|start| start.eq_ignore_ascii_case(prefix))
1118 } else {
1119 name.starts_with(prefix)
1120 }
1121}
1122
1123#[cfg(unix)]
1124fn is_executable(path: &Path) -> bool {
1125 use std::os::unix::fs::PermissionsExt;
1126 path.metadata()
1127 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
1128}
1129
1130#[cfg(windows)]
1131fn is_executable(path: &Path) -> bool {
1132 let extensions = env::var_os("PATHEXT").unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".into());
1133 path.is_file()
1134 && path.extension().is_some_and(|extension| {
1135 let extension = format!(".{}", extension.to_string_lossy());
1136 extensions
1137 .to_string_lossy()
1138 .split(';')
1139 .any(|candidate| candidate.eq_ignore_ascii_case(&extension))
1140 })
1141}
1142
1143struct Ctx<'a> {
1153 tera: &'a tera::Context,
1154 spec: &'a Spec,
1155 parsed: &'a ParseOutput,
1156 after_restart_token: bool,
1157}
1158
1159fn first_ordinary_arg(cmd: &SpecCommand) -> Option<&SpecArg> {
1161 cmd.args.iter().find(|arg| arg.sigil.is_none())
1162}
1163
1164fn first_active_arg(cmd: &SpecCommand) -> Option<&SpecArg> {
1166 cmd.clause
1167 .as_ref()
1168 .and_then(|clause| clause.args.first())
1169 .or_else(|| first_ordinary_arg(cmd))
1170}
1171
1172fn one_line(text: Option<&str>) -> String {
1178 text.unwrap_or_default()
1179 .lines()
1180 .next()
1181 .unwrap_or_default()
1182 .trim()
1183 .to_string()
1184}
1185
1186fn resolve_config_key<'a>(
1204 config: &'a usage::spec::config::SpecConfig,
1205 key: &str,
1206) -> Option<&'a SpecConfigProp> {
1207 let (_, mut prop) = config
1208 .props
1209 .iter()
1210 .find(|(name, prop)| name.as_str() == key || prop.aliases.iter().any(|a| a == key))?;
1211 for _ in 0..config.props.len() {
1212 let Some(target) = &prop.renamed_to else {
1213 return Some(prop);
1214 };
1215 let Some(next) = config.props.get(target) else {
1219 return Some(prop);
1220 };
1221 prop = next;
1222 }
1223 Some(prop)
1224}
1225
1226fn accepts_unenumerable_values(ty: &SpecConfigType) -> bool {
1232 match ty {
1233 SpecConfigType::Base(Base::Bool) => false,
1234 SpecConfigType::Option(inner) => accepts_unenumerable_values(inner),
1235 SpecConfigType::Union(members) => members.iter().any(accepts_unenumerable_values),
1236 _ => true,
1239 }
1240}
1241
1242fn holds_a_boolean(ty: &SpecConfigType) -> bool {
1248 match ty {
1249 SpecConfigType::Base(Base::Bool) => true,
1250 SpecConfigType::Option(inner) => holds_a_boolean(inner),
1251 SpecConfigType::Union(members) => members.iter().any(holds_a_boolean),
1252 _ => false,
1255 }
1256}
1257
1258fn zsh_shell_quote(s: &str) -> String {
1259 fn safe(c: char) -> bool {
1260 matches!(c,
1261 'a'..='z' | 'A'..='Z' | '0'..='9'
1262 | '_' | '-' | '.' | '/' | ':' | '@' | '+' | '=' | '%' | ','
1263 )
1264 }
1265 if !s.is_empty() && s.chars().all(safe) {
1266 return s.to_string();
1267 }
1268 let escaped = s.replace('\'', "'\\''");
1270 format!("'{escaped}'")
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275 use super::{command_name_starts_with, render_completer_run, rendered_separator};
1276
1277 #[test]
1278 fn a_slash_in_the_token_is_kept() {
1279 assert_eq!(rendered_separator("target/de"), "/");
1281 assert_eq!(rendered_separator("/abs/path"), "/");
1282 }
1283
1284 #[test]
1285 fn a_token_with_no_separator_yet_gets_a_slash() {
1286 assert_eq!(rendered_separator(""), "/");
1288 assert_eq!(rendered_separator("target"), "/");
1289 }
1290
1291 #[test]
1292 fn a_backslash_is_a_separator_only_on_windows() {
1293 let expected = if cfg!(windows) { "\\" } else { "/" };
1297 assert_eq!(rendered_separator(r"target\de"), expected);
1298 assert_eq!(rendered_separator(r"C:\Users\me"), expected);
1299 }
1300
1301 #[test]
1302 fn a_mixed_token_settles_on_the_slash() {
1303 assert_eq!(rendered_separator(r"target\de/inc"), "/");
1306 }
1307
1308 #[test]
1309 fn windows_command_prefixes_ignore_ascii_case() {
1310 assert!(command_name_starts_with("Cargo.EXE", "car", true));
1311 assert!(!command_name_starts_with("Cargo.EXE", "car", false));
1312 }
1313
1314 #[test]
1315 fn completer_templates_can_shell_quote_typed_words() {
1316 let mut ctx = tera::Context::new();
1317 ctx.insert("word", "a'b; echo injected");
1318 let rendered = render_completer_run("printf '%s\\n' {{ word | shell_quote }}", &ctx)
1319 .expect("the filter should render");
1320 assert_eq!(rendered, "printf '%s\\n' 'a'\\''b; echo injected'");
1321 }
1322
1323 #[cfg(unix)]
1324 #[test]
1325 fn shell_quoted_template_values_remain_one_literal_argument() {
1326 let mut ctx = tera::Context::new();
1327 ctx.insert("word", "$(printf injected); a'b");
1328 let rendered = render_completer_run("printf '%s\\n' {{ word | shell_quote }}", &ctx)
1329 .expect("the filter should render");
1330 let stdout = usage::sh::sh(&rendered).expect("the rendered command should run");
1331 assert_eq!(stdout, "$(printf injected); a'b\n");
1332 }
1333
1334 #[test]
1335 fn shell_quote_rejects_non_strings() {
1336 let mut ctx = tera::Context::new();
1337 ctx.insert("word", &42);
1338 let err = render_completer_run("{{ word | shell_quote }}", &ctx).unwrap_err();
1339 assert!(
1340 err.to_string().contains("shell_quote expects a string"),
1341 "{err}"
1342 );
1343 }
1344
1345 #[cfg(unix)]
1346 #[test]
1347 fn shell_join_preserves_the_argv_vector_when_forwarded_as_one_argument() {
1348 let expected = ["ex", "two words", "a'b"];
1349 let mut ctx = tera::Context::new();
1350 ctx.insert("words", &expected);
1351 let rendered = render_completer_run(
1352 "printf '%s\\n' {{ words | shell_join | shell_quote }}",
1353 &ctx,
1354 )
1355 .expect("the filters should render");
1356 let stdout = usage::sh::sh(&rendered).expect("the rendered command should run");
1357 let reparsed = shell_words::split(stdout.trim()).expect("the joined value should parse");
1358 assert_eq!(reparsed, expected);
1359 }
1360}