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}