duck_diag/diagnostic.rs
1use std::{fmt, sync::Arc};
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6/// Severity of a diagnostic.
7///
8/// `Bug` is reserved for internal compiler errors (ICEs) — anything that
9/// indicates a defect in the tool itself, not the user's input.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
12#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
13pub enum Severity {
14 /// Internal compiler error / ICE. Indicates a defect in the tool itself.
15 Bug,
16 /// Hard error. Stops the build / run.
17 Error,
18 /// Soft warning. Doesn't stop the build.
19 Warning,
20 /// Informational note attached to a diagnostic.
21 Note,
22 /// Suggestion or hint.
23 Help,
24}
25
26impl Severity {
27 /// Human-readable severity word used in rendered output (`error`,
28 /// `warning`, `note`, `help`, `internal error`).
29 pub fn label(self) -> &'static str {
30 match self {
31 Self::Bug => "internal error",
32 Self::Error => "error",
33 Self::Warning => "warning",
34 Self::Note => "note",
35 Self::Help => "help",
36 }
37 }
38}
39
40/// Implement this on your error enum to plug into the diagnostic system.
41///
42/// ```rust
43/// use duck_diag::{DiagnosticCode, Severity};
44///
45/// #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46/// enum MyError {
47/// SyntaxError,
48/// UnusedImport,
49/// }
50///
51/// impl DiagnosticCode for MyError {
52/// fn code(&self) -> &str {
53/// match self {
54/// Self::SyntaxError => "E0001",
55/// Self::UnusedImport => "W0001",
56/// }
57/// }
58/// fn severity(&self) -> Severity {
59/// match self {
60/// Self::SyntaxError => Severity::Error,
61/// Self::UnusedImport => Severity::Warning,
62/// }
63/// }
64/// fn url(&self) -> Option<&'static str> {
65/// match self {
66/// Self::SyntaxError => Some("https://example.com/E0001"),
67/// _ => None,
68/// }
69/// }
70/// }
71/// ```
72pub trait DiagnosticCode: fmt::Debug + Clone {
73 /// Stable string identifier rendered in the header (e.g. `"E0001"`).
74 fn code(&self) -> &str;
75 /// Severity inferred from this code.
76 fn severity(&self) -> Severity;
77
78 /// Optional documentation URL rendered after the code in pretty mode.
79 fn url(&self) -> Option<&'static str> {
80 None
81 }
82}
83
84/// Source span.
85///
86/// **Convention:** `line` and `column` are **1-based** (matches rustc / clippy / clang).
87/// If your front-end emits 0-based positions, use [`Span::from_zero_based`] to convert.
88///
89/// `length` is in **bytes** of the underlying source slice (not characters or columns).
90/// Rendering uses [`unicode-width`](https://docs.rs/unicode-width) to compute display width.
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[cfg_attr(feature = "serde", derive(Serialize))]
93#[cfg_attr(feature = "serde", derive(Deserialize))]
94pub struct Span {
95 /// Source file path.
96 pub file: Arc<str>,
97 /// 1-based line number.
98 pub line: usize,
99 /// 1-based column number.
100 pub column: usize,
101 /// Byte length of the spanned source slice.
102 pub length: usize,
103}
104
105impl Span {
106 /// Construct a 1-based span. Use this when your front-end already counts
107 /// from 1 (most do).
108 pub fn new(file: impl Into<Arc<str>>, line: usize, column: usize, length: usize) -> Self {
109 Self { file: file.into(), line, column, length }
110 }
111
112 /// Construct a span from 0-based line + column. The crate stores 1-based
113 /// internally, so this just adds 1 to each.
114 ///
115 /// ```
116 /// use duck_diag::Span;
117 /// let s = Span::from_zero_based("foo.rs", 0, 0, 1);
118 /// assert_eq!(s.line, 1);
119 /// assert_eq!(s.column, 1);
120 /// ```
121 pub fn from_zero_based(
122 file: impl Into<Arc<str>>,
123 line: usize,
124 column: usize,
125 length: usize,
126 ) -> Self {
127 Self { file: file.into(), line: line + 1, column: column + 1, length }
128 }
129
130 /// Convenience: synthetic span used for diagnostics that don't point at any
131 /// real source location (e.g. CLI flag errors).
132 pub fn synthetic(file: impl Into<Arc<str>>) -> Self {
133 Self { file: file.into(), line: 0, column: 0, length: 0 }
134 }
135}
136
137/// Caret style for a label. `Primary` underlines with `^`, `Secondary` with `-`.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
140#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
141pub enum LabelStyle {
142 /// Main error site. Rendered with `^` carets in the diagnostic color.
143 Primary,
144 /// Related context. Rendered with `-` carets in cyan.
145 Secondary,
146}
147
148/// A span + optional message + optional per-caret note. Multiple labels per
149/// diagnostic stack rustc-style.
150#[derive(Debug, Clone)]
151#[cfg_attr(feature = "serde", derive(Serialize))]
152pub struct Label {
153 /// Source location this label points at.
154 pub span: Span,
155 /// Inline message printed next to the caret.
156 pub message: Option<String>,
157 /// Caret style (`Primary` / `Secondary`).
158 pub style: LabelStyle,
159 /// Optional short note rendered immediately after the caret.
160 pub note: Option<String>,
161}
162
163impl Label {
164 /// Build a `Primary` label (main error site).
165 pub fn primary(span: Span, message: impl Into<Option<String>>) -> Self {
166 Self { span, message: message.into(), style: LabelStyle::Primary, note: None }
167 }
168
169 /// Build a `Secondary` label (related context).
170 pub fn secondary(span: Span, message: impl Into<Option<String>>) -> Self {
171 Self { span, message: message.into(), style: LabelStyle::Secondary, note: None }
172 }
173
174 /// Attach a short note rendered under the caret with a `↳` arrow.
175 pub fn with_note(mut self, note: impl Into<String>) -> Self {
176 self.note = Some(note.into());
177 self
178 }
179}
180
181/// How confident the suggestion is — controls whether IDEs may auto-apply it.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
184#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
185pub enum Applicability {
186 /// Safe to apply automatically.
187 MachineApplicable,
188 /// Likely correct but worth a human glance.
189 MaybeIncorrect,
190 /// Manual review required.
191 HasPlaceholders,
192 /// Don't auto-apply.
193 Unspecified,
194}
195
196/// Code rewrite suggestion attached to a diagnostic.
197#[derive(Debug, Clone)]
198#[cfg_attr(feature = "serde", derive(Serialize))]
199pub struct Suggestion {
200 /// Source range to replace.
201 pub span: Span,
202 /// Text spliced in place of `span`. May contain newlines.
203 pub replacement: String,
204 /// Header rendered above the diff (defaults to `"try this:"`).
205 pub message: Option<String>,
206 /// Confidence level for IDE auto-apply tooling.
207 pub applicability: Applicability,
208}
209
210impl Suggestion {
211 /// New suggestion with `Applicability::Unspecified`.
212 pub fn new(span: Span, replacement: impl Into<String>) -> Self {
213 Self {
214 span,
215 replacement: replacement.into(),
216 message: None,
217 applicability: Applicability::Unspecified,
218 }
219 }
220
221 /// Override the diff header text.
222 pub fn with_message(mut self, message: impl Into<String>) -> Self {
223 self.message = Some(message.into());
224 self
225 }
226
227 /// Set the applicability level.
228 pub fn with_applicability(mut self, app: Applicability) -> Self {
229 self.applicability = app;
230 self
231 }
232}
233
234/// One error / warning / note carrying a code, message, labels, notes, help,
235/// and suggestions.
236#[derive(Debug, Clone)]
237#[cfg_attr(feature = "serde", derive(Serialize))]
238pub struct Diagnostic<C: DiagnosticCode> {
239 /// User-supplied error code.
240 pub code: C,
241 /// Severity (taken from `code` at construction; can be overridden).
242 pub severity: Severity,
243 /// Top-line message rendered next to the code.
244 pub message: String,
245 /// Source labels (carets).
246 pub labels: Vec<Label>,
247 /// Free-form notes rendered as `= note: …`.
248 pub notes: Vec<String>,
249 /// Optional help line rendered as `= help: …`.
250 pub help: Option<String>,
251 /// Code rewrite suggestions rendered as `-`/`+` diff blocks.
252 pub suggestions: Vec<Suggestion>,
253}
254
255impl<C: DiagnosticCode> Diagnostic<C> {
256 /// Build a diagnostic. Severity is read from `code.severity()`.
257 pub fn new(code: C, message: impl Into<String>) -> Self {
258 let severity = code.severity();
259 Self {
260 code,
261 severity,
262 message: message.into(),
263 labels: Vec::new(),
264 notes: Vec::new(),
265 help: None,
266 suggestions: Vec::new(),
267 }
268 }
269
270 /// Attach a label.
271 pub fn with_label(mut self, label: Label) -> Self {
272 self.labels.push(label);
273 self
274 }
275
276 /// Append a free-form note line.
277 pub fn with_note(mut self, note: impl Into<String>) -> Self {
278 self.notes.push(note.into());
279 self
280 }
281
282 /// Set the trailing help line. Last call wins.
283 pub fn with_help(mut self, help: impl Into<String>) -> Self {
284 self.help = Some(help.into());
285 self
286 }
287
288 /// Attach a code-rewrite suggestion.
289 pub fn with_suggestion(mut self, suggestion: Suggestion) -> Self {
290 self.suggestions.push(suggestion);
291 self
292 }
293
294 /// Override the severity inferred from the code.
295 pub fn with_severity(mut self, severity: Severity) -> Self {
296 self.severity = severity;
297 self
298 }
299
300 /// Primary label, if any (first label, or first `Primary`-styled label).
301 pub fn primary_label(&self) -> Option<&Label> {
302 self.labels.iter().find(|l| l.style == LabelStyle::Primary).or_else(|| self.labels.first())
303 }
304
305 /// Render in compact (source-less) form. See [`crate::format_compact`].
306 pub fn format_compact(&self, color: bool) -> String {
307 crate::compact::format_compact(self, color)
308 }
309}