/home/noah/src/ruchy/src/parser/error_recovery.rs
Line | Count | Source |
1 | | //! Deterministic Error Recovery for Ruchy Parser |
2 | | //! |
3 | | //! Based on docs/ruchy-transpiler-docs.md Section 4: Deterministic Error Recovery |
4 | | //! Ensures predictable parser behavior on malformed input |
5 | | |
6 | | use crate::frontend::ast::{Expr, ExprKind, Literal, Param, Span}; |
7 | | |
8 | | /// Synthetic error node that can be embedded in the AST |
9 | | #[derive(Debug, Clone, PartialEq)] |
10 | | pub struct ErrorNode { |
11 | | /// The error message |
12 | | pub message: String, |
13 | | /// Source location of the error |
14 | | pub location: SourceLocation, |
15 | | /// The partial context that was successfully parsed |
16 | | pub context: ErrorContext, |
17 | | /// Recovery strategy used |
18 | | pub recovery: RecoveryStrategy, |
19 | | } |
20 | | |
21 | | #[derive(Debug, Clone, PartialEq)] |
22 | | pub struct SourceLocation { |
23 | | pub line: usize, |
24 | | pub column: usize, |
25 | | pub file: Option<String>, |
26 | | } |
27 | | |
28 | | #[derive(Debug, Clone, PartialEq)] |
29 | | pub enum ErrorContext { |
30 | | FunctionDecl { |
31 | | name: Option<String>, |
32 | | params: Option<Vec<Param>>, |
33 | | body: Option<Box<Expr>>, |
34 | | }, |
35 | | LetBinding { |
36 | | name: Option<String>, |
37 | | value: Option<Box<Expr>>, |
38 | | }, |
39 | | IfExpression { |
40 | | condition: Option<Box<Expr>>, |
41 | | then_branch: Option<Box<Expr>>, |
42 | | else_branch: Option<Box<Expr>>, |
43 | | }, |
44 | | ArrayLiteral { |
45 | | elements: Vec<Expr>, |
46 | | error_at_index: usize, |
47 | | }, |
48 | | BinaryOp { |
49 | | left: Option<Box<Expr>>, |
50 | | op: Option<String>, |
51 | | right: Option<Box<Expr>>, |
52 | | }, |
53 | | StructLiteral { |
54 | | name: Option<String>, |
55 | | fields: Vec<(String, Expr)>, |
56 | | error_field: Option<String>, |
57 | | }, |
58 | | } |
59 | | |
60 | | #[derive(Debug, Clone, PartialEq)] |
61 | | pub enum RecoveryStrategy { |
62 | | /// Skip tokens until a synchronization point |
63 | | SkipUntilSync, |
64 | | /// Insert a synthetic token |
65 | | InsertToken(String), |
66 | | /// Replace with a default value |
67 | | DefaultValue, |
68 | | /// Wrap partial parse in error node |
69 | | PartialParse, |
70 | | /// Panic mode - skip until statement boundary |
71 | | PanicMode, |
72 | | } |
73 | | |
74 | | /// Extension to Expr to support error nodes |
75 | | #[derive(Debug, Clone, PartialEq)] |
76 | | pub enum ExprWithError { |
77 | | Valid(Expr), |
78 | | Error(ErrorNode), |
79 | | } |
80 | | |
81 | | impl From<Expr> for ExprWithError { |
82 | 0 | fn from(expr: Expr) -> Self { |
83 | 0 | ExprWithError::Valid(expr) |
84 | 0 | } |
85 | | } |
86 | | |
87 | | impl From<ErrorNode> for ExprWithError { |
88 | 0 | fn from(error: ErrorNode) -> Self { |
89 | 0 | ExprWithError::Error(error) |
90 | 0 | } |
91 | | } |
92 | | |
93 | | /// Parser error recovery implementation |
94 | | pub struct ErrorRecovery { |
95 | | /// Synchronization tokens for panic mode recovery |
96 | | sync_tokens: Vec<String>, |
97 | | /// Maximum errors before giving up |
98 | | max_errors: usize, |
99 | | /// Current error count |
100 | | error_count: usize, |
101 | | } |
102 | | |
103 | | impl Default for ErrorRecovery { |
104 | 447 | fn default() -> Self { |
105 | 447 | Self { |
106 | 447 | sync_tokens: vec![ |
107 | 447 | ";".to_string(), |
108 | 447 | "}".to_string(), |
109 | 447 | "fun".to_string(), |
110 | 447 | "let".to_string(), |
111 | 447 | "if".to_string(), |
112 | 447 | "for".to_string(), |
113 | 447 | "while".to_string(), |
114 | 447 | "return".to_string(), |
115 | 447 | "struct".to_string(), |
116 | 447 | "enum".to_string(), |
117 | 447 | ], |
118 | 447 | max_errors: 100, |
119 | 447 | error_count: 0, |
120 | 447 | } |
121 | 447 | } |
122 | | } |
123 | | |
124 | | impl ErrorRecovery { |
125 | | #[must_use] |
126 | 447 | pub fn new() -> Self { |
127 | 447 | Self::default() |
128 | 447 | } |
129 | | |
130 | | /// Create a synthetic error node for missing function name |
131 | 4 | pub fn missing_function_name(&mut self, location: SourceLocation) -> ErrorNode { |
132 | 4 | self.error_count += 1; |
133 | 4 | ErrorNode { |
134 | 4 | message: "expected function name".to_string(), |
135 | 4 | location, |
136 | 4 | context: ErrorContext::FunctionDecl { |
137 | 4 | name: None, |
138 | 4 | params: None, |
139 | 4 | body: None, |
140 | 4 | }, |
141 | 4 | recovery: RecoveryStrategy::InsertToken("error_fn".to_string()), |
142 | 4 | } |
143 | 4 | } |
144 | | |
145 | | /// Create a synthetic error node for missing function parameters |
146 | 0 | pub fn missing_function_params(&mut self, name: String, location: SourceLocation) -> ErrorNode { |
147 | 0 | self.error_count += 1; |
148 | 0 | ErrorNode { |
149 | 0 | message: "expected function parameters".to_string(), |
150 | 0 | location, |
151 | 0 | context: ErrorContext::FunctionDecl { |
152 | 0 | name: Some(name), |
153 | 0 | params: None, |
154 | 0 | body: None, |
155 | 0 | }, |
156 | 0 | recovery: RecoveryStrategy::DefaultValue, |
157 | 0 | } |
158 | 0 | } |
159 | | |
160 | | /// Create a synthetic error node for missing function body |
161 | 0 | pub fn missing_function_body( |
162 | 0 | &mut self, |
163 | 0 | name: String, |
164 | 0 | params: Vec<Param>, |
165 | 0 | location: SourceLocation, |
166 | 0 | ) -> ErrorNode { |
167 | 0 | self.error_count += 1; |
168 | 0 | ErrorNode { |
169 | 0 | message: "expected function body".to_string(), |
170 | 0 | location, |
171 | 0 | context: ErrorContext::FunctionDecl { |
172 | 0 | name: Some(name), |
173 | 0 | params: Some(params), |
174 | 0 | body: None, |
175 | 0 | }, |
176 | 0 | recovery: RecoveryStrategy::InsertToken("{ /* missing body */ }".to_string()), |
177 | 0 | } |
178 | 0 | } |
179 | | |
180 | | /// Create error node for malformed let binding |
181 | 0 | pub fn malformed_let_binding( |
182 | 0 | &mut self, |
183 | 0 | partial_name: Option<String>, |
184 | 0 | partial_value: Option<Box<Expr>>, |
185 | 0 | location: SourceLocation, |
186 | 0 | ) -> ErrorNode { |
187 | 0 | self.error_count += 1; |
188 | 0 | ErrorNode { |
189 | 0 | message: "malformed let binding".to_string(), |
190 | 0 | location, |
191 | 0 | context: ErrorContext::LetBinding { |
192 | 0 | name: partial_name, |
193 | 0 | value: partial_value, |
194 | 0 | }, |
195 | 0 | recovery: RecoveryStrategy::PartialParse, |
196 | 0 | } |
197 | 0 | } |
198 | | |
199 | | /// Create error node for incomplete if expression |
200 | 0 | pub fn incomplete_if_expr( |
201 | 0 | &mut self, |
202 | 0 | condition: Option<Box<Expr>>, |
203 | 0 | then_branch: Option<Box<Expr>>, |
204 | 0 | location: SourceLocation, |
205 | 0 | ) -> ErrorNode { |
206 | 0 | self.error_count += 1; |
207 | 0 | ErrorNode { |
208 | 0 | message: "incomplete if expression".to_string(), |
209 | 0 | location, |
210 | 0 | context: ErrorContext::IfExpression { |
211 | 0 | condition, |
212 | 0 | then_branch, |
213 | 0 | else_branch: None, |
214 | 0 | }, |
215 | 0 | recovery: RecoveryStrategy::DefaultValue, |
216 | 0 | } |
217 | 0 | } |
218 | | |
219 | | /// Check if we should continue parsing or give up |
220 | | #[must_use] |
221 | 7 | pub fn should_continue(&self) -> bool { |
222 | 7 | self.error_count < self.max_errors |
223 | 7 | } |
224 | | |
225 | | /// Reset error count for new parsing session |
226 | 0 | pub fn reset(&mut self) { |
227 | 0 | self.error_count = 0; |
228 | 0 | } |
229 | | |
230 | | /// Check if token is a synchronization point |
231 | | #[must_use] |
232 | 5 | pub fn is_sync_token(&self, token: &str) -> bool { |
233 | 5 | self.sync_tokens.contains(&token.to_string()) |
234 | 5 | } |
235 | | |
236 | | /// Skip tokens until we find a synchronization point |
237 | 0 | pub fn skip_until_sync<'a, I>(&self, tokens: &mut I) -> Option<String> |
238 | 0 | where |
239 | 0 | I: Iterator<Item = &'a str>, |
240 | | { |
241 | 0 | for token in tokens { |
242 | 0 | if self.is_sync_token(token) { |
243 | 0 | return Some(token.to_string()); |
244 | 0 | } |
245 | | } |
246 | 0 | None |
247 | 0 | } |
248 | | } |
249 | | |
250 | | /// Error recovery rules for different contexts |
251 | | pub struct RecoveryRules; |
252 | | |
253 | | impl RecoveryRules { |
254 | | /// Determine recovery strategy based on context |
255 | | #[must_use] |
256 | 1 | pub fn select_strategy(context: &ErrorContext) -> RecoveryStrategy { |
257 | 1 | match context { |
258 | 1 | ErrorContext::FunctionDecl { name, params, body } => { |
259 | 1 | if name.is_none() { |
260 | 1 | RecoveryStrategy::InsertToken("error_fn".to_string()) |
261 | 0 | } else if params.is_none() { |
262 | 0 | RecoveryStrategy::DefaultValue |
263 | 0 | } else if body.is_none() { |
264 | 0 | RecoveryStrategy::InsertToken("{ }".to_string()) |
265 | | } else { |
266 | 0 | RecoveryStrategy::PartialParse |
267 | | } |
268 | | } |
269 | 0 | ErrorContext::LetBinding { .. } => RecoveryStrategy::SkipUntilSync, |
270 | 0 | ErrorContext::IfExpression { .. } => RecoveryStrategy::DefaultValue, |
271 | | ErrorContext::ArrayLiteral { .. } | ErrorContext::StructLiteral { .. } => { |
272 | 0 | RecoveryStrategy::PartialParse |
273 | | } |
274 | 0 | ErrorContext::BinaryOp { .. } => RecoveryStrategy::PanicMode, |
275 | | } |
276 | 1 | } |
277 | | |
278 | | /// Generate synthetic AST for error recovery |
279 | | #[must_use] |
280 | 1 | pub fn synthesize_ast(error: &ErrorNode) -> Expr { |
281 | 1 | let default_span = Span::new(0, 0); |
282 | 1 | match &error.context { |
283 | | ErrorContext::FunctionDecl { .. } => { |
284 | | // Return a synthetic function that does nothing |
285 | 0 | Expr::new( |
286 | 0 | ExprKind::Lambda { |
287 | 0 | params: vec![], |
288 | 0 | body: Box::new(Expr::new(ExprKind::Literal(Literal::Unit), default_span)), |
289 | 0 | }, |
290 | 0 | default_span, |
291 | | ) |
292 | | } |
293 | 1 | ErrorContext::LetBinding { name, value } => { |
294 | | // Create a let with whatever we could parse |
295 | 1 | Expr::new( |
296 | | ExprKind::Let { |
297 | 1 | name: name.clone().unwrap_or_else(|| "_error"0 .to_string0 ()), |
298 | 1 | type_annotation: None, |
299 | 1 | value: value.clone().unwrap_or_else(|| { |
300 | 1 | Box::new(Expr::new(ExprKind::Literal(Literal::Unit), default_span)) |
301 | 1 | }), |
302 | 1 | body: Box::new(Expr::new(ExprKind::Literal(Literal::Unit), default_span)), |
303 | | is_mutable: false, |
304 | | }, |
305 | 1 | default_span, |
306 | | ) |
307 | | } |
308 | | ErrorContext::IfExpression { |
309 | 0 | condition, |
310 | 0 | then_branch, |
311 | | .. |
312 | | } => { |
313 | | // Create an if with defaults for missing parts |
314 | 0 | Expr::new( |
315 | | ExprKind::If { |
316 | 0 | condition: condition.clone().unwrap_or_else(|| { |
317 | 0 | Box::new(Expr::new( |
318 | 0 | ExprKind::Literal(Literal::Bool(false)), |
319 | 0 | default_span, |
320 | | )) |
321 | 0 | }), |
322 | 0 | then_branch: then_branch.clone().unwrap_or_else(|| { |
323 | 0 | Box::new(Expr::new(ExprKind::Literal(Literal::Unit), default_span)) |
324 | 0 | }), |
325 | 0 | else_branch: Some(Box::new(Expr::new( |
326 | 0 | ExprKind::Literal(Literal::Unit), |
327 | 0 | default_span, |
328 | 0 | ))), |
329 | | }, |
330 | 0 | default_span, |
331 | | ) |
332 | | } |
333 | 0 | ErrorContext::ArrayLiteral { elements, .. } => { |
334 | | // Return partial array with valid elements |
335 | 0 | Expr::new(ExprKind::List(elements.clone()), default_span) |
336 | | } |
337 | 0 | ErrorContext::BinaryOp { left, .. } => { |
338 | | // Return left side if available, otherwise unit |
339 | 0 | if let Some(left) = left { |
340 | 0 | *left.clone() |
341 | | } else { |
342 | 0 | Expr::new(ExprKind::Literal(Literal::Unit), default_span) |
343 | | } |
344 | | } |
345 | 0 | ErrorContext::StructLiteral { name, fields, .. } => { |
346 | | // Return struct with partial fields |
347 | 0 | if let Some(name) = name { |
348 | 0 | Expr::new( |
349 | 0 | ExprKind::StructLiteral { |
350 | 0 | name: name.clone(), |
351 | 0 | fields: fields.clone(), |
352 | 0 | }, |
353 | 0 | default_span, |
354 | | ) |
355 | | } else { |
356 | 0 | Expr::new(ExprKind::Literal(Literal::Unit), default_span) |
357 | | } |
358 | | } |
359 | | } |
360 | 1 | } |
361 | | } |
362 | | |
363 | | /// Integration with parser |
364 | | pub trait ErrorRecoverable { |
365 | | /// Try to recover from parse error |
366 | | fn recover_from_error(&mut self, error: ErrorNode) -> Option<Expr>; |
367 | | |
368 | | /// Check if we're in a recoverable state |
369 | | fn can_recover(&self) -> bool; |
370 | | |
371 | | /// Get current error nodes |
372 | | fn get_errors(&self) -> Vec<ErrorNode>; |
373 | | } |
374 | | |
375 | | #[cfg(test)] |
376 | | #[allow(clippy::unwrap_used, clippy::panic)] |
377 | | mod tests { |
378 | | use super::*; |
379 | | |
380 | | #[test] |
381 | 1 | fn test_error_recovery_creation() { |
382 | 1 | let mut recovery = ErrorRecovery::new(); |
383 | | |
384 | 1 | let error = recovery.missing_function_name(SourceLocation { |
385 | 1 | line: 1, |
386 | 1 | column: 5, |
387 | 1 | file: None, |
388 | 1 | }); |
389 | | |
390 | 1 | assert_eq!(error.message, "expected function name"); |
391 | 1 | assert_eq!(recovery.error_count, 1); |
392 | 1 | assert!(recovery.should_continue()); |
393 | 1 | } |
394 | | |
395 | | #[test] |
396 | 1 | fn test_recovery_strategy_selection() { |
397 | 1 | let context = ErrorContext::FunctionDecl { |
398 | 1 | name: None, |
399 | 1 | params: None, |
400 | 1 | body: None, |
401 | 1 | }; |
402 | | |
403 | 1 | let strategy = RecoveryRules::select_strategy(&context); |
404 | | |
405 | 1 | match strategy { |
406 | 1 | RecoveryStrategy::InsertToken(token) => { |
407 | 1 | assert_eq!(token, "error_fn"); |
408 | | } |
409 | 0 | _ => panic!("Expected InsertToken strategy"), |
410 | | } |
411 | 1 | } |
412 | | |
413 | | #[test] |
414 | 1 | fn test_synthetic_ast_generation() { |
415 | 1 | let error = ErrorNode { |
416 | 1 | message: "test error".to_string(), |
417 | 1 | location: SourceLocation { |
418 | 1 | line: 1, |
419 | 1 | column: 1, |
420 | 1 | file: None, |
421 | 1 | }, |
422 | 1 | context: ErrorContext::LetBinding { |
423 | 1 | name: Some("x".to_string()), |
424 | 1 | value: None, |
425 | 1 | }, |
426 | 1 | recovery: RecoveryStrategy::DefaultValue, |
427 | 1 | }; |
428 | | |
429 | 1 | let ast = RecoveryRules::synthesize_ast(&error); |
430 | | |
431 | 1 | match ast.kind { |
432 | 1 | ExprKind::Let { name, type_annotation: _, value, .. } => { |
433 | 1 | assert_eq!(name, "x"); |
434 | 1 | match value.kind { |
435 | 1 | ExprKind::Literal(Literal::Unit) => {} |
436 | 0 | _ => panic!("Expected Unit value"), |
437 | | } |
438 | | } |
439 | 0 | _ => panic!("Expected Let expression"), |
440 | | } |
441 | 1 | } |
442 | | |
443 | | #[test] |
444 | 1 | fn test_sync_token_detection() { |
445 | 1 | let recovery = ErrorRecovery::new(); |
446 | | |
447 | 1 | assert!(recovery.is_sync_token(";")); |
448 | 1 | assert!(recovery.is_sync_token("fun")); |
449 | 1 | assert!(recovery.is_sync_token("let")); |
450 | 1 | assert!(!recovery.is_sync_token("=")); |
451 | 1 | assert!(!recovery.is_sync_token("+")); |
452 | 1 | } |
453 | | |
454 | | #[test] |
455 | 1 | fn test_max_errors_limit() { |
456 | 1 | let mut recovery = ErrorRecovery::new(); |
457 | 1 | recovery.max_errors = 3; |
458 | | |
459 | 6 | for i5 in 0..5 { |
460 | 5 | if recovery.should_continue() { |
461 | 3 | recovery.missing_function_name(SourceLocation { |
462 | 3 | line: i, |
463 | 3 | column: 0, |
464 | 3 | file: None, |
465 | 3 | }); |
466 | 3 | }2 |
467 | | } |
468 | | |
469 | 1 | assert_eq!(recovery.error_count, 3); |
470 | 1 | assert!(!recovery.should_continue()); |
471 | 1 | } |
472 | | } |