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 // Scan newlines chunk-by-chunk from the rope so we never materialise
864 // the full document as a contiguous byte slice.
865 let row_starts: Arc<Vec<usize>> = if client
866 .cache_row_starts
867 .as_ref()
868 .is_some_and(|(g, _)| *g == dg)
869 {
870 Arc::clone(&client.cache_row_starts.as_ref().unwrap().1)
871 } else {
872 // SIMD-vectorised newline scan via memchr — measurably faster than
873 // a per-byte loop. Pre-sized to row_count + 1 to avoid realloc churn.
874 let mut rs: Vec<usize> = Vec::with_capacity(row_count + 1);
875 rs.push(0);
876 let mut chunk_pos = 0usize;
877 for chunk in rope.chunks() {
878 for nl in memchr::memchr_iter(b'\n', chunk.as_bytes()) {
879 rs.push(chunk_pos + nl + 1);
880 }
881 chunk_pos += chunk.len();
882 }
883 let arc = Arc::new(rs);
884 client.cache_row_starts = Some((dg, Arc::clone(&arc)));
885 arc
886 };
887
888 // Reparse only when needed. Use rope-streaming parse to avoid passing
889 // the full bytes slice into the parser (tree-sitter reads chunk-by-chunk
890 // via the closure; no contiguous copy required for the parse step).
891 let needs_reparse = client.parsed_dirty_gen != Some(dg);
892 {
893 let highlighter = client.highlighter.as_mut()?;
894 if highlighter.tree().is_none() {
895 highlighter.parse_initial_rope(&rope);
896 if highlighter.tree().is_some() {
897 client.parsed_dirty_gen = Some(dg);
898 }
899 } else if needs_reparse {
900 // No-diff incremental: we discard the changed-byte ranges
901 // (cache is keyed by dirty_gen + viewport, not by edit
902 // ranges). Computing `old.changed_ranges(&new)` walks both
903 // trees and was ~54 % of per-keystroke CPU on a 1.86 M-line
904 // file.
905 let ok = highlighter.parse_incremental_rope(&rope);
906 if ok && highlighter.tree().is_some() {
907 client.parsed_dirty_gen = Some(dg);
908 }
909 }
910 }
911
912 // Compute colorizer gate before re-borrowing client mutably.
913 // Effective = global flag AND current language is in the allowlist.
914 let colorizer_enabled = {
915 let c = self.clients.get(&id)?;
916 let lang_name = c.current_lang.as_ref().map_or("", |g| g.name());
917 self.colorizer
918 && (self.colorizer_filetypes.is_empty()
919 || self.colorizer_filetypes.iter().any(|ft| ft == lang_name))
920 };
921 let rainbow_brackets_enabled = self.rainbow_brackets;
922
923 // Re-borrow after parse.
924 let client = self.clients.get_mut(&id)?;
925 let highlighter = client.highlighter.as_mut()?;
926
927 // If still no tree (parse failed), give up.
928 highlighter.tree()?;
929
930 let theme = self.theme.as_ref();
931 let directory = Arc::clone(&self.directory);
932
933 // Extend cache to cover [vp_top, vp_end).
934 if client.cache_rows.is_empty() {
935 // Case A: empty cache — walk full range.
936 client.cache_spans = walk_rows(
937 highlighter,
938 &rope,
939 &row_starts,
940 row_count,
941 vp_top,
942 vp_end,
943 theme,
944 &directory,
945 colorizer_enabled,
946 rainbow_brackets_enabled,
947 );
948 client.cache_rows = vp_top..vp_end;
949 client.cache_dirty_gen = Some(dg);
950 } else {
951 let cache_covers_overlap =
952 vp_top < client.cache_rows.end && vp_end > client.cache_rows.start;
953 if !cache_covers_overlap {
954 // Disjoint — just rebuild the whole viewport.
955 client.cache_spans = walk_rows(
956 highlighter,
957 &rope,
958 &row_starts,
959 row_count,
960 vp_top,
961 vp_end,
962 theme,
963 &directory,
964 colorizer_enabled,
965 rainbow_brackets_enabled,
966 );
967 client.cache_rows = vp_top..vp_end;
968 } else {
969 // Case B: extend prefix if needed.
970 if vp_top < client.cache_rows.start {
971 let new_rows = walk_rows(
972 highlighter,
973 &rope,
974 &row_starts,
975 row_count,
976 vp_top,
977 client.cache_rows.start,
978 theme,
979 &directory,
980 colorizer_enabled,
981 rainbow_brackets_enabled,
982 );
983 let mut combined = new_rows;
984 combined.append(&mut client.cache_spans);
985 client.cache_spans = combined;
986 client.cache_rows.start = vp_top;
987 }
988 // Case C: extend suffix if needed.
989 if vp_end > client.cache_rows.end {
990 let new_rows = walk_rows(
991 highlighter,
992 &rope,
993 &row_starts,
994 row_count,
995 client.cache_rows.end,
996 vp_end,
997 theme,
998 &directory,
999 colorizer_enabled,
1000 rainbow_brackets_enabled,
1001 );
1002 client.cache_spans.extend(new_rows);
1003 client.cache_rows.end = vp_end;
1004 }
1005 }
1006 client.cache_dirty_gen = Some(dg);
1007 }
1008
1009 // Bounds of the requested viewport inside the cache.
1010 let offset = vp_top - client.cache_rows.start;
1011 let len = vp_end - vp_top;
1012
1013 // Get or build signs, cached per (dirty_gen, vp_top, vp_end).
1014 // Done before borrowing `cache_spans` so the mutable client borrow
1015 // (needed for `highlighter` and the sign-cache write) has ended.
1016 let signs = if client
1017 .cache_signs
1018 .as_ref()
1019 .is_some_and(|(g, t, e, _)| *g == dg && *t == vp_top && *e == vp_end)
1020 {
1021 client.cache_signs.as_ref().unwrap().3.clone()
1022 } else {
1023 let s = collect_diag_signs_range(highlighter, &rope, &row_starts, vp_top, vp_end);
1024 client.cache_signs = Some((dg, vp_top, vp_end, s.clone()));
1025 s
1026 };
1027
1028 // Borrow the viewport slice out of the cache — no copy.
1029 let spans = &self.clients.get(&id)?.cache_spans[offset..offset + len];
1030
1031 Some(RenderOutputRef {
1032 buffer_id: id,
1033 spans,
1034 signs,
1035 key: (dg, vp_top, viewport_height),
1036 perf: PerfBreakdown::default(),
1037 })
1038 }
1039
1040 /// Resolve a path to its language name without loading a grammar.
1041 pub fn name_for_path(&self, path: &Path) -> Option<String> {
1042 self.directory.name_for_path(path)
1043 }
1044
1045 /// Returns `true` if a client is tracked for the given buffer id.
1046 #[doc(hidden)]
1047 pub fn has_client(&self, id: BufferId) -> bool {
1048 self.clients.contains_key(&id)
1049 }
1050
1051 /// Dispatch a [`LoadEvent`] through a caller-supplied handler.
1052 ///
1053 /// # Examples
1054 ///
1055 /// ```rust
1056 /// use hjkl_syntax::{LoadEvent, SyntaxLayer};
1057 ///
1058 /// let event = LoadEvent::Ready { id: 0, name: "rust".into() };
1059 /// let mut got_ready = false;
1060 /// let handled = SyntaxLayer::dispatch_load_event(&event, |ev| {
1061 /// use hjkl_syntax::LoadEventKind;
1062 /// match ev {
1063 /// LoadEventKind::Ready { id, name } => { got_ready = true; }
1064 /// LoadEventKind::Failed { .. } => {}
1065 /// }
1066 /// });
1067 /// assert!(handled);
1068 /// assert!(got_ready);
1069 /// ```
1070 pub fn dispatch_load_event(
1071 event: &LoadEvent,
1072 mut handler: impl FnMut(LoadEventKind<'_>),
1073 ) -> bool {
1074 #[allow(unreachable_patterns)]
1075 match event {
1076 LoadEvent::Ready { id, name } => {
1077 handler(LoadEventKind::Ready { id: *id, name });
1078 true
1079 }
1080 LoadEvent::Failed { id, name, error } => {
1081 handler(LoadEventKind::Failed {
1082 id: *id,
1083 name,
1084 error,
1085 });
1086 true
1087 }
1088 _ => false,
1089 }
1090 }
1091}
1092
1093// ---------------------------------------------------------------------------
1094// Rainbow palette
1095// ---------------------------------------------------------------------------
1096
1097/// 7-colour rainbow palette for bracket depth coloring (dark-bg readable).
1098/// Depth 0 → index 0, depth N → RAINBOW_PALETTE[N % RAINBOW_PALETTE.len()].
1099const RAINBOW_PALETTE: [Color; 7] = [
1100 Color::rgb(255, 100, 100), // red
1101 Color::rgb(255, 175, 80), // orange
1102 Color::rgb(255, 230, 80), // yellow
1103 Color::rgb(100, 220, 100), // green
1104 Color::rgb(80, 210, 220), // cyan
1105 Color::rgb(100, 140, 255), // blue
1106 Color::rgb(190, 120, 255), // violet
1107];
1108
1109// ---------------------------------------------------------------------------
1110// Helper: walk a row range against the retained tree
1111// ---------------------------------------------------------------------------
1112
1113#[allow(clippy::too_many_arguments)]
1114fn walk_rows(
1115 highlighter: &mut Highlighter,
1116 rope: &ropey::Rope,
1117 row_starts: &[usize],
1118 row_count: usize,
1119 seg_start: usize,
1120 seg_end: usize,
1121 theme: &dyn Theme,
1122 directory: &Arc<LanguageDirectory>,
1123 colorizer: bool,
1124 rainbow_brackets: bool,
1125) -> Vec<Vec<(usize, usize, StyleSpec)>> {
1126 let rope_len = rope.len_bytes();
1127 let byte_start = row_starts.get(seg_start).copied().unwrap_or(rope_len);
1128 let byte_end = row_starts
1129 .get(seg_end)
1130 .copied()
1131 .unwrap_or(rope_len)
1132 .min(rope_len)
1133 .max(byte_start);
1134
1135 let mut flat_spans =
1136 highlighter.highlight_range_with_injections_rope(rope, byte_start..byte_end, |name| {
1137 directory.by_name(name)
1138 });
1139
1140 let marker_pass = CommentMarkerPass::new();
1141 marker_pass.apply_rope(&mut flat_spans, rope);
1142 if colorizer {
1143 let hex_color_pass = HexColorPass::new();
1144 hex_color_pass.apply_range_rope(&mut flat_spans, rope, byte_start..byte_end);
1145 }
1146 if rainbow_brackets
1147 && let (Some(tree), Some(grammar)) = (highlighter.tree(), highlighter.grammar())
1148 {
1149 let rb_spans = rainbow_spans_rope(tree, grammar, rope, byte_start..byte_end);
1150 flat_spans.extend(rb_spans);
1151 }
1152
1153 // Bucket spans into ONLY the viewport row range. The prior version
1154 // called `build_by_row(..., row_count, ...)` and sliced the result,
1155 // which allocated `row_count` empty inner Vecs (8.58 M on a huge
1156 // file) just to throw away all but ~50 of them — that single line
1157 // was ~24 % of per-keystroke CPU during a paste burst.
1158 let _ = row_count; // kept in signature for the public build_by_row tests
1159 build_by_row_range(&flat_spans, rope_len, row_starts, seg_start..seg_end, theme)
1160}
1161
1162/// Viewport-bounded variant of [`build_by_row`]. Allocates exactly
1163/// `row_range.len()` inner Vecs instead of one per document row. Spans
1164/// whose byte range falls entirely outside `row_range` are skipped; spans
1165/// that overlap have their per-row slices recorded with positions local
1166/// to the viewport (so row `row_range.start` lands at index 0).
1167fn build_by_row_range(
1168 flat_spans: &[hjkl_bonsai::HighlightSpan],
1169 source_len: usize,
1170 row_starts: &[usize],
1171 row_range: Range<usize>,
1172 theme: &dyn Theme,
1173) -> Vec<Vec<(usize, usize, StyleSpec)>> {
1174 let seg_start = row_range.start;
1175 let seg_end = row_range.end.min(row_starts.len());
1176 if seg_end <= seg_start {
1177 return Vec::new();
1178 }
1179 let mut by_row: Vec<Vec<(usize, usize, StyleSpec)>> = vec![Vec::new(); seg_end - seg_start];
1180
1181 for span in flat_spans {
1182 let hex_style: Option<StyleSpec> = if span.capture() == HEX_COLOR_CAPTURE {
1183 let bg = match span.metadata().and_then(|m| m.get(HEX_BG_KEY)) {
1184 Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1185 _ => None,
1186 };
1187 let fg = match span.metadata().and_then(|m| m.get(HEX_FG_KEY)) {
1188 Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1189 _ => None,
1190 };
1191 bg.map(|bg| StyleSpec {
1192 fg,
1193 bg: Some(bg),
1194 modifiers: hjkl_theme::Modifiers::default(),
1195 })
1196 } else if span.capture() == RAINBOW_BRACKET_CAPTURE {
1197 let depth = match span.metadata().and_then(|m| m.get(RAINBOW_DEPTH_KEY)) {
1198 Some(MetaValue::Int(d)) => *d as usize,
1199 _ => 0,
1200 };
1201 let fg = RAINBOW_PALETTE[depth % RAINBOW_PALETTE.len()];
1202 Some(StyleSpec {
1203 fg: Some(fg),
1204 bg: None,
1205 modifiers: hjkl_theme::Modifiers::default(),
1206 })
1207 } else {
1208 None
1209 };
1210
1211 let style: StyleSpec = if let Some(s) = hex_style {
1212 s
1213 } else {
1214 match theme.style(span.capture()) {
1215 Some(s) => *s,
1216 None => continue,
1217 }
1218 };
1219
1220 let span_start = span.byte_range.start;
1221 let span_end = span.byte_range.end;
1222
1223 let start_row = row_starts
1224 .partition_point(|&rs| rs <= span_start)
1225 .saturating_sub(1);
1226
1227 let mut row = start_row.max(seg_start);
1228 while row < seg_end {
1229 let row_byte_start = row_starts[row];
1230 let row_byte_end = row_starts
1231 .get(row + 1)
1232 .map_or(source_len, |&s| s.saturating_sub(1));
1233
1234 if row_byte_start >= span_end {
1235 break;
1236 }
1237
1238 let local_start = span_start.saturating_sub(row_byte_start);
1239 let local_end = row_local_end(span_start, span_end, row_byte_start, row_byte_end);
1240
1241 if local_end > local_start {
1242 by_row[row - seg_start].push((local_start, local_end, style));
1243 }
1244
1245 row += 1;
1246 }
1247 }
1248
1249 by_row
1250}
1251
1252/// Row-local end offset for a span clipped to one row.
1253///
1254/// A MULTI-ROW span that covers this row's end — markdown's fenced code
1255/// block, a multi-line string — records one byte PAST the row's content (the
1256/// newline slot) instead of stopping at the last character. That is how the
1257/// renderer tells a block from a span that merely happens to reach
1258/// end-of-line, and it paints the block's bg across the whole row. An empty
1259/// row inside such a span gets `0..1`; without it the row produces no span
1260/// at all and shows as an untinted gap mid-block.
1261///
1262/// A span confined to ONE row keeps its exact end even when it reaches
1263/// end-of-line, so a hex-colour swatch or TODO marker never bleeds its bg
1264/// across the row.
1265fn row_local_end(
1266 span_start: usize,
1267 span_end: usize,
1268 row_byte_start: usize,
1269 row_byte_end: usize,
1270) -> usize {
1271 let multi_row = span_start < row_byte_start || span_end > row_byte_end;
1272 if multi_row && span_end >= row_byte_end {
1273 row_byte_end - row_byte_start + 1
1274 } else {
1275 span_end.min(row_byte_end) - row_byte_start
1276 }
1277}
1278
1279// ---------------------------------------------------------------------------
1280// Helper: build per-row span table (renderer-agnostic StyleSpec output)
1281// ---------------------------------------------------------------------------
1282
1283/// Resolve flat highlight spans into a per-row span table sized to `row_count`.
1284pub fn build_by_row(
1285 flat_spans: &[hjkl_bonsai::HighlightSpan],
1286 bytes: &[u8],
1287 row_starts: &[usize],
1288 row_count: usize,
1289 theme: &dyn Theme,
1290) -> Vec<Vec<(usize, usize, StyleSpec)>> {
1291 let mut by_row: Vec<Vec<(usize, usize, StyleSpec)>> = vec![Vec::new(); row_count];
1292
1293 for span in flat_spans {
1294 let hex_style: Option<StyleSpec> = if span.capture() == HEX_COLOR_CAPTURE {
1295 let bg = match span.metadata().and_then(|m| m.get(HEX_BG_KEY)) {
1296 Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1297 _ => None,
1298 };
1299 let fg = match span.metadata().and_then(|m| m.get(HEX_FG_KEY)) {
1300 Some(MetaValue::Str(s)) => hjkl_theme::Color::from_hex_str(s).ok(),
1301 _ => None,
1302 };
1303 bg.map(|bg| StyleSpec {
1304 fg,
1305 bg: Some(bg),
1306 modifiers: hjkl_theme::Modifiers::default(),
1307 })
1308 } else if span.capture() == RAINBOW_BRACKET_CAPTURE {
1309 let depth = match span.metadata().and_then(|m| m.get(RAINBOW_DEPTH_KEY)) {
1310 Some(MetaValue::Int(d)) => *d as usize,
1311 _ => 0,
1312 };
1313 let fg = RAINBOW_PALETTE[depth % RAINBOW_PALETTE.len()];
1314 Some(StyleSpec {
1315 fg: Some(fg),
1316 bg: None,
1317 modifiers: hjkl_theme::Modifiers::default(),
1318 })
1319 } else {
1320 None
1321 };
1322
1323 let style: StyleSpec = if let Some(s) = hex_style {
1324 s
1325 } else {
1326 match theme.style(span.capture()) {
1327 Some(s) => *s,
1328 None => continue,
1329 }
1330 };
1331 let style = &style;
1332
1333 let span_start = span.byte_range.start;
1334 let span_end = span.byte_range.end;
1335
1336 let start_row = row_starts
1337 .partition_point(|&rs| rs <= span_start)
1338 .saturating_sub(1);
1339
1340 let mut row = start_row;
1341 while row < row_count {
1342 // `row_count` is caller-supplied and may exceed `row_starts.len()`;
1343 // stop rather than index out of bounds.
1344 let Some(&row_byte_start) = row_starts.get(row) else {
1345 break;
1346 };
1347 let row_byte_end = row_starts
1348 .get(row + 1)
1349 .map_or(bytes.len(), |&s| s.saturating_sub(1));
1350
1351 if row_byte_start >= span_end {
1352 break;
1353 }
1354
1355 let local_start = span_start.saturating_sub(row_byte_start);
1356 let local_end = row_local_end(span_start, span_end, row_byte_start, row_byte_end);
1357
1358 if local_end > local_start {
1359 by_row[row].push((local_start, local_end, *style));
1360 }
1361
1362 row += 1;
1363 }
1364 }
1365
1366 by_row
1367}
1368
1369// ---------------------------------------------------------------------------
1370// Helper: collect diagnostic signs
1371// ---------------------------------------------------------------------------
1372
1373fn collect_diag_signs_range(
1374 h: &mut Highlighter,
1375 rope: &ropey::Rope,
1376 row_starts: &[usize],
1377 vp_top: usize,
1378 vp_end: usize,
1379) -> Vec<DiagSign> {
1380 let rope_len = rope.len_bytes();
1381 let byte_start = row_starts.get(vp_top).copied().unwrap_or(rope_len);
1382 let byte_end = row_starts.get(vp_end).copied().unwrap_or(rope_len);
1383 // The retained tree stores document-absolute byte offsets, and
1384 // `parse_errors_range` both filters nodes against `byte_range` and
1385 // harvests snippets by indexing `source` with those absolute offsets.
1386 // Materialise only the viewport window (typically ≪ 100 KB), but place
1387 // it at its absolute position in a zero-filled buffer so offsets line
1388 // up. `vec![0u8; n]` uses `alloc_zeroed`, so the prefix costs no
1389 // explicit writes. Passing a window-relative range here previously
1390 // reported errors from the wrong document region once scrolled.
1391 let source: Vec<u8> = if byte_start < byte_end && byte_end <= rope_len {
1392 let mut buf = vec![0u8; byte_end];
1393 let mut pos = byte_start;
1394 for chunk in rope.byte_slice(byte_start..byte_end).chunks() {
1395 buf[pos..pos + chunk.len()].copy_from_slice(chunk.as_bytes());
1396 pos += chunk.len();
1397 }
1398 buf
1399 } else {
1400 Vec::new()
1401 };
1402 let errors = h.parse_errors_range(&source, byte_start..byte_end);
1403 let mut signs: Vec<DiagSign> = Vec::new();
1404 let mut last_row: Option<usize> = None;
1405 for err in &errors {
1406 // Error byte ranges are already document-absolute.
1407 let abs_start = err.byte_range.start;
1408 let r = row_starts
1409 .partition_point(|&rs| rs <= abs_start)
1410 .saturating_sub(1);
1411 if last_row == Some(r) {
1412 continue;
1413 }
1414 last_row = Some(r);
1415 signs.push(DiagSign::new(r, 'E', 100));
1416 }
1417 signs
1418}
1419
1420// ---------------------------------------------------------------------------
1421// Factory helpers
1422// ---------------------------------------------------------------------------
1423
1424/// Build a `SyntaxLayer` using the given theme + language directory.
1425pub fn layer_with_theme(
1426 theme: Arc<DotFallbackTheme>,
1427 directory: Arc<LanguageDirectory>,
1428) -> SyntaxLayer {
1429 SyntaxLayer::new(theme, directory)
1430}
1431
1432/// Build a `SyntaxLayer` with hjkl-bonsai's bundled dark theme.
1433#[cfg(test)]
1434pub fn default_layer() -> SyntaxLayer {
1435 let directory = Arc::new(LanguageDirectory::new().expect("language directory"));
1436 SyntaxLayer::new(Arc::new(DotFallbackTheme::dark()), directory)
1437}
1438
1439// ---------------------------------------------------------------------------
1440// Tests
1441// ---------------------------------------------------------------------------
1442
1443#[cfg(test)]
1444mod tests {
1445 use super::*;
1446 use hjkl_buffer::View;
1447 use std::fmt::Write as _;
1448 use std::path::Path;
1449
1450 const TID: BufferId = 0;
1451
1452 // --- DiagSign ---
1453
1454 #[test]
1455 fn diag_sign_new_roundtrip() {
1456 let s = DiagSign::new(7, 'W', 50);
1457 assert_eq!(s.row, 7);
1458 assert_eq!(s.ch, 'W');
1459 assert_eq!(s.priority, 50);
1460 }
1461
1462 #[test]
1463 fn diag_sign_default_is_sensible() {
1464 let s = DiagSign::default();
1465 assert_eq!(s.row, 0);
1466 assert_eq!(s.ch, 'E');
1467 assert_eq!(s.priority, 0);
1468 }
1469
1470 // --- PerfBreakdown ---
1471
1472 #[test]
1473 fn perf_breakdown_default_zeros() {
1474 let p = PerfBreakdown::new();
1475 assert_eq!(p.source_build_us, 0);
1476 assert_eq!(p.parse_us, 0);
1477 assert_eq!(p.highlight_us, 0);
1478 assert_eq!(p.by_row_us, 0);
1479 assert_eq!(p.diag_us, 0);
1480 }
1481
1482 // --- SetLanguageOutcome ---
1483
1484 #[test]
1485 fn set_language_outcome_is_known() {
1486 assert!(SetLanguageOutcome::Ready.is_known());
1487 assert!(SetLanguageOutcome::Loading.is_known());
1488 assert!(!SetLanguageOutcome::Unknown.is_known());
1489 }
1490
1491 // --- RenderOutput ---
1492
1493 #[test]
1494 fn render_output_new_roundtrip() {
1495 let out = RenderOutput::new(
1496 99,
1497 vec![vec![]],
1498 vec![DiagSign::new(0, 'E', 100)],
1499 (7, 0, 30),
1500 PerfBreakdown::new(),
1501 );
1502 assert_eq!(out.buffer_id, 99);
1503 assert_eq!(out.key, (7, 0, 30));
1504 assert_eq!(out.signs.len(), 1);
1505 }
1506
1507 #[test]
1508 fn render_output_partial_eq_same() {
1509 let a = RenderOutput::new(
1510 0,
1511 vec![vec![(0, 5, StyleSpec::default())]],
1512 vec![],
1513 (1, 0, 10),
1514 PerfBreakdown::default(),
1515 );
1516 let b = a.clone();
1517 assert_eq!(a, b);
1518 }
1519
1520 // --- build_by_row ---
1521
1522 #[test]
1523 fn build_by_row_empty_spans_gives_empty_rows() {
1524 let by_row = build_by_row(
1525 &[],
1526 b"hello\nworld\n",
1527 &[0, 6, 12],
1528 2,
1529 &DotFallbackTheme::dark(),
1530 );
1531 assert_eq!(by_row.len(), 2);
1532 assert!(by_row[0].is_empty());
1533 assert!(by_row[1].is_empty());
1534 }
1535
1536 #[test]
1537 fn build_by_row_marks_every_full_row_of_a_multi_row_span() {
1538 // A multi-row span (markdown's fenced code block, a multi-line
1539 // string) records one byte PAST the content of every row it covers
1540 // to the end — including the row it ENDS on, so a code block renders
1541 // as a rectangle rather than a ragged shape with a short last line.
1542 //
1543 // row 0 "aaa" bytes 0..3 covered to eol → 0..4
1544 // row 1 "" byte 4 blank, covered → 0..1
1545 // row 2 "bb" bytes 5..7 ends AT this eol → 0..3
1546 // row 3 "cc" bytes 8..10 past the span → none
1547 let bytes = b"aaa\n\nbb\ncc\n";
1548 let span = hjkl_bonsai::HighlightSpan {
1549 byte_range: 0..7,
1550 capture: Arc::from("string"),
1551 metadata: None,
1552 };
1553 let by_row = build_by_row(
1554 &[span],
1555 bytes,
1556 &[0, 4, 5, 8, 11],
1557 4,
1558 &DotFallbackTheme::dark(),
1559 );
1560 assert_eq!(by_row[0].len(), 1);
1561 assert_eq!((by_row[0][0].0, by_row[0][0].1), (0, 4));
1562 assert_eq!(
1563 by_row[1].len(),
1564 1,
1565 "a blank row inside the span must still get one, or it renders as an untinted gap"
1566 );
1567 assert_eq!((by_row[1][0].0, by_row[1][0].1), (0, 1));
1568 assert_eq!(by_row[2].len(), 1);
1569 assert_eq!(
1570 (by_row[2][0].0, by_row[2][0].1),
1571 (0, 3),
1572 "the last row of the span reaches its eol, so it is marked too"
1573 );
1574 assert!(by_row[3].is_empty());
1575 }
1576
1577 #[test]
1578 fn build_by_row_multi_row_span_ending_mid_row_stops_there() {
1579 // A multi-row span that ends BEFORE the last row's eol marks only the
1580 // rows it covers to the end — the final partial row keeps its exact
1581 // offset, so no bg spills past the text it actually covers.
1582 //
1583 // row 0 "aaa" bytes 0..3 covered to eol → 0..4
1584 // row 1 "bbb" bytes 4..7 ends at byte 6 → 0..2
1585 let bytes = b"aaa\nbbb\n";
1586 let span = hjkl_bonsai::HighlightSpan {
1587 byte_range: 0..6,
1588 capture: Arc::from("string"),
1589 metadata: None,
1590 };
1591 let by_row = build_by_row(&[span], bytes, &[0, 4, 8], 2, &DotFallbackTheme::dark());
1592 assert_eq!((by_row[0][0].0, by_row[0][0].1), (0, 4));
1593 assert_eq!((by_row[1][0].0, by_row[1][0].1), (0, 2));
1594 }
1595
1596 #[test]
1597 fn build_by_row_single_row_span_ends_at_its_content() {
1598 // The counterpart: a span confined to one row is never marked, even
1599 // when it reaches end-of-line — a hex-colour swatch or TODO marker
1600 // must not bleed its bg across the rest of the row.
1601 let bytes = b"aaa\nbbb\n";
1602 let span = hjkl_bonsai::HighlightSpan {
1603 byte_range: 0..3,
1604 capture: Arc::from("string"),
1605 metadata: None,
1606 };
1607 let by_row = build_by_row(&[span], bytes, &[0, 4, 8], 2, &DotFallbackTheme::dark());
1608 assert_eq!(by_row[0].len(), 1);
1609 assert_eq!((by_row[0][0].0, by_row[0][0].1), (0, 3));
1610 assert!(by_row[1].is_empty());
1611 }
1612
1613 #[test]
1614 fn build_by_row_hex_color_uses_metadata_colors() {
1615 let bytes = b"--accent: #bb9af7;";
1616 let mut metadata = std::collections::HashMap::new();
1617 metadata.insert(
1618 HEX_BG_KEY.to_string(),
1619 MetaValue::Str("#bb9af7".to_string()),
1620 );
1621 metadata.insert(
1622 HEX_FG_KEY.to_string(),
1623 MetaValue::Str("#ffffff".to_string()),
1624 );
1625 let span = hjkl_bonsai::HighlightSpan {
1626 byte_range: 10..17,
1627 capture: Arc::from(HEX_COLOR_CAPTURE),
1628 metadata: Some(Box::new(metadata)),
1629 };
1630 let by_row = build_by_row(&[span], bytes, &[0], 1, &DotFallbackTheme::dark());
1631 assert_eq!(by_row.len(), 1);
1632 assert_eq!(by_row[0].len(), 1);
1633 let (_, _, style) = by_row[0][0];
1634 let bg = style.bg.expect("hex color must set background");
1635 assert_eq!((bg.r, bg.g, bg.b), (0xbb, 0x9a, 0xf7));
1636 let fg = style.fg.expect("hex color must set foreground");
1637 assert_eq!((fg.r, fg.g, fg.b), (0xff, 0xff, 0xff));
1638 }
1639
1640 #[test]
1641 fn build_by_row_row_count_beyond_row_starts_no_panic() {
1642 // `row_count` is caller-supplied; when it exceeds `row_starts.len()`
1643 // the walk must stop instead of indexing out of bounds.
1644 let bytes = b"foo";
1645 let mut metadata = std::collections::HashMap::new();
1646 metadata.insert(
1647 HEX_BG_KEY.to_string(),
1648 MetaValue::Str("#112233".to_string()),
1649 );
1650 let span = hjkl_bonsai::HighlightSpan {
1651 byte_range: 0..3,
1652 capture: Arc::from(HEX_COLOR_CAPTURE),
1653 metadata: Some(Box::new(metadata)),
1654 };
1655 let by_row = build_by_row(&[span], bytes, &[0], 3, &DotFallbackTheme::dark());
1656 assert_eq!(by_row.len(), 3);
1657 assert_eq!(by_row[0].len(), 1);
1658 assert!(by_row[1].is_empty());
1659 assert!(by_row[2].is_empty());
1660 }
1661
1662 #[test]
1663 fn build_by_row_hex_color_without_metadata_skips() {
1664 let span = hjkl_bonsai::HighlightSpan {
1665 byte_range: 0..3,
1666 capture: Arc::from(HEX_COLOR_CAPTURE),
1667 metadata: None,
1668 };
1669 let by_row = build_by_row(&[span], b"foo", &[0], 1, &DotFallbackTheme::dark());
1670 assert_eq!(by_row.len(), 1);
1671 assert!(by_row[0].is_empty());
1672 }
1673
1674 // --- SyntaxLayer basics (no network required) ---
1675
1676 #[test]
1677 fn render_viewport_with_no_language_returns_none() {
1678 let buf = View::from_str("hello world");
1679 let mut layer = default_layer();
1680 assert!(
1681 !layer
1682 .set_language_for_path(TID, Path::new("a.unknownext"))
1683 .is_known()
1684 );
1685 assert!(layer.render_viewport(TID, &buf, 0, 10).is_none());
1686 }
1687
1688 #[test]
1689 fn set_language_by_name_unknown_name_returns_unknown() {
1690 // Same contract as the path form: an unrecognised name must resolve
1691 // to Unknown (never Loading, never a panic) and leave the buffer
1692 // plain text.
1693 let buf = View::from_str("hello world");
1694 let mut layer = default_layer();
1695 assert!(
1696 !layer
1697 .set_language_by_name(TID, "definitely_not_a_real_language")
1698 .is_known()
1699 );
1700 assert!(layer.render_viewport(TID, &buf, 0, 10).is_none());
1701 }
1702
1703 #[test]
1704 fn apply_edits_with_no_language_is_noop() {
1705 let mut layer = default_layer();
1706 let edits = vec![hjkl_engine::ContentEdit {
1707 start_byte: 0,
1708 old_end_byte: 0,
1709 new_end_byte: 1,
1710 start_position: (0, 0),
1711 old_end_position: (0, 0),
1712 new_end_position: (0, 1),
1713 }];
1714 layer.apply_edits(TID, &edits);
1715 // No grammar attached → call must be a no-op (no panic).
1716 }
1717
1718 #[test]
1719 fn set_language_for_path_returns_unknown_for_unrecognized_extension() {
1720 let mut layer = default_layer();
1721 let outcome = layer.set_language_for_path(TID, Path::new("a.zzznope_not_real"));
1722 assert!(!outcome.is_known());
1723 assert!(matches!(outcome, SetLanguageOutcome::Unknown));
1724 }
1725
1726 #[test]
1727 fn poll_pending_loads_drains_ready_handles() {
1728 let mut layer = default_layer();
1729 let events = layer.poll_pending_loads();
1730 assert!(
1731 events.is_empty(),
1732 "expected no events with no pending loads"
1733 );
1734 }
1735
1736 #[test]
1737 fn forget_removes_client_state() {
1738 let mut layer = default_layer();
1739 layer.set_language_for_path(TID, Path::new("a.zzz_unknown"));
1740 layer.forget(TID);
1741 assert!(!layer.clients.contains_key(&TID));
1742 }
1743
1744 // --- Regression: fold extraction must return None when grammar not ready ---
1745 //
1746 // Before the fix (commit edbe2e99), `extract_fold_ranges` returned
1747 // `Vec::new()` for BOTH "grammar not ready" and "grammar ready but no
1748 // folds". The caller in `syntax_glue.rs::recompute_and_install` could not
1749 // distinguish the two cases, so it set `last_fold_dirty_gen = Some(dg)`
1750 // even when the grammar was still loading. When the grammar finished
1751 // loading, `dirty_gen` was unchanged → the fold-extraction condition
1752 // (`last_fold_dg != Some(dg)`) was false → folds were NEVER extracted.
1753 //
1754 // The fix changes `extract_fold_ranges` to return `Option<Vec<...>>`:
1755 // - `None` = grammar not ready yet — caller must NOT update dirty_gen,
1756 // so fold extraction retries on the next recompute.
1757 // - `Some` = grammar was ready and ran (ranges may be empty if no
1758 // folds.scm or no multi-line nodes).
1759 //
1760 // These tests exercise both branches WITHOUT requiring a downloaded grammar.
1761
1762 #[test]
1763 fn extract_fold_ranges_returns_none_when_no_language_attached() {
1764 // Simulates the "grammar still loading" or "unknown extension" state.
1765 // `extract_fold_ranges` must return `None` so the caller knows NOT to
1766 // mark the dirty_gen as processed. Before the fix this returned
1767 // `Vec::new()`, which the caller misinterpreted as "ran successfully,
1768 // no folds" → dirty_gen stamped → folds never re-tried after load.
1769 let buf =
1770 View::from_str("fn hello() {\n let x = 1;\n x\n}\n\nfn world() {\n 2\n}\n");
1771 let mut layer = default_layer();
1772 // Deliberately use an unknown extension so no grammar is attached.
1773 layer.set_language_for_path(TID, Path::new("a.zzz_no_grammar_here"));
1774 let result = layer.extract_fold_ranges(TID, &buf);
1775 assert!(
1776 result.is_none(),
1777 "extract_fold_ranges must return None when no grammar is attached \
1778 (grammar still loading or unknown extension); got {result:?}"
1779 );
1780 }
1781
1782 #[test]
1783 fn extract_fold_ranges_returns_none_when_no_client_registered() {
1784 // View ID with no prior `set_language_for_path` call — no client at all.
1785 let buf = View::from_str("fn foo() {}\n");
1786 let mut layer = default_layer();
1787 // Never called set_language_for_path for TID.
1788 let result = layer.extract_fold_ranges(TID, &buf);
1789 assert!(
1790 result.is_none(),
1791 "extract_fold_ranges must return None when buffer has no syntax client; \
1792 got {result:?}"
1793 );
1794 }
1795
1796 // --- Network-dependent tests (grammar needed) ---
1797
1798 /// Attach the grammar for `path` to `id` and return once it is usable.
1799 ///
1800 /// `set_language_for_path` may only START a load: with a warm
1801 /// `~/.cache/bonsai` the grammar is attached before the call returns, but
1802 /// on a cold machine — CI, which fetches and compiles it — the buffer has
1803 /// no grammar until a later `poll_pending_loads` completes the load, and
1804 /// until then `render_viewport` answers `None`. Rendering straight after
1805 /// `set_language_for_path` therefore passes locally and fails in the
1806 /// `grammar tests` lane, which is how these tests shipped red.
1807 ///
1808 /// A load that FAILS removes itself from the pending list and emits
1809 /// `LoadEvent::Failed`, after which nothing is in flight and no later poll
1810 /// will ever report readiness — so fail here, with the cause, instead of
1811 /// spinning to the deadline and reporting an ambiguous timeout.
1812 fn attach_grammar_when_ready(layer: &mut SyntaxLayer, id: BufferId, path: &str) {
1813 let outcome = layer.set_language_for_path(id, Path::new(path));
1814 assert!(
1815 outcome.is_known(),
1816 "no grammar is registered for {path} — the test can never succeed"
1817 );
1818 if matches!(outcome, SetLanguageOutcome::Ready) {
1819 return;
1820 }
1821 let deadline = std::time::Duration::from_secs(300);
1822 let start = std::time::Instant::now();
1823 loop {
1824 for event in layer.poll_pending_loads() {
1825 match event {
1826 LoadEvent::Failed { name, error, .. } => {
1827 panic!("grammar load for `{name}` ({path}) failed: {error}")
1828 }
1829 LoadEvent::Ready { id: ready, .. } if ready == id => return,
1830 LoadEvent::Ready { .. } => {}
1831 }
1832 }
1833 assert!(
1834 start.elapsed() < deadline,
1835 "grammar for {path} never became ready within {deadline:?}, \
1836 and no load reported a failure — it is still building, or \
1837 nothing was ever queued"
1838 );
1839 std::thread::sleep(std::time::Duration::from_millis(100));
1840 }
1841 }
1842
1843 #[test]
1844 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1845 fn parse_and_render_small_rust_buffer() {
1846 let buf = View::from_str("fn main() { let x = 1; }\n");
1847 let mut layer = default_layer();
1848 attach_grammar_when_ready(&mut layer, TID, "a.rs");
1849 let out = layer
1850 .render_viewport(TID, &buf, 0, 10)
1851 .expect("render output");
1852 assert!(
1853 out.spans.iter().any(|r| !r.is_empty()),
1854 "expected at least one styled span"
1855 );
1856 }
1857
1858 #[test]
1859 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1860 fn diagnostics_emit_sign_for_syntax_error() {
1861 let buf = View::from_str("fn main() {\nlet x = ;\n}\n");
1862 let mut layer = default_layer();
1863 attach_grammar_when_ready(&mut layer, TID, "a.rs");
1864 let out = layer.render_viewport(TID, &buf, 0, 10).unwrap();
1865 assert!(
1866 !out.signs.is_empty(),
1867 "expected at least one diagnostic sign for `let x = ;`"
1868 );
1869 assert!(
1870 out.signs.iter().any(|s| s.row == 1 && s.ch == 'E'),
1871 "expected an 'E' sign on row 1; got {:?}",
1872 out.signs
1873 );
1874 }
1875
1876 #[test]
1877 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1878 fn diagnostics_signs_correct_when_scrolled() {
1879 // Regression: `collect_diag_signs_range` used to pass a
1880 // window-relative byte range + window-only source to
1881 // `parse_errors_range`, which filters tree nodes by absolute
1882 // offsets — so once scrolled it reported errors from the top of
1883 // the document and shifted the resulting rows by the window
1884 // offset. The error below sits on rows 50–52; the viewport starts
1885 // at row 45.
1886 let mut src = String::new();
1887 for i in 0..50 {
1888 let _ = writeln!(src, "fn f{i}() {{}}");
1889 }
1890 src.push_str("fn broken() {\nlet x = ;\n}\n");
1891 let buf = View::from_str(&src);
1892 let mut layer = default_layer();
1893 attach_grammar_when_ready(&mut layer, TID, "a.rs");
1894 let out = layer.render_viewport(TID, &buf, 45, 20).unwrap();
1895 assert!(
1896 out.signs
1897 .iter()
1898 .any(|s| (50..=52).contains(&s.row) && s.ch == 'E'),
1899 "expected an 'E' sign on rows 50..=52; got {:?}",
1900 out.signs
1901 );
1902 }
1903
1904 /// Regression: `apply_edits` used to clear the parse, row-start and sign
1905 /// caches but leave `cache_spans`, relying on the buffer's `dirty_gen`
1906 /// having moved by the time `render_viewport` next ran. Both buffers here
1907 /// are freshly constructed and so share a `dirty_gen`, which is what makes
1908 /// the omission visible: the incremental render returned the PRE-edit span
1909 /// table, one byte short across the board and without the `@type` capture
1910 /// that `Ymain`'s new capital earns.
1911 #[test]
1912 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
1913 fn incremental_path_matches_cold_for_small_edit() {
1914 let pre = View::from_str("fn main() { let x = 1; }");
1915 let mut layer = default_layer();
1916 attach_grammar_when_ready(&mut layer, TID, "a.rs");
1917 let _ = layer.render_viewport(TID, &pre, 0, 10).unwrap();
1918 layer.apply_edits(
1919 TID,
1920 &[hjkl_engine::ContentEdit {
1921 start_byte: 3,
1922 old_end_byte: 3,
1923 new_end_byte: 4,
1924 start_position: (0, 3),
1925 old_end_position: (0, 3),
1926 new_end_position: (0, 4),
1927 }],
1928 );
1929 let post = View::from_str("fn Ymain() { let x = 1; }");
1930 let inc = layer.render_viewport(TID, &post, 0, 10).unwrap();
1931 let mut cold_layer = default_layer();
1932 attach_grammar_when_ready(&mut cold_layer, TID, "a.rs");
1933 let cold = cold_layer.render_viewport(TID, &post, 0, 10).unwrap();
1934 assert_eq!(inc.spans, cold.spans);
1935 }
1936
1937 /// Fold ranges for `buf`, waiting for the grammar to become available.
1938 ///
1939 /// `set_language_for_path` may only START a grammar load: on a machine with
1940 /// a warm `~/.cache/bonsai` the grammar is ready by the first
1941 /// `render_viewport`, but on a cold one — CI, which fetches and compiles it
1942 /// — `extract_fold_ranges` answers `None` for as long as the load is in
1943 /// flight. A single call plus `.expect("grammar ready")` therefore passes
1944 /// locally and panics in the `grammar tests` lane, which is exactly how
1945 /// these tests shipped red.
1946 ///
1947 /// Polls `poll_pending_loads` until extraction answers, then returns the
1948 /// ranges.
1949 ///
1950 /// The events `poll_pending_loads` returns are the whole point of reading
1951 /// them rather than discarding them: a load that *fails* removes itself
1952 /// from the pending list and emits `LoadEvent::Failed`, after which
1953 /// nothing is in flight and `extract_fold_ranges` answers `None`
1954 /// forever. Dropping the events made a hard failure look exactly like
1955 /// slow progress — the first version of this helper spun to a 300s
1956 /// deadline and reported "the load either failed or is not being polled",
1957 /// which is a confession that it could not tell, and it threw away the
1958 /// error text that says which. Fail on `Failed`, with the cause.
1959 fn fold_ranges_when_ready(
1960 layer: &mut SyntaxLayer,
1961 buf: &View,
1962 path: &str,
1963 rows: usize,
1964 ) -> Vec<(usize, usize)> {
1965 fold_ranges_when_ready_for(layer, TID, buf, path, rows)
1966 }
1967
1968 /// [`fold_ranges_when_ready`] against an explicit buffer id, for a test
1969 /// that needs the grammar warmed WITHOUT touching the client state of the
1970 /// id it measures — the injected-fold memo is per buffer client, so
1971 /// warming on the same id would leave nothing for a cold pass to parse.
1972 fn fold_ranges_when_ready_for(
1973 layer: &mut SyntaxLayer,
1974 id: BufferId,
1975 buf: &View,
1976 path: &str,
1977 rows: usize,
1978 ) -> Vec<(usize, usize)> {
1979 attach_grammar_when_ready(layer, id, path);
1980 // The host grammar is attached; injected grammars (a fenced ```rust
1981 // block inside markdown) load on their own and surface only as
1982 // extraction answering `None`, so keep polling and re-rendering until
1983 // it answers.
1984 let deadline = std::time::Duration::from_secs(300);
1985 let start = std::time::Instant::now();
1986 loop {
1987 for event in layer.poll_pending_loads() {
1988 if let LoadEvent::Failed { name, error, .. } = event {
1989 panic!("grammar load for `{name}` ({path}) failed: {error}");
1990 }
1991 }
1992 let _ = layer.render_viewport(id, buf, 0, rows);
1993 if let Some(ranges) = layer.extract_fold_ranges(id, buf) {
1994 return ranges;
1995 }
1996 assert!(
1997 start.elapsed() < deadline,
1998 "grammar for {path} never became ready within {deadline:?}, \
1999 and no load reported a failure — it is still building, or \
2000 nothing was ever queued"
2001 );
2002 std::thread::sleep(std::time::Duration::from_millis(100));
2003 }
2004 }
2005
2006 /// Markdown fold ranges, pinned against neovim's treesitter folds for the
2007 /// same document (`vim.treesitter.foldexpr`, folds enumerated with
2008 /// `foldclosed`/`foldclosedend`). Every range below was produced by nvim
2009 /// on this exact text.
2010 ///
2011 /// Before the fix hjkl returned `4..14`, `14..23`, `16..19`, `19..23`,
2012 /// `23..26` and `8..11` — each one row or more too long, so a closed
2013 /// section hid the NEXT section's heading and the last fold ran past the
2014 /// end of the buffer.
2015 #[test]
2016 #[ignore = "network + compiler: fetches the markdown grammar"]
2017 fn markdown_fold_ranges_match_neovim() {
2018 let src = concat!(
2019 "# Title\n\nIntro paragraph.\n\n",
2020 "## Section A\n\nText in A.\n\n```bash\nls -la\n```\n\nMore A text.\n\n",
2021 "## Section B\n\n- item one\n- item two\n\n",
2022 "### Nested B1\n\nDeep text.\n\n",
2023 "## Section C\n\nLast.\n",
2024 );
2025 let buf = View::from_str(src);
2026 let mut layer = default_layer();
2027 let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.md", 40);
2028 assert_eq!(
2029 ranges,
2030 vec![
2031 (0, 25), // # Title — to the last line, not one past it
2032 (4, 12), // ## Section A — ends on "More A text.", not on "## Section B"
2033 (8, 10), // the fenced block — ends on its closing fence
2034 (14, 21), // ## Section B
2035 (16, 17), // the list
2036 (19, 21), // ### Nested B1
2037 (23, 25), // ## Section C
2038 ]
2039 );
2040 }
2041
2042 // ── Folds inside injected languages ─────────────────────────────────
2043 //
2044 // The fixtures below are the ones the 2026-08-03 injection work was
2045 // measured on. neovim's side was enumerated the same way as every other
2046 // fold test here (`vim.treesitter.foldexpr`, `foldclosed`/`foldclosedend`
2047 // per `foldlevel`), with one addition that is easy to get wrong: a
2048 // headless nvim parses injections LAZILY, so the run must force
2049 // `vim.treesitter.get_parser(0):parse(true)` before enumerating. Without
2050 // it nvim reports only the host language's folds (152 instead of 173 on
2051 // `.github/workflows/ci.yml`) and reads as agreement.
2052
2053 /// A markdown document with ` ```rust ` and ` ```bash ` blocks.
2054 ///
2055 /// nvim on this fixture: `0,20` `4,10` `5,9` `6,8` `12,20` `14,18`
2056 /// `15,17` — identical to the assertion below.
2057 ///
2058 /// Three of those seven are inside the fenced blocks and come from the
2059 /// INJECTED grammars: `(5, 9)` is `fn main()`, `(6, 8)` its `if`, and
2060 /// `(15, 17)` the bash `for … done`. Before injected folds hjkl emitted
2061 /// only the other four (`(0, 20)`, `(4, 10)`, `(12, 20)`, `(14, 18)`).
2062 ///
2063 /// The rows are also the proof that the region → host row offset is
2064 /// applied: the rust block's own tree puts those folds at rows 0..4 and
2065 /// 1..3, and the bash block's at 0..2. Drop the offset and the expected
2066 /// vector below cannot be produced.
2067 #[test]
2068 #[ignore = "network + compiler: fetches the markdown, rust and bash grammars"]
2069 fn markdown_injected_fold_ranges_match_neovim() {
2070 let src = concat!(
2071 "# Title\n\nIntro text.\n\n",
2072 "```rust\nfn main() {\n if true {\n println!(\"hi\");\n }\n}\n```\n\n",
2073 "## Shell\n\n",
2074 "```bash\nfor f in a b; do\n echo \"$f\"\ndone\n```\n\n",
2075 "Trailing text.\n",
2076 );
2077 let buf = View::from_str(src);
2078 let mut layer = default_layer();
2079 let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.md", 40);
2080 assert_eq!(
2081 ranges,
2082 vec![
2083 (0, 20), // # Title
2084 (4, 10), // the ```rust fence
2085 (5, 9), // injected: fn main()
2086 (6, 8), // injected: the if
2087 (12, 20), // ## Shell
2088 (14, 18), // the ```bash fence
2089 (15, 17), // injected: for … done
2090 ]
2091 );
2092 }
2093
2094 /// Injected folds must follow their region when rows move above it.
2095 ///
2096 /// The memo behind injected folds (`InjectedFoldCache`) is keyed by the
2097 /// region's CONTENT, and stores region-relative rows. Inserting a line at
2098 /// the top of the document leaves every region's bytes identical — so the
2099 /// memo hits — while every host row shifts by one. If the memo stored host
2100 /// rows, or the offset were applied before storing instead of after
2101 /// reading, the second extraction below would return the FIRST document's
2102 /// rows for the injected folds and only the host's would move.
2103 #[test]
2104 #[ignore = "network + compiler: fetches the markdown, rust and bash grammars"]
2105 fn injected_folds_shift_with_the_region_on_a_memo_hit() {
2106 let body = concat!(
2107 "# Title\n\nIntro text.\n\n",
2108 "```rust\nfn main() {\n if true {\n println!(\"hi\");\n }\n}\n```\n\n",
2109 "## Shell\n\n",
2110 "```bash\nfor f in a b; do\n echo \"$f\"\ndone\n```\n\n",
2111 "Trailing text.\n",
2112 );
2113 let mut layer = default_layer();
2114 let before = View::from_str(body);
2115 // Warm the markdown/rust/bash grammars on a DIFFERENT buffer id: on a
2116 // cold cache (CI) the first extraction answers None until the loads
2117 // finish, but warming on TID would also fill TID's injected-fold memo
2118 // and leave the "cold" count below at zero.
2119 const WARM: BufferId = TID + 1;
2120 let _ = fold_ranges_when_ready_for(&mut layer, WARM, &before, "a.md", 40);
2121 layer.forget(WARM);
2122
2123 layer.set_language_for_path(TID, Path::new("a.md"));
2124 let _ = layer.render_viewport(TID, &before, 0, 40);
2125 hjkl_bonsai::injected_parse_counter::reset();
2126 let first = layer
2127 .extract_fold_ranges(TID, &before)
2128 .expect("grammar ready after the wait above");
2129 assert_eq!(
2130 hjkl_bonsai::injected_parse_counter::get(),
2131 2,
2132 "cold extraction must parse both injected regions (rust + bash)"
2133 );
2134
2135 // Same document with one extra line on top. Same buffer id, same
2136 // layer — the injected-region memo is live and every region's content
2137 // is byte-identical, so this must be the memo-hit path.
2138 let shifted = View::from_str(&format!("Added line.\n{body}"));
2139 layer.reset(TID);
2140 let _ = layer.render_viewport(TID, &shifted, 0, 40);
2141 hjkl_bonsai::injected_parse_counter::reset();
2142 let second = layer
2143 .extract_fold_ranges(TID, &shifted)
2144 .expect("grammar ready after the wait above");
2145 assert_eq!(
2146 hjkl_bonsai::injected_parse_counter::get(),
2147 0,
2148 "moved-but-unchanged regions must come from the memo, not a reparse"
2149 );
2150
2151 let expected: Vec<(usize, usize)> = first.iter().map(|&(s, e)| (s + 1, e + 1)).collect();
2152 assert_eq!(
2153 second, expected,
2154 "every fold, injected ones included, must move down exactly one row"
2155 );
2156 }
2157
2158 /// An HTML page with a `<style>` and a `<script>` block.
2159 ///
2160 /// nvim on this fixture: `1,23` `2,9` `3,8` `4,7` `10,22` `11,13`
2161 /// `14,21` `15,20` `16,18` — identical to the assertion below.
2162 ///
2163 /// `(4, 7)` is the CSS rule inside `<style>`; `(15, 20)` and `(16, 18)`
2164 /// are the JS function and its `if` inside `<script>`. hjkl emitted none
2165 /// of the three before injected folds.
2166 #[test]
2167 #[ignore = "network + compiler: fetches the html, css and javascript grammars"]
2168 fn html_injected_fold_ranges_match_neovim() {
2169 let src = concat!(
2170 "<!DOCTYPE html>\n<html>\n <head>\n",
2171 " <style>\n body {\n color: red;\n margin: 0;\n }\n",
2172 " </style>\n </head>\n <body>\n",
2173 " <div>\n <p>hi</p>\n </div>\n",
2174 " <script>\n function go(x) {\n if (x) {\n",
2175 " return 1;\n }\n return 0;\n }\n",
2176 " </script>\n </body>\n</html>\n",
2177 );
2178 let buf = View::from_str(src);
2179 let mut layer = default_layer();
2180 let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.html", 40);
2181 assert_eq!(
2182 ranges,
2183 vec![
2184 (1, 23), // <html>
2185 (2, 9), // <head>
2186 (3, 8), // <style>
2187 (4, 7), // injected css: the body rule
2188 (10, 22), // <body>
2189 (11, 13), // <div>
2190 (14, 21), // <script>
2191 (15, 20), // injected js: function go()
2192 (16, 18), // injected js: the if
2193 ]
2194 );
2195 }
2196
2197 /// YAML folds are anchored on the pair / sequence item, matching neovim.
2198 /// Capturing `(block_mapping)` instead put the fold on the container,
2199 /// which starts at its FIRST CHILD: `top:` never got a fold of its own,
2200 /// and the document-level mapping produced one fold starting at the first
2201 /// key that swallowed every sibling below it (`0..7` here).
2202 #[test]
2203 #[ignore = "network + compiler: fetches the yaml grammar"]
2204 fn yaml_fold_ranges_match_neovim() {
2205 let src = "top:\n a: 1\n b:\n - one\n - two\n\nother:\n c: 3\n";
2206 let buf = View::from_str(src);
2207 let mut layer = default_layer();
2208 let ranges = fold_ranges_when_ready(&mut layer, &buf, "a.yaml", 40);
2209 assert_eq!(ranges, vec![(0, 4), (2, 4), (6, 7)]);
2210 }
2211
2212 // ── Fold ranges pinned against neovim, per language ──────────────────
2213 //
2214 // Every expectation below was measured on neovim 0.12.4 with
2215 // `vim.treesitter.foldexpr()` over these exact fixtures, enumerating the
2216 // real folds with `foldclosed`/`foldclosedend` at each `foldlevel` (a
2217 // `foldlevel()` array merges adjacent siblings into one run and reads as
2218 // a false difference). Rows are 0-based inclusive, like hjkl's.
2219 //
2220 // Where hjkl emits ranges neovim does not, it is because hjkl's bundled
2221 // query captures a different SET of node types on purpose — the comment
2222 // on each test says which. The ranges themselves agree everywhere.
2223
2224 /// Extract hjkl's fold ranges for `src`, using `name` only to pick the
2225 /// grammar by extension.
2226 fn folds_for(name: &str, src: &str) -> Vec<(usize, usize)> {
2227 let buf = View::from_str(src);
2228 let mut layer = default_layer();
2229 fold_ranges_when_ready(&mut layer, &buf, name, 80)
2230 }
2231
2232 /// The lua fixture the `foldlevelstart` level rule is pinned on, run end to
2233 /// end: ranges from a REAL tree, then through the wiring that turns them
2234 /// into open/closed folds.
2235 ///
2236 /// `hjkl_buffer::folds`'s
2237 /// `set_auto_folds_closes_folds_deeper_than_foldlevelstart` asserts the same
2238 /// closed sets, but from a `NVIM_LUA_RANGES` constant typed into the test —
2239 /// so the LEVEL rule was covered while nothing checked that a live grammar
2240 /// and `folds/lua.scm` actually produce those ranges. The other end-to-end
2241 /// test, `auto_fold_pass_applies_foldlevelstart_as_a_level` in `syntax_glue`,
2242 /// uses `foldmethod=marker` so it can run without grammars. This is the only
2243 /// pass that runs the real query at a non-zero `foldlevelstart`.
2244 ///
2245 /// Measured on neovim 0.12.5 over this exact fixture with
2246 /// `nvim --headless -u NONE --cmd 'filetype on' --cmd 'set foldmethod=expr
2247 /// foldexpr=v:lua.vim.treesitter.foldexpr() foldenable foldlevelstart=N'`,
2248 /// enumerating closed folds with `foldclosed`/`foldclosedend` — rescanning
2249 /// after `zo` on each hit, because `foldclosed` reports only the OUTERMOST
2250 /// closed fold covering a line and the nested ones are invisible to a single
2251 /// sweep:
2252 ///
2253 /// ```text
2254 /// fls=0 closed=[(2, 16), (3, 14), (4, 7), (10, 13), (18, 23), (19, 21)]
2255 /// fls=1 closed=[(3, 14), (4, 7), (10, 13), (19, 21)]
2256 /// fls=2 closed=[(4, 7), (10, 13)]
2257 /// fls=3 closed=[]
2258 /// fls=99 closed=[]
2259 /// ```
2260 ///
2261 /// `-u NONE` keeps the measurement off the local plugin set; nvim's bundled
2262 /// `queries/lua/folds.scm` is byte-identical to nvim-treesitter's, which the
2263 /// other fixtures here were measured against.
2264 ///
2265 /// `local M = {}` on row 0 is a single-row `(table_constructor)`: neovim
2266 /// folds it no more than `set_auto_folds` does.
2267 #[test]
2268 #[ignore = "network + compiler: fetches the lua grammar"]
2269 fn lua_fold_ranges_and_foldlevelstart_match_neovim() {
2270 let src = concat!(
2271 "local M = {}\n\n",
2272 "function M.outer(a, b)\n",
2273 " if a > b then\n",
2274 " local t = {\n x = 1,\n y = 2,\n }\n",
2275 " print(t.x)\n",
2276 " -- count down\n",
2277 " for i = 1, 10 do\n print(i)\n print(i * 2)\n end\n",
2278 " end\n",
2279 " return a\n",
2280 "end\n\n",
2281 "function M.second(c)\n",
2282 " while c > 0 do\n c = c - 1\n end\n",
2283 " return c\n",
2284 "end\n\n",
2285 "return M\n",
2286 );
2287 let ranges = folds_for("a.lua", src);
2288 assert_eq!(
2289 ranges,
2290 vec![(2, 16), (3, 14), (4, 7), (10, 13), (18, 23), (19, 21)],
2291 "the live lua tree must produce the ranges the level rule is pinned on"
2292 );
2293
2294 // Closed auto folds after one `set_auto_folds` pass at `fls` — the same
2295 // call `View`'s auto-fold pass makes with the ranges above.
2296 let closed_at = |fls: u32| -> Vec<(usize, usize)> {
2297 let mut buf = View::from_str(src);
2298 buf.set_auto_folds(&ranges, fls);
2299 buf.folds()
2300 .iter()
2301 .filter(|f| f.closed)
2302 .map(|f| (f.start_row, f.end_row))
2303 .collect()
2304 };
2305
2306 assert_eq!(
2307 closed_at(0),
2308 vec![(2, 16), (3, 14), (4, 7), (10, 13), (18, 23), (19, 21)],
2309 "foldlevelstart=0 must close every fold the grammar found"
2310 );
2311 assert_eq!(
2312 closed_at(1),
2313 vec![(3, 14), (4, 7), (10, 13), (19, 21)],
2314 "foldlevelstart=1 must leave the two functions open and close what is inside"
2315 );
2316 assert_eq!(
2317 closed_at(2),
2318 vec![(4, 7), (10, 13)],
2319 "foldlevelstart=2 must close only the level-3 table and for-loop"
2320 );
2321 assert!(
2322 closed_at(3).is_empty(),
2323 "foldlevelstart=3 must leave this 3-level nesting fully open"
2324 );
2325 assert!(
2326 closed_at(99).is_empty(),
2327 "foldlevelstart=99 (hjkl's default) must open everything"
2328 );
2329 }
2330
2331 /// nvim on this fixture: `(2, 4)`, `(6, 10)`, `(7, 9)` — identical.
2332 #[test]
2333 #[ignore = "network + compiler: fetches the go grammar"]
2334 fn go_fold_ranges_match_neovim() {
2335 let src = concat!(
2336 "package main\n\n",
2337 "import (\n \"fmt\"\n)\n\n",
2338 "func main() {\n if true {\n fmt.Println(\"x\")\n }\n}\n",
2339 );
2340 assert_eq!(folds_for("a.go", src), vec![(2, 4), (6, 10), (7, 9)]);
2341 }
2342
2343 /// nvim on this fixture: `(0, 2)`, `(4, 9)`, `(5, 7)` — identical.
2344 #[test]
2345 #[ignore = "network + compiler: fetches the c grammar"]
2346 fn c_fold_ranges_match_neovim() {
2347 let src = concat!(
2348 "struct P {\n int x;\n};\n\n",
2349 "int main(void) {\n for (int i = 0; i < 2; i++) {\n i++;\n }\n return 0;\n}\n",
2350 );
2351 assert_eq!(folds_for("a.c", src), vec![(0, 2), (4, 9), (5, 7)]);
2352 }
2353
2354 /// nvim on this fixture: `(0, 9)`, `(2, 7)`, `(4, 6)` — identical.
2355 #[test]
2356 #[ignore = "network + compiler: fetches the cpp grammar"]
2357 fn cpp_fold_ranges_match_neovim() {
2358 let src = concat!(
2359 "namespace n {\n\n",
2360 "class B {\npublic:\n int get() {\n return 1;\n }\n};\n\n",
2361 "}\n",
2362 );
2363 assert_eq!(folds_for("a.cpp", src), vec![(0, 9), (2, 7), (4, 6)]);
2364 }
2365
2366 /// nvim on this fixture: `(0, 7)`, `(1, 5)`, `(3, 5)` — identical.
2367 ///
2368 /// Regression: `folds/cpp.scm` used to capture neither `(try_statement)`
2369 /// nor `(catch_clause)`, so the only fold anchored on `try {` was the try
2370 /// body's `(compound_statement)` — `(1, 3)` here, ending at the `}` that
2371 /// opens the catch instead of at the end of the whole statement. The
2372 /// catch clause got no fold of its own at all.
2373 #[test]
2374 #[ignore = "network + compiler: fetches the cpp grammar"]
2375 fn cpp_try_catch_fold_ranges_match_neovim() {
2376 let src = concat!(
2377 "int main() {\n",
2378 " try {\n f();\n",
2379 " } catch (int e) {\n g();\n }\n",
2380 " return 0;\n}\n",
2381 );
2382 assert_eq!(folds_for("a.cpp", src), vec![(0, 7), (1, 5), (3, 5)]);
2383 }
2384
2385 /// nvim on this fixture: `(0, 7)`, `(1, 6)`, `(2, 4)` — identical.
2386 ///
2387 /// The anchors agree only because the braces are K&R. neovim folds Java's
2388 /// `(class_body)` / `(block)`, which start ON the `{`; hjkl folds
2389 /// `(class_declaration)` / `(method_declaration)`, which start on the
2390 /// signature — and, when annotations precede it, on the FIRST annotation.
2391 /// See `docs/backlog.md` §1.4b.
2392 #[test]
2393 #[ignore = "network + compiler: fetches the java grammar"]
2394 fn java_fold_ranges_match_neovim() {
2395 let src = concat!(
2396 "public class A {\n",
2397 " public int run(int x) {\n",
2398 " if (x > 0) {\n return x;\n }\n",
2399 " return 0;\n }\n}\n",
2400 );
2401 assert_eq!(folds_for("A.java", src), vec![(0, 7), (1, 6), (2, 4)]);
2402 }
2403
2404 /// nvim on this fixture: `(2, 8)`, `(4, 7)`. hjkl adds `(5, 7)` — the
2405 /// Allman-braced `(compound_statement)` body, which neovim's PHP query
2406 /// does not capture at all. The two shared ranges are identical.
2407 #[test]
2408 #[ignore = "network + compiler: fetches the php grammar"]
2409 fn php_fold_ranges_match_neovim() {
2410 let src = concat!(
2411 "<?php\n\n",
2412 "class R\n{\n",
2413 " public function area(): float\n {\n return 1.0;\n }\n}\n",
2414 );
2415 assert_eq!(folds_for("a.php", src), vec![(2, 8), (4, 7), (5, 7)]);
2416 }
2417
2418 /// nvim on this fixture: `(0, 6)`, `(1, 5)`, `(2, 4)` — identical.
2419 #[test]
2420 #[ignore = "network + compiler: fetches the ruby grammar"]
2421 fn ruby_fold_ranges_match_neovim() {
2422 let src = "module M\n class R\n def area\n 1\n end\n end\nend\n";
2423 assert_eq!(folds_for("a.rb", src), vec![(0, 6), (1, 5), (2, 4)]);
2424 }
2425
2426 /// Regression: C# used to fold NOTHING.
2427 ///
2428 /// `bonsai.toml` has both `[language.c-sharp]` and `[language.c_sharp]`
2429 /// for the same grammar, and `GrammarRegistry` resolves an extension to
2430 /// the alphabetically first entry — so a `.cs` buffer loads under the name
2431 /// `c-sharp`, while `builtin_folds` was keyed only on `c_sharp` and
2432 /// returned `None`. This assertion was `[]` before the fix, against
2433 /// neovim's four folds below.
2434 ///
2435 /// nvim on this fixture: `(1, 13)`, `(3, 12)`, `(5, 11)`, `(7, 9)` — it
2436 /// anchors on the Allman `{` because its C# query captures
2437 /// `body: (declaration_list)` and `(block)`. hjkl also captures the
2438 /// declaration nodes, so it anchors one row earlier on the `namespace` /
2439 /// `class` / method signature and keeps neovim's brace-anchored ranges
2440 /// too. Every neovim range appears here.
2441 #[test]
2442 #[ignore = "network + compiler: fetches the c-sharp grammar"]
2443 fn c_sharp_fold_ranges_match_neovim() {
2444 let src = concat!(
2445 "namespace Demo\n{\n",
2446 " public class R\n {\n",
2447 " public int Area()\n {\n",
2448 " if (true)\n {\n return 1;\n }\n",
2449 " return 0;\n }\n }\n}\n",
2450 );
2451 assert_eq!(
2452 folds_for("P.cs", src),
2453 vec![(0, 13), (2, 12), (4, 11), (5, 11), (6, 9), (7, 9)]
2454 );
2455 }
2456
2457 /// nvim on this fixture: `(0, 4)`, `(1, 3)`, `(6, 11)`, `(7, 9)` —
2458 /// identical.
2459 #[test]
2460 #[ignore = "network + compiler: fetches the javascript grammar"]
2461 fn javascript_fold_ranges_match_neovim() {
2462 let src = concat!(
2463 "class R {\n area() {\n return 1;\n }\n}\n\n",
2464 "function run(xs) {\n const t = {\n a: 1,\n };\n return t;\n}\n",
2465 );
2466 assert_eq!(
2467 folds_for("a.js", src),
2468 vec![(0, 4), (1, 3), (6, 11), (7, 9)]
2469 );
2470 }
2471
2472 /// nvim on this fixture: `(0, 2)`, `(4, 6)`, `(8, 10)`, `(12, 14)` —
2473 /// identical. Covers the TypeScript-only nodes: `(interface_declaration)`,
2474 /// `(type_alias_declaration)` with an `(object_type)` body, and
2475 /// `(enum_declaration)`.
2476 #[test]
2477 #[ignore = "network + compiler: fetches the typescript grammar"]
2478 fn typescript_fold_ranges_match_neovim() {
2479 let src = concat!(
2480 "interface S {\n area(): number;\n}\n\n",
2481 "type H = {\n name: string;\n};\n\n",
2482 "enum C {\n Red,\n}\n\n",
2483 "function run(): number {\n return 1;\n}\n",
2484 );
2485 assert_eq!(
2486 folds_for("a.ts", src),
2487 vec![(0, 2), (4, 6), (8, 10), (12, 14)]
2488 );
2489 }
2490
2491 #[test]
2492 #[ignore = "network + compiler: needs tree-sitter-rust grammar"]
2493 fn forget_drops_buffer_state() {
2494 let buf = View::from_str("fn main() {}");
2495 let mut layer = default_layer();
2496 attach_grammar_when_ready(&mut layer, TID, "a.rs");
2497 let _ = layer.render_viewport(TID, &buf, 0, 10).unwrap();
2498 assert!(layer.clients.contains_key(&TID));
2499 layer.forget(TID);
2500 assert!(!layer.clients.contains_key(&TID));
2501 }
2502}