Skip to main content

zsh/extensions/
autosuggest.rs

1//! Port of fish-shell's autosuggestion state machine from `reader/reader.rs`
2//! (vendor/fish/reader/reader.rs:5231-5760) — native fish-style autosuggestions.
3//!
4//! zsh-autosuggestions is a script-level recreation of this fish machinery; this
5//! ports the origin directly and reads the plugin's config surface
6//! (`ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE`, `ZSH_AUTOSUGGEST_STRATEGY`,
7//! `ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE`) so existing user config applies unchanged.
8//!
9//! zshrs substrate swaps (each cited at its site):
10//!   * fish `Reader` fields         → module-level `AUTOSUGGEST_STATE` (the
11//!     zle_param_sync extension-state pattern)
12//!   * fish `History`/`HistorySearch` → `HistoryEngine::search_prefix`
13//!     (extensions/history.rs:349) + the in-memory `hist_ring` (newest at index 0,
14//!     ported/hist.rs:2617-2621)
15//!   * background debouncer          → synchronous compute in the post-widget slot
16//!     (the SQLite prefix probe is a single indexed LIKE; fish went async because of
17//!     file-IO validation, which here is budgeted by the OperationContext cancel flag)
18//!   * rendering                     → the suggestion suffix is published to
19//!     POSTDISPLAY by the ZLE wiring; this module owns only the state machine
20//!
21//! fish strategies vs zsh-autosuggestions strategies: fish searches history then
22//! falls back to completions (reader.rs:5462-5495). zsh-autosuggestions names these
23//! `history`, `completion`, and adds `match_prev_cmd`. `history` and
24//! `match_prev_cmd` are implemented; `completion` falls back to `history` until the
25//! native completion engine exposes an autosuggest-grade entry point.
26
27#![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/// fish:reader.rs:5231-5245 — `Autosuggestion`.
35#[derive(Default, Clone, Debug)]
36pub struct Autosuggestion {
37    /// fish:5232-5233 — The text to use, as an extension/replacement of the current
38    /// line.
39    pub text: String,
40
41    /// fish:5235-5237 — The range within the commandline that was searched. Always
42    /// at least whole line. (Char indices.)
43    pub search_string_range: Range<usize>,
44
45    /// fish:5239-5241 — If the autosuggestion is a case insensitive (prefix) match,
46    /// this indicates the number of code points we matched in the lowercase mapping
47    /// of the suggestion.
48    pub icase_matched_codepoints: Option<usize>,
49
50    /// fish:5243-5244 — Whether the autosuggestion is a whole match from history.
51    pub is_whole_item_from_history: bool,
52}
53
54impl Autosuggestion {
55    // fish:5248-5251 — Clear our contents.
56    pub fn clear(&mut self) {
57        self.text.clear();
58    }
59
60    // fish:5253-5256 — Return whether we have empty text.
61    pub fn is_empty(&self) -> bool {
62        self.text.is_empty()
63    }
64
65    /// The suffix beyond what the user typed — what POSTDISPLAY shows.
66    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/// fish:reader.rs:5259-5303 — `AutosuggestionResult`.
76#[derive(Default)]
77pub struct AutosuggestionResult {
78    // fish:5262-5263 — The autosuggestion.
79    pub autosuggestion: Autosuggestion,
80
81    // fish:5265-5266 — The commandline this result is based off.
82    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    /// fish:5280-5297 — `new`.
94    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    /// fish:5299-5302 — The line which was searched for.
113    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
122/// fish:reader.rs:5509-5516 — `AutosuggestionPortion`.
123pub enum AutosuggestionPortion {
124    Count(usize),
125    Line,
126    /// fish's PerMoveWordStyle, zsh spelling: one forward-word per `$WORDCHARS`.
127    Word,
128}
129
130/// The zsh-autosuggestions strategy config (`ZSH_AUTOSUGGEST_STRATEGY`).
131#[derive(Clone, Copy, PartialEq, Eq, Debug)]
132pub enum Strategy {
133    History,
134    MatchPrevCmd,
135}
136
137/// !!! WARNING: RUST-ONLY ADAPTER — NO DIRECT FISH COUNTERPART SHAPE !!!
138/// fish keeps these on `Reader` (reader.rs:631-692); zshrs has no reader object, so
139/// the extension owns them module-globally (one editor per process), following the
140/// zle_param_sync extension-state pattern.
141#[derive(Default)]
142pub struct AutosuggestState {
143    /// fish:reader.rs:671-672 — The current autosuggestion.
144    pub autosuggestion: Autosuggestion,
145    /// fish:reader.rs:673-674 — A previously valid autosuggestion (restored when a
146    /// deleted char is retyped).
147    pub saved_autosuggestion: Option<Autosuggestion>,
148    /// fish:reader.rs:679-680 — When backspacing, we temporarily suppress
149    /// autosuggestions.
150    pub suppress_autosuggestion: bool,
151    /// fish:reader.rs:748-752 — the text of the most recent request; used to skip
152    /// duplicate recomputes (the sync analog of in_flight_autosuggest_request).
153    pub last_request_line: String,
154    /// Set by the history-search module while a search is active
155    /// (fish:5525 `history_search.is_at_present()`).
156    pub history_search_active: bool,
157}
158
159/// Module-global state.
160pub static AUTOSUGGEST_STATE: Mutex<Option<AutosuggestState>> = Mutex::new(None);
161
162/// Run f over the state, initializing on first touch.
163pub 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
168/// fish:wcstringutil `string_prefixes_string_maybe_case_insensitive`.
169fn 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
181/// A history source for the suggestion search: yields commands newest-first.
182/// Injected so tests can drive the state machine without a live history engine;
183/// the ZLE wiring passes `history_commands_newest_first`.
184pub type HistorySource<'a> = dyn Fn(&str, usize) -> Vec<String> + 'a;
185
186/// Default source: SQLite session engine prefix query (history.rs:349), falling
187/// back to the in-memory ring (hist.rs:4841, newest at index 0).
188pub fn history_commands_newest_first(prefix: &str, limit: usize) -> Vec<String> {
189    // SQLite prefix probe — the case-sensitive GLOB variant; the LIKE-based
190    // search_prefix full-scans (no index) and is too slow per keystroke.
191    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    // In-memory ring fallback: newest at position 0 (hist.rs:2617-2621), skip
201    // foreign entries like hconsearch does (hist.rs:2614-2631).
202    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
210/// The last executed command, for the `match_prev_cmd` strategy.
211fn 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
216/// Read `$ZSH_AUTOSUGGEST_STRATEGY` (defaults to `history`). The plugin
217/// declares it as an ARRAY (`ZSH_AUTOSUGGEST_STRATEGY=( match_prev_cmd )`) —
218/// read it as one, falling back to a scalar spelling.
219pub 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
238/// fish:reader.rs:5305-5507 — `get_autosuggestion_performer`, collapsed to a
239/// synchronous compute (see module header). Whole-line Prefix search; the
240/// LinePrefix continuation-line pass (fish:5329-5350) is skipped — the ZLE buffer
241/// hands us single logical lines.
242pub 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; // fish:5320-5322
251    }
252
253    let line_len = command_line.chars().count();
254    let range = 0..line_len;
255    if range.is_empty() {
256        return nothing; // fish:5333-5335
257    }
258    let search_string = command_line;
259
260    // fish:5324-5325 — Only to be used if no case-sensitive suggestions are found.
261    let mut icase_history_result: Option<AutosuggestionResult> = None;
262
263    // `match_prev_cmd` (zsh-autosuggestions): restrict candidates to entries that
264    // followed the previously executed command in history.
265    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    // fish:5351-5359 — walk matching history newest-first.
275    let mut candidates = source(search_string, 64);
276    // zsh-autosuggestions `match_prev_cmd` is a PREFERENCE, not a filter: it
277    // picks the most recent match whose PRECEDING history item equals the
278    // last executed command, falling back to the plain most-recent match
279    // when no neighbor-match exists (the plugin's own code path).
280    // Implementing it as a hard filter killed all suggestions for configs
281    // like `ZSH_AUTOSUGGEST_STRATEGY=( match_prev_cmd )`.
282    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    // Budget-degradation fallback: fish validates candidates with unbounded
296    // background time; the synchronous pass caps validation work. If the
297    // budget dies before anything validates, suggest the first (newest)
298    // prefix match UNVALIDATED — exactly what zsh-autosuggestions does on
299    // every keystroke, so the degraded mode is still plugin-parity.
300    let mut unvalidated_fallback: Option<AutosuggestionResult> = None;
301
302    // Single-line ghost rule: when the typed buffer is single-line, a
303    // multiline history candidate is suggested only up to its first newline
304    // (fish's completion suggestions do the same via line_at_cursor,
305    // reader.rs:5494). POSTDISPLAY newlines render as real line breaks and
306    // collide with multi-row prompts (p10k) — the ghost scrambled the
307    // prompt block until the renderer learns multiline ghosts.
308    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        // fish:5362-5375 — case-sensitive prefix first, then one icase fallback.
322        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; // false when truncated at a newline
336        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        // fish:5413-5419 — validate (cd targets must exist, command must resolve).
353        if crate::syntax_highlight::autosuggest_validate_from_history(
354            full,
355            &[],
356            &working_directory,
357            ctx,
358        ) {
359            // fish:5420-5433
360            let result = make_result();
361            if icase {
362                icase_history_result = Some(result);
363            } else {
364                return result;
365            }
366        }
367    }
368
369    // fish:5468-5472 — no case-sensitive result: fall back to icase history.
370    if let Some(result) = icase_history_result {
371        return result;
372    }
373    // Budget exhausted before any validation succeeded: plugin-parity fallback.
374    if ctx.check_cancel() {
375        if let Some(result) = unvalidated_fallback {
376            return result;
377        }
378    }
379
380    // fish:5443-5495 — completion-based suggestions: not yet wired (see module
381    // header). cursor_pos is unused until then.
382    let _ = cursor_pos;
383    nothing
384}
385
386/// fish:reader.rs:5519-5531 — `can_autosuggest`.
387pub fn can_autosuggest(state: &AutosuggestState, line: &str) -> bool {
388    // We autosuggest if suppress_autosuggestion is not set, if we're not doing a
389    // history search, and our command line contains a non-whitespace character.
390    // zsh-autosuggestions config: a buffer longer than
391    // ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE disables suggestion compute.
392    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
403/// fish:reader.rs:5534-5573 — `autosuggest_completed` (sync path: staleness can't
404/// happen, but the prefix re-check is kept — the widget may have edited the line
405/// between compute and store in future async use).
406pub fn autosuggest_completed(state: &mut AutosuggestState, line: &str, result: AutosuggestionResult) {
407    if result.command_line != line {
408        // fish:5539-5542 — This autosuggestion is stale.
409        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        // fish:5559-5572 — Autosuggestion is active and the search term has not
419        // changed, so we're good to go.
420        state.autosuggestion = result.autosuggestion;
421    }
422}
423
424/// fish:reader.rs:5575-5610 — `update_autosuggestion`. Returns true when the stored
425/// suggestion changed (the caller repaints POSTDISPLAY).
426pub 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        // fish:5576-5581 — If we can't autosuggest, just clear it.
436        if !can_autosuggest(state, line) {
437            state.last_request_line.clear();
438            state.autosuggestion.clear();
439            return state.autosuggestion.text != before;
440        }
441
442        // fish:5583-5592 — still at a line with a valid suggestion: keep it.
443        if is_at_line_with_autosuggestion(state, line, cursor) {
444            return false;
445        }
446
447        // fish:5594-5597 — Do nothing if we've already kicked off this request.
448        if line == state.last_request_line {
449            // A stale suggestion that no longer prefixes must still be dropped.
450            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        // fish:5600-5609 — Clear the autosuggestion and (synchronously) recompute.
458        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
465/// fish:reader.rs:5620-5633 — `is_at_autosuggestion` (cursor exactly at the end of
466/// the searched range; CursorEndMode::Exclusive — the vi-cmdmode inclusive case is
467/// handled by the wiring passing an adjusted cursor).
468pub 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
475/// fish:reader.rs:5635-5650 — `is_at_line_with_autosuggestion`.
476pub 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    // Suggestion ranges are whole-line here; the line must still BE the search
482    // string (fish asserts the prefix relation at reader.rs:5586-5590).
483    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
492/// fish:reader.rs:5652-5760 — Accept any autosuggestion. Returns the (range,
493/// replacement) edit to apply to the command line: `range` is the char-index span
494/// to replace, `replacement` the text to splice in. None = nothing to accept.
495pub 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    // fish:5664-5719 — Accept the autosuggestion.
507    let (range, replacement): (Range<usize>, String) = match amount {
508        AutosuggestionPortion::Count(count) => {
509            if count == usize::MAX {
510                // full accept: replace the whole searched range with the suggestion
511                (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            // fish:5682-5695
528            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            // fish:5696-5719 — fish consumes MoveWordStateMachine chars; the zsh
543            // spelling is one forward-word over $WORDCHARS (word chars are
544            // alphanumerics plus $WORDCHARS members).
545            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            // skip any leading non-word chars, then consume the word
551            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    // fish:5722-5759 — full accept clears the suggestion; partial keeps the rest
568    // (the next update re-anchors it because the line now prefixes the text).
569    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
579/// fish deletion paths set `suppress_autosuggestion` and save the suggestion so
580/// retyping restores it (reader.rs:679-680 + delete_char). The wiring calls this
581/// after any deleting widget.
582pub 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
591/// Inserting text lifts the suppression (fish clears suppress_autosuggestion on
592/// insert); a saved suggestion that still prefixes is restored without a search.
593pub 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
611/// Line finished (accept-line/send-break): drop all suggestion state.
612pub 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    // fish:5362-5375 — case-sensitive match wins over icase.
642    #[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        // fish:5413-5419 — a history entry whose command no longer resolves is not
668        // suggested.
669        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()); // fish:5333-5335 / 5443-5446
681    }
682
683    // fish:5654-5719 — accept portions.
684    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        // consumes "tatus" then stops before the space→"--short" run begins…
720        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    // fish:679-680 — backspace suppression + retype restore.
731    #[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        // Retyping a prefix of the saved suggestion restores it.
741        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    // fish:5534-5542 — staleness check.
757    #[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        // Seed state via a real update.
777        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        // A line that matches nothing clears the suggestion.
783        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}