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 prefix.
63 ///
64 /// `bynk.parse.orphan_doc_block`, `bynk.given.unused_capability`,
65 /// `bynk.list.deprecated_function`, and the `bynk.index.*` hygiene hints
66 /// (`missing`/`unused`, ADR 0118 D4) are warnings; everything else is an
67 /// error. Future categories can be added as the diagnostic surface grows.
68 pub fn for_error(err: &CompileError) -> Severity {
69 match err.category {
70 "bynk.parse.orphan_doc_block"
71 | "bynk.given.unused_capability"
72 | "bynk.list.deprecated_function"
73 | "bynk.index.missing"
74 | "bynk.index.unused"
75 // A computed secret name is legal and sometimes reasonable; the
76 // program is correct, `deploy` simply cannot see it (ADR 0196 D1).
77 | "bynk.secrets.computed_name" => Severity::Warning,
78 _ => Severity::Error,
79 }
80 }
81}
82
83/// Split diagnostics into `(errors, warnings)` by severity (ADR 0117). The build
84/// fails iff the `errors` half is non-empty; the `warnings` half surfaces but
85/// does not gate compilation. Relative order within each half is preserved.
86pub fn partition_by_severity(
87 diagnostics: Vec<CompileError>,
88) -> (Vec<CompileError>, Vec<CompileError>) {
89 diagnostics
90 .into_iter()
91 .partition(|d| Severity::for_error(d) == Severity::Error)
92}
93
94impl CompileError {
95 pub fn new(category: &'static str, span: Span, message: impl Into<String>) -> Self {
96 Self {
97 category,
98 span,
99 message: message.into(),
100 labels: Vec::new(),
101 notes: Vec::new(),
102 suggestions: Vec::new(),
103 }
104 }
105
106 /// Shift every span in this diagnostic — the primary span, secondary
107 /// labels, and suggestion edits — right by `delta` bytes. Used to rebase a
108 /// diagnostic produced against a substring (e.g. an interpolation hole
109 /// re-lexed on its own) into the full source, so the location is correct
110 /// and every span stays a valid char boundary. (#716.)
111 pub fn offset_spans(mut self, delta: usize) -> Self {
112 self.span = self.span.offset(delta);
113 for (span, _) in &mut self.labels {
114 *span = span.offset(delta);
115 }
116 for suggestion in &mut self.suggestions {
117 for (span, _) in &mut suggestion.edits {
118 *span = span.offset(delta);
119 }
120 }
121 self
122 }
123
124 pub fn with_label(mut self, span: Span, label: impl Into<String>) -> Self {
125 self.labels.push((span, label.into()));
126 self
127 }
128
129 pub fn with_note(mut self, note: impl Into<String>) -> Self {
130 self.notes.push(note.into());
131 self
132 }
133
134 /// Attach a machine-applicable fix (v0.26). Mirrors [`Self::with_note`];
135 /// the suggestion is authored where the diagnostic is raised.
136 pub fn with_suggestion(
137 mut self,
138 message: impl Into<String>,
139 edits: Vec<(Span, String)>,
140 applicability: Applicability,
141 ) -> Self {
142 self.suggestions.push(Suggestion {
143 message: message.into(),
144 edits,
145 applicability,
146 });
147 self
148 }
149
150 /// Build an [`ariadne::Report`] for this error, rendered against `source`
151 /// (labelled `filename`). Colour is on (for the CLI and human-facing test
152 /// output).
153 ///
154 /// A **secondary** label whose span does not sit cleanly within `source`
155 /// belongs to *another* file — a cross-file "declared here" pointing at a
156 /// `uses`-imported callee, or (#696) at a sibling file in a multi-file unit.
157 /// Rendering it here would underline unrelated text, and a byte span that
158 /// lands mid-codepoint would panic ariadne's byte→char mapping (#716). Such a
159 /// label is demoted to a note so the information survives without the
160 /// misplacement or the panic. The demotion test is deliberately conservative:
161 /// out-of-bounds **or** not on a char boundary of `source`. It cannot catch a
162 /// cross-file span that happens to be in-bounds and boundary-aligned — those
163 /// labels still need per-label file identity (a follow-up); the always
164 /// cross-file diagnostics (`kind_conflict`, `inconsistent_commons_name`)
165 /// avoid the ambiguity by carrying their cross-file provenance as a note.
166 pub fn report_for<'a>(
167 &'a self,
168 filename: &'a str,
169 source: &str,
170 ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
171 self.report_with_config(filename, Config::default(), source)
172 }
173
174 /// [`Self::report_for`] with colour disabled, for transcripts committed to
175 /// the repo — no ANSI escape codes, so the output is byte-stable across
176 /// machines.
177 pub fn report_plain_for<'a>(
178 &'a self,
179 filename: &'a str,
180 source: &str,
181 ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
182 self.report_with_config(filename, Config::default().with_color(false), source)
183 }
184
185 /// True when `span` sits cleanly inside `source` — in-bounds and on char
186 /// boundaries at both ends — so ariadne can underline it without misplacing
187 /// the caret or panicking on a byte offset that splits a codepoint (#716).
188 fn label_fits(span: &Span, source: &str) -> bool {
189 span.end <= source.len()
190 && source.is_char_boundary(span.start)
191 && source.is_char_boundary(span.end)
192 }
193
194 fn report_with_config<'a>(
195 &'a self,
196 filename: &'a str,
197 config: Config,
198 source: &str,
199 ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
200 let primary_span = (filename, self.span.range());
201 // Spans are byte offsets into the UTF-8 source; ariadne 0.6 defaults
202 // to character indexing, which misplaces the underline on any line
203 // with non-ASCII text before the span.
204 let mut builder = Report::build(ReportKind::Error, primary_span.clone())
205 .with_config(config.with_index_type(IndexType::Byte))
206 .with_code(self.category)
207 .with_message(&self.message)
208 .with_label(
209 Label::new(primary_span)
210 .with_message(&self.message)
211 .with_color(Color::Red),
212 );
213
214 for (span, label) in &self.labels {
215 if !Self::label_fits(span, source) {
216 // The label's span does not fit this file's source — demote to a
217 // note rather than underlining unrelated text (or panicking on a
218 // mid-codepoint offset).
219 builder = builder.with_note(label);
220 continue;
221 }
222 builder = builder.with_label(
223 Label::new((filename, span.range()))
224 .with_message(label)
225 .with_color(Color::Yellow),
226 );
227 }
228
229 for note in &self.notes {
230 builder = builder.with_note(note);
231 }
232
233 builder.finish()
234 }
235}
236
237#[cfg(test)]
238mod warning_channel_tests {
239 use super::*;
240 use crate::span::Span;
241
242 #[test]
243 fn partition_splits_by_severity() {
244 let warn = CompileError::new("bynk.given.unused_capability", Span::default(), "unused");
245 let err = CompileError::new("bynk.types.argument_mismatch", Span::default(), "bad");
246 let (errors, warnings) = partition_by_severity(vec![warn, err]);
247 assert_eq!(errors.len(), 1);
248 assert_eq!(errors[0].category, "bynk.types.argument_mismatch");
249 assert_eq!(warnings.len(), 1);
250 assert_eq!(warnings[0].category, "bynk.given.unused_capability");
251 }
252}