Skip to main content

yaml_edit/
error_recovery.rs

1//! Error recovery mechanisms for YAML parsing
2//!
3//! This module provides enhanced error handling with:
4//! - Line and column information for errors
5//! - Recovery strategies to continue parsing after errors
6//! - Detailed error messages with context
7
8use crate::line_index::LineIndex;
9use crate::{lex::SyntaxKind, PositionedParseError};
10use rowan::{TextRange, TextSize};
11
12/// Error recovery strategy for the parser
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RecoveryStrategy {
15    /// Skip the current token and continue
16    SkipToken,
17    /// Skip until end of line
18    SkipToEndOfLine,
19    /// Skip until a safe synchronization point
20    SyncToSafePoint,
21    /// Insert a synthetic token to fix the parse
22    InsertToken(SyntaxKind),
23}
24
25/// Context for error recovery during parsing
26#[derive(Clone)]
27pub struct ErrorRecoveryContext {
28    /// The original text being parsed
29    index: LineIndex<String>,
30    /// Current position in the text
31    position: usize,
32    /// Stack of recovery points for nested structures
33    recovery_stack: Vec<RecoveryPoint>,
34}
35
36/// A recovery point in the parse
37#[derive(Debug, Clone)]
38pub struct RecoveryPoint {
39    /// The context we're in
40    pub context: ParseContext,
41    /// Position where this context started
42    pub start_position: usize,
43    /// Line where this context started
44    pub start_line: usize,
45    /// Column where this context started
46    pub start_column: usize,
47}
48
49/// The parsing context for error recovery
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ParseContext {
52    /// Top level document
53    Document,
54    /// Inside a mapping
55    Mapping,
56    /// Inside a sequence
57    Sequence,
58    /// Inside a flow sequence
59    FlowSequence,
60    /// Inside a flow mapping
61    FlowMapping,
62    /// Inside a block scalar
63    BlockScalar,
64    /// Inside a quoted string
65    QuotedString,
66}
67
68impl ErrorRecoveryContext {
69    /// Create a new error recovery context
70    pub fn new(text: String) -> Self {
71        Self {
72            index: LineIndex::new(text),
73            position: 0,
74            recovery_stack: vec![RecoveryPoint {
75                context: ParseContext::Document,
76                start_position: 0,
77                start_line: 1,
78                start_column: 1,
79            }],
80        }
81    }
82
83    /// Move the current position forward by `bytes`
84    pub fn advance(&mut self, bytes: usize) {
85        self.position = (self.position + bytes).min(self.index.text().len());
86    }
87
88    /// Get current line and column (both 1-based, the column counting
89    /// characters)
90    pub fn current_location(&self) -> (usize, usize) {
91        let lc = self.index.line_column(self.position);
92        (lc.line, lc.column)
93    }
94
95    /// Get a text range for the current position
96    pub fn current_range(&self, length: usize) -> TextRange {
97        let start = TextSize::from(self.position as u32);
98        let end = TextSize::from((self.position + length) as u32);
99        TextRange::new(start, end)
100    }
101
102    /// Push a new parsing context
103    pub fn push_context(&mut self, context: ParseContext) {
104        let (start_line, start_column) = self.current_location();
105        self.recovery_stack.push(RecoveryPoint {
106            context,
107            start_position: self.position,
108            start_line,
109            start_column,
110        });
111    }
112
113    /// Pop the current parsing context
114    pub fn pop_context(&mut self) {
115        if self.recovery_stack.len() > 1 {
116            self.recovery_stack.pop();
117        }
118    }
119
120    /// Get the current parsing context
121    pub fn current_context(&self) -> ParseContext {
122        self.recovery_stack
123            .last()
124            .map_or(ParseContext::Document, |r| r.context)
125    }
126
127    /// Create a positioned error with current location
128    pub fn create_error(
129        &self,
130        message: String,
131        length: usize,
132        kind: crate::ParseErrorKind,
133    ) -> PositionedParseError {
134        let range = self.current_range(length);
135
136        PositionedParseError {
137            message,
138            range: range.into(),
139            code: None,
140            kind,
141        }
142    }
143
144    /// Determine the best recovery strategy for the current error
145    pub fn suggest_recovery(
146        &self,
147        expected: SyntaxKind,
148        found: Option<SyntaxKind>,
149    ) -> RecoveryStrategy {
150        match self.current_context() {
151            ParseContext::FlowSequence => {
152                // In flow sequence, recover to comma or closing bracket
153                match expected {
154                    SyntaxKind::RIGHT_BRACKET => {
155                        // Missing closing bracket - insert it synthetically
156                        RecoveryStrategy::InsertToken(SyntaxKind::RIGHT_BRACKET)
157                    }
158                    _ => match found {
159                        Some(SyntaxKind::COMMA | SyntaxKind::RIGHT_BRACKET) => {
160                            RecoveryStrategy::SkipToken
161                        }
162                        _ => {
163                            // Don't use SkipUntil for brackets that might not exist
164                            // Skip to next safe point instead
165                            RecoveryStrategy::SkipToEndOfLine
166                        }
167                    },
168                }
169            }
170            ParseContext::FlowMapping => {
171                // In flow mapping, recover to comma or closing brace
172                match expected {
173                    SyntaxKind::RIGHT_BRACE => {
174                        // Missing closing brace - insert it synthetically
175                        RecoveryStrategy::InsertToken(SyntaxKind::RIGHT_BRACE)
176                    }
177                    _ => match found {
178                        Some(SyntaxKind::COMMA | SyntaxKind::RIGHT_BRACE) => {
179                            RecoveryStrategy::SkipToken
180                        }
181                        _ => {
182                            // Don't use SkipUntil for braces that might not exist
183                            // Skip to next safe point instead
184                            RecoveryStrategy::SkipToEndOfLine
185                        }
186                    },
187                }
188            }
189            ParseContext::Mapping => {
190                // In mapping, skip to next key or end of mapping
191                match expected {
192                    SyntaxKind::COLON => {
193                        // Missing colon after key, try to insert it
194                        RecoveryStrategy::InsertToken(SyntaxKind::COLON)
195                    }
196                    _ => RecoveryStrategy::SkipToEndOfLine,
197                }
198            }
199            ParseContext::Sequence => {
200                // In sequence, skip to next item
201                RecoveryStrategy::SkipToEndOfLine
202            }
203            ParseContext::QuotedString => {
204                // In quoted string, look for closing quote
205                match expected {
206                    SyntaxKind::QUOTE | SyntaxKind::SINGLE_QUOTE => {
207                        RecoveryStrategy::InsertToken(expected)
208                    }
209                    _ => RecoveryStrategy::SkipToken,
210                }
211            }
212            ParseContext::BlockScalar => {
213                // In block scalar, sync to dedent
214                RecoveryStrategy::SyncToSafePoint
215            }
216            ParseContext::Document => {
217                // At document level, skip to next document marker or directive
218                RecoveryStrategy::SyncToSafePoint
219            }
220        }
221    }
222
223    /// Whether a token of `kind` is a safe point to resume parsing at
224    pub(crate) fn is_sync_point(&self, kind: SyntaxKind) -> bool {
225        match self.current_context() {
226            ParseContext::Document => matches!(
227                kind,
228                SyntaxKind::DOC_START | SyntaxKind::DOC_END | SyntaxKind::DIRECTIVE
229            ),
230            ParseContext::Mapping | ParseContext::Sequence => {
231                matches!(kind, SyntaxKind::DASH | SyntaxKind::NEWLINE)
232            }
233            ParseContext::FlowSequence => {
234                matches!(kind, SyntaxKind::RIGHT_BRACKET | SyntaxKind::COMMA)
235            }
236            ParseContext::FlowMapping => {
237                matches!(kind, SyntaxKind::RIGHT_BRACE | SyntaxKind::COMMA)
238            }
239            _ => kind == SyntaxKind::NEWLINE,
240        }
241    }
242
243    /// Find the next safe synchronization point
244    pub fn find_sync_point(&self, tokens: &[(SyntaxKind, String)], current: usize) -> usize {
245        tokens[current..]
246            .iter()
247            .position(|(kind, _)| self.is_sync_point(*kind))
248            .map_or(tokens.len(), |i| current + i)
249    }
250
251    /// Get context information for error messages
252    pub fn get_context_snippet(&self, range: TextRange) -> String {
253        let start = range.start().into();
254        let end = range.end().into();
255
256        let text = self.index.text();
257        let line_start = self.index.line_start(start);
258        let line_end = text[end..]
259            .find(['\n', '\r'])
260            .map_or(text.len(), |i| end + i);
261
262        let line = &text[line_start..line_end];
263        let error_start = self.index.column(start);
264        let error_len = text[start..end.min(line_end)].chars().count();
265        let indicator = format!(
266            "{}{}",
267            " ".repeat(error_start),
268            "^".repeat(error_len.max(1))
269        );
270
271        format!("{line}\n{indicator}")
272    }
273}
274
275/// Enhanced error builder for creating detailed error messages
276pub struct ErrorBuilder {
277    message: String,
278    expected: Vec<String>,
279    found: Option<String>,
280    context: Option<String>,
281    suggestion: Option<String>,
282}
283
284impl ErrorBuilder {
285    /// Create a new error builder
286    pub fn new(message: impl Into<String>) -> Self {
287        Self {
288            message: message.into(),
289            expected: Vec::new(),
290            found: None,
291            context: None,
292            suggestion: None,
293        }
294    }
295
296    /// Add what was expected
297    pub fn expected(mut self, expected: impl Into<String>) -> Self {
298        self.expected.push(expected.into());
299        self
300    }
301
302    /// Add what was found instead
303    pub fn found(mut self, found: impl Into<String>) -> Self {
304        self.found = Some(found.into());
305        self
306    }
307
308    /// Add context information
309    pub fn context(mut self, context: impl Into<String>) -> Self {
310        self.context = Some(context.into());
311        self
312    }
313
314    /// Add a suggestion for fixing the error
315    pub fn suggestion(mut self, suggestion: impl Into<String>) -> Self {
316        self.suggestion = Some(suggestion.into());
317        self
318    }
319
320    /// Build the final error message
321    pub fn build(self) -> String {
322        let mut parts = vec![self.message];
323
324        if !self.expected.is_empty() {
325            parts.push(format!("Expected: {}", self.expected.join(" or ")));
326        }
327
328        if let Some(found) = self.found {
329            parts.push(format!("Found: {found}"));
330        }
331
332        if let Some(context) = self.context {
333            parts.push(format!("Context: {context}"));
334        }
335
336        if let Some(suggestion) = self.suggestion {
337            parts.push(format!("Suggestion: {suggestion}"));
338        }
339
340        parts.join(". ")
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_error_recovery_context() {
350        let mut ctx = ErrorRecoveryContext::new("foo: bar\nbaz: qux".to_string());
351
352        assert_eq!(ctx.current_location(), (1, 1));
353
354        ctx.advance(4); // "foo:"
355        assert_eq!(ctx.current_location(), (1, 5));
356
357        ctx.advance(5); // " bar\n"
358        assert_eq!(ctx.current_location(), (2, 1));
359    }
360
361    #[test]
362    fn test_error_builder() {
363        let error = ErrorBuilder::new("Syntax error")
364            .expected("colon")
365            .found("newline")
366            .context("in mapping")
367            .suggestion("add ':' after key")
368            .build();
369
370        assert_eq!(
371            error,
372            "Syntax error. Expected: colon. Found: newline. Context: in mapping. Suggestion: add ':' after key"
373        );
374    }
375
376    #[test]
377    fn test_context_snippet() {
378        let ctx = ErrorRecoveryContext::new("foo: bar\nbaz qux\nend".to_string());
379        let range = TextRange::new(TextSize::from(13), TextSize::from(16)); // "qux"
380
381        let snippet = ctx.get_context_snippet(range);
382        assert_eq!(snippet, "baz qux\n    ^^^");
383    }
384
385    #[test]
386    fn test_context_snippet_cr_line_breaks() {
387        let ctx = ErrorRecoveryContext::new("foo: bar\rbaz qux\r\nend".to_string());
388        let range = TextRange::new(TextSize::from(13), TextSize::from(16)); // "qux"
389
390        let snippet = ctx.get_context_snippet(range);
391        assert_eq!(snippet, "baz qux\n    ^^^");
392    }
393
394    #[test]
395    fn test_context_snippet_counts_chars() {
396        let ctx = ErrorRecoveryContext::new("k: \u{e9}\u{e9} bad\n".to_string());
397        let snippets = [(3, 7), (8, 11)].map(|(start, end)| {
398            ctx.get_context_snippet(TextRange::new(TextSize::from(start), TextSize::from(end)))
399        });
400        assert_eq!(
401            snippets,
402            [
403                "k: \u{e9}\u{e9} bad\n   ^^",
404                "k: \u{e9}\u{e9} bad\n      ^^^"
405            ]
406        );
407    }
408
409    #[test]
410    fn test_current_location_line_breaks_and_bom() {
411        let mut ctx = ErrorRecoveryContext::new("\u{FEFF}a\rb\r\nc".to_string());
412        ctx.advance(3);
413        let mut locations = vec![ctx.current_location()];
414        for _ in 0..6 {
415            ctx.advance(1);
416            locations.push(ctx.current_location());
417        }
418        assert_eq!(
419            locations,
420            vec![(1, 1), (1, 2), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2)]
421        );
422    }
423
424    #[test]
425    fn test_recovery_strategy() {
426        let ctx = ErrorRecoveryContext::new("test".to_string());
427
428        // Test flow sequence recovery
429        let mut ctx_flow = ctx;
430        ctx_flow.push_context(ParseContext::FlowSequence);
431        let strategy = ctx_flow.suggest_recovery(SyntaxKind::COMMA, Some(SyntaxKind::COLON));
432        assert_eq!(strategy, RecoveryStrategy::SkipToEndOfLine);
433
434        // Test mapping colon recovery
435        let mut ctx_map = ErrorRecoveryContext::new("test".to_string());
436        ctx_map.push_context(ParseContext::Mapping);
437        let strategy = ctx_map.suggest_recovery(SyntaxKind::COLON, Some(SyntaxKind::NEWLINE));
438        assert_eq!(strategy, RecoveryStrategy::InsertToken(SyntaxKind::COLON));
439    }
440}