Skip to main content

htl_core/
diagnostic.rs

1//! What a check reported, as a value rather than as a line of text.
2//!
3//! The checker formats its findings as `"<file>:<line>:<col>: <message>"`, and a lint
4//! adds ` [htl <rule>]`. Every reader that wants the position back — `--format json`,
5//! `htl fix`, and whatever comes next (an LSP, `--watch`, a `build.rs` that fails on a
6//! lint) — used to take that string apart for itself. [`Diagnostic::parse`] is the one
7//! place that does it now, and it lives here, beside the fix and the severity it hands
8//! over, rather than in each caller.
9//!
10//! The text is still what the checker produces and what the run cache stores, so it is
11//! parsed here rather than reconstructed: a report and a replay of it print the string
12//! the checker wrote, and only readers that ask for the parts pay for the split.
13
14use crate::Fix;
15use serde::Serialize;
16
17/// How loud a diagnostic is. `error` fails a check; `warning` and `lint` do not unless
18/// the caller promotes them (`htl check --strict`, `include_tl!`).
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "lowercase")]
21pub enum Severity {
22    /// The file did not type-check. From the Teal checker, and the only severity that
23    /// fails a run on its own.
24    Error,
25    /// A kind the Teal compiler reports for itself — an unused local, a redeclaration —
26    /// which htl surfaces under the rule name of its kind (`tl:unused`, ...) rather than
27    /// inventing one.
28    Warning,
29    /// A finding of one of htl's own rules. The only severity whose diagnostics carry a
30    /// [`rule`](Diagnostic::rule), because a rule is what a project sets a level for.
31    Lint,
32}
33
34impl Severity {
35    /// The lowercase word this severity is written as: in the `<severity>:` a report
36    /// prints, in `--format json`, and in the run cache. [`parse`](Self::parse) reads it
37    /// back, so the two are the one spelling that crosses between a run and a replay of it.
38    pub fn as_str(self) -> &'static str {
39        match self {
40            Severity::Error => "error",
41            Severity::Warning => "warning",
42            Severity::Lint => "lint",
43        }
44    }
45
46    /// The inverse of [`as_str`](Self::as_str). `None` for anything else, which is the
47    /// answer a reader of stored diagnostics wants: an entry written by a build that
48    /// knew a fourth severity is refused rather than guessed at.
49    pub fn parse(s: &str) -> Option<Self> {
50        match s {
51            "error" => Some(Severity::Error),
52            "warning" => Some(Severity::Warning),
53            "lint" => Some(Severity::Lint),
54            _ => None,
55        }
56    }
57}
58
59impl std::fmt::Display for Severity {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.write_str(self.as_str())
62    }
63}
64
65/// One `error:` / `warning:` / `lint:` finding, split into its parts.
66///
67/// The serialized form is what `htl check --format json` prints. Field names are stable;
68/// new fields may be added, existing ones are not renamed.
69#[derive(Serialize, Debug, Clone)]
70pub struct Diagnostic {
71    /// Which of the three this is, and so whether a run that reported it fails — the
72    /// caller's to decide for a `warning` or a `lint`, settled for an `error`.
73    pub severity: Severity,
74    /// The file the diagnostic is in, as the report spells it. Empty when the text
75    /// carried no position (a failure that is about a file rather than a place in one).
76    pub file: String,
77    /// The line, counted from 1 as the checker counts it. `0` alongside an empty
78    /// [`file`](Self::file): the text carried no position, rather than pointing at a
79    /// first line.
80    pub line: usize,
81    /// The column, counted from 1, and `0` under the same condition as
82    /// [`line`](Self::line).
83    pub col: usize,
84    /// The lint rule (`nil-index`, `contract`, ...) for `lint` diagnostics.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub rule: Option<String>,
87    /// What the finding says, with the position prefix and the ` [htl <rule>]` suffix
88    /// taken off — the sentence alone, so a reader that formats its own line does not
89    /// have to unpick one.
90    pub message: String,
91    /// A mechanical rewrite `htl fix` may apply, when the diagnostic has one.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub fix: Option<Fix>,
94    /// For an error in a module the check reached through `require`: the file whose
95    /// require pulled it in. Absent on the project's own diagnostics.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub required_by: Option<String>,
98    /// Where such a file lives: `dependency` (installed under `.htl/modules`, or a
99    /// vendored copy) or `external` (a `[check] paths` or contract directory). Absent for
100    /// a file of the project's own, and on the project's own diagnostics.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub origin: Option<String>,
103}
104
105impl Diagnostic {
106    /// `"<file>:<line>:<col>: <message>"` — what the checker formats — into its parts.
107    /// Text that is not in that shape keeps the whole of itself as the message, with no
108    /// file and no position.
109    ///
110    /// `fix`, `required_by` and `origin` are the caller's to fill in: they are not in the
111    /// text, they travel beside it.
112    pub fn parse(severity: Severity, text: &str) -> Self {
113        let Some((file, line, col, msg)) = position(text) else {
114            let (message, rule) = split_rule(text);
115            return Self {
116                severity,
117                file: String::new(),
118                line: 0,
119                col: 0,
120                rule,
121                message,
122                fix: None,
123                required_by: None,
124                origin: None,
125            };
126        };
127        let (message, rule) = split_rule(msg.trim_start());
128        Self {
129            severity,
130            file: file.to_string(),
131            line,
132            col,
133            rule,
134            message,
135            fix: None,
136            required_by: None,
137            origin: None,
138        }
139    }
140}
141
142/// The `<file>`, `<line>`, `<col>` and the rest of `"<file>:<line>:<col>: <message>"`.
143/// `None` when the text is not in that shape.
144///
145/// The only place a diagnostic's text is taken apart. `file` is a prefix of `text`, so a
146/// caller that wants to rewrite the file and keep the rest can slice by its length.
147pub fn position(text: &str) -> Option<(&str, usize, usize, &str)> {
148    let (file, rest) = text.split_once(':')?;
149    let (line, rest) = rest.split_once(':')?;
150    let (col, message) = rest.split_once(':')?;
151    Some((
152        file,
153        line.trim().parse().ok()?,
154        col.trim().parse().ok()?,
155        message,
156    ))
157}
158
159/// The rule name a finding's text ends with (` [htl <rule>]`), borrowed from it.
160///
161/// For a caller that has the text and wants only the name: which rule a run reported
162/// under, so its level can be asked for. Reading the whole diagnostic is
163/// [`Diagnostic::parse`], and both take the suffix apart here.
164pub fn rule_of(text: &str) -> Option<&str> {
165    if !text.ends_with(']') {
166        return None;
167    }
168    let start = text.rfind(" [htl ")?;
169    let rule = &text[start + " [htl ".len()..text.len() - 1];
170    (!rule.is_empty() && !rule.contains(' ')).then_some(rule)
171}
172
173/// Lint messages end with ` [htl <rule>]`. Splitting it off leaves the message reading
174/// as a sentence and the rule available as a name to filter on.
175fn split_rule(msg: &str) -> (String, Option<String>) {
176    match rule_of(msg) {
177        // The suffix is ` [htl ` + the name + `]`: seven characters around it.
178        Some(rule) => (
179            msg[..msg.len() - rule.len() - 7].to_string(),
180            Some(rule.to_string()),
181        ),
182        None => (msg.to_string(), None),
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn parses_position_and_message() {
192        let d = Diagnostic::parse(
193            Severity::Error,
194            "src/a.tl:12:3: expected string, got integer",
195        );
196        assert_eq!(d.file, "src/a.tl");
197        assert_eq!((d.line, d.col), (12, 3));
198        assert_eq!(d.message, "expected string, got integer");
199        assert_eq!(d.rule, None);
200    }
201
202    #[test]
203    fn splits_the_lint_rule_off_the_message() {
204        let d = Diagnostic::parse(
205            Severity::Lint,
206            "src/a.tl:4:1: t.x may be nil [htl nil-index]",
207        );
208        assert_eq!(d.rule.as_deref(), Some("nil-index"));
209        assert_eq!(d.message, "t.x may be nil");
210    }
211
212    #[test]
213    fn text_without_a_position_keeps_all_of_itself() {
214        let d = Diagnostic::parse(Severity::Error, "src/a.tl: generate failed: boom");
215        assert_eq!(d.file, "");
216        assert_eq!((d.line, d.col), (0, 0));
217        assert_eq!(d.message, "src/a.tl: generate failed: boom");
218    }
219
220    #[test]
221    fn a_bracket_that_is_not_a_rule_stays_in_the_message() {
222        let d = Diagnostic::parse(Severity::Lint, "src/a.tl:1:1: see [htl two words]");
223        assert_eq!(d.rule, None);
224        assert_eq!(d.message, "see [htl two words]");
225    }
226
227    #[test]
228    fn severity_round_trips_through_its_name() {
229        for s in [Severity::Error, Severity::Warning, Severity::Lint] {
230            assert_eq!(Severity::parse(s.as_str()), Some(s));
231        }
232        assert_eq!(Severity::parse("fatal"), None);
233    }
234}