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}