Skip to main content

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}