Skip to main content

lattice_lsp/
modes.rs

1//! LSP modes.
2//!
3//! - `lsp-mode` (minor; M.5.0) -- the umbrella gate. When active
4//!   on a buffer, LSP traffic flows: requests are issued,
5//!   diagnostics are applied, document sync runs. The activate
6//!   hook publishes `LspBufferAttached`; the typed Guard publishes
7//!   `LspBufferDetached` on drop.
8//!
9//! - LSP **sub-modes** (minors; M.6.0) -- one per LSP feature
10//!   surface. Each is independently toggleable on top of
11//!   `lsp-mode`. Marker modes with `type Guard = ()`.
12//!
13//! - `lsp-log-mode` / `lsp-trace-log-mode` / `lsp-server-log-mode`
14//!   (majors; M.3.0 / B'.3 / B'.4 / B'.5) -- the read-only buffers
15//!   backing the LSP observability surfaces. Each owns a typed
16//!   Guard holding its event-bus subscription handle; Drop
17//!   unsubscribes (M-async.1 Drop-based cleanup contract per
18//!   mode-architecture.md §7.1).
19//!
20//! - `lsp-folding-mode` (minor; 4.4.f) -- couples `foldmethod` to
21//!   the LSP `foldingRange` feature. Owns a typed Guard holding
22//!   `(prior_foldmethod, config_handle)`; Drop restores the prior
23//!   value.
24
25use std::sync::{Arc, OnceLock};
26
27use lattice_grammar::effect::{Effect, LspRequest};
28use lattice_mode::{
29    ActionContext, ActionHandler, ActionHandlerContribution, BufferStoreHandle, CapabilitySet,
30    DecorationCtx, GutterDecoration, GutterSeverityLevel, Keymap, KeymapEntry, LifecycleFuture,
31    Mode, ModeActivationError, ModeContext, ModeId, ModeKind, ModeRegistry, OptionOverrideSet,
32    Subscription, keymap_entry,
33};
34use lattice_runtime::EventBus;
35
36use crate::supervisor::LspSupervisorHandle;
37
38/// `lsp-server-log-mode` -- major mode for the per-instance
39/// `*lsp:<server>:<workspace>*` buffer (B'.4 / B'.7).
40/// `on_activate` derives its [`InstanceKey`] identity by parsing
41/// the buffer's synthetic name, subscribes to `LspLogPushed`, and
42/// spawns a drain task that appends matching records. The
43/// returned [`LogSubscriptionGuard`] unsubscribes on drop.
44pub struct LspServerLogMode;
45
46impl LspServerLogMode {
47    pub fn mode_id() -> ModeId {
48        ModeId::new("lsp-server-log-mode")
49    }
50}
51
52impl Mode for LspServerLogMode {
53    type Guard = Option<Subscription>;
54    fn id(&self) -> ModeId {
55        Self::mode_id()
56    }
57    fn kind(&self) -> ModeKind {
58        ModeKind::Major
59    }
60    fn options(&self) -> OptionOverrideSet {
61        lattice_config::overrides! {
62            lattice_config::ReadOnly = true,
63            lattice_config::NoFile = true,
64        }
65    }
66    fn required_capabilities(&self) -> CapabilitySet {
67        CapabilitySet::empty()
68    }
69
70    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
71        Box::pin(async move {
72            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
73            let Some(store) = ctx.service::<BufferStoreHandle>() else {
74                return Ok(None);
75            };
76            let Some(name) = store.name_for(buffer_id) else {
77                return Ok(None);
78            };
79            let Some(instance) = crate::parse_lsp_server_log_name(&name) else {
80                return Ok(None);
81            };
82            let Some(handle) = store.handle_for(buffer_id) else {
83                return Ok(None);
84            };
85            let Ok(runtime) = tokio::runtime::Handle::try_current() else {
86                return Ok(None);
87            };
88
89            // B'.4: seed the buffer from the per-instance ring so
90            // pre-existing records are visible immediately. Skip
91            // trace records (those go to LspTraceLogMode).
92            if let Some(logger) = ctx.service::<crate::LspLogger>() {
93                let snap = logger.snapshot_instance(&instance);
94                let mut text = String::new();
95                for record in snap.iter().filter(|r| r.source != crate::LogSource::Trace) {
96                    let line = crate::format_log_event_line(
97                        record.server_id.as_deref(),
98                        crate::log_level_tag(record.level),
99                        record.source.tag(),
100                        &record.message,
101                    );
102                    text.push_str(&line);
103                    text.push('\n');
104                }
105                if !text.is_empty() {
106                    let snapshot = handle.snapshot();
107                    let last_line = snapshot.buffer.rope_line_count().saturating_sub(1); // CV.3: rope — append point
108                    let line_text = snapshot.buffer.line(last_line).unwrap_or_default();
109                    let pos = lattice_protocol::position::Position::new(
110                        last_line,
111                        line_text.len() as u32,
112                    );
113                    let edit = lattice_protocol::edit::Edit::insert(pos, text);
114                    let handle_seed = handle.clone();
115                    runtime.spawn(async move {
116                        let _ = handle_seed.apply_edit_batch(vec![edit]).await;
117                    });
118                }
119            }
120
121            let (tx, mut rx) =
122                tokio::sync::mpsc::unbounded_channel::<crate::events::LspLogPushed>();
123            let sub_id = ctx
124                .events()
125                .subscribe_typed::<crate::events::LspLogPushed>(tx);
126            let bus_handle = ctx.events_handle();
127
128            let filter_server = Arc::clone(&instance.server_id);
129            let filter_workspace = Arc::clone(&instance.workspace);
130            runtime.spawn(async move {
131                while let Some(first) = rx.recv().await {
132                    let mut batch: Vec<crate::events::LspLogPushed> = vec![first];
133                    while let Ok(more) = rx.try_recv() {
134                        batch.push(more);
135                    }
136                    let mut text = String::new();
137                    for event in batch.iter().filter(|e| {
138                        let id_match = e
139                            .server_id
140                            .as_ref()
141                            .map(|s| Arc::ptr_eq(s, &filter_server) || s == &filter_server)
142                            .unwrap_or(false);
143                        let ws_match = e
144                            .workspace
145                            .as_ref()
146                            .map(|w| Arc::ptr_eq(w, &filter_workspace) || w == &filter_workspace)
147                            .unwrap_or(false);
148                        let is_trace = e.level == "trace" || e.source == "trace";
149                        id_match && ws_match && !is_trace
150                    }) {
151                        let line = crate::format_log_event_line(
152                            event.server_id.as_deref(),
153                            &event.level,
154                            &event.source,
155                            &event.message,
156                        );
157                        text.push_str(&line);
158                        text.push('\n');
159                    }
160                    if text.is_empty() {
161                        continue;
162                    }
163                    let snap = handle.snapshot();
164                    let last_line = snap.buffer.rope_line_count().saturating_sub(1); // CV.3: rope — append point
165                    let line_text = snap.buffer.line(last_line).unwrap_or_default();
166                    let pos = lattice_protocol::position::Position::new(
167                        last_line,
168                        line_text.len() as u32,
169                    );
170                    let edit = lattice_protocol::edit::Edit::insert(pos, text);
171                    let _ = handle.apply_edit_batch(vec![edit]).await;
172                }
173            });
174
175            Ok(Some(Subscription::new(bus_handle, sub_id)))
176        })
177    }
178}
179
180/// `lsp-log-mode` -- major mode for the subsystem-wide `*lsp*`
181/// buffer (B'.3). Same Drop-based subscription cleanup as
182/// `LspServerLogMode`.
183pub struct LspLogMode;
184
185impl LspLogMode {
186    pub fn mode_id() -> ModeId {
187        ModeId::new("lsp-log-mode")
188    }
189}
190
191impl Mode for LspLogMode {
192    type Guard = Option<Subscription>;
193    fn id(&self) -> ModeId {
194        Self::mode_id()
195    }
196    fn kind(&self) -> ModeKind {
197        ModeKind::Major
198    }
199    fn options(&self) -> OptionOverrideSet {
200        lattice_config::overrides! {
201            lattice_config::ReadOnly = true,
202            lattice_config::NoFile = true,
203        }
204    }
205    fn required_capabilities(&self) -> CapabilitySet {
206        CapabilitySet::empty()
207    }
208
209    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
210        Box::pin(async move {
211            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
212            let Some(store) = ctx.service::<BufferStoreHandle>() else {
213                return Ok(None);
214            };
215            let Some(handle) = store.handle_for(buffer_id) else {
216                return Ok(None);
217            };
218            let Ok(runtime) = tokio::runtime::Handle::try_current() else {
219                return Ok(None);
220            };
221
222            // B'.4: seed the buffer from the in-memory ring so
223            // pre-existing subsystem records are visible the moment
224            // the user opens `*lsp*`.
225            if let Some(logger) = ctx.service::<crate::LspLogger>() {
226                let snap = logger.snapshot_global();
227                let mut text = String::new();
228                for record in &snap {
229                    let line = crate::format_log_event_line(
230                        None,
231                        crate::log_level_tag(record.level),
232                        record.source.tag(),
233                        &record.message,
234                    );
235                    text.push_str(&line);
236                    text.push('\n');
237                }
238                if !text.is_empty() {
239                    let snapshot = handle.snapshot();
240                    let last_line = snapshot.buffer.rope_line_count().saturating_sub(1); // CV.3: rope — append point
241                    let line_text = snapshot.buffer.line(last_line).unwrap_or_default();
242                    let pos = lattice_protocol::position::Position::new(
243                        last_line,
244                        line_text.len() as u32,
245                    );
246                    let edit = lattice_protocol::edit::Edit::insert(pos, text);
247                    let handle_seed = handle.clone();
248                    runtime.spawn(async move {
249                        let _ = handle_seed.apply_edit_batch(vec![edit]).await;
250                    });
251                }
252            }
253
254            let (tx, mut rx) =
255                tokio::sync::mpsc::unbounded_channel::<crate::events::LspLogPushed>();
256            let sub_id = ctx
257                .events()
258                .subscribe_typed::<crate::events::LspLogPushed>(tx);
259            let bus_handle = ctx.events_handle();
260
261            runtime.spawn(async move {
262                while let Some(first) = rx.recv().await {
263                    let mut batch: Vec<crate::events::LspLogPushed> = vec![first];
264                    while let Ok(more) = rx.try_recv() {
265                        batch.push(more);
266                    }
267                    let mut text = String::new();
268                    for event in batch.iter().filter(|e| e.server_id.is_none()) {
269                        let line = crate::format_log_event_line(
270                            None,
271                            &event.level,
272                            &event.source,
273                            &event.message,
274                        );
275                        text.push_str(&line);
276                        text.push('\n');
277                    }
278                    if text.is_empty() {
279                        continue;
280                    }
281                    let snap = handle.snapshot();
282                    let last_line = snap.buffer.rope_line_count().saturating_sub(1); // CV.3: rope — append point
283                    let line_text = snap.buffer.line(last_line).unwrap_or_default();
284                    let pos = lattice_protocol::position::Position::new(
285                        last_line,
286                        line_text.len() as u32,
287                    );
288                    let edit = lattice_protocol::edit::Edit::insert(pos, text);
289                    let _ = handle.apply_edit_batch(vec![edit]).await;
290                }
291            });
292
293            Ok(Some(Subscription::new(bus_handle, sub_id)))
294        })
295    }
296}
297
298/// `lsp-trace-log-mode` -- per-instance trace buffer (B'.5 /
299/// B'.7). Twin of `LspServerLogMode` for trace-only records.
300pub struct LspTraceLogMode;
301
302impl LspTraceLogMode {
303    pub fn mode_id() -> ModeId {
304        ModeId::new("lsp-trace-log-mode")
305    }
306}
307
308impl Mode for LspTraceLogMode {
309    type Guard = Option<Subscription>;
310    fn id(&self) -> ModeId {
311        Self::mode_id()
312    }
313    fn kind(&self) -> ModeKind {
314        ModeKind::Major
315    }
316    fn options(&self) -> OptionOverrideSet {
317        lattice_config::overrides! {
318            lattice_config::ReadOnly = true,
319            lattice_config::NoFile = true,
320        }
321    }
322    fn required_capabilities(&self) -> CapabilitySet {
323        CapabilitySet::empty()
324    }
325
326    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
327        Box::pin(async move {
328            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
329            let Some(store) = ctx.service::<BufferStoreHandle>() else {
330                return Ok(None);
331            };
332            let Some(name) = store.name_for(buffer_id) else {
333                return Ok(None);
334            };
335            let Some(instance) = crate::parse_lsp_trace_log_name(&name) else {
336                return Ok(None);
337            };
338            let Some(handle) = store.handle_for(buffer_id) else {
339                return Ok(None);
340            };
341            let Ok(runtime) = tokio::runtime::Handle::try_current() else {
342                return Ok(None);
343            };
344
345            if let Some(logger) = ctx.service::<crate::LspLogger>() {
346                let snap = logger.snapshot_instance(&instance);
347                let mut text = String::new();
348                for record in snap.iter().filter(|r| {
349                    r.source == crate::LogSource::Trace || r.level == crate::LogLevel::Trace
350                }) {
351                    let line = crate::format_log_event_line(
352                        record.server_id.as_deref(),
353                        crate::log_level_tag(record.level),
354                        record.source.tag(),
355                        &record.message,
356                    );
357                    text.push_str(&line);
358                    text.push('\n');
359                }
360                if !text.is_empty() {
361                    let snapshot = handle.snapshot();
362                    let last_line = snapshot.buffer.rope_line_count().saturating_sub(1); // CV.3: rope — append point
363                    let line_text = snapshot.buffer.line(last_line).unwrap_or_default();
364                    let pos = lattice_protocol::position::Position::new(
365                        last_line,
366                        line_text.len() as u32,
367                    );
368                    let edit = lattice_protocol::edit::Edit::insert(pos, text);
369                    let handle_seed = handle.clone();
370                    runtime.spawn(async move {
371                        let _ = handle_seed.apply_edit_batch(vec![edit]).await;
372                    });
373                }
374            }
375
376            let (tx, mut rx) =
377                tokio::sync::mpsc::unbounded_channel::<crate::events::LspLogPushed>();
378            let sub_id = ctx
379                .events()
380                .subscribe_typed::<crate::events::LspLogPushed>(tx);
381            let bus_handle = ctx.events_handle();
382
383            let filter_server = Arc::clone(&instance.server_id);
384            let filter_workspace = Arc::clone(&instance.workspace);
385            runtime.spawn(async move {
386                while let Some(first) = rx.recv().await {
387                    let mut batch: Vec<crate::events::LspLogPushed> = vec![first];
388                    while let Ok(more) = rx.try_recv() {
389                        batch.push(more);
390                    }
391                    let mut text = String::new();
392                    for event in batch.iter().filter(|e| {
393                        let id_match = e
394                            .server_id
395                            .as_ref()
396                            .map(|s| Arc::ptr_eq(s, &filter_server) || s == &filter_server)
397                            .unwrap_or(false);
398                        let ws_match = e
399                            .workspace
400                            .as_ref()
401                            .map(|w| Arc::ptr_eq(w, &filter_workspace) || w == &filter_workspace)
402                            .unwrap_or(false);
403                        let is_trace = e.level == "trace" || e.source == "trace";
404                        id_match && ws_match && is_trace
405                    }) {
406                        let line = crate::format_log_event_line(
407                            event.server_id.as_deref(),
408                            &event.level,
409                            &event.source,
410                            &event.message,
411                        );
412                        text.push_str(&line);
413                        text.push('\n');
414                    }
415                    if text.is_empty() {
416                        continue;
417                    }
418                    let snap = handle.snapshot();
419                    let last_line = snap.buffer.rope_line_count().saturating_sub(1); // CV.3: rope — append point
420                    let line_text = snap.buffer.line(last_line).unwrap_or_default();
421                    let pos = lattice_protocol::position::Position::new(
422                        last_line,
423                        line_text.len() as u32,
424                    );
425                    let edit = lattice_protocol::edit::Edit::insert(pos, text);
426                    let _ = handle.apply_edit_batch(vec![edit]).await;
427                }
428            });
429
430            Ok(Some(Subscription::new(bus_handle, sub_id)))
431        })
432    }
433}
434
435/// Guard for `lsp-mode`. Holds the event-bus handle + buffer id;
436/// Drop publishes `LspBufferDetached` so subscribers (the LSP
437/// supervisor, the diagnostic clearer) tear down per-buffer
438/// state symmetrically with the `LspBufferAttached` published
439/// from `on_activate`.
440pub struct LspModeGuard {
441    bus: Arc<EventBus>,
442    buffer_id: lattice_protocol::ids::DocumentId,
443}
444
445impl Drop for LspModeGuard {
446    fn drop(&mut self) {
447        self.bus
448            .publish_typed(crate::events::LspBufferDetached { id: self.buffer_id });
449    }
450}
451
452/// MO.1: 7 Normal-mode LSP navigation bindings contributed by `LspMode::keymap()`.
453/// Moved out of `keymap_normal.rs` Builtin layer so K.1.c scoping fires them
454/// only when `lsp-mode` is active on the buffer, not globally.
455fn lsp_mode_keymap_entries() -> &'static [KeymapEntry] {
456    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
457    ENTRIES.get_or_init(|| {
458        vec![
459            keymap_entry! {
460                mode: Normal, chord: "K",
461                doc: "Show hover documentation",
462                cmd: "action:lsp-hover"
463            },
464            keymap_entry! {
465                mode: Normal, chord: "gd",
466                doc: "Go to definition",
467                cmd: "action:lsp-definition"
468            },
469            keymap_entry! {
470                mode: Normal, chord: "gD",
471                doc: "Go to declaration",
472                cmd: "action:lsp-declaration"
473            },
474            keymap_entry! {
475                mode: Normal, chord: "gy",
476                doc: "Go to type definition",
477                cmd: "action:lsp-type-definition"
478            },
479            keymap_entry! {
480                mode: Normal, chord: "gI",
481                doc: "Go to implementation",
482                cmd: "action:lsp-implementation"
483            },
484            keymap_entry! {
485                mode: Normal, chord: "gr",
486                doc: "Find references",
487                cmd: "action:lsp-references"
488            },
489            keymap_entry! {
490                mode: Normal, chord: "gx",
491                doc: "Follow document link at cursor",
492                cmd: "action:lsp-follow-link"
493            },
494        ]
495    })
496}
497
498/// `lsp-mode` -- the umbrella minor that gates LSP traffic on a
499/// buffer. Cascades to the 15 sub-modes via `Mode::implies()`.
500pub struct LspMode {
501    sub_modes: Vec<ModeId>,
502}
503
504impl LspMode {
505    pub fn mode_id() -> ModeId {
506        ModeId::new("lsp-mode")
507    }
508
509    pub fn new() -> Self {
510        Self {
511            sub_modes: vec![
512                LspCompletionMode::mode_id(),
513                LspDiagnosticsMode::mode_id(),
514                LspHoverMode::mode_id(),
515                LspSignatureMode::mode_id(),
516                LspFormatMode::mode_id(),
517                LspRenameMode::mode_id(),
518                LspSymbolsMode::mode_id(),
519                LspCodeActionMode::mode_id(),
520                LspNavMode::mode_id(),
521                LspProgressMode::mode_id(),
522                LspDocumentHighlightMode::mode_id(),
523                LspSelectionRangeMode::mode_id(),
524                LspFoldingMode::mode_id(),
525                LspInlayHintMode::mode_id(),
526                LspSemanticTokensMode::mode_id(),
527            ],
528        }
529    }
530}
531
532impl Default for LspMode {
533    fn default() -> Self {
534        Self::new()
535    }
536}
537
538impl Mode for LspMode {
539    type Guard = LspModeGuard;
540    fn id(&self) -> ModeId {
541        Self::mode_id()
542    }
543    fn kind(&self) -> ModeKind {
544        ModeKind::Minor
545    }
546    fn implies(&self) -> &[ModeId] {
547        &self.sub_modes
548    }
549    /// MO.1: 7 Normal-mode LSP navigation bindings.
550    /// Scoped to lsp-mode buffers by K.1.c; absent at the Builtin layer.
551    fn keymap(&self) -> Keymap {
552        Keymap::from_entries(lsp_mode_keymap_entries())
553    }
554    /// L7 (lsp-architecture.md §16): the handler bodies for the 7 nav
555    /// chords. Each closure decides only *which* request to fire and
556    /// returns the host-owned `Effect::Lsp(LspRequest::…)`; the host's
557    /// `editor.lsp_request` dispatcher runs the (unchanged) async request
558    /// substrate. The closures read nothing from the `ActionContext` —
559    /// the substrate reads live `Editor` cursor/scroll at apply time, so
560    /// the popup/jump anchors to the symbol the chord fired on. Global
561    /// (buffer-agnostic): registered once at boot by the
562    /// `register_mode_action_handlers` walk; K.1.c scopes *where* the
563    /// chords fire. This is L4b's `gl` pattern generalised across the
564    /// nav surface (`feedback_mode_owns_its_surface`).
565    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
566        fn nav(action_name: &'static str, req: LspRequest) -> ActionHandlerContribution {
567            let handler: ActionHandler =
568                std::sync::Arc::new(move |_ctx: &ActionContext<'_>| -> Option<Effect> {
569                    Some(Effect::Lsp(req))
570                });
571            ActionHandlerContribution {
572                action_name,
573                handler,
574            }
575        }
576        vec![
577            nav("action:lsp-hover", LspRequest::Hover),
578            nav("action:lsp-definition", LspRequest::Definition),
579            nav("action:lsp-declaration", LspRequest::Declaration),
580            nav("action:lsp-type-definition", LspRequest::TypeDefinition),
581            nav("action:lsp-implementation", LspRequest::Implementation),
582            nav("action:lsp-references", LspRequest::References),
583            nav("action:lsp-follow-link", LspRequest::FollowLink),
584        ]
585    }
586    fn options(&self) -> OptionOverrideSet {
587        OptionOverrideSet::default()
588    }
589    fn required_capabilities(&self) -> CapabilitySet {
590        CapabilitySet::empty()
591    }
592    // M-async.5: drive the LSP `initialize` round-trip from the
593    // mode's lifecycle directly. The mode's "active" state then
594    // genuinely means "LSP is ready to serve this buffer" --
595    // hover / completion / format requests issued immediately
596    // after activation are serviceable, not silently no-op.
597    //
598    // Flow:
599    // 1. Resolve the buffer's filesystem path + current text via
600    //    the `BufferStoreHandle` service. Path-less buffers
601    //    (scratch / unsaved) skip the initialize and succeed
602    //    with a no-op Guard -- they're still in `lsp-mode` for
603    //    the cascade's sake, but no server is attached.
604    // 2. Call `supervisor.open_buffer(path, text).await`. The
605    //    supervisor task spawns matching server actors (one
606    //    `initialize` handshake per fresh server) and registers
607    //    the buffer with each.
608    // 3. On success: publish `LspBufferAttached` AFTER initialize
609    //    completes (subscribers can now rely on it to mean
610    //    "operational"). Return the Guard.
611    // 4. On error: return `LifecycleFailed`. The dispatcher
612    //    publishes `ModeActivationFailed`; the App's
613    //    `drain_mode_lifecycle_events` subscriber calls
614    //    `deactivate_mode_by_id` to roll back `active_modes`.
615    //
616    // M-async.4 epoch counter protects against the rapid
617    // `:lsp-mode` toggle race: if the user deactivates while
618    // initialize is in flight, the spawn task's `try_insert`
619    // fails the epoch match, the Guard drops on the spawn
620    // side (publishing `LspBufferDetached`), and the App stays
621    // consistent.
622    // ML.3c: the `lsp` readiness badge moved off `status_line_items` to a
623    // registered modeline element produced by `crate::modeline`
624    // (`lsp_content` + the forwarder that accumulates `$/progress` /
625    // `serverStatus` and pushes per attached buffer).
626
627    /// MO.4.a: gutter severity marks. Reads `LspDiagnosticsData` injected by
628    /// the renderer for this pane's buffer URI, aggregates to the max
629    /// `GutterSeverityLevel` per line, and emits each as a SIGN (SG.4b).
630    ///
631    /// The aggregation stays here even though per-line contention is now the
632    /// renderer's job via priority: collapsing N diagnostics on a line to one
633    /// mark before crossing is strictly less work than emitting N placements
634    /// for the renderer to reduce, and the answer is the same either way
635    /// because the built-in priorities carry the severity order.
636    fn gutter_decorations(&self, ctx: &DecorationCtx<'_>) -> Vec<GutterDecoration> {
637        let Some(data) = ctx.service::<LspDiagnosticsData>() else {
638            return Vec::new();
639        };
640        let Some(diags) = &data.diagnostics else {
641            return Vec::new();
642        };
643        // SG.4b: the interned built-in sign ids, injected by the renderer.
644        // Absent in a stripped harness — no ids, no marks, rather than marks
645        // resolving to whatever sits at id 0.
646        let Some(ids) = ctx.service::<lattice_mode::BuiltinSignIds>() else {
647            return Vec::new();
648        };
649        let mut per_line: std::collections::HashMap<u32, GutterSeverityLevel> = Default::default();
650        for diag in diags.iter() {
651            let level = match diag.severity {
652                Some(crate::DiagnosticSeverity::ERROR) => GutterSeverityLevel::Error,
653                Some(crate::DiagnosticSeverity::WARNING) => GutterSeverityLevel::Warning,
654                Some(crate::DiagnosticSeverity::INFORMATION) => GutterSeverityLevel::Info,
655                Some(crate::DiagnosticSeverity::HINT) => GutterSeverityLevel::Hint,
656                _ => continue,
657            };
658            per_line
659                .entry(diag.range.start.line)
660                .and_modify(|e| {
661                    if level > *e {
662                        *e = level;
663                    }
664                })
665                .or_insert(level);
666        }
667        per_line
668            .into_iter()
669            .map(|(line, level)| GutterDecoration::Sign {
670                line,
671                sign: ids.for_severity(level),
672            })
673            .collect()
674    }
675
676    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
677        Box::pin(async move {
678            let buffer_id = lattice_protocol::ids::DocumentId::new(ctx.buffer_id().0);
679            // Resolve path + text via the buffer store. Modes
680            // without a registered store (test harness) skip the
681            // attach gracefully -- the mode is still "active"
682            // for cascade purposes but no server gets opened.
683            let path_and_text = ctx
684                .service::<BufferStoreHandle>()
685                .and_then(|store| {
686                    let core_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
687                    store.handle_for(core_id)
688                })
689                .and_then(|handle| {
690                    let path = handle.path()?;
691                    let text = handle.text();
692                    Some((path, text))
693                });
694
695            if let Some((path, text)) = path_and_text {
696                // Path-bearing buffer. Drive the supervisor's
697                // open_buffer (which internally pays the
698                // `initialize` handshake cost for any newly-
699                // spawned actor). Skip when no supervisor is
700                // wired (test paths that don't register one).
701                if let Some(sup) = ctx.service::<LspSupervisorHandle>()
702                    && let Err(e) = sup.open_buffer(path.clone(), text).await
703                {
704                    return Err(ModeActivationError::LifecycleFailed {
705                        mode: LspMode::mode_id(),
706                        reason: format!("open_buffer({}) failed: {e}", path.display()),
707                    });
708                }
709            }
710            // Publish AFTER initialize completes (or after the
711            // no-path / no-supervisor short-circuit).
712            // Subscribers (statusline, observability) see
713            // `LspBufferAttached` only when the mode is
714            // operational.
715            ctx.events()
716                .publish_typed(crate::events::LspBufferAttached { id: buffer_id });
717            Ok(LspModeGuard {
718                bus: ctx.events_handle(),
719                buffer_id,
720            })
721        })
722    }
723}
724
725/// M.6.0: declare an LSP sub-mode. Each is a marker minor with
726/// `Guard = ()` and a trivial `on_activate`. Gating logic lives
727/// at the request entry points and the
728/// publish-diagnostics / completion-source sites that consult
729/// `App::<feature>_mode_enabled_for`.
730macro_rules! lsp_sub_mode {
731    ($struct_name:ident, $mode_name:literal) => {
732        pub struct $struct_name;
733
734        impl $struct_name {
735            pub fn mode_id() -> ModeId {
736                ModeId::new($mode_name)
737            }
738        }
739
740        impl Mode for $struct_name {
741            type Guard = ();
742            fn id(&self) -> ModeId {
743                Self::mode_id()
744            }
745            fn kind(&self) -> ModeKind {
746                ModeKind::Minor
747            }
748            fn options(&self) -> OptionOverrideSet {
749                OptionOverrideSet::default()
750            }
751            fn required_capabilities(&self) -> CapabilitySet {
752                CapabilitySet::empty()
753            }
754            fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
755                Box::pin(async { Ok(()) })
756            }
757        }
758    };
759}
760
761// `LspCompletionMode` lives in `crate::completion` -- it's
762// source-contributing rather than a pure marker.
763pub use crate::completion::LspCompletionMode;
764// `lsp-diagnostics-mode` is a FULL mode (not the bare marker macro):
765// it owns the `gl` cursor popup + `]d`/`[d` jump bindings AND the
766// popup handler body (L4b, lsp-architecture.md §15). See below.
767
768/// L4b: read-only query handle the mode-owned diagnostic handlers in
769/// [`LspDiagnosticsMode`] use to read the cursor line's diagnostics
770/// for `gl`. The impl (lattice-host) resolves `buffer_id → uri →
771/// DiagnosticsLayer` over the live published render state, so the mode
772/// needs no host method, no direct layer access, and no URI map of
773/// its own. Register + look up under the
774/// [`DiagnosticsQueryHandle`] alias
775/// (`feedback_servicesregistry_arc_typeid`).
776pub trait DiagnosticsQuery: Send + Sync {
777    /// Diagnostics overlapping `line` of `buffer_id`, in the layer's
778    /// `(line, character)` order. Empty when the buffer has no URI
779    /// mapped / no LSP attachment.
780    fn on_line(
781        &self,
782        buffer_id: lattice_protocol::ids::BufferId,
783        line: u32,
784    ) -> Vec<crate::Diagnostic>;
785
786    /// IDE-protocol I2.0: every diagnostic for the file `uri` (string form,
787    /// e.g. `file:///x.rs`), in the layer's order. Empty when the uri has
788    /// no diagnostics or doesn't parse. Generic (any consumer of the
789    /// diagnostics service can use it — the Claude Code IDE peer's
790    /// `getDiagnostics` is the first). Default returns empty so impls that
791    /// only serve the `gl` line query keep compiling.
792    fn for_uri(&self, _uri: &str) -> Vec<crate::Diagnostic> {
793        Vec::new()
794    }
795
796    /// IDE-protocol I2.0: every file uri (string form) that currently has
797    /// diagnostics — lets a caller fan `for_uri` out for a workspace-wide
798    /// query. Default empty.
799    fn uris_with_diagnostics(&self) -> Vec<String> {
800        Vec::new()
801    }
802}
803
804/// Service alias for [`DiagnosticsQuery`]. Register the host impl as
805/// this exact type and look it up the same way.
806pub type DiagnosticsQueryHandle = std::sync::Arc<dyn DiagnosticsQuery>;
807
808#[cfg(test)]
809mod diagnostics_query_for_uri_tests {
810    use super::DiagnosticsQuery;
811    use lattice_protocol::ids::BufferId;
812
813    /// An impl serving only the `gl` line query — `for_uri` /
814    /// `uris_with_diagnostics` come from the trait defaults.
815    struct OnlyOnLine;
816    impl DiagnosticsQuery for OnlyOnLine {
817        fn on_line(&self, _b: BufferId, _l: u32) -> Vec<crate::Diagnostic> {
818            Vec::new()
819        }
820    }
821
822    #[test]
823    fn for_uri_methods_are_object_safe_and_default_to_empty() {
824        let q: &dyn DiagnosticsQuery = &OnlyOnLine;
825        assert!(q.for_uri("file:///x.rs").is_empty());
826        assert!(q.uris_with_diagnostics().is_empty());
827        assert!(q.on_line(BufferId::new(0), 0).is_empty());
828    }
829}
830
831/// L4b: format the cursor line's diagnostics into one popup line each:
832/// `<severity glyph> <message> [source:code] (+N related)`. The glyph
833/// is the BMP-fallback severity mark (degrade-safe per the icon-palette
834/// rule); the message is collapsed to its first line. Empty input →
835/// empty `Vec` (the host echoes "no diagnostics on line").
836pub fn format_diagnostic_popup_lines(diags: &[crate::Diagnostic]) -> Vec<(String, u8)> {
837    use crate::lsp_types::{DiagnosticSeverity, NumberOrString};
838    diags
839        .iter()
840        .map(|d| {
841            // (glyph, severity rank) — rank is Error = 0 … Hint = 3
842            // (unknown clamps to Hint), matching the host's severity
843            // colour map; the host highlights each line by it.
844            let (glyph, rank) = match d.severity {
845                Some(DiagnosticSeverity::ERROR) => ('■', 0u8),
846                Some(DiagnosticSeverity::WARNING) => ('▲', 1),
847                Some(DiagnosticSeverity::INFORMATION) => ('●', 2),
848                _ => ('·', 3),
849            };
850            let msg = d.message.lines().next().unwrap_or("").trim();
851            let mut line = format!("{glyph} {msg}");
852            let mut tags: Vec<String> = Vec::new();
853            if let Some(src) = &d.source {
854                tags.push(src.clone());
855            }
856            if let Some(code) = &d.code {
857                tags.push(match code {
858                    NumberOrString::Number(n) => n.to_string(),
859                    NumberOrString::String(s) => s.clone(),
860                });
861            }
862            if !tags.is_empty() {
863                line.push_str(&format!(" [{}]", tags.join(":")));
864            }
865            let related = d.related_information.as_ref().map_or(0, |r| r.len());
866            if related > 0 {
867                line.push_str(&format!(" (+{related} related)"));
868            }
869            (line, rank)
870        })
871        .collect()
872}
873
874/// L4b: `lsp-diagnostics-mode` keymap — the three diagnostic chords,
875/// scoped to lsp-diagnostics-mode buffers by K.1.c (absent at the
876/// Builtin layer). `gl` → the mode's own popup handler;
877/// `]d` / `[d` → the existing diagnostic-jump ex-commands (the mode
878/// owns the *binding*; the shared jump + its landed-message echo live
879/// in the host ex-command, used identically by `:cnext` / `:diag-next`).
880fn lsp_diagnostics_mode_keymap_entries() -> &'static [KeymapEntry] {
881    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
882    ENTRIES.get_or_init(|| {
883        vec![
884            keymap_entry! {
885                mode: Normal, chord: "gl",
886                doc: "Show diagnostics on the cursor line in a popup",
887                cmd: "action:lsp-diagnostic-popup"
888            },
889            keymap_entry! {
890                mode: Normal, chord: "]d",
891                doc: "Jump to the next diagnostic (echoes its message)",
892                cmd: "ex:diag-next"
893            },
894            keymap_entry! {
895                mode: Normal, chord: "[d",
896                doc: "Jump to the previous diagnostic (echoes its message)",
897                cmd: "ex:diag-prev"
898            },
899        ]
900    })
901}
902
903/// `lsp-diagnostics-mode` — owns the diagnostic cursor surfaces
904/// (L4b). Promoted from the `lsp_sub_mode!` marker so it can carry a
905/// keymap + the `gl` action handler.
906pub struct LspDiagnosticsMode;
907
908impl LspDiagnosticsMode {
909    pub fn mode_id() -> ModeId {
910        ModeId::new("lsp-diagnostics-mode")
911    }
912}
913
914impl Mode for LspDiagnosticsMode {
915    type Guard = ();
916    fn id(&self) -> ModeId {
917        Self::mode_id()
918    }
919    fn kind(&self) -> ModeKind {
920        ModeKind::Minor
921    }
922    fn options(&self) -> OptionOverrideSet {
923        OptionOverrideSet::default()
924    }
925    fn required_capabilities(&self) -> CapabilitySet {
926        CapabilitySet::empty()
927    }
928    fn keymap(&self) -> Keymap {
929        Keymap::from_entries(lsp_diagnostics_mode_keymap_entries())
930    }
931    /// `gl`: read the cursor line's diagnostics via the
932    /// [`DiagnosticsQueryHandle`] service, format them, and hand the
933    /// host an [`Effect::ShowDiagnosticsPopup`] to render through the
934    /// hover popup pipeline. Global (buffer-agnostic) — registered
935    /// once at boot; K.1.c scopes *where* `gl` fires.
936    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
937        let handler: ActionHandler =
938            std::sync::Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
939                let query = ctx.services.get::<DiagnosticsQueryHandle>()?;
940                let diags = query.on_line(ctx.buffer_id, ctx.cursor.line);
941                Some(Effect::ShowDiagnosticsPopup {
942                    lines: format_diagnostic_popup_lines(&diags),
943                })
944            });
945        vec![ActionHandlerContribution {
946            action_name: "action:lsp-diagnostic-popup",
947            handler,
948        }]
949    }
950    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
951        Box::pin(async { Ok(()) })
952    }
953}
954lsp_sub_mode!(LspHoverMode, "lsp-hover-mode");
955lsp_sub_mode!(LspSignatureMode, "lsp-signature-mode");
956lsp_sub_mode!(LspFormatMode, "lsp-format-mode");
957lsp_sub_mode!(LspRenameMode, "lsp-rename-mode");
958lsp_sub_mode!(LspSymbolsMode, "lsp-symbols-mode");
959lsp_sub_mode!(LspCodeActionMode, "lsp-code-action-mode");
960lsp_sub_mode!(LspNavMode, "lsp-nav-mode");
961
962/// Render-time service data for `LspMode::gutter_decorations`.
963/// The renderer registers one instance per pane render, populated
964/// from `rs.diagnostics.layer.diagnostics_arc(uri)` for the pane's
965/// buffer URI. `None` diagnostics means no URI mapped or no LSP
966/// attachment — no severity decorations are contributed.
967pub struct LspDiagnosticsData {
968    pub diagnostics: Option<std::sync::Arc<[crate::Diagnostic]>>,
969}
970
971/// `lsp-progress-mode` — a marker minor mode (ML.3c removed its
972/// hand-written `status_line_items`; the in-flight `$/progress` detail
973/// now ships as part of the `lsp` modeline element produced by
974/// `crate::modeline`). Kept as a distinct mode so existing activation /
975/// gating keys off it unchanged.
976pub struct LspProgressMode;
977
978impl LspProgressMode {
979    pub fn mode_id() -> ModeId {
980        ModeId::new("lsp-progress-mode")
981    }
982}
983
984impl Mode for LspProgressMode {
985    type Guard = ();
986    fn id(&self) -> ModeId {
987        Self::mode_id()
988    }
989    fn kind(&self) -> ModeKind {
990        ModeKind::Minor
991    }
992    fn options(&self) -> OptionOverrideSet {
993        OptionOverrideSet::default()
994    }
995    fn required_capabilities(&self) -> CapabilitySet {
996        CapabilitySet::empty()
997    }
998    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
999        Box::pin(async { Ok(()) })
1000    }
1001}
1002
1003lsp_sub_mode!(LspDocumentHighlightMode, "lsp-document-highlight-mode");
1004lsp_sub_mode!(LspSelectionRangeMode, "lsp-selection-range-mode");
1005lsp_sub_mode!(LspInlayHintMode, "lsp-inlay-hint-mode");
1006lsp_sub_mode!(LspSemanticTokensMode, "lsp-semantic-tokens-mode");
1007
1008/// Guard for `lsp-folding-mode`. Holds the prior `foldmethod`
1009/// value + a config handle; Drop restores the prior value via
1010/// `folding_sync::on_deactivate`. `None` prior means activation
1011/// was a no-op (foldmethod was already `Lsp`), so Drop also
1012/// does nothing.
1013/// 4.4.f: `textDocument/foldingRange` feeding `FoldMethod::Lsp`.
1014///
1015/// Coupled to the `foldmethod` option — declaratively, through
1016/// [`Mode::options`]. Activation resolves `foldmethod = lsp` **on this
1017/// mode's buffers**; deactivation drops the contribution and resolution
1018/// falls back to whatever the buffer would otherwise have had.
1019///
1020/// **2026-08-16.** This used to be a hand-written lifecycle that wrote
1021/// the option through `ConfigRegistry::set_typed` from `on_activate` and
1022/// restored it from a `Drop` guard. `set_typed` is the GLOBAL registry:
1023/// attaching a language server to one buffer set `foldmethod = lsp` for
1024/// every buffer in the editor, including ones with no server, whose
1025/// folds were then recomputed through the LSP provider's cascade. The
1026/// module's own doc claimed it swapped "the buffer's `foldmethod`" — the
1027/// buffer-scoped mechanism was `Mode::options()`, sitting right here
1028/// returning an empty set.
1029///
1030/// The stash-and-restore machinery (`folding_sync`, `LspFoldingGuard`,
1031/// the prior-value slot) existed only to undo a global write. A
1032/// contribution is scoped and reverts itself, so all of it is gone
1033/// rather than repaired.
1034pub struct LspFoldingMode;
1035
1036impl LspFoldingMode {
1037    pub fn mode_id() -> ModeId {
1038        ModeId::new("lsp-folding-mode")
1039    }
1040}
1041
1042impl Mode for LspFoldingMode {
1043    type Guard = ();
1044    fn id(&self) -> ModeId {
1045        Self::mode_id()
1046    }
1047    fn kind(&self) -> ModeKind {
1048        ModeKind::Minor
1049    }
1050    /// The whole of this mode's option coupling. Buffer-scoped by
1051    /// construction, and reverted by deactivation without a guard —
1052    /// see the type docs for what this replaced.
1053    fn options(&self) -> OptionOverrideSet {
1054        lattice_config::overrides! {
1055            lattice_config::FoldMethodOption = lattice_core::FoldMethod::Lsp,
1056        }
1057    }
1058    fn required_capabilities(&self) -> CapabilitySet {
1059        CapabilitySet::empty()
1060    }
1061    /// Nothing to do: the option contribution above IS the activation.
1062    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
1063        Box::pin(async { Ok(()) })
1064    }
1065}
1066
1067/// Register every LSP mode (the three log majors, the umbrella
1068/// `lsp-mode` minor, and the marker sub-modes) against
1069/// `registry`. `LspCompletionMode` is registered separately via
1070/// [`crate::completion::register_lsp_completion_mode`] because it
1071/// needs a supervisor handle.
1072pub fn register_lsp_log_modes(registry: &mut ModeRegistry) {
1073    registry
1074        .register(LspLogMode)
1075        .expect("lsp-log-mode register");
1076    registry
1077        .register(LspTraceLogMode)
1078        .expect("lsp-trace-log-mode register");
1079    registry
1080        .register(LspServerLogMode)
1081        .expect("lsp-server-log-mode register");
1082    registry
1083        .register(LspMode::new())
1084        .expect("lsp-mode register");
1085    registry
1086        .register(LspDiagnosticsMode)
1087        .expect("lsp-diagnostics-mode register");
1088    registry
1089        .register(LspHoverMode)
1090        .expect("lsp-hover-mode register");
1091    registry
1092        .register(LspSignatureMode)
1093        .expect("lsp-signature-mode register");
1094    registry
1095        .register(LspFormatMode)
1096        .expect("lsp-format-mode register");
1097    registry
1098        .register(LspRenameMode)
1099        .expect("lsp-rename-mode register");
1100    registry
1101        .register(LspSymbolsMode)
1102        .expect("lsp-symbols-mode register");
1103    registry
1104        .register(LspCodeActionMode)
1105        .expect("lsp-code-action-mode register");
1106    registry
1107        .register(LspNavMode)
1108        .expect("lsp-nav-mode register");
1109    registry
1110        .register(LspProgressMode)
1111        .expect("lsp-progress-mode register");
1112    registry
1113        .register(LspDocumentHighlightMode)
1114        .expect("lsp-document-highlight-mode register");
1115    registry
1116        .register(LspSelectionRangeMode)
1117        .expect("lsp-selection-range-mode register");
1118    registry
1119        .register(LspFoldingMode)
1120        .expect("lsp-folding-mode register");
1121    registry
1122        .register(LspInlayHintMode)
1123        .expect("lsp-inlay-hint-mode register");
1124    registry
1125        .register(LspSemanticTokensMode)
1126        .expect("lsp-semantic-tokens-mode register");
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131    use super::*;
1132
1133    #[test]
1134    fn each_lsp_mode_has_distinct_id() {
1135        let ids = [
1136            LspLogMode::mode_id(),
1137            LspTraceLogMode::mode_id(),
1138            LspServerLogMode::mode_id(),
1139            LspMode::mode_id(),
1140            LspCompletionMode::mode_id(),
1141            LspDiagnosticsMode::mode_id(),
1142            LspHoverMode::mode_id(),
1143            LspSignatureMode::mode_id(),
1144            LspFormatMode::mode_id(),
1145            LspRenameMode::mode_id(),
1146            LspSymbolsMode::mode_id(),
1147            LspCodeActionMode::mode_id(),
1148            LspNavMode::mode_id(),
1149            LspProgressMode::mode_id(),
1150            LspDocumentHighlightMode::mode_id(),
1151            LspSelectionRangeMode::mode_id(),
1152            LspFoldingMode::mode_id(),
1153            LspInlayHintMode::mode_id(),
1154            LspSemanticTokensMode::mode_id(),
1155        ];
1156        for (i, a) in ids.iter().enumerate() {
1157            for b in &ids[i + 1..] {
1158                assert_ne!(a, b);
1159            }
1160        }
1161    }
1162
1163    #[test]
1164    fn register_lsp_log_modes_populates_registry() {
1165        let mut registry = ModeRegistry::new();
1166        register_lsp_log_modes(&mut registry);
1167        assert!(registry.is_registered(LspLogMode::mode_id()));
1168        assert!(registry.is_registered(LspTraceLogMode::mode_id()));
1169        assert!(registry.is_registered(LspServerLogMode::mode_id()));
1170        assert!(registry.is_registered(LspMode::mode_id()));
1171        assert!(registry.is_registered(LspDiagnosticsMode::mode_id()));
1172        assert!(registry.is_registered(LspHoverMode::mode_id()));
1173        assert!(registry.is_registered(LspSignatureMode::mode_id()));
1174        assert!(registry.is_registered(LspFormatMode::mode_id()));
1175        assert!(registry.is_registered(LspRenameMode::mode_id()));
1176        assert!(registry.is_registered(LspSymbolsMode::mode_id()));
1177        assert!(registry.is_registered(LspCodeActionMode::mode_id()));
1178        assert!(registry.is_registered(LspNavMode::mode_id()));
1179        assert!(registry.is_registered(LspProgressMode::mode_id()));
1180        assert!(registry.is_registered(LspDocumentHighlightMode::mode_id()));
1181        assert!(registry.is_registered(LspSelectionRangeMode::mode_id()));
1182        assert!(registry.is_registered(LspFoldingMode::mode_id()));
1183        assert!(registry.is_registered(LspInlayHintMode::mode_id()));
1184        assert!(registry.is_registered(LspSemanticTokensMode::mode_id()));
1185    }
1186
1187    // ── MO.1: keymap convention checks ──────────────────────────────────────
1188
1189    #[test]
1190    fn lsp_mode_keymap_has_seven_entries() {
1191        let km = LspMode::new().keymap();
1192        assert_eq!(km.entries.len(), 7, "expected exactly 7 LSP nav entries");
1193    }
1194
1195    #[test]
1196    fn lsp_mode_keymap_entries_have_expected_commands() {
1197        let km = LspMode::new().keymap();
1198        let cmds: Vec<&str> = km.entries.iter().filter_map(|e| e.command).collect();
1199        for expected in [
1200            "action:lsp-hover",
1201            "action:lsp-definition",
1202            "action:lsp-declaration",
1203            "action:lsp-type-definition",
1204            "action:lsp-implementation",
1205            "action:lsp-references",
1206            "action:lsp-follow-link",
1207        ] {
1208            assert!(cmds.contains(&expected), "missing command {expected}");
1209        }
1210    }
1211
1212    #[test]
1213    fn lsp_mode_keymap_includes_all_chord_strings() {
1214        let km = LspMode::new().keymap();
1215        let chords: Vec<&str> = km.entries.iter().map(|e| e.chord).collect();
1216        for expected in ["K", "gd", "gD", "gy", "gI", "gr", "gx"] {
1217            assert!(chords.contains(&expected), "missing chord {expected}");
1218        }
1219    }
1220
1221    #[test]
1222    fn lsp_mode_is_minor_with_no_capability_requirements() {
1223        let m = LspMode::new();
1224        assert_eq!(m.kind(), ModeKind::Minor);
1225        assert_eq!(m.required_capabilities(), CapabilitySet::empty());
1226    }
1227
1228    // ── L7: lsp-mode owns the nav handlers (Effect::Lsp boundary) ────────────
1229
1230    #[test]
1231    fn lsp_mode_action_handlers_map_each_nav_chord_to_its_request() {
1232        // The 7 nav chords are mode-owned: each handler closure returns
1233        // the host-owned `Effect::Lsp(LspRequest::…)` the host dispatches
1234        // onto the request substrate. Mirrors L4b's `gl` handler test.
1235        let handlers = LspMode::new().action_handlers();
1236        assert_eq!(handlers.len(), 7, "expected one handler per nav chord");
1237
1238        let services = lattice_mode::ServiceRegistry::new();
1239        let events = EventBus::new();
1240        let ctx = ActionContext {
1241            buffer_id: lattice_protocol::ids::BufferId::new(0),
1242            cursor: lattice_protocol::position::Position::ZERO,
1243            selection: None,
1244            services: &services,
1245            events: &events,
1246            prompt_value: None,
1247            args: lattice_grammar::Args::None,
1248            buffer_locals: None,
1249        };
1250
1251        let expected: &[(&str, LspRequest)] = &[
1252            ("action:lsp-hover", LspRequest::Hover),
1253            ("action:lsp-definition", LspRequest::Definition),
1254            ("action:lsp-declaration", LspRequest::Declaration),
1255            ("action:lsp-type-definition", LspRequest::TypeDefinition),
1256            ("action:lsp-implementation", LspRequest::Implementation),
1257            ("action:lsp-references", LspRequest::References),
1258            ("action:lsp-follow-link", LspRequest::FollowLink),
1259        ];
1260        for (name, req) in expected {
1261            let contribution = handlers
1262                .iter()
1263                .find(|c| c.action_name == *name)
1264                .unwrap_or_else(|| panic!("missing handler for {name}"));
1265            match (contribution.handler)(&ctx) {
1266                Some(Effect::Lsp(got)) => {
1267                    assert_eq!(got, *req, "handler {name} fired the wrong request")
1268                }
1269                other => panic!("handler {name} returned {other:?}, want Effect::Lsp({req:?})"),
1270            }
1271        }
1272    }
1273
1274    // ── L4b: lsp-diagnostics-mode owns gl / ]d / [d + the popup ──────────────
1275
1276    fn diag(
1277        sev: crate::lsp_types::DiagnosticSeverity,
1278        msg: &str,
1279        source: Option<&str>,
1280        code: Option<&str>,
1281    ) -> crate::Diagnostic {
1282        crate::Diagnostic {
1283            range: crate::lsp_types::Range {
1284                start: crate::lsp_types::Position {
1285                    line: 0,
1286                    character: 0,
1287                },
1288                end: crate::lsp_types::Position {
1289                    line: 0,
1290                    character: 1,
1291                },
1292            },
1293            severity: Some(sev),
1294            code: code.map(|c| crate::lsp_types::NumberOrString::String(c.to_string())),
1295            code_description: None,
1296            source: source.map(str::to_string),
1297            message: msg.to_string(),
1298            related_information: None,
1299            tags: None,
1300            data: None,
1301        }
1302    }
1303
1304    #[test]
1305    fn lsp_diagnostics_mode_keymap_owns_the_three_chords() {
1306        let km = LspDiagnosticsMode.keymap();
1307        let pairs: Vec<(&str, &str)> = km
1308            .entries
1309            .iter()
1310            .filter_map(|e| e.command.map(|c| (e.chord, c)))
1311            .collect();
1312        assert!(pairs.contains(&("gl", "action:lsp-diagnostic-popup")));
1313        // `]d` / `[d` bind to the existing jump ex-commands (mode owns
1314        // the binding; the shared jump + echo live in the host).
1315        assert!(pairs.contains(&("]d", "ex:diag-next")));
1316        assert!(pairs.contains(&("[d", "ex:diag-prev")));
1317    }
1318
1319    #[test]
1320    fn lsp_diagnostics_mode_contributes_only_the_popup_handler() {
1321        let handlers = LspDiagnosticsMode.action_handlers();
1322        assert_eq!(handlers.len(), 1);
1323        assert_eq!(handlers[0].action_name, "action:lsp-diagnostic-popup");
1324    }
1325
1326    #[test]
1327    fn format_popup_lines_carries_glyph_message_source_code() {
1328        use crate::lsp_types::DiagnosticSeverity;
1329        let lines = format_diagnostic_popup_lines(&[diag(
1330            DiagnosticSeverity::ERROR,
1331            "mismatched types",
1332            Some("rustc"),
1333            Some("E0308"),
1334        )]);
1335        assert_eq!(lines.len(), 1);
1336        assert_eq!(lines[0].1, 0, "error → rank 0");
1337        assert!(lines[0].0.starts_with('■'), "got {:?}", lines[0]);
1338        assert!(lines[0].0.contains("mismatched types"));
1339        assert!(lines[0].0.contains("[rustc:E0308]"));
1340    }
1341
1342    #[test]
1343    fn format_popup_lines_one_per_diagnostic_empty_for_none() {
1344        use crate::lsp_types::DiagnosticSeverity;
1345        assert!(format_diagnostic_popup_lines(&[]).is_empty());
1346        let lines = format_diagnostic_popup_lines(&[
1347            diag(DiagnosticSeverity::WARNING, "unused", None, None),
1348            diag(DiagnosticSeverity::HINT, "consider", None, None),
1349        ]);
1350        assert_eq!(lines.len(), 2);
1351        assert!(lines[0].0.starts_with('▲'));
1352        assert_eq!(lines[0].1, 1, "warning → rank 1");
1353        assert!(lines[1].0.starts_with('·'));
1354        assert_eq!(lines[1].1, 3, "hint → rank 3");
1355    }
1356
1357    #[test]
1358    fn format_popup_lines_appends_related_count() {
1359        use crate::lsp_types::{
1360            DiagnosticRelatedInformation, DiagnosticSeverity, Location, Position, Range, Uri,
1361        };
1362        use std::str::FromStr;
1363        let mut d = diag(DiagnosticSeverity::ERROR, "boom", None, None);
1364        let loc = Location {
1365            uri: Uri::from_str("file:///x.rs").unwrap(),
1366            range: Range {
1367                start: Position {
1368                    line: 0,
1369                    character: 0,
1370                },
1371                end: Position {
1372                    line: 0,
1373                    character: 0,
1374                },
1375            },
1376        };
1377        d.related_information = Some(vec![
1378            DiagnosticRelatedInformation {
1379                location: loc.clone(),
1380                message: "a".into(),
1381            },
1382            DiagnosticRelatedInformation {
1383                location: loc,
1384                message: "b".into(),
1385            },
1386        ]);
1387        let lines = format_diagnostic_popup_lines(&[d]);
1388        assert!(lines[0].0.contains("(+2 related)"), "got {:?}", lines[0]);
1389    }
1390
1391    #[tokio::test]
1392    async fn lsp_mode_activates_through_registry_as_minor() {
1393        use crate::completion::register_lsp_completion_mode;
1394        use crate::supervisor::LspSupervisor;
1395        use lattice_mode::{ActiveModes, GuardStoreHandle};
1396        use lattice_protocol::ids::BufferId;
1397        let mut registry = ModeRegistry::new();
1398        register_lsp_log_modes(&mut registry);
1399        let sup = LspSupervisor::new(crate::LspLogger::with_defaults());
1400        let lsp_handle = sup.spawn(&tokio::runtime::Handle::current());
1401        register_lsp_completion_mode(&mut registry, lsp_handle);
1402        let mut active = ActiveModes::new();
1403        let guards = GuardStoreHandle::new();
1404        let cfg = Arc::new(lattice_config::ConfigRegistry::new());
1405        let evt = Arc::new(lattice_runtime::EventBus::new());
1406        let svc = Arc::new(lattice_mode::ServiceRegistry::new());
1407        registry
1408            .activate_minor(
1409                &mut active,
1410                &guards,
1411                &cfg,
1412                &evt,
1413                &svc,
1414                BufferId::new(1),
1415                LspMode::mode_id(),
1416                CapabilitySet::empty(),
1417            )
1418            .expect("activate lsp-mode + sub-mode cascade");
1419        // Sync prefix mutated active_modes for the umbrella +
1420        // implied children.
1421        assert!(active.has_minor(LspMode::mode_id()));
1422        assert!(active.has_minor(LspCompletionMode::mode_id()));
1423        assert!(active.has_minor(LspDiagnosticsMode::mode_id()));
1424        assert!(active.has_minor(LspFoldingMode::mode_id()));
1425    }
1426}