Skip to main content

lattice_ui_tui/
app.rs

1//! Pure application state and transitions.
2//!
3//! The state machine is intentionally separated from the IO loop so it can be
4//! unit-tested without spinning up a terminal. Each input keystroke becomes
5//! an `Action`; `App::apply` consumes the action, dispatching motion / edit
6//! work through `lattice_grammar::execute()` where appropriate.
7//!
8//! Slice 3c.final.E.swap aftermath: many App-side delegate methods +
9//! free helper functions in this file are only reachable from
10//! `#[cfg(test)] mod tests` blocks; the production paint / dispatch
11//! paths route through the `mutate_editor` / `read_editor` seam or
12//! the published RS sub-states (`ad()`, `panes()`, `popup()`, ...).
13//! The `#![allow(dead_code)]` below acknowledges that test-only state
14//! without restructuring 30+ methods into `#[cfg(test)] impl App`
15//! blocks. A follow-up cleanup slice (`3c.final.E.cleanup`) can
16//! tighten this if the lint signal becomes load-bearing for catching
17//! genuine future dead code.
18#![allow(dead_code)]
19//!
20//! ## Module layout
21//!
22//! This file holds the `App` struct definition, the cross-feature data
23//! types it carries (`Action`, the LSP outcome enums + structs,
24//! `OptionCache`, `PositionEntry`, `LspNavKind`, `CompletionState`,
25//! `Fold`, `EchoMessage`, `EchoLevel`, `SearchLine`, ...), the
26//! cross-module free helpers (`line_byte_len`, `is_word_char_byte`,
27//! `word_under_cursor`, etc.), and a `mod tests` block of cross-
28//! feature integration tests. Per-feature App methods live in
29//! `app/<feature>.rs` submodules -- see `docs/dev/notes/ui-tui-refactor.md`
30//! for the full per-module catalog. The R.1.x slice sequence
31//! (R.1.0 -- R.1.98) split the App's monolithic impl block apart.
32//!
33//! ## Where to look for App methods
34//!
35//! - `app/dispatch.rs` -- `apply` / `apply_effect` /
36//!   `apply_app_effect` / `handle_edits` / `dispatch_blocking` /
37//!   `run_*_invocation` / `execute_ex_line` / Effect classifiers.
38//! - `app/lsp.rs` -- every `lattice-lsp` consumer (requests,
39//!   drains, log buffers, hover, navigation, references,
40//!   signature help, completion, rename, code action, format,
41//!   symbols, trace, on-type formatting, trigger chars,
42//!   workspace/applyEdit + workspace/configuration drains).
43//! - `app/lifecycle.rs` -- `:e` / `:w` / `:q` / `:bn` / `:ls` /
44//!   `<C-l>`, save family, help-buffer adoption, document swap,
45//!   buffer-state hooks, pane snapshot, event publishers.
46//! - `app/completion.rs` -- popup state machine, ranker, ghost
47//!   text, snippet expansion, refilter, `EffectiveCompletionConfig`.
48//! - `app/edit.rs` -- actor-bridge mutation wrappers,
49//!   yank / paste / register store, Insert+Replace primitives,
50//!   `:d`, block-insert.
51//! - `app/motions.rs` -- bracket match, history walkers,
52//!   mark jump, viewport / scroll, cursor clamp, viewport
53//!   sizing, active-buffer accessors.
54//! - `app/folds.rs` -- fold compute / open / close / auto-open.
55//! - `app/search.rs` -- `/`, `?`, `:s`, `:%s`, find family.
56//! - `app/options.rs` -- `:set`, typed options, customize,
57//!   per-language overrides, typed-option getters.
58//! - `app/cmdline.rs` -- `:` minibuffer + completion.
59//! - `app/help.rs` -- `:help`, `:describe-*`, `:apropos`,
60//!   `:keymap`, `do_help_follow_link`.
61//! - `app/highlights.rs` -- tree-sitter highlight cache +
62//!   per-frame refresh + post-edit shift.
63//! - `app/visual.rs` -- charwise / linewise / blockwise
64//!   selection state, `set_selections_blocking`.
65//! - `app/picker.rs` -- picker state machine + candidate
66//!   builders.
67//! - `app/boot.rs` -- `App::new`, `build_lsp_subsystem`,
68//!   `load_persistent_config`, `sync_keymap_overlays`,
69//!   `sync_theme_from_config`.
70//! - `app/file_tree.rs` -- file-tree buffer ops.
71//! - `app/oil.rs` -- oil buffer ops.
72//! - `app/macros.rs` -- `q` recording / `@` replay.
73//! - `app/mode.rs` -- `modal_label` + `enter_mode` +
74//!   `activate_major_for_buffer_kind`.
75//! - `app/syntax.rs` -- `maybe_reparse_syntax`.
76//! - `app/test_helpers.rs` -- shared test fixtures.
77
78use lattice_core::Buffer;
79#[cfg(test)]
80use lattice_core::Document;
81// SearchDirection + YankKind + ModalState: only referenced
82// from `mod tests` after the migrations. Gate to test-only
83// (or `pub use`) so the `#![deny(unused_imports)]` lint
84// stays clean for production builds while submodule tests
85// keep resolving them via `use super::*;`.
86#[cfg(test)]
87use lattice_grammar::SearchDirection;
88#[cfg(test)]
89use lattice_grammar::YankKind;
90// Re-exported so submodule tests using `use super::*;` keep
91// seeing the names after Phase 5.B.* migrations.
92// `BufferRegistry` + `DocumentEntry` were used inline by the
93// App::new boot body before Phase 5.7.B.1 moved that work to
94// `lattice_host::editor::Editor::boot`. The crate-root re-export
95// (lib.rs `pub use buffer_registry::{...}`) keeps the external
96// path `lattice_ui_tui::BufferRegistry` available for downstream
97// consumers; `BufferData` + `BufferEntry` stay in scope here for
98// the submodule tests that build entries by hand.
99#[cfg(test)]
100use crate::buffer_registry::{BufferData, BufferEntry};
101#[cfg(test)]
102use crate::buffers::BufferFlags;
103use crate::buffers::{BufferId, BufferKind};
104pub use lattice_grammar::ModalState;
105pub use lattice_grammar::command::CommandInvocation;
106#[cfg(test)]
107use lattice_grammar::register::Register;
108use lattice_protocol::position::Position;
109#[cfg(test)]
110use lattice_protocol::selection::{Selection, SelectionSet};
111
112// Re-export PaneDirection so `super::*` in the tests module
113// resolves it. The `Action::NavigatePane(PaneDirection)` variant
114// now lives in `lattice_host::action::Action`; this re-export
115// keeps the test paths compiling.
116pub use lattice_grammar::PaneDirection;
117
118// R.1.0 -- app/ submodule skeleton. Each submodule is a
119// per-feature destination for the App's methods. R.1.0 only
120// creates the empty modules with scoping doc comments;
121// subsequent R.1.x slices move method blocks across without
122// rethinking the structure. See docs/dev/architecture/keymap-architecture.md
123// (or the dedicated R.1 doc) for the full feature -> module
124// mapping.
125mod boot;
126// CG.1: `<C-c>` foreground cancellation, exercised key-first through
127// translate + apply. Test-only — the handlers live in `lattice-host`.
128#[cfg(test)]
129mod cancel;
130mod cmdline;
131mod completion;
132mod dispatch;
133mod display;
134mod edit;
135mod file_tree;
136mod folds;
137mod help;
138mod highlights;
139// Paramount goal #1's ratchet clause: the keystroke→glyph distribution
140// gate. Test-only — it measures the production path (translate → apply →
141// compose) rather than adding one.
142/// The shipped `comment` plugin, driven through the real keystroke path.
143#[cfg(test)]
144mod comment_plugin;
145#[cfg(test)]
146mod keystroke_glyph_ratchet;
147mod lifecycle;
148mod lsp;
149mod lsp_log_buffers;
150mod lsp_watcher;
151mod macros;
152#[cfg(test)]
153mod magit_bindings;
154mod messages;
155mod mode;
156/// VM.3: operator+motion composition, driven through the real keystroke path.
157mod motion_composition;
158mod motions;
159mod oil;
160mod operators;
161mod options;
162pub(crate) mod picker;
163mod popup;
164mod search;
165mod state;
166#[cfg(test)]
167mod surround_bindings;
168mod syntax;
169mod visual;
170
171#[cfg(test)]
172pub(crate) mod test_helpers;
173
174// Slice 8.i.4.d: the `Pending` enum and `Action::SetPending`
175// variant retired here. All multi-key Normal-mode chord state
176// flows through `App::partial_chord` driven by
177// `Action::AbsorbPartialChord` (slices 8.i.4.a/b) plus
178// `AppEffect::AbsorbOperatorPrefix(_)` for operator prefixes
179// (slice 8.i.4.c). `App::apply`'s pending field, the
180// `Action::SetPending(_)` arm, and the "non-SetPending clears
181// pending" guard are gone.
182
183/// Vim's `H` / `M` / `L` cursor target within the visible
184/// viewport. Slice 8.i.2.c hoisted the type into
185/// `lattice_grammar::app_effect` so `AppEffect::JumpViewport`
186/// can carry it; this is a re-export so existing
187/// `crate::app::ViewportPos` callers stay compiling.
188pub use lattice_grammar::ViewportPos;
189
190/// Vim's `zz` / `zt` / `zb` post-scroll cursor target. Re-export
191/// of the `lattice_grammar` definition (see `ViewportPos` above
192/// for rationale).
193pub use lattice_grammar::ScrollPos;
194
195// Phase 5.2: `FindKind`, `EchoMessage`, `EchoLevel`, and the
196// `Action` enum (below) moved to `lattice_host::action`. The
197// re-exports preserve every existing `crate::app::Action` /
198// `crate::app::EchoLevel` import in this crate's modules + tests
199// + benches; no call-site changes needed.
200pub use lattice_host::action::{Action, EchoLevel, EchoMessage, FindKind};
201
202// `MessageRecord` / `MessagesRing` / `MessagePushed` are
203// renderer-agnostic, live in [`lattice_runtime`], and re-
204// exported below so existing call sites that imported them
205// through `crate::app::*` keep working. The wire-typed
206// `lattice_grammar::EchoLevel` rides through unchanged --
207// `crate::app::EchoLevel` is the display-typed peer kept for
208// renderer-side ergonomics; conversion lives at the
209// `set_message` seam.
210pub use lattice_runtime::{MessagePushed, MessageRecord, MessagesRing};
211
212/// Convert the renderer's display-typed `EchoLevel` to the
213/// wire-typed `lattice_grammar::EchoLevel` used by
214/// `MessageRecord` (and every other host-side / plugin-side
215/// subscriber on the typed bus). The variants are bit-
216/// identical; the split between display-typed (ui-tui) and
217/// wire-typed (grammar / runtime / plugin host) is the
218/// existing convention that kept the renderer surface from
219/// leaking into the grammar layer.
220/// Convert the renderer's display-typed `EchoLevel` to the
221/// wire-typed `lattice_grammar::EchoLevel`. Mirror exists in
222/// [`lattice_host::dispatch`] (private) for the host-side
223/// `set_message`; this copy stays around for any future ui-tui
224/// caller that needs wire-typed projection without crossing the
225/// host boundary.
226#[allow(dead_code)]
227pub(crate) fn echo_level_to_wire(level: EchoLevel) -> lattice_grammar::EchoLevel {
228    match level {
229        EchoLevel::Trace => lattice_grammar::EchoLevel::Trace,
230        EchoLevel::Debug => lattice_grammar::EchoLevel::Debug,
231        EchoLevel::Info => lattice_grammar::EchoLevel::Info,
232        EchoLevel::Warn => lattice_grammar::EchoLevel::Warn,
233        EchoLevel::Error => lattice_grammar::EchoLevel::Error,
234    }
235}
236
237/// Reverse of [`echo_level_to_wire`]: every renderer-side
238/// reader of `MessageRecord` (e.g. the `*messages*` formatter)
239/// projects the wire-typed level back to the renderer's
240/// display-typed enum. Allowed-dead until the renderer grows
241/// a reader site -- the formatter currently matches on the
242/// wire-typed enum directly to skip the round-trip.
243#[allow(dead_code)]
244pub(crate) fn echo_level_from_wire(level: lattice_grammar::EchoLevel) -> EchoLevel {
245    match level {
246        lattice_grammar::EchoLevel::Trace => EchoLevel::Trace,
247        lattice_grammar::EchoLevel::Debug => EchoLevel::Debug,
248        lattice_grammar::EchoLevel::Info => EchoLevel::Info,
249        lattice_grammar::EchoLevel::Warn => EchoLevel::Warn,
250        lattice_grammar::EchoLevel::Error => EchoLevel::Error,
251    }
252}
253
254// Convert grammar's wire-typed [`lattice_grammar::EchoLevel`] (carried
255// by `Effect::Echo`) into the App's display-typed `EchoLevel`. Two types
256// because the App's is part of the public crate API; the grammar's is a
257// dispatch detail.
258// Resolve user-typed command text to a `CommandId`, accepting
259// either the canonical registry name (`ex:write`) or an alias
260// (`write`, `w`). Used by App handlers that take a command name
261// from user input -- mirrors the two-stage logic in
262// `excommand::parse_invocation`.
263// 5.5.F.2: `resolve_command_name_or_alias` relocated to
264// `lattice_host::excommand::resolve_command_name_or_alias` alongside
265// `aliases()`. The in-module test now imports it directly through the
266// fully-qualified path.
267
268// Phase 5.2: `SearchLine`, `LastSearch`, `UnnamedRegister`,
269// `PrevPaneState` moved to `lattice_host::state`. Re-exported
270// below.
271pub use lattice_host::state::{
272    CompletionState, LastSearch, PrevPaneState, SearchLine, UnnamedRegister,
273};
274
275// Phase 5.B.13: `PendingPickerInit`, `LivePickerQueryState`,
276// `InFlightLiveQuery`, `LIVE_PICKER_DEBOUNCE` moved to
277// `lattice_host::state`. Re-exported below so existing
278// `crate::app::*` references keep resolving.
279pub use lattice_host::state::{
280    InFlightLiveQuery, LIVE_PICKER_DEBOUNCE, LivePickerQueryState, PendingPickerInit,
281};
282
283// `UnnamedRegister` and `PrevPaneState` moved to
284// `lattice_host::state` (re-export above).
285
286// Phase 5.2: LSP cache + outcome types moved to
287// `lattice_lsp::cache`. Re-exported here so existing
288// `crate::app::HoverOutcome` etc. references continue to
289// resolve unchanged across this crate + downstream consumers.
290// OR.7: the async-completion drain payload is `lattice-completion`'s now
291// (it carries every async source's candidates, not only LSP's).
292pub use lattice_completion::AsyncCompletionOutcome;
293pub use lattice_lsp::cache::{
294    CodeActionOutcome, CodeActionRow, CodeLensOutcome, CompletionItemRow, CompletionOutcome,
295    CompletionResolveOutcome, DecodedSemanticToken, DocumentColorOutcome, DocumentHighlightCache,
296    DocumentHighlightOutcome, DocumentLinksOutcome, FoldingRangeOutcome, FormatOutcome,
297    HoverOutcome, InlayHintOutcome, LspCodeLensCache, LspDocumentColorCache, LspDocumentLinksCache,
298    LspFoldsCache, LspInlayHintCache, LspNavKind, LspPullDiagnosticsCache, LspSelectionChain,
299    LspSemanticTokensCache, PullDiagnosticsOutcome, ReferencesOutcome, RenameOutcome,
300    SelectionRangeOutcome, SelectionRangeStep, SemanticTokensOutcome, SignatureHelpOutcome,
301    SymbolRow, SymbolsOutcome, apply_semantic_token_edits, decode_semantic_tokens,
302};
303
304// Phase 5.B.3: composition pivot (see
305// `docs/dev/architecture/phase-5b-app-design.md`). App is a
306// concrete struct -- a thin renderer-specific wrapper around
307// the renderer-agnostic `lattice_host::editor::Editor` plus
308// the two TUI-shaped caches (`theme`, `pane_render_registry`).
309// GPUI's analogue (future `lattice_ui_gpui::App`) takes the
310// same shape with its own renderer-specific caches. No
311// generics: every `impl App` inside this crate is a plain
312// inherent impl, the orphan rule doesn't bite, and per-cluster
313// field migrations from App → Editor can land green one at a
314// time. The 5.B.2 `App<R: Renderer>` parametrization is
315// reverted; the `lattice_host::Renderer` trait + `TuiRenderer`
316// marker are retained as Phase 5.6's `lattice-render` work may
317// reuse them.
318pub struct App {
319    /// Composition root for the renderer-agnostic editor state.
320    ///
321    /// Slice 3c.final.E.swap: the field is cfg-gated. In
322    /// production builds (`cfg(not(test))`) the renderer holds
323    /// an [`EditorActorHandle`] — Editor lives on its own
324    /// thread and the renderer reaches it only through the
325    /// handle's blocking RPCs (`mutate_blocking`,
326    /// `with_editor`, `mutate_blocking_with`,
327    /// `apply_blocking`). In test builds (`cfg(test)`) the
328    /// field keeps the original direct-ownership shape so the
329    /// existing test fixtures (which mutate state without going
330    /// through the dispatch path) keep compiling. This is the
331    /// architectural realisation of paramount goal #4: the
332    /// `&mut Editor` type cannot escape the actor thread in
333    /// production, enforced by the type system.
334    #[cfg(not(test))]
335    pub editor_actor: lattice_host::editor_actor::EditorActorHandle,
336    #[cfg(test)]
337    pub editor: lattice_host::editor::Editor,
338    /// Phase 5.8.AF.5 / Slice 3c.atomic.A: renderer-owned clone
339    /// of the editor's `Arc<ArcSwap<RenderState>>`. Lets the
340    /// renderer read state without going through `self.editor`,
341    /// which is the structural seam the eventual actor migration
342    /// pivots on. Today this Arc is a direct clone of
343    /// `editor.render_state` at construction; after 3c moves
344    /// Editor to its own thread, the same Arc is published from
345    /// the actor thread and read here -- identical contract,
346    /// different writer.
347    pub render_state: std::sync::Arc<arc_swap::ArcSwap<lattice_host::render_state::RenderState>>,
348    /// Per-frame pinned `ActiveDocumentRenderState`. When `Some`
349    /// (between `pin_render_state` / `unpin_render_state` calls),
350    /// [`Self::ad`] returns this cached snapshot so every `ad()` read
351    /// within a single frame draw sees the exact same cursor, scroll,
352    /// option, and modal state — regardless of how many call sites
353    /// exist or whether the actor publishes a new `RenderState` mid-
354    /// frame. Both the TUI and GPUI peers call pin/unpin (or equivalent
355    /// in their render loop); without this every independent `ad()`
356    /// site in a renderer loads a fresh `Arc` from the shared ArcSwap
357    /// and can see a torn snapshot relative to other sites.
358    frame_ad: std::sync::Mutex<
359        Option<std::sync::Arc<lattice_host::render_state::ActiveDocumentRenderState>>,
360    >,
361    /// Perf plan B.2 slice B.2.a: renderer-owned clone of the
362    /// editor's `syntax_static_overlay_quads_cell`. The cell is the
363    /// overlay worker's output channel — written by
364    /// `lattice_host::overlay_worker::recompute`, read by the
365    /// per-frame overlay paint path. Caching the `Arc` on App
366    /// matches the `render_state` pattern (slice 3c.atomic.A):
367    /// pre-swap this is a direct clone at construction; post-swap
368    /// the same Arc comes from the actor handle.
369    ///
370    /// display-line B4.2: the dead `syntax_visible_spans_cell` /
371    /// `syntax_visible_rows_cell` clones were deleted with the
372    /// overlay worker's span/row cache; this is the only worker
373    /// output cell the App keeps a clone of now.
374    pub syntax_static_overlay_quads_cell:
375        std::sync::Arc<arc_swap::ArcSwap<lattice_host::render_state::StaticOverlayQuads>>,
376    /// ML.4: where each clickable modeline element was painted, rebuilt
377    /// every frame and read when a mouse click arrives.
378    ///
379    /// `RefCell` because the paint path takes `&App` and this is the
380    /// one thing it produces rather than consumes. It is renderer-local
381    /// by nature — a record of where THIS terminal put THIS frame's
382    /// pixels — so it lives on the TUI's `App` rather than in
383    /// `RenderState`, which is the renderer-neutral publication the
384    /// GPUI peer reads too. GPUI needs no equivalent: its elements are
385    /// real `div`s and the window system does the hit test.
386    pub modeline_hits: std::cell::RefCell<lattice_host::modeline::ModelineHitMap>,
387    /// MO.2: this frame's pane bodies, for editor-body mouse
388    /// hit-testing. Same lifecycle as `modeline_hits` beside it —
389    /// recorded during paint, cleared at the top of each frame — so a
390    /// pane that stops painting stops taking clicks.
391    pub pane_hits: std::cell::RefCell<lattice_host::mouse::PaneHitMap>,
392    /// Handle to the per-document actor (DESIGN.md §5.2.1, §5.7).
393    /// The actor owns the writable `Document` (from `lattice-core`);
394    /// mutations route through it; reads load a versioned snapshot.
395    /// Denormalized from `documents[active_document_id].handle` for
396    /// hot-path access.
397    // Moved to `editor.document`.
398    /// Per-thread cached reader for [`Self::document`]'s published
399    /// snapshot cell (DESIGN.md §5.6.8). The renderer's per-frame
400    /// `snapshot_cache.load()` returns the current
401    /// `Arc<DocumentSnapshot>` in ~300ps in steady state (no edit
402    /// since last frame); ~16ns when the actor has just published.
403    /// Rebuilt whenever [`Self::document`] is reassigned --
404    /// `arc_swap::Cache` caches against a specific cell, so it must
405    /// follow the active document's handle.
406    // Moved to `editor.snapshot_cache`.
407    /// Stable id for the *active* document buffer. Mirrors the
408    /// active pane's `buffer_id` whenever that pane holds a
409    /// Document leaf. Position-history entries (§5.1.1) and
410    /// per-pane state record this id; switching the active
411    /// document via `:bnext` / `:e FILE` rotates `Self::document` /
412    /// `Self::syntax` etc. to the new active.
413    // Phase 5.B.16: `document_buffer_id`, `buffers`,
414    // `active_buffer`, `cursor`, `scroll`, `should_quit`,
415    // `viewport_height`, `terminal_width` moved to
416    // `editor.{document_buffer_id, buffers, active_buffer,
417    // cursor, scroll, should_quit, viewport_height,
418    // terminal_width}`.
419    /// Pane tree (DESIGN.md §5.9).
420    /// Moved to `editor.pane_tree` (Phase 5.B C4).
421    // Phase 5.B.15: `modal` moved to `editor.editor.modal`.
422    /// In-flight partial-chord stack from the trie (slice 8.i.4).
423    /// When the trie returns `LookupResult::Partial`,
424    /// `dispatch_normal` / `dispatch_insert` emit
425    /// `Action::AbsorbPartialChord(c)` and `App::apply` appends
426    /// `c` here. The next keystroke runs through the trie with
427    /// this stack as prefix and resolves the full multi-key
428    /// chord. Cleared on every non-`AbsorbPartialChord` action.
429    /// Operator-prefix pushes (8 prefixes: `d`, `c`, `y`, `>`,
430    /// `<`, `gU`, `gu`, `g~`) come from
431    /// `AppEffect::AbsorbOperatorPrefix(_)` via
432    /// `apply_app_effect`, which also latches `pending_count`
433    /// into `op_count` atomically with the prefix push.
434    // Phase 5.B.15: `partial_chord` moved to
435    // `editor.editor.partial_chord`.
436    /// Grammar registry shared with the document actor by `Arc`. The
437    /// actor calls `lattice_grammar::execute` with this registry from
438    /// inside its own task. The App also reads it directly for the
439    /// parser, completion pipeline, and introspection -- all
440    /// read-only operations.
441    // Phase 5.B.15: `registry` moved to `editor.editor.registry`.
442    /// In-process event bus (DESIGN.md §5.10). The App publishes
443    /// editor lifecycle events (DocumentChanged, SelectionsChanged,
444    /// ModalModeChanged, BeforeSave, DocumentSaved, BeforeQuit,
445    /// OptionChanged) after observing the corresponding state
446    /// transitions. The App itself subscribes to `OptionChanged`
447    /// for the cascade hook (see [`Self::option_change_rx`]);
448    /// other subscribers (plugins, autocmds) wire up the same way.
449    // Phase 5.B.15: `event_bus` moved to
450    // `editor.editor.event_bus`.
451    // Phase 5.B.19: all LSP per-feature request channel fields
452    // (`pending_hover_rx/token`, `pending_definition_rx/token`,
453    // `pending_nav_kind`, `pending_references_rx/token`,
454    // `pending_symbols_rx/token`, `pending_format_rx/token`,
455    // `pending_signature_help_rx/token`, `pending_completion_rx/token/items`,
456    // `pending_moniker_rx`, `pending_rename_rx/token`,
457    // `pending_code_action_rx/token/items/handle`) moved to
458    // `editor.*` -- access via `self.editor.pending_*`.
459    /// Receiver end of the App's own subscription to
460    /// `EventKind::OptionChanged` (DESIGN.md §5.10 + §5.12). The
461    /// typed-options registry publishes through `event_bus` on
462    /// every successful set; this channel queues those events for
463    /// [`Self::drain_option_changes`] to consume on the App's main
464    /// thread. Decouples cascade timing from the publish path
465    /// (publishes can come from any thread -- plugin tasks, future
466    /// LSP-driven config writes, the customize buffer) without
467    /// risking re-entrancy on the registry mutex or the renderer.
468    ///
469    /// `Option` only because the field needs to be `take`-able so
470    /// the drain method can borrow `&mut self` for cascade work
471    /// while iterating the receiver. Always `Some` between calls.
472    // Phase 5.B.14: `option_change_rx` moved to
473    // `editor.editor.option_change_rx`.
474    /// Shared language registry for tree-sitter highlighting. One
475    /// `Arc<LangRegistry>` services the document buffer's `Syntax`
476    /// AND every `HelpBuffer` constructed by `:describe-*` /
477    /// `:apropos` / `:keymap`. Help bodies render with markdown
478    /// highlighting (headings, fenced-block injections to the
479    /// language tag) sourced from this same registry.
480    // Phase 5.B.12: `lang_registry` moved to
481    // `editor.editor.lang_registry`.
482    // Phase 5.B.15: `builtins` moved to `editor.editor.builtins`.
483    /// App-side typed action IDs (`CommandKind::Action`
484    /// registrations from `crate::actions::populate`). Each
485    /// field is a `CommandId` resolving to an `ActionSpec` whose
486    /// `apply` returns `Effect::AppAction(AppEffect::Foo)`. Per-
487    /// mode keymap modules consume this alongside `builtins` to
488    /// build typed `CommandInvocation`s for chord bindings (slice
489    /// 8.i; see `docs/dev/notes/8i-approach.md`).
490    // Phase 5.B.15: `action_ids` moved to
491    // `editor.editor.action_ids`.
492    /// Layered keymap registry (DESIGN.md §5.2.3, audit slice 8.c).
493    /// Populated at construction time; the input dispatcher reads
494    /// from it on every keystroke. Wait-free reads via the
495    /// internal `ArcSwap`; concurrent registration writes (mode
496    /// push/pop, plugin registration, `:bind`) never stall the
497    /// input path. Slices 8.d / 8.e / 8.f wire Replace, Visual,
498    /// and Insert through this; Normal follows in 8.g.
499    // Phase 5.B.15: `keymap` moved to `editor.editor.keymap`.
500    /// `LayerId` of the active completion-popup minor-mode
501    /// layer when the popup is open; `None` otherwise. Pushed /
502    /// popped by [`Self::sync_keymap_overlays`] in lockstep with
503    /// `self.editor.insert_completion`. Slice 8.f.
504    // Phase 5.B.15: `completion_popup_layer` moved to
505    // `editor.editor.completion_popup_layer`.
506    /// `LayerId` of the active-snippet minor-mode layer when a
507    /// snippet is in flight; `None` otherwise. Same lockstep
508    /// pattern as [`Self::completion_popup_layer`].
509    // Phase 5.B.15: `snippet_layer` moved to
510    // `editor.editor.snippet_layer`.
511    // Phase 5.B.11: `command_line`, `last_message`,
512    // `messages`, `pending_message_event_rx`, `pending_redraw`
513    // moved to `editor.{command_line, last_message, messages,
514    // pending_message_event_rx, pending_redraw}`.
515    /// Per-document tree-sitter state. `None` when the document's language
516    /// is `Plain` (no grammar bundled).
517    ///
518    /// Audit slice 3: this is now an async handle. Reparses run
519    /// on a worker task (`tokio::task::spawn_blocking`) so the
520    /// UI thread never parses; reads against the latest snapshot
521    /// are wait-free via `ArcSwap`. The `Syntax` struct itself
522    /// stays accessible for one-shot users (help-buffer
523    /// markdown highlighting).
524    // Phase 5.B.12: `syntax`, `last_parsed_text_version`,
525    // `pending_syntax_edits`, `last_synced_syntax_version`,
526    // `visible_highlights` moved to `editor.{syntax,
527    // last_parsed_text_version, pending_syntax_edits,
528    // last_synced_syntax_version, visible_highlights}`.
529    /// Slice B.3: cache key validating the contents of
530    /// `visible_highlights`. When `refresh_highlights` finds the
531    /// freshly-computed key matches this stored key, the
532    /// existing `visible_highlights` is still valid and the
533    /// `highlight_lines` call is skipped entirely (~178µs at
534    /// 24-line viewport on rust per BENCHMARKS.md →
535    /// noise-floor on cache hit).
536    ///
537    /// Invariant: `Some` ⟹ `visible_highlights` was computed
538    /// against this key's snapshot+state. `None` after construction,
539    /// after the syntax handle is replaced, and after a state-
540    /// change render where the key check passes the recompute
541    /// branch (the new key is stored).
542    ///
543    /// Steady-state hit rate: ~100% (cursor blinking, no edit).
544    /// Drops cleanly to 0% during edits/scroll/fold-toggle.
545    // 5.B C5 cleanup: `visible_highlights_key` moved to
546    // `editor.visible_highlights_key`.
547    // Phase 5.B.7: `search_line`, `last_search`,
548    // `current_match`, `all_matches`, `substitute_preview`
549    // moved to `editor.search_line` / `editor.last_search`
550    // / `editor.current_match` / `editor.all_matches` /
551    // `editor.substitute_preview`.
552    // Phase 5.B.5: `unnamed_register` moved to
553    // `editor.unnamed_register`.
554    // Phase 5.B.8: `pending_count`, `op_count`,
555    // `visual_anchor`, `last_change`, `last_visual` moved
556    // to `editor.{pending_count, op_count, visual_anchor,
557    // last_change, last_visual}`.
558    // Phase 5.B.5: `marks` moved to `editor.marks`.
559    // Phase 5.B.9: `replace_history` moved to
560    // `editor.replace_history`.
561    // Phase 5.B.5: `registers` and `pending_register` moved
562    // to `editor.registers` / `editor.pending_register`.
563    /// Unified position-history ring (§5.1.1). Every entry is tagged
564    /// by source, so different keybindings can iterate filtered views
565    /// of the same data:
566    ///
567    /// - `Ctrl-O` / `Ctrl-I` (Tab) walk `AutoJump` and `PluginPush`.
568    /// - `g;` / `g,` walk `NamedMark`.
569    ///
570    /// Pushed before "big jumps" (gg, G, search submit, n / N, *, #,
571    /// %, mark jumps) with `AutoJump`, plus on every `mX` with
572    /// `NamedMark(X)`. The cursor sits at one past the last navigated
573    // Phase 5.B.6: `position_history`,
574    // `position_history_cursor`, `recent_files`, `tag_stack`,
575    // `pending_tag_origin` moved to
576    // `editor.position_history` / `editor.position_history_cursor`
577    // / `editor.recent_files` / `editor.tag_stack` /
578    // `editor.pending_tag_origin`.
579    // Phase 5.B.4: `macros`, `macro_recording`,
580    // `last_played_macro` moved to `editor.macros` /
581    // `editor.macro_recording` / `editor.last_played_macro`.
582    // Phase 5.B.8: `last_find` moved to `editor.last_find`.
583    /// Manual folds. v1 supports non-nested folds defined by line range.
584    /// `closed=true` means the fold's interior is skipped during render.
585    // Moved to editor.folds.
586    // Phase 5.B.9: `last_insert`, `pending_block_insert`,
587    // `recording_insert` moved to `editor.{last_insert,
588    // pending_block_insert, recording_insert}`.
589    /// Shared typed-options registry (DESIGN.md §5.12). Every
590    /// option's *current value* lives in here behind an
591    /// `ArcSwap<T>`; `:set` parses against it; the customize
592    /// buffer view (post-1.0) reads + writes through the same
593    /// surface. Renderer-agnostic options self-register via
594    /// the `linkme`-aggregated `OPTION_DECLS` slice; this
595    /// renderer's own options register via the `linkme` slice in
596    /// `crate::tui_options`.
597    // Phase 5.B.14: `config` moved to `editor.editor.config`.
598    /// Hot-path read cache for the option values. Populated at
599    /// [`Self::new`] time; refreshed inside the
600    /// `Event::OptionChanged` cascade so writes through any path
601    /// (cmdline, plugins, the future customize buffer) propagate.
602    /// Accessor methods on `App` (`foldmethod()` / `tabstop()` /
603    /// `show_line_numbers()` / ...) read the cached primitive
604    /// directly (~1ns field access) instead of going through the
605    /// registry's mutex + ArcSwap + downcast (~33ns). The
606    /// renderer hits these accessors per visible line, so the
607    /// difference is measurable on the 60-line / 120-line frame
608    /// benchmarks. Single source of truth stays in
609    /// [`Self::config`]; this struct is a derived projection.
610    // Phase 5.B.14: `option_cache` moved to
611    // `editor.editor.option_cache`.
612    // M.2.0c: TUI-specific options self-register via the
613    // linkme slice. No `tui_options` field needed -- callers
614    // read directly via `config.get_typed::<UiDimInactive>()`
615    // etc. (see `sync_theme_from_config`).
616    /// Mode registry (M.1). Owns the catalogue of registered
617    /// modes; activation / deactivation routes through here.
618    /// One process-shared registry; all Documents share the
619    /// same mode definitions.
620    // Phase 5.B.14: `mode_registry` moved to
621    // `editor.editor.mode_registry`.
622    /// Phase 3: typed service map subsystems hand off to modes
623    /// so the mode's `Mode::on_activate` can pull subsystem
624    /// handles via `ctx.service::<T>()`. Populated at boot (LSP
625    /// supervisor handle, buffer-uri resolver). Read-only after
626    /// init.
627    ///
628    /// M-async.1: `Arc<ServiceRegistry>` (not bare
629    /// `ServiceRegistry`) because `ModeContext` owns its handles
630    /// by Arc clone -- the dispatcher does `services.clone()`
631    /// per activation to build the owned ctx.
632    // Phase 5.B.14: `services` moved to `editor.editor.services`.
633    /// M-async.1/2: per-`(buffer, mode)` Guard storage. Modes
634    /// return an owned [`Mode::Guard`](lattice_mode::Mode::Guard)
635    /// from `on_activate`; the dispatcher stashes it here keyed
636    /// by `(BufferId, ModeId)`. On deactivation the dispatcher
637    /// drops the Guard, firing its `Drop` impl for synchronous
638    /// cleanup (unsubscribe, restore prior option, drop
639    /// supervisor handle).
640    ///
641    /// M-async.2: wrapped in [`GuardStoreHandle`] (Arc<Mutex<>>)
642    /// because the spawned lifecycle task locks + inserts on
643    /// `on_activate` resolve from a tokio worker thread; the App
644    /// thread locks + removes on deactivate.
645    // Phase 5.B.14: `mode_guards` moved to
646    // `editor.editor.mode_guards`.
647    /// Mode-keyed pane render dispatch (M.4 follow-up). Populated
648    /// at boot; lookup walks active minors then the major to find
649    /// the per-buffer renderer + status formatter, with the
650    /// document path as the fallback when no provider matches.
651    /// Replaces the helper-side `match buffer.kind` in
652    /// `draw_pane_content` and `pane_status_label`.
653    pub pane_render_registry: crate::pane_render::PaneRenderRegistry,
654    /// Per-buffer active modes (major + minors). M.1 wired the
655    /// field on `Document` for the document buffer, but
656    /// `Document` lives behind the actor's snapshot-cache, so
657    /// for M.2.1 the App layer maintains a parallel
658    /// per-buffer map keyed by `buffers::BufferId` -- this is
659    /// the version `recompute_options_for_buffer` reads to
660    /// pull mode contributions. `Document.modes` and this map
661    /// converge in M.4 when `ActiveModes` joins
662    /// `DocumentSnapshot`.
663    // Phase 5.B.14: `active_modes` moved to
664    // `editor.editor.active_modes`.
665    /// Per-buffer mode-owned local state (M.3.2.a). Modes
666    /// populate locals via the `BufferLocal` typed-map during
667    /// `on_activate`; the App routes
668    /// `&mut BufferLocals` into the registry's activation
669    /// methods. M.3.2.b/c migrates existing per-variant data
670    /// (`SyntaxHandle`, `Vec<Fold>`, etc.) into locals owned
671    /// by their respective modes; until then this map exists
672    /// to thread through the new activation API and to back
673    /// `:describe-buffer`'s inspection (no entries until
674    /// M.3.2.b).
675    // Phase 5.B.14: `buffer_locals` moved to
676    // `editor.editor.buffer_locals`.
677    /// Per-buffer mode-resolved options cache (M.2.1, see
678    /// `mode-architecture.md` §6.3 / §9.4 — note: the doc shows
679    /// this on `Document`, but lattice-core cannot depend on
680    /// lattice-config without a dep cycle, so the cache lives
681    /// at the App layer keyed by `buffers::BufferId` (the App's
682    /// per-buffer key, not the lower-level
683    /// `lattice_protocol::BufferId`). Refreshed eagerly on mode
684    /// toggle and option write per §6.3.1. Reads via type-keyed
685    /// access against the cached snapshot are O(1).
686    // Phase 5.B.14: `resolved_options` moved to
687    // `editor.editor.resolved_options`.
688    /// Buffer-local explicit overrides (`:setlocal foo=bar`)
689    /// per buffer. Inputs to resolution; the resolver chains
690    /// these with mode contributions before writing
691    /// [`Self::resolved_options`]. Empty for buffers the user
692    /// has never run `:setlocal` against.
693    // Phase 5.B.14: `buffer_local_overrides` moved to
694    // `editor.editor.buffer_local_overrides`.
695    /// Free-form help topic registry (DESIGN.md §5.11). `:help`
696    /// reads from this; built-ins are sourced from `docs/user/*.md`
697    /// at build time. Plugins / future LSP integrations register
698    /// additional topics through the same registry.
699    // Phase 5.B.14: `help_topics` moved to
700    // `editor.editor.help_topics`.
701    /// UI styling knobs (DESIGN.md §5.6). Carries per-pane status
702    /// line colors, the inactive-pane dim overlay, separator
703    /// characters, etc. Customizable via `:set ui.*` options.
704    ///
705    /// Phase 5.3: the canonical state is [`Self::host_theme`]
706    /// (renderer-neutral). This field is the cached ratatui-typed
707    /// adapter that the TUI renderer reads on the hot path; it's
708    /// rebuilt from `host_theme` on every successful
709    /// `sync_theme_from_config`. The duplication is transitional;
710    /// when GPUI lands and the TUI cache moves off `App`,
711    /// `App.theme` collapses into `host_theme` (renamed) and each
712    /// renderer maintains its own cached view.
713    pub theme: crate::theme::Theme,
714    // Phase 5.3: renderer-neutral canonical theme. `:set ui.*`
715    // writes this; the cached TUI adapter [`Self::theme`] is
716    // rebuilt from it. Future renderers (GPUI) read from this
717    // field and maintain their own cached view -- the host owns
718    // the canonical neutral state.
719    // Phase 5.B.14: `host_theme` moved to
720    // `editor.editor.host_theme`.
721    // Per-frame snapshot of inactive panes' visible-window syntax
722    // highlights, keyed by pane index. Refreshed by
723    // [`Self::refresh_pane_highlights`] before each draw so the
724    // renderer can read via `&App`. The active pane uses the live
725    // [`Self::visible_highlights`] field instead.
726    // Phase 5.B.12: `pane_highlights` moved to
727    // `editor.editor.pane_highlights`.
728    // Phase 5.B.11: `command_history`,
729    // `command_history_cursor`, `command_history_pending`
730    // moved to `editor.{command_history,
731    // command_history_cursor, command_history_pending}`.
732    // Active popup overlay's buffer id (DESIGN.md §5.11; M.4).
733    // `Some(id)` while a `:describe-*` / `:apropos` / hover / etc.
734    // popup is open; the actual buffer lives in
735    // [`Self::buffers`] (the unified registry) with
736    // `BufferFlags { listed: false, hidden: true }`. Resolve the
737    // concrete handle through [`Self::popup_help`] /
738    // [`Self::popup_help_mut`] -- the slot itself is just a
739    // reference into the registry. Display strategy is governed
740    // by [`lattice_core::ui::display::BufferDisplayCategory`];
741    // callers route through the private `display_buffer` helper.
742    // Phase 5.B.10: `popup_buffer` moved to
743    // `editor.popup_buffer`.
744    // Phase 5.B.20: `popup_back_stack` moved to
745    // `editor.popup_back_stack`.
746    // Phase 5.B.10: `prev_pane_for_popup` moved to
747    // `editor.prev_pane_for_popup`.
748    // Phase 5.B.10: `popup_placement` moved to
749    // `editor.popup_placement`.
750    // Phase 5.B.19: `pending_insert_completion_async_rx/token`,
751    // `pending_completion_resolve_rx/token` moved to `editor.*`.
752    // Phase 5.B.20: completion cluster tail moved to `editor.*`:
753    // `insert_completion`, `snippet_registry`,
754    // `insert_completion_snippet_meta`, `completion_accept_freq`,
755    // `pending_config_structural_sections`, `per_language_completion`,
756    // `completion_in_path_context`, `active_snippet`, `snippet_dirs`.
757    // Active vertico-style picker (DESIGN.md §5.9.7, §5.9.10).
758    // `Some` while a picker is open over a buffer / LSP instance
759    // / future generator. Input routes here in
760    // [`crate::input::translate`] before falling through to the
761    // modal handlers; render takes precedence over completion +
762    // hover popups.
763    // Phase 5.B.13: `picker` moved to `editor.editor.picker`.
764    // Metadata registry of every picker source the
765    // `:picker <source>` ex-command can dispatch to. Populated
766    // at boot with first-party source specs; feature crates
767    // (LSP, snippets) add their sources through dedicated
768    // `register_picker_sources` entry points. The plugin host
769    // will feed WIT-imported sources into the same registry
770    // (Phase 7+). See `docs/dev/architecture/picker.md`.
771    //
772    // Held as `Arc<...>` so the `gen:picker-sources` completion
773    // generator can capture a `Weak<PickerRegistry>` and walk
774    // the source list on every keystroke without keeping the
775    // registry alive past the App. Mirror of the
776    // `Arc<ModeRegistry>` pattern used by `gen:modes`.
777    // Phase 5.B.13: `picker_registry` moved to
778    // `editor.editor.picker_registry`.
779    // Picker MRU index. Loaded from
780    // `$XDG_CACHE_HOME/lattice/picker-mru.bincode` at boot
781    // (best-effort -- corruption / version-mismatch silently
782    // discards and starts fresh per `docs/dev/architecture/picker.md`
783    // § 9.3). `open_picker` reads via `frecency_bonus` on
784    // each candidate; `do_picker_accept` records on the
785    // chosen row and best-effort persists. Single-threaded
786    // host today; if plugin sources ever need shared access
787    // this becomes `Arc<RwLock<...>>` -- the trait surface
788    // already supports that move.
789    // Phase 5.B.13: `picker_mru` moved to
790    // `editor.editor.picker_mru`.
791    // Resolved persistence path for the MRU cache.
792    // `Some(path)` when `dirs::cache_dir()` returned a usable
793    // path at boot; `None` disables persistence (in-memory
794    // only -- sandboxed runs, headless test fixtures).
795    // Phase 5.B.13: `picker_mru_path` moved to
796    // `editor.editor.picker_mru_path`.
797    // `Some` while an async picker source's `init` future is
798    // in-flight. Holds the spawned-task channel handle + the
799    // generator + the source id; the main loop's
800    // `drain_pending_picker_init` pumps the channel and seats
801    // the picker once results arrive.
802    //
803    // A second `:picker <source>` invocation while one is
804    // pending cancels the predecessor (`cancel.cancel()`) and
805    // replaces this slot -- vim-style "do what I last said."
806    // Phase 5.B.13: `pending_picker_init` moved to
807    // `editor.editor.pending_picker_init`.
808    // `Some` while a live picker is open
809    // ([`PickerSourceSpec::live`] == true). Holds the
810    // generator + debounce deadline + any in-flight
811    // `on_query_changed` task. The main loop's
812    // `drain_pending_live_picker_query` fires the source's
813    // re-fetch when the debounce expires and seats the new
814    // batch when the future resolves.
815    // Phase 5.B.13: `live_picker_query` moved to
816    // `editor.editor.live_picker_query`.
817    // PI.5: the `previewing` flag is gone — picker preview is now an
818    // isolated projection (`editor.editor.preview_overrides`) that never
819    // activates a buffer, so no activate-path gating flag is needed.
820    // Receiver for [`lattice_lsp::LspLogPushed`] events (Phase
821    // 4; M.5.3.b moved the event type from `lattice-protocol`'s
822    // central enum to `lattice-lsp::events`). Drained once per
823    // main-loop tick by [`Self::drain_lsp_log_events`];
824    // matching log buffers in `BufferRegistry` are rebuilt from
825    // the logger snapshot so `*lsp*` / `*lsp:<server>*` /
826    // `*lsp:<server>:trace*` views update live without the
827    // user having to reopen them.
828    // 5.B.18b: `lsp_log_event_rx`, `lsp_progress_event_rx`
829    // moved to `editor.{lsp_log_event_rx,
830    // lsp_progress_event_rx}`.
831    // Phase 5.B.19: `pending_lsp_detach_rx`, `pending_mode_lifecycle_rx`,
832    // `pending_inlay_hint_refresh_rx`, `pending_semantic_tokens_refresh_rx`
833    // moved to `editor.*`.
834    // Accumulated `$/progress` state keyed by
835    // (server_id, token). `Begin` inserts; `Report` updates;
836    // `End` removes. The modeline picks the most recent
837    // active entry to surface.
838    // Phase 5.B.17: `lsp_progress` moved to
839    // `editor.lsp_progress`.
840    // 4.4.e: cached `textDocument/selectionRange` chain for
841    // the smart-expansion operator. Index 0 is the innermost
842    // range (closest to the cursor); each subsequent entry is
843    // one `parent` step outward. `cursor` and `buffer` are the
844    // anchor that captured this chain; we invalidate the cache
845    // when the cursor moves outside the innermost range or
846    // the active buffer changes.
847    // Phase 5.B.17: `lsp_selection_chain` moved to
848    // `editor.lsp_selection_chain`.
849    // 4.4.e: current step inside `lsp_selection_chain.ranges`.
850    // `0` = innermost; `chain.ranges.len() - 1` = outermost.
851    // Phase 5.B.17: `lsp_selection_chain_index` moved to
852    // `editor.lsp_selection_chain_index`.
853    // Phase 5.B.19: `pending_selection_range_rx/token` moved to `editor.*`.
854    // 4.4.e: cached `textDocument/documentHighlight` for the
855    // active buffer + symbol position. Refreshed by the
856    // per-tick pump when the cursor moves to a different
857    // (line, byte). Renderer reads this to paint the soft
858    // overlay across same-symbol occurrences.
859    // Phase 5.B.17: `lsp_document_highlights` moved to
860    // `editor.lsp_document_highlights`.
861    // 4.4.e: cursor position at which the most recent
862    // `documentHighlight` request was issued. Used by the
863    // pump to decide whether to re-issue (cursor moved) vs.
864    // reuse the cache. Distinct from `cache.cursor` because
865    // the in-flight request may not have landed yet.
866    // Phase 5.B.17: `last_document_highlight_issue_cursor`
867    // moved to
868    // `editor.last_document_highlight_issue_cursor`.
869    // Phase 5.B.19: `pending_document_highlight_token/rx` moved to `editor.*`.
870    // 4.4.f: per-buffer cache of the last `textDocument/foldingRange`
871    // response. Keyed by `BufferId` because the foldmethod is a
872    // per-buffer setting; multiple open buffers can each track
873    // their own LSP fold list. Invalidated by the pump when
874    // the document version bumps.
875    // Phase 5.B.17: `lsp_folds_cache` moved to
876    // `editor.lsp_folds_cache`.
877    // Phase 5.B.19: `pending_folding_range_token/rx` moved to `editor.*`.
878    // 4.4.g: per-buffer `inlayHint` cache. Refilled by the
879    // per-tick pump when the document version changes; the
880    // renderer overlay splices each hint as virtual text.
881    // Phase 5.B.17: `lsp_inlay_hints_cache` moved to
882    // `editor.lsp_inlay_hints_cache`.
883    // 4.5.c: per-buffer `documentLink` cache. Refilled by
884    // the per-tick pump on document-version change; consumed
885    // by `gx` (Normal-mode keystroke) -- the first link whose
886    // range covers the cursor wins. The renderer overlay
887    // (underline link ranges) is queued; today the cache only
888    // drives navigation, not visuals.
889    // Phase 5.B.17: `lsp_document_links_cache` moved to
890    // `editor.lsp_document_links_cache`.
891    // Phase 5.B.19: `pending_document_links_token/rx` moved to `editor.*`.
892    // 4.5.d: per-buffer code-lens cache. Refilled by the
893    // per-tick pump on document-version change; consumed by
894    // `:lsp-code-lens` (opens a picker over the cached
895    // lenses). Cleared via `workspace/codeLens/refresh` so
896    // servers that recompute lenses out-of-band (test runs,
897    // debug session start) can force a refetch.
898    // Phase 5.B.17: `lsp_code_lens_cache` moved to
899    // `editor.lsp_code_lens_cache`.
900    // Phase 5.B.19: `pending_code_lens_{token,rx,refresh_rx,items,server}`,
901    // `pending_document_color_{token,rx}`, `pending_color_{presentations,range}`,
902    // `pending_inlay_hint_{token,rx}` moved to `editor.*`.
903    // 4.4.h: per-buffer semantic-tokens cache. Refilled when
904    // the document version changes; the renderer overlay
905    // repaints span ranges that fall under a token with a
906    // kind-driven style.
907    // Phase 5.B.17: `lsp_semantic_tokens_cache` moved to
908    // `editor.lsp_semantic_tokens_cache`.
909    // Phase 5.B.19: `pending_semantic_tokens_{token,rx}` moved to `editor.*`.
910    // 4.4.j: per-buffer pull-diagnostics cache. Keys the
911    // last `result_id` the server issued so the next pull
912    // can be answered as `Unchanged` cheaply. The pump
913    // re-issues on document-version change OR cache miss
914    // (e.g. after `workspace/diagnostic/refresh` evicts).
915    // Phase 5.B.17: `lsp_pull_diagnostics_cache` moved to
916    // `editor.lsp_pull_diagnostics_cache`.
917    // Phase 5.B.19: `pending_pull_diagnostics_{token,rx}`,
918    // `pending_diagnostic_refresh_rx` moved to `editor.*`.
919    // 4.4.f: stash for `lsp-folding-mode` activation moved
920    // into `BufferLocals` (owned by the mode via the
921    // `PriorFoldmethod` typed local in
922    // `lattice_lsp::folding_sync`). Modes own their own
923    // lifecycle state; the App is just the orchestrator.
924    // `completion.auto_insert_single` lives on the typed-options
925    // registry (`self.editor.config` type-keyed by
926    // `lattice_config::CompletionAutoInsertSingle`). Read via
927    // [`Self::completion_auto_insert_single`].
928    // Phase 5.B.11: `auto_submit_after_chord` moved to
929    // `editor.editor.auto_submit_after_chord`.
930    // LSP subsystem handle (DESIGN.md §5.4, Phase 4.1.h +
931    // audit slice 1). Reads (`servers_for`, `running_actors`,
932    // `configs`, ...) are wait-free against an
933    // `ArcSwap<SupervisorSnapshot>`; writes (`open_buffer`,
934    // `attach_handle`, `close_buffer`, `flush*`, `shutdown`)
935    // route through the supervisor task's mailbox. The UI
936    // thread can call any read on the keystroke / render path
937    // without ever blocking, and the previous
938    // `Arc<tokio::sync::Mutex<LspSupervisor>>` -- which
939    // silently dropped work via `try_lock` whenever an async
940    // `:e <path>` held the mutex across the LSP `initialize`
941    // handshake -- is gone.
942    // 5.B.18b: `lsp`, `lsp_diagnostics`, `lsp_logger`
943    // moved to `editor.{lsp, lsp_diagnostics, lsp_logger}`.
944    // 5.8.AA.o: `lsp_file_watcher` moved to `editor.lsp_file_watcher`
945    // so the GPUI peer reaches the same watcher path via
946    // `Editor::refresh_lsp_file_watcher` + `drain_lsp_fs_events`
947    // (called from `run_tick_pending`).
948    // 5.B.18b: `pending_apply_edit_rx`,
949    // `pending_show_message_request_rx` moved to
950    // `editor.{pending_apply_edit_rx, pending_show_message_request_rx}`.
951    // BC.8b/BC.8c: `pending_configuration_rx` + `pending_show_document_rx`
952    // are gone entirely — those buses are now the generic `InboundBus`,
953    // drained per-tick through mode-owned handlers (`boot.inbound`).
954    // 5.B.18b: `lsp_pending_show_message_requests` moved to
955    // `editor.lsp_pending_show_message_requests`.
956    // 5.B.18b: `lsp_show_message_request_queue`,
957    // `lsp_next_show_message_request_id` moved to
958    // `editor.{lsp_show_message_request_queue,
959    // lsp_next_show_message_request_id}`.
960    // 5.B.18b: `lsp_config_tree` moved to
961    // `editor.lsp_config_tree`.
962    // 5.B.18b: `buffer_uris` moved to `editor.buffer_uris`.
963}
964
965/// One open completion popup (DESIGN.md §5.11.3 vertico-style
966/// rendering). Built by `Action::CommandLineCompleteOrAdvance`
967/// when the user presses Tab; consumed by accept / dismiss / scroll
968/// actions.
969// `Fold` + `FoldMethod` moved to `lattice_core::folding` for
970// renderer-agnostic ownership. Both re-exported through
971// `lattice_core`'s crate root + this re-export so existing
972// `crate::app::Fold` / `crate::app::FoldMethod` call sites
973// keep resolving unchanged.
974pub use lattice_core::{Fold, FoldMethod};
975
976/// CSM.8b: `LspCompletionMeta` + `LSP_COMPLETION_KIND_ID`
977/// moved into `lattice-lsp::completion` so the type lives in
978/// the crate that owns lsp-types. The host imports them via
979/// this re-export -- candidate payloads carry the serde-
980/// encoded form directly (`encode_meta` / `decode_meta`) so
981/// the parallel `App.insert_completion_lsp_meta` sidecar is
982/// gone; the candidate IS the metadata.
983pub use lattice_lsp::completion::{
984    LSP_COMPLETION_KIND_ID, LspCompletionMeta, decode_meta, encode_meta,
985};
986/// `Extension::kind_id` discriminant for snippet-sourced
987/// candidates (Phase 4.2.g.4). Sidecar metadata lives in
988/// `App.insert_completion_snippet_meta`.
989pub const SNIPPET_COMPLETION_KIND_ID: u32 = 2;
990
991/// Sidecar metadata for snippet candidates in the popup. The
992/// host renders the snippet body on accept and starts an
993/// `ActiveSnippet`; this struct carries the parsed body +
994/// the display fields the popup row uses.
995///
996/// Phase 5.B.20: definition moved to
997/// [`lattice_host::state::SnippetCandidateMeta`] alongside the
998/// `editor.insert_completion_snippet_meta` field that owns it.
999/// Re-exported here so existing callers keep compiling.
1000pub use lattice_host::state::SnippetCandidateMeta;
1001
1002// Phase 5.2: OptionCache, LastFind, MacroRecording, TagStackEntry,
1003// PositionEntry, PositionSource, ReplaceEntry, LastVisual,
1004// SubstitutePreview, PendingBlockInsert moved to lattice_host::state.
1005pub use lattice_host::state::{
1006    LastFind, LastVisual, MacroRecording, OptionCache, PendingBlockInsert, PositionEntry,
1007    PositionSource, ReplaceEntry, SubstitutePreview, TagStackEntry,
1008};
1009
1010impl std::fmt::Debug for App {
1011    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1012        // Slice 3c.final.E.5e: Debug reads route through RS
1013        // accessors + `read_editor` so post-swap the Editor lives
1014        // off-thread. `last_message` is cloned (`EchoMessage: Clone`).
1015        let ad = self.ad();
1016        let rs = self.render_state.load();
1017        // Slice 3c.final.X.cleanup: read from the published
1018        // `MessagesRenderState.last` (slice B.7) instead of routing
1019        // through the actor mailbox just to print Debug output.
1020        let last_message = self.messages().last.as_deref().cloned();
1021        f.debug_struct("App")
1022            .field("cursor", &self.cursor())
1023            .field("scroll", &ad.scroll)
1024            .field("should_quit", &rs.lifecycle.should_quit)
1025            .field("viewport_height", &ad.viewport_height)
1026            .field("modal", &ad.modal)
1027            .field("command_line", &self.command_line())
1028            .field("last_message", &last_message)
1029            .field("dirty", &ad.snapshot.dirty)
1030            .finish()
1031    }
1032}
1033
1034impl App {
1035    /// Phase 5.8.AF.5 / Slice 3c.2: wait-free snapshot of the
1036    /// active-buffer hot-path render state. Returns an `Arc`
1037    /// clone of `RenderState.active_document`. Used by renderer
1038    /// code that today reads `app.editor.cursor` / `.scroll` /
1039    /// `.modal` / etc. -- those reads migrate to
1040    /// `app.ad().cursor` (etc.) during 3c.2 / 3c.3. After 3c.5
1041    /// severs the `Arc<Editor>` reference, the method body
1042    /// flips to read from a renderer-owned `render_state`
1043    /// field; call sites stay unchanged.
1044    pub fn ad(&self) -> std::sync::Arc<lattice_host::render_state::ActiveDocumentRenderState> {
1045        // Per-frame pin: when the renderer has pinned a snapshot
1046        // (between pin/unpin calls), return it so all ad() call
1047        // sites within one frame draw see the same cursor, scroll,
1048        // options, and modal state — regardless of intermediate
1049        // actor publications. Without the pin, each independent
1050        // ad() loads a fresh Arc from the shared ArcSwap and can
1051        // observe a different snapshot, causing the cursorline
1052        // highlight and cursor blink position to disagree.
1053        if let Some(pinned) = self.frame_ad.lock().unwrap().as_ref() {
1054            return pinned.clone();
1055        }
1056        // Fall through to the live ArcSwap when no pin is active
1057        // (e.g. during non-render reads).
1058        self.render_state.load().active_document.load_full()
1059    }
1060
1061    /// Pin the current `ActiveDocumentRenderState` snapshot so every
1062    /// [`ad()`](Self::ad) call until [`unpin_render_state`](Self::unpin_render_state)
1063    /// returns the same `Arc`. Call at the start of each frame render
1064    /// (before `terminal.draw()` in the TUI, before the GPUI render).
1065    pub fn pin_render_state(&self) {
1066        let snap = self.render_state.load().active_document.load_full();
1067        *self.frame_ad.lock().unwrap() = Some(snap);
1068    }
1069
1070    /// Release the per-frame pin so the next [`ad()`](Self::ad) call
1071    /// loads a fresh snapshot from the actor. Call after each frame
1072    /// render completes.
1073    pub fn unpin_render_state(&self) {
1074        *self.frame_ad.lock().unwrap() = None;
1075    }
1076
1077    /// Phase 5.8.AF.5 / Slice 3c.final.B (group 1): wait-free
1078    /// snapshot of the pane-tree sub-state. Renderer code reads
1079    /// `app.panes().tree.X()` instead of `app.editor.pane_tree.X()`.
1080    /// Body matches `ad()` — one Arc clone off the same
1081    /// `Arc<ArcSwap<RenderState>>` cell.
1082    pub fn panes(&self) -> std::sync::Arc<lattice_host::render_state::PanesRenderState> {
1083        self.render_state.load().panes.clone()
1084    }
1085
1086    /// Phase 5.8.AF.5 / Slice 3c.final.B (group 1): wait-free
1087    /// snapshot of the buffer-registry sub-state. Renderer code
1088    /// reads `app.buffers().registry.X()` and
1089    /// `app.buffers().uris.get(&id)` instead of reaching through
1090    /// `app.editor.buffers.X()` / `app.editor.buffer_uris.get(...)`.
1091    pub fn buffers(&self) -> std::sync::Arc<lattice_host::render_state::BuffersRenderState> {
1092        self.render_state.load().buffers.clone()
1093    }
1094
1095    /// Phase 5.8.AF.5 / Slice 3c.final.B (group 3): wait-free
1096    /// snapshot of the picker sub-state. Renderer code reads
1097    /// `app.picker_state().state.as_deref()` instead of
1098    /// `app.editor.picker.as_ref()`. Named to avoid colliding with
1099    /// the legacy `app.editor.picker` field name.
1100    pub fn picker_state(&self) -> std::sync::Arc<lattice_host::render_state::PickerRenderState> {
1101        self.render_state.load().picker.clone()
1102    }
1103
1104    /// Phase 5.8.AF.5 / Slice 3c.final.B (group 3): wait-free
1105    /// snapshot of the completion sub-state. Carries both the
1106    /// in-buffer ghost popup (`insert`) and the cmdline
1107    /// completion popup (`state`).
1108    pub fn completion(&self) -> std::sync::Arc<lattice_host::render_state::CompletionRenderState> {
1109        self.render_state.load().completion.clone()
1110    }
1111
1112    /// Phase 5.8.AF.5 / Slice 3c.final.B (group 3): wait-free
1113    /// snapshot of the popup sub-state. Renderer code reads
1114    /// `app.popup().is_open()` / `.placement` / `.buffer_id`
1115    /// instead of `app.editor.popup_buffer.is_some()` etc.
1116    pub fn popup(&self) -> std::sync::Arc<lattice_host::render_state::PopupRenderState> {
1117        self.render_state.load().popup.clone()
1118    }
1119
1120    /// WK.12: the minibuffer band's published sub-state. Separate from
1121    /// [`Self::popup`] because both can be open at once — that independence is
1122    /// the point of the band.
1123    pub fn band(&self) -> std::sync::Arc<lattice_host::render_state::PopupRenderState> {
1124        self.render_state.load().band.clone()
1125    }
1126
1127    /// Slice 3c.final.B.7: published echo-area state. Replaces
1128    /// `read_editor(|e| e.last_message.clone())` in render.rs.
1129    pub fn messages(&self) -> std::sync::Arc<lattice_host::render_state::MessagesRenderState> {
1130        self.render_state.load().messages.clone()
1131    }
1132
1133    /// Slice 3c.final.B.7: published modeline + cmdline + search
1134    /// state. Replaces the per-frame `read_editor` calls for
1135    /// `command_line`, `auto_submit_after_chord`, `search_line`.
1136    pub fn modeline(&self) -> std::sync::Arc<lattice_host::render_state::ModelineRenderState> {
1137        self.render_state.load().modeline.clone()
1138    }
1139
1140    /// Slice 3c.final.B.10: published typed-options registry.
1141    /// Replaces `read_editor(|e| e.config.get_typed::<X>())` —
1142    /// caller does `app.options().config.get_typed::<X>()`.
1143    pub fn options(&self) -> std::sync::Arc<lattice_host::render_state::OptionsRenderState> {
1144        self.render_state.load().options.clone()
1145    }
1146
1147    /// Slice 3c.final.B.11: published active-modes map.
1148    /// `app.modes().map.get(&buf)` replaces
1149    /// `read_editor(|e| e.active_modes.get(&buf).cloned())`.
1150    pub fn modes(&self) -> std::sync::Arc<lattice_host::render_state::ModesRenderState> {
1151        self.render_state.load().modes.clone()
1152    }
1153
1154    /// Slice 3c.final.B.9: published buffer-locals map.
1155    /// `app.buffer_locals().map.get(&buf).and_then(|l| l.get::<T>())`
1156    /// replaces the equivalent `read_editor` chain.
1157    pub fn buffer_locals(
1158        &self,
1159    ) -> std::sync::Arc<lattice_host::render_state::BufferLocalsRenderState> {
1160        self.render_state.load().buffer_locals.clone()
1161    }
1162
1163    /// Phase 5.8.AF.5 / Slice 3c.final.E.2: routing helper for
1164    /// editor mutations. Pre-swap: runs `f` against the in-process
1165    /// `Editor` and publishes RenderState. Post-swap (when Editor
1166    /// moves to the actor thread): the body delegates to
1167    /// `self.editor_actor.mutate_blocking(Box::new(f))`. The
1168    /// `Send + 'static` bounds are forward-compatible — every
1169    /// caller already passes a closure that satisfies them today,
1170    /// so the swap is a one-line change in this method's body.
1171    /// Used to migrate `self.editor.X(args)` method call sites
1172    /// across the renderer App.
1173    pub fn mutate_editor<F>(&mut self, f: F)
1174    where
1175        F: FnOnce(&mut lattice_host::editor::Editor) + Send + 'static,
1176    {
1177        crate::actor_call_counter::bump();
1178        #[cfg(not(test))]
1179        {
1180            self.editor_actor
1181                .mutate_blocking(Box::new(f))
1182                .expect("editor actor alive");
1183        }
1184        #[cfg(test)]
1185        {
1186            f(&mut self.editor);
1187            self.editor.publish_render_state();
1188        }
1189    }
1190
1191    /// 2026-05-27: per-pane geometry hand-off. Mirrors the GPUI
1192    /// peer's `set_pane_viewport`. The host writes onto
1193    /// `PaneState[idx]`; for terminal-kind panes the
1194    /// `SetPaneViewport` handler also resizes alacritty + PTY so
1195    /// the shell wraps its output to the new column count.
1196    /// Without this, TUI terminal panes never reflowed on split /
1197    /// resize and content overflowed past the visible area.
1198    pub fn set_pane_viewport(&mut self, idx: usize, rows: u32, cols: u32) {
1199        #[cfg(not(test))]
1200        {
1201            let _ = self.editor_actor.set_pane_viewport(idx, rows, cols);
1202        }
1203        #[cfg(test)]
1204        {
1205            let active_idx = self.editor.pane_tree.active_index();
1206            let leaves = self.editor.pane_tree.leaves_mut();
1207            if idx < leaves.len() {
1208                leaves[idx].viewport_height = rows.max(1);
1209                leaves[idx].viewport_width = cols.max(1);
1210            }
1211            if idx == active_idx {
1212                self.editor.viewport_height = rows.max(1);
1213                self.editor.ensure_cursor_visible();
1214            }
1215            self.editor.publish_render_state();
1216        }
1217    }
1218
1219    /// PU.1b-3: floating-popup geometry hand-off. The renderer is the
1220    /// single authority on the popup's inner rect (it computes it from
1221    /// the buffer area + `popup_outer_size` + placement), so it pushes
1222    /// the resolved inner `(rows, cols)` to the host each frame —
1223    /// exactly mirroring [`Self::set_pane_viewport`] for real panes.
1224    /// `build_cells_panes` reads `popup_viewport_{height,width}` to size
1225    /// the synthetic popup-pane `DisplayMatrix`. Diff-then-send in the
1226    /// runtime loop keeps steady-state cost at zero.
1227    pub fn set_popup_viewport(&mut self, rows: u32, cols: u32) {
1228        self.mutate_editor(move |e| {
1229            e.popup_viewport_height = rows.max(1);
1230            e.popup_viewport_width = cols.max(1);
1231        });
1232    }
1233
1234    /// WK.12: the band's inner-geometry hand-off, the peer of
1235    /// [`Self::set_popup_viewport`] for `PaneId::MINIBUFFER_BAND`. Without it
1236    /// `build_cells_panes` never sizes the band's matrix and the band paints
1237    /// nothing.
1238    pub fn set_band_viewport(&mut self, rows: u32, cols: u32) {
1239        self.mutate_editor(move |e| {
1240            e.band_viewport_height = rows.max(1);
1241            e.band_viewport_width = cols.max(1);
1242        });
1243    }
1244
1245    /// PU.5c: completion-docs side-popup inner-geometry hand-off — the peer
1246    /// of [`Self::set_popup_viewport`] for the second synthetic popup
1247    /// (`PaneId::COMPLETION_DOCS`). `build_cells_panes` reads
1248    /// `Editor::completion_docs_viewport_{height,width}` to size the docs
1249    /// `DisplayMatrix`. Diff-then-send in the runtime loop keeps churn down.
1250    pub fn set_completion_docs_viewport(&mut self, rows: u32, cols: u32) {
1251        self.mutate_editor(move |e| {
1252            e.completion_docs_viewport_height = rows.max(1);
1253            e.completion_docs_viewport_width = cols.max(1);
1254        });
1255    }
1256
1257    /// Variant of [`Self::mutate_editor`] for closures that
1258    /// return a value (typically `Vec<RendererSignal>` from
1259    /// host helpers). Same routing contract.
1260    pub fn mutate_editor_with<F, R>(&mut self, f: F) -> R
1261    where
1262        F: FnOnce(&mut lattice_host::editor::Editor) -> R + Send + 'static,
1263        R: Send + 'static,
1264    {
1265        crate::actor_call_counter::bump();
1266        #[cfg(not(test))]
1267        {
1268            self.editor_actor.mutate_blocking_with(f)
1269        }
1270        #[cfg(test)]
1271        {
1272            let r = f(&mut self.editor);
1273            self.editor.publish_render_state();
1274            r
1275        }
1276    }
1277
1278    /// Slice 3c.final.E.swap: read-side helper for `&self`
1279    /// methods that need to query editor state without mutating.
1280    /// In production it routes through the actor handle's
1281    /// `with_editor` blocking RPC; in tests it calls the closure
1282    /// directly against the in-process Editor.
1283    pub fn read_editor<F, R>(&self, f: F) -> R
1284    where
1285        F: FnOnce(&lattice_host::editor::Editor) -> R + Send + 'static,
1286        R: Send + 'static,
1287    {
1288        crate::actor_call_counter::bump();
1289        #[cfg(not(test))]
1290        {
1291            self.editor_actor.with_editor(f)
1292        }
1293        #[cfg(test)]
1294        {
1295            f(&self.editor)
1296        }
1297    }
1298
1299    /// Slice 3c.final.E.5d: hot-field accessor for the active
1300    /// document's cursor. Earns its place by call-site frequency
1301    /// (rule of three: cursor has 8+ reader sites across
1302    /// `dispatch`, `help`, `search`, `lsp`, the Debug impl). Post-
1303    /// swap routes through `editor_actor.with_editor(|e| e.cursor)`
1304    /// in one place instead of N. `Position` is `Copy` so this is
1305    /// a value return, not an Arc clone.
1306    pub(crate) fn cursor(&self) -> lattice_protocol::position::Position {
1307        // Slice 3c.final.X.cleanup: published on `ad().cursor` —
1308        // wait-free Arc-bump read. Cursor changes always publish RS
1309        // via the dispatch tail; per-keystroke readers (motions,
1310        // edit, status redraw) now skip the actor mailbox.
1311        //
1312        // cfg(test) escape hatch reads from `self.editor.cursor`
1313        // directly so tests that mutate `editor.cursor.X = Y`
1314        // without `publish_render_state()` keep working. Production
1315        // always goes through RS; tests preserve the "direct
1316        // mutation is immediately visible" convention.
1317        #[cfg(test)]
1318        {
1319            self.editor.cursor
1320        }
1321        #[cfg(not(test))]
1322        {
1323            self.ad().cursor
1324        }
1325    }
1326
1327    /// Companion writer. Routes through `mutate_editor` so the
1328    /// post-swap landing is a no-touch on call sites.
1329    pub(crate) fn set_cursor(&mut self, pos: lattice_protocol::position::Position) {
1330        self.mutate_editor(move |e| e.cursor = pos);
1331    }
1332
1333    /// Slice 3c.final.E.5d: hot-field accessor for the active
1334    /// document's buffer id. 5+ real reader sites across `lsp`,
1335    /// `boot`, `lifecycle`. `BufferId` is `Copy`.
1336    pub(crate) fn document_buffer_id(&self) -> crate::buffers::BufferId {
1337        // Slice 3c.final.X.cleanup: `BufferId` is `Copy` and mirrored
1338        // on `ad().document_buffer_id`. cfg(test) escape hatch as
1339        // per `cursor()` — see its docstring.
1340        #[cfg(test)]
1341        {
1342            self.editor.document_buffer_id
1343        }
1344        #[cfg(not(test))]
1345        {
1346            self.ad().document_buffer_id
1347        }
1348    }
1349
1350    /// Slice 3c.final.E.5d: hot-field accessor for the `:` line
1351    /// command-buffer text. 5 reader sites across `cmdline`,
1352    /// `search`, the Debug impl. Returns by value — `String`
1353    /// clone — so the closure body is `Send + 'static`.
1354    pub(crate) fn command_line(&self) -> String {
1355        // Slice 3c.final.X.cleanup: read from the published
1356        // `ModelineRenderState.cmdline_text` (slice B.7). The Arc<str>
1357        // → String conversion is one heap copy, matching the prior
1358        // `e.command_line().clone()` cost without the mailbox RPC.
1359        // cfg(test) escape hatch as per `cursor()` — see its docstring.
1360        #[cfg(test)]
1361        {
1362            self.editor.command_line().clone()
1363        }
1364        #[cfg(not(test))]
1365        {
1366            self.modeline().cmdline_text.to_string()
1367        }
1368    }
1369
1370    /// MB.2: whether the `:` line is expanded into the tier-2 band. Read
1371    /// from the published modeline state (wait-free), test escape hatch as
1372    /// per [`Self::command_line`].
1373    pub(crate) fn command_line_expanded(&self) -> bool {
1374        #[cfg(test)]
1375        {
1376            self.editor.command_line_expanded()
1377        }
1378        #[cfg(not(test))]
1379        {
1380            self.modeline().cmdline_expanded
1381        }
1382    }
1383
1384    /// MB.2e: the resolved `command-line.expand-height` policy sizing the
1385    /// expanded band. Read from the published modeline state (wait-free);
1386    /// test escape hatch reads the editor directly, as [`Self::command_line_expanded`].
1387    pub(crate) fn command_line_expand_height(&self) -> lattice_config::ExpandHeight {
1388        #[cfg(test)]
1389        {
1390            self.editor.command_line_expand_height()
1391        }
1392        #[cfg(not(test))]
1393        {
1394            self.modeline().cmdline_expand_height
1395        }
1396    }
1397
1398    /// MB.2: the full (possibly multi-line) `:` line text for the expanded
1399    /// band renderer. Empty in tier 1.
1400    pub(crate) fn command_line_full_text(&self) -> String {
1401        #[cfg(test)]
1402        {
1403            self.editor.command_line_full_text()
1404        }
1405        #[cfg(not(test))]
1406        {
1407            self.modeline().cmdline_full_text.to_string()
1408        }
1409    }
1410
1411    /// Slice 3c.final.E.5e: ExCommandRegistry accessor. Backed by
1412    /// `Arc<CommandRegistry>` on the host so the clone is one Arc
1413    /// bump, not a deep copy. 6+ sites across `cmdline` and
1414    /// `dispatch` need a `&CommandRegistry` for parser /
1415    /// completion-slot lookups; callers do
1416    /// `let reg = self.registry(); ... &reg ...` to keep the Arc
1417    /// alive across the borrow.
1418    pub(crate) fn registry(&self) -> std::sync::Arc<lattice_grammar::CommandRegistry> {
1419        // B3b: `load_full` yields an owned snapshot of the current registry
1420        // (the field is now an `ArcSwap` handle) so callers keep receiving a
1421        // plain `Arc<CommandRegistry>` and see runtime plugin registrations.
1422        self.read_editor(|e| e.registry.load_full())
1423    }
1424
1425    /// Thin renderer-side wrapper around
1426    /// [`lattice_host::editor::Editor::set_message`].
1427    ///
1428    /// 5.5.D moved the implementation into the host so renderer and
1429    /// non-renderer call sites share one code path. Existing
1430    /// `app.set_message(...)` call sites in tests / boot keep working
1431    /// without churn; the body just routes through `mutate_editor`.
1432    /// The host-side impl preserves the legacy two-step contract:
1433    /// write to the echo-area slot + push to the bounded `*messages*`
1434    /// ring + emit a typed [`lattice_runtime::MessagePushed`] event
1435    /// so per-App isolation (one bus + ring per App) survives the
1436    /// move.
1437    pub fn set_message(&mut self, level: EchoLevel, text: impl Into<String>) {
1438        // Slice 3c.final.E.5i: materialize the `impl Into<String>`
1439        // conversion eagerly outside the closure so the captured
1440        // value is `Send + 'static`. Earlier E.5c attempt left this
1441        // on direct `self.editor.X` because the trait bound on the
1442        // impl arg isn't carried into the closure; the eager
1443        // `.into()` sidesteps that.
1444        let text: String = text.into();
1445        self.mutate_editor(move |e| e.set_message(level, text));
1446    }
1447}
1448
1449pub(crate) fn line_byte_len(buf: &Buffer, line: u32) -> u32 {
1450    // §8.2 hot path: use ropey's O(log n) line API instead of
1451    // materialising the whole buffer.
1452    buf.line_byte_len(line)
1453}
1454
1455/// The last line a motion or range may address — content space.
1456///
1457/// CV.3: the third of three hand-rolled copies of this correction,
1458/// all now sitting on `Buffer::content_line_count`.
1459pub(crate) fn last_addressable_line(buf: &Buffer) -> u32 {
1460    buf.content_line_count().saturating_sub(1)
1461}
1462
1463pub(super) fn is_valid_mark_name(c: char) -> bool {
1464    c.is_ascii_alphabetic() || c.is_ascii_digit()
1465}
1466
1467// 5.5.E.2: `preview_register` moved to
1468// [`lattice_host::dispatch::preview_register`] alongside its
1469// primary caller [`Editor::do_list_registers`]. The picker source
1470// import sites now route through the host crate directly.
1471
1472pub(super) fn is_word_char_byte(b: u8) -> bool {
1473    b.is_ascii_alphanumeric() || b == b'_'
1474}
1475
1476// 5.5.G.23.insert-prep: body migrated to
1477// [`lattice_host::dispatch::dedup_rendered_by_text`]. Re-exported at
1478// the App-side super scope so 4 existing call sites
1479// (`completion.rs` x2, `lsp.rs` x1, in-file test x1) continue
1480// working unchanged.
1481pub(super) use lattice_host::dispatch::dedup_rendered_by_text;
1482
1483/// True for bytes the path-completion source treats as part of
1484/// a filename / directory component. Wider than `is_word_char_byte`
1485/// so common filename characters (`.`, `-`, `~`) ride the same
1486/// segment; the trigger anchor breaks at `/` (dir boundary)
1487/// rather than at these characters.
1488pub(super) fn is_path_byte(b: u8) -> bool {
1489    b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'~' | b'+' | b'@')
1490}
1491
1492/// Expand `~/...` against `$HOME` and absolutise a relative
1493/// path against the process's current working directory.
1494///
1495/// Two failure modes this closes:
1496///
1497/// 1. `:e ~/foo.rs` -- without expansion, `Document::open`
1498///    forwards the literal `~` to `read_to_string` and fails
1499///    ENOENT. Cmdline completion (`gen:files`) already does
1500///    its own tilde expansion for `read_dir`, which is why
1501///    completion appears to work but submit fails.
1502///
1503/// 2. Oil opened at a single-component relative path (`src`,
1504///    `foo.rs`, `.`) navigating up. `Path::parent()` returns
1505///    `Some("")` for those, and `read_dir("")` is ENOENT.
1506///    Normalising before storing keeps `OilDir` absolute, so
1507///    `parent()` walks the filesystem correctly.
1508///
1509/// We deliberately do not call `canonicalize` -- it requires
1510/// the target to exist (`:e new.rs` against an unsaved path
1511/// has to work) and would surprise users by resolving symlinks
1512/// vim treats as opaque. Cwd-failure or HOME-missing falls
1513/// through to the input verbatim; the caller's open then
1514/// produces the same error it would have produced without
1515/// the normalise step.
1516/// 5.8.AA.j: migrated to `lattice_host::dispatch::normalize_user_path`.
1517/// Wrapper retained so the existing call sites in this peer keep
1518/// resolving via `crate::app::normalize_user_path`.
1519pub(super) fn normalize_user_path(path: &std::path::Path) -> std::path::PathBuf {
1520    lattice_host::dispatch::normalize_user_path(path)
1521}
1522
1523// 5.5.G.23: `is_blank_line` migrated to
1524// `lattice_host::dispatch` (private helper colocated with
1525// `Editor::snap_cursor_past_closed_folds`). Zero remaining App
1526// callers after `snap_cursor_past_closed_folds` and `fold_start_at`
1527// retire to delegators.
1528
1529/// Phase 4.2 features (hover, definition, references, completion)
1530/// all need this; later we'll thread the per-server negotiated
1531/// `PositionEncodingKind` through here so utf-8 / utf-32 servers
1532/// don't pay the utf-16 conversion. For 4.2.b utf-16 is correct
1533/// for every server we care about today.
1534/// Render an LSP `SignatureHelp` response into a markdown body
1535// 5.5.LSP.4: `signature_help_to_markdown` relocated to
1536// `lattice_host::lsp_helpers`. No production App-side caller
1537// remains; tests still exercise it through `super::*`, so the
1538// re-export stays under `cfg(test)`.
1539#[cfg(test)]
1540pub(crate) use lattice_host::lsp_helpers::signature_help_to_markdown;
1541
1542/// Inverse of `app_to_lsp_position` -- LSP utf-16 character
1543/// column → utf-8 byte column on the given line. Used by
1544/// `apply_lsp_text_edits` to convert TextEdit ranges back to
1545/// the App's `Position` shape.
1546pub(crate) fn lsp_position_to_app_byte(buffer: &Buffer, line: u32, character: u32) -> u32 {
1547    let line_text = buffer.line(line).unwrap_or_default();
1548    lattice_lsp::position::utf16_column_to_utf8_byte(&line_text, character)
1549}
1550
1551/// 4.4.e: does the App `(line, byte)` cursor sit inside the
1552/// half-open LSP `range`? We compare only by (line, character)
1553/// in LSP utf-16 space because the range itself is LSP-shape;
1554/// the App cursor is converted on the fly.
1555pub(crate) fn cursor_inside_range(cursor: Position, range: &lattice_lsp::lsp_types::Range) -> bool {
1556    let line = cursor.line;
1557    let col = cursor.byte;
1558    let start = (range.start.line, range.start.character);
1559    let end = (range.end.line, range.end.character);
1560    let here = (line, col);
1561    here >= start && here < end
1562}
1563
1564/// 4.4.f: convert an LSP `FoldingRange` to our `Fold`. LSP
1565/// returns line-based extents; the identity hash combines
1566/// start_line + end_line + kind so closed-state survives
1567/// re-fetches that produce the same logical fold.
1568pub(crate) fn folding_range_to_fold(r: lattice_lsp::lsp_types::FoldingRange) -> Fold {
1569    use std::hash::{Hash, Hasher};
1570    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1571    r.start_line.hash(&mut hasher);
1572    r.end_line.hash(&mut hasher);
1573    // Spec omits a stable u32 for `kind`; hash the string form
1574    // when present so a fold's kind change still re-keys.
1575    if let Some(kind) = r.kind.as_ref() {
1576        std::mem::discriminant(kind).hash(&mut hasher);
1577    }
1578    Fold {
1579        start_line: r.start_line,
1580        end_line: r.end_line,
1581        closed: false,
1582        identity: Some(hasher.finish()),
1583    }
1584}
1585
1586/// 4.4.e: flatten an LSP `SelectionRange` (linked list via
1587/// `parent`) into a `Vec<Range>` ordered innermost-first.
1588pub(crate) fn flatten_selection_range_chain(
1589    head: &lattice_lsp::lsp_types::SelectionRange,
1590) -> Vec<lattice_lsp::lsp_types::Range> {
1591    let mut out = Vec::new();
1592    let mut cur = Some(head);
1593    while let Some(node) = cur {
1594        out.push(node.range);
1595        cur = node.parent.as_deref();
1596    }
1597    out
1598}
1599
1600// 5.5.LSP.1 step 2: `app_to_lsp_position` and
1601// `hover_contents_to_markdown` (below) relocated to
1602// `lattice_host::lsp_helpers`. Re-exported under their original
1603// `crate::app::*` paths so the ~21 existing call sites continue
1604// to compile unchanged through the rest of the LSP cluster
1605// migration. Once every App-side LSP request helper has migrated
1606// host-side (LSP.5), the re-exports can be retired in a follow-
1607// up sweep alongside the helpers themselves.
1608pub(crate) use lattice_host::lsp_helpers::app_to_lsp_position;
1609
1610// 5.5.LSP.5: `flatten_document_symbol_response`,
1611// `symbol_information_to_row`, and `workspace_symbol_to_row`
1612// relocated to `lattice_host::lsp_helpers`. `flatten` has test
1613// callers in `mod lsp::tests`; the other two have none, so only
1614// `flatten` gets a re-export.
1615#[cfg(test)]
1616pub(crate) use lattice_host::lsp_helpers::flatten_document_symbol_response;
1617
1618/// 4.5.a: render one `CallHierarchyItem` (the caller in an
1619/// `IncomingCall.from` or the callee in an
1620/// `OutgoingCall.to`) as a [`SymbolRow`] for the picker. The
1621/// item's `detail` (if any) plus the *originating* callable's
1622/// name ride in `container` -- e.g. for an incoming-calls
1623/// listing of `foo`, the rows say "bar.rs:42 fn quux  (in foo)".
1624/// Falls back to `(path, 0, 0)` if the URI doesn't parse;
1625/// keeping the row visible beats silently dropping it.
1626pub(crate) fn call_hierarchy_to_row(
1627    item: &lattice_lsp::lsp_types::CallHierarchyItem,
1628    related_to: &str,
1629) -> SymbolRow {
1630    let path = lattice_lsp::actor::uri_to_path(&item.uri).unwrap_or_default();
1631    SymbolRow {
1632        name: item.name.clone(),
1633        kind_glyph: symbol_kind_glyph(item.kind),
1634        container: Some(format!("in {related_to}")),
1635        depth: 0,
1636        path,
1637        line: item.selection_range.start.line,
1638        col: item.selection_range.start.character,
1639    }
1640}
1641
1642/// 4.5.c: does the given `range` cover the LSP `position`?
1643/// Inclusive on both ends (matches VSCode's "click-through"
1644/// semantics on a link's rightmost char). Used by the `gx`
1645/// keystroke to find the first cached `documentLink` under
1646/// the cursor.
1647pub(crate) fn range_covers(
1648    range: lattice_lsp::lsp_types::Range,
1649    position: lattice_lsp::lsp_types::Position,
1650) -> bool {
1651    let after_start =
1652        (range.start.line, range.start.character) <= (position.line, position.character);
1653    let before_end = (position.line, position.character) <= (range.end.line, range.end.character);
1654    after_start && before_end
1655}
1656
1657/// 4.5.b: render one `TypeHierarchyItem` (a supertype or
1658/// subtype of the cursor's type) as a [`SymbolRow`] for the
1659/// picker. Same projection as [`call_hierarchy_to_row`]
1660/// because the item shape is identical (name, kind, uri,
1661/// range, selection_range); the container hint differs
1662/// (`"super of foo"` / `"sub of foo"`).
1663pub(crate) fn type_hierarchy_to_row(
1664    item: &lattice_lsp::lsp_types::TypeHierarchyItem,
1665    related_to: &str,
1666) -> SymbolRow {
1667    let path = lattice_lsp::actor::uri_to_path(&item.uri).unwrap_or_default();
1668    SymbolRow {
1669        name: item.name.clone(),
1670        kind_glyph: symbol_kind_glyph(item.kind),
1671        container: Some(format!("of {related_to}")),
1672        depth: 0,
1673        path,
1674        line: item.selection_range.start.line,
1675        col: item.selection_range.start.character,
1676    }
1677}
1678
1679/// Extract a placeholder string from a `PrepareRenameResponse`.
1680/// The spec gives three shapes: a Range (no placeholder, just
1681/// "you can rename here"), a Range+Placeholder (preferred), or
1682/// a DefaultBehavior signal (server defers to the editor's
1683/// word-under-cursor heuristic). We pull the placeholder when
1684/// present, else `None` so the App's caller can fall back to
1685/// the heuristic.
1686pub(crate) fn prepare_rename_placeholder(
1687    resp: &lattice_lsp::lsp_types::PrepareRenameResponse,
1688) -> Option<String> {
1689    match resp {
1690        lattice_lsp::lsp_types::PrepareRenameResponse::RangeWithPlaceholder {
1691            placeholder, ..
1692        } => Some(placeholder.clone()),
1693        lattice_lsp::lsp_types::PrepareRenameResponse::Range(_) => None,
1694        lattice_lsp::lsp_types::PrepareRenameResponse::DefaultBehavior { .. } => None,
1695    }
1696}
1697
1698/// Flatten a `WorkspaceEdit` into a per-file
1699/// `Vec<(Uri, Vec<TextEdit>)>`. Handles both the legacy `changes`
1700/// HashMap shape and the modern `document_changes` shape (which
1701/// also carries DocumentChangeOperation::Op create/rename/delete
1702/// -- those are skipped in v1; rename returns plain text edits
1703/// for ~100% of identifiers).
1704///
1705/// Empty Vec means "nothing to apply" (the App echoes `Empty`).
1706pub(crate) fn flatten_workspace_edit(
1707    we: lattice_lsp::lsp_types::WorkspaceEdit,
1708) -> Vec<(
1709    lattice_lsp::lsp_types::Uri,
1710    Vec<lattice_lsp::lsp_types::TextEdit>,
1711)> {
1712    let mut out: Vec<(
1713        lattice_lsp::lsp_types::Uri,
1714        Vec<lattice_lsp::lsp_types::TextEdit>,
1715    )> = Vec::new();
1716    if let Some(changes) = we.changes {
1717        for (uri, edits) in changes {
1718            if !edits.is_empty() {
1719                out.push((uri, edits));
1720            }
1721        }
1722    }
1723    if let Some(doc_changes) = we.document_changes {
1724        match doc_changes {
1725            lattice_lsp::lsp_types::DocumentChanges::Edits(edits) => {
1726                for te_doc in edits {
1727                    let uri = te_doc.text_document.uri.clone();
1728                    let raw_edits: Vec<lattice_lsp::lsp_types::TextEdit> = te_doc
1729                        .edits
1730                        .into_iter()
1731                        .filter_map(|e| match e {
1732                            lattice_lsp::lsp_types::OneOf::Left(te) => Some(te),
1733                            // AnnotatedTextEdit -- strip the
1734                            // annotation; v1 doesn't surface
1735                            // change-annotations to the user.
1736                            lattice_lsp::lsp_types::OneOf::Right(ate) => Some(ate.text_edit),
1737                        })
1738                        .collect();
1739                    if !raw_edits.is_empty() {
1740                        out.push((uri, raw_edits));
1741                    }
1742                }
1743            }
1744            // create-file / rename-file / delete-file ops are
1745            // skipped in v1 -- the rename use case is identifier
1746            // rewrites, which servers return as plain text edits.
1747            lattice_lsp::lsp_types::DocumentChanges::Operations(_) => {}
1748        }
1749    }
1750    out
1751}
1752
1753/// Single-character glyph for an LSP `CodeActionKind`. Maps
1754/// the standard kinds (quickfix, refactor*, source*) to a
1755/// short visual marker for the picker margin. Unknown / custom
1756/// kinds and bare `Command` payloads land on `?`.
1757pub(crate) fn code_action_kind_glyph(
1758    kind: Option<&lattice_lsp::lsp_types::CodeActionKind>,
1759) -> &'static str {
1760    use lattice_lsp::lsp_types::CodeActionKind as K;
1761    let Some(kind) = kind else {
1762        return "?";
1763    };
1764    if *kind == K::QUICKFIX {
1765        "🛠"
1766    } else if *kind == K::REFACTOR {
1767        "♻"
1768    } else if *kind == K::REFACTOR_EXTRACT {
1769        "↗"
1770    } else if *kind == K::REFACTOR_INLINE {
1771        "↘"
1772    } else if *kind == K::REFACTOR_REWRITE {
1773        "↺"
1774    } else if *kind == K::SOURCE {
1775        "★"
1776    } else if *kind == K::SOURCE_ORGANIZE_IMPORTS {
1777        "≡"
1778    } else if *kind == K::SOURCE_FIX_ALL {
1779        "✓"
1780    } else {
1781        "?"
1782    }
1783}
1784
1785/// Single-character glyph for an LSP `CompletionItemKind`.
1786// 5.5.LSP.4: `completion_kind_glyph` relocated to
1787// `lattice_host::lsp_helpers`. Tests in `app/completion.rs` call
1788// it as `crate::app::completion_kind_glyph`, so the re-export
1789// stays available; no production App-side caller remains, so
1790// it's `cfg(test)`-scoped.
1791#[cfg(test)]
1792pub(crate) use lattice_host::lsp_helpers::completion_kind_glyph;
1793
1794/// Single-character glyph for an LSP `SymbolKind`. Picked to
1795/// fit a fixed-width column in picker rows so the marginalia
1796/// column stays aligned. Falls back to `?` for kinds we don't
1797/// have a specific glyph for.
1798pub(crate) fn symbol_kind_glyph(kind: lattice_lsp::lsp_types::SymbolKind) -> &'static str {
1799    use lattice_lsp::lsp_types::SymbolKind as K;
1800    match kind {
1801        K::FILE => "📄",
1802        K::MODULE | K::NAMESPACE | K::PACKAGE => "📦",
1803        K::CLASS | K::INTERFACE => "🅒",
1804        K::METHOD | K::FUNCTION => "ƒ",
1805        K::CONSTRUCTOR => "🅒",
1806        K::PROPERTY | K::FIELD => "•",
1807        K::VARIABLE => "v",
1808        K::CONSTANT => "K",
1809        K::STRING | K::NUMBER | K::BOOLEAN | K::ARRAY | K::OBJECT => "≡",
1810        K::ENUM | K::ENUM_MEMBER => "🅔",
1811        K::STRUCT => "🅢",
1812        K::EVENT => "🅔",
1813        K::OPERATOR => "⊕",
1814        K::TYPE_PARAMETER => "T",
1815        _ => "?",
1816    }
1817}
1818
1819/// Word (alphanumeric + `_` run) under `cursor` in `buffer`, or
1820/// `None` when the cursor isn't on a word byte. Mirrors vim's
1821/// `<cword>` for the simple case that `:references` needs to
1822/// label its results buffer ("References for \"foo\""). Walks
1823/// the line once at the cursor column; doesn't scan forward to
1824/// the next word like `do_search_word_under_cursor` does --
1825/// "no symbol under cursor" is preferable to a label that
1826/// jumps to a different identifier than the user pointed at.
1827pub(crate) fn word_under_cursor(buffer: &Buffer, cursor: Position) -> Option<String> {
1828    let line = buffer.line(cursor.line)?;
1829    let bytes = line.as_bytes();
1830    let byte_idx = cursor.byte as usize;
1831    if byte_idx >= bytes.len() || !is_word_char_byte(bytes[byte_idx]) {
1832        return None;
1833    }
1834    let mut start = byte_idx;
1835    while start > 0 && is_word_char_byte(bytes[start - 1]) {
1836        start -= 1;
1837    }
1838    let mut end = byte_idx;
1839    while end < bytes.len() && is_word_char_byte(bytes[end]) {
1840        end += 1;
1841    }
1842    if start == end {
1843        return None;
1844    }
1845    Some(String::from_utf8_lossy(&bytes[start..end]).into_owned())
1846}
1847
1848// 5.5.LSP.2: `definition_response_to_locations` relocated to
1849// `lattice_host::lsp_helpers`. After the nav migration no
1850// production App-side caller remains (only `mod lsp::tests`
1851// exercises it through `super::*`), so the re-export is scoped
1852// under `cfg(test)` -- same shape as `hover_contents_to_markdown`.
1853#[cfg(test)]
1854pub(crate) use lattice_host::lsp_helpers::definition_response_to_locations;
1855
1856// 5.5.LSP.1 step 2: see the re-export block above
1857// (`app_to_lsp_position`). The hover-content renderer now lives at
1858// `lattice_host::lsp_helpers::hover_contents_to_markdown`. After
1859// LSP.1's hover-request migration there are no remaining App-side
1860// production callers (only the `mod lsp::tests` block exercises
1861// it), so the re-export is `cfg(test)`-scoped to satisfy
1862// `deny(unused_imports)` in release builds.
1863#[cfg(test)]
1864pub(crate) use lattice_host::lsp_helpers::hover_contents_to_markdown;
1865
1866// 5.5.H: `previous_position` retired (its sole caller was the
1867// search-side `step_byte`, also retired). Host's
1868// `lattice_host::dispatch::previous_position` is the live copy.
1869
1870#[cfg(test)]
1871mod tests {
1872    #![allow(clippy::unwrap_used, clippy::panic)]
1873    use super::test_helpers::{
1874        app_with, attach_test_syntax, invoke_motion, submit_ex, write_temp_file,
1875    };
1876    use super::*;
1877    use crate::help::HelpContent;
1878
1879    #[test]
1880    fn normalize_user_path_expands_tilde_slash_against_home() {
1881        // `~/foo.rs` → `$HOME/foo.rs`. Mutating env vars
1882        // requires `unsafe` post-edition-2024 and the workspace
1883        // bans `unsafe_code`, so anchor the assertion against
1884        // the runner's actual HOME instead. Skip silently if
1885        // HOME isn't set -- not a meaningful environment.
1886        let Some(home) = std::env::var_os("HOME") else {
1887            return;
1888        };
1889        let got = normalize_user_path(std::path::Path::new("~/projects/foo.rs"));
1890        let expected = std::path::PathBuf::from(&home).join("projects/foo.rs");
1891        assert_eq!(got, expected);
1892    }
1893
1894    #[test]
1895    fn normalize_user_path_expands_bare_tilde_to_home() {
1896        let Some(home) = std::env::var_os("HOME") else {
1897            return;
1898        };
1899        let got = normalize_user_path(std::path::Path::new("~"));
1900        assert_eq!(got, std::path::PathBuf::from(home));
1901    }
1902
1903    #[test]
1904    fn normalize_user_path_leaves_absolute_paths_unchanged() {
1905        // Absolute paths are pass-through -- the cwd-join branch
1906        // is skipped. Critical for the oil dedupe path
1907        // (`oil_with_dir`) where two callers compare against the
1908        // same stored absolute key.
1909        // `temp_dir()`, not `/tmp/...`: drive-less is not absolute on Windows.
1910        let abs = std::env::temp_dir().join("abs").join("path.rs");
1911        assert!(abs.is_absolute());
1912        let got = normalize_user_path(&abs);
1913        assert_eq!(got, abs);
1914    }
1915
1916    #[test]
1917    fn normalize_user_path_absolutises_relative_against_cwd() {
1918        // Single-component relative paths are the case that
1919        // broke oil's `-` (parent of `"foo.rs"` is `""`, and
1920        // `read_dir("")` is ENOENT). Post-fix the input is
1921        // already absolute by the time it reaches `read_dir`.
1922        let got = normalize_user_path(std::path::Path::new("foo.rs"));
1923        let expected = std::env::current_dir().unwrap().join("foo.rs");
1924        assert_eq!(got, expected);
1925    }
1926
1927    #[test]
1928    fn normalize_user_path_preserves_dotdot_relative_against_cwd() {
1929        // `..` segments stay -- we deliberately don't
1930        // canonicalise (would require the path to exist). The
1931        // filesystem resolves `../foo.rs` correctly through
1932        // `read_to_string`.
1933        let got = normalize_user_path(std::path::Path::new("../foo.rs"));
1934        let expected = std::env::current_dir().unwrap().join("../foo.rs");
1935        assert_eq!(got, expected);
1936    }
1937
1938    // Sanity check: a bare motion drives the cursor through
1939    // the full translate + apply path. If this fails, the
1940    // harness itself is broken; every other `press_*` test
1941    // is suspect.
1942    // Sanity check: an operator + motion deletes the right
1943    // span end-to-end. Exercises the `[d]` action-kind
1944    // short-circuit (pushes `d` into `partial_chord`) plus
1945    // the `[d, w]` resolution under the prefix.
1946    // -- count flow seam ----------------------------------------
1947    //
1948    // These pin the path translate -> attach_count -> dispatcher
1949    // multiplication. Slice 8.i.4.d documented the failure mode if
1950    // the action-kind short-circuit didn't bypass
1951    // `run_document_invocation`'s `pending_count = 0;`: the reset
1952    // would fire before `AbsorbOperatorPrefix` could latch op_count,
1953    // breaking `2dw`-style flows. These tests would catch a
1954    // regression of that fix.
1955
1956    /// `3j` moves down 3 lines: pending_count fed straight to a
1957    /// motion (no operator latch path).
1958    /// `3dd` deletes 3 lines: count latches into op_count via
1959    /// the `d` action-kind dispatch; the second `d` resolves
1960    /// linewise with the count baked in by `attach_count`. Pins
1961    /// slice 8.i.4.f's removal of the dispatcher's redundant
1962    /// multiplication, AND slice 8.i.4.g's `dd`-consumes-newline
1963    /// fix (line count drops by 3 -- the buffer goes from 5
1964    /// lines to 2, with no leading empty line).
1965    /// `d2w` deletes 2 words: the `2` between operator and motion
1966    /// must reach the digit accumulator, not get eaten by the
1967    /// partial_chord lookup. Pins slice 8.i.4.f's hoist of digit
1968    /// handling above the partial_chord short-circuit.
1969    /// `2d2w` deletes 4 words: op_count=2 multiplies with
1970    /// motion_count=2. Pins both 8.i.4.f fixes end-to-end --
1971    /// digit-after-operator survives, and the dispatcher honours
1972    /// the input-side baked count without re-multiplying.
1973    /// After `3j`, a bare `j` moves only one line: pending_count
1974    /// must reset after the motion fires.
1975    // -- partial_chord state machine ----------------------------
1976    //
1977    // Pins the multi-keystroke prefix walk. `gg` is a Normal-mode
1978    // multi-key motion (no operator); `dd` is the operator
1979    // self-key linewise resolution; `df,` is a 3-keystroke chord
1980    // (operator -> find-char prefix -> captured delimiter).
1981
1982    /// `gg` jumps to the first line: prefix `g` parks in
1983    /// partial_chord, second `g` resolves the terminal.
1984    /// `df,` deletes up to (exclusive of) the comma: 3-keystroke
1985    /// chord across the operator + find-char captured-delimiter
1986    /// sub-tree (slice 8.i.4.c). Lattice's `f`-motion is exclusive
1987    /// (see `df_deletes_through_target_char` near the dispatch
1988    /// tests), so the comma stays.
1989    // -- <C-w> sub-tree (action-kind short-circuit, 8.i.4.d) ----
1990
1991    /// `<C-w>v` splits the active pane vertically. Exercises the
1992    /// `<C-w>` action-kind short-circuit + the AfterCtrlW layer.
1993    // -- mode transition seam -----------------------------------
1994
1995    /// `i` enters Insert, typed chars land in the buffer, `<Esc>`
1996    /// returns to Normal. Pins the modal state machine across a
1997    /// mode round-trip in a single keystroke stream.
1998    /// Subscribe a channel sink to the App's event bus. Returns
1999
2000    // ---- Slice B.2 part 2: edit-delta accumulation -------------
2001    //
2002    // Pin that EditDeltas accumulate on App.editor.pending_syntax_edits
2003    // across edits, drain on maybe_reparse_syntax, and the
2004    // version baseline tracks correctly. The actual incremental
2005    // reparse correctness is covered by lattice-syntax's parity
2006    // tests; these App-level tests pin the plumbing.
2007
2008    // ---- Initial state ----
2009
2010    #[test]
2011    fn new_app_starts_at_origin_in_normal_mode() {
2012        let a = app_with("abc", 10);
2013        assert_eq!(a.editor.cursor, Position::ZERO);
2014        assert_eq!(a.editor.scroll, 0);
2015        assert!(!a.editor.should_quit);
2016        assert_eq!(a.editor.modal, ModalState::Normal);
2017        assert!(a.editor.partial_chord.is_empty());
2018    }
2019
2020    #[test]
2021    fn every_advertised_completion_source_resolves_at_boot() {
2022        // Every `ArgSpec::completion = Some(...)` declared in
2023        // `lattice_grammar::ex_commands` must resolve to a
2024        // generator registered at App boot. Otherwise typing
2025        // `<Tab>` on that arg silently produces no candidates --
2026        // the bug class that motivated this slice.
2027        let a = app_with("hi", 5);
2028        let reg = a.editor.registry.load();
2029        for name in reg.names() {
2030            let id = reg.id_by_name(name).unwrap();
2031            let Some(spec) = reg.ex_command_spec(id) else {
2032                continue;
2033            };
2034            for arg in &spec.args_schema {
2035                if let Some(source) = arg.completion.as_deref() {
2036                    assert!(
2037                        a.editor
2038                            .completion_registry
2039                            .generator_by_name(source)
2040                            .is_some(),
2041                        "{name}'s arg `{}` advertises completion source `{source}` \
2042                         but no generator with that name is registered at boot",
2043                        arg.name,
2044                    );
2045                }
2046            }
2047        }
2048    }
2049
2050    #[test]
2051    fn modal_label_reports_state() {
2052        let mut a = app_with("", 10);
2053        assert_eq!(a.modal_label(), "NORMAL");
2054        a.apply(Action::EnterMode(ModalState::Insert));
2055        assert_eq!(a.modal_label(), "INSERT");
2056    }
2057
2058    #[test]
2059    fn quit_sets_flag() {
2060        let mut a = app_with("abc", 10);
2061        a.apply(Action::Quit);
2062        assert!(a.editor.should_quit);
2063    }
2064
2065    // ---- Motion via grammar engine ----
2066
2067    // ---- Insert mode ----
2068
2069    // ---- Operator + motion composition ----
2070
2071    // ---- Undo / Redo ----
2072
2073    // ---- Viewport scrolling ----
2074
2075    // ---- Command-line minibuffer ----
2076
2077    #[test]
2078    fn submitting_returns_to_normal_modal() {
2079        let mut a = app_with("hello", 10);
2080        a.apply(Action::EnterCommandLine);
2081        a.apply(Action::CommandLineAppend('q'));
2082        a.apply(Action::CommandLineSubmit);
2083        assert_eq!(a.editor.modal, ModalState::Normal);
2084    }
2085
2086    #[test]
2087    fn echo_action_replaces_last_message() {
2088        let mut a = app_with("", 10);
2089        a.apply(Action::Echo(EchoMessage {
2090            text: "hi".into(),
2091            level: EchoLevel::Info,
2092        }));
2093        assert_eq!(a.editor.last_message.as_ref().unwrap().text, "hi");
2094        a.apply(Action::Echo(EchoMessage {
2095            text: "bye".into(),
2096            level: EchoLevel::Warn,
2097        }));
2098        assert_eq!(a.editor.last_message.as_ref().unwrap().text, "bye");
2099        assert_eq!(
2100            a.editor.last_message.as_ref().unwrap().level,
2101            EchoLevel::Warn
2102        );
2103    }
2104    // ---- change operator end-to-end ----
2105
2106    // ---- Substitute (:s/foo/bar/[g]) ----
2107
2108    // ---- Line join (J / gJ) ----
2109
2110    // ---- WORD motions (W, B, E) end-to-end ----
2111
2112    // ---- Position history (Ctrl-O / Ctrl-I) ----
2113
2114    // ---- partial_chord lifecycle (regression) ----
2115    //
2116    // Slice 8.i.4 retired the legacy `Pending` enum; the regression
2117    // these tests pin is now expressed against `partial_chord`:
2118    // any non-`AbsorbPartialChord(_)` action must clear the
2119    // chord stack so a leftover prefix doesn't mis-route the
2120    // next keystroke.
2121
2122    #[test]
2123    fn zz_clears_partial_chord_so_next_key_is_a_motion() {
2124        // Regression: previously `zz` left pending=AfterZ, so `j` after
2125        // `zz` was interpreted as `zj` (GotoNextFold) and emitted "no
2126        // more folds". Now expressed against partial_chord.
2127        let mut a = app_with("a\nb\nc\nd\ne", 10);
2128        a.apply(Action::AbsorbPartialChord(crate::chord::KeyChord::char(
2129            'z',
2130        )));
2131        a.apply(Action::ScrollCursorTo(ScrollPos::Center));
2132        assert!(a.editor.partial_chord.is_empty());
2133    }
2134
2135    #[test]
2136    fn play_macro_clears_partial_chord() {
2137        let mut a = app_with("hello", 10);
2138        a.apply(Action::AbsorbPartialChord(crate::chord::KeyChord::char(
2139            '@',
2140        )));
2141        // No macro recorded; this errors but should still clear partial_chord.
2142        a.apply(Action::PlayMacro('z'));
2143        assert!(a.editor.partial_chord.is_empty());
2144    }
2145
2146    // ---- §5.1.1 unified position history ----
2147
2148    #[test]
2149    fn g_semicolon_walks_named_mark_history_backward() {
2150        let mut a = app_with("a\nb\nc\nd\ne", 10);
2151        // Set marks at three positions.
2152        a.editor.cursor = Position::new(1, 0);
2153        a.apply(Action::SetMark('a'));
2154        a.editor.cursor = Position::new(3, 0);
2155        a.apply(Action::SetMark('b'));
2156        a.editor.cursor = Position::new(4, 0);
2157        // g; lands on 'b' (most recent named mark).
2158        a.apply(Action::WalkMarkHistoryBack);
2159        assert_eq!(a.editor.cursor, Position::new(3, 0));
2160        // g; again -> 'a'.
2161        a.apply(Action::WalkMarkHistoryBack);
2162        assert_eq!(a.editor.cursor, Position::new(1, 0));
2163    }
2164
2165    #[test]
2166    fn g_comma_walks_named_mark_history_forward() {
2167        let mut a = app_with("a\nb\nc\nd\ne", 10);
2168        a.editor.cursor = Position::new(1, 0);
2169        a.apply(Action::SetMark('a'));
2170        a.editor.cursor = Position::new(3, 0);
2171        a.apply(Action::SetMark('b'));
2172        a.editor.cursor = Position::new(4, 0);
2173        a.apply(Action::WalkMarkHistoryBack); // -> 'b'
2174        a.apply(Action::WalkMarkHistoryBack); // -> 'a'
2175        a.apply(Action::WalkMarkHistoryForward); // -> 'b'
2176        assert_eq!(a.editor.cursor, Position::new(3, 0));
2177    }
2178
2179    #[test]
2180    fn g_semicolon_with_no_named_marks_emits_error() {
2181        let mut a = app_with("a\nb\nc", 10);
2182        a.editor.cursor = Position::new(2, 0);
2183        a.apply(invoke_motion(a.editor.builtins.goto_first_line)); // pushes AutoJump
2184        a.apply(Action::WalkMarkHistoryBack);
2185        let msg = a.editor.last_message.as_ref().unwrap();
2186        assert_eq!(msg.level, EchoLevel::Error);
2187        assert!(msg.text.contains("no marks"));
2188    }
2189
2190    // ---- Multiple registers ----
2191
2192    // ---- ~ toggle case at cursor ----
2193
2194    // ---- Word-search (* / #) and matching-bracket (%) ----
2195
2196    #[test]
2197    fn hash_finds_previous_occurrence_of_word_under_cursor() {
2198        let mut a = app_with("foo bar foo bar", 10);
2199        a.editor.cursor = Position::new(0, 8); // on 'f' of second "foo"
2200        a.apply(Action::SearchWordUnderCursor(SearchDirection::Backward));
2201        assert_eq!(a.editor.cursor, Position::ZERO);
2202    }
2203
2204    // ---- Viewport motions ----
2205
2206    // ---- Replace mode ----
2207
2208    #[test]
2209    fn esc_exits_replace_to_normal_and_pulls_cursor_back() {
2210        let mut a = app_with("hello", 10);
2211        a.apply(Action::EnterMode(ModalState::Replace));
2212        a.apply(Action::OverwriteChar('H'));
2213        // Cursor at (0,1) after one overwrite.
2214        a.apply(Action::EnterMode(ModalState::Normal));
2215        // enter_mode pulls cursor back one byte on Normal entry.
2216        assert_eq!(a.editor.modal, ModalState::Normal);
2217        assert_eq!(a.editor.cursor, Position::new(0, 0));
2218    }
2219
2220    // ---- Marks ----
2221
2222    #[test]
2223    fn invalid_mark_name_emits_error() {
2224        let mut a = app_with("hello", 10);
2225        a.apply(Action::SetMark(' '));
2226        let msg = a.editor.last_message.as_ref().unwrap();
2227        assert_eq!(msg.level, EchoLevel::Error);
2228        assert!(a.editor.marks.is_empty());
2229    }
2230
2231    #[test]
2232    fn marks_are_keyed_by_name() {
2233        let mut a = app_with("hello\nworld", 10);
2234        a.editor.cursor = Position::new(0, 1);
2235        a.apply(Action::SetMark('a'));
2236        a.editor.cursor = Position::new(1, 3);
2237        a.apply(Action::SetMark('b'));
2238        a.editor.cursor = Position::ZERO;
2239        a.apply(Action::JumpToMarkExact('a'));
2240        assert_eq!(a.editor.cursor, Position::new(0, 1));
2241        a.apply(Action::JumpToMarkExact('b'));
2242        assert_eq!(a.editor.cursor, Position::new(1, 3));
2243    }
2244
2245    #[test]
2246    fn uppercase_mark_works_same_as_lowercase_in_v1() {
2247        // v1 makes no distinction between buffer-local (a-z) and global
2248        // (A-Z) marks since the TUI runs against a single document.
2249        let mut a = app_with("hello\nworld", 10);
2250        a.editor.cursor = Position::new(1, 2);
2251        a.apply(Action::SetMark('A'));
2252        a.editor.cursor = Position::ZERO;
2253        a.apply(Action::JumpToMarkExact('A'));
2254        assert_eq!(a.editor.cursor, Position::new(1, 2));
2255    }
2256
2257    #[test]
2258    fn jumping_to_mark_with_invalid_name_is_error() {
2259        let mut a = app_with("hello", 10);
2260        a.apply(Action::JumpToMarkExact(' '));
2261        let msg = a.editor.last_message.as_ref().unwrap();
2262        assert_eq!(msg.level, EchoLevel::Error);
2263    }
2264
2265    // ---- Dot-repeat ----
2266
2267    #[test]
2268    fn motion_does_not_record_last_change() {
2269        let mut a = app_with("hello world", 10);
2270        a.apply(invoke_motion(a.editor.builtins.word_forward));
2271        assert!(a.editor.last_change.is_none());
2272    }
2273
2274    // ---- count prefix end-to-end ----
2275
2276    #[test]
2277    fn push_digit_accumulates_pending_count() {
2278        let mut a = app_with("abc", 10);
2279        a.apply(Action::PushDigit(1));
2280        a.apply(Action::PushDigit(2));
2281        a.apply(Action::PushDigit(3));
2282        assert_eq!(a.editor.pending_count, 123);
2283    }
2284
2285    // Slice 8.i.4.f: the next batch of tests bake `Count(N)`
2286    // directly into the operator invocation -- mirroring what the
2287    // input-side `attach_count` produces when the keystroke
2288    // pipeline runs. The dispatcher's job is now to honour the
2289    // baked count and drain `pending_count` / `op_count` from
2290    // App state. The full keystroke -> count pipeline lives in
2291    // the `key_harness_*` press-harness tests.
2292
2293    // ---- find / till motions end-to-end ----
2294
2295    #[test]
2296    fn fz_jumps_to_z_on_current_line() {
2297        let mut a = app_with("hello, world", 10);
2298        let inv = CommandInvocation::of(a.editor.builtins.find_char_forward.0)
2299            .with_args(lattice_grammar::Args::Char('w'));
2300        a.apply(Action::Invoke(inv));
2301        assert_eq!(a.editor.cursor, Position::new(0, 7));
2302    }
2303
2304    #[test]
2305    fn t_lands_one_byte_before_target() {
2306        let mut a = app_with("hello, world", 10);
2307        let inv = CommandInvocation::of(a.editor.builtins.till_char_forward.0)
2308            .with_args(lattice_grammar::Args::Char('w'));
2309        a.apply(Action::Invoke(inv));
2310        assert_eq!(a.editor.cursor, Position::new(0, 6));
2311    }
2312
2313    #[test]
2314    fn df_deletes_through_target_char() {
2315        // From "hello, world" with cursor at 0, `df,` deletes "hello," and
2316        // leaves " world".
2317        let mut a = app_with("hello, world", 10);
2318        let inv = CommandInvocation::of(a.editor.builtins.delete.0).with_target(
2319            lattice_grammar::Target::Motion(
2320                a.editor.builtins.find_char_forward,
2321                lattice_grammar::Args::Char(','),
2322            ),
2323        );
2324        a.apply(Action::Invoke(inv));
2325        // `f` is inclusive: find_char_forward lands on the comma (byte 5) and
2326        // the operator covers it too -> [0, 6) = "hello," is deleted, leaving
2327        // " world". (Vim parity; the inclusive flag is now honoured.)
2328        assert_eq!(a.editor.document.text(), " world");
2329    }
2330
2331    #[test]
2332    fn ct_with_change_enters_insert_mode() {
2333        let mut a = app_with("hello, world", 10);
2334        let inv = CommandInvocation::of(a.editor.builtins.change.0).with_target(
2335            lattice_grammar::Target::Motion(
2336                a.editor.builtins.till_char_forward,
2337                lattice_grammar::Args::Char(','),
2338            ),
2339        );
2340        a.apply(Action::Invoke(inv));
2341        assert_eq!(a.editor.modal, ModalState::Insert);
2342    }
2343
2344    // ---- yank + paste end-to-end ----
2345
2346    #[test]
2347    fn yw_populates_unnamed_register_charwise() {
2348        let mut a = app_with("hello world", 10);
2349        let inv = CommandInvocation::of(a.editor.builtins.yank.0).with_target(
2350            lattice_grammar::Target::Motion(
2351                a.editor.builtins.word_forward,
2352                lattice_grammar::Args::None,
2353            ),
2354        );
2355        a.apply(Action::Invoke(inv));
2356        let reg = a.editor.unnamed_register.as_ref().unwrap();
2357        assert_eq!(reg.content, "hello ");
2358        assert_eq!(reg.kind, YankKind::Charwise);
2359        // Buffer untouched by yank.
2360        assert_eq!(a.editor.document.text(), "hello world");
2361    }
2362
2363    #[test]
2364    fn goto_first_line_into_closed_fold_auto_opens() {
2365        let initial = "# H1\nbody\nbody2\n# H2\nafter\n";
2366        let mut a = app_with(initial, 10);
2367        a.set_foldmethod_for_test(FoldMethod::Markdown);
2368        a.recompute_folds();
2369        let idx = a
2370            .editor
2371            .folds
2372            .iter()
2373            .position(|f| f.start_line == 0)
2374            .expect("H1 fold");
2375        a.editor.folds[idx].closed = true;
2376        // Move cursor away first (so gg is a non-trivial jump).
2377        a.editor.cursor = Position::new(4, 0);
2378        let inv = CommandInvocation::of(a.editor.builtins.goto_first_line.0);
2379        a.apply(Action::Invoke(inv));
2380        let fold = a
2381            .editor
2382            .folds
2383            .iter()
2384            .find(|f| f.start_line == 0)
2385            .expect("H1 fold still present");
2386        assert!(!fold.closed, "gg should auto-open the destination fold");
2387    }
2388
2389    #[test]
2390    fn zi_toggles_foldenable_and_renders_folds_open() {
2391        let mut a = app_with("# H\nbody\n# H2\n", 10);
2392        a.set_foldmethod_for_test(FoldMethod::Markdown);
2393        a.recompute_folds();
2394        let idx = a
2395            .editor
2396            .folds
2397            .iter()
2398            .position(|f| f.start_line == 0)
2399            .expect("H1 fold");
2400        a.editor.folds[idx].closed = true;
2401        a.editor.publish_render_state();
2402        // Sanity: the fold is closed and visible to the renderer.
2403        assert!(a.line_inside_closed_fold(1));
2404        assert!(a.fold_start_at(0).is_some());
2405        // zi disables.
2406        a.apply(Action::ToggleFoldEnable);
2407        assert!(!a.foldenable());
2408        // With foldenable=false, the renderer sees no closed folds.
2409        assert!(!a.line_inside_closed_fold(1));
2410        assert!(a.fold_start_at(0).is_none());
2411        // zi again re-enables and the closed-state is preserved.
2412        a.apply(Action::ToggleFoldEnable);
2413        assert!(a.foldenable());
2414        assert!(a.line_inside_closed_fold(1));
2415        assert!(a.fold_start_at(0).is_some());
2416    }
2417
2418    #[test]
2419    fn nofoldenable_disables_fold_aware_operators() {
2420        let initial = "# H1\nbody one\nbody two\n# H2\nafter\n";
2421        let mut a = app_with(initial, 10);
2422        a.set_foldmethod_for_test(FoldMethod::Markdown);
2423        a.recompute_folds();
2424        let idx = a
2425            .editor
2426            .folds
2427            .iter()
2428            .position(|f| f.start_line == 0)
2429            .expect("H1 fold");
2430        a.editor.folds[idx].closed = true;
2431        a.set_foldenable_for_test(false);
2432        a.editor.cursor = Position::new(0, 0);
2433        let inv = CommandInvocation::of(a.editor.builtins.delete.0)
2434            .with_range(lattice_grammar::Range::CurrentLine);
2435        a.apply(Action::Invoke(inv));
2436        // With foldenable=false, dd should affect just one line.
2437        let text = a.editor.document.text();
2438        assert!(
2439            !text.contains("# H1"),
2440            "heading should be deleted: {text:?}"
2441        );
2442        assert!(
2443            text.contains("body one"),
2444            "body one should remain: {text:?}"
2445        );
2446    }
2447
2448    #[test]
2449    fn linear_j_does_not_auto_open_fold() {
2450        // `docs/user/folding.md`: linear motions (j/k/h/l/w/b) do
2451        // NOT trigger auto-open. The cursor "skips" over closed
2452        // folds via `line_inside_closed_fold` filtering -- but the
2453        // fold itself stays closed. Here we simulate a synthetic
2454        // cursor move into the fold range to verify the rule.
2455        let initial = "# H1\nbody\nbody2\n# H2\nafter\n";
2456        let mut a = app_with(initial, 10);
2457        a.set_foldmethod_for_test(FoldMethod::Markdown);
2458        a.recompute_folds();
2459        let idx = a
2460            .editor
2461            .folds
2462            .iter()
2463            .position(|f| f.start_line == 0)
2464            .expect("H1 fold");
2465        a.editor.folds[idx].closed = true;
2466        // Direct cursor move (not via auto-open path).
2467        a.editor.cursor = Position::new(1, 0);
2468        let still_closed = a
2469            .editor
2470            .folds
2471            .iter()
2472            .find(|f| f.start_line == 0)
2473            .expect("H1 fold still present");
2474        assert!(
2475            still_closed.closed,
2476            "merely setting cursor should not open folds"
2477        );
2478    }
2479
2480    #[test]
2481    fn read_only_j_snaps_past_closed_fold() {
2482        // Regression (dashboard / help): read-only buffers fold markdown
2483        // sections too, but their motion runner (`run_read_only_motion`)
2484        // ignored folds — so `j` walked line-by-line through the hidden
2485        // body instead of stepping over the closed section to the next
2486        // visible line. The document path (`run_document_invocation`)
2487        // already snapped; this pins the read-only path to match.
2488        let mut a = app_with("# H1\nbody\nbody2\n# H2\nafter\n", 10);
2489        a.set_foldmethod_for_test(FoldMethod::Markdown);
2490        a.recompute_folds();
2491        let idx = a
2492            .editor
2493            .folds
2494            .iter()
2495            .position(|f| f.start_line == 0)
2496            .expect("H1 fold");
2497        let end = a.editor.folds[idx].end_line;
2498        a.editor.folds[idx].closed = true;
2499        a.editor.cursor = Position::new(0, 0);
2500        // Drive the read-only motion path directly (help-mode's runner).
2501        let inv = lattice_grammar::CommandInvocation::of(a.editor.builtins.line_down.0);
2502        a.editor.run_read_only_motion(inv);
2503        assert_eq!(
2504            a.editor.cursor.line,
2505            end + 1,
2506            "read-only `j` must land on the first visible line past the closed fold"
2507        );
2508    }
2509
2510    // ---- Bracketed-paste burst (Action::PasteText) ----
2511
2512    // ---- Blockwise visual operators (DESIGN.md §15:18) ----
2513
2514    /// Drive into Blockwise visual at `anchor`, then move the cursor to
2515    /// `head` so the rectangle is `[anchor, head]`. Returns the App
2516    /// ready for an operator dispatch.
2517    // ---- Help overlay (DESIGN.md §5.11) ----
2518
2519    // ---- Command-line completion (DESIGN.md §5.11.3) ----
2520
2521    #[test]
2522    fn accept_completion_replaces_prefix_with_chosen_text() {
2523        let mut a = app_in_command_mode("descri");
2524        a.apply(Action::CommandLineCompleteOrAdvance);
2525        // The accepted candidate uses the user-facing alias form,
2526        // not the canonical `ex:*` name. The first candidate (after
2527        // ranking) is one of the describe-* family.
2528        a.apply(Action::CommandLineAcceptCompletion);
2529        assert!(
2530            a.editor.command_line().starts_with("describe-")
2531                || a.editor.command_line() == "apropos",
2532            "expected user-facing alias, got `{}`",
2533            a.editor.command_line()
2534        );
2535        assert!(a.editor.completion_state.is_none());
2536    }
2537
2538    // ---- Chord-capture (DESIGN.md §B.1, ArgKind::Chord) ----
2539
2540    // ---- Missing-arg chord prompt (DESIGN.md §B.1) ----
2541
2542    #[test]
2543    fn cancel_clears_armed_chord_prompt() {
2544        let mut a = app_in_command_mode("describe-key");
2545        a.apply(Action::CommandLineSubmit);
2546        assert!(a.editor.auto_submit_after_chord);
2547        a.apply(Action::CommandLineCancel);
2548        assert!(!a.editor.auto_submit_after_chord);
2549    }
2550
2551    #[test]
2552    fn arg_slot_completion_for_describe_command_shows_command_names() {
2553        // After "describe-command moti", the slot is arg 0 with
2554        // completion source "gen:commands" -- the popup lists motion:*
2555        // commands. UD.2: motions are invocable via `:` (the host
2556        // dispatch is unified), so `gen:commands` includes them and
2557        // `:describe-command` can complete (and describe) them.
2558        let mut a = app_in_command_mode("describe-command moti");
2559        a.apply(Action::CommandLineCompleteOrAdvance);
2560        let state = a.editor.completion_state.as_ref().expect("popup");
2561        assert!(
2562            state
2563                .candidates
2564                .iter()
2565                .any(|c| c.raw.text.starts_with("motion:")),
2566            "expected motion:* candidates: {:?}",
2567            state
2568                .candidates
2569                .iter()
2570                .map(|c| &c.raw.text)
2571                .collect::<Vec<_>>()
2572        );
2573    }
2574
2575    #[test]
2576    fn accept_in_arg_slot_replaces_only_the_arg_prefix() {
2577        let mut a = app_in_command_mode("describe-command moti");
2578        a.apply(Action::CommandLineCompleteOrAdvance);
2579        a.apply(Action::CommandLineAcceptCompletion);
2580        // Should now be "describe-command motion:..." -- the command
2581        // word + space preserved; only the `moti` arg prefix replaced.
2582        assert!(
2583            a.editor
2584                .command_line()
2585                .starts_with("describe-command motion:"),
2586            "got: {:?}",
2587            a.editor.command_line()
2588        );
2589    }
2590
2591    // ---- Hybrid <C-h> (DESIGN.md §5.11.3 Q11) ----
2592
2593    // ---- delete_trailing_word helper ----
2594
2595    // ---- Alias preference for command candidates ----
2596
2597    #[test]
2598    fn resolve_command_name_or_alias_handles_both_forms() {
2599        let mut registry = lattice_grammar::CommandRegistry::new();
2600        let _ = lattice_grammar::builtins::populate(&mut registry);
2601        let _ = lattice_grammar::ex_commands::populate(&mut registry);
2602        // Canonical hits.
2603        assert!(
2604            lattice_host::excommand::resolve_command_name_or_alias(&registry, "ex:write").is_some()
2605        );
2606        assert!(
2607            lattice_host::excommand::resolve_command_name_or_alias(&registry, "ex:apropos")
2608                .is_some()
2609        );
2610        assert!(
2611            lattice_host::excommand::resolve_command_name_or_alias(&registry, "motion:line-down")
2612                .is_some()
2613        );
2614        // Alias hits.
2615        assert!(lattice_host::excommand::resolve_command_name_or_alias(&registry, "w").is_some());
2616        assert!(
2617            lattice_host::excommand::resolve_command_name_or_alias(&registry, "apropos").is_some()
2618        );
2619        assert!(
2620            lattice_host::excommand::resolve_command_name_or_alias(&registry, "describe-command")
2621                .is_some()
2622        );
2623        // Misses.
2624        assert!(
2625            lattice_host::excommand::resolve_command_name_or_alias(&registry, "nope").is_none()
2626        );
2627        assert!(lattice_host::excommand::resolve_command_name_or_alias(&registry, "").is_none());
2628    }
2629
2630    #[test]
2631    fn parser_accepts_canonical_name_directly() {
2632        // Defensive: even if the user types the canonical name
2633        // (`:ex:describe-command`), the parser resolves it. The
2634        // assertion: no "unknown command" error message. Whatever
2635        // happens downstream (e.g. `:ex:write` errors on no file
2636        // name) is unrelated to parser resolution.
2637        let mut a = app_in_command_mode("ex:describe-command ex:write");
2638        a.apply(Action::CommandLineSubmit);
2639        // Should have opened the help buffer; no "unknown
2640        // command" error from the parser.
2641        assert!(
2642            a.editor.popup_buffer.is_some(),
2643            "help should open from canonical-name describe-command"
2644        );
2645    }
2646
2647    // ---- completion.auto_insert_single (B + sub-decision (i)) ----
2648    //
2649    // Single-candidate auto-insert at popup-open: when `<Tab>` would
2650    // open a popup with exactly one candidate AND the option is on,
2651    // skip the popup and apply the candidate to the cmdline directly.
2652    // Today there's only one completion path (cmdline `:` Tab), so
2653    // this hook covers `gen:commands`, `gen:options`, and every other
2654    // arg-slot generator uniformly. When LSP / Insert-mode completion
2655    // lands (Phase 4.2, task #199), Phase 4.2 should reuse
2656    // `open_completion_popup` (or factor a shared helper) so this
2657    // option stays universal without a second knob.
2658
2659    // ---- Pane tree (DESIGN.md §5.9, B.1.b) ----
2660
2661    #[test]
2662    fn focused_hover_does_not_auto_dismiss_on_motion() {
2663        // State B: cursor is *inside* the popup; motions move the
2664        // popup's cursor, not the doc's. The State-A auto-dismiss
2665        // hook is gated on `prev_pane_for_popup.is_none()` -- in
2666        // State B that field is Some, so motion doesn't drop the
2667        // popup.
2668        let mut a = app_with("fn main() {}\n", 5);
2669        a.do_open_hover("line 1\nline 2\nline 3");
2670        a.apply_effect(lattice_grammar::Effect::Lsp(
2671            lattice_grammar::LspRequest::Hover,
2672        )); // -> State B (5.5.LSP.1)
2673        assert!(matches!(a.editor.active_buffer, BufferKind::Help));
2674        // Move within popup.
2675        let inv = lattice_grammar::CommandInvocation::of(a.editor.builtins.line_down.0);
2676        a.apply(Action::Invoke(inv));
2677        assert!(a.editor.popup_buffer.is_some(), "popup persists in State B");
2678        assert_eq!(a.editor.cursor.line, 1);
2679    }
2680
2681    // ---- Multiple Document buffers (DESIGN.md §5.9, B.1.c) ----
2682
2683    #[test]
2684    fn bnext_cycles_through_open_buffers() {
2685        let path = write_temp_file("b", "one\n");
2686        let mut a = app_with("xx", 10);
2687        let first_id = a.editor.document_buffer_id;
2688        a.editor
2689            .set_command_line_text(&format!("e {}", path.display()));
2690        a.editor.modal = ModalState::Command;
2691        a.apply(Action::CommandLineSubmit);
2692        let second_id = a.editor.document_buffer_id;
2693        assert_ne!(first_id, second_id);
2694        a.editor.set_command_line_text("bn");
2695        a.editor.modal = ModalState::Command;
2696        a.apply(Action::CommandLineSubmit);
2697        assert_eq!(a.editor.document_buffer_id, first_id);
2698        a.editor.set_command_line_text("bn");
2699        a.editor.modal = ModalState::Command;
2700        a.apply(Action::CommandLineSubmit);
2701        assert_eq!(a.editor.document_buffer_id, second_id);
2702        let _ = std::fs::remove_file(path);
2703    }
2704
2705    #[test]
2706    fn editing_already_open_path_switches_back_to_it() {
2707        let path = write_temp_file("d", "alpha\n");
2708        let mut a = app_with("xx", 10);
2709        let initial_id = a.editor.document_buffer_id;
2710        a.editor
2711            .set_command_line_text(&format!("e {}", path.display()));
2712        a.editor.modal = ModalState::Command;
2713        a.apply(Action::CommandLineSubmit);
2714        let new_id = a.editor.document_buffer_id;
2715        // Cycle back to first buffer.
2716        a.editor.set_command_line_text("bn");
2717        a.editor.modal = ModalState::Command;
2718        a.apply(Action::CommandLineSubmit);
2719        assert_eq!(a.editor.document_buffer_id, initial_id);
2720        // Re-editing the new file's path should switch to its
2721        // existing buffer rather than spawning a third.
2722        a.editor
2723            .set_command_line_text(&format!("e {}", path.display()));
2724        a.editor.modal = ModalState::Command;
2725        a.apply(Action::CommandLineSubmit);
2726        assert_eq!(a.editor.document_buffer_id, new_id);
2727        // Listed-count gates the "did we accidentally spawn a third?"
2728        // assertion; synthetic `*lsp*` doesn't count.
2729        assert_eq!(a.editor.buffers.listed_ids_sorted().len(), 2);
2730        let _ = std::fs::remove_file(path);
2731    }
2732
2733    // ---- Buffer activation lifecycle ----
2734    //
2735    // Regression coverage for the `<C-l>`-needed bug: opening a
2736    // second file via `:e <path>` left the new buffer with empty
2737    // folds and stale highlight caches because no single hook ran
2738    // on activation. `App::activate_buffer_state` is now the one
2739    // place to add buffer-level state that needs to come up with
2740    // the buffer.
2741
2742    #[test]
2743    fn activation_skips_fold_seed_for_manual_foldmethod() {
2744        // Manual foldmethod => activation must NOT auto-create folds
2745        // (the user's `zf` ranges are authoritative; auto-seeding
2746        // would surprise them).
2747        let path = write_temp_file("activate-manual", "a:\n    x\n    y\nb:\n    p\n    q\n");
2748        let mut a = app_with("xx", 10);
2749        a.set_foldmethod_for_test(FoldMethod::Manual);
2750        a.editor
2751            .set_command_line_text(&format!("e {}", path.display()));
2752        a.editor.modal = ModalState::Command;
2753        a.apply(Action::CommandLineSubmit);
2754        assert!(
2755            a.editor.folds.is_empty(),
2756            "manual foldmethod should not auto-seed folds: {:?}",
2757            a.editor.folds
2758        );
2759        let _ = std::fs::remove_file(path);
2760    }
2761
2762    // ---- File-tree buffer (DESIGN.md §5.9, B.1.d) ----
2763
2764    // ---- Typed options registry (DESIGN.md §5.12, B.2) ----
2765
2766    // ---- Hover popup (DESIGN.md §5.9.6, B.3) ----
2767
2768    // ---- LSP hover (Phase 4.2.b) ----
2769
2770    // ---- LSP goto-definition (Phase 4.2.c) ----
2771
2772    /// 4.5.c: `range_covers` returns true for points strictly
2773    /// inside the range; inclusive on both ends to match the
2774    /// "click on the rightmost char of a link" UX.
2775    #[test]
2776    fn range_covers_inclusive_at_both_ends() {
2777        let r = lattice_lsp::lsp_types::Range {
2778            start: lattice_lsp::lsp_types::Position {
2779                line: 2,
2780                character: 4,
2781            },
2782            end: lattice_lsp::lsp_types::Position {
2783                line: 2,
2784                character: 10,
2785            },
2786        };
2787        // Inside.
2788        assert!(super::range_covers(
2789            r,
2790            lattice_lsp::lsp_types::Position {
2791                line: 2,
2792                character: 6
2793            }
2794        ));
2795        // Boundary (inclusive).
2796        assert!(super::range_covers(r, r.start));
2797        assert!(super::range_covers(r, r.end));
2798        // Before start -> miss.
2799        assert!(!super::range_covers(
2800            r,
2801            lattice_lsp::lsp_types::Position {
2802                line: 2,
2803                character: 3
2804            }
2805        ));
2806        // After end -> miss.
2807        assert!(!super::range_covers(
2808            r,
2809            lattice_lsp::lsp_types::Position {
2810                line: 2,
2811                character: 11
2812            }
2813        ));
2814        // Different line outside the range.
2815        assert!(!super::range_covers(
2816            r,
2817            lattice_lsp::lsp_types::Position {
2818                line: 1,
2819                character: 7
2820            }
2821        ));
2822        assert!(!super::range_covers(
2823            r,
2824            lattice_lsp::lsp_types::Position {
2825                line: 3,
2826                character: 0
2827            }
2828        ));
2829    }
2830
2831    /// 4.5.c: `range_covers` works across line boundaries when
2832    /// the range spans multiple lines.
2833    #[test]
2834    fn range_covers_multi_line_range() {
2835        let r = lattice_lsp::lsp_types::Range {
2836            start: lattice_lsp::lsp_types::Position {
2837                line: 2,
2838                character: 4,
2839            },
2840            end: lattice_lsp::lsp_types::Position {
2841                line: 4,
2842                character: 8,
2843            },
2844        };
2845        // Mid-range second line: covered regardless of column.
2846        assert!(super::range_covers(
2847            r,
2848            lattice_lsp::lsp_types::Position {
2849                line: 3,
2850                character: 0
2851            }
2852        ));
2853        // First line before start column -> miss.
2854        assert!(!super::range_covers(
2855            r,
2856            lattice_lsp::lsp_types::Position {
2857                line: 2,
2858                character: 3
2859            }
2860        ));
2861        // Last line after end column -> miss.
2862        assert!(!super::range_covers(
2863            r,
2864            lattice_lsp::lsp_types::Position {
2865                line: 4,
2866                character: 9
2867            }
2868        ));
2869    }
2870
2871    #[test]
2872    fn symbol_kind_glyph_distinct_for_common_kinds() {
2873        // We don't assert the *exact* glyph (those may evolve);
2874        // we just want the common kinds to map to *distinct*
2875        // glyphs so a glance distinguishes a fn from a struct.
2876        use lattice_lsp::lsp_types::SymbolKind as K;
2877        let f = super::symbol_kind_glyph(K::FUNCTION);
2878        let s = super::symbol_kind_glyph(K::STRUCT);
2879        let m = super::symbol_kind_glyph(K::MODULE);
2880        let v = super::symbol_kind_glyph(K::VARIABLE);
2881        assert_ne!(f, s);
2882        assert_ne!(f, m);
2883        assert_ne!(f, v);
2884    }
2885
2886    #[test]
2887    fn prepare_rename_placeholder_extracted_from_range_with_placeholder() {
2888        let r = lattice_lsp::lsp_types::Range {
2889            start: lattice_lsp::lsp_types::Position {
2890                line: 0,
2891                character: 0,
2892            },
2893            end: lattice_lsp::lsp_types::Position {
2894                line: 0,
2895                character: 3,
2896            },
2897        };
2898        let resp = lattice_lsp::lsp_types::PrepareRenameResponse::RangeWithPlaceholder {
2899            range: r,
2900            placeholder: "foo".into(),
2901        };
2902        assert_eq!(
2903            super::prepare_rename_placeholder(&resp),
2904            Some("foo".to_string())
2905        );
2906        let resp = lattice_lsp::lsp_types::PrepareRenameResponse::Range(r);
2907        assert_eq!(super::prepare_rename_placeholder(&resp), None);
2908    }
2909
2910    #[test]
2911    fn picker_dismiss_clears_pending_tag_origin() {
2912        let mut a = app_with("foo\n", 10);
2913        a.editor.pending_tag_origin = Some(super::TagStackEntry {
2914            buffer: a.editor.active_buffer,
2915            buffer_id: a.active_pane_buffer_id(),
2916            position: Position::new(0, 0),
2917            label: "foo".into(),
2918        });
2919        // Open + dismiss a picker. We don't need real candidates;
2920        // a non-Some picker dismiss already takes the picker
2921        // first. Simulate by setting picker Some.
2922        let mut p = lattice_picker::Picker::new(
2923            "test",
2924            lattice_picker::PickerSource::LspLocations,
2925            lattice_picker::PickerAction::JumpToLspLocation,
2926        );
2927        p.set_lsp_locations(Vec::new());
2928        a.editor.picker = Some(p);
2929        a.apply(Action::PickerDismiss);
2930        assert!(a.editor.pending_tag_origin.is_none());
2931    }
2932
2933    #[test]
2934    fn diagnostics_picker_clears_stale_tag_origin() {
2935        // If a stale nav-intent origin was set (race scenario:
2936        // gd fired but drain hasn't run; user invokes
2937        // :diagnostics), opening the diagnostics picker MUST
2938        // clear the origin so a later JumpToLspLocation accept
2939        // doesn't push the wrong entry.
2940        let mut a = app_with("foo\n", 10);
2941        a.editor.pending_tag_origin = Some(super::TagStackEntry {
2942            buffer: a.editor.active_buffer,
2943            buffer_id: a.active_pane_buffer_id(),
2944            position: Position::new(0, 0),
2945            label: "stale".into(),
2946        });
2947        a.do_list_diagnostics();
2948        assert!(a.editor.pending_tag_origin.is_none());
2949    }
2950
2951    // ---- :help (DESIGN.md §5.11) ----
2952
2953    #[test]
2954    fn h_alias_resolves_to_help() {
2955        let mut a = app_with("xx", 10);
2956        a.editor.set_command_line_text("h folding");
2957        a.editor.modal = ModalState::Command;
2958        a.apply(Action::CommandLineSubmit);
2959        let h = a.popup_help().expect("help open");
2960        assert_eq!(h.title, "help folding");
2961    }
2962
2963    #[test]
2964    fn after_change_user_can_type_and_replacement_lands() {
2965        let mut a = app_with("hello world", 10);
2966        let inv = CommandInvocation::of(a.editor.builtins.change.0).with_target(
2967            lattice_grammar::Target::Motion(
2968                a.editor.builtins.word_forward,
2969                lattice_grammar::Args::None,
2970            ),
2971        );
2972        a.apply(Action::Invoke(inv));
2973        assert_eq!(a.editor.modal, ModalState::Insert);
2974        a.apply(Action::Insert("HEY ".into()));
2975        assert_eq!(a.editor.document.text(), "HEY world");
2976    }
2977
2978    // ---- LSP wiring tests (Phase 4.1.i) ---------------------
2979
2980    #[test]
2981    fn pathless_document_does_not_register_buffer_uri() {
2982        // Path-less scratch document -> `App::new` publishes no
2983        // `Event::DocumentOpened` (well, *publishes one for
2984        // observability, but with `path: None`*) and registers
2985        // no `buffer_uris` entry. The attach driver ignores
2986        // path-less events.
2987        let app = App::new(Document::from_text("fn main() {}"));
2988        assert!(app.buffer_uri(app.editor.document_buffer_id).is_none());
2989    }
2990
2991    // ---- LSP diagnostic navigation tests (Phase 4.1.d.iv) ----
2992
2993    /// Helper: seed N diagnostics into the App's LSP layer at
2994    /// the given lines + map a fake URI to the active buffer.
2995    // ---- LSP introspection tests (Phase 4.1.g) ---------------
2996
2997    #[test]
2998    fn k_chord_registered_in_lsp_mode_keymap() {
2999        // MO.1: `K` migrated from Builtin layer to LspMode::keymap().
3000        // `:describe-key K` on an lsp-mode buffer walks the mode-contributed
3001        // layer and finds it there. On a non-lsp buffer it correctly shows
3002        // "not bound" — the binding is mode-gated by K.1.c.
3003        use lattice_lsp::modes::LspMode;
3004        use lattice_mode::Mode as _;
3005        let km = LspMode::new().keymap();
3006        let k = km.entries.iter().find(|e| e.chord == "K");
3007        assert!(k.is_some(), "K should be in LspMode::keymap() entries");
3008        let entry = k.unwrap();
3009        assert!(
3010            entry.doc.to_lowercase().contains("hover"),
3011            "doc should mention hover, got {:?}",
3012            entry.doc
3013        );
3014    }
3015
3016    // ---- Edit-dispatch wiring tests (Phase 4.1.i.2) ----------
3017
3018    /// Path-bearing initial documents publish
3019    /// `Event::DocumentOpened` from `App::new` and register the
3020    /// URI eagerly. The attach driver picks the event up off
3021    /// the bus and submits to the supervisor on the LSP runtime
3022    /// -- the UI thread never parks. We verify the eager URI
3023    /// registration here (`buffer_uris` is observable on the
3024    /// public App API); full driver -> supervisor behaviour
3025    /// (the publish path itself) is covered in
3026    /// `lattice_lsp::attach_driver::tests`.
3027    #[test]
3028    fn path_bearing_initial_document_registers_uri_eagerly() {
3029        use std::str::FromStr;
3030        // Build a Document with a fixed path. We can't use
3031        // `Document::open` here without I/O; the builder
3032        // surface (DocumentBuilder::with_path) lets us seed
3033        // the path directly.
3034        let path = std::path::PathBuf::from("/tmp/lattice-test/initial.rs");
3035        let doc = lattice_core::DocumentBuilder::default()
3036            .with_path(path.clone())
3037            .with_text("fn main() {}")
3038            .build();
3039        let app = App::new(doc);
3040        let expected = <lattice_lsp::Uri as FromStr>::from_str(
3041            lattice_lsp::actor::uri_from_path(&path).as_str(),
3042        )
3043        .unwrap();
3044        assert_eq!(
3045            app.buffer_uri(app.editor.document_buffer_id),
3046            Some(expected),
3047            "path-bearing initial document must register URI eagerly"
3048        );
3049    }
3050
3051    // ---- Snippet host integration (Phase 4.2.g.4) ----
3052
3053    /// Test helper: attach a freshly-parsed `Syntax` for `lang`
3054    /// to `a`, wrapped in a [`SyntaxHandle`]. Mirrors the audit
3055
3056    #[test]
3057    fn dedup_helper_keeps_first_occurrence_by_text() {
3058        // Direct unit test on the dedup helper. Ranker has
3059        // already sorted; we feed in a vec mimicking the
3060        // post-rank state (highest-ranked entry first per
3061        // text), confirm the deduped vec keeps the first
3062        // occurrence and preserves order otherwise.
3063        use lattice_completion::{
3064            CandidateKind, MatchScore, RawCandidate, RenderedCandidate, ScoredCandidate, SourceId,
3065        };
3066        let mk = |text: &str, source: &str, score: u32| {
3067            let raw =
3068                RawCandidate::plain(text, CandidateKind::Plain).with_source(SourceId::new(source));
3069            RenderedCandidate::from_scored(ScoredCandidate {
3070                raw,
3071                score: MatchScore(score),
3072                match_ranges: Vec::new(),
3073            })
3074        };
3075        let mut rendered = vec![
3076            mk("outer", "gen:buffer-words", 200),
3077            mk("alpha", "gen:buffer-words", 180),
3078            mk("outer", "gen:tree-sitter-symbol", 180),
3079            mk("beta", "gen:tree-sitter-symbol", 150),
3080            mk("alpha", "gen:tree-sitter-symbol", 150),
3081        ];
3082        super::dedup_rendered_by_text(&mut rendered);
3083        let texts: Vec<&str> = rendered.iter().map(|c| c.raw.text.as_str()).collect();
3084        let sources: Vec<&str> = rendered
3085            .iter()
3086            .map(|c| c.raw.source.as_ref().map(|s| s.as_str()).unwrap_or(""))
3087            .collect();
3088        assert_eq!(texts, vec!["outer", "alpha", "beta"]);
3089        // Each kept row carries the higher-ranked source's
3090        // tag (the first occurrence of each text).
3091        assert_eq!(
3092            sources,
3093            vec![
3094                "gen:buffer-words",
3095                "gen:buffer-words",
3096                "gen:tree-sitter-symbol",
3097            ],
3098        );
3099    }
3100
3101    // Inject an `InboundApplyEdit` into the App's drain
3102    // receiver. Replaces whatever was there; tests start with
3103    // an empty receiver so this is fine.
3104
3105    fn install_lsp_candidate_with_commit_chars(
3106        a: &mut App,
3107        text: &str,
3108        commit_chars: Vec<char>,
3109        anchor: Position,
3110    ) {
3111        let cursor = a.editor.cursor;
3112        let snap = a.editor.document.snapshot();
3113        let line = snap.buffer.line(cursor.line).unwrap_or_default();
3114        let query = line
3115            .get(anchor.byte as usize..cursor.byte as usize)
3116            .unwrap_or("")
3117            .to_string();
3118        let mut state = lattice_completion::InsertCompletionState::open(
3119            lattice_completion::CompletionTrigger::Manual,
3120            anchor,
3121            cursor,
3122            query,
3123        );
3124        let meta = LspCompletionMeta {
3125            label: text.to_string(),
3126            insert_text: text.to_string(),
3127            filter_text: None,
3128            sort_text: None,
3129            detail: None,
3130            documentation: None,
3131            kind: Some(lattice_lsp::lsp_types::CompletionItemKind::FUNCTION),
3132            deprecated: false,
3133            preselect: false,
3134            commit_characters: commit_chars,
3135            additional_text_edits: Vec::new(),
3136            command: None,
3137            insert_text_format: lattice_lsp::lsp_types::InsertTextFormat::PLAIN_TEXT,
3138            replace_range: None,
3139            server_id: "test-server".to_string(),
3140            original_item: lattice_lsp::lsp_types::CompletionItem::new_simple(
3141                text.to_string(),
3142                String::new(),
3143            ),
3144            resolved: false,
3145        };
3146        let payload = lattice_lsp::completion::encode_meta(&meta);
3147        let mut raw =
3148            lattice_completion::RawCandidate::plain(text, lattice_completion::CandidateKind::Plain)
3149                .with_source(lattice_completion::SourceId::new(
3150                    lattice_completion::LSP_COMPLETION_SOURCE_ID,
3151                ));
3152        raw.data = lattice_completion::CandidateData::Extension {
3153            kind_id: LSP_COMPLETION_KIND_ID,
3154            payload,
3155        };
3156        state.raw.push(raw);
3157        a.refilter_insert_completion(&mut state);
3158        a.editor.insert_completion = Some(state);
3159    }
3160
3161    #[test]
3162    fn commit_char_in_lsp_item_accepts_then_inserts() {
3163        let mut a = app_with("foo", 10);
3164        a.editor.modal = ModalState::Insert;
3165        a.editor.cursor = Position::new(0, 3);
3166        install_lsp_candidate_with_commit_chars(&mut a, "foo", vec!['.', '('], Position::new(0, 0));
3167        a.do_completion_accept_then_insert('.');
3168        // Popup closed; accept replaced the partial with the
3169        // full LSP insert, then `.` was appended.
3170        assert!(
3171            a.editor.insert_completion.is_none(),
3172            "popup closed on commit"
3173        );
3174        assert_eq!(a.editor.document.snapshot().buffer.as_string(), "foo.");
3175    }
3176
3177    #[test]
3178    fn non_commit_char_is_plain_insert_popup_refilters() {
3179        let mut a = app_with("foo", 10);
3180        a.editor.modal = ModalState::Insert;
3181        a.editor.cursor = Position::new(0, 3);
3182        install_lsp_candidate_with_commit_chars(&mut a, "foo", vec!['.'], Position::new(0, 0));
3183        a.do_completion_accept_then_insert('a');
3184        // `a` isn't a commit char -> the focused candidate
3185        // wasn't accepted; `a` was inserted plainly. The
3186        // refresh hook closes the popup because the new
3187        // query "fooa" no longer matches the candidate
3188        // "foo" prefix-wise (matcher returns no rows).
3189        assert_eq!(a.editor.document.snapshot().buffer.as_string(), "fooa");
3190    }
3191
3192    #[test]
3193    fn extra_commit_chars_option_contributes_globally() {
3194        let mut a = app_with("foo", 10);
3195        a.editor.modal = ModalState::Insert;
3196        a.editor.cursor = Position::new(0, 3);
3197        // Server says no commit chars; the global option
3198        // adds `,`.
3199        install_lsp_candidate_with_commit_chars(&mut a, "foo", Vec::new(), Position::new(0, 0));
3200        a.do_set("completion.extra_commit_chars=,");
3201        a.do_completion_accept_then_insert(',');
3202        assert!(a.editor.insert_completion.is_none());
3203        assert_eq!(a.editor.document.snapshot().buffer.as_string(), "foo,");
3204    }
3205
3206    #[test]
3207    fn sync_candidate_honors_extra_commit_chars_only() {
3208        // A buffer-words candidate has no per-item commit
3209        // list (sync sources don't carry one). The global
3210        // extras still apply.
3211        let mut a = app_with("alpha bravo ", 10);
3212        a.editor.modal = ModalState::Insert;
3213        a.editor.cursor = Position::new(0, 12);
3214        a.do_completion_trigger();
3215        // Server-supplied list is empty for sync candidates;
3216        // set the global extras to include `;`.
3217        a.do_set("completion.extra_commit_chars=;");
3218        // Focus the `alpha` candidate (insert at cursor).
3219        if let Some(state) = a.editor.insert_completion.as_mut() {
3220            state.selected = state
3221                .rendered
3222                .iter()
3223                .position(|r| r.raw.text == "alpha")
3224                .expect("alpha");
3225        }
3226        a.do_completion_accept_then_insert(';');
3227        // Popup closed; `alpha` inserted then `;`.
3228        assert!(a.editor.insert_completion.is_none());
3229        let text = a.editor.document.snapshot().buffer.as_string();
3230        assert!(text.ends_with("alpha;"), "got `{text}`");
3231    }
3232
3233    #[test]
3234    fn populate_insert_completion_sync_drops_disabled_source() {
3235        // Inject a per-language override that limits rust to
3236        // snippets only -> buffer-words emit is suppressed even
3237        // though the buffer is full of word-completion fodder.
3238        let mut a = app_with("foo bar baz qux quux ", 10);
3239        a.editor.modal = ModalState::Insert;
3240        a.editor.cursor = Position::new(0, 21);
3241        // Pretend the active language is rust by overriding
3242        // the `rust` slot. (Test buffer has no path so
3243        // active_language_id() returns ""; insert that as the
3244        // key directly to land the override.)
3245        a.editor.per_language_completion.insert(
3246            String::new(),
3247            lattice_completion::PerLanguageOverrides {
3248                sources: Some(vec![lattice_completion::SourceId::new(
3249                    lattice_completion::SNIPPET_SOURCE_ID,
3250                )]),
3251                ..Default::default()
3252            },
3253        );
3254        a.do_completion_trigger();
3255        // Popup either closed (no candidates) or has only
3256        // snippet items. Buffer-words mustn't appear.
3257        if let Some(state) = a.editor.insert_completion.as_ref() {
3258            for cand in &state.rendered {
3259                let src = cand.raw.source.as_ref().map(|s| s.as_str()).unwrap_or("");
3260                assert_ne!(
3261                    src,
3262                    lattice_completion::BufferWordsSource::ID,
3263                    "buffer-words filtered out",
3264                );
3265            }
3266        }
3267    }
3268
3269    // ---- M.3.0: built-in major modes registered at boot ----
3270
3271    // ---- M.3.1: ReadOnly option flows from major modes ----
3272
3273    #[test]
3274    fn document_buffer_active_mode_is_text_mode() {
3275        // Plain document with no path ⇒ Lang::Plain ⇒ text-mode.
3276        let a = app_with("hi", 5);
3277        let buf = a.editor.document_buffer_id;
3278        let active = a
3279            .editor
3280            .active_modes
3281            .get(&buf)
3282            .expect("active_modes populated");
3283        assert_eq!(active.major(), Some(lattice_mode::TextMode::mode_id()));
3284    }
3285
3286    // ---- M.3.2.b.1: help-mode locals seeded at construction ----
3287
3288    #[test]
3289    fn renderer_reads_help_data_through_buffer_locals() {
3290        // M.3.2.c.5: BufferLocals are the canonical owner of help
3291        // per-buffer state -- the HelpBuffer struct no longer
3292        // carries `links` / `anchors` / `highlights` fields. Open
3293        // a help buffer (which seeds two parsed links into
3294        // locals), then mutate the locals; readers must reflect
3295        // the mutation since there's no struct-field fallback.
3296        let mut a = app_with("hi", 5);
3297        let help = crate::help::HelpContent::from_lines(
3298            "test-render",
3299            vec!["[link-a](command:a) and [link-b](command:b)".into()],
3300        );
3301        let help_id = a.open_help_in_pane(help);
3302
3303        let synthetic = crate::modes::HelpLinks(vec![crate::help::HelpLink {
3304            range: lattice_protocol::position::Range::new(
3305                lattice_protocol::position::Position::ZERO,
3306                lattice_protocol::position::Position::new(0, 5),
3307            ),
3308            target: crate::help::HelpLinkTarget::Unresolved("synthetic".into()),
3309        }]);
3310        a.editor
3311            .buffer_locals
3312            .get_mut(&help_id)
3313            .expect("locals seeded")
3314            .insert(synthetic);
3315
3316        let _ = a.popup_help().expect("popup_buffer set");
3317        let locals = a
3318            .editor
3319            .buffer_locals
3320            .get(&help_id)
3321            .expect("locals seeded by open_help_in_pane");
3322        let from_locals = locals.get::<crate::modes::HelpLinks>().unwrap();
3323        assert_eq!(from_locals.0.len(), 1);
3324        assert_eq!(
3325            from_locals.0[0].target,
3326            crate::help::HelpLinkTarget::Unresolved("synthetic".into())
3327        );
3328    }
3329
3330    #[test]
3331    fn help_buffer_gets_markdown_syntax_handle() {
3332        // PU.1b-1 (2A): `register_help_document` attaches a live
3333        // markdown `SyntaxHandle` so the cells worker builds the help
3334        // `DisplayMatrix` from grammar — the seam that lets help render
3335        // through `compose_pane_lines` like any document. Bespoke
3336        // renderers still paint this slice, so the attach is invisible;
3337        // here we just assert the handle is present.
3338        let mut a = app_with("hi", 5);
3339        let help = crate::help::HelpContent::from_lines(
3340            "syntax-check",
3341            vec!["# Title".into(), "body text".into()],
3342        );
3343        let help_id = a.open_help_in_pane(help);
3344        assert!(
3345            a.editor.document_syntax_for(help_id).is_some(),
3346            "help buffer should carry a markdown SyntaxHandle after register_help_document"
3347        );
3348    }
3349
3350    #[test]
3351    fn help_in_pane_seeds_link_extra_highlights() {
3352        // PU.1b-2b: opening help whose content has a link seeds the
3353        // link-only spans into `ExtraHighlights` so the cells worker
3354        // merges `Style::Link` into the help `DisplayMatrix` — the
3355        // grammar can't, since the `[label](target)` markup is stripped
3356        // before it parses.
3357        let mut a = app_with("hi", 5);
3358        let help_id = a.open_help_in_pane(crate::help::HelpContent::from_lines(
3359            "links",
3360            vec!["see [rust](mode:rust) docs".into()],
3361        ));
3362        let extra = a
3363            .editor
3364            .buffer_locals
3365            .get(&help_id)
3366            .and_then(|l| l.get::<crate::modes::ExtraHighlights>())
3367            .map(|e| e.0.clone())
3368            .unwrap_or_default();
3369        assert!(
3370            extra
3371                .iter()
3372                .flatten()
3373                .any(|s| s.style == lattice_syntax::Style::Link),
3374            "help link must seed a Style::Link span into ExtraHighlights for the matrix merge"
3375        );
3376    }
3377
3378    #[test]
3379    fn help_in_pane_swap_reseeds_links_and_syntax() {
3380        // PU.1b-2b: a same-title re-open swaps the help content in place
3381        // (reuses the buffer id) and routes through
3382        // `seed_help_metadata_locals`, which re-seeds `ExtraHighlights`
3383        // AND re-attaches the markdown SyntaxHandle from the NEW text —
3384        // so the matrix's link styling + grammar colour stay fresh across
3385        // swaps (link-follow / `<C-o>` take the same re-seed path).
3386        let mut a = app_with("hi", 5);
3387        let id1 = a.open_help_in_pane(crate::help::HelpContent::from_lines(
3388            "topic",
3389            vec!["[one](mode:rust)".into()],
3390        ));
3391        let id2 = a.open_help_in_pane(crate::help::HelpContent::from_lines(
3392            "topic",
3393            vec!["plain".into(), "[two](mode:zig) tail".into()],
3394        ));
3395        assert_eq!(
3396            id1, id2,
3397            "same-title re-open swaps in place (reuses the help buffer id)"
3398        );
3399        let extra = a
3400            .editor
3401            .buffer_locals
3402            .get(&id2)
3403            .and_then(|l| l.get::<crate::modes::ExtraHighlights>())
3404            .map(|e| e.0.clone())
3405            .unwrap_or_default();
3406        let has_link = |line: usize| {
3407            extra
3408                .get(line)
3409                .map(|v| v.iter().any(|s| s.style == lattice_syntax::Style::Link))
3410                .unwrap_or(false)
3411        };
3412        assert!(!has_link(0), "after swap, line 0 (plain) carries no link");
3413        assert!(has_link(1), "after swap, line 1 carries the new link");
3414        assert!(
3415            a.editor.document_syntax_for(id2).is_some(),
3416            "syntax handle re-attached against the swapped-in text"
3417        );
3418    }
3419
3420    // ---- M.3.2.c.2: file-tree-mode locals seeded + readers ----
3421
3422    // ---- M.3.2.c.3: oil-mode locals seeded ----
3423
3424    #[test]
3425    fn list_registers_with_no_state_says_so() {
3426        let mut a = app_with("hello", 10);
3427        submit_ex(&mut a, "reg");
3428        let msg = a.editor.last_message.as_ref().unwrap();
3429        assert!(msg.text.contains("no registers"));
3430    }
3431
3432    #[test]
3433    fn list_registers_includes_unnamed_and_zero() {
3434        let mut a = app_with("hello world", 10);
3435        let inv = CommandInvocation::of(a.editor.builtins.yank.0).with_target(
3436            lattice_grammar::Target::Motion(
3437                a.editor.builtins.word_forward,
3438                lattice_grammar::Args::None,
3439            ),
3440        );
3441        a.apply(Action::Invoke(inv));
3442        submit_ex(&mut a, "reg");
3443        let msg = a.editor.last_message.as_ref().unwrap();
3444        assert!(msg.text.contains("\"\""));
3445        assert!(msg.text.contains("\"0"));
3446    }
3447
3448    #[test]
3449    fn list_marks_with_no_marks_says_so() {
3450        let mut a = app_with("hello", 10);
3451        submit_ex(&mut a, "marks");
3452        let msg = a.editor.last_message.as_ref().unwrap();
3453        assert!(msg.text.contains("no marks"));
3454    }
3455
3456    #[test]
3457    fn list_marks_shows_set_marks() {
3458        let mut a = app_with("hello\nworld", 10);
3459        a.editor.cursor = Position::new(1, 2);
3460        a.apply(Action::SetMark('a'));
3461        submit_ex(&mut a, "marks");
3462        let msg = a.editor.last_message.as_ref().unwrap();
3463        assert!(msg.text.contains('a'));
3464        // Line 2 (1-indexed for display) at byte 2.
3465        assert!(msg.text.contains("2:2"));
3466    }
3467
3468    #[test]
3469    fn global_delete_matching_lines() {
3470        let mut a = app_with("foo\nbar\nfoo\nbaz", 10);
3471        submit_ex(&mut a, "g/foo/d");
3472        // Both "foo" lines deleted; "bar" and "baz" remain.
3473        assert_eq!(a.editor.document.text(), "bar\nbaz");
3474    }
3475
3476    #[test]
3477    fn vglobal_delete_non_matching_lines() {
3478        let mut a = app_with("foo\nbar\nfoo\nbaz", 10);
3479        submit_ex(&mut a, "v/foo/d");
3480        // Only "foo" lines remain.
3481        assert_eq!(a.editor.document.text(), "foo\nfoo");
3482    }
3483
3484    #[test]
3485    fn global_substitute_on_matching_lines() {
3486        let mut a = app_with("foo\nbaz\nfoo", 10);
3487        submit_ex(&mut a, "g/foo/s/foo/X/");
3488        // Both "foo" lines get substituted.
3489        assert_eq!(a.editor.document.text(), "X\nbaz\nX");
3490    }
3491
3492    #[test]
3493    fn global_no_matches_emits_error() {
3494        let mut a = app_with("hello\nworld", 10);
3495        submit_ex(&mut a, "g/xyz/d");
3496        let msg = a.editor.last_message.as_ref().unwrap();
3497        assert_eq!(msg.level, EchoLevel::Error);
3498    }
3499
3500    #[test]
3501    fn capital_w_skips_punctuation() {
3502        let mut a = app_with("foo,bar baz", 10);
3503        a.apply(invoke_motion(a.editor.builtins.big_word_forward));
3504        assert_eq!(a.editor.cursor, Position::new(0, 8));
3505    }
3506
3507    #[test]
3508    fn fold_action_clears_partial_chord() {
3509        let mut a = app_with("a\nb\nc", 10);
3510        a.apply(Action::AbsorbPartialChord(crate::chord::KeyChord::char(
3511            'z',
3512        )));
3513        a.apply(Action::OpenFoldAtCursor);
3514        assert!(a.editor.partial_chord.is_empty());
3515    }
3516
3517    #[test]
3518    fn yank_with_named_register_stores_into_named_and_unnamed() {
3519        let mut a = app_with("hello world", 10);
3520        a.apply(Action::SelectRegister(Register::Named('a')));
3521        let inv = CommandInvocation::of(a.editor.builtins.yank.0).with_target(
3522            lattice_grammar::Target::Motion(
3523                a.editor.builtins.word_forward,
3524                lattice_grammar::Args::None,
3525            ),
3526        );
3527        a.apply(Action::Invoke(inv));
3528        // Named slot populated.
3529        let named = a.editor.registers.get(&Register::Named('a')).unwrap();
3530        assert_eq!(named.content, "hello ");
3531        // Unnamed also populated.
3532        assert_eq!(
3533            a.editor.unnamed_register.as_ref().unwrap().content,
3534            "hello "
3535        );
3536        // Pending register consumed.
3537        assert!(a.editor.pending_register.is_none());
3538    }
3539
3540    #[test]
3541    fn yank_auto_populates_zero_register() {
3542        let mut a = app_with("hello world", 10);
3543        let inv = CommandInvocation::of(a.editor.builtins.yank.0).with_target(
3544            lattice_grammar::Target::Motion(
3545                a.editor.builtins.word_forward,
3546                lattice_grammar::Args::None,
3547            ),
3548        );
3549        a.apply(Action::Invoke(inv));
3550        let zero = a.editor.registers.get(&Register::Numbered(0)).unwrap();
3551        assert_eq!(zero.content, "hello ");
3552    }
3553
3554    #[test]
3555    fn yank_does_not_record_last_change() {
3556        let mut a = app_with("hello world", 10);
3557        let inv = CommandInvocation::of(a.editor.builtins.yank.0).with_target(
3558            lattice_grammar::Target::Motion(
3559                a.editor.builtins.word_forward,
3560                lattice_grammar::Args::None,
3561            ),
3562        );
3563        a.apply(Action::Invoke(inv));
3564        // Yank doesn't mutate the buffer; dot-repeat shouldn't pick this up.
3565        assert!(a.editor.last_change.is_none());
3566    }
3567
3568    #[test]
3569    fn change_records_last_change() {
3570        let mut a = app_with("hello world", 10);
3571        let inv = CommandInvocation::of(a.editor.builtins.change.0).with_target(
3572            lattice_grammar::Target::Motion(
3573                a.editor.builtins.word_forward,
3574                lattice_grammar::Args::None,
3575            ),
3576        );
3577        a.apply(Action::Invoke(inv));
3578        // change drops to Insert, but the change itself is recorded.
3579        assert!(a.editor.last_change.is_some());
3580    }
3581
3582    #[test]
3583    fn capital_f_jumps_backward() {
3584        let mut a = app_with("hello, world", 10);
3585        a.editor.cursor = Position::new(0, 11); // on 'd'
3586        let inv = CommandInvocation::of(a.editor.builtins.find_char_backward.0)
3587            .with_args(lattice_grammar::Args::Char('h'));
3588        a.apply(Action::Invoke(inv));
3589        assert_eq!(a.editor.cursor, Position::ZERO);
3590    }
3591
3592    fn app_in_command_mode(line: &str) -> App {
3593        let mut a = app_with("xx", 10);
3594        a.editor.modal = ModalState::Command;
3595        a.editor.set_command_line_text(line);
3596        a
3597    }
3598
3599    #[test]
3600    fn dismiss_completion_keeps_command_line_intact() {
3601        let mut a = app_in_command_mode("descri");
3602        a.apply(Action::CommandLineCompleteOrAdvance);
3603        a.apply(Action::CommandLineDismissCompletion);
3604        assert_eq!(a.editor.command_line(), "descri");
3605        assert!(a.editor.completion_state.is_none());
3606    }
3607
3608    #[test]
3609    fn clear_with_open_popup_widens_to_all_commands() {
3610        let mut a = app_in_command_mode("descri");
3611        a.apply(Action::CommandLineCompleteOrAdvance);
3612        let narrow_count = a.editor.completion_state.as_ref().unwrap().candidates.len();
3613        a.apply(Action::CommandLineClear);
3614        assert!(a.editor.completion_state.is_some());
3615        assert_eq!(a.editor.command_line(), "");
3616        let widened = a.editor.completion_state.as_ref().unwrap().candidates.len();
3617        assert!(widened >= narrow_count);
3618    }
3619
3620    #[test]
3621    /// DK.2: a token whose sequence is still a PREFIX stays on the line.
3622    ///
3623    /// `<C-w>` is the prefix here on purpose. A token that COMPLETES a
3624    /// sequence now submits immediately (the trie ends capture, not `<CR>`),
3625    /// so asserting on the command line after a bound chord would race the
3626    /// cmdline closing — which is what this test used to do with `<C-c>`.
3627    fn append_chord_concatenates_token() {
3628        let mut a = app_in_command_mode("describe-key ");
3629        a.apply(Action::CommandLineAppendChord("<C-w>".into()));
3630        assert_eq!(a.editor.command_line(), "describe-key <C-w>");
3631    }
3632
3633    /// Multi-stroke chords (`<C-w>j`) still accumulate token by token; only
3634    /// the token that completes the sequence ends capture.
3635    #[test]
3636    fn append_chord_supports_multi_token_sequences() {
3637        let mut a = app_in_command_mode("describe-key ");
3638        a.apply(Action::CommandLineAppendChord("<C-w>".into()));
3639        assert_eq!(
3640            a.editor.command_line(),
3641            "describe-key <C-w>",
3642            "the prefix alone must not submit"
3643        );
3644        a.apply(Action::CommandLineAppendChord("j".into()));
3645        assert!(
3646            matches!(a.editor.modal, ModalState::Normal),
3647            "`<C-w>j` is a complete sequence, so capture submits it"
3648        );
3649    }
3650
3651    #[test]
3652    fn close_last_pane_is_a_noop_with_warning() {
3653        let mut a = app_with("xx", 10);
3654        a.apply(Action::ClosePane);
3655        assert_eq!(a.editor.pane_tree.len(), 1);
3656        let msg = a.editor.last_message.as_ref().expect("warn echo");
3657        assert!(msg.text.contains("only one pane"));
3658    }
3659
3660    #[test]
3661    fn dismiss_focused_hover_restores_doc_cursor() {
3662        // Esc / q in State B routes to HelpDismiss, which restores
3663        // the pre-State-B cursor / scroll on the doc.
3664        let mut a = app_with("fn main() {}\nlet x = 1;\n", 5);
3665        a.editor.cursor = lattice_protocol::Position::new(1, 4);
3666        a.do_open_hover("hover body");
3667        a.apply_effect(lattice_grammar::Effect::Lsp(
3668            lattice_grammar::LspRequest::Hover,
3669        )); // -> State B (5.5.LSP.1)
3670        // Move inside the popup.
3671        let inv = lattice_grammar::CommandInvocation::of(a.editor.builtins.line_down.0);
3672        a.apply(Action::Invoke(inv));
3673        assert!(matches!(a.editor.active_buffer, BufferKind::Help));
3674        // Dismiss.
3675        a.apply(Action::HelpDismiss);
3676        assert!(a.editor.popup_buffer.is_none());
3677        assert!(matches!(a.editor.active_buffer, BufferKind::Document));
3678        assert_eq!(a.editor.cursor, lattice_protocol::Position::new(1, 4));
3679        assert!(a.editor.prev_pane_for_popup.is_none());
3680    }
3681
3682    #[test]
3683    fn opening_help_in_pane_swaps_document_and_preserves_underlying_syntax() {
3684        // Help opened in a pane is a real buffer switch, NOT a popup overlay
3685        // over the underlying document: the active pane is Help, the floating
3686        // overlay is suppressed (`render.rs` `active_pane_kind != Help`), so the
3687        // pane paints `self.document` — which must therefore FOLLOW the help
3688        // buffer, exactly as Messages / Multibuffer / Oil / FileTree do. When it
3689        // did not, a `:help` / `:describe-*` split rendered the buffer help
3690        // replaced instead of the help content ("describe-in-split shows the
3691        // previous buffer").
3692        //
3693        // The underlying document must not LOSE its syntax across the switch:
3694        // `activate_help_in_pane` -> `load_active_pane` ->
3695        // `snapshot_active_document` stashes it into the document's
3696        // buffer-locals while `active_buffer` is still Document, and the round
3697        // trip back restores it — the same invariant
3698        // `dismissing_tree_preserves_document_syntax_state` checks for the tree.
3699        let mut a = app_with("fn main() {}\n", 10);
3700        a.editor.terminal_width = Some(80);
3701        attach_test_syntax(&mut a, lattice_syntax::Lang::Rust);
3702        assert!(a.editor.syntax.is_some(), "fixture syntax wired");
3703        let doc_id = a
3704            .editor
3705            .buffers
3706            .document_ids_sorted()
3707            .first()
3708            .copied()
3709            .unwrap();
3710        // Open a help buffer in pane (mimics `:lsp-log rust`).
3711        let help_id =
3712            a.open_help_in_pane(HelpContent::from_lines("lsp:rust", vec!["log line".into()]));
3713        assert!(matches!(a.editor.active_buffer, BufferKind::Help));
3714        // The active document now IS the help buffer — the pane renders help,
3715        // not the buffer it replaced. This is what failed before the fix.
3716        assert_eq!(
3717            a.editor.document_buffer_id, help_id,
3718            "the active document must follow the help buffer so the pane paints \
3719             help content, not the underlying buffer"
3720        );
3721        // Round-trip back to the document: its syntax survived the switch.
3722        a.activate_document(doc_id);
3723        assert!(matches!(a.editor.active_buffer, BufferKind::Document));
3724        assert!(
3725            a.editor.syntax.is_some(),
3726            "syntax must survive the help-in-pane round trip"
3727        );
3728    }
3729
3730    #[test]
3731    fn dismissing_tree_preserves_document_syntax_state() {
3732        // Regression: opening `:Tree` and pressing `q` to dismiss
3733        // it returned to the document with `self.editor.syntax = None`,
3734        // so the renderer fell back to plain text (no
3735        // colours). Cause: the on-tree-open snapshot moved syntax
3736        // into the document entry, then activate_document on
3737        // dismiss called snapshot_active_document again and
3738        // overwrote the entry's stashed syntax with None.
3739        let dir = std::env::temp_dir().join(format!("lattice-tree-syntax-{}", std::process::id()));
3740        std::fs::create_dir_all(&dir).ok();
3741        let mut a = app_with("fn main() {}\n", 10);
3742        a.editor.terminal_width = Some(80);
3743        // Wire up a Rust syntax instance so there's something to lose.
3744        attach_test_syntax(&mut a, lattice_syntax::Lang::Rust);
3745        // Open the tree, then dismiss.
3746        a.editor
3747            .set_command_line_text(&format!("Filetree {}", dir.display()));
3748        a.editor.modal = ModalState::Command;
3749        a.apply(Action::CommandLineSubmit);
3750        assert!(matches!(
3751            a.editor.active_buffer,
3752            crate::buffers::BufferKind::FileTree
3753        ));
3754        // `:TreeClose` (the path `q` takes in the tree).
3755        a.editor.set_command_line_text("FiletreeClose");
3756        a.editor.modal = ModalState::Command;
3757        a.apply(Action::CommandLineSubmit);
3758        assert!(matches!(
3759            a.editor.active_buffer,
3760            crate::buffers::BufferKind::Document
3761        ));
3762        assert!(
3763            a.editor.syntax.is_some(),
3764            "syntax must survive the tree round-trip"
3765        );
3766        std::fs::remove_dir_all(&dir).ok();
3767    }
3768
3769    #[test]
3770    fn close_tree_pane_keeps_tree_in_registry() {
3771        // Trees now live in the unified buffer registry; closing
3772        // the only pane that referenced one leaves the tree
3773        // accessible via `:bn` / `:bp` / `:b N`. Use `:bd` to
3774        // actually drop it.
3775        let dir = std::env::temp_dir().join(format!("lattice-tree-gc-{}", std::process::id()));
3776        std::fs::create_dir_all(&dir).ok();
3777        let mut a = app_with("xx", 10);
3778        a.editor.terminal_width = Some(80);
3779        a.apply(Action::SplitPaneVertical);
3780        a.apply(Action::NavigatePane(PaneDirection::Right));
3781        a.editor
3782            .set_command_line_text(&format!("Filetree {}", dir.display()));
3783        a.editor.modal = ModalState::Command;
3784        a.apply(Action::CommandLineSubmit);
3785        assert_eq!(a.editor.buffers.file_tree_ids_sorted().len(), 1);
3786        a.apply(Action::ClosePane);
3787        // Tree stays in the registry post-close.
3788        assert_eq!(a.editor.buffers.file_tree_ids_sorted().len(), 1);
3789        std::fs::remove_dir_all(&dir).ok();
3790    }
3791
3792    #[test]
3793    fn bdelete_closes_active_buffer_and_picks_a_successor() {
3794        let path = write_temp_file("e", "alpha\n");
3795        let mut a = app_with("xx", 10);
3796        let initial_id = a.editor.document_buffer_id;
3797        a.editor
3798            .set_command_line_text(&format!("e {}", path.display()));
3799        a.editor.modal = ModalState::Command;
3800        a.apply(Action::CommandLineSubmit);
3801        // Now active = new buffer; delete it. Successor should
3802        // be initial_id.
3803        a.editor.set_command_line_text("bd");
3804        a.editor.modal = ModalState::Command;
3805        a.apply(Action::CommandLineSubmit);
3806        assert_eq!(a.editor.document_buffer_id, initial_id);
3807        // Listed-count: only the original document remains in the
3808        // user-facing cycle; the synthetic `*lsp*` is unlisted.
3809        assert_eq!(a.editor.buffers.listed_ids_sorted().len(), 1);
3810        let _ = std::fs::remove_file(path);
3811    }
3812
3813    #[test]
3814    fn bdelete_only_buffer_is_rejected() {
3815        let mut a = app_with("xx", 10);
3816        a.editor.set_command_line_text("bd");
3817        a.editor.modal = ModalState::Command;
3818        a.apply(Action::CommandLineSubmit);
3819        // The listed count gates the "only buffer" check.
3820        // Synthetic unlisted buffers (`*lsp*`, ...) don't count as
3821        // switch destinations, so the rejection still fires when
3822        // only one user-listed buffer remains.
3823        assert_eq!(a.editor.buffers.listed_ids_sorted().len(), 1);
3824        let msg = a.editor.last_message.as_ref().expect("error echo");
3825        assert!(msg.text.contains("only buffer"));
3826    }
3827
3828    #[test]
3829    fn opening_new_file_seeds_folds_for_indent_foldmethod() {
3830        // foldmethod=indent on the initial buffer; then `:e <new>`
3831        // should populate folds for the new buffer without requiring
3832        // a manual `<C-l>` redraw.
3833        let path = write_temp_file(
3834            "activate-folds-indent",
3835            "a:\n    x\n    y\nb:\n    p\n    q\n",
3836        );
3837        let mut a = app_with("xx", 10);
3838        a.set_foldmethod_for_test(FoldMethod::Indent);
3839        a.editor
3840            .set_command_line_text(&format!("e {}", path.display()));
3841        a.editor.modal = ModalState::Command;
3842        a.apply(Action::CommandLineSubmit);
3843        // The new buffer should have folds without `<C-l>`.
3844        assert!(
3845            !a.editor.folds.is_empty(),
3846            "expected folds to be seeded on activation, got empty"
3847        );
3848        assert!(
3849            a.editor.folds.iter().any(|f| f.start_line == 0),
3850            "expected a fold starting at line 0: {:?}",
3851            a.editor.folds
3852        );
3853        let _ = std::fs::remove_file(path);
3854    }
3855
3856    #[test]
3857    fn docs_toggle_pulls_body_from_cached_metadata_documentation() {
3858        let mut a = app_with("xx", 10);
3859        a.editor.modal = ModalState::Insert;
3860        a.editor.cursor = Position::ZERO;
3861        // Seed popup state with a single LSP candidate that
3862        // already has documentation cached.
3863        let mut state = lattice_completion::InsertCompletionState::open(
3864            lattice_completion::CompletionTrigger::Manual,
3865            Position::ZERO,
3866            Position::ZERO,
3867            String::new(),
3868        );
3869        let meta = super::LspCompletionMeta {
3870            label: "foo".into(),
3871            insert_text: "foo".into(),
3872            filter_text: None,
3873            sort_text: None,
3874            detail: Some("fn foo() -> i32".into()),
3875            documentation: Some("Returns 42.".into()),
3876            kind: Some(lattice_lsp::lsp_types::CompletionItemKind::FUNCTION),
3877            deprecated: false,
3878            preselect: false,
3879            commit_characters: Vec::new(),
3880            additional_text_edits: Vec::new(),
3881            command: None,
3882            insert_text_format: lattice_lsp::lsp_types::InsertTextFormat::PLAIN_TEXT,
3883            replace_range: None,
3884            server_id: "test-server".to_string(),
3885            original_item: lattice_lsp::lsp_types::CompletionItem::default(),
3886            resolved: true,
3887        };
3888        let mut raw = lattice_completion::RawCandidate::plain(
3889            "foo",
3890            lattice_completion::CandidateKind::Plain,
3891        );
3892        raw.display = "foo".into();
3893        raw.data = lattice_completion::CandidateData::Extension {
3894            kind_id: super::LSP_COMPLETION_KIND_ID,
3895            payload: lattice_lsp::completion::encode_meta(&meta),
3896        };
3897        let scored = lattice_completion::ScoredCandidate {
3898            raw,
3899            score: lattice_completion::MatchScore(100),
3900            match_ranges: Vec::new(),
3901        };
3902        state
3903            .rendered
3904            .push(lattice_completion::RenderedCandidate::from_scored(scored));
3905        // CSM.8b.5: candidate payload IS the meta -- no sidecar push.
3906        let _ = meta;
3907        a.editor.insert_completion = Some(state);
3908        a.do_completion_toggle_docs();
3909        let body = a
3910            .editor
3911            .insert_completion
3912            .as_ref()
3913            .and_then(|s| s.doc_popup.as_ref())
3914            .and_then(|d| d.body.clone())
3915            .expect("body populated");
3916        assert!(body.contains("fn foo() -> i32"));
3917        assert!(body.contains("Returns 42."));
3918    }
3919
3920    #[test]
3921    fn docs_toggle_a_second_time_closes_popup() {
3922        let mut a = app_with("xx", 10);
3923        a.editor.modal = ModalState::Insert;
3924        a.editor.cursor = Position::ZERO;
3925        a.editor.insert_completion = Some(lattice_completion::InsertCompletionState::open(
3926            lattice_completion::CompletionTrigger::Manual,
3927            Position::ZERO,
3928            Position::ZERO,
3929            String::new(),
3930        ));
3931        a.do_completion_toggle_docs();
3932        // Even with no candidate, the popup opens with an
3933        // empty body slot. Toggling again closes it.
3934        let was_open = a
3935            .editor
3936            .insert_completion
3937            .as_ref()
3938            .map(|s| s.doc_popup.is_some())
3939            .unwrap_or(false);
3940        assert!(was_open);
3941        a.do_completion_toggle_docs();
3942        let now_closed = a
3943            .editor
3944            .insert_completion
3945            .as_ref()
3946            .map(|s| s.doc_popup.is_none())
3947            .unwrap_or(true);
3948        assert!(now_closed);
3949    }
3950
3951    #[test]
3952    fn docs_scroll_clamps_at_zero_and_advances_by_eight() {
3953        let mut a = app_with("xx", 10);
3954        a.editor.modal = ModalState::Insert;
3955        a.editor.cursor = Position::ZERO;
3956        a.editor.insert_completion = Some(lattice_completion::InsertCompletionState::open(
3957            lattice_completion::CompletionTrigger::Manual,
3958            Position::ZERO,
3959            Position::ZERO,
3960            String::new(),
3961        ));
3962        a.do_completion_toggle_docs();
3963        // Default scroll is 0; up clamps at 0.
3964        assert_eq!(
3965            a.editor
3966                .insert_completion
3967                .as_ref()
3968                .and_then(|s| s.doc_popup.as_ref())
3969                .map(|d| d.scroll),
3970            Some(0)
3971        );
3972        a.apply(Action::CompletionDocsScrollUp);
3973        assert_eq!(
3974            a.editor
3975                .insert_completion
3976                .as_ref()
3977                .and_then(|s| s.doc_popup.as_ref())
3978                .map(|d| d.scroll),
3979            Some(0)
3980        );
3981        a.apply(Action::CompletionDocsScrollDown);
3982        assert_eq!(
3983            a.editor
3984                .insert_completion
3985                .as_ref()
3986                .and_then(|s| s.doc_popup.as_ref())
3987                .map(|d| d.scroll),
3988            Some(8)
3989        );
3990        a.apply(Action::CompletionDocsScrollDown);
3991        assert_eq!(
3992            a.editor
3993                .insert_completion
3994                .as_ref()
3995                .and_then(|s| s.doc_popup.as_ref())
3996                .map(|d| d.scroll),
3997            Some(16)
3998        );
3999        a.apply(Action::CompletionDocsScrollUp);
4000        assert_eq!(
4001            a.editor
4002                .insert_completion
4003                .as_ref()
4004                .and_then(|s| s.doc_popup.as_ref())
4005                .map(|d| d.scroll),
4006            Some(8)
4007        );
4008    }
4009
4010    #[test]
4011    fn follow_link_source_opens_file_at_line() {
4012        // `:describe-command :lsp-trace` (and similar) renders a
4013        // `[<source>](file:PATH:LINE)` link. Following it should
4014        // open the file via the multi-buffer machinery and
4015        // position the cursor at the requested line. Pre-fix this
4016        // arm just echoed "(file open arrives with multi-buffer)"
4017        // -- we already had multi-buffer; the placeholder was
4018        // stale.
4019        let path = std::env::temp_dir().join(format!("lattice-srclink-{}.rs", std::process::id()));
4020        std::fs::write(&path, "first\nsecond\nthird\nfourth\n").unwrap();
4021        let mut a = app_with("xx", 10);
4022        // Open a help buffer so the active modal/buffer state
4023        // matches what `FollowLink` expects.
4024        a.editor.set_command_line_text("help");
4025        a.editor.modal = ModalState::Command;
4026        a.apply(Action::CommandLineSubmit);
4027        // Build a synthetic source link inside the help buffer.
4028        // 1-based line number: line 3 in the file → cursor at
4029        // line index 2 in the buffer.
4030        let link = crate::help::HelpLink {
4031            range: lattice_protocol::Range::new(
4032                lattice_protocol::Position::ZERO,
4033                lattice_protocol::Position::new(0, 1),
4034            ),
4035            target: crate::help::HelpLinkTarget::Source {
4036                path: path.clone(),
4037                line: 3,
4038            },
4039        };
4040        // M.3.2.c.5: production reads route through buffer_locals;
4041        // seed the synthetic link there directly. The locals key
4042        // is the popup buffer's construction id (centred-popup
4043        // resolution rule).
4044        let buf_id = a.editor.popup_buffer.unwrap();
4045        // PU.1a: the focused help cursor is `editor.cursor`
4046        // (`popup_cursor` is the persisted stash). Zero both so
4047        // FollowLink reads the link at (0,0).
4048        a.editor.cursor = lattice_protocol::Position::ZERO;
4049        a.editor.popup_cursor = lattice_protocol::Position::ZERO;
4050        let mut existing_links = a
4051            .editor
4052            .buffer_locals
4053            .get(&buf_id)
4054            .and_then(|l| l.get::<crate::modes::HelpLinks>())
4055            .map(|l| l.0.clone())
4056            .unwrap_or_default();
4057        existing_links.push(link);
4058        a.editor
4059            .buffer_locals
4060            .entry(buf_id)
4061            .or_default()
4062            .insert(crate::modes::HelpLinks(existing_links));
4063        a.editor.active_buffer = BufferKind::Help;
4064        a.apply(Action::FollowLink);
4065        // The file should now be the active document.
4066        assert_eq!(a.editor.active_buffer, BufferKind::Document);
4067        let opened = a.editor.document.path().expect("active doc has a path");
4068        assert_eq!(opened, path);
4069        // Cursor at line index 2 (1-based 3 → 0-based 2).
4070        assert_eq!(a.editor.cursor.line, 2);
4071        // NOTE: a `PluginPush` history entry is pushed *before*
4072        // `do_edit` runs, but `do_edit`'s new-file branch clears
4073        // the position history (so a fresh buffer's `<C-o>` doesn't
4074        // walk into the previous buffer's positions). That means
4075        // cross-buffer jumps from FollowLink and from
4076        // `jump_to_lsp_location` currently lose their walk-back
4077        // entry. Per-buffer position history is queued as the
4078        // proper fix; for now this test asserts the open-and-jump
4079        // primary behaviour and lets the history side-effect
4080        // regress until that fix lands.
4081        let _ = std::fs::remove_file(path);
4082    }
4083
4084    #[test]
4085    fn follow_link_source_clamps_line_past_eof() {
4086        let path =
4087            std::env::temp_dir().join(format!("lattice-srclink-clamp-{}.rs", std::process::id()));
4088        std::fs::write(&path, "only-line\n").unwrap();
4089        let mut a = app_with("xx", 10);
4090        a.editor.set_command_line_text("help");
4091        a.editor.modal = ModalState::Command;
4092        a.apply(Action::CommandLineSubmit);
4093        let link = crate::help::HelpLink {
4094            range: lattice_protocol::Range::new(
4095                lattice_protocol::Position::ZERO,
4096                lattice_protocol::Position::new(0, 1),
4097            ),
4098            target: crate::help::HelpLinkTarget::Source {
4099                path: path.clone(),
4100                line: 999,
4101            },
4102        };
4103        // M.3.2.c.5: production reads route through buffer_locals;
4104        // seed the synthetic link there directly. The locals key
4105        // is the popup buffer's construction id (centred-popup
4106        // resolution rule).
4107        let buf_id = a.editor.popup_buffer.unwrap();
4108        // PU.1a: the focused help cursor is `editor.cursor`
4109        // (`popup_cursor` is the persisted stash). Zero both so
4110        // FollowLink reads the link at (0,0).
4111        a.editor.cursor = lattice_protocol::Position::ZERO;
4112        a.editor.popup_cursor = lattice_protocol::Position::ZERO;
4113        let mut existing_links = a
4114            .editor
4115            .buffer_locals
4116            .get(&buf_id)
4117            .and_then(|l| l.get::<crate::modes::HelpLinks>())
4118            .map(|l| l.0.clone())
4119            .unwrap_or_default();
4120        existing_links.push(link);
4121        a.editor
4122            .buffer_locals
4123            .entry(buf_id)
4124            .or_default()
4125            .insert(crate::modes::HelpLinks(existing_links));
4126        a.editor.active_buffer = BufferKind::Help;
4127        a.apply(Action::FollowLink);
4128        // Out-of-range line should clamp to the last valid line,
4129        // not panic and not echo a confusing error.
4130        let last_line = a
4131            .editor
4132            .document
4133            .snapshot()
4134            .buffer
4135            .content_line_count()
4136            .saturating_sub(1);
4137        assert_eq!(a.editor.cursor.line, last_line);
4138        let _ = std::fs::remove_file(path);
4139    }
4140
4141    #[test]
4142    fn yank_then_paste_round_trips_word() {
4143        let mut a = app_with("hello world", 10);
4144        let yank = CommandInvocation::of(a.editor.builtins.yank.0).with_target(
4145            lattice_grammar::Target::Motion(
4146                a.editor.builtins.word_forward,
4147                lattice_grammar::Args::None,
4148            ),
4149        );
4150        a.apply(Action::Invoke(yank));
4151        // Move cursor to end of buffer.
4152        a.editor.cursor = Position::new(0, 11);
4153        a.apply(Action::PasteAfter);
4154        assert_eq!(a.editor.document.text(), "hello worldhello ");
4155    }
4156
4157    #[tokio::test(flavor = "multi_thread")]
4158    async fn open_lsp_log_in_pane_renders_per_server_records() {
4159        // Slice B: per-server log lives as a Document in the
4160        // registry; records arrive through the event-bus drain
4161        // (`drain_lsp_log_events`) rather than a snapshot rebuild.
4162        // B'.4: use cwd-backed instance so it matches
4163        // `resolve_lsp_instance_for("rust")`'s no-actor fallback.
4164        let mut app = app_with("hi\n", 5);
4165        let instance = lattice_lsp::InstanceKey::new(
4166            std::sync::Arc::<str>::from("rust"),
4167            std::sync::Arc::<std::path::Path>::from(
4168                std::env::current_dir()
4169                    .unwrap_or_else(|_| std::path::PathBuf::from("/"))
4170                    .as_path(),
4171            ),
4172        );
4173        app.editor.lsp_logger.log(
4174            Some(&instance),
4175            lattice_lsp::LogLevel::Warn,
4176            lattice_lsp::LogSource::Stderr,
4177            "compile error",
4178        );
4179        // `open_lsp_log_in_pane` drains queued events first so the
4180        // buffer reflects pre-existing records.
4181        app.open_lsp_log_in_pane("rust");
4182        // Wait for LspServerLogMode's tokio task to drain the
4183        // pre-open record.
4184        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4185        let log_id = app
4186            .editor
4187            .buffers
4188            .by_name(&lattice_lsp::lsp_server_log_name(&instance))
4189            .expect("per-instance log buffer registered");
4190        assert_eq!(app.active_pane_buffer_id(), log_id);
4191        let body = app
4192            .editor
4193            .buffers
4194            .document_handle(log_id)
4195            .expect("log buffer is a Document")
4196            .text();
4197        assert!(body.contains("compile error"), "got `{body}`");
4198    }
4199
4200    #[tokio::test(flavor = "multi_thread")]
4201    async fn open_lsp_log_in_pane_excludes_trace_records() {
4202        let mut app = app_with("hi\n", 5);
4203        let instance = lattice_lsp::InstanceKey::new(
4204            std::sync::Arc::<str>::from("rust"),
4205            std::sync::Arc::<std::path::Path>::from(
4206                std::env::current_dir()
4207                    .unwrap_or_else(|_| std::path::PathBuf::from("/"))
4208                    .as_path(),
4209            ),
4210        );
4211        app.editor.lsp_logger.enable_trace(instance.clone());
4212        app.editor.lsp_logger.log(
4213            Some(&instance),
4214            lattice_lsp::LogLevel::Trace,
4215            lattice_lsp::LogSource::Trace,
4216            "→ Request id=1",
4217        );
4218        app.editor.lsp_logger.log(
4219            Some(&instance),
4220            lattice_lsp::LogLevel::Info,
4221            lattice_lsp::LogSource::Client,
4222            "lifecycle",
4223        );
4224        app.open_lsp_log_in_pane("rust");
4225        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4226        let log_id = app
4227            .editor
4228            .buffers
4229            .by_name(&lattice_lsp::lsp_server_log_name(&instance))
4230            .expect("per-instance log buffer registered");
4231        let body = app.editor.buffers.document_handle(log_id).unwrap().text();
4232        // Trace records route to the trace buffer; non-trace
4233        // records (including lifecycle) land here.
4234        assert!(!body.contains("→ Request"), "got `{body}`");
4235        assert!(body.contains("lifecycle"), "got `{body}`");
4236    }
4237
4238    #[tokio::test(flavor = "multi_thread")]
4239    async fn open_lsp_trace_log_in_pane_shows_only_trace_records() {
4240        let mut app = app_with("hi\n", 5);
4241        let instance = lattice_lsp::InstanceKey::new(
4242            std::sync::Arc::<str>::from("rust"),
4243            std::sync::Arc::<std::path::Path>::from(
4244                std::env::current_dir()
4245                    .unwrap_or_else(|_| std::path::PathBuf::from("/"))
4246                    .as_path(),
4247            ),
4248        );
4249        app.editor.lsp_logger.enable_trace(instance.clone());
4250        app.editor.lsp_logger.log(
4251            Some(&instance),
4252            lattice_lsp::LogLevel::Trace,
4253            lattice_lsp::LogSource::Trace,
4254            "→ Request id=1",
4255        );
4256        app.editor.lsp_logger.log(
4257            Some(&instance),
4258            lattice_lsp::LogLevel::Info,
4259            lattice_lsp::LogSource::Client,
4260            "lifecycle",
4261        );
4262        app.open_lsp_trace_log_in_pane("rust");
4263        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4264        let trace_id = app
4265            .editor
4266            .buffers
4267            .by_name(&lattice_lsp::lsp_server_trace_log_name(&instance))
4268            .expect("per-instance trace buffer registered");
4269        let body = app.editor.buffers.document_handle(trace_id).unwrap().text();
4270        // Trace records here; non-trace records routed to the
4271        // matching `*lsp:rust:<ws>*` buffer instead.
4272        assert!(body.contains("→ Request"), "got `{body}`");
4273        assert!(!body.contains("lifecycle"), "got `{body}`");
4274    }
4275
4276    #[test]
4277    fn app_boot_registers_every_built_in_major_mode() {
4278        let a = app_with("hi", 5);
4279        // Foundation
4280        assert!(
4281            a.editor
4282                .mode_registry
4283                .load()
4284                .is_registered(lattice_mode::TextMode::mode_id())
4285        );
4286        // Languages (lattice-syntax)
4287        assert!(
4288            a.editor
4289                .mode_registry
4290                .load()
4291                .is_registered(lattice_syntax::RustMode::mode_id())
4292        );
4293        assert!(
4294            a.editor
4295                .mode_registry
4296                .load()
4297                .is_registered(lattice_syntax::PythonMode::mode_id())
4298        );
4299        assert!(
4300            a.editor
4301                .mode_registry
4302                .load()
4303                .is_registered(lattice_syntax::JavascriptMode::mode_id())
4304        );
4305        assert!(
4306            a.editor
4307                .mode_registry
4308                .load()
4309                .is_registered(lattice_syntax::MarkdownMode::mode_id())
4310        );
4311        // Buffer-kind majors (lattice-ui-tui)
4312        assert!(
4313            a.editor
4314                .mode_registry
4315                .load()
4316                .is_registered(crate::modes::HelpMode::mode_id())
4317        );
4318        assert!(
4319            a.editor
4320                .mode_registry
4321                .load()
4322                .is_registered(crate::modes::FileTreeMode::mode_id())
4323        );
4324        assert!(
4325            a.editor
4326                .mode_registry
4327                .load()
4328                .is_registered(crate::modes::OilMode::mode_id())
4329        );
4330        // LSP log majors (lattice-lsp)
4331        assert!(
4332            a.editor
4333                .mode_registry
4334                .load()
4335                .is_registered(lattice_lsp::modes::LspLogMode::mode_id())
4336        );
4337        assert!(
4338            a.editor
4339                .mode_registry
4340                .load()
4341                .is_registered(lattice_lsp::modes::LspTraceLogMode::mode_id())
4342        );
4343        assert!(
4344            a.editor
4345                .mode_registry
4346                .load()
4347                .is_registered(lattice_lsp::modes::LspServerLogMode::mode_id())
4348        );
4349    }
4350
4351    #[test]
4352    fn open_file_tree_seeds_file_tree_locals() {
4353        // Construct a temp dir + file, open as a tree, confirm
4354        // the locals are populated.
4355        let tmp = std::env::temp_dir().join(format!("lattice-m3-2-c-2-{}", std::process::id()));
4356        let _ = std::fs::create_dir_all(&tmp);
4357        let f = tmp.join("file.txt");
4358        let _ = std::fs::write(&f, "hi");
4359
4360        let mut a = app_with("hi", 5);
4361        // Drive via the production path. `do_open_file_tree`
4362        // constructs the FileTreeBuffer, calls
4363        // `seed_file_tree_locals`, inserts into the registry,
4364        // and activates the pane on it.
4365        a.do_open_file_tree(Some(tmp.clone()));
4366        let tree_id = a.active_pane_buffer_id();
4367
4368        let locals = a
4369            .editor
4370            .buffer_locals
4371            .get(&tree_id)
4372            .expect("file-tree locals seeded");
4373        let root = locals
4374            .get::<crate::modes::FileTreeRoot>()
4375            .expect("FileTreeRoot local present");
4376        assert_eq!(root.0, tmp);
4377        let entries = locals
4378            .get::<crate::modes::FileTreeEntries>()
4379            .expect("FileTreeEntries local present");
4380        // At minimum the root row + the file row.
4381        assert!(entries.0.len() >= 2);
4382        // FileTreeNerdFonts seeded; concrete value matches
4383        // App's `theme.nerd_fonts` (we don't assert a specific
4384        // boolean since the theme default may evolve -- the
4385        // important contract is "the local exists post-seed").
4386        assert!(locals.get::<crate::modes::FileTreeNerdFonts>().is_some());
4387
4388        let _ = std::fs::remove_dir_all(&tmp);
4389    }
4390
4391    #[test]
4392    fn set_ui_nerd_fonts_reissues_open_file_tree_icons() {
4393        // Regression for the bug where toggling `ui.nerd_fonts` updated
4394        // the theme but left open trees showing the old palette.
4395        //
4396        // DL.4 moved WHERE that shows. The glyph used to be baked into
4397        // the rope, so the test asserted on the buffer text; icons are
4398        // virtual text now, so the rope holds names and the palette
4399        // flip has to re-publish the ICONS instead. The regression the
4400        // test guards is unchanged — flip the option, an open tree must
4401        // follow — only the surface it reads moved.
4402        let tmp =
4403            std::env::temp_dir().join(format!("lattice-tree-nerd-rerender-{}", std::process::id()));
4404        let _ = std::fs::create_dir_all(&tmp);
4405        std::fs::write(tmp.join("main.rs"), "").unwrap();
4406
4407        let mut a = app_with("hi", 5);
4408        a.do_open_file_tree(Some(tmp.clone()));
4409        let tree_id = a.active_pane_buffer_id();
4410
4411        // The rope is names — no glyph, either palette.
4412        let body = a
4413            .editor
4414            .buffers
4415            .document_handle(tree_id)
4416            .map(|h| h.snapshot().buffer.as_string())
4417            .unwrap();
4418        assert!(body.contains("main.rs"), "rope must hold the name: {body}");
4419        for glyph in ["\u{f1617} ", "· "] {
4420            assert!(
4421                !body.contains(glyph),
4422                "glyph {glyph:?} leaked into the rope: {body}"
4423            );
4424        }
4425
4426        // Icons publish through `PendingInlays` and land in the local on
4427        // the next tick, exactly as in production.
4428        let icons = |a: &mut App| -> Vec<String> {
4429            let _ = a.editor.run_tick_pending();
4430            a.editor
4431                .buffer_locals
4432                .get(&tree_id)
4433                .and_then(|l| l.get::<lattice_host::modes::ExtraInlays>())
4434                .map(|e| e.0.iter().map(|r| r.text.clone()).collect())
4435                .unwrap_or_default()
4436        };
4437
4438        let before = icons(&mut a);
4439        assert!(
4440            !before.is_empty(),
4441            "the tree must publish one icon per row before the toggle"
4442        );
4443
4444        // Flip the typed option via the same path `:set` takes.
4445        submit_ex(&mut a, "set ui.nerd_fonts=on");
4446
4447        let after = icons(&mut a);
4448        assert_ne!(
4449            before, after,
4450            "flipping ui.nerd_fonts must re-publish the tree's icons — an \
4451             open tree that keeps the old palette is the reported bug"
4452        );
4453
4454        let _ = std::fs::remove_dir_all(&tmp);
4455    }
4456
4457    #[test]
4458    fn follow_link_reads_link_from_buffer_locals() {
4459        // M.3.2.c.1: prove `do_help_follow_link` reads the
4460        // link data from `buffer_locals` (canonical), not the
4461        // HelpBuffer's struct field. We open a help buffer,
4462        // overwrite its locals with a synthetic link pointing
4463        // somewhere different than the buffer's actual links,
4464        // and verify FollowLink dispatches based on the
4465        // locals-side link.
4466        let mut a = app_with("xx", 10);
4467        let help = crate::help::HelpContent::from_lines(
4468            "test-locals-link",
4469            vec!["plain text -- no markdown link".into()],
4470        );
4471        let help_id = a.open_help_in_pane(help);
4472
4473        // Replace the locals-side links with a synthetic
4474        // Topic link that the production reader should pick
4475        // up -- the HelpBuffer's own `links` is empty (no
4476        // markdown link in the source), so without the
4477        // locals-first read, FollowLink would say "no link
4478        // under cursor".
4479        let synthetic = crate::modes::HelpLinks(vec![crate::help::HelpLink {
4480            range: lattice_protocol::Range::new(
4481                lattice_protocol::Position::ZERO,
4482                lattice_protocol::Position::new(0, 5),
4483            ),
4484            target: crate::help::HelpLinkTarget::Topic("synthetic-topic".into()),
4485        }]);
4486        a.editor
4487            .buffer_locals
4488            .get_mut(&help_id)
4489            .expect("locals seeded")
4490            .insert(synthetic);
4491
4492        a.editor.cursor = lattice_protocol::Position::new(0, 0);
4493        // `open_help_in_pane` already activates the pane on
4494        // the registered help buffer; FollowLink reads
4495        // `pane_tree.active().buffer_id` to look up locals.
4496        a.apply(Action::FollowLink);
4497
4498        // The link target was `help:synthetic-topic`; the
4499        // FollowLink path routes Topic targets to
4500        // `:help <topic>`. The topic doesn't exist so we
4501        // expect an info echo about the topic; the key
4502        // assertion is "the link was found and dispatched"
4503        // which we observe via the message kind. If the
4504        // production path had read from the (empty) struct
4505        // field, the message would have been "no link under
4506        // cursor".
4507        let msg = a
4508            .editor
4509            .last_message
4510            .as_ref()
4511            .expect("echo set by FollowLink");
4512        assert!(
4513            !msg.text.contains("no link under cursor"),
4514            "production reader should have found the link via buffer_locals, \
4515             got message: {}",
4516            msg.text
4517        );
4518    }
4519}