Skip to main content

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}