Skip to main content

elenchus_parser/
diag.rs

1//! Human-facing syntax diagnostics: the owned, renderable result of a failed
2//! parse.
3//!
4//! [`parse`](crate::parse) collects every error in one pass into [`Diagnostics`]
5//! (it recovers after each broken statement). Rendering **groups errors by
6//! class** — the keyword they are about — so the correct syntax and a real
7//! example are shown *once per class*, with every offending place listed beneath
8//! it (line, caret, the specific problem). Two independent caps control the
9//! volume: how many classes, and how many places per class. Output is ASCII-only
10//! and deterministic so a dumb terminal shows it correctly and snapshots stay
11//! stable.
12
13use alloc::string::String;
14use alloc::vec;
15use alloc::vec::Vec;
16use core::fmt::{self, Write as _};
17
18use crate::keywords::{card_for, top_level_forms};
19
20/// One syntax error, fully owned (no borrow of the source) so it can flow into
21/// `CompileError` and be rendered later with any limit.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Diagnostic {
24    /// 1-based source line of the failure.
25    pub(crate) line: usize,
26    /// 1-based column (in characters) where the caret points.
27    pub(crate) col: usize,
28    /// How many caret characters to draw (at least one).
29    pub(crate) width: usize,
30    /// The specific parser message ("FACT expects an atom: …").
31    pub(crate) message: String,
32    /// The keyword this error is about, if one is named in the message — selects
33    /// the class and its syntax card.
34    pub(crate) keyword: Option<&'static str>,
35    /// `true` for a line that started no statement keyword at all: its class is
36    /// the statement menu rather than a single card.
37    pub(crate) general: bool,
38    /// The verbatim offending source line (without its line ending).
39    pub(crate) line_text: String,
40}
41
42/// Every syntax error from one parse, plus the source label for the header.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Diagnostics {
45    /// The source label (file name) for the header; `None` until the compiler
46    /// attaches it.
47    pub(crate) file: Option<String>,
48    /// The errors, in source order.
49    pub(crate) errors: Vec<Diagnostic>,
50}
51
52/// The class an error is grouped under: a specific keyword, the "not a
53/// statement" menu, or self-explanatory leftovers with no card.
54#[derive(Clone, Copy, PartialEq, Eq)]
55enum Class {
56    /// A keyword named in the message — shows that keyword's syntax card.
57    Keyword(&'static str),
58    /// A line that started no top-level keyword — shows the statement menu.
59    Statement,
60    /// A self-explanatory error with no card (e.g. "needs at least two atoms").
61    Other,
62}
63
64impl Class {
65    /// The class an error belongs to.
66    fn of(d: &Diagnostic) -> Class {
67        match (d.keyword, d.general) {
68            (Some(kw), _) => Class::Keyword(kw),
69            (None, true) => Class::Statement,
70            (None, false) => Class::Other,
71        }
72    }
73
74    /// The class label shown in the header and the "… and N more" footer.
75    fn name(self) -> &'static str {
76        match self {
77            Class::Keyword(kw) => kw,
78            Class::Statement => "statement",
79            Class::Other => "other",
80        }
81    }
82}
83
84impl Diagnostics {
85    /// How many errors were found.
86    pub fn len(&self) -> usize {
87        self.errors.len()
88    }
89
90    /// Whether there are no errors (a `Diagnostics` is only constructed when the
91    /// parse failed, so in practice this is always `false`).
92    pub fn is_empty(&self) -> bool {
93        self.errors.is_empty()
94    }
95
96    /// Attach (or replace) the source label shown in the header — used by the
97    /// compiler, which knows the file name the parser does not.
98    pub fn set_file(&mut self, file: &str) {
99        self.file = Some(String::from(file));
100    }
101
102    /// Render the errors grouped by class.
103    ///
104    /// `max_classes` caps how many classes are shown; `max_per_class` caps how
105    /// many places are listed within each class. `None` (or `Some(0)`, or a cap
106    /// ≥ the count) means "all". A cap that hides some places adds a
107    /// `… and N more <class> problems` line; a cap that hides some classes adds
108    /// a `… and N more classes` footer.
109    pub fn render(&self, max_classes: Option<usize>, max_per_class: Option<usize>) -> String {
110        let groups = self.group();
111        let total = self.errors.len();
112        let total_classes = groups.len();
113        let shown_classes = cap(max_classes, total_classes);
114
115        let noun = if total == 1 { "error" } else { "errors" };
116        let mut out = String::new();
117        match &self.file {
118            Some(f) => {
119                let _ = write!(out, "RESULT: {total} syntax {noun} in {f}");
120            }
121            None => {
122                let _ = write!(out, "RESULT: {total} syntax {noun}");
123            }
124        }
125
126        for (class, items) in groups.iter().take(shown_classes) {
127            out.push_str("\n\n");
128            render_class(&mut out, *class, items, max_per_class);
129        }
130
131        if shown_classes < total_classes {
132            let rest = total_classes - shown_classes;
133            let plural = if rest == 1 { "class" } else { "classes" };
134            let _ = write!(
135                out,
136                "\n\n... and {rest} more {plural} — pass --max-classes 0 for all"
137            );
138        }
139        out
140    }
141
142    /// Group errors by [`Class`], preserving first-appearance order (so output
143    /// is deterministic and follows the source top to bottom).
144    fn group(&self) -> Vec<(Class, Vec<&Diagnostic>)> {
145        let mut groups: Vec<(Class, Vec<&Diagnostic>)> = Vec::new();
146        for d in &self.errors {
147            let class = Class::of(d);
148            match groups.iter_mut().find(|(c, _)| *c == class) {
149                Some((_, items)) => items.push(d),
150                None => groups.push((class, vec![d])),
151            }
152        }
153        groups
154    }
155}
156
157impl fmt::Display for Diagnostics {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        f.write_str(&self.render(None, None))
160    }
161}
162
163/// Resolve an optional cap against a total: `None`/`Some(0)`/`≥ total` ⇒ all.
164fn cap(limit: Option<usize>, total: usize) -> usize {
165    match limit {
166        Some(n) if n > 0 && n < total => n,
167        _ => total,
168    }
169}
170
171/// The `      | ` gutter that prefixes a place's source line and caret line.
172const PLACE_GUTTER: &str = "      | ";
173
174/// Append one class block (no trailing newline) to `out`.
175fn render_class(
176    out: &mut String,
177    class: Class,
178    items: &[&Diagnostic],
179    max_per_class: Option<usize>,
180) {
181    let name = class.name();
182    let n = items.len();
183    let problems = if n == 1 { "problem" } else { "problems" };
184    let _ = writeln!(out, "{name}  ({n} {problems})");
185
186    // The correct-syntax reference, shown once for the whole class.
187    match class {
188        Class::Keyword(kw) => {
189            if let Some(card) = card_for(kw) {
190                label(out, "syntax", card.form);
191                label(out, "example", card.example);
192            }
193        }
194        Class::Statement => {
195            out.push_str("  expected one of these statements:");
196            for k in top_level_forms() {
197                let _ = write!(out, "\n      {}", k.card.form);
198            }
199            out.push('\n');
200        }
201        // Self-explanatory: each place carries its own message, no shared card.
202        Class::Other => {}
203    }
204
205    let shown = cap(max_per_class, n);
206    for d in items.iter().take(shown) {
207        render_place(out, d);
208    }
209    if shown < n {
210        let rest = n - shown;
211        let p = if rest == 1 { "problem" } else { "problems" };
212        let _ = writeln!(out, "    ... and {rest} more {name} {p}");
213    }
214
215    // Strip the trailing newline so classes join with a single blank line.
216    if out.ends_with('\n') {
217        out.pop();
218    }
219}
220
221/// Append one place: its location + message, the source line, and the caret.
222fn render_place(out: &mut String, d: &Diagnostic) {
223    let _ = writeln!(out, "    line {}, col {} - {}", d.line, d.col, d.message);
224    let _ = writeln!(out, "{PLACE_GUTTER}{}", d.line_text);
225    let pad = " ".repeat(d.col.saturating_sub(1));
226    let carets = "^".repeat(d.width.max(1));
227    let _ = writeln!(out, "{PLACE_GUTTER}{pad}{carets}");
228}
229
230/// Write a `  label   : value` line under a class header; continuation lines of
231/// a multi-line value align under the value (2 + 7 + 3 = 12 columns).
232fn label(out: &mut String, name: &str, value: &str) {
233    const VALUE_INDENT: &str = "            ";
234    let mut lines = value.split('\n');
235    let first = lines.next().unwrap_or("");
236    let _ = writeln!(out, "  {name:<7} : {first}");
237    for line in lines {
238        let _ = writeln!(out, "{VALUE_INDENT}{line}");
239    }
240}