Skip to main content

hjkl_syntax/
lib.rs

1//! Renderer-agnostic syntax-highlighting pipeline for the hjkl editor stack.
2//!
3//! Fully synchronous: parse and highlight run on the main thread.
4//! Call [`SyntaxLayer::set_language_for_path`] after opening a file,
5//! [`SyntaxLayer::apply_edits`] after each batch of [`hjkl_engine::ContentEdit`]s,
6//! and [`SyntaxLayer::render_viewport`] to get styled spans for the visible rows.
7//!
8//! Output is renderer-agnostic: [`RenderOutput::spans`] carries
9//! `(byte_start, byte_end, [`StyleSpec`])` triples.
10//! A TUI adapter ([`hjkl-syntax-tui`]) maps these to `ratatui::style::Style`.
11
12use std::collections::HashMap;
13use std::ops::Range;
14use std::path::Path;
15use std::sync::Arc;
16
17use hjkl_bonsai::runtime::{Grammar, LoadHandle};
18use hjkl_bonsai::{
19    CommentMarkerPass, DotFallbackTheme, HEX_BG_KEY, HEX_COLOR_CAPTURE, HEX_FG_KEY, HexColorPass,
20    Highlighter, InputEdit, MetaValue, Point, RAINBOW_BRACKET_CAPTURE, RAINBOW_DEPTH_KEY, Theme,
21    extract_fold_ranges_rope, rainbow_spans_rope,
22};
23use hjkl_engine::Query;
24use hjkl_lang::{GrammarRequest, LanguageDirectory};
25
26pub use hjkl_theme::{Color, Modifiers, StyleSpec};
27
28/// Stable identifier for an open buffer.
29///
30/// # Examples
31///
32/// ```
33/// use hjkl_syntax::BufferId;
34/// let id: BufferId = 42;
35/// assert_eq!(id, 42);
36/// ```
37pub use hjkl_buffer::BufferId;
38
39// ---------------------------------------------------------------------------
40// Public output types
41// ---------------------------------------------------------------------------
42
43/// A single diagnostic sign emitted from the syntax pipeline.
44///
45/// # Examples
46///
47/// ```
48/// use hjkl_syntax::DiagSign;
49/// let s = DiagSign::new(3, 'E', 100);
50/// assert_eq!(s.row, 3);
51/// ```
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53#[non_exhaustive]
54pub struct DiagSign {
55    /// Document row (0-indexed).
56    pub row: usize,
57    /// Gutter character (e.g. `'E'` for a syntax error).
58    pub ch: char,
59    /// Gutter priority — higher wins when multiple signs land on the same row.
60    pub priority: u8,
61}
62
63impl Default for DiagSign {
64    fn default() -> Self {
65        Self {
66            row: 0,
67            ch: 'E',
68            priority: 0,
69        }
70    }
71}
72
73impl DiagSign {
74    /// Create a new diagnostic sign.
75    ///
76    /// # Examples
77    ///
78    /// ```
79    /// use hjkl_syntax::DiagSign;
80    /// let s = DiagSign::new(1, 'E', 100);
81    /// assert_eq!(s.row, 1);
82    /// ```
83    pub fn new(row: usize, ch: char, priority: u8) -> Self {
84        Self { row, ch, priority }
85    }
86}
87
88/// Per-call sub-step timings. Kept for API compat (PerfBreakdown is re-exported
89/// in the TUI shim and referenced from `:perf` overlay code).
90///
91/// # Examples
92///
93/// ```
94/// use hjkl_syntax::PerfBreakdown;
95/// let p = PerfBreakdown::default();
96/// assert_eq!(p.parse_us, 0);
97/// ```
98#[derive(Default, Debug, Clone, Copy)]
99#[non_exhaustive]
100pub struct PerfBreakdown {
101    /// Microseconds spent building the source string + row_starts table.
102    pub source_build_us: u128,
103    /// Microseconds spent in `tree_sitter::Parser::parse`.
104    pub parse_us: u128,
105    /// Microseconds spent in `hjkl_bonsai::Highlighter::highlight_range_*`.
106    pub highlight_us: u128,
107    /// Microseconds spent building the per-row span table from flat spans.
108    pub by_row_us: u128,
109    /// Microseconds spent scanning for diagnostic ERROR/MISSING nodes.
110    pub diag_us: u128,
111}
112
113impl PerfBreakdown {
114    /// Construct a zeroed breakdown.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use hjkl_syntax::PerfBreakdown;
120    /// let p = PerfBreakdown::new();
121    /// assert_eq!(p.highlight_us, 0);
122    /// ```
123    pub fn new() -> Self {
124        Self::default()
125    }
126}
127
128/// Per-frame output of the syntax pipeline.
129///
130/// Contains the styled span table (one inner `Vec` per document row) and the
131/// diagnostic signs for the gutter.
132///
133/// # Examples
134///
135/// ```
136/// use hjkl_syntax::{RenderOutput, PerfBreakdown};
137/// let out = RenderOutput::new(0, Vec::new(), Vec::new(), (0, 0, 0), PerfBreakdown::default());
138/// assert_eq!(out.buffer_id, 0);
139/// ```
140#[derive(Debug, Clone)]
141#[non_exhaustive]
142pub struct RenderOutput {
143    /// Routes spans/signs back to the matching buffer slot.
144    pub buffer_id: BufferId,
145    /// Per-row span table.
146    pub spans: Vec<Vec<(usize, usize, StyleSpec)>>,
147    /// Diagnostic signs for the gutter.
148    pub signs: Vec<DiagSign>,
149    /// `(dirty_gen, viewport_top, viewport_height)` cache key.
150    pub key: (u64, usize, usize),
151    /// Sub-step timing breakdown (zeroed in fully-sync path).
152    pub perf: PerfBreakdown,
153}
154
155impl RenderOutput {
156    /// Construct a new `RenderOutput`.
157    ///
158    /// # Examples
159    ///
160    /// ```
161    /// use hjkl_syntax::{RenderOutput, PerfBreakdown};
162    /// let out = RenderOutput::new(1, Vec::new(), Vec::new(), (7, 0, 30), PerfBreakdown::new());
163    /// assert_eq!(out.buffer_id, 1);
164    /// ```
165    pub fn new(
166        buffer_id: BufferId,
167        spans: Vec<Vec<(usize, usize, StyleSpec)>>,
168        signs: Vec<DiagSign>,
169        key: (u64, usize, usize),
170        perf: PerfBreakdown,
171    ) -> Self {
172        Self {
173            buffer_id,
174            spans,
175            signs,
176            key,
177            perf,
178        }
179    }
180}
181
182impl PartialEq for RenderOutput {
183    fn eq(&self, other: &Self) -> bool {
184        self.spans == other.spans
185            && self.signs.len() == other.signs.len()
186            && self
187                .signs
188                .iter()
189                .zip(other.signs.iter())
190                .all(|(a, b)| a.row == b.row && a.ch == b.ch && a.priority == b.priority)
191    }
192}
193
194// ---------------------------------------------------------------------------
195// Public outcome types for set_language_for_path / poll_pending_loads
196// ---------------------------------------------------------------------------
197
198/// Outcome of [`SyntaxLayer::set_language_for_path`].
199///
200/// # Examples
201///
202/// ```
203/// use hjkl_syntax::SetLanguageOutcome;
204/// assert!(SetLanguageOutcome::Ready.is_known());
205/// assert!(SetLanguageOutcome::Loading("rust".to_string()).is_known());
206/// assert!(!SetLanguageOutcome::Unknown.is_known());
207/// ```
208#[non_exhaustive]
209pub enum SetLanguageOutcome {
210    /// Grammar was already cached — installed immediately.
211    Ready,
212    /// Grammar is being fetched/compiled on the background pool.
213    Loading(#[allow(dead_code)] String),
214    /// Extension unrecognized. No grammar — plain text only.
215    Unknown,
216}
217
218impl SetLanguageOutcome {
219    /// `true` when a grammar was found (either already cached or now in flight).
220    pub fn is_known(&self) -> bool {
221        matches!(self, Self::Ready | Self::Loading(_))
222    }
223}
224
225/// Event emitted by [`SyntaxLayer::poll_pending_loads`].
226///
227/// # Examples
228///
229/// ```
230/// use hjkl_syntax::LoadEvent;
231/// let e = LoadEvent::Ready { id: 0, name: "rust".into() };
232/// match e {
233///     LoadEvent::Ready { id, name } => assert_eq!(name, "rust"),
234///     LoadEvent::Failed { .. } => panic!("unexpected"),
235///     _ => {}
236/// }
237/// ```
238#[non_exhaustive]
239pub enum LoadEvent {
240    /// Grammar installed; trigger a redraw + re-render for `id`.
241    Ready { id: BufferId, name: String },
242    /// Load failed; buffer stays plain text.
243    Failed {
244        id: BufferId,
245        name: String,
246        error: String,
247    },
248}
249
250/// Exhaustive view of a [`LoadEvent`] for dispatch callbacks.
251#[derive(Debug)]
252pub enum LoadEventKind<'a> {
253    /// Grammar installed successfully.
254    Ready { id: BufferId, name: &'a str },
255    /// Grammar load failed.
256    Failed {
257        id: BufferId,
258        name: &'a str,
259        error: &'a str,
260    },
261}
262
263// ---------------------------------------------------------------------------
264// In-flight grammar load tracking
265// ---------------------------------------------------------------------------
266
267struct PendingLoad {
268    id: BufferId,
269    name: String,
270    handle: LoadHandle,
271}
272
273// ---------------------------------------------------------------------------
274// Per-buffer client state (main thread)
275// ---------------------------------------------------------------------------
276
277/// Per-buffer state owned by the main-thread [`SyntaxLayer`].
278struct BufferClient {
279    has_language: bool,
280    current_lang: Option<Arc<Grammar>>,
281    /// Owns Parser + Tree for this buffer.
282    highlighter: Option<Highlighter>,
283    /// dirty_gen the cache was built at (None = cache absent).
284    cache_dirty_gen: Option<u64>,
285    /// Contiguous row range covered by `cache_spans`.
286    cache_rows: Range<usize>,
287    /// Per-row span table for `cache_rows`.
288    cache_spans: Vec<Vec<(usize, usize, StyleSpec)>>,
289    /// `(dirty_gen, row_starts)` — rebuilt only when dirty_gen changes.
290    cache_row_starts: Option<(u64, Arc<Vec<usize>>)>,
291    /// dirty_gen of the most recent successful parse. Gate reparsing.
292    parsed_dirty_gen: Option<u64>,
293    /// Cached diag signs keyed by `(dirty_gen, vp_top, vp_end)`.
294    cache_signs: Option<(u64, usize, usize, Vec<DiagSign>)>,
295}
296
297impl Default for BufferClient {
298    fn default() -> Self {
299        Self {
300            has_language: false,
301            current_lang: None,
302            highlighter: None,
303            cache_dirty_gen: None,
304            cache_rows: 0..0,
305            cache_spans: Vec::new(),
306            cache_row_starts: None,
307            parsed_dirty_gen: None,
308            cache_signs: None,
309        }
310    }
311}
312
313impl BufferClient {
314    fn invalidate_cache(&mut self) {
315        self.cache_dirty_gen = None;
316        self.cache_rows = 0..0;
317        self.cache_spans.clear();
318        self.cache_row_starts = None;
319        self.parsed_dirty_gen = None;
320        self.cache_signs = None;
321    }
322}
323
324// ---------------------------------------------------------------------------
325// SyntaxLayer — main-thread, fully synchronous
326// ---------------------------------------------------------------------------
327
328/// Per-App syntax highlighting layer. Multiplexes per-buffer state.
329/// Fully synchronous — no background thread.
330///
331/// # Examples
332///
333/// ```no_run
334/// use std::sync::Arc;
335/// use hjkl_syntax::SyntaxLayer;
336/// use hjkl_bonsai::DotFallbackTheme;
337/// use hjkl_lang::LanguageDirectory;
338///
339/// let theme = Arc::new(DotFallbackTheme::dark());
340/// let dir = Arc::new(LanguageDirectory::new().unwrap());
341/// let layer = SyntaxLayer::new(theme, dir);
342/// ```
343pub struct SyntaxLayer {
344    /// Shared grammar resolver.
345    pub directory: Arc<LanguageDirectory>,
346    theme: Arc<dyn Theme + Send + Sync>,
347    clients: HashMap<BufferId, BufferClient>,
348    pending_loads: Vec<PendingLoad>,
349    /// When `false`, `HexColorPass` is skipped for all buffers.
350    colorizer: bool,
351    /// Filetype allowlist for the colorizer. Empty = allow all.
352    colorizer_filetypes: Vec<String>,
353    /// When `true`, rainbow bracket overlay is applied. Default `true`.
354    rainbow_brackets: bool,
355}
356
357impl SyntaxLayer {
358    /// Create a new layer with no buffers attached.
359    ///
360    /// # Examples
361    ///
362    /// ```no_run
363    /// use std::sync::Arc;
364    /// use hjkl_syntax::SyntaxLayer;
365    /// use hjkl_bonsai::DotFallbackTheme;
366    /// use hjkl_lang::LanguageDirectory;
367    ///
368    /// let theme = Arc::new(DotFallbackTheme::dark());
369    /// let dir = Arc::new(LanguageDirectory::new().unwrap());
370    /// let layer = SyntaxLayer::new(theme, dir);
371    /// ```
372    pub fn new(theme: Arc<dyn Theme + Send + Sync>, directory: Arc<LanguageDirectory>) -> Self {
373        Self {
374            directory,
375            theme,
376            clients: HashMap::new(),
377            pending_loads: Vec::new(),
378            colorizer: true,
379            colorizer_filetypes: vec![
380                "css".to_string(),
381                "scss".to_string(),
382                "sass".to_string(),
383                "less".to_string(),
384                "html".to_string(),
385                "vue".to_string(),
386                "svelte".to_string(),
387                "tailwindcss".to_string(),
388                "toml".to_string(),
389                "lua".to_string(),
390                "vim".to_string(),
391            ],
392            rainbow_brackets: true,
393        }
394    }
395
396    /// Update rainbow bracket settings. Pass `enabled = false` to disable the
397    /// rainbow overlay globally. No-op when the value is unchanged so per-frame
398    /// pushes from the app stay cheap. Caches invalidate only on actual change.
399    pub fn set_rainbow_brackets(&mut self, enabled: bool) {
400        if self.rainbow_brackets == enabled {
401            return;
402        }
403        self.rainbow_brackets = enabled;
404        for client in self.clients.values_mut() {
405            client.invalidate_cache();
406        }
407    }
408
409    /// Update colorizer settings. Pass `enabled = false` to disable
410    /// the color-literal overlay globally. `filetypes` is the allowlist
411    /// of language names (e.g. `"css"`, `"toml"`); an empty slice means
412    /// no filetype is allowed (same effect as `enabled = false`).
413    ///
414    /// No-op when the values are unchanged so per-frame pushes from the
415    /// app stay cheap. Caches invalidate only on actual change.
416    pub fn set_colorizer(&mut self, enabled: bool, filetypes: Vec<String>) {
417        if self.colorizer == enabled && self.colorizer_filetypes == filetypes {
418            return;
419        }
420        self.colorizer = enabled;
421        self.colorizer_filetypes = filetypes;
422        for client in self.clients.values_mut() {
423            client.invalidate_cache();
424        }
425    }
426
427    /// Borrow the shared language directory.
428    pub fn directory(&self) -> &Arc<LanguageDirectory> {
429        &self.directory
430    }
431
432    fn client_mut(&mut self, id: BufferId) -> &mut BufferClient {
433        self.clients.entry(id).or_default()
434    }
435
436    /// Detect the language for `path` and attach a grammar.
437    ///
438    /// - `Ready`   — grammar cached; highlighter installed immediately.
439    /// - `Loading` — grammar compiling; renders as plain text until
440    ///   `poll_pending_loads` fires `LoadEvent::Ready`.
441    /// - `Unknown` — unrecognized extension; plain text only.
442    ///
443    /// # Examples
444    ///
445    /// ```no_run
446    /// use std::sync::Arc;
447    /// use std::path::Path;
448    /// use hjkl_syntax::{SyntaxLayer, SetLanguageOutcome};
449    /// use hjkl_bonsai::DotFallbackTheme;
450    /// use hjkl_lang::LanguageDirectory;
451    ///
452    /// let theme = Arc::new(DotFallbackTheme::dark());
453    /// let dir = Arc::new(LanguageDirectory::new().unwrap());
454    /// let mut layer = SyntaxLayer::new(theme, dir);
455    /// let outcome = layer.set_language_for_path(0, Path::new("a.zzz_not_real"));
456    /// assert!(!outcome.is_known());
457    /// ```
458    pub fn set_language_for_path(&mut self, id: BufferId, path: &Path) -> SetLanguageOutcome {
459        match self.directory.request_for_path(path) {
460            GrammarRequest::Cached(grammar) => {
461                self.attach_grammar(id, grammar.clone());
462                let c = self.client_mut(id);
463                c.current_lang = Some(grammar);
464                c.has_language = true;
465                SetLanguageOutcome::Ready
466            }
467            GrammarRequest::Loading { name, handle } => {
468                let c = self.client_mut(id);
469                c.current_lang = None;
470                c.has_language = false;
471                c.highlighter = None;
472                c.invalidate_cache();
473                self.pending_loads.push(PendingLoad {
474                    id,
475                    name: name.clone(),
476                    handle,
477                });
478                SetLanguageOutcome::Loading(name)
479            }
480            GrammarRequest::Unknown | _ => {
481                let c = self.client_mut(id);
482                c.current_lang = None;
483                c.has_language = false;
484                c.highlighter = None;
485                c.invalidate_cache();
486                SetLanguageOutcome::Unknown
487            }
488        }
489    }
490
491    /// Attach a grammar to a buffer, creating/replacing the Highlighter.
492    fn attach_grammar(&mut self, id: BufferId, grammar: Arc<Grammar>) {
493        let c = self.clients.entry(id).or_default();
494        c.invalidate_cache();
495        match Highlighter::new(grammar) {
496            Ok(h) => {
497                c.highlighter = Some(h);
498            }
499            Err(e) => {
500                tracing::error!(buffer_id = id, error = %e, "failed to attach highlighter");
501                c.highlighter = None;
502            }
503        }
504    }
505
506    /// Poll all in-flight grammar loads. Call once per tick.
507    ///
508    /// Returns one `LoadEvent` per handle that resolved during this tick.
509    pub fn poll_pending_loads(&mut self) -> Vec<LoadEvent> {
510        let mut events = Vec::new();
511        let mut i = 0;
512        while i < self.pending_loads.len() {
513            match self.pending_loads[i].handle.try_recv() {
514                None => {
515                    i += 1;
516                }
517                Some(Ok(lib_path)) => {
518                    let name = self.pending_loads[i].name.clone();
519                    let bid = self.pending_loads[i].id;
520                    self.pending_loads.swap_remove(i);
521                    match self.directory.complete_load(&name, lib_path) {
522                        Ok(grammar) => {
523                            self.attach_grammar(bid, grammar.clone());
524                            let c = self.client_mut(bid);
525                            c.current_lang = Some(grammar);
526                            c.has_language = true;
527                            events.push(LoadEvent::Ready { id: bid, name });
528                        }
529                        Err(e) => {
530                            events.push(LoadEvent::Failed {
531                                id: bid,
532                                name,
533                                error: format!("{e:#}"),
534                            });
535                        }
536                    }
537                }
538                Some(Err(err)) => {
539                    let name = self.pending_loads[i].name.clone();
540                    let bid = self.pending_loads[i].id;
541                    self.pending_loads.swap_remove(i);
542                    events.push(LoadEvent::Failed {
543                        id: bid,
544                        name,
545                        error: err.to_string(),
546                    });
547                }
548            }
549        }
550        events
551    }
552
553    /// Drop all state for a buffer. Call on close.
554    pub fn forget(&mut self, id: BufferId) {
555        self.clients.remove(&id);
556    }
557
558    /// Swap the active theme. Next `render_viewport` call uses the new theme.
559    pub fn set_theme(&mut self, theme: Arc<dyn Theme + Send + Sync>) {
560        self.theme = theme;
561        // Invalidate all per-buffer caches so they repaint with the new theme.
562        for c in self.clients.values_mut() {
563            c.invalidate_cache();
564        }
565    }
566
567    /// Apply a batch of engine `ContentEdit`s to the buffer's retained tree
568    /// synchronously. The cache will be invalidated on the next `render_viewport`
569    /// call via dirty_gen mismatch.
570    ///
571    /// No-op when no grammar is attached.
572    pub fn apply_edits(&mut self, id: BufferId, edits: &[hjkl_engine::ContentEdit]) {
573        let c = match self.clients.get_mut(&id) {
574            Some(c) if c.has_language => c,
575            _ => return,
576        };
577        let h = match c.highlighter.as_mut() {
578            Some(h) => h,
579            None => return,
580        };
581        for e in edits {
582            h.edit(&InputEdit {
583                start_byte: e.start_byte,
584                old_end_byte: e.old_end_byte,
585                new_end_byte: e.new_end_byte,
586                start_position: Point {
587                    row: e.start_position.0 as usize,
588                    column: e.start_position.1 as usize,
589                },
590                old_end_position: Point {
591                    row: e.old_end_position.0 as usize,
592                    column: e.old_end_position.1 as usize,
593                },
594                new_end_position: Point {
595                    row: e.new_end_position.0 as usize,
596                    column: e.new_end_position.1 as usize,
597                },
598            });
599        }
600        // dirty_gen will advance — invalidate parse + row_starts + sign caches.
601        // cache_spans / cache_rows are dropped on dirty_gen mismatch in render_viewport.
602        c.parsed_dirty_gen = None;
603        c.cache_row_starts = None;
604        c.cache_signs = None;
605    }
606
607    /// Drop the buffer's retained tree. Next `render_viewport` reparses from scratch.
608    ///
609    /// Call on `:e!` / content reset.
610    pub fn reset(&mut self, id: BufferId) {
611        if let Some(c) = self.clients.get_mut(&id) {
612            if let Some(h) = c.highlighter.as_mut() {
613                h.reset();
614            }
615            c.invalidate_cache();
616        }
617    }
618
619    /// Extract fold ranges from the buffer's retained tree using the bundled
620    /// `folds.scm` for this grammar.
621    ///
622    /// Returns `Some(ranges)` when the grammar is attached and the tree has
623    /// been parsed — `ranges` may be empty when the grammar has no bundled
624    /// `folds.scm` or the file contains no foldable nodes.
625    ///
626    /// Returns `None` when:
627    /// - No grammar is attached yet (grammar still loading or unknown extension).
628    /// - No highlighter has been created for this buffer.
629    /// - The tree has not been parsed yet (call `render_viewport` first).
630    ///
631    /// **Callers must treat `None` as "not ready — retry later"** and must NOT
632    /// record the dirty_gen as processed when `None` is returned. Returning
633    /// `Some(empty)` is the signal that the grammar ran but produced no folds
634    /// (e.g. no `folds.scm` for this language).
635    ///
636    /// **NOT viewport-bounded** — runs over the full tree (once per reparse).
637    /// Do not call this per-frame; call it only when `dirty_gen` has changed.
638    pub fn extract_fold_ranges(
639        &mut self,
640        id: BufferId,
641        buffer: &impl hjkl_engine::Query,
642    ) -> Option<Vec<(usize, usize)>> {
643        let client = match self.clients.get_mut(&id) {
644            Some(c) if c.has_language => c,
645            // Grammar not yet attached (loading or unknown) — signal "not ready".
646            _ => return None,
647        };
648        // Highlighter creation failed — signal "not ready".
649        let highlighter = client.highlighter.as_mut()?;
650        // Tree not yet parsed — signal "not ready".
651        let tree = highlighter.tree()?;
652        let grammar = highlighter.grammar()?;
653        let rope = buffer.rope();
654        // Grammar is ready and tree is parsed — return Some even if empty.
655        Some(extract_fold_ranges_rope(tree, grammar, &rope))
656    }
657
658    /// Render spans for the visible viewport. Fully synchronous.
659    ///
660    /// 1. Returns `None` when no grammar is attached.
661    /// 2. Clears the cache when `buffer.dirty_gen()` has advanced.
662    /// 3. Returns cached rows when the request is fully inside the cached range.
663    /// 4. Walks only rows outside the cache (extend prefix/suffix), splices into
664    ///    `cache_spans`, extends `cache_rows`.
665    pub fn render_viewport(
666        &mut self,
667        id: BufferId,
668        buffer: &impl Query,
669        viewport_top: usize,
670        viewport_height: usize,
671    ) -> Option<RenderOutput> {
672        let client = self.clients.get_mut(&id)?;
673        if !client.has_language {
674            return None;
675        }
676        let dg = buffer.dirty_gen();
677        let row_count = buffer.line_count() as usize;
678        if row_count == 0 || viewport_height == 0 {
679            return None;
680        }
681
682        let vp_top = viewport_top.min(row_count);
683        let vp_end = (vp_top + viewport_height).min(row_count);
684        if vp_end <= vp_top {
685            return None;
686        }
687
688        // Single dirty_gen invalidation point.
689        if client.cache_dirty_gen != Some(dg) {
690            client.invalidate_cache();
691        }
692
693        // Get a rope snapshot — O(1) Arc-clone from hjkl_buffer::Buffer.
694        // All downstream consumers (parse, highlight, row_starts, diag signs)
695        // now read directly from the rope: no full-document String allocation.
696        let rope = buffer.rope();
697
698        // Get or build row_starts, cached per dirty_gen.
699        // Scan newlines chunk-by-chunk from the rope so we never materialise
700        // the full document as a contiguous byte slice.
701        let row_starts: Arc<Vec<usize>> = if client
702            .cache_row_starts
703            .as_ref()
704            .is_some_and(|(g, _)| *g == dg)
705        {
706            Arc::clone(&client.cache_row_starts.as_ref().unwrap().1)
707        } else {
708            // SIMD-vectorised newline scan via memchr — measurably faster than
709            // a per-byte loop. Pre-sized to row_count + 1 to avoid realloc churn.
710            let mut rs: Vec<usize> = Vec::with_capacity(row_count + 1);
711            rs.push(0);
712            let mut chunk_pos = 0usize;
713            for chunk in rope.chunks() {
714                for nl in memchr::memchr_iter(b'\n', chunk.as_bytes()) {
715                    rs.push(chunk_pos + nl + 1);
716                }
717                chunk_pos += chunk.len();
718            }
719            let arc = Arc::new(rs);
720            client.cache_row_starts = Some((dg, Arc::clone(&arc)));
721            arc
722        };
723
724        // Reparse only when needed. Use rope-streaming parse to avoid passing
725        // the full bytes slice into the parser (tree-sitter reads chunk-by-chunk
726        // via the closure; no contiguous copy required for the parse step).
727        let needs_reparse = client.parsed_dirty_gen != Some(dg);
728        {
729            let highlighter = client.highlighter.as_mut()?;
730            if highlighter.tree().is_none() {
731                highlighter.parse_initial_rope(&rope);
732                if highlighter.tree().is_some() {
733                    client.parsed_dirty_gen = Some(dg);
734                }
735            } else if needs_reparse {
736                // No-diff incremental: we discard the changed-byte ranges
737                // (cache is keyed by dirty_gen + viewport, not by edit
738                // ranges). Computing `old.changed_ranges(&new)` walks both
739                // trees and was ~54 % of per-keystroke CPU on a 1.86 M-line
740                // file.
741                let ok = highlighter.parse_incremental_rope(&rope);
742                if ok && highlighter.tree().is_some() {
743                    client.parsed_dirty_gen = Some(dg);
744                }
745            }
746        }
747
748        // Compute colorizer gate before re-borrowing client mutably.
749        // Effective = global flag AND current language is in the allowlist.
750        let colorizer_enabled = {
751            let c = self.clients.get(&id)?;
752            let lang_name = c.current_lang.as_ref().map(|g| g.name()).unwrap_or("");
753            self.colorizer
754                && (self.colorizer_filetypes.is_empty()
755                    || self.colorizer_filetypes.iter().any(|ft| ft == lang_name))
756        };
757        let rainbow_brackets_enabled = self.rainbow_brackets;
758
759        // Re-borrow after parse.
760        let client = self.clients.get_mut(&id)?;
761        let highlighter = client.highlighter.as_mut()?;
762
763        // If still no tree (parse failed), give up.
764        highlighter.tree()?;
765
766        let theme = self.theme.as_ref();
767        let directory = Arc::clone(&self.directory);
768
769        // Extend cache to cover [vp_top, vp_end).
770        if client.cache_rows.is_empty() {
771            // Case A: empty cache — walk full range.
772            client.cache_spans = walk_rows(
773                highlighter,
774                &rope,
775                &row_starts,
776                row_count,
777                vp_top,
778                vp_end,
779                theme,
780                &directory,
781                colorizer_enabled,
782                rainbow_brackets_enabled,
783            );
784            client.cache_rows = vp_top..vp_end;
785            client.cache_dirty_gen = Some(dg);
786        } else {
787            let cache_covers_overlap =
788                vp_top < client.cache_rows.end && vp_end > client.cache_rows.start;
789            if !cache_covers_overlap {
790                // Disjoint — just rebuild the whole viewport.
791                client.cache_spans = walk_rows(
792                    highlighter,
793                    &rope,
794                    &row_starts,
795                    row_count,
796                    vp_top,
797                    vp_end,
798                    theme,
799                    &directory,
800                    colorizer_enabled,
801                    rainbow_brackets_enabled,
802                );
803                client.cache_rows = vp_top..vp_end;
804            } else {
805                // Case B: extend prefix if needed.
806                if vp_top < client.cache_rows.start {
807                    let new_rows = walk_rows(
808                        highlighter,
809                        &rope,
810                        &row_starts,
811                        row_count,
812                        vp_top,
813                        client.cache_rows.start,
814                        theme,
815                        &directory,
816                        colorizer_enabled,
817                        rainbow_brackets_enabled,
818                    );
819                    let mut combined = new_rows;
820                    combined.append(&mut client.cache_spans);
821                    client.cache_spans = combined;
822                    client.cache_rows.start = vp_top;
823                }
824                // Case C: extend suffix if needed.
825                if vp_end > client.cache_rows.end {
826                    let new_rows = walk_rows(
827                        highlighter,
828                        &rope,
829                        &row_starts,
830                        row_count,
831                        client.cache_rows.end,
832                        vp_end,
833                        theme,
834                        &directory,
835                        colorizer_enabled,
836                        rainbow_brackets_enabled,
837                    );
838                    client.cache_spans.extend(new_rows);
839                    client.cache_rows.end = vp_end;
840                }
841            }
842            client.cache_dirty_gen = Some(dg);
843        }
844
845        // Slice the requested viewport from the cache.
846        let offset = vp_top - client.cache_rows.start;
847        let len = vp_end - vp_top;
848        let spans: Vec<Vec<(usize, usize, StyleSpec)>> =
849            client.cache_spans[offset..offset + len].to_vec();
850
851        // Get or build signs, cached per (dirty_gen, vp_top, vp_end).
852        let signs = if client
853            .cache_signs
854            .as_ref()
855            .is_some_and(|(g, t, e, _)| *g == dg && *t == vp_top && *e == vp_end)
856        {
857            client.cache_signs.as_ref().unwrap().3.clone()
858        } else {
859            let s = collect_diag_signs_range(highlighter, &rope, &row_starts, vp_top, vp_end);
860            client.cache_signs = Some((dg, vp_top, vp_end, s.clone()));
861            s
862        };
863
864        Some(RenderOutput {
865            buffer_id: id,
866            spans,
867            signs,
868            key: (dg, vp_top, viewport_height),
869            perf: PerfBreakdown::default(),
870        })
871    }
872
873    /// Resolve a path to its language name without loading a grammar.
874    pub fn name_for_path(&self, path: &Path) -> Option<String> {
875        self.directory.name_for_path(path)
876    }
877
878    /// Returns `true` if a client is tracked for the given buffer id.
879    #[doc(hidden)]
880    pub fn has_client(&self, id: BufferId) -> bool {
881        self.clients.contains_key(&id)
882    }
883
884    /// Dispatch a [`LoadEvent`] through a caller-supplied handler.
885    ///
886    /// # Examples
887    ///
888    /// ```rust
889    /// use hjkl_syntax::{LoadEvent, SyntaxLayer};
890    ///
891    /// let event = LoadEvent::Ready { id: 0, name: "rust".into() };
892    /// let mut got_ready = false;
893    /// let handled = SyntaxLayer::dispatch_load_event(&event, |ev| {
894    ///     use hjkl_syntax::LoadEventKind;
895    ///     match ev {
896    ///         LoadEventKind::Ready { id, name } => { got_ready = true; }
897    ///         LoadEventKind::Failed { .. } => {}
898    ///     }
899    /// });
900    /// assert!(handled);
901    /// assert!(got_ready);
902    /// ```
903    pub fn dispatch_load_event(
904        event: &LoadEvent,
905        mut handler: impl FnMut(LoadEventKind<'_>),
906    ) -> bool {
907        #[allow(unreachable_patterns)]
908        match event {
909            LoadEvent::Ready { id, name } => {
910                handler(LoadEventKind::Ready { id: *id, name });
911                true
912            }
913            LoadEvent::Failed { id, name, error } => {
914                handler(LoadEventKind::Failed {
915                    id: *id,
916                    name,
917                    error,
918                });
919                true
920            }
921            _ => false,
922        }
923    }
924}
925
926// ---------------------------------------------------------------------------
927// Rainbow palette
928// ---------------------------------------------------------------------------
929
930/// 7-colour rainbow palette for bracket depth coloring (dark-bg readable).
931/// Depth 0 → index 0, depth N → RAINBOW_PALETTE[N % RAINBOW_PALETTE.len()].
932const RAINBOW_PALETTE: [Color; 7] = [
933    Color::rgb(255, 100, 100), // red
934    Color::rgb(255, 175, 80),  // orange
935    Color::rgb(255, 230, 80),  // yellow
936    Color::rgb(100, 220, 100), // green
937    Color::rgb(80, 210, 220),  // cyan
938    Color::rgb(100, 140, 255), // blue
939    Color::rgb(190, 120, 255), // violet
940];
941
942// ---------------------------------------------------------------------------
943// Helper: walk a row range against the retained tree
944// ---------------------------------------------------------------------------
945
946#[allow(clippy::too_many_arguments)]
947fn walk_rows(
948    highlighter: &mut Highlighter,
949    rope: &ropey::Rope,
950    row_starts: &[usize],
951    row_count: usize,
952    seg_start: usize,
953    seg_end: usize,
954    theme: &dyn Theme,
955    directory: &Arc<LanguageDirectory>,
956    colorizer: bool,
957    rainbow_brackets: bool,
958) -> Vec<Vec<(usize, usize, StyleSpec)>> {
959    let rope_len = rope.len_bytes();
960    let byte_start = row_starts.get(seg_start).copied().unwrap_or(rope_len);
961    let byte_end = row_starts
962        .get(seg_end)
963        .copied()
964        .unwrap_or(rope_len)
965        .min(rope_len)
966        .max(byte_start);
967
968    let mut flat_spans =
969        highlighter.highlight_range_with_injections_rope(rope, byte_start..byte_end, |name| {
970            directory.by_name(name)
971        });
972
973    let marker_pass = CommentMarkerPass::new();
974    marker_pass.apply_rope(&mut flat_spans, rope);
975    if colorizer {
976        let hex_color_pass = HexColorPass::new();
977        hex_color_pass.apply_range_rope(&mut flat_spans, rope, byte_start..byte_end);
978    }
979    if rainbow_brackets
980        && let (Some(tree), Some(grammar)) = (highlighter.tree(), highlighter.grammar())
981    {
982        let rb_spans = rainbow_spans_rope(tree, grammar, rope, byte_start..byte_end);
983        flat_spans.extend(rb_spans);
984    }
985
986    // Bucket spans into ONLY the viewport row range. The prior version
987    // called `build_by_row(..., row_count, ...)` and sliced the result,
988    // which allocated `row_count` empty inner Vecs (8.58 M on a huge
989    // file) just to throw away all but ~50 of them — that single line
990    // was ~24 % of per-keystroke CPU during a paste burst.
991    let _ = row_count; // kept in signature for the public build_by_row tests
992    build_by_row_range(&flat_spans, rope_len, row_starts, seg_start..seg_end, theme)
993}
994
995/// Viewport-bounded variant of [`build_by_row`]. Allocates exactly
996/// `row_range.len()` inner Vecs instead of one per document row. Spans
997/// whose byte range falls entirely outside `row_range` are skipped; spans
998/// that overlap have their per-row slices recorded with positions local
999/// to the viewport (so row `row_range.start` lands at index 0).
1000fn build_by_row_range(
1001    flat_spans: &[hjkl_bonsai::HighlightSpan],
1002    source_len: usize,
1003    row_starts: &[usize],
1004    row_range: Range<usize>,
1005    theme: &dyn Theme,
1006) -> Vec<Vec<(usize, usize, StyleSpec)>> {
1007    let seg_start = row_range.start;
1008    let seg_end = row_range.end.min(row_starts.len());
1009    if seg_end <= seg_start {
1010        return Vec::new();
1011    }
1012    let mut by_row: Vec<Vec<(usize, usize, StyleSpec)>> = vec![Vec::new(); seg_end - seg_start];
1013
1014    for span in flat_spans {
1015        let hex_style: Option<StyleSpec> = if span.capture() == HEX_COLOR_CAPTURE {
1016            let bg = match span.metadata.get(HEX_BG_KEY) {
1017                Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1018                _ => None,
1019            };
1020            let fg = match span.metadata.get(HEX_FG_KEY) {
1021                Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1022                _ => None,
1023            };
1024            bg.map(|bg| StyleSpec {
1025                fg,
1026                bg: Some(bg),
1027                modifiers: hjkl_theme::Modifiers::default(),
1028            })
1029        } else if span.capture() == RAINBOW_BRACKET_CAPTURE {
1030            let depth = match span.metadata.get(RAINBOW_DEPTH_KEY) {
1031                Some(MetaValue::Int(d)) => *d as usize,
1032                _ => 0,
1033            };
1034            let fg = RAINBOW_PALETTE[depth % RAINBOW_PALETTE.len()];
1035            Some(StyleSpec {
1036                fg: Some(fg),
1037                bg: None,
1038                modifiers: hjkl_theme::Modifiers::default(),
1039            })
1040        } else {
1041            None
1042        };
1043
1044        let style: StyleSpec = if let Some(s) = hex_style {
1045            s
1046        } else {
1047            match theme.style(span.capture()) {
1048                Some(s) => *s,
1049                None => continue,
1050            }
1051        };
1052
1053        let span_start = span.byte_range.start;
1054        let span_end = span.byte_range.end;
1055
1056        let start_row = row_starts
1057            .partition_point(|&rs| rs <= span_start)
1058            .saturating_sub(1);
1059
1060        let mut row = start_row.max(seg_start);
1061        while row < seg_end {
1062            let row_byte_start = row_starts[row];
1063            let row_byte_end = row_starts
1064                .get(row + 1)
1065                .map(|&s| s.saturating_sub(1))
1066                .unwrap_or(source_len);
1067
1068            if row_byte_start >= span_end {
1069                break;
1070            }
1071
1072            let local_start = span_start.saturating_sub(row_byte_start);
1073            let local_end = span_end.min(row_byte_end) - row_byte_start;
1074
1075            if local_end > local_start {
1076                by_row[row - seg_start].push((local_start, local_end, style));
1077            }
1078
1079            row += 1;
1080        }
1081    }
1082
1083    by_row
1084}
1085
1086// ---------------------------------------------------------------------------
1087// Helper: build per-row span table (renderer-agnostic StyleSpec output)
1088// ---------------------------------------------------------------------------
1089
1090/// Resolve flat highlight spans into a per-row span table sized to `row_count`.
1091pub fn build_by_row(
1092    flat_spans: &[hjkl_bonsai::HighlightSpan],
1093    bytes: &[u8],
1094    row_starts: &[usize],
1095    row_count: usize,
1096    theme: &dyn Theme,
1097) -> Vec<Vec<(usize, usize, StyleSpec)>> {
1098    let mut by_row: Vec<Vec<(usize, usize, StyleSpec)>> = vec![Vec::new(); row_count];
1099
1100    for span in flat_spans {
1101        let hex_style: Option<StyleSpec> = if span.capture() == HEX_COLOR_CAPTURE {
1102            let bg = match span.metadata.get(HEX_BG_KEY) {
1103                Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1104                _ => None,
1105            };
1106            let fg = match span.metadata.get(HEX_FG_KEY) {
1107                Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1108                _ => None,
1109            };
1110            bg.map(|bg| StyleSpec {
1111                fg,
1112                bg: Some(bg),
1113                modifiers: hjkl_theme::Modifiers::default(),
1114            })
1115        } else if span.capture() == RAINBOW_BRACKET_CAPTURE {
1116            let depth = match span.metadata.get(RAINBOW_DEPTH_KEY) {
1117                Some(MetaValue::Int(d)) => *d as usize,
1118                _ => 0,
1119            };
1120            let fg = RAINBOW_PALETTE[depth % RAINBOW_PALETTE.len()];
1121            Some(StyleSpec {
1122                fg: Some(fg),
1123                bg: None,
1124                modifiers: hjkl_theme::Modifiers::default(),
1125            })
1126        } else {
1127            None
1128        };
1129
1130        let style: StyleSpec = if let Some(s) = hex_style {
1131            s
1132        } else {
1133            match theme.style(span.capture()) {
1134                Some(s) => *s,
1135                None => continue,
1136            }
1137        };
1138        let style = &style;
1139
1140        let span_start = span.byte_range.start;
1141        let span_end = span.byte_range.end;
1142
1143        let start_row = row_starts
1144            .partition_point(|&rs| rs <= span_start)
1145            .saturating_sub(1);
1146
1147        let mut row = start_row;
1148        while row < row_count {
1149            // `row_count` is caller-supplied and may exceed `row_starts.len()`;
1150            // stop rather than index out of bounds.
1151            let Some(&row_byte_start) = row_starts.get(row) else {
1152                break;
1153            };
1154            let row_byte_end = row_starts
1155                .get(row + 1)
1156                .map(|&s| s.saturating_sub(1))
1157                .unwrap_or(bytes.len());
1158
1159            if row_byte_start >= span_end {
1160                break;
1161            }
1162
1163            let local_start = span_start.saturating_sub(row_byte_start);
1164            let local_end = span_end.min(row_byte_end) - row_byte_start;
1165
1166            if local_end > local_start {
1167                by_row[row].push((local_start, local_end, *style));
1168            }
1169
1170            row += 1;
1171        }
1172    }
1173
1174    by_row
1175}
1176
1177// ---------------------------------------------------------------------------
1178// Helper: collect diagnostic signs
1179// ---------------------------------------------------------------------------
1180
1181fn collect_diag_signs_range(
1182    h: &mut Highlighter,
1183    rope: &ropey::Rope,
1184    row_starts: &[usize],
1185    vp_top: usize,
1186    vp_end: usize,
1187) -> Vec<DiagSign> {
1188    let rope_len = rope.len_bytes();
1189    let byte_start = row_starts.get(vp_top).copied().unwrap_or(rope_len);
1190    let byte_end = row_starts.get(vp_end).copied().unwrap_or(rope_len);
1191    // The retained tree stores document-absolute byte offsets, and
1192    // `parse_errors_range` both filters nodes against `byte_range` and
1193    // harvests snippets by indexing `source` with those absolute offsets.
1194    // Materialise only the viewport window (typically ≪ 100 KB), but place
1195    // it at its absolute position in a zero-filled buffer so offsets line
1196    // up. `vec![0u8; n]` uses `alloc_zeroed`, so the prefix costs no
1197    // explicit writes. Passing a window-relative range here previously
1198    // reported errors from the wrong document region once scrolled.
1199    let source: Vec<u8> = if byte_start < byte_end && byte_end <= rope_len {
1200        let mut buf = vec![0u8; byte_end];
1201        let mut pos = byte_start;
1202        for chunk in rope.byte_slice(byte_start..byte_end).chunks() {
1203            buf[pos..pos + chunk.len()].copy_from_slice(chunk.as_bytes());
1204            pos += chunk.len();
1205        }
1206        buf
1207    } else {
1208        Vec::new()
1209    };
1210    let errors = h.parse_errors_range(&source, byte_start..byte_end);
1211    let mut signs: Vec<DiagSign> = Vec::new();
1212    let mut last_row: Option<usize> = None;
1213    for err in &errors {
1214        // Error byte ranges are already document-absolute.
1215        let abs_start = err.byte_range.start;
1216        let r = row_starts
1217            .partition_point(|&rs| rs <= abs_start)
1218            .saturating_sub(1);
1219        if last_row == Some(r) {
1220            continue;
1221        }
1222        last_row = Some(r);
1223        signs.push(DiagSign::new(r, 'E', 100));
1224    }
1225    signs
1226}
1227
1228// ---------------------------------------------------------------------------
1229// Factory helpers
1230// ---------------------------------------------------------------------------
1231
1232/// Build a `SyntaxLayer` using the given theme + language directory.
1233pub fn layer_with_theme(
1234    theme: Arc<DotFallbackTheme>,
1235    directory: Arc<LanguageDirectory>,
1236) -> SyntaxLayer {
1237    SyntaxLayer::new(theme, directory)
1238}
1239
1240/// Build a `SyntaxLayer` with hjkl-bonsai's bundled dark theme.
1241#[cfg(test)]
1242pub fn default_layer() -> SyntaxLayer {
1243    let directory = Arc::new(LanguageDirectory::new().expect("language directory"));
1244    SyntaxLayer::new(Arc::new(DotFallbackTheme::dark()), directory)
1245}
1246
1247// ---------------------------------------------------------------------------
1248// Tests
1249// ---------------------------------------------------------------------------
1250
1251#[cfg(test)]
1252mod tests {
1253    use super::*;
1254    use hjkl_buffer::Buffer;
1255    use std::path::Path;
1256
1257    const TID: BufferId = 0;
1258
1259    // --- DiagSign ---
1260
1261    #[test]
1262    fn diag_sign_new_roundtrip() {
1263        let s = DiagSign::new(7, 'W', 50);
1264        assert_eq!(s.row, 7);
1265        assert_eq!(s.ch, 'W');
1266        assert_eq!(s.priority, 50);
1267    }
1268
1269    #[test]
1270    fn diag_sign_default_is_sensible() {
1271        let s = DiagSign::default();
1272        assert_eq!(s.row, 0);
1273        assert_eq!(s.ch, 'E');
1274        assert_eq!(s.priority, 0);
1275    }
1276
1277    // --- PerfBreakdown ---
1278
1279    #[test]
1280    fn perf_breakdown_default_zeros() {
1281        let p = PerfBreakdown::new();
1282        assert_eq!(p.source_build_us, 0);
1283        assert_eq!(p.parse_us, 0);
1284        assert_eq!(p.highlight_us, 0);
1285        assert_eq!(p.by_row_us, 0);
1286        assert_eq!(p.diag_us, 0);
1287    }
1288
1289    // --- SetLanguageOutcome ---
1290
1291    #[test]
1292    fn set_language_outcome_is_known() {
1293        assert!(SetLanguageOutcome::Ready.is_known());
1294        assert!(SetLanguageOutcome::Loading("rust".to_string()).is_known());
1295        assert!(!SetLanguageOutcome::Unknown.is_known());
1296    }
1297
1298    // --- RenderOutput ---
1299
1300    #[test]
1301    fn render_output_new_roundtrip() {
1302        let out = RenderOutput::new(
1303            99,
1304            vec![vec![]],
1305            vec![DiagSign::new(0, 'E', 100)],
1306            (7, 0, 30),
1307            PerfBreakdown::new(),
1308        );
1309        assert_eq!(out.buffer_id, 99);
1310        assert_eq!(out.key, (7, 0, 30));
1311        assert_eq!(out.signs.len(), 1);
1312    }
1313
1314    #[test]
1315    fn render_output_partial_eq_same() {
1316        let a = RenderOutput::new(
1317            0,
1318            vec![vec![(0, 5, StyleSpec::default())]],
1319            vec![],
1320            (1, 0, 10),
1321            PerfBreakdown::default(),
1322        );
1323        let b = a.clone();
1324        assert_eq!(a, b);
1325    }
1326
1327    // --- build_by_row ---
1328
1329    #[test]
1330    fn build_by_row_empty_spans_gives_empty_rows() {
1331        let by_row = build_by_row(
1332            &[],
1333            b"hello\nworld\n",
1334            &[0, 6, 12],
1335            2,
1336            &DotFallbackTheme::dark(),
1337        );
1338        assert_eq!(by_row.len(), 2);
1339        assert!(by_row[0].is_empty());
1340        assert!(by_row[1].is_empty());
1341    }
1342
1343    #[test]
1344    fn build_by_row_hex_color_uses_metadata_colors() {
1345        let bytes = b"--accent: #bb9af7;";
1346        let mut metadata = std::collections::HashMap::new();
1347        metadata.insert(
1348            HEX_BG_KEY.to_string(),
1349            MetaValue::Str("#bb9af7".to_string()),
1350        );
1351        metadata.insert(
1352            HEX_FG_KEY.to_string(),
1353            MetaValue::Str("#ffffff".to_string()),
1354        );
1355        let span = hjkl_bonsai::HighlightSpan {
1356            byte_range: 10..17,
1357            capture: HEX_COLOR_CAPTURE.to_string(),
1358            metadata,
1359        };
1360        let by_row = build_by_row(&[span], bytes, &[0], 1, &DotFallbackTheme::dark());
1361        assert_eq!(by_row.len(), 1);
1362        assert_eq!(by_row[0].len(), 1);
1363        let (_, _, style) = by_row[0][0];
1364        let bg = style.bg.expect("hex color must set background");
1365        assert_eq!((bg.r, bg.g, bg.b), (0xbb, 0x9a, 0xf7));
1366        let fg = style.fg.expect("hex color must set foreground");
1367        assert_eq!((fg.r, fg.g, fg.b), (0xff, 0xff, 0xff));
1368    }
1369
1370    #[test]
1371    fn build_by_row_row_count_beyond_row_starts_no_panic() {
1372        // `row_count` is caller-supplied; when it exceeds `row_starts.len()`
1373        // the walk must stop instead of indexing out of bounds.
1374        let bytes = b"foo";
1375        let mut metadata = std::collections::HashMap::new();
1376        metadata.insert(
1377            HEX_BG_KEY.to_string(),
1378            MetaValue::Str("#112233".to_string()),
1379        );
1380        let span = hjkl_bonsai::HighlightSpan {
1381            byte_range: 0..3,
1382            capture: HEX_COLOR_CAPTURE.to_string(),
1383            metadata,
1384        };
1385        let by_row = build_by_row(&[span], bytes, &[0], 3, &DotFallbackTheme::dark());
1386        assert_eq!(by_row.len(), 3);
1387        assert_eq!(by_row[0].len(), 1);
1388        assert!(by_row[1].is_empty());
1389        assert!(by_row[2].is_empty());
1390    }
1391
1392    #[test]
1393    fn build_by_row_hex_color_without_metadata_skips() {
1394        let span = hjkl_bonsai::HighlightSpan {
1395            byte_range: 0..3,
1396            capture: HEX_COLOR_CAPTURE.to_string(),
1397            metadata: std::collections::HashMap::new(),
1398        };
1399        let by_row = build_by_row(&[span], b"foo", &[0], 1, &DotFallbackTheme::dark());
1400        assert_eq!(by_row.len(), 1);
1401        assert!(by_row[0].is_empty());
1402    }
1403
1404    // --- SyntaxLayer basics (no network required) ---
1405
1406    #[test]
1407    fn render_viewport_with_no_language_returns_none() {
1408        let buf = Buffer::from_str("hello world");
1409        let mut layer = default_layer();
1410        assert!(
1411            !layer
1412                .set_language_for_path(TID, Path::new("a.unknownext"))
1413                .is_known()
1414        );
1415        assert!(layer.render_viewport(TID, &buf, 0, 10).is_none());
1416    }
1417
1418    #[test]
1419    fn apply_edits_with_no_language_is_noop() {
1420        let mut layer = default_layer();
1421        let edits = vec![hjkl_engine::ContentEdit {
1422            start_byte: 0,
1423            old_end_byte: 0,
1424            new_end_byte: 1,
1425            start_position: (0, 0),
1426            old_end_position: (0, 0),
1427            new_end_position: (0, 1),
1428        }];
1429        layer.apply_edits(TID, &edits);
1430        // No grammar attached → call must be a no-op (no panic).
1431    }
1432
1433    #[test]
1434    fn set_language_for_path_returns_unknown_for_unrecognized_extension() {
1435        let mut layer = default_layer();
1436        let outcome = layer.set_language_for_path(TID, Path::new("a.zzznope_not_real"));
1437        assert!(!outcome.is_known());
1438        assert!(matches!(outcome, SetLanguageOutcome::Unknown));
1439    }
1440
1441    #[test]
1442    fn poll_pending_loads_drains_ready_handles() {
1443        let mut layer = default_layer();
1444        let events = layer.poll_pending_loads();
1445        assert!(
1446            events.is_empty(),
1447            "expected no events with no pending loads"
1448        );
1449    }
1450
1451    #[test]
1452    fn forget_removes_client_state() {
1453        let mut layer = default_layer();
1454        layer.set_language_for_path(TID, Path::new("a.zzz_unknown"));
1455        layer.forget(TID);
1456        assert!(!layer.clients.contains_key(&TID));
1457    }
1458
1459    // --- Regression: fold extraction must return None when grammar not ready ---
1460    //
1461    // Before the fix (commit edbe2e99), `extract_fold_ranges` returned
1462    // `Vec::new()` for BOTH "grammar not ready" and "grammar ready but no
1463    // folds". The caller in `syntax_glue.rs::recompute_and_install` could not
1464    // distinguish the two cases, so it set `last_fold_dirty_gen = Some(dg)`
1465    // even when the grammar was still loading. When the grammar finished
1466    // loading, `dirty_gen` was unchanged → the fold-extraction condition
1467    // (`last_fold_dg != Some(dg)`) was false → folds were NEVER extracted.
1468    //
1469    // The fix changes `extract_fold_ranges` to return `Option<Vec<...>>`:
1470    // - `None`  = grammar not ready yet — caller must NOT update dirty_gen,
1471    //             so fold extraction retries on the next recompute.
1472    // - `Some`  = grammar was ready and ran (ranges may be empty if no
1473    //             folds.scm or no multi-line nodes).
1474    //
1475    // These tests exercise both branches WITHOUT requiring a downloaded grammar.
1476
1477    #[test]
1478    fn extract_fold_ranges_returns_none_when_no_language_attached() {
1479        // Simulates the "grammar still loading" or "unknown extension" state.
1480        // `extract_fold_ranges` must return `None` so the caller knows NOT to
1481        // mark the dirty_gen as processed. Before the fix this returned
1482        // `Vec::new()`, which the caller misinterpreted as "ran successfully,
1483        // no folds" → dirty_gen stamped → folds never re-tried after load.
1484        let buf =
1485            Buffer::from_str("fn hello() {\n    let x = 1;\n    x\n}\n\nfn world() {\n    2\n}\n");
1486        let mut layer = default_layer();
1487        // Deliberately use an unknown extension so no grammar is attached.
1488        layer.set_language_for_path(TID, Path::new("a.zzz_no_grammar_here"));
1489        let result = layer.extract_fold_ranges(TID, &buf);
1490        assert!(
1491            result.is_none(),
1492            "extract_fold_ranges must return None when no grammar is attached \
1493             (grammar still loading or unknown extension); got {result:?}"
1494        );
1495    }
1496
1497    #[test]
1498    fn extract_fold_ranges_returns_none_when_no_client_registered() {
1499        // Buffer ID with no prior `set_language_for_path` call — no client at all.
1500        let buf = Buffer::from_str("fn foo() {}\n");
1501        let mut layer = default_layer();
1502        // Never called set_language_for_path for TID.
1503        let result = layer.extract_fold_ranges(TID, &buf);
1504        assert!(
1505            result.is_none(),
1506            "extract_fold_ranges must return None when buffer has no syntax client; \
1507             got {result:?}"
1508        );
1509    }
1510
1511    // --- Network-dependent tests (grammar needed) ---
1512
1513    #[test]
1514    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1515    fn parse_and_render_small_rust_buffer() {
1516        let buf = Buffer::from_str("fn main() { let x = 1; }\n");
1517        let mut layer = default_layer();
1518        assert!(
1519            layer
1520                .set_language_for_path(TID, Path::new("a.rs"))
1521                .is_known()
1522        );
1523        let out = layer
1524            .render_viewport(TID, &buf, 0, 10)
1525            .expect("render output");
1526        assert!(
1527            out.spans.iter().any(|r| !r.is_empty()),
1528            "expected at least one styled span"
1529        );
1530    }
1531
1532    #[test]
1533    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1534    fn diagnostics_emit_sign_for_syntax_error() {
1535        let buf = Buffer::from_str("fn main() {\nlet x = ;\n}\n");
1536        let mut layer = default_layer();
1537        layer.set_language_for_path(TID, Path::new("a.rs"));
1538        let out = layer.render_viewport(TID, &buf, 0, 10).unwrap();
1539        assert!(
1540            !out.signs.is_empty(),
1541            "expected at least one diagnostic sign for `let x = ;`"
1542        );
1543        assert!(
1544            out.signs.iter().any(|s| s.row == 1 && s.ch == 'E'),
1545            "expected an 'E' sign on row 1; got {:?}",
1546            out.signs
1547        );
1548    }
1549
1550    #[test]
1551    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1552    fn diagnostics_signs_correct_when_scrolled() {
1553        // Regression: `collect_diag_signs_range` used to pass a
1554        // window-relative byte range + window-only source to
1555        // `parse_errors_range`, which filters tree nodes by absolute
1556        // offsets — so once scrolled it reported errors from the top of
1557        // the document and shifted the resulting rows by the window
1558        // offset. The error below sits on rows 50–52; the viewport starts
1559        // at row 45.
1560        let mut src = String::new();
1561        for i in 0..50 {
1562            src.push_str(&format!("fn f{i}() {{}}\n"));
1563        }
1564        src.push_str("fn broken() {\nlet x = ;\n}\n");
1565        let buf = Buffer::from_str(&src);
1566        let mut layer = default_layer();
1567        layer.set_language_for_path(TID, Path::new("a.rs"));
1568        let out = layer.render_viewport(TID, &buf, 45, 20).unwrap();
1569        assert!(
1570            out.signs
1571                .iter()
1572                .any(|s| (50..=52).contains(&s.row) && s.ch == 'E'),
1573            "expected an 'E' sign on rows 50..=52; got {:?}",
1574            out.signs
1575        );
1576    }
1577
1578    #[test]
1579    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1580    fn incremental_path_matches_cold_for_small_edit() {
1581        let pre = Buffer::from_str("fn main() { let x = 1; }");
1582        let mut layer = default_layer();
1583        layer.set_language_for_path(TID, Path::new("a.rs"));
1584        let _ = layer.render_viewport(TID, &pre, 0, 10).unwrap();
1585        layer.apply_edits(
1586            TID,
1587            &[hjkl_engine::ContentEdit {
1588                start_byte: 3,
1589                old_end_byte: 3,
1590                new_end_byte: 4,
1591                start_position: (0, 3),
1592                old_end_position: (0, 3),
1593                new_end_position: (0, 4),
1594            }],
1595        );
1596        let post = Buffer::from_str("fn Ymain() { let x = 1; }");
1597        let inc = layer.render_viewport(TID, &post, 0, 10).unwrap();
1598        let mut cold_layer = default_layer();
1599        cold_layer.set_language_for_path(TID, Path::new("a.rs"));
1600        let cold = cold_layer.render_viewport(TID, &post, 0, 10).unwrap();
1601        assert_eq!(inc.spans, cold.spans);
1602    }
1603
1604    #[test]
1605    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1606    fn forget_drops_buffer_state() {
1607        let buf = Buffer::from_str("fn main() {}");
1608        let mut layer = default_layer();
1609        layer.set_language_for_path(TID, Path::new("a.rs"));
1610        let _ = layer.render_viewport(TID, &buf, 0, 10).unwrap();
1611        assert!(layer.clients.contains_key(&TID));
1612        layer.forget(TID);
1613        assert!(!layer.clients.contains_key(&TID));
1614    }
1615}