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`. All three are implemented;
24//! `completion` runs after history finds nothing, through the in-editor compsys
25//! entry point (`compsys::in_editor::complete_at`, no subprocess, short deadline).
26
27#![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/// fish:reader.rs:5231-5245 — `Autosuggestion`.
36#[derive(Default, Clone, Debug)]
37pub struct Autosuggestion {
38    /// fish:5232-5233 — The text to use, as an extension/replacement of the current
39    /// line.
40    pub text: String,
41
42    /// fish:5235-5237 — The range within the commandline that was searched. Always
43    /// at least whole line. (Char indices.)
44    pub search_string_range: Range<usize>,
45
46    /// fish:5239-5241 — If the autosuggestion is a case insensitive (prefix) match,
47    /// this indicates the number of code points we matched in the lowercase mapping
48    /// of the suggestion.
49    pub icase_matched_codepoints: Option<usize>,
50
51    /// fish:5243-5244 — Whether the autosuggestion is a whole match from history.
52    pub is_whole_item_from_history: bool,
53}
54
55impl Autosuggestion {
56    // fish:5248-5251 — Clear our contents.
57    pub fn clear(&mut self) {
58        self.text.clear();
59    }
60
61    // fish:5253-5256 — Return whether we have empty text.
62    pub fn is_empty(&self) -> bool {
63        self.text.is_empty()
64    }
65
66    /// The suffix beyond what the user typed — what POSTDISPLAY shows.
67    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/// fish:reader.rs:5259-5303 — `AutosuggestionResult`.
77#[derive(Default)]
78pub struct AutosuggestionResult {
79    // fish:5262-5263 — The autosuggestion.
80    pub autosuggestion: Autosuggestion,
81
82    // fish:5265-5266 — The commandline this result is based off.
83    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    /// fish:5280-5297 — `new`.
95    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    /// fish:5299-5302 — The line which was searched for.
114    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
123/// fish:reader.rs:5509-5516 — `AutosuggestionPortion`.
124pub enum AutosuggestionPortion {
125    Count(usize),
126    Line,
127    /// fish's PerMoveWordStyle, zsh spelling: one forward-word per `$WORDCHARS`.
128    Word,
129}
130
131/// The zsh-autosuggestions strategy config (`ZSH_AUTOSUGGEST_STRATEGY`).
132#[derive(Clone, Copy, PartialEq, Eq, Debug)]
133pub enum Strategy {
134    History,
135    MatchPrevCmd,
136    Completion,
137}
138
139/// !!! WARNING: RUST-ONLY ADAPTER — NO DIRECT FISH COUNTERPART SHAPE !!!
140/// fish keeps these on `Reader` (reader.rs:631-692); zshrs has no reader object, so
141/// the extension owns them module-globally (one editor per process), following the
142/// zle_param_sync extension-state pattern.
143#[derive(Default)]
144pub struct AutosuggestState {
145    /// fish:reader.rs:671-672 — The current autosuggestion.
146    pub autosuggestion: Autosuggestion,
147    /// fish:reader.rs:673-674 — A previously valid autosuggestion (restored when a
148    /// deleted char is retyped).
149    pub saved_autosuggestion: Option<Autosuggestion>,
150    /// fish:reader.rs:679-680 — When backspacing, we temporarily suppress
151    /// autosuggestions.
152    pub suppress_autosuggestion: bool,
153    /// fish:reader.rs:748-752 — the text of the most recent request; used to skip
154    /// duplicate recomputes (the sync analog of in_flight_autosuggest_request).
155    pub last_request_line: String,
156    /// Set by the history-search module while a search is active
157    /// (fish:5525 `history_search.is_at_present()`).
158    pub history_search_active: bool,
159}
160
161/// Module-global state.
162pub static AUTOSUGGEST_STATE: Mutex<Option<AutosuggestState>> = Mutex::new(None);
163
164/// Run f over the state, initializing on first touch.
165pub 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
170/// fish:wcstringutil `string_prefixes_string_maybe_case_insensitive`.
171fn 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
183/// A history source for the suggestion search: yields commands newest-first.
184/// Injected so tests can drive the state machine without a live history engine;
185/// the ZLE wiring passes `history_commands_newest_first`.
186pub type HistorySource<'a> = dyn Fn(&str, usize) -> Vec<String> + 'a;
187
188/// Default source: SQLite session engine prefix query (history.rs:349), falling
189/// back to the in-memory ring (hist.rs:4841, newest at index 0).
190pub fn history_commands_newest_first(prefix: &str, limit: usize) -> Vec<String> {
191    // SQLite prefix probe — the case-sensitive GLOB variant; the LIKE-based
192    // search_prefix full-scans (no index) and is too slow per keystroke.
193    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    // In-memory ring fallback: newest at position 0 (hist.rs:2617-2621).
203    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/// Ring indices whose text starts with `prefix`, newest first, as found so far
211/// for the line being edited. Every match in `ring[..scanned]` is in `hits`.
212#[derive(Default)]
213struct RingPrefixScan {
214    prefix: String,
215    ring_len: usize,
216    newest_histnum: i64,
217    hits: Vec<usize>,
218    scanned: usize,
219}
220
221/// The scan for the line being edited, kept across keystrokes.
222static RING_PREFIX_SCAN: Mutex<Option<RingPrefixScan>> = Mutex::new(None);
223
224/// The newest `want` ring entries whose text starts with `prefix`, as ring
225/// indices (newest first).
226///
227/// Suggestions are recomputed after every keystroke, and the ring holds the
228/// whole history once it is paged in (900k+ entries). Scanning it afresh per
229/// keystroke cost ~1s per command typed. Typing only extends the prefix, and
230/// every entry matching the longer prefix matched the shorter one, so the
231/// previous hits are filtered and the scan resumes where it stopped: a line
232/// costs at most one pass over the ring. Any other change to the prefix or to
233/// the ring restarts the scan.
234fn 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    // Skip foreign/empty entries like hconsearch does (hist.rs:2614-2631).
255    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
264/// The last executed command, for the `match_prev_cmd` strategy.
265fn 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
270/// Read `$ZSH_AUTOSUGGEST_STRATEGY` (defaults to `history`). The plugin
271/// declares it as an ARRAY (`ZSH_AUTOSUGGEST_STRATEGY=( match_prev_cmd )`) —
272/// read it as one, falling back to a scalar spelling.
273pub 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
293/// fish:reader.rs:5305-5507 — `get_autosuggestion_performer`, collapsed to a
294/// synchronous compute (see module header). Whole-line Prefix search; the
295/// LinePrefix continuation-line pass (fish:5329-5350) is skipped — the ZLE buffer
296/// hands us single logical lines.
297pub 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; // fish:5320-5322
306    }
307
308    let line_len = command_line.chars().count();
309    let range = 0..line_len;
310    if range.is_empty() {
311        return nothing; // fish:5333-5335
312    }
313    let search_string = command_line;
314
315    // fish:5324-5325 — Only to be used if no case-sensitive suggestions are found.
316    let mut icase_history_result: Option<AutosuggestionResult> = None;
317
318    // `match_prev_cmd` (zsh-autosuggestions): restrict candidates to entries that
319    // followed the previously executed command in history.
320    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    // fish:5351-5359 — walk matching history newest-first.
330    let mut candidates = source(search_string, 64);
331    // zsh-autosuggestions `match_prev_cmd` is a PREFERENCE, not a filter: it
332    // picks the most recent match whose PRECEDING history item equals the
333    // last executed command, falling back to the plain most-recent match
334    // when no neighbor-match exists (the plugin's own code path).
335    // Implementing it as a hard filter killed all suggestions for configs
336    // like `ZSH_AUTOSUGGEST_STRATEGY=( match_prev_cmd )`.
337    // The plugin looks only at the newest 200 matches
338    // (`history_match_keys[1,200]`, strategies/match_prev_cmd.zsh).
339    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    // Budget-degradation fallback: fish validates candidates with unbounded
354    // background time; the synchronous pass caps validation work. If the
355    // budget dies before anything validates, suggest the first (newest)
356    // prefix match UNVALIDATED — exactly what zsh-autosuggestions does on
357    // every keystroke, so the degraded mode is still plugin-parity.
358    let mut unvalidated_fallback: Option<AutosuggestionResult> = None;
359
360    // Single-line ghost rule: when the typed buffer is single-line, a
361    // multiline history candidate is suggested only up to its first newline
362    // (fish's completion suggestions do the same via line_at_cursor,
363    // reader.rs:5494). POSTDISPLAY newlines render as real line breaks and
364    // collide with multi-row prompts (p10k) — the ghost scrambled the
365    // prompt block until the renderer learns multiline ghosts.
366    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        // fish:5362-5375 — case-sensitive prefix first, then one icase fallback.
380        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; // false when truncated at a newline
394        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        // fish:5413-5419 — validate (cd targets must exist, command must resolve).
411        if crate::syntax_highlight::autosuggest_validate_from_history(
412            full,
413            &[],
414            &working_directory,
415            ctx,
416        ) {
417            // fish:5420-5433
418            let result = make_result();
419            if icase {
420                icase_history_result = Some(result);
421            } else {
422                return result;
423            }
424        }
425    }
426
427    // fish:5468-5472 — no case-sensitive result: fall back to icase history.
428    if let Some(result) = icase_history_result {
429        return result;
430    }
431    // Budget exhausted before any validation succeeded: plugin-parity fallback.
432    if ctx.check_cancel() {
433        if let Some(result) = unvalidated_fallback {
434            return result;
435        }
436    }
437
438    // fish:5443-5495 — completion-based suggestions (zsh-autosuggestions
439    // `completion` strategy).
440    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
448/// Budget for the in-editor completion probe behind the `completion`
449/// strategy. The probe runs synchronously per keystroke, so it is short; a cold
450/// shell thread answers "incomplete" immediately and the next keystroke retries.
451const COMPLETION_SUGGEST_BUDGET: std::time::Duration = std::time::Duration::from_millis(50);
452
453/// fish:reader.rs:5443-5507 — the "Try normal completions" half of
454/// `get_autosuggestion_performer`. Returns the first completion that extends the
455/// word under the cursor, applied append-only (fish `append_only = true`).
456fn completion_autosuggestion(
457    command_line: &str,
458    cursor_pos: usize,
459    ctx: &OperationContext,
460) -> Option<AutosuggestionResult> {
461    // fish:5445-5448 — an empty line has nothing to complete.
462    let last_char = command_line.chars().next_back()?;
463    // fish:5450-5458 — a line ending in whitespace with the cursor elsewhere
464    // would spam the right of the line while editing; a trailing quote at the
465    // end must not get stuff dumped after it.
466    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    // fish `CompletionRequestOptions::autosuggest()`: no command execution.
478    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    // fish:5476-5480 — first completion; here the first one that strictly
487    // extends the text it replaces (append-only application).
488    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    // fish:5481-5494 — `line_at_cursor`: a single-line ghost.
494    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
505/// fish:reader.rs:5519-5531 — `can_autosuggest`.
506pub fn can_autosuggest(state: &AutosuggestState, line: &str) -> bool {
507    // We autosuggest if suppress_autosuggestion is not set, if we're not doing a
508    // history search, and our command line contains a non-whitespace character.
509    // zsh-autosuggestions config: a buffer longer than
510    // ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE disables suggestion compute.
511    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
522/// fish:reader.rs:5534-5573 — `autosuggest_completed` (sync path: staleness can't
523/// happen, but the prefix re-check is kept — the widget may have edited the line
524/// between compute and store in future async use).
525pub fn autosuggest_completed(
526    state: &mut AutosuggestState,
527    line: &str,
528    result: AutosuggestionResult,
529) {
530    if result.command_line != line {
531        // fish:5539-5542 — This autosuggestion is stale.
532        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        // fish:5559-5572 — Autosuggestion is active and the search term has not
542        // changed, so we're good to go.
543        state.autosuggestion = result.autosuggestion;
544    }
545}
546
547/// fish:reader.rs:5575-5610 — `update_autosuggestion`. Returns true when the stored
548/// suggestion changed (the caller repaints POSTDISPLAY).
549pub 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        // fish:5576-5581 — If we can't autosuggest, just clear it.
559        if !can_autosuggest(state, line) {
560            state.last_request_line.clear();
561            state.autosuggestion.clear();
562            return state.autosuggestion.text != before;
563        }
564
565        // fish:5583-5592 — still at a line with a valid suggestion: keep it.
566        if is_at_line_with_autosuggestion(state, line, cursor) {
567            return false;
568        }
569
570        // fish:5594-5597 — Do nothing if we've already kicked off this request.
571        if line == state.last_request_line {
572            // A stale suggestion that no longer prefixes must still be dropped.
573            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        // fish:5600-5609 — Clear the autosuggestion and (synchronously) recompute.
581        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
588/// fish:reader.rs:5620-5633 — `is_at_autosuggestion` (cursor exactly at the end of
589/// the searched range; CursorEndMode::Exclusive — the vi-cmdmode inclusive case is
590/// handled by the wiring passing an adjusted cursor).
591pub 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
598/// fish:reader.rs:5635-5650 — `is_at_line_with_autosuggestion`.
599pub 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    // Suggestion ranges are whole-line here; the line must still BE the search
605    // string (fish asserts the prefix relation at reader.rs:5586-5590).
606    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
615/// fish:reader.rs:5652-5760 — Accept any autosuggestion. Returns the (range,
616/// replacement) edit to apply to the command line: `range` is the char-index span
617/// to replace, `replacement` the text to splice in. None = nothing to accept.
618pub 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    // fish:5664-5719 — Accept the autosuggestion.
630    let (range, replacement): (Range<usize>, String) = match amount {
631        AutosuggestionPortion::Count(count) => {
632            if count == usize::MAX {
633                // full accept: replace the whole searched range with the suggestion
634                (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            // fish:5682-5695
651            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            // fish:5696-5719 — fish consumes MoveWordStateMachine chars; the zsh
666            // spelling is one forward-word over $WORDCHARS (word chars are
667            // alphanumerics plus $WORDCHARS members).
668            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            // skip any leading non-word chars, then consume the word
674            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    // fish:5722-5759 — full accept clears the suggestion; partial keeps the rest
691    // (the next update re-anchors it because the line now prefixes the text).
692    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
702/// fish deletion paths set `suppress_autosuggestion` and save the suggestion so
703/// retyping restores it (reader.rs:679-680 + delete_char). The wiring calls this
704/// after any deleting widget.
705pub 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
714/// Inserting text lifts the suppression (fish clears suppress_autosuggestion on
715/// insert); a saved suggestion that still prefixes is restored without a search.
716pub 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
734/// Line finished (accept-line/send-break): drop all suggestion state.
735pub 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    // fish:5362-5375 — case-sensitive match wins over icase.
763    #[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    /// The case difference sits in the arguments, not the command word: a
775    /// suggestion is offered only when its command resolves
776    /// (`compute_rejects_invalid_command`), and `Echo` resolves on macOS's
777    /// case-insensitive filesystem but not on Linux, so a capitalised command
778    /// tested the host's filesystem rather than the icase fallback.
779    #[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        // fish:5413-5419 — a history entry whose command no longer resolves is not
794        // suggested.
795        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()); // fish:5333-5335 / 5443-5446
811    }
812
813    // fish:5654-5719 — accept portions.
814    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        // consumes "tatus" then stops before the space→"--short" run begins…
854        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    // fish:679-680 — backspace suppression + retype restore.
868    #[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        // Retyping a prefix of the saved suggestion restores it.
878        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    // fish:5534-5542 — staleness check.
894    #[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        // Seed state via a real update.
914        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        // A line that matches nothing clears the suggestion.
920        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    // Typing a line keystroke by keystroke must give the same hits as scanning
969    // afresh for each prefix, including after the cap stopped an earlier scan
970    // short and after a backspace.
971    #[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    // A new command at the ring head shifts every index: the scan restarts.
988    #[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}