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, InjectedFoldCache, InputEdit, MetaValue, Point, RAINBOW_BRACKET_CAPTURE,
21    RAINBOW_DEPTH_KEY, Theme, extract_fold_ranges_rope_with_injections, 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.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,
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    /// Memo of the folds each injected region produced, so one edit re-parses
366    /// only the region it touched. Content-hash keyed, so it survives
367    /// invalidation of everything else here.
368    fold_injections: InjectedFoldCache,
369}
370
371impl Default for BufferClient {
372    fn default() -> Self {
373        Self {
374            has_language: false,
375            current_lang: None,
376            highlighter: None,
377            cache_dirty_gen: None,
378            cache_rows: 0..0,
379            cache_spans: Vec::new(),
380            cache_row_starts: None,
381            parsed_dirty_gen: None,
382            cache_signs: None,
383            fold_injections: InjectedFoldCache::default(),
384        }
385    }
386}
387
388impl BufferClient {
389    fn invalidate_cache(&mut self) {
390        self.cache_dirty_gen = None;
391        self.cache_rows = 0..0;
392        self.cache_spans.clear();
393        self.cache_row_starts = None;
394        self.parsed_dirty_gen = None;
395        self.cache_signs = None;
396    }
397}
398
399// ---------------------------------------------------------------------------
400// SyntaxLayer — main-thread, fully synchronous
401// ---------------------------------------------------------------------------
402
403/// Per-App syntax highlighting layer. Multiplexes per-buffer state.
404/// Fully synchronous — no background thread.
405///
406/// # Examples
407///
408/// ```no_run
409/// use std::sync::Arc;
410/// use hjkl_syntax::SyntaxLayer;
411/// use hjkl_bonsai::DotFallbackTheme;
412/// use hjkl_lang::LanguageDirectory;
413///
414/// let theme = Arc::new(DotFallbackTheme::dark());
415/// let dir = Arc::new(LanguageDirectory::new().unwrap());
416/// let layer = SyntaxLayer::new(theme, dir);
417/// ```
418pub struct SyntaxLayer {
419    /// Shared grammar resolver.
420    pub directory: Arc<LanguageDirectory>,
421    theme: Arc<dyn Theme + Send + Sync>,
422    clients: HashMap<BufferId, BufferClient>,
423    pending_loads: Vec<PendingLoad>,
424    /// When `false`, `HexColorPass` is skipped for all buffers.
425    colorizer: bool,
426    /// Filetype allowlist for the colorizer. Empty = allow all.
427    colorizer_filetypes: Vec<String>,
428    /// When `true`, rainbow bracket overlay is applied. Default `true`.
429    rainbow_brackets: bool,
430}
431
432impl SyntaxLayer {
433    /// Create a new layer with no buffers attached.
434    ///
435    /// # Examples
436    ///
437    /// ```no_run
438    /// use std::sync::Arc;
439    /// use hjkl_syntax::SyntaxLayer;
440    /// use hjkl_bonsai::DotFallbackTheme;
441    /// use hjkl_lang::LanguageDirectory;
442    ///
443    /// let theme = Arc::new(DotFallbackTheme::dark());
444    /// let dir = Arc::new(LanguageDirectory::new().unwrap());
445    /// let layer = SyntaxLayer::new(theme, dir);
446    /// ```
447    pub fn new(theme: Arc<dyn Theme + Send + Sync>, directory: Arc<LanguageDirectory>) -> Self {
448        Self {
449            directory,
450            theme,
451            clients: HashMap::new(),
452            pending_loads: Vec::new(),
453            colorizer: true,
454            colorizer_filetypes: vec![
455                "css".to_string(),
456                "scss".to_string(),
457                "sass".to_string(),
458                "less".to_string(),
459                "html".to_string(),
460                "vue".to_string(),
461                "svelte".to_string(),
462                "tailwindcss".to_string(),
463                "toml".to_string(),
464                "lua".to_string(),
465                "vim".to_string(),
466            ],
467            rainbow_brackets: true,
468        }
469    }
470
471    /// Update rainbow bracket settings. Pass `enabled = false` to disable the
472    /// rainbow overlay globally. No-op when the value is unchanged so per-frame
473    /// pushes from the app stay cheap. Caches invalidate only on actual change.
474    pub fn set_rainbow_brackets(&mut self, enabled: bool) {
475        if self.rainbow_brackets == enabled {
476            return;
477        }
478        self.rainbow_brackets = enabled;
479        for client in self.clients.values_mut() {
480            client.invalidate_cache();
481        }
482    }
483
484    /// Update colorizer settings. Pass `enabled = false` to disable
485    /// the color-literal overlay globally. `filetypes` is the allowlist
486    /// of language names (e.g. `"css"`, `"toml"`); an empty slice means
487    /// no filetype is allowed (same effect as `enabled = false`).
488    ///
489    /// No-op when the values are unchanged so per-frame pushes from the
490    /// app stay cheap. Caches invalidate only on actual change.
491    pub fn set_colorizer(&mut self, enabled: bool, filetypes: Vec<String>) {
492        if self.colorizer == enabled && self.colorizer_filetypes == filetypes {
493            return;
494        }
495        self.colorizer = enabled;
496        self.colorizer_filetypes = filetypes;
497        for client in self.clients.values_mut() {
498            client.invalidate_cache();
499        }
500    }
501
502    /// Borrow the shared language directory.
503    pub fn directory(&self) -> &Arc<LanguageDirectory> {
504        &self.directory
505    }
506
507    fn client_mut(&mut self, id: BufferId) -> &mut BufferClient {
508        self.clients.entry(id).or_default()
509    }
510
511    /// Detect the language for `path` and attach a grammar.
512    ///
513    /// - `Ready`   — grammar cached; highlighter installed immediately.
514    /// - `Loading` — grammar compiling; renders as plain text until
515    ///   `poll_pending_loads` fires `LoadEvent::Ready`.
516    /// - `Unknown` — unrecognized extension; plain text only.
517    ///
518    /// # Examples
519    ///
520    /// ```no_run
521    /// use std::sync::Arc;
522    /// use std::path::Path;
523    /// use hjkl_syntax::{SyntaxLayer, SetLanguageOutcome};
524    /// use hjkl_bonsai::DotFallbackTheme;
525    /// use hjkl_lang::LanguageDirectory;
526    ///
527    /// let theme = Arc::new(DotFallbackTheme::dark());
528    /// let dir = Arc::new(LanguageDirectory::new().unwrap());
529    /// let mut layer = SyntaxLayer::new(theme, dir);
530    /// let outcome = layer.set_language_for_path(0, Path::new("a.zzz_not_real"));
531    /// assert!(!outcome.is_known());
532    /// ```
533    pub fn set_language_for_path(&mut self, id: BufferId, path: &Path) -> SetLanguageOutcome {
534        match self.directory.request_for_path(path) {
535            GrammarRequest::Cached(grammar) => {
536                self.attach_grammar(id, grammar.clone());
537                let c = self.client_mut(id);
538                c.current_lang = Some(grammar);
539                c.has_language = true;
540                SetLanguageOutcome::Ready
541            }
542            GrammarRequest::Loading { name, handle } => {
543                let c = self.client_mut(id);
544                c.current_lang = None;
545                c.has_language = false;
546                c.highlighter = None;
547                c.invalidate_cache();
548                self.pending_loads.push(PendingLoad {
549                    id,
550                    name: name.clone(),
551                    handle,
552                });
553                SetLanguageOutcome::Loading
554            }
555            GrammarRequest::Unknown | _ => {
556                let c = self.client_mut(id);
557                c.current_lang = None;
558                c.has_language = false;
559                c.highlighter = None;
560                c.invalidate_cache();
561                SetLanguageOutcome::Unknown
562            }
563        }
564    }
565
566    /// Attach a grammar by its canonical language name, bypassing path
567    /// detection entirely. The name may come from content detection (shebang,
568    /// modeline `ft=`) or a manual `:set filetype=`.
569    ///
570    /// Identical semantics to [`Self::set_language_for_path`] once the name
571    /// is known; an unrecognised name resolves to [`SetLanguageOutcome::Unknown`]
572    /// without attaching anything.
573    pub fn set_language_by_name(&mut self, id: BufferId, name: &str) -> SetLanguageOutcome {
574        match self.directory.request_by_name(name) {
575            GrammarRequest::Cached(grammar) => {
576                self.attach_grammar(id, grammar.clone());
577                let c = self.client_mut(id);
578                c.current_lang = Some(grammar);
579                c.has_language = true;
580                SetLanguageOutcome::Ready
581            }
582            GrammarRequest::Loading { name, handle } => {
583                let c = self.client_mut(id);
584                c.current_lang = None;
585                c.has_language = false;
586                c.highlighter = None;
587                c.invalidate_cache();
588                self.pending_loads.push(PendingLoad {
589                    id,
590                    name: name.clone(),
591                    handle,
592                });
593                SetLanguageOutcome::Loading
594            }
595            GrammarRequest::Unknown | _ => {
596                let c = self.client_mut(id);
597                c.current_lang = None;
598                c.has_language = false;
599                c.highlighter = None;
600                c.invalidate_cache();
601                SetLanguageOutcome::Unknown
602            }
603        }
604    }
605
606    /// Attach a grammar to a buffer, creating/replacing the Highlighter.
607    fn attach_grammar(&mut self, id: BufferId, grammar: Arc<Grammar>) {
608        let c = self.clients.entry(id).or_default();
609        c.invalidate_cache();
610        match Highlighter::new(grammar) {
611            Ok(h) => {
612                c.highlighter = Some(h);
613            }
614            Err(e) => {
615                tracing::error!(buffer_id = id, error = %e, "failed to attach highlighter");
616                c.highlighter = None;
617            }
618        }
619    }
620
621    /// Poll all in-flight grammar loads. Call once per tick.
622    ///
623    /// Returns one `LoadEvent` per handle that resolved during this tick.
624    pub fn poll_pending_loads(&mut self) -> Vec<LoadEvent> {
625        let mut events = Vec::new();
626        let mut i = 0;
627        while i < self.pending_loads.len() {
628            match self.pending_loads[i].handle.try_recv() {
629                None => {
630                    i += 1;
631                }
632                Some(Ok(lib_path)) => {
633                    let name = self.pending_loads[i].name.clone();
634                    let bid = self.pending_loads[i].id;
635                    self.pending_loads.swap_remove(i);
636                    match self.directory.complete_load(&name, &lib_path) {
637                        Ok(grammar) => {
638                            self.attach_grammar(bid, grammar.clone());
639                            let c = self.client_mut(bid);
640                            c.current_lang = Some(grammar);
641                            c.has_language = true;
642                            events.push(LoadEvent::Ready { id: bid, name });
643                        }
644                        Err(e) => {
645                            events.push(LoadEvent::Failed {
646                                id: bid,
647                                name,
648                                error: format!("{e:#}"),
649                            });
650                        }
651                    }
652                }
653                Some(Err(err)) => {
654                    let name = self.pending_loads[i].name.clone();
655                    let bid = self.pending_loads[i].id;
656                    self.pending_loads.swap_remove(i);
657                    events.push(LoadEvent::Failed {
658                        id: bid,
659                        name,
660                        error: err.to_string(),
661                    });
662                }
663            }
664        }
665        events
666    }
667
668    /// Drop all state for a buffer. Call on close.
669    pub fn forget(&mut self, id: BufferId) {
670        self.clients.remove(&id);
671    }
672
673    /// Swap the active theme. Next `render_viewport` call uses the new theme.
674    pub fn set_theme(&mut self, theme: Arc<dyn Theme + Send + Sync>) {
675        self.theme = theme;
676        // Invalidate all per-buffer caches so they repaint with the new theme.
677        for c in self.clients.values_mut() {
678            c.invalidate_cache();
679        }
680    }
681
682    /// Apply a batch of engine `ContentEdit`s to the buffer's retained tree
683    /// synchronously. The cache will be invalidated on the next `render_viewport`
684    /// call via dirty_gen mismatch.
685    ///
686    /// No-op when no grammar is attached.
687    pub fn apply_edits(&mut self, id: BufferId, edits: &[hjkl_engine::ContentEdit]) {
688        let c = match self.clients.get_mut(&id) {
689            Some(c) if c.has_language => c,
690            _ => return,
691        };
692        let Some(h) = c.highlighter.as_mut() else {
693            return;
694        };
695        for e in edits {
696            h.edit(&InputEdit {
697                start_byte: e.start_byte,
698                old_end_byte: e.old_end_byte,
699                new_end_byte: e.new_end_byte,
700                start_position: Point {
701                    row: e.start_position.0 as usize,
702                    column: e.start_position.1 as usize,
703                },
704                old_end_position: Point {
705                    row: e.old_end_position.0 as usize,
706                    column: e.old_end_position.1 as usize,
707                },
708                new_end_position: Point {
709                    row: e.new_end_position.0 as usize,
710                    column: e.new_end_position.1 as usize,
711                },
712            });
713        }
714        // Drop every cache, the span table included. This used to clear only
715        // the parse, row-start and sign caches and leave `cache_spans` to the
716        // `dirty_gen` mismatch in `render_viewport` — which does happen for a
717        // real buffer edit, but makes correctness here depend on a counter
718        // this function neither reads nor controls. A caller that edits
719        // through `apply_edits` without the buffer's `dirty_gen` moving got
720        // the PRE-edit spans back, reparse and all.
721        c.invalidate_cache();
722    }
723
724    /// Drop the buffer's retained tree. Next `render_viewport` reparses from scratch.
725    ///
726    /// Call on `:e!` / content reset.
727    pub fn reset(&mut self, id: BufferId) {
728        if let Some(c) = self.clients.get_mut(&id) {
729            if let Some(h) = c.highlighter.as_mut() {
730                h.reset();
731            }
732            c.invalidate_cache();
733        }
734    }
735
736    /// Extract fold ranges from the buffer's retained tree using the bundled
737    /// `folds.scm` for this grammar, plus one for each injected region using
738    /// ITS language's query.
739    ///
740    /// Returns `Some(ranges)` when the grammar is attached and the tree has
741    /// been parsed — `ranges` may be empty when the grammar has no bundled
742    /// `folds.scm` or the file contains no foldable nodes.
743    ///
744    /// Injected regions (a ` ```rust ` block in markdown, a `<script>` body in
745    /// HTML) are parsed with their own grammar, folded with that language's
746    /// query, and their rows offset into this document — see
747    /// [`hjkl_bonsai::extract_fold_ranges_rope_with_injections`]. Resolving an
748    /// injected grammar goes through the shared [`LanguageDirectory`], which
749    /// may build one on first use; only languages with a bundled fold query
750    /// can trigger that.
751    ///
752    /// Returns `None` when:
753    /// - No grammar is attached yet (grammar still loading or unknown extension).
754    /// - No highlighter has been created for this buffer.
755    /// - The tree has not been parsed yet (call `render_viewport` first).
756    ///
757    /// **Callers must treat `None` as "not ready — retry later"** and must NOT
758    /// record the dirty_gen as processed when `None` is returned. Returning
759    /// `Some(empty)` is the signal that the grammar ran but produced no folds
760    /// (e.g. no `folds.scm` for this language).
761    ///
762    /// **NOT viewport-bounded** — runs over the full tree (once per reparse).
763    /// Do not call this per-frame; call it only when `dirty_gen` has changed.
764    pub fn extract_fold_ranges(
765        &mut self,
766        id: BufferId,
767        buffer: &impl hjkl_engine::Query,
768    ) -> Option<Vec<(usize, usize)>> {
769        let directory = Arc::clone(&self.directory);
770        let client = match self.clients.get_mut(&id) {
771            Some(c) if c.has_language => c,
772            // Grammar not yet attached (loading or unknown) — signal "not ready".
773            _ => return None,
774        };
775        // Highlighter creation failed — signal "not ready".
776        let highlighter = client.highlighter.as_ref()?;
777        // Tree not yet parsed — signal "not ready".
778        let tree = highlighter.tree()?;
779        let grammar = highlighter.grammar()?;
780        let injections = highlighter.injection_query();
781        let rope = buffer.rope();
782        // Grammar is ready and tree is parsed — return Some even if empty.
783        // Injected regions (a ```rust block in markdown, the `<script>` body of
784        // an HTML page) are folded with their own grammar; a language whose
785        // grammar is not installed simply contributes nothing.
786        Some(extract_fold_ranges_rope_with_injections(
787            tree,
788            grammar,
789            &rope,
790            injections,
791            &mut client.fold_injections,
792            // Cache-only: a fold pass must never be what clone+compiles a
793            // grammar. Injected grammars are loaded (and cached) by the
794            // highlight path; until then that region simply contributes no
795            // folds, and a later frame picks it up.
796            |name| directory.by_name_cached(name),
797        ))
798    }
799
800    /// Render spans for the visible viewport, returning an owned span table.
801    ///
802    /// Thin wrapper over [`Self::render_viewport_ref`] that deep-copies the
803    /// cached rows. Callers that immediately convert the table into their own
804    /// style type (every renderer adapter) should use `render_viewport_ref`
805    /// instead and skip the copy.
806    pub fn render_viewport(
807        &mut self,
808        id: BufferId,
809        buffer: &impl Query,
810        viewport_top: usize,
811        viewport_height: usize,
812    ) -> Option<RenderOutput> {
813        Some(
814            self.render_viewport_ref(id, buffer, viewport_top, viewport_height)?
815                .into_owned(),
816        )
817    }
818
819    /// Render spans for the visible viewport. Fully synchronous.
820    ///
821    /// 1. Returns `None` when no grammar is attached.
822    /// 2. Clears the cache when `buffer.dirty_gen()` has advanced.
823    /// 3. Returns cached rows when the request is fully inside the cached range.
824    /// 4. Walks only rows outside the cache (extend prefix/suffix), splices into
825    ///    `cache_spans`, extends `cache_rows`.
826    ///
827    /// The returned [`RenderOutputRef`] borrows the viewport slice of
828    /// `cache_spans` — no per-call copy of the span table.
829    pub fn render_viewport_ref(
830        &mut self,
831        id: BufferId,
832        buffer: &impl Query,
833        viewport_top: usize,
834        viewport_height: usize,
835    ) -> Option<RenderOutputRef<'_>> {
836        let client = self.clients.get_mut(&id)?;
837        if !client.has_language {
838            return None;
839        }
840        let dg = buffer.dirty_gen();
841        let row_count = buffer.line_count() as usize;
842        if row_count == 0 || viewport_height == 0 {
843            return None;
844        }
845
846        let vp_top = viewport_top.min(row_count);
847        let vp_end = (vp_top + viewport_height).min(row_count);
848        if vp_end <= vp_top {
849            return None;
850        }
851
852        // Single dirty_gen invalidation point.
853        if client.cache_dirty_gen != Some(dg) {
854            client.invalidate_cache();
855        }
856
857        // Get a rope snapshot — O(1) Arc-clone from hjkl_buffer::View.
858        // All downstream consumers (parse, highlight, row_starts, diag signs)
859        // now read directly from the rope: no full-document String allocation.
860        let rope = buffer.rope();
861
862        // Get or build row_starts, cached per dirty_gen.
863        let row_starts: Arc<Vec<usize>> = if client
864            .cache_row_starts
865            .as_ref()
866            .is_some_and(|(g, _)| *g == dg)
867        {
868            Arc::clone(&client.cache_row_starts.as_ref().unwrap().1)
869        } else {
870            let arc = Arc::new(row_starts(&rope, row_count));
871            client.cache_row_starts = Some((dg, Arc::clone(&arc)));
872            arc
873        };
874
875        // Reparse only when needed. Use rope-streaming parse to avoid passing
876        // the full bytes slice into the parser (tree-sitter reads chunk-by-chunk
877        // via the closure; no contiguous copy required for the parse step).
878        let needs_reparse = client.parsed_dirty_gen != Some(dg);
879        {
880            let highlighter = client.highlighter.as_mut()?;
881            if highlighter.tree().is_none() {
882                highlighter.parse_initial_rope(&rope);
883                if highlighter.tree().is_some() {
884                    client.parsed_dirty_gen = Some(dg);
885                }
886            } else if needs_reparse {
887                // No-diff incremental: we discard the changed-byte ranges
888                // (cache is keyed by dirty_gen + viewport, not by edit
889                // ranges). Computing `old.changed_ranges(&new)` walks both
890                // trees and was ~54 % of per-keystroke CPU on a 1.86 M-line
891                // file.
892                let ok = highlighter.parse_incremental_rope(&rope);
893                if ok && highlighter.tree().is_some() {
894                    client.parsed_dirty_gen = Some(dg);
895                }
896            }
897        }
898
899        // Compute colorizer gate before re-borrowing client mutably.
900        // Effective = global flag AND current language is in the allowlist.
901        let colorizer_enabled = {
902            let c = self.clients.get(&id)?;
903            let lang_name = c.current_lang.as_ref().map_or("", |g| g.name());
904            self.colorizer
905                && (self.colorizer_filetypes.is_empty()
906                    || self.colorizer_filetypes.iter().any(|ft| ft == lang_name))
907        };
908        let rainbow_brackets_enabled = self.rainbow_brackets;
909
910        // Re-borrow after parse.
911        let client = self.clients.get_mut(&id)?;
912        let highlighter = client.highlighter.as_mut()?;
913
914        // If still no tree (parse failed), give up.
915        highlighter.tree()?;
916
917        let theme = self.theme.as_ref();
918        let directory = Arc::clone(&self.directory);
919
920        // Extend cache to cover [vp_top, vp_end).
921        if client.cache_rows.is_empty() {
922            // Case A: empty cache — walk full range.
923            client.cache_spans = walk_rows(
924                highlighter,
925                &rope,
926                &row_starts,
927                row_count,
928                vp_top,
929                vp_end,
930                theme,
931                &directory,
932                colorizer_enabled,
933                rainbow_brackets_enabled,
934            );
935            client.cache_rows = vp_top..vp_end;
936            client.cache_dirty_gen = Some(dg);
937        } else {
938            let cache_covers_overlap =
939                vp_top < client.cache_rows.end && vp_end > client.cache_rows.start;
940            if !cache_covers_overlap {
941                // Disjoint — just rebuild the whole viewport.
942                client.cache_spans = walk_rows(
943                    highlighter,
944                    &rope,
945                    &row_starts,
946                    row_count,
947                    vp_top,
948                    vp_end,
949                    theme,
950                    &directory,
951                    colorizer_enabled,
952                    rainbow_brackets_enabled,
953                );
954                client.cache_rows = vp_top..vp_end;
955            } else {
956                // Case B: extend prefix if needed.
957                if vp_top < client.cache_rows.start {
958                    let new_rows = walk_rows(
959                        highlighter,
960                        &rope,
961                        &row_starts,
962                        row_count,
963                        vp_top,
964                        client.cache_rows.start,
965                        theme,
966                        &directory,
967                        colorizer_enabled,
968                        rainbow_brackets_enabled,
969                    );
970                    let mut combined = new_rows;
971                    combined.append(&mut client.cache_spans);
972                    client.cache_spans = combined;
973                    client.cache_rows.start = vp_top;
974                }
975                // Case C: extend suffix if needed.
976                if vp_end > client.cache_rows.end {
977                    let new_rows = walk_rows(
978                        highlighter,
979                        &rope,
980                        &row_starts,
981                        row_count,
982                        client.cache_rows.end,
983                        vp_end,
984                        theme,
985                        &directory,
986                        colorizer_enabled,
987                        rainbow_brackets_enabled,
988                    );
989                    client.cache_spans.extend(new_rows);
990                    client.cache_rows.end = vp_end;
991                }
992            }
993            client.cache_dirty_gen = Some(dg);
994        }
995
996        // Bounds of the requested viewport inside the cache.
997        let offset = vp_top - client.cache_rows.start;
998        let len = vp_end - vp_top;
999
1000        // Get or build signs, cached per (dirty_gen, vp_top, vp_end).
1001        // Done before borrowing `cache_spans` so the mutable client borrow
1002        // (needed for `highlighter` and the sign-cache write) has ended.
1003        let signs = if client
1004            .cache_signs
1005            .as_ref()
1006            .is_some_and(|(g, t, e, _)| *g == dg && *t == vp_top && *e == vp_end)
1007        {
1008            client.cache_signs.as_ref().unwrap().3.clone()
1009        } else {
1010            let s = collect_diag_signs_range(highlighter, &rope, &row_starts, vp_top, vp_end);
1011            client.cache_signs = Some((dg, vp_top, vp_end, s.clone()));
1012            s
1013        };
1014
1015        // Borrow the viewport slice out of the cache — no copy.
1016        let spans = &self.clients.get(&id)?.cache_spans[offset..offset + len];
1017
1018        Some(RenderOutputRef {
1019            buffer_id: id,
1020            spans,
1021            signs,
1022            key: (dg, vp_top, viewport_height),
1023            perf: PerfBreakdown::default(),
1024        })
1025    }
1026
1027    /// Resolve a path to its language name without loading a grammar.
1028    pub fn name_for_path(&self, path: &Path) -> Option<String> {
1029        self.directory.name_for_path(path)
1030    }
1031
1032    /// Returns `true` if a client is tracked for the given buffer id.
1033    #[doc(hidden)]
1034    pub fn has_client(&self, id: BufferId) -> bool {
1035        self.clients.contains_key(&id)
1036    }
1037
1038    /// Dispatch a [`LoadEvent`] through a caller-supplied handler.
1039    ///
1040    /// # Examples
1041    ///
1042    /// ```rust
1043    /// use hjkl_syntax::{LoadEvent, SyntaxLayer};
1044    ///
1045    /// let event = LoadEvent::Ready { id: 0, name: "rust".into() };
1046    /// let mut got_ready = false;
1047    /// let handled = SyntaxLayer::dispatch_load_event(&event, |ev| {
1048    ///     use hjkl_syntax::LoadEventKind;
1049    ///     match ev {
1050    ///         LoadEventKind::Ready { id, name } => { got_ready = true; }
1051    ///         LoadEventKind::Failed { .. } => {}
1052    ///     }
1053    /// });
1054    /// assert!(handled);
1055    /// assert!(got_ready);
1056    /// ```
1057    pub fn dispatch_load_event(
1058        event: &LoadEvent,
1059        mut handler: impl FnMut(LoadEventKind<'_>),
1060    ) -> bool {
1061        #[allow(unreachable_patterns)]
1062        match event {
1063            LoadEvent::Ready { id, name } => {
1064                handler(LoadEventKind::Ready { id: *id, name });
1065                true
1066            }
1067            LoadEvent::Failed { id, name, error } => {
1068                handler(LoadEventKind::Failed {
1069                    id: *id,
1070                    name,
1071                    error,
1072                });
1073                true
1074            }
1075            _ => false,
1076        }
1077    }
1078}
1079
1080// ---------------------------------------------------------------------------
1081// Rainbow palette
1082// ---------------------------------------------------------------------------
1083
1084/// 7-colour rainbow palette for bracket depth coloring (dark-bg readable).
1085/// Depth 0 → index 0, depth N → RAINBOW_PALETTE[N % RAINBOW_PALETTE.len()].
1086const RAINBOW_PALETTE: [Color; 7] = [
1087    Color::rgb(255, 100, 100), // red
1088    Color::rgb(255, 175, 80),  // orange
1089    Color::rgb(255, 230, 80),  // yellow
1090    Color::rgb(100, 220, 100), // green
1091    Color::rgb(80, 210, 220),  // cyan
1092    Color::rgb(100, 140, 255), // blue
1093    Color::rgb(190, 120, 255), // violet
1094];
1095
1096/// Byte offset of the start of each of the rope's `row_count` rows.
1097///
1098/// Scans newlines chunk-by-chunk so the document is never materialised as
1099/// one contiguous byte slice; the memchr scan is measurably faster than a
1100/// per-byte loop. But the rows are the rope's lines, and ropey also breaks
1101/// on lone `\r`, VT, FF, NEL, LS and PS. When the `\n` scan disagrees with
1102/// `row_count`, one of those is present, and the row starts come from
1103/// ropey's own line iterator instead — so the span table built from them
1104/// always has a row per buffer row.
1105fn row_starts(rope: &ropey::Rope, row_count: usize) -> Vec<usize> {
1106    let mut rs: Vec<usize> = Vec::with_capacity(row_count + 1);
1107    rs.push(0);
1108    let mut chunk_pos = 0usize;
1109    for chunk in rope.chunks() {
1110        for nl in memchr::memchr_iter(b'\n', chunk.as_bytes()) {
1111            rs.push(chunk_pos + nl + 1);
1112        }
1113        chunk_pos += chunk.len();
1114    }
1115    if rs.len() == row_count {
1116        return rs;
1117    }
1118    let len = rope.len_bytes();
1119    if rs.len() + 1 == row_count && len > 0 && rope.byte(len - 1) == b'\r' {
1120        // Every CRLF file: load strips only the final `\n`, so its last line
1121        // ending is a lone `\r`, and the one row the scan missed is the empty
1122        // row after it. Kept off the slower line iterator because this runs
1123        // on every edit of such a file.
1124        rs.push(len);
1125        return rs;
1126    }
1127    rs.clear();
1128    let mut start = 0usize;
1129    for line in rope.lines() {
1130        rs.push(start);
1131        start += line.len_bytes();
1132    }
1133    rs
1134}
1135
1136// ---------------------------------------------------------------------------
1137// Helper: walk a row range against the retained tree
1138// ---------------------------------------------------------------------------
1139
1140#[allow(clippy::too_many_arguments)]
1141fn walk_rows(
1142    highlighter: &mut Highlighter,
1143    rope: &ropey::Rope,
1144    row_starts: &[usize],
1145    row_count: usize,
1146    seg_start: usize,
1147    seg_end: usize,
1148    theme: &dyn Theme,
1149    directory: &Arc<LanguageDirectory>,
1150    colorizer: bool,
1151    rainbow_brackets: bool,
1152) -> Vec<Vec<(usize, usize, StyleSpec)>> {
1153    let rope_len = rope.len_bytes();
1154    let byte_start = row_starts.get(seg_start).copied().unwrap_or(rope_len);
1155    let byte_end = row_starts
1156        .get(seg_end)
1157        .copied()
1158        .unwrap_or(rope_len)
1159        .min(rope_len)
1160        .max(byte_start);
1161
1162    let mut flat_spans =
1163        highlighter.highlight_range_with_injections_rope(rope, byte_start..byte_end, |name| {
1164            directory.by_name(name)
1165        });
1166
1167    let marker_pass = CommentMarkerPass::new();
1168    marker_pass.apply_rope(&mut flat_spans, rope);
1169    if colorizer {
1170        let hex_color_pass = HexColorPass::new();
1171        hex_color_pass.apply_range_rope(&mut flat_spans, rope, byte_start..byte_end);
1172    }
1173    if rainbow_brackets
1174        && let (Some(tree), Some(grammar)) = (highlighter.tree(), highlighter.grammar())
1175    {
1176        let rb_spans = rainbow_spans_rope(tree, grammar, rope, byte_start..byte_end);
1177        flat_spans.extend(rb_spans);
1178    }
1179
1180    // Bucket spans into ONLY the viewport row range. The prior version
1181    // called `build_by_row(..., row_count, ...)` and sliced the result,
1182    // which allocated `row_count` empty inner Vecs (8.58 M on a huge
1183    // file) just to throw away all but ~50 of them — that single line
1184    // was ~24 % of per-keystroke CPU during a paste burst.
1185    let _ = row_count; // kept in signature for the public build_by_row tests
1186    build_by_row_range(&flat_spans, rope_len, row_starts, seg_start..seg_end, theme)
1187}
1188
1189/// Viewport-bounded variant of [`build_by_row`]. Allocates exactly
1190/// `row_range.len()` inner Vecs instead of one per document row. Spans
1191/// whose byte range falls entirely outside `row_range` are skipped; spans
1192/// that overlap have their per-row slices recorded with positions local
1193/// to the viewport (so row `row_range.start` lands at index 0).
1194fn build_by_row_range(
1195    flat_spans: &[hjkl_bonsai::HighlightSpan],
1196    source_len: usize,
1197    row_starts: &[usize],
1198    row_range: Range<usize>,
1199    theme: &dyn Theme,
1200) -> Vec<Vec<(usize, usize, StyleSpec)>> {
1201    let seg_start = row_range.start;
1202    let seg_end = row_range.end.min(row_starts.len());
1203    if seg_end <= seg_start {
1204        return Vec::new();
1205    }
1206    let mut by_row: Vec<Vec<(usize, usize, StyleSpec)>> = vec![Vec::new(); seg_end - seg_start];
1207
1208    for span in flat_spans {
1209        let hex_style: Option<StyleSpec> = if span.capture() == HEX_COLOR_CAPTURE {
1210            let bg = match span.metadata().and_then(|m| m.get(HEX_BG_KEY)) {
1211                Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1212                _ => None,
1213            };
1214            let fg = match span.metadata().and_then(|m| m.get(HEX_FG_KEY)) {
1215                Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1216                _ => None,
1217            };
1218            bg.map(|bg| StyleSpec {
1219                fg,
1220                bg: Some(bg),
1221                modifiers: hjkl_theme::Modifiers::default(),
1222            })
1223        } else if span.capture() == RAINBOW_BRACKET_CAPTURE {
1224            let depth = match span.metadata().and_then(|m| m.get(RAINBOW_DEPTH_KEY)) {
1225                Some(MetaValue::Int(d)) => *d as usize,
1226                _ => 0,
1227            };
1228            let fg = RAINBOW_PALETTE[depth % RAINBOW_PALETTE.len()];
1229            Some(StyleSpec {
1230                fg: Some(fg),
1231                bg: None,
1232                modifiers: hjkl_theme::Modifiers::default(),
1233            })
1234        } else {
1235            None
1236        };
1237
1238        let style: StyleSpec = if let Some(s) = hex_style {
1239            s
1240        } else {
1241            match theme.style(span.capture()) {
1242                Some(s) => *s,
1243                None => continue,
1244            }
1245        };
1246
1247        let span_start = span.byte_range.start;
1248        let span_end = span.byte_range.end;
1249
1250        let start_row = row_starts
1251            .partition_point(|&rs| rs <= span_start)
1252            .saturating_sub(1);
1253
1254        let mut row = start_row.max(seg_start);
1255        while row < seg_end {
1256            let row_byte_start = row_starts[row];
1257            let row_byte_end = row_starts
1258                .get(row + 1)
1259                .map_or(source_len, |&s| s.saturating_sub(1));
1260
1261            if row_byte_start >= span_end {
1262                break;
1263            }
1264
1265            let local_start = span_start.saturating_sub(row_byte_start);
1266            let local_end = row_local_end(span_start, span_end, row_byte_start, row_byte_end);
1267
1268            if local_end > local_start {
1269                by_row[row - seg_start].push((local_start, local_end, style));
1270            }
1271
1272            row += 1;
1273        }
1274    }
1275
1276    by_row
1277}
1278
1279/// Row-local end offset for a span clipped to one row.
1280///
1281/// A MULTI-ROW span that covers this row's end — markdown's fenced code
1282/// block, a multi-line string — records one byte PAST the row's content (the
1283/// newline slot) instead of stopping at the last character. That is how the
1284/// renderer tells a block from a span that merely happens to reach
1285/// end-of-line, and it paints the block's bg across the whole row. An empty
1286/// row inside such a span gets `0..1`; without it the row produces no span
1287/// at all and shows as an untinted gap mid-block.
1288///
1289/// A span confined to ONE row keeps its exact end even when it reaches
1290/// end-of-line, so a hex-colour swatch or TODO marker never bleeds its bg
1291/// across the row.
1292fn row_local_end(
1293    span_start: usize,
1294    span_end: usize,
1295    row_byte_start: usize,
1296    row_byte_end: usize,
1297) -> usize {
1298    let multi_row = span_start < row_byte_start || span_end > row_byte_end;
1299    if multi_row && span_end >= row_byte_end {
1300        row_byte_end - row_byte_start + 1
1301    } else {
1302        span_end.min(row_byte_end) - row_byte_start
1303    }
1304}
1305
1306// ---------------------------------------------------------------------------
1307// Helper: build per-row span table (renderer-agnostic StyleSpec output)
1308// ---------------------------------------------------------------------------
1309
1310/// Resolve flat highlight spans into a per-row span table sized to `row_count`.
1311pub fn build_by_row(
1312    flat_spans: &[hjkl_bonsai::HighlightSpan],
1313    bytes: &[u8],
1314    row_starts: &[usize],
1315    row_count: usize,
1316    theme: &dyn Theme,
1317) -> Vec<Vec<(usize, usize, StyleSpec)>> {
1318    let mut by_row: Vec<Vec<(usize, usize, StyleSpec)>> = vec![Vec::new(); row_count];
1319
1320    for span in flat_spans {
1321        let hex_style: Option<StyleSpec> = if span.capture() == HEX_COLOR_CAPTURE {
1322            let bg = match span.metadata().and_then(|m| m.get(HEX_BG_KEY)) {
1323                Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1324                _ => None,
1325            };
1326            let fg = match span.metadata().and_then(|m| m.get(HEX_FG_KEY)) {
1327                Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1328                _ => None,
1329            };
1330            bg.map(|bg| StyleSpec {
1331                fg,
1332                bg: Some(bg),
1333                modifiers: hjkl_theme::Modifiers::default(),
1334            })
1335        } else if span.capture() == RAINBOW_BRACKET_CAPTURE {
1336            let depth = match span.metadata().and_then(|m| m.get(RAINBOW_DEPTH_KEY)) {
1337                Some(MetaValue::Int(d)) => *d as usize,
1338                _ => 0,
1339            };
1340            let fg = RAINBOW_PALETTE[depth % RAINBOW_PALETTE.len()];
1341            Some(StyleSpec {
1342                fg: Some(fg),
1343                bg: None,
1344                modifiers: hjkl_theme::Modifiers::default(),
1345            })
1346        } else {
1347            None
1348        };
1349
1350        let style: StyleSpec = if let Some(s) = hex_style {
1351            s
1352        } else {
1353            match theme.style(span.capture()) {
1354                Some(s) => *s,
1355                None => continue,
1356            }
1357        };
1358        let style = &style;
1359
1360        let span_start = span.byte_range.start;
1361        let span_end = span.byte_range.end;
1362
1363        let start_row = row_starts
1364            .partition_point(|&rs| rs <= span_start)
1365            .saturating_sub(1);
1366
1367        let mut row = start_row;
1368        while row < row_count {
1369            // `row_count` is caller-supplied and may exceed `row_starts.len()`;
1370            // stop rather than index out of bounds.
1371            let Some(&row_byte_start) = row_starts.get(row) else {
1372                break;
1373            };
1374            let row_byte_end = row_starts
1375                .get(row + 1)
1376                .map_or(bytes.len(), |&s| s.saturating_sub(1));
1377
1378            if row_byte_start >= span_end {
1379                break;
1380            }
1381
1382            let local_start = span_start.saturating_sub(row_byte_start);
1383            let local_end = row_local_end(span_start, span_end, row_byte_start, row_byte_end);
1384
1385            if local_end > local_start {
1386                by_row[row].push((local_start, local_end, *style));
1387            }
1388
1389            row += 1;
1390        }
1391    }
1392
1393    by_row
1394}
1395
1396// ---------------------------------------------------------------------------
1397// Helper: collect diagnostic signs
1398// ---------------------------------------------------------------------------
1399
1400fn collect_diag_signs_range(
1401    h: &mut Highlighter,
1402    rope: &ropey::Rope,
1403    row_starts: &[usize],
1404    vp_top: usize,
1405    vp_end: usize,
1406) -> Vec<DiagSign> {
1407    let rope_len = rope.len_bytes();
1408    let byte_start = row_starts.get(vp_top).copied().unwrap_or(rope_len);
1409    let byte_end = row_starts.get(vp_end).copied().unwrap_or(rope_len);
1410    // The retained tree stores document-absolute byte offsets, and
1411    // `parse_errors_range` both filters nodes against `byte_range` and
1412    // harvests snippets by indexing `source` with those absolute offsets.
1413    // Materialise only the viewport window (typically ≪ 100 KB), but place
1414    // it at its absolute position in a zero-filled buffer so offsets line
1415    // up. `vec![0u8; n]` uses `alloc_zeroed`, so the prefix costs no
1416    // explicit writes. Passing a window-relative range here previously
1417    // reported errors from the wrong document region once scrolled.
1418    let source: Vec<u8> = if byte_start < byte_end && byte_end <= rope_len {
1419        let mut buf = vec![0u8; byte_end];
1420        let mut pos = byte_start;
1421        for chunk in rope.byte_slice(byte_start..byte_end).chunks() {
1422            buf[pos..pos + chunk.len()].copy_from_slice(chunk.as_bytes());
1423            pos += chunk.len();
1424        }
1425        buf
1426    } else {
1427        Vec::new()
1428    };
1429    let errors = h.parse_errors_range(&source, byte_start..byte_end);
1430    let mut signs: Vec<DiagSign> = Vec::new();
1431    let mut last_row: Option<usize> = None;
1432    for err in &errors {
1433        // Error byte ranges are already document-absolute.
1434        let abs_start = err.byte_range.start;
1435        let r = row_starts
1436            .partition_point(|&rs| rs <= abs_start)
1437            .saturating_sub(1);
1438        if last_row == Some(r) {
1439            continue;
1440        }
1441        last_row = Some(r);
1442        signs.push(DiagSign::new(r, 'E', 100));
1443    }
1444    signs
1445}
1446
1447// ---------------------------------------------------------------------------
1448// Factory helpers
1449// ---------------------------------------------------------------------------
1450
1451/// Build a `SyntaxLayer` using the given theme + language directory.
1452pub fn layer_with_theme(
1453    theme: Arc<DotFallbackTheme>,
1454    directory: Arc<LanguageDirectory>,
1455) -> SyntaxLayer {
1456    SyntaxLayer::new(theme, directory)
1457}
1458
1459/// Build a `SyntaxLayer` with hjkl-bonsai's bundled dark theme.
1460#[cfg(test)]
1461pub fn default_layer() -> SyntaxLayer {
1462    let directory = Arc::new(LanguageDirectory::new().expect("language directory"));
1463    SyntaxLayer::new(Arc::new(DotFallbackTheme::dark()), directory)
1464}
1465
1466// ---------------------------------------------------------------------------
1467// Tests
1468// ---------------------------------------------------------------------------
1469
1470#[cfg(test)]
1471mod tests {
1472    use super::*;
1473    use hjkl_buffer::View;
1474    use std::fmt::Write as _;
1475    use std::path::Path;
1476
1477    const TID: BufferId = 0;
1478
1479    // --- DiagSign ---
1480
1481    #[test]
1482    fn diag_sign_new_roundtrip() {
1483        let s = DiagSign::new(7, 'W', 50);
1484        assert_eq!(s.row, 7);
1485        assert_eq!(s.ch, 'W');
1486        assert_eq!(s.priority, 50);
1487    }
1488
1489    #[test]
1490    fn diag_sign_default_is_sensible() {
1491        let s = DiagSign::default();
1492        assert_eq!(s.row, 0);
1493        assert_eq!(s.ch, 'E');
1494        assert_eq!(s.priority, 0);
1495    }
1496
1497    // --- PerfBreakdown ---
1498
1499    #[test]
1500    fn perf_breakdown_default_zeros() {
1501        let p = PerfBreakdown::new();
1502        assert_eq!(p.source_build_us, 0);
1503        assert_eq!(p.parse_us, 0);
1504        assert_eq!(p.highlight_us, 0);
1505        assert_eq!(p.by_row_us, 0);
1506        assert_eq!(p.diag_us, 0);
1507    }
1508
1509    // --- row_starts ---
1510
1511    #[test]
1512    fn row_starts_match_the_ropes_own_line_starts() {
1513        for src in [
1514            "",
1515            "a",
1516            "a\nb\n",
1517            "a\r\nb\r\n",
1518            // A CRLF file as loaded: the final `\n` stripped, its `\r` kept.
1519            "a\r\nb\r",
1520            "\r",
1521            "a\rb\u{b}c\u{c}d\u{85}e\u{2028}f\u{2029}g\r\nh\n",
1522            "only\u{2028}",
1523        ] {
1524            let rope = ropey::Rope::from_str(src);
1525            let rows = rope.len_lines();
1526            let want: Vec<usize> = (0..rows).map(|r| rope.line_to_byte(r)).collect();
1527            assert_eq!(row_starts(&rope, rows), want, "{src:?}");
1528        }
1529    }
1530
1531    // --- SetLanguageOutcome ---
1532
1533    #[test]
1534    fn set_language_outcome_is_known() {
1535        assert!(SetLanguageOutcome::Ready.is_known());
1536        assert!(SetLanguageOutcome::Loading.is_known());
1537        assert!(!SetLanguageOutcome::Unknown.is_known());
1538    }
1539
1540    // --- RenderOutput ---
1541
1542    #[test]
1543    fn render_output_new_roundtrip() {
1544        let out = RenderOutput::new(
1545            99,
1546            vec![vec![]],
1547            vec![DiagSign::new(0, 'E', 100)],
1548            (7, 0, 30),
1549            PerfBreakdown::new(),
1550        );
1551        assert_eq!(out.buffer_id, 99);
1552        assert_eq!(out.key, (7, 0, 30));
1553        assert_eq!(out.signs.len(), 1);
1554    }
1555
1556    #[test]
1557    fn render_output_partial_eq_same() {
1558        let a = RenderOutput::new(
1559            0,
1560            vec![vec![(0, 5, StyleSpec::default())]],
1561            vec![],
1562            (1, 0, 10),
1563            PerfBreakdown::default(),
1564        );
1565        let b = a.clone();
1566        assert_eq!(a, b);
1567    }
1568
1569    // --- build_by_row ---
1570
1571    #[test]
1572    fn build_by_row_empty_spans_gives_empty_rows() {
1573        let by_row = build_by_row(
1574            &[],
1575            b"hello\nworld\n",
1576            &[0, 6, 12],
1577            2,
1578            &DotFallbackTheme::dark(),
1579        );
1580        assert_eq!(by_row.len(), 2);
1581        assert!(by_row[0].is_empty());
1582        assert!(by_row[1].is_empty());
1583    }
1584
1585    #[test]
1586    fn build_by_row_marks_every_full_row_of_a_multi_row_span() {
1587        // A multi-row span (markdown's fenced code block, a multi-line
1588        // string) records one byte PAST the content of every row it covers
1589        // to the end — including the row it ENDS on, so a code block renders
1590        // as a rectangle rather than a ragged shape with a short last line.
1591        //
1592        //   row 0 "aaa"  bytes 0..3   covered to eol      → 0..4
1593        //   row 1 ""     byte  4      blank, covered      → 0..1
1594        //   row 2 "bb"   bytes 5..7   ends AT this eol    → 0..3
1595        //   row 3 "cc"   bytes 8..10  past the span       → none
1596        let bytes = b"aaa\n\nbb\ncc\n";
1597        let span = hjkl_bonsai::HighlightSpan {
1598            byte_range: 0..7,
1599            capture: Arc::from("string"),
1600            metadata: None,
1601        };
1602        let by_row = build_by_row(
1603            &[span],
1604            bytes,
1605            &[0, 4, 5, 8, 11],
1606            4,
1607            &DotFallbackTheme::dark(),
1608        );
1609        assert_eq!(by_row[0].len(), 1);
1610        assert_eq!((by_row[0][0].0, by_row[0][0].1), (0, 4));
1611        assert_eq!(
1612            by_row[1].len(),
1613            1,
1614            "a blank row inside the span must still get one, or it renders as an untinted gap"
1615        );
1616        assert_eq!((by_row[1][0].0, by_row[1][0].1), (0, 1));
1617        assert_eq!(by_row[2].len(), 1);
1618        assert_eq!(
1619            (by_row[2][0].0, by_row[2][0].1),
1620            (0, 3),
1621            "the last row of the span reaches its eol, so it is marked too"
1622        );
1623        assert!(by_row[3].is_empty());
1624    }
1625
1626    #[test]
1627    fn build_by_row_multi_row_span_ending_mid_row_stops_there() {
1628        // A multi-row span that ends BEFORE the last row's eol marks only the
1629        // rows it covers to the end — the final partial row keeps its exact
1630        // offset, so no bg spills past the text it actually covers.
1631        //
1632        //   row 0 "aaa" bytes 0..3  covered to eol → 0..4
1633        //   row 1 "bbb" bytes 4..7  ends at byte 6 → 0..2
1634        let bytes = b"aaa\nbbb\n";
1635        let span = hjkl_bonsai::HighlightSpan {
1636            byte_range: 0..6,
1637            capture: Arc::from("string"),
1638            metadata: None,
1639        };
1640        let by_row = build_by_row(&[span], bytes, &[0, 4, 8], 2, &DotFallbackTheme::dark());
1641        assert_eq!((by_row[0][0].0, by_row[0][0].1), (0, 4));
1642        assert_eq!((by_row[1][0].0, by_row[1][0].1), (0, 2));
1643    }
1644
1645    #[test]
1646    fn build_by_row_single_row_span_ends_at_its_content() {
1647        // The counterpart: a span confined to one row is never marked, even
1648        // when it reaches end-of-line — a hex-colour swatch or TODO marker
1649        // must not bleed its bg across the rest of the row.
1650        let bytes = b"aaa\nbbb\n";
1651        let span = hjkl_bonsai::HighlightSpan {
1652            byte_range: 0..3,
1653            capture: Arc::from("string"),
1654            metadata: None,
1655        };
1656        let by_row = build_by_row(&[span], bytes, &[0, 4, 8], 2, &DotFallbackTheme::dark());
1657        assert_eq!(by_row[0].len(), 1);
1658        assert_eq!((by_row[0][0].0, by_row[0][0].1), (0, 3));
1659        assert!(by_row[1].is_empty());
1660    }
1661
1662    #[test]
1663    fn build_by_row_hex_color_uses_metadata_colors() {
1664        let bytes = b"--accent: #bb9af7;";
1665        let mut metadata = std::collections::HashMap::new();
1666        metadata.insert(
1667            HEX_BG_KEY.to_string(),
1668            MetaValue::Str("#bb9af7".to_string()),
1669        );
1670        metadata.insert(
1671            HEX_FG_KEY.to_string(),
1672            MetaValue::Str("#ffffff".to_string()),
1673        );
1674        let span = hjkl_bonsai::HighlightSpan {
1675            byte_range: 10..17,
1676            capture: Arc::from(HEX_COLOR_CAPTURE),
1677            metadata: Some(Box::new(metadata)),
1678        };
1679        let by_row = build_by_row(&[span], bytes, &[0], 1, &DotFallbackTheme::dark());
1680        assert_eq!(by_row.len(), 1);
1681        assert_eq!(by_row[0].len(), 1);
1682        let (_, _, style) = by_row[0][0];
1683        let bg = style.bg.expect("hex color must set background");
1684        assert_eq!((bg.r, bg.g, bg.b), (0xbb, 0x9a, 0xf7));
1685        let fg = style.fg.expect("hex color must set foreground");
1686        assert_eq!((fg.r, fg.g, fg.b), (0xff, 0xff, 0xff));
1687    }
1688
1689    #[test]
1690    fn build_by_row_row_count_beyond_row_starts_no_panic() {
1691        // `row_count` is caller-supplied; when it exceeds `row_starts.len()`
1692        // the walk must stop instead of indexing out of bounds.
1693        let bytes = b"foo";
1694        let mut metadata = std::collections::HashMap::new();
1695        metadata.insert(
1696            HEX_BG_KEY.to_string(),
1697            MetaValue::Str("#112233".to_string()),
1698        );
1699        let span = hjkl_bonsai::HighlightSpan {
1700            byte_range: 0..3,
1701            capture: Arc::from(HEX_COLOR_CAPTURE),
1702            metadata: Some(Box::new(metadata)),
1703        };
1704        let by_row = build_by_row(&[span], bytes, &[0], 3, &DotFallbackTheme::dark());
1705        assert_eq!(by_row.len(), 3);
1706        assert_eq!(by_row[0].len(), 1);
1707        assert!(by_row[1].is_empty());
1708        assert!(by_row[2].is_empty());
1709    }
1710
1711    #[test]
1712    fn build_by_row_hex_color_without_metadata_skips() {
1713        let span = hjkl_bonsai::HighlightSpan {
1714            byte_range: 0..3,
1715            capture: Arc::from(HEX_COLOR_CAPTURE),
1716            metadata: None,
1717        };
1718        let by_row = build_by_row(&[span], b"foo", &[0], 1, &DotFallbackTheme::dark());
1719        assert_eq!(by_row.len(), 1);
1720        assert!(by_row[0].is_empty());
1721    }
1722
1723    // --- SyntaxLayer basics (no network required) ---
1724
1725    #[test]
1726    fn render_viewport_with_no_language_returns_none() {
1727        let buf = View::from_str("hello world");
1728        let mut layer = default_layer();
1729        assert!(
1730            !layer
1731                .set_language_for_path(TID, Path::new("a.unknownext"))
1732                .is_known()
1733        );
1734        assert!(layer.render_viewport(TID, &buf, 0, 10).is_none());
1735    }
1736
1737    #[test]
1738    fn set_language_by_name_unknown_name_returns_unknown() {
1739        // Same contract as the path form: an unrecognised name must resolve
1740        // to Unknown (never Loading, never a panic) and leave the buffer
1741        // plain text.
1742        let buf = View::from_str("hello world");
1743        let mut layer = default_layer();
1744        assert!(
1745            !layer
1746                .set_language_by_name(TID, "definitely_not_a_real_language")
1747                .is_known()
1748        );
1749        assert!(layer.render_viewport(TID, &buf, 0, 10).is_none());
1750    }
1751
1752    #[test]
1753    fn apply_edits_with_no_language_is_noop() {
1754        let mut layer = default_layer();
1755        let edits = vec![hjkl_engine::ContentEdit {
1756            start_byte: 0,
1757            old_end_byte: 0,
1758            new_end_byte: 1,
1759            start_position: (0, 0),
1760            old_end_position: (0, 0),
1761            new_end_position: (0, 1),
1762        }];
1763        layer.apply_edits(TID, &edits);
1764        // No grammar attached → call must be a no-op (no panic).
1765    }
1766
1767    #[test]
1768    fn set_language_for_path_returns_unknown_for_unrecognized_extension() {
1769        let mut layer = default_layer();
1770        let outcome = layer.set_language_for_path(TID, Path::new("a.zzznope_not_real"));
1771        assert!(!outcome.is_known());
1772        assert!(matches!(outcome, SetLanguageOutcome::Unknown));
1773    }
1774
1775    #[test]
1776    fn poll_pending_loads_drains_ready_handles() {
1777        let mut layer = default_layer();
1778        let events = layer.poll_pending_loads();
1779        assert!(
1780            events.is_empty(),
1781            "expected no events with no pending loads"
1782        );
1783    }
1784
1785    #[test]
1786    fn forget_removes_client_state() {
1787        let mut layer = default_layer();
1788        layer.set_language_for_path(TID, Path::new("a.zzz_unknown"));
1789        layer.forget(TID);
1790        assert!(!layer.clients.contains_key(&TID));
1791    }
1792
1793    // --- Regression: fold extraction must return None when grammar not ready ---
1794    //
1795    // Before the fix (commit edbe2e99), `extract_fold_ranges` returned
1796    // `Vec::new()` for BOTH "grammar not ready" and "grammar ready but no
1797    // folds". The caller in `syntax_glue.rs::recompute_and_install` could not
1798    // distinguish the two cases, so it set `last_fold_dirty_gen = Some(dg)`
1799    // even when the grammar was still loading. When the grammar finished
1800    // loading, `dirty_gen` was unchanged → the fold-extraction condition
1801    // (`last_fold_dg != Some(dg)`) was false → folds were NEVER extracted.
1802    //
1803    // The fix changes `extract_fold_ranges` to return `Option<Vec<...>>`:
1804    // - `None`  = grammar not ready yet — caller must NOT update dirty_gen,
1805    //             so fold extraction retries on the next recompute.
1806    // - `Some`  = grammar was ready and ran (ranges may be empty if no
1807    //             folds.scm or no multi-line nodes).
1808    //
1809    // These tests exercise both branches WITHOUT requiring a downloaded grammar.
1810
1811    #[test]
1812    fn extract_fold_ranges_returns_none_when_no_language_attached() {
1813        // Simulates the "grammar still loading" or "unknown extension" state.
1814        // `extract_fold_ranges` must return `None` so the caller knows NOT to
1815        // mark the dirty_gen as processed. Before the fix this returned
1816        // `Vec::new()`, which the caller misinterpreted as "ran successfully,
1817        // no folds" → dirty_gen stamped → folds never re-tried after load.
1818        let buf =
1819            View::from_str("fn hello() {\n    let x = 1;\n    x\n}\n\nfn world() {\n    2\n}\n");
1820        let mut layer = default_layer();
1821        // Deliberately use an unknown extension so no grammar is attached.
1822        layer.set_language_for_path(TID, Path::new("a.zzz_no_grammar_here"));
1823        let result = layer.extract_fold_ranges(TID, &buf);
1824        assert!(
1825            result.is_none(),
1826            "extract_fold_ranges must return None when no grammar is attached \
1827             (grammar still loading or unknown extension); got {result:?}"
1828        );
1829    }
1830
1831    #[test]
1832    fn extract_fold_ranges_returns_none_when_no_client_registered() {
1833        // View ID with no prior `set_language_for_path` call — no client at all.
1834        let buf = View::from_str("fn foo() {}\n");
1835        let mut layer = default_layer();
1836        // Never called set_language_for_path for TID.
1837        let result = layer.extract_fold_ranges(TID, &buf);
1838        assert!(
1839            result.is_none(),
1840            "extract_fold_ranges must return None when buffer has no syntax client; \
1841             got {result:?}"
1842        );
1843    }
1844
1845    // --- Network-dependent tests (grammar needed) ---
1846
1847    /// Attach the grammar for `path` to `id` and return once it is usable.
1848    ///
1849    /// `set_language_for_path` may only START a load: with a warm
1850    /// `~/.cache/bonsai` the grammar is attached before the call returns, but
1851    /// on a cold machine — CI, which fetches and compiles it — the buffer has
1852    /// no grammar until a later `poll_pending_loads` completes the load, and
1853    /// until then `render_viewport` answers `None`. Rendering straight after
1854    /// `set_language_for_path` therefore passes locally and fails in the
1855    /// `grammar tests` lane, which is how these tests shipped red.
1856    ///
1857    /// A load that FAILS removes itself from the pending list and emits
1858    /// `LoadEvent::Failed`, after which nothing is in flight and no later poll
1859    /// will ever report readiness — so fail here, with the cause, instead of
1860    /// spinning to the deadline and reporting an ambiguous timeout.
1861    fn attach_grammar_when_ready(layer: &mut SyntaxLayer, id: BufferId, path: &str) {
1862        let outcome = layer.set_language_for_path(id, Path::new(path));
1863        assert!(
1864            outcome.is_known(),
1865            "no grammar is registered for {path} — the test can never succeed"
1866        );
1867        if matches!(outcome, SetLanguageOutcome::Ready) {
1868            return;
1869        }
1870        let deadline = std::time::Duration::from_secs(300);
1871        let start = std::time::Instant::now();
1872        loop {
1873            for event in layer.poll_pending_loads() {
1874                match event {
1875                    LoadEvent::Failed { name, error, .. } => {
1876                        panic!("grammar load for `{name}` ({path}) failed: {error}")
1877                    }
1878                    LoadEvent::Ready { id: ready, .. } if ready == id => return,
1879                    LoadEvent::Ready { .. } => {}
1880                }
1881            }
1882            assert!(
1883                start.elapsed() < deadline,
1884                "grammar for {path} never became ready within {deadline:?}, \
1885                 and no load reported a failure — it is still building, or \
1886                 nothing was ever queued"
1887            );
1888            std::thread::sleep(std::time::Duration::from_millis(100));
1889        }
1890    }
1891
1892    #[test]
1893    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1894    fn parse_and_render_small_rust_buffer() {
1895        let buf = View::from_str("fn main() { let x = 1; }\n");
1896        let mut layer = default_layer();
1897        attach_grammar_when_ready(&mut layer, TID, "a.rs");
1898        let out = layer
1899            .render_viewport(TID, &buf, 0, 10)
1900            .expect("render output");
1901        assert!(
1902            out.spans.iter().any(|r| !r.is_empty()),
1903            "expected at least one styled span"
1904        );
1905    }
1906
1907    /// Every line separator ropey splits on — lone `\r`, VT, FF, NEL, LS, PS —
1908    /// is a row of the buffer, so the render cache must hold one span row for
1909    /// each. A `\n`-only row table came up short and the viewport slice
1910    /// panicked out of range.
1911    #[test]
1912    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1913    fn render_gives_one_row_per_rope_line_for_every_separator() {
1914        let src = "fn a() {}\rfn b() {}\u{b}fn c() {}\u{c}fn d() {}\u{85}\
1915                   fn e() {}\u{2028}fn f() {}\u{2029}fn g() {}\r\nfn h() {}\n";
1916        let buf = View::from_str(src);
1917        let rows = buf.rope().len_lines();
1918        let mut layer = default_layer();
1919        attach_grammar_when_ready(&mut layer, TID, "a.rs");
1920        let out = layer
1921            .render_viewport(TID, &buf, 0, rows)
1922            .expect("render output");
1923        assert_eq!(out.spans.len(), rows);
1924        // Each `fn` keyword starts its own row.
1925        for (row, spans) in out.spans.iter().enumerate().take(rows - 1) {
1926            assert!(
1927                spans.iter().any(|&(start, _, _)| start == 0),
1928                "row {row} has no span at column 0: {spans:?}"
1929            );
1930        }
1931    }
1932
1933    /// A CRLF file is loaded with only its final `\n` stripped, so its last
1934    /// line ending stays in the buffer as a lone `\r` — the everyday shape of
1935    /// the case above.
1936    #[test]
1937    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1938    fn render_covers_the_last_row_of_a_crlf_file_as_loaded() {
1939        let buf = View::from_str("fn a() {}\r\nfn b() {}\r");
1940        let rows = buf.rope().len_lines();
1941        let mut layer = default_layer();
1942        attach_grammar_when_ready(&mut layer, TID, "a.rs");
1943        let out = layer
1944            .render_viewport(TID, &buf, 0, rows)
1945            .expect("render output");
1946        assert_eq!(out.spans.len(), rows);
1947    }
1948
1949    #[test]
1950    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1951    fn diagnostics_emit_sign_for_syntax_error() {
1952        let buf = View::from_str("fn main() {\nlet x = ;\n}\n");
1953        let mut layer = default_layer();
1954        attach_grammar_when_ready(&mut layer, TID, "a.rs");
1955        let out = layer.render_viewport(TID, &buf, 0, 10).unwrap();
1956        assert!(
1957            !out.signs.is_empty(),
1958            "expected at least one diagnostic sign for `let x = ;`"
1959        );
1960        assert!(
1961            out.signs.iter().any(|s| s.row == 1 && s.ch == 'E'),
1962            "expected an 'E' sign on row 1; got {:?}",
1963            out.signs
1964        );
1965    }
1966
1967    #[test]
1968    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1969    fn diagnostics_signs_correct_when_scrolled() {
1970        // Regression: `collect_diag_signs_range` used to pass a
1971        // window-relative byte range + window-only source to
1972        // `parse_errors_range`, which filters tree nodes by absolute
1973        // offsets — so once scrolled it reported errors from the top of
1974        // the document and shifted the resulting rows by the window
1975        // offset. The error below sits on rows 50–52; the viewport starts
1976        // at row 45.
1977        let mut src = String::new();
1978        for i in 0..50 {
1979            let _ = writeln!(src, "fn f{i}() {{}}");
1980        }
1981        src.push_str("fn broken() {\nlet x = ;\n}\n");
1982        let buf = View::from_str(&src);
1983        let mut layer = default_layer();
1984        attach_grammar_when_ready(&mut layer, TID, "a.rs");
1985        let out = layer.render_viewport(TID, &buf, 45, 20).unwrap();
1986        assert!(
1987            out.signs
1988                .iter()
1989                .any(|s| (50..=52).contains(&s.row) && s.ch == 'E'),
1990            "expected an 'E' sign on rows 50..=52; got {:?}",
1991            out.signs
1992        );
1993    }
1994
1995    /// Regression: `apply_edits` used to clear the parse, row-start and sign
1996    /// caches but leave `cache_spans`, relying on the buffer's `dirty_gen`
1997    /// having moved by the time `render_viewport` next ran. Both buffers here
1998    /// are freshly constructed and so share a `dirty_gen`, which is what makes
1999    /// the omission visible: the incremental render returned the PRE-edit span
2000    /// table, one byte short across the board and without the `@type` capture
2001    /// that `Ymain`'s new capital earns.
2002    #[test]
2003    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
2004    fn incremental_path_matches_cold_for_small_edit() {
2005        let pre = View::from_str("fn main() { let x = 1; }");
2006        let mut layer = default_layer();
2007        attach_grammar_when_ready(&mut layer, TID, "a.rs");
2008        let _ = layer.render_viewport(TID, &pre, 0, 10).unwrap();
2009        layer.apply_edits(
2010            TID,
2011            &[hjkl_engine::ContentEdit {
2012                start_byte: 3,
2013                old_end_byte: 3,
2014                new_end_byte: 4,
2015                start_position: (0, 3),
2016                old_end_position: (0, 3),
2017                new_end_position: (0, 4),
2018            }],
2019        );
2020        let post = View::from_str("fn Ymain() { let x = 1; }");
2021        let inc = layer.render_viewport(TID, &post, 0, 10).unwrap();
2022        let mut cold_layer = default_layer();
2023        attach_grammar_when_ready(&mut cold_layer, TID, "a.rs");
2024        let cold = cold_layer.render_viewport(TID, &post, 0, 10).unwrap();
2025        assert_eq!(inc.spans, cold.spans);
2026    }
2027
2028    /// Fold ranges for `buf`, waiting for the grammar to become available.
2029    ///
2030    /// `set_language_for_path` may only START a grammar load: on a machine with
2031    /// a warm `~/.cache/bonsai` the grammar is ready by the first
2032    /// `render_viewport`, but on a cold one — CI, which fetches and compiles it
2033    /// — `extract_fold_ranges` answers `None` for as long as the load is in
2034    /// flight. A single call plus `.expect("grammar ready")` therefore passes
2035    /// locally and panics in the `grammar tests` lane, which is exactly how
2036    /// these tests shipped red.
2037    ///
2038    /// Polls `poll_pending_loads` until extraction answers, then returns the
2039    /// ranges.
2040    ///
2041    /// The events `poll_pending_loads` returns are the whole point of reading
2042    /// them rather than discarding them: a load that *fails* removes itself
2043    /// from the pending list and emits `LoadEvent::Failed`, after which
2044    /// nothing is in flight and `extract_fold_ranges` answers `None`
2045    /// forever. Dropping the events made a hard failure look exactly like
2046    /// slow progress — the first version of this helper spun to a 300s
2047    /// deadline and reported "the load either failed or is not being polled",
2048    /// which is a confession that it could not tell, and it threw away the
2049    /// error text that says which. Fail on `Failed`, with the cause.
2050    fn fold_ranges_when_ready(
2051        layer: &mut SyntaxLayer,
2052        buf: &View,
2053        path: &str,
2054        rows: usize,
2055    ) -> Vec<(usize, usize)> {
2056        fold_ranges_when_ready_for(layer, TID, buf, path, rows)
2057    }
2058
2059    /// [`fold_ranges_when_ready`] against an explicit buffer id, for a test
2060    /// that needs the grammar warmed WITHOUT touching the client state of the
2061    /// id it measures — the injected-fold memo is per buffer client, so
2062    /// warming on the same id would leave nothing for a cold pass to parse.
2063    fn fold_ranges_when_ready_for(
2064        layer: &mut SyntaxLayer,
2065        id: BufferId,
2066        buf: &View,
2067        path: &str,
2068        rows: usize,
2069    ) -> Vec<(usize, usize)> {
2070        attach_grammar_when_ready(layer, id, path);
2071        // The host grammar is attached; injected grammars (a fenced ```rust
2072        // block inside markdown) load on their own and surface only as
2073        // extraction answering `None`, so keep polling and re-rendering until
2074        // it answers.
2075        let deadline = std::time::Duration::from_secs(300);
2076        let start = std::time::Instant::now();
2077        loop {
2078            for event in layer.poll_pending_loads() {
2079                if let LoadEvent::Failed { name, error, .. } = event {
2080                    panic!("grammar load for `{name}` ({path}) failed: {error}");
2081                }
2082            }
2083            let _ = layer.render_viewport(id, buf, 0, rows);
2084            if let Some(ranges) = layer.extract_fold_ranges(id, buf) {
2085                return ranges;
2086            }
2087            assert!(
2088                start.elapsed() < deadline,
2089                "grammar for {path} never became ready within {deadline:?}, \
2090                 and no load reported a failure — it is still building, or \
2091                 nothing was ever queued"
2092            );
2093            std::thread::sleep(std::time::Duration::from_millis(100));
2094        }
2095    }
2096
2097    /// Markdown fold ranges, pinned against neovim's treesitter folds for the
2098    /// same document (`vim.treesitter.foldexpr`, folds enumerated with
2099    /// `foldclosed`/`foldclosedend`). Every range below was produced by nvim
2100    /// on this exact text.
2101    ///
2102    /// Before the fix hjkl returned `4..14`, `14..23`, `16..19`, `19..23`,
2103    /// `23..26` and `8..11` — each one row or more too long, so a closed
2104    /// section hid the NEXT section's heading and the last fold ran past the
2105    /// end of the buffer.
2106    #[test]
2107    #[ignore = "network + compiler: fetches the markdown grammar"]
2108    fn markdown_fold_ranges_match_neovim() {
2109        let src = concat!(
2110            "# Title\n\nIntro paragraph.\n\n",
2111            "## Section A\n\nText in A.\n\n```bash\nls -la\n```\n\nMore A text.\n\n",
2112            "## Section B\n\n- item one\n- item two\n\n",
2113            "### Nested B1\n\nDeep text.\n\n",
2114            "## Section C\n\nLast.\n",
2115        );
2116        let buf = View::from_str(src);
2117        let mut layer = default_layer();
2118        let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.md", 40);
2119        assert_eq!(
2120            ranges,
2121            vec![
2122                (0, 25),  // # Title — to the last line, not one past it
2123                (4, 12),  // ## Section A — ends on "More A text.", not on "## Section B"
2124                (8, 10),  // the fenced block — ends on its closing fence
2125                (14, 21), // ## Section B
2126                (16, 17), // the list
2127                (19, 21), // ### Nested B1
2128                (23, 25), // ## Section C
2129            ]
2130        );
2131    }
2132
2133    // ── Folds inside injected languages ─────────────────────────────────
2134    //
2135    // The fixtures below are the ones the 2026-08-03 injection work was
2136    // measured on. neovim's side was enumerated the same way as every other
2137    // fold test here (`vim.treesitter.foldexpr`, `foldclosed`/`foldclosedend`
2138    // per `foldlevel`), with one addition that is easy to get wrong: a
2139    // headless nvim parses injections LAZILY, so the run must force
2140    // `vim.treesitter.get_parser(0):parse(true)` before enumerating. Without
2141    // it nvim reports only the host language's folds (152 instead of 173 on
2142    // `.github/workflows/ci.yml`) and reads as agreement.
2143
2144    /// A markdown document with ` ```rust ` and ` ```bash ` blocks.
2145    ///
2146    /// nvim on this fixture: `0,20`  `4,10`  `5,9`  `6,8`  `12,20`  `14,18`
2147    /// `15,17` — identical to the assertion below.
2148    ///
2149    /// Three of those seven are inside the fenced blocks and come from the
2150    /// INJECTED grammars: `(5, 9)` is `fn main()`, `(6, 8)` its `if`, and
2151    /// `(15, 17)` the bash `for … done`. Before injected folds hjkl emitted
2152    /// only the other four (`(0, 20)`, `(4, 10)`, `(12, 20)`, `(14, 18)`).
2153    ///
2154    /// The rows are also the proof that the region → host row offset is
2155    /// applied: the rust block's own tree puts those folds at rows 0..4 and
2156    /// 1..3, and the bash block's at 0..2. Drop the offset and the expected
2157    /// vector below cannot be produced.
2158    #[test]
2159    #[ignore = "network + compiler: fetches the markdown, rust and bash grammars"]
2160    fn markdown_injected_fold_ranges_match_neovim() {
2161        let src = concat!(
2162            "# Title\n\nIntro text.\n\n",
2163            "```rust\nfn main() {\n    if true {\n        println!(\"hi\");\n    }\n}\n```\n\n",
2164            "## Shell\n\n",
2165            "```bash\nfor f in a b; do\n  echo \"$f\"\ndone\n```\n\n",
2166            "Trailing text.\n",
2167        );
2168        let buf = View::from_str(src);
2169        let mut layer = default_layer();
2170        let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.md", 40);
2171        assert_eq!(
2172            ranges,
2173            vec![
2174                (0, 20),  // # Title
2175                (4, 10),  // the ```rust fence
2176                (5, 9),   // injected: fn main()
2177                (6, 8),   // injected: the if
2178                (12, 20), // ## Shell
2179                (14, 18), // the ```bash fence
2180                (15, 17), // injected: for … done
2181            ]
2182        );
2183    }
2184
2185    /// Injected folds must follow their region when rows move above it.
2186    ///
2187    /// The memo behind injected folds (`InjectedFoldCache`) is keyed by the
2188    /// region's CONTENT, and stores region-relative rows. Inserting a line at
2189    /// the top of the document leaves every region's bytes identical — so the
2190    /// memo hits — while every host row shifts by one. If the memo stored host
2191    /// rows, or the offset were applied before storing instead of after
2192    /// reading, the second extraction below would return the FIRST document's
2193    /// rows for the injected folds and only the host's would move.
2194    #[test]
2195    #[ignore = "network + compiler: fetches the markdown, rust and bash grammars"]
2196    fn injected_folds_shift_with_the_region_on_a_memo_hit() {
2197        let body = concat!(
2198            "# Title\n\nIntro text.\n\n",
2199            "```rust\nfn main() {\n    if true {\n        println!(\"hi\");\n    }\n}\n```\n\n",
2200            "## Shell\n\n",
2201            "```bash\nfor f in a b; do\n  echo \"$f\"\ndone\n```\n\n",
2202            "Trailing text.\n",
2203        );
2204        let mut layer = default_layer();
2205        let before = View::from_str(body);
2206        // Warm the markdown/rust/bash grammars on a DIFFERENT buffer id: on a
2207        // cold cache (CI) the first extraction answers None until the loads
2208        // finish, but warming on TID would also fill TID's injected-fold memo
2209        // and leave the "cold" count below at zero.
2210        const WARM: BufferId = TID + 1;
2211        let _ = fold_ranges_when_ready_for(&mut layer, WARM, &before, "a.md", 40);
2212        layer.forget(WARM);
2213
2214        layer.set_language_for_path(TID, Path::new("a.md"));
2215        let _ = layer.render_viewport(TID, &before, 0, 40);
2216        hjkl_bonsai::injected_parse_counter::reset();
2217        let first = layer
2218            .extract_fold_ranges(TID, &before)
2219            .expect("grammar ready after the wait above");
2220        assert_eq!(
2221            hjkl_bonsai::injected_parse_counter::get(),
2222            2,
2223            "cold extraction must parse both injected regions (rust + bash)"
2224        );
2225
2226        // Same document with one extra line on top. Same buffer id, same
2227        // layer — the injected-region memo is live and every region's content
2228        // is byte-identical, so this must be the memo-hit path.
2229        let shifted = View::from_str(&format!("Added line.\n{body}"));
2230        layer.reset(TID);
2231        let _ = layer.render_viewport(TID, &shifted, 0, 40);
2232        hjkl_bonsai::injected_parse_counter::reset();
2233        let second = layer
2234            .extract_fold_ranges(TID, &shifted)
2235            .expect("grammar ready after the wait above");
2236        assert_eq!(
2237            hjkl_bonsai::injected_parse_counter::get(),
2238            0,
2239            "moved-but-unchanged regions must come from the memo, not a reparse"
2240        );
2241
2242        let expected: Vec<(usize, usize)> = first.iter().map(|&(s, e)| (s + 1, e + 1)).collect();
2243        assert_eq!(
2244            second, expected,
2245            "every fold, injected ones included, must move down exactly one row"
2246        );
2247    }
2248
2249    /// An HTML page with a `<style>` and a `<script>` block.
2250    ///
2251    /// nvim on this fixture: `1,23`  `2,9`  `3,8`  `4,7`  `10,22`  `11,13`
2252    /// `14,21`  `15,20`  `16,18` — identical to the assertion below.
2253    ///
2254    /// `(4, 7)` is the CSS rule inside `<style>`; `(15, 20)` and `(16, 18)`
2255    /// are the JS function and its `if` inside `<script>`. hjkl emitted none
2256    /// of the three before injected folds.
2257    #[test]
2258    #[ignore = "network + compiler: fetches the html, css and javascript grammars"]
2259    fn html_injected_fold_ranges_match_neovim() {
2260        let src = concat!(
2261            "<!DOCTYPE html>\n<html>\n  <head>\n",
2262            "    <style>\n      body {\n        color: red;\n        margin: 0;\n      }\n",
2263            "    </style>\n  </head>\n  <body>\n",
2264            "    <div>\n      <p>hi</p>\n    </div>\n",
2265            "    <script>\n      function go(x) {\n        if (x) {\n",
2266            "          return 1;\n        }\n        return 0;\n      }\n",
2267            "    </script>\n  </body>\n</html>\n",
2268        );
2269        let buf = View::from_str(src);
2270        let mut layer = default_layer();
2271        let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.html", 40);
2272        assert_eq!(
2273            ranges,
2274            vec![
2275                (1, 23),  // <html>
2276                (2, 9),   // <head>
2277                (3, 8),   // <style>
2278                (4, 7),   // injected css: the body rule
2279                (10, 22), // <body>
2280                (11, 13), // <div>
2281                (14, 21), // <script>
2282                (15, 20), // injected js: function go()
2283                (16, 18), // injected js: the if
2284            ]
2285        );
2286    }
2287
2288    /// YAML folds are anchored on the pair / sequence item, matching neovim.
2289    /// Capturing `(block_mapping)` instead put the fold on the container,
2290    /// which starts at its FIRST CHILD: `top:` never got a fold of its own,
2291    /// and the document-level mapping produced one fold starting at the first
2292    /// key that swallowed every sibling below it (`0..7` here).
2293    #[test]
2294    #[ignore = "network + compiler: fetches the yaml grammar"]
2295    fn yaml_fold_ranges_match_neovim() {
2296        let src = "top:\n  a: 1\n  b:\n    - one\n    - two\n\nother:\n  c: 3\n";
2297        let buf = View::from_str(src);
2298        let mut layer = default_layer();
2299        let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.yaml", 40);
2300        assert_eq!(ranges, vec![(0, 4), (2, 4), (6, 7)]);
2301    }
2302
2303    // ── Fold ranges pinned against neovim, per language ──────────────────
2304    //
2305    // Every expectation below was measured on neovim 0.12.4 with
2306    // `vim.treesitter.foldexpr()` over these exact fixtures, enumerating the
2307    // real folds with `foldclosed`/`foldclosedend` at each `foldlevel` (a
2308    // `foldlevel()` array merges adjacent siblings into one run and reads as
2309    // a false difference). Rows are 0-based inclusive, like hjkl's.
2310    //
2311    // Where hjkl emits ranges neovim does not, it is because hjkl's bundled
2312    // query captures a different SET of node types on purpose — the comment
2313    // on each test says which. The ranges themselves agree everywhere.
2314
2315    /// Extract hjkl's fold ranges for `src`, using `name` only to pick the
2316    /// grammar by extension.
2317    fn folds_for(name: &str, src: &str) -> Vec<(usize, usize)> {
2318        let buf = View::from_str(src);
2319        let mut layer = default_layer();
2320        fold_ranges_when_ready(&mut layer, &buf, name, 80)
2321    }
2322
2323    /// The lua fixture the `foldlevelstart` level rule is pinned on, run end to
2324    /// end: ranges from a REAL tree, then through the wiring that turns them
2325    /// into open/closed folds.
2326    ///
2327    /// `hjkl_buffer::folds`'s
2328    /// `set_auto_folds_closes_folds_deeper_than_foldlevelstart` asserts the same
2329    /// closed sets, but from a `NVIM_LUA_RANGES` constant typed into the test —
2330    /// so the LEVEL rule was covered while nothing checked that a live grammar
2331    /// and `folds/lua.scm` actually produce those ranges. The other end-to-end
2332    /// test, `auto_fold_pass_applies_foldlevelstart_as_a_level` in `syntax_glue`,
2333    /// uses `foldmethod=marker` so it can run without grammars. This is the only
2334    /// pass that runs the real query at a non-zero `foldlevelstart`.
2335    ///
2336    /// Measured on neovim 0.12.5 over this exact fixture with
2337    /// `nvim --headless -u NONE --cmd 'filetype on' --cmd 'set foldmethod=expr
2338    /// foldexpr=v:lua.vim.treesitter.foldexpr() foldenable foldlevelstart=N'`,
2339    /// enumerating closed folds with `foldclosed`/`foldclosedend` — rescanning
2340    /// after `zo` on each hit, because `foldclosed` reports only the OUTERMOST
2341    /// closed fold covering a line and the nested ones are invisible to a single
2342    /// sweep:
2343    ///
2344    /// ```text
2345    /// fls=0   closed=[(2, 16), (3, 14), (4, 7), (10, 13), (18, 23), (19, 21)]
2346    /// fls=1   closed=[(3, 14), (4, 7), (10, 13), (19, 21)]
2347    /// fls=2   closed=[(4, 7), (10, 13)]
2348    /// fls=3   closed=[]
2349    /// fls=99  closed=[]
2350    /// ```
2351    ///
2352    /// `-u NONE` keeps the measurement off the local plugin set; nvim's bundled
2353    /// `queries/lua/folds.scm` is byte-identical to nvim-treesitter's, which the
2354    /// other fixtures here were measured against.
2355    ///
2356    /// `local M = {}` on row 0 is a single-row `(table_constructor)`: neovim
2357    /// folds it no more than `set_auto_folds` does.
2358    #[test]
2359    #[ignore = "network + compiler: fetches the lua grammar"]
2360    fn lua_fold_ranges_and_foldlevelstart_match_neovim() {
2361        let src = concat!(
2362            "local M = {}\n\n",
2363            "function M.outer(a, b)\n",
2364            "  if a > b then\n",
2365            "    local t = {\n      x = 1,\n      y = 2,\n    }\n",
2366            "    print(t.x)\n",
2367            "    -- count down\n",
2368            "    for i = 1, 10 do\n      print(i)\n      print(i * 2)\n    end\n",
2369            "  end\n",
2370            "  return a\n",
2371            "end\n\n",
2372            "function M.second(c)\n",
2373            "  while c > 0 do\n    c = c - 1\n  end\n",
2374            "  return c\n",
2375            "end\n\n",
2376            "return M\n",
2377        );
2378        let ranges = folds_for("a.lua", src);
2379        assert_eq!(
2380            ranges,
2381            vec![(2, 16), (3, 14), (4, 7), (10, 13), (18, 23), (19, 21)],
2382            "the live lua tree must produce the ranges the level rule is pinned on"
2383        );
2384
2385        // Closed auto folds after one `set_auto_folds` pass at `fls` — the same
2386        // call `View`'s auto-fold pass makes with the ranges above.
2387        let closed_at = |fls: u32| -> Vec<(usize, usize)> {
2388            let mut buf = View::from_str(src);
2389            buf.set_auto_folds(&ranges, fls);
2390            buf.folds()
2391                .iter()
2392                .filter(|f| f.closed)
2393                .map(|f| (f.start_row, f.end_row))
2394                .collect()
2395        };
2396
2397        assert_eq!(
2398            closed_at(0),
2399            vec![(2, 16), (3, 14), (4, 7), (10, 13), (18, 23), (19, 21)],
2400            "foldlevelstart=0 must close every fold the grammar found"
2401        );
2402        assert_eq!(
2403            closed_at(1),
2404            vec![(3, 14), (4, 7), (10, 13), (19, 21)],
2405            "foldlevelstart=1 must leave the two functions open and close what is inside"
2406        );
2407        assert_eq!(
2408            closed_at(2),
2409            vec![(4, 7), (10, 13)],
2410            "foldlevelstart=2 must close only the level-3 table and for-loop"
2411        );
2412        assert!(
2413            closed_at(3).is_empty(),
2414            "foldlevelstart=3 must leave this 3-level nesting fully open"
2415        );
2416        assert!(
2417            closed_at(99).is_empty(),
2418            "foldlevelstart=99 (hjkl's default) must open everything"
2419        );
2420    }
2421
2422    /// nvim on this fixture: `(2, 4)`, `(6, 10)`, `(7, 9)` — identical.
2423    #[test]
2424    #[ignore = "network + compiler: fetches the go grammar"]
2425    fn go_fold_ranges_match_neovim() {
2426        let src = concat!(
2427            "package main\n\n",
2428            "import (\n  \"fmt\"\n)\n\n",
2429            "func main() {\n  if true {\n    fmt.Println(\"x\")\n  }\n}\n",
2430        );
2431        assert_eq!(folds_for("a.go", src), vec![(2, 4), (6, 10), (7, 9)]);
2432    }
2433
2434    /// nvim on this fixture: `(0, 2)`, `(4, 9)`, `(5, 7)` — identical.
2435    #[test]
2436    #[ignore = "network + compiler: fetches the c grammar"]
2437    fn c_fold_ranges_match_neovim() {
2438        let src = concat!(
2439            "struct P {\n  int x;\n};\n\n",
2440            "int main(void) {\n  for (int i = 0; i < 2; i++) {\n    i++;\n  }\n  return 0;\n}\n",
2441        );
2442        assert_eq!(folds_for("a.c", src), vec![(0, 2), (4, 9), (5, 7)]);
2443    }
2444
2445    /// nvim on this fixture: `(0, 9)`, `(2, 7)`, `(4, 6)` — identical.
2446    #[test]
2447    #[ignore = "network + compiler: fetches the cpp grammar"]
2448    fn cpp_fold_ranges_match_neovim() {
2449        let src = concat!(
2450            "namespace n {\n\n",
2451            "class B {\npublic:\n  int get() {\n    return 1;\n  }\n};\n\n",
2452            "}\n",
2453        );
2454        assert_eq!(folds_for("a.cpp", src), vec![(0, 9), (2, 7), (4, 6)]);
2455    }
2456
2457    /// nvim on this fixture: `(0, 7)`, `(1, 5)`, `(3, 5)` — identical.
2458    ///
2459    /// Regression: `folds/cpp.scm` used to capture neither `(try_statement)`
2460    /// nor `(catch_clause)`, so the only fold anchored on `try {` was the try
2461    /// body's `(compound_statement)` — `(1, 3)` here, ending at the `}` that
2462    /// opens the catch instead of at the end of the whole statement. The
2463    /// catch clause got no fold of its own at all.
2464    #[test]
2465    #[ignore = "network + compiler: fetches the cpp grammar"]
2466    fn cpp_try_catch_fold_ranges_match_neovim() {
2467        let src = concat!(
2468            "int main() {\n",
2469            "  try {\n    f();\n",
2470            "  } catch (int e) {\n    g();\n  }\n",
2471            "  return 0;\n}\n",
2472        );
2473        assert_eq!(folds_for("a.cpp", src), vec![(0, 7), (1, 5), (3, 5)]);
2474    }
2475
2476    /// nvim on this fixture: `(0, 7)`, `(1, 6)`, `(2, 4)` — identical.
2477    ///
2478    /// The anchors agree only because the braces are K&R. neovim folds Java's
2479    /// `(class_body)` / `(block)`, which start ON the `{`; hjkl folds
2480    /// `(class_declaration)` / `(method_declaration)`, which start on the
2481    /// signature — and, when annotations precede it, on the FIRST annotation.
2482    /// See `docs/backlog.md` §1.4b.
2483    #[test]
2484    #[ignore = "network + compiler: fetches the java grammar"]
2485    fn java_fold_ranges_match_neovim() {
2486        let src = concat!(
2487            "public class A {\n",
2488            "  public int run(int x) {\n",
2489            "    if (x > 0) {\n      return x;\n    }\n",
2490            "    return 0;\n  }\n}\n",
2491        );
2492        assert_eq!(folds_for("A.java", src), vec![(0, 7), (1, 6), (2, 4)]);
2493    }
2494
2495    /// nvim on this fixture: `(2, 8)`, `(4, 7)`. hjkl adds `(5, 7)` — the
2496    /// Allman-braced `(compound_statement)` body, which neovim's PHP query
2497    /// does not capture at all. The two shared ranges are identical.
2498    #[test]
2499    #[ignore = "network + compiler: fetches the php grammar"]
2500    fn php_fold_ranges_match_neovim() {
2501        let src = concat!(
2502            "<?php\n\n",
2503            "class R\n{\n",
2504            "  public function area(): float\n  {\n    return 1.0;\n  }\n}\n",
2505        );
2506        assert_eq!(folds_for("a.php", src), vec![(2, 8), (4, 7), (5, 7)]);
2507    }
2508
2509    /// nvim on this fixture: `(0, 6)`, `(1, 5)`, `(2, 4)` — identical.
2510    #[test]
2511    #[ignore = "network + compiler: fetches the ruby grammar"]
2512    fn ruby_fold_ranges_match_neovim() {
2513        let src = "module M\n  class R\n    def area\n      1\n    end\n  end\nend\n";
2514        assert_eq!(folds_for("a.rb", src), vec![(0, 6), (1, 5), (2, 4)]);
2515    }
2516
2517    /// Regression: C# used to fold NOTHING.
2518    ///
2519    /// `bonsai.toml` has both `[language.c-sharp]` and `[language.c_sharp]`
2520    /// for the same grammar, and `GrammarRegistry` resolves an extension to
2521    /// the alphabetically first entry — so a `.cs` buffer loads under the name
2522    /// `c-sharp`, while `builtin_folds` was keyed only on `c_sharp` and
2523    /// returned `None`. This assertion was `[]` before the fix, against
2524    /// neovim's four folds below.
2525    ///
2526    /// nvim on this fixture: `(1, 13)`, `(3, 12)`, `(5, 11)`, `(7, 9)` — it
2527    /// anchors on the Allman `{` because its C# query captures
2528    /// `body: (declaration_list)` and `(block)`. hjkl also captures the
2529    /// declaration nodes, so it anchors one row earlier on the `namespace` /
2530    /// `class` / method signature and keeps neovim's brace-anchored ranges
2531    /// too. Every neovim range appears here.
2532    #[test]
2533    #[ignore = "network + compiler: fetches the c-sharp grammar"]
2534    fn c_sharp_fold_ranges_match_neovim() {
2535        let src = concat!(
2536            "namespace Demo\n{\n",
2537            "  public class R\n  {\n",
2538            "    public int Area()\n    {\n",
2539            "      if (true)\n      {\n        return 1;\n      }\n",
2540            "      return 0;\n    }\n  }\n}\n",
2541        );
2542        assert_eq!(
2543            folds_for("P.cs", src),
2544            vec![(0, 13), (2, 12), (4, 11), (5, 11), (6, 9), (7, 9)]
2545        );
2546    }
2547
2548    /// nvim on this fixture: `(0, 4)`, `(1, 3)`, `(6, 11)`, `(7, 9)` —
2549    /// identical.
2550    #[test]
2551    #[ignore = "network + compiler: fetches the javascript grammar"]
2552    fn javascript_fold_ranges_match_neovim() {
2553        let src = concat!(
2554            "class R {\n  area() {\n    return 1;\n  }\n}\n\n",
2555            "function run(xs) {\n  const t = {\n    a: 1,\n  };\n  return t;\n}\n",
2556        );
2557        assert_eq!(
2558            folds_for("a.js", src),
2559            vec![(0, 4), (1, 3), (6, 11), (7, 9)]
2560        );
2561    }
2562
2563    /// nvim on this fixture: `(0, 2)`, `(4, 6)`, `(8, 10)`, `(12, 14)` —
2564    /// identical. Covers the TypeScript-only nodes: `(interface_declaration)`,
2565    /// `(type_alias_declaration)` with an `(object_type)` body, and
2566    /// `(enum_declaration)`.
2567    #[test]
2568    #[ignore = "network + compiler: fetches the typescript grammar"]
2569    fn typescript_fold_ranges_match_neovim() {
2570        let src = concat!(
2571            "interface S {\n  area(): number;\n}\n\n",
2572            "type H = {\n  name: string;\n};\n\n",
2573            "enum C {\n  Red,\n}\n\n",
2574            "function run(): number {\n  return 1;\n}\n",
2575        );
2576        assert_eq!(
2577            folds_for("a.ts", src),
2578            vec![(0, 2), (4, 6), (8, 10), (12, 14)]
2579        );
2580    }
2581
2582    #[test]
2583    #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
2584    fn forget_drops_buffer_state() {
2585        let buf = View::from_str("fn main() {}");
2586        let mut layer = default_layer();
2587        attach_grammar_when_ready(&mut layer, TID, "a.rs");
2588        let _ = layer.render_viewport(TID, &buf, 0, 10).unwrap();
2589        assert!(layer.clients.contains_key(&TID));
2590        layer.forget(TID);
2591        assert!(!layer.clients.contains_key(&TID));
2592    }
2593}