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}