Skip to main content

lattice_ui_tui/app/
lsp.rs

1//! LSP feature surface -- App methods for the various
2//! `:lsp-*` ex commands (admin / log / trace / status /
3//! restart) plus the request-driven LSP feature methods
4//! (hover, definition, references, completion, format,
5//! rename, code action, document / workspace symbols).
6//!
7//! Methods that live here:
8//! - LSP admin / log / trace ex-commands:
9//!   - do_open_lsp_log (`:lsp-log [server]`),
10//!   - do_open_lsp_trace_log (`:lsp-trace-log [server]`),
11//!   - do_toggle_lsp_trace (`:lsp-trace <name>`),
12//!   - do_lsp_status (`:lsp-status`),
13//!   - do_lsp_server_log_listing (`:lsp-server-log`),
14//!   - do_lsp_restart (`:lsp-restart <server>`),
15//!   - do_set_lsp_log_level
16//!     (`:lsp-log-level [server] <level>`),
17//!   - do_lsp_log_clear (`:lsp-log-clear [server]`).
18//! - LSP request handlers + their drain pumps:
19//!   - hover, nav (`gd` / `gD` / `gy` / `gI`),
20//!     references, signature help, completion (palette +
21//!     Insert-mode popup), document / workspace symbols,
22//!     format / format-range, rename, code action.
23//!   - drain_pending_lsp_*, drain_pending_completion_resolve,
24//!     drain_pending_insert_completion_lsp, etc.
25//! - apply_lsp_text_edits / apply_lsp_workspace_edit and
26//!   the per-feature outcome appliers
27//!   (apply_lsp_completion_accept, apply_lsp_format_outcome,
28//!   apply_lsp_rename_outcome, apply_code_action_outcome,
29//!   ...).
30//! - LSP completion meta sidecar + helpers
31//!   (lsp_completion_meta_for, dedup_rendered_by_text,
32//!   docs_body_for_selected, selected_needs_resolve).
33//! - apply_persistent_lsp_editor_options (lifecycle bridge)
34//!   and execute_lsp_command.
35//! - resolve_server_id / running_server_ids (pub(super);
36//!   shared with picker.rs).
37//!
38//! What does NOT live here: the LSP wire layer / actor /
39//! supervisor (those live in `lattice-lsp`). This module is
40//! about App's *consumption* of that layer.
41
42use lattice_protocol::position::Position;
43
44// 5.8.S-AA: several outcome types moved their drain bodies host-
45// side; the leftover `use` here keeps test-scope references
46// (`super::*Outcome`, `super::LspNavKind`) resolving. `#[allow]`
47// silences the unused-import warning in the non-test build.
48#[allow(unused_imports)]
49use super::{
50    App, AsyncCompletionOutcome, CodeActionOutcome, CodeActionRow, CompletionItemRow,
51    CompletionOutcome, CompletionResolveOutcome, EchoLevel, FormatOutcome, LSP_COMPLETION_KIND_ID,
52    LspCompletionMeta, LspNavKind, ReferencesOutcome, RenameOutcome, SignatureHelpOutcome,
53    SymbolRow, SymbolsOutcome, TagStackEntry, app_to_lsp_position, call_hierarchy_to_row,
54    code_action_kind_glyph, dedup_rendered_by_text, flatten_workspace_edit, last_addressable_line,
55    line_byte_len, lsp_position_to_app_byte, prepare_rename_placeholder, range_covers,
56    type_hierarchy_to_row, word_under_cursor,
57};
58// 5.5.LSP.1 / LSP.2 / LSP.4 / LSP.5: test-only utility imports
59// after the corresponding request-side migrations. Tests reach
60// them via `super::<fn>(...)` so they need to live in the `mod lsp`
61// scope, but `cfg(test)` keeps them out of release builds (where
62// `deny(unused_imports)` would flag them).
63#[cfg(test)]
64use super::{
65    definition_response_to_locations, flatten_document_symbol_response, hover_contents_to_markdown,
66    signature_help_to_markdown,
67};
68use crate::buffers::BufferId;
69use lattice_protocol::edit::Edit;
70
71// CSM.8b.3: host-side [`lattice_completion::CandidateSink`]
72// impl that buffers each `produce_async` push into a single
73// batch. The aggregator spawns the source's future, awaits
74// it, then drains the sink onto the existing
75// `AsyncCompletionOutcome::Items` channel -- the drain
76// path keeps its "replace prior LSP slice" semantics
77// untouched. `is_incomplete` rides on
78// [`lattice_completion::CandidateSink::mark_incomplete`].
79// Phase 5.8.AD.4: `BatchingSink` migrated to host as
80// `InsertCompletionBatchingSink` (private to `lattice_host::dispatch`).
81
82impl App {
83    /// M.5.0: is `lsp-mode` active for `buffer_id`? Every LSP
84    /// entry point (hover, completion, diagnostics-render,
85    /// document-sync, ...) gates on this in subsequent slices;
86    /// returns `true` when the minor is in
87    /// `active_modes[buffer_id]`'s minor list, `false`
88    /// otherwise (no buffer registered, or mode not active on
89    /// it).
90    ///
91    /// In M.5.0 nothing reads this -- the surface is here so
92    /// M.5.2 (auto-activation hook), M.5.3 (lifecycle), and
93    /// M.5.4+ (gates) have a single accessor to consume.
94    /// 5.5.F.5.1: see [`lattice_host::dispatch::Editor::lsp_mode_enabled_for`].
95    ///
96    /// Slice 3c.extension.fold-rs: route through the published
97    /// `ModesRenderState` (B.11) instead of `read_editor`. Per-frame
98    /// hot path — every paint queries this for the diagnostics /
99    /// semantic-token / document-highlight / inlay-hint / progress
100    /// gates, so each saved actor RPC drops ~100µs off the frame
101    /// budget.
102    pub fn lsp_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
103        // cfg(test) escape hatch — see `App::cursor()`.
104        #[cfg(test)]
105        {
106            self.editor.lsp_mode_enabled_for(buffer_id)
107        }
108        #[cfg(not(test))]
109        {
110            self.modes()
111                .map
112                .get(&buffer_id)
113                .map(|m| m.has_minor(lattice_lsp::modes::LspMode::mode_id()))
114                .unwrap_or(false)
115        }
116    }
117
118    /// M.6.0: is `mode_id` active on `buffer_id`? Generic minor-
119    /// mode accessor used by every M.6 sub-mode reader. Always
120    /// returns `false` when no entry exists for `buffer_id` --
121    /// matches the umbrella accessor's shape.
122    fn minor_mode_enabled_for(&self, buffer_id: BufferId, mode_id: lattice_mode::ModeId) -> bool {
123        self.read_editor(move |e| e.minor_mode_enabled_for(buffer_id, mode_id))
124    }
125
126    /// CSM.K1: is `completion-mode` (the persistent gate)
127    /// active on `buffer_id`? Auto-activates on writable buffer
128    /// kinds; the popup-trigger entry points check this before
129    /// opening the popup so read-only buffers (Help, FileTree,
130    /// Oil) silently no-op on `<C-Space>`.
131    pub fn completion_mode_active_for(&self, buffer_id: BufferId) -> bool {
132        // Phase 5.8.AD.4: migrated.
133        self.read_editor(move |e| e.completion_mode_active_for(buffer_id))
134    }
135
136    /// CSM.K1: is `completion-popup-mode` (the transient
137    /// popup-live marker) active on `buffer_id`? Architectural
138    /// answer to "is the insert-completion popup live on this
139    /// buffer." Tests + production code asking the popup-state
140    /// question should read this rather than poking
141    /// `App.insert_completion.is_some()` directly -- the field
142    /// is the popup's *content*; the mode is the *gate*.
143    pub fn completion_popup_mode_active_for(&self, buffer_id: BufferId) -> bool {
144        self.minor_mode_enabled_for(buffer_id, lattice_mode::CompletionPopupMode::mode_id())
145    }
146
147    /// Shorthand: is the insert-completion popup live on the
148    /// active document buffer? The popup is anchored to the doc
149    /// the user is typing in; v1 has a single
150    /// `self.document_buffer_id()`.
151    pub fn completion_popup_active(&self) -> bool {
152        // Slice I.7: RPC-free. This is read once per keystroke in the
153        // translate context (runtime.rs) BEFORE dispatch, to gate the
154        // insert-completion keymap layer. Routing through
155        // `completion_popup_mode_active_for` → `minor_mode_enabled_for`
156        // → `read_editor` cost a blocking actor round-trip on every
157        // keystroke; read the published `modes()` map instead (the
158        // popup mode's activation is established by the prior keystroke's
159        // `sync_keymap_overlays`, which publishes — so the published map
160        // is the right pre-dispatch state). Mirrors
161        // `lsp_diagnostics_mode_enabled_for`'s published-read shape.
162        // cfg(test) escape hatch reads the editor directly so tests that
163        // mutate modes without publishing keep working (see `cursor()`).
164        #[cfg(test)]
165        {
166            self.completion_popup_mode_active_for(self.document_buffer_id())
167        }
168        #[cfg(not(test))]
169        {
170            let buffer_id = self.document_buffer_id();
171            self.modes()
172                .map
173                .get(&buffer_id)
174                .map(|m| m.has_minor(lattice_mode::CompletionPopupMode::mode_id()))
175                .unwrap_or(false)
176        }
177    }
178
179    /// M.6.0: is `lsp-completion-mode` active on `buffer_id`? Read
180    /// by `do_lsp_completion_request` /
181    /// `do_async_insert_completion_requests` and the LSP completion
182    /// source filter once M.6.2 / M.6.3 wire the gates.
183    pub fn lsp_completion_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
184        self.minor_mode_enabled_for(buffer_id, lattice_lsp::modes::LspCompletionMode::mode_id())
185    }
186
187    /// M.6.0: is `lsp-diagnostics-mode` active on `buffer_id`?
188    /// Read by the publish-diagnostics paint pipeline and
189    /// `:diag-next` / `:diag-prev` once M.6.3 wires the gate.
190    pub fn lsp_diagnostics_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
191        #[cfg(test)]
192        {
193            self.editor.lsp_diagnostics_mode_enabled_for(buffer_id)
194        }
195        #[cfg(not(test))]
196        {
197            self.modes()
198                .map
199                .get(&buffer_id)
200                .map(|m| m.has_minor(lattice_lsp::modes::LspDiagnosticsMode::mode_id()))
201                .unwrap_or(false)
202        }
203    }
204
205    /// M.6.0: is `lsp-hover-mode` active on `buffer_id`? Read by
206    /// `do_lsp_hover_request` once M.6.2 wires the gate.
207    pub fn lsp_hover_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
208        self.minor_mode_enabled_for(buffer_id, lattice_lsp::modes::LspHoverMode::mode_id())
209    }
210
211    /// M.6.0: is `lsp-signature-mode` active on `buffer_id`?
212    pub fn lsp_signature_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
213        self.minor_mode_enabled_for(buffer_id, lattice_lsp::modes::LspSignatureMode::mode_id())
214    }
215
216    /// M.6.0: is `lsp-format-mode` active on `buffer_id`? Gates
217    /// `:lsp-format` / `:lsp-format-range` and `onTypeFormatting`.
218    pub fn lsp_format_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
219        self.read_editor(move |e| e.lsp_format_mode_enabled_for(buffer_id))
220    }
221
222    pub fn lsp_rename_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
223        self.read_editor(move |e| e.lsp_rename_mode_enabled_for(buffer_id))
224    }
225
226    pub fn lsp_symbols_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
227        self.read_editor(move |e| e.lsp_symbols_mode_enabled_for(buffer_id))
228    }
229
230    pub fn lsp_code_action_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
231        self.read_editor(move |e| e.lsp_code_action_mode_enabled_for(buffer_id))
232    }
233
234    pub fn lsp_nav_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
235        self.read_editor(move |e| e.lsp_nav_mode_enabled_for(buffer_id))
236    }
237
238    /// 4.4.c: is `lsp-progress-mode` active on `buffer_id`?
239    /// Gates the modeline `$/progress` segment and progress
240    /// accumulation for buffers attached to a server. With
241    /// the mode off, incoming progress events still flow on
242    /// the bus (plugins can subscribe) but the modeline stays
243    /// quiet for that buffer.
244    pub fn lsp_progress_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
245        #[cfg(test)]
246        {
247            self.editor.lsp_progress_mode_enabled_for(buffer_id)
248        }
249        #[cfg(not(test))]
250        {
251            self.modes()
252                .map
253                .get(&buffer_id)
254                .map(|m| m.has_minor(lattice_lsp::modes::LspProgressMode::mode_id()))
255                .unwrap_or(false)
256        }
257    }
258
259    /// 4.4.e: is `lsp-document-highlight-mode` active on
260    /// `buffer_id`? Gates the cursor-driven
261    /// `textDocument/documentHighlight` request issuance and
262    /// the soft-highlight decoration overlay.
263    pub fn lsp_document_highlight_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
264        #[cfg(test)]
265        {
266            self.editor
267                .lsp_document_highlight_mode_enabled_for(buffer_id)
268        }
269        #[cfg(not(test))]
270        {
271            self.modes()
272                .map
273                .get(&buffer_id)
274                .map(|m| m.has_minor(lattice_lsp::modes::LspDocumentHighlightMode::mode_id()))
275                .unwrap_or(false)
276        }
277    }
278
279    /// 4.4.e: is `lsp-selection-range-mode` active on
280    /// `buffer_id`? Gates `textDocument/selectionRange`
281    /// issuance for the smart-expansion operator.
282    pub fn lsp_selection_range_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
283        self.read_editor(move |e| e.lsp_selection_range_mode_enabled_for(buffer_id))
284    }
285
286    /// 4.4.f: is `lsp-folding-mode` active on `buffer_id`?
287    /// Gates `textDocument/foldingRange` issuance. Independent
288    /// of the foldmethod option: when the mode is off the
289    /// cache stays empty and `:set foldmethod=lsp` cascades to
290    /// `Syntax`.
291    pub fn lsp_folding_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
292        self.read_editor(move |e| e.lsp_folding_mode_enabled_for(buffer_id))
293    }
294
295    pub fn lsp_inlay_hint_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
296        #[cfg(test)]
297        {
298            self.editor.lsp_inlay_hint_mode_enabled_for(buffer_id)
299        }
300        #[cfg(not(test))]
301        {
302            self.modes()
303                .map
304                .get(&buffer_id)
305                .map(|m| m.has_minor(lattice_lsp::modes::LspInlayHintMode::mode_id()))
306                .unwrap_or(false)
307        }
308    }
309
310    pub fn lsp_semantic_tokens_mode_enabled_for(&self, buffer_id: BufferId) -> bool {
311        #[cfg(test)]
312        {
313            self.editor.lsp_semantic_tokens_mode_enabled_for(buffer_id)
314        }
315        #[cfg(not(test))]
316        {
317            self.modes()
318                .map
319                .get(&buffer_id)
320                .map(|m| m.has_minor(lattice_lsp::modes::LspSemanticTokensMode::mode_id()))
321                .unwrap_or(false)
322        }
323    }
324
325    /// M.5.4: shared gate for every LSP request entry point
326    /// (hover / definition / completion / format / rename /
327    /// code-action / symbols / signature / references). Returns
328    /// `true` when `lsp-mode` is active on the current document;
329    /// callers early-return on `false`. A single echo surfaces
330    /// the gate state so users discover the mode -- silent gates
331    /// are a documented anti-pattern when an editor's defaults
332    /// the user expects (`K`, `gd`) suddenly do nothing.
333    ///
334    /// The echo level is `Info` (not `Warn`) -- gated state is
335    /// expected user-controlled, not a misconfiguration.
336    pub(super) fn check_lsp_mode_gate(&mut self) -> bool {
337        if self.lsp_mode_enabled_for(self.document_buffer_id()) {
338            return true;
339        }
340        self.set_message(
341            EchoLevel::Info,
342            "lsp-mode disabled for this buffer (`:lsp-mode` to enable)".to_string(),
343        );
344        false
345    }
346
347    /// M.6.2: shared gate for a per-feature LSP sub-mode. Checks
348    /// the umbrella first (so the user gets one consistent
349    /// message-source-of-truth: enable `lsp-mode` first, then
350    /// the sub-mode); returns `true` only when both are active.
351    /// Echoes at `Info` matching the umbrella's level.
352    ///
353    /// Used by `do_lsp_*_request` methods that want a
354    /// user-discoverable bail message. Insert-mode auto-triggers
355    /// (insert completion, signature help, on-type formatting)
356    /// skip the echo path entirely and check the bool directly
357    /// -- a typed character that doesn't fire isn't a moment to
358    /// surface mode state.
359    fn check_lsp_sub_mode_gate(
360        &mut self,
361        sub_mode_id: lattice_mode::ModeId,
362        sub_mode_name: &str,
363    ) -> bool {
364        if !self.check_lsp_mode_gate() {
365            return false;
366        }
367        if self.minor_mode_enabled_for(self.document_buffer_id(), sub_mode_id) {
368            return true;
369        }
370        self.set_message(
371            EchoLevel::Info,
372            format!("{sub_mode_name} disabled for this buffer (`:{sub_mode_name}` to enable)"),
373        );
374        false
375    }
376
377    // 5.5.LSP.1: `K` -- `do_lsp_hover_request` relocated to
378    // [`lattice_host::dispatch::Editor::lsp_hover_request`]. The
379    // host-side body is identical (gates + URI lookup + cursor
380    // translation + spawn on the LSP runtime). L7: `K` is mode-owned —
381    // `lsp-mode`'s `action_handlers()` emits `Effect::Lsp(LspRequest::Hover)`,
382    // applied host-side via `editor.lsp_request`. Tests drive that exact
383    // path with `apply_effect(Effect::Lsp(LspRequest::…))`. The drain side
384    // (`drain_pending_hover`) stays App-resident.
385
386    /// Drain the channel populated by `do_lsp_hover_request` and
387    /// act on every pending `HoverOutcome`: open the popup for
388    /// `Body`, echo a clear message for `NoBody` / `NoServers` so
389    /// the user always knows their `K` press was processed.
390    /// Called once per main_loop iteration before draw; cheap
391    /// when the channel is empty (the common case).
392    pub fn drain_pending_hover(&mut self) {
393        // 5.8.W: drain body migrated to
394        // `lattice_host::dispatch::Editor::drain_pending_hover`
395        // so the GPUI peer reaches the same path. Returns
396        // RendererSignals (DisplayBuffer for Body; no signal for
397        // NoBody/NoServers — those echo via set_message inside
398        // the drain). This peer fans the signals through its
399        // existing handler.
400        let signals = self.mutate_editor_with(|e| e.drain_pending_hover());
401        for signal in signals {
402            self.handle_renderer_signal(signal);
403        }
404    }
405
406    /// Apply an accepted LSP completion item. Routes the main
407    /// insert (with `textEdit.range` honoured when present) plus
408    /// `additionalTextEdits` through `apply_lsp_text_edits` so
409    /// the whole set lands as one undo unit. Snippet-flavoured
410    /// items currently splice the literal body -- placeholder
411    /// navigation is in 4.2.g.4 with `lattice-snippet`.
412    pub(super) fn apply_lsp_completion_accept(
413        &mut self,
414        meta: LspCompletionMeta,
415        anchor: lattice_protocol::position::Position,
416    ) {
417        // Phase 5.8.AD.4: body migrated.
418        self.mutate_editor(move |e| e.apply_lsp_completion_accept(meta, anchor));
419    }
420
421    /// Fire `completionItem/resolve` for the focused candidate
422    /// (Phase 4.2.g.3). The original CompletionItem is round-
423    /// tripped to the originating server; the response fills in
424    /// `documentation` / `additionalTextEdits` / `detail` per
425    /// the LSP spec. Drain updates the meta + the docs popup
426    /// body in place.
427    pub(super) fn do_completion_resolve_focused(&mut self) {
428        // Phase 5.8.AD.4: body migrated.
429        // Slice 3c.final.E.4: route through `mutate_editor`.
430        self.mutate_editor(|e| e.do_completion_resolve_focused());
431    }
432
433    /// Fire `textDocument/completion` for the active Insert-
434    /// mode popup (Phase 4.2.g.2). The response merges into
435    /// `state.raw` via the per-frame drain. Cancellation token
436    /// rides on every keystroke that mutates the query when
437    /// `isIncomplete: true`; manual re-triggers always re-fire
438    /// fresh.
439    ///
440    /// Multi-server fan-out + dedup (label + kind) is the
441    /// architecture-doc strategy. Items beyond `MAX_LSP_ITEMS`
442    /// are dropped.
443    pub(super) fn do_async_insert_completion_requests(&mut self) {
444        // Phase 5.8.AD.4: body migrated.
445        self.mutate_editor(|e| e.do_async_insert_completion_requests());
446    }
447
448    /// Drain queued `completionItem/resolve` responses --
449    /// decode the matching candidate's payload, apply the
450    /// resolved fields, re-encode in place, then refresh the
451    /// docs-popup body when the resolved item is the popup's
452    /// currently-focused one. CSM.8b.5: state.raw is the
453    /// source of truth; no parallel sidecar to keep in sync.
454    pub fn drain_pending_completion_resolve(&mut self) {
455        // 5.8.AA.d: migrated to host.
456        self.mutate_editor(|e| e.drain_pending_completion_resolve());
457    }
458
459    /// Per-frame drain hook -- merge any LSP completion response
460    /// into the active popup's `raw` set, refilter, and update
461    /// the `lsp_incomplete` flag.
462    pub fn drain_pending_insert_completion_lsp(&mut self) {
463        // 5.8.AA.d: migrated to host.
464        self.mutate_editor(|e| e.drain_pending_insert_completion_lsp());
465    }
466
467    /// Drain queued `lattice_lsp::LspLogPushed` events for the
468    /// App-side concerns the modes don't own. After B'.6 the
469    /// three log majors (`LspLogMode`, `LspServerLogMode`,
470    /// `LspTraceLogMode`) own every buffer append; this drain
471    /// only surfaces `window/showMessage`-sourced records to the
472    /// minibuffer (vim's `:echom`-style transient surface) so
473    /// users see server-emitted notifications without opening the
474    /// LSP log buffer. Multiple showMessages in one tick collapse
475    /// to the last (matches successive `:echo` calls).
476    ///
477    /// Called once per main-loop tick.
478    pub fn drain_lsp_log_events(&mut self) {
479        // 5.8.AA.d: migrated to host.
480        self.mutate_editor(|e| e.drain_lsp_log_events());
481    }
482
483    /// 4.4.c: drain queued `LspProgressUpdate` events and
484    /// fold them into `self.editor.lsp_progress`. `Begin` inserts,
485    /// `Report` updates (preserving title from the prior
486    /// `Begin` when the report doesn't restate it), `End`
487    /// removes. Called once per main-loop tick.
488    ///
489    /// Cheap when no events arrived: a single try_recv that
490    /// returns `Empty` and exits.
491    /// Drain queued `lattice_lsp::LspBufferDetached` events
492    /// (published by `LspMode::on_deactivate` via Phase 2's
493    /// `ctx.events()`). For each event, call
494    /// [`Self::lsp_close_buffer`] to fire the wire-level
495    /// `textDocument/didClose` and clear the buffer's URI
496    /// mapping. Called once per main-loop tick.
497    ///
498    /// Cheap when no events arrived (single `try_recv` → `Empty`).
499    /// Cheap when the buffer has no URI mapping (the close path
500    /// short-circuits on `buffer_uris.remove` returning `None`).
501    pub fn drain_lsp_detach_events(&mut self) {
502        // 5.8.AA.d: migrated to host.
503        self.mutate_editor(|e| e.drain_lsp_detach_events());
504    }
505
506    // ML.3c: `drain_lsp_progress_events` retired — `$/progress` is folded
507    // into the lattice-lsp `LspProgressStore` by that crate's forwarder,
508    // not host-accumulated.
509
510    // Drain server-initiated `workspace/configuration` requests.
511    // Each request lands as a `lattice_lsp::InboundConfigurationRequest`
512    // carrying section paths + a oneshot for the response.
513    // Server-side keys come in their own namespaces (e.g.
514    // `"rust-analyzer.cargo.features"`); the editor's TOML places
515    // these under an `[lsp.<server>]` umbrella so multiple
516    // servers' keys don't collide. The drain prepends `lsp.` to
517    // the requested section before walking the tree.
518    // BC.8b: `drain_inbound_configuration_requests` wrapper removed — the
519    // `workspace/configuration` bus drains via the generic inbound tick-callback
520    // (mode-owned handler), run inside `run_tick_pending`; no host/TUI method.
521
522    /// 4.4.k: fan out `workspace/didChangeConfiguration` to
523    /// every running actor with the given `server_id`. Called
524    /// from the typed-option cascade
525    /// (the private `apply_option_cascade`) whenever a key under
526    /// `lsp.<server_id>.*` changes. The notification's
527    /// `settings` payload is the full `lsp.<server_id>` JSON
528    /// subtree from the merged TOML tree -- matches the shape
529    /// returned by `workspace/configuration` so servers that
530    /// pull and servers that read-inline see consistent data.
531    ///
532    /// Empty subtree (server doesn't exist in TOML) still
533    /// fires the notification with `settings: null`; per spec
534    /// servers MAY interpret that as "reset to defaults".
535    /// Notify-only -- no response, errors log and skip.
536    ///
537    /// Cross-workspace fan-out: if two actors share `server_id`
538    /// across different workspace roots, both receive the
539    /// notification (config is global, not workspace-scoped).
540    /// Thin wrapper around
541    /// [`lattice_host::editor::Editor::fan_out_did_change_configuration`]
542    /// (Phase 5.7.B.7 migration). Kept on `impl App` so the TUI
543    /// peer's `handle_renderer_signal` match arm doesn't churn
544    /// while the body lives host-side -- the GPUI peer reaches
545    /// the same logic through `editor.fan_out_did_change_configuration`.
546    pub fn fan_out_did_change_configuration(&mut self, server_id: &str) {
547        // Slice 3c.final.E.5: clone owned + route via mutate_editor.
548        let server_id = server_id.to_string();
549        self.mutate_editor(move |e| e.fan_out_did_change_configuration(&server_id));
550    }
551
552    // BC.8c: the TUI `drain_inbound_show_documents` wrapper is retired.
553    // `window/showDocument` now drains through the generic inbound
554    // tick-callback (mode-owned `lattice_lsp::show_document::make_handler` →
555    // host-applied open effects), so neither the host method nor this TUI
556    // wrapper exist anymore. Coverage moved to the lattice-lsp handler tests.
557
558    /// 4.4.b: drain server-initiated
559    /// Drain server-initiated `window/showMessageRequest`
560    /// inbound requests (4.4.b). Each request comes with a
561    /// prompt, an optional action list, and a oneshot for the
562    /// reply. The actionless case (just an info / warn / error
563    /// notification) auto-replies `null` and surfaces the
564    /// prompt on the minibuffer + LSP log. The actionful case
565    /// registers the request in `lsp_pending_show_message_requests`
566    /// and either opens an action picker (if no picker is
567    /// currently up) or queues the id behind the active one.
568    ///
569    /// Picker accept (the `AcceptShowMessageAction` routing
570    /// arm) and picker dismiss (the `LspShowMessageRequest`
571    /// source arm) both pull the slot out, ferry back the
572    /// response, then drain the queue so the next pending SMR
573    /// opens on the same tick.
574    pub fn drain_inbound_show_message_requests(&mut self) {
575        // 5.8.AA.e: migrated to host.
576        self.mutate_editor(|e| e.drain_inbound_show_message_requests());
577    }
578
579    /// Allocate a fresh `u32` request id for
580    /// `lsp_pending_show_message_requests`. Wraps on overflow
581    /// and skips any id currently in use -- collision is only
582    /// possible if `u32::MAX` actionful requests pile up at
583    /// once, which won't happen, but the loop keeps the
584    /// invariant honest.
585    /// 5.8.AA.e: open-picker body migrated to host. Thin wrapper
586    /// kept since `accept_show_message_action` (App-side, applies
587    /// the user's choice via the picker dispatcher path) calls it.
588    pub(super) fn open_show_message_request_picker(&mut self, request_id: u32) {
589        self.mutate_editor(move |e| e.open_show_message_request_picker(request_id));
590    }
591
592    /// Send the LSP response for one in-flight
593    /// `showMessageRequest`. `selected_index` of `None` is the
594    /// dismiss path (reply `null`); `Some(i)` ferries the
595    /// `i`-th MessageActionItem back. Idempotent on missing
596    /// ids (the slot may already have been answered if the
597    /// drain logic raced with picker close).
598    pub(crate) fn finalize_show_message_request(
599        &mut self,
600        request_id: u32,
601        selected_index: Option<u32>,
602    ) {
603        // Slice 3c.final.E.5j: remove via `mutate_editor_with` so
604        // the owned `req` flows out of the editor borrow.
605        let Some(req) = self
606            .mutate_editor_with(move |e| e.lsp_pending_show_message_requests.remove(&request_id))
607        else {
608            return;
609        };
610        let selected = selected_index.and_then(|i| req.actions.get(i as usize).cloned());
611        let _ = req
612            .response
613            .send(lattice_lsp::ShowMessageRequestOutcome { selected });
614    }
615
616    /// Advance the SMR queue. Called from the picker accept /
617    /// dismiss arms after the active request is resolved -- if
618    /// another id is queued, open its picker on the same tick
619    /// so the user sees the next prompt without a frame's gap.
620    pub(crate) fn open_next_queued_show_message_request(&mut self) {
621        // Slice 3c.final.E.5e: pop valid IDs through a single
622        // closure so the loop body crosses the actor seam exactly
623        // once per attempt. The host-side closure pops + filters
624        // until it finds a still-pending request id (or exhausts
625        // the queue).
626        let next = self.mutate_editor_with(|e| {
627            while let Some(id) = e.lsp_show_message_request_queue.pop_front() {
628                if e.lsp_pending_show_message_requests.contains_key(&id) {
629                    return Some(id);
630                }
631            }
632            None
633        });
634        if let Some(id) = next {
635            self.open_show_message_request_picker(id);
636        }
637    }
638
639    /// Drain server-initiated `workspace/applyEdit` requests
640    /// (Phase 4.3). Each request lands as a
641    /// `lattice_lsp::InboundApplyEdit` carrying a typed
642    /// `WorkspaceEdit` + a oneshot for the response. We flatten
643    /// the edit into per-file `Vec<TextEdit>` batches (same
644    /// `flatten_workspace_edit` path the `:rename` drain uses),
645    /// apply each, and reply via the oneshot.
646    ///
647    /// Apply semantics mirror `apply_rename_workspace_edit`:
648    /// edits to the active buffer land directly via
649    /// `apply_lsp_text_edits`; cross-file edits open the target
650    /// via `do_edit` and apply there. Failures on individual
651    /// files echo a warning but don't roll back successfully-
652    /// applied files.
653    pub fn drain_inbound_apply_edits(&mut self) {
654        // 5.8.AA.l.5: migrated to host.
655        let signals = self.mutate_editor_with(|e| e.drain_inbound_apply_edits());
656        for s in signals {
657            self.handle_renderer_signal(s);
658        }
659    }
660
661    // 5.8.AA.l.5: apply_inbound_workspace_edit body migrated
662    // to host.
663
664    /// Single canonical hook for "this buffer was just opened":
665    /// register `BufferId → Uri` eagerly (path-bearing only),
666    /// then publish `Event::DocumentOpened` on the bus. Both the
667    /// initial-document path (`App::new`) and the follow-up
668    /// `:e <path>` path (`App::do_edit`) call this helper.
669    ///
670    /// Idempotent against the supervisor: re-publishing the same
671    /// URI is a no-op because `LspSupervisorHandle::open_buffer`
672    /// short-circuits already-attached URIs.
673    /// Thin wrapper around
674    /// [`lattice_host::editor::Editor::publish_document_opened_for_active`]
675    /// (Phase 5.7.B.6 migration). The TUI peer's `App::new` calls
676    /// this name historically; keeping the wrapper avoids
677    /// churning the call site while the body lives host-side so
678    /// the GPUI peer can call it through `editor.publish_document_opened_for_active()`.
679    pub(super) fn publish_document_opened_for_active(&mut self) {
680        self.mutate_editor(|e| e.publish_document_opened_for_active());
681    }
682
683    /// Decode the LSP metadata directly from a candidate's
684    /// `CandidateData::Extension` payload (CSM.8b: the candidate
685    /// IS the metadata; no sidecar lookup). Returns `None` for
686    /// non-LSP candidates (buffer-words / snippet / path /
687    /// tree-sitter rows whose payload kind id is something else)
688    /// or when the payload doesn't decode (stale wire format).
689    ///
690    /// Owned return type: the candidate's payload bytes are
691    /// the source of truth, and `decode_meta` produces a
692    /// fresh `LspCompletionMeta` per call. Callers that read
693    /// many fields can clone the result into a local; the
694    /// per-frame docs / glyph / commit-char hot paths still
695    /// stay well inside the frame budget (serde_json decode
696    /// of a typical LSP item is microseconds).
697    pub(crate) fn lsp_completion_meta_for(
698        &self,
699        candidate: &lattice_completion::RenderedCandidate,
700    ) -> Option<LspCompletionMeta> {
701        // Phase 5.8.AD.4: body migrated.
702        // Slice 3c.final.E.swap-prep: clone for Send + 'static closure.
703        let candidate = candidate.clone();
704        self.read_editor(move |e| e.lsp_completion_meta_for(&candidate))
705    }
706
707    /// Look up the current URI of a buffer. None for buffers
708    /// that have no on-disk path yet (new unsaved scratch
709    /// buffers).
710    pub fn buffer_uri(&self, id: BufferId) -> Option<lattice_lsp::Uri> {
711        // Slice 3c.final.E.5d: returns owned `Uri` (not `&Uri`)
712        // because the `Arc<BuffersRenderState>` snapshot is a
713        // temporary in this stack frame; borrowing through it
714        // would escape the function. `Uri` is a thin Arc<str>
715        // wrapper under the hood, so this clone is one Arc bump.
716        self.buffers().uris.get(&id).cloned()
717    }
718
719    /// Flush queued didChange events for a buffer immediately.
720    /// Used by will-save hooks (4.3) so the server's view is
721    /// caught up before pre-save requests fire. Fire-and-forget
722    /// against the supervisor mailbox.
723    pub fn lsp_flush(&self, buffer_id: BufferId) {
724        let Some(uri) = self.buffers().uris.get(&buffer_id).cloned() else {
725            return;
726        };
727        // Slice 3c.final.E.5e: `uri` is owned (cloned above);
728        // `LspBatchingSink::flush(&self, Uri)` so `read_editor`
729        // closure satisfies `&Editor` + `Send + 'static`.
730        self.read_editor(move |e| e.lsp.flush(uri));
731    }
732
733    /// 5.5.F.4.4: see [`lattice_host::dispatch::Editor::lsp_close_buffer`].
734    /// App-side wrapper kept as a thin delegate because
735    /// `drain_lsp_detach_events` (App tick path) still calls it; tests
736    /// in this module call it directly. Deletes when the tick-event
737    /// drain moves host-side.
738    pub fn lsp_close_buffer(&mut self, buffer_id: BufferId) {
739        self.mutate_editor(move |e| e.lsp_close_buffer(buffer_id));
740    }
741
742    /// Apply editor-side LSP options that the user configured
743    /// under the top-level `[lsp]` TOML table (as distinct from
744    /// server-namespaced subtables like `[lsp.rust-analyzer]`,
745    /// which are served back to servers via
746    /// `workspace/configuration`).
747    ///
748    /// Today this handles:
749    /// - `lsp-mode.log-level` -- string, one of
750    ///   `error`/`warn`/`info`/`debug`/`trace`. Sets the
751    ///   subsystem-wide default min level (same effect as
752    ///   `:lsp-log-level <level>`). The mode-owned namespace
753    ///   (M.6.5): `lsp-mode.*` collects every option owned by
754    ///   the `lsp-mode` minor; `lsp.*` is reserved for the
755    ///   structural `workspace/configuration` passthrough
756    ///   (per-server TOML subtables like `[lsp.rust-analyzer]`).
757    /// - `lsp.log-level` -- legacy alias. Reads the same value;
758    ///   emits a deprecation warn echo. Removed in a follow-up
759    ///   minor version.
760    ///
761    /// Unknown / mistyped values surface a warn echo and the
762    /// option is skipped. Missing keys are silent.
763    pub(super) fn apply_persistent_lsp_editor_options(&mut self) {
764        // Phase 5.8.AA.u: body migrated to
765        // `lattice_host::dispatch::Editor::apply_persistent_lsp_editor_options`.
766        self.mutate_editor(|e| e.apply_persistent_lsp_editor_options());
767    }
768
769    /// Apply a `Vec<TextEdit>` (LSP utf-16 ranges) to the active
770    /// buffer as one undo unit. TextEdits are sorted in reverse
771    /// by start position so each application doesn't shift the
772    /// positions of the later ones (LSP convention: edits are
773    /// non-overlapping and reference the original document).
774    pub(super) fn apply_lsp_text_edits(
775        &mut self,
776        edits: Vec<lattice_lsp::lsp_types::TextEdit>,
777    ) -> Result<(), String> {
778        // Slice 3c.final.E.5: route through `mutate_editor_with`.
779        self.mutate_editor_with(move |e| e.apply_lsp_text_edits(edits))
780    }
781
782    /// 5.5.G.23.insert-prep: body migrated to
783    /// [`lattice_host::dispatch::Editor::on_type_formatting_trigger_chars`].
784    /// Retained as a delegate while `do_insert_text` still lives
785    /// App-side; deletion follows when `do_insert_text` migrates.
786    pub(super) fn on_type_formatting_trigger_chars(&self) -> Vec<char> {
787        self.read_editor(move |e| e.on_type_formatting_trigger_chars())
788    }
789
790    /// 5.5.G.23.insert-prep: body migrated to
791    /// [`lattice_host::dispatch::Editor::signature_help_trigger_chars`].
792    /// Retained as a delegate while `do_insert_text` still lives
793    /// App-side; deletion follows when `do_insert_text` migrates.
794    pub(super) fn signature_help_trigger_chars(&self) -> Vec<char> {
795        self.read_editor(move |e| e.signature_help_trigger_chars())
796    }
797
798    /// Fire `textDocument/onTypeFormatting` to the highest-
799    /// priority server advertising the trigger; apply the returned
800    /// edits as one undo unit.
801    pub(super) fn do_lsp_on_type_formatting_request(&mut self, trigger: char) {
802        // Phase 5.8.AD.4: body migrated.
803        self.mutate_editor(move |e| e.do_lsp_on_type_formatting_request(trigger));
804    }
805
806    /// `:rename <new-name>` (Phase 4.3). Fires
807    /// `textDocument/prepareRename` (when the server advertises
808    /// the prepare provider) to validate the cursor and pick up
809    /// the placeholder; then `textDocument/rename` to compute
810    /// the WorkspaceEdit; the App applies the edits per-file as
811    /// one undo unit per affected buffer (cross-file atomic
812    /// rollback is a follow-up).
813    ///
814    /// `new_name` empty falls back to `prepareRename`'s
815    /// placeholder (when available). When prepareRename returns
816    /// nothing AND `new_name` is empty, we error.
817    pub(super) fn do_lsp_rename_request(&mut self, new_name: &str) {
818        // Slice 3c.final.E.5: clone owned + route via mutate_editor.
819        let new_name = new_name.to_string();
820        self.mutate_editor(move |e| e.do_lsp_rename_request(&new_name));
821    }
822
823    /// Drain queued `:rename` responses; apply the WorkspaceEdit.
824    /// v1: per-file edits land as one undo unit in each affected
825    /// buffer.
826    pub fn drain_pending_rename(&mut self) {
827        // 5.8.AA.l.3: migrated to host.
828        let signals = self.mutate_editor_with(|e| e.drain_pending_rename());
829        for s in signals {
830            self.handle_renderer_signal(s);
831        }
832    }
833
834    /// Apply a per-file WorkspaceEdit returned by `:rename`. The
835    /// active buffer's edits land directly via apply_lsp_text_edits;
836    /// cross-file edits open the file via `:e` and apply.
837    pub(super) fn apply_rename_workspace_edit(
838        &mut self,
839        per_file: Vec<(
840            lattice_lsp::lsp_types::Uri,
841            Vec<lattice_lsp::lsp_types::TextEdit>,
842        )>,
843        new_name: String,
844    ) {
845        // 5.8.AA.l.2: migrated to host. Fan returned signals
846        // through the existing renderer-signal handler.
847        let signals =
848            self.mutate_editor_with(move |e| e.apply_rename_workspace_edit(per_file, new_name));
849        for s in signals {
850            self.handle_renderer_signal(s);
851        }
852    }
853
854    /// 5.8.AA.r: apply chain migrated to host. The picker accept
855    /// arm hits this wrapper, which fans the returned signals
856    /// through the existing `handle_renderer_signal` sink.
857    pub(super) fn apply_lsp_code_action(
858        &mut self,
859        row: CodeActionRow,
860        handle: Option<lattice_lsp::ServerHandle>,
861    ) {
862        let signals = self.mutate_editor_with(move |e| e.apply_lsp_code_action(row, handle));
863        for s in signals {
864            self.handle_renderer_signal(s);
865        }
866    }
867
868    pub(super) fn execute_lsp_command(
869        &mut self,
870        handle: Option<lattice_lsp::ServerHandle>,
871        cmd: lattice_lsp::lsp_types::Command,
872    ) {
873        // 5.8.AA.l.6: migrated to host.
874        self.mutate_editor(move |e| e.execute_lsp_command(handle, cmd));
875    }
876
877    /// Splice a chosen completion item into the buffer at its
878    /// captured replace range. Plain text only -- snippet
879    /// expansion lands with the buffer-level Insert-mode
880    /// completion shell.
881    pub(super) fn apply_lsp_completion_item(&mut self, item: &CompletionItemRow) {
882        let (start_byte, end_byte) = item.replace_range;
883        let range = lattice_protocol::position::Range::new(
884            Position::new(item.line, start_byte),
885            Position::new(item.line, end_byte),
886        );
887        let edit = Edit::replace(range, item.insert_text.clone());
888        match self.apply_edit_blocking(edit) {
889            Ok(applied) => {
890                self.set_cursor(applied.inserted_range.end);
891            }
892            Err(e) => {
893                self.set_message(EchoLevel::Error, format!("complete: apply failed: {e:?}"));
894            }
895        }
896    }
897
898    /// `:code-actions` (Phase 4.3). Run textDocument/codeAction
899    /// at the cursor (or active Visual selection); open the
900    /// merged item list as a vertico picker. v1 picks the first
901    /// server with `codeActionProvider`.
902    pub(super) fn do_lsp_code_action_request(&mut self) {
903        // Phase 5.8.AD.2: body migrated, including the
904        // `code_action_range` + `diagnostics_for_range` helpers.
905        self.mutate_editor(|e| e.do_lsp_code_action_request());
906    }
907
908    /// Drain queued code-action responses. 5.8.AA.r: full
909    /// apply chain (Items/Resolved/NoProvider + workspace-edit +
910    /// executeCommand) hoisted to host; this wrapper exists for
911    /// any remaining direct callers and the test-suite. The host
912    /// drain is folded into `run_tick_pending` so the TUI runtime
913    /// no longer needs an explicit call.
914    pub fn drain_pending_code_actions(&mut self) {
915        let signals = self.mutate_editor_with(|e| e.drain_pending_code_actions());
916        for s in signals {
917            self.handle_renderer_signal(s);
918        }
919    }
920
921    /// Pick the first attached server that advertises
922    /// `codeActionProvider` -- mirrors the choice the spawn task
923    /// made when firing the original request.
924    fn first_code_action_handle(&self) -> Option<lattice_lsp::ServerHandle> {
925        // Slice 3c.final.E.5j: buffer_uris + lsp.servers_for via
926        // published `buffers().uris` and `lsp().supervisor` sub-states.
927        let uri = self.buffers().uris.get(&self.document_buffer_id())?.clone();
928        self.render_state
929            .load()
930            .lsp
931            .supervisor
932            .servers_for(&uri)
933            .into_iter()
934            .find(|h| h.capabilities().supports_code_action())
935    }
936
937    // `:complete` (Phase 4.2.g). Fires
938    // `textDocument/completion` at the cursor; the merged item
939    // list opens as a vertico picker. Multi-server union;
940    // dedup by `(label, kind)`.
941    // 5.5.LSP.4: `do_lsp_completion_request` relocated to
942    // [`lattice_host::dispatch::Editor::lsp_completion_request`].
943    // Body identical (gate, URI / cursor resolve, prefix backwalk
944    // for replace range, per-server walk with capability check,
945    // merge + dedup). Drain (`drain_pending_completion`) stays
946    // App-resident until the picker side migrates.
947
948    /// Drain queued LSP completion responses and open a picker.
949    /// `NoServers` echoes; empty list echoes.
950    pub fn drain_pending_completion(&mut self) {
951        // 5.8.AA.c: migrated to host.
952        self.mutate_editor(|e| e.drain_pending_completion());
953    }
954
955    /// `:format` / `:format-range` (Phase 4.3). Picks the
956    /// highest-priority server with `documentFormattingProvider`
957    /// (or `documentRangeFormattingProvider` when `is_range`),
958    /// fires the request, applies the returned edits as one
959    /// undo unit.
960    ///
961    /// Single-server strategy per docs/dev/architecture/lsp-architecture.md §7b:
962    /// "Two formatters can't agree on whitespace." -- so unlike
963    /// nav we don't fan out / merge.
964    ///
965    /// Range source for `is_range`: active Visual selection (if
966    /// in Visual mode), else the whole buffer.
967    /// IN.8b: `:format` -- the LSP-independent cascade.
968    pub(super) fn do_format_request(&mut self) {
969        self.mutate_editor(|e| e.do_format_request());
970    }
971
972    pub(super) fn do_lsp_format_request(&mut self, is_range: bool) {
973        // Phase 5.8.AD.2: body migrated.
974        self.mutate_editor(move |e| e.do_lsp_format_request(is_range));
975    }
976
977    /// Drain the format response channel and apply the returned
978    /// edits as one undo unit. Echoes when the server returned no
979    /// edits ("already formatted") or no provider was available.
980    pub fn drain_pending_format(&mut self) {
981        // 5.8.AA.l.4: migrated to host.
982        self.mutate_editor(|e| e.drain_pending_format());
983    }
984
985    // `:lsp-symbols` (Phase 4.2.e). Send
986    // `textDocument/documentSymbol` to every attached server;
987    // flatten the hierarchy + merge across servers; drain on
988    // the next frame opens a picker.
989    // 5.5.LSP.5: `do_lsp_document_symbol_request` and
990    // `do_lsp_workspace_symbol_request` relocated to
991    // [`lattice_host::dispatch::Editor::lsp_document_symbol_request`]
992    // and [`Editor::lsp_workspace_symbol_request`]. Both bodies
993    // are identical (gate, URI / path resolve for document, fan-
994    // out for workspace, flatten + dedup). Drain
995    // (`drain_pending_symbols`) stays App-resident.
996
997    /// Drain queued document-symbol / workspace-symbol responses
998    /// and open the picker.
999    pub fn drain_pending_symbols(&mut self) {
1000        // 5.8.AA: migrated to
1001        // `lattice_host::dispatch::Editor::drain_pending_symbols`.
1002        self.mutate_editor(|e| e.drain_pending_symbols());
1003    }
1004
1005    /// 4.5.a: `:lsp-incoming-calls` / `:lsp-outgoing-calls`.
1006    /// Prepares call-hierarchy items at the cursor on the
1007    /// first attached server with `callHierarchyProvider`,
1008    /// then fans out the chosen direction
1009    /// (`incomingCalls` / `outgoingCalls`) for the first item
1010    /// in the response. The merged caller / callee list opens
1011    /// in a vertico picker. Single-server strategy mirrors
1012    /// the `:rename` choice -- a function's call graph is
1013    /// language-specific; merging across servers produces
1014    /// duplicated rows on mixed-language buffers.
1015    ///
1016    /// Picker rows reuse the `SymbolsOutcome` /
1017    /// `PickerSource::LspLocations` plumbing so accept jumps
1018    /// land through the existing `RoutingPayload::JumpToLspLocation`
1019    /// path -- no new picker action variant required.
1020    pub(super) fn do_lsp_call_hierarchy_request(&mut self, outgoing: bool) {
1021        // Phase 5.8.AD.2: body migrated.
1022        self.mutate_editor(move |e| e.do_lsp_call_hierarchy_request(outgoing));
1023    }
1024
1025    /// 4.5.b: `:lsp-supertypes` / `:lsp-subtypes`. Same shape
1026    /// as the call-hierarchy peer but for type relationships.
1027    /// `subtypes=false` -> supertypes ("what does this type
1028    /// subtype?"); `subtypes=true` -> subtypes ("what subtypes
1029    /// this type?"). Reuses the symbols outcome / picker
1030    /// plumbing.
1031    ///
1032    /// Capability: lsp-types 0.97 doesn't model a static
1033    /// `type_hierarchy_provider` field; the probe consults the
1034    /// dynamic registry only. Servers that support type
1035    /// hierarchy typically register it dynamically anyway
1036    /// (rust-analyzer, pyright). When the server doesn't
1037    /// advertise either path the command echoes "no LSP server
1038    /// with type-hierarchy support" instead of firing a request
1039    /// that would error.
1040    pub(super) fn do_lsp_type_hierarchy_request(&mut self, subtypes: bool) {
1041        // Phase 5.8.AD.2: body migrated.
1042        self.mutate_editor(move |e| e.do_lsp_type_hierarchy_request(subtypes));
1043    }
1044
1045    /// 4.5.g: `:lsp-moniker`. Fires `textDocument/moniker` on
1046    /// the first server with `monikerProvider`; the response is
1047    /// folded into a one-line summary echoed to the minibuffer.
1048    /// Fire-and-forget UX -- no picker, no jump. Useful for
1049    /// indexing tools / cross-repo navigation, where the
1050    /// moniker is metadata about the symbol identity rather
1051    /// than a navigation target.
1052    pub(super) fn do_lsp_moniker_request(&mut self) {
1053        // Phase 5.8.AD.2: body migrated.
1054        self.mutate_editor(|e| e.do_lsp_moniker_request());
1055    }
1056
1057    /// 4.5.g: drain queued moniker responses + echo. Called
1058    /// per main-loop tick like the other LSP drains; cheap when
1059    /// the channel is empty.
1060    pub fn drain_pending_moniker(&mut self) {
1061        // 5.8.AA.b: migrated to host.
1062        self.mutate_editor(|e| e.drain_pending_moniker());
1063    }
1064
1065    // `:lsp-signature-help` (Phase 4.3). Fan-out across attached
1066    // servers; first non-empty `SignatureHelp` response wins
1067    // (per docs/dev/architecture/lsp-architecture.md §7b "First non-empty wins.
1068    // Signatures are usually language-specific; merging rarely
1069    // useful.").
1070    // 5.5.LSP.4: `do_lsp_signature_help_request` relocated to
1071    // [`lattice_host::dispatch::Editor::lsp_signature_help_request`].
1072    // Body identical (silent gate, URI / cursor resolve, per-server
1073    // walk with capability check, first non-empty markdown wins).
1074    // Drain (`drain_pending_signature_help`) stays App-resident
1075    // until the popup pipeline migrates.
1076
1077    /// Drain queued signature-help responses. A non-empty body
1078    /// renders into the popup; empty echoes "no signature info";
1079    /// `NoServers` echoes the standard "no LSP server" message.
1080    pub fn drain_pending_signature_help(&mut self) {
1081        // 5.8.X: drain body migrated to
1082        // `lattice_host::dispatch::Editor::drain_pending_signature_help`
1083        // so the GPUI peer reaches the same path. Returns
1084        // RendererSignals; this peer fans them through its
1085        // existing handler.
1086        let signals = self.mutate_editor_with(|e| e.drain_pending_signature_help());
1087        for signal in signals {
1088            self.handle_renderer_signal(signal);
1089        }
1090    }
1091
1092    // 5.5.LSP.2: `do_lsp_nav_request(LspNavKind)` relocated to
1093    // [`lattice_host::dispatch::Editor::lsp_nav_request`]. The
1094    // host-side body is identical (same gate, same per-server
1095    // dispatch over the 4 LSP request shapes, same dedup) and runs
1096    // as the four `Action::Lsp{Definition,Declaration,
1097    // TypeDefinition,Implementation}Request` arms of
1098    // `Editor::dispatch`. The drain side
1099    // (`drain_pending_definitions`) stays App-resident until a
1100    // later phase migrates it -- it still calls
1101    // `jump_to_lsp_location` / `open_lsp_locations_picker`, both
1102    // of which remain App-side for now.
1103
1104    /// Drain queued nav (definition / declaration / typeDef /
1105    /// impl) results and act on them: 0 -> echo, 1 -> jump, N>1
1106    /// -> echo count + open picker. Pushes the pre-jump cursor
1107    /// onto the position history so `<C-o>` walks back. The verb
1108    /// in echoes (`definitions` vs `implementations` etc.) reads
1109    /// from `pending_nav_kind`.
1110    pub fn drain_pending_definitions(&mut self) {
1111        // 5.8.AA.p: hoisted in full to host; jump is performed
1112        // inside `Editor::drain_pending_definitions` and the
1113        // resulting renderer signals are fanned out here.
1114        let signals = self.mutate_editor_with(|e| e.drain_pending_definitions());
1115        for s in signals {
1116            self.handle_renderer_signal(s);
1117        }
1118    }
1119
1120    // `gr` (Phase 4.2.d). Send `textDocument/references` to
1121    // every attached LSP server with `include_declaration: true`
1122    // (vim convention -- `gr` includes the symbol's own
1123    // declaration in the list). Spawn the per-server walk on
1124    // the LSP runtime; drain on the next frame opens a buffer-
1125    // backed `*lsp:references*` view in the active pane.
1126    // 5.5.LSP.3: `do_lsp_references_request` relocated to
1127    // [`lattice_host::dispatch::Editor::lsp_references_request`].
1128    // Identical body shape to the nav helper (same gate, same
1129    // per-server walk, same dedup) but with a `ReferencesOutcome`
1130    // carrier (no tag-stack push -- `gr` is browse-style). Drain
1131    // (`drain_pending_references`) stays App-resident.
1132
1133    /// Drain queued references results. The merged list is
1134    /// rendered as a `*lsp:references*` help buffer and opened
1135    /// in-pane via the LSP-locations picker; existing follow-
1136    /// link machinery (`<CR>` on a Source link) handles jumps.
1137    /// `NoServers` echoes "no LSP server attached"; an empty
1138    /// `Found(_, [])` echoes "no references for X".
1139    pub fn drain_pending_references(&mut self) {
1140        // 5.8.AA: drain body migrated to
1141        // `lattice_host::dispatch::Editor::drain_pending_references`
1142        // so the GPUI peer reaches the same picker via
1143        // `run_tick_pending`.
1144        self.mutate_editor(|e| e.drain_pending_references());
1145    }
1146
1147    /// Jump to an LSP `Location`. If the target is the current
1148    /// buffer, just move the cursor + push history. If
1149    /// cross-file, route through `do_edit` so the `:e` machinery
1150    /// (LSP attach, buffer registry) handles the open; then move
1151    /// cursor.
1152    ///
1153    /// Pushes the *pre-jump* cursor onto position history with
1154    /// source `PositionSource::PluginPush` so `<C-o>` walks back.
1155    /// Tagging it as PluginPush (not AutoJump) reflects that the
1156    /// jump came from an external dispatch (LSP) rather than a
1157    /// vim-style motion.
1158    pub(super) fn jump_to_lsp_location(&mut self, loc: &lattice_lsp::lsp_types::Location) {
1159        // Slice 3c.final.E.5: clone owned + route via mutate_editor_with.
1160        let loc = loc.clone();
1161        let signals = self.mutate_editor_with(move |e| e.jump_to_lsp_location(&loc));
1162        for s in signals {
1163            self.handle_renderer_signal(s);
1164        }
1165    }
1166
1167    /// 5.5.F.7: see [`lattice_host::dispatch::Editor::do_list_diagnostics`].
1168    /// App-side wrapper retained because three tests
1169    /// (`app.rs::tests`, `lsp.rs::tests`) call it directly outside
1170    /// the Effect-arm dispatch path.
1171    #[allow(dead_code)]
1172    pub fn do_list_diagnostics(&mut self) {
1173        self.mutate_editor(|e| e.do_list_diagnostics());
1174    }
1175
1176    /// `]d` / `:diag-next` / `:cnext` -- move the cursor to the
1177    /// next diagnostic in the active buffer. Wraps to top.
1178    pub fn do_next_diagnostic(&mut self) {
1179        // Phase 5.8.AF.3: body migrated.
1180        self.mutate_editor(|e| e.do_next_diagnostic());
1181    }
1182
1183    /// `[d` / `:diag-prev` / `:cprev` -- move the cursor to the
1184    /// previous diagnostic in the active buffer. Wraps to bottom.
1185    pub fn do_prev_diagnostic(&mut self) {
1186        // Phase 5.8.AF.3: body migrated.
1187        self.mutate_editor(|e| e.do_prev_diagnostic());
1188    }
1189
1190    /// `:lsp-log [server]` -- activate the subsystem-wide `*lsp*`
1191    /// buffer (no arg) or a specific server's `*lsp:<server>*`
1192    /// buffer (with arg).
1193    ///
1194    /// Behaviour:
1195    /// - No arg: switch to `*lsp*`. Captures everything the LSP
1196    ///   subsystem logs (lifecycle, attach driver, supervisor,
1197    ///   plus every per-server record prefixed with the server
1198    ///   id). Always works, even when no servers are running --
1199    ///   `*lsp*` is created at boot.
1200    /// - With arg: switch to `*lsp:<server>*` after resolving the
1201    ///   name through the alias table. Errors if no running
1202    ///   instance matches.
1203    ///
1204    /// Use `:lsp-server-log` for the picker over running
1205    /// instances.
1206    pub fn do_open_lsp_log(&mut self, server_id: Option<&str>) {
1207        // Slice 3c.final.E.5: clone owned + route via mutate_editor.
1208        let server_id = server_id.map(|s| s.to_string());
1209        self.mutate_editor(move |e| e.do_open_lsp_log(server_id.as_deref()));
1210    }
1211
1212    /// `:lsp-trace-log [server]`. Phase 5.8.AD.2: migrated.
1213    pub fn do_open_lsp_trace_log(&mut self, server_id: Option<&str>) {
1214        let server_id = server_id.map(|s| s.to_string());
1215        self.mutate_editor(move |e| e.do_open_lsp_trace_log(server_id.as_deref()));
1216    }
1217
1218    /// `:lsp-trace <name>`. Phase 5.8.AD.2: migrated.
1219    pub fn do_toggle_lsp_trace(&mut self, name: &str) {
1220        let name = name.to_string();
1221        self.mutate_editor(move |e| e.do_toggle_lsp_trace(&name));
1222    }
1223
1224    /// EP.4: `:lsp-diagnostics-to-error-list` -- pull the currently
1225    /// published diagnostics into the error list. Body lives on
1226    /// `Editor` (host-resident, like the live feed); this is the thin
1227    /// renderer-side delegation.
1228    pub fn do_lsp_diagnostics_to_error_list(&mut self) {
1229        self.mutate_editor(|e| e.do_lsp_diagnostics_to_error_list());
1230    }
1231
1232    /// `:lsp-status` -- render every running server in a
1233    /// help-style buffer.
1234    pub fn do_lsp_status(&mut self) {
1235        // Phase 5.8.AD.2: body migrated to
1236        // `lattice_host::dispatch::Editor::do_lsp_status`. Returns
1237        // a `DisplayBuffer` signal that the renderer's handler
1238        // routes through `display_buffer`.
1239        let signals = self.mutate_editor_with(|e| e.do_lsp_status());
1240        for s in signals {
1241            self.handle_renderer_signal(s);
1242        }
1243    }
1244
1245    /// `:lsp-server-log` -- vertico picker over every running
1246    /// `(workspace, server_id)` LSP actor. `<CR>` opens the
1247    /// per-server log (`*lsp:<server>*`) for the chosen row.
1248    pub fn do_lsp_server_log_listing(&mut self) {
1249        // Phase 5.8.AD.2: body migrated.
1250        self.mutate_editor(|e| e.do_lsp_server_log_listing());
1251    }
1252
1253    /// `:lsp-restart <server>` -- supervisor restart hook.
1254    /// Currently emits an info message; full restart-with-
1255    /// backoff lands in 4.4.
1256    pub fn do_lsp_restart(&mut self, server_id: &str) {
1257        // Slice 3c.final.E.5: clone owned + route via mutate_editor.
1258        let server_id = server_id.to_string();
1259        self.mutate_editor(move |e| e.do_lsp_restart(&server_id));
1260    }
1261
1262    /// `:lsp-progress-cancel [server]` -- send
1263    /// `window/workDoneProgress/cancel` for every cancellable
1264    /// 4.4.f: per-tick `foldingRange` pump. Only fires when:
1265    /// - `:set foldmethod=lsp` is active for the buffer, AND
1266    /// - `lsp-folding-mode` is enabled, AND
1267    /// - the buffer's document version differs from the cached
1268    ///   version (or there's no cache), AND
1269    /// - no in-flight request is already chasing this version.
1270    ///
1271    /// Single-flight: each new request cancels its predecessor.
1272    /// The drain seats the response into `lsp_folds_cache` and
1273    /// triggers `recompute_folds` so the fold list refreshes
1274    /// without the user having to do anything.
1275    pub fn maybe_request_folding_range(&mut self) {
1276        // 5.8.AA.i: migrated to host.
1277        self.mutate_editor(|e| e.maybe_request_folding_range());
1278    }
1279
1280    // Phase 5.8.AF.5 / Slice 3b.1: `App::drain_pending_folding_range`
1281    // retired -- the spawned LSP request task writes directly
1282    // into `lsp_folds_cache` via `PerBufferCacheExt::insert_for`.
1283    // No drain needed; the `recompute_folds()` side-effect now
1284    // fires from `maybe_request_folding_range` on cache-version
1285    // flip.
1286
1287    /// 4.4.h: per-tick `semanticTokens/full` pump. Fires when
1288    /// `lsp-semantic-tokens-mode` is on AND the buffer's
1289    /// document version differs from the cache (or there's no
1290    /// cache). Single-flight; the decoder runs server-side on
1291    /// the spawned task and the drain seats decoded tokens
1292    /// into the cache.
1293    pub fn maybe_request_semantic_tokens(&mut self) {
1294        // 5.8.AA.i: migrated to host.
1295        self.mutate_editor(|e| e.maybe_request_semantic_tokens());
1296    }
1297
1298    // Phase 5.8.AF.5 / Slice 3b.2: `App::drain_pending_semantic_tokens`
1299    // retired -- spawned LSP request task writes directly into
1300    // `lsp_semantic_tokens_cache` via `PerBufferCacheExt::insert_for`.
1301    // No drain needed. Delta application is factored into
1302    // `Editor::apply_semantic_tokens_delta_outcome` -- the
1303    // synchronous tests that previously fed an outcome through the
1304    // drain now call that helper directly.
1305
1306    /// 4.4.j: per-tick `textDocument/diagnostic` (pull-based)
1307    /// pump. Fires when:
1308    /// - `lsp-diagnostics-mode` is enabled (umbrella +
1309    ///   diagnostics sub-mode), AND
1310    /// - the active server advertises pull diagnostics, AND
1311    /// - the buffer's document version differs from the
1312    ///   cached one (or there's no cache entry).
1313    ///
1314    /// Threads the cached `result_id` back via
1315    /// `previous_result_id` so the server can answer
1316    /// `Unchanged` cheaply when nothing moved. Single-flight:
1317    /// each new request cancels its predecessor. Failures
1318    /// surface as `PullDiagnosticsOutcome::Empty` and the
1319    /// drain seats a cache entry at the current version so
1320    /// the pump doesn't re-fire on the next tick without an
1321    /// actual edit.
1322    pub fn maybe_request_pull_diagnostics(&mut self) {
1323        // 5.8.AA.i: migrated to host.
1324        self.mutate_editor(|e| e.maybe_request_pull_diagnostics());
1325    }
1326
1327    // Phase 5.8.AF.5 / Slice 3b.5: `App::drain_pending_pull_diagnostics`
1328    // retired -- spawned task writes both the per-buffer cache
1329    // slot and fans into `lsp_diagnostics` (Arc-backed layer)
1330    // directly.
1331
1332    /// 4.4.j: drain `workspace/diagnostic/refresh` events.
1333    /// Each event names a server; evict the per-buffer
1334    /// `result_id` cache for every attached buffer so the
1335    /// next pump tick re-pulls without a `previous_result_id`
1336    /// and the server emits a forced `Full` report.
1337    pub fn drain_diagnostic_refresh(&mut self) {
1338        // 5.8.AA.c: migrated to host.
1339        self.mutate_editor(|e| e.drain_diagnostic_refresh());
1340    }
1341
1342    /// 4.4.g: per-tick `inlayHint` pump. Fires when:
1343    /// - `lsp-inlay-hint-mode` is enabled, AND
1344    /// - the buffer's document version differs from the cache
1345    ///   (or there's no cache).
1346    ///
1347    /// Single-flight: each new request cancels its predecessor.
1348    /// Whole-buffer range for simplicity -- the LSP request
1349    /// signature requires a range, but production servers
1350    /// happily handle the entire buffer span. Viewport-only
1351    /// fetching is a follow-up optimization.
1352    pub fn maybe_request_inlay_hint(&mut self) {
1353        // 5.8.AA.g: migrated to host.
1354        self.mutate_editor(|e| e.maybe_request_inlay_hint());
1355    }
1356
1357    /// 4.4.g: drain `workspace/inlayHint/refresh` events. Each
1358    /// event names a server; clear cached inlay hints for any
1359    /// buffer attached to that server so the next render
1360    /// tick's pump re-issues `inlayHint`.
1361    pub fn drain_inlay_hint_refresh(&mut self) {
1362        // 5.8.AA.c: migrated to host.
1363        self.mutate_editor(|e| e.drain_inlay_hint_refresh());
1364    }
1365
1366    /// 4.4.i: drain `workspace/semanticTokens/refresh` events.
1367    /// Same shape as the inlay-hint refresh drain: each event
1368    /// names a server; drop the semantic-tokens cache for every
1369    /// attached buffer so the next render tick's pump re-issues
1370    /// `semanticTokens/full` against a fresh baseline (dropping
1371    /// the now-stale `result_id` rules out a delta request that
1372    /// the server would reject).
1373    pub fn drain_semantic_tokens_refresh(&mut self) {
1374        // 5.8.AA.c: migrated to host.
1375        self.mutate_editor(|e| e.drain_semantic_tokens_refresh());
1376    }
1377
1378    // Phase 5.8.AF.5 / Slice 3b.1: `App::drain_pending_inlay_hint`
1379    // retired -- spawned LSP request task writes directly into
1380    // `lsp_inlay_hints_cache` via `PerBufferCacheExt::insert_for`.
1381    // No drain needed.
1382
1383    /// 4.5.c: per-tick `documentLink` pump. Fires on
1384    /// document-version change (cheap when versions match;
1385    /// the cache lookup short-circuits). Whole-buffer request
1386    /// since link ranges are typically sparse and not bound
1387    /// to a viewport. Single-flight per buffer; each new
1388    /// request cancels its predecessor.
1389    pub fn maybe_request_document_link(&mut self) {
1390        // 5.8.AA.i: migrated to host.
1391        self.mutate_editor(|e| e.maybe_request_document_link());
1392    }
1393
1394    // Phase 5.8.AF.5 / Slice 3b.4: `App::drain_pending_document_link`
1395    // retired -- spawned task writes directly via
1396    // `PerBufferCacheExt::insert_for`. `gx` reads via `get_for`.
1397
1398    /// 4.5.c: follow the LSP `documentLink` at the cursor (the
1399    /// `gx` keystroke). Walks the cache, picks the first link
1400    /// whose range covers the cursor, follows its `target`.
1401    /// When the link has no target AND the server advertises
1402    /// `documentLinkProvider.resolveProvider`, fires
1403    /// `documentLink/resolve` to fill in the target before
1404    /// following. Echoes `(no link at cursor)` when the cache
1405    /// is empty or the cursor sits outside every cached range.
1406    pub fn do_lsp_follow_link_at_cursor(&mut self) {
1407        // Phase 5.8.AD.2: body migrated.
1408        let signals = self.mutate_editor_with(|e| e.do_lsp_follow_link_at_cursor());
1409        for s in signals {
1410            self.handle_renderer_signal(s);
1411        }
1412    }
1413
1414    /// 4.5.d: per-tick `codeLens` pump. Fires on document-
1415    /// version change OR cache miss (`workspace/codeLens/refresh`
1416    /// evicts the entry). Single-flight per buffer.
1417    pub fn maybe_request_code_lens(&mut self) {
1418        // 5.8.AA.i: migrated to host.
1419        self.mutate_editor(|e| e.maybe_request_code_lens());
1420    }
1421
1422    // Phase 5.8.AF.5 / Slice 3b.3: `App::drain_pending_code_lens`
1423    // retired -- spawned LSP request task writes directly into
1424    // `lsp_code_lens_cache` via `PerBufferCacheExt::insert_for`.
1425    // The `:lsp-code-lens` picker reads through `get_for`.
1426
1427    /// 4.5.d: `:lsp-code-lens`. Open a picker over the
1428    /// active buffer's cached lenses. Empty cache -> echo.
1429    /// Accept routes through
1430    /// [`Self::accept_lsp_code_lens`].
1431    pub(super) fn do_lsp_code_lens_picker(&mut self) {
1432        // Phase 5.8.AD.2: body migrated.
1433        self.mutate_editor(|e| e.do_lsp_code_lens_picker());
1434    }
1435
1436    /// 4.5.d: accept a code lens by `index` (the routing
1437    /// payload). Resolves the lens via `codeLens/resolve`
1438    /// when its `command` is missing AND the server advertises
1439    /// `codeLensProvider.resolveProvider`. The resulting
1440    /// `command` routes through `workspace/executeCommand`
1441    /// on the originating server (the one that produced the
1442    /// cache).
1443    pub(super) fn accept_lsp_code_lens(&mut self, index: u32) {
1444        // Phase 5.8.AD.2: body migrated.
1445        self.mutate_editor(move |e| e.accept_lsp_code_lens(index));
1446    }
1447
1448    /// 4.5.e: per-tick `documentColor` pump. Same shape as
1449    /// the documentLink pump: fires on doc-version change,
1450    /// single-flight per buffer.
1451    pub fn maybe_request_document_color(&mut self) {
1452        // 5.8.AA.i: migrated to host.
1453        self.mutate_editor(|e| e.maybe_request_document_color());
1454    }
1455
1456    // Phase 5.8.AF.5 / Slice 3b.4: `App::drain_pending_document_color`
1457    // retired -- spawned task writes directly via
1458    // `PerBufferCacheExt::insert_for`.
1459
1460    /// 4.5.e: `:lsp-color-presentation`. Looks up the color
1461    /// literal under the cursor in the per-buffer cache and
1462    /// fires `textDocument/colorPresentation` to get the
1463    /// alternative formats; opens the result as a picker.
1464    /// Accept replaces the literal with the chosen
1465    /// `ColorPresentation.text_edit` (or `label` fallback) at
1466    /// the literal's range.
1467    pub(super) fn do_lsp_color_presentation(&mut self) {
1468        // Phase 5.8.AD.2: body migrated.
1469        self.mutate_editor(|e| e.do_lsp_color_presentation());
1470    }
1471
1472    /// 4.5.e: accept one color presentation by index. Phase 5.8.AD.2.
1473    pub(super) fn accept_lsp_color_presentation(&mut self, index: u32) {
1474        self.mutate_editor(move |e| e.accept_lsp_color_presentation(index));
1475    }
1476
1477    /// 4.5.d: drain `workspace/codeLens/refresh` events. Each
1478    /// event names a server; evict every cached code-lens
1479    /// entry that came from that server. The next pump tick
1480    /// re-issues `textDocument/codeLens`.
1481    pub fn drain_code_lens_refresh(&mut self) {
1482        // 5.8.AA.c: migrated to host.
1483        self.mutate_editor(|e| e.drain_code_lens_refresh());
1484    }
1485
1486    /// 4.4.e: per-tick `documentHighlight` pump. Compares the
1487    /// current cursor against the cache anchor; when they
1488    /// differ AND the sub-mode is on AND the buffer has an
1489    /// attached server advertising the capability, fires a
1490    /// fresh request (cancelling any in-flight).
1491    ///
1492    /// Phase 5.8.AF.5 / Slice 3b.0: the spawned LSP request task
1493    /// now writes results directly into
1494    /// `editor.lsp_document_highlights` (`Arc<ArcSwapOption<...>>`)
1495    /// when the response arrives -- no channel, no UI-thread
1496    /// drain. Self-cancelling: the cursor moves faster than the
1497    /// network round-trip during a `/word` search, so the
1498    /// `CancellationToken` invalidates every in-flight request
1499    /// the moment the next one fires.
1500    pub fn maybe_request_document_highlight(&mut self) {
1501        // 5.8.AA.g: migrated to host.
1502        self.mutate_editor(|e| e.maybe_request_document_highlight());
1503    }
1504    // Phase 5.8.AF.5 / Slice 3b.0: `App::drain_pending_document_highlight`
1505    // retired -- the spawned request task on the LSP runtime
1506    // writes directly into the cache slot's ArcSwap when the
1507    // response arrives. No drain needed.
1508
1509    /// 4.4.e: `:lsp-expand-region` -- structural smart-
1510    /// expansion. If a cached chain still applies (cursor sits
1511    /// inside its innermost range AND same buffer), step the
1512    /// index outward and apply the new selection. Otherwise
1513    /// fire `textDocument/selectionRange` and let the drain
1514    /// seat the chain + apply step 0 on completion.
1515    pub fn do_lsp_expand_region(&mut self) {
1516        // Phase 5.8.AD.2: body migrated to
1517        // `lattice_host::dispatch::Editor::do_lsp_expand_region`.
1518        self.mutate_editor(|e| e.do_lsp_expand_region());
1519    }
1520
1521    /// 4.4.e: `:lsp-shrink-region`. Phase 5.8.AD.2: migrated.
1522    pub fn do_lsp_shrink_region(&mut self) {
1523        self.mutate_editor(|e| e.do_lsp_shrink_region());
1524    }
1525
1526    /// 4.4.e: drain the in-flight `selectionRange` response.
1527    /// Seats the chain into `App::lsp_selection_chain` and
1528    /// applies the step the original invocation requested.
1529    pub fn drain_pending_selection_range(&mut self) {
1530        // 5.8.AA.m: migrated to host.
1531        self.mutate_editor(|e| e.drain_pending_selection_range());
1532    }
1533
1534    /// active progress entry (4.4.c). With `server_id == Some`,
1535    /// cancel only entries on that server; with `None`, cancel
1536    /// across every server attached to the current buffer.
1537    ///
1538    /// The entry stays in the accumulator until the server sends
1539    /// the `end` progress notification — `cancel` is best-effort
1540    /// per spec, and the server may decline.
1541    pub fn do_lsp_progress_cancel(&mut self, server_id: Option<&str>) {
1542        // Slice 3c.final.E.5: clone owned + route via mutate_editor.
1543        let server_id = server_id.map(|s| s.to_string());
1544        self.mutate_editor(move |e| e.do_lsp_progress_cancel(server_id.as_deref()));
1545    }
1546
1547    /// `:lsp-log-level [server] <level>` -- set the subsystem
1548    /// default min level (when no server) or a per-server
1549    /// override.
1550    pub fn do_set_lsp_log_level(&mut self, server_id: Option<&str>, level: &str) {
1551        // Slice 3c.final.E.5: clone owned + route via mutate_editor.
1552        let server_id = server_id.map(|s| s.to_string());
1553        let level = level.to_string();
1554        self.mutate_editor(move |e| e.do_set_lsp_log_level(server_id.as_deref(), &level));
1555    }
1556
1557    /// `:lsp-log-clear [server]`. Phase 5.8.AD.2: migrated.
1558    pub fn do_lsp_log_clear(&mut self, server_id: Option<&str>) {
1559        // Slice 3c.final.E.5: clone owned + route via mutate_editor.
1560        let server_id = server_id.map(|s| s.to_string());
1561        self.mutate_editor(move |e| e.do_lsp_log_clear(server_id.as_deref()));
1562    }
1563
1564    /// Activate the per-instance
1565    /// `*lsp:<server_id>:<workspace>*` Document buffer in the
1566    /// active pane. B'.4: when multiple instances of `server_id`
1567    /// are running, this opens the first match; the broader
1568    /// picker (`:lsp-server-log`) lets the user disambiguate.
1569    /// B'.7: thin wrapper over the generic
1570    /// `ensure_named_synthetic_document` helper -- canonical name +
1571    /// mode id come from `lattice-lsp`. Idempotent: the buffer
1572    /// is created lazily on first call.
1573    pub(super) fn open_lsp_log_in_pane(&mut self, server_id: &str) {
1574        // Slice 3c.final.E.5: clone owned + route via mutate_editor.
1575        let server_id = server_id.to_string();
1576        self.mutate_editor(move |e| e.open_lsp_log_in_pane(&server_id));
1577    }
1578
1579    /// Activate the per-instance trace-log Document buffer.
1580    pub(super) fn open_lsp_trace_log_in_pane(&mut self, server_id: &str) {
1581        let server_id = server_id.to_string();
1582        self.mutate_editor(move |e| e.open_lsp_trace_log_in_pane(&server_id));
1583    }
1584
1585    /// Helper: publish a position-only change event. Cheap
1586    /// stand-in for whatever the rest of the App uses to
1587    /// signal cursor moves. Currently a no-op since the
1588    /// renderer reads cursor directly; reserved for future
1589    /// position-history pushes.
1590    pub(super) fn publish_position_change(&self) {
1591        // 4.1.d.iv: position history hook reserved -- a real
1592        // PluginPush entry lands here when the position-history
1593        // wiring catches up.
1594    }
1595
1596    /// Resolve a user-supplied server name to a canonical server
1597    /// id. Tries, in order:
1598    ///
1599    /// 1. Exact id match against running actors (the common case
1600    ///    once a buffer has attached).
1601    /// 2. Exact id match against registered configs (so
1602    ///    `:lsp-trace rust` works pre-spawn -- e.g. enable trace
1603    ///    before opening the first .rs file).
1604    /// 3. Binary file-name (or stem) match against configs (so
1605    ///    `:lsp-trace rust-analyzer` resolves to the `rust` actor
1606    ///    id when the user types the binary they recognise).
1607    ///
1608    /// Returns `None` when none matches.
1609    pub(super) fn resolve_server_id(&self, name: &str) -> Option<String> {
1610        // Slice 3c.final.E.swap-prep: clone for Send + 'static closure.
1611        let name = name.to_string();
1612        self.read_editor(move |e| e.resolve_server_id(&name))
1613    }
1614
1615    /// Distinct server ids of every running actor.
1616    pub(super) fn running_server_ids(&self) -> Vec<String> {
1617        // Phase 5.8.AD.2: body migrated.
1618        self.read_editor(move |e| e.running_server_ids())
1619    }
1620}
1621
1622#[cfg(test)]
1623mod tests {
1624    #![allow(clippy::unwrap_used, clippy::panic)]
1625
1626    use super::*;
1627    use crate::app::test_helpers::{app_with, seed_diags_at_lines};
1628    use crate::app::*;
1629
1630    /// CSM.8b.3 test helper: pack a `LspCompletionMeta` into the
1631    /// `RawCandidate` shape `produce_async` emits -- display +
1632    /// match-text derived from label/detail/filter_text, payload
1633    /// is the serde-encoded meta. Mirrors the construction the
1634    /// production source does in `lattice-lsp::completion`.
1635    fn lsp_meta_candidate(
1636        meta: lattice_lsp::completion::LspCompletionMeta,
1637    ) -> lattice_completion::RawCandidate {
1638        let display = match meta.detail.as_ref() {
1639            Some(d) => format!("{}  {}", meta.label, d),
1640            None => meta.label.clone(),
1641        };
1642        let match_text = meta
1643            .filter_text
1644            .clone()
1645            .unwrap_or_else(|| meta.label.clone());
1646        let payload = lattice_lsp::completion::encode_meta(&meta);
1647        let mut raw = lattice_completion::RawCandidate::plain(
1648            match_text,
1649            lattice_completion::CandidateKind::Plain,
1650        )
1651        .with_source(lattice_completion::SourceId::new(
1652            lattice_completion::LSP_COMPLETION_SOURCE_ID,
1653        ));
1654        raw.display = display;
1655        raw.data = lattice_completion::CandidateData::Extension {
1656            kind_id: LSP_COMPLETION_KIND_ID,
1657            payload,
1658        };
1659        raw
1660    }
1661
1662    #[test]
1663    fn lsp_mode_gates_document_changed_typed_event_at_publish_site() {
1664        // M.5.5: the LSP fan-in subscribes to
1665        // `LspDocumentChanged` (typed bus), and App's
1666        // `publish_document_changed` gates the publish_typed
1667        // call on `lsp_mode_enabled_for`. This test verifies
1668        // the gate at the publish site:
1669        // - lsp-mode off → no LspDocumentChanged emitted.
1670        // - lsp-mode on  → LspDocumentChanged emitted on edit.
1671        let mut a = app_with("xx", 10);
1672        let (tx, mut rx) =
1673            tokio::sync::mpsc::unbounded_channel::<lattice_lsp::LspDocumentChanged>();
1674        a.editor.event_bus.subscribe_typed(tx);
1675        // Default (no path → lsp-mode off): drive an edit; no
1676        // typed event should reach the subscriber.
1677        a.apply(Action::Insert("a".into()));
1678        assert!(
1679            rx.try_recv().is_err(),
1680            "lsp-mode off should suppress LspDocumentChanged"
1681        );
1682        // Activate lsp-mode and edit again -- now the typed
1683        // event should publish.
1684        a.toggle_mode_by_name("lsp-mode");
1685        a.apply(Action::Insert("b".into()));
1686        let received = rx.try_recv();
1687        assert!(
1688            received.is_ok(),
1689            "lsp-mode on should emit LspDocumentChanged on edit"
1690        );
1691    }
1692
1693    #[tokio::test(flavor = "multi_thread")]
1694    async fn lsp_mode_round_trip_end_to_end() {
1695        // M.5.7 + M-async.5: end-to-end gate exercise across
1696        // one buffer's lifetime. Open a *.rs file -- under
1697        // M-async.5 `LspMode::on_activate` `.await`s the
1698        // supervisor's `open_buffer` mailbox, so the spawn
1699        // path is in flight after App::new returns. Toggle
1700        // off -> the deactivate bumps the epoch, the spawn
1701        // eventually completes + drops its stale Guard ->
1702        // LspBufferDetached publishes. Toggle on -> same
1703        // shape, new spawn. The gate flags (`lsp_mode_
1704        // enabled_for`) move synchronously via the sync
1705        // prefix.
1706        use crate::app::test_helpers::app_with_path;
1707        let mut a = app_with_path("fn main() {}", 5, std::path::PathBuf::from("foo.rs"));
1708        let id = a.editor.pane_tree.active().buffer_id;
1709        assert!(a.lsp_mode_enabled_for(id), "M.5.2 auto-activation");
1710
1711        let (detach_tx, mut detach_rx) =
1712            tokio::sync::mpsc::unbounded_channel::<lattice_lsp::LspBufferDetached>();
1713        a.editor.event_bus.subscribe_typed(detach_tx);
1714        let (changed_tx, mut changed_rx) =
1715            tokio::sync::mpsc::unbounded_channel::<lattice_lsp::LspDocumentChanged>();
1716        a.editor.event_bus.subscribe_typed(changed_tx);
1717
1718        // ---- toggle off: gate closes. ----
1719        a.toggle_mode_by_name("lsp-mode");
1720        assert!(!a.lsp_mode_enabled_for(id));
1721        // M.5.3 detach event fires (synchronously if Guard
1722        // landed pre-toggle, else when the spawn-side Drop
1723        // runs).
1724        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1725        while detach_rx.try_recv().is_err() {
1726            if std::time::Instant::now() >= deadline {
1727                panic!("LspBufferDetached did not arrive within 2s");
1728            }
1729            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1730        }
1731        // M.5.4 request gate: hover echoes the gate message.
1732        a.apply_effect(lattice_grammar::Effect::Lsp(
1733            lattice_grammar::LspRequest::Hover,
1734        ));
1735        let msg = a.editor.last_message.as_ref().expect("gate echo");
1736        assert!(
1737            msg.text.contains("lsp-mode disabled"),
1738            "expected gate echo, got: {}",
1739            msg.text
1740        );
1741        // M.5.5 sync gate: edits don't publish LspDocumentChanged.
1742        a.apply(Action::Insert("a".into()));
1743        assert!(
1744            changed_rx.try_recv().is_err(),
1745            "lsp-mode off should suppress LspDocumentChanged"
1746        );
1747
1748        // ---- toggle on: gate opens; signals resume. ----
1749        a.toggle_mode_by_name("lsp-mode");
1750        assert!(a.lsp_mode_enabled_for(id));
1751        a.apply(Action::Insert("b".into()));
1752        assert!(
1753            changed_rx.try_recv().is_ok(),
1754            "lsp-mode on should re-emit LspDocumentChanged"
1755        );
1756    }
1757
1758    #[test]
1759    fn lsp_mode_off_gates_request_entry_points_with_info_echo() {
1760        // M.5.4: `lsp-mode` off means LSP request entry points
1761        // bail with a discoverable echo (so users don't think
1762        // their bindings are broken). Verified for the
1763        // ex-command-driven path; keymap-driven (insert-mode
1764        // completion / signature) are silent by design.
1765        let mut a = app_with("xx", 10);
1766        // Default: no auto-activation (no path).
1767        assert!(!a.lsp_mode_enabled_for(a.editor.document_buffer_id));
1768        a.apply_effect(lattice_grammar::Effect::Lsp(
1769            lattice_grammar::LspRequest::Hover,
1770        ));
1771        let msg = a.editor.last_message.as_ref().expect("gate echo");
1772        assert_eq!(msg.level, EchoLevel::Info);
1773        assert!(
1774            msg.text.contains("lsp-mode disabled"),
1775            "expected lsp-mode-disabled echo, got: {}",
1776            msg.text
1777        );
1778    }
1779
1780    #[test]
1781    fn lsp_hover_mode_off_with_umbrella_on_echoes_sub_mode_message() {
1782        // M.6.2: when umbrella is on but sub-mode is off, the
1783        // gate echoes the *sub-mode's* name -- the user knows
1784        // exactly which switch to flip.
1785        let mut a = app_with("xx", 10);
1786        a.toggle_mode_by_name("lsp-mode");
1787        // Cascade activated lsp-hover-mode; toggle it off
1788        // independently.
1789        a.toggle_mode_by_name("lsp-hover-mode");
1790        assert!(a.lsp_mode_enabled_for(a.editor.document_buffer_id));
1791        assert!(!a.lsp_hover_mode_enabled_for(a.editor.document_buffer_id));
1792        // Hover request now bails with sub-mode echo (umbrella
1793        // is on, so the umbrella check inside the helper passes).
1794        a.apply_effect(lattice_grammar::Effect::Lsp(
1795            lattice_grammar::LspRequest::Hover,
1796        ));
1797        let msg = a.editor.last_message.as_ref().expect("gate echo");
1798        assert_eq!(msg.level, EchoLevel::Info);
1799        assert!(
1800            msg.text.contains("lsp-hover-mode disabled"),
1801            "expected lsp-hover-mode-disabled echo, got: {}",
1802            msg.text
1803        );
1804    }
1805
1806    #[test]
1807    fn lsp_format_mode_off_gates_format_request() {
1808        // M.6.2: independent disable of `lsp-format-mode`. Format
1809        // requests echo the sub-mode's name; other LSP requests
1810        // (hover, nav) keep working in the same buffer.
1811        let mut a = app_with("xx", 10);
1812        a.toggle_mode_by_name("lsp-mode");
1813        a.toggle_mode_by_name("lsp-format-mode");
1814        // Format gates. (Tested via the do_lsp_format_request
1815        // method directly -- the dispatch path through
1816        // ex-commands also routes here.)
1817        a.do_lsp_format_request(false);
1818        let msg = a.editor.last_message.as_ref().expect("format gate echo");
1819        assert!(
1820            msg.text.contains("lsp-format-mode disabled"),
1821            "expected lsp-format-mode-disabled echo, got: {}",
1822            msg.text
1823        );
1824    }
1825
1826    #[test]
1827    fn lsp_nav_mode_off_gates_definition_request() {
1828        // M.6.2: nav family (`gd` / `gD` / `gy` / `gI` / `gr`)
1829        // shares one sub-mode (`lsp-nav-mode`).
1830        let mut a = app_with("xx", 10);
1831        a.toggle_mode_by_name("lsp-mode");
1832        a.toggle_mode_by_name("lsp-nav-mode");
1833        a.apply_effect(lattice_grammar::Effect::Lsp(
1834            lattice_grammar::LspRequest::Definition,
1835        )); // 5.5.LSP.2
1836        let msg = a.editor.last_message.as_ref().expect("nav gate echo");
1837        assert!(
1838            msg.text.contains("lsp-nav-mode disabled"),
1839            "expected lsp-nav-mode-disabled echo, got: {}",
1840            msg.text
1841        );
1842    }
1843
1844    #[test]
1845    fn umbrella_off_wins_over_sub_mode_state() {
1846        // M.6.2: when the umbrella is off, the sub-mode message
1847        // never fires -- the umbrella check is the first thing
1848        // every gate does. The user sees one consistent message
1849        // ("enable lsp-mode first") rather than a stack of
1850        // sub-mode-disabled echoes.
1851        let mut a = app_with("xx", 10);
1852        // Activate umbrella + sub-modes via cascade, then turn
1853        // umbrella off. Sub-modes also flip off via cascade-off
1854        // — but even hypothetically a stale sub-mode entry
1855        // wouldn't bypass the umbrella check.
1856        a.toggle_mode_by_name("lsp-mode");
1857        a.toggle_mode_by_name("lsp-mode");
1858        assert!(!a.lsp_mode_enabled_for(a.editor.document_buffer_id));
1859        a.apply_effect(lattice_grammar::Effect::Lsp(
1860            lattice_grammar::LspRequest::Hover,
1861        ));
1862        let msg = a.editor.last_message.as_ref().expect("umbrella echo");
1863        // Umbrella echo, not sub-mode echo.
1864        assert!(
1865            msg.text.contains("lsp-mode disabled") && !msg.text.contains("lsp-hover-mode"),
1866            "expected umbrella echo (not sub-mode echo), got: {}",
1867            msg.text
1868        );
1869    }
1870
1871    #[test]
1872    fn lsp_mode_enabled_for_returns_false_by_default() {
1873        // M.5.0: a freshly-opened buffer has no `lsp-mode`
1874        // activated yet. Auto-activation lands in M.5.2 via the
1875        // `MajorEntered` event hook; until then the accessor is
1876        // false everywhere.
1877        let a = app_with("fn main() {}", 5);
1878        let id = a.editor.pane_tree.active().buffer_id;
1879        assert!(!a.lsp_mode_enabled_for(id));
1880    }
1881
1882    #[test]
1883    fn lsp_mode_enabled_for_tracks_minor_activation() {
1884        // M.5.0: activating `lsp-mode` through the registry
1885        // flips the accessor. M.5.3 will wrap this in actual
1886        // `:lsp-mode` toggle / auto-activation flow; for now
1887        // we drive the registry directly.
1888        let mut a = app_with("fn main() {}", 5);
1889        let id = a.editor.pane_tree.active().buffer_id;
1890        let proto_id = lattice_protocol::ids::BufferId::new(id.0 as u64);
1891        let mut active = a.editor.active_modes.remove(&id).unwrap_or_default();
1892        a.editor
1893            .mode_registry
1894            .load_full()
1895            .activate_minor(
1896                &mut active,
1897                &a.editor.mode_guards,
1898                &a.editor.config,
1899                &a.editor.event_bus,
1900                &a.editor.services,
1901                proto_id,
1902                lattice_lsp::modes::LspMode::mode_id(),
1903                lattice_mode::CapabilitySet::empty(),
1904            )
1905            .expect("activate lsp-mode");
1906        a.editor.active_modes.insert(id, active);
1907        a.editor.publish_render_state();
1908        assert!(a.lsp_mode_enabled_for(id));
1909    }
1910
1911    #[test]
1912    fn hover_dismisses_on_document_cursor_motion() {
1913        // Vim/emacs UX: any motion off the hovered symbol drops
1914        // the popup. Apply a hover popup directly (skipping the
1915        // async LSP path), move the cursor, assert dismissal.
1916        let mut a = app_with("fn main() {}\nlet x = 1;\n", 5);
1917        a.do_open_hover("hover body");
1918        assert!(a.editor.popup_buffer.is_some());
1919        // State A: focus still on doc, prev_pane_for_popup is None.
1920        assert!(a.editor.prev_pane_for_popup.is_none());
1921        assert!(matches!(a.editor.active_buffer, BufferKind::Document));
1922        // Drive a real motion through `apply` (`l` -- char-right).
1923        let inv = lattice_grammar::CommandInvocation::of(a.editor.builtins.char_right.0);
1924        a.apply(Action::Invoke(inv));
1925        assert!(
1926            a.editor.popup_buffer.is_none(),
1927            "hover popup should dismiss on cursor motion in State A"
1928        );
1929    }
1930
1931    #[test]
1932    fn hover_does_not_dismiss_when_cursor_unchanged() {
1933        // No-op actions (e.g. setting a no-arg ex command,
1934        // an out-of-bounds motion that clamps in place) must not
1935        // dismiss the popup. Use a count-only push (`5`) which
1936        // doesn't move the cursor.
1937        let mut a = app_with("fn main() {}\n", 5);
1938        a.do_open_hover("hover body");
1939        assert!(a.editor.popup_buffer.is_some());
1940        a.apply(Action::PushDigit(5));
1941        assert!(
1942            a.editor.popup_buffer.is_some(),
1943            "hover should survive a count-prefix push"
1944        );
1945    }
1946
1947    #[test]
1948    fn hover_open_populates_help_buffer() {
1949        let mut a = app_with("alpha\nbeta\ngamma", 10);
1950        a.editor.cursor = Position::new(1, 2);
1951        a.editor.set_command_line_text("hover documentation");
1952        a.editor.modal = ModalState::Command;
1953        a.apply(Action::CommandLineSubmit);
1954        let h = a.popup_help().expect("hover open");
1955        assert_eq!(h.title, "hover");
1956        assert!(h.content.as_string().contains("documentation"));
1957        // State A: focus stays on doc.
1958        assert!(matches!(a.editor.active_buffer, BufferKind::Document));
1959        assert!(a.editor.prev_pane_for_popup.is_none());
1960    }
1961
1962    #[test]
1963    fn hover_close_dismisses_popup() {
1964        let mut a = app_with("xx", 10);
1965        a.editor.set_command_line_text("hover x");
1966        a.editor.modal = ModalState::Command;
1967        a.apply(Action::CommandLineSubmit);
1968        assert!(a.editor.popup_buffer.is_some());
1969        a.editor.set_command_line_text("HoverClose");
1970        a.editor.modal = ModalState::Command;
1971        a.apply(Action::CommandLineSubmit);
1972        assert!(a.editor.popup_buffer.is_none());
1973    }
1974
1975    #[test]
1976    fn hover_with_no_arg_uses_placeholder() {
1977        let mut a = app_with("xx", 10);
1978        a.editor.set_command_line_text("hover");
1979        a.editor.modal = ModalState::Command;
1980        a.apply(Action::CommandLineSubmit);
1981        let h = a.popup_help().expect("hover open");
1982        assert!(h.content.as_string().contains("empty"));
1983    }
1984
1985    #[test]
1986    fn hover_contents_scalar_string_renders_verbatim() {
1987        let m = lattice_lsp::lsp_types::HoverContents::Scalar(
1988            lattice_lsp::lsp_types::MarkedString::String("fn foo() -> u32".into()),
1989        );
1990        assert_eq!(super::hover_contents_to_markdown(&m), "fn foo() -> u32");
1991    }
1992
1993    #[test]
1994    fn hover_contents_language_string_renders_as_fenced_block() {
1995        let m = lattice_lsp::lsp_types::HoverContents::Scalar(
1996            lattice_lsp::lsp_types::MarkedString::LanguageString(
1997                lattice_lsp::lsp_types::LanguageString {
1998                    language: "rust".into(),
1999                    value: "let x: u32 = 5;".into(),
2000                },
2001            ),
2002        );
2003        let md = super::hover_contents_to_markdown(&m);
2004        assert!(md.contains("```rust"));
2005        assert!(md.contains("let x: u32 = 5;"));
2006        assert!(md.ends_with("```"));
2007    }
2008
2009    #[test]
2010    fn hover_contents_array_joins_with_double_newline() {
2011        let m = lattice_lsp::lsp_types::HoverContents::Array(vec![
2012            lattice_lsp::lsp_types::MarkedString::String("first".into()),
2013            lattice_lsp::lsp_types::MarkedString::String("second".into()),
2014        ]);
2015        let md = super::hover_contents_to_markdown(&m);
2016        assert_eq!(md, "first\n\nsecond");
2017    }
2018
2019    #[test]
2020    fn hover_contents_markup_uses_value_as_markdown() {
2021        let m =
2022            lattice_lsp::lsp_types::HoverContents::Markup(lattice_lsp::lsp_types::MarkupContent {
2023                kind: lattice_lsp::lsp_types::MarkupKind::Markdown,
2024                value: "# heading\n\nbody".into(),
2025            });
2026        assert_eq!(super::hover_contents_to_markdown(&m), "# heading\n\nbody");
2027    }
2028
2029    #[test]
2030    fn lsp_hover_request_with_no_uri_echoes_no_lsp_attached() {
2031        // Initial document has no path, so no URI mapping; the
2032        // request should set an info message and not panic.
2033        // M.5.4: gate is checked first, so we activate lsp-mode
2034        // explicitly to test the URI-bail path the original test
2035        // was probing.
2036        let mut a = app_with("xx", 10);
2037        a.toggle_mode_by_name("lsp-mode");
2038        a.apply_effect(lattice_grammar::Effect::Lsp(
2039            lattice_grammar::LspRequest::Hover,
2040        ));
2041        let msg = a.editor.last_message.as_ref().expect("echo");
2042        assert_eq!(msg.level, EchoLevel::Info);
2043        assert!(msg.text.contains("no LSP server"));
2044    }
2045
2046    #[test]
2047    fn lsp_hover_request_pre_cancels_in_flight_token() {
2048        // Two K presses in a row: the first one's token must be
2049        // flipped before the second's request fires, so a slow
2050        // first response gets dropped by the relay's cancel-aware
2051        // poll loop.
2052        let mut a = app_with("xx", 10);
2053        // Manually install an in-flight token.
2054        let stale = lattice_protocol::CancellationToken::new();
2055        a.editor.pending_hover_token = Some(stale.clone());
2056        // Trigger another hover. With no LSP attached the new
2057        // request bails on the URI lookup, but the cancel of the
2058        // previous token should still happen first.
2059        a.apply_effect(lattice_grammar::Effect::Lsp(
2060            lattice_grammar::LspRequest::Hover,
2061        ));
2062        assert!(
2063            stale.is_cancelled(),
2064            "prior in-flight hover token should flip on a new K press"
2065        );
2066    }
2067
2068    #[test]
2069    fn drain_pending_hover_body_outcome_opens_popup() {
2070        let mut a = app_with("xx", 10);
2071        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<crate::app::HoverOutcome>();
2072        a.editor.pending_hover_rx = Some(rx);
2073        a.editor.pending_hover_token = Some(lattice_protocol::CancellationToken::new());
2074        tx.send(crate::app::HoverOutcome::Body("**bold body**".into()))
2075            .unwrap();
2076        a.drain_pending_hover();
2077        let h = a.popup_help().expect("popup");
2078        assert!(h.content.as_string().contains("**bold body**"));
2079        // State A entry: focus still on the doc.
2080        assert!(matches!(a.editor.active_buffer, BufferKind::Document));
2081        assert!(a.editor.prev_pane_for_popup.is_none());
2082        assert!(
2083            a.editor.pending_hover_token.is_none(),
2084            "delivering the outcome should clear the in-flight token"
2085        );
2086    }
2087
2088    #[test]
2089    fn drain_pending_hover_no_body_outcome_echoes_no_hover_info() {
2090        // Regression for the silent-K-press symptom: if every
2091        // attached server replies with empty contents,
2092        // `drain_pending_hover` should echo a clear "no hover
2093        // info" so the user knows their K press was received.
2094        let mut a = app_with("xx", 10);
2095        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<crate::app::HoverOutcome>();
2096        a.editor.pending_hover_rx = Some(rx);
2097        a.editor.pending_hover_token = Some(lattice_protocol::CancellationToken::new());
2098        tx.send(crate::app::HoverOutcome::NoBody { servers_tried: 1 })
2099            .unwrap();
2100        a.drain_pending_hover();
2101        assert!(a.editor.popup_buffer.is_none(), "no popup for empty hover");
2102        let msg = a
2103            .editor
2104            .last_message
2105            .as_ref()
2106            .expect("echo on no-hover-info");
2107        assert_eq!(msg.level, EchoLevel::Info);
2108        assert!(
2109            msg.text.contains("no hover info"),
2110            "expected 'no hover info' echo; got `{}`",
2111            msg.text
2112        );
2113    }
2114
2115    #[test]
2116    fn drain_pending_hover_no_servers_outcome_echoes_warn() {
2117        // Buffer URI maps to no attached servers (e.g. spawn
2118        // failed at boot). The user gets a Warn echo pointing at
2119        // :lsp-status / :lsp-log so they can investigate.
2120        let mut a = app_with("xx", 10);
2121        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<crate::app::HoverOutcome>();
2122        a.editor.pending_hover_rx = Some(rx);
2123        a.editor.pending_hover_token = Some(lattice_protocol::CancellationToken::new());
2124        tx.send(crate::app::HoverOutcome::NoServers).unwrap();
2125        a.drain_pending_hover();
2126        let msg = a
2127            .editor
2128            .last_message
2129            .as_ref()
2130            .expect("echo on no-servers-attached");
2131        assert_eq!(msg.level, EchoLevel::Warn);
2132        assert!(
2133            msg.text.contains("no LSP servers"),
2134            "expected NoServers warn echo; got `{}`",
2135            msg.text
2136        );
2137    }
2138
2139    #[test]
2140    fn drain_pending_hover_idle_channel_is_noop() {
2141        let mut a = app_with("xx", 10);
2142        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel::<crate::app::HoverOutcome>();
2143        a.editor.pending_hover_rx = Some(rx);
2144        a.drain_pending_hover();
2145        assert!(a.editor.popup_buffer.is_none());
2146        assert!(a.editor.last_message.is_none());
2147    }
2148
2149    #[test]
2150    fn app_to_lsp_position_converts_utf8_byte_to_utf16_column() {
2151        let buf = lattice_core::Buffer::from_text("hello\nαβγ\nworld\n");
2152        // Line 1 (αβγ): 2-byte UTF-8 chars; byte 4 = end of β.
2153        // utf-16 column at byte 4: α (1 unit) + β (1 unit) = 2.
2154        let p = super::app_to_lsp_position(&buf, Position::new(1, 4)).expect("in-range");
2155        assert_eq!(p.line, 1);
2156        assert_eq!(p.character, 2);
2157    }
2158
2159    #[test]
2160    fn app_to_lsp_position_returns_none_for_out_of_range_line() {
2161        let buf = lattice_core::Buffer::from_text("only-one-line\n");
2162        assert!(super::app_to_lsp_position(&buf, Position::new(99, 0)).is_none());
2163    }
2164
2165    /// 4.4.e: flatten LSP linked-list `SelectionRange` into a
2166    /// `Vec<Range>` ordered innermost-first.
2167    #[test]
2168    fn flatten_selection_range_chain_walks_parent_links() {
2169        let outer = lattice_lsp::lsp_types::SelectionRange {
2170            range: lattice_lsp::lsp_types::Range {
2171                start: lattice_lsp::lsp_types::Position {
2172                    line: 0,
2173                    character: 0,
2174                },
2175                end: lattice_lsp::lsp_types::Position {
2176                    line: 5,
2177                    character: 0,
2178                },
2179            },
2180            parent: None,
2181        };
2182        let middle = lattice_lsp::lsp_types::SelectionRange {
2183            range: lattice_lsp::lsp_types::Range {
2184                start: lattice_lsp::lsp_types::Position {
2185                    line: 1,
2186                    character: 0,
2187                },
2188                end: lattice_lsp::lsp_types::Position {
2189                    line: 3,
2190                    character: 0,
2191                },
2192            },
2193            parent: Some(Box::new(outer)),
2194        };
2195        let inner = lattice_lsp::lsp_types::SelectionRange {
2196            range: lattice_lsp::lsp_types::Range {
2197                start: lattice_lsp::lsp_types::Position {
2198                    line: 2,
2199                    character: 0,
2200                },
2201                end: lattice_lsp::lsp_types::Position {
2202                    line: 2,
2203                    character: 8,
2204                },
2205            },
2206            parent: Some(Box::new(middle)),
2207        };
2208        let flat = crate::app::flatten_selection_range_chain(&inner);
2209        assert_eq!(flat.len(), 3);
2210        assert_eq!(flat[0].start.line, 2); // innermost
2211        assert_eq!(flat[1].start.line, 1);
2212        assert_eq!(flat[2].start.line, 0); // outermost
2213    }
2214
2215    /// 4.4.f: an LSP `FoldingRange` converts to our `Fold`
2216    /// with start/end lines copied verbatim, `closed = false`
2217    /// (the carry-over path in `recompute_folds` re-closes
2218    /// 4.4.h: relative-position decoder produces absolute
2219    /// positions per the LSP §3.17.6 contract. Two tokens on
2220    /// the same line: the second's `delta_start` is relative
2221    /// to the first's start. A subsequent line: the next's
2222    /// `delta_start` is from column 0.
2223    #[test]
2224    fn decode_semantic_tokens_absolute_positions() {
2225        let token_types = vec![
2226            lattice_lsp::lsp_types::SemanticTokenType::KEYWORD,
2227            lattice_lsp::lsp_types::SemanticTokenType::FUNCTION,
2228        ];
2229        let token_modifiers = vec![
2230            lattice_lsp::lsp_types::SemanticTokenModifier::STATIC,
2231            lattice_lsp::lsp_types::SemanticTokenModifier::READONLY,
2232        ];
2233        // Three tokens:
2234        //  - Line 0, col 0, len 3, type=keyword, no mods.
2235        //  - Line 0, col 4, len 4, type=function, mod bit 0 (static).
2236        //  - Line 2, col 2, len 1, type=keyword, mod bits 0+1.
2237        let data = vec![
2238            lattice_lsp::lsp_types::SemanticToken {
2239                delta_line: 0,
2240                delta_start: 0,
2241                length: 3,
2242                token_type: 0,
2243                token_modifiers_bitset: 0,
2244            },
2245            lattice_lsp::lsp_types::SemanticToken {
2246                delta_line: 0,
2247                delta_start: 4,
2248                length: 4,
2249                token_type: 1,
2250                token_modifiers_bitset: 0b01,
2251            },
2252            lattice_lsp::lsp_types::SemanticToken {
2253                delta_line: 2,
2254                delta_start: 2,
2255                length: 1,
2256                token_type: 0,
2257                token_modifiers_bitset: 0b11,
2258            },
2259        ];
2260        let decoded = crate::app::decode_semantic_tokens(&data, &token_types, &token_modifiers);
2261        assert_eq!(decoded.len(), 3);
2262        assert_eq!(decoded[0].line, 0);
2263        assert_eq!(decoded[0].start_char, 0);
2264        assert_eq!(decoded[0].token_type, "keyword");
2265        assert!(decoded[0].modifiers.is_empty());
2266        // Second token: same line; start = 0 + 4.
2267        assert_eq!(decoded[1].line, 0);
2268        assert_eq!(decoded[1].start_char, 4);
2269        assert_eq!(decoded[1].token_type, "function");
2270        assert_eq!(decoded[1].modifiers, vec!["static"]);
2271        // Third token: new line; start = delta_start (no
2272        // accumulation across line changes per spec).
2273        assert_eq!(decoded[2].line, 2);
2274        assert_eq!(decoded[2].start_char, 2);
2275        assert_eq!(decoded[2].modifiers, vec!["static", "readonly"]);
2276    }
2277
2278    /// 4.4.h: tokens with type indexes past the legend are
2279    /// dropped (defense in depth; real servers don't emit).
2280    #[test]
2281    fn decode_semantic_tokens_drops_out_of_range_type() {
2282        let token_types = vec![lattice_lsp::lsp_types::SemanticTokenType::KEYWORD];
2283        let token_modifiers: Vec<lattice_lsp::lsp_types::SemanticTokenModifier> = Vec::new();
2284        let data = vec![
2285            lattice_lsp::lsp_types::SemanticToken {
2286                delta_line: 0,
2287                delta_start: 0,
2288                length: 3,
2289                token_type: 0,
2290                token_modifiers_bitset: 0,
2291            },
2292            lattice_lsp::lsp_types::SemanticToken {
2293                delta_line: 0,
2294                delta_start: 4,
2295                length: 4,
2296                token_type: 99, // out of range
2297                token_modifiers_bitset: 0,
2298            },
2299        ];
2300        let decoded = crate::app::decode_semantic_tokens(&data, &token_types, &token_modifiers);
2301        // Only the in-range token survives.
2302        assert_eq!(decoded.len(), 1);
2303        assert_eq!(decoded[0].token_type, "keyword");
2304    }
2305
2306    /// 4.4.i: a delta script that replaces a contiguous slice of
2307    /// the prior raw vec splices in place. Verifies both the
2308    /// length change and the resulting element identity.
2309    #[test]
2310    fn apply_semantic_token_edits_replace_middle() {
2311        let mut raw = vec![
2312            lattice_lsp::lsp_types::SemanticToken {
2313                delta_line: 0,
2314                delta_start: 0,
2315                length: 2,
2316                token_type: 0,
2317                token_modifiers_bitset: 0,
2318            },
2319            lattice_lsp::lsp_types::SemanticToken {
2320                delta_line: 0,
2321                delta_start: 3,
2322                length: 4,
2323                token_type: 1,
2324                token_modifiers_bitset: 0,
2325            },
2326            lattice_lsp::lsp_types::SemanticToken {
2327                delta_line: 1,
2328                delta_start: 2,
2329                length: 1,
2330                token_type: 0,
2331                token_modifiers_bitset: 0,
2332            },
2333        ];
2334        // Replace the middle token (index 1) with two new tokens.
2335        let edit = lattice_lsp::lsp_types::SemanticTokensEdit {
2336            start: 1,
2337            delete_count: 1,
2338            data: Some(vec![
2339                lattice_lsp::lsp_types::SemanticToken {
2340                    delta_line: 0,
2341                    delta_start: 3,
2342                    length: 2,
2343                    token_type: 2,
2344                    token_modifiers_bitset: 0,
2345                },
2346                lattice_lsp::lsp_types::SemanticToken {
2347                    delta_line: 0,
2348                    delta_start: 5,
2349                    length: 3,
2350                    token_type: 1,
2351                    token_modifiers_bitset: 0,
2352                },
2353            ]),
2354        };
2355        crate::app::apply_semantic_token_edits(&mut raw, &[edit])
2356            .expect("splice succeeds in range");
2357        assert_eq!(raw.len(), 4);
2358        assert_eq!(raw[1].token_type, 2);
2359        assert_eq!(raw[2].token_type, 1);
2360        // The trailing token wasn't touched.
2361        assert_eq!(raw[3].delta_line, 1);
2362    }
2363
2364    /// 4.4.i: an insert-only edit (delete_count = 0) splices the
2365    /// new tokens in without removing anything.
2366    #[test]
2367    fn apply_semantic_token_edits_insert_only() {
2368        let mut raw = vec![lattice_lsp::lsp_types::SemanticToken {
2369            delta_line: 0,
2370            delta_start: 0,
2371            length: 2,
2372            token_type: 0,
2373            token_modifiers_bitset: 0,
2374        }];
2375        let edit = lattice_lsp::lsp_types::SemanticTokensEdit {
2376            start: 1,
2377            delete_count: 0,
2378            data: Some(vec![lattice_lsp::lsp_types::SemanticToken {
2379                delta_line: 0,
2380                delta_start: 3,
2381                length: 4,
2382                token_type: 1,
2383                token_modifiers_bitset: 0,
2384            }]),
2385        };
2386        crate::app::apply_semantic_token_edits(&mut raw, &[edit]).expect("insert at end succeeds");
2387        assert_eq!(raw.len(), 2);
2388        assert_eq!(raw[1].token_type, 1);
2389    }
2390
2391    /// 4.4.i: a delete-only edit (data = None) removes the named
2392    /// range without inserting anything.
2393    #[test]
2394    fn apply_semantic_token_edits_delete_only() {
2395        let mut raw = vec![
2396            lattice_lsp::lsp_types::SemanticToken {
2397                delta_line: 0,
2398                delta_start: 0,
2399                length: 2,
2400                token_type: 0,
2401                token_modifiers_bitset: 0,
2402            },
2403            lattice_lsp::lsp_types::SemanticToken {
2404                delta_line: 0,
2405                delta_start: 3,
2406                length: 4,
2407                token_type: 1,
2408                token_modifiers_bitset: 0,
2409            },
2410        ];
2411        let edit = lattice_lsp::lsp_types::SemanticTokensEdit {
2412            start: 1,
2413            delete_count: 1,
2414            data: None,
2415        };
2416        crate::app::apply_semantic_token_edits(&mut raw, &[edit])
2417            .expect("delete in range succeeds");
2418        assert_eq!(raw.len(), 1);
2419        assert_eq!(raw[0].token_type, 0);
2420    }
2421
2422    /// 4.4.i: an out-of-bounds edit returns Err and leaves the
2423    /// vec untouched. The host treats this as a server bug and
2424    /// drops the cache to force a fresh full request.
2425    #[test]
2426    fn apply_semantic_token_edits_out_of_bounds_errs() {
2427        let mut raw = vec![lattice_lsp::lsp_types::SemanticToken {
2428            delta_line: 0,
2429            delta_start: 0,
2430            length: 2,
2431            token_type: 0,
2432            token_modifiers_bitset: 0,
2433        }];
2434        let edit = lattice_lsp::lsp_types::SemanticTokensEdit {
2435            start: 5,
2436            delete_count: 2,
2437            data: None,
2438        };
2439        assert!(
2440            crate::app::apply_semantic_token_edits(&mut raw, &[edit]).is_err(),
2441            "out-of-bounds edit must err"
2442        );
2443        // Pre-edit vec was a single token; verify it survived.
2444        assert_eq!(raw.len(), 1);
2445    }
2446
2447    /// 4.4.i: the drain's `Delta` arm splices a server-issued
2448    /// edit script into the cached raw vec and re-decodes,
2449    /// seating the updated decoded list in the cache.
2450    #[test]
2451    fn drain_semantic_tokens_delta_splices_and_redecodes() {
2452        let a = app_with("fn main() {}\n", 5);
2453        let buffer_id = a.editor.document_buffer_id;
2454        // Seed an initial cache entry with one keyword token and
2455        // a `result_id` the delta will reference. Use a single
2456        // legend entry so the decoder's name resolution is
2457        // deterministic.
2458        let initial_raw = vec![lattice_lsp::lsp_types::SemanticToken {
2459            delta_line: 0,
2460            delta_start: 0,
2461            length: 2,
2462            token_type: 0,
2463            token_modifiers_bitset: 0,
2464        }];
2465        // 5.8.AF.5 / Slice 3b.2: tests drive the delta path by
2466        // calling the pure helper directly. Channel + drain
2467        // dance is gone -- the spawned task in
2468        // `maybe_request_semantic_tokens` calls the same helper.
2469        {
2470            use lattice_host::per_buffer_cache::PerBufferCacheExt;
2471            a.editor.lsp_semantic_tokens_cache.insert_for(
2472                buffer_id,
2473                crate::app::LspSemanticTokensCache {
2474                    document_version: 1,
2475                    result_id: Some("r1".into()),
2476                    raw_data: initial_raw,
2477                    tokens: vec![crate::app::DecodedSemanticToken {
2478                        line: 0,
2479                        start_char: 0,
2480                        length: 2,
2481                        token_type: "keyword".into(),
2482                        modifiers: Vec::new(),
2483                    }],
2484                },
2485            );
2486        }
2487        // Apply a Delta outcome that appends a new "function"
2488        // token. The helper should splice into raw_data and
2489        // re-decode against the carried legend.
2490        let token_types = vec![
2491            lattice_lsp::lsp_types::SemanticTokenType::KEYWORD,
2492            lattice_lsp::lsp_types::SemanticTokenType::FUNCTION,
2493        ];
2494        let token_modifiers: Vec<lattice_lsp::lsp_types::SemanticTokenModifier> = Vec::new();
2495        let edits = vec![lattice_lsp::lsp_types::SemanticTokensEdit {
2496            start: 1,
2497            delete_count: 0,
2498            data: Some(vec![lattice_lsp::lsp_types::SemanticToken {
2499                delta_line: 0,
2500                delta_start: 3,
2501                length: 4,
2502                token_type: 1,
2503                token_modifiers_bitset: 0,
2504            }]),
2505        }];
2506        lattice_host::editor::Editor::apply_semantic_tokens_delta_outcome(
2507            &a.editor.lsp_semantic_tokens_cache,
2508            buffer_id,
2509            2,
2510            "r1",
2511            Some("r2".into()),
2512            &edits,
2513            &token_types,
2514            &token_modifiers,
2515        );
2516        let cache = {
2517            use lattice_host::per_buffer_cache::PerBufferCacheExt;
2518            a.editor
2519                .lsp_semantic_tokens_cache
2520                .get_for(buffer_id)
2521                .expect("cache seated")
2522        };
2523        assert_eq!(cache.document_version, 2);
2524        assert_eq!(cache.result_id.as_deref(), Some("r2"));
2525        assert_eq!(cache.raw_data.len(), 2);
2526        assert_eq!(cache.tokens.len(), 2);
2527        assert_eq!(cache.tokens[1].token_type, "function");
2528        assert_eq!(cache.tokens[1].start_char, 3);
2529    }
2530
2531    /// 4.4.i: when the cached `result_id` no longer matches
2532    /// the delta's `previous_result_id` (e.g. a concurrent
2533    /// refresh dropped the baseline), the drain evicts the
2534    /// cache so the next pump issues a fresh full request.
2535    #[test]
2536    fn drain_semantic_tokens_delta_stale_baseline_evicts_cache() {
2537        let a = app_with("fn main() {}\n", 5);
2538        let buffer_id = a.editor.document_buffer_id;
2539        // 5.8.AF.5 / Slice 3b.2: tests drive the delta path by
2540        // calling the pure helper directly (see twin test above).
2541        {
2542            use lattice_host::per_buffer_cache::PerBufferCacheExt;
2543            a.editor.lsp_semantic_tokens_cache.insert_for(
2544                buffer_id,
2545                crate::app::LspSemanticTokensCache {
2546                    document_version: 1,
2547                    result_id: Some("different-id".into()),
2548                    raw_data: Vec::new(),
2549                    tokens: Vec::new(),
2550                },
2551            );
2552        }
2553        lattice_host::editor::Editor::apply_semantic_tokens_delta_outcome(
2554            &a.editor.lsp_semantic_tokens_cache,
2555            buffer_id,
2556            2,
2557            "r1",
2558            Some("r2".into()),
2559            &[],
2560            &[],
2561            &[],
2562        );
2563        let evicted = {
2564            use lattice_host::per_buffer_cache::PerBufferCacheExt;
2565            a.editor
2566                .lsp_semantic_tokens_cache
2567                .get_for(buffer_id)
2568                .is_none()
2569        };
2570        assert!(evicted, "stale-baseline delta should evict the cache",);
2571    }
2572
2573    /// 4.4.i: multiple edits compose in order. The server
2574    /// constructs edits against the index space of the *input*
2575    /// to each step, so applying [e1, e2] means e2's indices
2576    /// refer to the vec after e1 has been applied.
2577    #[test]
2578    fn apply_semantic_token_edits_sequential() {
2579        let mut raw = vec![
2580            lattice_lsp::lsp_types::SemanticToken {
2581                delta_line: 0,
2582                delta_start: 0,
2583                length: 2,
2584                token_type: 0,
2585                token_modifiers_bitset: 0,
2586            },
2587            lattice_lsp::lsp_types::SemanticToken {
2588                delta_line: 0,
2589                delta_start: 3,
2590                length: 4,
2591                token_type: 1,
2592                token_modifiers_bitset: 0,
2593            },
2594        ];
2595        let edits = vec![
2596            // Delete index 0.
2597            lattice_lsp::lsp_types::SemanticTokensEdit {
2598                start: 0,
2599                delete_count: 1,
2600                data: None,
2601            },
2602            // Then insert a new token at the (now) start.
2603            lattice_lsp::lsp_types::SemanticTokensEdit {
2604                start: 0,
2605                delete_count: 0,
2606                data: Some(vec![lattice_lsp::lsp_types::SemanticToken {
2607                    delta_line: 0,
2608                    delta_start: 0,
2609                    length: 5,
2610                    token_type: 2,
2611                    token_modifiers_bitset: 0,
2612                }]),
2613            },
2614        ];
2615        crate::app::apply_semantic_token_edits(&mut raw, &edits).expect("sequential edits succeed");
2616        assert_eq!(raw.len(), 2);
2617        assert_eq!(raw[0].token_type, 2);
2618        assert_eq!(raw[1].token_type, 1);
2619    }
2620
2621    /// 4.4.j: drain seats the cache + applies a `Full` report
2622    /// to `DiagnosticsLayer`. Verifies (a) the cache stores
2623    /// the new result_id + version, and (b) the layer
2624    /// receives the diagnostics for the URI.
2625    #[test]
2626    fn drain_pending_pull_diagnostics_full_applies_to_layer() {
2627        use std::str::FromStr;
2628        let app = app_with("fn main() {}\n", 5);
2629        let buffer_id = app.editor.document_buffer_id;
2630        let uri = lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap();
2631        let server_id: std::sync::Arc<str> = std::sync::Arc::from("rust");
2632        let diag = lattice_lsp::lsp_types::Diagnostic {
2633            range: lattice_lsp::lsp_types::Range {
2634                start: lattice_lsp::lsp_types::Position {
2635                    line: 0,
2636                    character: 0,
2637                },
2638                end: lattice_lsp::lsp_types::Position {
2639                    line: 0,
2640                    character: 2,
2641                },
2642            },
2643            severity: Some(lattice_lsp::lsp_types::DiagnosticSeverity::ERROR),
2644            code: None,
2645            code_description: None,
2646            source: None,
2647            message: "boom".into(),
2648            related_information: None,
2649            tags: None,
2650            data: None,
2651        };
2652        // 5.8.AF.5 / Slice 3b.5: drive the outcome through the
2653        // pure helper. The spawned task in
2654        // `maybe_request_pull_diagnostics` calls the same helper.
2655        lattice_host::editor::Editor::apply_pull_diagnostics_outcome(
2656            &app.editor.lsp_pull_diagnostics_cache,
2657            &app.editor.lsp_diagnostics,
2658            crate::app::PullDiagnosticsOutcome::Full {
2659                buffer_id,
2660                server_id: server_id.clone(),
2661                uri: uri.clone(),
2662                document_version: 1,
2663                result_id: Some("r1".into()),
2664                diagnostics: vec![diag.clone()],
2665            },
2666        );
2667        let cache = {
2668            use lattice_host::per_buffer_cache::PerBufferCacheExt;
2669            app.editor
2670                .lsp_pull_diagnostics_cache
2671                .get_for(buffer_id)
2672                .expect("cache seated")
2673        };
2674        assert_eq!(cache.document_version, 1);
2675        assert_eq!(cache.result_id.as_deref(), Some("r1"));
2676        // Layer should now carry the diagnostic. URI equality
2677        // goes via `as_str()` (fluent_uri's Uri doesn't impl
2678        // PartialEq across the typed/owning split).
2679        let snap = app.editor.lsp_diagnostics.snapshot();
2680        let entry = snap
2681            .iter()
2682            .find(|(u, _)| u.as_str() == uri.as_str())
2683            .expect("uri in layer");
2684        let msgs: Vec<_> = entry.1.iter().map(|d| d.message.as_str()).collect();
2685        assert!(msgs.contains(&"boom"));
2686    }
2687
2688    /// 4.4.j: drain handles `Unchanged` reports by refreshing
2689    /// the (version, result_id) pair without touching the
2690    /// layer. The diagnostic state on the layer must be
2691    /// preserved verbatim.
2692    #[test]
2693    fn drain_pending_pull_diagnostics_unchanged_keeps_layer_state() {
2694        let app = app_with("fn main() {}\n", 5);
2695        let buffer_id = app.editor.document_buffer_id;
2696        // Seed initial cache state.
2697        {
2698            use lattice_host::per_buffer_cache::PerBufferCacheExt;
2699            app.editor.lsp_pull_diagnostics_cache.insert_for(
2700                buffer_id,
2701                crate::app::LspPullDiagnosticsCache {
2702                    document_version: 1,
2703                    result_id: Some("r1".into()),
2704                },
2705            );
2706        }
2707        // 5.8.AF.5 / Slice 3b.5: drive the outcome through the
2708        // helper directly.
2709        lattice_host::editor::Editor::apply_pull_diagnostics_outcome(
2710            &app.editor.lsp_pull_diagnostics_cache,
2711            &app.editor.lsp_diagnostics,
2712            crate::app::PullDiagnosticsOutcome::Unchanged {
2713                buffer_id,
2714                document_version: 2,
2715                result_id: "r2".into(),
2716            },
2717        );
2718        let cache = {
2719            use lattice_host::per_buffer_cache::PerBufferCacheExt;
2720            app.editor
2721                .lsp_pull_diagnostics_cache
2722                .get_for(buffer_id)
2723                .expect("cache seated")
2724        };
2725        assert_eq!(cache.document_version, 2);
2726        assert_eq!(cache.result_id.as_deref(), Some("r2"));
2727    }
2728
2729    /// 4.4.j: `workspace/diagnostic/refresh` drain evicts the
2730    /// per-buffer result_id cache for every buffer attached
2731    /// to the requesting server (here: by URI walk through
2732    /// `buffer_uris` + `lsp.servers_for`). For the unit-test
2733    /// scaffold there are no real running actors, so the
2734    /// attachment check returns no buffers and the cache
2735    /// stays -- this test exercises the no-attached-buffers
2736    /// path (the integration tests cover the eviction itself
2737    /// when actors are present).
2738    #[test]
2739    fn drain_diagnostic_refresh_handles_no_attached_buffers() {
2740        let mut app = app_with("fn main() {}\n", 5);
2741        let buffer_id = app.editor.document_buffer_id;
2742        // 5.8.AF.5 / Slice 3b.5: cache is `PerBufferCache`.
2743        {
2744            use lattice_host::per_buffer_cache::PerBufferCacheExt;
2745            app.editor.lsp_pull_diagnostics_cache.insert_for(
2746                buffer_id,
2747                crate::app::LspPullDiagnosticsCache {
2748                    document_version: 1,
2749                    result_id: Some("r1".into()),
2750                },
2751            );
2752        }
2753        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<lattice_lsp::LspDiagnosticRefresh>();
2754        tx.send(lattice_lsp::LspDiagnosticRefresh {
2755            server_id: std::sync::Arc::from("rust"),
2756        })
2757        .expect("send refresh");
2758        app.editor.pending_diagnostic_refresh_rx = Some(rx);
2759        app.drain_diagnostic_refresh();
2760        // No actors attached -> no eviction.
2761        use lattice_host::per_buffer_cache::PerBufferCacheExt;
2762        assert!(
2763            app.editor
2764                .lsp_pull_diagnostics_cache
2765                .get_for(buffer_id)
2766                .is_some()
2767        );
2768    }
2769
2770    /// 4.4.j: pump short-circuits when the cache's
2771    /// document_version matches the current snapshot. No
2772    /// request fires.
2773    #[test]
2774    fn pull_diagnostics_pump_skips_when_version_unchanged() {
2775        let mut app = app_with("fn main() {}\n", 5);
2776        let buffer_id = app.editor.document_buffer_id;
2777        let version = app.editor.document.snapshot().version;
2778        // 5.8.AF.5 / Slice 3b.5: cache is `PerBufferCache`; the
2779        // pump fires a request when the cache is stale and
2780        // installs a cancellation token (was `pending_*_rx`).
2781        {
2782            use lattice_host::per_buffer_cache::PerBufferCacheExt;
2783            app.editor.lsp_pull_diagnostics_cache.insert_for(
2784                buffer_id,
2785                crate::app::LspPullDiagnosticsCache {
2786                    document_version: version,
2787                    result_id: Some("r1".into()),
2788                },
2789            );
2790        }
2791        app.maybe_request_pull_diagnostics();
2792        assert!(
2793            app.editor.pending_pull_diagnostics_token.is_none(),
2794            "pump should short-circuit on unchanged version",
2795        );
2796    }
2797
2798    /// 4.4.g viewport: when the cached range covers the
2799    /// current viewport (with overscan baked in), the pump
2800    /// short-circuits -- no new request fires.
2801    #[test]
2802    fn inlay_hint_pump_skips_when_viewport_inside_cached_range() {
2803        use std::str::FromStr;
2804        let mut app = app_with("fn main() {}\n", 5);
2805        // Mode must be on; buffer_uris must be populated.
2806        if !app.lsp_inlay_hint_mode_enabled_for(app.editor.document_buffer_id) {
2807            app.toggle_mode_by_name("lsp-inlay-hint-mode");
2808        }
2809        let uri = lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap();
2810        app.editor
2811            .buffer_uris
2812            .insert(app.editor.document_buffer_id, uri);
2813        // Seed cache with a wide range covering 0..=1000.
2814        // 5.8.AF.5 / Slice 3b.1: `lsp_inlay_hints_cache` is now
2815        // a `PerBufferCache<...>`; tests use `insert_for` and
2816        // publish so renderer reads via `RenderState` match.
2817        {
2818            use lattice_host::per_buffer_cache::PerBufferCacheExt;
2819            app.editor.lsp_inlay_hints_cache.insert_for(
2820                app.editor.document_buffer_id,
2821                crate::app::LspInlayHintCache {
2822                    // AW.6: pumps now gate on `text_version` (caret moves bump
2823                    // `version` but not text_version); seed to match.
2824                    document_version: app.editor.document.snapshot().text_version,
2825                    hints: Vec::new(),
2826                    requested_first_line: 0,
2827                    requested_last_line: 1000,
2828                },
2829            );
2830        }
2831        app.editor.publish_render_state();
2832        // Viewport at line 0, height 5 (set by app_with) --
2833        // comfortably inside the cached range. Avoid calling
2834        // `set_viewport_height` here: it triggers
2835        // `ensure_cursor_visible` which would clamp scroll
2836        // back to the cursor line and mask the viewport state
2837        // the test wants to exercise.
2838        app.editor.scroll = 0;
2839        app.maybe_request_inlay_hint();
2840        assert!(
2841            app.editor.pending_inlay_hint_token.is_none(),
2842            "pump should short-circuit when viewport is inside cached range",
2843        );
2844    }
2845
2846    /// 4.4.g viewport: scrolling outside the cached range
2847    /// triggers a fresh request. The pump replaces
2848    /// `pending_inlay_hint_rx` with a new receiver before
2849    /// spawning the async fetch.
2850    #[test]
2851    fn inlay_hint_pump_refetches_when_viewport_outside_cached_range() {
2852        use std::str::FromStr;
2853        let mut app = app_with(&"a\n".repeat(2000), 5);
2854        if !app.lsp_inlay_hint_mode_enabled_for(app.editor.document_buffer_id) {
2855            app.toggle_mode_by_name("lsp-inlay-hint-mode");
2856        }
2857        let uri = lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap();
2858        app.editor
2859            .buffer_uris
2860            .insert(app.editor.document_buffer_id, uri);
2861        // Cached range: lines 0..=200.
2862        {
2863            use lattice_host::per_buffer_cache::PerBufferCacheExt;
2864            app.editor.lsp_inlay_hints_cache.insert_for(
2865                app.editor.document_buffer_id,
2866                crate::app::LspInlayHintCache {
2867                    // AW.6: pumps now gate on `text_version` (caret moves bump
2868                    // `version` but not text_version); seed to match.
2869                    document_version: app.editor.document.snapshot().text_version,
2870                    hints: Vec::new(),
2871                    requested_first_line: 0,
2872                    requested_last_line: 200,
2873                },
2874            );
2875        }
2876        app.editor.publish_render_state();
2877        // Viewport now far below the cached range. Skip
2878        // `set_viewport_height` -- it calls
2879        // `ensure_cursor_visible` which would snap scroll
2880        // back to the cursor line.
2881        app.editor.scroll = 1500;
2882        app.maybe_request_inlay_hint();
2883        assert!(
2884            app.editor.pending_inlay_hint_token.is_some(),
2885            "pump should issue a new request when viewport leaves cached range",
2886        );
2887    }
2888
2889    /// 4.4.g viewport: small scrolls within the overscan
2890    /// margin stay cached. Pump checks whether
2891    /// `viewport_first >= cache.first` AND
2892    /// `viewport_last <= cache.last`; the overscan-bounded
2893    /// fetch range gives small scrolls room to move without
2894    /// triggering a request.
2895    #[test]
2896    fn inlay_hint_pump_small_scroll_within_overscan_keeps_cache() {
2897        use std::str::FromStr;
2898        let mut app = app_with(&"a\n".repeat(2000), 5);
2899        if !app.lsp_inlay_hint_mode_enabled_for(app.editor.document_buffer_id) {
2900            app.toggle_mode_by_name("lsp-inlay-hint-mode");
2901        }
2902        let uri = lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap();
2903        app.editor
2904            .buffer_uris
2905            .insert(app.editor.document_buffer_id, uri);
2906        // Cache covers lines 100..=400 (the overscan-padded
2907        // window the pump would have fetched at scroll=200).
2908        {
2909            use lattice_host::per_buffer_cache::PerBufferCacheExt;
2910            app.editor.lsp_inlay_hints_cache.insert_for(
2911                app.editor.document_buffer_id,
2912                crate::app::LspInlayHintCache {
2913                    // AW.6: pumps now gate on `text_version` (caret moves bump
2914                    // `version` but not text_version); seed to match.
2915                    document_version: app.editor.document.snapshot().text_version,
2916                    hints: Vec::new(),
2917                    requested_first_line: 100,
2918                    requested_last_line: 400,
2919                },
2920            );
2921        }
2922        app.editor.publish_render_state();
2923        // Scroll a bit -- still well within the cached window.
2924        // Same `set_viewport_height` caveat as above.
2925        app.editor.scroll = 250;
2926        app.maybe_request_inlay_hint();
2927        assert!(
2928            app.editor.pending_inlay_hint_token.is_none(),
2929            "small scroll inside cached window should not refetch",
2930        );
2931    }
2932
2933    /// matching entries), and a stable identity hash.
2934    #[test]
2935    fn folding_range_to_fold_preserves_extents_and_keys_identity() {
2936        let r = lattice_lsp::lsp_types::FoldingRange {
2937            start_line: 2,
2938            end_line: 5,
2939            start_character: None,
2940            end_character: None,
2941            kind: Some(lattice_lsp::lsp_types::FoldingRangeKind::Comment),
2942            collapsed_text: None,
2943        };
2944        let f = crate::app::folding_range_to_fold(r.clone());
2945        assert_eq!(f.start_line, 2);
2946        assert_eq!(f.end_line, 5);
2947        assert!(!f.closed);
2948        assert!(f.identity.is_some());
2949
2950        // Same shape -> same identity, so closed-state survives
2951        // re-fetches.
2952        let f2 = crate::app::folding_range_to_fold(r);
2953        assert_eq!(f.identity, f2.identity);
2954
2955        // Different end-line -> different identity.
2956        let r3 = lattice_lsp::lsp_types::FoldingRange {
2957            start_line: 2,
2958            end_line: 9,
2959            start_character: None,
2960            end_character: None,
2961            kind: Some(lattice_lsp::lsp_types::FoldingRangeKind::Comment),
2962            collapsed_text: None,
2963        };
2964        let f3 = crate::app::folding_range_to_fold(r3);
2965        assert_ne!(f.identity, f3.identity);
2966    }
2967
2968    /// 4.4.f: activating `lsp-folding-mode` swaps `foldmethod`
2969    /// to `lsp` and stashes the prior value (inside the mode's
2970    /// typed `LspFoldingGuard`); deactivating drops the Guard,
2971    /// firing Drop which restores. M-async.1: the stash is no
2972    /// longer observable from outside the Guard -- the test
2973    /// asserts the public contract (foldmethod swap + restore)
2974    /// rather than the implementation detail.
2975    #[test]
2976    fn lsp_folding_mode_toggle_syncs_foldmethod() {
2977        use lattice_core::FoldMethod;
2978        let mut app = app_with("fn a() {}\n", 5);
2979        app.set_foldmethod_for_test(FoldMethod::Syntax);
2980        assert_eq!(app.foldmethod(), FoldMethod::Syntax);
2981        if app.lsp_folding_mode_enabled_for(app.editor.document_buffer_id) {
2982            app.toggle_mode_by_name("lsp-folding-mode");
2983        }
2984        // Activate -> mode swaps foldmethod to Lsp.
2985        app.toggle_mode_by_name("lsp-folding-mode");
2986        assert!(app.lsp_folding_mode_enabled_for(app.editor.document_buffer_id));
2987        assert_eq!(app.foldmethod(), FoldMethod::Lsp);
2988        // Deactivate -> Guard Drop restores foldmethod.
2989        app.toggle_mode_by_name("lsp-folding-mode");
2990        assert!(!app.lsp_folding_mode_enabled_for(app.editor.document_buffer_id));
2991        assert_eq!(app.foldmethod(), FoldMethod::Syntax);
2992    }
2993
2994    /// 4.4.f: a seeded `lsp_folds_cache` makes `recompute_folds`
2995    /// pick up the LSP fold list when `:set foldmethod=lsp`.
2996    #[test]
2997    fn recompute_folds_with_foldmethod_lsp_reads_cache() {
2998        use lattice_core::FoldMethod;
2999        let mut app = app_with("fn a() {}\nfn b() {}\nfn c() {}\n", 5);
3000        app.set_foldmethod_for_test(FoldMethod::Lsp);
3001        let fold = crate::app::folding_range_to_fold(lattice_lsp::lsp_types::FoldingRange {
3002            start_line: 0,
3003            end_line: 1,
3004            start_character: None,
3005            end_character: None,
3006            kind: None,
3007            collapsed_text: None,
3008        });
3009        // 5.8.AF.5 / Slice 3b.1: `lsp_folds_cache` is now a
3010        // `PerBufferCache<...>`; use `insert_for` + publish.
3011        {
3012            use lattice_host::per_buffer_cache::PerBufferCacheExt;
3013            app.editor.lsp_folds_cache.insert_for(
3014                app.editor.document_buffer_id,
3015                crate::app::LspFoldsCache {
3016                    // AW.6: pumps now gate on `text_version` (caret moves bump
3017                    // `version` but not text_version); seed to match.
3018                    document_version: app.editor.document.snapshot().text_version,
3019                    folds: vec![fold],
3020                },
3021            );
3022        }
3023        app.editor.publish_render_state();
3024        // Force `lsp-folding-mode` on so the cache is read
3025        // (the M.6.0 cascade may have left it off in test
3026        // setup).
3027        if !app.lsp_folding_mode_enabled_for(app.editor.document_buffer_id) {
3028            app.toggle_mode_by_name("lsp-folding-mode");
3029        }
3030        app.recompute_folds();
3031        assert!(
3032            app.editor
3033                .folds
3034                .iter()
3035                .any(|f| f.start_line == 0 && f.end_line == 1),
3036            "expected LSP fold from cache; got {:?}",
3037            app.editor.folds,
3038        );
3039    }
3040
3041    /// 4.4.e: cursor on the start-character of a range is
3042    /// "inside" (half-open); cursor on `end` is outside.
3043    #[test]
3044    fn cursor_inside_range_is_half_open() {
3045        let r = lattice_lsp::lsp_types::Range {
3046            start: lattice_lsp::lsp_types::Position {
3047                line: 1,
3048                character: 4,
3049            },
3050            end: lattice_lsp::lsp_types::Position {
3051                line: 1,
3052                character: 8,
3053            },
3054        };
3055        assert!(crate::app::cursor_inside_range(Position::new(1, 4), &r));
3056        assert!(crate::app::cursor_inside_range(Position::new(1, 6), &r));
3057        assert!(!crate::app::cursor_inside_range(Position::new(1, 8), &r));
3058        assert!(!crate::app::cursor_inside_range(Position::new(0, 6), &r));
3059        assert!(!crate::app::cursor_inside_range(Position::new(2, 6), &r));
3060    }
3061
3062    // The converter, not `file://` glued on: a Windows path is not a URI path.
3063    fn fake_uri(path: &str) -> lattice_lsp::lsp_types::Uri {
3064        lattice_lsp::actor::uri_from_path(std::path::Path::new(path))
3065    }
3066
3067    fn loc(path: &str, line: u32, col: u32) -> lattice_lsp::lsp_types::Location {
3068        lattice_lsp::lsp_types::Location {
3069            uri: fake_uri(path),
3070            range: lattice_lsp::lsp_types::Range {
3071                start: lattice_lsp::lsp_types::Position {
3072                    line,
3073                    character: col,
3074                },
3075                end: lattice_lsp::lsp_types::Position {
3076                    line,
3077                    character: col + 1,
3078                },
3079            },
3080        }
3081    }
3082
3083    #[test]
3084    fn definition_response_scalar_flattens_to_one_location() {
3085        let resp = lattice_lsp::lsp_types::GotoDefinitionResponse::Scalar(loc("/x.rs", 1, 2));
3086        let v = super::definition_response_to_locations(resp);
3087        assert_eq!(v.len(), 1);
3088        assert_eq!(v[0].range.start.line, 1);
3089    }
3090
3091    #[test]
3092    fn definition_response_array_flattens_verbatim() {
3093        let resp = lattice_lsp::lsp_types::GotoDefinitionResponse::Array(vec![
3094            loc("/a.rs", 0, 0),
3095            loc("/b.rs", 5, 5),
3096        ]);
3097        let v = super::definition_response_to_locations(resp);
3098        assert_eq!(v.len(), 2);
3099    }
3100
3101    #[test]
3102    fn definition_response_link_uses_target_selection_range() {
3103        // Link variant carries richer per-result info; we use
3104        // target_selection_range (narrower) for jumps.
3105        let link = lattice_lsp::lsp_types::LocationLink {
3106            origin_selection_range: None,
3107            target_uri: fake_uri("/x.rs"),
3108            target_range: lattice_lsp::lsp_types::Range {
3109                start: lattice_lsp::lsp_types::Position {
3110                    line: 0,
3111                    character: 0,
3112                },
3113                end: lattice_lsp::lsp_types::Position {
3114                    line: 10,
3115                    character: 0,
3116                },
3117            },
3118            target_selection_range: lattice_lsp::lsp_types::Range {
3119                start: lattice_lsp::lsp_types::Position {
3120                    line: 5,
3121                    character: 4,
3122                },
3123                end: lattice_lsp::lsp_types::Position {
3124                    line: 5,
3125                    character: 7,
3126                },
3127            },
3128        };
3129        let resp = lattice_lsp::lsp_types::GotoDefinitionResponse::Link(vec![link]);
3130        let v = super::definition_response_to_locations(resp);
3131        assert_eq!(v.len(), 1);
3132        // Should be the target_selection_range, not target_range.
3133        assert_eq!(v[0].range.start.line, 5);
3134        assert_eq!(v[0].range.start.character, 4);
3135    }
3136
3137    #[test]
3138    fn lsp_definition_request_with_no_uri_echoes_no_lsp_attached() {
3139        let mut a = app_with("xx", 10);
3140        a.toggle_mode_by_name("lsp-mode");
3141        a.apply_effect(lattice_grammar::Effect::Lsp(
3142            lattice_grammar::LspRequest::Definition,
3143        ));
3144        let msg = a.editor.last_message.as_ref().expect("echo");
3145        assert_eq!(msg.level, EchoLevel::Info);
3146        assert!(msg.text.contains("no LSP server"));
3147    }
3148
3149    #[test]
3150    fn lsp_declaration_request_routes_through_unified_nav_dispatch() {
3151        let mut a = app_with("xx", 10);
3152        a.toggle_mode_by_name("lsp-mode");
3153        a.apply_effect(lattice_grammar::Effect::Lsp(
3154            lattice_grammar::LspRequest::Declaration,
3155        ));
3156        // No URI mapped, same "no LSP server" guard fires.
3157        let msg = a.editor.last_message.as_ref().expect("echo");
3158        assert_eq!(msg.level, EchoLevel::Info);
3159        assert!(msg.text.contains("no LSP server"));
3160    }
3161
3162    #[test]
3163    fn lsp_type_definition_request_routes_through_unified_nav_dispatch() {
3164        let mut a = app_with("xx", 10);
3165        a.toggle_mode_by_name("lsp-mode");
3166        a.apply_effect(lattice_grammar::Effect::Lsp(
3167            lattice_grammar::LspRequest::TypeDefinition,
3168        ));
3169        let msg = a.editor.last_message.as_ref().expect("echo");
3170        assert!(msg.text.contains("no LSP server"));
3171    }
3172
3173    #[test]
3174    fn lsp_implementation_request_routes_through_unified_nav_dispatch() {
3175        let mut a = app_with("xx", 10);
3176        a.toggle_mode_by_name("lsp-mode");
3177        a.apply_effect(lattice_grammar::Effect::Lsp(
3178            lattice_grammar::LspRequest::Implementation,
3179        ));
3180        let msg = a.editor.last_message.as_ref().expect("echo");
3181        assert!(msg.text.contains("no LSP server"));
3182    }
3183
3184    #[test]
3185    fn drain_pending_no_implementations_echoes_kind_specific_message() {
3186        // Verify the kind drives the verb in the "no X found" echo.
3187        let mut a = app_with("xx", 10);
3188        let (tx, rx) =
3189            tokio::sync::mpsc::unbounded_channel::<Vec<lattice_lsp::lsp_types::Location>>();
3190        a.editor.pending_definition_rx = Some(rx);
3191        a.editor.pending_definition_token = Some(lattice_protocol::CancellationToken::new());
3192        a.editor.pending_nav_kind = Some(super::LspNavKind::Implementation);
3193        tx.send(Vec::new()).unwrap();
3194        a.drain_pending_definitions();
3195        let msg = a.editor.last_message.as_ref().expect("echo");
3196        assert!(
3197            msg.text.contains("no implementations"),
3198            "expected implementations echo, got: {}",
3199            msg.text
3200        );
3201        assert!(a.editor.pending_nav_kind.is_none());
3202    }
3203
3204    #[test]
3205    fn drain_pending_no_type_definitions_echoes_kind_specific_message() {
3206        let mut a = app_with("xx", 10);
3207        let (tx, rx) =
3208            tokio::sync::mpsc::unbounded_channel::<Vec<lattice_lsp::lsp_types::Location>>();
3209        a.editor.pending_definition_rx = Some(rx);
3210        a.editor.pending_definition_token = Some(lattice_protocol::CancellationToken::new());
3211        a.editor.pending_nav_kind = Some(super::LspNavKind::TypeDefinition);
3212        tx.send(Vec::new()).unwrap();
3213        a.drain_pending_definitions();
3214        let msg = a.editor.last_message.as_ref().expect("echo");
3215        assert!(msg.text.contains("no type definitions"));
3216    }
3217
3218    #[test]
3219    fn drain_pending_no_declarations_echoes_kind_specific_message() {
3220        let mut a = app_with("xx", 10);
3221        let (tx, rx) =
3222            tokio::sync::mpsc::unbounded_channel::<Vec<lattice_lsp::lsp_types::Location>>();
3223        a.editor.pending_definition_rx = Some(rx);
3224        a.editor.pending_definition_token = Some(lattice_protocol::CancellationToken::new());
3225        a.editor.pending_nav_kind = Some(super::LspNavKind::Declaration);
3226        tx.send(Vec::new()).unwrap();
3227        a.drain_pending_definitions();
3228        let msg = a.editor.last_message.as_ref().expect("echo");
3229        assert!(msg.text.contains("no declarations"));
3230    }
3231
3232    #[test]
3233    fn lsp_references_request_with_no_uri_echoes_no_lsp_attached() {
3234        let mut a = app_with("xx", 10);
3235        a.toggle_mode_by_name("lsp-mode");
3236        a.apply_effect(lattice_grammar::Effect::Lsp(
3237            lattice_grammar::LspRequest::References,
3238        ));
3239        let msg = a.editor.last_message.as_ref().expect("echo");
3240        assert!(msg.text.contains("no LSP server"));
3241    }
3242
3243    #[test]
3244    fn lsp_references_request_pre_cancels_in_flight_token() {
3245        let mut a = app_with("xx", 10);
3246        let stale = lattice_protocol::CancellationToken::new();
3247        a.editor.pending_references_token = Some(stale.clone());
3248        a.apply_effect(lattice_grammar::Effect::Lsp(
3249            lattice_grammar::LspRequest::References,
3250        ));
3251        assert!(stale.is_cancelled());
3252    }
3253
3254    #[test]
3255    fn drain_pending_references_no_servers_outcome_echoes() {
3256        let mut a = app_with("xx", 10);
3257        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::ReferencesOutcome>();
3258        a.editor.pending_references_rx = Some(rx);
3259        a.editor.pending_references_token = Some(lattice_protocol::CancellationToken::new());
3260        tx.send(super::ReferencesOutcome::NoServers).unwrap();
3261        a.drain_pending_references();
3262        let msg = a.editor.last_message.as_ref().expect("echo");
3263        assert!(msg.text.contains("no LSP server"));
3264        assert!(a.editor.pending_references_token.is_none());
3265    }
3266
3267    #[test]
3268    fn drain_pending_references_found_opens_lsp_locations_picker() {
3269        let mut a = app_with("xx", 10);
3270        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::ReferencesOutcome>();
3271        a.editor.pending_references_rx = Some(rx);
3272        a.editor.pending_references_token = Some(lattice_protocol::CancellationToken::new());
3273        tx.send(super::ReferencesOutcome::Found {
3274            symbol: "foo".into(),
3275            locations: vec![loc("/tmp/notarealfile.rs", 3, 5)],
3276        })
3277        .unwrap();
3278        a.drain_pending_references();
3279        // Picker opened, NOT a help buffer (the pre-picker shape).
3280        let picker = a.editor.picker.as_ref().expect("picker");
3281        assert_eq!(picker.title, "references: foo");
3282        assert!(matches!(
3283            picker.source,
3284            lattice_picker::PickerSource::LspLocations
3285        ));
3286        assert!(matches!(
3287            picker.on_accept,
3288            lattice_picker::PickerAction::JumpToLspLocation
3289        ));
3290        // The candidate's typed routing payload carries the
3291        // jump target -- post-4.2.g.7 this replaces the prior
3292        // tab-encoded `text` parsing.
3293        let c = picker.selected_candidate().expect("one row");
3294        let routing = picker.routing_for(c).expect("routing payload set");
3295        let lattice_picker::RoutingPayload::LspLocation { path, line, .. } = routing else {
3296            panic!("expected LspLocation routing, got {routing:?}");
3297        };
3298        // Through the same URI round trip the location took: on Windows the
3299        // drive-less `/tmp/...` gains the current drive.
3300        let expected = lattice_lsp::actor::uri_to_path(&lattice_lsp::actor::uri_from_path(
3301            std::path::Path::new("/tmp/notarealfile.rs"),
3302        ))
3303        .expect("file uri");
3304        assert_eq!(*path, expected);
3305        assert_eq!(*line, 3);
3306        // Column round-trips through utf-16→utf-8 conversion that
3307        // reads from the file's actual line text. For a missing
3308        // file the preview is empty so the conversion bottoms out
3309        // at 0; ASCII files round-trip cleanly. We don't assert on
3310        // col here because the conversion needs the line text and
3311        // the test fixture's path doesn't exist.
3312    }
3313
3314    #[test]
3315    fn drain_pending_references_empty_echoes_not_found() {
3316        // After the picker pivot, the empty-Found case echoes
3317        // rather than opening a buffer with a placeholder. The
3318        // picker UX expects "show the user a list to choose
3319        // from" -- showing an empty picker would be worse UX
3320        // than the echo.
3321        let mut a = app_with("xx", 10);
3322        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::ReferencesOutcome>();
3323        a.editor.pending_references_rx = Some(rx);
3324        a.editor.pending_references_token = Some(lattice_protocol::CancellationToken::new());
3325        tx.send(super::ReferencesOutcome::Found {
3326            symbol: "missing".into(),
3327            locations: Vec::new(),
3328        })
3329        .unwrap();
3330        a.drain_pending_references();
3331        assert!(a.editor.picker.is_none());
3332        let msg = a.editor.last_message.as_ref().expect("echo");
3333        assert!(msg.text.contains("no references"));
3334        assert!(msg.text.contains("missing"));
3335    }
3336
3337    #[test]
3338    fn flatten_document_symbol_response_flat_preserves_order() {
3339        use lattice_lsp::lsp_types::{Location as LLoc, Position as LPos, Range as LRange};
3340        let path = std::path::PathBuf::from("/tmp/x.rs");
3341        #[allow(deprecated)]
3342        let syms = vec![
3343            lattice_lsp::lsp_types::SymbolInformation {
3344                name: "foo".into(),
3345                kind: lattice_lsp::lsp_types::SymbolKind::FUNCTION,
3346                tags: None,
3347                deprecated: None,
3348                location: LLoc {
3349                    uri: super::tests::fake_uri("/tmp/x.rs"),
3350                    range: LRange {
3351                        start: LPos {
3352                            line: 5,
3353                            character: 0,
3354                        },
3355                        end: LPos {
3356                            line: 5,
3357                            character: 3,
3358                        },
3359                    },
3360                },
3361                container_name: None,
3362            },
3363            lattice_lsp::lsp_types::SymbolInformation {
3364                name: "bar".into(),
3365                kind: lattice_lsp::lsp_types::SymbolKind::METHOD,
3366                tags: None,
3367                deprecated: None,
3368                location: LLoc {
3369                    uri: super::tests::fake_uri("/tmp/x.rs"),
3370                    range: LRange {
3371                        start: LPos {
3372                            line: 10,
3373                            character: 4,
3374                        },
3375                        end: LPos {
3376                            line: 10,
3377                            character: 7,
3378                        },
3379                    },
3380                },
3381                container_name: Some("Bag".into()),
3382            },
3383        ];
3384        let resp = lattice_lsp::lsp_types::DocumentSymbolResponse::Flat(syms);
3385        let mut out = Vec::new();
3386        super::flatten_document_symbol_response(resp, &path, &mut out);
3387        assert_eq!(out.len(), 2);
3388        assert_eq!(out[0].name, "foo");
3389        assert_eq!(out[0].depth, 0);
3390        assert_eq!(out[1].name, "bar");
3391        assert_eq!(out[1].container.as_deref(), Some("Bag"));
3392    }
3393
3394    #[test]
3395    fn flatten_document_symbol_response_nested_assigns_depth_via_dfs() {
3396        use lattice_lsp::lsp_types::{DocumentSymbol, Position as LPos, Range as LRange};
3397        let path = std::path::PathBuf::from("/tmp/x.rs");
3398        // mod foo { fn bar() {} } -> outer at depth 0, bar at depth 1.
3399        let inner_range = LRange {
3400            start: LPos {
3401                line: 1,
3402                character: 4,
3403            },
3404            end: LPos {
3405                line: 3,
3406                character: 5,
3407            },
3408        };
3409        let outer_range = LRange {
3410            start: LPos {
3411                line: 0,
3412                character: 0,
3413            },
3414            end: LPos {
3415                line: 4,
3416                character: 0,
3417            },
3418        };
3419        #[allow(deprecated)]
3420        let inner = DocumentSymbol {
3421            name: "bar".into(),
3422            detail: None,
3423            kind: lattice_lsp::lsp_types::SymbolKind::FUNCTION,
3424            tags: None,
3425            deprecated: None,
3426            range: inner_range,
3427            selection_range: inner_range,
3428            children: None,
3429        };
3430        #[allow(deprecated)]
3431        let outer = DocumentSymbol {
3432            name: "foo".into(),
3433            detail: None,
3434            kind: lattice_lsp::lsp_types::SymbolKind::MODULE,
3435            tags: None,
3436            deprecated: None,
3437            range: outer_range,
3438            selection_range: outer_range,
3439            children: Some(vec![inner]),
3440        };
3441        let resp = lattice_lsp::lsp_types::DocumentSymbolResponse::Nested(vec![outer]);
3442        let mut out = Vec::new();
3443        super::flatten_document_symbol_response(resp, &path, &mut out);
3444        assert_eq!(out.len(), 2);
3445        assert_eq!(out[0].name, "foo");
3446        assert_eq!(out[0].depth, 0);
3447        assert_eq!(out[1].name, "bar");
3448        assert_eq!(out[1].depth, 1);
3449    }
3450
3451    #[test]
3452    fn drain_pending_symbols_no_servers_outcome_echoes() {
3453        let mut a = app_with("xx", 10);
3454        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::SymbolsOutcome>();
3455        a.editor.pending_symbols_rx = Some(rx);
3456        a.editor.pending_symbols_token = Some(lattice_protocol::CancellationToken::new());
3457        tx.send(super::SymbolsOutcome::NoServers).unwrap();
3458        a.drain_pending_symbols();
3459        let msg = a.editor.last_message.as_ref().expect("echo");
3460        assert!(msg.text.contains("no LSP server"));
3461        assert!(a.editor.pending_symbols_token.is_none());
3462    }
3463
3464    #[test]
3465    fn drain_pending_symbols_found_opens_picker() {
3466        let mut a = app_with("xx", 10);
3467        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::SymbolsOutcome>();
3468        a.editor.pending_symbols_rx = Some(rx);
3469        a.editor.pending_symbols_token = Some(lattice_protocol::CancellationToken::new());
3470        tx.send(super::SymbolsOutcome::Found {
3471            title: "symbols (2)".into(),
3472            rows: vec![
3473                super::SymbolRow {
3474                    name: "foo".into(),
3475                    kind_glyph: "ƒ",
3476                    container: None,
3477                    depth: 0,
3478                    path: std::path::PathBuf::from("/tmp/x.rs"),
3479                    line: 5,
3480                    col: 0,
3481                },
3482                super::SymbolRow {
3483                    name: "bar".into(),
3484                    kind_glyph: "v",
3485                    container: None,
3486                    depth: 1,
3487                    path: std::path::PathBuf::from("/tmp/x.rs"),
3488                    line: 10,
3489                    col: 4,
3490                },
3491            ],
3492        })
3493        .unwrap();
3494        a.drain_pending_symbols();
3495        let picker = a.editor.picker.as_ref().expect("picker");
3496        assert_eq!(picker.title, "symbols (2)");
3497        assert_eq!(picker.candidates.len(), 2);
3498        // depth-1 row carries indentation in display.
3499        let display = &picker.candidates[1].raw.display;
3500        assert!(display.contains("  v bar"), "got: {display}");
3501    }
3502
3503    #[test]
3504    fn drain_pending_symbols_empty_echoes() {
3505        let mut a = app_with("xx", 10);
3506        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::SymbolsOutcome>();
3507        a.editor.pending_symbols_rx = Some(rx);
3508        a.editor.pending_symbols_token = Some(lattice_protocol::CancellationToken::new());
3509        tx.send(super::SymbolsOutcome::Found {
3510            title: "symbols (0)".into(),
3511            rows: Vec::new(),
3512        })
3513        .unwrap();
3514        a.drain_pending_symbols();
3515        assert!(a.editor.picker.is_none());
3516        let msg = a.editor.last_message.as_ref().expect("echo");
3517        assert!(msg.text.contains("no symbols"));
3518    }
3519
3520    #[test]
3521    fn code_action_kind_glyph_distinct_for_common_kinds() {
3522        use lattice_lsp::lsp_types::CodeActionKind as K;
3523        let qf = super::code_action_kind_glyph(Some(&K::QUICKFIX));
3524        let rf = super::code_action_kind_glyph(Some(&K::REFACTOR));
3525        let sr = super::code_action_kind_glyph(Some(&K::SOURCE));
3526        assert_ne!(qf, rf);
3527        assert_ne!(qf, sr);
3528        assert_ne!(rf, sr);
3529    }
3530
3531    #[test]
3532    fn drain_pending_code_actions_no_provider_echoes() {
3533        let mut a = app_with("xx", 10);
3534        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::CodeActionOutcome>();
3535        a.editor.pending_code_action_rx = Some(rx);
3536        a.editor.pending_code_action_token = Some(lattice_protocol::CancellationToken::new());
3537        tx.send(super::CodeActionOutcome::NoProvider).unwrap();
3538        a.drain_pending_code_actions();
3539        let msg = a.editor.last_message.as_ref().expect("echo");
3540        assert!(msg.text.contains("codeActionProvider"));
3541    }
3542
3543    #[test]
3544    fn drain_pending_code_actions_empty_echoes_no_actions() {
3545        let mut a = app_with("xx", 10);
3546        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::CodeActionOutcome>();
3547        a.editor.pending_code_action_rx = Some(rx);
3548        a.editor.pending_code_action_token = Some(lattice_protocol::CancellationToken::new());
3549        tx.send(super::CodeActionOutcome::Items(Vec::new()))
3550            .unwrap();
3551        a.drain_pending_code_actions();
3552        assert!(a.editor.picker.is_none());
3553        let msg = a.editor.last_message.as_ref().expect("echo");
3554        assert!(msg.text.contains("no code actions"));
3555    }
3556
3557    #[test]
3558    fn drain_pending_code_actions_items_open_picker() {
3559        let mut a = app_with("foo\n", 10);
3560        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::CodeActionOutcome>();
3561        a.editor.pending_code_action_rx = Some(rx);
3562        a.editor.pending_code_action_token = Some(lattice_protocol::CancellationToken::new());
3563        let act = lattice_lsp::lsp_types::CodeAction {
3564            title: "Add `mut` modifier".into(),
3565            kind: Some(lattice_lsp::lsp_types::CodeActionKind::QUICKFIX),
3566            diagnostics: None,
3567            edit: None,
3568            command: None,
3569            is_preferred: None,
3570            disabled: None,
3571            data: None,
3572        };
3573        tx.send(super::CodeActionOutcome::Items(vec![
3574            super::CodeActionRow {
3575                title: act.title.clone(),
3576                kind_glyph: "🛠",
3577                action: lattice_lsp::lsp_types::CodeActionOrCommand::CodeAction(act),
3578            },
3579        ]))
3580        .unwrap();
3581        a.drain_pending_code_actions();
3582        let picker = a.editor.picker.as_ref().expect("picker");
3583        assert!(picker.title.starts_with("code-actions"));
3584        assert!(matches!(
3585            picker.on_accept,
3586            lattice_picker::PickerAction::AcceptLspCodeAction
3587        ));
3588        assert_eq!(picker.candidates.len(), 1);
3589        let display = &picker.candidates[0].raw.display;
3590        assert!(display.contains("🛠 Add `mut` modifier"));
3591        // Items pinned for the accept path.
3592        assert!(a.editor.pending_code_action_items.is_some());
3593    }
3594
3595    #[test]
3596    fn flatten_workspace_edit_collects_legacy_changes_map() {
3597        use std::collections::HashMap;
3598        let uri = super::tests::fake_uri("/tmp/x.rs");
3599        let mut changes: HashMap<
3600            lattice_lsp::lsp_types::Uri,
3601            Vec<lattice_lsp::lsp_types::TextEdit>,
3602        > = HashMap::new();
3603        changes.insert(
3604            uri.clone(),
3605            vec![lattice_lsp::lsp_types::TextEdit {
3606                range: lattice_lsp::lsp_types::Range {
3607                    start: lattice_lsp::lsp_types::Position {
3608                        line: 0,
3609                        character: 0,
3610                    },
3611                    end: lattice_lsp::lsp_types::Position {
3612                        line: 0,
3613                        character: 3,
3614                    },
3615                },
3616                new_text: "bar".into(),
3617            }],
3618        );
3619        let we = lattice_lsp::lsp_types::WorkspaceEdit {
3620            changes: Some(changes),
3621            document_changes: None,
3622            change_annotations: None,
3623        };
3624        let flat = super::flatten_workspace_edit(we);
3625        assert_eq!(flat.len(), 1);
3626        assert_eq!(flat[0].0, uri);
3627        assert_eq!(flat[0].1[0].new_text, "bar");
3628    }
3629
3630    #[test]
3631    fn drain_pending_rename_no_provider_echoes() {
3632        let mut a = app_with("xx", 10);
3633        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::RenameOutcome>();
3634        a.editor.pending_rename_rx = Some(rx);
3635        a.editor.pending_rename_token = Some(lattice_protocol::CancellationToken::new());
3636        tx.send(super::RenameOutcome::NoProvider).unwrap();
3637        a.drain_pending_rename();
3638        let msg = a.editor.last_message.as_ref().expect("echo");
3639        assert!(msg.text.contains("renameProvider"));
3640    }
3641
3642    #[test]
3643    fn drain_pending_rename_not_renameable_echoes_reason() {
3644        let mut a = app_with("xx", 10);
3645        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::RenameOutcome>();
3646        a.editor.pending_rename_rx = Some(rx);
3647        a.editor.pending_rename_token = Some(lattice_protocol::CancellationToken::new());
3648        tx.send(super::RenameOutcome::NotRenameable {
3649            reason: "out of bounds".into(),
3650        })
3651        .unwrap();
3652        a.drain_pending_rename();
3653        let msg = a.editor.last_message.as_ref().expect("echo");
3654        assert_eq!(msg.level, EchoLevel::Error);
3655        assert!(msg.text.contains("out of bounds"));
3656    }
3657
3658    #[test]
3659    fn drain_pending_rename_empty_echoes_no_changes() {
3660        let mut a = app_with("xx", 10);
3661        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::RenameOutcome>();
3662        a.editor.pending_rename_rx = Some(rx);
3663        a.editor.pending_rename_token = Some(lattice_protocol::CancellationToken::new());
3664        tx.send(super::RenameOutcome::Empty).unwrap();
3665        a.drain_pending_rename();
3666        let msg = a.editor.last_message.as_ref().expect("echo");
3667        assert!(msg.text.contains("no changes"));
3668    }
3669
3670    #[test]
3671    fn drain_pending_rename_applies_active_buffer_edits_as_one_undo_unit() {
3672        // End-to-end-ish: load a real document, send a rename
3673        // outcome targeting it, verify the buffer text changed
3674        // and a single undo restores.
3675        let path = std::env::temp_dir().join(format!("lattice-rename-{}.rs", std::process::id()));
3676        std::fs::write(&path, "let foo = 1;\nlet x = foo + 2;\n").unwrap();
3677        let doc = Document::open(&path).unwrap();
3678        let mut a = App::new(doc);
3679        a.set_viewport_height(10);
3680        let uri = super::tests::fake_uri(path.to_str().unwrap());
3681        let edits = vec![
3682            // Replace `foo` on line 0 col 4..7
3683            lattice_lsp::lsp_types::TextEdit {
3684                range: lattice_lsp::lsp_types::Range {
3685                    start: lattice_lsp::lsp_types::Position {
3686                        line: 0,
3687                        character: 4,
3688                    },
3689                    end: lattice_lsp::lsp_types::Position {
3690                        line: 0,
3691                        character: 7,
3692                    },
3693                },
3694                new_text: "bar".into(),
3695            },
3696            // Replace `foo` on line 1 col 8..11
3697            lattice_lsp::lsp_types::TextEdit {
3698                range: lattice_lsp::lsp_types::Range {
3699                    start: lattice_lsp::lsp_types::Position {
3700                        line: 1,
3701                        character: 8,
3702                    },
3703                    end: lattice_lsp::lsp_types::Position {
3704                        line: 1,
3705                        character: 11,
3706                    },
3707                },
3708                new_text: "bar".into(),
3709            },
3710        ];
3711        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::RenameOutcome>();
3712        a.editor.pending_rename_rx = Some(rx);
3713        a.editor.pending_rename_token = Some(lattice_protocol::CancellationToken::new());
3714        tx.send(super::RenameOutcome::Edits {
3715            per_file: vec![(uri, edits)],
3716            new_name: "bar".into(),
3717        })
3718        .unwrap();
3719        a.drain_pending_rename();
3720        let body = a.editor.document.snapshot().buffer.as_string();
3721        assert!(body.contains("let bar = 1;"));
3722        assert!(body.contains("let x = bar + 2;"));
3723        // One undo restores the pre-rename buffer (apply_lsp_text_edits
3724        // commits via apply_edit_batch_blocking which is one undo unit).
3725        let _ = a.undo_blocking();
3726        let restored = a.editor.document.snapshot().buffer.as_string();
3727        assert!(restored.contains("let foo = 1;"));
3728        let _ = std::fs::remove_file(path);
3729    }
3730
3731    #[test]
3732    fn drain_pending_insert_completion_lsp_no_servers_keeps_popup_open_if_sync_had_results() {
3733        // When sync sources gave us candidates and LSP says
3734        // NoServers, the popup stays open with the sync set.
3735        let mut a = app_with("alpha alphabet alligator\nal", 10);
3736        a.editor.modal = ModalState::Insert;
3737        a.editor.cursor = Position::new(1, 2);
3738        a.do_completion_trigger();
3739        // No URI mapped -> LSP request didn't fire; the popup
3740        // is open from the sync sources alone. Manually push
3741        // a NoServers outcome to verify the drain handles it
3742        // without exploding.
3743        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::AsyncCompletionOutcome>();
3744        a.editor.pending_insert_completion_async_rx = Some(rx);
3745        a.editor.pending_insert_completion_async_token =
3746            Some(lattice_protocol::CancellationToken::new());
3747        tx.send(super::AsyncCompletionOutcome::Nothing).unwrap();
3748        a.drain_pending_insert_completion_lsp();
3749        // Popup still open from sync sources.
3750        assert!(a.editor.insert_completion.is_some());
3751    }
3752
3753    #[test]
3754    fn drain_pending_insert_completion_lsp_items_merge_into_popup() {
3755        let mut a = app_with("\nfo", 10);
3756        a.editor.modal = ModalState::Insert;
3757        a.editor.cursor = Position::new(1, 2);
3758        // Seed the popup state directly -- skip do_completion_trigger
3759        // so the test doesn't depend on sync sources producing
3760        // matches first. The drain merges LSP items into
3761        // whatever raw set is present.
3762        a.editor.insert_completion = Some(lattice_completion::InsertCompletionState::open(
3763            lattice_completion::CompletionTrigger::Manual,
3764            Position::new(1, 0),
3765            Position::new(1, 2),
3766            "fo".to_string(),
3767        ));
3768        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::AsyncCompletionOutcome>();
3769        a.editor.pending_insert_completion_async_rx = Some(rx);
3770        a.editor.pending_insert_completion_async_token =
3771            Some(lattice_protocol::CancellationToken::new());
3772        tx.send(super::AsyncCompletionOutcome::Items {
3773            sources: vec![lattice_completion::SourceId::new(
3774                lattice_completion::LSP_COMPLETION_SOURCE_ID,
3775            )],
3776            candidates: vec![
3777                lsp_meta_candidate(super::LspCompletionMeta {
3778                    label: "foo".into(),
3779                    insert_text: "foo".into(),
3780                    filter_text: None,
3781                    sort_text: None,
3782                    detail: Some("fn() -> i32".into()),
3783                    documentation: None,
3784                    kind: Some(lattice_lsp::lsp_types::CompletionItemKind::FUNCTION),
3785                    deprecated: false,
3786                    preselect: false,
3787                    commit_characters: Vec::new(),
3788                    additional_text_edits: Vec::new(),
3789                    command: None,
3790                    insert_text_format: lattice_lsp::lsp_types::InsertTextFormat::PLAIN_TEXT,
3791                    replace_range: None,
3792                    server_id: "test-server".to_string(),
3793                    original_item: lattice_lsp::lsp_types::CompletionItem::default(),
3794                    resolved: false,
3795                }),
3796                lsp_meta_candidate(super::LspCompletionMeta {
3797                    label: "foobar".into(),
3798                    insert_text: "foobar".into(),
3799                    filter_text: None,
3800                    sort_text: None,
3801                    detail: None,
3802                    documentation: None,
3803                    kind: Some(lattice_lsp::lsp_types::CompletionItemKind::VARIABLE),
3804                    deprecated: false,
3805                    preselect: false,
3806                    commit_characters: Vec::new(),
3807                    additional_text_edits: Vec::new(),
3808                    command: None,
3809                    insert_text_format: lattice_lsp::lsp_types::InsertTextFormat::PLAIN_TEXT,
3810                    replace_range: None,
3811                    server_id: "test-server".to_string(),
3812                    original_item: lattice_lsp::lsp_types::CompletionItem::default(),
3813                    resolved: false,
3814                }),
3815            ],
3816            is_incomplete: false,
3817        })
3818        .unwrap();
3819        a.drain_pending_insert_completion_lsp();
3820        let state = a.editor.insert_completion.as_ref().expect("popup open");
3821        // Both items render; "foo" prefix matches both.
3822        let labels: Vec<String> = state
3823            .rendered
3824            .iter()
3825            .map(|c| c.raw.display.clone())
3826            .collect();
3827        assert!(labels.iter().any(|l| l.starts_with("foo")));
3828        assert!(labels.iter().any(|l| l.starts_with("foobar")));
3829        // CSM.8b.5: state.raw is the source of truth. Two LSP
3830        // rows present, each carrying their own payload-encoded
3831        // meta.
3832        let state = a.editor.insert_completion.as_ref().expect("popup");
3833        let lsp_rows = state
3834            .raw
3835            .iter()
3836            .filter(|r| {
3837                matches!(
3838                    r.data,
3839                    lattice_completion::CandidateData::Extension {
3840                        kind_id: LSP_COMPLETION_KIND_ID,
3841                        ..
3842                    }
3843                )
3844            })
3845            .count();
3846        assert_eq!(lsp_rows, 2);
3847    }
3848
3849    #[test]
3850    fn drain_pending_insert_completion_lsp_drops_prior_lsp_rows_on_refresh() {
3851        // First merge populates LSP rows; second merge with
3852        // a different item set should REPLACE (not append).
3853        let mut a = app_with("xx", 10);
3854        a.editor.modal = ModalState::Insert;
3855        a.editor.cursor = Position::ZERO;
3856        a.editor.insert_completion = Some(lattice_completion::InsertCompletionState::open(
3857            lattice_completion::CompletionTrigger::Manual,
3858            Position::ZERO,
3859            Position::ZERO,
3860            String::new(),
3861        ));
3862        let mk_item = |label: &str| super::LspCompletionMeta {
3863            label: label.into(),
3864            insert_text: label.into(),
3865            filter_text: None,
3866            sort_text: None,
3867            detail: None,
3868            documentation: None,
3869            kind: None,
3870            deprecated: false,
3871            preselect: false,
3872            commit_characters: Vec::new(),
3873            additional_text_edits: Vec::new(),
3874            command: None,
3875            insert_text_format: lattice_lsp::lsp_types::InsertTextFormat::PLAIN_TEXT,
3876            replace_range: None,
3877            server_id: "test-server".to_string(),
3878            original_item: lattice_lsp::lsp_types::CompletionItem::default(),
3879            resolved: false,
3880        };
3881        // First batch.
3882        let (tx1, rx1) = tokio::sync::mpsc::unbounded_channel::<super::AsyncCompletionOutcome>();
3883        a.editor.pending_insert_completion_async_rx = Some(rx1);
3884        a.editor.pending_insert_completion_async_token =
3885            Some(lattice_protocol::CancellationToken::new());
3886        tx1.send(super::AsyncCompletionOutcome::Items {
3887            sources: vec![lattice_completion::SourceId::new(
3888                lattice_completion::LSP_COMPLETION_SOURCE_ID,
3889            )],
3890            candidates: vec![
3891                lsp_meta_candidate(mk_item("alpha")),
3892                lsp_meta_candidate(mk_item("alphabet")),
3893            ],
3894            is_incomplete: false,
3895        })
3896        .unwrap();
3897        a.drain_pending_insert_completion_lsp();
3898        let pre = a
3899            .editor
3900            .insert_completion
3901            .as_ref()
3902            .map(|s| s.raw.len())
3903            .unwrap_or(0);
3904        assert_eq!(pre, 2);
3905        // Second batch -- only one item, "beta". Prior LSP
3906        // rows should be pruned.
3907        let (tx2, rx2) = tokio::sync::mpsc::unbounded_channel::<super::AsyncCompletionOutcome>();
3908        a.editor.pending_insert_completion_async_rx = Some(rx2);
3909        a.editor.pending_insert_completion_async_token =
3910            Some(lattice_protocol::CancellationToken::new());
3911        tx2.send(super::AsyncCompletionOutcome::Items {
3912            sources: vec![lattice_completion::SourceId::new(
3913                lattice_completion::LSP_COMPLETION_SOURCE_ID,
3914            )],
3915            candidates: vec![lsp_meta_candidate(mk_item("beta"))],
3916            is_incomplete: false,
3917        })
3918        .unwrap();
3919        a.drain_pending_insert_completion_lsp();
3920        let state = a.editor.insert_completion.as_ref().expect("popup");
3921        let lsp_rows: Vec<_> = state
3922            .raw
3923            .iter()
3924            .filter_map(|r| match &r.data {
3925                lattice_completion::CandidateData::Extension {
3926                    kind_id: LSP_COMPLETION_KIND_ID,
3927                    payload,
3928                } => lattice_lsp::completion::decode_meta(payload),
3929                _ => None,
3930            })
3931            .collect();
3932        assert_eq!(lsp_rows.len(), 1);
3933        assert_eq!(lsp_rows[0].label, "beta");
3934    }
3935
3936    #[test]
3937    fn lsp_completion_meta_for_returns_none_for_sync_sourced_candidates() {
3938        let a = app_with("xx", 10);
3939        let raw = lattice_completion::RawCandidate::plain(
3940            "foo",
3941            lattice_completion::CandidateKind::Plain,
3942        );
3943        let scored = lattice_completion::ScoredCandidate {
3944            raw,
3945            score: lattice_completion::MatchScore(100),
3946            match_ranges: Vec::new(),
3947        };
3948        let rendered = lattice_completion::RenderedCandidate::from_scored(scored);
3949        assert!(a.lsp_completion_meta_for(&rendered).is_none());
3950    }
3951
3952    #[test]
3953    fn drain_pending_completion_resolve_fills_metadata_and_body() {
3954        let mut a = app_with("xx", 10);
3955        a.editor.modal = ModalState::Insert;
3956        a.editor.cursor = Position::ZERO;
3957        // Build state with one candidate pointing at meta[0].
3958        let mut state = lattice_completion::InsertCompletionState::open(
3959            lattice_completion::CompletionTrigger::Manual,
3960            Position::ZERO,
3961            Position::ZERO,
3962            String::new(),
3963        );
3964        let meta = super::LspCompletionMeta {
3965            label: "foo".into(),
3966            insert_text: "foo".into(),
3967            filter_text: None,
3968            sort_text: None,
3969            detail: None,
3970            documentation: None,
3971            kind: None,
3972            deprecated: false,
3973            preselect: false,
3974            commit_characters: Vec::new(),
3975            additional_text_edits: Vec::new(),
3976            command: None,
3977            insert_text_format: lattice_lsp::lsp_types::InsertTextFormat::PLAIN_TEXT,
3978            replace_range: None,
3979            server_id: "test-server".to_string(),
3980            original_item: lattice_lsp::lsp_types::CompletionItem::default(),
3981            resolved: false,
3982        };
3983        let mut raw = lattice_completion::RawCandidate::plain(
3984            "foo",
3985            lattice_completion::CandidateKind::Plain,
3986        );
3987        raw.data = lattice_completion::CandidateData::Extension {
3988            kind_id: super::LSP_COMPLETION_KIND_ID,
3989            payload: lattice_lsp::completion::encode_meta(&meta),
3990        };
3991        state.raw.push(raw.clone());
3992        state
3993            .rendered
3994            .push(lattice_completion::RenderedCandidate::from_scored(
3995                lattice_completion::ScoredCandidate {
3996                    raw,
3997                    score: lattice_completion::MatchScore(100),
3998                    match_ranges: Vec::new(),
3999                },
4000            ));
4001        // Open the doc popup -- empty body initially because
4002        // meta has no documentation yet.
4003        state.doc_popup = Some(lattice_completion::DocPopupState {
4004            for_index: 0,
4005            body: None,
4006            scroll: 5, // verify scroll resets on body refresh
4007        });
4008        // CSM.8b.5: meta lives in candidate payload already.
4009        let _ = meta;
4010        a.editor.insert_completion = Some(state);
4011        // Push a resolve outcome that fills documentation.
4012        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::CompletionResolveOutcome>();
4013        a.editor.pending_completion_resolve_rx = Some(rx);
4014        a.editor.pending_completion_resolve_token =
4015            Some(lattice_protocol::CancellationToken::new());
4016        let mut resolved = lattice_lsp::lsp_types::CompletionItem {
4017            label: "foo".into(),
4018            detail: Some("fn foo() -> i32".into()),
4019            ..Default::default()
4020        };
4021        resolved.documentation = Some(lattice_lsp::lsp_types::Documentation::String(
4022            "Returns 42.".into(),
4023        ));
4024        tx.send(super::CompletionResolveOutcome {
4025            meta_index: 0,
4026            resolved,
4027        })
4028        .unwrap();
4029        a.drain_pending_completion_resolve();
4030        // CSM.8b.5: candidate payload (the source of truth) is
4031        // re-encoded in place with the resolved fields.
4032        let state = a.editor.insert_completion.as_ref().expect("popup");
4033        let payload = match &state.raw[0].data {
4034            lattice_completion::CandidateData::Extension { payload, .. } => payload.clone(),
4035            _ => panic!("expected Extension payload"),
4036        };
4037        let updated = lattice_lsp::completion::decode_meta(&payload).expect("decode");
4038        assert!(updated.resolved);
4039        assert_eq!(updated.detail.as_deref(), Some("fn foo() -> i32"));
4040        assert_eq!(updated.documentation.as_deref(), Some("Returns 42."));
4041        // Doc popup body refreshed; scroll reset to 0.
4042        let popup = state.doc_popup.as_ref().expect("popup");
4043        assert_eq!(popup.scroll, 0);
4044        let body = popup.body.as_deref().unwrap_or("");
4045        assert!(body.contains("fn foo() -> i32"));
4046        assert!(body.contains("Returns 42."));
4047    }
4048
4049    #[test]
4050    fn drain_pending_completion_resolve_drops_stale_index_after_selection_moved() {
4051        // Resolve arrives for the c0 candidate but selection has
4052        // moved to c1. The c0 payload still updates (so a future
4053        // refocus uses the cached docs) but the doc popup body
4054        // doesn't change.
4055        let mut a = app_with("xx", 10);
4056        let mut state = lattice_completion::InsertCompletionState::open(
4057            lattice_completion::CompletionTrigger::Manual,
4058            Position::ZERO,
4059            Position::ZERO,
4060            String::new(),
4061        );
4062        let mk_meta = |label: &str| super::LspCompletionMeta {
4063            label: label.into(),
4064            insert_text: label.into(),
4065            filter_text: None,
4066            sort_text: None,
4067            detail: None,
4068            documentation: None,
4069            kind: None,
4070            deprecated: false,
4071            preselect: false,
4072            commit_characters: Vec::new(),
4073            additional_text_edits: Vec::new(),
4074            command: None,
4075            insert_text_format: lattice_lsp::lsp_types::InsertTextFormat::PLAIN_TEXT,
4076            replace_range: None,
4077            server_id: "test-server".to_string(),
4078            original_item: {
4079                let ci = lattice_lsp::lsp_types::CompletionItem {
4080                    label: label.into(),
4081                    ..Default::default()
4082                };
4083                ci
4084            },
4085            resolved: false,
4086        };
4087        for label in ["c0", "c1"] {
4088            let meta = mk_meta(label);
4089            let mut raw = lattice_completion::RawCandidate::plain(
4090                label,
4091                lattice_completion::CandidateKind::Plain,
4092            );
4093            raw.data = lattice_completion::CandidateData::Extension {
4094                kind_id: super::LSP_COMPLETION_KIND_ID,
4095                payload: lattice_lsp::completion::encode_meta(&meta),
4096            };
4097            state.raw.push(raw.clone());
4098            state
4099                .rendered
4100                .push(lattice_completion::RenderedCandidate::from_scored(
4101                    lattice_completion::ScoredCandidate {
4102                        raw,
4103                        score: lattice_completion::MatchScore(100),
4104                        match_ranges: Vec::new(),
4105                    },
4106                ));
4107        }
4108        state.selected = 1; // user moved past c0
4109        state.doc_popup = Some(lattice_completion::DocPopupState {
4110            for_index: 1,
4111            body: Some("for c1".into()),
4112            scroll: 0,
4113        });
4114        a.editor.insert_completion = Some(state);
4115        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::CompletionResolveOutcome>();
4116        a.editor.pending_completion_resolve_rx = Some(rx);
4117        a.editor.pending_completion_resolve_token =
4118            Some(lattice_protocol::CancellationToken::new());
4119        let mut resolved = lattice_lsp::lsp_types::CompletionItem {
4120            label: "c0".into(),
4121            ..Default::default()
4122        };
4123        resolved.documentation = Some(lattice_lsp::lsp_types::Documentation::String(
4124            "stale".into(),
4125        ));
4126        tx.send(super::CompletionResolveOutcome {
4127            meta_index: 0,
4128            resolved,
4129        })
4130        .unwrap();
4131        a.drain_pending_completion_resolve();
4132        // c0's payload updated.
4133        let state = a.editor.insert_completion.as_ref().expect("popup");
4134        let c0_payload = match &state.raw[0].data {
4135            lattice_completion::CandidateData::Extension { payload, .. } => payload.clone(),
4136            _ => panic!("expected Extension"),
4137        };
4138        let c0_meta = lattice_lsp::completion::decode_meta(&c0_payload).expect("decode");
4139        assert!(c0_meta.resolved);
4140        assert_eq!(c0_meta.documentation.as_deref(), Some("stale"));
4141        // Doc popup body unchanged (still pointing at c1).
4142        let body = state.doc_popup.as_ref().and_then(|d| d.body.clone());
4143        assert_eq!(body.as_deref(), Some("for c1"));
4144    }
4145
4146    #[test]
4147    fn lsp_completion_meta_for_decodes_payload() {
4148        // CSM.8b: the candidate carries the encoded meta in its
4149        // own payload; `lsp_completion_meta_for` decodes it
4150        // directly with no sidecar lookup.
4151        let a = app_with("xx", 10);
4152        let meta = super::LspCompletionMeta {
4153            label: "second".into(),
4154            insert_text: "second".into(),
4155            filter_text: None,
4156            sort_text: None,
4157            detail: None,
4158            documentation: None,
4159            kind: None,
4160            deprecated: false,
4161            preselect: false,
4162            commit_characters: Vec::new(),
4163            additional_text_edits: Vec::new(),
4164            command: None,
4165            insert_text_format: lattice_lsp::lsp_types::InsertTextFormat::PLAIN_TEXT,
4166            replace_range: None,
4167            server_id: "test-server".to_string(),
4168            original_item: lattice_lsp::lsp_types::CompletionItem::default(),
4169            resolved: false,
4170        };
4171        let mut raw = lattice_completion::RawCandidate::plain(
4172            "second",
4173            lattice_completion::CandidateKind::Plain,
4174        );
4175        raw.data = lattice_completion::CandidateData::Extension {
4176            kind_id: super::LSP_COMPLETION_KIND_ID,
4177            payload: lattice_lsp::completion::encode_meta(&meta),
4178        };
4179        let scored = lattice_completion::ScoredCandidate {
4180            raw,
4181            score: lattice_completion::MatchScore(100),
4182            match_ranges: Vec::new(),
4183        };
4184        let rendered = lattice_completion::RenderedCandidate::from_scored(scored);
4185        let decoded = a.lsp_completion_meta_for(&rendered).expect("meta resolves");
4186        assert_eq!(decoded.label, "second");
4187    }
4188
4189    #[test]
4190    fn drain_pending_completion_no_servers_echoes() {
4191        let mut a = app_with("xx", 10);
4192        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::CompletionOutcome>();
4193        a.editor.pending_completion_rx = Some(rx);
4194        a.editor.pending_completion_token = Some(lattice_protocol::CancellationToken::new());
4195        tx.send(super::CompletionOutcome::NoServers).unwrap();
4196        a.drain_pending_completion();
4197        let msg = a.editor.last_message.as_ref().expect("echo");
4198        assert!(msg.text.contains("no LSP server"));
4199    }
4200
4201    #[test]
4202    fn drain_pending_completion_items_open_picker_with_indexed_text() {
4203        let mut a = app_with("foo\n", 10);
4204        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::CompletionOutcome>();
4205        a.editor.pending_completion_rx = Some(rx);
4206        a.editor.pending_completion_token = Some(lattice_protocol::CancellationToken::new());
4207        tx.send(super::CompletionOutcome::Items(vec![
4208            super::CompletionItemRow {
4209                label: "foo_bar".into(),
4210                kind_glyph: "ƒ",
4211                detail: Some("fn foo_bar()".into()),
4212                insert_text: "foo_bar()".into(),
4213                replace_range: (0, 3),
4214                line: 0,
4215            },
4216        ]))
4217        .unwrap();
4218        a.drain_pending_completion();
4219        let picker = a.editor.picker.as_ref().expect("picker");
4220        assert!(picker.title.starts_with("complete"));
4221        assert!(matches!(
4222            picker.on_accept,
4223            lattice_picker::PickerAction::AcceptLspCompletion
4224        ));
4225        assert_eq!(picker.candidates.len(), 1);
4226        // Display carries kind glyph + label + detail.
4227        let display = &picker.candidates[0].raw.display;
4228        assert!(display.contains("ƒ foo_bar"));
4229        assert!(display.contains("fn foo_bar()"));
4230        // Routing payload carries the typed LspCompletion index
4231        // (post-4.2.g.7 typed routing replaces the prior `#<idx>`
4232        // string encoding).
4233        let routing = picker
4234            .routing_for(&picker.candidates[0])
4235            .expect("routing payload set");
4236        match routing {
4237            lattice_picker::RoutingPayload::LspCompletion { index } => {
4238                assert_eq!(*index, 0);
4239            }
4240            other => panic!("expected LspCompletion routing, got {other:?}"),
4241        }
4242        // Items survive on the App for the accept path.
4243        assert!(a.editor.pending_completion_items.is_some());
4244    }
4245
4246    #[test]
4247    fn drain_pending_completion_empty_echoes_no_completions() {
4248        let mut a = app_with("xx", 10);
4249        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::CompletionOutcome>();
4250        a.editor.pending_completion_rx = Some(rx);
4251        a.editor.pending_completion_token = Some(lattice_protocol::CancellationToken::new());
4252        tx.send(super::CompletionOutcome::Items(Vec::new()))
4253            .unwrap();
4254        a.drain_pending_completion();
4255        assert!(a.editor.picker.is_none());
4256        let msg = a.editor.last_message.as_ref().expect("echo");
4257        assert!(msg.text.contains("no completions"));
4258    }
4259
4260    #[test]
4261    fn signature_help_to_markdown_renders_active_signature() {
4262        let sh = lattice_lsp::lsp_types::SignatureHelp {
4263            signatures: vec![lattice_lsp::lsp_types::SignatureInformation {
4264                label: "fn foo(a: i32, b: &str) -> i32".into(),
4265                documentation: Some(lattice_lsp::lsp_types::Documentation::String(
4266                    "Adds.".into(),
4267                )),
4268                parameters: Some(vec![
4269                    lattice_lsp::lsp_types::ParameterInformation {
4270                        label: lattice_lsp::lsp_types::ParameterLabel::Simple("a: i32".into()),
4271                        documentation: Some(lattice_lsp::lsp_types::Documentation::String(
4272                            "the first.".into(),
4273                        )),
4274                    },
4275                    lattice_lsp::lsp_types::ParameterInformation {
4276                        label: lattice_lsp::lsp_types::ParameterLabel::Simple("b: &str".into()),
4277                        documentation: None,
4278                    },
4279                ]),
4280                active_parameter: Some(0),
4281            }],
4282            active_signature: Some(0),
4283            active_parameter: None,
4284        };
4285        let body = super::signature_help_to_markdown(&sh);
4286        assert!(body.contains("fn foo(a: i32"));
4287        assert!(body.contains("**param:** `a: i32`"));
4288        assert!(body.contains("the first."));
4289        assert!(body.contains("Adds."));
4290    }
4291
4292    #[test]
4293    fn signature_help_to_markdown_empty_when_no_signatures() {
4294        let sh = lattice_lsp::lsp_types::SignatureHelp {
4295            signatures: vec![],
4296            active_signature: None,
4297            active_parameter: None,
4298        };
4299        assert_eq!(super::signature_help_to_markdown(&sh), "");
4300    }
4301
4302    #[test]
4303    fn drain_pending_signature_help_body_opens_popup() {
4304        let mut a = app_with("xx", 10);
4305        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::SignatureHelpOutcome>();
4306        a.editor.pending_signature_help_rx = Some(rx);
4307        a.editor.pending_signature_help_token = Some(lattice_protocol::CancellationToken::new());
4308        tx.send(super::SignatureHelpOutcome::Body(
4309            "```text\nfn x()\n```\n".into(),
4310        ))
4311        .unwrap();
4312        a.drain_pending_signature_help();
4313        let h = a.popup_help().expect("popup");
4314        assert_eq!(h.title, "hover");
4315        assert!(a.editor.pending_signature_help_token.is_none());
4316    }
4317
4318    #[test]
4319    fn drain_pending_signature_help_empty_body_echoes_no_signature_info() {
4320        let mut a = app_with("xx", 10);
4321        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::SignatureHelpOutcome>();
4322        a.editor.pending_signature_help_rx = Some(rx);
4323        a.editor.pending_signature_help_token = Some(lattice_protocol::CancellationToken::new());
4324        tx.send(super::SignatureHelpOutcome::Body(String::new()))
4325            .unwrap();
4326        a.drain_pending_signature_help();
4327        let msg = a.editor.last_message.as_ref().expect("echo");
4328        assert!(msg.text.contains("no signature info"));
4329        assert!(a.editor.popup_buffer.is_none());
4330    }
4331
4332    #[test]
4333    fn nav_request_captures_tag_origin_for_picker_consumption() {
4334        // `Editor::lsp_nav_request` should set `pending_tag_origin`
4335        // so a subsequent picker accept (multi-result) pushes
4336        // the right entry onto the tag stack.
4337        //
4338        // Phase 5.8.AF.5 / Slice X1: this test now exercises the
4339        // host method directly (`a.editor.lsp_nav_request(...)`)
4340        // instead of routing through `a.apply(Action::Lsp...)`.
4341        // Reason: post-X1, `apply` calls `editor.run_tick_pending()`
4342        // at its tail (paramount-goal-#1 cleanup -- I/O drain off
4343        // the renderer body). The spawned LSP task posts an
4344        // empty `Vec` synchronously when no LSP server is
4345        // attached (the test scenario), and the drain then
4346        // consumes that empty response and clears
4347        // `pending_tag_origin` via the "no definitions found"
4348        // branch. The pre-X1 test relied on the drain not
4349        // running in the same `apply` call; that race is closed
4350        // by X1. The host-method-level assertion captures the
4351        // same invariant (origin is staged by the request
4352        // handler) without depending on drain timing.
4353        let mut a = app_with("foo bar\nbaz\n", 10);
4354        // M.5.4: gate fires before tag-origin capture; activate
4355        // lsp-mode so the request gets that far.
4356        a.toggle_mode_by_name("lsp-mode");
4357        a.editor.cursor = Position::new(0, 1);
4358        // Manually set a uri so lsp_nav_request gets past
4359        // the "no LSP server" guard.
4360        use std::str::FromStr;
4361        a.editor.buffer_uris.insert(
4362            a.editor.document_buffer_id,
4363            lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap(),
4364        );
4365        a.editor
4366            .lsp_nav_request(lattice_lsp::cache::LspNavKind::Definition);
4367        let origin = a.editor.pending_tag_origin.as_ref().expect("origin set");
4368        assert_eq!(origin.position, Position::new(0, 1));
4369        assert_eq!(origin.label, "foo");
4370    }
4371
4372    #[test]
4373    fn lsp_nav_request_pre_cancels_prior_token_regardless_of_kind() {
4374        // A new nav request of any kind must cancel a still-in-flight
4375        // request of any other kind -- they all share one slot.
4376        let mut a = app_with("xx", 10);
4377        let stale = lattice_protocol::CancellationToken::new();
4378        a.editor.pending_definition_token = Some(stale.clone());
4379        a.apply_effect(lattice_grammar::Effect::Lsp(
4380            lattice_grammar::LspRequest::Implementation,
4381        ));
4382        assert!(stale.is_cancelled());
4383    }
4384
4385    #[test]
4386    fn lsp_definition_request_pre_cancels_in_flight_token() {
4387        let mut a = app_with("xx", 10);
4388        let stale = lattice_protocol::CancellationToken::new();
4389        a.editor.pending_definition_token = Some(stale.clone());
4390        a.apply_effect(lattice_grammar::Effect::Lsp(
4391            lattice_grammar::LspRequest::Definition,
4392        ));
4393        assert!(stale.is_cancelled());
4394    }
4395
4396    #[test]
4397    fn drain_pending_definitions_with_no_results_echoes_not_found() {
4398        let mut a = app_with("xx", 10);
4399        let (tx, rx) =
4400            tokio::sync::mpsc::unbounded_channel::<Vec<lattice_lsp::lsp_types::Location>>();
4401        a.editor.pending_definition_rx = Some(rx);
4402        a.editor.pending_definition_token = Some(lattice_protocol::CancellationToken::new());
4403        tx.send(Vec::new()).unwrap();
4404        a.drain_pending_definitions();
4405        let msg = a.editor.last_message.as_ref().expect("echo");
4406        assert!(msg.text.contains("no definitions"));
4407        assert!(a.editor.pending_definition_token.is_none());
4408    }
4409
4410    #[test]
4411    fn drain_pending_definitions_with_single_same_buffer_jumps_in_place() {
4412        // Set up an App whose document path matches the location's
4413        // uri, so the jump stays in-buffer (no `:e` round-trip).
4414        let path = std::env::temp_dir().join(format!("lattice-defjump-{}.rs", std::process::id()));
4415        std::fs::write(&path, "first line\nsecond line\nthird line\n").unwrap();
4416        let doc = Document::open(&path).unwrap();
4417        let mut a = App::new(doc);
4418        a.set_viewport_height(10);
4419        // Cursor starts at (0, 0). Drain a definition pointing at
4420        // line 2 col 5 (utf-16 character; same as utf-8 byte for
4421        // ASCII).
4422        let (tx, rx) =
4423            tokio::sync::mpsc::unbounded_channel::<Vec<lattice_lsp::lsp_types::Location>>();
4424        a.editor.pending_definition_rx = Some(rx);
4425        a.editor.pending_definition_token = Some(lattice_protocol::CancellationToken::new());
4426        let target = lattice_lsp::lsp_types::Location {
4427            uri: super::tests::fake_uri(path.to_str().unwrap()),
4428            range: lattice_lsp::lsp_types::Range {
4429                start: lattice_lsp::lsp_types::Position {
4430                    line: 2,
4431                    character: 5,
4432                },
4433                end: lattice_lsp::lsp_types::Position {
4434                    line: 2,
4435                    character: 6,
4436                },
4437            },
4438        };
4439        tx.send(vec![target]).unwrap();
4440        a.drain_pending_definitions();
4441        // Cursor moved to (2, 5).
4442        assert_eq!(a.editor.cursor.line, 2);
4443        assert_eq!(a.editor.cursor.byte, 5);
4444        // Pre-jump position pushed onto history as PluginPush.
4445        let pushed = a
4446            .editor
4447            .position_history
4448            .iter()
4449            .any(|e| e.source == PositionSource::PluginPush && e.position == Position::ZERO);
4450        assert!(pushed, "expected PluginPush entry for pre-jump cursor");
4451        let _ = std::fs::remove_file(path);
4452    }
4453
4454    #[test]
4455    fn drain_pending_definitions_with_multiple_opens_picker() {
4456        // After the picker pivot, multi-result nav opens the
4457        // vertico picker rather than auto-jumping to the first
4458        // result. Single-result jump path is still tested by
4459        // `drain_pending_definitions_with_single_same_buffer_jumps_in_place`.
4460        let path = std::env::temp_dir().join(format!("lattice-defmulti-{}.rs", std::process::id()));
4461        std::fs::write(&path, "alpha\nbeta\ngamma\n").unwrap();
4462        let doc = Document::open(&path).unwrap();
4463        let mut a = App::new(doc);
4464        a.set_viewport_height(10);
4465        let (tx, rx) =
4466            tokio::sync::mpsc::unbounded_channel::<Vec<lattice_lsp::lsp_types::Location>>();
4467        a.editor.pending_definition_rx = Some(rx);
4468        a.editor.pending_definition_token = Some(lattice_protocol::CancellationToken::new());
4469        a.editor.pending_nav_kind = Some(super::LspNavKind::Definition);
4470        let target_path = path.to_str().unwrap();
4471        tx.send(vec![
4472            super::tests::loc(target_path, 1, 0),
4473            super::tests::loc(target_path, 2, 0),
4474        ])
4475        .unwrap();
4476        a.drain_pending_definitions();
4477        let picker = a.editor.picker.as_ref().expect("multi-result opens picker");
4478        assert_eq!(picker.title, "lsp:definitions");
4479        assert_eq!(picker.candidates.len(), 2);
4480        assert!(matches!(
4481            picker.on_accept,
4482            lattice_picker::PickerAction::JumpToLspLocation
4483        ));
4484        // Cursor should NOT have moved (no auto-jump).
4485        assert_eq!(a.editor.cursor.line, 0);
4486        let _ = std::fs::remove_file(path);
4487    }
4488
4489    #[test]
4490    fn lsp_supervisor_constructed_with_builtin_configs() {
4491        let app = App::new(Document::from_text(""));
4492        // Builtin registry: rust, python, go, typescript, c-cpp,
4493        // lua. Six entries today.
4494        assert!(
4495            app.editor.lsp.configs().len() >= 6,
4496            "expected at least 6 builtin server configs"
4497        );
4498        // Supervisor starts dormant.
4499        assert_eq!(app.editor.lsp.running_actor_count(), 0);
4500        assert_eq!(app.editor.lsp.attached_buffer_count(), 0);
4501        assert!(app.editor.buffer_uris.is_empty());
4502    }
4503
4504    #[test]
4505    fn lsp_close_buffer_removes_uri_mapping_for_unattached_buffer() {
4506        let mut app = App::new(Document::from_text(""));
4507        // Seed a fake mapping (as if the attach driver's open
4508        // had landed for a path-bearing buffer).
4509        let fake_uri =
4510            <lattice_lsp::Uri as std::str::FromStr>::from_str("file:///tmp/x.rs").unwrap();
4511        app.editor
4512            .buffer_uris
4513            .insert(app.editor.document_buffer_id, fake_uri);
4514        // Slice 3c.final.E.5d: `buffer_uri()` reads from RS;
4515        // direct-field mutations in tests must publish.
4516        app.editor.publish_render_state();
4517        assert!(app.buffer_uri(app.editor.document_buffer_id).is_some());
4518
4519        app.lsp_close_buffer(app.editor.document_buffer_id);
4520        // `lsp_close_buffer` already publishes RS at the host
4521        // level on the success path, but we re-publish defensively
4522        // in case the closure ordering changes.
4523        app.editor.publish_render_state();
4524        assert!(app.buffer_uri(app.editor.document_buffer_id).is_none());
4525    }
4526
4527    #[test]
4528    fn lsp_close_buffer_is_noop_for_unmapped_id() {
4529        let mut app = App::new(Document::from_text(""));
4530        // No mapping exists; close must not panic.
4531        app.lsp_close_buffer(app.editor.document_buffer_id);
4532        assert!(app.editor.buffer_uris.is_empty());
4533    }
4534
4535    #[test]
4536    fn lsp_log_no_arg_activates_subsystem_buffer_even_with_no_running_servers() {
4537        // Bug #3 fix: `:lsp-log` (no arg) activates `*lsp*`
4538        // directly. Previously the no-arg form routed through the
4539        // running-server picker and errored out when no servers
4540        // were running -- leaving the user on the initial unnamed
4541        // buffer with `[no name]` in the modeline despite `*lsp*`
4542        // existing in the registry. The fix makes the no-arg form
4543        // a direct subsystem-buffer activation.
4544        //
4545        // Picker behaviour moved to `:lsp-server-log` for the
4546        // per-instance pick.
4547        let mut app = app_with("hi\n", 5);
4548        let lsp_buf = app.editor.buffers.by_name("*lsp*").expect("*lsp* at boot");
4549        let initial = app.active_pane_buffer_id();
4550        assert_ne!(initial, lsp_buf);
4551        app.do_open_lsp_log(None);
4552        assert!(app.editor.picker.is_none(), "no picker on no-arg :lsp-log");
4553        assert_eq!(
4554            app.active_pane_buffer_id(),
4555            lsp_buf,
4556            "active pane must switch to *lsp*"
4557        );
4558    }
4559
4560    #[test]
4561    fn lsp_log_with_arg_no_match_echoes_message() {
4562        let mut app = app_with("hi\n", 5);
4563        app.do_open_lsp_log(Some("rust"));
4564        let msg = app.editor.last_message.as_ref().unwrap();
4565        assert!(msg.text.contains("no LSP server"));
4566    }
4567
4568    #[tokio::test(flavor = "multi_thread")]
4569    async fn lsp_log_buffer_refreshes_live_when_record_appended() {
4570        // B'.4: LspServerLogMode owns the per-instance buffer;
4571        // its subscription handles the append via a tokio task,
4572        // so the test sleeps briefly to let the task drain.
4573        let mut app = app_with("hi\n", 5);
4574        let instance = lattice_lsp::InstanceKey::new(
4575            std::sync::Arc::<str>::from("rust"),
4576            std::sync::Arc::<std::path::Path>::from(
4577                std::env::current_dir()
4578                    .unwrap_or_else(|_| std::path::PathBuf::from("/"))
4579                    .as_path(),
4580            ),
4581        );
4582        let _id: std::sync::Arc<str> = std::sync::Arc::clone(&instance.server_id);
4583        app.open_lsp_log_in_pane("rust");
4584        let log_id = app
4585            .editor
4586            .buffers
4587            .by_name(&lattice_lsp::lsp_server_log_name(&instance))
4588            .expect("per-instance log buffer registered");
4589        let body_before = app.editor.buffers.document_handle(log_id).unwrap().text();
4590        assert!(!body_before.contains("fresh-after-open"));
4591        app.editor.lsp_logger.log(
4592            Some(&instance),
4593            lattice_lsp::LogLevel::Info,
4594            lattice_lsp::LogSource::Client,
4595            "fresh-after-open",
4596        );
4597        // Let LspServerLogMode's tokio task drain + apply.
4598        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4599        let body_after = app.editor.buffers.document_handle(log_id).unwrap().text();
4600        assert!(
4601            body_after.contains("fresh-after-open"),
4602            "expected new record visible after drain, got body:\n{body_after}"
4603        );
4604    }
4605
4606    /// 4.4.a: a `window/showMessage` arriving as a
4607    /// `LogSource::LspShowMessage` record fans out through
4608    /// the LspLogPushed typed event; the drain hook surfaces
4609    /// it to the minibuffer with severity matching the LSP
4610    /// level.
4611    #[test]
4612    fn lsp_log_drain_surfaces_show_message_to_minibuffer() {
4613        let mut app = app_with("hi\n", 5);
4614        let instance = lattice_lsp::InstanceKey::new(
4615            std::sync::Arc::<str>::from("rust"),
4616            std::sync::Arc::<std::path::Path>::from(std::path::Path::new("/tmp/test-ws")),
4617        );
4618        let _id: std::sync::Arc<str> = std::sync::Arc::clone(&instance.server_id);
4619        app.editor.lsp_logger.log(
4620            Some(&instance),
4621            lattice_lsp::LogLevel::Warn,
4622            lattice_lsp::LogSource::LspShowMessage,
4623            "indexing complete",
4624        );
4625        app.drain_lsp_log_events();
4626        let msg = app.editor.last_message.as_ref().expect("set_message fired");
4627        assert_eq!(msg.level, EchoLevel::Warn);
4628        assert!(msg.text.contains("indexing complete"), "got `{}`", msg.text);
4629        // Prefix carries the server id so multi-server users
4630        // know which attached server emitted the notification.
4631        assert!(msg.text.contains("[rust]"), "got `{}`", msg.text);
4632    }
4633
4634    /// 4.4.a: only `LspShowMessage`-sourced records hit the
4635    /// minibuffer. Regular `LspMessage` (server `window/logMessage`)
4636    /// records stay in the LSP log buffer and do NOT
4637    /// overwrite the echo area.
4638    #[test]
4639    fn lsp_log_drain_does_not_surface_log_message_to_minibuffer() {
4640        let mut app = app_with("hi\n", 5);
4641        let instance = lattice_lsp::InstanceKey::new(
4642            std::sync::Arc::<str>::from("rust"),
4643            std::sync::Arc::<std::path::Path>::from(std::path::Path::new("/tmp/test-ws")),
4644        );
4645        let _id: std::sync::Arc<str> = std::sync::Arc::clone(&instance.server_id);
4646        // Capture initial message to compare after drain.
4647        let before = app.editor.last_message.clone();
4648        app.editor.lsp_logger.log(
4649            Some(&instance),
4650            lattice_lsp::LogLevel::Info,
4651            lattice_lsp::LogSource::LspMessage,
4652            "internal log thing",
4653        );
4654        app.drain_lsp_log_events();
4655        assert_eq!(
4656            app.editor.last_message, before,
4657            "logMessage should NOT touch the echo area"
4658        );
4659    }
4660
4661    /// 4.4.o: `lsp.log_level` flips the logger's default
4662    /// min level at boot. Records below the configured
4663    /// level get dropped before the ring sees them.
4664    #[test]
4665    fn lsp_log_level_typed_option_seeds_boot_level() {
4666        let app = app_with("hi\n", 5);
4667        // Default is "info"; debug records should be filtered
4668        // when no per-server override is in place.
4669        let instance = lattice_lsp::InstanceKey::new(
4670            std::sync::Arc::<str>::from("rust"),
4671            std::sync::Arc::<std::path::Path>::from(std::path::Path::new("/tmp/test-ws")),
4672        );
4673        let _id: std::sync::Arc<str> = std::sync::Arc::clone(&instance.server_id);
4674        app.editor.lsp_logger.log(
4675            Some(&instance),
4676            lattice_lsp::LogLevel::Debug,
4677            lattice_lsp::LogSource::Client,
4678            "should-be-filtered",
4679        );
4680        let records = app.editor.lsp_logger.snapshot_instance(&instance);
4681        assert!(
4682            !records
4683                .iter()
4684                .any(|r| r.message.contains("should-be-filtered")),
4685            "Debug record should be filtered by the default Info level seed"
4686        );
4687        // After setting to "debug" at runtime, the same
4688        // record passes.
4689        app.editor
4690            .config
4691            .parse_and_set_command("lsp.log_level=debug")
4692            .unwrap();
4693        // The runtime path:
4694        app.editor
4695            .lsp_logger
4696            .set_default_level(lattice_lsp::LogLevel::Debug);
4697        app.editor.lsp_logger.log(
4698            Some(&instance),
4699            lattice_lsp::LogLevel::Debug,
4700            lattice_lsp::LogSource::Client,
4701            "should-pass-now",
4702        );
4703        let records = app.editor.lsp_logger.snapshot_instance(&instance);
4704        assert!(
4705            records
4706                .iter()
4707                .any(|r| r.message.contains("should-pass-now")),
4708            "Debug record should land after raising the level"
4709        );
4710    }
4711
4712    /// 4.4.a: telemetry/event records ride the existing log
4713    /// path with the new `LogSource::Telemetry` tag so
4714    /// plugin subscribers can filter without parsing message
4715    /// text.
4716    #[test]
4717    fn lsp_log_drain_telemetry_uses_distinct_source_tag() {
4718        // We can't directly assert the typed-event payload
4719        // here (the bus delivers to subscriber channels
4720        // outside this method's surface), but we can confirm
4721        // the `tag()` rendering matches expectations.
4722        assert_eq!(lattice_lsp::LogSource::Telemetry.tag(), "telemetry");
4723    }
4724
4725    #[test]
4726    fn lsp_log_drain_is_noop_when_no_log_buffer_open() {
4727        // Pushing log records with no log buffer open should not
4728        // crash or echo anything; the drain just consumes events
4729        // and finds no matching titles.
4730        let mut app = app_with("hi\n", 5);
4731        let instance = lattice_lsp::InstanceKey::new(
4732            std::sync::Arc::<str>::from("rust"),
4733            std::sync::Arc::<std::path::Path>::from(std::path::Path::new("/tmp/test-ws")),
4734        );
4735        let _id: std::sync::Arc<str> = std::sync::Arc::clone(&instance.server_id);
4736        app.editor.lsp_logger.log(
4737            Some(&instance),
4738            lattice_lsp::LogLevel::Info,
4739            lattice_lsp::LogSource::Client,
4740            "no-target",
4741        );
4742        app.drain_lsp_log_events();
4743        // No help buffers should have appeared.
4744        assert!(app.editor.buffers.help_with_title("lsp:rust").is_none());
4745        assert!(app.editor.buffers.help_with_title("lsp").is_none());
4746    }
4747
4748    #[tokio::test(flavor = "multi_thread")]
4749    async fn lsp_trace_buffer_refreshes_live_when_trace_record_appended() {
4750        // B'.5: LspTraceLogMode owns the trace buffer; its
4751        // subscription appends asynchronously, so the test
4752        // sleeps to let the spawned task drain.
4753        let mut app = app_with("hi\n", 5);
4754        let instance = lattice_lsp::InstanceKey::new(
4755            std::sync::Arc::<str>::from("rust"),
4756            std::sync::Arc::<std::path::Path>::from(
4757                std::env::current_dir()
4758                    .unwrap_or_else(|_| std::path::PathBuf::from("/"))
4759                    .as_path(),
4760            ),
4761        );
4762        let _id: std::sync::Arc<str> = std::sync::Arc::clone(&instance.server_id);
4763        app.editor.lsp_logger.enable_trace(instance.clone());
4764        app.open_lsp_trace_log_in_pane("rust");
4765        let trace_id = app
4766            .editor
4767            .buffers
4768            .by_name(&lattice_lsp::lsp_server_trace_log_name(&instance))
4769            .expect("trace buffer registered");
4770        let before = app.editor.buffers.document_handle(trace_id).unwrap().text();
4771        assert!(!before.contains("→ NEW"));
4772        app.editor.lsp_logger.log(
4773            Some(&instance),
4774            lattice_lsp::LogLevel::Trace,
4775            lattice_lsp::LogSource::Trace,
4776            "→ NEW request id=42",
4777        );
4778        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4779        let after = app.editor.buffers.document_handle(trace_id).unwrap().text();
4780        assert!(after.contains("→ NEW"));
4781    }
4782
4783    #[tokio::test(flavor = "multi_thread")]
4784    async fn lsp_log_burst_coalesces_into_one_refresh() {
4785        // Slice B / B'.4: 50 records published → LspServerLogMode's
4786        // tokio task coalesces them into one apply_edit_batch.
4787        let mut app = app_with("hi\n", 5);
4788        let instance = lattice_lsp::InstanceKey::new(
4789            std::sync::Arc::<str>::from("rust"),
4790            std::sync::Arc::<std::path::Path>::from(
4791                std::env::current_dir()
4792                    .unwrap_or_else(|_| std::path::PathBuf::from("/"))
4793                    .as_path(),
4794            ),
4795        );
4796        let _id: std::sync::Arc<str> = std::sync::Arc::clone(&instance.server_id);
4797        app.open_lsp_log_in_pane("rust");
4798        for i in 0..50 {
4799            app.editor.lsp_logger.log(
4800                Some(&instance),
4801                lattice_lsp::LogLevel::Info,
4802                lattice_lsp::LogSource::Client,
4803                format!("msg-{i}"),
4804            );
4805        }
4806        tokio::time::sleep(std::time::Duration::from_millis(80)).await;
4807        let log_id = app
4808            .editor
4809            .buffers
4810            .by_name(&lattice_lsp::lsp_server_log_name(&instance))
4811            .expect("per-instance log buffer registered");
4812        let body = app.editor.buffers.document_handle(log_id).unwrap().text();
4813        // First and last pushed records both visible.
4814        assert!(body.contains("msg-0"));
4815        assert!(body.contains("msg-49"));
4816    }
4817
4818    #[test]
4819    fn lsp_trace_toggle_flips_state_without_opening_buffer() {
4820        let mut app = app_with("hi\n", 5);
4821        // B'.2: with no running actor, do_toggle_lsp_trace
4822        // synthesises an instance against cwd. Match that here.
4823        let instance = lattice_lsp::InstanceKey::new(
4824            std::sync::Arc::<str>::from("rust"),
4825            std::sync::Arc::<std::path::Path>::from(
4826                std::env::current_dir()
4827                    .unwrap_or_else(|_| std::path::PathBuf::from("/"))
4828                    .as_path(),
4829            ),
4830        );
4831        let _id: std::sync::Arc<str> = std::sync::Arc::clone(&instance.server_id);
4832        // Off -> on.
4833        app.do_toggle_lsp_trace("rust");
4834        assert!(app.editor.lsp_logger.is_tracing(&instance));
4835        // Pure toggle now -- the trace buffer is opened separately
4836        // via :lsp-trace-log so peeking doesn't flip the toggle off.
4837        assert!(app.editor.popup_buffer.is_none());
4838        let msg = app.editor.last_message.as_ref().unwrap();
4839        assert!(msg.text.contains("on"));
4840        assert!(msg.text.contains(":lsp-trace-log"));
4841        // On -> off.
4842        app.do_toggle_lsp_trace("rust");
4843        assert!(!app.editor.lsp_logger.is_tracing(&instance));
4844        assert!(app.editor.popup_buffer.is_none());
4845    }
4846
4847    #[test]
4848    fn lsp_trace_resolves_binary_name_to_canonical_id() {
4849        // `:lsp-trace rust-analyzer` should resolve to the `rust`
4850        // config id (the registered binary file_name match) and
4851        // toggle the trace flag on `rust`, NOT a phantom
4852        // `rust-analyzer` id that nothing else looks at.
4853        let mut app = app_with("hi\n", 5);
4854        let ws: std::sync::Arc<std::path::Path> =
4855            std::sync::Arc::from(std::path::Path::new("/tmp/test-ws"));
4856        let canonical = lattice_lsp::InstanceKey::new(
4857            std::sync::Arc::<str>::from("rust"),
4858            std::sync::Arc::clone(&ws),
4859        );
4860        let phantom = lattice_lsp::InstanceKey::new(
4861            std::sync::Arc::<str>::from("rust-analyzer"),
4862            std::sync::Arc::clone(&ws),
4863        );
4864        app.do_toggle_lsp_trace("rust-analyzer");
4865        // Note: without a running actor, the toggle currently
4866        // resolves no instances; this test asserts the resolution
4867        // *would* target `rust`, not the phantom. Both are off
4868        // because there's no actor to attach to.
4869        assert!(!app.editor.lsp_logger.is_tracing(&canonical));
4870        assert!(!app.editor.lsp_logger.is_tracing(&phantom));
4871        let msg = app.editor.last_message.as_ref().unwrap();
4872        assert!(msg.text.contains("resolved"));
4873    }
4874
4875    #[test]
4876    fn lsp_trace_unknown_name_echoes_error_with_running_servers() {
4877        let mut app = app_with("hi\n", 5);
4878        app.do_toggle_lsp_trace("totally-fake-server-name");
4879        let msg = app.editor.last_message.as_ref().unwrap();
4880        assert!(matches!(msg.level, EchoLevel::Error));
4881        assert!(msg.text.contains("totally-fake-server-name"));
4882    }
4883
4884    // ML.3c: `lsp_progress_drain_accumulates_lifecycle` retired with the
4885    // host accumulator. The Begin/Report/End fold is now covered by
4886    // `lattice_lsp::modeline::tests::store_folds_progress_and_snapshots`.
4887
4888    #[test]
4889    fn lsp_status_with_no_servers_renders_placeholder() {
4890        let mut app = app_with("hi\n", 5);
4891        app.do_lsp_status();
4892        let body = app.popup_help().unwrap().content.as_string();
4893        assert!(body.contains("0 server"));
4894        assert!(body.contains("no LSP servers running"));
4895    }
4896
4897    #[test]
4898    fn lsp_log_level_subsystem_wide_accepts_known_levels() {
4899        let mut app = app_with("hi\n", 5);
4900        for lvl in ["error", "warn", "info", "debug", "trace"] {
4901            app.do_set_lsp_log_level(None, lvl);
4902            let msg = app.editor.last_message.as_ref().unwrap();
4903            assert!(
4904                msg.text.contains(lvl),
4905                "echo should mention {lvl}, got {}",
4906                msg.text
4907            );
4908        }
4909    }
4910
4911    #[test]
4912    fn lsp_log_level_rejects_unknown_level() {
4913        let mut app = app_with("hi\n", 5);
4914        app.do_set_lsp_log_level(None, "babble");
4915        let msg = app.editor.last_message.as_ref().unwrap();
4916        assert!(msg.text.contains("unknown log level"));
4917    }
4918
4919    #[test]
4920    fn lsp_log_level_per_server_override() {
4921        let mut app = app_with("hi\n", 5);
4922        app.do_set_lsp_log_level(Some("rust"), "debug");
4923        // B'.2: with no running actor, `do_set_lsp_log_level`
4924        // applies the override to a synthetic instance at cwd.
4925        // Match that here so the Debug record's level filter
4926        // sees the override.
4927        let instance = lattice_lsp::InstanceKey::new(
4928            std::sync::Arc::<str>::from("rust"),
4929            std::sync::Arc::<std::path::Path>::from(
4930                std::env::current_dir()
4931                    .unwrap_or_else(|_| std::path::PathBuf::from("/"))
4932                    .as_path(),
4933            ),
4934        );
4935        app.editor.lsp_logger.log(
4936            Some(&instance),
4937            lattice_lsp::LogLevel::Debug,
4938            lattice_lsp::LogSource::Client,
4939            "debug event",
4940        );
4941        let recs = app.editor.lsp_logger.snapshot_instance(&instance);
4942        assert!(recs.iter().any(|r| r.message == "debug event"));
4943    }
4944
4945    #[test]
4946    fn lsp_log_clear_drops_global_records() {
4947        let mut app = app_with("hi\n", 5);
4948        app.editor.lsp_logger.log(
4949            None,
4950            lattice_lsp::LogLevel::Info,
4951            lattice_lsp::LogSource::Client,
4952            "x",
4953        );
4954        assert_eq!(app.editor.lsp_logger.snapshot_global().len(), 1);
4955        app.do_lsp_log_clear(None);
4956        assert_eq!(app.editor.lsp_logger.snapshot_global().len(), 0);
4957    }
4958
4959    #[test]
4960    fn lsp_log_clear_drops_per_server_records() {
4961        let mut app = app_with("hi\n", 5);
4962        let instance = lattice_lsp::InstanceKey::new(
4963            std::sync::Arc::<str>::from("rust"),
4964            std::sync::Arc::<std::path::Path>::from(std::path::Path::new("/tmp/test-ws")),
4965        );
4966        let _id: std::sync::Arc<str> = std::sync::Arc::clone(&instance.server_id);
4967        app.editor.lsp_logger.log(
4968            Some(&instance),
4969            lattice_lsp::LogLevel::Info,
4970            lattice_lsp::LogSource::Client,
4971            "x",
4972        );
4973        assert_eq!(app.editor.lsp_logger.snapshot_instance(&instance).len(), 1);
4974        app.do_lsp_log_clear(Some("rust"));
4975        assert_eq!(app.editor.lsp_logger.snapshot_instance(&instance).len(), 0);
4976    }
4977
4978    // BC.8c: the two show-document TUI drain tests
4979    // (`show_document_file_uri_opens_buffer_and_replies_success`,
4980    // `show_document_refuses_non_file_uri_without_external`) moved to
4981    // `lattice-lsp/src/show_document.rs` handler tests, their new home now
4982    // that the bus is the generic inbound primitive (mode-owned handler). The
4983    // host-applied open path (do_edit + UTF-16 cursor on the async tick) is
4984    // pinned by `lattice-host`'s `show_document_*` integration tests.
4985
4986    /// Helper: inject a single inbound showMessageRequest into
4987    /// the App's drain receiver and run the drain. Returns the
4988    /// response receiver so the test can assert on the reply.
4989    fn inject_show_message_request(app: &mut App, req: lattice_lsp::InboundShowMessageRequest) {
4990        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
4991        tx.send(req).unwrap();
4992        app.editor.pending_show_message_request_rx = Some(rx);
4993        app.drain_inbound_show_message_requests();
4994    }
4995
4996    fn make_smr(
4997        server_id: &std::sync::Arc<str>,
4998        message: &str,
4999        actions: Vec<&str>,
5000    ) -> (
5001        lattice_lsp::InboundShowMessageRequest,
5002        tokio::sync::oneshot::Receiver<lattice_lsp::ShowMessageRequestOutcome>,
5003    ) {
5004        let (response_tx, response_rx) = tokio::sync::oneshot::channel();
5005        let req = lattice_lsp::InboundShowMessageRequest {
5006            server_id: server_id.clone(),
5007            workspace: std::sync::Arc::<std::path::Path>::from(std::path::Path::new("/tmp")),
5008            level: lattice_lsp::lsp_types::MessageType::INFO,
5009            message: message.into(),
5010            actions: actions
5011                .into_iter()
5012                .map(|t| lattice_lsp::lsp_types::MessageActionItem {
5013                    title: t.into(),
5014                    properties: Default::default(),
5015                })
5016                .collect(),
5017            response: response_tx,
5018        };
5019        (req, response_rx)
5020    }
5021
5022    /// 4.4.b: actionless showMessageRequest auto-replies with
5023    /// `null` (spec-compliant; no picker, the prompt is purely
5024    /// informational), surfaces on the minibuffer, and logs.
5025    #[test]
5026    fn show_message_request_actionless_auto_dismisses() {
5027        let mut app = app_with("hi\n", 5);
5028        let server_id: std::sync::Arc<str> = std::sync::Arc::from("rust");
5029        let (req, mut response_rx) = make_smr(&server_id, "Heads up!", Vec::new());
5030        inject_show_message_request(&mut app, req);
5031        let outcome = response_rx.try_recv().expect("reply landed");
5032        assert!(
5033            outcome.selected.is_none(),
5034            "actionless prompt should auto-dismiss",
5035        );
5036        assert!(
5037            app.editor.picker.is_none(),
5038            "no picker for actionless prompt"
5039        );
5040        let msg = app.editor.last_message.as_ref().expect("minibuffer set");
5041        assert!(msg.text.contains("Heads up!"));
5042        let records = app
5043            .editor
5044            .lsp_logger
5045            .snapshot_instance(&lattice_lsp::InstanceKey::new(
5046                std::sync::Arc::clone(&server_id),
5047                std::sync::Arc::<std::path::Path>::from(std::path::Path::new("/tmp")),
5048            ));
5049        assert!(
5050            records
5051                .iter()
5052                .any(|r| r.message.contains("showMessageRequest"))
5053        );
5054    }
5055
5056    /// 4.4.b: actionful prompt opens a picker; accepting a row
5057    /// replies with the matching `MessageActionItem`.
5058    #[test]
5059    fn show_message_request_accept_replies_with_selected_action() {
5060        let mut app = app_with("hi\n", 5);
5061        let server_id: std::sync::Arc<str> = std::sync::Arc::from("rust");
5062        let (req, mut response_rx) = make_smr(&server_id, "Reload workspace?", vec!["Yes", "No"]);
5063        inject_show_message_request(&mut app, req);
5064        // Picker opened; pending slot registered.
5065        assert!(
5066            app.editor.picker.is_some(),
5067            "picker should open for actionful prompt"
5068        );
5069        assert_eq!(app.editor.lsp_pending_show_message_requests.len(), 1);
5070        // Move the cursor to the second action ("No") and
5071        // accept. PickerNext is the canonical down-arrow
5072        // action.
5073        app.apply(crate::Action::PickerSelectNext);
5074        app.apply(crate::Action::PickerAccept);
5075        let outcome = response_rx.try_recv().expect("reply landed");
5076        let selected = outcome.selected.expect("an action was selected");
5077        assert_eq!(selected.title, "No");
5078        assert!(app.editor.picker.is_none(), "picker closed after accept");
5079        assert!(app.editor.lsp_pending_show_message_requests.is_empty());
5080    }
5081
5082    /// 4.4.b: dismissing the picker replies `null`. The pending
5083    /// slot is cleared and no further state lingers.
5084    #[test]
5085    fn show_message_request_dismiss_replies_null() {
5086        let mut app = app_with("hi\n", 5);
5087        let server_id: std::sync::Arc<str> = std::sync::Arc::from("rust");
5088        let (req, mut response_rx) = make_smr(&server_id, "Reload workspace?", vec!["Yes", "No"]);
5089        inject_show_message_request(&mut app, req);
5090        assert!(app.editor.picker.is_some());
5091        app.apply(crate::Action::PickerDismiss);
5092        let outcome = response_rx.try_recv().expect("reply landed");
5093        assert!(outcome.selected.is_none(), "dismiss should reply null",);
5094        assert!(app.editor.picker.is_none());
5095        assert!(app.editor.lsp_pending_show_message_requests.is_empty());
5096    }
5097
5098    /// 4.4.b: two requests in one tick -- the picker opens the
5099    /// first, the second waits in the queue; after dismiss, the
5100    /// queued one opens automatically.
5101    #[test]
5102    fn show_message_request_queues_when_picker_already_open() {
5103        let mut app = app_with("hi\n", 5);
5104        let server_id: std::sync::Arc<str> = std::sync::Arc::from("rust");
5105        let (req1, mut rx1) = make_smr(&server_id, "First?", vec!["A", "B"]);
5106        let (req2, mut rx2) = make_smr(&server_id, "Second?", vec!["X", "Y"]);
5107        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
5108        tx.send(req1).unwrap();
5109        tx.send(req2).unwrap();
5110        app.editor.pending_show_message_request_rx = Some(rx);
5111        app.drain_inbound_show_message_requests();
5112        // First request: picker open. Second: queued.
5113        assert!(app.editor.picker.is_some());
5114        assert_eq!(app.editor.lsp_show_message_request_queue.len(), 1);
5115        assert_eq!(app.editor.lsp_pending_show_message_requests.len(), 2);
5116        // Dismiss the first; the second picker should open
5117        // immediately on the same tick. Verify by asserting
5118        // the picker title is the second request's prompt.
5119        app.apply(crate::Action::PickerDismiss);
5120        let outcome1 = rx1.try_recv().expect("first reply landed");
5121        assert!(outcome1.selected.is_none());
5122        assert!(
5123            app.editor.picker.is_some(),
5124            "queued picker should auto-open after dismiss",
5125        );
5126        let title = app.editor.picker.as_ref().unwrap().title.clone();
5127        assert!(title.contains("Second?"));
5128        // Accept the second.
5129        app.apply(crate::Action::PickerAccept);
5130        let outcome2 = rx2.try_recv().expect("second reply landed");
5131        let selected = outcome2.selected.expect("action picked");
5132        assert_eq!(selected.title, "X");
5133        assert!(app.editor.lsp_pending_show_message_requests.is_empty());
5134        assert!(app.editor.lsp_show_message_request_queue.is_empty());
5135    }
5136
5137    #[test]
5138    fn lsp_restart_queues_via_supervisor_mailbox() {
5139        // 4.4.d: the placeholder echo path is gone; the real
5140        // dispatcher posts a `Restart` cmd onto the supervisor
5141        // mailbox and echoes "queued" while the async work
5142        // unfolds. The supervisor's response (success/error,
5143        // including the backoff cooldown) lands in the *lsp*
5144        // log via `LspLogger::log`, which is asserted in the
5145        // supervisor's own tests.
5146        let mut app = app_with("hi\n", 5);
5147        app.do_lsp_restart("rust");
5148        let msg = app.editor.last_message.as_ref().unwrap();
5149        assert!(
5150            msg.text.contains("queued"),
5151            "expected immediate `queued` echo; got `{}`",
5152            msg.text,
5153        );
5154        assert!(matches!(msg.level, EchoLevel::Info));
5155    }
5156
5157    fn app_with_path(text: &str, viewport: u32, path: std::path::PathBuf) -> App {
5158        let doc = lattice_core::DocumentBuilder::default()
5159            .with_text(text)
5160            .with_path(path)
5161            .build();
5162        let mut a = App::new(doc);
5163        a.set_viewport_height(viewport);
5164        a
5165    }
5166
5167    fn inject_inbound_apply_edit(a: &mut App, inbound: lattice_lsp::InboundApplyEdit) {
5168        // BC.8d: `ApplyEditBus` is now the generic `InboundBus` (no `::new()`).
5169        // The host-side drain (`Editor::drain_inbound_apply_edits`) reads the
5170        // `pending_apply_edit_rx` receiver, so the test seeds it directly.
5171        let (tx, new_rx) = tokio::sync::mpsc::unbounded_channel();
5172        tx.send(inbound).expect("send to fresh receiver");
5173        a.editor.pending_apply_edit_rx = Some(new_rx);
5174    }
5175
5176    #[test]
5177    fn drain_inbound_apply_edits_applies_active_buffer_edit() {
5178        // Synthesise an inbound `workspace/applyEdit` against
5179        // the active buffer. Drain should apply the edit and
5180        // signal `applied: true` on the oneshot.
5181        let dir =
5182            std::env::temp_dir().join(format!("lattice-applyedit-test-{}", std::process::id(),));
5183        let _ = std::fs::remove_dir_all(&dir);
5184        std::fs::create_dir_all(&dir).unwrap();
5185        let path = dir.join("buffer.rs");
5186        std::fs::write(&path, "fn main() {}\n").unwrap();
5187        let mut a = app_with_path("fn main() {}\n", 5, path.clone());
5188        let uri: lattice_lsp::lsp_types::Uri = lattice_lsp::actor::uri_from_path(&path);
5189        // Edit replaces `main` (line 0, char 3..7) with `xyz`.
5190        let edit = lattice_lsp::lsp_types::TextEdit {
5191            range: lattice_lsp::lsp_types::Range {
5192                start: lattice_lsp::lsp_types::Position {
5193                    line: 0,
5194                    character: 3,
5195                },
5196                end: lattice_lsp::lsp_types::Position {
5197                    line: 0,
5198                    character: 7,
5199                },
5200            },
5201            new_text: "xyz".into(),
5202        };
5203        let mut changes = std::collections::HashMap::new();
5204        changes.insert(uri, vec![edit]);
5205        let workspace_edit = lattice_lsp::lsp_types::WorkspaceEdit {
5206            changes: Some(changes),
5207            document_changes: None,
5208            change_annotations: None,
5209        };
5210        let (resp_tx, mut resp_rx) = tokio::sync::oneshot::channel();
5211        inject_inbound_apply_edit(
5212            &mut a,
5213            lattice_lsp::InboundApplyEdit {
5214                server_id: std::sync::Arc::from("test-server"),
5215                workspace: std::sync::Arc::<std::path::Path>::from(std::path::Path::new("/tmp")),
5216                label: Some("rename main".into()),
5217                edit: workspace_edit,
5218                response: resp_tx,
5219            },
5220        );
5221        a.drain_inbound_apply_edits();
5222        // Drain ran synchronously; the oneshot is already
5223        // populated -- `try_recv` returns Ok.
5224        let outcome = resp_rx.try_recv().expect("drain replied via oneshot");
5225        assert!(
5226            outcome.applied,
5227            "edit applied: {:?}",
5228            outcome.failure_reason,
5229        );
5230        let after = a.editor.document.snapshot().buffer.as_string();
5231        assert_eq!(after, "fn xyz() {}\n");
5232    }
5233
5234    #[test]
5235    fn drain_inbound_apply_edits_empty_workspace_edit_replies_applied_true() {
5236        // An empty WorkspaceEdit (no changes, no
5237        // document_changes) is a server no-op. Spec: reply
5238        // applied=true so the server doesn't think we
5239        // failed -- just nothing to do.
5240        let mut a = app_with("", 5);
5241        let workspace_edit = lattice_lsp::lsp_types::WorkspaceEdit::default();
5242        let (resp_tx, mut resp_rx) = tokio::sync::oneshot::channel();
5243        inject_inbound_apply_edit(
5244            &mut a,
5245            lattice_lsp::InboundApplyEdit {
5246                server_id: std::sync::Arc::from("test-server"),
5247                workspace: std::sync::Arc::<std::path::Path>::from(std::path::Path::new("/tmp")),
5248                label: None,
5249                edit: workspace_edit,
5250                response: resp_tx,
5251            },
5252        );
5253        a.drain_inbound_apply_edits();
5254        let outcome = resp_rx.try_recv().expect("drain replied");
5255        assert!(outcome.applied);
5256        assert_eq!(
5257            outcome.failure_reason.as_deref(),
5258            Some("empty workspace edit"),
5259        );
5260    }
5261
5262    // BC.8b: the four `drain_inbound_configuration_*` TUI tests were removed.
5263    // The `workspace/configuration` flow is now the mode-owned
5264    // `lattice_lsp::configuration::make_handler` (a pure read → reply over the
5265    // shared `lsp.*` tree), drained via the generic inbound tick-callback — no
5266    // host/TUI drain method to exercise. The section-resolution behaviour they
5267    // covered (prefix walk, null-for-missing, empty-section → whole subtree) is
5268    // tested at its new home in `lattice-lsp/src/configuration.rs`.
5269
5270    #[test]
5271    fn drain_inbound_apply_edits_no_op_when_channel_empty() {
5272        // Idle drain: no requests, no outgoing oneshots, no
5273        // panic. Cheap path that runs every frame.
5274        let mut a = app_with("", 5);
5275        a.drain_inbound_apply_edits();
5276        // Receiver is restored after the drain (the take + put-back).
5277        assert!(a.editor.pending_apply_edit_rx.is_some());
5278    }
5279
5280    #[test]
5281    fn lsp_snippet_with_additional_edits_lands_as_one_undo_unit() {
5282        // Buffer has space for the auto-import on line 0 and
5283        // the snippet expansion on line 2. The accept path
5284        // applies BOTH edits in a single batch; one Ctrl-Z
5285        // reverts both.
5286        let mut a = app_with("\n\nfor", 10);
5287        a.editor.modal = ModalState::Insert;
5288        a.editor.cursor = Position::new(2, 3);
5289        // Manually install the popup state: one candidate
5290        // with snippet `insertTextFormat`, an auto-import
5291        // additionalTextEdit at line 0, and a snippet body
5292        // that splices `[anchor, cursor]`.
5293        let mut state = lattice_completion::InsertCompletionState::open(
5294            lattice_completion::CompletionTrigger::Manual,
5295            Position::new(2, 0),
5296            Position::new(2, 3),
5297            "for".into(),
5298        );
5299        let meta = LspCompletionMeta {
5300            label: "for-loop".into(),
5301            // Snippet body with one tabstop -- expand_snippet_with_lsp_edits
5302            // sets up the active snippet, focuses $1.
5303            insert_text: "for ${1:i} in iter {}".into(),
5304            filter_text: None,
5305            sort_text: None,
5306            detail: None,
5307            documentation: None,
5308            kind: Some(lattice_lsp::lsp_types::CompletionItemKind::SNIPPET),
5309            deprecated: false,
5310            preselect: false,
5311            commit_characters: Vec::new(),
5312            additional_text_edits: vec![lattice_lsp::lsp_types::TextEdit {
5313                range: lattice_lsp::lsp_types::Range {
5314                    start: lattice_lsp::lsp_types::Position {
5315                        line: 0,
5316                        character: 0,
5317                    },
5318                    end: lattice_lsp::lsp_types::Position {
5319                        line: 0,
5320                        character: 0,
5321                    },
5322                },
5323                new_text: "use std::iter;\n".into(),
5324            }],
5325            command: None,
5326            insert_text_format: lattice_lsp::lsp_types::InsertTextFormat::SNIPPET,
5327            replace_range: None,
5328            server_id: "test-server".to_string(),
5329            original_item: lattice_lsp::lsp_types::CompletionItem::default(),
5330            resolved: true,
5331        };
5332        let mut raw = lattice_completion::RawCandidate::plain(
5333            "for",
5334            lattice_completion::CandidateKind::Plain,
5335        )
5336        .with_source(lattice_completion::SourceId::new(
5337            lattice_completion::LSP_COMPLETION_SOURCE_ID,
5338        ));
5339        raw.data = lattice_completion::CandidateData::Extension {
5340            kind_id: LSP_COMPLETION_KIND_ID,
5341            payload: lattice_lsp::completion::encode_meta(&meta),
5342        };
5343        state.raw.push(raw.clone());
5344        state
5345            .rendered
5346            .push(lattice_completion::RenderedCandidate::from_scored(
5347                lattice_completion::ScoredCandidate {
5348                    raw,
5349                    score: lattice_completion::MatchScore(100),
5350                    match_ranges: Vec::new(),
5351                },
5352            ));
5353        // CSM.8b.5: meta lives in candidate payload already.
5354        let _ = meta;
5355        a.editor.insert_completion = Some(state);
5356        a.do_completion_accept();
5357        // After accept: line 0 has the auto-import, line 2
5358        // (now line 3 after the import inserted a newline,
5359        // wait -- the import is `use std::iter;\n` which adds
5360        // an extra newline; existing line 0 was empty so the
5361        // buffer is now: line 0 = "use std::iter;", line 1 = "",
5362        // line 2 = "", line 3 = "for i in iter {}").
5363        let after_accept = a.editor.document.snapshot().buffer.as_string();
5364        assert!(
5365            after_accept.contains("use std::iter;"),
5366            "auto-import applied: `{after_accept}`"
5367        );
5368        assert!(
5369            after_accept.contains("for i in iter {}"),
5370            "snippet expanded: `{after_accept}`"
5371        );
5372        // Active snippet focused on $1 ("i").
5373        assert!(
5374            a.editor
5375                .snippet_session
5376                .is_active(a.editor.document_buffer_id),
5377            "active snippet started"
5378        );
5379        // Undo ONCE -> both the auto-import AND the snippet
5380        // expansion revert.
5381        a.undo_blocking().expect("undo");
5382        let after_undo = a.editor.document.snapshot().buffer.as_string();
5383        assert_eq!(
5384            after_undo, "\n\nfor",
5385            "single undo reverted both auto-import and snippet (`{after_undo}`)",
5386        );
5387    }
5388
5389    #[test]
5390    fn next_diagnostic_advances_cursor() {
5391        let mut app = app_with("a\nb\nc\nd\ne\n", 10);
5392        seed_diags_at_lines(&mut app, &[1, 3]);
5393        app.editor.cursor = Position::new(0, 0);
5394        app.do_next_diagnostic();
5395        assert_eq!(app.editor.cursor, Position::new(1, 0));
5396        app.do_next_diagnostic();
5397        assert_eq!(app.editor.cursor, Position::new(3, 0));
5398        // Past the last -> wraps to the first.
5399        app.do_next_diagnostic();
5400        assert_eq!(app.editor.cursor, Position::new(1, 0));
5401    }
5402
5403    #[test]
5404    fn prev_diagnostic_walks_backward() {
5405        let mut app = app_with("a\nb\nc\nd\ne\n", 10);
5406        seed_diags_at_lines(&mut app, &[1, 3]);
5407        app.editor.cursor = Position::new(4, 0);
5408        app.do_prev_diagnostic();
5409        assert_eq!(app.editor.cursor, Position::new(3, 0));
5410        app.do_prev_diagnostic();
5411        assert_eq!(app.editor.cursor, Position::new(1, 0));
5412        // Past the first -> wraps to the last.
5413        app.do_prev_diagnostic();
5414        assert_eq!(app.editor.cursor, Position::new(3, 0));
5415    }
5416
5417    /// L4b: landing on a diagnostic echoes its message (shared by `]d`
5418    /// / `:diag-next` / `:cnext`).
5419    #[test]
5420    fn next_diagnostic_echoes_landed_message() {
5421        let mut app = app_with("a\nb\nc\nd\ne\n", 10);
5422        seed_diags_at_lines(&mut app, &[1, 3]);
5423        app.editor.cursor = Position::new(0, 0);
5424        app.do_next_diagnostic();
5425        assert_eq!(app.editor.cursor, Position::new(1, 0));
5426        let msg = app
5427            .editor
5428            .last_message
5429            .as_ref()
5430            .expect("jump should echo the landed diagnostic");
5431        assert!(msg.text.contains("err on line 1"), "got: {}", msg.text);
5432    }
5433
5434    #[test]
5435    fn next_diagnostic_with_no_attachment_echoes_error() {
5436        let mut app = app_with("hi\n", 5);
5437        // M.6.3: gate on lsp-diagnostics-mode runs before the
5438        // URI check; activate lsp-mode so the cascade brings
5439        // diagnostics-mode up, then the no-URI branch is what
5440        // we exercise.
5441        app.toggle_mode_by_name("lsp-mode");
5442        // No buffer_uris mapping -> "no LSP attachment".
5443        app.do_next_diagnostic();
5444        let msg = app.editor.last_message.as_ref().expect("expected echo");
5445        assert!(msg.text.contains("no LSP attachment"), "got: {}", msg.text);
5446    }
5447
5448    #[test]
5449    fn next_diagnostic_with_no_diagnostics_echoes_info() {
5450        let mut app = app_with("hi\n", 5);
5451        // Seed an empty layer mapping + activate lsp-mode (cascade
5452        // activates lsp-diagnostics-mode).
5453        use std::str::FromStr;
5454        let uri = lattice_lsp::Uri::from_str("file:///tmp/empty.rs").unwrap();
5455        app.editor
5456            .buffer_uris
5457            .insert(app.editor.document_buffer_id, uri);
5458        app.toggle_mode_by_name("lsp-mode");
5459        app.do_next_diagnostic();
5460        let msg = app.editor.last_message.as_ref().expect("expected echo");
5461        assert!(msg.text.contains("no diagnostics"), "got: {}", msg.text);
5462    }
5463
5464    #[test]
5465    fn m6_end_to_end_independent_sub_modes_per_feature() {
5466        // M.6.4: full contract exercised end-to-end.
5467        // 1. `:lsp-mode` cascade-on activates all 9 sub-modes.
5468        // 2. Disable one sub-mode (`lsp-format-mode`); other
5469        //    features still fire (or echo their own sub-mode
5470        //    name on bail).
5471        // 3. Re-enable; everything works again.
5472        let mut a = app_with("xx", 10);
5473        a.toggle_mode_by_name("lsp-mode");
5474        assert!(a.lsp_format_mode_enabled_for(a.editor.document_buffer_id));
5475        assert!(a.lsp_hover_mode_enabled_for(a.editor.document_buffer_id));
5476
5477        // Disable just format-mode.
5478        a.toggle_mode_by_name("lsp-format-mode");
5479        assert!(!a.lsp_format_mode_enabled_for(a.editor.document_buffer_id));
5480        // Other sub-modes still active.
5481        assert!(a.lsp_hover_mode_enabled_for(a.editor.document_buffer_id));
5482        assert!(a.lsp_completion_mode_enabled_for(a.editor.document_buffer_id));
5483        assert!(a.lsp_diagnostics_mode_enabled_for(a.editor.document_buffer_id));
5484
5485        // Format request bails with format-mode echo.
5486        a.do_lsp_format_request(false);
5487        assert!(
5488            a.editor
5489                .last_message
5490                .as_ref()
5491                .map(|m| m.text.contains("lsp-format-mode disabled"))
5492                .unwrap_or(false),
5493            "expected format-mode echo, got: {:?}",
5494            a.editor.last_message,
5495        );
5496
5497        // Hover still works (well, fails for "no LSP server" but
5498        // not for "mode disabled" -- the sub-mode gate passes).
5499        a.editor.last_message = None;
5500        a.apply_effect(lattice_grammar::Effect::Lsp(
5501            lattice_grammar::LspRequest::Hover,
5502        ));
5503        if let Some(msg) = &a.editor.last_message {
5504            assert!(
5505                !msg.text.contains("lsp-hover-mode disabled"),
5506                "hover sub-mode unexpectedly gated: {}",
5507                msg.text,
5508            );
5509        }
5510
5511        // Re-enable format-mode.
5512        a.toggle_mode_by_name("lsp-format-mode");
5513        assert!(a.lsp_format_mode_enabled_for(a.editor.document_buffer_id));
5514    }
5515
5516    #[test]
5517    fn next_diagnostic_with_lsp_diagnostics_mode_off_echoes_gate() {
5518        // M.6.3 contract: `:lsp-diagnostics-mode` off ⇒ the
5519        // navigation gate echoes the sub-mode name and bails
5520        // before any URI / data lookup.
5521        let mut app = app_with("hi\n", 5);
5522        seed_diags_at_lines(&mut app, &[0]);
5523        // Helper auto-activated lsp-mode + cascade. Disable just
5524        // diagnostics-mode.
5525        app.toggle_mode_by_name("lsp-diagnostics-mode");
5526        app.do_next_diagnostic();
5527        let msg = app.editor.last_message.as_ref().expect("gate echo");
5528        assert!(
5529            msg.text.contains("lsp-diagnostics-mode disabled"),
5530            "expected sub-mode gate echo, got: {}",
5531            msg.text,
5532        );
5533    }
5534
5535    #[test]
5536    fn list_diagnostics_opens_picker() {
5537        let mut app = app_with("hi\n", 5);
5538        seed_diags_at_lines(&mut app, &[0, 1]);
5539        app.do_list_diagnostics();
5540        let picker = app.editor.picker.as_ref().expect("picker should open");
5541        assert!(picker.title.starts_with("diagnostics"));
5542        assert!(matches!(
5543            picker.source,
5544            lattice_picker::PickerSource::LspLocations
5545        ));
5546        assert!(matches!(
5547            picker.on_accept,
5548            lattice_picker::PickerAction::JumpToLspLocation
5549        ));
5550        // Two diagnostic rows.
5551        assert_eq!(picker.candidates.len(), 2);
5552        // Severity prefix marginalia in display.
5553        let display = &picker.candidates[0].raw.display;
5554        assert!(display.starts_with("[E]"), "got: {display}");
5555        // Help buffer is NOT opened (the pre-picker shape).
5556        assert!(app.editor.popup_buffer.is_none());
5557    }
5558
5559    #[test]
5560    fn list_diagnostics_with_empty_layer_echoes() {
5561        let mut app = app_with("hi\n", 5);
5562        // No diagnostics seeded.
5563        app.do_list_diagnostics();
5564        // Empty diagnostics: no picker, just an echo.
5565        assert!(app.editor.picker.is_none());
5566        let msg = app.editor.last_message.as_ref().expect("echo");
5567        assert!(msg.text.contains("no diagnostics"));
5568    }
5569
5570    // ---------------------------------------------------------------
5571    // OR.7: the async completion fan-out drives EVERY async source.
5572    //
5573    // Before OR.7 it looked up exactly one — the source whose id was
5574    // `gen:lsp-completion` — so a plugin's completion source could
5575    // register a carrier mode, spawn an actor and connect an adapter
5576    // and still never have `generate` called. These tests fail on that
5577    // version: the first because no rows arrive, the second because the
5578    // absence of an LSP server used to abandon the whole round.
5579    // ---------------------------------------------------------------
5580
5581    /// A stand-in for a plugin's `WasmCompletionSource`: an async source
5582    /// that is emphatically not LSP. It records the context it was handed
5583    /// so the tests can assert the guest sees the line it is completing.
5584    #[derive(Debug)]
5585    struct FakeAsyncSource {
5586        id: &'static str,
5587        candidates: Vec<String>,
5588        seen: std::sync::Arc<std::sync::Mutex<Option<lattice_completion::InsertContextSnapshot>>>,
5589    }
5590
5591    impl lattice_completion::AsyncCompletionSource for FakeAsyncSource {
5592        fn produce_async(
5593            &self,
5594            ctx: lattice_completion::InsertContextSnapshot,
5595            sink: std::sync::Arc<dyn lattice_completion::CandidateSink>,
5596            _token: lattice_protocol::CancellationToken,
5597        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
5598            *self.seen.lock().unwrap() = Some(ctx);
5599            let id = lattice_completion::SourceId::new(self.id);
5600            let candidates = self.candidates.clone();
5601            Box::pin(async move {
5602                for text in candidates {
5603                    sink.push(
5604                        lattice_completion::RawCandidate::plain(
5605                            text,
5606                            lattice_completion::CandidateKind::Plain,
5607                        )
5608                        .with_source(id.clone()),
5609                    );
5610                }
5611            })
5612        }
5613    }
5614
5615    fn seed_async_source(
5616        a: &mut App,
5617        id: &'static str,
5618        candidates: &[&str],
5619    ) -> std::sync::Arc<std::sync::Mutex<Option<lattice_completion::InsertContextSnapshot>>> {
5620        let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
5621        let source = std::sync::Arc::new(FakeAsyncSource {
5622            id,
5623            candidates: candidates.iter().map(|s| s.to_string()).collect(),
5624            seen: seen.clone(),
5625        });
5626        let contribution = lattice_completion::CompletionSourceContribution {
5627            accepts_non_word_query: false,
5628            id: lattice_completion::SourceId::new(id),
5629            default_priority: 100,
5630            auto_trigger: true,
5631            trigger_chars: Vec::new(),
5632            popup_filter_chord: None,
5633            kind: lattice_completion::CompletionSourceKind::Async(source),
5634        };
5635        let buffer = a.editor.document_buffer_id;
5636        a.editor
5637            .buffer_locals
5638            .entry(buffer)
5639            .or_default()
5640            .insert(lattice_mode::ActiveCompletionSources(vec![contribution]));
5641        seen
5642    }
5643
5644    /// Poll the drain until the source's rows land. The fan-out runs on
5645    /// the shared runtime, so the result arrives on another thread —
5646    /// spinning here is the test's stand-in for the editor's
5647    /// `async_landed` wake.
5648    fn settle_async_completion(a: &mut App, want: usize) {
5649        for _ in 0..200 {
5650            a.drain_pending_insert_completion_lsp();
5651            let n = a
5652                .editor
5653                .insert_completion
5654                .as_ref()
5655                .map(|s| s.raw.len())
5656                .unwrap_or(0);
5657            if n >= want {
5658                return;
5659            }
5660            std::thread::sleep(std::time::Duration::from_millis(5));
5661        }
5662    }
5663
5664    #[test]
5665    fn async_fan_out_drives_a_non_lsp_source() {
5666        let mut a = app_with("\nal", 10);
5667        a.editor.modal = ModalState::Insert;
5668        a.editor.cursor = Position::new(1, 2);
5669        seed_async_source(&mut a, "gen:test-plugin", &["alpha", "alphabet"]);
5670        a.editor.insert_completion = Some(lattice_completion::InsertCompletionState::open(
5671            lattice_completion::CompletionTrigger::Manual,
5672            Position::new(1, 0),
5673            Position::new(1, 2),
5674            "al".to_string(),
5675        ));
5676
5677        a.mutate_editor(|e| e.do_async_insert_completion_requests());
5678        settle_async_completion(&mut a, 2);
5679
5680        let state = a.editor.insert_completion.as_ref().expect("popup open");
5681        let texts: Vec<&str> = state.raw.iter().map(|c| c.text.as_str()).collect();
5682        assert!(
5683            texts.contains(&"alpha") && texts.contains(&"alphabet"),
5684            "a non-LSP async source contributed nothing: {texts:?}"
5685        );
5686    }
5687
5688    #[test]
5689    fn a_plugin_source_runs_with_no_lsp_server_attached() {
5690        // The buffer has no URI and no server, which used to return
5691        // before the fan-out began. A plugin source does not care.
5692        let mut a = app_with("\nal", 10);
5693        a.editor.modal = ModalState::Insert;
5694        a.editor.cursor = Position::new(1, 2);
5695        assert!(
5696            a.editor
5697                .buffer_uris
5698                .get(&a.editor.document_buffer_id)
5699                .is_none(),
5700            "precondition: no URI, so LSP sits the round out"
5701        );
5702        seed_async_source(&mut a, "gen:test-plugin", &["alpha"]);
5703        a.editor.insert_completion = Some(lattice_completion::InsertCompletionState::open(
5704            lattice_completion::CompletionTrigger::Manual,
5705            Position::new(1, 0),
5706            Position::new(1, 2),
5707            "al".to_string(),
5708        ));
5709
5710        a.mutate_editor(|e| e.do_async_insert_completion_requests());
5711        settle_async_completion(&mut a, 1);
5712
5713        let state = a.editor.insert_completion.as_ref().expect("popup open");
5714        assert_eq!(
5715            state
5716                .raw
5717                .iter()
5718                .map(|c| c.text.as_str())
5719                .collect::<Vec<_>>(),
5720            vec!["alpha"]
5721        );
5722    }
5723
5724    #[test]
5725    fn a_source_sees_the_line_before_the_cursor() {
5726        // The field org-roam's node source reads to tell "[[Ti" (offer
5727        // link targets) from a bare "Ti" (do not).
5728        let mut a = app_with("\nsee [[Ti", 10);
5729        a.editor.modal = ModalState::Insert;
5730        a.editor.cursor = Position::new(1, 9);
5731        let seen = seed_async_source(&mut a, "gen:test-plugin", &["Title"]);
5732        a.editor.insert_completion = Some(lattice_completion::InsertCompletionState::open(
5733            lattice_completion::CompletionTrigger::Manual,
5734            Position::new(1, 7),
5735            Position::new(1, 9),
5736            "Ti".to_string(),
5737        ));
5738
5739        a.mutate_editor(|e| e.do_async_insert_completion_requests());
5740        settle_async_completion(&mut a, 1);
5741
5742        let ctx = seen.lock().unwrap().clone().expect("the source was called");
5743        assert_eq!(ctx.line_before_cursor, "see [[Ti");
5744        assert_eq!(ctx.query, "Ti");
5745    }
5746
5747    #[test]
5748    fn each_source_replaces_only_its_own_rows() {
5749        // Two senders now, so "keep the latest outcome" would drop one
5750        // of them, and replacing by LSP's extension kind_id would let
5751        // plugin rows pile up a copy per round.
5752        let mut a = app_with("\nal", 10);
5753        a.editor.modal = ModalState::Insert;
5754        a.editor.cursor = Position::new(1, 2);
5755        a.editor.insert_completion = Some(lattice_completion::InsertCompletionState::open(
5756            lattice_completion::CompletionTrigger::Manual,
5757            Position::new(1, 0),
5758            Position::new(1, 2),
5759            "al".to_string(),
5760        ));
5761        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<super::AsyncCompletionOutcome>();
5762        a.editor.pending_insert_completion_async_rx = Some(rx);
5763        let one = lattice_completion::SourceId::new("gen:one");
5764        let two = lattice_completion::SourceId::new("gen:two");
5765        let row = |text: &str, src: &lattice_completion::SourceId| {
5766            lattice_completion::RawCandidate::plain(
5767                text.to_string(),
5768                lattice_completion::CandidateKind::Plain,
5769            )
5770            .with_source(src.clone())
5771        };
5772        tx.send(super::AsyncCompletionOutcome::Items {
5773            candidates: vec![row("alpha", &one)],
5774            sources: vec![one.clone()],
5775            is_incomplete: false,
5776        })
5777        .unwrap();
5778        tx.send(super::AsyncCompletionOutcome::Items {
5779            candidates: vec![row("also", &two)],
5780            sources: vec![two.clone()],
5781            is_incomplete: false,
5782        })
5783        .unwrap();
5784        a.drain_pending_insert_completion_lsp();
5785
5786        let mut texts: Vec<String> = a
5787            .editor
5788            .insert_completion
5789            .as_ref()
5790            .expect("popup")
5791            .raw
5792            .iter()
5793            .map(|c| c.text.clone())
5794            .collect();
5795        texts.sort();
5796        assert_eq!(texts, vec!["alpha", "also"], "both senders' rows survived");
5797
5798        // `one` re-fires with a different row; `two`'s row must stay put
5799        // and `one`'s previous row must not.
5800        tx.send(super::AsyncCompletionOutcome::Items {
5801            candidates: vec![row("altered", &one)],
5802            sources: vec![one],
5803            is_incomplete: false,
5804        })
5805        .unwrap();
5806        a.drain_pending_insert_completion_lsp();
5807        let mut texts: Vec<String> = a
5808            .editor
5809            .insert_completion
5810            .as_ref()
5811            .expect("popup")
5812            .raw
5813            .iter()
5814            .map(|c| c.text.clone())
5815            .collect();
5816        texts.sort();
5817        assert_eq!(texts, vec!["also", "altered"]);
5818    }
5819
5820    #[test]
5821    fn a_candidate_may_insert_something_other_than_what_it_matched() {
5822        // The generic form of LSP's and snippets' own escape hatches: a
5823        // source offers a readable label and inserts machine syntax.
5824        // Org-roam's node source matches a title and inserts an
5825        // `[[id:…][…]]` link; matching on the link instead would score
5826        // every candidate on its uuid.
5827        let mut a = app_with("\nTi", 10);
5828        a.editor.modal = ModalState::Insert;
5829        a.editor.cursor = Position::new(1, 2);
5830        let mut state = lattice_completion::InsertCompletionState::open(
5831            lattice_completion::CompletionTrigger::Manual,
5832            Position::new(1, 0),
5833            Position::new(1, 2),
5834            "Ti".to_string(),
5835        );
5836        let mut raw = lattice_completion::RawCandidate::plain(
5837            "Title".to_string(),
5838            lattice_completion::CandidateKind::Plain,
5839        );
5840        raw.insert_text = Some("[[id:abc][Title]]".to_string());
5841        state.raw = vec![raw];
5842        a.editor.insert_completion = Some(state);
5843        a.mutate_editor(|e| {
5844            let mut s = e.insert_completion.take().expect("popup");
5845            e.refilter_insert_completion(&mut s);
5846            e.insert_completion = Some(s);
5847        });
5848
5849        a.mutate_editor(|e| e.do_completion_accept());
5850
5851        let text = a.editor.document.snapshot().buffer.line(1).unwrap();
5852        assert_eq!(text, "[[id:abc][Title]]");
5853    }
5854}