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, 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 /// Categories starting with `bynk.parse.orphan_doc_block` or
65 /// `bynk.given.unused_capability` are warnings; everything else is an
66 /// error. Future categories can be added as the diagnostic surface grows.
67 pub fn for_error(err: &CompileError) -> Severity {
68 match err.category {
69 "bynk.parse.orphan_doc_block" | "bynk.given.unused_capability" => Severity::Warning,
70 _ => Severity::Error,
71 }
72 }
73}
74
75impl CompileError {
76 pub fn new(category: &'static str, span: Span, message: impl Into<String>) -> Self {
77 Self {
78 category,
79 span,
80 message: message.into(),
81 labels: Vec::new(),
82 notes: Vec::new(),
83 suggestions: Vec::new(),
84 }
85 }
86
87 pub fn with_label(mut self, span: Span, label: impl Into<String>) -> Self {
88 self.labels.push((span, label.into()));
89 self
90 }
91
92 pub fn with_note(mut self, note: impl Into<String>) -> Self {
93 self.notes.push(note.into());
94 self
95 }
96
97 /// Attach a machine-applicable fix (v0.26). Mirrors [`Self::with_note`];
98 /// the suggestion is authored where the diagnostic is raised.
99 pub fn with_suggestion(
100 mut self,
101 message: impl Into<String>,
102 edits: Vec<(Span, String)>,
103 applicability: Applicability,
104 ) -> Self {
105 self.suggestions.push(Suggestion {
106 message: message.into(),
107 edits,
108 applicability,
109 });
110 self
111 }
112
113 /// Build an [`ariadne::Report`] for this error, anchored to the given
114 /// filename. Colour is on (for the CLI and human-facing test output).
115 pub fn report<'a>(
116 &'a self,
117 filename: &'a str,
118 ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
119 self.report_with_config(filename, Config::default())
120 }
121
122 /// Build a colourless [`ariadne::Report`], for transcripts committed to the
123 /// repo — no ANSI escape codes, so the output is byte-stable across machines.
124 pub fn report_plain<'a>(
125 &'a self,
126 filename: &'a str,
127 ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
128 self.report_with_config(filename, Config::default().with_color(false))
129 }
130
131 fn report_with_config<'a>(
132 &'a self,
133 filename: &'a str,
134 config: Config,
135 ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
136 let primary_span = (filename, self.span.range());
137 let mut builder = Report::build(ReportKind::Error, primary_span.clone())
138 .with_config(config)
139 .with_code(self.category)
140 .with_message(&self.message)
141 .with_label(
142 Label::new(primary_span)
143 .with_message(&self.message)
144 .with_color(Color::Red),
145 );
146
147 for (span, label) in &self.labels {
148 builder = builder.with_label(
149 Label::new((filename, span.range()))
150 .with_message(label)
151 .with_color(Color::Yellow),
152 );
153 }
154
155 for note in &self.notes {
156 builder = builder.with_note(note);
157 }
158
159 builder.finish()
160 }
161}