Skip to main content

lattice_compilation/
parser.rs

1//! CM.3a (2026-07-22): the compilation **parser registry** — the
2//! extensibility seam that turns streamed compiler-output lines into
3//! navigable [`ErrorEntry`]s (emacs `compilation-error-regexp-alist`,
4//! on Lattice's substrate).
5//!
6//! A [`CompilationParser`] is a stateful matcher over lines fed in
7//! arrival order. Multi-line formats (cargo/rustc emits an
8//! `error[E0308]: …` header then a following `  --> path:line:col`
9//! location line) are supported by holding a *pending* severity +
10//! message and emitting the entry when the location line arrives.
11//!
12//! The [`ParserRegistry`] feeds each line to every registered parser
13//! and concatenates the results. For CM.3a the active set is the
14//! built-in cargo/rustc + gnu-style pair; Phase 7 opens contribution
15//! to WASM plugins via this same `Vec<Box<dyn CompilationParser>>`
16//! seam (`docs/dev/architecture/compilation-mode.md` §5).
17//!
18//! ## API for CM.3b
19//!
20//! CM.3b (in-buffer decoration + `<CR>`-jump) reuses the exact
21//! line→entry mapping here: feed a `*compilation*` buffer line to a
22//! [`ParserRegistry`] and the returned [`ErrorEntry`]s carry the
23//! 0-based `line`/`col` + severity + message for the matched source
24//! location. The parser is the single source of truth for "is this
25//! log line a navigable error, and where does it point?".
26
27use std::path::PathBuf;
28
29use lattice_protocol::error_list::{ErrorEntry, ErrorSeverity};
30
31/// CM.3b: a source location parsed out of a single `*compilation*`
32/// buffer line by [`parse_location_line`] for the `<CR>`-jump. `line` /
33/// `col` are **0-based** — the error substrate +
34/// `Editor::jump_to_file_line_col` convention.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct CompilationLocation {
37    pub path: PathBuf,
38    /// 0-based source line.
39    pub line: u32,
40    /// 0-based source column (`0` for gnu short-form lines with no
41    /// column).
42    pub col: u32,
43}
44
45/// CM.3b: parse a source location out of ONE `*compilation*` buffer
46/// line — the `<CR>`-jump seam. Runs the built-in rustc-`-->` and
47/// gnu-style location regexes (the same compiled patterns the
48/// streaming parsers use, via [`crate::parsers::match_location_line`])
49/// against the line and returns the first match, converting 1-based
50/// line/col to 0-based. `None` when the line carries no navigable
51/// location (progress, summary, backtraces, prose).
52///
53/// Deliberately independent of the streaming [`ParserRegistry`]'s
54/// pending multi-line state: stdout/stderr interleave in the buffer,
55/// so a buffer-line→entry map isn't reliable. Parsing the cursor line
56/// directly is interleaving-proof and covers both the gnu lines and
57/// the cargo `-->` location line. CM.3c's severity gutter decoration
58/// reuses this exact function.
59pub fn parse_location_line(line: &str) -> Option<CompilationLocation> {
60    let (path, line0, col0) = crate::parsers::match_location_line(line)?;
61    Some(CompilationLocation {
62        path,
63        line: line0,
64        col: col0,
65    })
66}
67
68/// CM.3c: the severity a single `*compilation*` line declares, or `None`
69/// when it carries no severity keyword. Reuses the built-in rustc-header
70/// and gnu-full-form patterns (via [`crate::parsers::match_severity`]) —
71/// the same compiled regexes the streaming parsers use. Keyword-driven:
72/// progress / summary / prose / location (`-->`) / gnu-short lines return
73/// `None`. The severity gutter mark lands on the line where the severity is
74/// textually visible (the rustc header line; the gnu full-form line),
75/// mirroring emacs `compilation-mode`.
76pub fn match_severity(line: &str) -> Option<ErrorSeverity> {
77    crate::parsers::match_severity(line)
78}
79
80/// CM.3c: scan a block of streamed text for severity lines, returning
81/// `(absolute_line, severity)` for each match. `base_line` is the 0-based
82/// buffer line number the block's FIRST line lands on; line `i` of the
83/// block (via [`str::lines`]) maps to absolute line `base_line + i`.
84///
85/// This is the pure, host-free unit-test seam the compilation drain uses:
86/// the drain tracks the running buffer line number (a `Reset` sets it to
87/// the header's newline count; each `Append`/`Finished` advances it by its
88/// own newline count) and calls this per chunk to grow the buffer's
89/// severity index. Text is assumed newline-terminated per line (the pipe
90/// readers append `\n` after every captured line); a trailing partial line
91/// with no `\n` is still scanned but a following chunk that continues it
92/// may not be re-attributed (erring toward not-decorating a partial line,
93/// which is acceptable and does not occur with the newline-terminated
94/// reader output).
95/// CM.3c: scan a block of streamed text for location-bearing
96/// lines (lines whose text contains a file path + line:col that
97/// `parse_location_line` can navigate to). Returns
98/// `(absolute_line, path_byte_start, path_byte_end)` for each match.
99///
100/// Mirrors [`scan_severities`]: `base_line` is the 0-based buffer
101/// line number the block's FIRST line lands on; line `i` of the
102/// block maps to absolute line `base_line + i`. The compilation
103/// drain calls this per chunk to grow the buffer's location-line
104/// index. The byte range is the span of the file-path portion
105/// within the line text (for link-like fg highlighting).
106pub fn scan_location_lines(base_line: u32, text: &str) -> Vec<(u32, u32, u32)> {
107    text.lines()
108        .enumerate()
109        .filter_map(|(i, line)| {
110            let (start, end) = location_path_byte_range(line)?;
111            Some((base_line + i as u32, start as u32, end as u32))
112        })
113        .collect()
114}
115
116/// Return the byte range of the file-path portion of a location
117/// line. Uses [`parse_location_line`] to locate the path, then
118/// searches for its string representation in the line text.
119fn location_path_byte_range(line: &str) -> Option<(usize, usize)> {
120    let loc = parse_location_line(line)?;
121    let path_str = loc.path.to_str()?;
122    let byte_start = line.find(path_str)?;
123    let byte_end = byte_start + path_str.len();
124    Some((byte_start, byte_end))
125}
126
127/// CM.3c: scan a block of streamed text for severity lines, returning
128/// `(absolute_line, severity)` for each match. `base_line` is the 0-based
129/// buffer line number the block's FIRST line lands on; line `i` of the
130/// block (via [`str::lines`]) maps to absolute line `base_line + i`.
131pub fn scan_severities(base_line: u32, text: &str) -> Vec<(u32, ErrorSeverity)> {
132    text.lines()
133        .enumerate()
134        .filter_map(|(i, line)| match_severity(line).map(|sev| (base_line + i as u32, sev)))
135        .collect()
136}
137
138/// A named matcher over streamed compiler-output lines producing
139/// [`ErrorEntry`]s.
140///
141/// `feed` is called once per line in arrival order and returns the
142/// entries that line *completed* (zero for a header line that only
143/// primes a pending diagnostic, or for a line matching nothing).
144/// `reset` drops any pending multi-line state at the start of a run.
145///
146/// Implementors never panic on the parse path: a malformed-but-claimed
147/// match is logged at `debug!` and skipped (paramount goal #1's
148/// "log + skip, never panic on the process/parse path").
149pub trait CompilationParser: Send {
150    /// Feed one line; return entries completed by it.
151    fn feed(&mut self, line: &str) -> Vec<ErrorEntry>;
152}
153
154/// The active set of parsers. Feeds each line to every parser and
155/// concatenates their entries. The `Vec<Box<dyn CompilationParser>>`
156/// is the extensibility seam: built-in parsers, plus (CM.6) WASM-contributed
157/// ones — a plugin implementing the `error-parser` world is registered here
158/// as one more `Box<dyn CompilationParser>` and is indistinguishable from a
159/// native parser downstream.
160pub struct ParserRegistry {
161    parsers: Vec<Box<dyn CompilationParser>>,
162}
163
164impl ParserRegistry {
165    /// An empty registry (no parsers). Prefer [`Self::with_builtins`].
166    pub fn new() -> Self {
167        Self {
168            parsers: Vec::new(),
169        }
170    }
171
172    /// The default registry: the built-in cargo/rustc (multi-line) +
173    /// gnu-style (single-line) parsers.
174    pub fn with_builtins() -> Self {
175        let mut registry = Self::new();
176        registry.register(Box::new(crate::parsers::CargoRustcParser::new()));
177        registry.register(Box::new(crate::parsers::GnuStyleParser::new()));
178        registry.register(Box::new(crate::parsers::TestPanicParser::new()));
179        registry.register(Box::new(crate::parsers::GeneralParser::new()));
180        registry
181    }
182
183    /// Add a parser to the active set. Order is preserved; each line
184    /// is fed to parsers in registration order.
185    ///
186    /// CM.6: plugin parsers register **before** [`Self::with_builtins`]'s
187    /// catch-all would claim a line, but after the format-specific natives.
188    /// The dedup in [`Self::feed`] is first-entry-wins per location, so a
189    /// plugin that recognises a line rustc also recognises does not displace
190    /// rustc's richer entry — and a line only the plugin understands is still
191    /// its own.
192    pub fn register(&mut self, parser: Box<dyn CompilationParser>) {
193        self.parsers.push(parser);
194    }
195
196    /// CM.6: register a plugin parser, placed ahead of the catch-all.
197    ///
198    /// `with_builtins` ends with `GeneralParser`, whose job is to salvage a
199    /// `file:line:col` out of anything. If a plugin registered after it, the
200    /// catch-all's thin `Info` entry would win the dedup for every location
201    /// the plugin also matched, and the plugin's severity and message would
202    /// be silently discarded — the plugin would look like it did nothing.
203    pub fn register_before_catch_all(&mut self, parser: Box<dyn CompilationParser>) {
204        let at = self.parsers.len().saturating_sub(1);
205        self.parsers.insert(at, parser);
206    }
207
208    /// Feed one line to every registered parser and concatenate the
209    /// entries they complete. Deduplicates by `(path, line, col)` —
210    /// the FIRST entry for each location wins. Format-specific parsers
211    /// register first and produce richer metadata (severity, message);
212    /// the catch-all [`crate::parsers::GeneralParser`] registers last
213    /// and its `Info`/empty duplicates are silently dropped.
214    pub fn feed(&mut self, line: &str) -> Vec<ErrorEntry> {
215        let mut out: Vec<ErrorEntry> = Vec::new();
216        for parser in &mut self.parsers {
217            for entry in parser.feed(line) {
218                let dup = out.iter().any(|existing| {
219                    existing.path == entry.path
220                        && existing.line == entry.line
221                        && existing.col == entry.col
222                });
223                if !dup {
224                    out.push(entry);
225                }
226            }
227        }
228        out
229    }
230}
231
232impl Default for ParserRegistry {
233    fn default() -> Self {
234        Self::with_builtins()
235    }
236}
237
238#[cfg(test)]
239mod cm6_tests {
240    use super::*;
241
242    /// A stand-in for a plugin parser: recognises one bespoke shape.
243    struct FakePlugin;
244    impl CompilationParser for FakePlugin {
245        fn feed(&mut self, line: &str) -> Vec<ErrorEntry> {
246            line.strip_prefix("QQ ")
247                .map(|rest| {
248                    vec![ErrorEntry {
249                        path: std::path::PathBuf::from(rest),
250                        line: 0,
251                        col: 0,
252                        severity: ErrorSeverity::Error,
253                        message: "from the plugin".into(),
254                    }]
255                })
256                .unwrap_or_default()
257        }
258    }
259
260    /// A parser that claims the same location the catch-all would, so the
261    /// ordering is observable.
262    struct ClaimsMainRs;
263    impl CompilationParser for ClaimsMainRs {
264        fn feed(&mut self, line: &str) -> Vec<ErrorEntry> {
265            if line.contains("main.rs:10:5") {
266                vec![ErrorEntry {
267                    path: std::path::PathBuf::from("main.rs"),
268                    line: 9,
269                    col: 4,
270                    severity: ErrorSeverity::Warning,
271                    message: "the plugin's richer message".into(),
272                }]
273            } else {
274                Vec::new()
275            }
276        }
277    }
278
279    #[test]
280    fn a_registered_plugin_parser_contributes_entries() {
281        let mut r = ParserRegistry::with_builtins();
282        r.register_before_catch_all(Box::new(FakePlugin));
283        let got = r.feed("QQ weird/format.q");
284        assert_eq!(got.len(), 1, "got {got:?}");
285        assert_eq!(got[0].message, "from the plugin");
286    }
287
288    #[test]
289    fn a_plugin_parser_beats_the_catch_all_for_the_same_location() {
290        // The ordering this exists for. Registered after `GeneralParser`, the
291        // catch-all's thin Info entry would win the first-entry-wins dedup and
292        // the plugin would appear to do nothing.
293        let mut r = ParserRegistry::with_builtins();
294        r.register_before_catch_all(Box::new(ClaimsMainRs));
295        let got = r.feed("something main.rs:10:5 something");
296        assert_eq!(got.len(), 1, "deduped by location: {got:?}");
297        assert_eq!(
298            got[0].message, "the plugin's richer message",
299            "the plugin's entry must win over the catch-all's salvage"
300        );
301        assert_eq!(got[0].severity, ErrorSeverity::Warning);
302    }
303
304    #[test]
305    fn a_plugin_parser_does_not_displace_a_format_specific_native() {
306        // The other half of the ordering: a native parser that understands
307        // the format properly still wins, because it registers first.
308        let mut r = ParserRegistry::with_builtins();
309        r.register_before_catch_all(Box::new(ClaimsMainRs));
310        let got = r.feed("main.rs:10:5: error: real gnu-style diagnostic");
311        assert_eq!(got.len(), 1);
312        assert_eq!(
313            got[0].message, "real gnu-style diagnostic",
314            "the gnu parser understands this line better than the plugin: {got:?}"
315        );
316    }
317
318    #[test]
319    fn a_silent_plugin_parser_costs_nothing() {
320        let mut r = ParserRegistry::with_builtins();
321        r.register_before_catch_all(Box::new(FakePlugin));
322        assert!(r.feed("   Compiling foo v0.1.0").is_empty());
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use lattice_protocol::error_list::ErrorSeverity;
330    use std::path::PathBuf;
331
332    /// Feed every line of `block` through one registry in order and
333    /// return the concatenated entries (as the stderr reader does).
334    fn parse_block(block: &str) -> Vec<ErrorEntry> {
335        let mut registry = ParserRegistry::with_builtins();
336        let mut out = Vec::new();
337        for line in block.lines() {
338            out.extend(registry.feed(line));
339        }
340        out
341    }
342
343    #[test]
344    fn cargo_multiline_error_block_yields_one_zero_based_entry() {
345        let block = "\
346Compiling foo v0.1.0 (/tmp/foo)
347error[E0308]: mismatched types
348  --> src/foo.rs:12:9
349   |
35012 |     let x: u32 = \"s\";
351   |            ---   ^^^ expected `u32`, found `&str`
352   |
353error: aborting due to 1 previous error
354";
355        let entries = parse_block(block);
356        assert_eq!(entries.len(), 1, "one located diagnostic, got {entries:?}");
357        let e = &entries[0];
358        assert_eq!(e.path, PathBuf::from("src/foo.rs"));
359        assert_eq!(e.line, 11, "rustc 1-based 12 → 0-based 11");
360        assert_eq!(e.col, 8, "rustc 1-based 9 → 0-based 8");
361        assert_eq!(e.severity, ErrorSeverity::Error);
362        assert_eq!(e.message, "mismatched types");
363    }
364
365    #[test]
366    fn cargo_warning_block_yields_warning_severity() {
367        let block = "\
368warning: unused variable: `y`
369  --> src/lib.rs:3:5
370";
371        let entries = parse_block(block);
372        assert_eq!(entries.len(), 1);
373        assert_eq!(entries[0].severity, ErrorSeverity::Warning);
374        assert_eq!(entries[0].line, 2);
375        assert_eq!(entries[0].col, 4);
376        assert_eq!(entries[0].message, "unused variable: `y`");
377    }
378
379    #[test]
380    fn gnu_full_form_yields_correct_entry() {
381        let entries = parse_block("main.c:10:5: error: 'foo' undeclared\n");
382        assert_eq!(entries.len(), 1);
383        let e = &entries[0];
384        assert_eq!(e.path, PathBuf::from("main.c"));
385        assert_eq!(e.line, 9);
386        assert_eq!(e.col, 4);
387        assert_eq!(e.severity, ErrorSeverity::Error);
388        assert_eq!(e.message, "'foo' undeclared");
389    }
390
391    #[test]
392    fn gnu_warning_and_note_severities_map() {
393        let warn = parse_block("a.c:1:1: warning: w\n");
394        assert_eq!(warn[0].severity, ErrorSeverity::Warning);
395        let note = parse_block("a.c:2:2: note: n\n");
396        assert_eq!(note[0].severity, ErrorSeverity::Note);
397        // "fatal error" maps to Error.
398        let fatal = parse_block("a.c:3:3: fatal error: f\n");
399        assert_eq!(fatal[0].severity, ErrorSeverity::Error);
400    }
401
402    #[test]
403    fn gnu_short_form_line_only_defaults_to_info() {
404        let entries = parse_block("Makefile:42: missing separator\n");
405        assert_eq!(entries.len(), 1);
406        let e = &entries[0];
407        assert_eq!(e.path, PathBuf::from("Makefile"));
408        assert_eq!(e.line, 41);
409        assert_eq!(e.col, 0, "short form has no column → 0");
410        assert_eq!(e.severity, ErrorSeverity::Info);
411        assert_eq!(e.message, "missing separator");
412    }
413
414    #[test]
415    fn non_matching_lines_yield_no_entries_and_no_panic() {
416        let block = "\
417   Compiling something
418    Finished dev [unoptimized] target(s) in 1.23s
419just some prose with a colon: not a location
420==== running 3 tests ====
421";
422        assert!(parse_block(block).is_empty());
423    }
424
425    #[test]
426    fn cargo_location_line_is_not_double_counted_by_gnu() {
427        // The gnu parser must NOT also fire on rustc's `-->` location
428        // line (it has leading whitespace); exactly one entry results.
429        let block = "\
430error[E0433]: failed to resolve
431  --> src/a.rs:7:13
432";
433        let entries = parse_block(block);
434        assert_eq!(
435            entries.len(),
436            1,
437            "cargo block yields exactly one entry, got {entries:?}"
438        );
439        assert_eq!(entries[0].path, PathBuf::from("src/a.rs"));
440    }
441
442    #[test]
443    fn parse_location_line_matches_cargo_arrow() {
444        let loc = parse_location_line("  --> src/foo.rs:12:9").expect("cargo location");
445        assert_eq!(loc.path, PathBuf::from("src/foo.rs"));
446        assert_eq!(loc.line, 11, "rustc 1-based 12 → 0-based 11");
447        assert_eq!(loc.col, 8, "rustc 1-based 9 → 0-based 8");
448    }
449
450    #[test]
451    fn parse_location_line_matches_gnu_full() {
452        let loc = parse_location_line("main.c:10:5: error: x").expect("gnu location");
453        assert_eq!(loc.path, PathBuf::from("main.c"));
454        assert_eq!(loc.line, 9, "gnu 1-based 10 → 0-based 9");
455        assert_eq!(loc.col, 4, "gnu 1-based 5 → 0-based 4");
456    }
457
458    #[test]
459    fn parse_location_line_rejects_plain_text() {
460        assert_eq!(parse_location_line("just some prose, not a location"), None);
461        assert_eq!(parse_location_line("   Compiling foo v0.1.0"), None);
462    }
463
464    // ── CM.3c: match_severity + scan_severities ──────────────────────────
465
466    #[test]
467    fn match_severity_maps_cargo_headers() {
468        assert_eq!(
469            match_severity("error[E0308]: mismatched types"),
470            Some(ErrorSeverity::Error)
471        );
472        assert_eq!(
473            match_severity("error: aborting due to 1 previous error"),
474            Some(ErrorSeverity::Error)
475        );
476        assert_eq!(
477            match_severity("warning: unused variable: `y`"),
478            Some(ErrorSeverity::Warning)
479        );
480    }
481
482    #[test]
483    fn match_severity_maps_gnu_full_form() {
484        assert_eq!(
485            match_severity("main.c:10:5: error: 'foo' undeclared"),
486            Some(ErrorSeverity::Error)
487        );
488        assert_eq!(
489            match_severity("a.c:1:1: warning: w"),
490            Some(ErrorSeverity::Warning)
491        );
492        assert_eq!(
493            match_severity("a.c:2:2: note: n"),
494            Some(ErrorSeverity::Note)
495        );
496        assert_eq!(
497            match_severity("a.c:3:3: fatal error: f"),
498            Some(ErrorSeverity::Error)
499        );
500    }
501
502    #[test]
503    fn match_severity_none_on_non_severity_lines() {
504        // Progress, the rustc `-->` location line (no keyword), gnu short
505        // form (no keyword), prose, and empty all carry no severity keyword.
506        assert_eq!(match_severity("   Compiling foo v0.1.0"), None);
507        assert_eq!(match_severity("  --> src/foo.rs:12:9"), None);
508        assert_eq!(match_severity("Makefile:42: missing separator"), None);
509        assert_eq!(
510            match_severity("just some prose with a colon: not a location"),
511            None
512        );
513        assert_eq!(match_severity(""), None);
514    }
515
516    #[test]
517    fn scan_severities_yields_absolute_line_numbers() {
518        // Block: line 0 progress, line 1 cargo header (Error), line 2 the
519        // `-->` location (no keyword), line 3 a warning header.
520        let block = "\
521Compiling foo
522error[E0308]: mismatched types
523  --> src/foo.rs:12:9
524warning: unused
525";
526        assert_eq!(
527            scan_severities(0, block),
528            vec![(1, ErrorSeverity::Error), (3, ErrorSeverity::Warning)]
529        );
530    }
531
532    #[test]
533    fn scan_severities_respects_base_line_offset() {
534        assert_eq!(
535            scan_severities(10, "error: boom\n"),
536            vec![(10, ErrorSeverity::Error)]
537        );
538        // gnu full-form on the 2nd line of a block based at line 5 → line 6.
539        assert_eq!(
540            scan_severities(5, "x\nmain.c:1:1: warning: w\n"),
541            vec![(6, ErrorSeverity::Warning)]
542        );
543    }
544
545    #[test]
546    fn scan_severities_empty_and_plain() {
547        assert!(scan_severities(0, "").is_empty());
548        assert!(scan_severities(0, "Compiling\nFinished\n").is_empty());
549    }
550
551    // ── CM.3c: scan_location_lines ─────────────────────────────
552
553    #[test]
554    fn scan_location_lines_matches_cargo_arrow_and_gnu() {
555        let block = "\
556Compiling foo v0.1.0
557  --> src/foo.rs:12:9
558warning: unused
559main.c:10:5: error: x
560plain prose
561";
562        let locs = scan_location_lines(0, block);
563        assert_eq!(locs.len(), 2, "two location lines expected");
564        assert_eq!(locs[0].0, 1, "line 1 = cargo `-->`");
565        assert_eq!(locs[1].0, 3, "line 3 = gnu full-form location");
566    }
567
568    #[test]
569    fn scan_location_lines_respects_base_line_offset() {
570        assert_eq!(
571            scan_location_lines(10, "  --> src/a.rs:1:1\n"),
572            vec![(10, 6, 14)]
573        );
574        assert_eq!(
575            scan_location_lines(5, "x\nmain.c:3:3: error: e\n"),
576            vec![(6, 0, 6)]
577        );
578    }
579
580    #[test]
581    fn scan_location_lines_empty_and_plain() {
582        assert!(scan_location_lines(0, "").is_empty());
583        assert!(scan_location_lines(0, "Compiling\nFinished\n").is_empty());
584        assert!(scan_location_lines(0, "just some prose\n").is_empty());
585    }
586}