Skip to main content

shape_ast/error/
formatting.rs

1//! Error formatting and display utilities
2//!
3//! This module provides functionality for formatting errors with
4//! rich context, source code display, and error codes.
5
6use super::types::{ErrorCode, ShapeError};
7
8impl ShapeError {
9    /// Format the error with source location, hints, and notes (Rust-style)
10    pub fn format_with_source(&self) -> String {
11        let mut output = String::new();
12
13        // Get location if available
14        let location = match self {
15            ShapeError::ParseError { location, .. }
16            | ShapeError::LexError { location, .. }
17            | ShapeError::SemanticError { location, .. }
18            | ShapeError::RuntimeError { location, .. } => location.as_ref(),
19            _ => None,
20        };
21
22        // Error header with location
23        output.push_str("error");
24
25        // Add error code if we can infer it from the error type
26        if let Some(code) = self.error_code() {
27            output.push_str(&format!("[{}]", code.as_str()));
28        }
29        output.push_str(": ");
30        output.push_str(&self.diagnostic_message());
31        output.push('\n');
32
33        // Location arrow
34        if let Some(loc) = location {
35            let file_str = loc.file.as_deref().unwrap_or("<input>");
36            output.push_str(&format!(
37                "  --> {}:{}:{}
38",
39                file_str, loc.line, loc.column
40            ));
41
42            // Source line with line number gutter
43            if let Some(source) = &loc.source_line {
44                let line_num = loc.line.to_string();
45                let padding = " ".repeat(line_num.len());
46
47                output.push_str(&format!("   {} |\n", padding));
48                output.push_str(&format!(
49                    " {} | {}
50",
51                    line_num, source
52                ));
53
54                // Error pointer
55                let mut pointer = format!(
56                    "   {} | {}",
57                    padding,
58                    " ".repeat(loc.column.saturating_sub(1))
59                );
60                pointer.push('^');
61                if let Some(len) = loc.length {
62                    pointer.push_str(&"~".repeat(len.saturating_sub(1)));
63                }
64                output.push_str(&pointer);
65                output.push('\n');
66            }
67        }
68
69        // Display notes
70        if let Some(loc) = location {
71            for note in &loc.notes {
72                output.push_str("   |\n");
73                output.push_str(&format!(
74                    "   = note: {}
75",
76                    note.message
77                ));
78
79                if let Some(note_loc) = &note.location {
80                    let file_str = note_loc.file.as_deref().unwrap_or("<input>");
81                    output.push_str(&format!(
82                        "  --> {}:{}:{}
83",
84                        file_str, note_loc.line, note_loc.column
85                    ));
86
87                    if let Some(source) = &note_loc.source_line {
88                        let line_num = note_loc.line.to_string();
89                        let padding = " ".repeat(line_num.len());
90                        output.push_str(&format!("   {} |\n", padding));
91                        output.push_str(&format!(
92                            " {} | {}
93",
94                            line_num, source
95                        ));
96                        output.push_str(&format!(
97                            "   {} | {}-
98",
99                            padding,
100                            " ".repeat(note_loc.column.saturating_sub(1))
101                        ));
102                    }
103                }
104            }
105        }
106
107        // Display hints
108        if let Some(loc) = location {
109            for hint in &loc.hints {
110                output.push_str("   |\n");
111                output.push_str(&format!(
112                    "   = help: {}
113",
114                    hint
115                ));
116            }
117        }
118
119        output
120    }
121
122    fn diagnostic_message(&self) -> String {
123        match self {
124            ShapeError::ParseError { message, .. }
125            | ShapeError::LexError { message, .. }
126            | ShapeError::SemanticError { message, .. }
127            | ShapeError::RuntimeError { message, .. }
128            | ShapeError::ConfigError { message }
129            | ShapeError::CacheError { message } => message.clone(),
130            ShapeError::TypeError(message) | ShapeError::VMError(message) => message.clone(),
131            ShapeError::PatternError { message, .. }
132            | ShapeError::SimulationError { message, .. }
133            | ShapeError::DataProviderError { message, .. }
134            | ShapeError::TestError { message, .. }
135            | ShapeError::StreamError { message, .. }
136            | ShapeError::AlignmentError { message, .. } => message.clone(),
137            ShapeError::DataError { message, .. } | ShapeError::ModuleError { message, .. } => {
138                message.clone()
139            }
140            _ => self.to_string(),
141        }
142    }
143
144    /// Try to determine an error code based on the error type and message
145    pub fn error_code(&self) -> Option<ErrorCode> {
146        match self {
147            ShapeError::ParseError { message, .. } => {
148                if message.contains("unexpected") {
149                    Some(ErrorCode::E0001)
150                } else if message.contains("unterminated") {
151                    Some(ErrorCode::E0002)
152                } else if message.contains("semicolon") {
153                    Some(ErrorCode::E0004)
154                } else if message.contains("bracket") || message.contains("paren") {
155                    Some(ErrorCode::E0005)
156                } else {
157                    Some(ErrorCode::ParseError)
158                }
159            }
160            ShapeError::TypeError(_) => Some(ErrorCode::TypeError),
161            ShapeError::SemanticError { message, .. } => {
162                // R8 W10 LSP-G — narrow SemanticError messages into the
163                // numbered E-code family so LSP code-action dispatch can
164                // key off `diagnostic.code` instead of substring-matching
165                // the message text (audit `v0.3-lsp-parity-audit.md` §E /
166                // LSP-N §D regression #6).
167                if message.contains("type mismatch") {
168                    Some(ErrorCode::E0100)
169                } else if message.starts_with("Undefined variable")
170                    || message.starts_with("Undefined variables")
171                {
172                    Some(ErrorCode::E0101)
173                } else {
174                    Some(ErrorCode::SemanticError)
175                }
176            }
177            ShapeError::RuntimeError { .. } => Some(ErrorCode::RuntimeError),
178            ShapeError::DataError { .. } => Some(ErrorCode::DataError),
179            ShapeError::ModuleError { .. } => Some(ErrorCode::ModuleError),
180            _ => None,
181        }
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::error::SourceLocation;
189
190    #[test]
191    fn semantic_error_code_is_not_unknown() {
192        let err = ShapeError::SemanticError {
193            message: "boom".to_string(),
194            location: Some(SourceLocation::new(2, 4)),
195        };
196
197        let rendered = err.format_with_source();
198        assert!(rendered.starts_with("error[SEMANTIC]: boom"));
199        assert!(!rendered.contains("UNKNOWN"));
200    }
201
202    #[test]
203    fn format_with_source_uses_message_without_redundant_prefix() {
204        let err = ShapeError::RuntimeError {
205            message: "division by zero".to_string(),
206            location: Some(SourceLocation::new(1, 1).with_source_line("1 / 0".to_string())),
207        };
208
209        let rendered = err.format_with_source();
210        assert!(rendered.contains("error[RUNTIME]: division by zero"));
211        assert!(!rendered.contains("Runtime error: Runtime error:"));
212    }
213
214    /// R8 W10 LSP-G — SemanticError carrying "Undefined variable" should
215    /// be mapped onto E0101 so LSP code-action dispatch can key off
216    /// `diagnostic.code` instead of `diagnostic.message` substring
217    /// matching. Single- and multi-variable variants both surface E0101.
218    #[test]
219    fn semantic_error_code_undefined_variable_maps_to_e0101() {
220        let single = ShapeError::SemanticError {
221            message: "Undefined variable: 'zzz_undefined'".to_string(),
222            location: None,
223        };
224        assert!(matches!(single.error_code(), Some(ErrorCode::E0101)));
225
226        let multi = ShapeError::SemanticError {
227            message: "Undefined variables: 'h', 'i'".to_string(),
228            location: None,
229        };
230        assert!(matches!(multi.error_code(), Some(ErrorCode::E0101)));
231
232        // Other SemanticError shapes remain bucketed as SEMANTIC.
233        let other = ShapeError::SemanticError {
234            message: "some other semantic error".to_string(),
235            location: None,
236        };
237        assert!(matches!(other.error_code(), Some(ErrorCode::SemanticError)));
238    }
239}