use std::borrow::Cow;
use std::error::Error as StdError;
use std::fmt;
use std::ops::Range;
const SNIPPET_WIDTH: usize = 160;
const ELLIPSIS: &str = "...";
const ELIDED_LINES: &str = "...";
#[derive(Debug)]
pub struct ParseError {
cause: RawError,
head: MarkedLine,
tail: Option<Box<MarkedLine>>,
source_name: Option<Box<str>>,
}
#[derive(Debug)]
struct MarkedLine {
line: usize,
column: usize,
snippet: Box<str>,
marker: Range<usize>,
}
enum Edge {
Opens,
Closes,
}
impl MarkedLine {
fn mark(line: &Line<'_>, span: &Range<usize>, edge: Edge) -> Self {
let visible_end = line.end();
let from = match edge {
Edge::Opens => span.start.clamp(line.start, visible_end),
Edge::Closes => line.start,
} - line.start;
let to = span.end.clamp(line.start, visible_end) - line.start;
let focus = match edge {
Edge::Opens => from,
Edge::Closes => to,
};
let column = line.text[..from].chars().count() + 1;
let (snippet, marker) = window(line.text, from..to, focus);
Self {
line: line.number,
column,
snippet,
marker,
}
}
fn write(&self, f: &mut fmt::Formatter<'_>, pad: usize) -> fmt::Result {
let (line, snippet) = (self.line, &self.snippet);
writeln!(f, "{line:pad$} | {snippet}")?;
write!(f, "{:pad$} | ", "")?;
for ch in snippet[..self.marker.start].chars() {
write!(f, "{}", if ch == '\t' { '\t' } else { ' ' })?;
}
let width = snippet[self.marker.clone()].chars().count().max(1);
write!(f, "{:^<width$}", "")
}
}
impl ParseError {
pub(super) fn new(cause: RawError, source: &str, span: Range<usize>) -> Self {
let offset = if span.start < source.len() {
span.start
} else {
step_back(source, source.len())
};
let opening = line_at(source, offset);
let head = MarkedLine::mark(&opening, &(offset..span.end), Edge::Opens);
let last = step_back(source, span.end);
let tail = source
.get(opening.start..last)
.is_some_and(|opened| opened.contains('\n'))
.then(|| {
let closing = line_at(source, last);
Box::new(MarkedLine::mark(&closing, &span, Edge::Closes))
});
Self {
cause,
head,
tail,
source_name: None,
}
}
pub fn line(&self) -> usize {
self.head.line
}
pub fn column(&self) -> usize {
self.head.column
}
pub fn snippet(&self) -> &str {
&self.head.snippet
}
pub fn with_source_name(mut self, name: impl Into<Box<str>>) -> Self {
self.source_name = Some(name.into());
self
}
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "error: {}", self.cause)?;
write!(f, " --> ")?;
if let Some(name) = &self.source_name {
write!(f, "{name}:")?;
}
writeln!(f, "{}:{}", self.head.line, self.head.column)?;
let last_line = self.tail.as_ref().map_or(self.head.line, |tail| tail.line);
let pad = last_line.to_string().len();
writeln!(f, "{:pad$} |", "")?;
self.head.write(f, pad)?;
let Some(tail) = &self.tail else {
return Ok(());
};
writeln!(f)?;
if tail.line > self.head.line + 1 {
writeln!(f, "{ELIDED_LINES}")?;
}
tail.write(f, pad)
}
}
impl StdError for ParseError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
self.cause.0.source.as_deref().map(|source| source as _)
}
}
pub(crate) struct RawError(Box<Inner>);
struct Inner {
message: Cow<'static, str>,
trail: Vec<Cow<'static, str>>,
source: Option<Box<dyn StdError + Send + Sync>>,
}
impl RawError {
pub(crate) fn new(message: impl Into<Cow<'static, str>>) -> Self {
Self(Box::new(Inner {
message: message.into(),
trail: Vec::new(),
source: None,
}))
}
fn push(mut self, crumb: impl Into<Cow<'static, str>>) -> Self {
self.0.trail.push(crumb.into());
self
}
}
impl fmt::Display for RawError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for crumb in self.0.trail.iter().rev() {
write!(f, "{crumb}: ")?;
}
write!(f, "{}", self.0.message)
}
}
impl fmt::Debug for RawError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RawError")
.field("message", &self.0.message)
.field("trail", &self.0.trail)
.field("source", &self.0.source)
.finish()
}
}
impl<E: StdError + Send + Sync + 'static> From<E> for RawError {
fn from(error: E) -> Self {
Self(Box::new(Inner {
message: error.to_string().into(),
trail: Vec::new(),
source: Some(Box::new(error)),
}))
}
}
macro_rules! bail {
($($arg:tt)*) => {
return Err($crate::usda::error::RawError::new(format!($($arg)*)))
};
}
macro_rules! ensure {
($cond:expr, $($arg:tt)*) => {
if !$cond {
bail!($($arg)*);
}
};
}
pub(crate) use {bail, ensure};
pub(crate) trait Ctx<T> {
fn context(self, crumb: impl Into<Cow<'static, str>>) -> Result<T, RawError>;
fn with_context<C: Into<Cow<'static, str>>>(self, f: impl FnOnce() -> C) -> Result<T, RawError>;
}
impl<T, E: Into<RawError>> Ctx<T> for Result<T, E> {
fn context(self, crumb: impl Into<Cow<'static, str>>) -> Result<T, RawError> {
self.map_err(|error| error.into().push(crumb))
}
fn with_context<C: Into<Cow<'static, str>>>(self, f: impl FnOnce() -> C) -> Result<T, RawError> {
self.map_err(|error| error.into().push(f()))
}
}
impl<T> Ctx<T> for Option<T> {
fn context(self, crumb: impl Into<Cow<'static, str>>) -> Result<T, RawError> {
self.ok_or_else(|| RawError::new(crumb))
}
fn with_context<C: Into<Cow<'static, str>>>(self, f: impl FnOnce() -> C) -> Result<T, RawError> {
self.ok_or_else(|| RawError::new(f()))
}
}
fn step_back(source: &str, offset: usize) -> usize {
let mut offset = offset.min(source.len());
if offset > 0 {
offset -= 1;
while !source.is_char_boundary(offset) {
offset -= 1;
}
}
offset
}
struct Line<'a> {
number: usize,
start: usize,
text: &'a str,
}
impl Line<'_> {
fn end(&self) -> usize {
self.start + self.text.len()
}
}
fn line_at(source: &str, offset: usize) -> Line<'_> {
let start = source[..offset].rfind('\n').map_or(0, |index| index + 1);
let end = source[offset..].find('\n').map_or(source.len(), |index| offset + index);
let text = &source[start..end];
Line {
number: source[..start].matches('\n').count() + 1,
start,
text: text.strip_suffix('\r').unwrap_or(text),
}
}
fn window(line: &str, marker: Range<usize>, focus: usize) -> (Box<str>, Range<usize>) {
if line.len() <= SNIPPET_WIDTH {
return (line.into(), marker);
}
let mut start = focus.saturating_sub(SNIPPET_WIDTH / 2);
while start > 0 && !line.is_char_boundary(start) {
start -= 1;
}
let mut end = start.saturating_add(SNIPPET_WIDTH).min(line.len());
while end < line.len() && !line.is_char_boundary(end) {
end += 1;
}
let prefix = if start > 0 { ELLIPSIS } else { "" };
let suffix = if end < line.len() { ELLIPSIS } else { "" };
let excerpt = format!("{prefix}{}{suffix}", &line[start..end]);
let shift = prefix.len();
let rebased = (marker.start.max(start) - start + shift)..(marker.end.min(end) - start + shift);
(excerpt.into(), rebased)
}
#[cfg(test)]
mod tests {
use super::*;
fn locate(source: &str, span: Range<usize>) -> ParseError {
ParseError::new(RawError::new("boom"), source, span)
}
#[test]
fn eof_lands_on_text() {
let source = "abc\ndef\n";
let error = locate(source, source.len()..source.len());
assert_eq!(error.line(), 2);
assert_eq!(error.snippet(), "def");
}
#[test]
fn empty_source() {
let error = locate("", 0..0);
assert_eq!(error.line(), 1);
assert_eq!(error.column(), 1);
assert_eq!(error.snippet(), "");
}
#[test]
fn crlf_marker_clamped() {
let source = "ab\r\ncd\r\n";
let error = locate(source, 2..4);
assert_eq!(error.snippet(), "ab");
assert_eq!(error.column(), 3);
assert!(error.to_string().contains("1 | ab"), "got: {error}");
}
#[test]
fn crlf_eof_single_line() {
let source = "float x = 1\r\n";
let error = locate(source, source.len()..source.len());
let rendered = error.to_string();
assert_eq!(rendered.matches("float x = 1").count(), 1, "{rendered}");
assert_eq!(rendered.lines().count(), 5, "{rendered}");
}
#[test]
fn column_matches_caret() {
let source = " float x = =\n";
let error = locate(source, 12..13);
let rendered = error.to_string();
let caret = rendered.lines().nth(4).expect("caret line");
let caret_column = caret.find('^').expect("caret") - " | ".len() + 1;
assert_eq!(caret_column, error.column());
}
#[test]
fn multibyte_column() {
let source = "\u{e9}\u{e9}\u{e9} = =\n";
let error = locate(source, 9..10);
assert_eq!(error.column(), 7, "columns count characters, not bytes");
}
#[test]
fn long_line_windowed() {
let line = format!("float[] p = [{}]", "1.0, ".repeat(400));
let marker = line.len() - 2;
let error = locate(&line, marker..line.len() - 1);
assert!(
error.snippet().len() < SNIPPET_WIDTH + 2 * ELLIPSIS.len() + 8,
"snippet should be windowed, got {} bytes",
error.snippet().len()
);
assert!(error.snippet().starts_with(ELLIPSIS), "got: {}", error.snippet());
assert_eq!(error.column(), marker + 1);
}
#[test]
fn long_marker_capped() {
let line = format!("x = \"{}\"", "y".repeat(4000));
let error = locate(&line, 4..line.len());
assert!(
error.snippet().len() <= SNIPPET_WIDTH + 2 * ELLIPSIS.len(),
"snippet should stay bounded, got {} bytes",
error.snippet().len()
);
assert!(
error.to_string().lines().count() == 5,
"render stays five lines, got: {error}"
);
}
#[test]
fn short_line_kept_whole() {
let error = locate("float x = 1\n", 10..11);
assert_eq!(error.snippet(), "float x = 1");
assert!(!error.snippet().contains(ELLIPSIS));
}
#[test]
fn empty_span_at_line_start() {
let error = locate("a\nb\n", 2..2);
assert_eq!(error.line(), 2);
assert_eq!(error.column(), 1);
assert_eq!(error.to_string().lines().count(), 5);
}
#[test]
fn crlf_multiline_span() {
let source = "float x = \"\"\"a\r\nb\"\"\"\r\n";
let error = locate(source, 10..20);
let rendered = error.to_string();
let lines: Vec<_> = rendered.lines().collect();
assert_eq!(lines[3], "1 | float x = \"\"\"a");
assert_eq!(lines[5], "2 | b\"\"\"");
assert_eq!(lines[6], " | ^^^^");
}
#[test]
fn long_close_stays_visible() {
let source = format!("x = \"\"\"a\n{}\"\"\"", "y".repeat(400));
let end = source.rfind("\"\"\"").expect("token") + 3;
let error = locate(&source, 4..end);
let rendered = error.to_string();
let closing = rendered.lines().nth(5).expect("closing line");
assert!(closing.starts_with("2 | "), "got: {closing}");
assert!(closing.ends_with("\"\"\""), "got: {closing}");
assert!(closing.contains(ELLIPSIS), "got: {closing}");
}
#[test]
fn multiline_span_shows_close() {
let source = "float x = \"\"\"a\nb\"\"\"\n";
let start = source.find("\"\"\"").expect("token");
let end = source.rfind("\"\"\"").expect("token") + 3;
let error = locate(source, start..end);
assert_eq!(error.line(), 1);
assert_eq!(error.column(), 11);
let rendered = error.to_string();
let lines: Vec<_> = rendered.lines().collect();
assert_eq!(lines[3], "1 | float x = \"\"\"a");
assert_eq!(lines[4], " | ^^^^", "the opening line marks to its end");
assert_eq!(lines[5], "2 | b\"\"\"");
assert_eq!(lines[6], " | ^^^^", "the closing line marks from its start");
}
#[test]
fn long_span_elides_middle() {
let source = "a\nb\nc\nd\n";
let error = locate(source, 0..source.len());
let rendered = error.to_string();
let lines: Vec<_> = rendered.lines().collect();
assert_eq!(lines[3], "1 | a");
assert_eq!(lines[5], ELIDED_LINES);
assert_eq!(lines[6], "4 | d");
let single = locate("float x = 1\n", 6..7).to_string();
assert!(!single.contains(ELIDED_LINES), "{single}");
assert_eq!(single.lines().count(), 5);
}
}