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