1use crate::line_index::LineIndex;
9use crate::{lex::SyntaxKind, PositionedParseError};
10use rowan::{TextRange, TextSize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RecoveryStrategy {
15 SkipToken,
17 SkipToEndOfLine,
19 SyncToSafePoint,
21 InsertToken(SyntaxKind),
23}
24
25#[derive(Clone)]
27pub struct ErrorRecoveryContext {
28 index: LineIndex<String>,
30 position: usize,
32 recovery_stack: Vec<RecoveryPoint>,
34}
35
36#[derive(Debug, Clone)]
38pub struct RecoveryPoint {
39 pub context: ParseContext,
41 pub start_position: usize,
43 pub start_line: usize,
45 pub start_column: usize,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ParseContext {
52 Document,
54 Mapping,
56 Sequence,
58 FlowSequence,
60 FlowMapping,
62 BlockScalar,
64 QuotedString,
66}
67
68impl ErrorRecoveryContext {
69 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 pub fn advance(&mut self, bytes: usize) {
85 self.position = (self.position + bytes).min(self.index.text().len());
86 }
87
88 pub fn current_location(&self) -> (usize, usize) {
91 let lc = self.index.line_column(self.position);
92 (lc.line, lc.column)
93 }
94
95 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 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 pub fn pop_context(&mut self) {
115 if self.recovery_stack.len() > 1 {
116 self.recovery_stack.pop();
117 }
118 }
119
120 pub fn current_context(&self) -> ParseContext {
122 self.recovery_stack
123 .last()
124 .map_or(ParseContext::Document, |r| r.context)
125 }
126
127 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 pub fn suggest_recovery(
146 &self,
147 expected: SyntaxKind,
148 found: Option<SyntaxKind>,
149 ) -> RecoveryStrategy {
150 match self.current_context() {
151 ParseContext::FlowSequence => {
152 match expected {
154 SyntaxKind::RIGHT_BRACKET => {
155 RecoveryStrategy::InsertToken(SyntaxKind::RIGHT_BRACKET)
157 }
158 _ => match found {
159 Some(SyntaxKind::COMMA | SyntaxKind::RIGHT_BRACKET) => {
160 RecoveryStrategy::SkipToken
161 }
162 _ => {
163 RecoveryStrategy::SkipToEndOfLine
166 }
167 },
168 }
169 }
170 ParseContext::FlowMapping => {
171 match expected {
173 SyntaxKind::RIGHT_BRACE => {
174 RecoveryStrategy::InsertToken(SyntaxKind::RIGHT_BRACE)
176 }
177 _ => match found {
178 Some(SyntaxKind::COMMA | SyntaxKind::RIGHT_BRACE) => {
179 RecoveryStrategy::SkipToken
180 }
181 _ => {
182 RecoveryStrategy::SkipToEndOfLine
185 }
186 },
187 }
188 }
189 ParseContext::Mapping => {
190 match expected {
192 SyntaxKind::COLON => {
193 RecoveryStrategy::InsertToken(SyntaxKind::COLON)
195 }
196 _ => RecoveryStrategy::SkipToEndOfLine,
197 }
198 }
199 ParseContext::Sequence => {
200 RecoveryStrategy::SkipToEndOfLine
202 }
203 ParseContext::QuotedString => {
204 match expected {
206 SyntaxKind::QUOTE | SyntaxKind::SINGLE_QUOTE => {
207 RecoveryStrategy::InsertToken(expected)
208 }
209 _ => RecoveryStrategy::SkipToken,
210 }
211 }
212 ParseContext::BlockScalar => {
213 RecoveryStrategy::SyncToSafePoint
215 }
216 ParseContext::Document => {
217 RecoveryStrategy::SyncToSafePoint
219 }
220 }
221 }
222
223 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 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 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
275pub 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 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 pub fn expected(mut self, expected: impl Into<String>) -> Self {
298 self.expected.push(expected.into());
299 self
300 }
301
302 pub fn found(mut self, found: impl Into<String>) -> Self {
304 self.found = Some(found.into());
305 self
306 }
307
308 pub fn context(mut self, context: impl Into<String>) -> Self {
310 self.context = Some(context.into());
311 self
312 }
313
314 pub fn suggestion(mut self, suggestion: impl Into<String>) -> Self {
316 self.suggestion = Some(suggestion.into());
317 self
318 }
319
320 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); assert_eq!(ctx.current_location(), (1, 5));
356
357 ctx.advance(5); 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)); 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)); 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 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 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}