Skip to main content

espforge_diagnostics/
lib.rs

1//! One diagnostic model for the whole pipeline.
2//!
3//! Before this crate there were five error conventions: `espf::diag::EspfError`
4//! (a title, one label, one span), `String` returns in the element wrappers,
5//! `Invalid::{Spans, Message}` in the CLI, `anyhow` at the boundaries and bare
6//! exit codes. A problem could be a sentence with no location, a snippet with
7//! no code, or a `❌` line from a library module.
8//!
9//! This crate is the destination of extreme.md §3.4: one [`Diagnostic`] with a
10//! stable code, a severity, a primary span, any number of labels, optional
11//! help and machine-applicable fixes. Renderers live here too, so the CLI
12//! prints and the library never does: [`to_human`] reproduces today's snippet
13//! output exactly, [`to_json`] is what CI and editors consume.
14//!
15//! # Codes
16//!
17//! Codes are five characters: `E` or `W` followed by four digits. The ranges
18//! are reserved by stage so two producers cannot pick the same number:
19//!
20//! | Range | Stage |
21//! |-------|-------|
22//! | `E01xx` | syntax (parser junk, section headers) |
23//! | `E02xx` | `# espforge` core and meta |
24//! | `E03xx` | options and their answers |
25//! | `E04xx` | `$` graph resolution |
26//! | `E05xx` | peripherals (`<gpio>`, `<i2c>`, `<spi>`, `<uart>`) |
27//! | `E06xx` | components (`<led>`, `<button>`, `<*_component>`) |
28//! | `E07xx` | ownership and conflicts |
29//! | `E08xx` | services and driver routing |
30//! | `W0xxx` | warnings |
31//!
32//! `E0999` is the sentinel for a message missing from the table; it is a bug
33//! if it reaches output, and the JSON contract test rejects it.
34//!
35//! Every diagnostic carries a code. The table lives in [`codes`], one entry
36//! per message, so a stage cannot invent a number another stage already uses.
37//! The human renderer ignores the code; [`to_json`] is where it is a contract.
38
39use std::ops::Range;
40
41pub mod codes;
42pub mod json;
43pub mod render;
44
45pub use codes::for_message;
46
47pub use json::to_json;
48pub use render::to_human;
49
50/// A byte range into the original source.
51pub type Span = Range<usize>;
52
53/// The whole set of problems from one stage, in render order.
54pub type Diagnostics = Vec<Diagnostic>;
55
56/// How loudly a diagnostic is reported. Only errors make a command fail.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
58pub enum Severity {
59    Error,
60    Warning,
61    Note,
62}
63
64impl Severity {
65    /// The lowercase spelling used by the machine format.
66    pub fn as_str(self) -> &'static str {
67        match self {
68            Severity::Error => "error",
69            Severity::Warning => "warning",
70            Severity::Note => "note",
71        }
72    }
73}
74
75/// A stable diagnostic code (`E0701`).
76///
77/// The format is `E`/`W` plus four digits; the ranges are documented on the
78/// crate. Codes are a public contract once they reach [`to_json`], so never
79/// reuse one for a different message.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
81pub struct DiagCode(&'static str);
82
83impl DiagCode {
84    /// A code with the `E`/`W` + four-digit spelling.
85    pub const fn new(code: &'static str) -> Self {
86        Self(code)
87    }
88
89    /// The code as it appears in machine output.
90    pub const fn as_str(self) -> &'static str {
91        self.0
92    }
93}
94
95/// One underline and its explanation.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct Label {
98    pub span: Span,
99    pub text: String,
100}
101
102/// One text replacement. Spans are byte offsets into the original source.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Edit {
105    pub span: Span,
106    pub text: String,
107}
108
109/// A suggested edit, shown as one action (`espforge fix`, an LSP quick-fix).
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct Fix {
112    pub title: String,
113    pub edits: Vec<Edit>,
114}
115
116/// One located problem.
117///
118/// `labels` carries the underline text: the first label whose span equals
119/// [`Diagnostic::primary`] is rendered as the primary annotation, every other
120/// label as context. A diagnostic with no matching label still underlines the
121/// primary span, with no text.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct Diagnostic {
124    /// Stable code for this message (see [`codes`]).
125    pub code: DiagCode,
126    pub severity: Severity,
127    /// Short summary on the `error:` line (today's `EspfError::title`).
128    pub message: String,
129    /// Where the diagnostic points.
130    pub primary: Span,
131    /// Underline text, primary label included.
132    pub labels: Vec<Label>,
133    pub help: Option<String>,
134    pub fixes: Vec<Fix>,
135}
136
137impl Diagnostic {
138    /// An error with a primary span and one label.
139    pub fn error(
140        code: DiagCode,
141        primary: Span,
142        message: impl Into<String>,
143        label: impl Into<String>,
144    ) -> Self {
145        Self {
146            code,
147            severity: Severity::Error,
148            message: message.into(),
149            labels: vec![Label {
150                span: primary.clone(),
151                text: label.into(),
152            }],
153            primary,
154            help: None,
155            fixes: Vec::new(),
156        }
157    }
158
159    /// A warning with a primary span and one label.
160    pub fn warning(
161        code: DiagCode,
162        primary: Span,
163        message: impl Into<String>,
164        label: impl Into<String>,
165    ) -> Self {
166        Self {
167            severity: Severity::Warning,
168            ..Self::error(code, primary, message, label)
169        }
170    }
171
172    /// Add a context underline.
173    pub fn with_label(mut self, span: Span, text: impl Into<String>) -> Self {
174        self.labels.push(Label {
175            span,
176            text: text.into(),
177        });
178        self
179    }
180
181    /// Add a `help:` line.
182    pub fn with_help(mut self, help: impl Into<String>) -> Self {
183        self.help = Some(help.into());
184        self
185    }
186
187    /// Add a machine-applicable fix.
188    pub fn with_fix(mut self, fix: Fix) -> Self {
189        self.fixes.push(fix);
190        self
191    }
192
193    /// The label rendered as the primary annotation, if one matches the
194    /// primary span exactly.
195    pub fn primary_label(&self) -> Option<&Label> {
196        self.labels.iter().find(|label| label.span == self.primary)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn a_diagnostic_has_one_primary_label() {
206        let d = Diagnostic::error(codes::GPIO_INVALID_PIN, 4..8, "bad pin", "no GPIO99")
207            .with_label(20..24, "declared here");
208        assert_eq!(d.severity, Severity::Error);
209        assert_eq!(d.primary_label().unwrap().text, "no GPIO99");
210        assert_eq!(d.labels.len(), 2);
211    }
212
213    #[test]
214    fn codes_are_data_not_prefixes() {
215        let d = Diagnostic::error(DiagCode::new("E0701"), 0..1, "boom", "here");
216        assert_eq!(d.code.as_str(), "E0701");
217    }
218
219    #[test]
220    fn the_machine_severity_spelling_is_stable() {
221        assert_eq!(Severity::Error.as_str(), "error");
222        assert_eq!(Severity::Warning.as_str(), "warning");
223        assert_eq!(Severity::Note.as_str(), "note");
224    }
225}