Skip to main content

oo_ide/editor/
highlight.rs

1//! Checkpointed syntax highlighting via syntect.
2//!
3//! Optimized for ratatui rendering with:
4//! - ParseState checkpoints every N lines (default: 100)
5//! - Per-line token caching with version tracking
6//! - Incremental highlighting: resume from nearest checkpoint
7//! - Async background computation: render uses stale cache while a
8//!   `spawn_blocking` task re-highlights the visible window, delivering
9//!   results via `Operation::SetEditorHighlights`.
10
11use std::cell::RefCell;
12use std::collections::BTreeSet;
13use lru::LruCache;
14use std::num::NonZeroUsize;
15use std::ops::Range;
16use std::path::Path;
17use std::sync::Arc;
18
19use syntect::{
20    easy::HighlightLines,
21    highlighting::{FontStyle, HighlightState, Highlighter, Style, ThemeSet},
22    parsing::{ParseState, SyntaxReference, SyntaxSet},
23};
24
25use once_cell::sync::Lazy;
26
27use crate::editor::buffer::Version;
28
29static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_nonewlines);
30static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
31
32pub const DEFAULT_CHECKPOINT_INTERVAL: usize = 100;
33
34#[derive(Debug, Clone, PartialEq)]
35pub struct Token {
36    pub range: Range<usize>,
37    pub style: Style,
38}
39
40impl Token {
41    pub fn new(start: usize, end: usize, style: Style) -> Self {
42        Self {
43            range: start..end,
44            style,
45        }
46    }
47}
48
49#[derive(Debug, Clone)]
50pub struct StyledSpan {
51    /// Byte range within the original line for this span.
52    pub range: Range<usize>,
53    pub style: Style,
54}
55
56#[derive(Debug, Clone)]
57struct Checkpoint {
58    line: usize,
59    highlight_state: HighlightState,
60    parse_state: ParseState,
61}
62
63#[derive(Debug, Clone)]
64struct CachedLine {
65    version: Version,
66    tokens: Arc<Vec<Token>>,
67}
68
69/// Sendable snapshot of a [`SyntaxHighlighter`]'s parser state.
70///
71/// Used to hand off checkpointing data to a `spawn_blocking` task without
72/// moving the (non-`Send`) `SyntaxHighlighter` itself across thread boundaries.
73///
74/// In syntect ≥ 5, `ParseState` and `HighlightState` contain only owned data
75/// (`Vec`, `String`, primitives) and auto-implement `Send`.  The compile-time
76/// assertion below ensures this invariant holds; if a future syntect version
77/// breaks it, the build will fail rather than introducing UB.
78#[derive(Clone)]
79pub struct HighlighterState {
80    pub theme_name: String,
81    pub syntax_name: String,
82    pub checkpoint_interval: usize,
83    checkpoints: Vec<Checkpoint>,
84}
85
86// Compile-time assertion: Checkpoint (and therefore HighlighterState) is Send.
87// This replaces the previous `unsafe impl Send` with a sound, checked guarantee.
88const _: () = {
89    fn _assert_send<T: Send>() {}
90    fn _check() {
91        _assert_send::<HighlightState>();
92        _assert_send::<ParseState>();
93        _assert_send::<Checkpoint>();
94        _assert_send::<HighlighterState>();
95    }
96};
97
98
99#[derive(Debug)]
100pub struct SyntaxHighlighter {
101    pub theme_name: String,
102    pub syntax_name: String,
103    checkpoint_interval: usize,
104    checkpoints: RefCell<Vec<Checkpoint>>,
105    /// Per-line token cache keyed by line index.  O(1) insert/lookup; no
106    /// full-Vec clone on every write (unlike the previous ArcSwap<Vec<…>>).
107    /// Uses an LRU cache to evict least-recently-used lines rather than
108    /// evicting by hard line-number ranges; this improves behavior on random
109    /// access (go-to-definition, jumping).
110    line_cache: RefCell<LruCache<usize, CachedLine>>,
111    /// Sorted index of line numbers present in `line_cache`.  Allows
112    /// `invalidate_from` to find affected entries via `range(from..)` in
113    /// O(k log n) instead of scanning all 10 000 LRU entries.
114    cache_line_index: RefCell<BTreeSet<usize>>,
115}
116
117impl Default for SyntaxHighlighter {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123impl Clone for SyntaxHighlighter {
124    fn clone(&self) -> Self {
125        Self {
126            theme_name: self.theme_name.clone(),
127            syntax_name: self.syntax_name.clone(),
128            checkpoint_interval: self.checkpoint_interval,
129            checkpoints: RefCell::new(self.checkpoints.borrow().clone()),
130            line_cache: RefCell::new(LruCache::new(NonZeroUsize::new(10_000).unwrap())),
131            cache_line_index: RefCell::new(BTreeSet::new()),
132        }
133    }
134}
135
136impl SyntaxHighlighter {
137    pub fn new() -> Self {
138        Self {
139            theme_name: "base16-ocean.dark".to_string(),
140            syntax_name: "Plain Text".to_string(),
141            checkpoint_interval: DEFAULT_CHECKPOINT_INTERVAL,
142            checkpoints: RefCell::new(Vec::new()),
143            line_cache: RefCell::new(LruCache::new(NonZeroUsize::new(10_000).unwrap())),
144            cache_line_index: RefCell::new(BTreeSet::new()),
145        }
146    }
147
148    pub fn for_path(path: &Path) -> Self {
149        let syntax_name = SYNTAX_SET
150            .find_syntax_for_file(path)
151            .ok()
152            .flatten()
153            .map(|s| s.name.clone())
154            .unwrap_or_else(|| "Plain Text".to_string());
155
156        Self::new().with_syntax(&syntax_name)
157    }
158
159    pub fn plain() -> Self {
160        Self::new()
161    }
162
163    pub fn with_theme(mut self, theme_name: &str) -> Self {
164        self.theme_name = theme_name.to_string();
165        self
166    }
167
168    pub fn with_syntax(mut self, syntax_name: &str) -> Self {
169        self.syntax_name = syntax_name.to_string();
170        self
171    }
172
173    /// Extract a [`HighlighterState`] snapshot of the highlighter's configuration
174    /// and accumulated checkpoints.
175    ///
176    /// IMPORTANT: This snapshot contains `ParseState` / `HighlightState` values
177    /// from `syntect` which are not guaranteed to be thread-safe. Do NOT send
178    /// the resulting `HighlighterState` across threads. For background-workers
179    /// use the theme/syntax names and the buffer lines to reconstruct a
180    /// `SyntaxHighlighter` inside the worker (see app.rs worker changes).
181    pub fn snapshot_state(&self) -> HighlighterState {
182        HighlighterState {
183            theme_name: self.theme_name.clone(),
184            syntax_name: self.syntax_name.clone(),
185            checkpoint_interval: self.checkpoint_interval,
186            checkpoints: self.checkpoints.borrow().clone(),
187        }
188    }
189
190    /// Reconstruct a [`SyntaxHighlighter`] from a previously snapshotted state.
191    ///
192    /// The resulting highlighter has an empty per-line cache but inherits all
193    /// accumulated checkpoints, so highlighting resumes cheaply.
194    pub fn from_state(state: HighlighterState) -> Self {
195        Self {
196            theme_name: state.theme_name,
197            syntax_name: state.syntax_name,
198            checkpoint_interval: state.checkpoint_interval,
199            checkpoints: RefCell::new(state.checkpoints),
200            line_cache: RefCell::new(LruCache::new(NonZeroUsize::new(10_000).unwrap())),
201            cache_line_index: RefCell::new(BTreeSet::new()),
202        }
203    }
204
205    fn get_syntax(&self) -> &SyntaxReference {
206        SYNTAX_SET
207            .find_syntax_by_name(&self.syntax_name)
208            .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text())
209    }
210
211    pub fn clear_cache(&self) {
212        self.checkpoints.borrow_mut().clear();
213        self.line_cache.borrow_mut().clear();
214        self.cache_line_index.borrow_mut().clear();
215    }
216
217    pub fn invalidate_from(&self, from_line: usize) {
218        // Invalidate any checkpoint whose line is at or after `from_line`.
219        // Edits starting exactly at a checkpoint boundary must invalidate that checkpoint.
220        // Edits in the middle of an interval keep the previous checkpoint.
221        self.checkpoints.borrow_mut().retain(|c| c.line < from_line);
222
223        let mut cache = self.line_cache.borrow_mut();
224        let mut index = self.cache_line_index.borrow_mut();
225        // Use the BTreeSet index to find only the affected lines in O(k log n)
226        // instead of scanning the entire LRU cache.
227        let to_remove: Vec<usize> = index.range(from_line..).copied().collect();
228        for k in to_remove {
229            cache.pop(&k);
230            index.remove(&k);
231        }
232    }
233
234    fn get_or_create_checkpoint(&self, lines: &[String], line: usize) -> Checkpoint {
235        let interval = self.checkpoint_interval;
236        let checkpoint_line = (line / interval) * interval;
237
238        if let Some(cp) = self
239            .checkpoints
240            .borrow()
241            .iter()
242            .find(|c| c.line == checkpoint_line)
243            .cloned()
244        {
245            return cp;
246        }
247
248        let checkpoints_borrow = self.checkpoints.borrow();
249
250        let prev_checkpoint = checkpoints_borrow
251            .iter()
252            .filter(|c| c.line < checkpoint_line)
253            .max_by_key(|c| c.line)
254            .cloned();
255
256        let theme = &THEME_SET.themes[&self.theme_name];
257        let highlighter = Highlighter::new(theme);
258
259        let (highlight_state, parse_state, start_line) = if let Some(ref prev) = prev_checkpoint {
260            (
261                prev.highlight_state.clone(),
262                prev.parse_state.clone(),
263                prev.line,
264            )
265        } else {
266            // No previous checkpoint: create one on-demand starting from line 0.
267            // Avoid pre-filling checkpoints which caused O(N²) behavior for large
268            // files. Instead, construct a fresh ParseState/HighlightState and
269            // parse from line 0 up to the desired checkpoint_line.
270            let parse = ParseState::new(self.get_syntax());
271            let high =
272                HighlightState::new(&highlighter, syntect::parsing::ScopeStack::new());
273            (high, parse, 0)
274        };
275
276        drop(checkpoints_borrow); // Release borrow before spawn_blocking
277
278        let mut h = HighlightLines::from_state(theme, highlight_state, parse_state);
279
280        for i in start_line..checkpoint_line {
281            if i < lines.len() {
282                let _ = h.highlight_line(&lines[i], &SYNTAX_SET);
283            }
284        }
285
286        let (highlight_state, parse_state) = h.state();
287
288        let checkpoint = Checkpoint {
289            line: checkpoint_line,
290            highlight_state,
291            parse_state,
292        };
293
294        let mut checkpoints = self.checkpoints.borrow_mut();
295        let mut insert_pos = checkpoints
296            .iter()
297            .position(|c| c.line > checkpoint_line)
298            .unwrap_or(checkpoints.len());
299        checkpoints.insert(insert_pos, checkpoint);
300
301        // Evict farthest checkpoints when capacity exceeded. Keep the checkpoints
302        // closest to the requested checkpoint_line to minimize recomputation cost.
303        const MAX_CHECKPOINTS: usize = 256;
304        if checkpoints.len() > MAX_CHECKPOINTS {
305            // Build (index, distance) pairs
306            let mut distances: Vec<(usize, usize)> = checkpoints
307                .iter()
308                .enumerate()
309                .map(|(i, c)| (i, c.line.abs_diff(checkpoint_line)))
310                .collect();
311            // Sort by distance ascending and keep the nearest MAX_CHECKPOINTS
312            distances.sort_by_key(|&(_, d)| d);
313            use std::collections::HashSet;
314            let keep: HashSet<usize> = distances.into_iter().take(MAX_CHECKPOINTS).map(|(i, _)| i).collect();
315
316            // Rebuild checkpoints preserving ascending line order
317            let mut kept = Vec::with_capacity(MAX_CHECKPOINTS);
318            for (i, cp) in checkpoints.iter().enumerate() {
319                if keep.contains(&i) {
320                    kept.push(cp.clone());
321                }
322            }
323            *checkpoints = kept;
324
325            // Recompute the inserted checkpoint's index in the compacted vec
326            insert_pos = checkpoints
327                .iter()
328                .position(|c| c.line == checkpoint_line)
329                .unwrap_or(checkpoints.len());
330        }
331
332        checkpoints[insert_pos].clone()
333    }
334
335    fn get_cached_line(&self, line: usize, version: Version) -> Option<Arc<Vec<Token>>> {
336        let mut cache = self.line_cache.borrow_mut();
337        match cache.get(&line) {
338            Some(cached) if cached.version == version => Some(cached.tokens.clone()),
339            _ => None,
340        }
341    }
342
343    fn cache_line(&self, line: usize, version: Version, tokens: Arc<Vec<Token>>) {
344        let mut cache = self.line_cache.borrow_mut();
345        let mut index = self.cache_line_index.borrow_mut();
346        if let Some((evicted_key, _)) = cache.push(line, CachedLine { version, tokens })
347            && evicted_key != line {
348                // An LRU eviction occurred; remove the evicted key from the index.
349                index.remove(&evicted_key);
350            }
351        index.insert(line);
352    }
353
354    pub fn highlight_line_at(&self, lines: &[String], line: usize, version: Version) -> Arc<Vec<Token>> {
355        if let Some(tokens) = self.get_cached_line(line, version) {
356            return tokens;
357        }
358
359        let checkpoint = self.get_or_create_checkpoint(lines, line);
360        let theme = &THEME_SET.themes[&self.theme_name];
361        let mut h = HighlightLines::from_state(
362            theme,
363            checkpoint.highlight_state.clone(),
364            checkpoint.parse_state.clone(),
365        );
366
367        for i in checkpoint.line..line {
368            if i < lines.len() {
369                let _ = h.highlight_line(&lines[i], &SYNTAX_SET);
370            }
371        }
372
373        let highlighted = if line < lines.len() {
374            h.highlight_line(&lines[line], &SYNTAX_SET)
375                .unwrap_or_default()
376        } else {
377            Vec::new()
378        };
379
380        let tokens_vec = self.tokens_from_highlight(&highlighted);
381        let tokens_arc = Arc::new(tokens_vec);
382
383        self.cache_line(line, version, tokens_arc.clone());
384
385        tokens_arc
386    }
387
388    fn tokens_from_highlight(&self, highlighted: &[(Style, &str)]) -> Vec<Token> {
389        let mut tokens = Vec::new();
390        let mut byte_offset = 0;
391
392        for (style, text) in highlighted {
393            let end = byte_offset + text.len();
394            tokens.push(Token::new(byte_offset, end, *style));
395            byte_offset = end;
396        }
397        tokens
398    }
399
400    pub fn highlight_range(
401        &self,
402        all_lines: &[String],
403        version: Version,
404        start_row: usize,
405        count: usize,
406    ) -> Vec<Vec<StyledSpan>> {
407        let end_row = (start_row + count).min(all_lines.len());
408
409        (start_row..end_row)
410            .map(|i| {
411                let tokens = self.highlight_line_at(all_lines, i, version);
412                self.tokens_to_spans(&all_lines[i], tokens.as_ref())
413            })
414            .collect()
415    }
416
417    pub fn highlight_tokens(
418        &self,
419        all_lines: &[String],
420        version: Version,
421        start_line: usize,
422        count: usize,
423    ) -> Vec<Arc<Vec<Token>>> {
424        let end_line = (start_line + count).min(all_lines.len());
425
426        (start_line..end_line)
427            .map(|i| self.highlight_line_at(all_lines, i, version))
428            .collect()
429    }
430
431    /// Cancellable variant of `highlight_tokens` that checks `is_cancelled`
432    /// between lines and returns early when it returns `true`. Useful for
433    /// aborting long-running background highlight tasks.
434    pub fn highlight_tokens_cancellable(
435        &self,
436        all_lines: &[String],
437        version: Version,
438        start_line: usize,
439        count: usize,
440        is_cancelled: impl Fn() -> bool,
441    ) -> Vec<Arc<Vec<Token>>> {
442        let end_line = (start_line + count).min(all_lines.len());
443        let mut results = Vec::new();
444        for i in start_line..end_line {
445            if is_cancelled() {
446                return results;
447            }
448            results.push(self.highlight_line_at(all_lines, i, version));
449        }
450        results
451    }
452
453    /// Highlight a sorted batch of lines efficiently using sequential parse-state reuse.
454    ///
455    /// Unlike calling [`highlight_line_at`] independently for each line (which
456    /// re-parses from the nearest checkpoint every time), this method carries
457    /// the parse state forward between adjacent lines.  For a contiguous range
458    /// of N lines this reduces per-line cost from O(checkpoint_interval) to O(1).
459    ///
460    /// `requested_lines` **must** be sorted in ascending order.
461    pub fn highlight_lines_batch(
462        &self,
463        all_lines: &[String],
464        version: Version,
465        requested_lines: &[usize],
466    ) -> Vec<(usize, Arc<Vec<Token>>)> {
467        if requested_lines.is_empty() {
468            return Vec::new();
469        }
470
471        let interval = self.checkpoint_interval;
472        let theme = &THEME_SET.themes[&self.theme_name];
473        let mut results = Vec::with_capacity(requested_lines.len());
474
475        // Sequential parse cursor.  Carrying state forward between adjacent
476        // lines avoids the O(n²) cost of independently re-parsing from a
477        // checkpoint for each line.
478        let mut parse_pos: usize = 0;
479        let mut h: Option<HighlightLines> = None;
480
481        for &line in requested_lines {
482            if line >= all_lines.len() {
483                results.push((line, Arc::new(Vec::new())));
484                continue;
485            }
486
487            if let Some(tokens) = self.get_cached_line(line, version) {
488                results.push((line, tokens));
489                // Cannot carry parse state past a skipped line.
490                h = None;
491                continue;
492            }
493
494            // Continue from the current cursor only when the gap is small.
495            let can_continue =
496                h.is_some() && parse_pos <= line && (line - parse_pos) <= interval;
497
498            if !can_continue {
499                let checkpoint = self.get_or_create_checkpoint(all_lines, line);
500                h = Some(HighlightLines::from_state(
501                    theme,
502                    checkpoint.highlight_state.clone(),
503                    checkpoint.parse_state.clone(),
504                ));
505                parse_pos = checkpoint.line;
506            }
507
508            let highlighter = h.as_mut().unwrap();
509
510            // Advance to the target line.
511            while parse_pos < line {
512                if parse_pos < all_lines.len() {
513                    let _ = highlighter.highlight_line(&all_lines[parse_pos], &SYNTAX_SET);
514                }
515                parse_pos += 1;
516            }
517
518            // Highlight the target line.
519            let highlighted = highlighter
520                .highlight_line(&all_lines[line], &SYNTAX_SET)
521                .unwrap_or_default();
522            parse_pos = line + 1;
523
524            let tokens_vec = self.tokens_from_highlight(&highlighted);
525            let tokens_arc = Arc::new(tokens_vec);
526            self.cache_line(line, version, tokens_arc.clone());
527            results.push((line, tokens_arc));
528        }
529
530        results
531    }
532
533    pub fn tokens_to_spans(&self, _line: &str, tokens: &[Token]) -> Vec<StyledSpan> {
534        // Avoid allocating per-token strings. Return spans as (range, style)
535        // and let the renderer slice the original line when drawing.
536        tokens
537            .iter()
538            .map(|t| StyledSpan { range: t.range.clone(), style: t.style })
539            .collect()
540    }
541}
542
543#[inline]
544pub fn to_ratatui_color(c: syntect::highlighting::Color) -> ratatui::style::Color {
545    ratatui::style::Color::Rgb(c.r, c.g, c.b)
546}
547
548pub fn to_ratatui_style(style: &Style) -> ratatui::style::Style {
549    use ratatui::style::Modifier;
550    let mut s = ratatui::style::Style::default().fg(to_ratatui_color(style.foreground));
551    if style.font_style.contains(FontStyle::BOLD) {
552        s = s.add_modifier(Modifier::BOLD);
553    }
554    if style.font_style.contains(FontStyle::ITALIC) {
555        s = s.add_modifier(Modifier::ITALIC);
556    }
557    if style.font_style.contains(FontStyle::UNDERLINE) {
558        s = s.add_modifier(Modifier::UNDERLINED);
559    }
560    s
561}
562
563pub const BRACE_PAIRS: &[(char, char)] = &[('{', '}'), ('(', ')'), ('[', ']')];
564
565/// Find the matching brace for the character at (cursor_row, cursor_col).
566/// `cursor_col` is a character index (0-indexed), not a byte index.
567pub fn find_matching_brace(
568    lines: &[String],
569    cursor_row: usize,
570    cursor_col: usize,
571) -> Option<(usize, usize)> {
572    let line = lines.get(cursor_row)?;
573
574    // Get the character at cursor_col (character index)
575    let ch = line.chars().nth(cursor_col)?;
576
577    let (open, close, forward) = BRACE_PAIRS.iter().find_map(|&(o, c)| {
578        if ch == o {
579            Some((o, c, true))
580        } else if ch == c {
581            Some((o, c, false))
582        } else {
583            None
584        }
585    })?;
586
587    if forward {
588        // Search forward for matching closing brace
589        let mut depth = 0i32;
590        for (row, line) in lines.iter().enumerate().skip(cursor_row) {
591            let start_char = if row == cursor_row { cursor_col } else { 0 };
592            for (char_idx, c) in line.chars().enumerate().skip(start_char) {
593                if c == open {
594                    depth += 1;
595                }
596                if c == close {
597                    depth -= 1;
598                    if depth == 0 && (row, char_idx) != (cursor_row, cursor_col) {
599                        return Some((row, char_idx));
600                    }
601                }
602            }
603        }
604    } else {
605        // Search backward for matching opening brace
606        let mut depth = 0i32;
607        for row in (0..=cursor_row).rev() {
608            let line = &lines[row];
609            let chars: Vec<char> = line.chars().collect();
610            let end_char = if row == cursor_row {
611                cursor_col + 1
612            } else {
613                chars.len()
614            };
615
616            for (char_idx, c) in chars.into_iter().enumerate().take(end_char).rev() {
617                if c == close {
618                    depth += 1;
619                }
620                if c == open {
621                    depth -= 1;
622                    if depth == 0 && (row, char_idx) != (cursor_row, cursor_col) {
623                        return Some((row, char_idx));
624                    }
625                }
626            }
627        }
628    }
629    None
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635
636    #[test]
637    fn brace_match_forward() {
638        let lines = vec!["fn f() {".to_string(), "    x".to_string(), "}".to_string()];
639        assert_eq!(find_matching_brace(&lines, 0, 7), Some((2, 0)));
640    }
641
642    #[test]
643    fn brace_match_backward() {
644        let lines = vec!["fn f() {".to_string(), "    x".to_string(), "}".to_string()];
645        assert_eq!(find_matching_brace(&lines, 2, 0), Some((0, 7)));
646    }
647
648    #[test]
649    fn brace_match_nested() {
650        let lines = vec!["if (a && (b || c)) {".to_string(), "}".to_string()];
651        assert_eq!(find_matching_brace(&lines, 0, 3), Some((0, 17)));
652    }
653
654    #[test]
655    fn no_match_for_plain_char() {
656        let lines = vec!["let x = 1;".to_string()];
657        assert_eq!(find_matching_brace(&lines, 0, 4), None);
658    }
659
660    #[test]
661    fn brace_match_with_utf8() {
662        // UTF-8 character '∞' takes 3 bytes, so character positions are:
663        // "∞ (" -> char 0='∞', char 1=' ', char 2='(', char 3=')'
664        let lines = vec!["∞ ()".to_string()];
665        // '(' is at character index 2, ')' is at character index 3
666        assert_eq!(find_matching_brace(&lines, 0, 2), Some((0, 3)));
667        assert_eq!(find_matching_brace(&lines, 0, 3), Some((0, 2)));
668    }
669
670    #[test]
671    fn tokens_to_spans_with_utf8_characters() {
672        let highlighter = SyntaxHighlighter::new();
673        let line = "∞ code";
674        // '∞' is 3 bytes, ' ' is 1 byte, so:
675        // bytes 0-3: '∞'
676        // bytes 3-4: ' '
677        // bytes 4-8: 'code'
678
679        // Create tokens with byte offsets
680        let tokens = vec![
681            Token::new(0, 3, Style::default()), // '∞'
682            Token::new(3, 4, Style::default()), // ' '
683            Token::new(4, 8, Style::default()), // 'code'
684        ];
685
686        let spans = highlighter.tokens_to_spans(line, &tokens);
687        assert_eq!(spans.len(), 3);
688        assert_eq!(&line[spans[0].range.start..spans[0].range.end], "∞");
689        assert_eq!(&line[spans[1].range.start..spans[1].range.end], " ");
690        assert_eq!(&line[spans[2].range.start..spans[2].range.end], "code");
691    }
692
693    #[test]
694    fn token_creation() {
695        let token = Token::new(0, 5, Style::default());
696        assert_eq!(token.range, 0..5);
697    }
698
699    #[test]
700    fn highlighting_produces_tokens() {
701        let highlighter = SyntaxHighlighter::new();
702        let lines = vec!["fn main() {}".to_string()];
703        let tokens = highlighter.highlight_line_at(&lines, 0, Version::new());
704        assert!(!tokens.is_empty());
705    }
706
707    #[test]
708    fn cache_works() {
709        let highlighter = SyntaxHighlighter::new();
710        let version = Version::new();
711        let lines = vec!["fn main() {}".to_string()];
712
713        let tokens1 = highlighter.highlight_line_at(&lines, 0, version);
714        let tokens2 = highlighter.highlight_line_at(&lines, 0, version);
715
716        assert_eq!(tokens1.len(), tokens2.len());
717    }
718
719    #[test]
720    fn checkpoint_interval() {
721        let highlighter = SyntaxHighlighter::new();
722        let mut h = highlighter.clone();
723        h.checkpoint_interval = 50;
724
725        let version = Version::new();
726        let lines: Vec<String> = (0..200).map(|i| format!("line {}", i)).collect();
727
728        for i in 0..200 {
729            h.highlight_line_at(&lines, i, version);
730        }
731
732        let checkpoints = h.checkpoints.borrow();
733        assert!(!checkpoints.is_empty());
734        assert!(checkpoints.iter().all(|c| c.line % 50 == 0));
735    }
736
737    #[test]
738    fn checkpoint_eviction_keeps_nearby_checkpoints() {
739        // Create a highlighter that checkpoints every line so we can produce
740        // many checkpoints quickly.
741        let mut h = SyntaxHighlighter::new();
742        h.checkpoint_interval = 1;
743        let version = Version::new();
744        let lines: Vec<String> = (0..300).map(|i| format!("line {}", i)).collect();
745
746        // Create checkpoints for all lines except the center line. This ensures
747        // inserting the center will be the operation that triggers eviction.
748        for i in 0..300 {
749            if i == 150 { continue; }
750            h.highlight_line_at(&lines, i, version);
751        }
752
753        // Insert the center checkpoint last to trigger eviction centered on 150
754        h.highlight_line_at(&lines, 150, version);
755
756        let checkpoints = h.checkpoints.borrow();
757        // Eviction should shrink the checkpoint set to <= 256
758        assert!(checkpoints.len() <= 256);
759
760        let cp_lines: Vec<usize> = checkpoints.iter().map(|c| c.line).collect();
761        assert!(cp_lines.contains(&150));
762
763        // The kept checkpoints should form a contiguous window around the
764        // requested checkpoint_line and its size should be <= 256.
765        let min = *cp_lines.first().unwrap();
766        let max = *cp_lines.last().unwrap();
767        assert!(min <= 150 && 150 <= max);
768        assert!(max - min < 256);
769    }
770
771    #[test]
772    fn spans_from_highlight_cover_entire_line() {
773        let h = SyntaxHighlighter::new();
774        let version = Version::new();
775        let lines = vec!["fn test_span_cover() { let x = 1; }".to_string()];
776
777        let tokens = h.highlight_line_at(&lines, 0, version);
778        let spans = h.tokens_to_spans(&lines[0], tokens.as_ref());
779
780        let mut reconstructed = String::new();
781        for s in &spans {
782            reconstructed.push_str(&lines[0][s.range.start..s.range.end]);
783        }
784
785        assert_eq!(reconstructed, lines[0]);
786    }
787
788    #[test]
789    fn invalidate_from_boundary_behaviour() {
790        // Create a highlighter that checkpoints every 50 lines.
791        let mut h = SyntaxHighlighter::new();
792        h.checkpoint_interval = 50;
793        let version = Version::new();
794        let lines: Vec<String> = (0..200).map(|i| format!("line {}", i)).collect();
795
796        // Build checkpoints by highlighting many lines
797        for i in 0..200 {
798            h.highlight_line_at(&lines, i, version);
799        }
800
801        // Ensure a checkpoint exists at the boundary (100)
802        let cp_lines: Vec<usize> = h.checkpoints.borrow().iter().map(|c| c.line).collect();
803        assert!(cp_lines.contains(&100));
804
805        // Invalidate starting exactly at the checkpoint boundary: 100
806        h.invalidate_from(100);
807        let cp_after: Vec<usize> = h.checkpoints.borrow().iter().map(|c| c.line).collect();
808        // The 100 checkpoint must be removed and all remaining checkpoints be < 100
809        assert!(!cp_after.contains(&100));
810        assert!(cp_after.iter().all(|&l| l < 100));
811
812        // Rebuild checkpoints again
813        for i in 0..200 {
814            h.highlight_line_at(&lines, i, version);
815        }
816
817        // Invalidate starting in the middle of an interval (101). The checkpoint
818        // at 100 should remain because the edit begins after it.
819        h.invalidate_from(101);
820        let cp_after_mid: Vec<usize> = h.checkpoints.borrow().iter().map(|c| c.line).collect();
821        assert!(cp_after_mid.contains(&100));
822    }
823
824    // -------------------------------------------------------------------------
825    // Caching: version mismatch, Arc identity, clear_cache
826    // -------------------------------------------------------------------------
827
828    #[test]
829    fn cache_miss_on_version_change() {
830        let h = SyntaxHighlighter::new();
831        let lines = vec!["fn main() {}".to_string()];
832        let v1 = Version::from_raw(0);
833        let v2 = Version::from_raw(1);
834
835        let t1 = h.highlight_line_at(&lines, 0, v1);
836        let t2 = h.highlight_line_at(&lines, 0, v2);
837
838        // Both should produce tokens; the second is a fresh computation (different
839        // version breaks the cache key) but must still produce identical content.
840        assert_eq!(t1.len(), t2.len());
841    }
842
843    #[test]
844    fn cache_hit_returns_same_arc() {
845        let h = SyntaxHighlighter::new();
846        let version = Version::new();
847        let lines = vec!["let x = 42;".to_string()];
848
849        let t1 = h.highlight_line_at(&lines, 0, version);
850        let t2 = h.highlight_line_at(&lines, 0, version);
851
852        // Same Arc allocation — pointer equality proves the cache was hit.
853        assert!(Arc::ptr_eq(&t1, &t2));
854    }
855
856    #[test]
857    fn clear_cache_forces_recomputation() {
858        let h = SyntaxHighlighter::new();
859        let version = Version::new();
860        let lines = vec!["let x = 42;".to_string()];
861
862        let t1 = h.highlight_line_at(&lines, 0, version);
863        h.clear_cache();
864        let t2 = h.highlight_line_at(&lines, 0, version);
865
866        // After clear the Arc must be a fresh allocation.
867        assert!(!Arc::ptr_eq(&t1, &t2));
868        // But the content should be identical.
869        assert_eq!(t1.len(), t2.len());
870    }
871
872    #[test]
873    fn clear_cache_removes_all_checkpoints() {
874        let mut h = SyntaxHighlighter::new();
875        h.checkpoint_interval = 10;
876        let version = Version::new();
877        let lines: Vec<String> = (0..50).map(|i| format!("line {}", i)).collect();
878
879        for i in 0..50 {
880            h.highlight_line_at(&lines, i, version);
881        }
882        assert!(!h.checkpoints.borrow().is_empty());
883
884        h.clear_cache();
885        assert!(h.checkpoints.borrow().is_empty());
886    }
887
888    #[test]
889    fn lru_eviction_does_not_panic() {
890        // Default LRU capacity is 10,000. Inserting more entries should silently
891        // evict without panicking.
892        let h = SyntaxHighlighter::new();
893        let version = Version::new();
894        let lines: Vec<String> = (0..10_200).map(|i| format!("// line {}", i)).collect();
895
896        for i in 0..10_200 {
897            h.highlight_line_at(&lines, i, version);
898        }
899        // If we reach here, no panic occurred.
900    }
901
902    // -------------------------------------------------------------------------
903    // invalidate_from: cache-line semantics
904    // -------------------------------------------------------------------------
905
906    #[test]
907    fn invalidate_from_zero_clears_line_cache_entirely() {
908        let h = SyntaxHighlighter::new();
909        let version = Version::new();
910        let lines: Vec<String> = (0..10).map(|i| format!("let x{} = {};", i, i)).collect();
911
912        // Populate cache for all 10 lines.
913        for i in 0..10 {
914            h.highlight_line_at(&lines, i, version);
915        }
916
917        h.invalidate_from(0);
918
919        // After invalidation from 0, every line should produce a fresh Arc.
920        for (i, _line) in lines.iter().enumerate().take(10) {
921            let before_ptr = {
922                // Re-cache line i to get a fresh Arc, then capture pointer.
923                h.highlight_line_at(&lines, i, version)
924            };
925            // The first call after invalidation must NOT reuse the old Arc stored
926            // pre-invalidation. However since we just called highlight_line_at, the
927            // cache is now populated again; verify content is valid.
928            let after_ptr = h.highlight_line_at(&lines, i, version);
929            assert!(Arc::ptr_eq(&before_ptr, &after_ptr), "line {} should now be cached", i);
930        }
931    }
932
933    #[test]
934    fn invalidate_from_preserves_lines_before_edit_point() {
935        let h = SyntaxHighlighter::new();
936        let version = Version::new();
937        let lines: Vec<String> = (0..20).map(|i| format!("let x{} = {};", i, i)).collect();
938
939        // Cache all 20 lines.
940        let arcs_before: Vec<_> = (0..20)
941            .map(|i| h.highlight_line_at(&lines, i, version))
942            .collect();
943
944        // Invalidate from line 10 onward.
945        h.invalidate_from(10);
946
947        // Lines 0..10 should still be served from cache (same Arc pointer).
948        for (i, _line) in arcs_before.iter().enumerate().take(10) {
949            let after = h.highlight_line_at(&lines, i, version);
950            assert!(
951                Arc::ptr_eq(&arcs_before[i], &after),
952                "line {} before invalidation point should still be cached", i
953            );
954        }
955    }
956
957    #[test]
958    fn invalidate_from_removes_lines_at_and_after_edit_point() {
959        let h = SyntaxHighlighter::new();
960        let version = Version::new();
961        let lines: Vec<String> = (0..20).map(|i| format!("let x{} = {};", i, i)).collect();
962
963        let arcs_before: Vec<_> = (0..20)
964            .map(|i| h.highlight_line_at(&lines, i, version))
965            .collect();
966
967        h.invalidate_from(10);
968
969        // Lines 10..20 must have been evicted from the cache: a fresh Arc is
970        // produced by the next call.
971        for (i, _line) in arcs_before.iter().enumerate().take(20).skip(10) {
972            let after = h.highlight_line_at(&lines, i, version);
973            assert!(
974                !Arc::ptr_eq(&arcs_before[i], &after),
975                "line {} at/after invalidation point should be evicted", i
976            );
977        }
978    }
979
980    #[test]
981    fn invalidate_from_exact_boundary_removes_that_line() {
982        let h = SyntaxHighlighter::new();
983        let version = Version::new();
984        let lines = vec!["fn a() {}".to_string(), "fn b() {}".to_string()];
985
986        let arc0 = h.highlight_line_at(&lines, 0, version);
987        let arc1 = h.highlight_line_at(&lines, 1, version);
988
989        // Invalidate from exactly line 1.
990        h.invalidate_from(1);
991
992        // Line 0 should still be cached.
993        let after0 = h.highlight_line_at(&lines, 0, version);
994        assert!(Arc::ptr_eq(&arc0, &after0), "line 0 must remain cached");
995
996        // Line 1 should be evicted.
997        let after1 = h.highlight_line_at(&lines, 1, version);
998        assert!(!Arc::ptr_eq(&arc1, &after1), "line 1 must be evicted");
999    }
1000
1001    // -------------------------------------------------------------------------
1002    // Editing simulation
1003    // -------------------------------------------------------------------------
1004
1005    #[test]
1006    fn edit_at_beginning_invalidates_all_lines() {
1007        let h = SyntaxHighlighter::new();
1008        let version = Version::new();
1009        let mut lines: Vec<String> = (0..10).map(|i| format!("let x{} = {};", i, i)).collect();
1010
1011        let arcs_before: Vec<_> = (0..10)
1012            .map(|i| h.highlight_line_at(&lines, i, version))
1013            .collect();
1014
1015        // Simulate inserting a new first line.
1016        lines.insert(0, "// new comment".to_string());
1017        h.invalidate_from(0);
1018
1019        // Every line's cached Arc is now stale (edit at line 0 shifts all).
1020        // Fresh computation must differ from the pre-edit Arcs.
1021        for (i, _line) in arcs_before.iter().enumerate().take(10) {
1022            let after = h.highlight_line_at(&lines, i, version);
1023            assert!(!Arc::ptr_eq(&arcs_before[i], &after),
1024                "line {} must be recomputed after edit at line 0", i);
1025        }
1026    }
1027
1028    #[test]
1029    fn edit_at_middle_preserves_preceding_lines() {
1030        let h = SyntaxHighlighter::new();
1031        let version = Version::new();
1032        let mut lines: Vec<String> = (0..20).map(|i| format!("// line {}", i)).collect();
1033
1034        let arcs_before: Vec<_> = (0..20)
1035            .map(|i| h.highlight_line_at(&lines, i, version))
1036            .collect();
1037
1038        // Simulate an edit at line 10.
1039        lines[10] = "// CHANGED".to_string();
1040        h.invalidate_from(10);
1041
1042        // Lines 0..10 should still be in cache.
1043        for (i, _line) in arcs_before.iter().enumerate().take(10) {
1044            let after = h.highlight_line_at(&lines, i, version);
1045            assert!(Arc::ptr_eq(&arcs_before[i], &after),
1046                "line {} before edit should still be cached", i);
1047        }
1048
1049        // Lines 10..20 should be fresh.
1050        for (i, _line) in arcs_before.iter().enumerate().take(20).skip(10) {
1051            let after = h.highlight_line_at(&lines, i, version);
1052            assert!(!Arc::ptr_eq(&arcs_before[i], &after),
1053                "line {} at/after edit point should be recomputed", i);
1054        }
1055    }
1056
1057    #[test]
1058    fn edit_at_last_line_preserves_rest() {
1059        let h = SyntaxHighlighter::new();
1060        let version = Version::new();
1061        let mut lines: Vec<String> = (0..5).map(|i| format!("// line {}", i)).collect();
1062
1063        let arcs: Vec<_> = (0..5)
1064            .map(|i| h.highlight_line_at(&lines, i, version))
1065            .collect();
1066
1067        lines[4] = "// CHANGED LAST".to_string();
1068        h.invalidate_from(4);
1069
1070        for (i, _line) in arcs.iter().enumerate().take(4) {
1071            let after = h.highlight_line_at(&lines, i, version);
1072            assert!(Arc::ptr_eq(&arcs[i], &after), "line {} must stay cached", i);
1073        }
1074
1075        let after4 = h.highlight_line_at(&lines, 4, version);
1076        assert!(!Arc::ptr_eq(&arcs[4], &after4), "last line must be recomputed");
1077    }
1078
1079    #[test]
1080    fn sequential_edits_produce_consistent_results() {
1081        // Simulate a realistic editing session: three edits at decreasing positions.
1082        let h = SyntaxHighlighter::new();
1083        let version = Version::new();
1084        let mut lines: Vec<String> = (0..30).map(|i| format!("let v{} = {};", i, i)).collect();
1085
1086        // First: highlight all 30 lines.
1087        for i in 0..30 {
1088            h.highlight_line_at(&lines, i, version);
1089        }
1090
1091        // Edit 1: line 25.
1092        lines[25] = "let v25 = 9999;".to_string();
1093        h.invalidate_from(25);
1094        for i in 0..30 {
1095            let t = h.highlight_line_at(&lines, i, version);
1096            assert!(!t.is_empty() || lines[i].is_empty(), "line {} should produce tokens", i);
1097        }
1098
1099        // Edit 2: line 10.
1100        lines[10] = "let v10 = 8888;".to_string();
1101        h.invalidate_from(10);
1102        for i in 0..30 {
1103            let t = h.highlight_line_at(&lines, i, version);
1104            assert!(!t.is_empty() || lines[i].is_empty());
1105        }
1106
1107        // Edit 3: line 0.
1108        lines[0] = "// file header".to_string();
1109        h.invalidate_from(0);
1110        for i in 0..30 {
1111            let t = h.highlight_line_at(&lines, i, version);
1112            assert!(!t.is_empty() || lines[i].is_empty());
1113        }
1114    }
1115
1116    // -------------------------------------------------------------------------
1117    // Scrolling simulation
1118    // -------------------------------------------------------------------------
1119
1120    #[test]
1121    fn scroll_down_highlights_new_lines() {
1122        let h = SyntaxHighlighter::new();
1123        let version = Version::new();
1124        let lines: Vec<String> = (0..100).map(|i| format!("// line {}", i)).collect();
1125
1126        // First screen (lines 0..40).
1127        for i in 0..40 {
1128            h.highlight_line_at(&lines, i, version);
1129        }
1130
1131        // Scroll to second screen (lines 40..80).
1132        let second_screen: Vec<_> = (40..80)
1133            .map(|i| h.highlight_line_at(&lines, i, version))
1134            .collect();
1135
1136        assert_eq!(second_screen.len(), 40);
1137        for tokens in &second_screen {
1138            assert!(!tokens.is_empty());
1139        }
1140    }
1141
1142    #[test]
1143    fn scroll_back_up_uses_cache() {
1144        let h = SyntaxHighlighter::new();
1145        let version = Version::new();
1146        let lines: Vec<String> = (0..100).map(|i| format!("// line {}", i)).collect();
1147
1148        // Highlight first 40 lines and capture Arcs.
1149        let first_screen: Vec<_> = (0..40)
1150            .map(|i| h.highlight_line_at(&lines, i, version))
1151            .collect();
1152
1153        // Scroll down to lines 40..80.
1154        for i in 40..80 {
1155            h.highlight_line_at(&lines, i, version);
1156        }
1157
1158        // Scroll back up: lines 0..40 should be served from cache.
1159        for (i, _line) in first_screen.iter().enumerate().take(40) {
1160            let after = h.highlight_line_at(&lines, i, version);
1161            assert!(Arc::ptr_eq(&first_screen[i], &after),
1162                "line {} should be cache-hit on scroll back", i);
1163        }
1164    }
1165
1166    #[test]
1167    fn highlight_window_jump_no_panic() {
1168        // Simulate jumping from line 0 to line 5000 (e.g. go-to-line command).
1169        let h = SyntaxHighlighter::new();
1170        let version = Version::new();
1171        let lines: Vec<String> = (0..6000).map(|i| format!("// line {}", i)).collect();
1172
1173        // Highlight first screen.
1174        for i in 0..40 {
1175            h.highlight_line_at(&lines, i, version);
1176        }
1177
1178        // Jump to line 5000 and highlight a screen worth of lines.
1179        for i in 5000..5040 {
1180            let t = h.highlight_line_at(&lines, i, version);
1181            assert!(!t.is_empty());
1182        }
1183    }
1184
1185    // -------------------------------------------------------------------------
1186    // highlight_range and highlight_tokens
1187    // -------------------------------------------------------------------------
1188
1189    #[test]
1190    fn highlight_range_returns_correct_count() {
1191        let h = SyntaxHighlighter::new();
1192        let version = Version::new();
1193        let lines: Vec<String> = (0..20).map(|i| format!("let x{} = {};", i, i)).collect();
1194
1195        let spans = h.highlight_range(&lines, version, 5, 10);
1196        assert_eq!(spans.len(), 10, "should return exactly 10 rows");
1197    }
1198
1199    #[test]
1200    fn highlight_range_zero_count_returns_empty() {
1201        let h = SyntaxHighlighter::new();
1202        let version = Version::new();
1203        let lines = vec!["let x = 1;".to_string()];
1204
1205        let spans = h.highlight_range(&lines, version, 0, 0);
1206        assert!(spans.is_empty());
1207    }
1208
1209    #[test]
1210    fn highlight_range_clamped_to_buffer_length() {
1211        let h = SyntaxHighlighter::new();
1212        let version = Version::new();
1213        let lines: Vec<String> = (0..5).map(|i| format!("// {}", i)).collect();
1214
1215        // Requesting more rows than exist: should clamp, not panic.
1216        let spans = h.highlight_range(&lines, version, 0, 100);
1217        assert_eq!(spans.len(), 5);
1218    }
1219
1220    #[test]
1221    fn highlight_range_spans_cover_full_line() {
1222        let h = SyntaxHighlighter::new().with_syntax("Rust");
1223        let version = Version::new();
1224        let lines = vec![
1225            "fn main() {".to_string(),
1226            "    let x = 42;".to_string(),
1227            "}".to_string(),
1228        ];
1229
1230        let all_spans = h.highlight_range(&lines, version, 0, 3);
1231        for (row, spans) in all_spans.iter().enumerate() {
1232            let mut reconstructed = String::new();
1233            for s in spans {
1234                reconstructed.push_str(&lines[row][s.range.start..s.range.end]);
1235            }
1236            assert_eq!(reconstructed, lines[row], "spans must reconstruct line {}", row);
1237        }
1238    }
1239
1240    #[test]
1241    fn highlight_tokens_matches_individual_highlight_line_at() {
1242        let h = SyntaxHighlighter::new().with_syntax("Rust");
1243        let version = Version::new();
1244        let lines: Vec<String> = (0..10).map(|i| format!("let v{} = {};", i, i)).collect();
1245
1246        let batch = h.highlight_tokens(&lines, version, 0, 10);
1247
1248        // Rebuild the highlighter to compare against individual calls (cache cleared).
1249        let h2 = SyntaxHighlighter::new().with_syntax("Rust");
1250        for (i, _line) in batch.iter().enumerate().take(10) {
1251            let individual = h2.highlight_line_at(&lines, i, version);
1252            assert_eq!(
1253                batch[i].len(), individual.len(),
1254                "batch and individual token counts must match for line {}", i
1255            );
1256        }
1257    }
1258
1259    #[test]
1260    fn highlight_tokens_beyond_bounds_clamped() {
1261        let h = SyntaxHighlighter::new();
1262        let version = Version::new();
1263        let lines = vec!["one".to_string(), "two".to_string()];
1264
1265        let tokens = h.highlight_tokens(&lines, version, 0, 1000);
1266        assert_eq!(tokens.len(), 2, "should clamp to buffer length");
1267    }
1268
1269    // -------------------------------------------------------------------------
1270    // highlight_lines_batch
1271    // -------------------------------------------------------------------------
1272
1273    #[test]
1274    fn batch_matches_individual() {
1275        let h1 = SyntaxHighlighter::new().with_syntax("Rust");
1276        let version = Version::new();
1277        let lines: Vec<String> = (0..20).map(|i| format!("let v{} = {};", i, i)).collect();
1278
1279        let requested: Vec<usize> = (0..20).collect();
1280        let batch = h1.highlight_lines_batch(&lines, version, &requested);
1281
1282        let h2 = SyntaxHighlighter::new().with_syntax("Rust");
1283        for (line, tokens) in &batch {
1284            let individual = h2.highlight_line_at(&lines, *line, version);
1285            assert_eq!(
1286                tokens.len(), individual.len(),
1287                "batch and individual token counts must match for line {}", line
1288            );
1289        }
1290    }
1291
1292    #[test]
1293    fn batch_empty_request() {
1294        let h = SyntaxHighlighter::new();
1295        let version = Version::new();
1296        let lines = vec!["hello".to_string()];
1297        let result = h.highlight_lines_batch(&lines, version, &[]);
1298        assert!(result.is_empty());
1299    }
1300
1301    #[test]
1302    fn batch_out_of_bounds_returns_empty_tokens() {
1303        let h = SyntaxHighlighter::new();
1304        let version = Version::new();
1305        let lines = vec!["hello".to_string()];
1306        let result = h.highlight_lines_batch(&lines, version, &[0, 5, 100]);
1307        assert_eq!(result.len(), 3);
1308        assert!(!result[0].1.is_empty()); // line 0 valid
1309        assert!(result[1].1.is_empty());  // line 5 OOB
1310        assert!(result[2].1.is_empty());  // line 100 OOB
1311    }
1312
1313    #[test]
1314    fn batch_with_gaps() {
1315        let h1 = SyntaxHighlighter::new().with_syntax("Rust");
1316        let version = Version::new();
1317        let lines: Vec<String> = (0..200).map(|i| format!("let v{} = {};", i, i)).collect();
1318
1319        let requested = vec![0, 5, 50, 100, 150, 199];
1320        let batch = h1.highlight_lines_batch(&lines, version, &requested);
1321
1322        let h2 = SyntaxHighlighter::new().with_syntax("Rust");
1323        for (line, tokens) in &batch {
1324            let individual = h2.highlight_line_at(&lines, *line, version);
1325            assert_eq!(
1326                tokens.len(), individual.len(),
1327                "batch and individual must match for line {}", line
1328            );
1329        }
1330    }
1331
1332    #[test]
1333    fn batch_reuses_state_for_adjacent_lines() {
1334        // Verify that batch with sorted adjacent lines produces correct results.
1335        // This implicitly tests state reuse since adjacent lines share parse state.
1336        let h1 = SyntaxHighlighter::new().with_syntax("Rust");
1337        let version = Version::new();
1338        let lines: Vec<String> = (0..50).map(|i| format!("fn f{}() {{ }}", i)).collect();
1339
1340        let requested: Vec<usize> = (10..30).collect();
1341        let batch = h1.highlight_lines_batch(&lines, version, &requested);
1342
1343        assert_eq!(batch.len(), 20);
1344        let h2 = SyntaxHighlighter::new().with_syntax("Rust");
1345        for (line, tokens) in &batch {
1346            let individual = h2.highlight_line_at(&lines, *line, version);
1347            assert_eq!(tokens.len(), individual.len(), "mismatch at line {}", line);
1348        }
1349    }
1350
1351    // -------------------------------------------------------------------------
1352    // highlight_tokens_cancellable
1353    // -------------------------------------------------------------------------
1354
1355    #[test]
1356    fn cancellable_no_cancel_same_as_normal() {
1357        let h = SyntaxHighlighter::new();
1358        let version = Version::new();
1359        let lines: Vec<String> = (0..10).map(|i| format!("// line {}", i)).collect();
1360
1361        let normal = h.highlight_tokens(&lines, version, 0, 10);
1362        // Rebuild to ensure fresh cache for comparison.
1363        let h2 = SyntaxHighlighter::new();
1364        let cancellable = h2.highlight_tokens_cancellable(&lines, version, 0, 10, || false);
1365
1366        assert_eq!(normal.len(), cancellable.len());
1367        for i in 0..normal.len() {
1368            assert_eq!(normal[i].len(), cancellable[i].len(),
1369                "token count for line {} must match", i);
1370        }
1371    }
1372
1373    #[test]
1374    fn cancellable_cancel_before_start_returns_empty() {
1375        let h = SyntaxHighlighter::new();
1376        let version = Version::new();
1377        let lines: Vec<String> = (0..10).map(|i| format!("// line {}", i)).collect();
1378
1379        let result = h.highlight_tokens_cancellable(&lines, version, 0, 10, || true);
1380        assert!(result.is_empty(), "cancelled before start must return empty vec");
1381    }
1382
1383    #[test]
1384    fn cancellable_cancel_mid_way_returns_partial() {
1385        use std::sync::atomic::{AtomicBool, Ordering};
1386        use std::sync::Arc;
1387
1388        // We cannot cancel precisely between lines in a single-threaded test,
1389        // but we CAN verify that a pre-set cancellation after first line works
1390        // by using a shared flag set externally.
1391        let h = SyntaxHighlighter::new();
1392        let version = Version::new();
1393        let lines: Vec<String> = (0..50).map(|i| format!("// line {}", i)).collect();
1394        let cancelled = Arc::new(AtomicBool::new(false));
1395        let cancelled_clone = cancelled.clone();
1396
1397        // Cancel after a short moment in a separate thread — this races, but
1398        // since the highlighting is CPU-bound and cancellation is polled between
1399        // lines, we just verify the result is a subset [0, 50].
1400        let handle = std::thread::spawn(move || {
1401            std::thread::sleep(std::time::Duration::from_micros(1));
1402            cancelled_clone.store(true, Ordering::SeqCst);
1403        });
1404
1405        let result = h.highlight_tokens_cancellable(
1406            &lines, version, 0, 50,
1407            || cancelled.load(Ordering::SeqCst),
1408        );
1409        handle.join().unwrap();
1410
1411        // Result must be a strict prefix of the full 50-line set.
1412        assert!(result.len() <= 50, "result cannot exceed total lines");
1413    }
1414
1415    // -------------------------------------------------------------------------
1416    // Edge cases
1417    // -------------------------------------------------------------------------
1418
1419    #[test]
1420    fn empty_lines_array_returns_empty() {
1421        let h = SyntaxHighlighter::new();
1422        let version = Version::new();
1423        let lines: Vec<String> = vec![];
1424
1425        // highlight_line_at on empty array: should return empty, not panic.
1426        let tokens = h.highlight_line_at(&lines, 0, version);
1427        assert!(tokens.is_empty());
1428
1429        let range = h.highlight_range(&lines, version, 0, 10);
1430        assert!(range.is_empty());
1431    }
1432
1433    #[test]
1434    fn out_of_bounds_line_returns_empty_tokens() {
1435        let h = SyntaxHighlighter::new();
1436        let version = Version::new();
1437        let lines = vec!["let x = 1;".to_string()];
1438
1439        let tokens = h.highlight_line_at(&lines, 999, version);
1440        assert!(tokens.is_empty(), "OOB line must return empty tokens, not panic");
1441    }
1442
1443    #[test]
1444    fn single_empty_string_line_no_panic() {
1445        let h = SyntaxHighlighter::new();
1446        let version = Version::new();
1447        let lines = vec!["".to_string()];
1448
1449        let tokens = h.highlight_line_at(&lines, 0, version);
1450        // Empty line may produce zero or one tokens — just must not panic.
1451        let _ = tokens;
1452    }
1453
1454    #[test]
1455    fn very_long_line_no_panic() {
1456        let h = SyntaxHighlighter::new().with_syntax("Rust");
1457        let version = Version::new();
1458        let long_line = "x".repeat(100_000);
1459        let lines = vec![format!("let s = \"{}\";", long_line)];
1460
1461        let tokens = h.highlight_line_at(&lines, 0, version);
1462        assert!(!tokens.is_empty());
1463    }
1464
1465    #[test]
1466    fn unicode_heavy_line_no_panic() {
1467        let h = SyntaxHighlighter::new();
1468        let version = Version::new();
1469        let lines = vec!["// ∞ ★ 𝓤𝓷𝓲𝓬𝓸𝓭𝓮 αβγδ 日本語 العربية".to_string()];
1470
1471        let tokens = h.highlight_line_at(&lines, 0, version);
1472        assert!(!tokens.is_empty());
1473    }
1474
1475    #[test]
1476    fn line_of_only_whitespace_no_panic() {
1477        let h = SyntaxHighlighter::new();
1478        let version = Version::new();
1479        let lines = vec!["    \t    ".to_string()];
1480
1481        let _ = h.highlight_line_at(&lines, 0, version);
1482    }
1483
1484    #[test]
1485    fn null_bytes_in_line_no_panic() {
1486        // Ensure the highlighter handles unexpected bytes gracefully.
1487        let h = SyntaxHighlighter::new();
1488        let version = Version::new();
1489        let lines = vec!["normal text".to_string()];
1490        let _ = h.highlight_line_at(&lines, 0, version);
1491    }
1492
1493    // -------------------------------------------------------------------------
1494    // Construction and state snapshot
1495    // -------------------------------------------------------------------------
1496
1497    #[test]
1498    fn for_path_rs_detects_rust_syntax() {
1499        use std::path::Path;
1500        let h = SyntaxHighlighter::for_path(Path::new("src/main.rs"));
1501        assert_eq!(h.syntax_name, "Rust");
1502    }
1503
1504    #[test]
1505    fn for_path_unknown_extension_uses_plain_text() {
1506        use std::path::Path;
1507        let h = SyntaxHighlighter::for_path(Path::new("file.xyzdoesnotexist123"));
1508        assert_eq!(h.syntax_name, "Plain Text");
1509    }
1510
1511    #[test]
1512    fn snapshot_state_roundtrip_preserves_config() {
1513        let original = SyntaxHighlighter::new()
1514            .with_theme("base16-ocean.dark")
1515            .with_syntax("Rust");
1516
1517        let state = original.snapshot_state();
1518        assert_eq!(state.theme_name, "base16-ocean.dark");
1519        assert_eq!(state.syntax_name, "Rust");
1520
1521        let restored = SyntaxHighlighter::from_state(state);
1522        assert_eq!(restored.theme_name, "base16-ocean.dark");
1523        assert_eq!(restored.syntax_name, "Rust");
1524    }
1525
1526    #[test]
1527    fn snapshot_state_carries_accumulated_checkpoints() {
1528        let mut h = SyntaxHighlighter::new();
1529        h.checkpoint_interval = 10;
1530        let version = Version::new();
1531        let lines: Vec<String> = (0..30).map(|i| format!("// line {}", i)).collect();
1532
1533        for i in 0..30 {
1534            h.highlight_line_at(&lines, i, version);
1535        }
1536
1537        let state = h.snapshot_state();
1538        // Checkpoints should be present in the snapshot.
1539        assert!(!state.checkpoints.is_empty());
1540    }
1541
1542    #[test]
1543    fn from_state_has_empty_line_cache() {
1544        // Constructing from a state snapshot gives an empty line cache, so the
1545        // first highlight_line_at after from_state always recomputes.
1546        let mut h = SyntaxHighlighter::new();
1547        h.checkpoint_interval = 1;
1548        let version = Version::new();
1549        let lines = vec!["fn test() {}".to_string()];
1550
1551        let arc_original = h.highlight_line_at(&lines, 0, version);
1552        let state = h.snapshot_state();
1553
1554        let h2 = SyntaxHighlighter::from_state(state);
1555        let arc_restored = h2.highlight_line_at(&lines, 0, version);
1556
1557        // Different Arcs (fresh cache) but same content.
1558        assert!(!Arc::ptr_eq(&arc_original, &arc_restored));
1559        assert_eq!(arc_original.len(), arc_restored.len());
1560    }
1561
1562    // -------------------------------------------------------------------------
1563    // Style conversion helpers
1564    // -------------------------------------------------------------------------
1565
1566    #[test]
1567    fn to_ratatui_color_converts_rgb() {
1568        use syntect::highlighting::Color;
1569        let c = Color { r: 255, g: 128, b: 0, a: 255 };
1570        let ratatui_color = to_ratatui_color(c);
1571        assert_eq!(ratatui_color, ratatui::style::Color::Rgb(255, 128, 0));
1572    }
1573
1574    #[test]
1575    fn to_ratatui_style_plain_produces_no_modifiers() {
1576        use syntect::highlighting::Style;
1577        let style = Style::default();
1578        let ratatui_style = to_ratatui_style(&style);
1579        // No bold/italic/underline bits should be set.
1580        assert_eq!(ratatui_style.add_modifier, ratatui::style::Modifier::empty());
1581    }
1582
1583    #[test]
1584    fn to_ratatui_style_bold_sets_modifier() {
1585        use syntect::highlighting::{FontStyle, Style};
1586        let style = Style { font_style: FontStyle::BOLD, ..Default::default() };
1587        let ratatui_style = to_ratatui_style(&style);
1588        assert!(ratatui_style.add_modifier.contains(ratatui::style::Modifier::BOLD));
1589    }
1590
1591    // -------------------------------------------------------------------------
1592    // Consistency: batch vs individual, multi-language
1593    // -------------------------------------------------------------------------
1594
1595    #[test]
1596    fn tokens_to_spans_empty_tokens_gives_empty_spans() {
1597        let h = SyntaxHighlighter::new();
1598        let spans = h.tokens_to_spans("", &[]);
1599        assert!(spans.is_empty());
1600    }
1601
1602    #[test]
1603    fn highlight_with_rust_syntax_produces_more_tokens_than_plain() {
1604        // Rust syntax should produce more distinct tokens for Rust code than
1605        // plain-text mode (which emits a single token per line).
1606        let rust_h = SyntaxHighlighter::new().with_syntax("Rust");
1607        let plain_h = SyntaxHighlighter::new(); // Plain Text default
1608        let version = Version::new();
1609        let lines = vec!["fn main() { let x = 42; }".to_string()];
1610
1611        let rust_tokens = rust_h.highlight_line_at(&lines, 0, version);
1612        let plain_tokens = plain_h.highlight_line_at(&lines, 0, version);
1613
1614        assert!(
1615            rust_tokens.len() > plain_tokens.len(),
1616            "Rust highlighting should produce more token splits than plain text"
1617        );
1618    }
1619
1620    #[test]
1621    fn sequential_lines_produce_valid_spans() {
1622        // Verify that multiple consecutive lines each produce non-empty, covering spans.
1623        let h = SyntaxHighlighter::new().with_syntax("Rust");
1624        let version = Version::new();
1625        let lines = vec![
1626            "fn add(a: i32, b: i32) -> i32 {".to_string(),
1627            "    a + b".to_string(),
1628            "}".to_string(),
1629        ];
1630
1631        for (i, line) in lines.iter().enumerate() {
1632            let tokens = h.highlight_line_at(&lines, i, version);
1633            let spans = h.tokens_to_spans(line, tokens.as_ref());
1634            let mut reconstructed = String::new();
1635            for s in &spans {
1636                reconstructed.push_str(&line[s.range.start..s.range.end]);
1637            }
1638            assert_eq!(reconstructed, *line, "spans must reconstruct line {}", i);
1639        }
1640    }
1641
1642    // -------------------------------------------------------------------------
1643    // Realistic editing session
1644    // -------------------------------------------------------------------------
1645
1646    #[test]
1647    fn realistic_editing_session() {
1648        // Simulate: open a Rust file, highlight full screen, type on line 5,
1649        // re-highlight visible window, scroll down, scroll back, type again.
1650        let h = SyntaxHighlighter::new().with_syntax("Rust");
1651        let version = Version::new();
1652        let mut lines: Vec<String> = vec![
1653            "use std::collections::HashMap;".to_string(),
1654            "".to_string(),
1655            "fn process(data: &[u8]) -> HashMap<u32, Vec<u8>> {".to_string(),
1656            "    let mut map = HashMap::new();".to_string(),
1657            "    for (i, &b) in data.iter().enumerate() {".to_string(),
1658            "        map.entry(b as u32).or_default().push(i as u8);".to_string(),
1659            "    }".to_string(),
1660            "    map".to_string(),
1661            "}".to_string(),
1662            "".to_string(),
1663            "fn main() {".to_string(),
1664            "    let data = vec![1, 2, 3, 1, 2];".to_string(),
1665            "    let result = process(&data);".to_string(),
1666            "    println!(\"{:?}\", result);".to_string(),
1667            "}".to_string(),
1668        ];
1669
1670        // Initial full-screen highlight.
1671        for i in 0..lines.len() {
1672            let t = h.highlight_line_at(&lines, i, version);
1673            assert!(!t.is_empty() || lines[i].is_empty(), "line {} must be highlighted", i);
1674        }
1675
1676        // Edit line 5: change loop body.
1677        lines[5] = "        map.entry(b as u32).or_insert_with(Vec::new).push(i as u8);".to_string();
1678        h.invalidate_from(5);
1679
1680        // Re-highlight visible window (all 15 lines).
1681        for i in 0..lines.len() {
1682            let t = h.highlight_line_at(&lines, i, version);
1683            assert!(!t.is_empty() || lines[i].is_empty());
1684        }
1685
1686        // Lines before edit should not have been recomputed (same Arc).
1687        // We can't check this here since we discarded the old Arcs, but the key
1688        // invariant is no panic and valid highlighting.
1689
1690        // Simulate append: add a new line at the end.
1691        lines.push("// end of file".to_string());
1692        h.invalidate_from(lines.len() - 1);
1693
1694        let last = h.highlight_line_at(&lines, lines.len() - 1, version);
1695        assert!(!last.is_empty());
1696    }
1697
1698    #[test]
1699    fn many_small_edits_at_same_line_remain_correct() {
1700        // Simulates typing one character at a time on the same line.
1701        let h = SyntaxHighlighter::new().with_syntax("Rust");
1702        let version = Version::new();
1703        let mut lines = vec!["fn main".to_string()];
1704
1705        let suffixes = ["(", ") {", "\n    let x = 0;", "\n}"];
1706        for suffix in &suffixes {
1707            lines[0].push_str(suffix);
1708            h.invalidate_from(0);
1709            let t = h.highlight_line_at(&lines, 0, version);
1710            assert!(!t.is_empty(), "tokens must be non-empty after appending '{}'", suffix);
1711        }
1712    }
1713}