/home/noah/src/ruchy/src/frontend/diagnostics.rs
Line | Count | Source |
1 | | //! Enhanced error diagnostics with source code display and suggestions |
2 | | |
3 | | use crate::frontend::error_recovery::{ParseError, ErrorSeverity}; |
4 | | use crate::frontend::ast::Span; |
5 | | use std::fmt; |
6 | | |
7 | | /// Enhanced diagnostic information with source context |
8 | | #[derive(Debug, Clone)] |
9 | | pub struct Diagnostic { |
10 | | pub error: ParseError, |
11 | | pub source_code: String, |
12 | | pub filename: Option<String>, |
13 | | pub suggestions: Vec<Suggestion>, |
14 | | } |
15 | | |
16 | | /// A suggestion for fixing an error |
17 | | #[derive(Debug, Clone)] |
18 | | pub struct Suggestion { |
19 | | pub message: String, |
20 | | pub replacement: Option<String>, |
21 | | pub span: Span, |
22 | | } |
23 | | |
24 | | impl Diagnostic { |
25 | 1 | pub fn new(error: ParseError, source_code: String) -> Self { |
26 | 1 | Self { |
27 | 1 | error, |
28 | 1 | source_code, |
29 | 1 | filename: None, |
30 | 1 | suggestions: Vec::new(), |
31 | 1 | } |
32 | 1 | } |
33 | | |
34 | 0 | pub fn with_filename(mut self, filename: String) -> Self { |
35 | 0 | self.filename = Some(filename); |
36 | 0 | self |
37 | 0 | } |
38 | | |
39 | 0 | pub fn add_suggestion(&mut self, suggestion: Suggestion) { |
40 | 0 | self.suggestions.push(suggestion); |
41 | 0 | } |
42 | | |
43 | | /// Extract the relevant source lines with context |
44 | 1 | fn get_source_context(&self) -> (Vec<String>, usize, usize, usize) { |
45 | 1 | let lines: Vec<String> = self.source_code.lines().map(std::string::ToString::to_string).collect(); |
46 | | |
47 | | // Find line and column from byte offset |
48 | 1 | let mut current_pos = 0; |
49 | 1 | let mut line_num = 0; |
50 | 1 | let mut col_start = 0; |
51 | | |
52 | 1 | for (i, line) in lines.iter().enumerate() { |
53 | 1 | let line_len = line.len() + 1; // +1 for newline |
54 | 1 | if current_pos + line_len > self.error.span.start { |
55 | 1 | line_num = i; |
56 | 1 | col_start = self.error.span.start - current_pos; |
57 | 1 | break; |
58 | 0 | } |
59 | 0 | current_pos += line_len; |
60 | | } |
61 | | |
62 | | // Calculate error span width |
63 | 1 | let col_end = col_start + (self.error.span.end - self.error.span.start); |
64 | | |
65 | | // Get context lines (2 before, 2 after) |
66 | 1 | let context_start = line_num.saturating_sub(2); |
67 | 1 | let context_end = (line_num + 3).min(lines.len()); |
68 | 1 | let context_lines = lines[context_start..context_end].to_vec(); |
69 | | |
70 | 1 | (context_lines, line_num - context_start, col_start, col_end) |
71 | 1 | } |
72 | | |
73 | | /// Generate colored output for terminal display |
74 | 1 | pub fn format_colored(&self) -> String { |
75 | 1 | let mut output = String::new(); |
76 | | |
77 | | // Header with severity and location |
78 | 1 | let severity_color = match self.error.severity { |
79 | 1 | ErrorSeverity::Error => "\x1b[31m", // Red |
80 | 0 | ErrorSeverity::Warning => "\x1b[33m", // Yellow |
81 | 0 | ErrorSeverity::Info => "\x1b[34m", // Blue |
82 | 0 | ErrorSeverity::Hint => "\x1b[36m", // Cyan |
83 | | }; |
84 | 1 | let reset = "\x1b[0m"; |
85 | 1 | let bold = "\x1b[1m"; |
86 | | |
87 | | // File and location header |
88 | 1 | if let Some(ref filename0 ) = self.filename { |
89 | 0 | output.push_str(&format!( |
90 | 0 | "{bold}{severity_color}error[{:?}]{reset}: {}\n", |
91 | 0 | self.error.error_code, |
92 | 0 | self.error.message |
93 | 0 | )); |
94 | 0 | output.push_str(&format!( |
95 | 0 | " {bold}-->{reset} {}:{}:{}\n", |
96 | 0 | filename, |
97 | 0 | self.error.span.start / 100 + 1, // Rough line estimate |
98 | 0 | self.error.span.start % 100 + 1 // Rough column estimate |
99 | 0 | )); |
100 | 1 | } else { |
101 | 1 | output.push_str(&format!( |
102 | 1 | "{bold}{severity_color}error[{:?}]{reset}: {}\n", |
103 | 1 | self.error.error_code, |
104 | 1 | self.error.message |
105 | 1 | )); |
106 | 1 | } |
107 | | |
108 | | // Source code context with error highlighting |
109 | 1 | let (context_lines, error_line_idx, col_start, col_end) = self.get_source_context(); |
110 | 1 | let line_num_start = (self.error.span.start / 100 + 1).saturating_sub(error_line_idx); |
111 | | |
112 | 3 | for (i, line) in context_lines.iter()1 .enumerate1 () { |
113 | 3 | let line_num = line_num_start + i; |
114 | 3 | let is_error_line = i == error_line_idx; |
115 | | |
116 | | // Line number and content |
117 | 3 | if is_error_line { |
118 | 1 | output.push_str(&format!( |
119 | 1 | "{bold}{line_num:4} |{reset} {line}\n" |
120 | 1 | )); |
121 | | |
122 | | // Error underline |
123 | 1 | let spaces = " ".repeat(col_start); |
124 | 1 | let arrows = "^".repeat((col_end - col_start).max(1)); |
125 | 1 | output.push_str(&format!( |
126 | 1 | " | {spaces}{severity_color}{arrows}\n" |
127 | 1 | )); |
128 | | |
129 | | // Error message under the line |
130 | 1 | if let Some(ref hint0 ) = self.error.recovery_hint { |
131 | 0 | output.push_str(&format!( |
132 | 0 | " {} {}{}{reset} {}\n", |
133 | 0 | "|", |
134 | 0 | " ".repeat(col_start), |
135 | 0 | severity_color, |
136 | 0 | hint |
137 | 0 | )); |
138 | 1 | } |
139 | 2 | } else { |
140 | 2 | output.push_str(&format!("{line_num:4} | {line}\n")); |
141 | 2 | } |
142 | | } |
143 | | |
144 | | // Suggestions |
145 | 1 | if !self.suggestions.is_empty() { |
146 | 0 | output.push_str(&format!("\n{bold}help{reset}: ")); |
147 | 0 | for suggestion in &self.suggestions { |
148 | 0 | output.push_str(&format!("{}\n", suggestion.message)); |
149 | 0 | if let Some(ref replacement) = suggestion.replacement { |
150 | 0 | output.push_str(&format!(" suggested fix: `{replacement}`\n")); |
151 | 0 | } |
152 | | } |
153 | 1 | } |
154 | | |
155 | 1 | output.push_str(reset); |
156 | 1 | output |
157 | 1 | } |
158 | | } |
159 | | |
160 | | impl fmt::Display for Diagnostic { |
161 | 1 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
162 | 1 | write!(f, "{}", self.format_colored()) |
163 | 1 | } |
164 | | } |
165 | | |
166 | | /// Common error patterns and their suggestions |
167 | 1 | pub fn suggest_for_error(error: &ParseError) -> Vec<Suggestion> { |
168 | 1 | let mut suggestions = Vec::new(); |
169 | | |
170 | | // Common typo suggestions |
171 | 1 | if error.message.contains("unexpected") { |
172 | 1 | if let Some(ref _found) = error.found { |
173 | 1 | // Token-specific suggestions would go here |
174 | 1 | suggestions.push(Suggestion { |
175 | 1 | message: "Check for typos or missing operators".to_string(), |
176 | 1 | replacement: None, |
177 | 1 | span: error.span, |
178 | 1 | }); |
179 | 1 | }0 |
180 | 0 | } |
181 | | |
182 | | // Missing semicolon suggestion |
183 | 1 | if error.message.contains("expected") && error.message.contains("semicolon") { |
184 | 0 | suggestions.push(Suggestion { |
185 | 0 | message: "Add a semicolon at the end of the statement".to_string(), |
186 | 0 | replacement: Some(";".to_string()), |
187 | 0 | span: Span { |
188 | 0 | start: error.span.end, |
189 | 0 | end: error.span.end, |
190 | 0 | }, |
191 | 0 | }); |
192 | 1 | } |
193 | | |
194 | | // Unclosed delimiter suggestions |
195 | 1 | if error.message.contains("unclosed") || error.message.contains("unmatched") { |
196 | 0 | if error.message.contains("paren") { |
197 | 0 | suggestions.push(Suggestion { |
198 | 0 | message: "Add closing parenthesis ')'".to_string(), |
199 | 0 | replacement: Some(")".to_string()), |
200 | 0 | span: Span { |
201 | 0 | start: error.span.end, |
202 | 0 | end: error.span.end, |
203 | 0 | }, |
204 | 0 | }); |
205 | 0 | } else if error.message.contains("brace") { |
206 | 0 | suggestions.push(Suggestion { |
207 | 0 | message: "Add closing brace '}'".to_string(), |
208 | 0 | replacement: Some("}".to_string()), |
209 | 0 | span: Span { |
210 | 0 | start: error.span.end, |
211 | 0 | end: error.span.end, |
212 | 0 | }, |
213 | 0 | }); |
214 | 0 | } else if error.message.contains("bracket") { |
215 | 0 | suggestions.push(Suggestion { |
216 | 0 | message: "Add closing bracket ']'".to_string(), |
217 | 0 | replacement: Some("]".to_string()), |
218 | 0 | span: Span { |
219 | 0 | start: error.span.end, |
220 | 0 | end: error.span.end, |
221 | 0 | }, |
222 | 0 | }); |
223 | 0 | } |
224 | 1 | } |
225 | | |
226 | 1 | suggestions |
227 | 1 | } |
228 | | |
229 | | #[cfg(test)] |
230 | | mod tests { |
231 | | use super::*; |
232 | | |
233 | | #[test] |
234 | 1 | fn test_diagnostic_display() { |
235 | 1 | let error = ParseError::new( |
236 | 1 | "Unexpected token".to_string(), |
237 | 1 | Span { start: 10, end: 15 }, |
238 | | ); |
239 | | |
240 | 1 | let source = "let x = 10\nlet y = @invalid\nlet z = 30".to_string(); |
241 | 1 | let diag = Diagnostic::new(error, source); |
242 | | |
243 | 1 | let output = format!("{diag}"); |
244 | 1 | assert!(output.contains("Unexpected token")); |
245 | 1 | } |
246 | | |
247 | | #[test] |
248 | 1 | fn test_suggestions() { |
249 | 1 | let mut error = ParseError::new( |
250 | 1 | "unexpected '='".to_string(), |
251 | 1 | Span { start: 5, end: 6 }, |
252 | | ); |
253 | 1 | error.found = Some(crate::frontend::lexer::Token::Equal); |
254 | | |
255 | 1 | let suggestions = suggest_for_error(&error); |
256 | 1 | assert!(!suggestions.is_empty()); |
257 | 1 | } |
258 | | } |