1use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex};
16use std::{collections as path_std_collections, fmt};
17
18use tau_cli_term_raw::{
19 Candidate, CompletionAcceptance, CompletionView, Span, StyledBlock, StyledText,
20};
21use tau_term_screen::{display_width, truncate_to_width};
22use tau_themes::Theme;
23
24use crate::resolve;
25
26mod git_files;
27
28#[derive(Clone, Debug, PartialEq, Eq, Hash)]
30pub struct CommandName(String);
31
32impl CommandName {
33 pub fn new(name: impl Into<String>) -> Self {
40 let s = name.into();
41 assert!(
42 is_valid_command_name(&s),
43 "CommandName must be ':' followed by one command token"
44 );
45 Self(s)
46 }
47
48 pub fn as_str(&self) -> &str {
50 &self.0
51 }
52}
53
54impl fmt::Display for CommandName {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 f.write_str(&self.0)
57 }
58}
59
60#[derive(Clone, Debug)]
62pub struct CommandCompletion {
63 pub name: CommandName,
65 pub description: String,
67}
68
69impl CommandCompletion {
70 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
77 Self {
78 name: CommandName::new(name),
79 description: description.into(),
80 }
81 }
82}
83
84pub(crate) fn is_valid_command_name(name: &str) -> bool {
85 let Some(token) = name.strip_prefix(':') else {
86 return false;
87 };
88 let mut chars = token.chars();
89 chars.next().is_some_and(|ch| ch.is_ascii_alphanumeric())
90 && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
91}
92
93#[derive(Clone, Debug)]
95pub struct CompletionItem {
96 pub value: String,
98 pub description: String,
100}
101
102impl CompletionItem {
103 pub fn new(value: impl Into<String>, description: impl Into<String>) -> Self {
105 Self {
106 value: value.into(),
107 description: description.into(),
108 }
109 }
110
111 pub fn plain(value: impl Into<String>) -> Self {
113 Self {
114 value: value.into(),
115 description: String::new(),
116 }
117 }
118}
119
120pub type ArgCompleter = Arc<dyn Fn(&[&str]) -> Vec<CompletionItem> + Send + Sync>;
129
130#[derive(Default)]
132struct CompletionInner {
133 static_command_descriptions: HashMap<CommandName, String>,
135 arg_completers: HashMap<CommandName, ArgCompleter>,
136 dynamic_arg_completers: HashMap<CommandName, ArgCompleter>,
137 dynamic_commands: Vec<CommandCompletion>,
138 agent_mention_completer: Option<ArgCompleter>,
139 session_completer: Option<ArgCompleter>,
140}
141
142#[derive(Clone, Default)]
148pub struct CompletionData {
149 inner: Arc<Mutex<CompletionInner>>,
150}
151
152impl CompletionData {
153 pub fn new() -> Self {
155 Self::default()
156 }
157
158 pub fn set_dynamic_commands(&self, commands: Vec<CommandCompletion>) {
161 self.set_dynamic_commands_and_arg_completers(commands, Vec::new());
162 }
163
164 pub fn set_dynamic_commands_and_arg_completers(
167 &self,
168 commands: Vec<CommandCompletion>,
169 arg_completers: Vec<(CommandName, ArgCompleter)>,
170 ) {
171 let mut inner = self.inner.lock().expect("completion data lock");
172 inner.dynamic_commands = commands;
173 inner.dynamic_arg_completers = arg_completers.into_iter().collect();
174 }
175
176 pub fn set_static_command_descriptions(
181 &self,
182 descriptions: impl IntoIterator<Item = (CommandName, String)>,
183 ) {
184 self.inner
185 .lock()
186 .expect("completion data lock")
187 .static_command_descriptions = descriptions.into_iter().collect();
188 }
189
190 pub fn set_arg_completions(&self, command: CommandName, items: Vec<CompletionItem>) {
196 let indexed: Arc<Vec<(CompletionItem, String)>> = Arc::new(
199 items
200 .into_iter()
201 .map(|item| {
202 let lower = item.value.to_lowercase();
203 (item, lower)
204 })
205 .collect(),
206 );
207 let completer: ArgCompleter = Arc::new(move |args: &[&str]| {
208 if args.len() != 1 {
211 return Vec::new();
212 }
213 let needle = args[0].to_lowercase();
214 let mut prefix_matches = Vec::new();
215 let mut substr_matches = Vec::new();
216 for (item, value_lower) in indexed.iter() {
217 if needle.is_empty() || value_lower.starts_with(&needle) {
218 prefix_matches.push(item.clone());
219 } else if value_lower.contains(&needle) {
220 substr_matches.push(item.clone());
221 }
222 }
223 prefix_matches.extend(substr_matches);
224 prefix_matches
225 });
226 self.inner
227 .lock()
228 .expect("completion data lock")
229 .arg_completers
230 .insert(command, completer);
231 }
232
233 pub fn set_arg_completer(&self, command: CommandName, completer: ArgCompleter) {
237 self.inner
238 .lock()
239 .expect("completion data lock")
240 .arg_completers
241 .insert(command, completer);
242 }
243
244 fn get_arg_completer(&self, command: &CommandName) -> Option<ArgCompleter> {
245 let inner = self.inner.lock().expect("completion data lock");
246 inner
247 .arg_completers
248 .get(command)
249 .or_else(|| inner.dynamic_arg_completers.get(command))
250 .cloned()
251 }
252
253 pub fn set_agent_mention_completer(&self, completer: ArgCompleter) {
256 self.inner
257 .lock()
258 .expect("completion data lock")
259 .agent_mention_completer = Some(completer);
260 }
261
262 pub fn set_session_completer(&self, completer: ArgCompleter) {
265 self.inner
266 .lock()
267 .expect("completion data lock")
268 .session_completer = Some(completer);
269 }
270
271 fn get_agent_mention_completer(&self) -> Option<ArgCompleter> {
272 self.inner
273 .lock()
274 .expect("completion data lock")
275 .agent_mention_completer
276 .clone()
277 }
278
279 fn get_session_completer(&self) -> Option<ArgCompleter> {
280 self.inner
281 .lock()
282 .expect("completion data lock")
283 .session_completer
284 .clone()
285 }
286
287 fn root_commands(&self) -> (HashMap<CommandName, String>, Vec<CommandCompletion>) {
288 let inner = self.inner.lock().expect("completion data lock");
289 (
290 inner.static_command_descriptions.clone(),
291 inner.dynamic_commands.clone(),
292 )
293 }
294
295 fn dynamic_commands(&self) -> Vec<CommandCompletion> {
296 self.inner
297 .lock()
298 .expect("completion data lock")
299 .dynamic_commands
300 .clone()
301 }
302}
303
304#[derive(Clone, Debug, PartialEq, Eq)]
306pub enum CompletionRuleKind {
307 Agents,
309 Sessions,
311 Path,
313 PathFuzzy,
316 Actions,
318 Command(Vec<String>),
320}
321
322#[derive(Clone, Debug, PartialEq, Eq)]
324pub struct CompletionRule {
325 pub prefix: String,
327 pub kind: CompletionRuleKind,
329}
330
331#[derive(Clone, Debug, PartialEq, Eq)]
333pub(super) struct CompletionCommand {
334 program: String,
336 args: Vec<String>,
338}
339
340impl CompletionCommand {
341 fn from_argv(argv: Vec<String>) -> Option<Self> {
343 let mut argv = argv.into_iter();
344 Some(Self {
345 program: argv.next()?,
346 args: argv.collect(),
347 })
348 }
349
350 pub(super) fn program(&self) -> &str {
352 &self.program
353 }
354
355 pub(super) fn args(&self) -> &[String] {
357 &self.args
358 }
359}
360
361pub(super) enum CommandCompletionMatch<'a> {
363 Command(&'a CompletionCommand),
365 EmptyCommand,
368}
369
370#[derive(Clone, Debug)]
372struct RuntimeCompletionRule {
373 prefix: String,
375 kind: RuntimeCompletionRuleKind,
377}
378
379impl From<&CompletionRule> for RuntimeCompletionRule {
380 fn from(rule: &CompletionRule) -> Self {
381 Self {
382 prefix: rule.prefix.clone(),
383 kind: (&rule.kind).into(),
384 }
385 }
386}
387
388#[derive(Clone, Debug)]
390enum RuntimeCompletionRuleKind {
391 Agents,
393 Sessions,
395 Path,
397 PathFuzzy,
399 Actions,
401 Command(Option<CompletionCommand>),
403}
404
405impl From<&CompletionRuleKind> for RuntimeCompletionRuleKind {
406 fn from(kind: &CompletionRuleKind) -> Self {
407 match kind {
408 CompletionRuleKind::Agents => Self::Agents,
409 CompletionRuleKind::Sessions => Self::Sessions,
410 CompletionRuleKind::Path => Self::Path,
411 CompletionRuleKind::PathFuzzy => Self::PathFuzzy,
412 CompletionRuleKind::Actions => Self::Actions,
413 CompletionRuleKind::Command(argv) => {
414 Self::Command(CompletionCommand::from_argv(argv.clone()))
415 }
416 }
417 }
418}
419
420impl CompletionRule {
421 pub fn parse(prefix: impl Into<String>, spec: &str) -> Option<Self> {
424 let prefix = prefix.into();
425 let mut parts = spec.split_whitespace();
426 let name = parts.next()?;
427 let kind = match name {
428 "complete_agents" => CompletionRuleKind::Agents,
429 "complete_sessions" => CompletionRuleKind::Sessions,
430 "complete_path" => CompletionRuleKind::Path,
431 "complete_path_fuzzy" => CompletionRuleKind::PathFuzzy,
432 "complete_actions" => CompletionRuleKind::Actions,
433 "complete_with_command" => {
434 let args = parts.map(ToOwned::to_owned).collect::<Vec<_>>();
435 if args.is_empty() {
436 return None;
437 }
438 CompletionRuleKind::Command(args)
439 }
440 _ => return None,
441 };
442 Some(Self { prefix, kind })
443 }
444}
445
446#[derive(Clone, Debug)]
448pub struct CompletionRules {
449 rules: Vec<CompletionRule>,
452 command_rules: CompletionCommandRules,
455}
456
457impl CompletionRules {
458 pub fn new(mut rules: Vec<CompletionRule>) -> Self {
460 rules.sort_by(|a, b| {
461 b.prefix
462 .len()
463 .cmp(&a.prefix.len())
464 .then(a.prefix.cmp(&b.prefix))
465 });
466 let command_rules = CompletionCommandRules::from_public_rules(&rules);
467 Self {
468 rules,
469 command_rules,
470 }
471 }
472
473 pub fn built_in() -> Self {
475 Self::new(vec![
476 CompletionRule::parse("@", "complete_agents").expect("valid built-in completion"),
477 CompletionRule::parse("&", "complete_sessions").expect("valid built-in completion"),
478 CompletionRule::parse("./", "complete_path").expect("valid built-in completion"),
479 CompletionRule::parse("../", "complete_path").expect("valid built-in completion"),
480 CompletionRule::parse("/", "complete_path").expect("valid built-in completion"),
481 CompletionRule::parse("~", "complete_path").expect("valid built-in completion"),
482 CompletionRule::parse("~/", "complete_path").expect("valid built-in completion"),
483 ])
484 }
485
486 fn matching_rule(&self, token_prefix: &str) -> Option<&CompletionRule> {
487 self.rules
488 .iter()
489 .find(|rule| token_prefix.starts_with(&rule.prefix))
490 }
491
492 pub fn command_for_exact_token<'a>(
495 &'a self,
496 buffer: &'a str,
497 cursor: usize,
498 ) -> Option<(&'a [String], &'a str, &'a str)> {
499 let token = exact_command_token(buffer, cursor)?;
500 let rule = self.rules.iter().find(|rule| rule.prefix == token.prefix)?;
501 match &rule.kind {
502 CompletionRuleKind::Command(command) => Some((command, token.before, token.after)),
503 _ => None,
504 }
505 }
506
507 pub(super) fn command_rules(&self) -> &CompletionCommandRules {
509 &self.command_rules
510 }
511}
512
513#[derive(Clone, Debug)]
515pub(super) struct CompletionCommandRules {
516 rules: Vec<RuntimeCompletionRule>,
519}
520
521impl CompletionCommandRules {
522 fn from_public_rules(rules: &[CompletionRule]) -> Self {
524 Self {
525 rules: rules.iter().map(RuntimeCompletionRule::from).collect(),
526 }
527 }
528
529 pub(super) fn command_for_exact_token<'a>(
532 &'a self,
533 buffer: &'a str,
534 cursor: usize,
535 ) -> Option<(CommandCompletionMatch<'a>, &'a str, &'a str)> {
536 let token = exact_command_token(buffer, cursor)?;
537 let rule = self.rules.iter().find(|rule| rule.prefix == token.prefix)?;
538 match &rule.kind {
539 RuntimeCompletionRuleKind::Command(Some(command)) => Some((
540 CommandCompletionMatch::Command(command),
541 token.before,
542 token.after,
543 )),
544 RuntimeCompletionRuleKind::Command(None) => Some((
545 CommandCompletionMatch::EmptyCommand,
546 token.before,
547 token.after,
548 )),
549 _ => None,
550 }
551 }
552}
553
554fn exact_command_token(buffer: &str, cursor: usize) -> Option<PathToken<'_>> {
557 if first_non_whitespace_starts_command(buffer) {
558 return None;
559 }
560 let token = word_token(buffer, cursor)?;
561 if buffer
562 .get(cursor..)?
563 .chars()
564 .next()
565 .is_some_and(|ch| !ch.is_whitespace())
566 {
567 return None;
568 }
569 Some(token)
570}
571
572impl Default for CompletionRules {
573 fn default() -> Self {
574 Self::built_in()
575 }
576}
577
578pub fn build_candidates(
580 commands: &[CommandCompletion],
581 data: &CompletionData,
582 buffer: &str,
583 cursor: usize,
584) -> Vec<Candidate> {
585 build_candidates_with_rules(commands, data, &CompletionRules::default(), buffer, cursor)
586}
587
588pub fn build_candidates_with_rules(
590 commands: &[CommandCompletion],
591 data: &CompletionData,
592 rules: &CompletionRules,
593 buffer: &str,
594 cursor: usize,
595) -> Vec<Candidate> {
596 build_candidates_with_home_and_rules(
597 commands,
598 data,
599 rules,
600 buffer,
601 cursor,
602 home_dir().as_deref(),
603 )
604}
605
606#[cfg(test)]
607pub(crate) fn build_candidates_with_home(
608 commands: &[CommandCompletion],
609 data: &CompletionData,
610 buffer: &str,
611 cursor: usize,
612 home_dir: Option<&Path>,
613) -> Vec<Candidate> {
614 build_candidates_with_home_and_rules(
615 commands,
616 data,
617 &CompletionRules::default(),
618 buffer,
619 cursor,
620 home_dir,
621 )
622}
623
624pub(crate) fn build_candidates_with_home_and_rules(
625 commands: &[CommandCompletion],
626 data: &CompletionData,
627 rules: &CompletionRules,
628 buffer: &str,
629 cursor: usize,
630 home_dir: Option<&Path>,
631) -> Vec<Candidate> {
632 build_candidates_with_home_and_rules_at_cwd(
633 commands, data, rules, buffer, cursor, home_dir, None,
634 )
635}
636
637#[cfg(test)]
639pub(crate) fn build_candidates_with_home_and_cwd(
640 commands: &[CommandCompletion],
641 data: &CompletionData,
642 buffer: &str,
643 cursor: usize,
644 home_dir: Option<&Path>,
645 cwd: &Path,
646) -> Vec<Candidate> {
647 build_candidates_with_home_and_rules_at_cwd(
648 commands,
649 data,
650 &CompletionRules::default(),
651 buffer,
652 cursor,
653 home_dir,
654 Some(cwd),
655 )
656}
657
658fn build_candidates_with_home_and_rules_at_cwd(
659 commands: &[CommandCompletion],
660 data: &CompletionData,
661 rules: &CompletionRules,
662 buffer: &str,
663 cursor: usize,
664 home_dir: Option<&Path>,
665 working_dir: Option<&Path>,
666) -> Vec<Candidate> {
667 if first_non_whitespace_starts_command(buffer) {
668 let leading_len = buffer.len() - buffer.trim_start().len();
669 let view = &buffer[leading_len..];
670 if cursor < leading_len {
671 return Vec::new();
672 }
673 let view_cursor = clamp_to_char_boundary(view, cursor.saturating_sub(leading_len));
674 if view_cursor == 0 {
675 return Vec::new();
676 }
677 let command_token_end = first_whitespace(view)
678 .map(|(index, _)| index)
679 .unwrap_or(view.len());
680 if view_cursor <= command_token_end {
681 let prefix = &view[..view_cursor];
682 let suffix = &view[command_token_end..];
683 let (static_descriptions, dynamic_commands) = data.root_commands();
684 let candidates =
685 build_cmd_candidates(commands, &dynamic_commands, &static_descriptions, prefix);
686 return replace_token_candidates(&buffer[..leading_len], suffix, candidates);
687 }
688
689 if let Some((space_pos, space_ch)) = first_whitespace(view) {
690 let cmd = &view[..space_pos];
691 if cmd.is_empty() {
692 return Vec::new();
693 }
694 let rest_start = space_pos + space_ch.len_utf8();
695 let rest = &view[rest_start..];
696 let rest_cursor = view_cursor.saturating_sub(rest_start).min(rest.len());
697 let candidates = build_arg_candidates(data, cmd, rest, rest_cursor);
698 return prepend_to_replacements(&buffer[..leading_len], candidates);
699 }
700 }
701
702 let Some(token) = word_token(buffer, cursor) else {
703 return Vec::new();
704 };
705 let Some(rule) = rules.matching_rule(token.prefix) else {
706 return Vec::new();
707 };
708
709 match &rule.kind {
710 CompletionRuleKind::Agents => build_agent_mention_candidates(data, &token, &rule.prefix),
711 CompletionRuleKind::Sessions => build_session_candidates(data, &token, &rule.prefix),
712 CompletionRuleKind::Path => {
713 build_filesystem_candidates_with_home(&token, home_dir, false, working_dir)
714 }
715 CompletionRuleKind::PathFuzzy => {
716 build_filesystem_candidates_with_home(&token, home_dir, true, working_dir)
717 }
718 CompletionRuleKind::Actions => {
719 build_action_token_candidates(commands, &data.dynamic_commands(), &token, &rule.prefix)
720 }
721 CompletionRuleKind::Command(_) => Vec::new(),
722 }
723}
724
725fn build_cmd_candidates(
726 static_commands: &[CommandCompletion],
727 dynamic_commands: &[CommandCompletion],
728 static_descriptions: &HashMap<CommandName, String>,
729 prefix: &str,
730) -> Vec<Candidate> {
731 let mut seen = path_std_collections::HashSet::new();
732 static_commands
733 .iter()
734 .chain(dynamic_commands)
735 .filter(|cmd| seen.insert(cmd.name.to_string()))
736 .filter(|cmd| cmd.name.as_str().starts_with(prefix))
737 .map(|cmd| Candidate {
738 label: cmd.name.to_string(),
739 description: static_descriptions
740 .get(&cmd.name)
741 .cloned()
742 .unwrap_or_else(|| cmd.description.clone()),
743 replacement: cmd.name.to_string(),
744 cursor: cmd.name.as_str().len(),
745 acceptance: None,
746 })
747 .collect()
748}
749fn prepend_to_replacements(prefix: &str, candidates: Vec<Candidate>) -> Vec<Candidate> {
750 candidates
751 .into_iter()
752 .map(|candidate| Candidate {
753 replacement: format!("{prefix}{}", candidate.replacement),
754 cursor: prefix.len() + candidate.cursor,
755 ..candidate
756 })
757 .collect()
758}
759
760fn replace_token_candidates(
761 before: &str,
762 after: &str,
763 candidates: Vec<Candidate>,
764) -> Vec<Candidate> {
765 candidates
766 .into_iter()
767 .map(|candidate| {
768 let accepted = candidate.replacement.clone();
769 replace_candidate(candidate, before, &accepted, after)
770 })
771 .collect()
772}
773
774fn replace_candidate(candidate: Candidate, before: &str, accepted: &str, after: &str) -> Candidate {
775 Candidate {
776 replacement: format!("{before}{accepted}{after}"),
777 cursor: before.len() + accepted.len(),
778 ..candidate
779 }
780}
781
782fn build_action_token_candidates(
783 static_commands: &[CommandCompletion],
784 dynamic_commands: &[CommandCompletion],
785 token: &PathToken<'_>,
786 trigger_prefix: &str,
787) -> Vec<Candidate> {
788 let partial = token
789 .prefix
790 .strip_prefix(trigger_prefix)
791 .unwrap_or(token.prefix);
792 let lookup_prefix = if trigger_prefix == ":" {
793 token.prefix.to_owned()
794 } else {
795 format!(":{partial}")
796 };
797 build_cmd_candidates(
798 static_commands,
799 dynamic_commands,
800 &HashMap::new(),
801 &lookup_prefix,
802 )
803 .into_iter()
804 .map(|candidate| {
805 let replacement = if trigger_prefix == ":" {
806 candidate.replacement.clone()
807 } else {
808 format!(
809 "{trigger_prefix}{}",
810 candidate.replacement.trim_start_matches(':')
811 )
812 };
813 replace_candidate(candidate, token.before, &replacement, token.after)
814 })
815 .collect()
816}
817struct PathToken<'a> {
818 prefix: &'a str,
819 suffix: &'a str,
821 before: &'a str,
822 after: &'a str,
823}
824
825fn first_non_whitespace_starts_command(buffer: &str) -> bool {
826 let trimmed = buffer.trim_start();
827 trimmed.starts_with(':') && !trimmed.starts_with("::")
828}
829
830fn word_token(buffer: &str, cursor: usize) -> Option<PathToken<'_>> {
831 let before_cursor = buffer.get(..cursor)?;
832 let after_cursor = buffer.get(cursor..)?;
833 let token_start = before_cursor
834 .char_indices()
835 .rev()
836 .find_map(|(idx, ch)| ch.is_whitespace().then_some(idx + ch.len_utf8()))
837 .unwrap_or(0);
838 let token_end = after_cursor
839 .char_indices()
840 .find_map(|(idx, ch)| ch.is_whitespace().then_some(cursor + idx))
841 .unwrap_or(buffer.len());
842 Some(PathToken {
843 prefix: &buffer[token_start..cursor],
844 suffix: &buffer[cursor..token_end],
845 before: &buffer[..token_start],
846 after: &buffer[token_end..],
847 })
848}
849
850fn build_session_candidates(
851 data: &CompletionData,
852 token: &PathToken<'_>,
853 trigger_prefix: &str,
854) -> Vec<Candidate> {
855 if token.prefix.contains('/') || token.suffix.contains('/') {
856 return Vec::new();
857 }
858 let Some(completer) = data.get_session_completer() else {
859 return Vec::new();
860 };
861 let partial = token
862 .prefix
863 .strip_prefix(trigger_prefix)
864 .unwrap_or(token.prefix);
865 completer(&[partial])
866 .into_iter()
867 .map(|item| {
868 let accepted = format!("{trigger_prefix}{}", item.value);
869 replace_candidate(
870 Candidate {
871 label: item.value,
872 description: item.description,
873 replacement: String::new(),
874 cursor: 0,
875 acceptance: None,
876 },
877 token.before,
878 &accepted,
879 token.after,
880 )
881 })
882 .collect()
883}
884fn build_agent_mention_candidates(
885 data: &CompletionData,
886 token: &PathToken<'_>,
887 trigger_prefix: &str,
888) -> Vec<Candidate> {
889 let Some(completer) = data.get_agent_mention_completer() else {
890 return Vec::new();
891 };
892 let partial = token
893 .prefix
894 .strip_prefix(trigger_prefix)
895 .unwrap_or(token.prefix);
896 completer(&[partial])
897 .into_iter()
898 .map(|item| {
899 let accepted = format!("{trigger_prefix}{}", item.value);
900 replace_candidate(
901 Candidate {
902 label: item.value,
903 description: item.description,
904 replacement: String::new(),
905 cursor: 0,
906 acceptance: None,
907 },
908 token.before,
909 &accepted,
910 token.after,
911 )
912 })
913 .collect()
914}
915
916fn cwd() -> PathBuf {
917 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
918}
919
920fn home_dir() -> Option<PathBuf> {
921 let home = std::env::var_os("HOME")?;
922 if home.as_os_str().is_empty() {
923 None
924 } else {
925 Some(PathBuf::from(home))
926 }
927}
928fn home_expanded_path(prefix: &str, home_dir: Option<&Path>) -> Option<PathBuf> {
929 if prefix == "~" {
930 Some(home_dir?.to_path_buf())
931 } else if let Some(rest) = prefix.strip_prefix("~/") {
932 Some(home_dir?.join(rest))
933 } else {
934 Some(PathBuf::from(prefix))
935 }
936}
937
938fn build_filesystem_candidates_with_home(
939 path_token: &PathToken<'_>,
940 home_dir: Option<&Path>,
941 fuzzy_git_files: bool,
942 working_dir: Option<&Path>,
943) -> Vec<Candidate> {
944 let prefix = path_token.prefix;
945 let Some(lookup_path) = home_expanded_path(prefix, home_dir) else {
946 return Vec::new();
947 };
948 let display_path = Path::new(prefix);
949 let (lookup_dir, display_dir, partial) = if prefix == "~" {
950 (lookup_path, PathBuf::from("~"), "")
951 } else if prefix.ends_with('/') {
952 (lookup_path, display_path.to_path_buf(), "")
953 } else {
954 let Some(lookup_parent) = lookup_path.parent() else {
955 return Vec::new();
956 };
957 let Some(display_parent) = display_path.parent() else {
958 return Vec::new();
959 };
960 let partial = display_path
961 .file_name()
962 .and_then(|s| s.to_str())
963 .unwrap_or("");
964 let lookup_dir = if lookup_parent.as_os_str().is_empty() {
965 PathBuf::from(".")
966 } else {
967 lookup_parent.to_path_buf()
968 };
969 let display_dir = if display_parent.as_os_str().is_empty() {
970 PathBuf::from(".")
971 } else {
972 display_parent.to_path_buf()
973 };
974 (lookup_dir, display_dir, partial)
975 };
976
977 if fuzzy_git_files && prefix.starts_with("./") && !partial.is_empty() {
978 let cwd = working_dir.map(Path::to_path_buf).unwrap_or_else(cwd);
979 if let Some((repo_root, files)) = git_files::git_repo_files(&cwd) {
980 let matches = git_files::fuzzy_match_git_files(partial, &files);
981 if !matches.is_empty() {
982 return matches
983 .into_iter()
984 .map(|path| {
985 let display = git_files::dotslash_display_path(path, &repo_root, &cwd);
986 replace_candidate(
987 Candidate {
988 label: display.clone(),
989 description: "git file".to_owned(),
990 replacement: String::new(),
991 cursor: 0,
992 acceptance: None,
993 },
994 path_token.before,
995 &display,
996 path_token.after,
997 )
998 })
999 .collect();
1000 }
1001 }
1002 }
1003
1004 let lookup_dir = working_dir
1005 .filter(|_| lookup_dir.is_relative())
1006 .map_or(lookup_dir.clone(), |cwd| cwd.join(lookup_dir));
1007 let Ok(entries) = std::fs::read_dir(lookup_dir) else {
1008 return Vec::new();
1009 };
1010
1011 let mut candidates = Vec::new();
1012 for entry in entries.flatten() {
1013 let name = entry.file_name();
1014 let Some(name) = name.to_str() else {
1015 continue;
1016 };
1017 if !name.starts_with(partial) {
1018 continue;
1019 }
1020 if !partial.starts_with('.') && name.starts_with('.') {
1021 continue;
1022 }
1023
1024 let is_dir = entry.file_type().map(|ty| ty.is_dir()).unwrap_or(false);
1025 let mut replacement = display_dir.join(name).to_string_lossy().into_owned();
1026 if is_dir && !replacement.ends_with('/') {
1027 replacement.push('/');
1028 }
1029 let acceptance = home_completion_acceptance(
1030 path_token,
1031 home_dir,
1032 &replacement,
1033 path_token.before,
1034 path_token.after,
1035 );
1036 candidates.push(replace_candidate(
1037 Candidate {
1038 label: replacement.clone(),
1039 description: if is_dir { "directory" } else { "file" }.to_owned(),
1040 replacement: String::new(),
1041 cursor: 0,
1042 acceptance,
1043 },
1044 path_token.before,
1045 &replacement,
1046 path_token.after,
1047 ));
1048 }
1049
1050 candidates.sort_by(|a, b| a.label.cmp(&b.label));
1051 candidates
1052}
1053
1054fn clamp_to_char_boundary(text: &str, cursor: usize) -> usize {
1055 let mut cursor = cursor.min(text.len());
1056 while 0 < cursor && !text.is_char_boundary(cursor) {
1057 cursor -= 1;
1058 }
1059 cursor
1060}
1061
1062fn first_whitespace(text: &str) -> Option<(usize, char)> {
1063 text.char_indices().find(|(_, ch)| ch.is_whitespace())
1064}
1065
1066fn build_arg_candidates(
1067 data: &CompletionData,
1068 cmd: &str,
1069 rest: &str,
1070 rest_cursor: usize,
1071) -> Vec<Candidate> {
1072 let cmd_name = CommandName::new(cmd);
1073 let Some(completer) = data.get_arg_completer(&cmd_name) else {
1074 return Vec::new();
1075 };
1076
1077 let rest_cursor = clamp_to_char_boundary(rest, rest_cursor);
1078 let token_start = rest[..rest_cursor]
1079 .char_indices()
1080 .rev()
1081 .find_map(|(pos, ch)| ch.is_whitespace().then_some(pos + ch.len_utf8()))
1082 .unwrap_or(0);
1083 let token_end = rest[rest_cursor..]
1084 .find(char::is_whitespace)
1085 .map(|pos| rest_cursor + pos)
1086 .unwrap_or(rest.len());
1087
1088 let mut args: Vec<&str> = rest[..token_start].split_whitespace().collect();
1089 args.push(&rest[token_start..rest_cursor]);
1090
1091 let replacement_prefix = format!("{cmd} {}", &rest[..token_start]);
1092 let replacement_suffix = &rest[token_end..];
1093
1094 completer(&args)
1095 .into_iter()
1096 .map(|item| {
1097 let accepted = item.value.clone();
1098 replace_candidate(
1099 Candidate {
1100 label: item.value,
1101 description: item.description,
1102 replacement: String::new(),
1103 cursor: 0,
1104 acceptance: None,
1105 },
1106 &replacement_prefix,
1107 &accepted,
1108 replacement_suffix,
1109 )
1110 })
1111 .collect()
1112}
1113
1114fn home_completion_acceptance(
1117 path_token: &PathToken<'_>,
1118 home_dir: Option<&Path>,
1119 display_replacement: &str,
1120 before: &str,
1121 after: &str,
1122) -> Option<CompletionAcceptance> {
1123 path_token.prefix.strip_prefix("~/")?;
1124 let home_dir = home_dir?;
1125 let completed = display_replacement.strip_prefix("~/")?;
1126 let mut absolute = home_dir.join(completed).to_string_lossy().into_owned();
1127 if display_replacement.ends_with('/') && !absolute.ends_with('/') {
1128 absolute.push('/');
1129 }
1130 Some(CompletionAcceptance {
1131 cursor: before.len() + absolute.len(),
1132 replacement: format!("{before}{absolute}{after}"),
1133 })
1134}
1135
1136const COMPLETION_MENU_MAX_HEIGHT_PERCENT: usize = 30;
1137
1138pub fn render_menu_block(
1141 view: &CompletionView,
1142 theme: &Theme,
1143 terminal_width: usize,
1144 terminal_height: usize,
1145) -> StyledBlock {
1146 render_menu_block_with_max_rows(
1147 view,
1148 theme,
1149 terminal_width,
1150 completion_menu_max_rows(terminal_height),
1151 )
1152}
1153
1154fn completion_menu_max_rows(terminal_height: usize) -> usize {
1155 (terminal_height * COMPLETION_MENU_MAX_HEIGHT_PERCENT / 100).max(1)
1156}
1157
1158fn visible_candidate_range(view: &CompletionView, max_rows: usize) -> std::ops::Range<usize> {
1159 let total = view.candidates.len();
1160 let max_rows = max_rows.max(1).min(total.max(1));
1161 if total <= max_rows {
1162 return 0..total;
1163 }
1164
1165 let selected = view.selected.unwrap_or(0).min(total - 1);
1166 let half = max_rows / 2;
1167 let start = selected.saturating_sub(half).min(total - max_rows);
1168 start..start + max_rows
1169}
1170
1171struct MenuLineParts {
1172 label: String,
1173 padding: usize,
1174 description: String,
1175}
1176
1177fn menu_line_parts(
1178 candidate: &Candidate,
1179 max_label_width: usize,
1180 terminal_width: usize,
1181) -> MenuLineParts {
1182 let inner_width = if terminal_width < 4 {
1183 terminal_width.max(1)
1184 } else {
1185 terminal_width.max(1).saturating_sub(4)
1186 };
1187 let label_budget = max_label_width.min(inner_width);
1188 let label = truncate_to_width(&candidate.label, label_budget);
1189 let label_width = display_width(label.as_str());
1190 let remaining = inner_width.saturating_sub(label_width);
1191
1192 let mut padding = 0;
1193 let mut description = String::new();
1194 if !candidate.description.is_empty() && 0 < remaining {
1195 padding = (max_label_width.saturating_sub(label_width) + 2).min(remaining);
1196 let desc_budget = remaining.saturating_sub(padding);
1197 if 0 < desc_budget {
1198 description = truncate_to_width(&candidate.description, desc_budget);
1199 }
1200 }
1201
1202 MenuLineParts {
1203 label,
1204 padding,
1205 description,
1206 }
1207}
1208
1209fn render_menu_block_with_max_rows(
1210 view: &CompletionView,
1211 theme: &Theme,
1212 terminal_width: usize,
1213 max_rows: usize,
1214) -> StyledBlock {
1215 let selected_style = resolve::resolve(theme, tau_themes::names::COMPLETION_SELECTED);
1216 let label_style = resolve::resolve(theme, tau_themes::names::COMPLETION_LABEL);
1217 let desc_style = resolve::resolve(theme, tau_themes::names::COMPLETION_DESC);
1218
1219 let visible = visible_candidate_range(view, max_rows);
1220 let max_label_width = view.candidates[visible.clone()]
1221 .iter()
1222 .map(|c| display_width(c.label.as_str()))
1223 .max()
1224 .unwrap_or(0);
1225
1226 let mut spans: Vec<Span> = Vec::new();
1227 for (row, i) in visible.enumerate() {
1228 let candidate = &view.candidates[i];
1229 if 0 < row {
1230 spans.push(Span::plain("\n"));
1231 }
1232
1233 let is_selected = view.selected == Some(i);
1234 let parts = menu_line_parts(candidate, max_label_width, terminal_width);
1235
1236 let line_text = if terminal_width < 4 {
1237 truncate_to_width(&parts.label, terminal_width)
1238 } else if parts.description.is_empty() {
1239 format!(" {} ", parts.label)
1240 } else {
1241 format!(
1242 " {}{:padding$}{} ",
1243 parts.label,
1244 "",
1245 parts.description,
1246 padding = parts.padding,
1247 )
1248 };
1249
1250 if terminal_width < 4 {
1251 spans.push(Span::plain(line_text));
1252 } else if is_selected {
1253 spans.push(Span::new(line_text, selected_style));
1254 } else {
1255 spans.push(Span::plain(" "));
1256 spans.push(Span::new(parts.label, label_style));
1257 if !parts.description.is_empty() {
1258 spans.push(Span::plain(format!(
1259 "{:padding$}",
1260 "",
1261 padding = parts.padding
1262 )));
1263 spans.push(Span::new(parts.description, desc_style));
1264 }
1265 spans.push(Span::plain(" "));
1266 }
1267 }
1268
1269 StyledBlock::new(StyledText::from(spans))
1270}
1271
1272#[cfg(test)]
1273#[path = "completion_rule_tests.rs"]
1274mod completion_rule_tests;
1275#[cfg(test)]
1276mod render_tests;