1#![allow(non_snake_case)]
28
29use crate::ported::params::getsparam;
30use crate::zle_file_tester::OperationContext;
31use std::ops::Range;
32use std::sync::Mutex;
33
34#[derive(Default, Clone, Debug)]
36pub struct Autosuggestion {
37 pub text: String,
40
41 pub search_string_range: Range<usize>,
44
45 pub icase_matched_codepoints: Option<usize>,
49
50 pub is_whole_item_from_history: bool,
52}
53
54impl Autosuggestion {
55 pub fn clear(&mut self) {
57 self.text.clear();
58 }
59
60 pub fn is_empty(&self) -> bool {
62 self.text.is_empty()
63 }
64
65 pub fn suffix(&self) -> &str {
67 let typed = self.search_string_range.len();
68 match self.text.char_indices().nth(typed) {
69 Some((byte, _)) => &self.text[byte..],
70 None => "",
71 }
72 }
73}
74
75#[derive(Default)]
77pub struct AutosuggestionResult {
78 pub autosuggestion: Autosuggestion,
80
81 pub command_line: String,
83}
84
85impl std::ops::Deref for AutosuggestionResult {
86 type Target = Autosuggestion;
87 fn deref(&self) -> &Self::Target {
88 &self.autosuggestion
89 }
90}
91
92impl AutosuggestionResult {
93 fn new(
95 command_line: String,
96 search_string_range: Range<usize>,
97 text: String,
98 icase_matched_codepoints: Option<usize>,
99 is_whole_item_from_history: bool,
100 ) -> Self {
101 Self {
102 autosuggestion: Autosuggestion {
103 text,
104 search_string_range,
105 icase_matched_codepoints,
106 is_whole_item_from_history,
107 },
108 command_line,
109 }
110 }
111
112 fn search_string(&self) -> String {
114 self.command_line
115 .chars()
116 .skip(self.search_string_range.start)
117 .take(self.search_string_range.len())
118 .collect()
119 }
120}
121
122pub enum AutosuggestionPortion {
124 Count(usize),
125 Line,
126 Word,
128}
129
130#[derive(Clone, Copy, PartialEq, Eq, Debug)]
132pub enum Strategy {
133 History,
134 MatchPrevCmd,
135}
136
137#[derive(Default)]
142pub struct AutosuggestState {
143 pub autosuggestion: Autosuggestion,
145 pub saved_autosuggestion: Option<Autosuggestion>,
148 pub suppress_autosuggestion: bool,
151 pub last_request_line: String,
154 pub history_search_active: bool,
157}
158
159pub static AUTOSUGGEST_STATE: Mutex<Option<AutosuggestState>> = Mutex::new(None);
161
162pub fn with_state<R>(f: impl FnOnce(&mut AutosuggestState) -> R) -> R {
164 let mut guard = AUTOSUGGEST_STATE.lock().unwrap();
165 f(guard.get_or_insert_with(Default::default))
166}
167
168fn string_prefixes_string_maybe_case_insensitive(icase: bool, prefix: &str, value: &str) -> bool {
170 if icase {
171 let mut vc = value.chars();
172 prefix.chars().all(|pc| match vc.next() {
173 Some(c) => c.to_lowercase().eq(pc.to_lowercase()),
174 None => false,
175 })
176 } else {
177 value.starts_with(prefix)
178 }
179}
180
181pub type HistorySource<'a> = dyn Fn(&str, usize) -> Vec<String> + 'a;
185
186pub fn history_commands_newest_first(prefix: &str, limit: usize) -> Vec<String> {
189 let from_db = crate::history::with_session_engine(|eng| {
192 eng.search_prefix_cs(prefix, limit)
193 .map(|entries| entries.into_iter().map(|e| e.command).collect::<Vec<_>>())
194 .unwrap_or_default()
195 })
196 .unwrap_or_default();
197 if !from_db.is_empty() {
198 return from_db;
199 }
200 let ring = crate::ported::hist::hist_ring.lock().unwrap();
203 ring.iter()
204 .filter(|e| !e.node.nam.is_empty() && e.node.nam.starts_with(prefix))
205 .take(limit)
206 .map(|e| e.node.nam.clone())
207 .collect()
208}
209
210fn previous_command() -> Option<String> {
212 let ring = crate::ported::hist::hist_ring.lock().unwrap();
213 ring.first().map(|e| e.node.nam.clone())
214}
215
216pub fn configured_strategies() -> Vec<Strategy> {
220 let raw = crate::ported::params::getaparam("ZSH_AUTOSUGGEST_STRATEGY")
221 .map(|v| v.join(" "))
222 .or_else(|| getsparam("ZSH_AUTOSUGGEST_STRATEGY"))
223 .unwrap_or_default();
224 let mut out: Vec<Strategy> = raw
225 .split_whitespace()
226 .filter_map(|s| match s {
227 "history" | "completion" => Some(Strategy::History),
228 "match_prev_cmd" => Some(Strategy::MatchPrevCmd),
229 _ => None,
230 })
231 .collect();
232 if out.is_empty() {
233 out.push(Strategy::History);
234 }
235 out
236}
237
238pub fn compute_autosuggestion(
243 command_line: &str,
244 cursor_pos: usize,
245 source: &HistorySource<'_>,
246 ctx: &OperationContext,
247) -> AutosuggestionResult {
248 let nothing = AutosuggestionResult::default();
249 if ctx.check_cancel() {
250 return nothing; }
252
253 let line_len = command_line.chars().count();
254 let range = 0..line_len;
255 if range.is_empty() {
256 return nothing; }
258 let search_string = command_line;
259
260 let mut icase_history_result: Option<AutosuggestionResult> = None;
262
263 let strategies = configured_strategies();
266 let prev_cmd = if strategies.contains(&Strategy::MatchPrevCmd) {
267 previous_command()
268 } else {
269 None
270 };
271
272 let working_directory = getsparam("PWD").unwrap_or_else(|| ".".to_owned());
273
274 let mut candidates = source(search_string, 64);
276 if let Some(prev) = &prev_cmd {
283 let preferred: Option<String> = {
284 let ring = crate::ported::hist::hist_ring.lock().unwrap();
285 ring.windows(2)
286 .find(|w| &w[1].node.nam == prev && w[0].node.nam.starts_with(search_string))
287 .map(|w| w[0].node.nam.clone())
288 };
289 if let Some(preferred) = preferred {
290 candidates.retain(|c| c != &preferred);
291 candidates.insert(0, preferred);
292 }
293 }
294
295 let mut unvalidated_fallback: Option<AutosuggestionResult> = None;
301
302 let single_line_buffer = !search_string.contains('\n');
309
310 for full_item in &candidates {
311 let full: &str = if single_line_buffer {
312 match full_item.split_once('\n') {
313 Some((first, _)) => first,
314 None => full_item.as_str(),
315 }
316 } else {
317 full_item.as_str()
318 };
319 let full = &full.to_string();
320
321 let (matches, icase) = if full.starts_with(search_string) {
323 (true, false)
324 } else if icase_history_result.is_none()
325 && string_prefixes_string_maybe_case_insensitive(true, search_string, full)
326 {
327 (true, true)
328 } else {
329 (false, false)
330 };
331 if !matches || full.chars().count() <= line_len {
332 continue;
333 }
334
335 let is_whole = full == full_item; let make_result = || {
337 AutosuggestionResult::new(
338 command_line.to_owned(),
339 range.clone(),
340 full.clone(),
341 icase.then(|| search_string.chars().count()),
342 is_whole,
343 )
344 };
345 if !icase && unvalidated_fallback.is_none() {
346 unvalidated_fallback = Some(make_result());
347 }
348 if ctx.check_cancel() {
349 break;
350 }
351
352 if crate::syntax_highlight::autosuggest_validate_from_history(
354 full,
355 &[],
356 &working_directory,
357 ctx,
358 ) {
359 let result = make_result();
361 if icase {
362 icase_history_result = Some(result);
363 } else {
364 return result;
365 }
366 }
367 }
368
369 if let Some(result) = icase_history_result {
371 return result;
372 }
373 if ctx.check_cancel() {
375 if let Some(result) = unvalidated_fallback {
376 return result;
377 }
378 }
379
380 let _ = cursor_pos;
383 nothing
384}
385
386pub fn can_autosuggest(state: &AutosuggestState, line: &str) -> bool {
388 let max_size = getsparam("ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE")
393 .and_then(|s| s.parse::<usize>().ok())
394 .unwrap_or(usize::MAX);
395 !state.suppress_autosuggestion
396 && !state.history_search_active
397 && line.chars().count() <= max_size
398 && line
399 .chars()
400 .any(|c| !matches!(c, ' ' | '\t' | '\r' | '\n' | '\x0B'))
401}
402
403pub fn autosuggest_completed(state: &mut AutosuggestState, line: &str, result: AutosuggestionResult) {
407 if result.command_line != line {
408 return;
410 }
411 if !result.is_empty()
412 && string_prefixes_string_maybe_case_insensitive(
413 result.icase_matched_codepoints.is_some(),
414 &result.search_string(),
415 &result.text,
416 )
417 {
418 state.autosuggestion = result.autosuggestion;
421 }
422}
423
424pub fn update_autosuggestion(
427 line: &str,
428 cursor: usize,
429 source: &HistorySource<'_>,
430 ctx: &OperationContext,
431) -> bool {
432 with_state(|state| {
433 let before = state.autosuggestion.text.clone();
434
435 if !can_autosuggest(state, line) {
437 state.last_request_line.clear();
438 state.autosuggestion.clear();
439 return state.autosuggestion.text != before;
440 }
441
442 if is_at_line_with_autosuggestion(state, line, cursor) {
444 return false;
445 }
446
447 if line == state.last_request_line {
449 if !state.autosuggestion.is_empty() {
451 state.autosuggestion.clear();
452 }
453 return state.autosuggestion.text != before;
454 }
455 state.last_request_line = line.to_owned();
456
457 state.autosuggestion.clear();
459 let result = compute_autosuggestion(line, cursor, source, ctx);
460 autosuggest_completed(state, line, result);
461 state.autosuggestion.text != before
462 })
463}
464
465pub fn is_at_autosuggestion(state: &AutosuggestState, cursor: usize) -> bool {
469 if state.autosuggestion.is_empty() {
470 return false;
471 }
472 cursor == state.autosuggestion.search_string_range.end
473}
474
475pub fn is_at_line_with_autosuggestion(state: &AutosuggestState, line: &str, cursor: usize) -> bool {
477 if state.autosuggestion.is_empty() {
478 return false;
479 }
480 let range = &state.autosuggestion.search_string_range;
481 let line_len = line.chars().count();
484 (range.start == 0 && range.end == line_len && cursor <= range.end)
485 && string_prefixes_string_maybe_case_insensitive(
486 state.autosuggestion.icase_matched_codepoints.is_some(),
487 line,
488 &state.autosuggestion.text,
489 )
490}
491
492pub fn accept_autosuggestion(
496 state: &mut AutosuggestState,
497 amount: AutosuggestionPortion,
498) -> Option<(Range<usize>, String)> {
499 let autosuggestion = &state.autosuggestion;
500 if autosuggestion.is_empty() {
501 return None;
502 }
503 let autosuggestion_text: Vec<char> = autosuggestion.text.chars().collect();
504 let search_string_range = autosuggestion.search_string_range.clone();
505
506 let (range, replacement): (Range<usize>, String) = match amount {
508 AutosuggestionPortion::Count(count) => {
509 if count == usize::MAX {
510 (search_string_range, autosuggestion_text.iter().collect())
512 } else {
513 let pos = search_string_range.end;
514 let available = autosuggestion_text.len() - search_string_range.len();
515 let count = count.min(available);
516 if count == 0 {
517 return None;
518 }
519 let start = autosuggestion_text.len() - available;
520 (
521 pos..pos,
522 autosuggestion_text[start..start + count].iter().collect(),
523 )
524 }
525 }
526 AutosuggestionPortion::Line => {
527 let suggested = &autosuggestion_text[search_string_range.len()..];
529 let line_end = suggested
530 .iter()
531 .position(|&c| c == '\n')
532 .unwrap_or(suggested.len());
533 if line_end == 0 {
534 return None;
535 }
536 (
537 search_string_range.end..search_string_range.end,
538 suggested[..line_end].iter().collect(),
539 )
540 }
541 AutosuggestionPortion::Word => {
542 let wordchars =
546 getsparam("WORDCHARS").unwrap_or_else(|| "*?_-.[]~=/&;!#$%^(){}<>".to_owned());
547 let is_word = |c: char| c.is_alphanumeric() || wordchars.contains(c);
548 let have = search_string_range.len();
549 let mut want = have;
550 while want < autosuggestion_text.len() && !is_word(autosuggestion_text[want]) {
552 want += 1;
553 }
554 while want < autosuggestion_text.len() && is_word(autosuggestion_text[want]) {
555 want += 1;
556 }
557 if want == have {
558 return None;
559 }
560 (
561 search_string_range.end..search_string_range.end,
562 autosuggestion_text[have..want].iter().collect(),
563 )
564 }
565 };
566
567 if range == (0..0) && replacement.is_empty() {
570 return None;
571 }
572 if matches!(amount, AutosuggestionPortion::Count(usize::MAX)) {
573 state.autosuggestion.clear();
574 state.last_request_line.clear();
575 }
576 Some((range, replacement))
577}
578
579pub fn on_delete(state: &mut AutosuggestState) {
583 if !state.autosuggestion.is_empty() {
584 state.saved_autosuggestion = Some(state.autosuggestion.clone());
585 }
586 state.suppress_autosuggestion = true;
587 state.autosuggestion.clear();
588 state.last_request_line.clear();
589}
590
591pub fn on_insert(state: &mut AutosuggestState, line: &str) {
594 state.suppress_autosuggestion = false;
595 if let Some(saved) = state.saved_autosuggestion.take() {
596 let line_len = line.chars().count();
597 if string_prefixes_string_maybe_case_insensitive(
598 saved.icase_matched_codepoints.is_some(),
599 line,
600 &saved.text,
601 ) && line_len < saved.text.chars().count()
602 {
603 let mut restored = saved;
604 restored.search_string_range = 0..line_len;
605 state.autosuggestion = restored;
606 state.last_request_line = line.to_owned();
607 }
608 }
609}
610
611pub fn on_line_finish(state: &mut AutosuggestState) {
613 state.autosuggestion.clear();
614 state.saved_autosuggestion = None;
615 state.suppress_autosuggestion = false;
616 state.last_request_line.clear();
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 fn lock() -> std::sync::MutexGuard<'static, ()> {
624 crate::test_util::global_state_lock()
625 }
626
627 fn src(items: &[&str]) -> impl Fn(&str, usize) -> Vec<String> {
628 let owned: Vec<String> = items.iter().map(|s| s.to_string()).collect();
629 move |prefix: &str, limit: usize| {
630 owned
631 .iter()
632 .filter(|c| {
633 c.to_lowercase().starts_with(&prefix.to_lowercase())
634 })
635 .take(limit)
636 .cloned()
637 .collect()
638 }
639 }
640
641 #[test]
643 fn compute_prefers_case_sensitive() {
644 let _g = lock();
645 let ctx = OperationContext::empty();
646 let source = src(&["Echo one", "echo two"]);
647 let r = compute_autosuggestion("echo", 4, &source, &ctx);
648 assert_eq!(r.text, "echo two");
649 assert!(r.icase_matched_codepoints.is_none());
650 assert!(r.is_whole_item_from_history);
651 }
652
653 #[test]
654 fn compute_falls_back_to_icase() {
655 let _g = lock();
656 let ctx = OperationContext::empty();
657 let source = src(&["Echo one"]);
658 let r = compute_autosuggestion("echo", 4, &source, &ctx);
659 assert_eq!(r.text, "Echo one");
660 assert_eq!(r.icase_matched_codepoints, Some(4));
661 }
662
663 #[test]
664 fn compute_rejects_invalid_command() {
665 let _g = lock();
666 let ctx = OperationContext::empty();
667 let source = src(&["nonexistent_zshrs_cmd_xyz --flag"]);
670 let r = compute_autosuggestion("nonex", 5, &source, &ctx);
671 assert!(r.is_empty(), "invalid command must not be suggested: {:?}", r.text);
672 }
673
674 #[test]
675 fn compute_empty_line_suggests_nothing() {
676 let _g = lock();
677 let ctx = OperationContext::empty();
678 let source = src(&["echo hi"]);
679 let r = compute_autosuggestion("", 0, &source, &ctx);
680 assert!(r.is_empty()); }
682
683 fn state_with_suggestion(line: &str, text: &str) -> AutosuggestState {
685 AutosuggestState {
686 autosuggestion: Autosuggestion {
687 text: text.to_owned(),
688 search_string_range: 0..line.chars().count(),
689 icase_matched_codepoints: None,
690 is_whole_item_from_history: true,
691 },
692 ..Default::default()
693 }
694 }
695
696 #[test]
697 fn accept_full_replaces_line() {
698 let mut st = state_with_suggestion("git s", "git status --short");
699 let (range, repl) =
700 accept_autosuggestion(&mut st, AutosuggestionPortion::Count(usize::MAX)).unwrap();
701 assert_eq!(range, 0..5);
702 assert_eq!(repl, "git status --short");
703 assert!(st.autosuggestion.is_empty(), "full accept clears suggestion");
704 }
705
706 #[test]
707 fn accept_count_appends_chars() {
708 let mut st = state_with_suggestion("git s", "git status --short");
709 let (range, repl) = accept_autosuggestion(&mut st, AutosuggestionPortion::Count(3)).unwrap();
710 assert_eq!(range, 5..5);
711 assert_eq!(repl, "tat");
712 }
713
714 #[test]
715 fn accept_word_stops_at_boundary() {
716 let mut st = state_with_suggestion("git s", "git status --short");
717 let (range, repl) = accept_autosuggestion(&mut st, AutosuggestionPortion::Word).unwrap();
718 assert_eq!(range, 5..5);
719 assert!(repl.starts_with("tatus"), "got {repl:?}");
721 assert!(!repl.contains("short"), "must stop at word boundary: {repl:?}");
722 }
723
724 #[test]
725 fn accept_on_empty_suggestion_is_none() {
726 let mut st = AutosuggestState::default();
727 assert!(accept_autosuggestion(&mut st, AutosuggestionPortion::Count(usize::MAX)).is_none());
728 }
729
730 #[test]
732 fn delete_suppresses_and_insert_restores() {
733 let _g = lock();
734 let mut st = state_with_suggestion("git s", "git status");
735 on_delete(&mut st);
736 assert!(st.suppress_autosuggestion);
737 assert!(st.autosuggestion.is_empty());
738 assert!(!can_autosuggest(&st, "git "));
739
740 on_insert(&mut st, "git st");
742 assert!(!st.suppress_autosuggestion);
743 assert_eq!(st.autosuggestion.text, "git status");
744 assert_eq!(st.autosuggestion.search_string_range, 0..6);
745 }
746
747 #[test]
748 fn insert_discards_saved_when_no_longer_prefix() {
749 let _g = lock();
750 let mut st = state_with_suggestion("git s", "git status");
751 on_delete(&mut st);
752 on_insert(&mut st, "ls -");
753 assert!(st.autosuggestion.is_empty());
754 }
755
756 #[test]
758 fn stale_result_is_dropped() {
759 let mut st = AutosuggestState::default();
760 let result = AutosuggestionResult::new(
761 "old line".to_owned(),
762 0..8,
763 "old line more".to_owned(),
764 None,
765 true,
766 );
767 autosuggest_completed(&mut st, "new line", result);
768 assert!(st.autosuggestion.is_empty());
769 }
770
771 #[test]
772 fn update_clears_when_line_no_longer_matches() {
773 let _g = lock();
774 let ctx = OperationContext::empty();
775 let source = src(&["echo hello"]);
776 let changed = update_autosuggestion("echo h", 6, &source, &ctx);
778 assert!(changed);
779 with_state(|st| {
780 assert_eq!(st.autosuggestion.text, "echo hello");
781 });
782 let changed2 = update_autosuggestion("zzz_nothing", 11, &source, &ctx);
784 assert!(changed2);
785 with_state(|st| {
786 assert!(st.autosuggestion.is_empty());
787 st.autosuggestion.clear();
788 st.last_request_line.clear();
789 st.saved_autosuggestion = None;
790 });
791 }
792
793 #[test]
794 fn suggestion_suffix() {
795 let s = Autosuggestion {
796 text: "git status".to_owned(),
797 search_string_range: 0..5,
798 icase_matched_codepoints: None,
799 is_whole_item_from_history: true,
800 };
801 assert_eq!(s.suffix(), "tatus");
802 }
803}