use super::parser::expr::errors::SourceLocation;
pub struct ErrorFormat<'a> {
pub message: &'a str,
pub source: &'a str,
pub position: usize,
pub filename: &'a str,
pub line_offset: usize,
pub annotation: Option<String>,
pub help: Option<&'a str>,
}
impl<'a> ErrorFormat<'a> {
pub fn new(message: &'a str, source: &'a str, position: usize) -> Self {
Self {
message,
source,
position,
filename: "input",
line_offset: 0,
annotation: None,
help: None,
}
}
pub fn filename(mut self, filename: &'a str) -> Self {
self.filename = filename;
self
}
pub fn line_offset(mut self, offset: usize) -> Self {
self.line_offset = offset;
self
}
pub fn annotation(mut self, ann: String) -> Self {
self.annotation = Some(ann);
self
}
pub fn help(mut self, help: &'a str) -> Self {
self.help = Some(help);
self
}
pub fn format(&self) -> String {
let loc = SourceLocation::from_offset(self.source, self.position);
let absolute_line = loc.line + self.line_offset;
let mut msg = format!("error: {}\n", self.message);
msg.push_str(&format!(
" --> {}:{}:{}\n",
self.filename, absolute_line, loc.column
));
let lines: Vec<&str> = self.source.lines().collect();
if loc.line > 0 && loc.line <= lines.len() {
let line_content = lines[loc.line - 1];
let expanded_content = line_content.replace('\t', " ");
let line_start_offset = self.source[..self.position]
.rfind('\n')
.map(|pos| pos + 1)
.unwrap_or(0);
let byte_offset_in_line = self.position.saturating_sub(line_start_offset);
let visual_column: usize = line_content
.char_indices()
.take_while(|(i, _)| *i < byte_offset_in_line)
.map(|(_, c)| if c == '\t' { 4 } else { 1 })
.sum();
let leading_spaces = expanded_content.len() - expanded_content.trim_start().len();
let trimmed_content = expanded_content.trim().to_string();
let adjusted_column = visual_column.saturating_sub(leading_spaces);
let annotation = self.annotation.as_deref().unwrap_or("");
const MAX_LINE_LEN: usize = 80;
const CONTEXT_CHARS: usize = 30;
let (display_content, display_caret_col) = if trimmed_content.len() > MAX_LINE_LEN {
let content_chars: Vec<char> = trimmed_content.chars().collect();
let char_len = content_chars.len();
let desired_start = adjusted_column.saturating_sub(CONTEXT_CHARS);
let desired_end = adjusted_column + CONTEXT_CHARS;
let actual_start = desired_start.min(char_len);
let actual_end = desired_end.min(char_len);
let prefix = if actual_start > 0 { "..." } else { "" };
let suffix = if actual_end < char_len { "..." } else { "" };
let snippet: String = content_chars[actual_start..actual_end].iter().collect();
let new_caret_col = adjusted_column.saturating_sub(actual_start) + prefix.len();
(format!("{}{}{}", prefix, snippet, suffix), new_caret_col)
} else {
(trimmed_content, adjusted_column)
};
let line_num_width = absolute_line.to_string().len();
let source_line = format!(
"{:>width$} | {}",
absolute_line,
display_content,
width = line_num_width
);
let caret_content = format!("{:>col$}^ {}", "", annotation, col = display_caret_col);
let caret_line = format!("{:>width$} | {}", "", caret_content, width = line_num_width);
let max_len = source_line.len().max(caret_line.len());
let padded_source = format!("{:<width$}", source_line, width = max_len);
let padded_caret = format!("{:<width$}", caret_line, width = max_len);
msg.push_str(&format!("`{}`\n", padded_source));
msg.push_str(&format!("`{}`\n", padded_caret));
}
if let Some(help) = self.help {
msg.push_str(&format!("help: {}\n", help));
}
msg
}
}
pub fn build_annotation(found: Option<&str>, expected: &[&str]) -> Option<String> {
if let Some(f) = found {
Some(format!("found: {}", f))
} else if !expected.is_empty() {
Some(format!("expected: {}", expected.join(" or ")))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_format_basic() {
let formatted = ErrorFormat::new("unexpected token", "let x = ;", 8)
.filename("test.ts")
.annotation("expected: expression".to_string())
.format();
assert!(formatted.contains("error: unexpected token"));
assert!(formatted.contains("--> test.ts:1:9"));
assert!(formatted.contains("let x = ;"));
assert!(formatted.contains("^"));
assert!(formatted.contains("expected: expression"));
}
#[test]
fn test_error_format_with_help() {
let formatted = ErrorFormat::new("missing semicolon", "let x = 1", 9)
.help("add `;` at end of statement")
.format();
assert!(formatted.contains("help: add `;` at end of statement"));
}
#[test]
fn test_error_format_multiline() {
let source = "function foo() {\n return;\n}";
let formatted = ErrorFormat::new("unexpected return", source, 19)
.filename("test.ts")
.format();
assert!(formatted.contains("--> test.ts:2:"));
assert!(formatted.contains("return;"));
}
#[test]
fn test_build_annotation_found() {
let ann = build_annotation(Some("identifier"), &[]);
assert_eq!(ann, Some("found: identifier".to_string()));
}
#[test]
fn test_build_annotation_expected() {
let ann = build_annotation(None, &["expression", "statement"]);
assert_eq!(ann, Some("expected: expression or statement".to_string()));
}
#[test]
fn test_build_annotation_none() {
let ann = build_annotation(None, &[]);
assert_eq!(ann, None);
}
#[test]
fn test_long_line_truncation_both_ends() {
let long_line = "let veryLongVariableName = someFunction(anotherLongArgument, yetAnotherArgument, andEvenMoreArguments, finalArgument);";
let formatted = ErrorFormat::new("test error", long_line, 50)
.filename("test.ts")
.format();
assert!(
formatted.contains("`1 | ..."),
"should have prefix ellipsis, got:\n{}",
formatted
);
let ellipsis_count = formatted.matches("...").count();
assert!(
ellipsis_count >= 2,
"should have both prefix and suffix ellipsis (found {}), got:\n{}",
ellipsis_count,
formatted
);
}
#[test]
fn test_long_line_truncation_suffix_only() {
let long_line = "x = someVeryLongFunctionCallWithManyArguments(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8);";
let formatted = ErrorFormat::new("test error", long_line, 2)
.filename("test.ts")
.format();
assert!(
!formatted.contains("`1 | ..."),
"should NOT have prefix ellipsis for error near start, got:\n{}",
formatted
);
assert!(
formatted.contains("..."),
"should have suffix ellipsis, got:\n{}",
formatted
);
}
#[test]
fn test_long_line_truncation_prefix_only() {
let long_line = "let result = someVeryLongFunctionCallWithManyArguments(arg1, arg2, arg3, arg4, arg5, arg6, arg7);";
let len = long_line.len();
let formatted = ErrorFormat::new("test error", long_line, len - 5)
.filename("test.ts")
.format();
assert!(
formatted.contains("..."),
"should have prefix ellipsis, got:\n{}",
formatted
);
assert!(
formatted.contains(");"),
"line should show the actual end, got:\n{}",
formatted
);
}
#[test]
fn test_indented_line_strips_whitespace() {
let indented_line = " let x = error;";
let formatted = ErrorFormat::new("test error", indented_line, 28)
.filename("test.ts")
.format();
assert!(
formatted.contains("| let x"),
"should strip leading whitespace, got:\n{}",
formatted
);
assert!(
!formatted.contains("..."),
"short line should not be truncated, got:\n{}",
formatted
);
}
#[test]
fn test_indented_long_line_truncates_correctly() {
let indented_long = " let veryLongVariableName = someFunction(anotherLongArgument, yetAnotherArgument, moreArgs);";
let formatted = ErrorFormat::new("test error", indented_long, 60)
.filename("test.ts")
.format();
assert!(
formatted.contains("..."),
"long content should be truncated, got:\n{}",
formatted
);
assert!(
formatted.contains("| ..."),
"should show truncated content without leading spaces, got:\n{}",
formatted
);
}
}