Skip to main content

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}