Skip to main content

tix/
diagnostics.rs

1//! The crate's one diagnostic renderer (#1576).
2//!
3//! Before this module, `annotate-snippets` was used in exactly one place
4//! (`commands::lint`) and every other user-facing message was hand-rolled plain
5//! text: `TixError` through `thiserror`, [`crate::error::render_diagnostics`] as
6//! `kind: message | …`, the relationship/hierarchy/workflow refusals, the editor
7//! round-trip labels, and the ad-hoc `note:`/`warning:` lines. A user who learned
8//! one surface's format had to learn the next one from scratch. This module is the
9//! single renderer they all route through.
10//!
11//! A [`Diagnostic`] is a **value**, not a string: it carries the severity, a stable
12//! id (`E-tix-lint`, `E-tix-relationship`, …), the headline, an optional source
13//! location, and `note:`/`help:` footers. Rendering is a pure function of that
14//! value, so the same diagnostic prints identically from the CLI, the TUI, and
15//! MCP, and the format is unit-testable without a terminal.
16//!
17//! ## Colour
18//!
19//! [`Diagnostic::render`] is **plain** — deterministic bytes, what tests, pipes,
20//! and log files want. [`Diagnostic::render_styled`] adds colour and is for a
21//! real terminal. Nothing in the crate decides which by sniffing `isatty`; the
22//! caller picks, so a test never depends on where its output happens to go.
23//!
24//! ## Ids
25//!
26//! The id is the rustc-style bracket name (`error[E-tix-lint]`). It is **stable**
27//! and machine-matchable, and it is deliberately *not* the contract's diagnostic
28//! kind (`self_link`, `unmapped_column_default`, …): the kind is the wire
29//! vocabulary that MCP and tests match on, the id is the rendering name, and
30//! keeping them separate means neither can be renamed by accident while porting
31//! the other.
32
33use std::ops::Range;
34use std::path::PathBuf;
35
36use annotate_snippets::{AnnotationKind, Group, Level, Renderer, Snippet};
37
38/// How much a diagnostic should worry the reader.
39///
40/// Mirrors [`crate::commands::lint::Severity`] deliberately: the crate already
41/// decided that only errors set an exit code and that a warning is printed and
42/// not fatal (`.tools`/#1361), and the distinction has to be the same everywhere
43/// or the exit code and the output stop agreeing.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Severity {
46    /// Printed and fatal — the caller decides the exit code, but this is a
47    /// problem with the data.
48    Error,
49    /// Printed, counted, and **not** fatal.
50    Warning,
51    /// Purely informational — a `note:`, not a problem.
52    Note,
53}
54
55impl Severity {
56    fn level(self) -> Level<'static> {
57        match self {
58            Severity::Error => Level::ERROR,
59            Severity::Warning => Level::WARNING,
60            Severity::Note => Level::NOTE,
61        }
62    }
63}
64
65/// Where a diagnostic points, when it can point at all.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Location {
68    /// The file the span is into.
69    pub path: PathBuf,
70    /// Byte range into that file's text.
71    pub span: Range<usize>,
72}
73
74/// One rendered-or-renderable finding.
75///
76/// Build with [`Diagnostic::error`] / [`Diagnostic::warning`], then chain
77/// [`Diagnostic::at`], [`Diagnostic::labelled`], [`Diagnostic::with_note`], and
78/// [`Diagnostic::with_help`]. Every builder returns `Self`, so a diagnostic is
79/// assembled in one expression.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Diagnostic {
82    severity: Severity,
83    id: &'static str,
84    message: String,
85    location: Option<Location>,
86    label: Option<String>,
87    notes: Vec<String>,
88    help: Option<String>,
89}
90
91impl Diagnostic {
92    /// A fatal finding.
93    #[must_use]
94    pub fn error(id: &'static str, message: impl Into<String>) -> Self {
95        Self::new(Severity::Error, id, message)
96    }
97
98    /// A non-fatal finding.
99    #[must_use]
100    pub fn warning(id: &'static str, message: impl Into<String>) -> Self {
101        Self::new(Severity::Warning, id, message)
102    }
103
104    /// An informational note — no problem, just something worth saying.
105    #[must_use]
106    pub fn note(id: &'static str, message: impl Into<String>) -> Self {
107        Self::new(Severity::Note, id, message)
108    }
109
110    #[must_use]
111    fn new(severity: Severity, id: &'static str, message: impl Into<String>) -> Self {
112        Self {
113            severity,
114            id,
115            message: message.into(),
116            location: None,
117            label: None,
118            notes: Vec::new(),
119            help: None,
120        }
121    }
122
123    /// Point the diagnostic at a byte span in `path`.
124    ///
125    /// The file is read at render time, not here: the diagnostic stays a plain
126    /// value that can be moved across threads or compared, and a file that has
127    /// gone missing degrades to a title-only block instead of panicking.
128    #[must_use]
129    pub fn at(mut self, path: impl Into<PathBuf>, span: Range<usize>) -> Self {
130        self.location = Some(Location {
131            path: path.into(),
132            span,
133        });
134        self
135    }
136
137    /// The label underlined on the span. Defaults to the headline when unset.
138    #[must_use]
139    pub fn labelled(mut self, label: impl Into<String>) -> Self {
140        self.label = Some(label.into());
141        self
142    }
143
144    /// Add a `= note:` footer.
145    #[must_use]
146    pub fn with_note(mut self, note: impl Into<String>) -> Self {
147        self.notes.push(note.into());
148        self
149    }
150
151    /// Add a `= help:` footer — the remedy.
152    #[must_use]
153    pub fn with_help(mut self, help: impl Into<String>) -> Self {
154        self.help = Some(help.into());
155        self
156    }
157
158    /// The severity.
159    #[must_use]
160    pub fn severity(&self) -> Severity {
161        self.severity
162    }
163
164    /// The stable id.
165    #[must_use]
166    pub fn id(&self) -> &'static str {
167        self.id
168    }
169
170    /// The headline message.
171    #[must_use]
172    pub fn message(&self) -> &str {
173        &self.message
174    }
175
176    /// Render without colour — the default, for tests, pipes, and logs.
177    #[must_use]
178    pub fn render(&self) -> String {
179        self.render_with(false)
180    }
181
182    /// Render with colour, for a real terminal.
183    #[must_use]
184    pub fn render_styled(&self) -> String {
185        self.render_with(true)
186    }
187
188    /// Render styled when stderr is a terminal, plain otherwise.
189    ///
190    /// This is the one place that consults `isatty`, and it is at the point of
191    /// printing: a captured stderr (a test, a pipe, a CI log) is not a terminal,
192    /// so it gets plain bytes and its assertions match without stripping escapes,
193    /// while an interactive run keeps its colour.
194    #[must_use]
195    pub fn render_auto(&self) -> String {
196        if std::io::IsTerminal::is_terminal(&std::io::stderr()) {
197            self.render_styled()
198        } else {
199            self.render()
200        }
201    }
202
203    fn render_with(&self, styled: bool) -> String {
204        let renderer = if styled {
205            Renderer::styled()
206        } else {
207            Renderer::plain()
208        };
209        let title = self
210            .severity
211            .level()
212            .primary_title(self.message.clone())
213            .id(self.id);
214        let mut group = match self.source() {
215            Some((location, text)) => {
216                let label = self.label.clone().unwrap_or_else(|| self.message.clone());
217                title.element(
218                    Snippet::source(text)
219                        .line_start(1)
220                        .path(location.path.display().to_string())
221                        .annotation(
222                            AnnotationKind::Primary
223                                .span(location.span.clone())
224                                .label(label),
225                        ),
226                )
227            }
228            None => Group::with_title(title),
229        };
230        for note in &self.notes {
231            group = group.element(Level::NOTE.message(note.clone()));
232        }
233        if let Some(help) = &self.help {
234            group = group.element(Level::HELP.message(help.clone()));
235        }
236        // `annotate-snippets` ends every block with a newline; the crate's callers
237        // join blocks and print them, so the trailing one is noise here.
238        renderer.render(&[group]).trim_end().to_string()
239    }
240
241    fn source(&self) -> Option<(&Location, String)> {
242        let location = self.location.as_ref()?;
243        let text = std::fs::read_to_string(&location.path).ok()?;
244        Some((location, text))
245    }
246}
247
248/// Render several diagnostics, one block per line.
249///
250/// A refusal can carry more than one finding, and showing one at a time makes a
251/// user fix and retry to discover the rest — the reason the old
252/// `render_diagnostics` reported every pair. Order is preserved.
253#[must_use]
254pub fn render_all(diagnostics: &[Diagnostic]) -> String {
255    diagnostics
256        .iter()
257        .map(Diagnostic::render)
258        .collect::<Vec<_>>()
259        .join("\n")
260}
261
262#[cfg(test)]
263mod tests {
264    #[allow(clippy::wildcard_imports)]
265    use super::*;
266
267    #[test]
268    fn a_title_only_diagnostic_names_level_and_id() {
269        let rendered = Diagnostic::error("E-tix-cfg", "tix.toml is invalid").render();
270        assert_eq!(rendered, "error[E-tix-cfg]: tix.toml is invalid");
271    }
272
273    #[test]
274    fn a_warning_renders_at_the_warning_level() {
275        let rendered = Diagnostic::warning("W-tix-cfg", "no category map").render();
276        assert!(rendered.starts_with("warning[W-tix-cfg]:"), "{rendered}");
277    }
278
279    #[test]
280    fn notes_and_help_are_footers() {
281        let rendered = Diagnostic::error("E-tix-x", "boom")
282            .with_note("why it happened")
283            .with_help("do this instead")
284            .render();
285        assert!(rendered.contains("note"), "{rendered}");
286        assert!(rendered.contains("why it happened"), "{rendered}");
287        assert!(rendered.contains("do this instead"), "{rendered}");
288    }
289
290    #[test]
291    fn a_location_renders_a_snippet_with_the_path_and_span() {
292        let dir = std::env::temp_dir().join("tix-diagnostics-test");
293        std::fs::create_dir_all(&dir).unwrap();
294        let path = dir.join("x.md");
295        std::fs::write(&path, "line one\nline two\n").unwrap();
296        let rendered = Diagnostic::error("E-tix-x", "bad value")
297            .at(&path, 9..13)
298            .labelled("this is the value")
299            .render();
300        assert!(rendered.contains("bad value"), "{rendered}");
301        assert!(rendered.contains("x.md"), "{rendered}");
302        assert!(rendered.contains("line two"), "{rendered}");
303        assert!(rendered.contains("this is the value"), "{rendered}");
304        let _ = std::fs::remove_file(&path);
305    }
306
307    #[test]
308    fn a_missing_file_degrades_to_a_title() {
309        let rendered = Diagnostic::error("E-tix-x", "bad value")
310            .at("/nonexistent/tix/diagnostics/x.md", 0..3)
311            .render();
312        assert_eq!(rendered, "error[E-tix-x]: bad value");
313    }
314
315    #[test]
316    fn render_all_joins_one_block_per_line() {
317        let rendered = render_all(&[
318            Diagnostic::error("E-tix-a", "first"),
319            Diagnostic::error("E-tix-a", "second"),
320        ]);
321        assert_eq!(rendered, "error[E-tix-a]: first\nerror[E-tix-a]: second");
322    }
323
324    #[test]
325    fn styled_and_plain_carry_the_same_text() {
326        let diagnostic = Diagnostic::error("E-tix-x", "boom").with_help("remedy");
327        assert_eq!(strip_ansi(&diagnostic.render_styled()), diagnostic.render());
328    }
329
330    /// Remove ANSI escape sequences so a styled render can be compared to plain.
331    fn strip_ansi(text: &str) -> String {
332        let mut out = String::new();
333        let mut chars = text.chars().peekable();
334        while let Some(c) = chars.next() {
335            if c == '\u{1b}' {
336                for e in chars.by_ref() {
337                    if e.is_ascii_alphabetic() {
338                        break;
339                    }
340                }
341            } else {
342                out.push(c);
343            }
344        }
345        out
346    }
347}