1use 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 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#[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#[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 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 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 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 self.checkpoints.borrow_mut().retain(|c| c.line < from_line);
207
208 let mut cache = self.line_cache.borrow_mut();
209 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 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); 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 const MAX_CHECKPOINTS: usize = 256;
290 if checkpoints.len() > MAX_CHECKPOINTS {
291 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 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 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 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 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 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
465pub 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 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 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 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 let lines = vec!["∞ ()".to_string()];
565 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 let tokens = vec![
581 Token::new(0, 3, Style::default()), Token::new(3, 4, Style::default()), Token::new(4, 8, Style::default()), ];
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 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 for i in 0..300 {
649 if i == 150 { continue; }
650 h.highlight_line_at(&lines, i, version);
651 }
652
653 h.highlight_line_at(&lines, 150, version);
655
656 let checkpoints = h.checkpoints.borrow();
657 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 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 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 for i in 0..200 {
698 h.highlight_line_at(&lines, i, version);
699 }
700
701 let cp_lines: Vec<usize> = h.checkpoints.borrow().iter().map(|c| c.line).collect();
703 assert!(cp_lines.contains(&100));
704
705 h.invalidate_from(100);
707 let cp_after: Vec<usize> = h.checkpoints.borrow().iter().map(|c| c.line).collect();
708 assert!(!cp_after.contains(&100));
710 assert!(cp_after.iter().all(|&l| l < 100));
711
712 for i in 0..200 {
714 h.highlight_line_at(&lines, i, version);
715 }
716
717 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 #[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 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 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 assert!(!Arc::ptr_eq(&t1, &t2));
768 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 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 }
801
802 #[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 for i in 0..10 {
814 h.highlight_line_at(&lines, i, version);
815 }
816
817 h.invalidate_from(0);
818
819 for (i, _line) in lines.iter().enumerate().take(10) {
821 let before_ptr = {
822 h.highlight_line_at(&lines, i, version)
824 };
825 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 let arcs_before: Vec<_> = (0..20)
841 .map(|i| h.highlight_line_at(&lines, i, version))
842 .collect();
843
844 h.invalidate_from(10);
846
847 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 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 h.invalidate_from(1);
891
892 let after0 = h.highlight_line_at(&lines, 0, version);
894 assert!(Arc::ptr_eq(&arc0, &after0), "line 0 must remain cached");
895
896 let after1 = h.highlight_line_at(&lines, 1, version);
898 assert!(!Arc::ptr_eq(&arc1, &after1), "line 1 must be evicted");
899 }
900
901 #[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 lines.insert(0, "// new comment".to_string());
917 h.invalidate_from(0);
918
919 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 lines[10] = "// CHANGED".to_string();
940 h.invalidate_from(10);
941
942 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 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 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 for i in 0..30 {
988 h.highlight_line_at(&lines, i, version);
989 }
990
991 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 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 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 #[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 for i in 0..40 {
1028 h.highlight_line_at(&lines, i, version);
1029 }
1030
1031 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 let first_screen: Vec<_> = (0..40)
1050 .map(|i| h.highlight_line_at(&lines, i, version))
1051 .collect();
1052
1053 for i in 40..80 {
1055 h.highlight_line_at(&lines, i, version);
1056 }
1057
1058 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 let h = SyntaxHighlighter::new();
1070 let version = Version::new();
1071 let lines: Vec<String> = (0..6000).map(|i| format!("// line {}", i)).collect();
1072
1073 for i in 0..40 {
1075 h.highlight_line_at(&lines, i, version);
1076 }
1077
1078 for i in 5000..5040 {
1080 let t = h.highlight_line_at(&lines, i, version);
1081 assert!(!t.is_empty());
1082 }
1083 }
1084
1085 #[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 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 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 #[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 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); 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 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 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 assert!(result.len() <= 50, "result cannot exceed total lines");
1232 }
1233
1234 #[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 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 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 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 #[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 assert!(!state.checkpoints.is_empty());
1359 }
1360
1361 #[test]
1362 fn from_state_has_empty_line_cache() {
1363 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 assert!(!Arc::ptr_eq(&arc_original, &arc_restored));
1378 assert_eq!(arc_original.len(), arc_restored.len());
1379 }
1380
1381 #[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 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 #[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 let rust_h = SyntaxHighlighter::new().with_syntax("Rust");
1426 let plain_h = SyntaxHighlighter::new(); 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 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 #[test]
1466 fn realistic_editing_session() {
1467 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 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 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 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.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 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}