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