hjkl_syntax/lib.rs
1//! Renderer-agnostic syntax-highlighting pipeline for the hjkl editor stack.
2//!
3//! Owns a [`SyntaxWorker`] background thread (holding the `Highlighter`
4//! and retained `tree_sitter::Tree`) plus a main-thread `RenderCache` of
5//! `(source, row_starts)`. Call
6//! [`SyntaxLayer::set_language_for_path`] after opening a file, then
7//! [`SyntaxLayer::apply_edits`] for each frame's queued
8//! [`hjkl_engine::ContentEdit`] batch and [`SyntaxLayer::submit_render`]
9//! to enqueue a parse + viewport-scoped highlight on the worker. Drain
10//! results via [`SyntaxLayer::take_all_results`] each frame and route them
11//! to the correct per-slot cache field.
12//!
13//! # Design
14//!
15//! Output is renderer-agnostic: [`RenderOutput::spans`] carries
16//! `(byte_start, byte_end, `[`StyleSpec`]`)` triples rather than
17//! renderer-specific style types. A TUI adapter ([`hjkl-syntax-tui`]) maps
18//! these to `ratatui::style::Style`; a future GUI adapter will map them to
19//! `cosmic_text` attributes.
20//!
21//! [`StyleSpec`]: hjkl_theme::StyleSpec
22
23use std::collections::HashMap;
24use std::path::Path;
25use std::sync::{Arc, Condvar, Mutex};
26use std::thread::{self, JoinHandle};
27
28use hjkl_bonsai::runtime::{Grammar, LoadHandle};
29use hjkl_bonsai::{CommentMarkerPass, DotFallbackTheme, Highlighter, InputEdit, Point, Theme};
30use hjkl_engine::Query;
31
32use hjkl_lang::{GrammarRequest, LanguageDirectory};
33
34pub use hjkl_theme::{Color, Modifiers, StyleSpec};
35
36/// Stable identifier for an open buffer. Assigned by the App; carried
37/// through every syntax-pipeline message so the worker can multiplex
38/// per-buffer tree state.
39///
40/// # Examples
41///
42/// ```
43/// use hjkl_syntax::BufferId;
44/// let id: BufferId = 42;
45/// assert_eq!(id, 42);
46/// ```
47pub use hjkl_buffer::BufferId;
48
49// ---------------------------------------------------------------------------
50// Public output types
51// ---------------------------------------------------------------------------
52
53/// Discriminates the purpose of a parse request / result so the App can
54/// route it to the correct per-slot cache field.
55///
56/// - `Viewport` — the current visible region (already existed; default).
57/// - `Top` — rows `0..min(3*h, line_count)` pre-cached so `gg` never
58/// flashes un-highlighted rows.
59/// - `Bottom` — rows `line_count - min(3*h, line_count)..line_count`
60/// pre-cached so `G` never flashes un-highlighted rows.
61///
62/// **Ordering is load-bearing for the perf invariant:** the worker queue
63/// is FIFO, so submitting `Viewport` first, then `Top`, then `Bottom`
64/// ensures the retained tree is built on the Viewport pass and the
65/// subsequent passes ride it incrementally (~1-5 ms each).
66///
67/// # Examples
68///
69/// ```
70/// use hjkl_syntax::ParseKind;
71/// assert_ne!(ParseKind::Viewport, ParseKind::Top);
72/// assert_ne!(ParseKind::Top, ParseKind::Bottom);
73/// ```
74#[derive(Copy, Clone, Debug, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum ParseKind {
77 /// The current visible viewport region.
78 Viewport,
79 /// The top of the document (rows 0..N) — pre-cached for `gg`.
80 Top,
81 /// The bottom of the document (rows line_count-N..line_count) — pre-cached for `G`.
82 Bottom,
83}
84
85/// A single diagnostic sign emitted from the syntax pipeline.
86///
87/// Carries only renderer-agnostic fields: `row`, `ch`, and `priority`.
88/// The TUI adapter converts these to ratatui-styled `hjkl_buffer::Sign`
89/// objects using its own colour choices.
90///
91/// # Examples
92///
93/// ```
94/// use hjkl_syntax::DiagSign;
95/// let s = DiagSign::new(3, 'E', 100);
96/// assert_eq!(s.row, 3);
97/// ```
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99#[non_exhaustive]
100pub struct DiagSign {
101 /// Document row (0-indexed).
102 pub row: usize,
103 /// Gutter character (e.g. `'E'` for a syntax error).
104 pub ch: char,
105 /// Gutter priority — higher wins when multiple signs land on the same row.
106 pub priority: u8,
107}
108
109impl Default for DiagSign {
110 fn default() -> Self {
111 Self {
112 row: 0,
113 ch: 'E',
114 priority: 0,
115 }
116 }
117}
118
119impl DiagSign {
120 /// Create a new diagnostic sign.
121 ///
122 /// # Examples
123 ///
124 /// ```
125 /// use hjkl_syntax::DiagSign;
126 /// let s = DiagSign::new(1, 'E', 100);
127 /// assert_eq!(s.row, 1);
128 /// assert_eq!(s.ch, 'E');
129 /// assert_eq!(s.priority, 100);
130 /// ```
131 pub fn new(row: usize, ch: char, priority: u8) -> Self {
132 Self { row, ch, priority }
133 }
134}
135
136/// Per-call sub-step timings exposed to apps' `:perf` overlay.
137/// Recorded on the worker side and shipped back inside [`RenderOutput`].
138///
139/// # Examples
140///
141/// ```
142/// use hjkl_syntax::PerfBreakdown;
143/// let p = PerfBreakdown::default();
144/// assert_eq!(p.parse_us, 0);
145/// ```
146#[derive(Default, Debug, Clone, Copy)]
147#[non_exhaustive]
148pub struct PerfBreakdown {
149 /// Microseconds spent building the source string + row_starts table.
150 pub source_build_us: u128,
151 /// Microseconds spent in `tree_sitter::Parser::parse`.
152 pub parse_us: u128,
153 /// Microseconds spent in `hjkl_bonsai::Highlighter::highlight_range_*`.
154 pub highlight_us: u128,
155 /// Microseconds spent building the per-row span table from flat spans.
156 pub by_row_us: u128,
157 /// Microseconds spent scanning for diagnostic ERROR/MISSING nodes.
158 pub diag_us: u128,
159}
160
161impl PerfBreakdown {
162 /// Construct a zeroed breakdown.
163 ///
164 /// # Examples
165 ///
166 /// ```
167 /// use hjkl_syntax::PerfBreakdown;
168 /// let p = PerfBreakdown::new();
169 /// assert_eq!(p.highlight_us, 0);
170 /// ```
171 pub fn new() -> Self {
172 Self::default()
173 }
174}
175
176/// Per-frame output of the syntax worker.
177///
178/// Contains the styled span table (one inner `Vec` per document row), the
179/// diagnostic signs for the gutter, the cache key the request was tagged
180/// with, and a [`PerfBreakdown`] describing where the worker spent its time.
181///
182/// Spans use [`StyleSpec`] (renderer-agnostic). The TUI adapter
183/// ([`hjkl-syntax-tui`]) converts these to `ratatui::style::Style`.
184///
185/// # Examples
186///
187/// ```
188/// use hjkl_syntax::{RenderOutput, ParseKind, PerfBreakdown};
189/// let out = RenderOutput::new(0, Vec::new(), Vec::new(), (0, 0, 0), PerfBreakdown::default(), ParseKind::Viewport);
190/// assert_eq!(out.kind, ParseKind::Viewport);
191/// ```
192#[derive(Debug, Clone)]
193#[non_exhaustive]
194pub struct RenderOutput {
195 /// Routes spans/signs back to the matching buffer slot in App::slots.
196 /// Install path discards the result when this doesn't match the now-active
197 /// buffer (race fix: a parse queued before a tab/buffer switch must not
198 /// paint onto the new active buffer).
199 pub buffer_id: BufferId,
200 /// Per-row span table. Each inner `Vec` contains `(byte_start, byte_end,
201 /// StyleSpec)` triples for the characters on that row. The outer index is
202 /// the document row (0-indexed). The table is sized to `row_count` even
203 /// when only a viewport slice was requested — rows outside the viewport
204 /// have empty inner Vecs.
205 pub spans: Vec<Vec<(usize, usize, StyleSpec)>>,
206 /// Diagnostic signs for the gutter (one per row with a tree-sitter
207 /// ERROR / MISSING node intersecting the viewport).
208 pub signs: Vec<DiagSign>,
209 /// `(dirty_gen, viewport_top, viewport_height)` — same shape the App
210 /// uses for its own cache key. Pair the result with this on receive.
211 pub key: (u64, usize, usize),
212 /// Sub-step timing breakdown from the worker.
213 pub perf: PerfBreakdown,
214 /// Which region this result covers — used by the install path to route
215 /// into the correct per-slot cache field.
216 pub kind: ParseKind,
217}
218
219impl RenderOutput {
220 /// Construct a new `RenderOutput`.
221 ///
222 /// # Examples
223 ///
224 /// ```
225 /// use hjkl_syntax::{RenderOutput, ParseKind, PerfBreakdown};
226 /// let out = RenderOutput::new(1, Vec::new(), Vec::new(), (7, 0, 30), PerfBreakdown::new(), ParseKind::Top);
227 /// assert_eq!(out.buffer_id, 1);
228 /// assert_eq!(out.kind, ParseKind::Top);
229 /// ```
230 pub fn new(
231 buffer_id: BufferId,
232 spans: Vec<Vec<(usize, usize, StyleSpec)>>,
233 signs: Vec<DiagSign>,
234 key: (u64, usize, usize),
235 perf: PerfBreakdown,
236 kind: ParseKind,
237 ) -> Self {
238 Self {
239 buffer_id,
240 spans,
241 signs,
242 key,
243 perf,
244 kind,
245 }
246 }
247}
248
249impl PartialEq for RenderOutput {
250 fn eq(&self, other: &Self) -> bool {
251 self.kind == other.kind
252 && self.spans == other.spans
253 && self.signs.len() == other.signs.len()
254 && self
255 .signs
256 .iter()
257 .zip(other.signs.iter())
258 .all(|(a, b)| a.row == b.row && a.ch == b.ch && a.priority == b.priority)
259 }
260}
261
262// ---------------------------------------------------------------------------
263// Public outcome types for set_language_for_path / poll_pending_loads
264// ---------------------------------------------------------------------------
265
266/// Outcome of [`SyntaxLayer::set_language_for_path`].
267///
268/// Callers that previously tested the return value as a `bool` should use
269/// `outcome.is_known()` for equivalent behaviour.
270///
271/// # Examples
272///
273/// ```
274/// use hjkl_syntax::SetLanguageOutcome;
275/// assert!(SetLanguageOutcome::Ready.is_known());
276/// assert!(SetLanguageOutcome::Loading("rust".to_string()).is_known());
277/// assert!(!SetLanguageOutcome::Unknown.is_known());
278/// ```
279#[non_exhaustive]
280pub enum SetLanguageOutcome {
281 /// Grammar was already cached (or found fresh on disk) — installed
282 /// immediately. Buffer will highlight on the next render.
283 Ready,
284 /// Grammar is being fetched/compiled on the background pool. Buffer
285 /// renders as plain text until [`SyntaxLayer::poll_pending_loads`] fires
286 /// the `Ready` event for this buffer. The inner `String` is the language
287 /// name (useful for status-line indicators).
288 Loading(#[allow(dead_code)] String),
289 /// Extension unrecognized. No grammar — plain text only.
290 Unknown,
291}
292
293impl SetLanguageOutcome {
294 /// `true` when a grammar was found (either already cached or now in
295 /// flight). Drop-in replacement for the old `bool` return value.
296 pub fn is_known(&self) -> bool {
297 matches!(self, Self::Ready | Self::Loading(_))
298 }
299}
300
301/// Event emitted by [`SyntaxLayer::poll_pending_loads`] for each handle that
302/// resolved during the tick.
303///
304/// # Examples
305///
306/// ```
307/// use hjkl_syntax::LoadEvent;
308/// let e = LoadEvent::Ready { id: 0, name: "rust".into() };
309/// match e {
310/// LoadEvent::Ready { id, name } => assert_eq!(name, "rust"),
311/// LoadEvent::Failed { .. } => panic!("unexpected"),
312/// // LoadEvent is #[non_exhaustive] — handle future variants.
313/// _ => {}
314/// }
315/// ```
316#[non_exhaustive]
317pub enum LoadEvent {
318 /// Grammar installed; trigger a redraw + re-submit for `id`.
319 Ready {
320 /// The buffer id the grammar was loaded for.
321 id: BufferId,
322 /// The resolved language name (e.g. `"rust"`).
323 name: String,
324 },
325 /// Load failed (clone/compile error); buffer stays plain text.
326 Failed {
327 /// The buffer id the grammar was loaded for.
328 id: BufferId,
329 /// The resolved language name.
330 name: String,
331 /// Human-readable error message.
332 error: String,
333 },
334}
335
336/// Exhaustive view of a [`LoadEvent`] for use in
337/// [`SyntaxLayer::dispatch_load_event`] callbacks.
338///
339/// Unlike [`LoadEvent`] (which is `#[non_exhaustive]`), matching on this enum
340/// requires no wildcard arm and produces a compile error when new variants are
341/// added.
342#[derive(Debug)]
343pub enum LoadEventKind<'a> {
344 /// Grammar installed successfully.
345 Ready {
346 /// The buffer id the grammar was loaded for.
347 id: BufferId,
348 /// The resolved language name.
349 name: &'a str,
350 },
351 /// Grammar load failed.
352 Failed {
353 /// The buffer id the grammar was loaded for.
354 id: BufferId,
355 /// The resolved language name.
356 name: &'a str,
357 /// Human-readable error message.
358 error: &'a str,
359 },
360}
361
362/// Exhaustive view of a [`ParseKind`] for use in
363/// [`SyntaxLayer::dispatch_parse_kind`] callbacks.
364///
365/// Unlike [`ParseKind`] (which is `#[non_exhaustive]`), matching on this enum
366/// requires no wildcard arm.
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum ParseKindKind {
369 /// The current visible viewport region.
370 Viewport,
371 /// The top of the document — pre-cached for `gg`.
372 Top,
373 /// The bottom of the document — pre-cached for `G`.
374 Bottom,
375}
376
377// ---------------------------------------------------------------------------
378// Internal types
379// ---------------------------------------------------------------------------
380
381/// Cached `(source, row_starts)` keyed off buffer identity (dirty_gen +
382/// shape). Built once per buffer mutation on the **main** thread and
383/// shipped to the worker as `Arc`s so the worker doesn't memcpy a 1.3 MB
384/// Rust file for every parse request.
385struct RenderCache {
386 dirty_gen: u64,
387 len_bytes: usize,
388 line_count: u32,
389 source: Arc<String>,
390 row_starts: Arc<Vec<usize>>,
391}
392
393/// A parse + render job submitted to the worker. The worker owns the
394/// retained tree, applies any queued `InputEdit`s, reparses, runs the
395/// viewport highlight + error scan, builds the per-row span table, and
396/// sends the result back via mpsc.
397struct ParseRequest {
398 buffer_id: BufferId,
399 source: Arc<String>,
400 row_starts: Arc<Vec<usize>>,
401 edits: Vec<InputEdit>,
402 viewport_byte_range: std::ops::Range<usize>,
403 viewport_top: usize,
404 viewport_height: usize,
405 row_count: usize,
406 dirty_gen: u64,
407 /// When `true` the worker drops its retained tree before parsing.
408 /// Used after `:e` reload / theme swap so the next parse is cold.
409 reset: bool,
410 kind: ParseKind,
411}
412
413/// Control + data messages the worker thread waits on.
414enum Msg {
415 /// Set / replace the highlighter for a buffer. `None` detaches.
416 SetLanguage(BufferId, Option<Arc<Grammar>>),
417 /// Remove all worker state for a buffer (highlighter, retained tree,
418 /// parse-cache key). Sent on buffer close.
419 Forget(BufferId),
420 /// Replace the theme. Style resolution happens on the worker.
421 SetTheme(Arc<dyn Theme + Send + Sync>),
422 /// A parse + render job. Coalesced — only the latest pending
423 /// `Parse` survives if the worker is busy.
424 Parse(ParseRequest),
425 /// Worker should exit. Sent on `SyntaxWorker::drop`.
426 Quit,
427}
428
429/// Maximum number of parse requests allowed in the queue at once.
430const PARSE_QUEUE_CAP: usize = 8;
431
432/// Shared slot the main thread drops new requests into.
433struct Pending {
434 /// FIFO of parse requests. Per-(buffer_id, kind) deduped: submitting a
435 /// request for buffer A + kind Viewport replaces any existing entry for
436 /// that pair rather than appending. Capped at [`PARSE_QUEUE_CAP`] total entries.
437 parse_queue: std::collections::VecDeque<ParseRequest>,
438 /// FIFO of control messages (SetLanguage, Forget, SetTheme, Quit).
439 controls: std::collections::VecDeque<Msg>,
440}
441
442impl Pending {
443 fn new() -> Self {
444 Self {
445 parse_queue: std::collections::VecDeque::new(),
446 controls: std::collections::VecDeque::new(),
447 }
448 }
449
450 fn has_work(&self) -> bool {
451 !self.parse_queue.is_empty() || !self.controls.is_empty()
452 }
453
454 /// Enqueue a parse request with per-`(buffer_id, kind)` deduplication.
455 ///
456 /// - If a request for the same `(buffer_id, kind)` pair is already in
457 /// the queue, replace it in-place (latest wins). Requests with the
458 /// same buffer_id but different kinds (Viewport / Top / Bottom)
459 /// coexist in the queue so all three regions can be pre-cached.
460 /// - If the queue is at capacity and there is no existing entry for
461 /// this `(buffer_id, kind)`, evict the oldest entry before pushing.
462 fn push_parse(&mut self, req: ParseRequest) {
463 for slot in self.parse_queue.iter_mut() {
464 if slot.buffer_id == req.buffer_id && slot.kind == req.kind {
465 *slot = req;
466 return;
467 }
468 }
469 if self.parse_queue.len() >= PARSE_QUEUE_CAP {
470 self.parse_queue.pop_front();
471 }
472 self.parse_queue.push_back(req);
473 }
474}
475
476// ---------------------------------------------------------------------------
477// SyntaxWorker — background thread
478// ---------------------------------------------------------------------------
479
480/// Background worker that owns the `Highlighter` and the retained
481/// tree-sitter `Tree`. Communicates with the main thread via a
482/// `Mutex<Pending>` + `Condvar` for submits, and an mpsc channel for
483/// rendered output.
484///
485/// # Examples
486///
487/// ```no_run
488/// use std::sync::Arc;
489/// use hjkl_syntax::SyntaxWorker;
490/// use hjkl_bonsai::DotFallbackTheme;
491/// use hjkl_lang::LanguageDirectory;
492///
493/// let theme = Arc::new(DotFallbackTheme::dark());
494/// let dir = Arc::new(LanguageDirectory::new().unwrap());
495/// let worker = SyntaxWorker::spawn(theme, dir);
496/// drop(worker); // joins the thread
497/// ```
498pub struct SyntaxWorker {
499 pending: Arc<(Mutex<Pending>, Condvar)>,
500 rx: std::sync::mpsc::Receiver<RenderOutput>,
501 handle: Option<JoinHandle<()>>,
502}
503
504impl SyntaxWorker {
505 /// Spawn a fresh worker thread with the given theme and language directory.
506 pub fn spawn(theme: Arc<dyn Theme + Send + Sync>, directory: Arc<LanguageDirectory>) -> Self {
507 let pending = Arc::new((Mutex::new(Pending::new()), Condvar::new()));
508 let (tx, rx) = std::sync::mpsc::channel();
509 let pending_for_thread = Arc::clone(&pending);
510 let handle = thread::Builder::new()
511 .name("hjkl-syntax".into())
512 .spawn(move || worker_loop(pending_for_thread, tx, theme, directory))
513 .expect("spawn syntax worker");
514 Self {
515 pending,
516 rx,
517 handle: Some(handle),
518 }
519 }
520
521 /// Send a control message. Wakes the worker.
522 fn enqueue_control(&self, msg: Msg) {
523 let (lock, cvar) = &*self.pending;
524 let mut p = lock.lock().expect("syntax pending mutex poisoned");
525 p.controls.push_back(msg);
526 cvar.notify_one();
527 }
528
529 /// Set / replace the highlighter for a buffer. `None` detaches.
530 pub fn set_language(&self, id: BufferId, grammar: Option<Arc<Grammar>>) {
531 self.enqueue_control(Msg::SetLanguage(id, grammar));
532 }
533
534 /// Forget all worker state for a buffer (highlighter + tree).
535 /// Sent on buffer close.
536 pub fn forget(&self, id: BufferId) {
537 self.enqueue_control(Msg::Forget(id));
538 }
539
540 /// Replace the theme used for capture → style resolution.
541 pub fn set_theme(&self, theme: Arc<dyn Theme + Send + Sync>) {
542 self.enqueue_control(Msg::SetTheme(theme));
543 }
544
545 /// Submit a parse job. Per-`(buffer_id, kind)` deduplication: if a
546 /// request for the same pair is already pending it is replaced in-place.
547 /// Across different pairs the queue is FIFO. Returns immediately.
548 fn submit(&self, req: ParseRequest) {
549 let (lock, cvar) = &*self.pending;
550 let mut p = lock.lock().expect("syntax pending mutex poisoned");
551 p.push_parse(req);
552 cvar.notify_one();
553 }
554
555 /// Drain all available render results, returning the most recent
556 /// one. Earlier results are discarded — they'd just be overwritten
557 /// by the latest install anyway, and this keeps the install path
558 /// O(1) per frame regardless of backlog depth.
559 #[allow(dead_code)]
560 pub fn try_recv_latest(&self) -> Option<RenderOutput> {
561 let mut latest: Option<RenderOutput> = None;
562 while let Ok(out) = self.rx.try_recv() {
563 latest = Some(out);
564 }
565 latest
566 }
567
568 /// Drain all available render results, returning them all (one per
569 /// `(buffer_id, kind)` pair that completed). Unlike
570 /// [`Self::try_recv_latest`] this does not discard earlier results —
571 /// required so pre-warmed results for non-active buffers can be routed
572 /// to the right slot cache.
573 pub fn try_recv_all(&self) -> Vec<RenderOutput> {
574 let mut results = Vec::new();
575 while let Ok(out) = self.rx.try_recv() {
576 if let Some(existing) = results
577 .iter_mut()
578 .find(|r: &&mut RenderOutput| r.buffer_id == out.buffer_id && r.kind == out.kind)
579 {
580 *existing = out;
581 } else {
582 results.push(out);
583 }
584 }
585 results
586 }
587
588 /// Wait up to `timeout` for the next result, then drain anything
589 /// else that arrived after it and return the latest.
590 pub fn wait_for_latest(&self, timeout: std::time::Duration) -> Option<RenderOutput> {
591 let mut latest = self.rx.recv_timeout(timeout).ok();
592 while let Ok(out) = self.rx.try_recv() {
593 latest = Some(out);
594 }
595 latest
596 }
597
598 /// Wait up to `timeout` for the first result to arrive, then drain
599 /// every additional result already in the channel. Returns ALL
600 /// results in arrival order (latest per `(buffer_id, kind)` coalesced).
601 ///
602 /// Unlike [`Self::wait_for_latest`] this does NOT discard earlier
603 /// results — required when pre-warming non-active buffers so both the
604 /// active buffer's result and pre-warm results reach their slot caches.
605 pub fn wait_then_recv_all(&self, timeout: std::time::Duration) -> Vec<RenderOutput> {
606 let mut results: Vec<RenderOutput> = Vec::new();
607 if let Ok(first) = self.rx.recv_timeout(timeout) {
608 results.push(first);
609 }
610 while let Ok(out) = self.rx.try_recv() {
611 if let Some(existing) = results
612 .iter_mut()
613 .find(|r: &&mut RenderOutput| r.buffer_id == out.buffer_id && r.kind == out.kind)
614 {
615 *existing = out;
616 } else {
617 results.push(out);
618 }
619 }
620 results
621 }
622}
623
624impl Drop for SyntaxWorker {
625 fn drop(&mut self) {
626 {
627 let (lock, cvar) = &*self.pending;
628 if let Ok(mut p) = lock.lock() {
629 p.controls.push_back(Msg::Quit);
630 cvar.notify_one();
631 }
632 }
633 if let Some(h) = self.handle.take() {
634 let _ = h.join();
635 }
636 }
637}
638
639// ---------------------------------------------------------------------------
640// Worker-side per-buffer state
641// ---------------------------------------------------------------------------
642
643/// Per-buffer state retained on the worker side.
644struct WorkerBufferState {
645 highlighter: Highlighter,
646 last_parsed_dirty_gen: Option<u64>,
647}
648
649// ---------------------------------------------------------------------------
650// Worker loop
651// ---------------------------------------------------------------------------
652
653fn worker_loop(
654 pending: Arc<(Mutex<Pending>, Condvar)>,
655 tx: std::sync::mpsc::Sender<RenderOutput>,
656 initial_theme: Arc<dyn Theme + Send + Sync>,
657 directory: Arc<LanguageDirectory>,
658) {
659 use std::time::Instant;
660
661 let mut buffers: HashMap<BufferId, WorkerBufferState> = HashMap::new();
662 let mut theme: Arc<dyn Theme + Send + Sync> = initial_theme;
663 let marker_pass = CommentMarkerPass::new();
664
665 loop {
666 let msg = {
667 let (lock, cvar) = &*pending;
668 let mut p = lock.lock().expect("syntax pending mutex poisoned");
669 while !p.has_work() {
670 p = cvar.wait(p).expect("syntax pending cvar poisoned");
671 }
672 // Drain controls first so SetLanguage / Forget / SetTheme
673 // that arrived alongside a Parse get applied before we run
674 // the parse with stale state.
675 if let Some(c) = p.controls.pop_front() {
676 c
677 } else {
678 Msg::Parse(
679 p.parse_queue
680 .pop_front()
681 .expect("has_work() implies parse_queue non-empty"),
682 )
683 }
684 };
685
686 match msg {
687 Msg::Quit => return,
688 Msg::SetLanguage(id, None) => {
689 buffers.remove(&id);
690 }
691 Msg::SetLanguage(id, Some(grammar)) => {
692 let lang = grammar.name().to_string();
693 match Highlighter::new(grammar) {
694 Ok(h) => {
695 buffers.insert(
696 id,
697 WorkerBufferState {
698 highlighter: h,
699 last_parsed_dirty_gen: None,
700 },
701 );
702 }
703 Err(e) => {
704 tracing::error!(
705 buffer_id = id,
706 language = %lang,
707 error = %e,
708 "failed to attach syntax highlighter"
709 );
710 buffers.remove(&id);
711 }
712 }
713 }
714 Msg::Forget(id) => {
715 buffers.remove(&id);
716 }
717 Msg::SetTheme(t) => {
718 theme = t;
719 }
720 Msg::Parse(req) => {
721 let Some(state) = buffers.get_mut(&req.buffer_id) else {
722 continue;
723 };
724 let h = &mut state.highlighter;
725 let mut perf = PerfBreakdown::default();
726 if req.reset {
727 h.reset();
728 state.last_parsed_dirty_gen = None;
729 }
730 // Only (re-)parse if the retained tree does not already
731 // represent this dirty_gen. Non-empty `edits` are applied
732 // below when a parse IS needed; if the tree is already current
733 // they are stale (a prior request for the same dirty_gen
734 // already processed them) and must be discarded — applying
735 // them again would corrupt the tree's node positions.
736 let needs_parse =
737 h.tree().is_none() || state.last_parsed_dirty_gen != Some(req.dirty_gen);
738 if needs_parse {
739 for e in &req.edits {
740 h.edit(e);
741 }
742 let bytes = req.source.as_bytes();
743 let t = Instant::now();
744 let parsed_ok = if h.tree().is_none() {
745 h.parse_initial(bytes);
746 true
747 } else {
748 h.parse_incremental(bytes)
749 };
750 if !parsed_ok {
751 continue;
752 }
753 perf.parse_us = t.elapsed().as_micros();
754 state.last_parsed_dirty_gen = Some(req.dirty_gen);
755 }
756 let bytes = req.source.as_bytes();
757
758 let t = Instant::now();
759 let mut flat_spans = h.highlight_range_with_injections(
760 bytes,
761 req.viewport_byte_range.clone(),
762 |name| directory.by_name(name),
763 );
764 perf.highlight_us = t.elapsed().as_micros();
765
766 // Overlay TODO/FIXME/NOTE/WARN marker spans onto comment spans.
767 marker_pass.apply(&mut flat_spans, bytes);
768
769 let t = Instant::now();
770 let by_row = build_by_row(
771 &flat_spans,
772 bytes,
773 &req.row_starts,
774 req.row_count,
775 theme.as_ref(),
776 );
777 perf.by_row_us = t.elapsed().as_micros();
778
779 let t = Instant::now();
780 let signs = collect_diag_signs(h, bytes, req.viewport_byte_range, &req.row_starts);
781 perf.diag_us = t.elapsed().as_micros();
782
783 let key = (req.dirty_gen, req.viewport_top, req.viewport_height);
784 let _ = tx.send(RenderOutput {
785 buffer_id: req.buffer_id,
786 spans: by_row,
787 signs,
788 key,
789 perf,
790 kind: req.kind,
791 });
792 }
793 }
794 }
795}
796
797// ---------------------------------------------------------------------------
798// Helper: build per-row span table (renderer-agnostic StyleSpec output)
799// ---------------------------------------------------------------------------
800
801/// Resolve flat highlight spans into a per-row span table sized to
802/// `row_count`. Pulled out so the worker can call it in isolation and tests
803/// can exercise it without a running thread.
804///
805/// Output: `Vec<Vec<(byte_start, byte_end, StyleSpec)>>` indexed by row.
806pub fn build_by_row(
807 flat_spans: &[hjkl_bonsai::HighlightSpan],
808 bytes: &[u8],
809 row_starts: &[usize],
810 row_count: usize,
811 theme: &dyn Theme,
812) -> Vec<Vec<(usize, usize, StyleSpec)>> {
813 let mut by_row: Vec<Vec<(usize, usize, StyleSpec)>> = vec![Vec::new(); row_count];
814
815 for span in flat_spans {
816 let style = match theme.style(span.capture()) {
817 Some(s) => s,
818 None => continue,
819 };
820
821 let span_start = span.byte_range.start;
822 let span_end = span.byte_range.end;
823
824 let start_row = row_starts
825 .partition_point(|&rs| rs <= span_start)
826 .saturating_sub(1);
827
828 let mut row = start_row;
829 while row < row_count {
830 let row_byte_start = row_starts[row];
831 let row_byte_end = row_starts
832 .get(row + 1)
833 .map(|&s| s.saturating_sub(1))
834 .unwrap_or(bytes.len());
835
836 if row_byte_start >= span_end {
837 break;
838 }
839
840 let local_start = span_start.saturating_sub(row_byte_start);
841 let local_end = span_end.min(row_byte_end) - row_byte_start;
842
843 if local_end > local_start {
844 by_row[row].push((local_start, local_end, *style));
845 }
846
847 row += 1;
848 }
849 }
850
851 by_row
852}
853
854// ---------------------------------------------------------------------------
855// Helper: collect diagnostic signs
856// ---------------------------------------------------------------------------
857
858/// Collect diagnostic [`DiagSign`]s from tree-sitter ERROR / MISSING nodes
859/// intersecting the viewport, deduped to one per row.
860fn collect_diag_signs(
861 h: &mut Highlighter,
862 bytes: &[u8],
863 viewport_byte_range: std::ops::Range<usize>,
864 row_starts: &[usize],
865) -> Vec<DiagSign> {
866 let errors = h.parse_errors_range(bytes, viewport_byte_range);
867 let mut signs: Vec<DiagSign> = Vec::new();
868 let mut last_row: Option<usize> = None;
869 for err in &errors {
870 let r = row_starts
871 .partition_point(|&rs| rs <= err.byte_range.start)
872 .saturating_sub(1);
873 if last_row == Some(r) {
874 continue;
875 }
876 last_row = Some(r);
877 signs.push(DiagSign::new(r, 'E', 100));
878 }
879 signs
880}
881
882// ---------------------------------------------------------------------------
883// Per-buffer client state (main thread)
884// ---------------------------------------------------------------------------
885
886/// Per-buffer client-side state. One of these per open buffer in
887/// `SyntaxLayer.clients`. Mirrors the worker's `WorkerBufferState` but
888/// holds the source-cache + edit queue, which live on the main thread.
889#[derive(Default)]
890struct BufferClient {
891 has_language: bool,
892 current_lang: Option<Arc<Grammar>>,
893 cache: Option<RenderCache>,
894 pending_edits: Vec<InputEdit>,
895 pending_reset: bool,
896 last_submitted_dirty_gen: Option<u64>,
897}
898
899// ---------------------------------------------------------------------------
900// In-flight grammar load tracking
901// ---------------------------------------------------------------------------
902
903struct PendingLoad {
904 id: BufferId,
905 name: String,
906 handle: LoadHandle,
907}
908
909// ---------------------------------------------------------------------------
910// SyntaxLayer — main-thread facade
911// ---------------------------------------------------------------------------
912
913/// Per-App syntax highlighting layer. Multiplexes per-buffer state
914/// (helix-style): each open buffer carries its own retained tree
915/// (worker-side) plus source-cache and edit queue (here). One worker
916/// thread serves all buffers.
917///
918/// # Examples
919///
920/// ```no_run
921/// use std::sync::Arc;
922/// use hjkl_syntax::SyntaxLayer;
923/// use hjkl_bonsai::DotFallbackTheme;
924/// use hjkl_lang::LanguageDirectory;
925///
926/// let theme = Arc::new(DotFallbackTheme::dark());
927/// let dir = Arc::new(LanguageDirectory::new().unwrap());
928/// let layer = SyntaxLayer::new(theme, dir);
929/// ```
930pub struct SyntaxLayer {
931 /// Shared grammar resolver.
932 pub directory: Arc<LanguageDirectory>,
933 /// Active theme.
934 theme: Arc<dyn Theme + Send + Sync>,
935 worker: SyntaxWorker,
936 clients: HashMap<BufferId, BufferClient>,
937 pending_loads: Vec<PendingLoad>,
938 /// Per-grammar synchronous `Highlighter` cache used by [`Self::preview_render`].
939 preview_highlighters: Mutex<HashMap<String, Highlighter>>,
940 /// Last perf breakdown received via `take_all_results`. Updated on every
941 /// successful drain.
942 pub last_perf: PerfBreakdown,
943}
944
945impl SyntaxLayer {
946 /// Create a new layer with no buffers attached, the given theme, and
947 /// the given language directory.
948 ///
949 /// # Examples
950 ///
951 /// ```no_run
952 /// use std::sync::Arc;
953 /// use hjkl_syntax::SyntaxLayer;
954 /// use hjkl_bonsai::DotFallbackTheme;
955 /// use hjkl_lang::LanguageDirectory;
956 ///
957 /// let theme = Arc::new(DotFallbackTheme::dark());
958 /// let dir = Arc::new(LanguageDirectory::new().unwrap());
959 /// let layer = SyntaxLayer::new(theme, dir);
960 /// ```
961 pub fn new(theme: Arc<dyn Theme + Send + Sync>, directory: Arc<LanguageDirectory>) -> Self {
962 let worker = SyntaxWorker::spawn(Arc::clone(&theme), Arc::clone(&directory));
963 Self {
964 directory,
965 theme,
966 worker,
967 clients: HashMap::new(),
968 pending_loads: Vec::new(),
969 preview_highlighters: Mutex::new(HashMap::new()),
970 last_perf: PerfBreakdown::default(),
971 }
972 }
973
974 fn client_mut(&mut self, id: BufferId) -> &mut BufferClient {
975 self.clients.entry(id).or_default()
976 }
977
978 /// Detect the language for `path` and ship it to the worker.
979 ///
980 /// Non-blocking: uses the async grammar loader so opening a file with an
981 /// uninstalled grammar no longer freezes the UI.
982 ///
983 /// - `Ready` — grammar cached/found on disk; installed immediately.
984 /// - `Loading` — clone+compile kicked off; buffer renders as plain text
985 /// until `poll_pending_loads` fires the companion `LoadEvent::Ready`.
986 /// - `Unknown` — unrecognized extension; plain text only.
987 ///
988 /// # Examples
989 ///
990 /// ```no_run
991 /// use std::sync::Arc;
992 /// use std::path::Path;
993 /// use hjkl_syntax::{SyntaxLayer, SetLanguageOutcome};
994 /// use hjkl_bonsai::DotFallbackTheme;
995 /// use hjkl_lang::LanguageDirectory;
996 ///
997 /// let theme = Arc::new(DotFallbackTheme::dark());
998 /// let dir = Arc::new(LanguageDirectory::new().unwrap());
999 /// let mut layer = SyntaxLayer::new(theme, dir);
1000 /// let outcome = layer.set_language_for_path(0, Path::new("a.zzz_not_real"));
1001 /// assert!(!outcome.is_known());
1002 /// ```
1003 pub fn set_language_for_path(&mut self, id: BufferId, path: &Path) -> SetLanguageOutcome {
1004 match self.directory.request_for_path(path) {
1005 GrammarRequest::Cached(grammar) => {
1006 self.worker.set_language(id, Some(grammar.clone()));
1007 let c = self.client_mut(id);
1008 c.current_lang = Some(grammar);
1009 c.has_language = true;
1010 SetLanguageOutcome::Ready
1011 }
1012 GrammarRequest::Loading { name, handle } => {
1013 self.worker.set_language(id, None);
1014 let c = self.client_mut(id);
1015 c.current_lang = None;
1016 c.has_language = false;
1017 self.pending_loads.push(PendingLoad {
1018 id,
1019 name: name.clone(),
1020 handle,
1021 });
1022 SetLanguageOutcome::Loading(name)
1023 }
1024 GrammarRequest::Unknown => {
1025 self.worker.set_language(id, None);
1026 let c = self.client_mut(id);
1027 c.current_lang = None;
1028 c.has_language = false;
1029 SetLanguageOutcome::Unknown
1030 }
1031 _ => {
1032 // Future GrammarRequest variants — treat as Unknown.
1033 self.worker.set_language(id, None);
1034 let c = self.client_mut(id);
1035 c.current_lang = None;
1036 c.has_language = false;
1037 SetLanguageOutcome::Unknown
1038 }
1039 }
1040 }
1041
1042 /// Poll all in-flight grammar loads. Call this once per tick from the
1043 /// main loop (alongside `take_all_results`) so completed loads install
1044 /// immediately without waiting for the next file open.
1045 ///
1046 /// Returns one `LoadEvent` per handle that resolved during this tick.
1047 /// Non-empty results should trigger a redraw and re-submit render.
1048 pub fn poll_pending_loads(&mut self) -> Vec<LoadEvent> {
1049 let mut events = Vec::new();
1050 let mut i = 0;
1051 while i < self.pending_loads.len() {
1052 match self.pending_loads[i].handle.try_recv() {
1053 None => {
1054 i += 1;
1055 }
1056 Some(Ok(lib_path)) => {
1057 let name = self.pending_loads[i].name.clone();
1058 let bid = self.pending_loads[i].id;
1059 self.pending_loads.swap_remove(i);
1060 match self.directory.complete_load(&name, lib_path) {
1061 Ok(grammar) => {
1062 self.worker.set_language(bid, Some(grammar.clone()));
1063 let c = self.client_mut(bid);
1064 c.current_lang = Some(grammar);
1065 c.has_language = true;
1066 events.push(LoadEvent::Ready { id: bid, name });
1067 }
1068 Err(e) => {
1069 events.push(LoadEvent::Failed {
1070 id: bid,
1071 name,
1072 error: format!("{e:#}"),
1073 });
1074 }
1075 }
1076 }
1077 Some(Err(err)) => {
1078 let name = self.pending_loads[i].name.clone();
1079 let bid = self.pending_loads[i].id;
1080 self.pending_loads.swap_remove(i);
1081 events.push(LoadEvent::Failed {
1082 id: bid,
1083 name,
1084 error: err.to_string(),
1085 });
1086 }
1087 }
1088 }
1089 events
1090 }
1091
1092 /// Drop all state for a buffer. Call on close.
1093 pub fn forget(&mut self, id: BufferId) {
1094 self.clients.remove(&id);
1095 self.worker.forget(id);
1096 }
1097
1098 /// Swap the active theme. Next render call will use the new theme.
1099 pub fn set_theme(&mut self, theme: Arc<dyn Theme + Send + Sync>) {
1100 self.theme = Arc::clone(&theme);
1101 self.worker.set_theme(theme);
1102 }
1103
1104 /// Synchronous viewport-only preview render. Builds a `String`
1105 /// containing **only** the visible rows, parses it from scratch with a
1106 /// one-shot `Highlighter`, runs `highlight_range` over the slice, and
1107 /// returns a `RenderOutput` whose `spans` table is padded with empty rows
1108 /// above the viewport so the install path indexes the right rows.
1109 ///
1110 /// Returns `None` when no language is attached or when the viewport is empty.
1111 pub fn preview_render(
1112 &self,
1113 id: BufferId,
1114 buffer: &impl Query,
1115 viewport_top: usize,
1116 viewport_height: usize,
1117 ) -> Option<RenderOutput> {
1118 let grammar = self.clients.get(&id).and_then(|c| c.current_lang.clone())?;
1119 let row_count = buffer.line_count() as usize;
1120 if row_count == 0 || viewport_height == 0 {
1121 return None;
1122 }
1123 let vp_top = viewport_top.min(row_count);
1124 let vp_end_row = (vp_top + viewport_height).min(row_count);
1125 if vp_end_row <= vp_top {
1126 return None;
1127 }
1128
1129 let mut source = String::new();
1130 for r in vp_top..vp_end_row {
1131 if r > vp_top {
1132 source.push('\n');
1133 }
1134 source.push_str(&buffer.line(r as u32));
1135 }
1136 let bytes = source.as_bytes();
1137 let mut row_starts: Vec<usize> = vec![0];
1138 for (i, &b) in bytes.iter().enumerate() {
1139 if b == b'\n' {
1140 row_starts.push(i + 1);
1141 }
1142 }
1143 let local_row_count = vp_end_row - vp_top;
1144
1145 let grammar_name = grammar.name().to_string();
1146 let mut cache = self.preview_highlighters.lock().ok()?;
1147 let h = match cache.entry(grammar_name) {
1148 std::collections::hash_map::Entry::Occupied(o) => {
1149 let h = o.into_mut();
1150 h.reset();
1151 h
1152 }
1153 std::collections::hash_map::Entry::Vacant(v) => match Highlighter::new(grammar) {
1154 Ok(h) => v.insert(h),
1155 Err(_) => return None,
1156 },
1157 };
1158 let mut flat_spans =
1159 h.highlight_with_injections(bytes, |name| self.directory.by_name(name));
1160 drop(cache);
1161
1162 let marker_pass = CommentMarkerPass::new();
1163 marker_pass.apply(&mut flat_spans, bytes);
1164
1165 let local_by_row = build_by_row(
1166 &flat_spans,
1167 bytes,
1168 &row_starts,
1169 local_row_count,
1170 self.theme.as_ref(),
1171 );
1172
1173 let mut spans: Vec<Vec<(usize, usize, StyleSpec)>> = vec![Vec::new(); vp_top];
1174 spans.extend(local_by_row);
1175
1176 Some(RenderOutput {
1177 buffer_id: id,
1178 spans,
1179 signs: Vec::new(),
1180 key: (buffer.dirty_gen(), viewport_top, viewport_height),
1181 perf: PerfBreakdown::default(),
1182 kind: ParseKind::Viewport,
1183 })
1184 }
1185
1186 /// Ask the worker to drop this buffer's retained tree on the next
1187 /// parse so the next submission is cold.
1188 pub fn reset(&mut self, id: BufferId) {
1189 self.client_mut(id).pending_reset = true;
1190 }
1191
1192 /// Buffer a batch of engine `ContentEdit`s to be shipped to the
1193 /// worker on the next `submit_render`. Translates the engine's
1194 /// position pairs into `tree_sitter::InputEdit`s up front.
1195 pub fn apply_edits(&mut self, id: BufferId, edits: &[hjkl_engine::ContentEdit]) {
1196 let c = self.client_mut(id);
1197 if !c.has_language {
1198 return;
1199 }
1200 for e in edits {
1201 c.pending_edits.push(InputEdit {
1202 start_byte: e.start_byte,
1203 old_end_byte: e.old_end_byte,
1204 new_end_byte: e.new_end_byte,
1205 start_position: Point {
1206 row: e.start_position.0 as usize,
1207 column: e.start_position.1 as usize,
1208 },
1209 old_end_position: Point {
1210 row: e.old_end_position.0 as usize,
1211 column: e.old_end_position.1 as usize,
1212 },
1213 new_end_position: Point {
1214 row: e.new_end_position.0 as usize,
1215 column: e.new_end_position.1 as usize,
1216 },
1217 });
1218 }
1219 }
1220
1221 /// Build (or reuse) the cached `(source, row_starts)` for the
1222 /// current buffer state and submit a parse + render job to the worker.
1223 /// Returns immediately. Drain the result with [`Self::take_all_results`].
1224 ///
1225 /// `kind` tags the request so the App can route the result to the correct
1226 /// per-slot cache field. Pass [`ParseKind::Viewport`] for normal
1227 /// scroll-driven parses.
1228 ///
1229 /// Returns `None` and submits nothing when no language is attached.
1230 /// Returns `Some(source_build_us)` when a request was submitted — `0`
1231 /// means the cache was reused.
1232 pub fn submit_render(
1233 &mut self,
1234 id: BufferId,
1235 buffer: &impl Query,
1236 viewport_top: usize,
1237 viewport_height: usize,
1238 kind: ParseKind,
1239 ) -> Option<u128> {
1240 use std::time::Instant;
1241 let c = self.client_mut(id);
1242 if !c.has_language {
1243 return None;
1244 }
1245
1246 let dg = buffer.dirty_gen();
1247 let lb = buffer.len_bytes();
1248 let lc = buffer.line_count();
1249 let row_count = lc as usize;
1250
1251 // Rebuild source + row_starts only when the buffer has changed.
1252 let needs_rebuild = match &c.cache {
1253 Some(rc) => rc.dirty_gen != dg || rc.len_bytes != lb || rc.line_count != lc,
1254 None => true,
1255 };
1256 let mut source_build_us = 0u128;
1257 if needs_rebuild {
1258 let t = Instant::now();
1259 let mut source = String::with_capacity(lb);
1260 for r in 0..row_count {
1261 if r > 0 {
1262 source.push('\n');
1263 }
1264 source.push_str(&buffer.line(r as u32));
1265 }
1266 let mut row_starts: Vec<usize> = vec![0];
1267 for (i, &b) in source.as_bytes().iter().enumerate() {
1268 if b == b'\n' {
1269 row_starts.push(i + 1);
1270 }
1271 }
1272 c.cache = Some(RenderCache {
1273 dirty_gen: dg,
1274 len_bytes: lb,
1275 line_count: lc,
1276 source: Arc::new(source),
1277 row_starts: Arc::new(row_starts),
1278 });
1279 source_build_us = t.elapsed().as_micros();
1280 }
1281 let cache = c.cache.as_ref().expect("cache populated above");
1282
1283 let bytes_len = cache.source.len();
1284 let vp_start = buffer.byte_of_row(viewport_top);
1285 let vp_end_row = viewport_top + viewport_height + 1;
1286 let vp_end = buffer.byte_of_row(vp_end_row).min(bytes_len);
1287 let vp_end = vp_end.max(vp_start);
1288
1289 let edits = std::mem::take(&mut c.pending_edits);
1290 let reset = std::mem::replace(&mut c.pending_reset, false);
1291 c.last_submitted_dirty_gen = Some(dg);
1292 let source_arc = Arc::clone(&cache.source);
1293 let row_starts_arc = Arc::clone(&cache.row_starts);
1294
1295 self.worker.submit(ParseRequest {
1296 buffer_id: id,
1297 source: source_arc,
1298 row_starts: row_starts_arc,
1299 edits,
1300 viewport_byte_range: vp_start..vp_end,
1301 viewport_top,
1302 viewport_height,
1303 row_count,
1304 dirty_gen: dg,
1305 reset,
1306 kind,
1307 });
1308
1309 Some(source_build_us)
1310 }
1311
1312 /// Drain the most recent render result the worker has produced (if any).
1313 /// Older results are discarded — only the latest matters for install.
1314 /// Updates `last_perf` as a side effect.
1315 ///
1316 /// Kept for use in perf-smoke tests; production code drains via
1317 /// [`Self::take_all_results`] so multi-buffer results are routed.
1318 #[allow(dead_code)]
1319 pub fn take_result(&mut self) -> Option<RenderOutput> {
1320 let out = self.worker.try_recv_latest()?;
1321 self.last_perf = out.perf;
1322 Some(out)
1323 }
1324
1325 /// Drain all render results the worker has produced since the last drain
1326 /// (one per `(buffer_id, kind)` that completed). Updates `last_perf` from
1327 /// the last result if any are present.
1328 pub fn take_all_results(&mut self) -> Vec<RenderOutput> {
1329 let results = self.worker.try_recv_all();
1330 if let Some(last) = results.last() {
1331 self.last_perf = last.perf;
1332 }
1333 results
1334 }
1335
1336 /// Block up to `timeout` for the worker's next result, then drain any
1337 /// others that arrived after it.
1338 pub fn wait_result(&mut self, timeout: std::time::Duration) -> Option<RenderOutput> {
1339 let out = self.worker.wait_for_latest(timeout)?;
1340 self.last_perf = out.perf;
1341 Some(out)
1342 }
1343
1344 /// Block up to `timeout` for the first result, then drain ALL available
1345 /// results in arrival order (per-`(buffer_id, kind)` coalesced). Used for
1346 /// big-jump paths that submit the active buffer's parse AND pre-warms for
1347 /// other open buffers in the same tick.
1348 pub fn wait_all_results(&mut self, timeout: std::time::Duration) -> Vec<RenderOutput> {
1349 let results = self.worker.wait_then_recv_all(timeout);
1350 if let Some(last) = results.last() {
1351 self.last_perf = last.perf;
1352 }
1353 results
1354 }
1355
1356 /// Synchronously drain the next result, blocking up to `timeout`.
1357 /// Returns `None` on timeout. Used at startup so the very first frame
1358 /// can paint with highlights when the worker is fast enough.
1359 pub fn wait_for_initial_result(
1360 &mut self,
1361 timeout: std::time::Duration,
1362 ) -> Option<RenderOutput> {
1363 self.wait_result(timeout)
1364 }
1365
1366 /// Test-only alias for [`Self::wait_for_initial_result`].
1367 #[cfg(test)]
1368 pub fn wait_for_result(&mut self, timeout: std::time::Duration) -> Option<RenderOutput> {
1369 self.wait_for_initial_result(timeout)
1370 }
1371
1372 /// Returns `true` if a client is tracked for the given buffer id.
1373 /// Exposed for tests in consumer crates that wrap `SyntaxLayer`.
1374 #[doc(hidden)]
1375 pub fn has_client(&self, id: BufferId) -> bool {
1376 self.clients.contains_key(&id)
1377 }
1378
1379 /// Returns the `pending_reset` flag for the given buffer id, or `false`
1380 /// if no client is tracked.
1381 /// Exposed for tests in consumer crates that wrap `SyntaxLayer`.
1382 #[doc(hidden)]
1383 pub fn client_pending_reset(&self, id: BufferId) -> bool {
1384 self.clients
1385 .get(&id)
1386 .map(|c| c.pending_reset)
1387 .unwrap_or(false)
1388 }
1389
1390 /// Dispatch a [`LoadEvent`] through a caller-supplied handler.
1391 ///
1392 /// The handler receives each known variant as an exhaustive inner enum so
1393 /// consumers never need a `_ => {}` wildcard arm for `LoadEvent`'s
1394 /// `#[non_exhaustive]` restriction. Unknown future variants are silently
1395 /// ignored (this method is updated when new variants land).
1396 ///
1397 /// Returns `true` when the event was dispatched to a known variant,
1398 /// `false` when it was an unknown future variant and the handler was not
1399 /// called.
1400 ///
1401 /// # Examples
1402 ///
1403 /// ```rust
1404 /// use hjkl_syntax::{LoadEvent, SyntaxLayer};
1405 ///
1406 /// let event = LoadEvent::Ready { id: 0, name: "rust".into() };
1407 /// let mut got_ready = false;
1408 /// let handled = SyntaxLayer::dispatch_load_event(&event, |ev| {
1409 /// use hjkl_syntax::LoadEventKind;
1410 /// match ev {
1411 /// LoadEventKind::Ready { id, name } => { got_ready = true; }
1412 /// LoadEventKind::Failed { .. } => {}
1413 /// }
1414 /// });
1415 /// assert!(handled);
1416 /// assert!(got_ready);
1417 /// ```
1418 pub fn dispatch_load_event(
1419 event: &LoadEvent,
1420 mut handler: impl FnMut(LoadEventKind<'_>),
1421 ) -> bool {
1422 // `#[allow(unreachable_patterns)]` because from inside this crate all
1423 // LoadEvent variants are known; the wildcard exists so this helper
1424 // stays future-proof for external consumers when new variants land.
1425 #[allow(unreachable_patterns)]
1426 match event {
1427 LoadEvent::Ready { id, name } => {
1428 handler(LoadEventKind::Ready { id: *id, name });
1429 true
1430 }
1431 LoadEvent::Failed { id, name, error } => {
1432 handler(LoadEventKind::Failed {
1433 id: *id,
1434 name,
1435 error,
1436 });
1437 true
1438 }
1439 // Unknown future variant — ignore gracefully.
1440 _ => false,
1441 }
1442 }
1443
1444 /// Dispatch a [`ParseKind`] value through a caller-supplied handler.
1445 ///
1446 /// Eliminates `_ => {}` wildcards in consumer match arms by providing an
1447 /// exhaustive inner enum. Unknown future variants fall back to the
1448 /// `ParseKind::Viewport` path (conservative: treat unknown as viewport).
1449 ///
1450 /// Returns `true` for known variants, `false` for unknown ones (and calls
1451 /// the handler with `ParseKindKind::Viewport` as the fallback).
1452 ///
1453 /// # Examples
1454 ///
1455 /// ```rust
1456 /// use hjkl_syntax::{ParseKind, ParseKindKind, SyntaxLayer};
1457 ///
1458 /// let known = SyntaxLayer::dispatch_parse_kind(ParseKind::Top, |k| {
1459 /// assert_eq!(k, ParseKindKind::Top);
1460 /// });
1461 /// assert!(known);
1462 /// ```
1463 pub fn dispatch_parse_kind(kind: ParseKind, mut handler: impl FnMut(ParseKindKind)) -> bool {
1464 // `#[allow(unreachable_patterns)]` because from inside this crate all
1465 // ParseKind variants are known; the wildcard exists so this helper
1466 // stays future-proof for external consumers when new variants land.
1467 #[allow(unreachable_patterns)]
1468 match kind {
1469 ParseKind::Viewport => {
1470 handler(ParseKindKind::Viewport);
1471 true
1472 }
1473 ParseKind::Top => {
1474 handler(ParseKindKind::Top);
1475 true
1476 }
1477 ParseKind::Bottom => {
1478 handler(ParseKindKind::Bottom);
1479 true
1480 }
1481 // Unknown future variant — fall back to Viewport so the caller
1482 // still gets a sensible route.
1483 _ => {
1484 handler(ParseKindKind::Viewport);
1485 false
1486 }
1487 }
1488 }
1489}
1490
1491// ---------------------------------------------------------------------------
1492// Factory helpers
1493// ---------------------------------------------------------------------------
1494
1495/// Build a `SyntaxLayer` using the given theme + language directory.
1496pub fn layer_with_theme(
1497 theme: Arc<DotFallbackTheme>,
1498 directory: Arc<LanguageDirectory>,
1499) -> SyntaxLayer {
1500 SyntaxLayer::new(theme, directory)
1501}
1502
1503/// Build a `SyntaxLayer` with hjkl-bonsai's bundled dark theme.
1504/// Used by tests; the production app constructs via [`layer_with_theme`].
1505#[cfg(test)]
1506pub fn default_layer() -> SyntaxLayer {
1507 let directory = Arc::new(LanguageDirectory::new().expect("language directory"));
1508 SyntaxLayer::new(Arc::new(DotFallbackTheme::dark()), directory)
1509}
1510
1511// ---------------------------------------------------------------------------
1512// Tests
1513// ---------------------------------------------------------------------------
1514
1515#[cfg(test)]
1516mod tests {
1517 use super::*;
1518 use hjkl_buffer::Buffer;
1519 use std::path::Path;
1520 use std::time::Duration;
1521
1522 fn submit_and_wait(
1523 layer: &mut SyntaxLayer,
1524 buf: &Buffer,
1525 top: usize,
1526 height: usize,
1527 ) -> Option<RenderOutput> {
1528 layer.submit_render(TID, buf, top, height, ParseKind::Viewport)?;
1529 layer.wait_for_result(Duration::from_secs(5))
1530 }
1531
1532 const TID: BufferId = 0;
1533
1534 // --- ParseKind ordering ---
1535
1536 #[test]
1537 fn parse_kind_ordering_is_distinct() {
1538 // Perf invariant: the three variants must be distinct so the queue
1539 // deduplication does not accidentally coalesce different region requests.
1540 assert_ne!(ParseKind::Viewport, ParseKind::Top);
1541 assert_ne!(ParseKind::Viewport, ParseKind::Bottom);
1542 assert_ne!(ParseKind::Top, ParseKind::Bottom);
1543 }
1544
1545 // --- DiagSign ---
1546
1547 #[test]
1548 fn diag_sign_new_roundtrip() {
1549 let s = DiagSign::new(7, 'W', 50);
1550 assert_eq!(s.row, 7);
1551 assert_eq!(s.ch, 'W');
1552 assert_eq!(s.priority, 50);
1553 }
1554
1555 #[test]
1556 fn diag_sign_default_is_sensible() {
1557 let s = DiagSign::default();
1558 assert_eq!(s.row, 0);
1559 assert_eq!(s.ch, 'E');
1560 assert_eq!(s.priority, 0);
1561 }
1562
1563 // --- PerfBreakdown ---
1564
1565 #[test]
1566 fn perf_breakdown_default_zeros() {
1567 let p = PerfBreakdown::new();
1568 assert_eq!(p.source_build_us, 0);
1569 assert_eq!(p.parse_us, 0);
1570 assert_eq!(p.highlight_us, 0);
1571 assert_eq!(p.by_row_us, 0);
1572 assert_eq!(p.diag_us, 0);
1573 }
1574
1575 // --- SetLanguageOutcome ---
1576
1577 #[test]
1578 fn set_language_outcome_is_known() {
1579 assert!(SetLanguageOutcome::Ready.is_known());
1580 assert!(SetLanguageOutcome::Loading("rust".to_string()).is_known());
1581 assert!(!SetLanguageOutcome::Unknown.is_known());
1582 }
1583
1584 // --- RenderOutput ---
1585
1586 #[test]
1587 fn render_output_new_roundtrip() {
1588 let out = RenderOutput::new(
1589 99,
1590 vec![vec![]],
1591 vec![DiagSign::new(0, 'E', 100)],
1592 (7, 0, 30),
1593 PerfBreakdown::new(),
1594 ParseKind::Bottom,
1595 );
1596 assert_eq!(out.buffer_id, 99);
1597 assert_eq!(out.kind, ParseKind::Bottom);
1598 assert_eq!(out.key, (7, 0, 30));
1599 assert_eq!(out.signs.len(), 1);
1600 }
1601
1602 #[test]
1603 fn render_output_partial_eq_same() {
1604 let a = RenderOutput::new(
1605 0,
1606 vec![vec![(0, 5, StyleSpec::default())]],
1607 vec![],
1608 (1, 0, 10),
1609 PerfBreakdown::default(),
1610 ParseKind::Viewport,
1611 );
1612 let b = a.clone();
1613 assert_eq!(a, b);
1614 }
1615
1616 #[test]
1617 fn render_output_partial_eq_different_kind() {
1618 let a = RenderOutput::new(
1619 0,
1620 vec![],
1621 vec![],
1622 (0, 0, 10),
1623 PerfBreakdown::default(),
1624 ParseKind::Viewport,
1625 );
1626 let b = RenderOutput::new(
1627 0,
1628 vec![],
1629 vec![],
1630 (0, 0, 10),
1631 PerfBreakdown::default(),
1632 ParseKind::Top,
1633 );
1634 assert_ne!(a, b);
1635 }
1636
1637 // --- build_by_row ---
1638
1639 #[test]
1640 fn build_by_row_empty_spans_gives_empty_rows() {
1641 let by_row = build_by_row(
1642 &[],
1643 b"hello\nworld\n",
1644 &[0, 6, 12],
1645 2,
1646 &DotFallbackTheme::dark(),
1647 );
1648 assert_eq!(by_row.len(), 2);
1649 assert!(by_row[0].is_empty());
1650 assert!(by_row[1].is_empty());
1651 }
1652
1653 // --- Pending queue deduplication ---
1654
1655 #[test]
1656 fn pending_push_parse_replaces_same_buffer_kind() {
1657 let mut p = Pending::new();
1658 let make_req = |kind: ParseKind, dirty_gen: u64| ParseRequest {
1659 buffer_id: 0,
1660 source: Arc::new(String::new()),
1661 row_starts: Arc::new(vec![]),
1662 edits: vec![],
1663 viewport_byte_range: 0..0,
1664 viewport_top: 0,
1665 viewport_height: 10,
1666 row_count: 0,
1667 dirty_gen,
1668 reset: false,
1669 kind,
1670 };
1671 p.push_parse(make_req(ParseKind::Viewport, 1));
1672 p.push_parse(make_req(ParseKind::Viewport, 2));
1673 // Same (buffer_id=0, kind=Viewport) — should replace, not append.
1674 assert_eq!(p.parse_queue.len(), 1);
1675 assert_eq!(p.parse_queue[0].dirty_gen, 2);
1676 }
1677
1678 #[test]
1679 fn pending_push_parse_keeps_different_kinds() {
1680 let mut p = Pending::new();
1681 let make_req = |kind: ParseKind| ParseRequest {
1682 buffer_id: 0,
1683 source: Arc::new(String::new()),
1684 row_starts: Arc::new(vec![]),
1685 edits: vec![],
1686 viewport_byte_range: 0..0,
1687 viewport_top: 0,
1688 viewport_height: 10,
1689 row_count: 0,
1690 dirty_gen: 1,
1691 reset: false,
1692 kind,
1693 };
1694 p.push_parse(make_req(ParseKind::Viewport));
1695 p.push_parse(make_req(ParseKind::Top));
1696 p.push_parse(make_req(ParseKind::Bottom));
1697 // All three kinds for the same buffer must coexist.
1698 assert_eq!(p.parse_queue.len(), 3);
1699 }
1700
1701 #[test]
1702 fn pending_push_parse_evicts_oldest_at_cap() {
1703 let mut p = Pending::new();
1704 // Fill past capacity with distinct (buffer_id, kind) pairs.
1705 for i in 0..(PARSE_QUEUE_CAP + 2) {
1706 p.push_parse(ParseRequest {
1707 buffer_id: i as BufferId,
1708 source: Arc::new(String::new()),
1709 row_starts: Arc::new(vec![]),
1710 edits: vec![],
1711 viewport_byte_range: 0..0,
1712 viewport_top: 0,
1713 viewport_height: 10,
1714 row_count: 0,
1715 dirty_gen: i as u64,
1716 reset: false,
1717 kind: ParseKind::Viewport,
1718 });
1719 }
1720 // Queue must not grow past cap.
1721 assert!(p.parse_queue.len() <= PARSE_QUEUE_CAP);
1722 }
1723
1724 // --- SyntaxLayer basics (no network required) ---
1725
1726 #[test]
1727 fn submit_with_no_language_returns_none() {
1728 let buf = Buffer::from_str("hello world");
1729 let mut layer = default_layer();
1730 assert!(
1731 !layer
1732 .set_language_for_path(TID, Path::new("a.unknownext"))
1733 .is_known()
1734 );
1735 assert!(
1736 layer
1737 .submit_render(TID, &buf, 0, 10, ParseKind::Viewport)
1738 .is_none()
1739 );
1740 }
1741
1742 #[test]
1743 fn apply_edits_with_no_language_is_noop() {
1744 let mut layer = default_layer();
1745 let edits = vec![hjkl_engine::ContentEdit {
1746 start_byte: 0,
1747 old_end_byte: 0,
1748 new_end_byte: 1,
1749 start_position: (0, 0),
1750 old_end_position: (0, 0),
1751 new_end_position: (0, 1),
1752 }];
1753 layer.apply_edits(TID, &edits);
1754 assert!(
1755 layer
1756 .clients
1757 .get(&TID)
1758 .map(|c| c.pending_edits.is_empty())
1759 .unwrap_or(true)
1760 );
1761 }
1762
1763 #[test]
1764 fn worker_handles_quit_cleanly() {
1765 let layer = default_layer();
1766 drop(layer);
1767 }
1768
1769 #[test]
1770 fn set_language_for_path_returns_unknown_for_unrecognized_extension() {
1771 let mut layer = default_layer();
1772 let outcome = layer.set_language_for_path(TID, Path::new("a.zzznope_not_real"));
1773 assert!(!outcome.is_known());
1774 assert!(matches!(outcome, SetLanguageOutcome::Unknown));
1775 }
1776
1777 #[test]
1778 fn poll_pending_loads_drains_ready_handles() {
1779 let mut layer = default_layer();
1780 let events = layer.poll_pending_loads();
1781 assert!(
1782 events.is_empty(),
1783 "expected no events with no pending loads"
1784 );
1785 }
1786
1787 #[test]
1788 fn forget_removes_client_state() {
1789 let mut layer = default_layer();
1790 // Trigger client entry creation.
1791 layer.set_language_for_path(TID, Path::new("a.zzz_unknown"));
1792 // Even if no client was inserted (Unknown path), forget must not panic.
1793 layer.forget(TID);
1794 assert!(!layer.clients.contains_key(&TID));
1795 }
1796
1797 #[test]
1798 fn take_all_results_empty_when_nothing_submitted() {
1799 let mut layer = default_layer();
1800 let results = layer.take_all_results();
1801 assert!(results.is_empty());
1802 }
1803
1804 // --- Network-dependent tests (grammar needed) ---
1805
1806 #[test]
1807 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1808 fn parse_and_render_small_rust_buffer() {
1809 let buf = Buffer::from_str("fn main() { let x = 1; }\n");
1810 let mut layer = default_layer();
1811 assert!(
1812 layer
1813 .set_language_for_path(TID, Path::new("a.rs"))
1814 .is_known()
1815 );
1816 let out = submit_and_wait(&mut layer, &buf, 0, 10).expect("worker output");
1817 assert_eq!(out.spans.len(), buf.row_count());
1818 assert!(
1819 out.spans.iter().any(|r| !r.is_empty()),
1820 "expected at least one styled span"
1821 );
1822 }
1823
1824 #[test]
1825 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1826 fn first_load_highlights_entire_viewport() {
1827 let mut content = String::new();
1828 for i in 0..50 {
1829 content.push_str(&format!("fn f{i}() {{ let x = {i}; }}\n"));
1830 }
1831 let buf = Buffer::from_str(content.strip_suffix('\n').unwrap_or(&content));
1832 let mut layer = default_layer();
1833 assert!(
1834 layer
1835 .set_language_for_path(TID, Path::new("a.rs"))
1836 .is_known()
1837 );
1838 let out = submit_and_wait(&mut layer, &buf, 0, 30).unwrap();
1839 for (r, row) in out.spans.iter().take(30).enumerate() {
1840 assert!(
1841 !row.is_empty(),
1842 "row {r} has no highlight spans on first load"
1843 );
1844 }
1845 }
1846
1847 #[test]
1848 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1849 fn diagnostics_emit_sign_for_syntax_error() {
1850 let buf = Buffer::from_str("fn main() {\nlet x = ;\n}\n");
1851 let mut layer = default_layer();
1852 layer.set_language_for_path(TID, Path::new("a.rs"));
1853 let out = submit_and_wait(&mut layer, &buf, 0, 10).unwrap();
1854 assert!(
1855 !out.signs.is_empty(),
1856 "expected at least one diagnostic sign for `let x = ;`"
1857 );
1858 assert!(
1859 out.signs.iter().any(|s| s.row == 1 && s.ch == 'E'),
1860 "expected an 'E' sign on row 1; got {:?}",
1861 out.signs
1862 );
1863 }
1864
1865 #[test]
1866 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1867 fn incremental_path_matches_cold_for_small_edit() {
1868 let pre = Buffer::from_str("fn main() { let x = 1; }");
1869 let mut layer = default_layer();
1870 layer.set_language_for_path(TID, Path::new("a.rs"));
1871 let _ = submit_and_wait(&mut layer, &pre, 0, 10).unwrap();
1872 layer.apply_edits(
1873 TID,
1874 &[hjkl_engine::ContentEdit {
1875 start_byte: 3,
1876 old_end_byte: 3,
1877 new_end_byte: 4,
1878 start_position: (0, 3),
1879 old_end_position: (0, 3),
1880 new_end_position: (0, 4),
1881 }],
1882 );
1883 let post = Buffer::from_str("fn Ymain() { let x = 1; }");
1884 let inc = submit_and_wait(&mut layer, &post, 0, 10).unwrap();
1885 let mut cold_layer = default_layer();
1886 cold_layer.set_language_for_path(TID, Path::new("a.rs"));
1887 let cold = submit_and_wait(&mut cold_layer, &post, 0, 10).unwrap();
1888 assert_eq!(inc.spans, cold.spans);
1889 }
1890
1891 #[test]
1892 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1893 fn reset_pending_request_is_consumed_once() {
1894 let buf = Buffer::from_str("fn main() {}");
1895 let mut layer = default_layer();
1896 layer.set_language_for_path(TID, Path::new("a.rs"));
1897 layer.reset(TID);
1898 assert!(layer.clients.get(&TID).unwrap().pending_reset);
1899 let _ = submit_and_wait(&mut layer, &buf, 0, 10).unwrap();
1900 assert!(
1901 !layer.clients.get(&TID).unwrap().pending_reset,
1902 "pending_reset should clear after submit"
1903 );
1904 }
1905
1906 #[test]
1907 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1908 fn forget_drops_buffer_state() {
1909 let buf = Buffer::from_str("fn main() {}");
1910 let mut layer = default_layer();
1911 layer.set_language_for_path(TID, Path::new("a.rs"));
1912 let _ = submit_and_wait(&mut layer, &buf, 0, 10).unwrap();
1913 assert!(layer.clients.contains_key(&TID));
1914 layer.forget(TID);
1915 assert!(!layer.clients.contains_key(&TID));
1916 }
1917}
1918
1919#[cfg(test)]
1920mod perf_smoke {
1921 use super::*;
1922 use hjkl_buffer::Buffer;
1923 use std::path::Path;
1924 use std::time::{Duration, Instant};
1925
1926 #[test]
1927 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1928 fn big_rs_smoke() {
1929 let path = Path::new("/tmp/big.rs");
1930 if !path.exists() {
1931 eprintln!("/tmp/big.rs not present; skipping perf smoke");
1932 return;
1933 }
1934 let content = std::fs::read_to_string(path).unwrap();
1935 let buf = Buffer::from_str(content.strip_suffix('\n').unwrap_or(&content));
1936 let mut layer = default_layer();
1937 const TID: BufferId = 0;
1938 assert!(layer.set_language_for_path(TID, path).is_known());
1939
1940 let t0 = Instant::now();
1941 layer.submit_render(TID, &buf, 0, 50, ParseKind::Viewport);
1942 let main_t = t0.elapsed();
1943 let out = layer.wait_for_result(Duration::from_secs(10));
1944 eprintln!(
1945 "first submit_render main-thread: {:?}, worker turnaround total: {:?}",
1946 main_t,
1947 t0.elapsed()
1948 );
1949 assert!(out.is_some(), "first parse should produce output");
1950
1951 let t0 = Instant::now();
1952 let mut main_total = Duration::ZERO;
1953 for top in 0..100 {
1954 let s = Instant::now();
1955 layer.submit_render(TID, &buf, top * 100, 50, ParseKind::Viewport);
1956 main_total += s.elapsed();
1957 }
1958 while layer.take_result().is_some() {}
1959 eprintln!(
1960 "100 viewport scrolls: total wall {:?}, main-thread total {:?} (avg {:?}/submit)",
1961 t0.elapsed(),
1962 main_total,
1963 main_total / 100
1964 );
1965 }
1966}