Skip to main content

lattice_plugin_host/
error_parser_host.rs

1//! CM.6: the host side of the plugin-contributed compilation-parser seam.
2//!
3//! Design: [`compilation-mode.md`](../../../docs/dev/architecture/compilation-mode.md)
4//! §5 (the parser registry as an extensibility seam) and the `error-parser`
5//! WIT interface.
6//!
7//! A plugin implementing `error-parser-plugin` gets fed every captured
8//! compilation line and returns the diagnostics it recognised. The native
9//! `CompilationParser` trait is a one-method interface, so the WIT world
10//! mirrors it rather than inventing a second shape for the same job.
11//!
12//! ## Sync, unlike most seams here
13//!
14//! The other async seams here exist because their work is genuinely
15//! concurrent — a picker query, an event fan-out. Parsing one line is not:
16//! it is a pure function of the line plus the guest's pending state, called
17//! in strict arrival order by a single reader. An async call per line would
18//! buy nothing and cost a suspend per line of build output.
19//!
20//! It is off the UI and actor threads (the compilation reader owns it), but
21//! it *is* on the critical path of a fast producer, so it carries the same
22//! Reflex-class budget the grammar seam uses rather than the generous
23//! lifecycle default.
24//!
25//! ## Guest output is untrusted
26//!
27//! Every returned entry is validated host-side and dropped on failure, never
28//! trapped on. A guest returning an empty path or a line number that cannot
29//! be a line number is a buggy plugin, and a buggy plugin must cost its own
30//! entries — not the build.
31
32use lattice_protocol::error_list::{ErrorEntry, ErrorSeverity};
33
34use crate::{Component, PluginBudget, PluginHost, PluginHostError, PluginManifest, TrustTier};
35
36pub(crate) mod bindings {
37    wasmtime::component::bindgen!({
38        world: "error-parser-plugin",
39        path: "../lattice-wit/wit",
40        // Sync exports — see the module docs. `feed` runs once per captured
41        // line and must not suspend.
42    });
43}
44
45use bindings::lattice::plugin_host::error_parser as wit;
46
47/// A plugin-backed compilation parser, ready to be registered into the
48/// native `ParserRegistry`.
49///
50/// Holds its own `Store`, so its pending multi-line state is per-instance
51/// exactly like a native parser's `&mut self`.
52pub struct WasmErrorParser {
53    store: wasmtime::Store<crate::PluginState>,
54    bindings: bindings::ErrorParserPlugin,
55    plugin: String,
56    /// Re-armed before EVERY `feed` / `reset`. Fuel is a per-call budget, not
57    /// a per-instance one.
58    budget: PluginBudget,
59    /// Set once the guest traps. A trapped component is dead until reloaded
60    /// (wasmtime offers no rollback), and continuing to call it would trap
61    /// once per line for the rest of the build.
62    poisoned: bool,
63}
64
65impl std::fmt::Debug for WasmErrorParser {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("WasmErrorParser")
68            .field("plugin", &self.plugin)
69            .field("poisoned", &self.poisoned)
70            .finish()
71    }
72}
73
74impl WasmErrorParser {
75    /// Re-arm the per-call budget.
76    ///
77    /// **Fuel is spent per call, and a parser is fed every captured line of a
78    /// build.** Arming once at instantiate — which is correct for a
79    /// declare-once seam — makes the parser work for the first stretch of a
80    /// build and then trap on exhaustion, after which it silently drops every
81    /// remaining diagnostic. The cliff is far enough out that short builds
82    /// never reach it, which is exactly what made this survive review: the
83    /// bug only shows on the large, noisy builds where the error list matters
84    /// most.
85    ///
86    /// Returns false when re-arming failed (the parser is then poisoned).
87    fn rearm(&mut self) -> bool {
88        if let Err(e) = crate::arm_store(&mut self.store, self.budget) {
89            self.poisoned = true;
90            tracing::warn!(
91                plugin = %self.plugin,
92                error = %e,
93                "could not re-arm an error-parser's budget; it will contribute nothing further"
94            );
95            return false;
96        }
97        true
98    }
99
100    /// Drop pending multi-line state — the start of a compilation run.
101    pub fn reset(&mut self) {
102        if self.poisoned {
103            return;
104        }
105        if !self.rearm() {
106            return;
107        }
108        if let Err(e) = self.bindings.call_reset(&mut self.store) {
109            self.poison("reset", &e);
110        }
111    }
112
113    /// Feed one line; return the entries it completed.
114    ///
115    /// A trap poisons the parser and yields nothing from then on: the plugin
116    /// stops contributing, the build keeps streaming, and the other parsers
117    /// (native and plugin) carry on.
118    pub fn feed(&mut self, line: &str) -> Vec<ErrorEntry> {
119        if self.poisoned {
120            return Vec::new();
121        }
122        if !self.rearm() {
123            return Vec::new();
124        }
125        match self.bindings.call_feed(&mut self.store, line) {
126            Ok(entries) => entries
127                .into_iter()
128                .filter_map(|e| validate(&self.plugin, e))
129                .collect(),
130            Err(e) => {
131                self.poison("feed", &e);
132                Vec::new()
133            }
134        }
135    }
136
137    /// The plugin this parser came from — for logs and for
138    /// [`WasmErrorParserFactory`]'s provenance.
139    pub fn plugin(&self) -> &str {
140        &self.plugin
141    }
142
143    fn poison(&mut self, func: &str, error: &wasmtime::Error) {
144        self.poisoned = true;
145        tracing::warn!(
146            plugin = %self.plugin,
147            func,
148            error = %error,
149            "error-parser plugin trapped; it will contribute nothing further this session"
150        );
151    }
152}
153
154/// CM.6b: a plugin parser IS a [`CompilationParser`] — the compilation
155/// crate's registry, its dedup, and its ordering treat it exactly like a
156/// native one, which is the whole point of mirroring the native trait in
157/// the WIT world.
158///
159/// [`WasmErrorParser::reset`] has no counterpart here because the factory
160/// below mints a fresh instance per run: there is never pending state to
161/// drop. `reset` stays on the inherent impl because it is part of the WIT
162/// world's contract (a future host that pools instances needs it) and the
163/// `error_parser` host test drives it.
164impl lattice_compilation::CompilationParser for WasmErrorParser {
165    fn feed(&mut self, line: &str) -> Vec<ErrorEntry> {
166        WasmErrorParser::feed(self, line)
167    }
168}
169
170/// CM.6b: mints a [`WasmErrorParser`] per compilation pipe reader.
171///
172/// Holds everything `spawn_error_parser` needs, so a reader can ask for an
173/// instance long after load. `Component` is `Arc`-backed, so cloning it per
174/// run is a refcount bump, not a recompile — the expensive Cranelift work
175/// happened once at load.
176///
177/// Why a factory at all: see
178/// [`lattice_compilation::CompilationParserFactory`]. The short version is
179/// that a `Store` cannot be shared across the two reader threads.
180pub struct WasmErrorParserFactory {
181    host: std::sync::Arc<PluginHost>,
182    component: Component,
183    manifest: PluginManifest,
184    tier: TrustTier,
185    budget: PluginBudget,
186    /// Host-issued plugin id — teardown removes this plugin's factories by
187    /// it (provenance IS the token).
188    plugin_id: u64,
189}
190
191impl std::fmt::Debug for WasmErrorParserFactory {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        f.debug_struct("WasmErrorParserFactory")
194            .field("plugin", &self.manifest.id)
195            .field("plugin_id", &self.plugin_id)
196            .finish()
197    }
198}
199
200impl WasmErrorParserFactory {
201    /// Bundle a compiled `error-parser` component with what it takes to
202    /// instantiate one.
203    pub fn new(
204        host: std::sync::Arc<PluginHost>,
205        component: Component,
206        manifest: PluginManifest,
207        tier: TrustTier,
208        budget: PluginBudget,
209        plugin_id: u64,
210    ) -> Self {
211        Self {
212            host,
213            component,
214            manifest,
215            tier,
216            budget,
217            plugin_id,
218        }
219    }
220}
221
222impl lattice_compilation::CompilationParserFactory for WasmErrorParserFactory {
223    fn plugin_id(&self) -> u64 {
224        self.plugin_id
225    }
226
227    fn create(&self) -> Option<Box<dyn lattice_compilation::CompilationParser>> {
228        match self
229            .host
230            .spawn_error_parser(&self.component, &self.manifest, self.tier, self.budget)
231        {
232            Ok(parser) => Some(Box::new(parser)),
233            Err(e) => {
234                // A build must never fail because a plugin would not
235                // instantiate. `warn!` and not `info!`/`error!`: it is
236                // once per reader per run, user-actionable, and the run
237                // itself is unharmed.
238                tracing::warn!(
239                    plugin = %self.manifest.id,
240                    error = %e,
241                    "error-parser plugin failed to instantiate; \
242                     this compilation runs without it"
243                );
244                None
245            }
246        }
247    }
248}
249
250/// Convert a guest entry into a host one, or drop it.
251///
252/// The two rejections are the ones a buggy guest actually produces: an empty
253/// path (nothing to navigate to) and a line/col that would overflow when the
254/// host adds its own offsets. Both are logged at `debug!` — a per-line
255/// diagnostic, so `info!` would flood a noisy build (see the diagnostic-logs
256/// rule in CLAUDE.md).
257fn validate(plugin: &str, e: wit::Entry) -> Option<ErrorEntry> {
258    if e.path.trim().is_empty() {
259        tracing::debug!(
260            plugin,
261            "error-parser returned an entry with no path; skipping"
262        );
263        return None;
264    }
265    // A line number near u32::MAX is not a line number; it is an underflow in
266    // the guest's own 1-based → 0-based conversion.
267    if e.line == u32::MAX || e.col == u32::MAX {
268        tracing::debug!(
269            plugin,
270            line = e.line,
271            col = e.col,
272            "error-parser returned an out-of-range position; skipping"
273        );
274        return None;
275    }
276    Some(ErrorEntry {
277        path: std::path::PathBuf::from(e.path),
278        line: e.line,
279        col: e.col,
280        severity: match e.severity {
281            wit::Severity::Error => ErrorSeverity::Error,
282            wit::Severity::Warning => ErrorSeverity::Warning,
283            wit::Severity::Info => ErrorSeverity::Info,
284            wit::Severity::Note => ErrorSeverity::Note,
285        },
286        message: e.message,
287    })
288}
289
290impl PluginHost {
291    /// CM.6b: allocate this plugin's identity and hand back the factory
292    /// the compilation readers mint from.
293    ///
294    /// Instantiates **once** here and throws the result away. That costs
295    /// one store at load and buys a load that fails loudly: an
296    /// `error-parser` component that cannot instantiate is a broken
297    /// plugin, and the alternative is a load that reports success and
298    /// then contributes nothing to every build forever — the exact
299    /// silent no-op the `NotWired` placeholder existed to avoid.
300    ///
301    /// Takes `&Arc<Self>` because the factory outlives the call: a reader
302    /// asks it for an instance at the start of every compilation run,
303    /// long after load.
304    pub fn error_parser_factory(
305        self: &std::sync::Arc<Self>,
306        component: &Component,
307        manifest: &PluginManifest,
308        tier: TrustTier,
309        budget: PluginBudget,
310    ) -> Result<(crate::PluginId, WasmErrorParserFactory), PluginHostError> {
311        let probe = self.spawn_error_parser(component, manifest, tier, budget)?;
312        drop(probe);
313        let id = self.alloc_id();
314        Ok((
315            id,
316            WasmErrorParserFactory::new(
317                self.clone(),
318                component.clone(),
319                manifest.clone(),
320                tier,
321                budget,
322                id.0 as u64,
323            ),
324        ))
325    }
326
327    /// CM.6: instantiate `component` as an error-parser and hand back a
328    /// parser the compilation reader can drive.
329    ///
330    /// Uses the **sync** linker: `feed` runs once per captured line and must
331    /// not suspend (see the module docs).
332    pub fn spawn_error_parser(
333        &self,
334        component: &Component,
335        manifest: &PluginManifest,
336        tier: TrustTier,
337        budget: PluginBudget,
338    ) -> Result<WasmErrorParser, PluginHostError> {
339        let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
340        for denied in &outcome.denied {
341            tracing::warn!(
342                plugin = %manifest.id,
343                capability = ?denied,
344                "error-parser plugin loaded with a withheld capability (reduced function)"
345            );
346        }
347        let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
348        let bindings =
349            // The SYNC linker. It is named for grammar because grammar was
350            // its first user, but it is the host's one sync import table —
351            // sync WASI plus the sync host funcs — and instantiating against
352            // a superset of a world's imports is exactly what the multi-seam
353            // path already does.
354            bindings::ErrorParserPlugin::instantiate(&mut store, component, &self.grammar_linker)
355                .map_err(|e| PluginHostError::Instantiate(e.into()))?;
356        crate::arm_store(&mut store, budget)?;
357        Ok(WasmErrorParser {
358            store,
359            bindings,
360            plugin: manifest.id.clone(),
361            budget,
362            poisoned: false,
363        })
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    fn entry(path: &str, line: u32, col: u32) -> wit::Entry {
372        wit::Entry {
373            path: path.to_string(),
374            line,
375            col,
376            severity: wit::Severity::Error,
377            message: "boom".to_string(),
378        }
379    }
380
381    #[test]
382    fn a_well_formed_entry_converts() {
383        let got = validate("p", entry("src/main.rs", 9, 4)).expect("accepted");
384        assert_eq!(got.path, std::path::PathBuf::from("src/main.rs"));
385        assert_eq!((got.line, got.col), (9, 4));
386        assert_eq!(got.severity, ErrorSeverity::Error);
387    }
388
389    #[test]
390    fn an_entry_with_no_path_is_dropped() {
391        // Nothing to navigate to — it would be a quickfix row that goes
392        // nowhere.
393        assert!(validate("p", entry("", 1, 1)).is_none());
394        assert!(validate("p", entry("   ", 1, 1)).is_none());
395    }
396
397    #[test]
398    fn an_out_of_range_position_is_dropped() {
399        // What a guest's own 1-based → 0-based conversion produces when it
400        // underflows on line 0.
401        assert!(validate("p", entry("a.rs", u32::MAX, 0)).is_none());
402        assert!(validate("p", entry("a.rs", 0, u32::MAX)).is_none());
403    }
404
405    #[test]
406    fn every_severity_maps() {
407        for (wit_sev, host_sev) in [
408            (wit::Severity::Error, ErrorSeverity::Error),
409            (wit::Severity::Warning, ErrorSeverity::Warning),
410            (wit::Severity::Info, ErrorSeverity::Info),
411            (wit::Severity::Note, ErrorSeverity::Note),
412        ] {
413            let e = wit::Entry {
414                severity: wit_sev,
415                ..entry("a.rs", 0, 0)
416            };
417            assert_eq!(validate("p", e).unwrap().severity, host_sev);
418        }
419    }
420}