rustyfi_syntax/parse_error.rs
1//! Turning a failed parse into one diagnostic a human can act on.
2//!
3//! [`locate`] is the whole module: given the source, the [`AtomStream`] the
4//! parse ran on and syan's error tree, it produces one [`ParseFileError`] with
5//! a position that names the construct the author actually got wrong.
6//!
7//! It lives beside [`crate::cst::parse_file`] and [`crate::cst_v1::
8//! parse_file_v1`] rather than inside either, because both need it and both
9//! used to carry a private `render_parse_error` that was
10//! `format!("{err:?}")` — a `Debug` dump of the whole tree, at the aggregate's
11//! span. Two things were wrong with that, and they are worth stating because
12//! either alone would have looked like a small cosmetic bug:
13//!
14//! - **The span was not the failure's.** `ParseError::from_cause` documents
15//! that an `Alternatives` aggregate "takes the FIRST alternative's span:
16//! with no record of how far each alternative got, that is the only
17//! deterministic choice available". For a whole-file rule the first
18//! alternative typically dies on the first token, so the aggregate points at
19//! byte 0.
20//! - **Even the right leaf can be stale.** A repetition discards the failure
21//! that ended it: `Vec<TopBinding>` rolls back to the start of the binding
22//! that would not parse, and what surfaces is "expected end of input" *at
23//! that binding's start*. Under 0.1 a whole library is one `module` binding,
24//! so every error in the file reported on line 1.
25//!
26//! The first is fixed by the standard furthest-failure rule over the tree
27//! ([`best_failure`]); the second only by the stream's own high-water mark
28//! (`AtomStream::furthest`), because by then the tree has forgotten. [`locate`]
29//! uses both, and prefers the tree when the two agree on how far the parse got
30//! — the tree's message names the expected alternatives, which the mark cannot.
31
32use crate::span::{floor_char_boundary, Span};
33use crate::stream::AtomStream;
34use syan::error::ParseError;
35
36/// What kind of failure a [`ParseFileError`] reports.
37///
38/// The distinction that earns this type is [`Self::GaveUp`]: a parse stopped
39/// by [`crate::stream::Budget`] has established nothing about the source, and
40/// must not be presented as though it had.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ParseFailureKind {
43 /// The lexer rejected the characters. These already carry a hand-written
44 /// message and a tight span, and are passed through untouched.
45 Lex,
46 /// The grammar rejected the token stream. [`ParseFileError::span`] is the
47 /// token the parse could not get past.
48 Syntax,
49 /// The parse ran out of backtracking budget before reaching any verdict.
50 /// [`ParseFileError::span`] is the furthest token it reached, which is
51 /// where an unfinished construct usually is — but the file may well be
52 /// valid, and this is not a claim that it is not.
53 GaveUp,
54}
55
56/// A parse failure, positioned at the construct that caused it.
57///
58/// [`Self::message`] is the bare reason, one line, with no position and no
59/// severity word in it, so that a caller can frame it for its own medium; the
60/// [`Display`](std::fmt::Display) impl is the terminal's framing, and
61/// [`Self::render`] is that framing without the position.
62#[derive(Debug, Clone, thiserror::Error)]
63#[error("{span}: {}", self.render())]
64pub struct ParseFileError {
65 pub span: Span,
66 pub message: String,
67 pub kind: ParseFailureKind,
68}
69
70impl ParseFileError {
71 /// A lexer failure, passed through unchanged.
72 pub fn from_lex(e: crate::lexer::LexError) -> Self {
73 ParseFileError {
74 span: e.span,
75 message: e.msg,
76 kind: ParseFailureKind::Lex,
77 }
78 }
79
80 /// The one-line message, with the framing the kind calls for and without
81 /// the position — what a language server puts in a diagnostic, and what
82 /// [`Display`](std::fmt::Display) prints after the span.
83 pub fn render(&self) -> String {
84 match self.kind {
85 ParseFailureKind::Lex | ParseFailureKind::Syntax => {
86 format!("parse error: {}", self.message)
87 }
88 // NOT "parse error": nothing here says the source is wrong.
89 ParseFailureKind::GaveUp => format!("gave up: {}", self.message),
90 }
91 }
92}
93
94/// The message [`ParseFailureKind::GaveUp`] carries.
95///
96/// Says three things, in this order, because that is the order a reader needs
97/// them: that no verdict was reached, where the parser was when it stopped,
98/// and what that usually means.
99const GAVE_UP: &str = "this file needs more backtracking than the parser allows. \
100 The parse got this far and no further, which usually means a \
101 construct at or above this point is unfinished";
102
103/// Reduce a failed parse to one position and one message.
104///
105/// `stream` must be the stream the parse ran on — that is where the high-water
106/// mark is. See the module doc for why the error tree alone is not enough.
107pub fn locate(source: &str, stream: &AtomStream, err: &ParseError<Span>) -> ParseFileError {
108 let furthest = stream.furthest();
109 let stalled = stream.furthest_span();
110
111 // The parse never reached a verdict — the budget cut it off. The
112 // `ParseError` in hand says only "the input ended", which the budget
113 // caused and the source did not, so it must not be repeated as if it
114 // described the source.
115 if stream.exhausted() {
116 return ParseFileError {
117 span: stalled.unwrap_or(*err.span()),
118 message: GAVE_UP.to_string(),
119 kind: ParseFailureKind::GaveUp,
120 };
121 }
122
123 let (span, message) = best_failure(err);
124 // Where the error TREE points, versus how far the parse actually got. The
125 // mark is >= any leaf's end by construction (a leaf's span comes from a
126 // token that was served), so this partitions cleanly: EQUAL means the tree
127 // knows as much as the stream, and its message is strictly more
128 // informative because it names the expected alternatives; LESS means a
129 // repetition swallowed the real failure and the tree is stale, and then
130 // the mark is the only signal there is.
131 if span.end.byte >= furthest {
132 return ParseFileError {
133 span,
134 message,
135 kind: ParseFailureKind::Syntax,
136 };
137 }
138 let stalled = stalled.unwrap_or(span);
139 ParseFileError {
140 message: stalled_message(source, stalled),
141 span: stalled,
142 kind: ParseFailureKind::Syntax,
143 }
144}
145
146/// Message for a failure located by the high-water mark rather than by the
147/// error tree.
148///
149/// The tree's own message cannot be reused here: it describes the *outermost*
150/// alternative that failed ("expected end of input", for a 0.1 file whose one
151/// top-level `module` binding did not parse), which paired with an inner
152/// position would read as a claim about that position that is not true. What
153/// is known for certain is which token the parse could not get past, so that
154/// is what the message says — quoting the source's own text, so the reader
155/// sees exactly the characters involved.
156fn stalled_message(source: &str, span: Span) -> String {
157 const MAX: usize = 24;
158 let start = floor_char_boundary(source, span.start.byte);
159 let end = floor_char_boundary(source, span.end.byte.max(start));
160 let raw = source[start..end].trim();
161 if raw.is_empty() {
162 // The end-of-input sentinel has a zero-width span past the last
163 // character, so there is nothing to quote; say what that means rather
164 // than pointing wordlessly at a position.
165 return match start >= source.trim_end().len() {
166 true => "unexpected end of input".to_string(),
167 false => "unexpected input here".to_string(),
168 };
169 }
170 // One line, so a token spanning a whole `'<...>` block does not paste a
171 // paragraph into a diagnostics pane.
172 let text: String = raw
173 .chars()
174 .take_while(|c| *c != '\n' && *c != '\r')
175 .collect();
176 let text = text.trim_end();
177 if text.chars().count() > MAX {
178 let cut: String = text.chars().take(MAX).collect();
179 return format!("unexpected `{cut}...`");
180 }
181 format!("unexpected `{text}`")
182}
183
184/// Reduce syan's error tree to one position and one message, by the standard
185/// furthest-failure rule: walk to the leaves, keep the ones that got deepest
186/// into the token stream, and report their position with their (short,
187/// `Display`-rendered) reasons joined. The depth measure is the leaf span's
188/// **end** byte, because that is how far the parser had consumed when it gave
189/// up.
190fn best_failure(err: &ParseError<Span>) -> (Span, String) {
191 let mut deepest: Option<Span> = None;
192 let mut reasons: Vec<String> = Vec::new();
193 visit_leaves(err, &mut |leaf| {
194 let span = *leaf.span();
195 let depth = span.end.byte;
196 let best = deepest.map(|s| s.end.byte);
197 if best.is_none_or(|b| depth > b) {
198 deepest = Some(span);
199 reasons.clear();
200 }
201 if deepest.map(|s| s.end.byte) == Some(depth) {
202 let reason = leaf_reason(leaf);
203 if !reasons.contains(&reason) {
204 reasons.push(reason);
205 }
206 }
207 });
208
209 let span = deepest.unwrap_or(*err.span());
210 (span, render_reasons(&reasons))
211}
212
213/// Join the furthest-failure reasons into one message.
214///
215/// Two touches beyond a plain join:
216///
217/// - **The list is capped.** A grammar this size offers dozens of
218/// continuations at a single position, and "expected A, B, C, … and 40 more"
219/// is no more useful than the first few.
220/// - **A shared `expected` is factored out.** Every leaf renders as
221/// `"expected 'let'"`, so a naive join gives "expected 'let', expected 'if',
222/// expected 'fun'", which reads like three separate complaints. Factoring
223/// only happens when *every* reason has the prefix — a mixed list (an
224/// `expected` beside a hand-written `ParseError::Other`) is joined verbatim
225/// rather than mangled into a false parallel.
226fn render_reasons(reasons: &[String]) -> String {
227 const MAX_REASONS: usize = 4;
228 const PREFIX: &str = "expected ";
229
230 if reasons.is_empty() {
231 return "the input does not parse here".to_string();
232 }
233 let (kept, extra) = match reasons.len() > MAX_REASONS {
234 true => (&reasons[..MAX_REASONS], reasons.len() - MAX_REASONS),
235 false => (reasons, 0),
236 };
237 let all_expected = kept.iter().all(|r| r.starts_with(PREFIX));
238 let body = if all_expected {
239 let stripped: Vec<String> = kept.iter().map(|r| r[PREFIX.len()..].to_string()).collect();
240 format!("expected {}", join_alternatives(&stripped))
241 } else {
242 join_alternatives(kept)
243 };
244 match extra {
245 0 => body,
246 n => format!("{body} (and {n} more)"),
247 }
248}
249
250/// Depth-first walk over the non-`Alternatives` leaves of an error tree. An
251/// `Alternatives` node with no children is itself a leaf (syan builds one for
252/// an empty alternative set).
253fn visit_leaves(err: &ParseError<Span>, f: &mut impl FnMut(&ParseError<Span>)) {
254 let alts = err.alternatives();
255 if alts.is_empty() {
256 f(err);
257 return;
258 }
259 for alt in alts {
260 visit_leaves(alt, f);
261 }
262}
263
264/// One leaf's reason, without syan's `Display` position suffix.
265///
266/// `ParseError`'s own `Display` appends `" at {span:?}"` for a spanned error,
267/// which would put a `Span { start: Loc { line: .., col: .., byte: .. } }`
268/// dump in the middle of the message. The span is already the diagnostic's
269/// position, so it is dropped here rather than repeated in words.
270fn leaf_reason(leaf: &ParseError<Span>) -> String {
271 let rendered = leaf.to_string();
272 match rendered.rfind(" at Span {") {
273 Some(cut) => rendered[..cut].to_string(),
274 None => rendered,
275 }
276}
277
278/// `["a", "b", "c"]` → `"a, b, or c"`.
279fn join_alternatives(reasons: &[String]) -> String {
280 match reasons {
281 [] => String::new(),
282 [one] => one.clone(),
283 [head @ .., last] => format!("{}, or {last}", head.join(", ")),
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn join_alternatives_reads_as_english() {
293 assert_eq!(join_alternatives(&[]), "");
294 assert_eq!(join_alternatives(&["a".into()]), "a");
295 assert_eq!(join_alternatives(&["a".into(), "b".into()]), "a, or b");
296 assert_eq!(
297 join_alternatives(&["a".into(), "b".into(), "c".into()]),
298 "a, b, or c"
299 );
300 }
301
302 #[test]
303 fn render_reasons_factors_out_a_shared_expected() {
304 assert_eq!(render_reasons(&[]), "the input does not parse here");
305 assert_eq!(
306 render_reasons(&["expected 'let'".into(), "expected 'if'".into()]),
307 "expected 'let', or 'if'"
308 );
309 // Mixed with a non-`expected` reason: joined verbatim, because
310 // factoring would attach `expected` to something that is not a thing
311 // the parser expected.
312 assert_eq!(
313 render_reasons(&["expected 'let'".into(), "unexpected end of input".into()]),
314 "expected 'let', or unexpected end of input"
315 );
316 }
317
318 #[test]
319 fn render_reasons_caps_a_long_alternative_list() {
320 let many: Vec<String> = (0..9).map(|i| format!("expected '{i}'")).collect();
321 assert_eq!(
322 render_reasons(&many),
323 "expected '0', '1', '2', or '3' (and 5 more)"
324 );
325 }
326
327 #[test]
328 fn leaf_reason_drops_syans_span_suffix() {
329 let span = Span::default();
330 let leaf = ParseError::expected(span, "end of input");
331 let rendered = leaf.to_string();
332 assert!(
333 rendered.contains("at Span {"),
334 "syan changed its Display: {rendered}"
335 );
336 assert_eq!(leaf_reason(&leaf), "expected end of input");
337 }
338
339 /// A give-up is framed as a give-up, and a syntax error as a syntax
340 /// error. The whole point of [`ParseFailureKind`] is that these two do not
341 /// read alike.
342 #[test]
343 fn the_two_kinds_are_framed_differently() {
344 let syntax = ParseFileError {
345 span: Span::default(),
346 message: "expected 'in'".to_string(),
347 kind: ParseFailureKind::Syntax,
348 };
349 assert_eq!(syntax.render(), "parse error: expected 'in'");
350 let gave_up = ParseFileError {
351 span: Span::default(),
352 message: GAVE_UP.to_string(),
353 kind: ParseFailureKind::GaveUp,
354 };
355 assert!(gave_up.render().starts_with("gave up: "), "{gave_up}");
356 assert!(!gave_up.render().contains("parse error"), "{gave_up}");
357 }
358}