Skip to main content

praxis_runtime/
parse_detail.rs

1//! Rich parse-failure detail (§7.11).
2//!
3//! A parse mismatch (§7.11) is signalled as [`crate::FaultKind::ParseFailed`],
4//! but the fault kind alone carries no detail. The crash debugger and the
5//! noninteractive fallback need the *structured* information §7.11 lists:
6//!
7//! ```text
8//! input span          — where in the input the mismatch occurred
9//! parser span         — the source span of the failing parser expression
10//! expected description — what the parser expected (e.g. "int", "literal ':'")
11//! actual preview      — a bounded slice of the input around the mismatch
12//! parser path         — the active input parser path (reserved)
13//! partial root value  — best-effort deepest successfully-built sub-value
14//! ```
15//!
16//! This module owns those fields. The [`ParseDetail`] lives on the [`Runtime`]
17//! (its address is installed on every [`crate::RuntimeContext`] at
18//! `parse_detail`), and the parser interpreter writes a [`ParseFail`] into it
19//! on every mismatch. The deepest (most specific) failure wins: an inner
20//! capture failure (`expected "int"` at offset 12) is more useful than the
21//! outer constructor's generic failure, so we keep the failure with the
22//! furthest input offset seen.
23//!
24//! The detail is **host-managed**: generated code never reads or writes it. It
25//! is appended at the end of `RuntimeContext` so existing field offsets read by
26//! JIT code are unchanged (§11.6 ABI stability).
27
28use crate::gc::GcRef;
29
30/// A single parse mismatch (§7.11), carrying structured detail for the crash
31/// debugger and the noninteractive fallback.
32///
33/// Constructed by the parser interpreter at each failure site via
34/// [`ParseFail::here`]; the [`ParseDetail`] slot keeps the most specific one.
35#[derive(Clone, Debug)]
36pub struct ParseFail {
37    /// `[start, end)` byte offsets into the input buffer where the mismatch
38    /// occurred. `end` may equal `start` for a zero-width expectation (e.g.
39    /// "expected a digit, found end-of-input").
40    pub input_span: (usize, usize),
41    /// What the parser expected, as a short human description (`"int"`,
42    /// `"literal ':'"`, `"section header"`, ``"6 sections for `shapes`"``, …).
43    /// A `String` rather than a `&'static str` precisely so a description can
44    /// name the thing that came up short.
45    pub expected: String,
46    /// The source span of the failing parser expression (byte offsets into the
47    /// program source). `None` when no span was threaded (an internal-only
48    /// plan node); the renderer falls back to `<unknown parser>`.
49    pub parser_span: Option<(u32, u32)>,
50    /// The best-effort partial root value built before the failure (the deepest
51    /// successfully-assembled sub-value), or `None` when no partial value was
52    /// available. The collector retains it because the runtime roots
53    /// [`ParseDetail`] (see [`crate::ParseDetail`]).
54    pub partial: Option<GcRef>,
55}
56
57impl ParseFail {
58    /// Construct a failure at `offset` (zero-width) expecting `expected`.
59    /// Convenience for the common "found X, expected Y at this point" case.
60    pub fn here(offset: usize, expected: impl Into<String>) -> Self {
61        ParseFail {
62            input_span: (offset, offset),
63            expected: expected.into(),
64            parser_span: None,
65            partial: None,
66        }
67    }
68
69    /// With a width: the mismatch span is `[offset, offset + len)`.
70    pub fn at(offset: usize, len: usize, expected: impl Into<String>) -> Self {
71        ParseFail {
72            input_span: (offset, offset + len),
73            expected: expected.into(),
74            parser_span: None,
75            partial: None,
76        }
77    }
78
79    /// Attach the source span of the failing parser expression.
80    #[must_use]
81    pub fn with_parser_span(mut self, span: Option<(u32, u32)>) -> Self {
82        self.parser_span = span;
83        self
84    }
85
86    /// Attach the best-effort partial root value.
87    #[must_use]
88    pub fn with_partial(mut self, partial: Option<GcRef>) -> Self {
89        self.partial = partial;
90        self
91    }
92}
93
94/// The runtime-owned slot that holds the richest parse failure seen during the
95/// last `run_plan`, plus a bounded preview of the input around it.
96///
97/// Lives on [`crate::Runtime`] (so its address is stable) and is exposed to
98/// generated code only as an opaque pointer field on
99/// [`crate::RuntimeContext`]; the host (CLI / debugger) reads it after a
100/// `FaultKind::ParseFailed`.
101#[derive(Debug, Default)]
102pub struct ParseDetail {
103    /// The richest failure, when one occurred this run.
104    pub fail: Option<ParseFail>,
105    /// A bounded UTF-8-lossy preview of the input bytes around the failure
106    /// offset (set by the runtime when the failure is recorded, so the debugger
107    /// does not need to re-read the input buffer). Empty until populated.
108    pub actual_preview: String,
109}
110
111impl ParseDetail {
112    /// A fresh, clear detail slot.
113    pub fn new() -> Self {
114        ParseDetail::default()
115    }
116
117    /// Clear any recorded failure. Called at the start of each `run_plan` so a
118    /// stale detail from a prior parse does not leak into the next.
119    pub fn clear(&mut self) {
120        self.fail = None;
121        self.actual_preview.clear();
122    }
123
124    /// True iff a parse failure was recorded.
125    pub fn is_set(&self) -> bool {
126        self.fail.is_some()
127    }
128
129    /// Consider recording `fail`. The **deepest** failure wins: we keep the one
130    /// whose input offset is furthest into the buffer, since that is the most
131    /// specific point at which parsing actually broke. Ties keep the first
132    /// (earliest-recorded) failure, which is the innermost in source order.
133    pub fn consider(&mut self, fail: ParseFail, input: &[u8]) {
134        let wins = match &self.fail {
135            None => true,
136            // Strictly greater: a tie (same offset) keeps the existing failure,
137            // which is the innermost in recording order.
138            Some(existing) => fail.input_span.0 > existing.input_span.0,
139        };
140        if wins {
141            self.actual_preview = preview_around(input, fail.input_span.0);
142            self.fail = Some(fail);
143        }
144    }
145}
146
147/// The number of bytes of context shown on each side of the failure offset in
148/// the [`ParseDetail::actual_preview`]. Kept small so the preview is a useful
149/// one-line glance for the debugger, not a buffer dump.
150const PREVIEW_RADIUS: usize = 24;
151
152/// Build a bounded, UTF-8-lossy, single-line preview of `input` around `offset`.
153/// Newlines are rendered as `⏎` so the preview stays on one line.
154fn preview_around(input: &[u8], offset: usize) -> String {
155    // `start` is clamped to `end`, not merely to the radius. An offset past the
156    // end of the buffer gives `start > end`, and `&input[start..end]` panics on
157    // an inverted range — inside `extern "C"`, where the ABI guard turns a panic
158    // into an internal fault rather than the preview a total function gives
159    // (ADR-080). Such an offset is reachable: a ragged grid's fill is parsed
160    // against its own buffer, so a failure there carries an offset that means
161    // nothing here.
162    let end = (offset + PREVIEW_RADIUS).min(input.len());
163    let start = offset.saturating_sub(PREVIEW_RADIUS).min(end);
164    let slice = &input[start..end];
165    let lossy = String::from_utf8_lossy(slice);
166    let mut out = String::with_capacity(lossy.len());
167    for ch in lossy.chars() {
168        if ch == '\n' || ch == '\r' {
169            out.push('⏎');
170        } else {
171            out.push(ch);
172        }
173    }
174    out
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    /// A failure offset past the end of the buffer must not make the preview
182    /// slice an inverted range: `start = offset - 24` and
183    /// `end = min(offset + 24, len)`, so without the clamp of `start` to `end`
184    /// any offset more than 24 bytes past the end panics in `&input[start..end]`.
185    #[test]
186    fn a_failure_offset_past_the_buffer_previews_rather_than_panicking() {
187        let mut d = ParseDetail::new();
188        d.consider(ParseFail::here(10_000, "int"), b"short");
189        assert!(d.is_set());
190        assert_eq!(
191            d.actual_preview, "",
192            "there is nothing within 24 bytes of an offset past the end, and nothing is a preview"
193        );
194    }
195
196    #[test]
197    fn first_failure_sets_detail() {
198        let mut d = ParseDetail::new();
199        d.consider(ParseFail::here(5, "int"), b"abc123");
200        assert!(d.is_set());
201        assert_eq!(d.fail.as_ref().unwrap().input_span, (5, 5));
202        assert_eq!(d.fail.as_ref().unwrap().expected, "int");
203    }
204
205    #[test]
206    fn deeper_failure_wins() {
207        let mut d = ParseDetail::new();
208        d.consider(ParseFail::here(3, "outer"), b"abcdef");
209        d.consider(ParseFail::here(10, "inner"), b"abcdef");
210        // The offset-10 failure is deeper → it wins.
211        assert_eq!(d.fail.as_ref().unwrap().expected, "inner");
212        assert_eq!(d.fail.as_ref().unwrap().input_span.0, 10);
213    }
214
215    #[test]
216    fn shallower_failure_loses() {
217        let mut d = ParseDetail::new();
218        d.consider(ParseFail::here(20, "deep"), b"abcdef");
219        d.consider(ParseFail::here(5, "shallow"), b"abcdef");
220        // The offset-20 failure stays; the offset-5 one is less specific.
221        assert_eq!(d.fail.as_ref().unwrap().expected, "deep");
222    }
223
224    #[test]
225    fn tie_keeps_first() {
226        let mut d = ParseDetail::new();
227        d.consider(ParseFail::here(8, "first"), b"abcdef");
228        d.consider(ParseFail::here(8, "second"), b"abcdef");
229        assert_eq!(d.fail.as_ref().unwrap().expected, "first");
230    }
231
232    #[test]
233    fn clear_resets() {
234        let mut d = ParseDetail::new();
235        d.consider(ParseFail::here(3, "int"), b"abc");
236        d.clear();
237        assert!(!d.is_set());
238        assert!(d.actual_preview.is_empty());
239    }
240
241    #[test]
242    fn preview_is_single_line_and_bounded() {
243        let input = b"aaaa\nbbbb\ncccc\ndddd\neeee";
244        let preview = preview_around(input, 12);
245        assert!(!preview.contains('\n'));
246        assert!(!preview.contains('\r'));
247        assert!(preview.contains('⏎'));
248        // Bounded: at most 2 * PREVIEW_RADIUS characters (plus replacement chars).
249        assert!(preview.chars().count() <= 2 * PREVIEW_RADIUS + 4);
250    }
251
252    #[test]
253    fn preview_at_buffer_start() {
254        let preview = preview_around(b"hello world", 0);
255        assert!(preview.starts_with("hello"));
256    }
257
258    #[test]
259    fn preview_at_buffer_end() {
260        let preview = preview_around(b"hello world", 11);
261        assert!(preview.ends_with("world"));
262    }
263}