Skip to main content

lattice_plugin_trace/
mode.rs

1//! PO.4.1 — `plugin-trace-mode`: the major mode backing the plugin
2//! boundary-trace buffers.
3//!
4//! One mode serves BOTH surfaces (design §6). The `:plugin-trace` ex-command
5//! opens the shared `*plugin-trace*` firehose; PO.4.2 opens the per-plugin
6//! `*plugin-trace:<name>*` view via the `:plugins` manager `t` drill-in. The mode
7//! parses its own buffer name in [`on_activate`] to decide the filter — the
8//! `lsp-server-log-mode` precedent — so the two views share one code path,
9//! differing only by an `Option<u32>` plugin filter (paramount #3: the split is
10//! *data*, not a second mode).
11//!
12//! `on_activate` (the `lsp-log-mode` structure): seed the buffer from the tracer
13//! ring, subscribe to [`PluginTracePushed`], and spawn an OFF-thread drain that
14//! formats + appends the live tail. Nothing is formatted or written on the
15//! UI/actor thread (design §4 / paramount #1). Read-only + no-file, so `:w`
16//! won't try to save and the user can't edit; the owner writes through
17//! `apply_edit_batch` (which bypasses the modal read-only gate by construction).
18//!
19//! [`on_activate`]: PluginTraceMode::on_activate
20
21use std::sync::Arc;
22
23use lattice_mode::{
24    BufferStoreHandle, CapabilitySet, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind,
25    OptionOverrideSet, Subscription,
26};
27use lattice_plugin_host::{PluginTracePushed, PluginTraceRecord, PluginTracerHandle};
28use lattice_plugin_loader::PluginLoaderHandle;
29use lattice_runtime::Document;
30
31use crate::format::{TRACE_MODE_ID, format_trace_line, parse_per_plugin_name};
32
33/// Which records a trace buffer shows, decided once at activation from the
34/// buffer's synthetic name (design §6).
35#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36enum TraceFilter {
37    /// `*plugin-trace*` — every plugin's records, interleaved (the firehose).
38    Shared,
39    /// `*plugin-trace:<name>*` where `<name>` resolved to this host-issued id.
40    Plugin(u32),
41    /// `*plugin-trace:<name>*` whose `<name>` is not a loaded plugin — an empty
42    /// view (never the firehose, which would mislabel the buffer).
43    Unknown,
44}
45
46/// The `*plugin-trace*` / `*plugin-trace:<name>*` buffers' major mode.
47pub struct PluginTraceMode;
48
49impl PluginTraceMode {
50    pub fn mode_id() -> ModeId {
51        ModeId::new(TRACE_MODE_ID)
52    }
53}
54
55/// Append `text` (already newline-joined + trailing newline) to the end of the
56/// buffer. Runs on the caller's task; callers spawn it off the actor thread.
57pub(crate) async fn append_text(handle: &Arc<dyn Document>, text: String) {
58    if text.is_empty() {
59        return;
60    }
61    let snap = handle.snapshot();
62    // CV.3: ROPE space — the append point is the very end of the
63    // buffer, past the terminating newline.
64    let last_line = snap.buffer.rope_line_count().saturating_sub(1);
65    let line_text = snap.buffer.line(last_line).unwrap_or_default();
66    let pos = lattice_protocol::position::Position::new(last_line, line_text.len() as u32);
67    let edit = lattice_protocol::edit::Edit::insert(pos, text);
68    let _ = handle.apply_edit_batch(vec![edit]).await;
69}
70
71/// Whether a record belongs in a view with `filter`.
72fn keep(record: &PluginTraceRecord, filter: TraceFilter) -> bool {
73    match filter {
74        TraceFilter::Shared => true,
75        TraceFilter::Plugin(id) => record.plugin == id,
76        TraceFilter::Unknown => false,
77    }
78}
79
80/// Join the matching records into buffer text (one `format_trace_line` per line,
81/// trailing newline). Empty when nothing matches.
82fn render_records(records: &[PluginTraceRecord], filter: TraceFilter) -> String {
83    let mut text = String::new();
84    for record in records.iter().filter(|r| keep(r, filter)) {
85        text.push_str(&format_trace_line(record));
86        text.push('\n');
87    }
88    text
89}
90
91/// Resolve the buffer's filter from its synthetic name: `*plugin-trace*` →
92/// `Shared`; `*plugin-trace:<name>*` → `Plugin(id)` via the loader's
93/// `plugin_status()` name→id map, or `Unknown` if `<name>` isn't loaded.
94fn resolve_filter(name: &str, loader: Option<&PluginLoaderHandle>) -> TraceFilter {
95    let Some(plugin_name) = parse_per_plugin_name(name) else {
96        return TraceFilter::Shared;
97    };
98    let id = loader.and_then(|l| {
99        l.plugin_status()
100            .into_iter()
101            .find(|s| s.name == plugin_name)
102            .map(|s| s.id)
103    });
104    id.map_or(TraceFilter::Unknown, TraceFilter::Plugin)
105}
106
107impl Mode for PluginTraceMode {
108    type Guard = Option<Subscription>;
109
110    fn id(&self) -> ModeId {
111        Self::mode_id()
112    }
113
114    fn kind(&self) -> ModeKind {
115        // Content-type identity of the trace buffers — a major mode, like
116        // `lsp-log-mode` / `plugins-mode`.
117        ModeKind::Major
118    }
119
120    /// MG.RO: `read-only-mode` is where the operator gate actually is.
121    ///
122    /// `ReadOnly = true` above stops Insert-mode TYPING and nothing else — it
123    /// is read by `read_only_edit_rejected`, which guards the char path, while
124    /// a `Document`'s grammar dispatch applies its own edits and hands the host
125    /// an already-applied `Effect::Edits`. So `x` deleted a character out of
126    /// this buffer while it reported itself read-only. Verified, not inferred.
127    ///
128    /// `read-only-mode` carries the option AND the `invocation_runner`
129    /// (`Editor::run_read_only_motion`): motions move, `:` and `/` fall
130    /// through, mutating operators echo instead of silently editing. Declared
131    /// on the MAJOR because an implied mode is followed from the mode being
132    /// activated.
133    fn implies(&self) -> &[lattice_mode::ModeId] {
134        static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
135        IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
136    }
137
138    fn options(&self) -> OptionOverrideSet {
139        lattice_config::overrides! {
140            lattice_config::ReadOnly = true,
141            lattice_config::NoFile = true,
142        }
143    }
144
145    fn required_capabilities(&self) -> CapabilitySet {
146        CapabilitySet::empty()
147    }
148
149    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
150        Box::pin(async move {
151            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
152            let Some(store) = ctx.service::<BufferStoreHandle>() else {
153                return Ok(None);
154            };
155            let Some(handle) = store.handle_for(buffer_id) else {
156                return Ok(None);
157            };
158            let Ok(runtime) = tokio::runtime::Handle::try_current() else {
159                return Ok(None);
160            };
161            let Some(tracer) = ctx.service::<PluginTracerHandle>() else {
162                // No tracer wired (a test harness without plugin support) — the
163                // buffer stays empty, never a panic.
164                return Ok(None);
165            };
166
167            // The filter is decided once, from the buffer name: `*plugin-trace*` →
168            // the firehose; `*plugin-trace:<name>*` → that plugin (or an empty
169            // `Unknown` view if it isn't loaded), resolved via the loader.
170            let name = store.name_for(buffer_id).unwrap_or_default();
171            let filter = resolve_filter(&name, ctx.service::<PluginLoaderHandle>().as_deref());
172
173            // Subscribe to the live tail FIRST (cheap + synchronous), so records
174            // pushed while the seed renders are buffered in the channel rather than
175            // lost in the gap between snapshot and subscription. A record in the
176            // tiny [subscribe, snapshot] window may then appear once more in the
177            // tail — acceptable for a best-effort trace log, and strictly better
178            // than dropping it.
179            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<PluginTracePushed>();
180            let sub_id = ctx.events().subscribe_typed::<PluginTracePushed>(tx);
181            let bus_handle = ctx.events_handle();
182
183            // ONE off-thread task does BOTH the seed and the live tail, so the
184            // O(ring) snapshot-clone + `render_records` format NEVER runs on the
185            // actor thread (paramount #1 — the future's synchronous prefix, which
186            // the cascade polls inline, must not do document-proportional work).
187            // Seed first (pre-existing records), then drain the tail in order.
188            let tracer = tracer.clone();
189            runtime.spawn(async move {
190                let snapshot = match filter {
191                    TraceFilter::Plugin(id) => tracer.snapshot_plugin(id),
192                    TraceFilter::Shared => tracer.snapshot_global(),
193                    TraceFilter::Unknown => Vec::new(),
194                };
195                let seed = render_records(&snapshot, filter);
196                if !seed.is_empty() {
197                    append_text(&handle, seed).await;
198                }
199                // The `LspLogPushed` drain, verbatim: batch a burst, format, append.
200                while let Some(first) = rx.recv().await {
201                    let mut batch = vec![first.record];
202                    while let Ok(more) = rx.try_recv() {
203                        batch.push(more.record);
204                    }
205                    let text = render_records(&batch, filter);
206                    if text.is_empty() {
207                        continue;
208                    }
209                    append_text(&handle, text).await;
210                }
211            });
212
213            Ok(Some(Subscription::new(bus_handle, sub_id)))
214        })
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use lattice_plugin_host::{Direction, PluginSeam, TraceLevel, TraceOutcome};
222
223    fn rec(plugin: u32) -> PluginTraceRecord {
224        PluginTraceRecord {
225            plugin,
226            seam: PluginSeam::Grammar,
227            direction: Direction::GuestExport,
228            call: "apply-motion".into(),
229            level: TraceLevel::Debug,
230            outcome: TraceOutcome::Ok {
231                micros: 5,
232                fuel_delta: 0,
233            },
234            detail: None,
235        }
236    }
237
238    #[test]
239    fn the_shared_view_keeps_every_plugins_records() {
240        let records = [rec(1), rec(2), rec(1)];
241        let text = render_records(&records, TraceFilter::Shared);
242        assert_eq!(text.lines().count(), 3);
243    }
244
245    #[test]
246    fn a_plugin_filter_keeps_only_that_plugin() {
247        let records = [rec(1), rec(2), rec(1)];
248        let text = render_records(&records, TraceFilter::Plugin(1));
249        assert_eq!(text.lines().count(), 2, "two records for plugin 1");
250        assert!(text.lines().all(|l| l.contains("[plugin:1]")));
251    }
252
253    #[test]
254    fn no_matches_render_empty() {
255        // A plugin filter with no matching records.
256        assert!(render_records(&[rec(1)], TraceFilter::Plugin(9)).is_empty());
257        // The `Unknown` view (an unloaded plugin name) keeps nothing, even
258        // records that exist.
259        assert!(render_records(&[rec(1), rec(2)], TraceFilter::Unknown).is_empty());
260        assert!(render_records(&[], TraceFilter::Shared).is_empty());
261    }
262
263    #[test]
264    fn the_shared_name_resolves_to_the_firehose_without_a_loader() {
265        assert_eq!(
266            resolve_filter("*plugin-trace*", None),
267            TraceFilter::Shared,
268            "the shared name never needs the loader"
269        );
270    }
271
272    #[test]
273    fn an_unresolvable_per_plugin_name_is_the_unknown_view() {
274        // Per-plugin name but no loader (or plugin not loaded) → empty, NOT the
275        // firehose (which would mislabel the buffer).
276        assert_eq!(
277            resolve_filter("*plugin-trace:ghost*", None),
278            TraceFilter::Unknown
279        );
280    }
281
282    #[test]
283    fn the_mode_is_a_read_only_no_file_major() {
284        let m = PluginTraceMode;
285        assert_eq!(m.kind(), ModeKind::Major);
286        assert_eq!(m.required_capabilities(), CapabilitySet::empty());
287        // Read-only + no-file so `:w` is inert and the user can't edit — the two
288        // overrides the `overrides!` macro emits (same as `plugins-mode`).
289        assert_eq!(m.options().len(), 2, "ReadOnly + NoFile overrides");
290    }
291}