1use super::{
6 ErrorCode, ExpectedToken, ParseErrorKind, StructuredParseError, TokenCategory, TokenKind,
7 parse_error::{HighlightStyle, Suggestion},
8};
9
10pub trait ErrorRenderer {
12 type Output;
13
14 fn render(&self, error: &StructuredParseError) -> Self::Output;
16
17 fn render_all(&self, errors: &[StructuredParseError]) -> Self::Output;
19}
20
21#[derive(Debug, Clone)]
23pub struct CliRendererConfig {
24 pub use_colors: bool,
26 pub context_lines: usize,
28 pub show_error_codes: bool,
30 pub show_suggestions: bool,
32 pub show_related: bool,
34 pub terminal_width: usize,
36}
37
38impl Default for CliRendererConfig {
39 fn default() -> Self {
40 Self {
41 use_colors: true,
42 context_lines: 2,
43 show_error_codes: true,
44 show_suggestions: true,
45 show_related: true,
46 terminal_width: 80,
47 }
48 }
49}
50
51impl CliRendererConfig {
52 pub fn plain() -> Self {
54 Self {
55 use_colors: false,
56 ..Default::default()
57 }
58 }
59}
60
61pub struct CliErrorRenderer {
63 config: CliRendererConfig,
64}
65
66impl CliErrorRenderer {
67 pub fn new(config: CliRendererConfig) -> Self {
68 Self { config }
69 }
70
71 pub fn with_colors() -> Self {
72 Self::new(CliRendererConfig::default())
73 }
74
75 pub fn without_colors() -> Self {
76 Self::new(CliRendererConfig::plain())
77 }
78
79 fn bold_red(&self, s: &str) -> String {
81 if self.config.use_colors {
82 format!("\x1b[1;31m{}\x1b[0m", s)
83 } else {
84 s.to_string()
85 }
86 }
87
88 fn yellow(&self, s: &str) -> String {
89 if self.config.use_colors {
90 format!("\x1b[33m{}\x1b[0m", s)
91 } else {
92 s.to_string()
93 }
94 }
95
96 fn blue(&self, s: &str) -> String {
97 if self.config.use_colors {
98 format!("\x1b[34m{}\x1b[0m", s)
99 } else {
100 s.to_string()
101 }
102 }
103
104 fn cyan(&self, s: &str) -> String {
105 if self.config.use_colors {
106 format!("\x1b[36m{}\x1b[0m", s)
107 } else {
108 s.to_string()
109 }
110 }
111
112 fn bold(&self, s: &str) -> String {
113 if self.config.use_colors {
114 format!("\x1b[1m{}\x1b[0m", s)
115 } else {
116 s.to_string()
117 }
118 }
119
120 fn dim(&self, s: &str) -> String {
121 if self.config.use_colors {
122 format!("\x1b[2m{}\x1b[0m", s)
123 } else {
124 s.to_string()
125 }
126 }
127
128 fn format_header(&self, error: &StructuredParseError) -> String {
130 let severity = match error.severity {
131 super::ErrorSeverity::Error => self.bold_red("error"),
132 super::ErrorSeverity::Warning => self.yellow("warning"),
133 super::ErrorSeverity::Info => self.blue("info"),
134 super::ErrorSeverity::Hint => self.cyan("hint"),
135 };
136
137 let code = if self.config.show_error_codes {
138 format!("[{}]", self.format_error_code(error.code))
139 } else {
140 String::new()
141 };
142
143 let message = self.bold(&self.format_error_message(&error.kind));
144
145 format!("{}{}: {}", severity, code, message)
146 }
147
148 fn format_error_code(&self, code: ErrorCode) -> String {
150 code.as_str().to_string()
151 }
152
153 fn format_error_message(&self, kind: &ParseErrorKind) -> String {
155 match kind {
156 ParseErrorKind::UnexpectedToken { found, expected } => {
157 let found_str = self.format_token_info(found);
158 let expected_str = self.format_expected_list(expected);
159 format!("unexpected {}, expected {}", found_str, expected_str)
160 }
161 ParseErrorKind::UnexpectedEof { expected } => {
162 let expected_str = self.format_expected_list(expected);
163 format!("unexpected end of file, expected {}", expected_str)
164 }
165 ParseErrorKind::UnterminatedString { delimiter, .. } => {
166 let delim_char = match delimiter {
167 super::parse_error::StringDelimiter::DoubleQuote => '"',
168 super::parse_error::StringDelimiter::SingleQuote => '\'',
169 super::parse_error::StringDelimiter::Backtick => '`',
170 };
171 format!(
172 "unterminated string literal, missing closing `{}`",
173 delim_char
174 )
175 }
176 ParseErrorKind::UnterminatedComment { .. } => {
177 "unterminated block comment, missing `*/`".to_string()
178 }
179 ParseErrorKind::UnbalancedDelimiter { opener, found, .. } => {
180 let closer = matching_close(*opener);
181 match found {
182 Some(c) => {
183 format!("mismatched delimiter: expected `{}`, found `{}`", closer, c)
184 }
185 None => format!("unclosed `{}`, missing `{}`", opener, closer),
186 }
187 }
188 ParseErrorKind::InvalidNumber { text, reason } => {
189 let reason_str = match reason {
190 super::parse_error::NumberError::InvalidDigit(c) => {
191 return format!("invalid digit `{}` in number `{}`", c, text);
192 }
193 super::parse_error::NumberError::TooLarge => "number too large",
194 super::parse_error::NumberError::MultipleDecimalPoints => {
195 "multiple decimal points"
196 }
197 super::parse_error::NumberError::InvalidExponent => "invalid exponent",
198 super::parse_error::NumberError::TrailingDecimalPoint => {
199 "trailing decimal point"
200 }
201 super::parse_error::NumberError::LeadingZeros => "leading zeros not allowed",
202 super::parse_error::NumberError::Empty => "empty number",
203 };
204 format!("invalid number `{}`: {}", text, reason_str)
205 }
206 ParseErrorKind::InvalidEscape { sequence, .. } => {
207 format!("invalid escape sequence `{}`", sequence)
208 }
209 ParseErrorKind::InvalidCharacter { char, codepoint } => {
210 if char.is_control() {
211 format!("invalid character U+{:04X}", codepoint)
212 } else {
213 format!("invalid character `{}`", char)
214 }
215 }
216 ParseErrorKind::ReservedKeyword { keyword, .. } => {
217 format!("`{}` is a reserved keyword", keyword)
218 }
219 ParseErrorKind::MissingComponent { component, after } => {
220 let comp_str = match component {
221 super::parse_error::MissingComponentKind::Semicolon => "`;`",
222 super::parse_error::MissingComponentKind::Colon => "`:`",
223 super::parse_error::MissingComponentKind::Arrow => "`->`",
224 super::parse_error::MissingComponentKind::ClosingParen => "`)`",
225 super::parse_error::MissingComponentKind::ClosingBrace => "`}`",
226 super::parse_error::MissingComponentKind::ClosingBracket => "`]`",
227 super::parse_error::MissingComponentKind::FunctionBody => "function body",
228 super::parse_error::MissingComponentKind::Expression => "expression",
229 super::parse_error::MissingComponentKind::TypeAnnotation => "type annotation",
230 super::parse_error::MissingComponentKind::Identifier => "identifier",
231 };
232 match after {
233 Some(a) => format!("missing {} after `{}`", comp_str, a),
234 None => format!("missing {}", comp_str),
235 }
236 }
237 ParseErrorKind::Custom { message } => message.clone(),
238 }
239 }
240
241 fn format_token_info(&self, token: &super::parse_error::TokenInfo) -> String {
243 match &token.kind {
244 Some(TokenKind::EndOfInput) => "end of input".to_string(),
245 Some(TokenKind::Keyword(k)) => format!("keyword `{}`", k),
246 Some(TokenKind::Identifier) => format!("identifier `{}`", token.text),
247 Some(TokenKind::Number) => format!("number `{}`", token.text),
248 Some(TokenKind::String) => format!("string `{}`", token.text),
249 Some(TokenKind::Punctuation) | Some(TokenKind::Operator) => {
250 format!("`{}`", token.text)
251 }
252 Some(TokenKind::Whitespace) => "whitespace".to_string(),
253 Some(TokenKind::Comment) => "comment".to_string(),
254 Some(TokenKind::Unknown) | None => {
255 if token.text.is_empty() {
256 "unknown token".to_string()
257 } else {
258 format!("`{}`", token.text)
259 }
260 }
261 }
262 }
263
264 fn format_expected_list(&self, expected: &[ExpectedToken]) -> String {
266 if expected.is_empty() {
267 return "something else".to_string();
268 }
269
270 let formatted: Vec<String> = expected
271 .iter()
272 .filter_map(|e| match e {
273 ExpectedToken::Literal(s) => Some(format!("`{}`", s)),
274 ExpectedToken::Category(cat) => Some(match cat {
275 TokenCategory::Identifier => "identifier".to_string(),
276 TokenCategory::Expression => "expression".to_string(),
277 TokenCategory::Statement => "statement".to_string(),
278 TokenCategory::Literal => "literal".to_string(),
279 TokenCategory::Operator => "operator".to_string(),
280 TokenCategory::Type => "type".to_string(),
281 TokenCategory::Pattern => "pattern".to_string(),
282 TokenCategory::Delimiter => "delimiter".to_string(),
283 }),
284 ExpectedToken::Rule(r) => {
285 let name = super::parse_error::rule_to_friendly_name(r);
286 if name.is_empty() { None } else { Some(name) }
287 }
288 })
289 .collect();
290
291 if formatted.is_empty() {
292 return "valid syntax".to_string();
293 }
294
295 if formatted.len() == 1 {
296 formatted[0].clone()
297 } else if formatted.len() == 2 {
298 format!("{} or {}", formatted[0], formatted[1])
299 } else {
300 let (last, rest) = formatted.split_last().unwrap();
301 format!("{}, or {}", rest.join(", "), last)
302 }
303 }
304
305 fn format_location(&self, error: &StructuredParseError, filename: Option<&str>) -> String {
307 let file = filename.unwrap_or("<input>");
308 let location = format!("{}:{}:{}", file, error.location.line, error.location.column);
309 format!(" {} {}", self.blue("-->"), location)
310 }
311
312 fn format_source_context(&self, error: &StructuredParseError) -> String {
314 let ctx = &error.source_context;
315 if ctx.lines.is_empty() {
316 return String::new();
317 }
318
319 let max_line_num = ctx.lines.iter().map(|l| l.number).max().unwrap_or(1);
320 let gutter_width = max_line_num.to_string().len();
321
322 let mut output = Vec::new();
323
324 output.push(format!("{} {}", " ".repeat(gutter_width), self.blue("|")));
326
327 for source_line in &ctx.lines {
328 let line_num = format!("{:>width$}", source_line.number, width = gutter_width);
330 output.push(format!(
331 "{} {} {}",
332 self.blue(&line_num),
333 self.blue("|"),
334 source_line.content
335 ));
336
337 for highlight in &source_line.highlights {
339 let prefix_spaces = " ".repeat(highlight.start.saturating_sub(1));
340 let marker_len = (highlight.end - highlight.start).max(1);
341 let marker_char = match highlight.style {
342 HighlightStyle::Primary => '^',
343 HighlightStyle::Secondary => '-',
344 HighlightStyle::Suggestion => '~',
345 };
346 let marker = marker_char.to_string().repeat(marker_len);
347
348 let colored_marker = match highlight.style {
349 HighlightStyle::Primary => self.bold_red(&marker),
350 HighlightStyle::Secondary => self.blue(&marker),
351 HighlightStyle::Suggestion => self.cyan(&marker),
352 };
353
354 let label = highlight
355 .label
356 .as_ref()
357 .map(|l| format!(" {}", l))
358 .unwrap_or_default();
359 let colored_label = match highlight.style {
360 HighlightStyle::Primary => self.bold_red(&label),
361 HighlightStyle::Secondary => self.blue(&label),
362 HighlightStyle::Suggestion => self.cyan(&label),
363 };
364
365 output.push(format!(
366 "{} {} {}{}{}",
367 " ".repeat(gutter_width),
368 self.blue("|"),
369 prefix_spaces,
370 colored_marker,
371 colored_label
372 ));
373 }
374 }
375
376 output.push(format!("{} {}", " ".repeat(gutter_width), self.blue("|")));
378
379 output.join("\n")
380 }
381
382 fn format_suggestions(&self, suggestions: &[Suggestion]) -> String {
384 if suggestions.is_empty() || !self.config.show_suggestions {
385 return String::new();
386 }
387
388 let mut output = Vec::new();
389 for suggestion in suggestions {
390 let prefix = match suggestion.confidence {
391 super::parse_error::SuggestionConfidence::Certain => {
392 self.bold(&self.cyan("= fix: "))
393 }
394 super::parse_error::SuggestionConfidence::Likely => {
395 self.bold(&self.yellow("= help: "))
396 }
397 super::parse_error::SuggestionConfidence::Maybe => self.bold(&self.dim("= note: ")),
398 };
399 output.push(format!(" {}{}", prefix, suggestion.message));
400 }
401
402 output.join("\n")
403 }
404
405 fn format_related(&self, related: &[super::parse_error::RelatedInfo]) -> String {
407 if related.is_empty() || !self.config.show_related {
408 return String::new();
409 }
410
411 let mut output = Vec::new();
412 for info in related {
413 let location = format!("{}:{}", info.location.line, info.location.column);
414 output.push(format!(
415 " {} {}: {}",
416 self.blue("note:"),
417 self.dim(&location),
418 info.message
419 ));
420 }
421
422 output.join("\n")
423 }
424}
425
426impl ErrorRenderer for CliErrorRenderer {
427 type Output = String;
428
429 fn render(&self, error: &StructuredParseError) -> String {
430 self.render_with_filename(error, None)
431 }
432
433 fn render_all(&self, errors: &[StructuredParseError]) -> String {
434 errors
435 .iter()
436 .map(|e| self.render(e))
437 .collect::<Vec<_>>()
438 .join("\n\n")
439 }
440}
441
442impl CliErrorRenderer {
443 pub fn render_with_filename(
445 &self,
446 error: &StructuredParseError,
447 filename: Option<&str>,
448 ) -> String {
449 let mut parts = Vec::new();
450
451 parts.push(self.format_header(error));
453
454 parts.push(self.format_location(error, filename));
456
457 let source = self.format_source_context(error);
459 if !source.is_empty() {
460 parts.push(source);
461 }
462
463 let related = self.format_related(&error.related);
465 if !related.is_empty() {
466 parts.push(related);
467 }
468
469 let suggestions = self.format_suggestions(&error.suggestions);
471 if !suggestions.is_empty() {
472 parts.push(suggestions);
473 }
474
475 parts.join("\n")
476 }
477}
478
479fn matching_close(opener: char) -> char {
481 match opener {
482 '(' => ')',
483 '[' => ']',
484 '{' => '}',
485 '<' => '>',
486 _ => opener,
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use crate::error::{SourceLocation, parse_error::TokenInfo};
494
495 #[test]
496 fn test_format_expected_single() {
497 let renderer = CliErrorRenderer::without_colors();
498 let expected = vec![ExpectedToken::Literal(";".to_string())];
499 assert_eq!(renderer.format_expected_list(&expected), "`;`");
500 }
501
502 #[test]
503 fn test_format_expected_two() {
504 let renderer = CliErrorRenderer::without_colors();
505 let expected = vec![
506 ExpectedToken::Category(TokenCategory::Identifier),
507 ExpectedToken::Literal("(".to_string()),
508 ];
509 assert_eq!(
510 renderer.format_expected_list(&expected),
511 "identifier or `(`"
512 );
513 }
514
515 #[test]
516 fn test_format_expected_many() {
517 let renderer = CliErrorRenderer::without_colors();
518 let expected = vec![
519 ExpectedToken::Category(TokenCategory::Identifier),
520 ExpectedToken::Literal("(".to_string()),
521 ExpectedToken::Literal("{".to_string()),
522 ];
523 assert_eq!(
524 renderer.format_expected_list(&expected),
525 "identifier, `(`, or `{`"
526 );
527 }
528
529 #[test]
530 fn test_format_token_info() {
531 let renderer = CliErrorRenderer::without_colors();
532
533 let token = TokenInfo::new(")").with_kind(TokenKind::Punctuation);
534 assert_eq!(renderer.format_token_info(&token), "`)`");
535
536 let token = TokenInfo::new("foo").with_kind(TokenKind::Identifier);
537 assert_eq!(renderer.format_token_info(&token), "identifier `foo`");
538
539 let token = TokenInfo::end_of_input();
540 assert_eq!(renderer.format_token_info(&token), "end of input");
541 }
542
543 #[test]
544 fn test_render_unexpected_token() {
545 let renderer = CliErrorRenderer::without_colors();
546
547 let error = StructuredParseError::new(
548 ParseErrorKind::UnexpectedToken {
549 found: TokenInfo::new(")").with_kind(TokenKind::Punctuation),
550 expected: vec![ExpectedToken::Category(TokenCategory::Identifier)],
551 },
552 SourceLocation::new(1, 10),
553 );
554
555 let output = renderer.render(&error);
556 assert!(output.contains("unexpected `)`"));
557 assert!(output.contains("expected identifier"));
558 }
559}