use crate::model::buffer::Buffer;
use crate::model::marker::{MarkerId, MarkerList};
use crate::primitives::grammar::GrammarRegistry;
use crate::primitives::highlighter::{
highlight_color, HighlightCategory, HighlightSpan, Highlighter, Language,
};
use crate::view::theme::Theme;
use std::collections::HashMap;
use std::ops::Range;
use std::path::Path;
use std::sync::Arc;
use syntect::parsing::SyntaxSet;
fn scope_to_category(scope: &str) -> Option<HighlightCategory> {
let scope_lower = scope.to_lowercase();
if scope_lower.starts_with("comment") {
return Some(HighlightCategory::Comment);
}
if scope_lower.starts_with("string") {
return Some(HighlightCategory::String);
}
if scope_lower.starts_with("markup.heading") || scope_lower.starts_with("entity.name.section") {
return Some(HighlightCategory::Keyword); }
if scope_lower.starts_with("markup.bold") {
return Some(HighlightCategory::Constant); }
if scope_lower.starts_with("markup.italic") {
return Some(HighlightCategory::Variable); }
if scope_lower.starts_with("markup.raw") || scope_lower.starts_with("markup.inline.raw") {
return Some(HighlightCategory::String); }
if scope_lower.starts_with("markup.underline.link") {
return Some(HighlightCategory::Function); }
if scope_lower.starts_with("markup.underline") {
return Some(HighlightCategory::Function);
}
if scope_lower.starts_with("markup.quote") {
return Some(HighlightCategory::Comment); }
if scope_lower.starts_with("markup.list") {
return Some(HighlightCategory::Operator); }
if scope_lower.starts_with("markup.strikethrough") {
return Some(HighlightCategory::Comment); }
if scope_lower.starts_with("keyword.control")
|| scope_lower.starts_with("keyword.other")
|| scope_lower.starts_with("keyword.declaration")
|| scope_lower.starts_with("keyword")
{
if !scope_lower.starts_with("keyword.operator") {
return Some(HighlightCategory::Keyword);
}
}
if scope_lower.starts_with("punctuation.definition.comment") {
return Some(HighlightCategory::Comment);
}
if scope_lower.starts_with("punctuation.definition.string") {
return Some(HighlightCategory::String);
}
if scope_lower.starts_with("keyword.operator") {
return Some(HighlightCategory::Operator);
}
if scope_lower.starts_with("punctuation.section")
|| scope_lower.starts_with("punctuation.bracket")
|| scope_lower.starts_with("punctuation.definition.array")
|| scope_lower.starts_with("punctuation.definition.block")
|| scope_lower.starts_with("punctuation.definition.brackets")
|| scope_lower.starts_with("punctuation.definition.group")
|| scope_lower.starts_with("punctuation.definition.inline-table")
|| scope_lower.starts_with("punctuation.definition.section")
|| scope_lower.starts_with("punctuation.definition.table")
|| scope_lower.starts_with("punctuation.definition.tag")
{
return Some(HighlightCategory::PunctuationBracket);
}
if scope_lower.starts_with("punctuation.separator")
|| scope_lower.starts_with("punctuation.terminator")
|| scope_lower.starts_with("punctuation.accessor")
{
return Some(HighlightCategory::PunctuationDelimiter);
}
if scope_lower.starts_with("entity.name.function")
|| scope_lower.starts_with("support.function")
|| scope_lower.starts_with("meta.function-call")
|| scope_lower.starts_with("variable.function")
{
return Some(HighlightCategory::Function);
}
if scope_lower.starts_with("entity.name.type")
|| scope_lower.starts_with("entity.name.class")
|| scope_lower.starts_with("entity.name.struct")
|| scope_lower.starts_with("entity.name.enum")
|| scope_lower.starts_with("entity.name.interface")
|| scope_lower.starts_with("entity.name.trait")
|| scope_lower.starts_with("support.type")
|| scope_lower.starts_with("support.class")
|| scope_lower.starts_with("storage.type")
{
return Some(HighlightCategory::Type);
}
if scope_lower.starts_with("storage.modifier") {
return Some(HighlightCategory::Keyword);
}
if scope_lower.starts_with("constant.numeric")
|| scope_lower.starts_with("constant.language.boolean")
{
return Some(HighlightCategory::Number);
}
if scope_lower.starts_with("constant") {
return Some(HighlightCategory::Constant);
}
if scope_lower.starts_with("variable.parameter")
|| scope_lower.starts_with("variable.other")
|| scope_lower.starts_with("variable.language")
{
return Some(HighlightCategory::Variable);
}
if scope_lower.starts_with("entity.name.tag")
|| scope_lower.starts_with("support.other.property")
|| scope_lower.starts_with("meta.object-literal.key")
|| scope_lower.starts_with("variable.other.property")
|| scope_lower.starts_with("variable.other.object.property")
{
return Some(HighlightCategory::Property);
}
if scope_lower.starts_with("entity.other.attribute")
|| scope_lower.starts_with("meta.attribute")
|| scope_lower.starts_with("entity.name.decorator")
{
return Some(HighlightCategory::Attribute);
}
if scope_lower.starts_with("variable") {
return Some(HighlightCategory::Variable);
}
None
}
#[derive(Default)]
pub enum HighlightEngine {
TreeSitter(Box<Highlighter>),
TextMate(Box<TextMateEngine>),
#[default]
None,
}
pub struct TextMateEngine {
syntax_set: Arc<SyntaxSet>,
syntax_index: usize,
checkpoint_markers: MarkerList,
checkpoint_states:
HashMap<MarkerId, (syntect::parsing::ParseState, syntect::parsing::ScopeStack)>,
dirty_from: Option<usize>,
cache: Option<TextMateCache>,
last_buffer_len: usize,
ts_language: Option<Language>,
stats: HighlightStats,
scope_category_cache: HashMap<syntect::parsing::Scope, Option<HighlightCategory>>,
}
#[derive(Debug, Default, Clone)]
pub struct HighlightStats {
pub bytes_parsed: usize,
pub cache_hits: usize,
pub cache_misses: usize,
pub checkpoints_updated: usize,
pub convergences: usize,
}
#[derive(Debug, Clone)]
struct TextMateCache {
range: Range<usize>,
spans: Vec<CachedSpan>,
tail_state: Option<(syntect::parsing::ParseState, syntect::parsing::ScopeStack)>,
}
#[derive(Debug, Clone)]
struct CachedSpan {
range: Range<usize>,
category: crate::primitives::highlighter::HighlightCategory,
}
const MAX_PARSE_BYTES: usize = 1024 * 1024;
const CHECKPOINT_INTERVAL: usize = 256;
const CONVERGENCE_BUDGET: usize = 64 * 1024;
impl TextMateEngine {
pub fn new(syntax_set: Arc<SyntaxSet>, syntax_index: usize) -> Self {
Self {
syntax_set,
syntax_index,
checkpoint_markers: MarkerList::new(),
checkpoint_states: HashMap::new(),
dirty_from: None,
cache: None,
last_buffer_len: 0,
ts_language: None,
stats: HighlightStats::default(),
scope_category_cache: HashMap::new(),
}
}
pub fn with_language(
syntax_set: Arc<SyntaxSet>,
syntax_index: usize,
ts_language: Option<Language>,
) -> Self {
Self {
syntax_set,
syntax_index,
checkpoint_markers: MarkerList::new(),
checkpoint_states: HashMap::new(),
dirty_from: None,
cache: None,
last_buffer_len: 0,
ts_language,
stats: HighlightStats::default(),
scope_category_cache: HashMap::new(),
}
}
pub fn stats(&self) -> &HighlightStats {
&self.stats
}
pub fn reset_stats(&mut self) {
self.stats = HighlightStats::default();
}
pub fn language(&self) -> Option<&Language> {
self.ts_language.as_ref()
}
pub fn notify_insert(&mut self, position: usize, length: usize) {
self.checkpoint_markers.adjust_for_insert(position, length);
self.dirty_from = Some(self.dirty_from.map_or(position, |d| d.min(position)));
if let Some(cache) = &mut self.cache {
for span in &mut cache.spans {
if span.range.start >= position {
span.range.start += length;
span.range.end += length;
} else if span.range.end > position {
span.range.end += length;
}
}
if cache.range.end >= position {
cache.range.end += length;
if position < cache.range.end {
cache.tail_state = None;
}
}
}
}
pub fn notify_delete(&mut self, position: usize, length: usize) {
self.checkpoint_markers.adjust_for_delete(position, length);
self.dirty_from = Some(self.dirty_from.map_or(position, |d| d.min(position)));
if let Some(cache) = &mut self.cache {
let delete_end = position + length;
cache.spans.retain_mut(|span| {
if span.range.start >= delete_end {
span.range.start -= length;
span.range.end -= length;
true
} else if span.range.end <= position {
true
} else if span.range.start >= position && span.range.end <= delete_end {
false
} else {
if span.range.start < position {
span.range.end = position.min(span.range.end);
} else {
span.range.start = position;
span.range.end = position + span.range.end.saturating_sub(delete_end);
}
span.range.start < span.range.end
}
});
if cache.range.end > delete_end {
cache.range.end -= length;
} else if cache.range.end > position {
cache.range.end = position;
}
if position < cache.range.end {
cache.tail_state = None;
}
}
}
pub fn highlight_viewport(
&mut self,
buffer: &Buffer,
viewport_start: usize,
viewport_end: usize,
theme: &Theme,
context_bytes: usize,
) -> Vec<HighlightSpan> {
let buf_len = buffer.len();
let (desired_parse_start, parse_end) = if buf_len <= MAX_PARSE_BYTES {
(0, buf_len)
} else {
let s = viewport_start.saturating_sub(context_bytes);
let e = (viewport_end + context_bytes).min(buf_len);
(s, e)
};
let dirty = self.dirty_from.take();
let cache_covers_viewport = self.cache.as_ref().is_some_and(|c| {
c.range.start <= desired_parse_start && c.range.end >= desired_parse_start
});
let exact_cache_hit = cache_covers_viewport
&& dirty.is_none()
&& self.last_buffer_len == buffer.len()
&& self
.cache
.as_ref()
.is_some_and(|c| c.range.end >= parse_end);
if exact_cache_hit {
self.stats.cache_hits += 1;
return self.filter_cached_spans(viewport_start, viewport_end, theme);
}
if dirty.is_none()
&& cache_covers_viewport
&& self.last_buffer_len == buffer.len()
&& self
.cache
.as_ref()
.is_some_and(|c| c.range.end < parse_end && c.tail_state.is_some())
{
return self.extend_cache_forward(
buffer,
parse_end,
viewport_start,
viewport_end,
theme,
);
}
if cache_covers_viewport && dirty.is_some() {
if let Some(dirty_pos) = dirty {
if dirty_pos < parse_end {
if let Some(result) = self.try_partial_update(
buffer,
dirty_pos,
desired_parse_start,
parse_end,
viewport_start,
viewport_end,
theme,
) {
return result;
}
} else {
self.dirty_from = Some(dirty_pos);
self.stats.cache_hits += 1;
return self.filter_cached_spans(viewport_start, viewport_end, theme);
}
}
} else if let Some(d) = dirty {
self.dirty_from = Some(d);
}
self.full_parse(
buffer,
desired_parse_start,
parse_end,
viewport_start,
viewport_end,
theme,
context_bytes,
)
}
fn filter_cached_spans(
&self,
viewport_start: usize,
viewport_end: usize,
theme: &Theme,
) -> Vec<HighlightSpan> {
let cache = self.cache.as_ref().unwrap();
cache
.spans
.iter()
.filter(|span| span.range.start < viewport_end && span.range.end > viewport_start)
.map(|span| HighlightSpan {
range: span.range.clone(),
color: highlight_color(span.category, theme),
category: Some(span.category),
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn try_partial_update(
&mut self,
buffer: &Buffer,
dirty_pos: usize,
desired_parse_start: usize,
parse_end: usize,
viewport_start: usize,
viewport_end: usize,
theme: &Theme,
) -> Option<Vec<HighlightSpan>> {
let syntax = &self.syntax_set.syntaxes()[self.syntax_index];
let (actual_start, mut state, mut current_scopes) = {
let search_start = dirty_pos.saturating_sub(MAX_PARSE_BYTES);
let markers = self.checkpoint_markers.query_range(search_start, dirty_pos);
let nearest = markers.into_iter().max_by_key(|(_, start, _)| *start);
if let Some((id, cp_pos, _)) = nearest {
if let Some((s, sc)) = self.checkpoint_states.get(&id) {
(cp_pos, s.clone(), sc.clone())
} else {
return None; }
} else if parse_end <= MAX_PARSE_BYTES {
(
0,
syntect::parsing::ParseState::new(syntax),
syntect::parsing::ScopeStack::new(),
)
} else {
return None; }
};
let mut markers_ahead: Vec<(MarkerId, usize)> = self
.checkpoint_markers
.query_range(dirty_pos, parse_end)
.into_iter()
.map(|(id, start, _)| (id, start))
.collect();
markers_ahead.sort_by_key(|(_, pos)| *pos);
let mut marker_idx = 0;
let content_end = parse_end.min(buffer.len());
if actual_start >= content_end {
return None;
}
let content = buffer.slice_bytes(actual_start..content_end);
let content_str = match std::str::from_utf8(&content) {
Ok(s) => s,
Err(_) => return None,
};
let mut new_spans = Vec::new();
let content_bytes = content_str.as_bytes();
let mut pos = 0;
let mut current_offset = actual_start;
let mut converged_at: Option<usize> = None;
let mut budget_hit_at: Option<usize> = None;
let mut bytes_since_checkpoint: usize = 0;
while pos < content_bytes.len() {
if bytes_since_checkpoint >= CHECKPOINT_INTERVAL {
let nearby = self.checkpoint_markers.query_range(
current_offset.saturating_sub(CHECKPOINT_INTERVAL / 2),
current_offset + CHECKPOINT_INTERVAL / 2,
);
if nearby.is_empty() {
let marker_id = self.checkpoint_markers.create(current_offset, true);
self.checkpoint_states
.insert(marker_id, (state.clone(), current_scopes.clone()));
}
bytes_since_checkpoint = 0;
}
let line_start = pos;
let mut line_end = pos;
while line_end < content_bytes.len() {
if content_bytes[line_end] == b'\n' {
line_end += 1;
break;
} else if content_bytes[line_end] == b'\r' {
if line_end + 1 < content_bytes.len() && content_bytes[line_end + 1] == b'\n' {
line_end += 2;
} else {
line_end += 1;
}
break;
}
line_end += 1;
}
let line_bytes = &content_bytes[line_start..line_end];
let actual_line_byte_len = line_bytes.len();
let line_str = match std::str::from_utf8(line_bytes) {
Ok(s) => s,
Err(_) => {
pos = line_end;
current_offset += actual_line_byte_len;
bytes_since_checkpoint += actual_line_byte_len;
continue;
}
};
let line_content = line_str.trim_end_matches(&['\r', '\n'][..]);
let line_for_syntect = if line_end < content_bytes.len() || line_str.ends_with('\n') {
format!("{}\n", line_content)
} else {
line_content.to_string()
};
let ops = match state.parse_line(&line_for_syntect, &self.syntax_set) {
Ok(ops) => ops,
Err(_) => {
pos = line_end;
current_offset += actual_line_byte_len;
bytes_since_checkpoint += actual_line_byte_len;
continue;
}
};
let collect_spans =
current_offset + actual_line_byte_len > desired_parse_start.max(actual_start);
let mut syntect_offset = 0;
let line_content_len = line_content.len();
for (op_offset, op) in ops {
let clamped_op_offset = op_offset.min(line_content_len);
if collect_spans && clamped_op_offset > syntect_offset {
if let Some(category) = self.scope_stack_to_category(¤t_scopes) {
let byte_start = current_offset + syntect_offset;
let byte_end = current_offset + clamped_op_offset;
let clamped_start = byte_start.max(actual_start);
if clamped_start < byte_end {
new_spans.push(CachedSpan {
range: clamped_start..byte_end,
category,
});
}
}
}
syntect_offset = clamped_op_offset;
#[allow(clippy::let_underscore_must_use)]
let _ = current_scopes.apply(&op);
}
if collect_spans && syntect_offset < line_content_len {
if let Some(category) = self.scope_stack_to_category(¤t_scopes) {
let byte_start = current_offset + syntect_offset;
let byte_end = current_offset + line_content_len;
let clamped_start = byte_start.max(actual_start);
if clamped_start < byte_end {
new_spans.push(CachedSpan {
range: clamped_start..byte_end,
category,
});
}
}
}
pos = line_end;
current_offset += actual_line_byte_len;
bytes_since_checkpoint += actual_line_byte_len;
while marker_idx < markers_ahead.len() && markers_ahead[marker_idx].1 <= current_offset
{
let (marker_id, _) = markers_ahead[marker_idx];
marker_idx += 1;
if let Some(stored) = self.checkpoint_states.get(&marker_id) {
if *stored == (state.clone(), current_scopes.clone()) {
self.stats.convergences += 1;
converged_at = Some(current_offset);
break;
}
}
self.stats.checkpoints_updated += 1;
self.checkpoint_states
.insert(marker_id, (state.clone(), current_scopes.clone()));
}
if converged_at.is_some() {
break;
}
if current_offset.saturating_sub(dirty_pos) >= CONVERGENCE_BUDGET {
budget_hit_at = Some(current_offset);
break;
}
}
self.stats.bytes_parsed += current_offset.saturating_sub(actual_start);
let (splice_end, dirty_after) = if let Some(c) = converged_at {
(c, None)
} else if let Some(b) = budget_hit_at {
(b, Some(b))
} else {
(current_offset, None)
};
self.stats.cache_misses += 1;
Self::merge_adjacent_spans(&mut new_spans);
if let Some(cache) = &mut self.cache {
let splice_start = actual_start;
cache
.spans
.retain(|span| span.range.end <= splice_start || span.range.start >= splice_end);
cache.spans.extend(new_spans);
cache.spans.sort_by_key(|s| s.range.start);
Self::merge_adjacent_spans(&mut cache.spans);
if splice_end > cache.range.end {
cache.range.end = splice_end;
}
cache.tail_state = None;
}
self.last_buffer_len = buffer.len();
self.dirty_from = dirty_after;
Some(self.filter_cached_spans(viewport_start, viewport_end, theme))
}
fn extend_cache_forward(
&mut self,
buffer: &Buffer,
parse_end: usize,
viewport_start: usize,
viewport_end: usize,
theme: &Theme,
) -> Vec<HighlightSpan> {
self.stats.cache_misses += 1;
let buf_len = buffer.len();
let parse_end = parse_end.min(buf_len);
let (extension_start, mut state, mut current_scopes) = {
let cache = self
.cache
.as_ref()
.expect("extend_cache_forward: cache must exist");
let (s, sc) = cache
.tail_state
.as_ref()
.expect("extend_cache_forward: tail_state must exist")
.clone();
(cache.range.end, s, sc)
};
if parse_end <= extension_start {
return self.filter_cached_spans(viewport_start, viewport_end, theme);
}
let content = buffer.slice_bytes(extension_start..parse_end);
let content_str = match std::str::from_utf8(&content) {
Ok(s) => s,
Err(_) => return self.filter_cached_spans(viewport_start, viewport_end, theme),
};
let mut new_spans = Vec::new();
let content_bytes = content_str.as_bytes();
let mut pos = 0;
let mut current_offset = extension_start;
let mut bytes_since_checkpoint: usize = 0;
while pos < content_bytes.len() {
if bytes_since_checkpoint >= CHECKPOINT_INTERVAL {
let nearby = self.checkpoint_markers.query_range(
current_offset.saturating_sub(CHECKPOINT_INTERVAL / 2),
current_offset + CHECKPOINT_INTERVAL / 2,
);
if nearby.is_empty() {
let marker_id = self.checkpoint_markers.create(current_offset, true);
self.checkpoint_states
.insert(marker_id, (state.clone(), current_scopes.clone()));
}
bytes_since_checkpoint = 0;
}
let line_start = pos;
let mut line_end = pos;
while line_end < content_bytes.len() {
if content_bytes[line_end] == b'\n' {
line_end += 1;
break;
} else if content_bytes[line_end] == b'\r' {
if line_end + 1 < content_bytes.len() && content_bytes[line_end + 1] == b'\n' {
line_end += 2;
} else {
line_end += 1;
}
break;
}
line_end += 1;
}
let line_bytes = &content_bytes[line_start..line_end];
let actual_line_byte_len = line_bytes.len();
let line_str = match std::str::from_utf8(line_bytes) {
Ok(s) => s,
Err(_) => {
pos = line_end;
current_offset += actual_line_byte_len;
bytes_since_checkpoint += actual_line_byte_len;
continue;
}
};
let line_content = line_str.trim_end_matches(&['\r', '\n'][..]);
let line_for_syntect = if line_end < content_bytes.len() || line_str.ends_with('\n') {
format!("{}\n", line_content)
} else {
line_content.to_string()
};
let ops = match state.parse_line(&line_for_syntect, &self.syntax_set) {
Ok(ops) => ops,
Err(_) => {
pos = line_end;
current_offset += actual_line_byte_len;
bytes_since_checkpoint += actual_line_byte_len;
continue;
}
};
let mut syntect_offset = 0;
let line_content_len = line_content.len();
for (op_offset, op) in ops {
let clamped_op_offset = op_offset.min(line_content_len);
if clamped_op_offset > syntect_offset {
if let Some(category) = self.scope_stack_to_category(¤t_scopes) {
let byte_start = current_offset + syntect_offset;
let byte_end = current_offset + clamped_op_offset;
if byte_start < byte_end {
new_spans.push(CachedSpan {
range: byte_start..byte_end,
category,
});
}
}
}
syntect_offset = clamped_op_offset;
#[allow(clippy::let_underscore_must_use)]
let _ = current_scopes.apply(&op);
}
if syntect_offset < line_content_len {
if let Some(category) = self.scope_stack_to_category(¤t_scopes) {
let byte_start = current_offset + syntect_offset;
let byte_end = current_offset + line_content_len;
if byte_start < byte_end {
new_spans.push(CachedSpan {
range: byte_start..byte_end,
category,
});
}
}
}
pos = line_end;
current_offset += actual_line_byte_len;
bytes_since_checkpoint += actual_line_byte_len;
}
self.stats.bytes_parsed += parse_end - extension_start;
Self::merge_adjacent_spans(&mut new_spans);
let cache = self
.cache
.as_mut()
.expect("extend_cache_forward: cache must still exist");
cache.spans.extend(new_spans);
Self::merge_adjacent_spans(&mut cache.spans);
cache.range.end = parse_end;
cache.tail_state = Some((state, current_scopes));
self.last_buffer_len = buf_len;
self.filter_cached_spans(viewport_start, viewport_end, theme)
}
#[allow(clippy::too_many_arguments)]
fn full_parse(
&mut self,
buffer: &Buffer,
desired_parse_start: usize,
parse_end: usize,
viewport_start: usize,
viewport_end: usize,
theme: &Theme,
_context_bytes: usize,
) -> Vec<HighlightSpan> {
self.stats.cache_misses += 1;
self.dirty_from = None;
if parse_end <= desired_parse_start {
return Vec::new();
}
let syntax = &self.syntax_set.syntaxes()[self.syntax_index];
let (actual_start, mut state, mut current_scopes, create_checkpoints) =
self.find_parse_resume_point(desired_parse_start, parse_end, syntax);
let content = buffer.slice_bytes(actual_start..parse_end);
let content_str = match std::str::from_utf8(&content) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let mut spans = Vec::new();
let content_bytes = content_str.as_bytes();
let mut pos = 0;
let mut current_offset = actual_start;
let mut bytes_since_checkpoint: usize = 0;
while pos < content_bytes.len() {
if create_checkpoints && bytes_since_checkpoint >= CHECKPOINT_INTERVAL {
let nearby = self.checkpoint_markers.query_range(
current_offset.saturating_sub(CHECKPOINT_INTERVAL / 2),
current_offset + CHECKPOINT_INTERVAL / 2,
);
if nearby.is_empty() {
let marker_id = self.checkpoint_markers.create(current_offset, true);
self.checkpoint_states
.insert(marker_id, (state.clone(), current_scopes.clone()));
}
bytes_since_checkpoint = 0;
}
let line_start = pos;
let mut line_end = pos;
while line_end < content_bytes.len() {
if content_bytes[line_end] == b'\n' {
line_end += 1;
break;
} else if content_bytes[line_end] == b'\r' {
if line_end + 1 < content_bytes.len() && content_bytes[line_end + 1] == b'\n' {
line_end += 2;
} else {
line_end += 1;
}
break;
}
line_end += 1;
}
let line_bytes = &content_bytes[line_start..line_end];
let actual_line_byte_len = line_bytes.len();
let line_str = match std::str::from_utf8(line_bytes) {
Ok(s) => s,
Err(_) => {
pos = line_end;
current_offset += actual_line_byte_len;
bytes_since_checkpoint += actual_line_byte_len;
continue;
}
};
let line_content = line_str.trim_end_matches(&['\r', '\n'][..]);
let line_for_syntect = if line_end < content_bytes.len() || line_str.ends_with('\n') {
format!("{}\n", line_content)
} else {
line_content.to_string()
};
let ops = match state.parse_line(&line_for_syntect, &self.syntax_set) {
Ok(ops) => ops,
Err(_) => {
pos = line_end;
current_offset += actual_line_byte_len;
bytes_since_checkpoint += actual_line_byte_len;
continue;
}
};
let collect_spans = current_offset + actual_line_byte_len > desired_parse_start;
let mut syntect_offset = 0;
let line_content_len = line_content.len();
for (op_offset, op) in ops {
let clamped_op_offset = op_offset.min(line_content_len);
if collect_spans && clamped_op_offset > syntect_offset {
if let Some(category) = self.scope_stack_to_category(¤t_scopes) {
let byte_start = current_offset + syntect_offset;
let byte_end = current_offset + clamped_op_offset;
let clamped_start = byte_start.max(desired_parse_start);
if clamped_start < byte_end {
spans.push(CachedSpan {
range: clamped_start..byte_end,
category,
});
}
}
}
syntect_offset = clamped_op_offset;
#[allow(clippy::let_underscore_must_use)]
let _ = current_scopes.apply(&op);
}
if collect_spans && syntect_offset < line_content_len {
if let Some(category) = self.scope_stack_to_category(¤t_scopes) {
let byte_start = current_offset + syntect_offset;
let byte_end = current_offset + line_content_len;
let clamped_start = byte_start.max(desired_parse_start);
if clamped_start < byte_end {
spans.push(CachedSpan {
range: clamped_start..byte_end,
category,
});
}
}
}
pos = line_end;
current_offset += actual_line_byte_len;
bytes_since_checkpoint += actual_line_byte_len;
let markers_here: Vec<(MarkerId, usize)> = self
.checkpoint_markers
.query_range(
current_offset.saturating_sub(actual_line_byte_len),
current_offset,
)
.into_iter()
.map(|(id, start, _)| (id, start))
.collect();
for (marker_id, _) in markers_here {
self.checkpoint_states
.insert(marker_id, (state.clone(), current_scopes.clone()));
}
}
self.stats.bytes_parsed += parse_end.saturating_sub(actual_start);
Self::merge_adjacent_spans(&mut spans);
self.cache = Some(TextMateCache {
range: desired_parse_start..parse_end,
spans: spans.clone(),
tail_state: Some((state, current_scopes)),
});
self.last_buffer_len = buffer.len();
spans
.into_iter()
.filter(|span| span.range.start < viewport_end && span.range.end > viewport_start)
.map(|span| {
let cat = span.category;
HighlightSpan {
range: span.range,
color: highlight_color(cat, theme),
category: Some(cat),
}
})
.collect()
}
fn find_parse_resume_point(
&self,
desired_start: usize,
parse_end: usize,
syntax: &syntect::parsing::SyntaxReference,
) -> (
usize,
syntect::parsing::ParseState,
syntect::parsing::ScopeStack,
bool,
) {
use syntect::parsing::{ParseState, ScopeStack};
let search_start = desired_start.saturating_sub(MAX_PARSE_BYTES);
let markers = self
.checkpoint_markers
.query_range(search_start, desired_start + 1);
let nearest = markers.into_iter().max_by_key(|(_, start, _)| *start);
if let Some((id, cp_pos, _)) = nearest {
if let Some((s, sc)) = self.checkpoint_states.get(&id) {
return (cp_pos, s.clone(), sc.clone(), true);
}
}
if parse_end <= MAX_PARSE_BYTES {
(0, ParseState::new(syntax), ScopeStack::new(), true)
} else {
(
desired_start,
ParseState::new(syntax),
ScopeStack::new(),
true,
)
}
}
fn scope_stack_to_category(
&mut self,
scopes: &syntect::parsing::ScopeStack,
) -> Option<HighlightCategory> {
for scope in scopes.as_slice().iter().rev() {
let cat = match self.scope_category_cache.get(scope) {
Some(c) => *c,
None => {
let computed = scope_to_category(&scope.build_string());
self.scope_category_cache.insert(*scope, computed);
computed
}
};
if let Some(c) = cat {
return Some(c);
}
}
None
}
fn merge_adjacent_spans(spans: &mut Vec<CachedSpan>) {
if spans.len() < 2 {
return;
}
let mut write_idx = 0;
for read_idx in 1..spans.len() {
if spans[write_idx].category == spans[read_idx].category
&& spans[write_idx].range.end == spans[read_idx].range.start
{
spans[write_idx].range.end = spans[read_idx].range.end;
} else {
write_idx += 1;
if write_idx != read_idx {
spans[write_idx] = spans[read_idx].clone();
}
}
}
spans.truncate(write_idx + 1);
}
pub fn invalidate_range(&mut self, _edit_range: Range<usize>) {
}
pub fn invalidate_all(&mut self) {
self.cache = None;
let ids: Vec<MarkerId> = self.checkpoint_states.keys().copied().collect();
for id in ids {
self.checkpoint_markers.delete(id);
}
self.checkpoint_states.clear();
self.dirty_from = None;
}
pub fn category_at_position(&self, position: usize) -> Option<HighlightCategory> {
let cache = self.cache.as_ref()?;
cache
.spans
.iter()
.find(|span| span.range.start <= position && position < span.range.end)
.map(|span| span.category)
}
pub fn syntax_name(&self) -> &str {
&self.syntax_set.syntaxes()[self.syntax_index].name
}
}
impl HighlightEngine {
pub fn from_entry(
entry: &crate::primitives::grammar::GrammarEntry,
registry: &GrammarRegistry,
) -> Self {
let syntax_set = registry.syntax_set_arc();
if let Some(index) = entry.engines.syntect {
return Self::TextMate(Box::new(TextMateEngine::with_language(
syntax_set,
index,
entry.engines.tree_sitter,
)));
}
if let Some(lang) = entry.engines.tree_sitter {
if let Ok(highlighter) = Highlighter::new(lang) {
return Self::TreeSitter(Box::new(highlighter));
}
}
Self::None
}
pub fn for_file(path: &Path, first_line: Option<&str>, registry: &GrammarRegistry) -> Self {
if let Some(entry) = registry.find_by_path(path, first_line) {
return Self::from_entry(entry, registry);
}
Self::None
}
pub fn for_syntax_name(name: &str, registry: &GrammarRegistry) -> Self {
if let Some(entry) = registry.find_by_name(name) {
return Self::from_entry(entry, registry);
}
Self::None
}
pub fn highlight_viewport(
&mut self,
buffer: &Buffer,
viewport_start: usize,
viewport_end: usize,
theme: &Theme,
context_bytes: usize,
) -> Vec<HighlightSpan> {
match self {
Self::TreeSitter(h) => {
h.highlight_viewport(buffer, viewport_start, viewport_end, theme, context_bytes)
}
Self::TextMate(h) => {
h.highlight_viewport(buffer, viewport_start, viewport_end, theme, context_bytes)
}
Self::None => Vec::new(),
}
}
pub fn notify_insert(&mut self, position: usize, length: usize) {
if let Self::TextMate(h) = self {
h.notify_insert(position, length);
}
}
pub fn notify_delete(&mut self, position: usize, length: usize) {
if let Self::TextMate(h) = self {
h.notify_delete(position, length);
}
}
pub fn invalidate_range(&mut self, edit_range: Range<usize>) {
match self {
Self::TreeSitter(h) => h.invalidate_range(edit_range),
Self::TextMate(h) => h.invalidate_range(edit_range),
Self::None => {}
}
}
pub fn invalidate_all(&mut self) {
match self {
Self::TreeSitter(h) => h.invalidate_all(),
Self::TextMate(h) => h.invalidate_all(),
Self::None => {}
}
}
pub fn has_highlighting(&self) -> bool {
!matches!(self, Self::None)
}
pub fn backend_name(&self) -> &str {
match self {
Self::TreeSitter(_) => "tree-sitter",
Self::TextMate(_) => "textmate",
Self::None => "none",
}
}
pub fn highlight_stats(&self) -> Option<&HighlightStats> {
if let Self::TextMate(h) = self {
Some(h.stats())
} else {
None
}
}
pub fn reset_highlight_stats(&mut self) {
if let Self::TextMate(h) = self {
h.reset_stats();
}
}
pub fn syntax_name(&self) -> Option<&str> {
match self {
Self::TreeSitter(_) => None, Self::TextMate(h) => Some(h.syntax_name()),
Self::None => None,
}
}
pub fn category_at_position(&self, position: usize) -> Option<HighlightCategory> {
match self {
Self::TreeSitter(h) => h.category_at_position(position),
Self::TextMate(h) => h.category_at_position(position),
Self::None => None,
}
}
pub fn language(&self) -> Option<&Language> {
match self {
Self::TreeSitter(h) => Some(h.language()),
Self::TextMate(h) => h.language(),
Self::None => None,
}
}
}
pub fn highlight_string(
code: &str,
lang_hint: &str,
registry: &GrammarRegistry,
theme: &Theme,
) -> Vec<HighlightSpan> {
use syntect::parsing::{ParseState, ScopeStack};
let syntax = match registry.syntax_set().find_syntax_by_token(lang_hint) {
Some(s) => s,
None => return Vec::new(),
};
let syntax_set = registry.syntax_set();
let mut state = ParseState::new(syntax);
let mut spans = Vec::new();
let mut current_scopes = ScopeStack::new();
let mut current_offset = 0;
for line in code.split_inclusive('\n') {
let line_start = current_offset;
let line_len = line.len();
let line_content = line.trim_end_matches(&['\r', '\n'][..]);
let line_for_syntect = if line.ends_with('\n') {
format!("{}\n", line_content)
} else {
line_content.to_string()
};
let ops = match state.parse_line(&line_for_syntect, syntax_set) {
Ok(ops) => ops,
Err(_) => {
current_offset += line_len;
continue;
}
};
let mut syntect_offset = 0;
let line_content_len = line_content.len();
for (op_offset, op) in ops {
let clamped_op_offset = op_offset.min(line_content_len);
if clamped_op_offset > syntect_offset {
if let Some(category) = scope_stack_to_category(¤t_scopes) {
let byte_start = line_start + syntect_offset;
let byte_end = line_start + clamped_op_offset;
if byte_start < byte_end {
spans.push(HighlightSpan {
range: byte_start..byte_end,
color: highlight_color(category, theme),
category: Some(category),
});
}
}
}
syntect_offset = clamped_op_offset;
#[allow(clippy::let_underscore_must_use)]
let _ = current_scopes.apply(&op);
}
if syntect_offset < line_content_len {
if let Some(category) = scope_stack_to_category(¤t_scopes) {
let byte_start = line_start + syntect_offset;
let byte_end = line_start + line_content_len;
if byte_start < byte_end {
spans.push(HighlightSpan {
range: byte_start..byte_end,
color: highlight_color(category, theme),
category: Some(category),
});
}
}
}
current_offset += line_len;
}
merge_adjacent_highlight_spans(&mut spans);
spans
}
fn scope_stack_to_category(scopes: &syntect::parsing::ScopeStack) -> Option<HighlightCategory> {
for scope in scopes.as_slice().iter().rev() {
let scope_str = scope.build_string();
if let Some(cat) = scope_to_category(&scope_str) {
return Some(cat);
}
}
None
}
fn merge_adjacent_highlight_spans(spans: &mut Vec<HighlightSpan>) {
if spans.len() < 2 {
return;
}
let mut write_idx = 0;
for read_idx in 1..spans.len() {
if spans[write_idx].color == spans[read_idx].color
&& spans[write_idx].range.end == spans[read_idx].range.start
{
spans[write_idx].range.end = spans[read_idx].range.end;
} else {
write_idx += 1;
if write_idx != read_idx {
spans[write_idx] = spans[read_idx].clone();
}
}
}
spans.truncate(write_idx + 1);
}
#[cfg(test)]
mod tests {
use crate::model::filesystem::StdFileSystem;
use std::sync::Arc;
fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
Arc::new(StdFileSystem)
}
use super::*;
use crate::view::theme;
#[test]
fn test_highlight_engine_default() {
let engine = HighlightEngine::default();
assert!(!engine.has_highlighting());
assert_eq!(engine.backend_name(), "none");
}
#[test]
fn test_textmate_backend_selection() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let engine = HighlightEngine::for_file(Path::new("test.rs"), None, ®istry);
assert_eq!(engine.backend_name(), "textmate");
assert!(engine.language().is_some());
let engine = HighlightEngine::for_file(Path::new("test.py"), None, ®istry);
assert_eq!(engine.backend_name(), "textmate");
assert!(engine.language().is_some());
let engine = HighlightEngine::for_file(Path::new("test.js"), None, ®istry);
assert_eq!(engine.backend_name(), "tree-sitter");
assert!(engine.language().is_some());
let engine = HighlightEngine::for_file(Path::new("test.ts"), None, ®istry);
assert_eq!(engine.backend_name(), "tree-sitter");
assert!(engine.language().is_some());
let engine = HighlightEngine::for_file(Path::new("test.tsx"), None, ®istry);
assert_eq!(engine.backend_name(), "tree-sitter");
assert!(engine.language().is_some());
}
#[test]
fn test_tree_sitter_direct() {
let highlighter = Highlighter::new(Language::Rust);
assert!(highlighter.is_ok());
}
#[test]
fn test_unknown_extension() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let engine = HighlightEngine::for_file(Path::new("test.unknown_xyz_123"), None, ®istry);
let _ = engine.backend_name();
}
#[test]
fn test_highlight_viewport_empty_buffer_no_panic() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let mut engine = HighlightEngine::for_file(Path::new("test.rs"), None, ®istry);
let buffer = Buffer::from_str("", 0, test_fs());
let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
if let HighlightEngine::TextMate(ref mut tm) = engine {
let spans = tm.highlight_viewport(&buffer, 100, 200, &theme, 10);
assert!(spans.is_empty());
}
}
#[test]
fn test_textmate_engine_crlf_byte_offsets() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let mut engine = HighlightEngine::for_file(Path::new("test.java"), None, ®istry);
let content = b"public\r\npublic\r\npublic\r\n";
let buffer = Buffer::from_bytes(content.to_vec(), test_fs());
let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
if let HighlightEngine::TextMate(ref mut tm) = engine {
let spans = tm.highlight_viewport(&buffer, 0, content.len(), &theme, 0);
eprintln!(
"Spans: {:?}",
spans.iter().map(|s| &s.range).collect::<Vec<_>>()
);
let has_span_at = |start: usize, end: usize| -> bool {
spans
.iter()
.any(|s| s.range.start <= start && s.range.end >= end)
};
assert!(
has_span_at(0, 6),
"Should have span covering bytes 0-6 (line 1 'public'). Spans: {:?}",
spans.iter().map(|s| &s.range).collect::<Vec<_>>()
);
assert!(
has_span_at(8, 14),
"Should have span covering bytes 8-14 (line 2 'public'). \
If this fails, CRLF offset drift is occurring. Spans: {:?}",
spans.iter().map(|s| &s.range).collect::<Vec<_>>()
);
assert!(
has_span_at(16, 22),
"Should have span covering bytes 16-22 (line 3 'public'). \
If this fails, CRLF offset drift is occurring. Spans: {:?}",
spans.iter().map(|s| &s.range).collect::<Vec<_>>()
);
} else {
panic!("Expected TextMate engine for .java file");
}
}
#[test]
fn test_git_rebase_todo_highlighting() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let engine = HighlightEngine::for_file(Path::new("git-rebase-todo"), None, ®istry);
assert_eq!(engine.backend_name(), "textmate");
assert!(engine.has_highlighting());
}
#[test]
fn test_git_commit_message_highlighting() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let engine = HighlightEngine::for_file(Path::new("COMMIT_EDITMSG"), None, ®istry);
assert_eq!(engine.backend_name(), "textmate");
assert!(engine.has_highlighting());
let engine = HighlightEngine::for_file(Path::new("MERGE_MSG"), None, ®istry);
assert_eq!(engine.backend_name(), "textmate");
assert!(engine.has_highlighting());
}
#[test]
fn test_gitignore_highlighting() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let engine = HighlightEngine::for_file(Path::new(".gitignore"), None, ®istry);
assert_eq!(engine.backend_name(), "textmate");
assert!(engine.has_highlighting());
let engine = HighlightEngine::for_file(Path::new(".dockerignore"), None, ®istry);
assert_eq!(engine.backend_name(), "textmate");
assert!(engine.has_highlighting());
}
#[test]
fn test_gitconfig_highlighting() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let engine = HighlightEngine::for_file(Path::new(".gitconfig"), None, ®istry);
assert_eq!(engine.backend_name(), "textmate");
assert!(engine.has_highlighting());
let engine = HighlightEngine::for_file(Path::new(".gitmodules"), None, ®istry);
assert_eq!(engine.backend_name(), "textmate");
assert!(engine.has_highlighting());
}
#[test]
fn test_gitattributes_highlighting() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let engine = HighlightEngine::for_file(Path::new(".gitattributes"), None, ®istry);
assert_eq!(engine.backend_name(), "textmate");
assert!(engine.has_highlighting());
}
#[test]
fn test_comment_delimiter_uses_comment_color() {
assert_eq!(
scope_to_category("punctuation.definition.comment"),
Some(HighlightCategory::Comment)
);
assert_eq!(
scope_to_category("punctuation.definition.comment.python"),
Some(HighlightCategory::Comment)
);
assert_eq!(
scope_to_category("punctuation.definition.comment.begin"),
Some(HighlightCategory::Comment)
);
}
#[test]
fn test_string_delimiter_uses_string_color() {
assert_eq!(
scope_to_category("punctuation.definition.string.begin"),
Some(HighlightCategory::String)
);
assert_eq!(
scope_to_category("punctuation.definition.string.end"),
Some(HighlightCategory::String)
);
}
#[test]
fn test_punctuation_bracket() {
assert_eq!(
scope_to_category("punctuation.section"),
Some(HighlightCategory::PunctuationBracket)
);
assert_eq!(
scope_to_category("punctuation.section.block.begin.c"),
Some(HighlightCategory::PunctuationBracket)
);
assert_eq!(
scope_to_category("punctuation.bracket"),
Some(HighlightCategory::PunctuationBracket)
);
assert_eq!(
scope_to_category("punctuation.definition.array.begin.toml"),
Some(HighlightCategory::PunctuationBracket)
);
assert_eq!(
scope_to_category("punctuation.definition.block.code.typst"),
Some(HighlightCategory::PunctuationBracket)
);
assert_eq!(
scope_to_category("punctuation.definition.group.typst"),
Some(HighlightCategory::PunctuationBracket)
);
assert_eq!(
scope_to_category("punctuation.definition.inline-table.begin.toml"),
Some(HighlightCategory::PunctuationBracket)
);
assert_eq!(
scope_to_category("punctuation.definition.tag.end.svelte"),
Some(HighlightCategory::PunctuationBracket)
);
}
#[test]
fn test_punctuation_delimiter() {
assert_eq!(
scope_to_category("punctuation.separator"),
Some(HighlightCategory::PunctuationDelimiter)
);
assert_eq!(
scope_to_category("punctuation.terminator.statement.c"),
Some(HighlightCategory::PunctuationDelimiter)
);
assert_eq!(
scope_to_category("punctuation.accessor"),
Some(HighlightCategory::PunctuationDelimiter)
);
}
#[test]
fn test_small_file_scroll_is_cache_hit() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let mut engine = HighlightEngine::for_file(Path::new("test.rs"), None, ®istry);
let mut content = String::new();
for i in 0..200 {
content.push_str(&format!("fn f_{i}() {{ let x = {i}; }}\n"));
}
let buffer = Buffer::from_str(&content, 0, test_fs());
let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
let HighlightEngine::TextMate(ref mut tm) = engine else {
panic!("expected TextMate engine for .rs");
};
let _ = tm.highlight_viewport(&buffer, 0, 200, &theme, 10_000);
let stats_after_first = tm.stats().clone();
assert_eq!(
stats_after_first.cache_hits, 0,
"first call cannot hit cache"
);
assert_eq!(
stats_after_first.cache_misses, 1,
"first call must be a miss"
);
let mid = buffer.len() / 2;
let near_end = buffer.len().saturating_sub(200);
let probes = [(0, 200), (mid, mid + 200), (near_end, buffer.len())];
for (vs, ve) in probes {
let _ = tm.highlight_viewport(&buffer, vs, ve, &theme, 10_000);
}
let stats_after_scroll = tm.stats().clone();
assert_eq!(
stats_after_scroll.cache_misses,
1,
"scrolling must not add cache misses (got extra: {})",
stats_after_scroll.cache_misses - 1
);
assert_eq!(
stats_after_scroll.cache_hits, 3,
"all three scroll probes must hit the cache"
);
assert_eq!(
stats_after_scroll.bytes_parsed, stats_after_first.bytes_parsed,
"scrolling must not parse any new bytes"
);
}
#[test]
fn test_small_file_edit_uses_partial_update() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let mut engine = HighlightEngine::for_file(Path::new("test.rs"), None, ®istry);
let mut content = String::new();
for i in 0..200 {
content.push_str(&format!("fn f_{i}() {{ let x = {i}; }}\n"));
}
let buffer = Buffer::from_str(&content, 0, test_fs());
let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
let HighlightEngine::TextMate(ref mut tm) = engine else {
panic!("expected TextMate engine for .rs");
};
let _ = tm.highlight_viewport(&buffer, 0, 100, &theme, 10_000);
let bytes_before_edit = tm.stats().bytes_parsed;
let buf_len = buffer.len();
assert!(
buf_len > 4000,
"test needs a buffer larger than the partial-update region"
);
let edit_pos = buf_len / 2;
tm.notify_insert(edit_pos, 1);
let _ = tm.highlight_viewport(&buffer, 0, 100, &theme, 10_000);
let bytes_after_edit = tm.stats().bytes_parsed;
let parsed = bytes_after_edit - bytes_before_edit;
assert!(
parsed < buf_len,
"edit must not trigger a whole-file reparse (parsed {parsed}, file {buf_len})"
);
}
#[test]
fn test_partial_update_budget_caps_work() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let mut engine = HighlightEngine::for_file(Path::new("test.rs"), None, ®istry);
let mut content = String::new();
while content.len() < (CONVERGENCE_BUDGET * 4) {
content.push_str("fn name() { let mut v = 0; v += 1; }\n");
}
let buffer = Buffer::from_str(&content, 0, test_fs());
let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
let HighlightEngine::TextMate(ref mut tm) = engine else {
panic!("expected TextMate engine for .rs");
};
let _ = tm.highlight_viewport(&buffer, 0, 200, &theme, 10_000);
tm.notify_insert(100, 0);
tm.checkpoint_states.clear();
let bytes_before = tm.stats().bytes_parsed;
let _ = tm.highlight_viewport(&buffer, 0, 200, &theme, 10_000);
let parsed = tm.stats().bytes_parsed - bytes_before;
assert!(
parsed <= CONVERGENCE_BUDGET + 4096,
"partial update parsed {parsed}, expected <= {} \
(budget {CONVERGENCE_BUDGET} + slack)",
CONVERGENCE_BUDGET + 4096
);
assert!(
tm.dirty_from.is_some(),
"budget exit must keep dirty_from set"
);
}
#[test]
fn test_large_file_uses_windowed_parse() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let mut engine = HighlightEngine::for_file(Path::new("test.rs"), None, ®istry);
let line = "fn long_name_for_padding() { let v = 1; v + 1; }\n";
let bytes_needed = MAX_PARSE_BYTES * 2;
let lines_needed = bytes_needed / line.len() + 100;
let mut content = String::with_capacity(lines_needed * line.len());
for _ in 0..lines_needed {
content.push_str(line);
}
assert!(content.len() > MAX_PARSE_BYTES * 2);
let buffer = Buffer::from_str(&content, 0, test_fs());
let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
let HighlightEngine::TextMate(ref mut tm) = engine else {
panic!("expected TextMate engine for .rs");
};
let context_bytes = 10_000usize;
let viewport_start = MAX_PARSE_BYTES + 200_000;
let viewport_end = viewport_start + 1000;
let _ = tm.highlight_viewport(&buffer, viewport_start, viewport_end, &theme, context_bytes);
let parsed = tm.stats().bytes_parsed;
let window = (viewport_end - viewport_start) + 2 * context_bytes;
assert!(
parsed <= window * 4,
"large file windowed parse should be ~{window} bytes, got {parsed} \
(file {})",
buffer.len()
);
}
#[test]
fn test_javascript_template_literal_does_not_bleed() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let mut engine = HighlightEngine::for_file(Path::new("repro.js"), None, ®istry);
let source = "class ExampleClass {\n\
\texampleFunction = exampleArg => `${exampleArg}`;\n\
\n\
\tconstructor() {\n\
\t\t// constructor body\n\
\t}\n\
\n\
\t/* multiline comment */\n\
}\n";
let buffer = Buffer::from_str(source, 0, test_fs());
let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
let _ = engine.highlight_viewport(&buffer, 0, source.len(), &theme, 0);
let ctor_pos = source.find("constructor").expect("locate constructor");
let ctor_cat = engine.category_at_position(ctor_pos);
assert_ne!(
ctor_cat,
Some(HighlightCategory::String),
"constructor keyword must not inherit string state from earlier \
template literal (got {:?})",
ctor_cat,
);
let last_brace = source.rfind('}').expect("locate closing brace");
let brace_cat = engine.category_at_position(last_brace);
assert_ne!(
brace_cat,
Some(HighlightCategory::String),
"closing class brace must not be highlighted as string \
(got {:?})",
brace_cat,
);
}
#[test]
fn test_javascript_template_substitution_closing_tokens_are_string() {
let registry =
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
let mut engine = HighlightEngine::for_file(Path::new("tmpl.js"), None, ®istry);
let source = "const x = `${name}`;\n";
let buffer = Buffer::from_str(source, 0, test_fs());
let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
let _ = engine.highlight_viewport(&buffer, 0, source.len(), &theme, 0);
let close_brace = source
.find("}`")
.expect("locate substitution closing brace");
let close_backtick = close_brace + 1;
let name_pos = source.find("name").expect("locate identifier");
let name_cat = engine.category_at_position(name_pos);
assert_eq!(
name_cat,
Some(HighlightCategory::Variable),
"substitution identifier should be Variable (got {:?})",
name_cat,
);
let brace_cat = engine.category_at_position(close_brace);
assert_eq!(
brace_cat,
Some(HighlightCategory::String),
"closing }} of ${{…}} must be String (got {:?})",
brace_cat,
);
let backtick_cat = engine.category_at_position(close_backtick);
assert_eq!(
backtick_cat,
Some(HighlightCategory::String),
"closing backtick of template literal must be String \
(got {:?})",
backtick_cat,
);
}
}