lattice_syntax/handle.rs
1//! `SyntaxHandle` -- the async wrapper around per-document
2//! [`Syntax`] that runs reparses off the UI thread.
3//!
4//! ## Why this exists
5//!
6//! The audit's C1 finding: `Syntax::parse` runs synchronously on
7//! whatever thread calls it, and the App was calling it from
8//! `App::apply` (every `Action`) and `App::refresh_highlights`
9//! (per frame). On a multi-MB buffer that's a sub-millisecond
10//! to multi-millisecond stall on the UI thread -- a direct
11//! violation of paramount goal #1 ("UI thread does no … parsing").
12//!
13//! ## Architecture
14//!
15//! Mirrors the patterns we use for the document actor and the
16//! LSP supervisor:
17//!
18//! - **Worker task.** Owns the `Syntax` instance + `Parser`.
19//! Receives `(from_version, text_version, buffer, edits)`
20//! reparse requests on an unbounded mpsc channel. Each request
21//! runs the parse on `tokio::task::spawn_blocking` so the
22//! long-running tree-sitter call doesn't tie up a worker
23//! thread for the whole runtime; on completion the worker
24//! stores a fresh [`SyntaxSnapshot`] in the handle's `ArcSwap`
25//! cell.
26//! - **Buffer-not-text** (slice B.5). The request carries a
27//! `Buffer` (O(1) Arc-bump clone via ropey's internal sharing)
28//! instead of a pre-materialized `String`. The worker calls
29//! `buffer.as_string()` on its `spawn_blocking` thread, so the
30//! O(n) source materialization stays off the input thread per
31//! paramount goal #1.
32//! - **Incremental reparse with intermediate publish** (slices
33//! B.2 + C.2). Non-empty `edits` route to
34//! `Syntax::try_apply_intermediate` first -- this applies
35//! `tree.edit()` per delta and updates the cached source +
36//! `text_version`, but does NOT yet run `Parser::parse`. The
37//! worker then publishes an intermediate `ArcSwap::store` of
38//! this byte-shifted-but-pre-parse-shape snapshot, so renderers
39//! immediately see byte-aligned spans for unchanged content
40//! (only the changed region's tree shape is briefly stale).
41//! THEN `reparse_with_cached_tree` runs `Parser::parse(_,
42//! Some(&old_tree))` which reuses unchanged subtrees, and
43//! the worker publishes the final snapshot. Empty edits or
44//! any guard violation in `try_apply_intermediate` falls
45//! through to full reparse with a single publish.
46//! - **Coalescing.** When multiple requests are queued, the
47//! worker accumulates `edits` in arrival order, takes the
48//! latest `buffer` and `text_version`, keeps the earliest
49//! `from_version`. Preserves edit ordering across the burst;
50//! the burst maps to a single `Parser::parse`. Coalesced edit
51//! count is capped at `MAX_INCREMENTAL_EDITS_PER_REQUEST`
52//! (256) to bound worst-case `tree.edit()` overhead;
53//! pathological bursts fall through to full reparse.
54//! - **Wait-free reads.** The App / renderer / fold provider
55//! reads the latest snapshot via `handle.snapshot()` (one
56//! `ArcSwap::load_full`). No mutex, no actor round-trip.
57//!
58//! ## Test path
59//!
60//! For sync tests that pre-build a parsed `Syntax` and want to
61//! plug it into the handle directly, [`SyntaxHandle::seeded`]
62//! constructs a handle whose snapshot starts populated and
63//! whose worker task either runs (if a tokio runtime is
64//! present) or is skipped (if not -- read-only handle).
65
66use std::sync::Arc;
67
68use arc_swap::ArcSwap;
69use tokio::sync::mpsc;
70
71use lattice_core::Buffer;
72use lattice_protocol::edit::EditDelta;
73
74use crate::lang::Lang;
75use crate::registry::LangRegistry;
76use crate::syntax::{Syntax, SyntaxError, SyntaxSnapshot};
77
78/// Cap on the per-request edit count. Bounds worst-case
79/// `tree.edit()` cost in the worker before parse: at ~500ns per
80/// edit, 256 edits = ~128µs of pre-parse work, comparable to the
81/// parse itself. Beyond that, the bookkeeping cost exceeds the
82/// incremental win, so we drop the edits and force a full
83/// reparse. Pathological case: a 100k-char paste delivered as
84/// per-character edits (shouldn't happen via Action paths, but
85/// belt-and-suspenders).
86pub(crate) const MAX_INCREMENTAL_EDITS_PER_REQUEST: usize = 256;
87
88/// Editor-facing handle. Cheap to clone; cloning gives a
89/// reference to the same underlying snapshot cell + the same
90/// reparse-request channel.
91#[derive(Clone)]
92pub struct SyntaxHandle {
93 snapshot: Arc<ArcSwap<SyntaxSnapshot>>,
94 cmd_tx: mpsc::UnboundedSender<ReparseRequest>,
95}
96
97struct ReparseRequest {
98 /// Version the worker's tree is expected to be at BEFORE
99 /// applying `edits`. The worker compares this against its
100 /// own `tree.text_version` and falls back to full reparse on
101 /// mismatch (see [`Syntax::parse_at_with_edits`] guards).
102 from_version: u64,
103 /// Version the resulting snapshot should be stamped with
104 /// AFTER the parse completes.
105 text_version: u64,
106 /// Buffer at `text_version`. Slice B.5: carries the rope
107 /// (cloning is O(1) via ropey's internal Arc) instead of a
108 /// pre-materialized `String`. The worker materializes the
109 /// rope to bytes via `buffer.as_string()` on the worker
110 /// thread immediately before parse, moving the O(n) alloc
111 /// off the input thread per paramount goal #1 ("UI thread
112 /// does no I/O, no parsing"). Input-thread cost goes from
113 /// O(n) String alloc + memcpy down to O(1) Arc bump (~ns).
114 buffer: Buffer,
115 /// Edit deltas in apply-order, taking the buffer from
116 /// `from_version` to `text_version`. Empty = "no deltas
117 /// available, do full reparse" (file load, document replace).
118 edits: Vec<EditDelta>,
119}
120
121impl SyntaxHandle {
122 /// Build a handle for `lang` and start the worker task.
123 /// Returns `Ok(None)` for `Lang::Plain` or any language not
124 /// registered in the supplied registry -- the App treats
125 /// `None` as "no syntax highlighting for this buffer".
126 pub fn spawn(lang: Lang, registry: Arc<LangRegistry>) -> Result<Option<Self>, SyntaxError> {
127 let Some(syntax) = Syntax::for_language_with_registry(lang, registry)? else {
128 return Ok(None);
129 };
130 Ok(Some(Self::seeded(syntax)))
131 }
132
133 /// Wrap a pre-built `Syntax` (already parsed or not) in a
134 /// handle. Used by tests that want to drive parses
135 /// synchronously and then read the result, and by callers
136 /// that already constructed a `Syntax` for other reasons
137 /// (one-shot help-buffer markdown highlighting).
138 ///
139 /// **Production callers must use [`Self::seeded_with_runtime`]
140 /// instead.** This method falls back to
141 /// `Handle::try_current()` which silently fails when the
142 /// caller hasn't entered a tokio context (notably: editor
143 /// startup runs from a synchronous `main()`, before the
144 /// runtime is set up). When that fails, the worker is never
145 /// spawned and `request_reparse` calls go to a dropped
146 /// channel -- the snapshot stays at the seeded state forever
147 /// and no reparses ever run. LSP solved the same problem
148 /// with an explicit runtime handle (see app.rs:96-100); this
149 /// constructor remains for tests that already run inside a
150 /// tokio context.
151 pub fn seeded(syntax: Syntax) -> Self {
152 let snapshot = Arc::new(ArcSwap::from_pointee(syntax.snapshot_owned()));
153 let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<ReparseRequest>();
154 let snapshot_for_task = snapshot.clone();
155 match tokio::runtime::Handle::try_current() {
156 Ok(handle) => {
157 handle.spawn(worker_main(syntax, cmd_rx, snapshot_for_task, None));
158 }
159 Err(_) => {
160 drop(cmd_rx);
161 drop(syntax);
162 }
163 }
164 Self { snapshot, cmd_tx }
165 }
166
167 /// Wrap a pre-built `Syntax` and spawn the worker on the
168 /// supplied tokio runtime handle. **This is the production
169 /// constructor.** Mirrors what
170 /// `lattice_lsp::supervisor::LspSupervisor::spawn` does: take
171 /// an explicit handle so the worker actually starts even when
172 /// the caller hasn't entered a tokio context yet (editor
173 /// startup runs from synchronous `main()` and constructs the
174 /// handle before the main loop enters tokio).
175 ///
176 /// Without this, `Handle::try_current()` in [`Self::seeded`]
177 /// silently fails, the worker is never spawned, and Option B's
178 /// entire incremental-reparse pipeline is dead -- `request_reparse`
179 /// sends to a dropped channel, the snapshot stays at the seeded
180 /// state, and no edit ever produces a fresh tree. The user-visible
181 /// symptom is "syntax highlighting is stuck to byte positions and
182 /// never tracks document edits" -- which was the bug originally
183 /// reported against indent (`>>`) and backspace flows.
184 /// `on_publish`, when `Some`, is called after every snapshot publish
185 /// (both the intermediate edit-shift and the final reparse).
186 /// Production passes a closure that fires the host's `async_landed`
187 /// Notify (waking the actor) and publishes a `SyntaxReparsed` event
188 /// on the event bus; the event subscriber then fires `cells_wake` so
189 /// idle reparses repaint without a keystroke. Tests pass `None`.
190 pub fn seeded_with_runtime(
191 syntax: Syntax,
192 runtime: &tokio::runtime::Handle,
193 on_publish: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
194 ) -> Self {
195 let snapshot = Arc::new(ArcSwap::from_pointee(syntax.snapshot_owned()));
196 let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<ReparseRequest>();
197 let snapshot_for_task = snapshot.clone();
198 runtime.spawn(worker_main(syntax, cmd_rx, snapshot_for_task, on_publish));
199 Self { snapshot, cmd_tx }
200 }
201
202 /// Wait-free read of the latest parse snapshot. Hot path;
203 /// renderer / fold provider / completion call this on every
204 /// frame.
205 pub fn snapshot(&self) -> Arc<SyntaxSnapshot> {
206 self.snapshot.load_full()
207 }
208
209 /// Borrow the snapshot via `ArcSwap`'s short-lived guard.
210 /// Cheaper than [`Self::snapshot`] when the caller only
211 /// needs to read a single field (no `Arc::clone`).
212 pub fn with_snapshot<R>(&self, f: impl FnOnce(&SyntaxSnapshot) -> R) -> R {
213 let guard = self.snapshot.load();
214 f(&guard)
215 }
216
217 /// Convenience: latest `Lang`. Wait-free.
218 pub fn lang(&self) -> Lang {
219 self.snapshot.load().lang()
220 }
221
222 /// Request that the worker reparse `buffer` and stamp the
223 /// resulting snapshot with `text_version`. Fire-and-forget;
224 /// the new snapshot is observable via [`Self::snapshot`]
225 /// once the worker completes the parse.
226 ///
227 /// Slice B.5: takes `Buffer` (clones in O(1) via ropey's
228 /// internal Arc) rather than `String`. The full source
229 /// materialization moves to the worker, off the input
230 /// thread.
231 ///
232 /// `from_version` is the version-baseline the worker's tree
233 /// is expected to be at BEFORE applying `edits`. The worker
234 /// uses it to detect mismatches (dropped requests, file
235 /// load, document replace) and falls back to full reparse
236 /// when the cached tree's version doesn't match.
237 ///
238 /// `edits` carries the tree-sitter-shaped deltas in apply
239 /// order, taking the buffer from `from_version` to
240 /// `text_version`. Empty `edits` = "do a full reparse"
241 /// (file load / cold-start path).
242 ///
243 /// Coalesced: queued requests' edits are accumulated in
244 /// order; the latest queued request's `buffer` and
245 /// `text_version` win; the earliest queued request's
246 /// `from_version` survives so the burst's baseline is
247 /// preserved.
248 pub fn request_reparse(
249 &self,
250 from_version: u64,
251 text_version: u64,
252 buffer: Buffer,
253 edits: Vec<EditDelta>,
254 ) {
255 let _ = self.cmd_tx.send(ReparseRequest {
256 from_version,
257 text_version,
258 buffer,
259 edits,
260 });
261 }
262}
263
264impl std::fmt::Debug for SyntaxHandle {
265 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266 let snap = self.snapshot.load();
267 f.debug_struct("SyntaxHandle")
268 .field("lang", &snap.lang())
269 .field("text_version", &snap.text_version())
270 .finish_non_exhaustive()
271 }
272}
273
274// K.4.7 (2026-06-08): `ExcerptHighlighter` impl so `MultibufferDocumentHandle`
275// can return `Arc<dyn ExcerptHighlighter>` without exposing `SyntaxHandle`
276// to `lattice-runtime` (which would create a dep cycle).
277impl lattice_cells::ExcerptHighlighter for SyntaxHandle {
278 fn highlight_lines(&self, lo: u32, hi: u32) -> Option<Vec<Vec<lattice_cells::StyledSpan>>> {
279 self.snapshot().highlight_lines(lo, hi).ok()
280 }
281
282 fn highlight_version(&self) -> u64 {
283 // `render_version`, NOT `text_version`: one edit publishes twice
284 // (byte-shifted intermediate, then completed reparse) and both carry
285 // the same `text_version`, so a cache keyed on it never rebuilds with
286 // colour. See `SyntaxSnapshot::render_version`.
287 self.snapshot().render_version()
288 }
289}
290
291/// Test-only: make the next parse inside the worker panic.
292///
293/// A tree-sitter panic cannot be induced on demand from outside, and the
294/// property it guards — the worker SURVIVES one and keeps serving later
295/// requests — is the difference between one stale frame and a buffer whose
296/// highlighting is dead until it is reopened. That is worth a hook.
297#[cfg(test)]
298pub(crate) static PANIC_NEXT_PARSE: std::sync::atomic::AtomicBool =
299 std::sync::atomic::AtomicBool::new(false);
300
301/// Worker loop. Owns the `Syntax` exclusively; processes
302/// reparse requests in FIFO order, coalescing newer requests on
303/// top of older ones before running each parse on a blocking
304/// pool thread. Exits when every handle is dropped (sender
305/// closes the channel).
306///
307/// Coalescing semantics (slice B.2): when multiple requests
308/// arrive while the worker is busy, edits accumulate in arrival
309/// order while `text` and `text_version` snap to the latest, and
310/// `from_version` snaps to the earliest. Result: a single parse
311/// applies the union of edits in order, taking the cached tree
312/// from the burst's baseline to the burst's tip in one step.
313/// Dropping older requests in favour of the latest (the pre-B.2
314/// behaviour) would lose their edits and silently corrupt the
315/// cached tree's byte ranges -- the new shape preserves them.
316///
317/// Edit count is capped at [`MAX_INCREMENTAL_EDITS_PER_REQUEST`].
318/// Beyond that, the worker drops the edits and lets the syntax's
319/// full-reparse fallback fire (still publishes a correct tree,
320/// just at full-reparse cost).
321async fn worker_main(
322 mut syntax: Syntax,
323 mut cmd_rx: mpsc::UnboundedReceiver<ReparseRequest>,
324 snapshot: Arc<ArcSwap<SyntaxSnapshot>>,
325 on_publish: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
326) {
327 while let Some(mut req) = cmd_rx.recv().await {
328 // Coalesce queued requests: accumulate edits in order,
329 // take latest buffer/text_version, keep earliest
330 // from_version. The mailbox is FIFO so this preserves
331 // edit ordering across the burst.
332 let mut acc_edits = std::mem::take(&mut req.edits);
333 while let Ok(mut next) = cmd_rx.try_recv() {
334 if next.text_version >= req.text_version {
335 acc_edits.append(&mut next.edits);
336 req.buffer = next.buffer;
337 req.text_version = next.text_version;
338 }
339 }
340 req.edits = acc_edits;
341
342 // Pathological-burst guard: if the accumulated edit list
343 // exceeds the cap, drop the edits and let the syntax's
344 // empty-edits guard route us to a full reparse. Cheaper
345 // than applying ~thousands of `tree.edit()` calls before
346 // the parse.
347 if req.edits.len() > MAX_INCREMENTAL_EDITS_PER_REQUEST {
348 req.edits.clear();
349 }
350
351 // Run the parse on a blocking thread; tree-sitter
352 // parses can take ~ms on large buffers and we don't
353 // want to tie up a tokio worker thread. The closure
354 // moves `syntax` in and back out so we keep ownership.
355 let ReparseRequest {
356 from_version,
357 text_version,
358 buffer,
359 edits,
360 } = req;
361 let snapshot_for_intermediate = snapshot.clone();
362 let parsed = tokio::task::spawn_blocking(move || {
363 // The parse is wrapped so a panic inside tree-sitter cannot take
364 // `syntax` with it: the closure hands ownership back either way and
365 // the caller decides what to do. See the `Err` arm below for why
366 // that matters more than it looks.
367 let mut panicked = false;
368 // Slice B.5: materialize the source bytes on the
369 // worker thread, not the input thread. `as_string`
370 // is O(n) for the rope but happens here on the
371 // spawn_blocking pool.
372 let text = buffer.as_string();
373 // Slice C.2: two-stage parse with intermediate
374 // publish.
375 //
376 // Stage 1 (fast, ~µs): try_apply_intermediate runs
377 // tree.edit() per delta -- shifts every node's byte
378 // range to track the edits -- and updates the
379 // snapshot's source + text_version. The result is
380 // byte-aligned (all node ranges match the new
381 // source) but tree shape is pre-parse for the
382 // changed regions.
383 //
384 // Publishing the intermediate now means the renderer
385 // sees byte-aligned spans for the entire parse
386 // window. Lines below a deleted line, lines after a
387 // multi-byte insert, etc. all paint at correct
388 // positions immediately -- no flicker for unchanged
389 // content. Only the changed region's tree shape is
390 // briefly stale (which usually doesn't affect
391 // colour: shape-staleness within a string node, an
392 // identifier node, etc. produces the same span as
393 // the post-parse shape).
394 //
395 // Stage 2 (slow, ~50-300µs): reparse_with_cached_tree
396 // runs Parser::parse with the (already edited) tree
397 // as seed. tree-sitter reuses unchanged subtrees;
398 // only the edited region gets re-parsed. The final
399 // publish lands a moment later.
400 //
401 // On guard failure (no cached tree, version mismatch,
402 // byte-length mismatch), Stage 1 returns Err, and we
403 // fall through to a full reparse with a single
404 // publish.
405 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
406 #[cfg(test)]
407 if PANIC_NEXT_PARSE.swap(false, std::sync::atomic::Ordering::SeqCst) {
408 panic!("injected parse panic");
409 }
410 let intermediate_ok = !edits.is_empty()
411 && syntax
412 .try_apply_intermediate(&text, text_version, from_version, &edits)
413 .is_ok();
414 if intermediate_ok {
415 snapshot_for_intermediate.store(Arc::new(syntax.snapshot_owned()));
416 syntax.reparse_with_cached_tree(from_version);
417 } else {
418 syntax.parse_at(&text, text_version);
419 }
420 }));
421 if outcome.is_err() {
422 panicked = true;
423 }
424 (syntax, panicked)
425 })
426 .await;
427 let (next, panicked) = match parsed {
428 Ok(pair) => pair,
429 Err(join_err) => {
430 // The task could not be joined at all — cancelled, or the
431 // runtime is shutting down. Nothing left to own, so the worker
432 // does have to stop here; but it says so first, because the
433 // symptom on screen (highlighting frozen forever, redraw does
434 // not help, reopening the file fixes it) says nothing about
435 // which layer died.
436 tracing::error!(
437 target: "lattice_host::syntax",
438 error = %join_err,
439 text_version,
440 "syntax_reparse_worker_stopped"
441 );
442 return;
443 }
444 };
445 if panicked {
446 // A PANIC IN THE PARSE IS NO LONGER FATAL TO THE WORKER.
447 //
448 // It used to be: the panic destroyed `syntax`, the worker
449 // `return`ed, and every later request went into a channel with no
450 // receiver. The snapshot froze at the last good parse for the life
451 // of that buffer — `<C-l>` cannot help, because a redraw rebuilds
452 // from a snapshot that is never going to change again, and only
453 // reopening the file (a NEW handle, a NEW worker) recovered.
454 //
455 // It also died SILENTLY, which is why the pair of
456 // `syntax_reparse_requested` / `syntax_reparse_published` lines
457 // pointed at the worker and then stopped: nothing recorded the
458 // reason. Now the panic is caught inside the closure, `syntax`
459 // comes back, and the worker keeps serving the next request — so a
460 // parse that fails while a grammar is mid-rebuild costs one stale
461 // frame instead of a permanently dead buffer.
462 //
463 // Skipping the publish is deliberate: the tree is in whatever state
464 // the panic left it, and painting from it would be worse than
465 // painting from the last known-good snapshot.
466 tracing::error!(
467 target: "lattice_host::syntax",
468 from_version,
469 text_version,
470 "syntax_reparse_panicked"
471 );
472 syntax = next;
473 continue;
474 }
475 snapshot.store(Arc::new(next.snapshot_owned()));
476 syntax = next;
477 // The far end of `syntax_reparse_requested`. A request logged with no
478 // publish logged after it means the parse never completed — the
479 // blocking task panicked and the `return` above took the worker down
480 // with it, after which every later request sends into a channel with
481 // no receiver and the snapshot is frozen for good. That is a
482 // never-self-heals shape, and it is indistinguishable on screen from
483 // a stale display matrix; these two lines together are what tell them
484 // apart. One line per reparse, not per frame.
485 tracing::debug!(
486 target: "lattice_host::syntax",
487 text_version = snapshot.load().text_version(),
488 "syntax_reparse_published"
489 );
490 // 2026-06-03 (slice B.1): wake the host so the editor actor
491 // re-publishes render state now that fresh syntax is
492 // available — without this, an idle reparse (no keystroke in
493 // flight) wouldn't repaint until the next key. Reached by both
494 // the incremental and full-reparse paths (single fire point).
495 // The intermediate publish above already advanced the snapshot
496 // version, so one wake here suffices.
497 if let Some(cb) = on_publish.as_ref() {
498 cb();
499 }
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use crate::lang::Lang;
507 use crate::syntax::Syntax;
508 use std::time::Duration;
509 use tokio::sync::Notify;
510
511 /// Serialise the worker tests: [`PANIC_NEXT_PARSE`] is PROCESS-GLOBAL.
512 ///
513 /// Run concurrently, one test's armed panic is consumed by the other's
514 /// parse — the panic lands in the wrong worker and both fail, which is
515 /// exactly what happened the first time this test was written. A global
516 /// injection point needs a global lock; making the tests look independent
517 /// when they share a static is how a suite earns a reputation for
518 /// flakiness. `tokio::sync::Mutex` because the critical section spans
519 /// `.await`.
520 static WORKER_TESTS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
521
522 /// Slice B.1 (2026-06-03): a reparse publish must fire the
523 /// `on_publish` wake so the editor actor can re-publish render
524 /// state on idle reparse completion. Without this, an idle reparse
525 /// (no keystroke in flight) never repaints — the markdown
526 /// "highlighting never comes back" symptom's idle half.
527
528 /// A panicking parse must NOT kill the worker.
529 ///
530 /// It used to: the panic destroyed the `Syntax`, the worker returned, and
531 /// every later request went into a channel with no receiver. The snapshot
532 /// froze at the last good parse for the life of that buffer — `<C-l>` could
533 /// not help, because a redraw rebuilds from a snapshot that will never
534 /// change again, and only reopening the file recovered.
535 ///
536 /// It died silently too, which is why a debug log showed
537 /// `syntax_reparse_requested` lines with no `syntax_reparse_published`
538 /// after them and nothing saying why.
539 ///
540 /// The second request is the whole test: one bad parse costs a stale frame,
541 /// not the buffer.
542 #[tokio::test]
543 async fn a_panicking_parse_does_not_kill_the_worker() {
544 let _serialised = WORKER_TESTS.lock().await;
545 let mut s = Syntax::for_language(Lang::Rust).unwrap().unwrap();
546 s.parse_at("fn main() {}\n", 1);
547 let wake = Arc::new(Notify::new());
548 let wake_cb = wake.clone();
549 let handle = SyntaxHandle::seeded_with_runtime(
550 s,
551 &tokio::runtime::Handle::current(),
552 Some(Arc::new(move || wake_cb.notify_one())),
553 );
554
555 // Arm the panic, then ask for a reparse. It must publish nothing.
556 PANIC_NEXT_PARSE.store(true, std::sync::atomic::Ordering::SeqCst);
557 handle.request_reparse(
558 1,
559 2,
560 Buffer::from_text("fn main() {}\n// two\n"),
561 Vec::<EditDelta>::new(),
562 );
563 assert!(
564 tokio::time::timeout(Duration::from_millis(500), wake.notified())
565 .await
566 .is_err(),
567 "a panicking parse must not publish a half-built tree"
568 );
569
570 // The worker is still alive: the NEXT request publishes normally.
571 handle.request_reparse(
572 1,
573 3,
574 Buffer::from_text("fn main() {}\n// three\n"),
575 Vec::<EditDelta>::new(),
576 );
577 tokio::time::timeout(Duration::from_secs(2), wake.notified())
578 .await
579 .expect("the worker survived the panic and published the next parse");
580 assert_eq!(
581 handle.snapshot().text_version(),
582 3,
583 "and the snapshot advanced to the version that parsed cleanly"
584 );
585 }
586
587 #[tokio::test]
588 async fn reparse_publish_fires_on_publish_wake() {
589 let _serialised = WORKER_TESTS.lock().await;
590 let mut s = Syntax::for_language(Lang::Rust).unwrap().unwrap();
591 s.parse_at("fn main() {}\n", 1);
592 let wake = Arc::new(Notify::new());
593 let wake_cb = wake.clone();
594 let handle = SyntaxHandle::seeded_with_runtime(
595 s,
596 &tokio::runtime::Handle::current(),
597 Some(Arc::new(move || wake_cb.notify_one())),
598 );
599
600 // Full reparse (empty edits) to version 2.
601 handle.request_reparse(
602 1,
603 2,
604 Buffer::from_text("fn main() {}\n// new\n"),
605 Vec::<EditDelta>::new(),
606 );
607
608 // The worker must fire the wake after publishing.
609 tokio::time::timeout(Duration::from_secs(2), wake.notified())
610 .await
611 .expect("on_publish wake must fire after a reparse publish");
612 assert_eq!(
613 handle.snapshot().text_version(),
614 2,
615 "snapshot must reflect the reparsed version once the wake fires"
616 );
617 }
618}