1#![allow(non_snake_case)]
28
29use crate::ported::params::getsparam;
30use crate::ported::zsh_h::histent;
31use crate::zle_file_tester::OperationContext;
32use std::ops::Range;
33use std::sync::Mutex;
34
35#[derive(Default, Clone, Debug)]
37pub struct Autosuggestion {
38 pub text: String,
41
42 pub search_string_range: Range<usize>,
45
46 pub icase_matched_codepoints: Option<usize>,
50
51 pub is_whole_item_from_history: bool,
53}
54
55impl Autosuggestion {
56 pub fn clear(&mut self) {
58 self.text.clear();
59 }
60
61 pub fn is_empty(&self) -> bool {
63 self.text.is_empty()
64 }
65
66 pub fn suffix(&self) -> &str {
68 let typed = self.search_string_range.len();
69 match self.text.char_indices().nth(typed) {
70 Some((byte, _)) => &self.text[byte..],
71 None => "",
72 }
73 }
74}
75
76#[derive(Default)]
78pub struct AutosuggestionResult {
79 pub autosuggestion: Autosuggestion,
81
82 pub command_line: String,
84}
85
86impl std::ops::Deref for AutosuggestionResult {
87 type Target = Autosuggestion;
88 fn deref(&self) -> &Self::Target {
89 &self.autosuggestion
90 }
91}
92
93impl AutosuggestionResult {
94 fn new(
96 command_line: String,
97 search_string_range: Range<usize>,
98 text: String,
99 icase_matched_codepoints: Option<usize>,
100 is_whole_item_from_history: bool,
101 ) -> Self {
102 Self {
103 autosuggestion: Autosuggestion {
104 text,
105 search_string_range,
106 icase_matched_codepoints,
107 is_whole_item_from_history,
108 },
109 command_line,
110 }
111 }
112
113 fn search_string(&self) -> String {
115 self.command_line
116 .chars()
117 .skip(self.search_string_range.start)
118 .take(self.search_string_range.len())
119 .collect()
120 }
121}
122
123pub enum AutosuggestionPortion {
125 Count(usize),
126 Line,
127 Word,
129}
130
131#[derive(Clone, Copy, PartialEq, Eq, Debug)]
133pub enum Strategy {
134 History,
135 MatchPrevCmd,
136 Completion,
137}
138
139#[derive(Default)]
144pub struct AutosuggestState {
145 pub autosuggestion: Autosuggestion,
147 pub saved_autosuggestion: Option<Autosuggestion>,
150 pub suppress_autosuggestion: bool,
153 pub last_request_line: String,
156 pub history_search_active: bool,
159}
160
161pub static AUTOSUGGEST_STATE: Mutex<Option<AutosuggestState>> = Mutex::new(None);
163
164pub fn with_state<R>(f: impl FnOnce(&mut AutosuggestState) -> R) -> R {
166 let mut guard = AUTOSUGGEST_STATE.lock().unwrap();
167 f(guard.get_or_insert_with(Default::default))
168}
169
170fn string_prefixes_string_maybe_case_insensitive(icase: bool, prefix: &str, value: &str) -> bool {
172 if icase {
173 let mut vc = value.chars();
174 prefix.chars().all(|pc| match vc.next() {
175 Some(c) => c.to_lowercase().eq(pc.to_lowercase()),
176 None => false,
177 })
178 } else {
179 value.starts_with(prefix)
180 }
181}
182
183pub type HistorySource<'a> = dyn Fn(&str, usize) -> Vec<String> + 'a;
187
188pub fn history_commands_newest_first(prefix: &str, limit: usize) -> Vec<String> {
191 let from_db = crate::history::with_session_engine(|eng| {
194 eng.search_prefix_cs(prefix, limit)
195 .map(|entries| entries.into_iter().map(|e| e.command).collect::<Vec<_>>())
196 .unwrap_or_default()
197 })
198 .unwrap_or_default();
199 if !from_db.is_empty() {
200 return from_db;
201 }
202 let ring = crate::ported::hist::hist_ring.lock().unwrap();
204 ring_prefix_hits(&ring, prefix, limit)
205 .into_iter()
206 .map(|i| ring[i].node.nam.clone())
207 .collect()
208}
209
210#[derive(Default)]
213struct RingPrefixScan {
214 prefix: String,
215 ring_len: usize,
216 newest_histnum: i64,
217 hits: Vec<usize>,
218 scanned: usize,
219}
220
221static RING_PREFIX_SCAN: Mutex<Option<RingPrefixScan>> = Mutex::new(None);
223
224fn ring_prefix_hits(ring: &[histent], prefix: &str, want: usize) -> Vec<usize> {
235 let mut guard = RING_PREFIX_SCAN.lock().unwrap();
236 let scan = guard.get_or_insert_with(Default::default);
237 advance_prefix_scan(scan, ring, prefix, want);
238 scan.hits.iter().take(want).copied().collect()
239}
240
241fn advance_prefix_scan(scan: &mut RingPrefixScan, ring: &[histent], prefix: &str, want: usize) {
242 let newest_histnum = ring.first().map_or(0, |e| e.histnum);
243 let same_ring = scan.ring_len == ring.len() && scan.newest_histnum == newest_histnum;
244 if same_ring && prefix.starts_with(scan.prefix.as_str()) {
245 scan.hits.retain(|&i| ring[i].node.nam.starts_with(prefix));
246 } else {
247 *scan = RingPrefixScan {
248 ring_len: ring.len(),
249 newest_histnum,
250 ..Default::default()
251 };
252 }
253 scan.prefix = prefix.to_owned();
254 while scan.hits.len() < want && scan.scanned < ring.len() {
256 let nam = &ring[scan.scanned].node.nam;
257 if !nam.is_empty() && nam.starts_with(prefix) {
258 scan.hits.push(scan.scanned);
259 }
260 scan.scanned += 1;
261 }
262}
263
264fn previous_command() -> Option<String> {
266 let ring = crate::ported::hist::hist_ring.lock().unwrap();
267 ring.first().map(|e| e.node.nam.clone())
268}
269
270pub fn configured_strategies() -> Vec<Strategy> {
274 let raw = crate::ported::params::getaparam("ZSH_AUTOSUGGEST_STRATEGY")
275 .map(|v| v.join(" "))
276 .or_else(|| getsparam("ZSH_AUTOSUGGEST_STRATEGY"))
277 .unwrap_or_default();
278 let mut out: Vec<Strategy> = raw
279 .split_whitespace()
280 .filter_map(|s| match s {
281 "history" => Some(Strategy::History),
282 "completion" => Some(Strategy::Completion),
283 "match_prev_cmd" => Some(Strategy::MatchPrevCmd),
284 _ => None,
285 })
286 .collect();
287 if out.is_empty() {
288 out.push(Strategy::History);
289 }
290 out
291}
292
293pub fn compute_autosuggestion(
298 command_line: &str,
299 cursor_pos: usize,
300 source: &HistorySource<'_>,
301 ctx: &OperationContext,
302) -> AutosuggestionResult {
303 let nothing = AutosuggestionResult::default();
304 if ctx.check_cancel() {
305 return nothing; }
307
308 let line_len = command_line.chars().count();
309 let range = 0..line_len;
310 if range.is_empty() {
311 return nothing; }
313 let search_string = command_line;
314
315 let mut icase_history_result: Option<AutosuggestionResult> = None;
317
318 let strategies = configured_strategies();
321 let prev_cmd = if strategies.contains(&Strategy::MatchPrevCmd) {
322 previous_command()
323 } else {
324 None
325 };
326
327 let working_directory = getsparam("PWD").unwrap_or_else(|| ".".to_owned());
328
329 let mut candidates = source(search_string, 64);
331 if let Some(prev) = &prev_cmd {
340 let preferred: Option<String> = {
341 let ring = crate::ported::hist::hist_ring.lock().unwrap();
342 ring_prefix_hits(&ring, search_string, 200)
343 .into_iter()
344 .find(|&i| ring.get(i + 1).is_some_and(|older| &older.node.nam == prev))
345 .map(|i| ring[i].node.nam.clone())
346 };
347 if let Some(preferred) = preferred {
348 candidates.retain(|c| c != &preferred);
349 candidates.insert(0, preferred);
350 }
351 }
352
353 let mut unvalidated_fallback: Option<AutosuggestionResult> = None;
359
360 let single_line_buffer = !search_string.contains('\n');
367
368 for full_item in &candidates {
369 let full: &str = if single_line_buffer {
370 match full_item.split_once('\n') {
371 Some((first, _)) => first,
372 None => full_item.as_str(),
373 }
374 } else {
375 full_item.as_str()
376 };
377 let full = &full.to_string();
378
379 let (matches, icase) = if full.starts_with(search_string) {
381 (true, false)
382 } else if icase_history_result.is_none()
383 && string_prefixes_string_maybe_case_insensitive(true, search_string, full)
384 {
385 (true, true)
386 } else {
387 (false, false)
388 };
389 if !matches || full.chars().count() <= line_len {
390 continue;
391 }
392
393 let is_whole = full == full_item; let make_result = || {
395 AutosuggestionResult::new(
396 command_line.to_owned(),
397 range.clone(),
398 full.clone(),
399 icase.then(|| search_string.chars().count()),
400 is_whole,
401 )
402 };
403 if !icase && unvalidated_fallback.is_none() {
404 unvalidated_fallback = Some(make_result());
405 }
406 if ctx.check_cancel() {
407 break;
408 }
409
410 if crate::syntax_highlight::autosuggest_validate_from_history(
412 full,
413 &[],
414 &working_directory,
415 ctx,
416 ) {
417 let result = make_result();
419 if icase {
420 icase_history_result = Some(result);
421 } else {
422 return result;
423 }
424 }
425 }
426
427 if let Some(result) = icase_history_result {
429 return result;
430 }
431 if ctx.check_cancel() {
433 if let Some(result) = unvalidated_fallback {
434 return result;
435 }
436 }
437
438 if strategies.contains(&Strategy::Completion) {
441 if let Some(result) = completion_autosuggestion(command_line, cursor_pos, ctx) {
442 return result;
443 }
444 }
445 nothing
446}
447
448const COMPLETION_SUGGEST_BUDGET: std::time::Duration = std::time::Duration::from_millis(50);
452
453fn completion_autosuggestion(
457 command_line: &str,
458 cursor_pos: usize,
459 ctx: &OperationContext,
460) -> Option<AutosuggestionResult> {
461 let last_char = command_line.chars().next_back()?;
463 let cursor_at_end = cursor_pos >= command_line.chars().count();
467 if !cursor_at_end && last_char.is_whitespace() {
468 return None;
469 }
470 if cursor_at_end && matches!(last_char, '\'' | '"') {
471 return None;
472 }
473 if ctx.check_cancel() {
474 return None;
475 }
476
477 let mut req = crate::compsys::in_editor::CompsysRequest::new_with_budget(
479 command_line,
480 command_line.len(),
481 COMPLETION_SUGGEST_BUDGET,
482 );
483 req.allow_exec = false;
484 let response = crate::compsys::in_editor::complete_at(req);
485
486 let (prefix, completion) = response.matches.iter().find_map(|m| {
489 let typed = command_line.get(m.replace_start..)?;
490 (m.completion.starts_with(typed) && m.completion.len() > typed.len())
491 .then(|| (&command_line[..m.replace_start], m.completion.as_str()))
492 })?;
493 let completion = completion.split('\n').next().unwrap_or(completion);
495 let line_len = command_line.chars().count();
496 Some(AutosuggestionResult::new(
497 command_line.to_owned(),
498 0..line_len,
499 format!("{prefix}{completion}"),
500 None,
501 false,
502 ))
503}
504
505pub fn can_autosuggest(state: &AutosuggestState, line: &str) -> bool {
507 let max_size = getsparam("ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE")
512 .and_then(|s| s.parse::<usize>().ok())
513 .unwrap_or(usize::MAX);
514 !state.suppress_autosuggestion
515 && !state.history_search_active
516 && line.chars().count() <= max_size
517 && line
518 .chars()
519 .any(|c| !matches!(c, ' ' | '\t' | '\r' | '\n' | '\x0B'))
520}
521
522pub fn autosuggest_completed(
526 state: &mut AutosuggestState,
527 line: &str,
528 result: AutosuggestionResult,
529) {
530 if result.command_line != line {
531 return;
533 }
534 if !result.is_empty()
535 && string_prefixes_string_maybe_case_insensitive(
536 result.icase_matched_codepoints.is_some(),
537 &result.search_string(),
538 &result.text,
539 )
540 {
541 state.autosuggestion = result.autosuggestion;
544 }
545}
546
547pub fn update_autosuggestion(
550 line: &str,
551 cursor: usize,
552 source: &HistorySource<'_>,
553 ctx: &OperationContext,
554) -> bool {
555 with_state(|state| {
556 let before = state.autosuggestion.text.clone();
557
558 if !can_autosuggest(state, line) {
560 state.last_request_line.clear();
561 state.autosuggestion.clear();
562 return state.autosuggestion.text != before;
563 }
564
565 if is_at_line_with_autosuggestion(state, line, cursor) {
567 return false;
568 }
569
570 if line == state.last_request_line {
572 if !state.autosuggestion.is_empty() {
574 state.autosuggestion.clear();
575 }
576 return state.autosuggestion.text != before;
577 }
578 state.last_request_line = line.to_owned();
579
580 state.autosuggestion.clear();
582 let result = compute_autosuggestion(line, cursor, source, ctx);
583 autosuggest_completed(state, line, result);
584 state.autosuggestion.text != before
585 })
586}
587
588pub fn is_at_autosuggestion(state: &AutosuggestState, cursor: usize) -> bool {
592 if state.autosuggestion.is_empty() {
593 return false;
594 }
595 cursor == state.autosuggestion.search_string_range.end
596}
597
598pub fn is_at_line_with_autosuggestion(state: &AutosuggestState, line: &str, cursor: usize) -> bool {
600 if state.autosuggestion.is_empty() {
601 return false;
602 }
603 let range = &state.autosuggestion.search_string_range;
604 let line_len = line.chars().count();
607 (range.start == 0 && range.end == line_len && cursor <= range.end)
608 && string_prefixes_string_maybe_case_insensitive(
609 state.autosuggestion.icase_matched_codepoints.is_some(),
610 line,
611 &state.autosuggestion.text,
612 )
613}
614
615pub fn accept_autosuggestion(
619 state: &mut AutosuggestState,
620 amount: AutosuggestionPortion,
621) -> Option<(Range<usize>, String)> {
622 let autosuggestion = &state.autosuggestion;
623 if autosuggestion.is_empty() {
624 return None;
625 }
626 let autosuggestion_text: Vec<char> = autosuggestion.text.chars().collect();
627 let search_string_range = autosuggestion.search_string_range.clone();
628
629 let (range, replacement): (Range<usize>, String) = match amount {
631 AutosuggestionPortion::Count(count) => {
632 if count == usize::MAX {
633 (search_string_range, autosuggestion_text.iter().collect())
635 } else {
636 let pos = search_string_range.end;
637 let available = autosuggestion_text.len() - search_string_range.len();
638 let count = count.min(available);
639 if count == 0 {
640 return None;
641 }
642 let start = autosuggestion_text.len() - available;
643 (
644 pos..pos,
645 autosuggestion_text[start..start + count].iter().collect(),
646 )
647 }
648 }
649 AutosuggestionPortion::Line => {
650 let suggested = &autosuggestion_text[search_string_range.len()..];
652 let line_end = suggested
653 .iter()
654 .position(|&c| c == '\n')
655 .unwrap_or(suggested.len());
656 if line_end == 0 {
657 return None;
658 }
659 (
660 search_string_range.end..search_string_range.end,
661 suggested[..line_end].iter().collect(),
662 )
663 }
664 AutosuggestionPortion::Word => {
665 let wordchars =
669 getsparam("WORDCHARS").unwrap_or_else(|| "*?_-.[]~=/&;!#$%^(){}<>".to_owned());
670 let is_word = |c: char| c.is_alphanumeric() || wordchars.contains(c);
671 let have = search_string_range.len();
672 let mut want = have;
673 while want < autosuggestion_text.len() && !is_word(autosuggestion_text[want]) {
675 want += 1;
676 }
677 while want < autosuggestion_text.len() && is_word(autosuggestion_text[want]) {
678 want += 1;
679 }
680 if want == have {
681 return None;
682 }
683 (
684 search_string_range.end..search_string_range.end,
685 autosuggestion_text[have..want].iter().collect(),
686 )
687 }
688 };
689
690 if range == (0..0) && replacement.is_empty() {
693 return None;
694 }
695 if matches!(amount, AutosuggestionPortion::Count(usize::MAX)) {
696 state.autosuggestion.clear();
697 state.last_request_line.clear();
698 }
699 Some((range, replacement))
700}
701
702pub fn on_delete(state: &mut AutosuggestState) {
706 if !state.autosuggestion.is_empty() {
707 state.saved_autosuggestion = Some(state.autosuggestion.clone());
708 }
709 state.suppress_autosuggestion = true;
710 state.autosuggestion.clear();
711 state.last_request_line.clear();
712}
713
714pub fn on_insert(state: &mut AutosuggestState, line: &str) {
717 state.suppress_autosuggestion = false;
718 if let Some(saved) = state.saved_autosuggestion.take() {
719 let line_len = line.chars().count();
720 if string_prefixes_string_maybe_case_insensitive(
721 saved.icase_matched_codepoints.is_some(),
722 line,
723 &saved.text,
724 ) && line_len < saved.text.chars().count()
725 {
726 let mut restored = saved;
727 restored.search_string_range = 0..line_len;
728 state.autosuggestion = restored;
729 state.last_request_line = line.to_owned();
730 }
731 }
732}
733
734pub fn on_line_finish(state: &mut AutosuggestState) {
736 state.autosuggestion.clear();
737 state.saved_autosuggestion = None;
738 state.suppress_autosuggestion = false;
739 state.last_request_line.clear();
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745
746 fn lock() -> std::sync::MutexGuard<'static, ()> {
747 crate::test_util::global_state_lock()
748 }
749
750 fn src(items: &[&str]) -> impl Fn(&str, usize) -> Vec<String> {
751 let owned: Vec<String> = items.iter().map(|s| s.to_string()).collect();
752 move |prefix: &str, limit: usize| {
753 owned
754 .iter()
755 .filter(|c| c.to_lowercase().starts_with(&prefix.to_lowercase()))
756 .take(limit)
757 .cloned()
758 .collect()
759 }
760 }
761
762 #[test]
764 fn compute_prefers_case_sensitive() {
765 let _g = lock();
766 let ctx = OperationContext::empty();
767 let source = src(&["Echo one", "echo two"]);
768 let r = compute_autosuggestion("echo", 4, &source, &ctx);
769 assert_eq!(r.text, "echo two");
770 assert!(r.icase_matched_codepoints.is_none());
771 assert!(r.is_whole_item_from_history);
772 }
773
774 #[test]
780 fn compute_falls_back_to_icase() {
781 let _g = lock();
782 let ctx = OperationContext::empty();
783 let source = src(&["echo ONE"]);
784 let r = compute_autosuggestion("echo o", 6, &source, &ctx);
785 assert_eq!(r.text, "echo ONE");
786 assert_eq!(r.icase_matched_codepoints, Some(6));
787 }
788
789 #[test]
790 fn compute_rejects_invalid_command() {
791 let _g = lock();
792 let ctx = OperationContext::empty();
793 let source = src(&["nonexistent_zshrs_cmd_xyz --flag"]);
796 let r = compute_autosuggestion("nonex", 5, &source, &ctx);
797 assert!(
798 r.is_empty(),
799 "invalid command must not be suggested: {:?}",
800 r.text
801 );
802 }
803
804 #[test]
805 fn compute_empty_line_suggests_nothing() {
806 let _g = lock();
807 let ctx = OperationContext::empty();
808 let source = src(&["echo hi"]);
809 let r = compute_autosuggestion("", 0, &source, &ctx);
810 assert!(r.is_empty()); }
812
813 fn state_with_suggestion(line: &str, text: &str) -> AutosuggestState {
815 AutosuggestState {
816 autosuggestion: Autosuggestion {
817 text: text.to_owned(),
818 search_string_range: 0..line.chars().count(),
819 icase_matched_codepoints: None,
820 is_whole_item_from_history: true,
821 },
822 ..Default::default()
823 }
824 }
825
826 #[test]
827 fn accept_full_replaces_line() {
828 let mut st = state_with_suggestion("git s", "git status --short");
829 let (range, repl) =
830 accept_autosuggestion(&mut st, AutosuggestionPortion::Count(usize::MAX)).unwrap();
831 assert_eq!(range, 0..5);
832 assert_eq!(repl, "git status --short");
833 assert!(
834 st.autosuggestion.is_empty(),
835 "full accept clears suggestion"
836 );
837 }
838
839 #[test]
840 fn accept_count_appends_chars() {
841 let mut st = state_with_suggestion("git s", "git status --short");
842 let (range, repl) =
843 accept_autosuggestion(&mut st, AutosuggestionPortion::Count(3)).unwrap();
844 assert_eq!(range, 5..5);
845 assert_eq!(repl, "tat");
846 }
847
848 #[test]
849 fn accept_word_stops_at_boundary() {
850 let mut st = state_with_suggestion("git s", "git status --short");
851 let (range, repl) = accept_autosuggestion(&mut st, AutosuggestionPortion::Word).unwrap();
852 assert_eq!(range, 5..5);
853 assert!(repl.starts_with("tatus"), "got {repl:?}");
855 assert!(
856 !repl.contains("short"),
857 "must stop at word boundary: {repl:?}"
858 );
859 }
860
861 #[test]
862 fn accept_on_empty_suggestion_is_none() {
863 let mut st = AutosuggestState::default();
864 assert!(accept_autosuggestion(&mut st, AutosuggestionPortion::Count(usize::MAX)).is_none());
865 }
866
867 #[test]
869 fn delete_suppresses_and_insert_restores() {
870 let _g = lock();
871 let mut st = state_with_suggestion("git s", "git status");
872 on_delete(&mut st);
873 assert!(st.suppress_autosuggestion);
874 assert!(st.autosuggestion.is_empty());
875 assert!(!can_autosuggest(&st, "git "));
876
877 on_insert(&mut st, "git st");
879 assert!(!st.suppress_autosuggestion);
880 assert_eq!(st.autosuggestion.text, "git status");
881 assert_eq!(st.autosuggestion.search_string_range, 0..6);
882 }
883
884 #[test]
885 fn insert_discards_saved_when_no_longer_prefix() {
886 let _g = lock();
887 let mut st = state_with_suggestion("git s", "git status");
888 on_delete(&mut st);
889 on_insert(&mut st, "ls -");
890 assert!(st.autosuggestion.is_empty());
891 }
892
893 #[test]
895 fn stale_result_is_dropped() {
896 let mut st = AutosuggestState::default();
897 let result = AutosuggestionResult::new(
898 "old line".to_owned(),
899 0..8,
900 "old line more".to_owned(),
901 None,
902 true,
903 );
904 autosuggest_completed(&mut st, "new line", result);
905 assert!(st.autosuggestion.is_empty());
906 }
907
908 #[test]
909 fn update_clears_when_line_no_longer_matches() {
910 let _g = lock();
911 let ctx = OperationContext::empty();
912 let source = src(&["echo hello"]);
913 let changed = update_autosuggestion("echo h", 6, &source, &ctx);
915 assert!(changed);
916 with_state(|st| {
917 assert_eq!(st.autosuggestion.text, "echo hello");
918 });
919 let changed2 = update_autosuggestion("zzz_nothing", 11, &source, &ctx);
921 assert!(changed2);
922 with_state(|st| {
923 assert!(st.autosuggestion.is_empty());
924 st.autosuggestion.clear();
925 st.last_request_line.clear();
926 st.saved_autosuggestion = None;
927 });
928 }
929
930 #[test]
931 fn suggestion_suffix() {
932 let s = Autosuggestion {
933 text: "git status".to_owned(),
934 search_string_range: 0..5,
935 icase_matched_codepoints: None,
936 is_whole_item_from_history: true,
937 };
938 assert_eq!(s.suffix(), "tatus");
939 }
940
941 fn ring_of(items: &[&str]) -> Vec<histent> {
942 items
943 .iter()
944 .enumerate()
945 .map(|(i, nam)| histent {
946 node: crate::ported::zsh_h::hashnode {
947 nam: nam.to_string(),
948 ..Default::default()
949 },
950 up: None,
951 down: None,
952 zle_text: None,
953 stim: 0,
954 ftim: 0,
955 words: Vec::new(),
956 nwords: 0,
957 histnum: (items.len() - i) as i64,
958 })
959 .collect()
960 }
961
962 fn fresh_hits(ring: &[histent], prefix: &str, want: usize) -> Vec<usize> {
963 let mut scan = RingPrefixScan::default();
964 advance_prefix_scan(&mut scan, ring, prefix, want);
965 scan.hits.into_iter().take(want).collect()
966 }
967
968 #[test]
972 fn incremental_prefix_scan_matches_fresh_scan() {
973 let ring = ring_of(&[
974 "git status", "ls", "git stash", "gitk", "git status -s", "grep x", "git stash pop",
975 "", "git st", "git show",
976 ]);
977 let mut scan = RingPrefixScan::default();
978 for prefix in ["g", "gi", "git", "git s", "git st", "git sta", "git st", "git stash p", "l"] {
979 for want in [1, 2, 200] {
980 advance_prefix_scan(&mut scan, &ring, prefix, want);
981 let got: Vec<usize> = scan.hits.iter().take(want).copied().collect();
982 assert_eq!(got, fresh_hits(&ring, prefix, want), "prefix {prefix:?} want {want}");
983 }
984 }
985 }
986
987 #[test]
989 fn prefix_scan_restarts_when_ring_changes() {
990 let mut scan = RingPrefixScan::default();
991 let ring = ring_of(&["git status", "ls"]);
992 advance_prefix_scan(&mut scan, &ring, "git", 10);
993 let grown = ring_of(&["gitk", "git status", "ls"]);
994 advance_prefix_scan(&mut scan, &grown, "git", 10);
995 assert_eq!(scan.hits, vec![0, 1]);
996 }
997}