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}