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    /// Build a base by fully parsing `text`, the way the host does.
314    fn base_of(text: &str) -> (Parse, ParseCtx, ResolvedDeclarations) {
315        let declared = ResolvedDeclarations::default();
316        let (parse, ctx) = parse_with_declarations_resolved(text, LatexFlavor::Document, &declared);
317        (parse, ctx, declared)
318    }
319
320    fn with_base<R>(text: &str, f: impl FnOnce(&ReparseBase<'_>) -> R) -> R {
321        let (parse, ctx, declared) = base_of(text);
322        f(&ReparseBase::from_parts(
323            text,
324            &parse.green,
325            &parse.errors,
326            &ctx,
327            LatexFlavor::Document.into(),
328            &declared,
329        ))
330    }
331
332    fn edit(range: std::ops::Range<usize>, insert: &str) -> Edit {
333        Edit {
334            range,
335            insert: insert.to_string(),
336        }
337    }
338
339    /// An edit that lands on no spliceable leaf falls back. The ladder is
340    /// refusal-first, so "no tier claimed it" is the ordinary outcome and has to
341    /// stay cheap and silent rather than becoming an error.
342    #[test]
343    fn an_edit_outside_a_plain_leaf_falls_back() {
344        with_base("\\section{Hi}\n\nbody text\n", |base| {
345            // On the `{`: a structural token, not a leaf this tier relexes.
346            let e = edit(8..8, "x");
347            assert!(reparse(base, &e, &e.apply(base.text)).is_none());
348            // Straddling two tokens: the covering element is a node.
349            let e = edit(7..10, "zz");
350            assert!(reparse(base, &e, &e.apply(base.text)).is_none());
351        });
352    }
353
354    /// An edit that does not fit the base is refused before anything slices it. A
355    /// language server can stage a chain against a buffer that has since moved.
356    #[test]
357    fn an_edit_that_does_not_fit_the_base_is_refused() {
358        with_base("abc\n", |base| {
359            assert!(reparse(base, &edit(90..99, "x"), "abc\n").is_none());
360            // Offset 1 is inside a two-byte char.
361            assert!(reparse(base, &edit(1..1, "x"), "abc\n").is_none());
362        });
363        with_base("α\n", |base| {
364            assert!(reparse(base, &edit(1..1, "x"), "αx\n").is_none());
365        });
366    }
367
368    #[test]
369    fn an_empty_chain_is_refused() {
370        with_base("abc\n", |base| {
371            assert!(reparse_edits(base, &[], "abc\n").is_none());
372        });
373    }
374
375    /// A chain that applies cleanly but composes to some *other* text is stale, and
376    /// answering for it would answer for the wrong buffer.
377    #[test]
378    fn a_chain_that_lands_elsewhere_is_refused() {
379        with_base("abc\n", |base| {
380            assert!(reparse_edits(base, &[edit(0..0, "x")], "totally different").is_none());
381        });
382    }
383
384    #[test]
385    fn spans_its_text_measures_the_green_width() {
386        with_base("\\section{Hi}\n", |base| {
387            assert!(spans_its_text(base.green, base.text));
388            assert!(!spans_its_text(base.green, "\\section{Hi}"));
389            assert!(!spans_its_text(base.green, "\\section{Hi}\n\n"));
390        });
391    }
392
393    /// The release-build backstop: `finish` refuses a tree that does not span its
394    /// text rather than handing back a splice with bad offsets. It must *bail*, not
395    /// panic — the contract is refusal-first even for a bug.
396    #[test]
397    fn finish_refuses_a_tree_that_does_not_span_its_text() {
398        with_base("\\section{Hi}\n", |base| {
399            let out = finish(
400                base.green.clone(),
401                base.errors.to_vec(),
402                ReparseTier::Token,
403                base,
404                // One byte longer than the tree.
405                "\\section{Hi}\n\n",
406            );
407            assert!(out.is_none());
408        });
409    }
410
411    /// The happy path through the single exit, so the length check and the oracle
412    /// are both exercised on a result that should pass. An identity splice is the
413    /// only result Phase 1 can construct honestly.
414    #[test]
415    fn finish_accepts_an_identity_splice() {
416        with_base("\\section{Hi}\n\nbody\n", |base| {
417            let out = finish(
418                base.green.clone(),
419                base.errors.to_vec(),
420                ReparseTier::Token,
421                base,
422                base.text,
423            );
424            let out = out.expect("an identity splice matches a full parse");
425            assert_eq!(out.tier, ReparseTier::Token);
426            assert_eq!(&out.green, base.green);
427        });
428    }
429
430    #[test]
431    fn tiers_order_cheapest_first() {
432        assert!(ReparseTier::Token < ReparseTier::Verbatim);
433        assert!(ReparseTier::Verbatim < ReparseTier::Math);
434        assert!(ReparseTier::Math < ReparseTier::Region);
435    }
436
437    #[test]
438    fn fingerprint_separates_trees_that_differ_only_in_token_text() {
439        let a = crate::parser::parse("\\a{b}");
440        let b = crate::parser::parse("\\a{c}");
441        assert_ne!(fingerprint(&a.syntax()), fingerprint(&b.syntax()));
442    }
443
444    #[test]
445    fn fingerprint_agrees_with_itself_across_equal_parses() {
446        let a = crate::parser::parse("\\section{Hi}\n\nbody $x^2$ % c\n");
447        let b = crate::parser::parse("\\section{Hi}\n\nbody $x^2$ % c\n");
448        assert_eq!(fingerprint(&a.syntax()), fingerprint(&b.syntax()));
449    }
450
451    /// # Oracle self-tests
452    ///
453    /// The oracle is the whole safety story, so it has to be shown capable of
454    /// failing. Without these, Phase 1 ships a net with no proof it catches
455    /// anything — and it would keep passing if `assert_matches_full_parse` were
456    /// accidentally compiled out.
457    ///
458    /// `debug_assertions`-gated because that is what the assert itself is gated on;
459    /// under `--release` there is deliberately nothing here to trip.
460    #[cfg(debug_assertions)]
461    mod oracle_self_tests {
462        use super::*;
463
464        #[test]
465        #[should_panic(expected = "different tree")]
466        fn the_oracle_rejects_a_wrong_tree() {
467            with_base("\\section{Hi}\n", |base| {
468                // A tree of the right *width* but the wrong content: the length
469                // backstop cannot see this one, which is exactly why the oracle exists.
470                let wrong = crate::parser::parse("\\section{Ho}\n");
471                let _ = finish(
472                    wrong.green,
473                    base.errors.to_vec(),
474                    ReparseTier::Token,
475                    base,
476                    base.text,
477                );
478            });
479        }
480
481        #[test]
482        #[should_panic(expected = "different errors")]
483        fn the_oracle_rejects_a_perturbed_error_vector() {
484            with_base("\\section{Hi}\n", |base| {
485                let mut errors = base.errors.to_vec();
486                errors.push(SyntaxError {
487                    message: "invented".to_string(),
488                    start: 0,
489                    end: 1,
490                });
491                let _ = finish(
492                    base.green.clone(),
493                    errors,
494                    ReparseTier::Token,
495                    base,
496                    base.text,
497                );
498            });
499        }
500
501        /// The error check is not satisfied by a mere count match — a diagnostic
502        /// that moved is as wrong as one that appeared.
503        #[test]
504        #[should_panic(expected = "different errors")]
505        fn the_oracle_rejects_an_error_that_moved() {
506            let text = "\\begin{itemize}\n";
507            with_base(text, |base| {
508                assert!(
509                    !base.errors.is_empty(),
510                    "this fixture exists to carry an error"
511                );
512                let mut errors = base.errors.to_vec();
513                errors[0].start += 1;
514                let _ = finish(
515                    base.green.clone(),
516                    errors,
517                    ReparseTier::Token,
518                    base,
519                    base.text,
520                );
521            });
522        }
523    }
524}