use crate::Fix;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Error,
Warning,
Lint,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Lint => "lint",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"error" => Some(Severity::Error),
"warning" => Some(Severity::Warning),
"lint" => Some(Severity::Lint),
_ => None,
}
}
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Serialize, Debug, Clone)]
pub struct Diagnostic {
pub severity: Severity,
pub file: String,
pub line: usize,
pub col: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule: Option<String>,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub fix: Option<Fix>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
}
impl Diagnostic {
pub fn parse(severity: Severity, text: &str) -> Self {
let Some((file, line, col, msg)) = position(text) else {
let (message, rule) = split_rule(text);
return Self {
severity,
file: String::new(),
line: 0,
col: 0,
rule,
message,
fix: None,
required_by: None,
origin: None,
};
};
let (message, rule) = split_rule(msg.trim_start());
Self {
severity,
file: file.to_string(),
line,
col,
rule,
message,
fix: None,
required_by: None,
origin: None,
}
}
}
pub fn position(text: &str) -> Option<(&str, usize, usize, &str)> {
let (file, rest) = text.split_once(':')?;
let (line, rest) = rest.split_once(':')?;
let (col, message) = rest.split_once(':')?;
Some((
file,
line.trim().parse().ok()?,
col.trim().parse().ok()?,
message,
))
}
pub fn rule_of(text: &str) -> Option<&str> {
if !text.ends_with(']') {
return None;
}
let start = text.rfind(" [htl ")?;
let rule = &text[start + " [htl ".len()..text.len() - 1];
(!rule.is_empty() && !rule.contains(' ')).then_some(rule)
}
fn split_rule(msg: &str) -> (String, Option<String>) {
match rule_of(msg) {
Some(rule) => (
msg[..msg.len() - rule.len() - 7].to_string(),
Some(rule.to_string()),
),
None => (msg.to_string(), None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_position_and_message() {
let d = Diagnostic::parse(
Severity::Error,
"src/a.tl:12:3: expected string, got integer",
);
assert_eq!(d.file, "src/a.tl");
assert_eq!((d.line, d.col), (12, 3));
assert_eq!(d.message, "expected string, got integer");
assert_eq!(d.rule, None);
}
#[test]
fn splits_the_lint_rule_off_the_message() {
let d = Diagnostic::parse(
Severity::Lint,
"src/a.tl:4:1: t.x may be nil [htl nil-index]",
);
assert_eq!(d.rule.as_deref(), Some("nil-index"));
assert_eq!(d.message, "t.x may be nil");
}
#[test]
fn text_without_a_position_keeps_all_of_itself() {
let d = Diagnostic::parse(Severity::Error, "src/a.tl: generate failed: boom");
assert_eq!(d.file, "");
assert_eq!((d.line, d.col), (0, 0));
assert_eq!(d.message, "src/a.tl: generate failed: boom");
}
#[test]
fn a_bracket_that_is_not_a_rule_stays_in_the_message() {
let d = Diagnostic::parse(Severity::Lint, "src/a.tl:1:1: see [htl two words]");
assert_eq!(d.rule, None);
assert_eq!(d.message, "see [htl two words]");
}
#[test]
fn severity_round_trips_through_its_name() {
for s in [Severity::Error, Severity::Warning, Severity::Lint] {
assert_eq!(Severity::parse(s.as_str()), Some(s));
}
assert_eq!(Severity::parse("fatal"), None);
}
}