use ariadne::{Color, Label, Report, ReportKind, Source};
use styx_parse::{ParseErrorKind, Span};
#[derive(Debug, Clone)]
pub struct ParseError {
pub kind: ParseErrorKind,
pub span: Span,
}
impl ParseError {
pub fn new(kind: ParseErrorKind, span: Span) -> Self {
Self { kind, span }
}
pub fn render(&self, filename: &str, source: &str) -> String {
let mut output = Vec::new();
self.write_report(filename, source, &mut output);
String::from_utf8(output).unwrap_or_else(|_| format!("{}", self))
}
pub fn write_report<W: std::io::Write>(&self, filename: &str, source: &str, writer: W) {
let report = self.build_report(filename);
let _ = report
.finish()
.write((filename, Source::from(source)), writer);
}
fn build_report<'a>(
&self,
filename: &'a str,
) -> ariadne::ReportBuilder<'static, (&'a str, std::ops::Range<usize>)> {
let range = self.span.start as usize..self.span.end as usize;
match &self.kind {
ParseErrorKind::DuplicateKey { original } => {
let original_range = original.start as usize..original.end as usize;
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("duplicate key")
.with_label(
Label::new((filename, original_range))
.with_message("first defined here")
.with_color(Color::Blue),
)
.with_label(
Label::new((filename, range))
.with_message("duplicate key")
.with_color(Color::Red),
)
.with_help("each key must appear only once in an object")
}
ParseErrorKind::UnclosedObject => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("unclosed object")
.with_label(
Label::new((filename, range))
.with_message("object opened here")
.with_color(Color::Red),
)
.with_help("add a closing '}'"),
ParseErrorKind::UnclosedSequence => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("unclosed sequence")
.with_label(
Label::new((filename, range))
.with_message("sequence opened here")
.with_color(Color::Red),
)
.with_help("add a closing ')'"),
ParseErrorKind::InvalidEscape(seq) => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("invalid escape sequence '{}'", seq))
.with_label(
Label::new((filename, range))
.with_message("invalid escape")
.with_color(Color::Red),
)
.with_help("valid escapes are: \\\\, \\\", \\n, \\r, \\t, \\uXXXX, \\u{X...}"),
ParseErrorKind::UnexpectedToken => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("unexpected token")
.with_label(
Label::new((filename, range))
.with_message("unexpected")
.with_color(Color::Red),
),
ParseErrorKind::ExpectedKey => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("expected key")
.with_label(
Label::new((filename, range))
.with_message("expected a key here")
.with_color(Color::Red),
),
ParseErrorKind::ExpectedValue => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("expected value")
.with_label(
Label::new((filename, range))
.with_message("expected a value here")
.with_color(Color::Red),
),
ParseErrorKind::UnexpectedEof => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("unexpected end of input")
.with_label(
Label::new((filename, range))
.with_message("input ends here")
.with_color(Color::Red),
),
ParseErrorKind::InvalidTagName => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("invalid tag name")
.with_label(
Label::new((filename, range))
.with_message("invalid tag")
.with_color(Color::Red),
)
.with_help("tag names must match @[A-Za-z_][A-Za-z0-9_.-]*"),
ParseErrorKind::InvalidKey => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("invalid key")
.with_label(
Label::new((filename, range))
.with_message("cannot be used as a key")
.with_color(Color::Red),
)
.with_help("keys must be scalars or unit, optionally tagged (no objects, sequences, or heredocs)"),
ParseErrorKind::DanglingDocComment => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("dangling doc comment")
.with_label(
Label::new((filename, range))
.with_message("doc comment not followed by entry")
.with_color(Color::Red),
)
.with_help("doc comments (///) must be followed by an entry"),
ParseErrorKind::TooManyAtoms => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("unexpected atom after value")
.with_label(
Label::new((filename, range))
.with_message("unexpected third atom")
.with_color(Color::Red),
)
.with_help("did you mean `@tag{}`? whitespace is not allowed between a tag and its payload"),
ParseErrorKind::ReopenedPath { closed_path } => {
let path_str = closed_path.join(".");
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("cannot reopen path `{}`", path_str))
.with_label(
Label::new((filename, range))
.with_message("path was closed when sibling appeared")
.with_color(Color::Red),
)
.with_help("sibling paths must appear contiguously; once you move to a different path, you cannot go back")
}
ParseErrorKind::NestIntoTerminal { terminal_path } => {
let path_str = terminal_path.join(".");
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("cannot nest into `{}`", path_str))
.with_label(
Label::new((filename, range))
.with_message("path has a terminal value")
.with_color(Color::Red),
)
.with_help("you cannot add children to a path that already has a scalar, sequence, tag, or unit value")
}
ParseErrorKind::CommaInSequence => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("unexpected comma in sequence")
.with_label(
Label::new((filename, range))
.with_message("comma not allowed here")
.with_color(Color::Red),
)
.with_help("sequences are whitespace-separated, not comma-separated"),
ParseErrorKind::MissingWhitespaceBeforeBlock => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("missing whitespace before block")
.with_label(
Label::new((filename, range))
.with_message("add whitespace before this")
.with_color(Color::Red),
)
.with_help("bare keys must be separated from `{` or `(` by whitespace (to distinguish from tags like `@tag{}`)"),
ParseErrorKind::TrailingContent => Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("trailing content after explicit root object")
.with_label(
Label::new((filename, range))
.with_message("unexpected content here")
.with_color(Color::Red),
)
.with_help("an explicit root object `{...}` is the entire document; nothing can follow it"),
}
}
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.kind {
ParseErrorKind::DuplicateKey { .. } => write!(f, "duplicate key"),
ParseErrorKind::UnclosedObject => write!(f, "unclosed object"),
ParseErrorKind::UnclosedSequence => write!(f, "unclosed sequence"),
ParseErrorKind::InvalidEscape(seq) => write!(f, "invalid escape sequence '{}'", seq),
ParseErrorKind::UnexpectedToken => write!(f, "unexpected token"),
ParseErrorKind::ExpectedKey => write!(f, "expected key"),
ParseErrorKind::ExpectedValue => write!(f, "expected value"),
ParseErrorKind::UnexpectedEof => write!(f, "unexpected end of input"),
ParseErrorKind::InvalidTagName => write!(f, "invalid tag name"),
ParseErrorKind::InvalidKey => write!(f, "invalid key"),
ParseErrorKind::DanglingDocComment => write!(f, "dangling doc comment"),
ParseErrorKind::TooManyAtoms => write!(f, "unexpected atom after value"),
ParseErrorKind::ReopenedPath { closed_path } => {
write!(f, "cannot reopen path `{}`", closed_path.join("."))
}
ParseErrorKind::NestIntoTerminal { terminal_path } => {
write!(f, "cannot nest into `{}`", terminal_path.join("."))
}
ParseErrorKind::CommaInSequence => write!(f, "unexpected comma in sequence"),
ParseErrorKind::MissingWhitespaceBeforeBlock => {
write!(f, "missing whitespace before block")
}
ParseErrorKind::TrailingContent => {
write!(f, "trailing content after explicit root object")
}
}?;
write!(f, " at offset {}", self.span.start)
}
}
impl std::error::Error for ParseError {}
#[cfg(test)]
mod tests {
use super::*;
fn parse_with_errors(source: &str) -> Vec<ParseError> {
let mut parser = styx_parse::Parser::new(source);
let mut errors = Vec::new();
while let Some(event) = parser.next_event() {
if let styx_parse::Event {
kind: styx_parse::EventKind::Error { kind },
span,
} = event
{
errors.push(ParseError::new(kind, span));
}
}
errors
}
macro_rules! assert_snapshot_stripped {
($value:expr) => {{
let stripped = String::from_utf8(strip_ansi_escapes::strip(&$value)).unwrap();
insta::assert_snapshot!(stripped);
}};
}
#[test]
fn test_duplicate_key_diagnostic() {
let source = "a 1\na 2";
let errors = parse_with_errors(source);
assert_eq!(errors.len(), 1);
assert_snapshot_stripped!(errors[0].render("test.styx", source));
}
#[test]
fn test_invalid_escape_diagnostic() {
let source = r#"name "hello\qworld""#;
let errors = parse_with_errors(source);
assert!(!errors.is_empty(), "expected InvalidEscape error");
assert_snapshot_stripped!(errors[0].render("test.styx", source));
}
#[test]
fn test_unclosed_object_diagnostic() {
let source = "server {\n host localhost";
let errors = parse_with_errors(source);
assert!(!errors.is_empty(), "expected UnclosedObject error");
assert_snapshot_stripped!(errors[0].render("test.styx", source));
}
}