use sui_ir::lower::{lower_file, LowerError};
use zahyou::{Lines, Range};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Finding {
#[error("unexpected token")]
Unexpected,
#[error("unexpected token after the end of the expression")]
UnexpectedExtra,
#[error("unexpected {got:?}, expected one of {wanted:?}")]
UnexpectedWanted {
got: rnix::SyntaxKind,
wanted: Vec<rnix::SyntaxKind>,
},
#[error("this pattern argument is bound twice")]
UnexpectedDoubleBind,
#[error("duplicate formal argument `{name}`")]
DuplicatedArgs { name: String },
#[error("unexpected end of file")]
UnexpectedEof,
#[error("unexpected end of file, expected one of {wanted:?}")]
UnexpectedEofWanted { wanted: Vec<rnix::SyntaxKind> },
#[error("expression nests too deeply to parse")]
RecursionLimit,
#[error("sui could not interpret this parse error: {rendered}")]
UnrecognizedParseError { rendered: String },
#[error("this part of the file could not be parsed")]
ParseErrorNode,
#[error("`{construct}` is missing its `{field}`")]
Missing {
construct: &'static str,
field: &'static str,
},
#[error("integer literal `{text}` does not fit in a 64-bit signed integer")]
IntOutOfRange { text: String },
#[error("`{text}` is not a valid floating-point literal")]
BadFloat { text: String },
}
impl Finding {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::Unexpected => "sui/unexpected",
Self::UnexpectedExtra => "sui/unexpected-extra",
Self::UnexpectedWanted { .. } => "sui/unexpected-wanted",
Self::UnexpectedDoubleBind => "sui/double-bind",
Self::DuplicatedArgs { .. } => "sui/duplicate-arg",
Self::UnexpectedEof => "sui/unexpected-eof",
Self::UnexpectedEofWanted { .. } => "sui/unexpected-eof-wanted",
Self::RecursionLimit => "sui/recursion-limit",
Self::UnrecognizedParseError { .. } => "sui/unrecognized-parse-error",
Self::ParseErrorNode => "sui/parse-error-node",
Self::Missing { .. } => "sui/missing-child",
Self::IntOutOfRange { .. } => "sui/int-out-of-range",
Self::BadFloat { .. } => "sui/bad-float",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Anchor {
Span { start: u32, end: u32 },
EndOfInput,
UniqueOccurrence(String),
WholeDocument,
}
impl Anchor {
fn resolve(&self, src: &str, lines: &Lines) -> Range {
match self {
Self::Span { start, end } => lines.range(src, *start as usize, *end as usize),
Self::EndOfInput => lines.range(src, src.len(), src.len()),
Self::UniqueOccurrence(text) => {
let mut hits = src.match_indices(text.as_str());
match (hits.next(), hits.next()) {
(Some((at, _)), None) => lines.range(src, at, at + text.len()),
_ => Self::WholeDocument.resolve(src, lines),
}
}
Self::WholeDocument => lines.range(src, 0, src.len()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub range: Range,
pub severity: Severity,
pub finding: Finding,
}
impl Diagnostic {
#[must_use]
pub fn message(&self) -> String {
self.finding.to_string()
}
}
#[must_use]
pub fn check(src: &str) -> Vec<Diagnostic> {
let lines = Lines::new(src);
let parse = rnix::Root::parse(src);
let parse_errors: Vec<Diagnostic> = parse
.errors()
.iter()
.map(|e| {
let (finding, anchor) = classify_parse_error(e);
Diagnostic {
range: anchor.resolve(src, &lines),
severity: Severity::Error,
finding,
}
})
.collect();
if !parse_errors.is_empty() {
return parse_errors;
}
match lower_file(src) {
Ok(_) => Vec::new(),
Err(e) => {
let (finding, anchor) = classify_lower_error(&e);
vec![Diagnostic {
range: anchor.resolve(src, &lines),
severity: Severity::Error,
finding,
}]
}
}
}
fn classify_parse_error(e: &rnix::ParseError) -> (Finding, Anchor) {
use rnix::ParseError as P;
let span = |r: &rowan::TextRange| Anchor::Span {
start: u32::from(r.start()),
end: u32::from(r.end()),
};
match e {
P::Unexpected(r) => (Finding::Unexpected, span(r)),
P::UnexpectedExtra(r) => (Finding::UnexpectedExtra, span(r)),
P::UnexpectedWanted(got, r, wanted) => (
Finding::UnexpectedWanted {
got: *got,
wanted: wanted.to_vec(),
},
span(r),
),
P::UnexpectedDoubleBind(r) => (Finding::UnexpectedDoubleBind, span(r)),
P::DuplicatedArgs(r, name) => (Finding::DuplicatedArgs { name: name.clone() }, span(r)),
P::UnexpectedEOF => (Finding::UnexpectedEof, Anchor::EndOfInput),
P::UnexpectedEOFWanted(wanted) => (
Finding::UnexpectedEofWanted {
wanted: wanted.to_vec(),
},
Anchor::EndOfInput,
),
P::RecursionLimitExceeded => (Finding::RecursionLimit, Anchor::WholeDocument),
other => (
Finding::UnrecognizedParseError {
rendered: other.to_string(),
},
Anchor::WholeDocument,
),
}
}
fn classify_lower_error(e: &LowerError) -> (Finding, Anchor) {
match e {
LowerError::ParseFailure { .. } => (Finding::Unexpected, Anchor::WholeDocument),
LowerError::ParseErrorNode { start, end } => (
Finding::ParseErrorNode,
Anchor::Span {
start: *start,
end: *end,
},
),
LowerError::Missing { construct, field } => (
Finding::Missing { construct, field },
Anchor::WholeDocument,
),
LowerError::IntOutOfRange { text } => (
Finding::IntOutOfRange { text: text.clone() },
Anchor::UniqueOccurrence(text.clone()),
),
LowerError::BadFloat { text } => (
Finding::BadFloat { text: text.clone() },
Anchor::UniqueOccurrence(text.clone()),
),
}
}
#[cfg(test)]
mod tests {
use super::{check, Anchor, Severity};
use zahyou::{Lines, Position};
#[test]
fn a_valid_file_produces_nothing() {
assert!(check("{ x = 1; }").is_empty());
assert!(check("let x = 1; in x").is_empty());
assert!(check("# just a comment\nnull").is_empty());
}
#[test]
fn a_broken_file_produces_a_located_error() {
let d = check("{ x = ; }");
assert!(!d.is_empty(), "a missing value must be reported");
assert_eq!(d[0].severity, Severity::Error);
assert_eq!(d[0].range.start.line, 0);
}
#[test]
fn every_parse_error_is_reported_not_just_the_first() {
let src = "{ a = ; b = ; c = ; }";
let n = rnix::Root::parse(src).errors().len();
assert_eq!(check(src).len(), n, "must forward all {n} parse errors");
}
#[test]
fn an_end_of_input_error_anchors_at_the_end_not_line_zero() {
let src = "{\n a = 1;\n b = 2;\n c = {\n";
let d = check(src);
assert!(!d.is_empty(), "an unclosed brace must be reported");
let last_line = u32::try_from(Lines::new(src).line_count() - 1).unwrap();
assert!(
d.iter().all(|x| x.range.start.line > 0),
"nothing may anchor at line 0: {:?}",
d.iter().map(|x| x.range.start).collect::<Vec<_>>()
);
assert!(
d.iter().any(|x| x.range.start.line == last_line),
"the unclosed construct should point near the end (line {last_line})"
);
}
#[test]
fn a_unique_literal_anchors_exactly_on_that_literal() {
let src = "let a = 1;\n b = BADLIT;\nin a";
let lines = Lines::new(src);
let r = Anchor::UniqueOccurrence("BADLIT".to_string()).resolve(src, &lines);
assert_eq!(r.start, Position::new(1, 8));
assert_eq!(r.end, Position::new(1, 14));
}
#[test]
fn an_ambiguous_literal_degrades_to_the_whole_document_rather_than_guessing() {
let src = "let a = DUP; b = DUP; in a";
let lines = Lines::new(src);
let r = Anchor::UniqueOccurrence("DUP".to_string()).resolve(src, &lines);
let whole = Anchor::WholeDocument.resolve(src, &lines);
assert_eq!(r, whole, "two occurrences must not be guessed between");
}
#[test]
fn a_missing_literal_degrades_rather_than_panicking() {
let src = "let a = 1; in a";
let lines = Lines::new(src);
let r = Anchor::UniqueOccurrence("nowhere".to_string()).resolve(src, &lines);
assert_eq!(r, Anchor::WholeDocument.resolve(src, &lines));
}
#[test]
fn columns_are_utf16_not_bytes() {
let src = "# 🎉 a comment\n{ x = ; }";
let d = check(src);
assert!(!d.is_empty());
assert_eq!(d[0].range.start.line, 1);
}
#[test]
fn an_empty_document_does_not_panic() {
let d = check("");
for x in &d {
assert_eq!(x.range.start, Position::new(0, 0));
}
}
#[test]
fn the_message_comes_from_the_typed_finding() {
let d = check("{ x = ; }");
assert!(!d[0].message().is_empty());
assert!(d[0].finding.code().starts_with("sui/"));
}
}