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}