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