use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Error,
Warning,
Hint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Span {
pub start: usize,
pub end: usize,
}
impl From<std::ops::Range<usize>> for Span {
fn from(range: std::ops::Range<usize>) -> Self {
Self {
start: range.start,
end: range.end,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Location {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<Utf8PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub span: Option<Span>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Diagnostic {
pub severity: Severity,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub location: Option<Location>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hints: Vec<String>,
}
impl Diagnostic {
pub fn warning(message: impl Into<String>) -> Self {
Self {
severity: Severity::Warning,
message: message.into(),
location: None,
hints: Vec::new(),
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
severity: Severity::Error,
message: message.into(),
location: None,
hints: Vec::new(),
}
}
pub fn hint(message: impl Into<String>) -> Self {
Self {
severity: Severity::Hint,
message: message.into(),
location: None,
hints: Vec::new(),
}
}
pub fn at_file(mut self, file: impl Into<Utf8PathBuf>) -> Self {
let location = self.location.get_or_insert(Location {
file: None,
span: None,
});
location.file = Some(file.into());
self
}
}
pub(crate) fn parse_failure(diagnostics: &[Diagnostic], kind: &str) -> String {
let causes: Vec<String> = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.map(|d| one_line(&d.message))
.collect();
if causes.is_empty() {
format!("the {kind} configuration could not be parsed")
} else {
causes.join("; ")
}
}
fn one_line(message: &str) -> String {
message
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_line_keeps_every_part_of_a_multi_line_message() {
assert_eq!(one_line("first\n\n second \nthird"), "first second third");
assert_eq!(one_line("already one line"), "already one line");
}
#[test]
fn parse_failure_keeps_every_error_and_drops_the_rest() {
let diagnostics = [
Diagnostic::error("TOML parse error at line 1, column 10\n |\n1 | not toml"),
Diagnostic::warning("unknown attribute 'colour'"),
Diagnostic::error("second cause"),
];
assert_eq!(
parse_failure(&diagnostics, "pantry"),
"TOML parse error at line 1, column 10 | 1 | not toml; second cause"
);
}
#[test]
fn parse_failure_falls_back_to_naming_the_configuration() {
assert_eq!(
parse_failure(&[], "pantry"),
"the pantry configuration could not be parsed"
);
assert_eq!(
parse_failure(&[Diagnostic::warning("only a warning")], "aisle"),
"the aisle configuration could not be parsed"
);
}
#[test]
fn warning_constructor_sets_severity() {
let d = Diagnostic::warning("bad aisle line");
assert_eq!(d.severity, Severity::Warning);
assert_eq!(d.message, "bad aisle line");
assert!(d.location.is_none());
}
#[test]
fn hint_constructor_sets_severity() {
let d = Diagnostic::hint("consider adding a servings metadata key");
assert_eq!(d.severity, Severity::Hint);
assert!(d.location.is_none());
}
#[test]
fn at_file_attaches_location() {
let d = Diagnostic::error("boom").at_file("config/aisle.conf");
let location = d.location.expect("location set");
assert_eq!(
location.file.as_deref().map(|p| p.as_str()),
Some("config/aisle.conf")
);
}
#[test]
fn at_file_preserves_an_existing_span() {
let d = Diagnostic {
severity: Severity::Error,
message: "boom".to_string(),
location: Some(Location {
file: None,
span: Some(Span { start: 12, end: 20 }),
}),
hints: Vec::new(),
}
.at_file("soup.cook");
let location = d.location.expect("location set");
assert_eq!(
location.file.as_deref().map(|p| p.as_str()),
Some("soup.cook")
);
assert_eq!(location.span, Some(Span { start: 12, end: 20 }));
}
#[test]
fn span_converts_from_a_range() {
assert_eq!(Span::from(4..9), Span { start: 4, end: 9 });
}
#[test]
fn serializes_without_null_location() {
let d = Diagnostic::warning("no location");
let json = serde_json::to_string(&d).unwrap();
assert_eq!(json, r#"{"severity":"warning","message":"no location"}"#);
}
#[test]
fn serializes_a_full_location_with_a_named_span() {
let d = Diagnostic {
severity: Severity::Error,
message: "bad quantity".to_string(),
location: Some(Location {
file: Some(Utf8PathBuf::from("soup.cook")),
span: Some(Span { start: 12, end: 20 }),
}),
hints: Vec::new(),
};
let json = serde_json::to_string(&d).unwrap();
assert_eq!(
json,
r#"{"severity":"error","message":"bad quantity","location":{"file":"soup.cook","span":{"start":12,"end":20}}}"#
);
}
#[test]
fn hints_roundtrip_and_are_omitted_when_empty() {
let mut d = Diagnostic::warning("duplicate key");
d.hints = vec![
"Replace the entries with this:\n---\ntitle: B\n---\n".to_string(),
"second hint".to_string(),
];
let json = serde_json::to_string(&d).unwrap();
let back: Diagnostic = serde_json::from_str(&json).unwrap();
assert_eq!(back, d, "hints must survive a serde roundtrip in order");
assert_eq!(back.hints.len(), 2);
assert_eq!(back.hints[1], "second hint");
let empty = Diagnostic::warning("nothing to suggest");
let json = serde_json::to_string(&empty).unwrap();
assert_eq!(
json,
r#"{"severity":"warning","message":"nothing to suggest"}"#
);
let back: Diagnostic = serde_json::from_str(&json).unwrap();
assert_eq!(back, empty);
assert!(back.hints.is_empty());
}
#[test]
fn omitted_fields_deserialize_back_to_none() {
let d: Diagnostic =
serde_json::from_str(r#"{"severity":"warning","message":"no location"}"#).unwrap();
assert_eq!(d, Diagnostic::warning("no location"));
let d: Diagnostic = serde_json::from_str(
r#"{"severity":"error","message":"m","location":{"file":"a.cook"}}"#,
)
.unwrap();
let location = d.location.expect("location set");
assert_eq!(location.file.as_deref().map(|p| p.as_str()), Some("a.cook"));
assert_eq!(location.span, None);
let d: Diagnostic =
serde_json::from_str(r#"{"severity":"hint","message":"m","location":{}}"#).unwrap();
assert_eq!(
d.location,
Some(Location {
file: None,
span: None
})
);
}
}