Skip to main content

badness_parser/parser/
reparse.rs

1//! Incremental reparse: splice a small edit into the previous green tree instead
2//! of re-parsing the whole text.
3//!
4//! # Contract
5//!
6//! A successful reparse must produce the same green tree and [`SyntaxError`]
7//! vector as a full parse of the edited text. Incremental reparse is only a
8//! performance optimization; a failed proof falls back to a full parse.
9//!
10//! Guards return [`None`] when they cannot prove equivalence. Extend them by
11//! adding supported cases or conservative bailouts, never by weakening the oracle.
12//!
13//! The previous-parse cache cannot affect the query result. A cold, stale, or
14//! evicted cache only forces a full parse.
15//!
16//! # Design
17//!
18//! The tiers sit strictly **on top of** [`parse_with_declarations_resolved`] and
19//! [`lex_with`]. There is no incremental lexer, no token-stream reuse, no restarting
20//! the grammar at an offset:
21//!
22//! - the token tier relexes one leaf in isolation, proves the relex is a
23//!   single token of the same kind that joins to its neighbours the same way, and
24//!   splices with rowan's [`SyntaxToken::replace_with`], sharing every green node
25//!   off the leaf-to-root path — `O(depth)`, not `O(file)`;
26//! - the protected-body tier splices the same way, but proves it differently: a raw
27//!   capture cannot be relexed alone, so it relexes the leaf's whole enclosing node
28//!   with its delimiters and requires that to reproduce the tree's own tokens;
29//! - the math tier reparses the outermost enclosing delimiter-bearing math node,
30//!   after the token tier declines a change to the virtual-atom partition;
31//! - the region tier re-runs the *ordinary* parser over a substring and splices the
32//!   resulting children under `ROOT`, using neighbour-sized boundary parses purely
33//!   as proofs that the substring is decoupled from its context.
34//!
35//! This avoids checkpointing lexer state, prescan indices, or forward shape-gate
36//! scans. The math and region tiers decline edits whose effects may escape their
37//! fragments.
38
39mod leaf;
40mod math;
41mod protected;
42mod region;
43mod token;
44
45use rowan::GreenNode;
46
47use crate::declarations::ResolvedDeclarations;
48use crate::parser::core::{Parse, SyntaxError, parse_with_declarations_resolved};
49use crate::parser::lexer::{LexConfig, ParseCtx, dtx_has_expl_signal};
50use crate::syntax::SyntaxNode;
51
52pub use crate::parser::edit::{Edit, apply_edits, diff_edit, try_apply_edits};
53
54/// Which tier produced a [`Reparsed`]. Surfaced for tests and benchmarks, which
55/// assert the tier a scenario reaches — a grammar change that silently downgrades
56/// one should fail loudly rather than quietly show up as a slower number.
57///
58/// Ordered cheapest-first, so a chain can report the most expensive tier any of its
59/// steps needed with `max`.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
61pub enum ReparseTier {
62    /// One leaf token was relexed in isolation and spliced in place.
63    Token,
64    /// A protected body (`VERBATIM_BODY`, `VERB`) was relexed with its enclosing
65    /// node's delimiters and spliced in place.
66    Verbatim,
67    /// A delimiter-bearing inline, display, or environment math fragment was
68    /// reparsed and spliced in place.
69    Math,
70    /// A run of top-level children was reparsed and spliced under `ROOT`.
71    Region,
72}
73
74/// A successful incremental reparse: the new whole-file green tree and its errors,
75/// both in the *new* text's offsets.
76#[derive(Debug, Clone)]
77pub struct Reparsed {
78    pub green: GreenNode,
79    pub errors: Vec<SyntaxError>,
80    pub tier: ReparseTier,
81}
82
83/// The previous parse a reparse splices against.
84///
85/// `ctx` is the context the tree was parsed under, from
86/// [`parse_with_declarations_resolved`] — a tier that relexes a fragment must use
87/// the same one, or a `\newcommand` the definition scan found makes the fragment's
88/// tokens disagree with the tree's. `config` and `declared` are the parse's other
89/// two inputs, needed to reproduce it exactly.
90#[derive(Debug, Clone, Copy)]
91pub struct ReparseBase<'a> {
92    pub text: &'a str,
93    pub green: &'a GreenNode,
94    pub errors: &'a [SyntaxError],
95    pub ctx: &'a ParseCtx,
96    pub config: LexConfig,
97    /// The file-level `.dtx` implicit-expl signal (`%<@@=...>` / `\ProvidesExpl*`)
98    /// computed from the full base text.
99    ///
100    /// The lexer derives this before tokenizing; fragment relexes need the same
101    /// regime to be faithful.
102    pub implicit_expl: bool,
103    pub declared: &'a ResolvedDeclarations,
104}
105
106impl<'a> ReparseBase<'a> {
107    pub fn from_parts(
108        text: &'a str,
109        green: &'a GreenNode,
110        errors: &'a [SyntaxError],
111        ctx: &'a ParseCtx,
112        config: LexConfig,
113        declared: &'a ResolvedDeclarations,
114    ) -> Self {
115        Self {
116            text,
117            green,
118            errors,
119            ctx,
120            config,
121            implicit_expl: implicit_expl_for(text, config),
122            declared,
123        }
124    }
125
126    /// Materialize a red-tree cursor over the base. Cheap (an atomic clone).
127    pub fn syntax(&self) -> SyntaxNode {
128        SyntaxNode::new_root(self.green.clone())
129    }
130}
131
132fn implicit_expl_for(text: &str, config: LexConfig) -> bool {
133    config.dtx && dtx_has_expl_signal(text)
134}
135
136/// Attempt an incremental reparse of `base` under `edit`, which transforms
137/// `base.text` into `new_text`. [`None`] means no tier applied and the caller must
138/// do a full parse.
139pub fn reparse(base: &ReparseBase<'_>, edit: &Edit, new_text: &str) -> Option<Reparsed> {
140    // The edit is untrusted: a chain staged against a buffer that has since moved
141    // slices out of bounds, and a panic here takes down an analysis query where a
142    // bail would have cost one parse.
143    if !edit.fits(base.text) {
144        return None;
145    }
146    reparse_one(base, edit, new_text)
147}
148
149/// [`reparse`] for a chain of edits, each expressed against the text its
150/// predecessors produced — the shape an LSP `didChange` batch arrives in.
151///
152/// Replaying the chain is not the same as collapsing it: a diff of scattered edits
153/// spans everything between them, which a cost guard declines outright, while the
154/// chain splices each edit on its own.
155pub fn reparse_edits(base: &ReparseBase<'_>, edits: &[Edit], new_text: &str) -> Option<Reparsed> {
156    if edits.is_empty() {
157        return None;
158    }
159
160    // Verify the chain describes exactly the transform claimed, then replay it. The
161    // fold is deliberately *not* hoisted ahead of the splices as a pre-check: it
162    // costs the same order as the work it would guard, and each step below already
163    // validates against the text its predecessors produced.
164    let mut text = base.text.to_string();
165    let mut green = base.green.clone();
166    let mut errors = base.errors.to_vec();
167    let mut tier: Option<ReparseTier> = None;
168
169    for edit in edits {
170        if !edit.fits(&text) {
171            return None;
172        }
173        let next = edit.apply(&text);
174        let step = {
175            let step_base = ReparseBase::from_parts(
176                &text,
177                &green,
178                &errors,
179                base.ctx,
180                base.config,
181                base.declared,
182            );
183            reparse_one(&step_base, edit, &next)?
184        };
185        text = next;
186        green = step.green;
187        errors = step.errors;
188        tier = Some(tier.map_or(step.tier, |t| t.max(step.tier)));
189    }
190
191    // A stale chain can apply cleanly and still land somewhere other than the
192    // buffer the caller is asking about. Reject it rather than answer for the wrong
193    // text.
194    if text != new_text {
195        return None;
196    }
197
198    Some(Reparsed {
199        green,
200        errors,
201        tier: tier?,
202    })
203}
204
205/// The tier ladder for one already-validated edit, cheapest first.
206///
207/// Each tier lands here as an `.or_else` and returns through [`finish`], so none
208/// can skip the length check or the oracle.
209fn reparse_one(base: &ReparseBase<'_>, edit: &Edit, new_text: &str) -> Option<Reparsed> {
210    token::reparse_token(base, edit, new_text)
211        .or_else(|| protected::reparse_protected(base, edit, new_text))
212        .or_else(|| math::reparse_math(base, edit, new_text))
213        .or_else(|| region::reparse_region(base, edit, new_text))
214}
215
216/// The single exit for every tier.
217///
218/// Routing all of them through one function is deliberate: a tier cannot return a
219/// result without paying the every-build length check and the debug oracle, so
220/// "did the new tier remember to verify?" is not a question a reviewer has to ask.
221///
222/// The length check is the release-build backstop. The oracle below is
223/// `debug_assertions`-only because it costs a full parse, which would defeat the
224/// point in the build that ships — but that is also the build whose formatter
225/// rewrites the user's file, so *something* must hold there. A tree that does not
226/// span exactly its text is the cheap, `O(1)`, always-affordable half of the
227/// invariant, and it catches the whole class of offset-arithmetic bugs a splice can
228/// have. It *falls back* rather than panicking, per the refusal-first contract.
229fn finish(
230    green: GreenNode,
231    errors: Vec<SyntaxError>,
232    tier: ReparseTier,
233    base: &ReparseBase<'_>,
234    new_text: &str,
235) -> Option<Reparsed> {
236    if !spans_its_text(&green, new_text) {
237        return None;
238    }
239    let out = Reparsed {
240        green,
241        errors,
242        tier,
243    };
244    assert_matches_full_parse(&out, base, new_text);
245    Some(out)
246}
247
248/// Whether `green` spans exactly `text`. `O(1)` — rowan stores the width.
249fn spans_its_text(green: &GreenNode, text: &str) -> bool {
250    usize::from(green.text_len()) == text.len()
251}
252
253/// Render every node and token in preorder as `KIND@range "text"`.
254///
255/// Equal fingerprints mean byte-identical trees, and an unequal pair names the
256/// first place they diverge, which a `GreenNode` inequality does not. Public (and
257/// hidden) so the in-crate assert and the external harness share one definition of
258/// "identical" and can never drift apart.
259#[doc(hidden)]
260pub fn fingerprint(node: &SyntaxNode) -> String {
261    use std::fmt::Write as _;
262
263    let mut out = String::new();
264    for element in node.descendants_with_tokens() {
265        match element {
266            rowan::NodeOrToken::Node(n) => {
267                let _ = writeln!(out, "{:?}@{:?}", n.kind(), n.text_range());
268            }
269            rowan::NodeOrToken::Token(t) => {
270                let _ = writeln!(out, "{:?}@{:?} {:?}", t.kind(), t.text_range(), t.text());
271            }
272        }
273    }
274    out
275}
276
277/// Assert the governing invariant on a result about to be returned.
278///
279/// **Every failure here is an incremental-parser bug whose fix is a new
280/// bail-to-full-parse condition, never a relaxation of this assert.** If a tier
281/// produces a tree a full parse would not, the tier does not understand the
282/// construct it just spliced, and the honest repair is to stop claiming it does.
283#[cfg(debug_assertions)]
284fn assert_matches_full_parse(result: &Reparsed, base: &ReparseBase<'_>, new_text: &str) {
285    let full = full_parse(base, new_text);
286    debug_assert_eq!(
287        fingerprint(&SyntaxNode::new_root(result.green.clone())),
288        fingerprint(&full.syntax()),
289        "reparse ({:?}) produced a different tree than a full parse",
290        result.tier,
291    );
292    debug_assert_eq!(
293        result.errors, full.errors,
294        "reparse ({:?}) produced different errors than a full parse",
295        result.tier,
296    );
297}
298
299#[cfg(not(debug_assertions))]
300fn assert_matches_full_parse(_: &Reparsed, _: &ReparseBase<'_>, _: &str) {}
301
302/// The full parse a reparse must agree with, under the base's own inputs.
303#[cfg_attr(not(debug_assertions), allow(dead_code))]
304fn full_parse(base: &ReparseBase<'_>, text: &str) -> Parse {
305    parse_with_declarations_resolved(text, base.config, base.declared).0
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::parser::lexer::LatexFlavor;
312
313    fn base_of(text: &str) -> (Parse, ParseCtx, ResolvedDeclarations) {
314        let declared = ResolvedDeclarations::default();
315        let (parse, ctx) = parse_with_declarations_resolved(text, LatexFlavor::Document, &declared);
316        (parse, ctx, declared)
317    }
318
319    fn with_base<R>(text: &str, f: impl FnOnce(&ReparseBase<'_>) -> R) -> R {
320        let (parse, ctx, declared) = base_of(text);
321        f(&ReparseBase::from_parts(
322            text,
323            &parse.green,
324            &parse.errors,
325            &ctx,
326            LatexFlavor::Document.into(),
327            &declared,
328        ))
329    }
330
331    fn edit(range: std::ops::Range<usize>, insert: &str) -> Edit {
332        Edit {
333            range,
334            insert: insert.to_string(),
335        }
336    }
337
338    #[test]
339    fn an_edit_outside_a_plain_leaf_falls_back() {
340        with_base("\\section{Hi}\n\nbody text\n", |base| {
341            let e = edit(8..8, "x");
342            assert!(reparse(base, &e, &e.apply(base.text)).is_none());
343            let e = edit(7..10, "zz");
344            assert!(reparse(base, &e, &e.apply(base.text)).is_none());
345        });
346    }
347
348    #[test]
349    fn an_edit_that_does_not_fit_the_base_is_refused() {
350        with_base("abc\n", |base| {
351            assert!(reparse(base, &edit(90..99, "x"), "abc\n").is_none());
352            assert!(reparse(base, &edit(1..1, "x"), "abc\n").is_none());
353        });
354        with_base("α\n", |base| {
355            assert!(reparse(base, &edit(1..1, "x"), "αx\n").is_none());
356        });
357    }
358
359    #[test]
360    fn an_empty_chain_is_refused() {
361        with_base("abc\n", |base| {
362            assert!(reparse_edits(base, &[], "abc\n").is_none());
363        });
364    }
365
366    #[test]
367    fn a_chain_that_lands_elsewhere_is_refused() {
368        with_base("abc\n", |base| {
369            assert!(reparse_edits(base, &[edit(0..0, "x")], "totally different").is_none());
370        });
371    }
372
373    #[test]
374    fn spans_its_text_measures_the_green_width() {
375        with_base("\\section{Hi}\n", |base| {
376            assert!(spans_its_text(base.green, base.text));
377            assert!(!spans_its_text(base.green, "\\section{Hi}"));
378            assert!(!spans_its_text(base.green, "\\section{Hi}\n\n"));
379        });
380    }
381
382    #[test]
383    fn finish_refuses_a_tree_that_does_not_span_its_text() {
384        with_base("\\section{Hi}\n", |base| {
385            let out = finish(
386                base.green.clone(),
387                base.errors.to_vec(),
388                ReparseTier::Token,
389                base,
390                "\\section{Hi}\n\n",
391            );
392            assert!(out.is_none());
393        });
394    }
395
396    #[test]
397    fn finish_accepts_an_identity_splice() {
398        with_base("\\section{Hi}\n\nbody\n", |base| {
399            let out = finish(
400                base.green.clone(),
401                base.errors.to_vec(),
402                ReparseTier::Token,
403                base,
404                base.text,
405            );
406            let out = out.expect("an identity splice matches a full parse");
407            assert_eq!(out.tier, ReparseTier::Token);
408            assert_eq!(&out.green, base.green);
409        });
410    }
411
412    #[test]
413    fn tiers_order_cheapest_first() {
414        assert!(ReparseTier::Token < ReparseTier::Verbatim);
415        assert!(ReparseTier::Verbatim < ReparseTier::Math);
416        assert!(ReparseTier::Math < ReparseTier::Region);
417    }
418
419    #[test]
420    fn fingerprint_separates_trees_that_differ_only_in_token_text() {
421        let a = crate::parser::parse("\\a{b}");
422        let b = crate::parser::parse("\\a{c}");
423        assert_ne!(fingerprint(&a.syntax()), fingerprint(&b.syntax()));
424    }
425
426    #[test]
427    fn fingerprint_agrees_with_itself_across_equal_parses() {
428        let a = crate::parser::parse("\\section{Hi}\n\nbody $x^2$ % c\n");
429        let b = crate::parser::parse("\\section{Hi}\n\nbody $x^2$ % c\n");
430        assert_eq!(fingerprint(&a.syntax()), fingerprint(&b.syntax()));
431    }
432
433    #[cfg(debug_assertions)]
434    mod oracle_self_tests {
435        use super::*;
436
437        #[test]
438        #[should_panic(expected = "different tree")]
439        fn the_oracle_rejects_a_wrong_tree() {
440            with_base("\\section{Hi}\n", |base| {
441                let wrong = crate::parser::parse("\\section{Ho}\n");
442                let _ = finish(
443                    wrong.green,
444                    base.errors.to_vec(),
445                    ReparseTier::Token,
446                    base,
447                    base.text,
448                );
449            });
450        }
451
452        #[test]
453        #[should_panic(expected = "different errors")]
454        fn the_oracle_rejects_a_perturbed_error_vector() {
455            with_base("\\section{Hi}\n", |base| {
456                let mut errors = base.errors.to_vec();
457                errors.push(SyntaxError {
458                    message: "invented".to_string(),
459                    start: 0,
460                    end: 1,
461                });
462                let _ = finish(
463                    base.green.clone(),
464                    errors,
465                    ReparseTier::Token,
466                    base,
467                    base.text,
468                );
469            });
470        }
471
472        #[test]
473        #[should_panic(expected = "different errors")]
474        fn the_oracle_rejects_an_error_that_moved() {
475            let text = "\\begin{itemize}\n";
476            with_base(text, |base| {
477                assert!(
478                    !base.errors.is_empty(),
479                    "this fixture exists to carry an error"
480                );
481                let mut errors = base.errors.to_vec();
482                errors[0].start += 1;
483                let _ = finish(
484                    base.green.clone(),
485                    errors,
486                    ReparseTier::Token,
487                    base,
488                    base.text,
489                );
490            });
491        }
492    }
493}