Skip to main content

bynk_syntax/
error.rs

1//! Compiler diagnostics.
2//!
3//! Every error has a category (a dotted namespace string like
4//! `bynk.parse.expected_token`), a primary span, a primary message, and
5//! optionally some secondary labels and notes. Rendering goes through
6//! [`ariadne`] for source-pointing colour output.
7
8use ariadne::{Color, Config, IndexType, Label, Report, ReportKind};
9
10use crate::span::Span;
11
12/// A compile error.
13#[derive(Debug, Clone)]
14pub struct CompileError {
15    pub category: &'static str,
16    pub span: Span,
17    pub message: String,
18    pub labels: Vec<(Span, String)>,
19    pub notes: Vec<String>,
20    /// v0.26 (ADR 0054): machine-applicable fixes, authored at the diagnosis
21    /// site — the only place the exact spans and replacement are known.
22    /// Consumed by the LSP (`codeAction`) and, later, a CLI `--fix`.
23    pub suggestions: Vec<Suggestion>,
24}
25
26/// Which file one of a [`CompileError`]'s secondary labels lies in, for
27/// [`CompileError::report_in`]. A label's span is a byte offset into its own
28/// file's text, which need not be the file the error itself is reported in.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum LabelHome<'a> {
31    /// The file the error is reported in.
32    Primary,
33    /// Another file: the name to show it under and its text.
34    File { filename: &'a str, source: &'a str },
35    /// A file with no text to show — the label is rendered as a note.
36    Unavailable,
37}
38
39/// A structured fix for the error it is attached to (v0.26, ADR 0054).
40///
41/// `edits` are span → replacement: an empty replacement deletes the span; an
42/// empty span inserts at its position. Spans are offsets into the same source
43/// text as the error's own span.
44#[derive(Debug, Clone)]
45pub struct Suggestion {
46    /// Human-facing action title, e.g. "remove `Clock` from the `given` clause".
47    pub message: String,
48    pub edits: Vec<(Span, String)>,
49    pub applicability: Applicability,
50}
51
52/// Whether a [`Suggestion`] can be applied without review (mirrors rustc;
53/// gates a future CLI `--fix` and the LSP's one-click apply).
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Applicability {
56    /// The fix is exactly right — safe to apply mechanically.
57    MachineApplicable,
58    /// The fix contains placeholder text a human must complete; never
59    /// auto-applied.
60    HasPlaceholders,
61    /// The fix is a complete edit but changes meaning the author must choose
62    /// (`derives Ord` makes declaration order significant): offered as a quick
63    /// fix, never taken by a fix-all or `--fix`.
64    MaybeIncorrect,
65}
66
67/// Severity classification for a [`CompileError`]. Mirrors LSP severity levels
68/// so the LSP server can map diagnostics to the protocol without reinterpreting
69/// error categories. Lives in the syntax leaf beside `CompileError` (it
70/// classifies one): shared by the IDE diagnose path (`bynk-ide`) and the
71/// `short`/`json` renderers, without either depending on the other.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum Severity {
74    Error,
75    Warning,
76    /// #2838: a suggestion, quieter than a warning. Never fails a build, is
77    /// hidden from the `bynk check` / `bynk build` terminal output, and is
78    /// surfaced by the editor (an LSP hint) and the machine-readable surfaces.
79    /// Its machine-applicable fix is still taken by `check --fix`.
80    Hint,
81}
82
83impl Severity {
84    /// Classify a [`CompileError`] by its category's registered severity
85    /// (`crate::diagnostics::REGISTRY`) — the registry entry built via `warn`
86    /// is the single source of truth for which codes are non-failing
87    /// warnings (ADR 0117); everything else defaults to `Error`, including a
88    /// category that isn't registered at all (which `tests/diagnostics_registry.rs`
89    /// asserts cannot happen for a code actually emitted in source).
90    pub fn for_error(err: &CompileError) -> Severity {
91        crate::diagnostics::lookup(err.category)
92            .map(|d| d.severity)
93            .unwrap_or(Severity::Error)
94    }
95}
96
97/// Split diagnostics into `(errors, warnings)` by severity (ADR 0117). The build
98/// fails iff the `errors` half is non-empty; the `warnings` half surfaces but
99/// does not gate compilation. Relative order within each half is preserved.
100pub fn partition_by_severity(
101    diagnostics: Vec<CompileError>,
102) -> (Vec<CompileError>, Vec<CompileError>) {
103    diagnostics
104        .into_iter()
105        .partition(|d| Severity::for_error(d) == Severity::Error)
106}
107
108impl CompileError {
109    pub fn new(category: &'static str, span: Span, message: impl Into<String>) -> Self {
110        Self {
111            category,
112            span,
113            message: message.into(),
114            labels: Vec::new(),
115            notes: Vec::new(),
116            suggestions: Vec::new(),
117        }
118    }
119
120    /// Shift every span in this diagnostic — the primary span, secondary
121    /// labels, and suggestion edits — right by `delta` bytes. Used to rebase a
122    /// diagnostic produced against a substring (e.g. an interpolation hole
123    /// re-lexed on its own) into the full source, so the location is correct
124    /// and every span stays a valid char boundary. (#716.)
125    pub fn offset_spans(mut self, delta: usize) -> Self {
126        self.span = self.span.offset(delta);
127        for (span, _) in &mut self.labels {
128            *span = span.offset(delta);
129        }
130        for suggestion in &mut self.suggestions {
131            for (span, _) in &mut suggestion.edits {
132                *span = span.offset(delta);
133            }
134        }
135        self
136    }
137
138    pub fn with_label(mut self, span: Span, label: impl Into<String>) -> Self {
139        self.labels.push((span, label.into()));
140        self
141    }
142
143    pub fn with_note(mut self, note: impl Into<String>) -> Self {
144        self.notes.push(note.into());
145        self
146    }
147
148    /// Attach a machine-applicable fix (v0.26). Mirrors [`Self::with_note`];
149    /// the suggestion is authored where the diagnostic is raised.
150    pub fn with_suggestion(
151        mut self,
152        message: impl Into<String>,
153        edits: Vec<(Span, String)>,
154        applicability: Applicability,
155    ) -> Self {
156        self.suggestions.push(Suggestion {
157            message: message.into(),
158            edits,
159            applicability,
160        });
161        self
162    }
163
164    /// Build an [`ariadne::Report`] for this error, rendered against `source`
165    /// (labelled `filename`). Colour is on (for the CLI and human-facing test
166    /// output). Every secondary label is taken to lie in `source` too; see
167    /// [`Self::report_in`] for one whose labels may lie in other files.
168    ///
169    /// A **secondary** label whose span does not sit cleanly within `source`
170    /// belongs to *another* file — a cross-file "declared here" pointing at a
171    /// `uses`-imported callee, or (#696) at a sibling file in a multi-file unit.
172    /// Rendering it here would underline unrelated text, and a byte span that
173    /// lands mid-codepoint would panic ariadne's byte→char mapping (#716). Such a
174    /// label is demoted to a note so the information survives without the
175    /// misplacement or the panic. The demotion test is deliberately conservative:
176    /// out-of-bounds **or** not on a char boundary of `source`. It cannot catch a
177    /// cross-file span that happens to be in-bounds and boundary-aligned — a
178    /// caller that knows each label's file passes it to [`Self::report_in`].
179    pub fn report_for<'a>(
180        &'a self,
181        filename: &'a str,
182        source: &str,
183    ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
184        self.report_with_config(filename, source, &[], Config::default())
185    }
186
187    /// [`Self::report_for`] with colour disabled, for transcripts committed to
188    /// the repo — no ANSI escape codes, so the output is byte-stable across
189    /// machines.
190    pub fn report_plain_for<'a>(
191        &'a self,
192        filename: &'a str,
193        source: &str,
194    ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
195        self.report_with_config(filename, source, &[], Config::default().with_color(false))
196    }
197
198    /// [`Self::report_for`] for an error whose secondary labels may lie in other
199    /// files: `homes[i]` says which file `self.labels[i]` lies in (a label past
200    /// the end of `homes` is [`LabelHome::Primary`]). A label in another file is
201    /// drawn against that file's text, under its own filename, so the report's
202    /// cache must hold every [`LabelHome::File`] source as well as `source`.
203    /// `color` picks [`Self::report_for`]'s or [`Self::report_plain_for`]'s
204    /// output.
205    pub fn report_in<'a>(
206        &'a self,
207        filename: &'a str,
208        source: &str,
209        homes: &[LabelHome<'a>],
210        color: bool,
211    ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
212        self.report_with_config(filename, source, homes, Config::default().with_color(color))
213    }
214
215    /// True when `span` sits cleanly inside `source` — in-bounds and on char
216    /// boundaries at both ends — so ariadne can underline it without misplacing
217    /// the caret or panicking on a byte offset that splits a codepoint (#716).
218    fn label_fits(span: &Span, source: &str) -> bool {
219        span.end <= source.len()
220            && source.is_char_boundary(span.start)
221            && source.is_char_boundary(span.end)
222    }
223
224    /// `span`'s byte range in `source` as the char range ariadne indexes by.
225    /// An offset inside a codepoint counts from that codepoint's start; one
226    /// past the end stays past it by the same amount, so ariadne still sees an
227    /// out-of-bounds span as out of bounds.
228    fn char_range(source: &str, span: &Span) -> std::ops::Range<usize> {
229        let at = |byte: usize| {
230            if byte > source.len() {
231                source.chars().count() + (byte - source.len())
232            } else {
233                source[..source.floor_char_boundary(byte)].chars().count()
234            }
235        };
236        at(span.start)..at(span.end)
237    }
238
239    fn report_with_config<'a>(
240        &'a self,
241        filename: &'a str,
242        source: &str,
243        homes: &[LabelHome<'a>],
244        config: Config,
245    ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
246        let primary_span = (filename, Self::char_range(source, &self.span));
247        // ADR 0117: a warning-severity diagnostic must render as a warning, not
248        // an error — `report_with_config` is the only `ReportKind` in the
249        // workspace, so this is the one place that decides it.
250        let kind = match Severity::for_error(self) {
251            Severity::Error => ReportKind::Error,
252            Severity::Warning => ReportKind::Warning,
253            Severity::Hint => ReportKind::Advice,
254        };
255        // Spans are byte offsets into the UTF-8 source, but ariadne is handed
256        // char offsets ([`Self::char_range`]), each converted against the text
257        // its span lies in. Its `IndexType::Byte` mode is wrong for a label in
258        // another file: ariadne 0.6 heads that file's snippet by looking the
259        // label's *char* offset up as a byte offset, so the header names the
260        // wrong line and column after any non-ASCII text — and panics when
261        // that offset splits a codepoint (#1956: `bynk.bynk` is full of `—`).
262        let mut builder = Report::build(kind, primary_span.clone())
263            .with_config(config.with_index_type(IndexType::Char))
264            .with_code(self.category)
265            .with_message(&self.message)
266            .with_label(
267                Label::new(primary_span)
268                    .with_message(&self.message)
269                    .with_color(Color::Red),
270            );
271
272        for (i, (span, label)) in self.labels.iter().enumerate() {
273            let (label_file, label_source) = match homes.get(i).unwrap_or(&LabelHome::Primary) {
274                LabelHome::Primary => (filename, source),
275                LabelHome::File { filename, source } => (*filename, *source),
276                LabelHome::Unavailable => {
277                    builder = builder.with_note(label);
278                    continue;
279                }
280            };
281            if !Self::label_fits(span, label_source) {
282                // The label's span does not fit its file's source — demote to a
283                // note rather than underlining unrelated text (or panicking on a
284                // mid-codepoint offset).
285                builder = builder.with_note(label);
286                continue;
287            }
288            builder = builder.with_label(
289                Label::new((label_file, Self::char_range(label_source, span)))
290                    .with_message(label)
291                    .with_color(Color::Yellow),
292            );
293        }
294
295        for note in &self.notes {
296            builder = builder.with_note(note);
297        }
298
299        // Finding #49 (ADR 0054): a structured suggestion previously reached
300        // only the LSP's code-action surface; the CLI never rendered it at
301        // all. Shown as a `help:`-prefixed note — mirrors rustc's own
302        // note/help distinction — rather than a new ariadne builder call, so
303        // it composes with an arbitrary number of suggestions.
304        for suggestion in &self.suggestions {
305            builder = builder.with_note(format!("help: {}", suggestion.message));
306        }
307
308        builder.finish()
309    }
310}
311
312#[cfg(test)]
313mod warning_channel_tests {
314    use super::*;
315    use crate::span::Span;
316
317    #[test]
318    fn partition_splits_by_severity() {
319        let warn = CompileError::new("bynk.given.unused_capability", Span::default(), "unused");
320        let err = CompileError::new("bynk.types.argument_mismatch", Span::default(), "bad");
321        let (errors, warnings) = partition_by_severity(vec![warn, err]);
322        assert_eq!(errors.len(), 1);
323        assert_eq!(errors[0].category, "bynk.types.argument_mismatch");
324        assert_eq!(warnings.len(), 1);
325        assert_eq!(warnings[0].category, "bynk.given.unused_capability");
326    }
327
328    /// `report_with_config` (the only `ReportKind` in the workspace) hardcoded
329    /// `ReportKind::Error`, so a warning-severity diagnostic printed "Error:"
330    /// and there was no way for a renderer built on `report_for` to tell them
331    /// apart. It must pick the `ReportKind` from `Severity::for_error`.
332    #[test]
333    fn report_for_renders_warning_severity_as_a_warning_not_an_error() {
334        let source = "commons w\n\nfn f() -> Int { 1 }\n";
335        let warn = CompileError::new("bynk.given.unused_capability", Span::default(), "unused");
336        let rendered = {
337            let mut out = Vec::new();
338            let mut cache = ("w.bynk", ariadne::Source::from(source));
339            warn.report_plain_for("w.bynk", source)
340                .write(&mut cache, &mut out)
341                .unwrap();
342            String::from_utf8(out).unwrap()
343        };
344        assert!(
345            rendered.contains("Warning:"),
346            "expected a `Warning:` report for a warning-severity category, got:\n{rendered}"
347        );
348        assert!(
349            !rendered.contains("Error:"),
350            "a warning-severity category must not render as `Error:`, got:\n{rendered}"
351        );
352
353        let err = CompileError::new("bynk.types.argument_mismatch", Span::default(), "mismatch");
354        let rendered_err = {
355            let mut out = Vec::new();
356            let mut cache = ("w.bynk", ariadne::Source::from(source));
357            err.report_plain_for("w.bynk", source)
358                .write(&mut cache, &mut out)
359                .unwrap();
360            String::from_utf8(out).unwrap()
361        };
362        assert!(
363            rendered_err.contains("Error:"),
364            "an error-severity category must still render as `Error:`, got:\n{rendered_err}"
365        );
366    }
367}