Skip to main content

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}