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}