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("rust".to_string()).is_known());
276/// assert!(!SetLanguageOutcome::Unknown.is_known());
277/// ```
278#[non_exhaustive]
279pub enum SetLanguageOutcome {
280 /// Grammar was already cached — installed immediately.
281 Ready,
282 /// Grammar is being fetched/compiled on the background pool.
283 Loading(#[allow(dead_code)] String),
284 /// Extension unrecognized. No grammar — plain text only.
285 Unknown,
286}
287
288impl SetLanguageOutcome {
289 /// `true` when a grammar was found (either already cached or now in flight).
290 pub fn is_known(&self) -> bool {
291 matches!(self, Self::Ready | Self::Loading(_))
292 }
293}
294
295/// Event emitted by [`SyntaxLayer::poll_pending_loads`].
296///
297/// # Examples
298///
299/// ```
300/// use hjkl_syntax::LoadEvent;
301/// let e = LoadEvent::Ready { id: 0, name: "rust".into() };
302/// match e {
303/// LoadEvent::Ready { id, name } => assert_eq!(name, "rust"),
304/// LoadEvent::Failed { .. } => panic!("unexpected"),
305/// _ => {}
306/// }
307/// ```
308#[non_exhaustive]
309pub enum LoadEvent {
310 /// Grammar installed; trigger a redraw + re-render for `id`.
311 Ready { id: BufferId, name: String },
312 /// Load failed; buffer stays plain text.
313 Failed {
314 id: BufferId,
315 name: String,
316 error: String,
317 },
318}
319
320/// Exhaustive view of a [`LoadEvent`] for dispatch callbacks.
321#[derive(Debug)]
322pub enum LoadEventKind<'a> {
323 /// Grammar installed successfully.
324 Ready { id: BufferId, name: &'a str },
325 /// Grammar load failed.
326 Failed {
327 id: BufferId,
328 name: &'a str,
329 error: &'a str,
330 },
331}
332
333// ---------------------------------------------------------------------------
334// In-flight grammar load tracking
335// ---------------------------------------------------------------------------
336
337struct PendingLoad {
338 id: BufferId,
339 name: String,
340 handle: LoadHandle,
341}
342
343// ---------------------------------------------------------------------------
344// Per-buffer client state (main thread)
345// ---------------------------------------------------------------------------
346
347/// Per-buffer state owned by the main-thread [`SyntaxLayer`].
348struct BufferClient {
349 has_language: bool,
350 current_lang: Option<Arc<Grammar>>,
351 /// Owns Parser + Tree for this buffer.
352 highlighter: Option<Highlighter>,
353 /// dirty_gen the cache was built at (None = cache absent).
354 cache_dirty_gen: Option<u64>,
355 /// Contiguous row range covered by `cache_spans`.
356 cache_rows: Range<usize>,
357 /// Per-row span table for `cache_rows`.
358 cache_spans: Vec<Vec<(usize, usize, StyleSpec)>>,
359 /// `(dirty_gen, row_starts)` — rebuilt only when dirty_gen changes.
360 cache_row_starts: Option<(u64, Arc<Vec<usize>>)>,
361 /// dirty_gen of the most recent successful parse. Gate reparsing.
362 parsed_dirty_gen: Option<u64>,
363 /// Cached diag signs keyed by `(dirty_gen, vp_top, vp_end)`.
364 cache_signs: Option<(u64, usize, usize, Vec<DiagSign>)>,
365 /// 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(name)
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 to a buffer, creating/replacing the Highlighter.
567 fn attach_grammar(&mut self, id: BufferId, grammar: Arc<Grammar>) {
568 let c = self.clients.entry(id).or_default();
569 c.invalidate_cache();
570 match Highlighter::new(grammar) {
571 Ok(h) => {
572 c.highlighter = Some(h);
573 }
574 Err(e) => {
575 tracing::error!(buffer_id = id, error = %e, "failed to attach highlighter");
576 c.highlighter = None;
577 }
578 }
579 }
580
581 /// Poll all in-flight grammar loads. Call once per tick.
582 ///
583 /// Returns one `LoadEvent` per handle that resolved during this tick.
584 pub fn poll_pending_loads(&mut self) -> Vec<LoadEvent> {
585 let mut events = Vec::new();
586 let mut i = 0;
587 while i < self.pending_loads.len() {
588 match self.pending_loads[i].handle.try_recv() {
589 None => {
590 i += 1;
591 }
592 Some(Ok(lib_path)) => {
593 let name = self.pending_loads[i].name.clone();
594 let bid = self.pending_loads[i].id;
595 self.pending_loads.swap_remove(i);
596 match self.directory.complete_load(&name, &lib_path) {
597 Ok(grammar) => {
598 self.attach_grammar(bid, grammar.clone());
599 let c = self.client_mut(bid);
600 c.current_lang = Some(grammar);
601 c.has_language = true;
602 events.push(LoadEvent::Ready { id: bid, name });
603 }
604 Err(e) => {
605 events.push(LoadEvent::Failed {
606 id: bid,
607 name,
608 error: format!("{e:#}"),
609 });
610 }
611 }
612 }
613 Some(Err(err)) => {
614 let name = self.pending_loads[i].name.clone();
615 let bid = self.pending_loads[i].id;
616 self.pending_loads.swap_remove(i);
617 events.push(LoadEvent::Failed {
618 id: bid,
619 name,
620 error: err.to_string(),
621 });
622 }
623 }
624 }
625 events
626 }
627
628 /// Drop all state for a buffer. Call on close.
629 pub fn forget(&mut self, id: BufferId) {
630 self.clients.remove(&id);
631 }
632
633 /// Swap the active theme. Next `render_viewport` call uses the new theme.
634 pub fn set_theme(&mut self, theme: Arc<dyn Theme + Send + Sync>) {
635 self.theme = theme;
636 // Invalidate all per-buffer caches so they repaint with the new theme.
637 for c in self.clients.values_mut() {
638 c.invalidate_cache();
639 }
640 }
641
642 /// Apply a batch of engine `ContentEdit`s to the buffer's retained tree
643 /// synchronously. The cache will be invalidated on the next `render_viewport`
644 /// call via dirty_gen mismatch.
645 ///
646 /// No-op when no grammar is attached.
647 pub fn apply_edits(&mut self, id: BufferId, edits: &[hjkl_engine::ContentEdit]) {
648 let c = match self.clients.get_mut(&id) {
649 Some(c) if c.has_language => c,
650 _ => return,
651 };
652 let Some(h) = c.highlighter.as_mut() else {
653 return;
654 };
655 for e in edits {
656 h.edit(&InputEdit {
657 start_byte: e.start_byte,
658 old_end_byte: e.old_end_byte,
659 new_end_byte: e.new_end_byte,
660 start_position: Point {
661 row: e.start_position.0 as usize,
662 column: e.start_position.1 as usize,
663 },
664 old_end_position: Point {
665 row: e.old_end_position.0 as usize,
666 column: e.old_end_position.1 as usize,
667 },
668 new_end_position: Point {
669 row: e.new_end_position.0 as usize,
670 column: e.new_end_position.1 as usize,
671 },
672 });
673 }
674 // Drop every cache, the span table included. This used to clear only
675 // the parse, row-start and sign caches and leave `cache_spans` to the
676 // `dirty_gen` mismatch in `render_viewport` — which does happen for a
677 // real buffer edit, but makes correctness here depend on a counter
678 // this function neither reads nor controls. A caller that edits
679 // through `apply_edits` without the buffer's `dirty_gen` moving got
680 // the PRE-edit spans back, reparse and all.
681 c.invalidate_cache();
682 }
683
684 /// Drop the buffer's retained tree. Next `render_viewport` reparses from scratch.
685 ///
686 /// Call on `:e!` / content reset.
687 pub fn reset(&mut self, id: BufferId) {
688 if let Some(c) = self.clients.get_mut(&id) {
689 if let Some(h) = c.highlighter.as_mut() {
690 h.reset();
691 }
692 c.invalidate_cache();
693 }
694 }
695
696 /// Extract fold ranges from the buffer's retained tree using the bundled
697 /// `folds.scm` for this grammar, plus one for each injected region using
698 /// ITS language's query.
699 ///
700 /// Returns `Some(ranges)` when the grammar is attached and the tree has
701 /// been parsed — `ranges` may be empty when the grammar has no bundled
702 /// `folds.scm` or the file contains no foldable nodes.
703 ///
704 /// Injected regions (a ` ```rust ` block in markdown, a `<script>` body in
705 /// HTML) are parsed with their own grammar, folded with that language's
706 /// query, and their rows offset into this document — see
707 /// [`hjkl_bonsai::extract_fold_ranges_rope_with_injections`]. Resolving an
708 /// injected grammar goes through the shared [`LanguageDirectory`], which
709 /// may build one on first use; only languages with a bundled fold query
710 /// can trigger that.
711 ///
712 /// Returns `None` when:
713 /// - No grammar is attached yet (grammar still loading or unknown extension).
714 /// - No highlighter has been created for this buffer.
715 /// - The tree has not been parsed yet (call `render_viewport` first).
716 ///
717 /// **Callers must treat `None` as "not ready — retry later"** and must NOT
718 /// record the dirty_gen as processed when `None` is returned. Returning
719 /// `Some(empty)` is the signal that the grammar ran but produced no folds
720 /// (e.g. no `folds.scm` for this language).
721 ///
722 /// **NOT viewport-bounded** — runs over the full tree (once per reparse).
723 /// Do not call this per-frame; call it only when `dirty_gen` has changed.
724 pub fn extract_fold_ranges(
725 &mut self,
726 id: BufferId,
727 buffer: &impl hjkl_engine::Query,
728 ) -> Option<Vec<(usize, usize)>> {
729 let directory = Arc::clone(&self.directory);
730 let client = match self.clients.get_mut(&id) {
731 Some(c) if c.has_language => c,
732 // Grammar not yet attached (loading or unknown) — signal "not ready".
733 _ => return None,
734 };
735 // Highlighter creation failed — signal "not ready".
736 let highlighter = client.highlighter.as_ref()?;
737 // Tree not yet parsed — signal "not ready".
738 let tree = highlighter.tree()?;
739 let grammar = highlighter.grammar()?;
740 let injections = highlighter.injection_query();
741 let rope = buffer.rope();
742 // Grammar is ready and tree is parsed — return Some even if empty.
743 // Injected regions (a ```rust block in markdown, the `<script>` body of
744 // an HTML page) are folded with their own grammar; a language whose
745 // grammar is not installed simply contributes nothing.
746 Some(extract_fold_ranges_rope_with_injections(
747 tree,
748 grammar,
749 &rope,
750 injections,
751 &mut client.fold_injections,
752 |name| directory.by_name(name),
753 ))
754 }
755
756 /// Render spans for the visible viewport, returning an owned span table.
757 ///
758 /// Thin wrapper over [`Self::render_viewport_ref`] that deep-copies the
759 /// cached rows. Callers that immediately convert the table into their own
760 /// style type (every renderer adapter) should use `render_viewport_ref`
761 /// instead and skip the copy.
762 pub fn render_viewport(
763 &mut self,
764 id: BufferId,
765 buffer: &impl Query,
766 viewport_top: usize,
767 viewport_height: usize,
768 ) -> Option<RenderOutput> {
769 Some(
770 self.render_viewport_ref(id, buffer, viewport_top, viewport_height)?
771 .into_owned(),
772 )
773 }
774
775 /// Render spans for the visible viewport. Fully synchronous.
776 ///
777 /// 1. Returns `None` when no grammar is attached.
778 /// 2. Clears the cache when `buffer.dirty_gen()` has advanced.
779 /// 3. Returns cached rows when the request is fully inside the cached range.
780 /// 4. Walks only rows outside the cache (extend prefix/suffix), splices into
781 /// `cache_spans`, extends `cache_rows`.
782 ///
783 /// The returned [`RenderOutputRef`] borrows the viewport slice of
784 /// `cache_spans` — no per-call copy of the span table.
785 pub fn render_viewport_ref(
786 &mut self,
787 id: BufferId,
788 buffer: &impl Query,
789 viewport_top: usize,
790 viewport_height: usize,
791 ) -> Option<RenderOutputRef<'_>> {
792 let client = self.clients.get_mut(&id)?;
793 if !client.has_language {
794 return None;
795 }
796 let dg = buffer.dirty_gen();
797 let row_count = buffer.line_count() as usize;
798 if row_count == 0 || viewport_height == 0 {
799 return None;
800 }
801
802 let vp_top = viewport_top.min(row_count);
803 let vp_end = (vp_top + viewport_height).min(row_count);
804 if vp_end <= vp_top {
805 return None;
806 }
807
808 // Single dirty_gen invalidation point.
809 if client.cache_dirty_gen != Some(dg) {
810 client.invalidate_cache();
811 }
812
813 // Get a rope snapshot — O(1) Arc-clone from hjkl_buffer::View.
814 // All downstream consumers (parse, highlight, row_starts, diag signs)
815 // now read directly from the rope: no full-document String allocation.
816 let rope = buffer.rope();
817
818 // Get or build row_starts, cached per dirty_gen.
819 // Scan newlines chunk-by-chunk from the rope so we never materialise
820 // the full document as a contiguous byte slice.
821 let row_starts: Arc<Vec<usize>> = if client
822 .cache_row_starts
823 .as_ref()
824 .is_some_and(|(g, _)| *g == dg)
825 {
826 Arc::clone(&client.cache_row_starts.as_ref().unwrap().1)
827 } else {
828 // SIMD-vectorised newline scan via memchr — measurably faster than
829 // a per-byte loop. Pre-sized to row_count + 1 to avoid realloc churn.
830 let mut rs: Vec<usize> = Vec::with_capacity(row_count + 1);
831 rs.push(0);
832 let mut chunk_pos = 0usize;
833 for chunk in rope.chunks() {
834 for nl in memchr::memchr_iter(b'\n', chunk.as_bytes()) {
835 rs.push(chunk_pos + nl + 1);
836 }
837 chunk_pos += chunk.len();
838 }
839 let arc = Arc::new(rs);
840 client.cache_row_starts = Some((dg, Arc::clone(&arc)));
841 arc
842 };
843
844 // Reparse only when needed. Use rope-streaming parse to avoid passing
845 // the full bytes slice into the parser (tree-sitter reads chunk-by-chunk
846 // via the closure; no contiguous copy required for the parse step).
847 let needs_reparse = client.parsed_dirty_gen != Some(dg);
848 {
849 let highlighter = client.highlighter.as_mut()?;
850 if highlighter.tree().is_none() {
851 highlighter.parse_initial_rope(&rope);
852 if highlighter.tree().is_some() {
853 client.parsed_dirty_gen = Some(dg);
854 }
855 } else if needs_reparse {
856 // No-diff incremental: we discard the changed-byte ranges
857 // (cache is keyed by dirty_gen + viewport, not by edit
858 // ranges). Computing `old.changed_ranges(&new)` walks both
859 // trees and was ~54 % of per-keystroke CPU on a 1.86 M-line
860 // file.
861 let ok = highlighter.parse_incremental_rope(&rope);
862 if ok && highlighter.tree().is_some() {
863 client.parsed_dirty_gen = Some(dg);
864 }
865 }
866 }
867
868 // Compute colorizer gate before re-borrowing client mutably.
869 // Effective = global flag AND current language is in the allowlist.
870 let colorizer_enabled = {
871 let c = self.clients.get(&id)?;
872 let lang_name = c.current_lang.as_ref().map_or("", |g| g.name());
873 self.colorizer
874 && (self.colorizer_filetypes.is_empty()
875 || self.colorizer_filetypes.iter().any(|ft| ft == lang_name))
876 };
877 let rainbow_brackets_enabled = self.rainbow_brackets;
878
879 // Re-borrow after parse.
880 let client = self.clients.get_mut(&id)?;
881 let highlighter = client.highlighter.as_mut()?;
882
883 // If still no tree (parse failed), give up.
884 highlighter.tree()?;
885
886 let theme = self.theme.as_ref();
887 let directory = Arc::clone(&self.directory);
888
889 // Extend cache to cover [vp_top, vp_end).
890 if client.cache_rows.is_empty() {
891 // Case A: empty cache — walk full range.
892 client.cache_spans = walk_rows(
893 highlighter,
894 &rope,
895 &row_starts,
896 row_count,
897 vp_top,
898 vp_end,
899 theme,
900 &directory,
901 colorizer_enabled,
902 rainbow_brackets_enabled,
903 );
904 client.cache_rows = vp_top..vp_end;
905 client.cache_dirty_gen = Some(dg);
906 } else {
907 let cache_covers_overlap =
908 vp_top < client.cache_rows.end && vp_end > client.cache_rows.start;
909 if !cache_covers_overlap {
910 // Disjoint — just rebuild the whole viewport.
911 client.cache_spans = walk_rows(
912 highlighter,
913 &rope,
914 &row_starts,
915 row_count,
916 vp_top,
917 vp_end,
918 theme,
919 &directory,
920 colorizer_enabled,
921 rainbow_brackets_enabled,
922 );
923 client.cache_rows = vp_top..vp_end;
924 } else {
925 // Case B: extend prefix if needed.
926 if vp_top < client.cache_rows.start {
927 let new_rows = walk_rows(
928 highlighter,
929 &rope,
930 &row_starts,
931 row_count,
932 vp_top,
933 client.cache_rows.start,
934 theme,
935 &directory,
936 colorizer_enabled,
937 rainbow_brackets_enabled,
938 );
939 let mut combined = new_rows;
940 combined.append(&mut client.cache_spans);
941 client.cache_spans = combined;
942 client.cache_rows.start = vp_top;
943 }
944 // Case C: extend suffix if needed.
945 if vp_end > client.cache_rows.end {
946 let new_rows = walk_rows(
947 highlighter,
948 &rope,
949 &row_starts,
950 row_count,
951 client.cache_rows.end,
952 vp_end,
953 theme,
954 &directory,
955 colorizer_enabled,
956 rainbow_brackets_enabled,
957 );
958 client.cache_spans.extend(new_rows);
959 client.cache_rows.end = vp_end;
960 }
961 }
962 client.cache_dirty_gen = Some(dg);
963 }
964
965 // Bounds of the requested viewport inside the cache.
966 let offset = vp_top - client.cache_rows.start;
967 let len = vp_end - vp_top;
968
969 // Get or build signs, cached per (dirty_gen, vp_top, vp_end).
970 // Done before borrowing `cache_spans` so the mutable client borrow
971 // (needed for `highlighter` and the sign-cache write) has ended.
972 let signs = if client
973 .cache_signs
974 .as_ref()
975 .is_some_and(|(g, t, e, _)| *g == dg && *t == vp_top && *e == vp_end)
976 {
977 client.cache_signs.as_ref().unwrap().3.clone()
978 } else {
979 let s = collect_diag_signs_range(highlighter, &rope, &row_starts, vp_top, vp_end);
980 client.cache_signs = Some((dg, vp_top, vp_end, s.clone()));
981 s
982 };
983
984 // Borrow the viewport slice out of the cache — no copy.
985 let spans = &self.clients.get(&id)?.cache_spans[offset..offset + len];
986
987 Some(RenderOutputRef {
988 buffer_id: id,
989 spans,
990 signs,
991 key: (dg, vp_top, viewport_height),
992 perf: PerfBreakdown::default(),
993 })
994 }
995
996 /// Resolve a path to its language name without loading a grammar.
997 pub fn name_for_path(&self, path: &Path) -> Option<String> {
998 self.directory.name_for_path(path)
999 }
1000
1001 /// Returns `true` if a client is tracked for the given buffer id.
1002 #[doc(hidden)]
1003 pub fn has_client(&self, id: BufferId) -> bool {
1004 self.clients.contains_key(&id)
1005 }
1006
1007 /// Dispatch a [`LoadEvent`] through a caller-supplied handler.
1008 ///
1009 /// # Examples
1010 ///
1011 /// ```rust
1012 /// use hjkl_syntax::{LoadEvent, SyntaxLayer};
1013 ///
1014 /// let event = LoadEvent::Ready { id: 0, name: "rust".into() };
1015 /// let mut got_ready = false;
1016 /// let handled = SyntaxLayer::dispatch_load_event(&event, |ev| {
1017 /// use hjkl_syntax::LoadEventKind;
1018 /// match ev {
1019 /// LoadEventKind::Ready { id, name } => { got_ready = true; }
1020 /// LoadEventKind::Failed { .. } => {}
1021 /// }
1022 /// });
1023 /// assert!(handled);
1024 /// assert!(got_ready);
1025 /// ```
1026 pub fn dispatch_load_event(
1027 event: &LoadEvent,
1028 mut handler: impl FnMut(LoadEventKind<'_>),
1029 ) -> bool {
1030 #[allow(unreachable_patterns)]
1031 match event {
1032 LoadEvent::Ready { id, name } => {
1033 handler(LoadEventKind::Ready { id: *id, name });
1034 true
1035 }
1036 LoadEvent::Failed { id, name, error } => {
1037 handler(LoadEventKind::Failed {
1038 id: *id,
1039 name,
1040 error,
1041 });
1042 true
1043 }
1044 _ => false,
1045 }
1046 }
1047}
1048
1049// ---------------------------------------------------------------------------
1050// Rainbow palette
1051// ---------------------------------------------------------------------------
1052
1053/// 7-colour rainbow palette for bracket depth coloring (dark-bg readable).
1054/// Depth 0 → index 0, depth N → RAINBOW_PALETTE[N % RAINBOW_PALETTE.len()].
1055const RAINBOW_PALETTE: [Color; 7] = [
1056 Color::rgb(255, 100, 100), // red
1057 Color::rgb(255, 175, 80), // orange
1058 Color::rgb(255, 230, 80), // yellow
1059 Color::rgb(100, 220, 100), // green
1060 Color::rgb(80, 210, 220), // cyan
1061 Color::rgb(100, 140, 255), // blue
1062 Color::rgb(190, 120, 255), // violet
1063];
1064
1065// ---------------------------------------------------------------------------
1066// Helper: walk a row range against the retained tree
1067// ---------------------------------------------------------------------------
1068
1069#[allow(clippy::too_many_arguments)]
1070fn walk_rows(
1071 highlighter: &mut Highlighter,
1072 rope: &ropey::Rope,
1073 row_starts: &[usize],
1074 row_count: usize,
1075 seg_start: usize,
1076 seg_end: usize,
1077 theme: &dyn Theme,
1078 directory: &Arc<LanguageDirectory>,
1079 colorizer: bool,
1080 rainbow_brackets: bool,
1081) -> Vec<Vec<(usize, usize, StyleSpec)>> {
1082 let rope_len = rope.len_bytes();
1083 let byte_start = row_starts.get(seg_start).copied().unwrap_or(rope_len);
1084 let byte_end = row_starts
1085 .get(seg_end)
1086 .copied()
1087 .unwrap_or(rope_len)
1088 .min(rope_len)
1089 .max(byte_start);
1090
1091 let mut flat_spans =
1092 highlighter.highlight_range_with_injections_rope(rope, byte_start..byte_end, |name| {
1093 directory.by_name(name)
1094 });
1095
1096 let marker_pass = CommentMarkerPass::new();
1097 marker_pass.apply_rope(&mut flat_spans, rope);
1098 if colorizer {
1099 let hex_color_pass = HexColorPass::new();
1100 hex_color_pass.apply_range_rope(&mut flat_spans, rope, byte_start..byte_end);
1101 }
1102 if rainbow_brackets
1103 && let (Some(tree), Some(grammar)) = (highlighter.tree(), highlighter.grammar())
1104 {
1105 let rb_spans = rainbow_spans_rope(tree, grammar, rope, byte_start..byte_end);
1106 flat_spans.extend(rb_spans);
1107 }
1108
1109 // Bucket spans into ONLY the viewport row range. The prior version
1110 // called `build_by_row(..., row_count, ...)` and sliced the result,
1111 // which allocated `row_count` empty inner Vecs (8.58 M on a huge
1112 // file) just to throw away all but ~50 of them — that single line
1113 // was ~24 % of per-keystroke CPU during a paste burst.
1114 let _ = row_count; // kept in signature for the public build_by_row tests
1115 build_by_row_range(&flat_spans, rope_len, row_starts, seg_start..seg_end, theme)
1116}
1117
1118/// Viewport-bounded variant of [`build_by_row`]. Allocates exactly
1119/// `row_range.len()` inner Vecs instead of one per document row. Spans
1120/// whose byte range falls entirely outside `row_range` are skipped; spans
1121/// that overlap have their per-row slices recorded with positions local
1122/// to the viewport (so row `row_range.start` lands at index 0).
1123fn build_by_row_range(
1124 flat_spans: &[hjkl_bonsai::HighlightSpan],
1125 source_len: usize,
1126 row_starts: &[usize],
1127 row_range: Range<usize>,
1128 theme: &dyn Theme,
1129) -> Vec<Vec<(usize, usize, StyleSpec)>> {
1130 let seg_start = row_range.start;
1131 let seg_end = row_range.end.min(row_starts.len());
1132 if seg_end <= seg_start {
1133 return Vec::new();
1134 }
1135 let mut by_row: Vec<Vec<(usize, usize, StyleSpec)>> = vec![Vec::new(); seg_end - seg_start];
1136
1137 for span in flat_spans {
1138 let hex_style: Option<StyleSpec> = if span.capture() == HEX_COLOR_CAPTURE {
1139 let bg = match span.metadata().and_then(|m| m.get(HEX_BG_KEY)) {
1140 Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1141 _ => None,
1142 };
1143 let fg = match span.metadata().and_then(|m| m.get(HEX_FG_KEY)) {
1144 Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1145 _ => None,
1146 };
1147 bg.map(|bg| StyleSpec {
1148 fg,
1149 bg: Some(bg),
1150 modifiers: hjkl_theme::Modifiers::default(),
1151 })
1152 } else if span.capture() == RAINBOW_BRACKET_CAPTURE {
1153 let depth = match span.metadata().and_then(|m| m.get(RAINBOW_DEPTH_KEY)) {
1154 Some(MetaValue::Int(d)) => *d as usize,
1155 _ => 0,
1156 };
1157 let fg = RAINBOW_PALETTE[depth % RAINBOW_PALETTE.len()];
1158 Some(StyleSpec {
1159 fg: Some(fg),
1160 bg: None,
1161 modifiers: hjkl_theme::Modifiers::default(),
1162 })
1163 } else {
1164 None
1165 };
1166
1167 let style: StyleSpec = if let Some(s) = hex_style {
1168 s
1169 } else {
1170 match theme.style(span.capture()) {
1171 Some(s) => *s,
1172 None => continue,
1173 }
1174 };
1175
1176 let span_start = span.byte_range.start;
1177 let span_end = span.byte_range.end;
1178
1179 let start_row = row_starts
1180 .partition_point(|&rs| rs <= span_start)
1181 .saturating_sub(1);
1182
1183 let mut row = start_row.max(seg_start);
1184 while row < seg_end {
1185 let row_byte_start = row_starts[row];
1186 let row_byte_end = row_starts
1187 .get(row + 1)
1188 .map_or(source_len, |&s| s.saturating_sub(1));
1189
1190 if row_byte_start >= span_end {
1191 break;
1192 }
1193
1194 let local_start = span_start.saturating_sub(row_byte_start);
1195 let local_end = row_local_end(span_start, span_end, row_byte_start, row_byte_end);
1196
1197 if local_end > local_start {
1198 by_row[row - seg_start].push((local_start, local_end, style));
1199 }
1200
1201 row += 1;
1202 }
1203 }
1204
1205 by_row
1206}
1207
1208/// Row-local end offset for a span clipped to one row.
1209///
1210/// A MULTI-ROW span that covers this row's end — markdown's fenced code
1211/// block, a multi-line string — records one byte PAST the row's content (the
1212/// newline slot) instead of stopping at the last character. That is how the
1213/// renderer tells a block from a span that merely happens to reach
1214/// end-of-line, and it paints the block's bg across the whole row. An empty
1215/// row inside such a span gets `0..1`; without it the row produces no span
1216/// at all and shows as an untinted gap mid-block.
1217///
1218/// A span confined to ONE row keeps its exact end even when it reaches
1219/// end-of-line, so a hex-colour swatch or TODO marker never bleeds its bg
1220/// across the row.
1221fn row_local_end(
1222 span_start: usize,
1223 span_end: usize,
1224 row_byte_start: usize,
1225 row_byte_end: usize,
1226) -> usize {
1227 let multi_row = span_start < row_byte_start || span_end > row_byte_end;
1228 if multi_row && span_end >= row_byte_end {
1229 row_byte_end - row_byte_start + 1
1230 } else {
1231 span_end.min(row_byte_end) - row_byte_start
1232 }
1233}
1234
1235// ---------------------------------------------------------------------------
1236// Helper: build per-row span table (renderer-agnostic StyleSpec output)
1237// ---------------------------------------------------------------------------
1238
1239/// Resolve flat highlight spans into a per-row span table sized to `row_count`.
1240pub fn build_by_row(
1241 flat_spans: &[hjkl_bonsai::HighlightSpan],
1242 bytes: &[u8],
1243 row_starts: &[usize],
1244 row_count: usize,
1245 theme: &dyn Theme,
1246) -> Vec<Vec<(usize, usize, StyleSpec)>> {
1247 let mut by_row: Vec<Vec<(usize, usize, StyleSpec)>> = vec![Vec::new(); row_count];
1248
1249 for span in flat_spans {
1250 let hex_style: Option<StyleSpec> = if span.capture() == HEX_COLOR_CAPTURE {
1251 let bg = match span.metadata().and_then(|m| m.get(HEX_BG_KEY)) {
1252 Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1253 _ => None,
1254 };
1255 let fg = match span.metadata().and_then(|m| m.get(HEX_FG_KEY)) {
1256 Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1257 _ => None,
1258 };
1259 bg.map(|bg| StyleSpec {
1260 fg,
1261 bg: Some(bg),
1262 modifiers: hjkl_theme::Modifiers::default(),
1263 })
1264 } else if span.capture() == RAINBOW_BRACKET_CAPTURE {
1265 let depth = match span.metadata().and_then(|m| m.get(RAINBOW_DEPTH_KEY)) {
1266 Some(MetaValue::Int(d)) => *d as usize,
1267 _ => 0,
1268 };
1269 let fg = RAINBOW_PALETTE[depth % RAINBOW_PALETTE.len()];
1270 Some(StyleSpec {
1271 fg: Some(fg),
1272 bg: None,
1273 modifiers: hjkl_theme::Modifiers::default(),
1274 })
1275 } else {
1276 None
1277 };
1278
1279 let style: StyleSpec = if let Some(s) = hex_style {
1280 s
1281 } else {
1282 match theme.style(span.capture()) {
1283 Some(s) => *s,
1284 None => continue,
1285 }
1286 };
1287 let style = &style;
1288
1289 let span_start = span.byte_range.start;
1290 let span_end = span.byte_range.end;
1291
1292 let start_row = row_starts
1293 .partition_point(|&rs| rs <= span_start)
1294 .saturating_sub(1);
1295
1296 let mut row = start_row;
1297 while row < row_count {
1298 // `row_count` is caller-supplied and may exceed `row_starts.len()`;
1299 // stop rather than index out of bounds.
1300 let Some(&row_byte_start) = row_starts.get(row) else {
1301 break;
1302 };
1303 let row_byte_end = row_starts
1304 .get(row + 1)
1305 .map_or(bytes.len(), |&s| s.saturating_sub(1));
1306
1307 if row_byte_start >= span_end {
1308 break;
1309 }
1310
1311 let local_start = span_start.saturating_sub(row_byte_start);
1312 let local_end = row_local_end(span_start, span_end, row_byte_start, row_byte_end);
1313
1314 if local_end > local_start {
1315 by_row[row].push((local_start, local_end, *style));
1316 }
1317
1318 row += 1;
1319 }
1320 }
1321
1322 by_row
1323}
1324
1325// ---------------------------------------------------------------------------
1326// Helper: collect diagnostic signs
1327// ---------------------------------------------------------------------------
1328
1329fn collect_diag_signs_range(
1330 h: &mut Highlighter,
1331 rope: &ropey::Rope,
1332 row_starts: &[usize],
1333 vp_top: usize,
1334 vp_end: usize,
1335) -> Vec<DiagSign> {
1336 let rope_len = rope.len_bytes();
1337 let byte_start = row_starts.get(vp_top).copied().unwrap_or(rope_len);
1338 let byte_end = row_starts.get(vp_end).copied().unwrap_or(rope_len);
1339 // The retained tree stores document-absolute byte offsets, and
1340 // `parse_errors_range` both filters nodes against `byte_range` and
1341 // harvests snippets by indexing `source` with those absolute offsets.
1342 // Materialise only the viewport window (typically ≪ 100 KB), but place
1343 // it at its absolute position in a zero-filled buffer so offsets line
1344 // up. `vec![0u8; n]` uses `alloc_zeroed`, so the prefix costs no
1345 // explicit writes. Passing a window-relative range here previously
1346 // reported errors from the wrong document region once scrolled.
1347 let source: Vec<u8> = if byte_start < byte_end && byte_end <= rope_len {
1348 let mut buf = vec![0u8; byte_end];
1349 let mut pos = byte_start;
1350 for chunk in rope.byte_slice(byte_start..byte_end).chunks() {
1351 buf[pos..pos + chunk.len()].copy_from_slice(chunk.as_bytes());
1352 pos += chunk.len();
1353 }
1354 buf
1355 } else {
1356 Vec::new()
1357 };
1358 let errors = h.parse_errors_range(&source, byte_start..byte_end);
1359 let mut signs: Vec<DiagSign> = Vec::new();
1360 let mut last_row: Option<usize> = None;
1361 for err in &errors {
1362 // Error byte ranges are already document-absolute.
1363 let abs_start = err.byte_range.start;
1364 let r = row_starts
1365 .partition_point(|&rs| rs <= abs_start)
1366 .saturating_sub(1);
1367 if last_row == Some(r) {
1368 continue;
1369 }
1370 last_row = Some(r);
1371 signs.push(DiagSign::new(r, 'E', 100));
1372 }
1373 signs
1374}
1375
1376// ---------------------------------------------------------------------------
1377// Factory helpers
1378// ---------------------------------------------------------------------------
1379
1380/// Build a `SyntaxLayer` using the given theme + language directory.
1381pub fn layer_with_theme(
1382 theme: Arc<DotFallbackTheme>,
1383 directory: Arc<LanguageDirectory>,
1384) -> SyntaxLayer {
1385 SyntaxLayer::new(theme, directory)
1386}
1387
1388/// Build a `SyntaxLayer` with hjkl-bonsai's bundled dark theme.
1389#[cfg(test)]
1390pub fn default_layer() -> SyntaxLayer {
1391 let directory = Arc::new(LanguageDirectory::new().expect("language directory"));
1392 SyntaxLayer::new(Arc::new(DotFallbackTheme::dark()), directory)
1393}
1394
1395// ---------------------------------------------------------------------------
1396// Tests
1397// ---------------------------------------------------------------------------
1398
1399#[cfg(test)]
1400mod tests {
1401 use super::*;
1402 use hjkl_buffer::View;
1403 use std::fmt::Write as _;
1404 use std::path::Path;
1405
1406 const TID: BufferId = 0;
1407
1408 // --- DiagSign ---
1409
1410 #[test]
1411 fn diag_sign_new_roundtrip() {
1412 let s = DiagSign::new(7, 'W', 50);
1413 assert_eq!(s.row, 7);
1414 assert_eq!(s.ch, 'W');
1415 assert_eq!(s.priority, 50);
1416 }
1417
1418 #[test]
1419 fn diag_sign_default_is_sensible() {
1420 let s = DiagSign::default();
1421 assert_eq!(s.row, 0);
1422 assert_eq!(s.ch, 'E');
1423 assert_eq!(s.priority, 0);
1424 }
1425
1426 // --- PerfBreakdown ---
1427
1428 #[test]
1429 fn perf_breakdown_default_zeros() {
1430 let p = PerfBreakdown::new();
1431 assert_eq!(p.source_build_us, 0);
1432 assert_eq!(p.parse_us, 0);
1433 assert_eq!(p.highlight_us, 0);
1434 assert_eq!(p.by_row_us, 0);
1435 assert_eq!(p.diag_us, 0);
1436 }
1437
1438 // --- SetLanguageOutcome ---
1439
1440 #[test]
1441 fn set_language_outcome_is_known() {
1442 assert!(SetLanguageOutcome::Ready.is_known());
1443 assert!(SetLanguageOutcome::Loading("rust".to_string()).is_known());
1444 assert!(!SetLanguageOutcome::Unknown.is_known());
1445 }
1446
1447 // --- RenderOutput ---
1448
1449 #[test]
1450 fn render_output_new_roundtrip() {
1451 let out = RenderOutput::new(
1452 99,
1453 vec![vec![]],
1454 vec![DiagSign::new(0, 'E', 100)],
1455 (7, 0, 30),
1456 PerfBreakdown::new(),
1457 );
1458 assert_eq!(out.buffer_id, 99);
1459 assert_eq!(out.key, (7, 0, 30));
1460 assert_eq!(out.signs.len(), 1);
1461 }
1462
1463 #[test]
1464 fn render_output_partial_eq_same() {
1465 let a = RenderOutput::new(
1466 0,
1467 vec![vec![(0, 5, StyleSpec::default())]],
1468 vec![],
1469 (1, 0, 10),
1470 PerfBreakdown::default(),
1471 );
1472 let b = a.clone();
1473 assert_eq!(a, b);
1474 }
1475
1476 // --- build_by_row ---
1477
1478 #[test]
1479 fn build_by_row_empty_spans_gives_empty_rows() {
1480 let by_row = build_by_row(
1481 &[],
1482 b"hello\nworld\n",
1483 &[0, 6, 12],
1484 2,
1485 &DotFallbackTheme::dark(),
1486 );
1487 assert_eq!(by_row.len(), 2);
1488 assert!(by_row[0].is_empty());
1489 assert!(by_row[1].is_empty());
1490 }
1491
1492 #[test]
1493 fn build_by_row_marks_every_full_row_of_a_multi_row_span() {
1494 // A multi-row span (markdown's fenced code block, a multi-line
1495 // string) records one byte PAST the content of every row it covers
1496 // to the end — including the row it ENDS on, so a code block renders
1497 // as a rectangle rather than a ragged shape with a short last line.
1498 //
1499 // row 0 "aaa" bytes 0..3 covered to eol → 0..4
1500 // row 1 "" byte 4 blank, covered → 0..1
1501 // row 2 "bb" bytes 5..7 ends AT this eol → 0..3
1502 // row 3 "cc" bytes 8..10 past the span → none
1503 let bytes = b"aaa\n\nbb\ncc\n";
1504 let span = hjkl_bonsai::HighlightSpan {
1505 byte_range: 0..7,
1506 capture: Arc::from("string"),
1507 metadata: None,
1508 };
1509 let by_row = build_by_row(
1510 &[span],
1511 bytes,
1512 &[0, 4, 5, 8, 11],
1513 4,
1514 &DotFallbackTheme::dark(),
1515 );
1516 assert_eq!(by_row[0].len(), 1);
1517 assert_eq!((by_row[0][0].0, by_row[0][0].1), (0, 4));
1518 assert_eq!(
1519 by_row[1].len(),
1520 1,
1521 "a blank row inside the span must still get one, or it renders as an untinted gap"
1522 );
1523 assert_eq!((by_row[1][0].0, by_row[1][0].1), (0, 1));
1524 assert_eq!(by_row[2].len(), 1);
1525 assert_eq!(
1526 (by_row[2][0].0, by_row[2][0].1),
1527 (0, 3),
1528 "the last row of the span reaches its eol, so it is marked too"
1529 );
1530 assert!(by_row[3].is_empty());
1531 }
1532
1533 #[test]
1534 fn build_by_row_multi_row_span_ending_mid_row_stops_there() {
1535 // A multi-row span that ends BEFORE the last row's eol marks only the
1536 // rows it covers to the end — the final partial row keeps its exact
1537 // offset, so no bg spills past the text it actually covers.
1538 //
1539 // row 0 "aaa" bytes 0..3 covered to eol → 0..4
1540 // row 1 "bbb" bytes 4..7 ends at byte 6 → 0..2
1541 let bytes = b"aaa\nbbb\n";
1542 let span = hjkl_bonsai::HighlightSpan {
1543 byte_range: 0..6,
1544 capture: Arc::from("string"),
1545 metadata: None,
1546 };
1547 let by_row = build_by_row(&[span], bytes, &[0, 4, 8], 2, &DotFallbackTheme::dark());
1548 assert_eq!((by_row[0][0].0, by_row[0][0].1), (0, 4));
1549 assert_eq!((by_row[1][0].0, by_row[1][0].1), (0, 2));
1550 }
1551
1552 #[test]
1553 fn build_by_row_single_row_span_ends_at_its_content() {
1554 // The counterpart: a span confined to one row is never marked, even
1555 // when it reaches end-of-line — a hex-colour swatch or TODO marker
1556 // must not bleed its bg across the rest of the row.
1557 let bytes = b"aaa\nbbb\n";
1558 let span = hjkl_bonsai::HighlightSpan {
1559 byte_range: 0..3,
1560 capture: Arc::from("string"),
1561 metadata: None,
1562 };
1563 let by_row = build_by_row(&[span], bytes, &[0, 4, 8], 2, &DotFallbackTheme::dark());
1564 assert_eq!(by_row[0].len(), 1);
1565 assert_eq!((by_row[0][0].0, by_row[0][0].1), (0, 3));
1566 assert!(by_row[1].is_empty());
1567 }
1568
1569 #[test]
1570 fn build_by_row_hex_color_uses_metadata_colors() {
1571 let bytes = b"--accent: #bb9af7;";
1572 let mut metadata = std::collections::HashMap::new();
1573 metadata.insert(
1574 HEX_BG_KEY.to_string(),
1575 MetaValue::Str("#bb9af7".to_string()),
1576 );
1577 metadata.insert(
1578 HEX_FG_KEY.to_string(),
1579 MetaValue::Str("#ffffff".to_string()),
1580 );
1581 let span = hjkl_bonsai::HighlightSpan {
1582 byte_range: 10..17,
1583 capture: Arc::from(HEX_COLOR_CAPTURE),
1584 metadata: Some(Box::new(metadata)),
1585 };
1586 let by_row = build_by_row(&[span], bytes, &[0], 1, &DotFallbackTheme::dark());
1587 assert_eq!(by_row.len(), 1);
1588 assert_eq!(by_row[0].len(), 1);
1589 let (_, _, style) = by_row[0][0];
1590 let bg = style.bg.expect("hex color must set background");
1591 assert_eq!((bg.r, bg.g, bg.b), (0xbb, 0x9a, 0xf7));
1592 let fg = style.fg.expect("hex color must set foreground");
1593 assert_eq!((fg.r, fg.g, fg.b), (0xff, 0xff, 0xff));
1594 }
1595
1596 #[test]
1597 fn build_by_row_row_count_beyond_row_starts_no_panic() {
1598 // `row_count` is caller-supplied; when it exceeds `row_starts.len()`
1599 // the walk must stop instead of indexing out of bounds.
1600 let bytes = b"foo";
1601 let mut metadata = std::collections::HashMap::new();
1602 metadata.insert(
1603 HEX_BG_KEY.to_string(),
1604 MetaValue::Str("#112233".to_string()),
1605 );
1606 let span = hjkl_bonsai::HighlightSpan {
1607 byte_range: 0..3,
1608 capture: Arc::from(HEX_COLOR_CAPTURE),
1609 metadata: Some(Box::new(metadata)),
1610 };
1611 let by_row = build_by_row(&[span], bytes, &[0], 3, &DotFallbackTheme::dark());
1612 assert_eq!(by_row.len(), 3);
1613 assert_eq!(by_row[0].len(), 1);
1614 assert!(by_row[1].is_empty());
1615 assert!(by_row[2].is_empty());
1616 }
1617
1618 #[test]
1619 fn build_by_row_hex_color_without_metadata_skips() {
1620 let span = hjkl_bonsai::HighlightSpan {
1621 byte_range: 0..3,
1622 capture: Arc::from(HEX_COLOR_CAPTURE),
1623 metadata: None,
1624 };
1625 let by_row = build_by_row(&[span], b"foo", &[0], 1, &DotFallbackTheme::dark());
1626 assert_eq!(by_row.len(), 1);
1627 assert!(by_row[0].is_empty());
1628 }
1629
1630 // --- SyntaxLayer basics (no network required) ---
1631
1632 #[test]
1633 fn render_viewport_with_no_language_returns_none() {
1634 let buf = View::from_str("hello world");
1635 let mut layer = default_layer();
1636 assert!(
1637 !layer
1638 .set_language_for_path(TID, Path::new("a.unknownext"))
1639 .is_known()
1640 );
1641 assert!(layer.render_viewport(TID, &buf, 0, 10).is_none());
1642 }
1643
1644 #[test]
1645 fn apply_edits_with_no_language_is_noop() {
1646 let mut layer = default_layer();
1647 let edits = vec![hjkl_engine::ContentEdit {
1648 start_byte: 0,
1649 old_end_byte: 0,
1650 new_end_byte: 1,
1651 start_position: (0, 0),
1652 old_end_position: (0, 0),
1653 new_end_position: (0, 1),
1654 }];
1655 layer.apply_edits(TID, &edits);
1656 // No grammar attached → call must be a no-op (no panic).
1657 }
1658
1659 #[test]
1660 fn set_language_for_path_returns_unknown_for_unrecognized_extension() {
1661 let mut layer = default_layer();
1662 let outcome = layer.set_language_for_path(TID, Path::new("a.zzznope_not_real"));
1663 assert!(!outcome.is_known());
1664 assert!(matches!(outcome, SetLanguageOutcome::Unknown));
1665 }
1666
1667 #[test]
1668 fn poll_pending_loads_drains_ready_handles() {
1669 let mut layer = default_layer();
1670 let events = layer.poll_pending_loads();
1671 assert!(
1672 events.is_empty(),
1673 "expected no events with no pending loads"
1674 );
1675 }
1676
1677 #[test]
1678 fn forget_removes_client_state() {
1679 let mut layer = default_layer();
1680 layer.set_language_for_path(TID, Path::new("a.zzz_unknown"));
1681 layer.forget(TID);
1682 assert!(!layer.clients.contains_key(&TID));
1683 }
1684
1685 // --- Regression: fold extraction must return None when grammar not ready ---
1686 //
1687 // Before the fix (commit edbe2e99), `extract_fold_ranges` returned
1688 // `Vec::new()` for BOTH "grammar not ready" and "grammar ready but no
1689 // folds". The caller in `syntax_glue.rs::recompute_and_install` could not
1690 // distinguish the two cases, so it set `last_fold_dirty_gen = Some(dg)`
1691 // even when the grammar was still loading. When the grammar finished
1692 // loading, `dirty_gen` was unchanged → the fold-extraction condition
1693 // (`last_fold_dg != Some(dg)`) was false → folds were NEVER extracted.
1694 //
1695 // The fix changes `extract_fold_ranges` to return `Option<Vec<...>>`:
1696 // - `None` = grammar not ready yet — caller must NOT update dirty_gen,
1697 // so fold extraction retries on the next recompute.
1698 // - `Some` = grammar was ready and ran (ranges may be empty if no
1699 // folds.scm or no multi-line nodes).
1700 //
1701 // These tests exercise both branches WITHOUT requiring a downloaded grammar.
1702
1703 #[test]
1704 fn extract_fold_ranges_returns_none_when_no_language_attached() {
1705 // Simulates the "grammar still loading" or "unknown extension" state.
1706 // `extract_fold_ranges` must return `None` so the caller knows NOT to
1707 // mark the dirty_gen as processed. Before the fix this returned
1708 // `Vec::new()`, which the caller misinterpreted as "ran successfully,
1709 // no folds" → dirty_gen stamped → folds never re-tried after load.
1710 let buf =
1711 View::from_str("fn hello() {\n let x = 1;\n x\n}\n\nfn world() {\n 2\n}\n");
1712 let mut layer = default_layer();
1713 // Deliberately use an unknown extension so no grammar is attached.
1714 layer.set_language_for_path(TID, Path::new("a.zzz_no_grammar_here"));
1715 let result = layer.extract_fold_ranges(TID, &buf);
1716 assert!(
1717 result.is_none(),
1718 "extract_fold_ranges must return None when no grammar is attached \
1719 (grammar still loading or unknown extension); got {result:?}"
1720 );
1721 }
1722
1723 #[test]
1724 fn extract_fold_ranges_returns_none_when_no_client_registered() {
1725 // View ID with no prior `set_language_for_path` call — no client at all.
1726 let buf = View::from_str("fn foo() {}\n");
1727 let mut layer = default_layer();
1728 // Never called set_language_for_path for TID.
1729 let result = layer.extract_fold_ranges(TID, &buf);
1730 assert!(
1731 result.is_none(),
1732 "extract_fold_ranges must return None when buffer has no syntax client; \
1733 got {result:?}"
1734 );
1735 }
1736
1737 // --- Network-dependent tests (grammar needed) ---
1738
1739 #[test]
1740 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1741 fn parse_and_render_small_rust_buffer() {
1742 let buf = View::from_str("fn main() { let x = 1; }\n");
1743 let mut layer = default_layer();
1744 assert!(
1745 layer
1746 .set_language_for_path(TID, Path::new("a.rs"))
1747 .is_known()
1748 );
1749 let out = layer
1750 .render_viewport(TID, &buf, 0, 10)
1751 .expect("render output");
1752 assert!(
1753 out.spans.iter().any(|r| !r.is_empty()),
1754 "expected at least one styled span"
1755 );
1756 }
1757
1758 #[test]
1759 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1760 fn diagnostics_emit_sign_for_syntax_error() {
1761 let buf = View::from_str("fn main() {\nlet x = ;\n}\n");
1762 let mut layer = default_layer();
1763 layer.set_language_for_path(TID, Path::new("a.rs"));
1764 let out = layer.render_viewport(TID, &buf, 0, 10).unwrap();
1765 assert!(
1766 !out.signs.is_empty(),
1767 "expected at least one diagnostic sign for `let x = ;`"
1768 );
1769 assert!(
1770 out.signs.iter().any(|s| s.row == 1 && s.ch == 'E'),
1771 "expected an 'E' sign on row 1; got {:?}",
1772 out.signs
1773 );
1774 }
1775
1776 #[test]
1777 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1778 fn diagnostics_signs_correct_when_scrolled() {
1779 // Regression: `collect_diag_signs_range` used to pass a
1780 // window-relative byte range + window-only source to
1781 // `parse_errors_range`, which filters tree nodes by absolute
1782 // offsets — so once scrolled it reported errors from the top of
1783 // the document and shifted the resulting rows by the window
1784 // offset. The error below sits on rows 50–52; the viewport starts
1785 // at row 45.
1786 let mut src = String::new();
1787 for i in 0..50 {
1788 let _ = writeln!(src, "fn f{i}() {{}}");
1789 }
1790 src.push_str("fn broken() {\nlet x = ;\n}\n");
1791 let buf = View::from_str(&src);
1792 let mut layer = default_layer();
1793 layer.set_language_for_path(TID, Path::new("a.rs"));
1794 let out = layer.render_viewport(TID, &buf, 45, 20).unwrap();
1795 assert!(
1796 out.signs
1797 .iter()
1798 .any(|s| (50..=52).contains(&s.row) && s.ch == 'E'),
1799 "expected an 'E' sign on rows 50..=52; got {:?}",
1800 out.signs
1801 );
1802 }
1803
1804 /// Regression: `apply_edits` used to clear the parse, row-start and sign
1805 /// caches but leave `cache_spans`, relying on the buffer's `dirty_gen`
1806 /// having moved by the time `render_viewport` next ran. Both buffers here
1807 /// are freshly constructed and so share a `dirty_gen`, which is what makes
1808 /// the omission visible: the incremental render returned the PRE-edit span
1809 /// table, one byte short across the board and without the `@type` capture
1810 /// that `Ymain`'s new capital earns.
1811 #[test]
1812 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1813 fn incremental_path_matches_cold_for_small_edit() {
1814 let pre = View::from_str("fn main() { let x = 1; }");
1815 let mut layer = default_layer();
1816 layer.set_language_for_path(TID, Path::new("a.rs"));
1817 let _ = layer.render_viewport(TID, &pre, 0, 10).unwrap();
1818 layer.apply_edits(
1819 TID,
1820 &[hjkl_engine::ContentEdit {
1821 start_byte: 3,
1822 old_end_byte: 3,
1823 new_end_byte: 4,
1824 start_position: (0, 3),
1825 old_end_position: (0, 3),
1826 new_end_position: (0, 4),
1827 }],
1828 );
1829 let post = View::from_str("fn Ymain() { let x = 1; }");
1830 let inc = layer.render_viewport(TID, &post, 0, 10).unwrap();
1831 let mut cold_layer = default_layer();
1832 cold_layer.set_language_for_path(TID, Path::new("a.rs"));
1833 let cold = cold_layer.render_viewport(TID, &post, 0, 10).unwrap();
1834 assert_eq!(inc.spans, cold.spans);
1835 }
1836
1837 /// Fold ranges for `buf`, waiting for the grammar to become available.
1838 ///
1839 /// `set_language_for_path` may only START a grammar load: on a machine with
1840 /// a warm `~/.cache/bonsai` the grammar is ready by the first
1841 /// `render_viewport`, but on a cold one — CI, which fetches and compiles it
1842 /// — `extract_fold_ranges` answers `None` for as long as the load is in
1843 /// flight. A single call plus `.expect("grammar ready")` therefore passes
1844 /// locally and panics in the `grammar tests` lane, which is exactly how
1845 /// these tests shipped red.
1846 ///
1847 /// Polls `poll_pending_loads` until extraction answers, then returns the
1848 /// ranges.
1849 ///
1850 /// The events `poll_pending_loads` returns are the whole point of reading
1851 /// them rather than discarding them: a load that *fails* removes itself
1852 /// from the pending list and emits `LoadEvent::Failed`, after which
1853 /// nothing is in flight and `extract_fold_ranges` answers `None`
1854 /// forever. Dropping the events made a hard failure look exactly like
1855 /// slow progress — the first version of this helper spun to a 300s
1856 /// deadline and reported "the load either failed or is not being polled",
1857 /// which is a confession that it could not tell, and it threw away the
1858 /// error text that says which. Fail on `Failed`, with the cause.
1859 fn fold_ranges_when_ready(
1860 layer: &mut SyntaxLayer,
1861 buf: &View,
1862 path: &str,
1863 rows: usize,
1864 ) -> Vec<(usize, usize)> {
1865 fold_ranges_when_ready_for(layer, TID, buf, path, rows)
1866 }
1867
1868 /// [`fold_ranges_when_ready`] against an explicit buffer id, for a test
1869 /// that needs the grammar warmed WITHOUT touching the client state of the
1870 /// id it measures — the injected-fold memo is per buffer client, so
1871 /// warming on the same id would leave nothing for a cold pass to parse.
1872 fn fold_ranges_when_ready_for(
1873 layer: &mut SyntaxLayer,
1874 id: BufferId,
1875 buf: &View,
1876 path: &str,
1877 rows: usize,
1878 ) -> Vec<(usize, usize)> {
1879 let outcome = layer.set_language_for_path(id, Path::new(path));
1880 assert!(
1881 outcome.is_known(),
1882 "no grammar is registered for {path} — the test can never succeed"
1883 );
1884 let deadline = std::time::Duration::from_secs(300);
1885 let start = std::time::Instant::now();
1886 loop {
1887 for event in layer.poll_pending_loads() {
1888 if let LoadEvent::Failed { name, error, .. } = event {
1889 panic!("grammar load for `{name}` ({path}) failed: {error}");
1890 }
1891 }
1892 let _ = layer.render_viewport(id, buf, 0, rows);
1893 if let Some(ranges) = layer.extract_fold_ranges(id, buf) {
1894 return ranges;
1895 }
1896 assert!(
1897 start.elapsed() < deadline,
1898 "grammar for {path} never became ready within {deadline:?}, \
1899 and no load reported a failure — it is still building, or \
1900 nothing was ever queued"
1901 );
1902 std::thread::sleep(std::time::Duration::from_millis(100));
1903 }
1904 }
1905
1906 /// Markdown fold ranges, pinned against neovim's treesitter folds for the
1907 /// same document (`vim.treesitter.foldexpr`, folds enumerated with
1908 /// `foldclosed`/`foldclosedend`). Every range below was produced by nvim
1909 /// on this exact text.
1910 ///
1911 /// Before the fix hjkl returned `4..14`, `14..23`, `16..19`, `19..23`,
1912 /// `23..26` and `8..11` — each one row or more too long, so a closed
1913 /// section hid the NEXT section's heading and the last fold ran past the
1914 /// end of the buffer.
1915 #[test]
1916 #[ignore = "network + compiler: fetches the markdown grammar"]
1917 fn markdown_fold_ranges_match_neovim() {
1918 let src = concat!(
1919 "# Title\n\nIntro paragraph.\n\n",
1920 "## Section A\n\nText in A.\n\n```bash\nls -la\n```\n\nMore A text.\n\n",
1921 "## Section B\n\n- item one\n- item two\n\n",
1922 "### Nested B1\n\nDeep text.\n\n",
1923 "## Section C\n\nLast.\n",
1924 );
1925 let buf = View::from_str(src);
1926 let mut layer = default_layer();
1927 let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.md", 40);
1928 assert_eq!(
1929 ranges,
1930 vec![
1931 (0, 25), // # Title — to the last line, not one past it
1932 (4, 12), // ## Section A — ends on "More A text.", not on "## Section B"
1933 (8, 10), // the fenced block — ends on its closing fence
1934 (14, 21), // ## Section B
1935 (16, 17), // the list
1936 (19, 21), // ### Nested B1
1937 (23, 25), // ## Section C
1938 ]
1939 );
1940 }
1941
1942 // ── Folds inside injected languages ─────────────────────────────────
1943 //
1944 // The fixtures below are the ones the 2026-08-03 injection work was
1945 // measured on. neovim's side was enumerated the same way as every other
1946 // fold test here (`vim.treesitter.foldexpr`, `foldclosed`/`foldclosedend`
1947 // per `foldlevel`), with one addition that is easy to get wrong: a
1948 // headless nvim parses injections LAZILY, so the run must force
1949 // `vim.treesitter.get_parser(0):parse(true)` before enumerating. Without
1950 // it nvim reports only the host language's folds (152 instead of 173 on
1951 // `.github/workflows/ci.yml`) and reads as agreement.
1952
1953 /// A markdown document with ` ```rust ` and ` ```bash ` blocks.
1954 ///
1955 /// nvim on this fixture: `0,20` `4,10` `5,9` `6,8` `12,20` `14,18`
1956 /// `15,17` — identical to the assertion below.
1957 ///
1958 /// Three of those seven are inside the fenced blocks and come from the
1959 /// INJECTED grammars: `(5, 9)` is `fn main()`, `(6, 8)` its `if`, and
1960 /// `(15, 17)` the bash `for … done`. Before injected folds hjkl emitted
1961 /// only the other four (`(0, 20)`, `(4, 10)`, `(12, 20)`, `(14, 18)`).
1962 ///
1963 /// The rows are also the proof that the region → host row offset is
1964 /// applied: the rust block's own tree puts those folds at rows 0..4 and
1965 /// 1..3, and the bash block's at 0..2. Drop the offset and the expected
1966 /// vector below cannot be produced.
1967 #[test]
1968 #[ignore = "network + compiler: fetches the markdown, rust and bash grammars"]
1969 fn markdown_injected_fold_ranges_match_neovim() {
1970 let src = concat!(
1971 "# Title\n\nIntro text.\n\n",
1972 "```rust\nfn main() {\n if true {\n println!(\"hi\");\n }\n}\n```\n\n",
1973 "## Shell\n\n",
1974 "```bash\nfor f in a b; do\n echo \"$f\"\ndone\n```\n\n",
1975 "Trailing text.\n",
1976 );
1977 let buf = View::from_str(src);
1978 let mut layer = default_layer();
1979 let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.md", 40);
1980 assert_eq!(
1981 ranges,
1982 vec![
1983 (0, 20), // # Title
1984 (4, 10), // the ```rust fence
1985 (5, 9), // injected: fn main()
1986 (6, 8), // injected: the if
1987 (12, 20), // ## Shell
1988 (14, 18), // the ```bash fence
1989 (15, 17), // injected: for … done
1990 ]
1991 );
1992 }
1993
1994 /// Injected folds must follow their region when rows move above it.
1995 ///
1996 /// The memo behind injected folds (`InjectedFoldCache`) is keyed by the
1997 /// region's CONTENT, and stores region-relative rows. Inserting a line at
1998 /// the top of the document leaves every region's bytes identical — so the
1999 /// memo hits — while every host row shifts by one. If the memo stored host
2000 /// rows, or the offset were applied before storing instead of after
2001 /// reading, the second extraction below would return the FIRST document's
2002 /// rows for the injected folds and only the host's would move.
2003 #[test]
2004 #[ignore = "network + compiler: fetches the markdown, rust and bash grammars"]
2005 fn injected_folds_shift_with_the_region_on_a_memo_hit() {
2006 let body = concat!(
2007 "# Title\n\nIntro text.\n\n",
2008 "```rust\nfn main() {\n if true {\n println!(\"hi\");\n }\n}\n```\n\n",
2009 "## Shell\n\n",
2010 "```bash\nfor f in a b; do\n echo \"$f\"\ndone\n```\n\n",
2011 "Trailing text.\n",
2012 );
2013 let mut layer = default_layer();
2014 let before = View::from_str(body);
2015 // Warm the markdown/rust/bash grammars on a DIFFERENT buffer id: on a
2016 // cold cache (CI) the first extraction answers None until the loads
2017 // finish, but warming on TID would also fill TID's injected-fold memo
2018 // and leave the "cold" count below at zero.
2019 const WARM: BufferId = TID + 1;
2020 let _ = fold_ranges_when_ready_for(&mut layer, WARM, &before, "a.md", 40);
2021 layer.forget(WARM);
2022
2023 layer.set_language_for_path(TID, Path::new("a.md"));
2024 let _ = layer.render_viewport(TID, &before, 0, 40);
2025 hjkl_bonsai::injected_parse_counter::reset();
2026 let first = layer
2027 .extract_fold_ranges(TID, &before)
2028 .expect("grammar ready after the wait above");
2029 assert_eq!(
2030 hjkl_bonsai::injected_parse_counter::get(),
2031 2,
2032 "cold extraction must parse both injected regions (rust + bash)"
2033 );
2034
2035 // Same document with one extra line on top. Same buffer id, same
2036 // layer — the injected-region memo is live and every region's content
2037 // is byte-identical, so this must be the memo-hit path.
2038 let shifted = View::from_str(&format!("Added line.\n{body}"));
2039 layer.reset(TID);
2040 let _ = layer.render_viewport(TID, &shifted, 0, 40);
2041 hjkl_bonsai::injected_parse_counter::reset();
2042 let second = layer
2043 .extract_fold_ranges(TID, &shifted)
2044 .expect("grammar ready after the wait above");
2045 assert_eq!(
2046 hjkl_bonsai::injected_parse_counter::get(),
2047 0,
2048 "moved-but-unchanged regions must come from the memo, not a reparse"
2049 );
2050
2051 let expected: Vec<(usize, usize)> = first.iter().map(|&(s, e)| (s + 1, e + 1)).collect();
2052 assert_eq!(
2053 second, expected,
2054 "every fold, injected ones included, must move down exactly one row"
2055 );
2056 }
2057
2058 /// An HTML page with a `<style>` and a `<script>` block.
2059 ///
2060 /// nvim on this fixture: `1,23` `2,9` `3,8` `4,7` `10,22` `11,13`
2061 /// `14,21` `15,20` `16,18` — identical to the assertion below.
2062 ///
2063 /// `(4, 7)` is the CSS rule inside `<style>`; `(15, 20)` and `(16, 18)`
2064 /// are the JS function and its `if` inside `<script>`. hjkl emitted none
2065 /// of the three before injected folds.
2066 #[test]
2067 #[ignore = "network + compiler: fetches the html, css and javascript grammars"]
2068 fn html_injected_fold_ranges_match_neovim() {
2069 let src = concat!(
2070 "<!DOCTYPE html>\n<html>\n <head>\n",
2071 " <style>\n body {\n color: red;\n margin: 0;\n }\n",
2072 " </style>\n </head>\n <body>\n",
2073 " <div>\n <p>hi</p>\n </div>\n",
2074 " <script>\n function go(x) {\n if (x) {\n",
2075 " return 1;\n }\n return 0;\n }\n",
2076 " </script>\n </body>\n</html>\n",
2077 );
2078 let buf = View::from_str(src);
2079 let mut layer = default_layer();
2080 let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.html", 40);
2081 assert_eq!(
2082 ranges,
2083 vec![
2084 (1, 23), // <html>
2085 (2, 9), // <head>
2086 (3, 8), // <style>
2087 (4, 7), // injected css: the body rule
2088 (10, 22), // <body>
2089 (11, 13), // <div>
2090 (14, 21), // <script>
2091 (15, 20), // injected js: function go()
2092 (16, 18), // injected js: the if
2093 ]
2094 );
2095 }
2096
2097 /// YAML folds are anchored on the pair / sequence item, matching neovim.
2098 /// Capturing `(block_mapping)` instead put the fold on the container,
2099 /// which starts at its FIRST CHILD: `top:` never got a fold of its own,
2100 /// and the document-level mapping produced one fold starting at the first
2101 /// key that swallowed every sibling below it (`0..7` here).
2102 #[test]
2103 #[ignore = "network + compiler: fetches the yaml grammar"]
2104 fn yaml_fold_ranges_match_neovim() {
2105 let src = "top:\n a: 1\n b:\n - one\n - two\n\nother:\n c: 3\n";
2106 let buf = View::from_str(src);
2107 let mut layer = default_layer();
2108 let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.yaml", 40);
2109 assert_eq!(ranges, vec![(0, 4), (2, 4), (6, 7)]);
2110 }
2111
2112 // ── Fold ranges pinned against neovim, per language ──────────────────
2113 //
2114 // Every expectation below was measured on neovim 0.12.4 with
2115 // `vim.treesitter.foldexpr()` over these exact fixtures, enumerating the
2116 // real folds with `foldclosed`/`foldclosedend` at each `foldlevel` (a
2117 // `foldlevel()` array merges adjacent siblings into one run and reads as
2118 // a false difference). Rows are 0-based inclusive, like hjkl's.
2119 //
2120 // Where hjkl emits ranges neovim does not, it is because hjkl's bundled
2121 // query captures a different SET of node types on purpose — the comment
2122 // on each test says which. The ranges themselves agree everywhere.
2123
2124 /// Extract hjkl's fold ranges for `src`, using `name` only to pick the
2125 /// grammar by extension.
2126 fn folds_for(name: &str, src: &str) -> Vec<(usize, usize)> {
2127 let buf = View::from_str(src);
2128 let mut layer = default_layer();
2129 fold_ranges_when_ready(&mut layer, &buf, name, 80)
2130 }
2131
2132 /// nvim on this fixture: `(2, 4)`, `(6, 10)`, `(7, 9)` — identical.
2133 #[test]
2134 #[ignore = "network + compiler: fetches the go grammar"]
2135 fn go_fold_ranges_match_neovim() {
2136 let src = concat!(
2137 "package main\n\n",
2138 "import (\n \"fmt\"\n)\n\n",
2139 "func main() {\n if true {\n fmt.Println(\"x\")\n }\n}\n",
2140 );
2141 assert_eq!(folds_for("a.go", src), vec![(2, 4), (6, 10), (7, 9)]);
2142 }
2143
2144 /// nvim on this fixture: `(0, 2)`, `(4, 9)`, `(5, 7)` — identical.
2145 #[test]
2146 #[ignore = "network + compiler: fetches the c grammar"]
2147 fn c_fold_ranges_match_neovim() {
2148 let src = concat!(
2149 "struct P {\n int x;\n};\n\n",
2150 "int main(void) {\n for (int i = 0; i < 2; i++) {\n i++;\n }\n return 0;\n}\n",
2151 );
2152 assert_eq!(folds_for("a.c", src), vec![(0, 2), (4, 9), (5, 7)]);
2153 }
2154
2155 /// nvim on this fixture: `(0, 9)`, `(2, 7)`, `(4, 6)` — identical.
2156 #[test]
2157 #[ignore = "network + compiler: fetches the cpp grammar"]
2158 fn cpp_fold_ranges_match_neovim() {
2159 let src = concat!(
2160 "namespace n {\n\n",
2161 "class B {\npublic:\n int get() {\n return 1;\n }\n};\n\n",
2162 "}\n",
2163 );
2164 assert_eq!(folds_for("a.cpp", src), vec![(0, 9), (2, 7), (4, 6)]);
2165 }
2166
2167 /// nvim on this fixture: `(0, 7)`, `(1, 5)`, `(3, 5)` — identical.
2168 ///
2169 /// Regression: `folds/cpp.scm` used to capture neither `(try_statement)`
2170 /// nor `(catch_clause)`, so the only fold anchored on `try {` was the try
2171 /// body's `(compound_statement)` — `(1, 3)` here, ending at the `}` that
2172 /// opens the catch instead of at the end of the whole statement. The
2173 /// catch clause got no fold of its own at all.
2174 #[test]
2175 #[ignore = "network + compiler: fetches the cpp grammar"]
2176 fn cpp_try_catch_fold_ranges_match_neovim() {
2177 let src = concat!(
2178 "int main() {\n",
2179 " try {\n f();\n",
2180 " } catch (int e) {\n g();\n }\n",
2181 " return 0;\n}\n",
2182 );
2183 assert_eq!(folds_for("a.cpp", src), vec![(0, 7), (1, 5), (3, 5)]);
2184 }
2185
2186 /// nvim on this fixture: `(0, 7)`, `(1, 6)`, `(2, 4)` — identical.
2187 ///
2188 /// The anchors agree only because the braces are K&R. neovim folds Java's
2189 /// `(class_body)` / `(block)`, which start ON the `{`; hjkl folds
2190 /// `(class_declaration)` / `(method_declaration)`, which start on the
2191 /// signature — and, when annotations precede it, on the FIRST annotation.
2192 /// See `docs/backlog.md` §1.4b.
2193 #[test]
2194 #[ignore = "network + compiler: fetches the java grammar"]
2195 fn java_fold_ranges_match_neovim() {
2196 let src = concat!(
2197 "public class A {\n",
2198 " public int run(int x) {\n",
2199 " if (x > 0) {\n return x;\n }\n",
2200 " return 0;\n }\n}\n",
2201 );
2202 assert_eq!(folds_for("A.java", src), vec![(0, 7), (1, 6), (2, 4)]);
2203 }
2204
2205 /// nvim on this fixture: `(2, 8)`, `(4, 7)`. hjkl adds `(5, 7)` — the
2206 /// Allman-braced `(compound_statement)` body, which neovim's PHP query
2207 /// does not capture at all. The two shared ranges are identical.
2208 #[test]
2209 #[ignore = "network + compiler: fetches the php grammar"]
2210 fn php_fold_ranges_match_neovim() {
2211 let src = concat!(
2212 "<?php\n\n",
2213 "class R\n{\n",
2214 " public function area(): float\n {\n return 1.0;\n }\n}\n",
2215 );
2216 assert_eq!(folds_for("a.php", src), vec![(2, 8), (4, 7), (5, 7)]);
2217 }
2218
2219 /// nvim on this fixture: `(0, 6)`, `(1, 5)`, `(2, 4)` — identical.
2220 #[test]
2221 #[ignore = "network + compiler: fetches the ruby grammar"]
2222 fn ruby_fold_ranges_match_neovim() {
2223 let src = "module M\n class R\n def area\n 1\n end\n end\nend\n";
2224 assert_eq!(folds_for("a.rb", src), vec![(0, 6), (1, 5), (2, 4)]);
2225 }
2226
2227 /// Regression: C# used to fold NOTHING.
2228 ///
2229 /// `bonsai.toml` has both `[language.c-sharp]` and `[language.c_sharp]`
2230 /// for the same grammar, and `GrammarRegistry` resolves an extension to
2231 /// the alphabetically first entry — so a `.cs` buffer loads under the name
2232 /// `c-sharp`, while `builtin_folds` was keyed only on `c_sharp` and
2233 /// returned `None`. This assertion was `[]` before the fix, against
2234 /// neovim's four folds below.
2235 ///
2236 /// nvim on this fixture: `(1, 13)`, `(3, 12)`, `(5, 11)`, `(7, 9)` — it
2237 /// anchors on the Allman `{` because its C# query captures
2238 /// `body: (declaration_list)` and `(block)`. hjkl also captures the
2239 /// declaration nodes, so it anchors one row earlier on the `namespace` /
2240 /// `class` / method signature and keeps neovim's brace-anchored ranges
2241 /// too. Every neovim range appears here.
2242 #[test]
2243 #[ignore = "network + compiler: fetches the c-sharp grammar"]
2244 fn c_sharp_fold_ranges_match_neovim() {
2245 let src = concat!(
2246 "namespace Demo\n{\n",
2247 " public class R\n {\n",
2248 " public int Area()\n {\n",
2249 " if (true)\n {\n return 1;\n }\n",
2250 " return 0;\n }\n }\n}\n",
2251 );
2252 assert_eq!(
2253 folds_for("P.cs", src),
2254 vec![(0, 13), (2, 12), (4, 11), (5, 11), (6, 9), (7, 9)]
2255 );
2256 }
2257
2258 /// nvim on this fixture: `(0, 4)`, `(1, 3)`, `(6, 11)`, `(7, 9)` —
2259 /// identical.
2260 #[test]
2261 #[ignore = "network + compiler: fetches the javascript grammar"]
2262 fn javascript_fold_ranges_match_neovim() {
2263 let src = concat!(
2264 "class R {\n area() {\n return 1;\n }\n}\n\n",
2265 "function run(xs) {\n const t = {\n a: 1,\n };\n return t;\n}\n",
2266 );
2267 assert_eq!(
2268 folds_for("a.js", src),
2269 vec![(0, 4), (1, 3), (6, 11), (7, 9)]
2270 );
2271 }
2272
2273 /// nvim on this fixture: `(0, 2)`, `(4, 6)`, `(8, 10)`, `(12, 14)` —
2274 /// identical. Covers the TypeScript-only nodes: `(interface_declaration)`,
2275 /// `(type_alias_declaration)` with an `(object_type)` body, and
2276 /// `(enum_declaration)`.
2277 #[test]
2278 #[ignore = "network + compiler: fetches the typescript grammar"]
2279 fn typescript_fold_ranges_match_neovim() {
2280 let src = concat!(
2281 "interface S {\n area(): number;\n}\n\n",
2282 "type H = {\n name: string;\n};\n\n",
2283 "enum C {\n Red,\n}\n\n",
2284 "function run(): number {\n return 1;\n}\n",
2285 );
2286 assert_eq!(
2287 folds_for("a.ts", src),
2288 vec![(0, 2), (4, 6), (8, 10), (12, 14)]
2289 );
2290 }
2291
2292 #[test]
2293 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
2294 fn forget_drops_buffer_state() {
2295 let buf = View::from_str("fn main() {}");
2296 let mut layer = default_layer();
2297 layer.set_language_for_path(TID, Path::new("a.rs"));
2298 let _ = layer.render_viewport(TID, &buf, 0, 10).unwrap();
2299 assert!(layer.clients.contains_key(&TID));
2300 layer.forget(TID);
2301 assert!(!layer.clients.contains_key(&TID));
2302 }
2303}