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}