Skip to main content

shape_ast/error/
renderer.rs

1//! Error rendering for different output targets
2//!
3//! Provides trait-based rendering of structured errors for CLI and other targets.
4
5use super::{
6    ErrorCode, ExpectedToken, ParseErrorKind, StructuredParseError, TokenCategory, TokenKind,
7    parse_error::{HighlightStyle, Suggestion},
8};
9
10/// Trait for rendering structured parse errors to different output formats
11pub trait ErrorRenderer {
12    type Output;
13
14    /// Render a single error
15    fn render(&self, error: &StructuredParseError) -> Self::Output;
16
17    /// Render multiple errors
18    fn render_all(&self, errors: &[StructuredParseError]) -> Self::Output;
19}
20
21/// Configuration for CLI error rendering
22#[derive(Debug, Clone)]
23pub struct CliRendererConfig {
24    /// Use ANSI colors in output
25    pub use_colors: bool,
26    /// Number of context lines to show before/after error
27    pub context_lines: usize,
28    /// Show error codes (e.g., E0001)
29    pub show_error_codes: bool,
30    /// Show suggestions
31    pub show_suggestions: bool,
32    /// Show related information
33    pub show_related: bool,
34    /// Terminal width for wrapping (0 = no wrap)
35    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    /// Create a config without colors (for testing or non-terminal output)
53    pub fn plain() -> Self {
54        Self {
55            use_colors: false,
56            ..Default::default()
57        }
58    }
59}
60
61/// CLI error renderer with ANSI color support
62pub 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    // ANSI color codes
80    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    /// Format the error header line
129    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    /// Format error code
149    fn format_error_code(&self, code: ErrorCode) -> String {
150        code.as_str().to_string()
151    }
152
153    /// Format the main error message
154    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    /// Format token info for display
242    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    /// Format list of expected tokens
265    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    /// Format source location line
306    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    /// Format source context with line numbers and highlights
313    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        // Empty gutter line for visual spacing
325        output.push(format!("{} {}", " ".repeat(gutter_width), self.blue("|")));
326
327        for source_line in &ctx.lines {
328            // Line number and content
329            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            // Render highlights for this line
338            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        // Empty gutter line at end
377        output.push(format!("{} {}", " ".repeat(gutter_width), self.blue("|")));
378
379        output.join("\n")
380    }
381
382    /// Format suggestions
383    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    /// Format related information
406    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    /// Render with an optional filename for location display
444    pub fn render_with_filename(
445        &self,
446        error: &StructuredParseError,
447        filename: Option<&str>,
448    ) -> String {
449        let mut parts = Vec::new();
450
451        // Header: error[E0001]: message
452        parts.push(self.format_header(error));
453
454        // Location: --> file.shape:1:5
455        parts.push(self.format_location(error, filename));
456
457        // Source context with highlights
458        let source = self.format_source_context(error);
459        if !source.is_empty() {
460            parts.push(source);
461        }
462
463        // Related info
464        let related = self.format_related(&error.related);
465        if !related.is_empty() {
466            parts.push(related);
467        }
468
469        // Suggestions
470        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
479/// Get the matching close delimiter for an opener
480fn 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}