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