Skip to main content

kimun_notes/components/autocomplete/
controller.rs

1use std::num::NonZeroU64;
2use std::ops::Range;
3use std::sync::Arc;
4use std::time::Duration;
5
6use kimun_core::NoteVault;
7use kimun_core::note::ExclusionZones;
8use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
9
10use super::host::AutocompleteHost;
11use super::popup::{PopupAction, PopupOutcome, handle_key as popup_handle_key};
12use super::state::{AutocompleteState, DEFAULT_MAX_VISIBLE_ROWS, Suggestion};
13use super::trigger::{TriggerKind, TriggerOptions, ZoneOracle, detect_trigger_with_oracle};
14#[cfg(test)]
15use crate::components::text_editor::snapshot::EditorSnapshot;
16use crate::util::single_slot_task::SingleSlotTask;
17
18/// Hard cap on suggestions fetched from core per query. The popup itself
19/// only shows `max_visible_rows` at a time and scrolls inside the fetched
20/// set, so a few dozen rows is plenty.
21const DEFAULT_FETCH_LIMIT: usize = 50;
22
23/// Wait this long after a query-refinement keystroke before hitting the
24/// vault. Two cases:
25///
26/// - Fast typing (inter-keystroke gap < `DEFAULT_DEBOUNCE`): each new
27///   keystroke aborts the previous in-flight task while it is still
28///   inside `tokio::time::sleep`, so only the final keystroke's query
29///   reaches SQLite. This is the case the debounce is optimised for.
30/// - Normal typing (inter-keystroke gap ≥ `DEFAULT_DEBOUNCE`): every
31///   keystroke still runs its own query, with `DEFAULT_DEBOUNCE` of added
32///   latency between keystroke and popup update. The debounce does NOT
33///   reduce work in this regime — it bounds responsiveness.
34///
35/// The first query of a popup (kind change / popup opening) skips the
36/// debounce entirely so the popup feels instant on open.
37const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(80);
38
39/// Whether wikilink triggers are honoured. The editor uses
40/// `Both`; the search box uses `HashtagOnly` because the search syntax has
41/// no `[[…]]` operator.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum AutocompleteMode {
44    Both,
45    HashtagOnly,
46}
47
48/// Owns the popup lifecycle and the (debounced via generation tokens)
49/// query plumbing. The host calls `sync(host)` after every edit; the
50/// controller decides whether to open, refresh, or close the popup.
51pub struct AutocompleteController {
52    state: Option<AutocompleteState>,
53    vault: Arc<NoteVault>,
54    mode: AutocompleteMode,
55    /// Trigger-detection options passed to `detect_trigger_with` on every
56    /// `sync`. The editor leaves header disambiguation on; the search box
57    /// switches it off because its input has no Markdown headers.
58    trigger_opts: TriggerOptions,
59    /// Monotonic counter incremented on every fired query. Responses that
60    /// arrive with a stale generation are discarded.
61    generation: u64,
62    result_tx: UnboundedSender<QueryResult>,
63    result_rx: UnboundedReceiver<QueryResult>,
64    fetch_limit: usize,
65    max_visible_rows: usize,
66    /// Handle of the most recently spawned query task. Spawning a new
67    /// query into this slot aborts the previous one, so a burst of
68    /// keystrokes does not pile up N concurrent SQLite queries holding
69    /// the vault `Arc` open. The slot's `Drop` also aborts, so the
70    /// task cannot outlive the controller.
71    in_flight: SingleSlotTask<()>,
72    /// Delay inserted before each refinement query hits the vault. A burst
73    /// of typing aborts the prior in-flight task during this window, so
74    /// only the final keystroke's query reaches SQLite. The first query of
75    /// a popup (kind change / open) bypasses the debounce. Tests override
76    /// to `Duration::ZERO` via `with_debounce`.
77    debounce: Duration,
78    /// The joined buffer text keyed on the host's `content_revision`,
79    /// plus its `ExclusionZones` computed LAZILY (`None` until the
80    /// trigger veto first needs them). The text is rebuilt only when the
81    /// revision moves; cursor moves reuse it. The zones — a full-buffer
82    /// pulldown-cmark + regex scan — are computed only when a `[[`/`#`
83    /// opener is found, then memoized here so a later cursor move at the
84    /// same revision reuses them. Hosts with no stable revision identity
85    /// (search-box modal) return `None` from `content_revision` and never
86    /// populate this slot.
87    cached_text: Option<(NonZeroU64, String, Option<ExclusionZones>)>,
88    /// Optional callback used to wake the host's render loop after an
89    /// async query posts its result. Decoupled from any specific event
90    /// bus so the controller stays usable wherever the host can
91    /// trigger a redraw.
92    redraw_cb: Option<RedrawCallback>,
93}
94
95/// Fire-and-forget redraw signal owned by the controller and invoked
96/// from the spawned query task. The host wires this to its event loop
97/// (e.g. `tx.send(AppEvent::Redraw)`).
98pub type RedrawCallback = Arc<dyn Fn() + Send + Sync + 'static>;
99
100#[derive(Debug)]
101struct QueryResult {
102    generation: u64,
103    kind: TriggerKind,
104    items: Vec<Suggestion>,
105}
106
107/// [`ZoneOracle`] that computes `ExclusionZones` from `text` on first
108/// query and memoizes the result back into the borrowed slot — so the
109/// full-buffer scan runs at most once per buffer revision, and only when
110/// a trigger candidate actually reaches the exclusion veto.
111struct LazyZoneOracle<'a> {
112    text: &'a str,
113    zones: &'a mut Option<ExclusionZones>,
114}
115
116impl ZoneOracle for LazyZoneOracle<'_> {
117    fn contains(&mut self, cursor: usize) -> bool {
118        let text = self.text;
119        self.zones
120            .get_or_insert_with(|| ExclusionZones::from_text(text))
121            .contains(cursor)
122    }
123
124    fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool {
125        let text = self.text;
126        self.zones
127            .get_or_insert_with(|| ExclusionZones::from_text(text))
128            .contains_code_link_or_frontmatter(cursor)
129    }
130}
131
132impl AutocompleteController {
133    pub fn new(vault: Arc<NoteVault>, mode: AutocompleteMode) -> Self {
134        let (result_tx, result_rx) = unbounded_channel();
135        Self {
136            state: None,
137            vault,
138            mode,
139            trigger_opts: TriggerOptions::default(),
140            generation: 0,
141            result_tx,
142            result_rx,
143            fetch_limit: DEFAULT_FETCH_LIMIT,
144            max_visible_rows: DEFAULT_MAX_VISIBLE_ROWS,
145            in_flight: SingleSlotTask::empty(),
146            debounce: DEFAULT_DEBOUNCE,
147            cached_text: None,
148            redraw_cb: None,
149        }
150    }
151
152    /// Override the trigger-detection options. Used by the search-box
153    /// controller to disable the column-0 header disambiguation rule
154    /// (Markdown headers don't exist in a search input).
155    pub fn with_trigger_opts(mut self, opts: TriggerOptions) -> Self {
156        self.trigger_opts = opts;
157        self
158    }
159
160    /// Override the per-refinement debounce window. Tests pass
161    /// `Duration::ZERO` so query results land promptly inside `drain_results`.
162    #[cfg(test)]
163    pub fn with_debounce(mut self, debounce: Duration) -> Self {
164        self.debounce = debounce;
165        self
166    }
167
168    /// Register a redraw callback. Without one, the popup state updates
169    /// on background threads but the render loop has no signal to
170    /// wake. Idempotent — safe to call from a host's first
171    /// `handle_input` to lazily bind once the host has a way to
172    /// trigger redraws.
173    pub fn set_redraw_callback(&mut self, cb: RedrawCallback) {
174        self.redraw_cb = Some(cb);
175    }
176
177    /// Whether the popup is currently *interactive* — held state AND at
178    /// least one visible suggestion. Returns `false` while a query is
179    /// in flight (state exists but items not yet arrived) or when a
180    /// query returned no matches: in both cases the popup is not drawn
181    /// and must not intercept key events, so Esc/Up/Down/Tab fall
182    /// through to the host (modal Esc closes the modal, list Up/Down
183    /// navigates files, etc).
184    pub fn is_open(&self) -> bool {
185        self.state.as_ref().is_some_and(|s| !s.items.is_empty())
186    }
187
188    /// Borrow the popup state for read-only inspection (rendering,
189    /// query introspection, tests). Returns `None` whenever the popup
190    /// is not active.
191    pub fn state(&self) -> Option<&AutocompleteState> {
192        self.state.as_ref()
193    }
194
195    /// Borrow the popup state mutably. The only legitimate
196    /// caller-side use today is the host's render path, which
197    /// re-anchors `state.anchor` from the freshly rendered caret
198    /// position so the popup follows the cursor without a one-frame
199    /// lag. Mutating `items` / `highlighted` / `scroll_offset` from
200    /// outside the controller will desync the popup; use the
201    /// dedicated `sync` / `refresh_if_open` / `handle_key` entry
202    /// points for those.
203    pub fn state_mut(&mut self) -> Option<&mut AutocompleteState> {
204        self.state.as_mut()
205    }
206
207    /// Close the popup immediately. Safe to call when already closed.
208    /// Use whenever focus moves away from the host or the host
209    /// triggers a buffer-replacement that invalidates the trigger
210    /// context (e.g. `set_text`).
211    ///
212    /// Also aborts any in-flight query task — without this, pressing Esc
213    /// during the 80ms debounce window leaks a spawned tokio task that
214    /// continues to the SQLite hit and posts a result discarded later
215    /// via generation mismatch.
216    pub fn close(&mut self) {
217        self.state = None;
218        self.in_flight.abort();
219        // Drop the cached buffer text + zones. On a multi-MB note these
220        // hold a full clone of the buffer + parsed exclusion-zone
221        // ranges; without this clear they survive popup dismissal
222        // until the next text edit overwrites the slot.
223        self.cached_text = None;
224    }
225
226    /// Route a key event through the popup when one is open. Returns a
227    /// `HandleKeyOutcome` so the host can decide whether to apply an
228    /// accept, fall through to its own key handling, etc. The controller
229    /// never mutates the host's buffer directly — on accept it returns an
230    /// `AcceptAction` describing the replacement.
231    pub fn handle_key<H: AutocompleteHost>(
232        &mut self,
233        key: ratatui::crossterm::event::KeyEvent,
234        host: &H,
235    ) -> HandleKeyOutcome {
236        let Some(state) = self.state.as_mut() else {
237            return HandleKeyOutcome::NotHandled;
238        };
239        let outcome = popup_handle_key(state, key);
240        match outcome {
241            PopupOutcome::Consumed(PopupAction::None) => HandleKeyOutcome::Consumed,
242            PopupOutcome::Consumed(PopupAction::Accept) => {
243                // Compute the accept BEFORE closing so a stale-range
244                // failure (None) can fall through to the host's normal
245                // key handling instead of silently swallowing the key:
246                // user pressed Tab expecting an indent or Enter
247                // expecting a newline; if the accept can't run we
248                // should still give them the key back.
249                match self.compute_accept(host) {
250                    Some(action) => {
251                        self.close();
252                        HandleKeyOutcome::Accepted(action)
253                    }
254                    None => {
255                        self.close();
256                        HandleKeyOutcome::NotHandled
257                    }
258                }
259            }
260            PopupOutcome::Consumed(PopupAction::Dismiss) => {
261                self.close();
262                HandleKeyOutcome::Dismissed
263            }
264            PopupOutcome::NotHandled => HandleKeyOutcome::NotHandled,
265        }
266    }
267
268    /// Inspect the host's current buffer + cursor and reconcile the popup
269    /// state. Call this after a **text edit** (insert / delete / paste /
270    /// any change that modifies the buffer). Will open a fresh popup
271    /// when the cursor lands inside a trigger context, refresh an open
272    /// popup's range/query/anchor, or close the popup when the trigger
273    /// is gone.
274    pub fn sync<H: AutocompleteHost>(&mut self, host: &H) {
275        self.reconcile(host, true);
276    }
277
278    /// Refresh the popup state for a **cursor-only** event (arrow keys,
279    /// click, Home/End, etc). If the popup is closed, this is a no-op —
280    /// cursor movement never opens a new popup. If the popup is open,
281    /// it follows the cursor: query, range, and anchor update; the
282    /// popup closes when the cursor leaves the trigger range.
283    pub fn refresh_if_open<H: AutocompleteHost>(&mut self, host: &H) {
284        if self.state.is_some() {
285            self.reconcile(host, false);
286        }
287    }
288
289    fn reconcile<H: AutocompleteHost>(&mut self, host: &H, allow_open: bool) {
290        // Single borrow of the host's buffer + cursor. The snapshot
291        // is borrowed (Textarea backend) so no per-keystroke lines
292        // clone happens here.
293        let snap = host.buffer_snapshot();
294        let cursor = snap.cursor_byte_offset();
295        let cache_key = host.cache_key();
296        // Rebuild the joined buffer text only when the host's cache key
297        // has moved on; cursor moves reuse it. The expensive
298        // `ExclusionZones` scan (full-buffer pulldown-cmark + regex)
299        // stays LAZY — the oracle below computes it only if the local
300        // trigger scan finds a `[[`/`#` opener that needs the exclusion
301        // veto, and memoizes it into the cache slot so a later cursor
302        // move at the same revision reuses it. Normal prose keystrokes
303        // (no opener at the caret) never pay the scan at all.
304        //
305        // `cache_key == None` opts the host out (search-box modal): join
306        // locally, use a throwaway lazy memo, and never touch the cache.
307        let trigger = match cache_key {
308            Some(rev) => {
309                let hit = matches!(&self.cached_text, Some((r, _, _)) if *r == rev);
310                if !hit {
311                    self.cached_text = Some((rev, snap.lines.join("\n"), None));
312                }
313                let (_, text, zones_slot) = self.cached_text.as_mut().expect("just populated");
314                let text: &str = text;
315                let mut oracle = LazyZoneOracle {
316                    text,
317                    zones: zones_slot,
318                };
319                detect_trigger_with_oracle(text, cursor, self.trigger_opts, &mut oracle)
320            }
321            None => {
322                let text = snap.lines.join("\n");
323                let mut zones: Option<ExclusionZones> = None;
324                let mut oracle = LazyZoneOracle {
325                    text: &text,
326                    zones: &mut zones,
327                };
328                detect_trigger_with_oracle(&text, cursor, self.trigger_opts, &mut oracle)
329            }
330        };
331
332        // Filter by mode before deciding anything else.
333        let trigger = trigger.filter(|t| match (self.mode, t.kind) {
334            (AutocompleteMode::Both, _) => true,
335            (AutocompleteMode::HashtagOnly, TriggerKind::Hashtag) => true,
336            (AutocompleteMode::HashtagOnly, TriggerKind::Wikilink) => false,
337        });
338
339        let Some(trigger) = trigger else {
340            self.close();
341            return;
342        };
343
344        let Some(anchor) = host.screen_anchor_for(trigger.anchor_col) else {
345            self.close();
346            return;
347        };
348
349        let query_changed;
350        let kind_changed;
351        match self.state.as_ref() {
352            None => {
353                kind_changed = true;
354                query_changed = true;
355            }
356            Some(existing) => {
357                kind_changed = existing.kind != trigger.kind;
358                query_changed = kind_changed || existing.query != trigger.query;
359            }
360        }
361
362        // On a cursor-only reconcile (allow_open=false), neither
363        // opening a brand-new popup NOR replacing an existing popup
364        // with a different trigger kind counts as a refresh — the
365        // user did not type into the new context. Close instead so
366        // the popup doesn't materialise from a mouse click or arrow
367        // move into a different trigger zone.
368        if !allow_open && (self.state.is_none() || kind_changed) {
369            self.close();
370            return;
371        }
372
373        if self.state.is_none() || kind_changed {
374            let mut st = AutocompleteState::new(trigger.kind, anchor);
375            st.max_visible_rows = self.max_visible_rows;
376            self.state = Some(st);
377        }
378
379        if let Some(state) = self.state.as_mut() {
380            state.kind = trigger.kind;
381            state.query = trigger.query.clone();
382            state.replace_range = trigger.replace_range.clone();
383            state.anchor = anchor;
384        }
385
386        if query_changed {
387            // First query of a popup (kind change / open) fires instantly
388            // for snappy UX. Refinement queries on the same popup are
389            // debounced so a burst of typing only hits the vault once.
390            let instant = kind_changed;
391            self.fire_query(trigger.kind, trigger.query, instant);
392        }
393    }
394
395    /// Drain pending query responses and apply the latest one whose
396    /// generation matches the controller's current generation. Older
397    /// responses (stale) are discarded.
398    pub fn poll_results(&mut self) {
399        while let Ok(result) = self.result_rx.try_recv() {
400            if result.generation != self.generation {
401                continue;
402            }
403            let Some(state) = self.state.as_mut() else {
404                continue;
405            };
406            if state.kind != result.kind {
407                continue;
408            }
409            state.set_items(result.items);
410        }
411    }
412
413    fn fire_query(&mut self, kind: TriggerKind, query: String, instant: bool) {
414        // `SingleSlotTask::spawn` aborts the previous in-flight task —
415        // its result would be discarded on receive (generation
416        // mismatch) but the SQLite hit would still happen and the
417        // vault `Arc` would stay alive until the task drained.
418        self.generation = self.generation.wrapping_add(1);
419        let req_gen = self.generation;
420        let tx = self.result_tx.clone();
421        let redraw = self.redraw_cb.clone();
422        let vault = self.vault.clone();
423        let limit = self.fetch_limit;
424        let debounce = if instant {
425            Duration::ZERO
426        } else {
427            self.debounce
428        };
429        self.in_flight.spawn(async move {
430            // Aborted by the next `fire_query` before this sleep completes
431            // for a burst of typing — the SQLite hit below never runs.
432            if !debounce.is_zero() {
433                tokio::time::sleep(debounce).await;
434            }
435            let items: Vec<Suggestion> = match kind {
436                TriggerKind::Wikilink => match vault.suggest_notes_by_prefix(&query, limit).await {
437                    Ok(notes) => notes
438                        .into_iter()
439                        .map(|n| Suggestion {
440                            display: n.name,
441                            secondary: Some(n.path.to_string()),
442                        })
443                        .collect(),
444                    Err(e) => {
445                        log::warn!(
446                            "autocomplete: suggest_notes_by_prefix({:?}) failed: {}",
447                            query,
448                            e
449                        );
450                        Vec::new()
451                    }
452                },
453                TriggerKind::Hashtag => match vault.suggest_tags_by_prefix(&query, limit).await {
454                    Ok(tags) => tags
455                        .into_iter()
456                        .map(|t| Suggestion {
457                            display: t.label,
458                            secondary: Some(format!("{}×", t.usage_count)),
459                        })
460                        .collect(),
461                    Err(e) => {
462                        log::warn!(
463                            "autocomplete: suggest_tags_by_prefix({:?}) failed: {}",
464                            query,
465                            e
466                        );
467                        Vec::new()
468                    }
469                },
470            };
471            let _ = tx.send(QueryResult {
472                generation: req_gen,
473                kind,
474                items,
475            });
476            // Wake the host's render loop so the popup actually paints
477            // with the new items. `redraw_cb` may be None in unit
478            // tests; production hosts bind it via `set_redraw_callback`.
479            if let Some(redraw) = redraw {
480                redraw();
481            }
482        });
483    }
484
485    fn compute_accept<H: AutocompleteHost>(&self, host: &H) -> Option<AcceptAction> {
486        let state = self.state.as_ref()?;
487        let suggestion = state.selected()?.clone();
488        let kind = state.kind;
489        let range = state.replace_range.clone();
490        // Accept is a once-per-popup-acceptance path; allocating the
491        // joined buffer text here is fine. The hot path is reconcile,
492        // which uses the cached `text` slot built once per text edit.
493        let buffer = host.buffer_snapshot().lines.join("\n");
494
495        // Guard against a stale snapshot — if the live buffer shrank
496        // below the trigger range, drop the accept rather than producing
497        // a malformed insertion (or panicking on String::replace_range
498        // in the search-box host).
499        if range.start > range.end || range.end > buffer.len() {
500            return None;
501        }
502        if !buffer.is_char_boundary(range.start) || !buffer.is_char_boundary(range.end) {
503            return None;
504        }
505
506        match kind {
507            TriggerKind::Wikilink => {
508                let extent = scan_wikilink_extent(&buffer, range.end);
509                // Replace from the trigger's start through the end of
510                // the stale wikilink-target region. This consumes any
511                // characters the user already typed past the cursor up
512                // to (but not including) `]]`, `|`, a newline, `[`, or
513                // EOF — preventing artefacts like `[[meeting]]e]]` when
514                // the popup is reopened mid-target.
515                let new_range = range.start..extent.end;
516                let needs_close = !extent.existing_close && !extent.has_alias;
517                let new_text = if needs_close {
518                    format!("{}]]", suggestion.display)
519                } else {
520                    suggestion.display.clone()
521                };
522                let cursor_offset_in_target = suggestion.display.len();
523                let new_cursor_byte = if extent.has_alias {
524                    // Keep the cursor right before `|alias]]` so the
525                    // user can edit the alias next.
526                    range.start.saturating_add(cursor_offset_in_target)
527                } else {
528                    // Land just past `]]` — whether we appended it or
529                    // it already existed.
530                    range
531                        .start
532                        .saturating_add(cursor_offset_in_target)
533                        .saturating_add(2)
534                };
535                Some(AcceptAction {
536                    range: new_range,
537                    new_text,
538                    new_cursor_byte,
539                })
540            }
541            TriggerKind::Hashtag => {
542                let new_cursor_byte = range.start.saturating_add(suggestion.display.len());
543                Some(AcceptAction {
544                    range,
545                    new_text: suggestion.display,
546                    new_cursor_byte,
547                })
548            }
549        }
550    }
551}
552
553/// Where the stale wikilink-target region around the cursor ends, plus
554/// what kind of suffix is already present.
555struct WikilinkExtent {
556    /// Byte offset (≥ `start`) of the first character that is NOT part
557    /// of the target region: either the first `]` of an existing `]]`,
558    /// a `|` alias separator, a newline, a `[`, or EOF.
559    end: usize,
560    /// `true` when an existing `]]` follows immediately at `end`.
561    existing_close: bool,
562    /// `true` when `end` points at a `|` separator — the user has
563    /// already started typing an alias which we must preserve.
564    has_alias: bool,
565}
566
567/// Walk forward from `start` over the bytes that look like wikilink
568/// target characters (anything except `]`, `|`, `\n`, `\r`, `[`).
569/// Lone `]` bytes (without a following `]`) are treated as stale
570/// characters and consumed — invalid inside a wikilink target anyway.
571///
572/// All decision bytes are ASCII so byte-level scanning is UTF-8 safe.
573fn scan_wikilink_extent(buffer: &str, start: usize) -> WikilinkExtent {
574    let bytes = buffer.as_bytes();
575    let mut i = start.min(bytes.len());
576    while i < bytes.len() {
577        match bytes[i] {
578            b']' => {
579                if bytes.get(i + 1) == Some(&b']') {
580                    return WikilinkExtent {
581                        end: i,
582                        existing_close: true,
583                        has_alias: false,
584                    };
585                }
586                // Lone `]` — consume and keep scanning. Invalid inside
587                // a wikilink target so dropping it is the safe default.
588                i += 1;
589            }
590            b'|' => {
591                return WikilinkExtent {
592                    end: i,
593                    existing_close: false,
594                    has_alias: true,
595                };
596            }
597            b'\n' | b'\r' | b'[' => {
598                return WikilinkExtent {
599                    end: i,
600                    existing_close: false,
601                    has_alias: false,
602                };
603            }
604            _ => i += 1,
605        }
606    }
607    WikilinkExtent {
608        end: i,
609        existing_close: false,
610        has_alias: false,
611    }
612}
613
614/// What the controller decided when forwarded a key event.
615#[derive(Debug, Clone, PartialEq, Eq)]
616pub enum HandleKeyOutcome {
617    /// Popup was open and consumed the key as navigation.
618    Consumed,
619    /// Popup was open; user dismissed with Esc.
620    Dismissed,
621    /// Popup was open; user accepted. The host should apply the action.
622    Accepted(AcceptAction),
623    /// Popup was either closed or did not handle this key — host should
624    /// process it as a normal key event, then call `sync()` afterward.
625    NotHandled,
626}
627
628/// A buffer replacement the host needs to perform after an accept.
629#[derive(Debug, Clone, PartialEq, Eq)]
630pub struct AcceptAction {
631    pub range: Range<usize>,
632    pub new_text: String,
633    pub new_cursor_byte: usize,
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use kimun_core::nfs::VaultPath;
640    use kimun_core::{NoteVault, VaultConfig};
641    use std::sync::Arc;
642    use std::sync::atomic::{AtomicU64, Ordering};
643    use tempfile::TempDir;
644
645    /// Global per-test counter so each `FakeHost::new` returns a distinct
646    /// `content_revision` and the controller's cache is invalidated between
647    /// successive sync calls in the same test (mirrors production where
648    /// rebuilding the buffer always advances the editor's revision).
649    static FAKE_REV: AtomicU64 = AtomicU64::new(1);
650
651    struct FakeHost {
652        buffer: String,
653        cursor: usize,
654        /// `Some(rev)` to participate in the controller's cache.
655        /// `None` mirrors the search-box modal opting out.
656        revision: Option<NonZeroU64>,
657    }
658
659    impl FakeHost {
660        fn new(buffer: &str, cursor: usize) -> Self {
661            Self {
662                buffer: buffer.to_string(),
663                cursor,
664                revision: NonZeroU64::new(FAKE_REV.fetch_add(1, Ordering::SeqCst)),
665            }
666        }
667
668        fn apply(&mut self, action: &AcceptAction) {
669            self.buffer
670                .replace_range(action.range.clone(), &action.new_text);
671            self.cursor = action.new_cursor_byte;
672            self.revision = self
673                .revision
674                .and_then(|r| NonZeroU64::new(r.get().wrapping_add(1)));
675        }
676
677        /// Split `self.buffer` into lines and convert the byte cursor
678        /// into `(row, char_col)`. Re-derived on every
679        /// `buffer_snapshot()` call so tests that mutate
680        /// `host.buffer` / `host.cursor` directly stay in sync.
681        fn lines_and_cursor(&self) -> (Vec<String>, (usize, usize)) {
682            let lines: Vec<String> = self.buffer.split('\n').map(|s| s.to_string()).collect();
683            let mut byte_running = 0;
684            for (row, line) in lines.iter().enumerate() {
685                let line_end = byte_running + line.len();
686                if self.cursor <= line_end {
687                    let col_byte = self.cursor - byte_running;
688                    let col = line[..col_byte].chars().count();
689                    return (lines, (row, col));
690                }
691                byte_running = line_end + 1; // +1 for '\n'
692            }
693            // Past EOF — clamp to last row's end.
694            let row = lines.len().saturating_sub(1);
695            let col = lines.get(row).map(|l| l.chars().count()).unwrap_or(0);
696            (lines, (row, col))
697        }
698    }
699
700    impl AutocompleteHost for FakeHost {
701        fn buffer_snapshot(&self) -> EditorSnapshot<'_> {
702            let rev = self.revision.unwrap_or_else(|| NonZeroU64::new(1).unwrap());
703            let (lines, cursor) = self.lines_and_cursor();
704            // Owned because we constructed `lines` locally — tests
705            // don't hold the snapshot long enough to care about the
706            // allocation.
707            EditorSnapshot::owned(lines, cursor, rev)
708        }
709        fn cache_key(&self) -> Option<NonZeroU64> {
710            self.revision
711        }
712        fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
713            Some((0, 0))
714        }
715    }
716
717    async fn new_vault_with(
718        notes: &[&str],
719        tag_notes: &[(&str, &str)],
720    ) -> (TempDir, Arc<NoteVault>) {
721        let tmp = TempDir::new().unwrap();
722        let cfg = VaultConfig::new(tmp.path().to_path_buf());
723        let vault = NoteVault::new(cfg).await.unwrap();
724        vault.validate_and_init().await.unwrap();
725        for name in notes {
726            vault
727                .create_note(&VaultPath::note_path_from(format!("/{name}.md")), "body")
728                .await
729                .unwrap();
730        }
731        for (path, body) in tag_notes {
732            vault
733                .create_note(&VaultPath::note_path_from(format!("/{path}.md")), *body)
734                .await
735                .unwrap();
736        }
737        (tmp, Arc::new(vault))
738    }
739
740    async fn drain_results(controller: &mut AutocompleteController) {
741        // The spawned query task completes promptly on an in-memory DB;
742        // yield once to let it run and post the result.
743        tokio::task::yield_now().await;
744        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
745        controller.poll_results();
746    }
747
748    /// Builds a controller with debounce disabled so tests don't pay the
749    /// 80ms refinement window before each query reaches the in-memory DB.
750    fn make_controller(vault: Arc<NoteVault>, mode: AutocompleteMode) -> AutocompleteController {
751        AutocompleteController::new(vault, mode).with_debounce(Duration::ZERO)
752    }
753
754    // ---- Lifecycle ----
755
756    #[tokio::test]
757    async fn no_trigger_keeps_popup_closed() {
758        let (_tmp, vault) = new_vault_with(&[], &[]).await;
759        let mut c = make_controller(vault, AutocompleteMode::Both);
760        let host = FakeHost::new("plain text", 5);
761        c.sync(&host);
762        assert!(!c.is_open());
763    }
764
765    #[tokio::test]
766    async fn wikilink_trigger_opens_popup_and_loads_results() {
767        let (_tmp, vault) = new_vault_with(&["meeting", "music", "novel"], &[]).await;
768        let mut c = make_controller(vault, AutocompleteMode::Both);
769        let host = FakeHost::new("see [[me", 8);
770        c.sync(&host);
771        // State exists immediately; is_open() flips to true only once
772        // items have arrived (the popup is not "interactive" while a
773        // query is in flight).
774        assert!(c.state().is_some());
775        assert!(!c.is_open());
776        drain_results(&mut c).await;
777        assert!(c.is_open());
778        let st = c.state().unwrap();
779        assert_eq!(st.kind, TriggerKind::Wikilink);
780        assert_eq!(st.query, "me");
781        let names: Vec<&str> = st.items.iter().map(|s| s.display.as_str()).collect();
782        assert!(names.contains(&"meeting"));
783        assert!(!names.contains(&"novel"));
784    }
785
786    #[tokio::test]
787    async fn refresh_if_open_closes_on_kind_change() {
788        // Popup is open for Hashtag; cursor-only move (refresh_if_open)
789        // into a Wikilink context must NOT replace the popup with a
790        // wikilink one — close it instead. Opening a wikilink popup
791        // on cursor movement violates the refresh-only contract.
792        let (_tmp, vault) = new_vault_with(&["meeting"], &[("a", "x #proj")]).await;
793        let mut c = make_controller(vault, AutocompleteMode::Both);
794        let mut host = FakeHost::new("#pro [[me", 4); // cursor after `#pro`
795        c.sync(&host);
796        drain_results(&mut c).await;
797        assert!(c.is_open());
798        assert_eq!(c.state().unwrap().kind, TriggerKind::Hashtag);
799        // Cursor jumps into the wikilink target (mouse click simulation).
800        host.cursor = 9;
801        c.refresh_if_open(&host);
802        assert!(c.state().is_none(), "kind change on movement must close");
803    }
804
805    #[tokio::test]
806    async fn accept_with_stale_range_falls_through_not_consumed() {
807        // Previously: Tab/Enter on a stale-range accept returned
808        // Consumed and silently ate the keystroke. Now returns
809        // NotHandled so the host can give the user back their Tab
810        // indent / Enter newline.
811        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
812        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
813        let mut c = make_controller(vault, AutocompleteMode::Both);
814        let mut host = FakeHost::new("see [[me", 8);
815        c.sync(&host);
816        drain_results(&mut c).await;
817        // Live buffer shrinks below the trigger range between sync
818        // and accept (e.g. an async event truncated the buffer).
819        host.buffer = "see [".into();
820        host.cursor = 5;
821        let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
822        assert_eq!(outcome, HandleKeyOutcome::NotHandled);
823        assert!(c.state().is_none(), "popup must close even on fallthrough");
824    }
825
826    #[tokio::test]
827    async fn refresh_if_open_does_not_open_new_popup() {
828        // Cursor moves into a fresh trigger context without any text
829        // edit: refresh_if_open must NOT open a popup. This is the
830        // behaviour that prevents cursor-only navigation over an
831        // existing wikilink from re-popping the suggestions.
832        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
833        let mut c = make_controller(vault, AutocompleteMode::Both);
834        // Cursor inside an existing wikilink — but the popup is closed.
835        let host = FakeHost::new("[[meeting]]", 4);
836        c.refresh_if_open(&host);
837        assert!(c.state().is_none());
838    }
839
840    #[tokio::test]
841    async fn refresh_if_open_closes_popup_when_cursor_leaves_trigger() {
842        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
843        let mut c = make_controller(vault, AutocompleteMode::Both);
844        let mut host = FakeHost::new("see [[me", 8);
845        c.sync(&host);
846        drain_results(&mut c).await;
847        assert!(c.is_open());
848        // Cursor moves before the `[[` — trigger context is gone.
849        host.cursor = 0;
850        c.refresh_if_open(&host);
851        assert!(c.state().is_none());
852    }
853
854    #[tokio::test]
855    async fn popup_with_zero_results_is_not_interactive() {
856        // Trigger fires but the query returns nothing → state exists but
857        // is_open() is false so Esc/Up/Down/Tab fall through to the
858        // modal/editor instead of being swallowed.
859        let (_tmp, vault) = new_vault_with(&[], &[]).await; // empty vault
860        let mut c = make_controller(vault, AutocompleteMode::Both);
861        let host = FakeHost::new("see [[xyz", 9);
862        c.sync(&host);
863        drain_results(&mut c).await;
864        assert!(c.state().is_some());
865        assert_eq!(c.state().unwrap().items.len(), 0);
866        assert!(!c.is_open());
867    }
868
869    #[tokio::test]
870    async fn hashtag_trigger_opens_popup_and_loads_results() {
871        let (_tmp, vault) = new_vault_with(&[], &[("a", "x #projects"), ("b", "y #pro")]).await;
872        let mut c = make_controller(vault, AutocompleteMode::Both);
873        let host = FakeHost::new("about #pro", 10);
874        c.sync(&host);
875        drain_results(&mut c).await;
876        let st = c.state().unwrap();
877        assert_eq!(st.kind, TriggerKind::Hashtag);
878        let labels: Vec<&str> = st.items.iter().map(|s| s.display.as_str()).collect();
879        assert!(labels.contains(&"pro"));
880        assert!(labels.contains(&"projects"));
881    }
882
883    #[tokio::test]
884    async fn hashtag_only_mode_ignores_wikilinks() {
885        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
886        let mut c = make_controller(vault, AutocompleteMode::HashtagOnly);
887        let host = FakeHost::new("see [[me", 8);
888        c.sync(&host);
889        assert!(!c.is_open());
890    }
891
892    #[tokio::test]
893    async fn losing_trigger_context_closes_popup() {
894        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
895        let mut c = make_controller(vault, AutocompleteMode::Both);
896        let mut host = FakeHost::new("see [[me", 8);
897        c.sync(&host);
898        drain_results(&mut c).await;
899        assert!(c.is_open());
900        // User types a space — hashtag context for the typed query is now
901        // broken; for wikilinks, the trigger stays alive but the query
902        // gains a space. Here we simulate the cursor jumping outside.
903        host.buffer = "see [[me\n".into();
904        host.cursor = 9;
905        c.sync(&host);
906        assert!(!c.is_open());
907    }
908
909    // ---- Accept actions ----
910
911    #[tokio::test]
912    async fn accepting_wikilink_inserts_name_and_closes_brackets() {
913        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
914        let mut c = make_controller(vault, AutocompleteMode::Both);
915        let mut host = FakeHost::new("see [[me", 8);
916        c.sync(&host);
917        drain_results(&mut c).await;
918        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
919        let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
920        let HandleKeyOutcome::Accepted(action) = outcome else {
921            panic!("expected Accepted, got {:?}", outcome);
922        };
923        host.apply(&action);
924        assert_eq!(host.buffer, "see [[meeting]]");
925        assert_eq!(host.cursor, host.buffer.len());
926        assert!(!c.is_open());
927    }
928
929    #[tokio::test]
930    async fn accepting_wikilink_consumes_stale_chars_before_existing_close() {
931        // Reopened mid-target: the user moved the cursor back inside an
932        // already-closed wikilink and is replacing the target. The stale
933        // characters between the cursor and `]]` must be consumed, not
934        // left as `[[meeting]]e]]`.
935        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
936        let mut c = make_controller(vault, AutocompleteMode::Both);
937        let mut host = FakeHost::new("see [[me]]", 7); // cursor between `m` and `e`
938        c.sync(&host);
939        drain_results(&mut c).await;
940        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
941        let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
942        let HandleKeyOutcome::Accepted(action) = outcome else {
943            panic!("expected Accepted, got {:?}", outcome);
944        };
945        host.apply(&action);
946        assert_eq!(host.buffer, "see [[meeting]]");
947        assert_eq!(host.cursor, host.buffer.len());
948    }
949
950    #[tokio::test]
951    async fn accepting_wikilink_with_lone_trailing_bracket_does_not_triple() {
952        // Buffer has a single stray `]` after the target — must not
953        // produce `]]]`.
954        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
955        let mut c = make_controller(vault, AutocompleteMode::Both);
956        let mut host = FakeHost::new("see [[me]", 8);
957        c.sync(&host);
958        drain_results(&mut c).await;
959        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
960        let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
961        let HandleKeyOutcome::Accepted(action) = outcome else {
962            panic!("expected Accepted, got {:?}", outcome);
963        };
964        host.apply(&action);
965        assert_eq!(host.buffer, "see [[meeting]]");
966    }
967
968    #[tokio::test]
969    async fn accepting_wikilink_preserves_existing_alias() {
970        // `[[me|alias]]` — cursor in the target portion; alias must
971        // survive and the cursor must land right before `|alias]]`.
972        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
973        let mut c = make_controller(vault, AutocompleteMode::Both);
974        let mut host = FakeHost::new("see [[me|alias]]", 8); // cursor before `|`
975        c.sync(&host);
976        drain_results(&mut c).await;
977        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
978        let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
979        let HandleKeyOutcome::Accepted(action) = outcome else {
980            panic!("expected Accepted, got {:?}", outcome);
981        };
982        host.apply(&action);
983        assert_eq!(host.buffer, "see [[meeting|alias]]");
984        // Cursor right after `meeting`, before `|alias]]`.
985        assert_eq!(host.cursor, "see [[meeting".len());
986    }
987
988    #[tokio::test]
989    async fn accepting_wikilink_preserves_existing_closing_brackets() {
990        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
991        let mut c = make_controller(vault, AutocompleteMode::Both);
992        let mut host = FakeHost::new("see [[me]]", 8);
993        c.sync(&host);
994        drain_results(&mut c).await;
995        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
996        let outcome = c.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &host);
997        let HandleKeyOutcome::Accepted(action) = outcome else {
998            panic!("expected Accepted, got {:?}", outcome);
999        };
1000        host.apply(&action);
1001        assert_eq!(host.buffer, "see [[meeting]]");
1002        assert_eq!(host.cursor, host.buffer.len());
1003    }
1004
1005    #[tokio::test]
1006    async fn accepting_hashtag_inserts_label_no_trailing_space() {
1007        let (_tmp, vault) = new_vault_with(&[], &[("a", "x #projects")]).await;
1008        let mut c = make_controller(vault, AutocompleteMode::Both);
1009        let mut host = FakeHost::new("about #pro", 10);
1010        c.sync(&host);
1011        drain_results(&mut c).await;
1012        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1013        let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
1014        let HandleKeyOutcome::Accepted(action) = outcome else {
1015            panic!("expected Accepted, got {:?}", outcome);
1016        };
1017        host.apply(&action);
1018        assert_eq!(host.buffer, "about #projects");
1019        assert_eq!(host.cursor, host.buffer.len());
1020    }
1021
1022    #[tokio::test]
1023    async fn esc_dismisses_without_changing_buffer() {
1024        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
1025        let mut c = make_controller(vault, AutocompleteMode::Both);
1026        let host = FakeHost::new("see [[me", 8);
1027        c.sync(&host);
1028        drain_results(&mut c).await;
1029        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1030        let outcome = c.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &host);
1031        assert_eq!(outcome, HandleKeyOutcome::Dismissed);
1032        assert_eq!(host.buffer, "see [[me");
1033        assert!(!c.is_open());
1034    }
1035
1036    // ---- Generation / drop-stale ----
1037
1038    #[tokio::test]
1039    async fn stale_results_are_dropped_on_query_change() {
1040        let (_tmp, vault) = new_vault_with(&["meeting", "memory"], &[]).await;
1041        let mut c = make_controller(vault, AutocompleteMode::Both);
1042        // First query for `me` — fires generation 1.
1043        let host1 = FakeHost::new("see [[me", 8);
1044        c.sync(&host1);
1045        // Immediately change query to `mem` — fires generation 2 before
1046        // generation 1 has had a chance to respond.
1047        let host2 = FakeHost::new("see [[mem", 9);
1048        c.sync(&host2);
1049        drain_results(&mut c).await;
1050        let st = c.state().unwrap();
1051        // Only the `mem` results should be present — `meeting` doesn't
1052        // start with `mem` so the only match is `memory`.
1053        assert_eq!(st.query, "mem");
1054        let names: Vec<&str> = st.items.iter().map(|s| s.display.as_str()).collect();
1055        assert_eq!(names, vec!["memory"]);
1056    }
1057
1058    /// Regression for the opt-out cache-write skip (originally commit
1059    /// 5dc15309 against the `revision == 0` sentinel; now expressed
1060    /// via `content_revision() -> None`). Two invariants:
1061    ///   1. A sync from an opt-out host (revision == None) must NOT
1062    ///      populate `cached_text` — the search-box modal would
1063    ///      otherwise churn the heap allocating String + zones per
1064    ///      keystroke for a slot nothing ever reads.
1065    ///   2. An opt-out sync following a cached sync must NOT consult
1066    ///      the previously-cached entry, even though the cache slot
1067    ///      is Some(…). Otherwise stale zones from a prior editor
1068    ///      session could serve a fresh search-box host as a false hit.
1069    #[tokio::test]
1070    async fn opt_out_revision_does_not_populate_or_consult_cache() {
1071        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
1072        let mut c = make_controller(vault, AutocompleteMode::Both);
1073
1074        // (1) Opt-out from a fresh controller: cache stays empty.
1075        let mut sentinel = FakeHost::new("see [[me", 8);
1076        sentinel.revision = None;
1077        c.sync(&sentinel);
1078        assert!(
1079            c.cached_text.is_none(),
1080            "opt-out sync must not write to cached_text"
1081        );
1082
1083        // Populate the cache with a normal (cached) reconcile.
1084        let host = FakeHost::new("see [[me", 8); // FakeHost::new auto-bumps revision
1085        c.sync(&host);
1086        let cached_rev = c
1087            .cached_text
1088            .as_ref()
1089            .map(|(rev, _, _)| *rev)
1090            .expect("cached sync should have populated the cache");
1091
1092        // (2) Opt-out sync now: must not be served by the stale cache,
1093        // and must not overwrite the cache slot either.
1094        let mut sentinel2 = FakeHost::new("see [[nope", 10);
1095        sentinel2.revision = None;
1096        c.sync(&sentinel2);
1097        let preserved_rev = c
1098            .cached_text
1099            .as_ref()
1100            .map(|(rev, _, _)| *rev)
1101            .expect("opt-out sync should leave the previous cache entry alone");
1102        assert_eq!(
1103            preserved_rev, cached_rev,
1104            "opt-out sync must not overwrite cached_text"
1105        );
1106    }
1107
1108    /// Regression: cursor moves on a host whose `content_revision`
1109    /// stays constant must HIT the cache slot — same key, same text
1110    /// pointer, same zones — not rebuild `ExclusionZones` or
1111    /// re-allocate the buffer text. This is the invariant the
1112    /// `text_revision` → `content_revision` rename was designed to
1113    /// preserve cleanly: cursor-only events never invalidate cached
1114    /// zones.
1115    #[tokio::test]
1116    async fn cursor_only_move_within_trigger_hits_cache() {
1117        let (_tmp, vault) = new_vault_with(&["memory"], &[]).await;
1118        let mut c = make_controller(vault, AutocompleteMode::Both);
1119
1120        // Open the popup at the end of `[[me`.
1121        let mut host = FakeHost::new("see [[me", 8);
1122        c.sync(&host);
1123        let (cached_rev, cached_text_ptr) = {
1124            let (rev, text, _) = c
1125                .cached_text
1126                .as_ref()
1127                .expect("initial sync populates cache");
1128            (*rev, text.as_ptr())
1129        };
1130
1131        // Cursor moves one char back inside the same trigger token.
1132        // Revision UNCHANGED — controller must serve from cache.
1133        host.cursor = 7;
1134        c.sync(&host);
1135        let (preserved_rev, preserved_text_ptr) = {
1136            let (rev, text, _) = c
1137                .cached_text
1138                .as_ref()
1139                .expect("cursor-only sync must leave the cache populated");
1140            (*rev, text.as_ptr())
1141        };
1142        assert_eq!(
1143            preserved_rev, cached_rev,
1144            "cursor-only sync must not change the cache key"
1145        );
1146        assert_eq!(
1147            preserved_text_ptr, cached_text_ptr,
1148            "cursor-only sync must reuse the cached String, not rebuild it"
1149        );
1150    }
1151
1152    /// Regression: `close()` must drop the `cached_text` slot. On a
1153    /// multi-MB note the slot holds a full clone of the buffer plus
1154    /// the parsed `ExclusionZones`; without the clear it survives
1155    /// popup dismissal until the next text edit overwrites it.
1156    #[tokio::test]
1157    async fn close_clears_cached_text() {
1158        let (_tmp, vault) = new_vault_with(&["memory"], &[]).await;
1159        let mut c = make_controller(vault, AutocompleteMode::Both);
1160
1161        let host = FakeHost::new("see [[me", 8);
1162        c.sync(&host);
1163        assert!(
1164            c.cached_text.is_some(),
1165            "sync should have populated cached_text"
1166        );
1167
1168        c.close();
1169        assert!(
1170            c.cached_text.is_none(),
1171            "close() must drop cached_text so the buffer clone doesn't outlive the popup"
1172        );
1173    }
1174
1175    /// A `[[` candidate reaches the wikilink veto, so zones are computed
1176    /// and memoized; a subsequent cursor-only move at the same revision
1177    /// reuses them rather than recomputing.
1178    #[tokio::test]
1179    async fn trigger_candidate_computes_zones_once() {
1180        let (_tmp, vault) = new_vault_with(&["memory"], &[]).await;
1181        let mut c = make_controller(vault, AutocompleteMode::Both);
1182
1183        let mut host = FakeHost::new("see [[me", 8);
1184        c.sync(&host);
1185        assert!(
1186            c.cached_text.as_ref().unwrap().2.is_some(),
1187            "a [[ candidate reaching the veto must compute and memoize zones"
1188        );
1189
1190        host.cursor = 7;
1191        c.sync(&host);
1192        assert!(
1193            c.cached_text.as_ref().unwrap().2.is_some(),
1194            "memoized zones survive a cursor-only move at the same revision"
1195        );
1196    }
1197}