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}