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}
62
63/// Severity classification for a [`CompileError`]. Mirrors LSP severity levels
64/// so the LSP server can map diagnostics to the protocol without reinterpreting
65/// error categories. Lives in the syntax leaf beside `CompileError` (it
66/// classifies one): shared by the IDE diagnose path (`bynk-ide`) and the
67/// `short`/`json` renderers, without either depending on the other.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Severity {
70 Error,
71 Warning,
72}
73
74impl Severity {
75 /// Classify a [`CompileError`] by its category's registered severity
76 /// (`crate::diagnostics::REGISTRY`) — the registry entry built via `warn`
77 /// is the single source of truth for which codes are non-failing
78 /// warnings (ADR 0117); everything else defaults to `Error`, including a
79 /// category that isn't registered at all (which `tests/diagnostics_registry.rs`
80 /// asserts cannot happen for a code actually emitted in source).
81 pub fn for_error(err: &CompileError) -> Severity {
82 crate::diagnostics::lookup(err.category)
83 .map(|d| d.severity)
84 .unwrap_or(Severity::Error)
85 }
86}
87
88/// Split diagnostics into `(errors, warnings)` by severity (ADR 0117). The build
89/// fails iff the `errors` half is non-empty; the `warnings` half surfaces but
90/// does not gate compilation. Relative order within each half is preserved.
91pub fn partition_by_severity(
92 diagnostics: Vec<CompileError>,
93) -> (Vec<CompileError>, Vec<CompileError>) {
94 diagnostics
95 .into_iter()
96 .partition(|d| Severity::for_error(d) == Severity::Error)
97}
98
99impl CompileError {
100 pub fn new(category: &'static str, span: Span, message: impl Into<String>) -> Self {
101 Self {
102 category,
103 span,
104 message: message.into(),
105 labels: Vec::new(),
106 notes: Vec::new(),
107 suggestions: Vec::new(),
108 }
109 }
110
111 /// Shift every span in this diagnostic — the primary span, secondary
112 /// labels, and suggestion edits — right by `delta` bytes. Used to rebase a
113 /// diagnostic produced against a substring (e.g. an interpolation hole
114 /// re-lexed on its own) into the full source, so the location is correct
115 /// and every span stays a valid char boundary. (#716.)
116 pub fn offset_spans(mut self, delta: usize) -> Self {
117 self.span = self.span.offset(delta);
118 for (span, _) in &mut self.labels {
119 *span = span.offset(delta);
120 }
121 for suggestion in &mut self.suggestions {
122 for (span, _) in &mut suggestion.edits {
123 *span = span.offset(delta);
124 }
125 }
126 self
127 }
128
129 pub fn with_label(mut self, span: Span, label: impl Into<String>) -> Self {
130 self.labels.push((span, label.into()));
131 self
132 }
133
134 pub fn with_note(mut self, note: impl Into<String>) -> Self {
135 self.notes.push(note.into());
136 self
137 }
138
139 /// Attach a machine-applicable fix (v0.26). Mirrors [`Self::with_note`];
140 /// the suggestion is authored where the diagnostic is raised.
141 pub fn with_suggestion(
142 mut self,
143 message: impl Into<String>,
144 edits: Vec<(Span, String)>,
145 applicability: Applicability,
146 ) -> Self {
147 self.suggestions.push(Suggestion {
148 message: message.into(),
149 edits,
150 applicability,
151 });
152 self
153 }
154
155 /// Build an [`ariadne::Report`] for this error, rendered against `source`
156 /// (labelled `filename`). Colour is on (for the CLI and human-facing test
157 /// output). Every secondary label is taken to lie in `source` too; see
158 /// [`Self::report_in`] for one whose labels may lie in other files.
159 ///
160 /// A **secondary** label whose span does not sit cleanly within `source`
161 /// belongs to *another* file — a cross-file "declared here" pointing at a
162 /// `uses`-imported callee, or (#696) at a sibling file in a multi-file unit.
163 /// Rendering it here would underline unrelated text, and a byte span that
164 /// lands mid-codepoint would panic ariadne's byte→char mapping (#716). Such a
165 /// label is demoted to a note so the information survives without the
166 /// misplacement or the panic. The demotion test is deliberately conservative:
167 /// out-of-bounds **or** not on a char boundary of `source`. It cannot catch a
168 /// cross-file span that happens to be in-bounds and boundary-aligned — a
169 /// caller that knows each label's file passes it to [`Self::report_in`].
170 pub fn report_for<'a>(
171 &'a self,
172 filename: &'a str,
173 source: &str,
174 ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
175 self.report_with_config(filename, source, &[], Config::default())
176 }
177
178 /// [`Self::report_for`] with colour disabled, for transcripts committed to
179 /// the repo — no ANSI escape codes, so the output is byte-stable across
180 /// machines.
181 pub fn report_plain_for<'a>(
182 &'a self,
183 filename: &'a str,
184 source: &str,
185 ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
186 self.report_with_config(filename, source, &[], Config::default().with_color(false))
187 }
188
189 /// [`Self::report_for`] for an error whose secondary labels may lie in other
190 /// files: `homes[i]` says which file `self.labels[i]` lies in (a label past
191 /// the end of `homes` is [`LabelHome::Primary`]). A label in another file is
192 /// drawn against that file's text, under its own filename, so the report's
193 /// cache must hold every [`LabelHome::File`] source as well as `source`.
194 /// `color` picks [`Self::report_for`]'s or [`Self::report_plain_for`]'s
195 /// output.
196 pub fn report_in<'a>(
197 &'a self,
198 filename: &'a str,
199 source: &str,
200 homes: &[LabelHome<'a>],
201 color: bool,
202 ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
203 self.report_with_config(filename, source, homes, Config::default().with_color(color))
204 }
205
206 /// True when `span` sits cleanly inside `source` — in-bounds and on char
207 /// boundaries at both ends — so ariadne can underline it without misplacing
208 /// the caret or panicking on a byte offset that splits a codepoint (#716).
209 fn label_fits(span: &Span, source: &str) -> bool {
210 span.end <= source.len()
211 && source.is_char_boundary(span.start)
212 && source.is_char_boundary(span.end)
213 }
214
215 fn report_with_config<'a>(
216 &'a self,
217 filename: &'a str,
218 source: &str,
219 homes: &[LabelHome<'a>],
220 config: Config,
221 ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
222 let primary_span = (filename, self.span.range());
223 // ADR 0117: a warning-severity diagnostic must render as a warning, not
224 // an error — `report_with_config` is the only `ReportKind` in the
225 // workspace, so this is the one place that decides it.
226 let kind = match Severity::for_error(self) {
227 Severity::Error => ReportKind::Error,
228 Severity::Warning => ReportKind::Warning,
229 };
230 // Spans are byte offsets into the UTF-8 source; ariadne 0.6 defaults
231 // to character indexing, which misplaces the underline on any line
232 // with non-ASCII text before the span.
233 let mut builder = Report::build(kind, primary_span.clone())
234 .with_config(config.with_index_type(IndexType::Byte))
235 .with_code(self.category)
236 .with_message(&self.message)
237 .with_label(
238 Label::new(primary_span)
239 .with_message(&self.message)
240 .with_color(Color::Red),
241 );
242
243 for (i, (span, label)) in self.labels.iter().enumerate() {
244 let (label_file, label_source) = match homes.get(i).unwrap_or(&LabelHome::Primary) {
245 LabelHome::Primary => (filename, source),
246 LabelHome::File { filename, source } => (*filename, *source),
247 LabelHome::Unavailable => {
248 builder = builder.with_note(label);
249 continue;
250 }
251 };
252 if !Self::label_fits(span, label_source) {
253 // The label's span does not fit its file's source — demote to a
254 // note rather than underlining unrelated text (or panicking on a
255 // mid-codepoint offset).
256 builder = builder.with_note(label);
257 continue;
258 }
259 builder = builder.with_label(
260 Label::new((label_file, span.range()))
261 .with_message(label)
262 .with_color(Color::Yellow),
263 );
264 }
265
266 for note in &self.notes {
267 builder = builder.with_note(note);
268 }
269
270 // Finding #49 (ADR 0054): a structured suggestion previously reached
271 // only the LSP's code-action surface; the CLI never rendered it at
272 // all. Shown as a `help:`-prefixed note — mirrors rustc's own
273 // note/help distinction — rather than a new ariadne builder call, so
274 // it composes with an arbitrary number of suggestions.
275 for suggestion in &self.suggestions {
276 builder = builder.with_note(format!("help: {}", suggestion.message));
277 }
278
279 builder.finish()
280 }
281}
282
283#[cfg(test)]
284mod warning_channel_tests {
285 use super::*;
286 use crate::span::Span;
287
288 #[test]
289 fn partition_splits_by_severity() {
290 let warn = CompileError::new("bynk.given.unused_capability", Span::default(), "unused");
291 let err = CompileError::new("bynk.types.argument_mismatch", Span::default(), "bad");
292 let (errors, warnings) = partition_by_severity(vec![warn, err]);
293 assert_eq!(errors.len(), 1);
294 assert_eq!(errors[0].category, "bynk.types.argument_mismatch");
295 assert_eq!(warnings.len(), 1);
296 assert_eq!(warnings[0].category, "bynk.given.unused_capability");
297 }
298
299 /// `report_with_config` (the only `ReportKind` in the workspace) hardcoded
300 /// `ReportKind::Error`, so a warning-severity diagnostic printed "Error:"
301 /// and there was no way for a renderer built on `report_for` to tell them
302 /// apart. It must pick the `ReportKind` from `Severity::for_error`.
303 #[test]
304 fn report_for_renders_warning_severity_as_a_warning_not_an_error() {
305 let source = "commons w\n\nfn f() -> Int { 1 }\n";
306 let warn = CompileError::new("bynk.given.unused_capability", Span::default(), "unused");
307 let rendered = {
308 let mut out = Vec::new();
309 let mut cache = ("w.bynk", ariadne::Source::from(source));
310 warn.report_plain_for("w.bynk", source)
311 .write(&mut cache, &mut out)
312 .unwrap();
313 String::from_utf8(out).unwrap()
314 };
315 assert!(
316 rendered.contains("Warning:"),
317 "expected a `Warning:` report for a warning-severity category, got:\n{rendered}"
318 );
319 assert!(
320 !rendered.contains("Error:"),
321 "a warning-severity category must not render as `Error:`, got:\n{rendered}"
322 );
323
324 let err = CompileError::new("bynk.types.argument_mismatch", Span::default(), "mismatch");
325 let rendered_err = {
326 let mut out = Vec::new();
327 let mut cache = ("w.bynk", ariadne::Source::from(source));
328 err.report_plain_for("w.bynk", source)
329 .write(&mut cache, &mut out)
330 .unwrap();
331 String::from_utf8(out).unwrap()
332 };
333 assert!(
334 rendered_err.contains("Error:"),
335 "an error-severity category must still render as `Error:`, got:\n{rendered_err}"
336 );
337 }
338}