Skip to main content

lattice_lsp/
modeline.rs

1//! LSP modeline element (ML.3c) + the shared progress/status store it is
2//! built from. Produced entirely in `lattice-lsp` (the owner) and pushed
3//! over the event bus.
4//!
5//! ## Why a forwarder task + shared store here, not host accumulation
6//!
7//! Decision (A): `$/progress` + `experimental/serverStatus` are LSP
8//! protocol state, so their accumulation belongs in `lattice-lsp`, not
9//! `dispatch.rs`. The LSP *actor* only parses and emits these as events
10//! (`actor.rs`); nothing accumulates them server-side. This module owns
11//! that accumulation in [`LspProgressStore`] (an `ArcSwap`-backed shared
12//! handle):
13//!
14//! - the **forwarder task** subscribes the relevant events, folds them
15//!   into the store, and publishes [`ModelineElementUpdate`] for each
16//!   LSP-attached buffer — exactly as a WASM plugin would
17//!   (`docs/dev/architecture/modeline.md` §6). The host's §12 wake
18//!   forwarder turns each push into an off-keystroke repaint; no producer
19//!   runs on the render path (paramount #1).
20//! - the host reads the **same** store (via the
21//!   [`LspProgressStoreHandle`] it stashes at boot, the pattern it
22//!   already uses for `LspSupervisorHandle`) for `:lsp-progress-cancel`,
23//!   which needs the in-flight cancellable-token list. One accumulator,
24//!   two readers — no duplication, and the accumulation logic + state are
25//!   out of the host.
26//!
27//! Per-buffer keying gates the badge to LSP buffers: content is pushed
28//! keyed `Buffer(id)` for each attached buffer (the badge is process-wide
29//! today, so every attached buffer gets the same value) and cleared on
30//! detach. A non-LSP pane has no `lsp` content and renders nothing.
31//!
32//! Replaces the retired `LspMode::status_line_items` (badge) +
33//! `LspProgressMode::status_line_items` (progress) pull and the host-side
34//! `drain_lsp_progress_events` / `drain_lsp_server_status` accumulators.
35
36use std::collections::{HashMap, HashSet};
37use std::sync::Arc;
38
39use arc_swap::ArcSwap;
40use lattice_core::BufferId;
41use lattice_mode::{
42    ElementContent, ElementId, ModelineElement, ModelineElementUpdate, ModelineKey, ModelineRole,
43    ModelineService, Zone,
44};
45use lattice_runtime::EventBus;
46
47use crate::events::{
48    LspBufferAttached, LspBufferDetached, LspProgressKind, LspProgressUpdate, LspServerHealth,
49    LspServerStatusChanged,
50};
51
52/// Modeline element id for the LSP readiness badge + progress. Owned by
53/// `lattice-lsp` (`feedback_mode_owns_its_surface`).
54pub const LSP_ELEMENT: &str = "lsp";
55
56/// Theme role the badge spans resolve through. `ModelineRole` is a
57/// string key by design (dep-inversion, see its doc); matches the
58/// `modeline.mode_item` element registered in `lattice-theme` (ML.1b).
59const LSP_ROLE: &str = "modeline.mode_item";
60
61/// Register the `lsp` descriptor (ML.3c). PaneLocal (content pushed per
62/// attached buffer), Right zone before `core.position` (priority 5).
63pub fn register_lsp_modeline_element(svc: &ModelineService) {
64    svc.register(ModelineElement::new(
65        ElementId::new(LSP_ELEMENT),
66        Zone::Right,
67        5,
68    ));
69}
70
71/// The relocated LSP progress/status accumulator (decision A). The LSP
72/// actor only emits `$/progress` / `serverStatus` as events; this is the
73/// single place they are folded into a queryable map. Two `ArcSwap`s for
74/// wait-free reads + lock-free single-writer (forwarder) updates, so the
75/// host can read [`Self::progress_snapshot`] from the actor thread for
76/// `:lsp-progress-cancel` while the off-actor forwarder writes.
77#[derive(Debug)]
78pub struct LspProgressStore {
79    progress: ArcSwap<HashMap<(Arc<str>, String), LspProgressUpdate>>,
80    server_status: ArcSwap<HashMap<Arc<str>, LspServerStatusChanged>>,
81}
82
83/// Shared handle to the [`LspProgressStore`] — the host stashes one at
84/// boot; the forwarder writes through its clone.
85pub type LspProgressStoreHandle = Arc<LspProgressStore>;
86
87impl Default for LspProgressStore {
88    fn default() -> Self {
89        Self {
90            progress: ArcSwap::from_pointee(HashMap::new()),
91            server_status: ArcSwap::from_pointee(HashMap::new()),
92        }
93    }
94}
95
96impl LspProgressStore {
97    pub fn new() -> Self {
98        Self::default()
99    }
100
101    /// Fold one `$/progress` event: Begin inserts, Report merges
102    /// title/percentage from the prior entry, End removes. Relocated
103    /// verbatim from the host's old `drain_lsp_progress_events`.
104    pub fn apply_progress(&self, ev: LspProgressUpdate) {
105        self.progress.rcu(|cur| {
106            let mut next = (**cur).clone();
107            let key = (ev.server_id.clone(), ev.token.clone());
108            match ev.kind {
109                LspProgressKind::Begin => {
110                    next.insert(key, ev.clone());
111                }
112                LspProgressKind::Report => {
113                    if let Some(prev) = next.get(&key) {
114                        let title = ev.title.clone().or_else(|| prev.title.clone());
115                        let merged = LspProgressUpdate {
116                            server_id: ev.server_id.clone(),
117                            token: ev.token.clone(),
118                            kind: ev.kind,
119                            title,
120                            message: ev.message.clone(),
121                            percentage: ev.percentage.or(prev.percentage),
122                            cancellable: ev.cancellable,
123                        };
124                        next.insert(key, merged);
125                    } else {
126                        next.insert(key, ev.clone());
127                    }
128                }
129                LspProgressKind::End => {
130                    next.remove(&key);
131                }
132            }
133            next
134        });
135    }
136
137    /// Record the latest `experimental/serverStatus` for a server.
138    pub fn set_server_status(&self, ev: LspServerStatusChanged) {
139        self.server_status.rcu(|cur| {
140            let mut next = (**cur).clone();
141            next.insert(ev.server_id.clone(), ev.clone());
142            next
143        });
144    }
145
146    /// Wait-free snapshot of the in-flight progress map. `:lsp-progress-cancel`
147    /// reads this (host/actor thread) to find cancellable tokens.
148    pub fn progress_snapshot(&self) -> Arc<HashMap<(Arc<str>, String), LspProgressUpdate>> {
149        self.progress.load_full()
150    }
151
152    /// Current `lsp` element content from the accumulated maps.
153    fn content(&self) -> ElementContent {
154        lsp_content(&self.progress.load_full(), &self.server_status.load_full())
155    }
156}
157
158/// Build the `lsp` element content from progress + server-status maps.
159/// Always non-empty (the badge always shows on an attached buffer):
160/// `lsp ✗` on any health error, `lsp ⟳` while any server is busy / a
161/// `$/progress` token is in flight, else `lsp ✓`, with the
162/// highest-percentage in-flight token's `<title> NN%` appended. Glyphs
163/// are BMP-safe (lsp-architecture.md §14). Pure — unit-tested.
164pub fn lsp_content(
165    progress: &HashMap<(Arc<str>, String), LspProgressUpdate>,
166    server_status: &HashMap<Arc<str>, LspServerStatusChanged>,
167) -> ElementContent {
168    let indexing = progress
169        .values()
170        .any(|u| !matches!(u.kind, LspProgressKind::End));
171    let mut any_error = false;
172    let mut any_busy = false;
173    for s in server_status.values() {
174        if matches!(s.health, LspServerHealth::Error) {
175            any_error = true;
176        }
177        if !s.quiescent {
178            any_busy = true;
179        }
180    }
181    let badge = if any_error {
182        "lsp ✗"
183    } else if indexing || any_busy {
184        "lsp ⟳"
185    } else {
186        "lsp ✓"
187    };
188
189    // Highest-percentage in-flight token's detail (MO.4.b parity).
190    let mut best: Option<&LspProgressUpdate> = None;
191    for update in progress.values() {
192        if matches!(update.kind, LspProgressKind::End) {
193            continue;
194        }
195        best = Some(match best {
196            None => update,
197            Some(cur) => {
198                if update.percentage.unwrap_or(0) >= cur.percentage.unwrap_or(0) {
199                    update
200                } else {
201                    cur
202                }
203            }
204        });
205    }
206    let mut text = badge.to_string();
207    if let Some(p) = best {
208        let mut detail = String::new();
209        if let Some(title) = &p.title {
210            detail.push_str(title);
211        }
212        if let Some(pct) = p.percentage {
213            if !detail.is_empty() {
214                detail.push(' ');
215            }
216            detail.push_str(&format!("{pct}%"));
217        }
218        if detail.is_empty() {
219            detail.push_str(&p.token);
220        }
221        text.push(' ');
222        text.push_str(&detail);
223    }
224    ElementContent::text(text, ModelineRole::new(LSP_ROLE))
225}
226
227/// Publish `content` for buffer `buf`'s `lsp` slot.
228fn push(bus: &EventBus, buf: BufferId, content: ElementContent) {
229    bus.publish_typed(ModelineElementUpdate {
230        key: ModelineKey::Buffer(buf),
231        id: ElementId::new(LSP_ELEMENT),
232        content,
233    });
234}
235
236/// Push the current badge content to every attached buffer's `lsp` slot.
237fn broadcast(bus: &EventBus, store: &LspProgressStore, attached: &HashSet<BufferId>) {
238    if attached.is_empty() {
239        return;
240    }
241    let content = store.content();
242    for buf in attached {
243        push(bus, *buf, content.clone());
244    }
245}
246
247/// Spawn the LSP modeline forwarder (ML.3c). Subscribes the four event
248/// types, folds progress/status into the shared `store`, and republishes
249/// the `lsp` element content per attached buffer on every change. The
250/// host wires this at boot with the shared bus + the store handle it also
251/// keeps for `:lsp-progress-cancel` + the LSP runtime handle. A closed
252/// bus (all senders dropped) ends the task cleanly — never panics.
253pub fn spawn_modeline_forwarder(
254    bus: Arc<EventBus>,
255    store: LspProgressStoreHandle,
256    runtime: &tokio::runtime::Handle,
257) {
258    use tokio::sync::mpsc;
259    let (prog_tx, mut prog_rx) = mpsc::unbounded_channel::<LspProgressUpdate>();
260    bus.subscribe_typed(prog_tx);
261    let (status_tx, mut status_rx) = mpsc::unbounded_channel::<LspServerStatusChanged>();
262    bus.subscribe_typed(status_tx);
263    let (attach_tx, mut attach_rx) = mpsc::unbounded_channel::<LspBufferAttached>();
264    bus.subscribe_typed(attach_tx);
265    let (detach_tx, mut detach_rx) = mpsc::unbounded_channel::<LspBufferDetached>();
266    bus.subscribe_typed(detach_tx);
267
268    runtime.spawn(async move {
269        let mut attached: HashSet<BufferId> = HashSet::new();
270        loop {
271            tokio::select! {
272                ev = prog_rx.recv() => match ev {
273                    Some(ev) => {
274                        store.apply_progress(ev);
275                        broadcast(&bus, &store, &attached);
276                    }
277                    None => break,
278                },
279                ev = status_rx.recv() => match ev {
280                    Some(ev) => {
281                        store.set_server_status(ev);
282                        broadcast(&bus, &store, &attached);
283                    }
284                    None => break,
285                },
286                ev = attach_rx.recv() => match ev {
287                    Some(ev) => {
288                        let buf = BufferId(ev.id.raw() as u32);
289                        attached.insert(buf);
290                        // Seed the freshly-attached buffer with current state.
291                        push(&bus, buf, store.content());
292                    }
293                    None => break,
294                },
295                ev = detach_rx.recv() => match ev {
296                    Some(ev) => {
297                        let buf = BufferId(ev.id.raw() as u32);
298                        attached.remove(&buf);
299                        // Empty content clears the slot (badge disappears).
300                        push(&bus, buf, ElementContent::default());
301                    }
302                    None => break,
303                },
304            }
305        }
306    });
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn prog(
314        server: &str,
315        token: &str,
316        kind: LspProgressKind,
317        title: Option<&str>,
318        pct: Option<u32>,
319    ) -> LspProgressUpdate {
320        LspProgressUpdate {
321            server_id: Arc::from(server),
322            token: token.to_string(),
323            kind,
324            title: title.map(String::from),
325            message: None,
326            percentage: pct,
327            cancellable: false,
328        }
329    }
330
331    fn status(server: &str, quiescent: bool, health: LspServerHealth) -> LspServerStatusChanged {
332        LspServerStatusChanged {
333            server_id: Arc::from(server),
334            quiescent,
335            health,
336            message: None,
337        }
338    }
339
340    /// Quiescent, no progress → `lsp ✓`.
341    #[test]
342    fn badge_ok_when_quiescent() {
343        let mut ss = HashMap::new();
344        ss.insert(
345            Arc::<str>::from("rust"),
346            status("rust", true, LspServerHealth::Ok),
347        );
348        assert_eq!(lsp_content(&HashMap::new(), &ss).plain(), "lsp ✓");
349    }
350
351    /// Non-quiescent server OR in-flight progress → `lsp ⟳`, with the
352    /// highest-percentage token's `<title> NN%` appended.
353    #[test]
354    fn badge_busy_with_progress_detail() {
355        let mut p = HashMap::new();
356        p.insert(
357            (Arc::<str>::from("rust"), "tok1".to_string()),
358            prog(
359                "rust",
360                "tok1",
361                LspProgressKind::Report,
362                Some("indexing"),
363                Some(40),
364            ),
365        );
366        p.insert(
367            (Arc::<str>::from("rust"), "tok2".to_string()),
368            prog(
369                "rust",
370                "tok2",
371                LspProgressKind::Begin,
372                Some("building"),
373                Some(80),
374            ),
375        );
376        // Highest percentage (80%) wins the detail.
377        assert_eq!(
378            lsp_content(&p, &HashMap::new()).plain(),
379            "lsp ⟳ building 80%"
380        );
381    }
382
383    /// Health error wins over busy/quiescent → `lsp ✗`.
384    #[test]
385    fn badge_error_takes_priority() {
386        let mut ss = HashMap::new();
387        ss.insert(
388            Arc::<str>::from("rust"),
389            status("rust", false, LspServerHealth::Error),
390        );
391        assert_eq!(lsp_content(&HashMap::new(), &ss).plain(), "lsp ✗");
392    }
393
394    /// The store folds Begin/Report/End: merges title + percentage,
395    /// removes ended tokens; the cancel-facing snapshot reflects it.
396    #[test]
397    fn store_folds_progress_and_snapshots() {
398        let store = LspProgressStore::new();
399        store.apply_progress(prog(
400            "rust",
401            "t",
402            LspProgressKind::Begin,
403            Some("scan"),
404            None,
405        ));
406        // Report without a title keeps the Begin title; adds percentage.
407        store.apply_progress(prog("rust", "t", LspProgressKind::Report, None, Some(50)));
408        let snap = store.progress_snapshot();
409        let entry = snap
410            .get(&(Arc::<str>::from("rust"), "t".to_string()))
411            .unwrap();
412        assert_eq!(entry.title.as_deref(), Some("scan"));
413        assert_eq!(entry.percentage, Some(50));
414        // The badge reflects the in-flight token (busy).
415        assert_eq!(store.content().plain(), "lsp ⟳ scan 50%");
416        // End removes it; snapshot empties.
417        store.apply_progress(prog("rust", "t", LspProgressKind::End, None, None));
418        assert!(store.progress_snapshot().is_empty());
419    }
420
421    /// The `lsp` descriptor is Right zone, priority 5 (before
422    /// `core.position` at 10).
423    #[test]
424    fn register_lsp_element_is_right_zone() {
425        let svc = ModelineService::new();
426        register_lsp_modeline_element(&svc);
427        let snap = svc.snapshot();
428        let el = snap
429            .registry
430            .get(&ElementId::new(LSP_ELEMENT))
431            .expect("lsp descriptor");
432        assert_eq!(el.zone, Zone::Right);
433        assert_eq!(el.priority, 5);
434    }
435}