use std::cell::RefCell;
use std::collections::BTreeSet;
use lru::LruCache;
use std::num::NonZeroUsize;
use std::ops::Range;
use std::path::Path;
use std::sync::Arc;
use syntect::{
easy::HighlightLines,
highlighting::{FontStyle, HighlightState, Highlighter, Style, ThemeSet},
parsing::{ParseState, SyntaxReference, SyntaxSet},
};
use once_cell::sync::Lazy;
use crate::editor::buffer::Version;
static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_nonewlines);
static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
pub const DEFAULT_CHECKPOINT_INTERVAL: usize = 100;
#[derive(Debug, Clone, PartialEq)]
pub struct Token {
pub range: Range<usize>,
pub style: Style,
}
impl Token {
pub fn new(start: usize, end: usize, style: Style) -> Self {
Self {
range: start..end,
style,
}
}
}
#[derive(Debug, Clone)]
pub struct StyledSpan {
pub range: Range<usize>,
pub style: Style,
}
#[derive(Debug, Clone)]
struct Checkpoint {
line: usize,
highlight_state: HighlightState,
parse_state: ParseState,
}
#[derive(Debug, Clone)]
struct CachedLine {
version: Version,
tokens: Arc<Vec<Token>>,
}
#[derive(Clone)]
pub struct HighlighterState {
pub theme_name: String,
pub syntax_name: String,
pub checkpoint_interval: usize,
checkpoints: Vec<Checkpoint>,
}
const _: () = {
fn _assert_send<T: Send>() {}
fn _check() {
_assert_send::<HighlightState>();
_assert_send::<ParseState>();
_assert_send::<Checkpoint>();
_assert_send::<HighlighterState>();
}
};
#[derive(Debug)]
pub struct SyntaxHighlighter {
pub theme_name: String,
pub syntax_name: String,
checkpoint_interval: usize,
checkpoints: RefCell<Vec<Checkpoint>>,
line_cache: RefCell<LruCache<usize, CachedLine>>,
cache_line_index: RefCell<BTreeSet<usize>>,
}
impl Default for SyntaxHighlighter {
fn default() -> Self {
Self::new()
}
}
impl Clone for SyntaxHighlighter {
fn clone(&self) -> Self {
Self {
theme_name: self.theme_name.clone(),
syntax_name: self.syntax_name.clone(),
checkpoint_interval: self.checkpoint_interval,
checkpoints: RefCell::new(self.checkpoints.borrow().clone()),
line_cache: RefCell::new(LruCache::new(NonZeroUsize::new(10_000).unwrap())),
cache_line_index: RefCell::new(BTreeSet::new()),
}
}
}
impl SyntaxHighlighter {
pub fn new() -> Self {
Self {
theme_name: "base16-ocean.dark".to_string(),
syntax_name: "Plain Text".to_string(),
checkpoint_interval: DEFAULT_CHECKPOINT_INTERVAL,
checkpoints: RefCell::new(Vec::new()),
line_cache: RefCell::new(LruCache::new(NonZeroUsize::new(10_000).unwrap())),
cache_line_index: RefCell::new(BTreeSet::new()),
}
}
pub fn for_path(path: &Path) -> Self {
let syntax_name = SYNTAX_SET
.find_syntax_for_file(path)
.ok()
.flatten()
.map(|s| s.name.clone())
.unwrap_or_else(|| "Plain Text".to_string());
Self::new().with_syntax(&syntax_name)
}
pub fn plain() -> Self {
Self::new()
}
pub fn with_theme(mut self, theme_name: &str) -> Self {
self.theme_name = theme_name.to_string();
self
}
pub fn with_syntax(mut self, syntax_name: &str) -> Self {
self.syntax_name = syntax_name.to_string();
self
}
pub fn snapshot_state(&self) -> HighlighterState {
HighlighterState {
theme_name: self.theme_name.clone(),
syntax_name: self.syntax_name.clone(),
checkpoint_interval: self.checkpoint_interval,
checkpoints: self.checkpoints.borrow().clone(),
}
}
pub fn from_state(state: HighlighterState) -> Self {
Self {
theme_name: state.theme_name,
syntax_name: state.syntax_name,
checkpoint_interval: state.checkpoint_interval,
checkpoints: RefCell::new(state.checkpoints),
line_cache: RefCell::new(LruCache::new(NonZeroUsize::new(10_000).unwrap())),
cache_line_index: RefCell::new(BTreeSet::new()),
}
}
fn get_syntax(&self) -> &SyntaxReference {
SYNTAX_SET
.find_syntax_by_name(&self.syntax_name)
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text())
}
pub fn clear_cache(&self) {
self.checkpoints.borrow_mut().clear();
self.line_cache.borrow_mut().clear();
self.cache_line_index.borrow_mut().clear();
}
pub fn invalidate_from(&self, from_line: usize) {
self.checkpoints.borrow_mut().retain(|c| c.line < from_line);
let mut cache = self.line_cache.borrow_mut();
let mut index = self.cache_line_index.borrow_mut();
let to_remove: Vec<usize> = index.range(from_line..).copied().collect();
for k in to_remove {
cache.pop(&k);
index.remove(&k);
}
}
fn get_or_create_checkpoint(&self, lines: &[String], line: usize) -> Checkpoint {
let interval = self.checkpoint_interval;
let checkpoint_line = (line / interval) * interval;
if let Some(cp) = self
.checkpoints
.borrow()
.iter()
.find(|c| c.line == checkpoint_line)
.cloned()
{
return cp;
}
let checkpoints_borrow = self.checkpoints.borrow();
let prev_checkpoint = checkpoints_borrow
.iter()
.filter(|c| c.line < checkpoint_line)
.max_by_key(|c| c.line)
.cloned();
let theme = &THEME_SET.themes[&self.theme_name];
let highlighter = Highlighter::new(theme);
let (highlight_state, parse_state, start_line) = if let Some(ref prev) = prev_checkpoint {
(
prev.highlight_state.clone(),
prev.parse_state.clone(),
prev.line,
)
} else {
let parse = ParseState::new(self.get_syntax());
let high =
HighlightState::new(&highlighter, syntect::parsing::ScopeStack::new());
(high, parse, 0)
};
drop(checkpoints_borrow);
let mut h = HighlightLines::from_state(theme, highlight_state, parse_state);
for i in start_line..checkpoint_line {
if i < lines.len() {
let _ = h.highlight_line(&lines[i], &SYNTAX_SET);
}
}
let (highlight_state, parse_state) = h.state();
let checkpoint = Checkpoint {
line: checkpoint_line,
highlight_state,
parse_state,
};
let mut checkpoints = self.checkpoints.borrow_mut();
let mut insert_pos = checkpoints
.iter()
.position(|c| c.line > checkpoint_line)
.unwrap_or(checkpoints.len());
checkpoints.insert(insert_pos, checkpoint);
const MAX_CHECKPOINTS: usize = 256;
if checkpoints.len() > MAX_CHECKPOINTS {
let mut distances: Vec<(usize, usize)> = checkpoints
.iter()
.enumerate()
.map(|(i, c)| (i, c.line.abs_diff(checkpoint_line)))
.collect();
distances.sort_by_key(|&(_, d)| d);
use std::collections::HashSet;
let keep: HashSet<usize> = distances.into_iter().take(MAX_CHECKPOINTS).map(|(i, _)| i).collect();
let mut kept = Vec::with_capacity(MAX_CHECKPOINTS);
for (i, cp) in checkpoints.iter().enumerate() {
if keep.contains(&i) {
kept.push(cp.clone());
}
}
*checkpoints = kept;
insert_pos = checkpoints
.iter()
.position(|c| c.line == checkpoint_line)
.unwrap_or(checkpoints.len());
}
checkpoints[insert_pos].clone()
}
fn get_cached_line(&self, line: usize, version: Version) -> Option<Arc<Vec<Token>>> {
let mut cache = self.line_cache.borrow_mut();
match cache.get(&line) {
Some(cached) if cached.version == version => Some(cached.tokens.clone()),
_ => None,
}
}
fn cache_line(&self, line: usize, version: Version, tokens: Arc<Vec<Token>>) {
let mut cache = self.line_cache.borrow_mut();
let mut index = self.cache_line_index.borrow_mut();
if let Some((evicted_key, _)) = cache.push(line, CachedLine { version, tokens })
&& evicted_key != line {
index.remove(&evicted_key);
}
index.insert(line);
}
pub fn highlight_line_at(&self, lines: &[String], line: usize, version: Version) -> Arc<Vec<Token>> {
if let Some(tokens) = self.get_cached_line(line, version) {
return tokens;
}
let checkpoint = self.get_or_create_checkpoint(lines, line);
let theme = &THEME_SET.themes[&self.theme_name];
let mut h = HighlightLines::from_state(
theme,
checkpoint.highlight_state.clone(),
checkpoint.parse_state.clone(),
);
for i in checkpoint.line..line {
if i < lines.len() {
let _ = h.highlight_line(&lines[i], &SYNTAX_SET);
}
}
let highlighted = if line < lines.len() {
h.highlight_line(&lines[line], &SYNTAX_SET)
.unwrap_or_default()
} else {
Vec::new()
};
let tokens_vec = self.tokens_from_highlight(&highlighted);
let tokens_arc = Arc::new(tokens_vec);
self.cache_line(line, version, tokens_arc.clone());
tokens_arc
}
fn tokens_from_highlight(&self, highlighted: &[(Style, &str)]) -> Vec<Token> {
let mut tokens = Vec::new();
let mut byte_offset = 0;
for (style, text) in highlighted {
let end = byte_offset + text.len();
tokens.push(Token::new(byte_offset, end, *style));
byte_offset = end;
}
tokens
}
pub fn highlight_range(
&self,
all_lines: &[String],
version: Version,
start_row: usize,
count: usize,
) -> Vec<Vec<StyledSpan>> {
let end_row = (start_row + count).min(all_lines.len());
(start_row..end_row)
.map(|i| {
let tokens = self.highlight_line_at(all_lines, i, version);
self.tokens_to_spans(&all_lines[i], tokens.as_ref())
})
.collect()
}
pub fn highlight_tokens(
&self,
all_lines: &[String],
version: Version,
start_line: usize,
count: usize,
) -> Vec<Arc<Vec<Token>>> {
let end_line = (start_line + count).min(all_lines.len());
(start_line..end_line)
.map(|i| self.highlight_line_at(all_lines, i, version))
.collect()
}
pub fn highlight_tokens_cancellable(
&self,
all_lines: &[String],
version: Version,
start_line: usize,
count: usize,
is_cancelled: impl Fn() -> bool,
) -> Vec<Arc<Vec<Token>>> {
let end_line = (start_line + count).min(all_lines.len());
let mut results = Vec::new();
for i in start_line..end_line {
if is_cancelled() {
return results;
}
results.push(self.highlight_line_at(all_lines, i, version));
}
results
}
pub fn highlight_lines_batch(
&self,
all_lines: &[String],
version: Version,
requested_lines: &[usize],
) -> Vec<(usize, Arc<Vec<Token>>)> {
if requested_lines.is_empty() {
return Vec::new();
}
let interval = self.checkpoint_interval;
let theme = &THEME_SET.themes[&self.theme_name];
let mut results = Vec::with_capacity(requested_lines.len());
let mut parse_pos: usize = 0;
let mut h: Option<HighlightLines> = None;
for &line in requested_lines {
if line >= all_lines.len() {
results.push((line, Arc::new(Vec::new())));
continue;
}
if let Some(tokens) = self.get_cached_line(line, version) {
results.push((line, tokens));
h = None;
continue;
}
let can_continue =
h.is_some() && parse_pos <= line && (line - parse_pos) <= interval;
if !can_continue {
let checkpoint = self.get_or_create_checkpoint(all_lines, line);
h = Some(HighlightLines::from_state(
theme,
checkpoint.highlight_state.clone(),
checkpoint.parse_state.clone(),
));
parse_pos = checkpoint.line;
}
let highlighter = h.as_mut().unwrap();
while parse_pos < line {
if parse_pos < all_lines.len() {
let _ = highlighter.highlight_line(&all_lines[parse_pos], &SYNTAX_SET);
}
parse_pos += 1;
}
let highlighted = highlighter
.highlight_line(&all_lines[line], &SYNTAX_SET)
.unwrap_or_default();
parse_pos = line + 1;
let tokens_vec = self.tokens_from_highlight(&highlighted);
let tokens_arc = Arc::new(tokens_vec);
self.cache_line(line, version, tokens_arc.clone());
results.push((line, tokens_arc));
}
results
}
pub fn tokens_to_spans(&self, _line: &str, tokens: &[Token]) -> Vec<StyledSpan> {
tokens
.iter()
.map(|t| StyledSpan { range: t.range.clone(), style: t.style })
.collect()
}
}
#[inline]
pub fn to_ratatui_color(c: syntect::highlighting::Color) -> ratatui::style::Color {
ratatui::style::Color::Rgb(c.r, c.g, c.b)
}
pub fn to_ratatui_style(style: &Style) -> ratatui::style::Style {
use ratatui::style::Modifier;
let mut s = ratatui::style::Style::default().fg(to_ratatui_color(style.foreground));
if style.font_style.contains(FontStyle::BOLD) {
s = s.add_modifier(Modifier::BOLD);
}
if style.font_style.contains(FontStyle::ITALIC) {
s = s.add_modifier(Modifier::ITALIC);
}
if style.font_style.contains(FontStyle::UNDERLINE) {
s = s.add_modifier(Modifier::UNDERLINED);
}
s
}
pub const BRACE_PAIRS: &[(char, char)] = &[('{', '}'), ('(', ')'), ('[', ']')];
pub fn find_matching_brace(
lines: &[String],
cursor_row: usize,
cursor_col: usize,
) -> Option<(usize, usize)> {
let line = lines.get(cursor_row)?;
let ch = line.chars().nth(cursor_col)?;
let (open, close, forward) = BRACE_PAIRS.iter().find_map(|&(o, c)| {
if ch == o {
Some((o, c, true))
} else if ch == c {
Some((o, c, false))
} else {
None
}
})?;
if forward {
let mut depth = 0i32;
for (row, line) in lines.iter().enumerate().skip(cursor_row) {
let start_char = if row == cursor_row { cursor_col } else { 0 };
for (char_idx, c) in line.chars().enumerate().skip(start_char) {
if c == open {
depth += 1;
}
if c == close {
depth -= 1;
if depth == 0 && (row, char_idx) != (cursor_row, cursor_col) {
return Some((row, char_idx));
}
}
}
}
} else {
let mut depth = 0i32;
for row in (0..=cursor_row).rev() {
let line = &lines[row];
let chars: Vec<char> = line.chars().collect();
let end_char = if row == cursor_row {
cursor_col + 1
} else {
chars.len()
};
for (char_idx, c) in chars.into_iter().enumerate().take(end_char).rev() {
if c == close {
depth += 1;
}
if c == open {
depth -= 1;
if depth == 0 && (row, char_idx) != (cursor_row, cursor_col) {
return Some((row, char_idx));
}
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn brace_match_forward() {
let lines = vec!["fn f() {".to_string(), " x".to_string(), "}".to_string()];
assert_eq!(find_matching_brace(&lines, 0, 7), Some((2, 0)));
}
#[test]
fn brace_match_backward() {
let lines = vec!["fn f() {".to_string(), " x".to_string(), "}".to_string()];
assert_eq!(find_matching_brace(&lines, 2, 0), Some((0, 7)));
}
#[test]
fn brace_match_nested() {
let lines = vec!["if (a && (b || c)) {".to_string(), "}".to_string()];
assert_eq!(find_matching_brace(&lines, 0, 3), Some((0, 17)));
}
#[test]
fn no_match_for_plain_char() {
let lines = vec!["let x = 1;".to_string()];
assert_eq!(find_matching_brace(&lines, 0, 4), None);
}
#[test]
fn brace_match_with_utf8() {
let lines = vec!["∞ ()".to_string()];
assert_eq!(find_matching_brace(&lines, 0, 2), Some((0, 3)));
assert_eq!(find_matching_brace(&lines, 0, 3), Some((0, 2)));
}
#[test]
fn tokens_to_spans_with_utf8_characters() {
let highlighter = SyntaxHighlighter::new();
let line = "∞ code";
let tokens = vec![
Token::new(0, 3, Style::default()), Token::new(3, 4, Style::default()), Token::new(4, 8, Style::default()), ];
let spans = highlighter.tokens_to_spans(line, &tokens);
assert_eq!(spans.len(), 3);
assert_eq!(&line[spans[0].range.start..spans[0].range.end], "∞");
assert_eq!(&line[spans[1].range.start..spans[1].range.end], " ");
assert_eq!(&line[spans[2].range.start..spans[2].range.end], "code");
}
#[test]
fn token_creation() {
let token = Token::new(0, 5, Style::default());
assert_eq!(token.range, 0..5);
}
#[test]
fn highlighting_produces_tokens() {
let highlighter = SyntaxHighlighter::new();
let lines = vec!["fn main() {}".to_string()];
let tokens = highlighter.highlight_line_at(&lines, 0, Version::new());
assert!(!tokens.is_empty());
}
#[test]
fn cache_works() {
let highlighter = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec!["fn main() {}".to_string()];
let tokens1 = highlighter.highlight_line_at(&lines, 0, version);
let tokens2 = highlighter.highlight_line_at(&lines, 0, version);
assert_eq!(tokens1.len(), tokens2.len());
}
#[test]
fn checkpoint_interval() {
let highlighter = SyntaxHighlighter::new();
let mut h = highlighter.clone();
h.checkpoint_interval = 50;
let version = Version::new();
let lines: Vec<String> = (0..200).map(|i| format!("line {}", i)).collect();
for i in 0..200 {
h.highlight_line_at(&lines, i, version);
}
let checkpoints = h.checkpoints.borrow();
assert!(!checkpoints.is_empty());
assert!(checkpoints.iter().all(|c| c.line % 50 == 0));
}
#[test]
fn checkpoint_eviction_keeps_nearby_checkpoints() {
let mut h = SyntaxHighlighter::new();
h.checkpoint_interval = 1;
let version = Version::new();
let lines: Vec<String> = (0..300).map(|i| format!("line {}", i)).collect();
for i in 0..300 {
if i == 150 { continue; }
h.highlight_line_at(&lines, i, version);
}
h.highlight_line_at(&lines, 150, version);
let checkpoints = h.checkpoints.borrow();
assert!(checkpoints.len() <= 256);
let cp_lines: Vec<usize> = checkpoints.iter().map(|c| c.line).collect();
assert!(cp_lines.contains(&150));
let min = *cp_lines.first().unwrap();
let max = *cp_lines.last().unwrap();
assert!(min <= 150 && 150 <= max);
assert!(max - min < 256);
}
#[test]
fn spans_from_highlight_cover_entire_line() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec!["fn test_span_cover() { let x = 1; }".to_string()];
let tokens = h.highlight_line_at(&lines, 0, version);
let spans = h.tokens_to_spans(&lines[0], tokens.as_ref());
let mut reconstructed = String::new();
for s in &spans {
reconstructed.push_str(&lines[0][s.range.start..s.range.end]);
}
assert_eq!(reconstructed, lines[0]);
}
#[test]
fn invalidate_from_boundary_behaviour() {
let mut h = SyntaxHighlighter::new();
h.checkpoint_interval = 50;
let version = Version::new();
let lines: Vec<String> = (0..200).map(|i| format!("line {}", i)).collect();
for i in 0..200 {
h.highlight_line_at(&lines, i, version);
}
let cp_lines: Vec<usize> = h.checkpoints.borrow().iter().map(|c| c.line).collect();
assert!(cp_lines.contains(&100));
h.invalidate_from(100);
let cp_after: Vec<usize> = h.checkpoints.borrow().iter().map(|c| c.line).collect();
assert!(!cp_after.contains(&100));
assert!(cp_after.iter().all(|&l| l < 100));
for i in 0..200 {
h.highlight_line_at(&lines, i, version);
}
h.invalidate_from(101);
let cp_after_mid: Vec<usize> = h.checkpoints.borrow().iter().map(|c| c.line).collect();
assert!(cp_after_mid.contains(&100));
}
#[test]
fn cache_miss_on_version_change() {
let h = SyntaxHighlighter::new();
let lines = vec!["fn main() {}".to_string()];
let v1 = Version::from_raw(0);
let v2 = Version::from_raw(1);
let t1 = h.highlight_line_at(&lines, 0, v1);
let t2 = h.highlight_line_at(&lines, 0, v2);
assert_eq!(t1.len(), t2.len());
}
#[test]
fn cache_hit_returns_same_arc() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec!["let x = 42;".to_string()];
let t1 = h.highlight_line_at(&lines, 0, version);
let t2 = h.highlight_line_at(&lines, 0, version);
assert!(Arc::ptr_eq(&t1, &t2));
}
#[test]
fn clear_cache_forces_recomputation() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec!["let x = 42;".to_string()];
let t1 = h.highlight_line_at(&lines, 0, version);
h.clear_cache();
let t2 = h.highlight_line_at(&lines, 0, version);
assert!(!Arc::ptr_eq(&t1, &t2));
assert_eq!(t1.len(), t2.len());
}
#[test]
fn clear_cache_removes_all_checkpoints() {
let mut h = SyntaxHighlighter::new();
h.checkpoint_interval = 10;
let version = Version::new();
let lines: Vec<String> = (0..50).map(|i| format!("line {}", i)).collect();
for i in 0..50 {
h.highlight_line_at(&lines, i, version);
}
assert!(!h.checkpoints.borrow().is_empty());
h.clear_cache();
assert!(h.checkpoints.borrow().is_empty());
}
#[test]
fn lru_eviction_does_not_panic() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines: Vec<String> = (0..10_200).map(|i| format!("// line {}", i)).collect();
for i in 0..10_200 {
h.highlight_line_at(&lines, i, version);
}
}
#[test]
fn invalidate_from_zero_clears_line_cache_entirely() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines: Vec<String> = (0..10).map(|i| format!("let x{} = {};", i, i)).collect();
for i in 0..10 {
h.highlight_line_at(&lines, i, version);
}
h.invalidate_from(0);
for (i, _line) in lines.iter().enumerate().take(10) {
let before_ptr = {
h.highlight_line_at(&lines, i, version)
};
let after_ptr = h.highlight_line_at(&lines, i, version);
assert!(Arc::ptr_eq(&before_ptr, &after_ptr), "line {} should now be cached", i);
}
}
#[test]
fn invalidate_from_preserves_lines_before_edit_point() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines: Vec<String> = (0..20).map(|i| format!("let x{} = {};", i, i)).collect();
let arcs_before: Vec<_> = (0..20)
.map(|i| h.highlight_line_at(&lines, i, version))
.collect();
h.invalidate_from(10);
for (i, _line) in arcs_before.iter().enumerate().take(10) {
let after = h.highlight_line_at(&lines, i, version);
assert!(
Arc::ptr_eq(&arcs_before[i], &after),
"line {} before invalidation point should still be cached", i
);
}
}
#[test]
fn invalidate_from_removes_lines_at_and_after_edit_point() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines: Vec<String> = (0..20).map(|i| format!("let x{} = {};", i, i)).collect();
let arcs_before: Vec<_> = (0..20)
.map(|i| h.highlight_line_at(&lines, i, version))
.collect();
h.invalidate_from(10);
for (i, _line) in arcs_before.iter().enumerate().take(20).skip(10) {
let after = h.highlight_line_at(&lines, i, version);
assert!(
!Arc::ptr_eq(&arcs_before[i], &after),
"line {} at/after invalidation point should be evicted", i
);
}
}
#[test]
fn invalidate_from_exact_boundary_removes_that_line() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec!["fn a() {}".to_string(), "fn b() {}".to_string()];
let arc0 = h.highlight_line_at(&lines, 0, version);
let arc1 = h.highlight_line_at(&lines, 1, version);
h.invalidate_from(1);
let after0 = h.highlight_line_at(&lines, 0, version);
assert!(Arc::ptr_eq(&arc0, &after0), "line 0 must remain cached");
let after1 = h.highlight_line_at(&lines, 1, version);
assert!(!Arc::ptr_eq(&arc1, &after1), "line 1 must be evicted");
}
#[test]
fn edit_at_beginning_invalidates_all_lines() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let mut lines: Vec<String> = (0..10).map(|i| format!("let x{} = {};", i, i)).collect();
let arcs_before: Vec<_> = (0..10)
.map(|i| h.highlight_line_at(&lines, i, version))
.collect();
lines.insert(0, "// new comment".to_string());
h.invalidate_from(0);
for (i, _line) in arcs_before.iter().enumerate().take(10) {
let after = h.highlight_line_at(&lines, i, version);
assert!(!Arc::ptr_eq(&arcs_before[i], &after),
"line {} must be recomputed after edit at line 0", i);
}
}
#[test]
fn edit_at_middle_preserves_preceding_lines() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let mut lines: Vec<String> = (0..20).map(|i| format!("// line {}", i)).collect();
let arcs_before: Vec<_> = (0..20)
.map(|i| h.highlight_line_at(&lines, i, version))
.collect();
lines[10] = "// CHANGED".to_string();
h.invalidate_from(10);
for (i, _line) in arcs_before.iter().enumerate().take(10) {
let after = h.highlight_line_at(&lines, i, version);
assert!(Arc::ptr_eq(&arcs_before[i], &after),
"line {} before edit should still be cached", i);
}
for (i, _line) in arcs_before.iter().enumerate().take(20).skip(10) {
let after = h.highlight_line_at(&lines, i, version);
assert!(!Arc::ptr_eq(&arcs_before[i], &after),
"line {} at/after edit point should be recomputed", i);
}
}
#[test]
fn edit_at_last_line_preserves_rest() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let mut lines: Vec<String> = (0..5).map(|i| format!("// line {}", i)).collect();
let arcs: Vec<_> = (0..5)
.map(|i| h.highlight_line_at(&lines, i, version))
.collect();
lines[4] = "// CHANGED LAST".to_string();
h.invalidate_from(4);
for (i, _line) in arcs.iter().enumerate().take(4) {
let after = h.highlight_line_at(&lines, i, version);
assert!(Arc::ptr_eq(&arcs[i], &after), "line {} must stay cached", i);
}
let after4 = h.highlight_line_at(&lines, 4, version);
assert!(!Arc::ptr_eq(&arcs[4], &after4), "last line must be recomputed");
}
#[test]
fn sequential_edits_produce_consistent_results() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let mut lines: Vec<String> = (0..30).map(|i| format!("let v{} = {};", i, i)).collect();
for i in 0..30 {
h.highlight_line_at(&lines, i, version);
}
lines[25] = "let v25 = 9999;".to_string();
h.invalidate_from(25);
for i in 0..30 {
let t = h.highlight_line_at(&lines, i, version);
assert!(!t.is_empty() || lines[i].is_empty(), "line {} should produce tokens", i);
}
lines[10] = "let v10 = 8888;".to_string();
h.invalidate_from(10);
for i in 0..30 {
let t = h.highlight_line_at(&lines, i, version);
assert!(!t.is_empty() || lines[i].is_empty());
}
lines[0] = "// file header".to_string();
h.invalidate_from(0);
for i in 0..30 {
let t = h.highlight_line_at(&lines, i, version);
assert!(!t.is_empty() || lines[i].is_empty());
}
}
#[test]
fn scroll_down_highlights_new_lines() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines: Vec<String> = (0..100).map(|i| format!("// line {}", i)).collect();
for i in 0..40 {
h.highlight_line_at(&lines, i, version);
}
let second_screen: Vec<_> = (40..80)
.map(|i| h.highlight_line_at(&lines, i, version))
.collect();
assert_eq!(second_screen.len(), 40);
for tokens in &second_screen {
assert!(!tokens.is_empty());
}
}
#[test]
fn scroll_back_up_uses_cache() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines: Vec<String> = (0..100).map(|i| format!("// line {}", i)).collect();
let first_screen: Vec<_> = (0..40)
.map(|i| h.highlight_line_at(&lines, i, version))
.collect();
for i in 40..80 {
h.highlight_line_at(&lines, i, version);
}
for (i, _line) in first_screen.iter().enumerate().take(40) {
let after = h.highlight_line_at(&lines, i, version);
assert!(Arc::ptr_eq(&first_screen[i], &after),
"line {} should be cache-hit on scroll back", i);
}
}
#[test]
fn highlight_window_jump_no_panic() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines: Vec<String> = (0..6000).map(|i| format!("// line {}", i)).collect();
for i in 0..40 {
h.highlight_line_at(&lines, i, version);
}
for i in 5000..5040 {
let t = h.highlight_line_at(&lines, i, version);
assert!(!t.is_empty());
}
}
#[test]
fn highlight_range_returns_correct_count() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines: Vec<String> = (0..20).map(|i| format!("let x{} = {};", i, i)).collect();
let spans = h.highlight_range(&lines, version, 5, 10);
assert_eq!(spans.len(), 10, "should return exactly 10 rows");
}
#[test]
fn highlight_range_zero_count_returns_empty() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec!["let x = 1;".to_string()];
let spans = h.highlight_range(&lines, version, 0, 0);
assert!(spans.is_empty());
}
#[test]
fn highlight_range_clamped_to_buffer_length() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines: Vec<String> = (0..5).map(|i| format!("// {}", i)).collect();
let spans = h.highlight_range(&lines, version, 0, 100);
assert_eq!(spans.len(), 5);
}
#[test]
fn highlight_range_spans_cover_full_line() {
let h = SyntaxHighlighter::new().with_syntax("Rust");
let version = Version::new();
let lines = vec![
"fn main() {".to_string(),
" let x = 42;".to_string(),
"}".to_string(),
];
let all_spans = h.highlight_range(&lines, version, 0, 3);
for (row, spans) in all_spans.iter().enumerate() {
let mut reconstructed = String::new();
for s in spans {
reconstructed.push_str(&lines[row][s.range.start..s.range.end]);
}
assert_eq!(reconstructed, lines[row], "spans must reconstruct line {}", row);
}
}
#[test]
fn highlight_tokens_matches_individual_highlight_line_at() {
let h = SyntaxHighlighter::new().with_syntax("Rust");
let version = Version::new();
let lines: Vec<String> = (0..10).map(|i| format!("let v{} = {};", i, i)).collect();
let batch = h.highlight_tokens(&lines, version, 0, 10);
let h2 = SyntaxHighlighter::new().with_syntax("Rust");
for (i, _line) in batch.iter().enumerate().take(10) {
let individual = h2.highlight_line_at(&lines, i, version);
assert_eq!(
batch[i].len(), individual.len(),
"batch and individual token counts must match for line {}", i
);
}
}
#[test]
fn highlight_tokens_beyond_bounds_clamped() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec!["one".to_string(), "two".to_string()];
let tokens = h.highlight_tokens(&lines, version, 0, 1000);
assert_eq!(tokens.len(), 2, "should clamp to buffer length");
}
#[test]
fn batch_matches_individual() {
let h1 = SyntaxHighlighter::new().with_syntax("Rust");
let version = Version::new();
let lines: Vec<String> = (0..20).map(|i| format!("let v{} = {};", i, i)).collect();
let requested: Vec<usize> = (0..20).collect();
let batch = h1.highlight_lines_batch(&lines, version, &requested);
let h2 = SyntaxHighlighter::new().with_syntax("Rust");
for (line, tokens) in &batch {
let individual = h2.highlight_line_at(&lines, *line, version);
assert_eq!(
tokens.len(), individual.len(),
"batch and individual token counts must match for line {}", line
);
}
}
#[test]
fn batch_empty_request() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec!["hello".to_string()];
let result = h.highlight_lines_batch(&lines, version, &[]);
assert!(result.is_empty());
}
#[test]
fn batch_out_of_bounds_returns_empty_tokens() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec!["hello".to_string()];
let result = h.highlight_lines_batch(&lines, version, &[0, 5, 100]);
assert_eq!(result.len(), 3);
assert!(!result[0].1.is_empty()); assert!(result[1].1.is_empty()); assert!(result[2].1.is_empty()); }
#[test]
fn batch_with_gaps() {
let h1 = SyntaxHighlighter::new().with_syntax("Rust");
let version = Version::new();
let lines: Vec<String> = (0..200).map(|i| format!("let v{} = {};", i, i)).collect();
let requested = vec![0, 5, 50, 100, 150, 199];
let batch = h1.highlight_lines_batch(&lines, version, &requested);
let h2 = SyntaxHighlighter::new().with_syntax("Rust");
for (line, tokens) in &batch {
let individual = h2.highlight_line_at(&lines, *line, version);
assert_eq!(
tokens.len(), individual.len(),
"batch and individual must match for line {}", line
);
}
}
#[test]
fn batch_reuses_state_for_adjacent_lines() {
let h1 = SyntaxHighlighter::new().with_syntax("Rust");
let version = Version::new();
let lines: Vec<String> = (0..50).map(|i| format!("fn f{}() {{ }}", i)).collect();
let requested: Vec<usize> = (10..30).collect();
let batch = h1.highlight_lines_batch(&lines, version, &requested);
assert_eq!(batch.len(), 20);
let h2 = SyntaxHighlighter::new().with_syntax("Rust");
for (line, tokens) in &batch {
let individual = h2.highlight_line_at(&lines, *line, version);
assert_eq!(tokens.len(), individual.len(), "mismatch at line {}", line);
}
}
#[test]
fn cancellable_no_cancel_same_as_normal() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines: Vec<String> = (0..10).map(|i| format!("// line {}", i)).collect();
let normal = h.highlight_tokens(&lines, version, 0, 10);
let h2 = SyntaxHighlighter::new();
let cancellable = h2.highlight_tokens_cancellable(&lines, version, 0, 10, || false);
assert_eq!(normal.len(), cancellable.len());
for i in 0..normal.len() {
assert_eq!(normal[i].len(), cancellable[i].len(),
"token count for line {} must match", i);
}
}
#[test]
fn cancellable_cancel_before_start_returns_empty() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines: Vec<String> = (0..10).map(|i| format!("// line {}", i)).collect();
let result = h.highlight_tokens_cancellable(&lines, version, 0, 10, || true);
assert!(result.is_empty(), "cancelled before start must return empty vec");
}
#[test]
fn cancellable_cancel_mid_way_returns_partial() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines: Vec<String> = (0..50).map(|i| format!("// line {}", i)).collect();
let cancelled = Arc::new(AtomicBool::new(false));
let cancelled_clone = cancelled.clone();
let handle = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_micros(1));
cancelled_clone.store(true, Ordering::SeqCst);
});
let result = h.highlight_tokens_cancellable(
&lines, version, 0, 50,
|| cancelled.load(Ordering::SeqCst),
);
handle.join().unwrap();
assert!(result.len() <= 50, "result cannot exceed total lines");
}
#[test]
fn empty_lines_array_returns_empty() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines: Vec<String> = vec![];
let tokens = h.highlight_line_at(&lines, 0, version);
assert!(tokens.is_empty());
let range = h.highlight_range(&lines, version, 0, 10);
assert!(range.is_empty());
}
#[test]
fn out_of_bounds_line_returns_empty_tokens() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec!["let x = 1;".to_string()];
let tokens = h.highlight_line_at(&lines, 999, version);
assert!(tokens.is_empty(), "OOB line must return empty tokens, not panic");
}
#[test]
fn single_empty_string_line_no_panic() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec!["".to_string()];
let tokens = h.highlight_line_at(&lines, 0, version);
let _ = tokens;
}
#[test]
fn very_long_line_no_panic() {
let h = SyntaxHighlighter::new().with_syntax("Rust");
let version = Version::new();
let long_line = "x".repeat(100_000);
let lines = vec![format!("let s = \"{}\";", long_line)];
let tokens = h.highlight_line_at(&lines, 0, version);
assert!(!tokens.is_empty());
}
#[test]
fn unicode_heavy_line_no_panic() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec!["// ∞ ★ 𝓤𝓷𝓲𝓬𝓸𝓭𝓮 αβγδ 日本語 العربية".to_string()];
let tokens = h.highlight_line_at(&lines, 0, version);
assert!(!tokens.is_empty());
}
#[test]
fn line_of_only_whitespace_no_panic() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec![" \t ".to_string()];
let _ = h.highlight_line_at(&lines, 0, version);
}
#[test]
fn null_bytes_in_line_no_panic() {
let h = SyntaxHighlighter::new();
let version = Version::new();
let lines = vec!["normal text".to_string()];
let _ = h.highlight_line_at(&lines, 0, version);
}
#[test]
fn for_path_rs_detects_rust_syntax() {
use std::path::Path;
let h = SyntaxHighlighter::for_path(Path::new("src/main.rs"));
assert_eq!(h.syntax_name, "Rust");
}
#[test]
fn for_path_unknown_extension_uses_plain_text() {
use std::path::Path;
let h = SyntaxHighlighter::for_path(Path::new("file.xyzdoesnotexist123"));
assert_eq!(h.syntax_name, "Plain Text");
}
#[test]
fn snapshot_state_roundtrip_preserves_config() {
let original = SyntaxHighlighter::new()
.with_theme("base16-ocean.dark")
.with_syntax("Rust");
let state = original.snapshot_state();
assert_eq!(state.theme_name, "base16-ocean.dark");
assert_eq!(state.syntax_name, "Rust");
let restored = SyntaxHighlighter::from_state(state);
assert_eq!(restored.theme_name, "base16-ocean.dark");
assert_eq!(restored.syntax_name, "Rust");
}
#[test]
fn snapshot_state_carries_accumulated_checkpoints() {
let mut h = SyntaxHighlighter::new();
h.checkpoint_interval = 10;
let version = Version::new();
let lines: Vec<String> = (0..30).map(|i| format!("// line {}", i)).collect();
for i in 0..30 {
h.highlight_line_at(&lines, i, version);
}
let state = h.snapshot_state();
assert!(!state.checkpoints.is_empty());
}
#[test]
fn from_state_has_empty_line_cache() {
let mut h = SyntaxHighlighter::new();
h.checkpoint_interval = 1;
let version = Version::new();
let lines = vec!["fn test() {}".to_string()];
let arc_original = h.highlight_line_at(&lines, 0, version);
let state = h.snapshot_state();
let h2 = SyntaxHighlighter::from_state(state);
let arc_restored = h2.highlight_line_at(&lines, 0, version);
assert!(!Arc::ptr_eq(&arc_original, &arc_restored));
assert_eq!(arc_original.len(), arc_restored.len());
}
#[test]
fn to_ratatui_color_converts_rgb() {
use syntect::highlighting::Color;
let c = Color { r: 255, g: 128, b: 0, a: 255 };
let ratatui_color = to_ratatui_color(c);
assert_eq!(ratatui_color, ratatui::style::Color::Rgb(255, 128, 0));
}
#[test]
fn to_ratatui_style_plain_produces_no_modifiers() {
use syntect::highlighting::Style;
let style = Style::default();
let ratatui_style = to_ratatui_style(&style);
assert_eq!(ratatui_style.add_modifier, ratatui::style::Modifier::empty());
}
#[test]
fn to_ratatui_style_bold_sets_modifier() {
use syntect::highlighting::{FontStyle, Style};
let style = Style { font_style: FontStyle::BOLD, ..Default::default() };
let ratatui_style = to_ratatui_style(&style);
assert!(ratatui_style.add_modifier.contains(ratatui::style::Modifier::BOLD));
}
#[test]
fn tokens_to_spans_empty_tokens_gives_empty_spans() {
let h = SyntaxHighlighter::new();
let spans = h.tokens_to_spans("", &[]);
assert!(spans.is_empty());
}
#[test]
fn highlight_with_rust_syntax_produces_more_tokens_than_plain() {
let rust_h = SyntaxHighlighter::new().with_syntax("Rust");
let plain_h = SyntaxHighlighter::new(); let version = Version::new();
let lines = vec!["fn main() { let x = 42; }".to_string()];
let rust_tokens = rust_h.highlight_line_at(&lines, 0, version);
let plain_tokens = plain_h.highlight_line_at(&lines, 0, version);
assert!(
rust_tokens.len() > plain_tokens.len(),
"Rust highlighting should produce more token splits than plain text"
);
}
#[test]
fn sequential_lines_produce_valid_spans() {
let h = SyntaxHighlighter::new().with_syntax("Rust");
let version = Version::new();
let lines = vec![
"fn add(a: i32, b: i32) -> i32 {".to_string(),
" a + b".to_string(),
"}".to_string(),
];
for (i, line) in lines.iter().enumerate() {
let tokens = h.highlight_line_at(&lines, i, version);
let spans = h.tokens_to_spans(line, tokens.as_ref());
let mut reconstructed = String::new();
for s in &spans {
reconstructed.push_str(&line[s.range.start..s.range.end]);
}
assert_eq!(reconstructed, *line, "spans must reconstruct line {}", i);
}
}
#[test]
fn realistic_editing_session() {
let h = SyntaxHighlighter::new().with_syntax("Rust");
let version = Version::new();
let mut lines: Vec<String> = vec![
"use std::collections::HashMap;".to_string(),
"".to_string(),
"fn process(data: &[u8]) -> HashMap<u32, Vec<u8>> {".to_string(),
" let mut map = HashMap::new();".to_string(),
" for (i, &b) in data.iter().enumerate() {".to_string(),
" map.entry(b as u32).or_default().push(i as u8);".to_string(),
" }".to_string(),
" map".to_string(),
"}".to_string(),
"".to_string(),
"fn main() {".to_string(),
" let data = vec![1, 2, 3, 1, 2];".to_string(),
" let result = process(&data);".to_string(),
" println!(\"{:?}\", result);".to_string(),
"}".to_string(),
];
for i in 0..lines.len() {
let t = h.highlight_line_at(&lines, i, version);
assert!(!t.is_empty() || lines[i].is_empty(), "line {} must be highlighted", i);
}
lines[5] = " map.entry(b as u32).or_insert_with(Vec::new).push(i as u8);".to_string();
h.invalidate_from(5);
for i in 0..lines.len() {
let t = h.highlight_line_at(&lines, i, version);
assert!(!t.is_empty() || lines[i].is_empty());
}
lines.push("// end of file".to_string());
h.invalidate_from(lines.len() - 1);
let last = h.highlight_line_at(&lines, lines.len() - 1, version);
assert!(!last.is_empty());
}
#[test]
fn many_small_edits_at_same_line_remain_correct() {
let h = SyntaxHighlighter::new().with_syntax("Rust");
let version = Version::new();
let mut lines = vec!["fn main".to_string()];
let suffixes = ["(", ") {", "\n let x = 0;", "\n}"];
for suffix in &suffixes {
lines[0].push_str(suffix);
h.invalidate_from(0);
let t = h.highlight_line_at(&lines, 0, version);
assert!(!t.is_empty(), "tokens must be non-empty after appending '{}'", suffix);
}
}
}