Skip to main content

lattice_ui_tui/app/
mode.rs

1//! Modal-state transitions -- the buffer-level state machine
2//! (Normal / Insert / Visual / Op-pending / Command /
3//! Search) and the major-mode activation hooks that fire
4//! on transition.
5//!
6//! Methods that live here:
7//! - `modal_label` (status-line label of the current
8//!   modal-state).
9//!
10//! Methods that move here in R.1 (deferred):
11//! - `enter_normal`, `enter_insert`, `enter_replace`,
12//!   `enter_op_pending`, `enter_command`, `enter_search`.
13//! - The `ModeContext` builder used by major modes during
14//!   `on_activate` -- including the `setup_buffer_locals`
15//!   path that seeds `BufferLocals` from struct fields.
16//! - Cursor-shape recompute on mode transitions (the
17//!   App-side hook; renderer reads the resulting state).
18//! - `set_active_mode_for_buffer` and the `Mode` trait
19//!   dispatch that calls `on_activate` / `on_deactivate`.
20//!
21//! Insert-mode entry methods (`enter_insert_after`,
22//! `enter_insert_line_start`, `enter_insert_line_end`,
23//! `enter_insert_open_below`, `enter_insert_open_above`)
24//! landed in `app/edit.rs` via R.1.47 because they pair
25//! with the Insert / Replace edit primitives that already
26//! lived there; they read as edit-flow rather than mode-
27//! flow.
28//!
29//! What does NOT live here: the `Mode` trait itself
30//! (lives in `crate::modes`), the per-major-mode impls
31//! (`TextMode`, `RustMode`, ...) -- those stay in
32//! `crate::modes`.
33
34use lattice_grammar::ModalState;
35use lattice_mode::ModeId;
36
37use super::{App, BufferId, BufferKind};
38
39impl App {
40    /// 5.5.F.5.3: see [`lattice_host::dispatch::Editor::activate_major_for_buffer_kind`].
41    /// Wrapper fans host-returned `RendererSignal`s through
42    /// [`Self::handle_renderer_signal`].
43    pub fn activate_major_for_buffer_kind(&mut self, buffer_id: BufferId, kind: BufferKind) {
44        // Slice 3c.final.E.2: route through `mutate_editor_with`.
45        let signals =
46            self.mutate_editor_with(move |e| e.activate_major_for_buffer_kind(buffer_id, kind));
47        for sig in signals {
48            self.handle_renderer_signal(sig);
49        }
50    }
51
52    // 5.5.F.5.2/F.5.3: `maybe_auto_activate_lsp_mode` +
53    // `path_for_buffer` relocated to
54    // [`lattice_host::dispatch::Editor`]; the App-side wrappers
55    // delete entirely because their callers (`activate_major_for_buffer_kind`
56    // and the activate/deactivate trio) all migrated host-side.
57
58    /// M.5.1: programmatic activation of `mode_id` on `buffer_id`.
59    /// Used by hooks (auto-activation on `MajorEntered` etc.) and
60    /// by the auto-generated `:<mode-name>` toggle command. The
61    /// registry decides Major-vs-Minor and runs the appropriate
62    /// activation; for majors the previous major is deactivated
63    /// first.
64    ///
65    /// On failure, surfaces an `EchoLevel::Warn` and returns
66    /// without mutating state. Callers that need to know the
67    /// outcome can read `self.editor.active_modes[buffer_id]` after.
68    /// 5.5.F.5.2: see [`lattice_host::dispatch::Editor::activate_mode_by_id`].
69    /// Wrapper fans host-returned `RendererSignal`s through
70    /// [`Self::handle_renderer_signal`].
71    pub fn activate_mode_by_id(&mut self, buffer_id: BufferId, mode_id: ModeId) {
72        // Slice 3c.final.E.2: route through `mutate_editor_with`.
73        let signals = self.mutate_editor_with(move |e| e.activate_mode_by_id(buffer_id, mode_id));
74        for sig in signals {
75            self.handle_renderer_signal(sig);
76        }
77    }
78
79    /// M.5.1: programmatic deactivation of `mode_id` on
80    /// `buffer_id`. Symmetric to [`Self::activate_mode_by_id`].
81    /// Major deactivation leaves the buffer with no active major
82    /// until the next activation; user-facing flows usually flow
83    /// through the toggle command which performs swap rather
84    /// than a bare deactivate.
85    /// 5.5.F.5.2: see [`lattice_host::dispatch::Editor::deactivate_mode_by_id`].
86    /// Wrapper fans host-returned `RendererSignal`s through
87    /// [`Self::handle_renderer_signal`].
88    pub fn deactivate_mode_by_id(&mut self, buffer_id: BufferId, mode_id: ModeId) {
89        // Slice 3c.final.E.2: route through `mutate_editor_with`.
90        let signals = self.mutate_editor_with(move |e| e.deactivate_mode_by_id(buffer_id, mode_id));
91        for sig in signals {
92            self.handle_renderer_signal(sig);
93        }
94    }
95
96    // M.5.3: lsp-mode activated on `buffer_id`. Emits
97    // `LspBufferAttached` on the editor's event bus so
98    // subscribers see the gate flip. Wire-level `didOpen` is
99    // already driven by the `attach_driver` subscribing to
100    // `Event::DocumentOpened` from the file-open path.
101    // Phase 3 removed `on_lsp_mode_activated` /
102    // `_deactivated`. The work they did is owned by the mode
103    // now:
104    //
105    // - Event publication (`LspBufferAttached` /
106    //   `LspBufferDetached`) lives in `LspMode::on_activate` /
107    //   `on_deactivate` via `ctx.events()` (Phase 2).
108    // - Sub-mode cascade (activate + deactivate of the 13
109    //   LSP sub-modes) lives in `Mode::implies()` --
110    //   `LspMode::new` builds the list once; the registry's
111    //   `activate_minor` / `deactivate_minor` walk the list
112    //   (the latter symmetrically via the Phase 3 cascade
113    //   extension).
114    //
115    // The wire-level `didClose` (`lsp_close_buffer`) is the
116    // only remaining App-side action; it lives at the
117    // `deactivate_mode_by_id` call site directly. Moving it
118    // requires a `LspBufferDetached` subscriber in boot;
119    // queued for a follow-up slice.
120
121    // 4.4.f: `lsp-folding-mode` lifecycle moved into
122    // `LspFoldingMode::on_activate` / `on_deactivate` in
123    // `lattice-lsp`. The mode owns its work; the App is just
124    // the orchestrator. `drain_option_changes()` in the
125    // activate/deactivate call sites picks up the option
126    // mutation the mode emits via `ctx.editor.config()`.
127
128    // Phase 3 removed `activate_lsp_sub_modes_for` /
129    // `deactivate_lsp_sub_modes_for`. The sub-mode cascade
130    // lives in the registry now, driven by
131    // `LspMode::implies()` (a Vec built once at
132    // `LspMode::new()`). `ModeRegistry::activate_minor`
133    // walks `implies()` on activation; the symmetric
134    // `deactivate_minor` extension walks it on
135    // deactivation. Adding a new LSP sub-mode is one
136    // entry in `LspMode::new()`'s `vec![...]`; no App-side
137    // edits.
138
139    /// M.5.1: toggle a mode by name on the active pane's buffer.
140    /// This is the apply-fn target for the auto-generated
141    /// `:<mode-name>` ex-commands (mode-architecture §9.6.1).
142    /// Toggle semantics:
143    /// - **Minor**: deactivate if active; activate if inactive.
144    /// - **Major**: activate if not currently the major; if it's
145    ///   already the active major, the registry treats this as
146    ///   a *reload* (deactivate then re-activate, per §9.6).
147    ///
148    /// Activating a major that differs from the current major
149    /// performs a swap -- the registry deactivates the previous
150    /// major before activating the new one. Active minors stay
151    /// untouched across the swap (their state lives in
152    /// type-keyed `BufferLocals` owned per-mode; no
153    /// `kill-all-local-variables` semantics).
154    /// 5.5.F.5.2: see [`lattice_host::dispatch::Editor::toggle_mode_by_name`].
155    /// Wrapper fans host-returned `RendererSignal`s through
156    /// [`Self::handle_renderer_signal`].
157    pub fn toggle_mode_by_name(&mut self, name: &str) {
158        // Slice 3c.final.E.2: route through `mutate_editor_with`.
159        // Closure needs `Send + 'static`, so promote `&str` to `String`.
160        let name_owned = name.to_string();
161        let signals = self.mutate_editor_with(move |e| e.toggle_mode_by_name(&name_owned));
162        for sig in signals {
163            self.handle_renderer_signal(sig);
164        }
165    }
166
167    /// 5.5.F.5.3: see [`lattice_host::dispatch::Editor::drain_mode_lifecycle_events`].
168    /// Wrapper fans host-returned `RendererSignal`s through
169    /// [`Self::handle_renderer_signal`].
170    pub fn drain_mode_lifecycle_events(&mut self) {
171        // Slice 3c.final.E.2: route through `mutate_editor_with`.
172        let signals = self.mutate_editor_with(|e| e.drain_mode_lifecycle_events());
173        for sig in signals {
174            self.handle_renderer_signal(sig);
175        }
176    }
177
178    pub fn modal_label(&self) -> &'static str {
179        // ML.1a-render: the modal-label vocabulary is defined once,
180        // host-side (`lattice_host::modeline::modal_label`), so the
181        // modeline's `core.mode` content and this accessor never drift.
182        // Reads the published `ActiveDocumentRenderState` cell (no actor
183        // crossing); called once per pane per frame.
184        lattice_host::modeline::modal_label(&self.render_state.load())
185    }
186
187    /// 5.5.G.17: body migrated to
188    /// [`lattice_host::dispatch::Editor::enter_mode`]. Kept as a
189    /// delegate -- a handful of paths still drive modal state
190    /// directly (the `RepeatLastChange` apply arm, the
191    /// `Effect::EnterMode` apply_effect arm, and several
192    /// `do_*` helpers in `edit` / `motions` / `lsp` modules).
193    /// These retire as their callers migrate host-side.
194    pub(super) fn enter_mode(&mut self, state: ModalState) {
195        // Slice 3c.final.E.2: route through `mutate_editor`.
196        self.mutate_editor(move |e| e.enter_mode(state));
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    #![allow(clippy::unwrap_used)]
203
204    use crate::app::test_helpers::app_with;
205
206    #[test]
207    fn spawned_terminal_starts_in_terminal_insert_mode() {
208        // T-scrollback-fix (2026-07-03): a freshly spawned terminal lands
209        // in Terminal-Insert (Job) mode — vim `:terminal` / tmux / kitty
210        // convention. The earlier "spawn into Normal-in-terminal" default
211        // activated `TerminalNormalMode` immediately, freezing a read-only
212        // scrollback `SyntheticDoc` from the still-empty grid (the child
213        // hasn't rendered at spawn), so `k`/`gg` could not reach the history
214        // the child then streamed in until the first insert→normal
215        // round-trip rebuilt the doc. Starting in Insert defers that rope
216        // build to the user's first deliberate `<C-\><C-n>` into Normal.
217        let mut a = app_with("", 24);
218        a.apply(crate::app::Action::TerminalSpawn(Some("sh".to_string())));
219
220        assert_eq!(
221            a.editor.active_buffer,
222            super::BufferKind::Terminal,
223            "spawn should activate the terminal buffer",
224        );
225        let buf_id = a.editor.active_pane_buffer_id();
226        let modes = a
227            .editor
228            .active_modes
229            .get(&buf_id)
230            .expect("spawned terminal buffer has active modes");
231        assert!(
232            modes.is_active(lattice_terminal::TerminalInsertMode::mode_id()),
233            "spawned terminal should start in Terminal-Insert (Job) mode",
234        );
235        assert!(
236            !modes.is_active(lattice_terminal::TerminalNormalMode::mode_id()),
237            "spawned terminal must NOT start frozen in Normal-in-terminal",
238        );
239    }
240
241    #[test]
242    fn zz_family_scrolls_the_terminal_scrollback_viewport() {
243        // Regression: `zz` / `zt` / `zb` did nothing in Terminal-Normal
244        // because do_scroll_cursor_to only moved the (inert-for-terminals)
245        // document `scroll` field. They must reposition the alacritty
246        // scrollback viewport. Spawn a shell that emits 40 lines into a
247        // 10-row terminal (so ~30 lines of scrollback exist), enter
248        // Terminal-Normal, park the nav cursor mid-history, and check that
249        // `zt` and `zb` produce different viewport offsets.
250        let mut a = app_with("", 10);
251        // `seq 60` prints 60 lines into a 10-row terminal -> ~50 lines of
252        // scrollback. A single-token program + arg, because the spawn
253        // command line is whitespace-split (no shell-quote parsing). `seq`
254        // exits after printing; the grid retains its scrollback.
255        a.apply(crate::app::Action::TerminalSpawn(Some(
256            "seq 60".to_string(),
257        )));
258        let id = a.editor.active_pane_buffer_id();
259
260        // Wait for the child's output to build scrollback history.
261        let mut history = 0i32;
262        for _ in 0..300 {
263            history = a
264                .editor
265                .buffers
266                .with_terminal(id, |t| -t.term.line_bounds().0)
267                .unwrap_or(0);
268            if history >= 25 {
269                break;
270            }
271            std::thread::sleep(std::time::Duration::from_millis(20));
272        }
273        assert!(
274            history >= 25,
275            "terminal never built enough scrollback (history={history})"
276        );
277
278        // Enter Terminal-Normal (spawn leaves us in Insert) — builds the
279        // scrollback SyntheticDoc — and park the nav cursor mid-history so
280        // both zt and zb have room to move the viewport.
281        a.apply(crate::app::Action::ExitTerminalInsert);
282        a.editor.cursor = lattice_protocol::position::Position::new(10, 0);
283
284        let offset = |a: &super::App| {
285            a.editor
286                .buffers
287                .with_terminal(id, |t| t.snapshot.load().scroll_offset)
288                .unwrap()
289        };
290        a.apply(crate::app::Action::ScrollCursorTo(
291            lattice_grammar::ScrollPos::Top,
292        ));
293        let off_top = offset(&a);
294        a.apply(crate::app::Action::ScrollCursorTo(
295            lattice_grammar::ScrollPos::Bottom,
296        ));
297        let off_bot = offset(&a);
298
299        // `zt` puts the cursor row at the top of the window, `zb` at the
300        // bottom; the viewport offsets must differ (pre-fix both were 0 —
301        // the command was a no-op). `zb` scrolls further up than `zt`.
302        assert!(
303            off_bot > off_top,
304            "zt/zb must move the terminal viewport (off_top={off_top}, off_bot={off_bot})"
305        );
306    }
307
308    #[test]
309    fn line_numbers_mode_overrides_typed_option_layer() {
310        // M.7.0: the mode-contribution layer wins against the
311        // typed-option layer. `Number` defaults to `true`; the
312        // user can `:set nonumber` to flip the typed-option
313        // layer to `false`, then `:line-numbers-mode` to
314        // re-enable just for this buffer via the mode layer.
315        // The resolved value reflects the mode contribution.
316        let mut a = app_with("hi", 5);
317        let id = a.editor.pane_tree.active().buffer_id;
318        // Flip the typed-option layer to false globally.
319        a.do_set("nonumber");
320        assert!(!a.show_line_numbers(), "set nonumber should flip cache");
321        // Activate `:line-numbers-mode` for this buffer; the
322        // mode-contribution layer overrides the typed-option
323        // layer's false.
324        a.toggle_mode_by_name("line-numbers-mode");
325        assert!(
326            *a.resolved_option::<lattice_config::Number>(id),
327            "mode contribution should override typed-option false",
328        );
329        assert!(a.show_line_numbers(), "hot-path cache should track");
330        // Deactivating the mode removes the contribution; the
331        // typed-option layer (false) takes over again.
332        a.toggle_mode_by_name("line-numbers-mode");
333        assert!(!a.show_line_numbers());
334    }
335
336    #[test]
337    fn relative_line_numbers_mode_implies_line_numbers() {
338        // M.7.0: `:relative-line-numbers-mode` contributes both
339        // RelativeNumber=true AND Number=true (vim's `:set rnu`
340        // implies `:set nu` cascade, baked into the mode's
341        // contribution so users who never touch
342        // `:line-numbers-mode` still get a visible gutter).
343        let mut a = app_with("hi", 5);
344        let id = a.editor.pane_tree.active().buffer_id;
345        a.toggle_mode_by_name("relative-line-numbers-mode");
346        assert!(*a.resolved_option::<lattice_config::RelativeNumber>(id));
347        assert!(*a.resolved_option::<lattice_config::Number>(id));
348    }
349
350    #[test]
351    fn wrap_mode_toggle_flips_wrap_lines() {
352        let mut a = app_with("hi", 5);
353        let id = a.editor.pane_tree.active().buffer_id;
354        assert!(!a.wrap_lines());
355        a.toggle_mode_by_name("wrap-mode");
356        assert!(*a.resolved_option::<lattice_config::Wrap>(id));
357        a.toggle_mode_by_name("wrap-mode");
358        assert!(!a.wrap_lines());
359    }
360
361    #[test]
362    fn set_number_true_activates_line_numbers_mode_on_active_buffer() {
363        // M.7.1 convergence: `:set number=true` activates
364        // `line-numbers-mode` on the active buffer; `:set
365        // nonumber` deactivates it. The two surfaces stay in
366        // sync.
367        let mut a = app_with("hi", 5);
368        let id = a.editor.pane_tree.active().buffer_id;
369        // Default Number=true; M.7.1 means the mode should
370        // already be active on the active buffer (initial cascade
371        // when typed-option is true at startup). But initial
372        // boot doesn't fire OptionChanged, so the mode isn't
373        // pre-activated. Verify by explicit set.
374        a.do_set("nonumber");
375        assert!(
376            !a.editor
377                .active_modes
378                .get(&id)
379                .unwrap()
380                .has_minor(lattice_mode::modes::LineNumbersMode::mode_id())
381        );
382        a.do_set("number");
383        assert!(
384            a.editor
385                .active_modes
386                .get(&id)
387                .unwrap()
388                .has_minor(lattice_mode::modes::LineNumbersMode::mode_id())
389        );
390        a.do_set("nonumber");
391        assert!(
392            !a.editor
393                .active_modes
394                .get(&id)
395                .unwrap()
396                .has_minor(lattice_mode::modes::LineNumbersMode::mode_id())
397        );
398    }
399
400    #[test]
401    fn set_wrap_converges_with_wrap_mode() {
402        let mut a = app_with("hi", 5);
403        let id = a.editor.pane_tree.active().buffer_id;
404        let wrap_id = lattice_mode::modes::WrapMode::mode_id();
405        a.do_set("wrap");
406        assert!(a.editor.active_modes.get(&id).unwrap().has_minor(wrap_id));
407        a.do_set("nowrap");
408        assert!(!a.editor.active_modes.get(&id).unwrap().has_minor(wrap_id));
409    }
410
411    #[test]
412    fn set_list_converges_with_whitespace_show_mode() {
413        // M.7.2: `:set list` (vim alias for whitespace-show)
414        // activates `whitespace-show-mode`; `:set nolist`
415        // deactivates.
416        let mut a = app_with("hi", 5);
417        let id = a.editor.pane_tree.active().buffer_id;
418        let mode_id = lattice_mode::modes::WhitespaceShowMode::mode_id();
419        a.do_set("list");
420        assert!(a.editor.active_modes.get(&id).unwrap().has_minor(mode_id));
421        a.do_set("nolist");
422        assert!(!a.editor.active_modes.get(&id).unwrap().has_minor(mode_id));
423    }
424
425    #[test]
426    fn set_cursorline_converges_with_current_line_highlight_mode() {
427        // M.7.2: `:set cursorline` (vim alias) ↔
428        // `:current-line-highlight-mode`.
429        let mut a = app_with("hi", 5);
430        let id = a.editor.pane_tree.active().buffer_id;
431        let mode_id = lattice_mode::modes::CurrentLineHighlightMode::mode_id();
432        a.do_set("cursorline");
433        assert!(a.editor.active_modes.get(&id).unwrap().has_minor(mode_id));
434        a.do_set("nocursorline");
435        assert!(!a.editor.active_modes.get(&id).unwrap().has_minor(mode_id));
436    }
437
438    #[test]
439    fn line_numbers_mode_activation_matches_set_number() {
440        // M.7.1 the other direction: activating the mode AND
441        // running `:set number` produce the same observable
442        // state. (Mode activation doesn't reach back into the
443        // typed-option layer in v1 -- that's a separate
444        // refinement -- but the resolved-option view converges
445        // because the mode contribution wins regardless.)
446        let mut a = app_with("hi", 5);
447        let id = a.editor.pane_tree.active().buffer_id;
448        a.do_set("nonumber");
449        assert!(!a.show_line_numbers());
450        // Mode activation flips the mode-contribution layer.
451        a.toggle_mode_by_name("line-numbers-mode");
452        assert!(
453            a.show_line_numbers(),
454            "mode contribution overrides set false"
455        );
456        // `:set number` activates the mode (already active --
457        // no-op) and flips the typed-option layer.
458        a.do_set("number");
459        assert!(a.show_line_numbers());
460        assert!(
461            a.editor
462                .active_modes
463                .get(&id)
464                .unwrap()
465                .has_minor(lattice_mode::modes::LineNumbersMode::mode_id())
466        );
467    }
468
469    #[test]
470    fn read_only_mode_toggle_flips_read_only_resolved_value() {
471        // M.7.0: `:read-only-mode` lets users mark an arbitrary
472        // buffer read-only. `ReadOnly` itself is
473        // `customizable = false` (no `:set` surface); this is
474        // the user-typed pathway.
475        let mut a = app_with("hi", 5);
476        let id = a.editor.pane_tree.active().buffer_id;
477        assert!(!*a.resolved_option::<lattice_config::ReadOnly>(id));
478        a.toggle_mode_by_name("read-only-mode");
479        assert!(*a.resolved_option::<lattice_config::ReadOnly>(id));
480        a.toggle_mode_by_name("read-only-mode");
481        assert!(!*a.resolved_option::<lattice_config::ReadOnly>(id));
482    }
483
484    #[test]
485    fn toggle_minor_mode_by_name_activates_then_deactivates() {
486        // M.5.1: `:lsp-mode` (or any minor name) toggles. First
487        // call activates, second deactivates. The mode is
488        // registered at boot so name lookup succeeds.
489        let mut a = app_with("hi", 5);
490        let id = a.editor.pane_tree.active().buffer_id;
491        let lsp_mode = lattice_lsp::modes::LspMode::mode_id();
492        assert!(!a.lsp_mode_enabled_for(id));
493        a.toggle_mode_by_name("lsp-mode");
494        assert!(a.lsp_mode_enabled_for(id));
495        assert!(a.editor.active_modes.get(&id).unwrap().has_minor(lsp_mode));
496        a.toggle_mode_by_name("lsp-mode");
497        assert!(!a.lsp_mode_enabled_for(id));
498    }
499
500    #[test]
501    fn activating_lsp_mode_cascades_all_sub_modes_on() {
502        // M.6.1 cascade-on: toggling `:lsp-mode` activates every
503        // LSP sub-mode in one step. The umbrella is the gate;
504        // the sub-modes are user-controllable disable switches
505        // that default to "track the umbrella".
506        let mut a = app_with("hi", 5);
507        let id = a.editor.pane_tree.active().buffer_id;
508        a.toggle_mode_by_name("lsp-mode");
509        assert!(a.lsp_mode_enabled_for(id));
510        // All nine sub-modes flipped on.
511        assert!(a.lsp_completion_mode_enabled_for(id));
512        assert!(a.lsp_diagnostics_mode_enabled_for(id));
513        assert!(a.lsp_hover_mode_enabled_for(id));
514        assert!(a.lsp_signature_mode_enabled_for(id));
515        assert!(a.lsp_format_mode_enabled_for(id));
516        assert!(a.lsp_rename_mode_enabled_for(id));
517        assert!(a.lsp_symbols_mode_enabled_for(id));
518        assert!(a.lsp_code_action_mode_enabled_for(id));
519        assert!(a.lsp_nav_mode_enabled_for(id));
520    }
521
522    #[test]
523    fn deactivating_lsp_mode_cascades_all_sub_modes_off() {
524        // M.6.1 cascade-off: toggling `:lsp-mode` off deactivates
525        // every sub-mode atomically.
526        let mut a = app_with("hi", 5);
527        let id = a.editor.pane_tree.active().buffer_id;
528        a.toggle_mode_by_name("lsp-mode");
529        assert!(a.lsp_hover_mode_enabled_for(id));
530        a.toggle_mode_by_name("lsp-mode");
531        assert!(!a.lsp_mode_enabled_for(id));
532        // All nine sub-modes flipped off.
533        assert!(!a.lsp_completion_mode_enabled_for(id));
534        assert!(!a.lsp_diagnostics_mode_enabled_for(id));
535        assert!(!a.lsp_hover_mode_enabled_for(id));
536        assert!(!a.lsp_signature_mode_enabled_for(id));
537        assert!(!a.lsp_format_mode_enabled_for(id));
538        assert!(!a.lsp_rename_mode_enabled_for(id));
539        assert!(!a.lsp_symbols_mode_enabled_for(id));
540        assert!(!a.lsp_code_action_mode_enabled_for(id));
541        assert!(!a.lsp_nav_mode_enabled_for(id));
542    }
543
544    #[test]
545    fn user_disabling_a_sub_mode_after_cascade_keeps_others_active() {
546        // M.6.1 contract: cascade-on activates everything; user
547        // can then independently disable one sub-mode and the
548        // others stay on. This is the "disable LSP completion
549        // but keep diagnostics" use case from §4.2.1.
550        let mut a = app_with("hi", 5);
551        let id = a.editor.pane_tree.active().buffer_id;
552        a.toggle_mode_by_name("lsp-mode");
553        // Independently disable `lsp-completion-mode`.
554        a.toggle_mode_by_name("lsp-completion-mode");
555        assert!(!a.lsp_completion_mode_enabled_for(id));
556        // Other sub-modes still active.
557        assert!(a.lsp_diagnostics_mode_enabled_for(id));
558        assert!(a.lsp_hover_mode_enabled_for(id));
559        assert!(a.lsp_format_mode_enabled_for(id));
560        // Umbrella still active.
561        assert!(a.lsp_mode_enabled_for(id));
562    }
563
564    #[test]
565    fn re_activating_lsp_mode_after_user_disable_re_cascades_sub_modes_on() {
566        // Edge case: user toggles lsp-mode off, then on again.
567        // The cascade-on should re-activate every sub-mode,
568        // including any the user had previously disabled.
569        // (Toggling the umbrella is the user's "reset to defaults"
570        // gesture.)
571        let mut a = app_with("hi", 5);
572        let id = a.editor.pane_tree.active().buffer_id;
573        a.toggle_mode_by_name("lsp-mode");
574        a.toggle_mode_by_name("lsp-hover-mode");
575        assert!(!a.lsp_hover_mode_enabled_for(id));
576        // Cycle the umbrella: cascade-off then cascade-on.
577        a.toggle_mode_by_name("lsp-mode");
578        a.toggle_mode_by_name("lsp-mode");
579        assert!(a.lsp_hover_mode_enabled_for(id));
580    }
581
582    #[test]
583    fn lsp_sub_modes_default_off_and_independently_toggleable() {
584        // M.6.0: each sub-mode accessor returns false on a fresh
585        // buffer; toggling each by name flips only that sub-mode.
586        // (M.6.1 will add capability-driven cascade from
587        // `:lsp-mode` activation -- this test pins the manual-
588        // toggle pathway, which is the v1 escape hatch when a
589        // user wants a sub-mode active independently of the
590        // umbrella's auto-activation logic.)
591        let mut a = app_with("hi", 5);
592        let id = a.editor.pane_tree.active().buffer_id;
593        // All nine sub-modes start off.
594        assert!(!a.lsp_completion_mode_enabled_for(id));
595        assert!(!a.lsp_diagnostics_mode_enabled_for(id));
596        assert!(!a.lsp_hover_mode_enabled_for(id));
597        assert!(!a.lsp_signature_mode_enabled_for(id));
598        assert!(!a.lsp_format_mode_enabled_for(id));
599        assert!(!a.lsp_rename_mode_enabled_for(id));
600        assert!(!a.lsp_symbols_mode_enabled_for(id));
601        assert!(!a.lsp_code_action_mode_enabled_for(id));
602        assert!(!a.lsp_nav_mode_enabled_for(id));
603        // Toggling `lsp-hover-mode` flips its accessor but no
604        // other sub-mode's accessor.
605        a.toggle_mode_by_name("lsp-hover-mode");
606        assert!(a.lsp_hover_mode_enabled_for(id));
607        assert!(!a.lsp_completion_mode_enabled_for(id));
608        assert!(!a.lsp_diagnostics_mode_enabled_for(id));
609        assert!(!a.lsp_format_mode_enabled_for(id));
610        // Round-trip back off.
611        a.toggle_mode_by_name("lsp-hover-mode");
612        assert!(!a.lsp_hover_mode_enabled_for(id));
613    }
614
615    #[test]
616    fn toggle_unknown_mode_name_emits_error_echo() {
617        // Unknown name → error message; no state change.
618        let mut a = app_with("hi", 5);
619        let id = a.editor.pane_tree.active().buffer_id;
620        let before_minors_len = a
621            .editor
622            .active_modes
623            .get(&id)
624            .map(|m| m.minors().len())
625            .unwrap_or(0);
626        a.toggle_mode_by_name("definitely-not-a-mode");
627        let msg = a.editor.last_message.as_ref().expect("error echo");
628        assert!(msg.text.contains("not a registered mode"));
629        let after_minors_len = a
630            .editor
631            .active_modes
632            .get(&id)
633            .map(|m| m.minors().len())
634            .unwrap_or(0);
635        assert_eq!(before_minors_len, after_minors_len);
636    }
637
638    #[test]
639    fn toggle_major_mode_by_name_swaps_active_major() {
640        // Major-mode toggle = swap. The buffer starts on the
641        // resolver's pick (text-mode for plain content); flipping
642        // to `markdown-mode` deactivates text-mode and activates
643        // markdown-mode. Active minors stay untouched.
644        let mut a = app_with("# heading", 5);
645        let id = a.editor.pane_tree.active().buffer_id;
646        // Activate a minor first so we can verify it survives.
647        a.toggle_mode_by_name("lsp-mode");
648        assert!(a.lsp_mode_enabled_for(id));
649        // Swap major.
650        a.toggle_mode_by_name("markdown-mode");
651        let modes = a.editor.active_modes.get(&id).expect("modes for buffer");
652        assert_eq!(modes.major(), Some(lattice_syntax::MarkdownMode::mode_id()));
653        // Minor unaffected by major swap (M.5 design).
654        assert!(modes.has_minor(lattice_lsp::modes::LspMode::mode_id()));
655    }
656
657    #[test]
658    fn lsp_mode_auto_activates_on_language_with_configured_server() {
659        // M.5.2: opening a buffer whose path matches a configured
660        // server's `file_patterns` auto-activates `lsp-mode`. The
661        // bundled rust-analyzer config matches `*.rs`.
662        use crate::app::test_helpers::app_with_path;
663        let a = app_with_path("fn main() {}", 5, std::path::PathBuf::from("foo.rs"));
664        let id = a.editor.pane_tree.active().buffer_id;
665        assert!(
666            a.lsp_mode_enabled_for(id),
667            "lsp-mode should auto-activate on a *.rs buffer with rust-analyzer configured"
668        );
669    }
670
671    #[test]
672    fn lsp_mode_does_not_auto_activate_for_unconfigured_extensions() {
673        // No bundled server matches `*.unknown_ext`, so lsp-mode
674        // should stay inactive even after the major activates.
675        use crate::app::test_helpers::app_with_path;
676        let a = app_with_path(
677            "plain text",
678            5,
679            std::path::PathBuf::from("notes.unknown_ext"),
680        );
681        let id = a.editor.pane_tree.active().buffer_id;
682        assert!(
683            !a.lsp_mode_enabled_for(id),
684            "lsp-mode shouldn't auto-activate when no server config matches"
685        );
686    }
687
688    #[test]
689    fn lsp_mode_does_not_auto_activate_for_pathless_scratch_buffers() {
690        // Scratch buffers without a path don't get auto-activation
691        // (path-driven check). Standalone-server use cases require
692        // explicit `:lsp-mode`.
693        let a = app_with("fn main() {}", 5);
694        let id = a.editor.pane_tree.active().buffer_id;
695        assert!(!a.lsp_mode_enabled_for(id));
696    }
697
698    /// Wait up to `budget` for `predicate` to return true.
699    /// Polls every 5ms; uses `tokio::time::sleep` so the
700    /// current task yields to the runtime (letting the
701    /// shared-runtime spawn from `LspMode::on_activate`
702    /// complete). Returns whether the predicate held by the
703    /// deadline.
704    async fn wait_for(mut predicate: impl FnMut() -> bool, budget: std::time::Duration) -> bool {
705        let deadline = std::time::Instant::now() + budget;
706        while !predicate() {
707            if std::time::Instant::now() >= deadline {
708                return false;
709            }
710            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
711        }
712        true
713    }
714
715    #[tokio::test(flavor = "multi_thread")]
716    async fn deactivating_lsp_mode_emits_lsp_buffer_detached_event() {
717        // M.5.3 + M-async.5: deactivating `lsp-mode` publishes
718        // `LspBufferDetached` on the editor bus. Under
719        // M-async.5 `LspMode::on_activate` `.await`s the
720        // supervisor's `open_buffer` mailbox, so the Guard
721        // lands asynchronously after App::new returns. Wait
722        // for the activation to settle before toggling off
723        // (otherwise the deactivate hits an empty store and
724        // no Drop fires synchronously).
725        use crate::app::test_helpers::app_with_path;
726        let mut a = app_with_path("fn main() {}", 5, std::path::PathBuf::from("foo.rs"));
727        let id = a.editor.pane_tree.active().buffer_id;
728        // Sync prefix mutated active_modes for lsp-mode; the
729        // spawn task is still in flight (open_buffer.await
730        // round-trips the supervisor mailbox).
731        assert!(a.lsp_mode_enabled_for(id));
732        // Subscribe BEFORE the activation settles so we don't
733        // miss any events. The detach we care about fires
734        // either synchronously (if Guard stashed by toggle-off
735        // time) or on the spawn side (if activation's spawn
736        // hasn't completed -- the stale Guard drops via
737        // try_insert mismatch and still publishes
738        // LspBufferDetached).
739        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<lattice_lsp::LspBufferDetached>();
740        a.editor.event_bus.subscribe_typed(tx);
741        a.toggle_mode_by_name("lsp-mode");
742        assert!(!a.lsp_mode_enabled_for(id));
743        let got_detach =
744            wait_for(|| rx.try_recv().is_ok(), std::time::Duration::from_secs(2)).await;
745        assert!(
746            got_detach,
747            "expected LspBufferDetached on bus after `:lsp-mode` toggle off",
748        );
749    }
750
751    #[tokio::test(flavor = "multi_thread")]
752    async fn deactivating_lsp_mode_clears_buffer_uri_mapping() {
753        // M.5.3 + M-async.5: the wire-level `didClose` +
754        // `buffer_uris` cleanup runs from the
755        // `LspBufferDetached` drain, not from the mode-
756        // activation path itself. Test mirrors the runtime
757        // tick: toggle the mode, wait for the detach event to
758        // hit the channel, then call the drain (consumes the
759        // event + clears `buffer_uris`).
760        use crate::app::test_helpers::app_with_path;
761        let mut a = app_with_path("fn main() {}", 5, std::path::PathBuf::from("foo.rs"));
762        let id = a.editor.pane_tree.active().buffer_id;
763        assert!(a.buffer_uri(id).is_some());
764        a.toggle_mode_by_name("lsp-mode");
765        // Wait for the detach event to land on the bus.
766        // `drain_lsp_detach_events` is non-blocking; without
767        // the wait the spawn-side Drop hasn't fired yet.
768        let ready = wait_for(
769            || {
770                a.drain_lsp_detach_events();
771                a.buffer_uri(id).is_none()
772            },
773            std::time::Duration::from_secs(2),
774        )
775        .await;
776        assert!(
777            ready,
778            "lsp-mode deactivate should clear buffer_uris[id] after detach drain"
779        );
780    }
781
782    #[tokio::test(flavor = "multi_thread")]
783    async fn lsp_mode_auto_activates_on_each_new_rust_buffer() {
784        // Bug fix: pre-fix, only the boot buffer ran
785        // `activate_major_for_buffer_kind`; `:e other.rs` skipped
786        // it, so `lsp-mode` never woke up on subsequent buffers.
787        // Post-fix, `activate_buffer_state` re-runs the major
788        // activation (idempotent on re-entry), which triggers
789        // the existing `maybe_auto_activate_lsp_mode` hook for
790        // every newly-visited buffer whose path has a server
791        // configured.
792        use crate::app::test_helpers::{app_with_path, write_temp_file};
793        let mut a = app_with_path("fn first() {}", 5, std::path::PathBuf::from("first.rs"));
794        let first_id = a.editor.pane_tree.active().buffer_id;
795        // Boot path's lsp-mode activation is async since
796        // M-async.5 -- wait for it to settle.
797        let first_attached = wait_for(
798            || a.lsp_mode_enabled_for(first_id),
799            std::time::Duration::from_secs(2),
800        )
801        .await;
802        assert!(
803            first_attached,
804            "first rust buffer should auto-attach lsp-mode at boot"
805        );
806        // Open a second `.rs` file. Pre-fix this was the bug:
807        // `do_edit` created the buffer and called
808        // `activate_buffer_state` but skipped major activation,
809        // so the auto-LSP hook never fired.
810        let second_path = write_temp_file("auto-lsp-second.rs", "fn second() {}\n");
811        a.do_edit(Some(second_path.clone()), false);
812        let second_id = a.editor.document_buffer_id;
813        assert_ne!(second_id, first_id, ":e should have opened a new buffer");
814        let second_attached = wait_for(
815            || a.lsp_mode_enabled_for(second_id),
816            std::time::Duration::from_secs(2),
817        )
818        .await;
819        assert!(
820            second_attached,
821            "lsp-mode should auto-activate on the second rust buffer after :e (buffer-switch fix)"
822        );
823        // First buffer's lsp-mode untouched -- per-buffer state.
824        assert!(
825            a.lsp_mode_enabled_for(first_id),
826            "first buffer's lsp-mode should persist after switch"
827        );
828        // Switching back via the activate-document path: the
829        // idempotency guard inside `activate_major_for_buffer_kind`
830        // skips the registry reload, the auto-LSP hook short-
831        // circuits (already-active minor). lsp-mode stays on.
832        a.activate_document(first_id);
833        assert!(
834            a.lsp_mode_enabled_for(first_id),
835            "first buffer's lsp-mode should still be active after switch-back via :b"
836        );
837        let _ = std::fs::remove_file(second_path);
838    }
839
840    #[test]
841    fn lsp_mode_survives_major_swap() {
842        // M.5 design: major swaps don't touch active minors. Open
843        // a rust file (auto-activates lsp-mode), swap to text-mode,
844        // lsp-mode stays active. User runs `:lsp-mode` to flip off.
845        use crate::app::test_helpers::app_with_path;
846        let mut a = app_with_path("fn main() {}", 5, std::path::PathBuf::from("foo.rs"));
847        let id = a.editor.pane_tree.active().buffer_id;
848        assert!(a.lsp_mode_enabled_for(id));
849        a.toggle_mode_by_name("text-mode");
850        assert!(
851            a.lsp_mode_enabled_for(id),
852            "lsp-mode should survive major-mode swap (M.5 design)"
853        );
854    }
855
856    #[test]
857    fn describe_events_renders_catalogue_grouped_by_source_crate() {
858        // M.5.3.c: `:describe-events` walks `EVENT_DESCRIPTORS`
859        // (the linkme distributed slice every `register_event!`
860        // pushes into) and renders a help buffer. Every event
861        // in the registry should appear in the rendered body.
862        let mut a = app_with("hi", 5);
863        a.do_describe_events();
864        let h = a
865            .popup_help()
866            .expect(":describe-events should open a help popup");
867        let body = h.content.as_string();
868        // The three M.5.3.b LSP events should be listed.
869        assert!(
870            body.contains("lsp.buffer-attached"),
871            "describe-events body missing `lsp.buffer-attached`; got:\n{body}"
872        );
873        assert!(
874            body.contains("lsp.buffer-detached"),
875            "describe-events body missing `lsp.buffer-detached`; got:\n{body}"
876        );
877        assert!(
878            body.contains("lsp.log-pushed"),
879            "describe-events body missing `lsp.log-pushed`; got:\n{body}"
880        );
881        // Source crate header for grouped section.
882        assert!(
883            body.contains("lattice-lsp"),
884            "describe-events body should group by source crate; got:\n{body}"
885        );
886    }
887
888    #[test]
889    fn describe_event_renders_single_descriptor() {
890        let mut a = app_with("hi", 5);
891        a.do_describe_event("lsp.buffer-attached");
892        let h = a
893            .popup_help()
894            .expect(":describe-event should open a help popup");
895        let body = h.content.as_string();
896        assert!(body.contains("lsp.buffer-attached"));
897        assert!(body.contains("lattice-lsp"));
898    }
899
900    #[test]
901    fn describe_event_unknown_name_emits_error_echo() {
902        let mut a = app_with("hi", 5);
903        a.do_describe_event("definitely-not-an-event");
904        let msg = a.editor.last_message.as_ref().expect("error echo");
905        assert!(msg.text.contains("no event named"));
906        assert!(a.editor.popup_buffer.is_none());
907    }
908
909    #[test]
910    fn toggle_command_resolves_through_ex_command_registry() {
911        // M.5.1: the `:<mode-name>` ex-command auto-registered at
912        // boot drives the same toggle. End-to-end through
913        // `apply(Action::ExecuteEx(...))` would be the full flow;
914        // here we verify the registry entry exists with the
915        // expected name (the apply fn is exercised by the tests
916        // above via direct `toggle_mode_by_name`).
917        let a = app_with("hi", 5);
918        // Every registered mode should have a corresponding
919        // ex-command keyword in the registry.
920        for (mode_id, _kind) in a.editor.mode_registry.load().iter_meta() {
921            let name = mode_id.to_string();
922            assert!(
923                a.editor.registry.load().id_by_name(&name).is_some(),
924                "no ex-command registered for mode `{name}`"
925            );
926        }
927    }
928}