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/// 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" => Severity::Warning,
75            _ => Severity::Error,
76        }
77    }
78}
79
80/// Split diagnostics into `(errors, warnings)` by severity (ADR 0117). The build
81/// fails iff the `errors` half is non-empty; the `warnings` half surfaces but
82/// does not gate compilation. Relative order within each half is preserved.
83pub fn partition_by_severity(
84    diagnostics: Vec<CompileError>,
85) -> (Vec<CompileError>, Vec<CompileError>) {
86    diagnostics
87        .into_iter()
88        .partition(|d| Severity::for_error(d) == Severity::Error)
89}
90
91impl CompileError {
92    pub fn new(category: &'static str, span: Span, message: impl Into<String>) -> Self {
93        Self {
94            category,
95            span,
96            message: message.into(),
97            labels: Vec::new(),
98            notes: Vec::new(),
99            suggestions: Vec::new(),
100        }
101    }
102
103    pub fn with_label(mut self, span: Span, label: impl Into<String>) -> Self {
104        self.labels.push((span, label.into()));
105        self
106    }
107
108    pub fn with_note(mut self, note: impl Into<String>) -> Self {
109        self.notes.push(note.into());
110        self
111    }
112
113    /// Attach a machine-applicable fix (v0.26). Mirrors [`Self::with_note`];
114    /// the suggestion is authored where the diagnostic is raised.
115    pub fn with_suggestion(
116        mut self,
117        message: impl Into<String>,
118        edits: Vec<(Span, String)>,
119        applicability: Applicability,
120    ) -> Self {
121        self.suggestions.push(Suggestion {
122            message: message.into(),
123            edits,
124            applicability,
125        });
126        self
127    }
128
129    /// Build an [`ariadne::Report`] for this error, anchored to the given
130    /// filename. Colour is on (for the CLI and human-facing test output).
131    pub fn report<'a>(
132        &'a self,
133        filename: &'a str,
134    ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
135        self.report_for(filename, usize::MAX)
136    }
137
138    /// [`Self::report`], bounded by the rendered source's byte length: a label
139    /// whose span lies past the end of the source belongs to *another* file
140    /// (e.g. a `uses`-imported callee's "declared here"), and rendering it
141    /// against this file would underline unrelated text. Such labels are
142    /// demoted to notes so the information survives without the misplacement.
143    pub fn report_for<'a>(
144        &'a self,
145        filename: &'a str,
146        source_len: usize,
147    ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
148        self.report_with_config(filename, Config::default(), source_len)
149    }
150
151    /// Build a colourless [`ariadne::Report`], for transcripts committed to the
152    /// repo — no ANSI escape codes, so the output is byte-stable across machines.
153    pub fn report_plain<'a>(
154        &'a self,
155        filename: &'a str,
156    ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
157        self.report_plain_for(filename, usize::MAX)
158    }
159
160    /// [`Self::report_plain`], bounded by the source length — see
161    /// [`Self::report_for`].
162    pub fn report_plain_for<'a>(
163        &'a self,
164        filename: &'a str,
165        source_len: usize,
166    ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
167        self.report_with_config(filename, Config::default().with_color(false), source_len)
168    }
169
170    fn report_with_config<'a>(
171        &'a self,
172        filename: &'a str,
173        config: Config,
174        source_len: usize,
175    ) -> Report<'a, (&'a str, std::ops::Range<usize>)> {
176        let primary_span = (filename, self.span.range());
177        // Spans are byte offsets into the UTF-8 source; ariadne 0.6 defaults
178        // to character indexing, which misplaces the underline on any line
179        // with non-ASCII text before the span.
180        let mut builder = Report::build(ReportKind::Error, primary_span.clone())
181            .with_config(config.with_index_type(IndexType::Byte))
182            .with_code(self.category)
183            .with_message(&self.message)
184            .with_label(
185                Label::new(primary_span)
186                    .with_message(&self.message)
187                    .with_color(Color::Red),
188            );
189
190        for (span, label) in &self.labels {
191            if span.end > source_len {
192                // The label's span lies in another file — demote to a note
193                // rather than underlining unrelated text in this one.
194                builder = builder.with_note(label);
195                continue;
196            }
197            builder = builder.with_label(
198                Label::new((filename, span.range()))
199                    .with_message(label)
200                    .with_color(Color::Yellow),
201            );
202        }
203
204        for note in &self.notes {
205            builder = builder.with_note(note);
206        }
207
208        builder.finish()
209    }
210}
211
212#[cfg(test)]
213mod warning_channel_tests {
214    use super::*;
215    use crate::span::Span;
216
217    #[test]
218    fn partition_splits_by_severity() {
219        let warn = CompileError::new("bynk.given.unused_capability", Span::default(), "unused");
220        let err = CompileError::new("bynk.types.argument_mismatch", Span::default(), "bad");
221        let (errors, warnings) = partition_by_severity(vec![warn, err]);
222        assert_eq!(errors.len(), 1);
223        assert_eq!(errors[0].category, "bynk.types.argument_mismatch");
224        assert_eq!(warnings.len(), 1);
225        assert_eq!(warnings[0].category, "bynk.given.unused_capability");
226    }
227}