///|
/// Structured syntax errors for the notiz markdown parser.
///
/// The parser engine (`talcparsec`) reports failures as string labels.
/// [`SyntaxError::of_parse_error`] converts those labels into the typed
/// [`Expected`] vocabulary once, at the package boundary, so error handling
/// below never matches on raw strings.
///|
/// What the parser expected to find at a failure position.
pub(all) enum Expected {
/// A specific character, e.g. `'*'` from the `char` combinator.
Char(Char)
/// A specific literal, e.g. `"```"` from the `string` combinator.
Str(String)
/// Any character of a set, e.g. the inline special characters.
OneOf(String)
/// The parser ran out of input.
EndOfInput
/// The `| --- | --- |` row that must follow a table header row.
TableDelimiterRow
/// Any other parser label, e.g. `"not newline"`.
Other(String)
} derive(Eq, Debug)
///|
pub extend Expected with Eq::{not_equal, equal}
///|
pub extend Expected with Debug::{to_repr}
///|
/// A syntax error raised while parsing notiz markdown.
///
/// Carries the failure [`position`](`SyntaxError::position`) and a structured
/// description of what went wrong, instead of an opaque message string.
pub(all) suberror SyntaxError {
/// At the position, the input did not match any of the expected items.
Mismatch(@talc.Position, Array[Expected])
/// At the position, the forbidden construct was found.
Unexpected(@talc.Position, String)
} derive(Eq, Debug)
///|
pub extend SyntaxError with Eq::{not_equal, equal}
///|
pub extend SyntaxError with Debug::{to_repr}
///|
/// The source position where the parse failed.
pub fn SyntaxError::position(self : SyntaxError) -> @talc.Position {
match self {
Mismatch(position, _) | Unexpected(position, _) => position
}
}
///|
/// Converts a `@talc.ParseError` into the typed [`SyntaxError`].
///
/// This is the single place that interprets the parser engine's string
/// labels; every label below this point is structured.
pub fn SyntaxError::of_parse_error(err : @talc.ParseError) -> SyntaxError {
let position = @talc.Position::{
offset: err.offset(),
line: err.line(),
column: err.column(),
}
let message = err.message_text()
if message.has_prefix("unexpected ") {
SyntaxError::Unexpected(position, message.sub(start=11).to_owned())
} else if message.has_prefix("expected ") {
SyntaxError::Mismatch(position, [
classify_expected(message.sub(start=9).to_owned()),
])
} else if !message.is_empty() {
SyntaxError::Mismatch(position, [Expected::Other(message)])
} else {
SyntaxError::Mismatch(position, err.expected().map(classify_expected))
}
}
///|
/// One-line summary, e.g. `parse error at 1:2: expected a space`.
///
/// The full report with the source snippet, caret, and found token is
/// [`format_parse_error`], which needs the source text.
pub impl Show for SyntaxError with fn output(self, logger) {
let position = self.position()
let detail = match self {
Mismatch(_, expected) => "expected \{describe_all(expected)}"
Unexpected(_, token) => "unexpected \{token}"
}
logger.write_string(
"parse error at \{position.line}:\{position.column}: \{detail}",
)
}
///|
pub extend SyntaxError with Show::{to_string, output}
///|
/// Formats a syntax error with a source snippet and caret marker.
///
/// Returns a multi-line message like:
///
/// ```text
/// parse error at 1:2: expected a space, found 'a'
///
/// 1 | hello world
/// | ^
/// ```
pub fn format_parse_error(err : SyntaxError, source : String) -> String {
let position = err.position()
let detail = error_detail(err, source)
let lines = source.split("\n").to_array()
let source_line = {
guard position.line >= 1 && position.line <= lines.length() else { "" }
lines[position.line - 1].to_owned()
}
let label = position.line.to_string()
let gutter = " ".repeat(label.length())
let sb = StringBuilder()
sb <+ "parse error at \{position.line}:\{position.column}: \{detail}"
if !source_line.is_empty() {
sb.write_string("\n\n")
sb <+ "\{gutter} |"
sb.write_string("\n")
sb <+ "\{label} | \{source_line}"
sb.write_string("\n")
sb <+ "\{gutter} | \{caret_marker(source_line, position.column)}"
}
sb.to_string()
}
///|
fn error_detail(err : SyntaxError, source : String) -> String {
let position = err.position()
match err {
Mismatch(_, expected) =>
if expected.is_empty() {
"syntax error"
} else {
"expected \{describe_all(expected)}, found \{found_token(source, position.offset)}"
}
Unexpected(_, token) => "unexpected \{token}"
}
}
///|
fn classify_expected(label : String) -> Expected {
let len = label.length()
if len >= 2 &&
label.get_char(0) == Some('\'') &&
label.get_char(len - 1) == Some('\'') {
Expected::Char(label.get_char(len - 2).unwrap())
} else if len >= 2 &&
label.get_char(0) == Some('"') &&
label.get_char(len - 1) == Some('"') {
Expected::Str(label.sub(start=1, end=len - 1).to_owned())
} else if label.has_prefix("one of ") {
Expected::OneOf(label.sub(start=7).to_owned())
} else {
match label {
"end of input" => Expected::EndOfInput
"table delimiter row" => Expected::TableDelimiterRow
_ => Expected::Other(label)
}
}
}
///|
/// Human-readable phrasing of an expected item.
fn describe(expected : Expected) -> String {
match expected {
Char(' ') => "a space"
Char('\n') => "a newline"
Char(c) => "'\{c}'"
Str(s) => "\"\{s}\""
OneOf(s) => "one of \{s}"
EndOfInput => "end of input"
TableDelimiterRow => "a table delimiter row such as | --- | --- |"
Other(s) => s
}
}
///|
fn describe_all(expected : Array[Expected]) -> String {
expected.map(describe).join(" or ")
}
///|
/// What the parser actually encountered at the failure offset.
fn found_token(source : String, offset : Int) -> String {
match source.get_char(offset) {
None => "end of input"
Some('\n') => "a newline"
Some(' ') => "a space"
Some(c) => "'\{c}'"
}
}
///|
/// One space per column before the error, then `^`.
fn caret_marker(line : String, col : Int) -> String {
let sb = StringBuilder()
let mut count = 0
for _ in line.iter() {
if count >= col - 1 {
break
}
sb.write_char(' ')
count = count + 1
}
sb.write_char('^')
sb.to_string()
}