noyalib/recovery.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Error-recovering YAML parser for LSP / IDE partial parsing.
5//!
6//! The default `from_str` family returns `Err` at the first
7//! syntax violation. Language Server Protocol implementations
8//! need the opposite contract: keep going past errors, build a
9//! best-effort partial tree, and collect every error encountered
10//! so the editor can show a complete diagnostics list and offer
11//! autocomplete on the recoverable subtrees.
12//!
13//! `parse_lenient` is that contract:
14//!
15//! * Top-level `---` document boundaries are scanned first; each
16//! document is parsed independently so one broken document
17//! never prevents the others from being recovered.
18//! * Within a single document, if the strict pass fails, the
19//! recoverer retries with [`DuplicateKeyPolicy::Last`] — the
20//! most common LSP-time error mode (a user typing a new key
21//! while an old one is still on screen). Successful recovery
22//! yields the post-retry value plus the original error in the
23//! error list, so the editor still flags the duplicate.
24//! * If that retry also fails, the recoverer performs **line
25//! truncation recovery**: drop trailing lines one by one and
26//! re-parse until either a parse succeeds or the input is
27//! exhausted. The successful prefix becomes the recovered
28//! value; everything past the truncation point is summarised
29//! as a synthetic [`Value::Null`].
30//! * A configurable error cap (`LenientConfig::max_errors`)
31//! stops further recovery once enough diagnostics have been
32//! collected — useful when the document is so malformed that
33//! every line errors.
34//!
35//! Gated behind the `recovery` Cargo feature.
36//!
37//! # Output shape
38//!
39//! For multi-document input the result's `ParseResult::value`
40//! is a `Value::Sequence` of per-document values (recovered or
41//! `Null`) — this matches what an LSP would walk to label
42//! per-document diagnostics. For single-document input the
43//! result's `value` is the recovered document directly (not
44//! wrapped in a sequence).
45//!
46//! # Example
47//!
48//! ```
49//! # #[cfg(feature = "recovery")] {
50//! let yaml = "a: 1\nb: [unclosed\nc: 3\n";
51//! let result = noyalib::recovery::parse_lenient(yaml);
52//! assert!(!result.is_complete);
53//! assert!(!result.errors.is_empty());
54//! // `value` is the best-effort tree the recoverer salvaged.
55//! # }
56//! ```
57
58use crate::de::{DuplicateKeyPolicy, ParserConfig, from_str_with_config};
59use crate::error::Error;
60use crate::value::Value;
61
62/// Result of an error-recovering parse pass.
63///
64/// `value` is the best-effort tree the recoverer was able to
65/// salvage from the input. `errors` lists every error the
66/// recoverer encountered, in the order it found them.
67/// `is_complete` is `true` when no errors were collected — the
68/// input parsed cleanly on the first attempt.
69#[derive(Debug)]
70#[non_exhaustive]
71pub struct ParseResult {
72 /// Best-effort recovered value. For multi-document input
73 /// this is a [`Value::Sequence`] of per-document values
74 /// (recovered or [`Value::Null`]); for single-document input
75 /// it is the recovered document directly.
76 pub value: Value,
77 /// Every error the recoverer encountered, in source order.
78 pub errors: Vec<Error>,
79 /// `true` when no errors were collected.
80 pub is_complete: bool,
81}
82
83/// Knobs for the recovery passes.
84///
85/// Constructed via [`LenientConfig::default`]; tweak fields
86/// inline. The struct is intentionally not marked
87/// `#[non_exhaustive]` so callers can use struct-literal syntax
88/// like `LenientConfig { max_errors: 50, ..Default::default() }`.
89/// Adding a field is a semver-minor breaking change pre-1.0.
90#[derive(Debug, Clone)]
91pub struct LenientConfig {
92 /// Stop collecting diagnostics once this many errors have
93 /// been recorded. Defaults to `100`.
94 pub max_errors: usize,
95 /// When the strict parse fails, retry with
96 /// [`DuplicateKeyPolicy::Last`] before giving up. Defaults to
97 /// `true`.
98 pub recover_duplicate_keys: bool,
99 /// When both the strict and duplicate-key retries fail,
100 /// drop trailing lines one by one and re-parse. Defaults to
101 /// `true`.
102 pub line_truncation: bool,
103 /// Base parser configuration. Defaults to
104 /// [`ParserConfig::default`].
105 pub base_config: ParserConfig,
106 /// Cumulative byte budget for line-truncation retries
107 /// across one document. Each retry costs `prefix.len()`
108 /// from this budget; when the next candidate prefix would
109 /// exceed it, the recoverer stops salvaging and returns
110 /// `Null`. Defaults to `1 MiB`, enough to retry a few
111 /// hundred candidates on a typical LSP-edit buffer while
112 /// bounding worst-case CPU on adversarial input.
113 pub truncation_event_budget: usize,
114}
115
116impl Default for LenientConfig {
117 fn default() -> Self {
118 Self {
119 max_errors: 100,
120 recover_duplicate_keys: true,
121 line_truncation: true,
122 base_config: ParserConfig::default(),
123 truncation_event_budget: 1024 * 1024,
124 }
125 }
126}
127
128/// Parse `input` with full error recovery.
129///
130/// Equivalent to [`parse_lenient_with`] with
131/// [`LenientConfig::default`].
132#[must_use]
133pub fn parse_lenient(input: &str) -> ParseResult {
134 parse_lenient_with(input, &LenientConfig::default())
135}
136
137/// Parse `input` with caller-supplied recovery knobs.
138///
139/// See the [module docs](self) for the recovery strategy.
140///
141/// Strips a leading UTF-8 BOM (`U+FEFF`) — Windows editors emit
142/// one by default and recovery is the one entry point callers
143/// expect to absorb it.
144///
145/// Hostile `---`-spam inputs are rejected at
146/// [`ParserConfig::max_documents`]: the boundary scanner probes
147/// one document beyond the cap instead of returning a truncated stream.
148/// Per-document parsing then re-enforces every other
149/// `ParserConfig` limit (`max_depth`, `max_events`,
150/// `max_document_length`, …).
151#[must_use]
152pub fn parse_lenient_with(input: &str, config: &LenientConfig) -> ParseResult {
153 // C5 — strip a leading BOM so Windows-saved buffers parse
154 // identically to the LF-on-Linux equivalent.
155 let bom_skip = crate::doc_boundary::strip_bom(input.as_bytes());
156 let input = &input[bom_skip..];
157
158 if input.len() > config.base_config.max_stream_bytes {
159 return ParseResult {
160 value: Value::Null,
161 errors: vec![Error::Parse(format!(
162 "stream exceeds max_stream_bytes of {} bytes",
163 config.base_config.max_stream_bytes
164 ))],
165 is_complete: false,
166 };
167 }
168 if let Err(error) =
169 crate::doc_boundary::validate_document_budget(input, config.base_config.max_documents)
170 {
171 return ParseResult {
172 value: Value::Null,
173 errors: vec![error],
174 is_complete: false,
175 };
176 }
177
178 // The strict pass of every document charges one stream-wide tally,
179 // so the stream budgets are not reset per document; the salvage
180 // passes stay per document, or retrying would charge it twice.
181 let strict = crate::parser::meter::StreamTally::share(&config.base_config);
182 let mut docs =
183 crate::doc_boundary::DocumentStream::new(input, config.base_config.max_documents);
184 let first = match docs.next() {
185 Some(Ok(document)) => document,
186 Some(Err(error)) => {
187 return ParseResult {
188 value: Value::Null,
189 errors: vec![error],
190 is_complete: false,
191 };
192 }
193 None => {
194 return ParseResult {
195 value: Value::Null,
196 errors: Vec::new(),
197 is_complete: true,
198 };
199 }
200 };
201 let second = match docs.next() {
202 Some(Ok(document)) => document,
203 Some(Err(error)) => {
204 return ParseResult {
205 value: Value::Null,
206 errors: vec![error],
207 is_complete: false,
208 };
209 }
210 None => {
211 let (value, errors) = recover_one(first, config, &strict, config.max_errors);
212 let is_complete = errors.is_empty();
213 return ParseResult {
214 value,
215 errors,
216 is_complete,
217 };
218 }
219 };
220
221 let initial = [Ok(first), Ok(second)];
222 let mut values: Vec<Value> = Vec::with_capacity(2);
223 let mut errors: Vec<Error> = Vec::new();
224 let mut budget = config.max_errors;
225 // M2 — preserve per-document index alignment for LSP
226 // diagnostic joiners by pushing `Null` for every
227 // document we skip after the budget runs out.
228 let mut budget_exhausted = false;
229 for document in initial.into_iter().chain(docs) {
230 let doc = match document {
231 Ok(document) => document,
232 Err(error) => {
233 errors.push(error);
234 values.push(Value::Null);
235 break;
236 }
237 };
238 if budget_exhausted {
239 values.push(Value::Null);
240 continue;
241 }
242 let (value, doc_errors) = recover_one(doc, config, &strict, budget);
243 budget = budget.saturating_sub(doc_errors.len());
244 errors.extend(doc_errors);
245 values.push(value);
246 if budget == 0 {
247 budget_exhausted = true;
248 }
249 }
250 let is_complete = errors.is_empty();
251 ParseResult {
252 value: Value::Sequence(values),
253 errors,
254 is_complete,
255 }
256}
257
258/// Recover a single document.
259///
260/// Returns the best-effort `Value` and the list of errors
261/// encountered (every pass that emitted an `Err` contributes one
262/// entry). Bounded by `budget` — once exhausted, the recoverer
263/// returns whatever it has and stops.
264fn recover_one(
265 input: &str,
266 config: &LenientConfig,
267 strict: &ParserConfig,
268 budget: usize,
269) -> (Value, Vec<Error>) {
270 if budget == 0 {
271 return (Value::Null, Vec::new());
272 }
273
274 // Pass 1: strict, under the stream-wide tally.
275 let strict_err = match from_str_with_config::<Value>(input, strict) {
276 Ok(v) => return (v, Vec::new()),
277 Err(e) => e,
278 };
279 let mut errors = vec![strict_err];
280
281 // Pass 2: duplicate-key recovery via DuplicateKeyPolicy::Last.
282 //
283 // M13 — clone the base config exactly once per `recover_one`
284 // so per-document hot paths on LSP keystrokes don't pay
285 // the per-pass clone tax.
286 let mut tweaked_cfg: Option<ParserConfig> = None;
287 if config.recover_duplicate_keys
288 && config.base_config.duplicate_key_policy != DuplicateKeyPolicy::Last
289 && errors.len() < budget
290 {
291 let cfg2 = tweaked_cfg.insert({
292 let mut c = config.base_config.clone();
293 c.duplicate_key_policy = DuplicateKeyPolicy::Last;
294 c
295 });
296 match from_str_with_config::<Value>(input, cfg2) {
297 Ok(v) => return (v, errors),
298 // M1 — collect the Pass-2 error too so the editor
299 // sees every diagnostic, not just the first.
300 Err(e) => errors.push(e),
301 }
302 }
303
304 // Pass 3: line-truncation recovery, bounded by the per-document
305 // event budget so an adversarial 10k-line input cannot drive
306 // O(N×max_events) re-parses (security finding C1).
307 if config.line_truncation && errors.len() < budget {
308 let pass3_cfg = tweaked_cfg.as_ref().unwrap_or(&config.base_config);
309 match try_line_truncation(input, pass3_cfg, config.truncation_event_budget) {
310 TruncationOutcome::Recovered(v) => return (v, errors),
311 // M1 — collect the final truncation-failure error so
312 // the editor can show what went wrong after
313 // every salvage attempt was exhausted.
314 TruncationOutcome::Exhausted(Some(e)) if errors.len() < budget => errors.push(e),
315 TruncationOutcome::Exhausted(_) => {}
316 }
317 }
318
319 (Value::Null, errors)
320}
321
322/// Result of the line-truncation pass.
323enum TruncationOutcome {
324 /// A truncated prefix parsed cleanly.
325 Recovered(Value),
326 /// No prefix parsed; the final attempt's error is carried back
327 /// so [`recover_one`] can surface it as a diagnostic.
328 Exhausted(Option<Error>),
329}
330
331/// Drop trailing lines one at a time, retrying the parse, until
332/// a prefix succeeds, the cumulative parser-event budget is
333/// exhausted, or no candidate prefixes remain.
334///
335/// `event_budget` caps how many bytes the recovery loop may
336/// re-feed into the parser **in total**. Each attempted prefix
337/// costs `prefix.len()` from the budget; this turns a hostile
338/// 10k-line input from O(N×input_len) into bounded work without
339/// regressing recovery quality on realistic LSP-edit inputs.
340///
341/// Honours M3 — the buffer end itself is a candidate cut so a
342/// malformed last line without a trailing newline (the universal
343/// mid-typing case) is still tried.
344fn try_line_truncation(
345 input: &str,
346 config: &ParserConfig,
347 event_budget: usize,
348) -> TruncationOutcome {
349 // Collect line boundaries; the buffer end is a synthetic
350 // candidate so the no-trailing-newline case is exercised.
351 let mut boundaries: Vec<usize> = Vec::new();
352 for (i, b) in input.as_bytes().iter().enumerate() {
353 if *b == b'\n' {
354 boundaries.push(i);
355 }
356 }
357 if boundaries.last().copied() != Some(input.len()) {
358 boundaries.push(input.len());
359 }
360
361 let mut budget_remaining = event_budget;
362 let mut last_err: Option<Error> = None;
363 for &cut in boundaries.iter().rev() {
364 let candidate = &input[..cut];
365 if candidate.trim().is_empty() {
366 continue;
367 }
368 // Budget gate: re-parsing `candidate.len()` bytes costs
369 // proportionally; saturating-sub avoids panic on overflow.
370 let cost = candidate.len();
371 if cost > budget_remaining {
372 break;
373 }
374 budget_remaining = budget_remaining.saturating_sub(cost);
375 match from_str_with_config::<Value>(candidate, config) {
376 Ok(v) => return TruncationOutcome::Recovered(v),
377 Err(e) => last_err = Some(e),
378 }
379 }
380 TruncationOutcome::Exhausted(last_err)
381}
382
383/// Split `input` on top-level YAML `---` document markers.
384///
385/// Thin wrapper around [`crate::doc_boundary::split_documents`]
386/// that bounds the marker cap by [`crate::de::ParserConfig::max_documents`].
387///
388/// Hostile `---`-spam inputs cannot drive unbounded `Vec`
389/// growth because the underlying scanner stops after
390/// `max_markers` boundaries.
391#[cfg(test)]
392fn split_documents<'a>(input: &'a str, config: &ParserConfig) -> crate::Result<Vec<&'a str>> {
393 crate::doc_boundary::split_documents_checked(input, config.max_documents)
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn valid_input_is_complete() {
402 let r = parse_lenient("a: 1\nb: 2\n");
403 assert!(r.is_complete);
404 assert!(r.errors.is_empty(), "expected empty, got {:?}", r.errors);
405 let m = r.value.as_mapping().unwrap();
406 assert!(m.contains_key("a"));
407 assert!(m.contains_key("b"));
408 }
409
410 #[test]
411 fn empty_input_is_complete() {
412 let r = parse_lenient("");
413 assert!(r.is_complete);
414 assert!(r.errors.is_empty(), "expected empty, got {:?}", r.errors);
415 assert!(matches!(r.value, Value::Null));
416 }
417
418 #[test]
419 fn duplicate_key_is_recovered() {
420 // The recovery pass kicks in only when the base config
421 // is strict about duplicate keys; the workspace default
422 // (DuplicateKeyPolicy::Last) silently accepts them.
423 let cfg = LenientConfig {
424 base_config: ParserConfig::default().duplicate_key_policy(DuplicateKeyPolicy::Error),
425 ..LenientConfig::default()
426 };
427 let r = parse_lenient_with("a: 1\na: 2\n", &cfg);
428 assert!(!r.is_complete);
429 assert_eq!(r.errors.len(), 1);
430 let m = r.value.as_mapping().unwrap();
431 let v = m.get("a").unwrap();
432 assert_eq!(v.as_i64(), Some(2));
433 }
434
435 #[test]
436 fn unrecoverable_input_yields_null_with_errors() {
437 // `[` opens a flow sequence that never closes — no
438 // truncation makes this valid.
439 let r = parse_lenient("[\n");
440 assert!(!r.is_complete);
441 assert!(!r.errors.is_empty(), "expected a non-empty value");
442 }
443
444 #[test]
445 fn line_truncation_recovers_trailing_garbage() {
446 // First two lines parse; third line is malformed flow.
447 let r = parse_lenient("a: 1\nb: 2\nc: [unclosed\n");
448 assert!(!r.is_complete);
449 // The recoverer should salvage at least the strict-error
450 // for the malformed third line.
451 assert!(!r.errors.is_empty(), "expected a non-empty value");
452 // Best-effort tree: should contain `a` (and may contain `b`).
453 if let Value::Mapping(m) = &r.value {
454 assert!(m.contains_key("a"));
455 }
456 }
457
458 #[test]
459 fn multi_doc_recovers_each_independently() {
460 let yaml = "---\na: 1\n---\nb: [unclosed\n---\nc: 3\n";
461 let r = parse_lenient(yaml);
462 assert!(!r.is_complete);
463 let seq = match &r.value {
464 Value::Sequence(s) => s,
465 _ => panic!("expected sequence for multi-doc input"),
466 };
467 assert_eq!(seq.len(), 3);
468 // Docs 0 and 2 should recover; doc 1 is the bad one.
469 assert!(matches!(&seq[0], Value::Mapping(_)));
470 assert!(matches!(&seq[2], Value::Mapping(_)));
471 }
472
473 #[test]
474 fn max_errors_caps_collection() {
475 let cfg = LenientConfig {
476 max_errors: 1,
477 ..LenientConfig::default()
478 };
479 let yaml = "---\na: [bad\n---\nb: [bad\n---\nc: [bad\n";
480 let r = parse_lenient_with(yaml, &cfg);
481 assert!(r.errors.len() <= 1);
482 }
483
484 #[test]
485 fn split_documents_handles_single() {
486 let d = split_documents("a: 1\n", &ParserConfig::default()).unwrap();
487 assert_eq!(d.len(), 1);
488 }
489
490 #[test]
491 fn split_documents_handles_empty() {
492 let cfg = ParserConfig::default();
493 let docs = split_documents("", &cfg).unwrap();
494 assert!(docs.is_empty(), "expected empty, got {docs:?}");
495 assert!(
496 split_documents(" \n", &cfg).unwrap().is_empty(),
497 "expected empty, got {:?}",
498 split_documents(" \n", &cfg).unwrap()
499 );
500 }
501
502 #[test]
503 fn recover_disabled_passes_just_collect_errors() {
504 // With both recovery passes disabled, an invalid input
505 // should still produce Null + the strict error — exercises
506 // the "fall through every pass" branch in recover_one.
507 let cfg = LenientConfig {
508 recover_duplicate_keys: false,
509 line_truncation: false,
510 ..LenientConfig::default()
511 };
512 let r = parse_lenient_with("[unclosed", &cfg);
513 assert!(!r.is_complete);
514 assert_eq!(r.errors.len(), 1);
515 assert!(matches!(r.value, Value::Null));
516 }
517
518 #[test]
519 fn line_truncation_disabled_skips_third_pass() {
520 let cfg = LenientConfig {
521 line_truncation: false,
522 ..LenientConfig::default()
523 };
524 let r = parse_lenient_with("a: 1\nb: [bad\n", &cfg);
525 assert!(!r.is_complete);
526 // With truncation off, no salvage attempt — value stays Null.
527 assert!(matches!(r.value, Value::Null));
528 }
529
530 #[test]
531 fn config_is_debug_and_clone() {
532 // Cheap reflection — keeps Debug + Clone derives covered.
533 let cfg = LenientConfig::default();
534 let _printed = format!("{cfg:?}");
535 let cloned = cfg.clone();
536 assert_eq!(cloned.max_errors, cfg.max_errors);
537 }
538
539 #[test]
540 fn parse_result_is_debug() {
541 let r = parse_lenient("a: 1\n");
542 let _printed = format!("{r:?}");
543 }
544
545 #[test]
546 fn split_documents_handles_implicit_first_doc() {
547 // Content before the first `---` is an implicit doc.
548 let d = split_documents("name: pre\n---\nname: post\n", &ParserConfig::default()).unwrap();
549 assert_eq!(d.len(), 2);
550 }
551
552 #[test]
553 fn split_documents_ignores_mid_line_dashes() {
554 // `---` mid-line is not a document marker.
555 let d = split_documents("a: ---\nb: 2\n", &ParserConfig::default()).unwrap();
556 assert_eq!(d.len(), 1);
557 }
558
559 #[test]
560 fn crlf_input_recovers_cleanly() {
561 // Windows-saved buffer with `\r\n` line endings.
562 let r = parse_lenient("a: 1\r\nb: 2\r\n");
563 assert!(r.is_complete);
564 if let Value::Mapping(m) = &r.value {
565 assert!(m.contains_key("a"));
566 assert!(m.contains_key("b"));
567 } else {
568 panic!("expected mapping for CRLF input, got {:?}", r.value);
569 }
570 }
571
572 #[test]
573 fn bom_prefix_is_stripped() {
574 let r = parse_lenient("\u{FEFF}a: 1\nb: 2\n");
575 assert!(r.is_complete);
576 if let Value::Mapping(m) = &r.value {
577 assert!(m.contains_key("a"));
578 } else {
579 panic!("BOM-prefixed input should parse cleanly");
580 }
581 }
582
583 #[test]
584 fn marker_spam_is_bounded() {
585 // 10k `---\n` markers in a row must produce one bounded
586 // budget diagnostic, never a silently truncated result.
587 let yaml = "---\n".repeat(10_000);
588 let r = parse_lenient(&yaml);
589 assert!(matches!(r.value, Value::Null));
590 assert!(matches!(
591 r.errors.as_slice(),
592 [Error::Budget(crate::BudgetBreach::MaxDocuments {
593 limit: 1000,
594 ..
595 })]
596 ));
597 }
598
599 #[test]
600 fn truncation_handles_no_trailing_newline() {
601 // M3 — the prefix `"a: 1\nb: ["` ends without `\n`; the
602 // fix treats the buffer end as a truncation
603 // candidate so the prefix `"a: 1\n"` is salvaged.
604 let r = parse_lenient("a: 1\nb: [bad");
605 if let Value::Mapping(m) = &r.value {
606 assert_eq!(m.get("a").and_then(|v| v.as_i64()), Some(1));
607 }
608 }
609
610 #[test]
611 fn budget_exhaustion_preserves_indices() {
612 // M2 — when the budget runs out mid-stream, remaining
613 // docs become Null (not dropped) so per-doc
614 // diagnostic indices still line up.
615 let cfg = LenientConfig {
616 max_errors: 1,
617 ..LenientConfig::default()
618 };
619 let yaml = "---\na: [bad\n---\nb: [bad\n---\nc: [bad\n";
620 let r = parse_lenient_with(yaml, &cfg);
621 if let Value::Sequence(s) = &r.value {
622 assert_eq!(s.len(), 3);
623 } else {
624 panic!("expected sequence with all 3 indices preserved");
625 }
626 }
627
628 #[test]
629 fn truncation_budget_caps_retries() {
630 // C1 — adversarial 10k-line input cannot drive unbounded
631 // re-parses. With a tiny truncation budget the
632 // recoverer gives up early but does not hang.
633 let cfg = LenientConfig {
634 truncation_event_budget: 64,
635 ..LenientConfig::default()
636 };
637 // 10k malformed lines after one valid line.
638 let mut yaml = String::from("a: 1\n");
639 for _ in 0..10_000 {
640 yaml.push_str("[bad\n");
641 }
642 let _r = parse_lenient_with(&yaml, &cfg);
643 // No panic, no hang.
644 }
645}