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