Skip to main content

lang_forge/
error.rs

1//! [`Error`]: why a sketch could not be forged, or a pipeline assembled.
2
3use alloc::{boxed::Box, vec::Vec};
4use core::fmt;
5
6use diag_lang::{Code, Diagnostic, Label, Severity, SourceMap};
7use syntax_lang::Span;
8
9/// Why a sketch could not be forged into a [`Language`](crate::Language),
10/// or a capability pipeline could not be assembled.
11///
12/// Forging checks the whole sketch and reports every problem it finds, not
13/// just the first, so one round of fixes is usually enough. Each problem is a
14/// [`Diagnostic`] with a [`Code`] (`LSF` and four digits, LSF2 §27) and a
15/// label that points into the sketch. For a single document handed to
16/// [`Language::from_lsf`](crate::Language::from_lsf) the spans are byte
17/// offsets into that text: add it to a fresh
18/// [`SourceMap`](diag_lang::SourceMap) and the spans line up. For a
19/// [`Sketch`](crate::Sketch) of several files they are positions in the
20/// sketch's own [`source_map`](crate::Sketch::source_map). Either way
21/// [`Renderer`](diag_lang::Renderer) draws each problem under the line at
22/// fault.
23///
24/// `Display` prints the first problem as `line:column: message` (with the
25/// file's path in front for a multi-file sketch), which is enough for a log
26/// line or a test failure; render the diagnostics for the full picture.
27///
28/// # What to do with one
29///
30/// An `Error` always means the sketch needs editing: forging is
31/// deterministic, so retrying the same text fails the same way. Every message
32/// names what was found and what was expected, and many carry a `help` line
33/// with the fix.
34///
35/// # Examples
36///
37/// ```
38/// use lang_forge::Language;
39/// use lang_forge::diag_lang::{Renderer, SourceMap};
40///
41/// let schematic = r#"
42/// [language]
43/// name = "broken"
44///
45/// [rules]
46/// program = "stmt*"
47/// stmt    = "exprr ';'"
48/// expr    = "NUMBER"
49/// "#;
50///
51/// let err = Language::from_lsf(schematic).unwrap_err();
52/// assert_eq!(err.to_string(), "7:12: undefined rule `exprr`");
53/// assert_eq!(err.diagnostics().len(), 1);
54/// assert_eq!(err.diagnostics()[0].code().map(|c| c.to_string()).as_deref(), Some("LSF4101"));
55///
56/// // Render it with source context.
57/// let mut map = SourceMap::new();
58/// map.add("broken.lsf", schematic).expect("fits");
59/// let text = Renderer::new().render(&err.diagnostics()[0], &map);
60/// assert!(text.contains("help: did you mean `expr`?"));
61/// ```
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct Error {
64    diagnostics: Vec<Diagnostic>,
65    line: u32,
66    column: u32,
67    /// The file of the first problem, for multi-file sketches.
68    path: Option<Box<str>>,
69}
70
71impl Error {
72    /// An error from collected diagnostics, locating the first in `text`.
73    pub(crate) fn new(diagnostics: Vec<Diagnostic>, text: &str) -> Self {
74        let offset = diagnostics
75            .first()
76            .map_or(0, |d| d.primary().span().start().to_usize());
77        let (line, column) = line_col(text, offset);
78        Self {
79            diagnostics,
80            line,
81            column,
82            path: None,
83        }
84    }
85
86    /// An error whose diagnostics have spans into `map`, locating the first
87    /// in the file it falls in.
88    pub(crate) fn in_map(diagnostics: Vec<Diagnostic>, map: &SourceMap) -> Self {
89        let mut error = Self {
90            diagnostics,
91            line: 1,
92            column: 1,
93            path: None,
94        };
95        let at = error
96            .diagnostics
97            .first()
98            .map(|d| d.primary().span().start());
99        if let Some((id, local)) = at.and_then(|at| map.locate(at)) {
100            if let Some(file) = map.source(id) {
101                let (line, column) = line_col(file.text(), local.to_usize());
102                error.line = line;
103                error.column = column;
104                error.path = Some(Box::from(file.name()));
105            }
106        }
107        error
108    }
109
110    /// An error whose first diagnostic is already located.
111    pub(crate) fn located(diagnostics: Vec<Diagnostic>, line: u32, column: u32) -> Self {
112        Self {
113            diagnostics,
114            line,
115            column,
116            path: None,
117        }
118    }
119
120    /// Every problem found, in the order they appear in the sketch.
121    ///
122    /// Never empty. Spans are byte offsets into the sketch text (positions in
123    /// the sketch's source map for a multi-file [`Sketch`](crate::Sketch)).
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use lang_forge::Language;
129    ///
130    /// let err = Language::from_lsf("[language]\n[rules]\n").unwrap_err();
131    /// let messages: Vec<&str> = err.diagnostics().iter().map(|d| d.message()).collect();
132    /// assert_eq!(messages, ["missing `name` in [language]", "[rules] declares no rules"]);
133    /// ```
134    #[inline]
135    #[must_use]
136    pub fn diagnostics(&self) -> &[Diagnostic] {
137        &self.diagnostics
138    }
139}
140
141impl fmt::Display for Error {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        let message = self.diagnostics.first().map_or("", |d| d.message());
144        if let Some(path) = &self.path {
145            write!(f, "{path}:")?;
146        }
147        write!(f, "{}:{}: {}", self.line, self.column, message)?;
148        match self.diagnostics.len() {
149            0 | 1 => Ok(()),
150            2 => f.write_str(" (and 1 more error)"),
151            n => write!(f, " (and {} more errors)", n - 1),
152        }
153    }
154}
155
156impl core::error::Error for Error {}
157
158/// The 1-based line and column (in characters) of byte `offset` in `text`.
159pub(crate) fn line_col(text: &str, offset: usize) -> (u32, u32) {
160    let mut offset = offset.min(text.len());
161    while !text.is_char_boundary(offset) {
162        offset -= 1;
163    }
164    let before = &text[..offset];
165    let line_start = before.rfind('\n').map_or(0, |i| i + 1);
166    let line = before.bytes().filter(|b| *b == b'\n').count() + 1;
167    let column = before[line_start..].chars().count() + 1;
168    (saturate(line), saturate(column))
169}
170
171fn saturate(n: usize) -> u32 {
172    u32::try_from(n).unwrap_or(u32::MAX)
173}
174
175/// An error diagnostic with a code, a span, and a message.
176pub(crate) fn coded(code: Code, span: Span, message: impl Into<Box<str>>) -> Diagnostic {
177    Diagnostic::new(Severity::Error, message, Label::unlabelled(span)).with_code(code)
178}
179
180/// Collects the problems found while forging.
181#[derive(Debug)]
182pub(crate) struct Report {
183    diagnostics: Vec<Diagnostic>,
184    errors: usize,
185    /// The did-you-mean work budget shared by the whole forge (ISSUES P10).
186    budget: u64,
187}
188
189impl Default for Report {
190    fn default() -> Self {
191        Self {
192            diagnostics: Vec::new(),
193            errors: 0,
194            budget: crate::suggest::BUDGET,
195        }
196    }
197}
198
199impl Report {
200    /// "did you mean `x`?" for the closest of `candidates` within `limit`
201    /// edits, from the forge's bounded suggestion budget.
202    pub(crate) fn suggest<'a>(
203        &mut self,
204        name: &str,
205        candidates: impl Iterator<Item = &'a str>,
206        limit: usize,
207    ) -> Option<alloc::string::String> {
208        crate::suggest::suggest_with(name, candidates, limit, &mut self.budget)
209    }
210
211    /// Records an error at `span`.
212    pub(crate) fn error(&mut self, code: Code, span: Span, message: impl Into<Box<str>>) {
213        self.diagnostic(coded(code, span, message));
214    }
215
216    /// Records an error at `span` with a `help` line.
217    pub(crate) fn error_help(
218        &mut self,
219        code: Code,
220        span: Span,
221        message: impl Into<Box<str>>,
222        help: impl Into<Box<str>>,
223    ) {
224        self.diagnostic(coded(code, span, message).with_help(help));
225    }
226
227    /// Records a warning at `span`: reported, but the sketch still forges.
228    pub(crate) fn warning(&mut self, code: Code, span: Span, message: impl Into<Box<str>>) {
229        self.diagnostic(
230            Diagnostic::new(Severity::Warning, message, Label::unlabelled(span)).with_code(code),
231        );
232    }
233
234    /// Records a fully built diagnostic.
235    pub(crate) fn diagnostic(&mut self, diagnostic: Diagnostic) {
236        if diagnostic.severity() == Severity::Error {
237            self.errors += 1;
238        }
239        self.diagnostics.push(diagnostic);
240    }
241
242    /// Whether no error has been recorded (warnings may have been).
243    pub(crate) fn is_clean(&self) -> bool {
244        self.errors == 0
245    }
246
247    /// How many errors have been recorded.
248    pub(crate) fn errors(&self) -> usize {
249        self.errors
250    }
251
252    /// Sorts the problems by position, stably.
253    fn sort(&mut self) {
254        self.diagnostics
255            .sort_by_key(|d| d.primary().span().start().to_u32());
256    }
257
258    /// Converts the report into an error, ordering problems by position.
259    pub(crate) fn into_error(mut self, text: &str) -> Error {
260        self.sort();
261        Error::new(self.diagnostics, text)
262    }
263
264    /// Converts the report into an error for a single format-2 document:
265    /// sorted by span start, span end, code, and message, with exact
266    /// duplicates removed (LSF2 §5.6).
267    pub(crate) fn into_error_v2(self, text: &str) -> Error {
268        let diagnostics = sorted_for_sketch(self.diagnostics, &|_| 0);
269        Error::new(diagnostics, text)
270    }
271
272    /// The warnings of a single format-2 document, in LSF2 §5.6 order.
273    pub(crate) fn into_warnings_v2(self) -> Vec<Diagnostic> {
274        sorted_for_sketch(self.diagnostics, &|_| 0)
275    }
276
277    /// Converts the report into an error whose spans are positions in `map`,
278    /// sorted by (`rank` of the file, span start, span end, code, message)
279    /// and with exact duplicates removed (LSF2 §5.6).
280    pub(crate) fn into_sketch_error(self, map: &SourceMap, rank: &dyn Fn(Span) -> u32) -> Error {
281        let diagnostics = sorted_for_sketch(self.diagnostics, rank);
282        Error::in_map(diagnostics, map)
283    }
284
285    /// The warnings, in position order (once forging has succeeded).
286    pub(crate) fn into_warnings(mut self) -> Vec<Diagnostic> {
287        self.sort();
288        self.diagnostics
289    }
290
291    /// The warnings of a multi-file sketch, in sketch order.
292    pub(crate) fn into_sketch_warnings(self, rank: &dyn Fn(Span) -> u32) -> Vec<Diagnostic> {
293        sorted_for_sketch(self.diagnostics, rank)
294    }
295}
296
297/// LSF2 §5.6 ordering, with exact duplicates removed.
298fn sorted_for_sketch(
299    mut diagnostics: Vec<Diagnostic>,
300    rank: &dyn Fn(Span) -> u32,
301) -> Vec<Diagnostic> {
302    diagnostics.sort_by(|a, b| {
303        let (sa, sb) = (a.primary().span(), b.primary().span());
304        rank(sa)
305            .cmp(&rank(sb))
306            .then(sa.start().cmp(&sb.start()))
307            .then(sa.end().cmp(&sb.end()))
308            .then(a.code().cmp(&b.code()))
309            .then(a.message().cmp(b.message()))
310    });
311    diagnostics.dedup_by(|a, b| {
312        a.primary().span() == b.primary().span()
313            && a.code() == b.code()
314            && a.message() == b.message()
315    });
316    diagnostics
317}
318
319#[cfg(test)]
320mod tests {
321    #![allow(clippy::unwrap_used, clippy::expect_used)]
322
323    use alloc::string::ToString;
324
325    use super::*;
326    use crate::codes;
327
328    #[test]
329    fn test_line_col_counts_characters_not_bytes() {
330        let text = "ab\nçd\n";
331        assert_eq!(line_col(text, 0), (1, 1));
332        assert_eq!(line_col(text, 3), (2, 1));
333        // `d` sits after the two-byte `ç`.
334        assert_eq!(line_col(text, 5), (2, 2));
335        assert_eq!(line_col(text, 999), (3, 1));
336    }
337
338    #[test]
339    fn test_line_col_inside_multibyte_char_does_not_panic() {
340        assert_eq!(line_col("ç", 1), (1, 1));
341    }
342
343    #[test]
344    fn test_display_counts_additional_errors() {
345        let mut report = Report::default();
346        report.error(codes::MISSING, Span::new(4, 5), "second");
347        report.error(codes::MISSING, Span::new(0, 1), "first");
348        let err = report.into_error("abcdef");
349        assert_eq!(err.to_string(), "1:1: first (and 1 more error)");
350
351        let mut report = Report::default();
352        for _ in 0..3 {
353            report.error(codes::MISSING, Span::new(0, 0), "same");
354        }
355        assert_eq!(
356            report.into_error("").to_string(),
357            "1:1: same (and 2 more errors)"
358        );
359    }
360
361    #[test]
362    fn test_report_help_is_attached() {
363        let mut report = Report::default();
364        report.error_help(codes::MISSING, Span::new(0, 1), "bad", "fix it");
365        assert!(!report.is_clean());
366        let err = report.into_error("x");
367        assert_eq!(err.diagnostics()[0].help().collect::<Vec<_>>(), ["fix it"]);
368        assert_eq!(err.diagnostics()[0].code(), Some(codes::MISSING));
369    }
370
371    #[test]
372    fn test_report_warnings_keep_it_clean() {
373        let mut report = Report::default();
374        report.warning(codes::UNUSED_RULE, Span::new(0, 1), "unused");
375        assert!(report.is_clean());
376        assert_eq!(report.errors(), 0);
377        let warnings = report.into_warnings();
378        assert_eq!(warnings.len(), 1);
379        assert_eq!(warnings[0].severity(), Severity::Warning);
380    }
381
382    #[test]
383    fn test_sketch_errors_name_the_file_and_dedup() {
384        let mut map = SourceMap::new();
385        let _ = map.add("a.lsf", "x\n").expect("fits");
386        let _ = map.add("b.lsf", "yy\nzz\n").expect("fits");
387        let mut report = Report::default();
388        report.error(codes::MISSING, Span::new(6, 7), "late");
389        report.error(codes::MISSING, Span::new(6, 7), "late");
390        report.error(codes::MISSING, Span::new(1, 2), "early");
391        let err = report.into_sketch_error(&map, &|_| 0);
392        assert_eq!(err.diagnostics().len(), 2);
393        assert_eq!(err.to_string(), "a.lsf:1:2: early (and 1 more error)");
394    }
395}