use crate::model::buffer::Buffer;
use crate::primitives::grammar_registry::GrammarRegistry;
use crate::primitives::highlighter::{HighlightSpan, Highlighter, Language};
use crate::view::theme::Theme;
use std::ops::Range;
use std::path::Path;
use std::sync::Arc;
use syntect::parsing::SyntaxSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HighlighterPreference {
#[default]
Auto,
TreeSitter,
TextMate,
}
pub enum HighlightEngine {
TreeSitter(Highlighter),
TextMate(TextMateEngine),
None,
}
pub struct TextMateEngine {
syntax_set: Arc<SyntaxSet>,
syntax_index: usize,
cache: Option<TextMateCache>,
last_buffer_len: usize,
ts_language: Option<Language>,
}
#[derive(Debug, Clone)]
struct TextMateCache {
range: Range<usize>,
spans: Vec<CachedSpan>,
}
#[derive(Debug, Clone)]
struct CachedSpan {
range: Range<usize>,
category: crate::primitives::highlighter::HighlightCategory,
}
const MAX_PARSE_BYTES: usize = 1024 * 1024;
impl TextMateEngine {
pub fn new(syntax_set: Arc<SyntaxSet>, syntax_index: usize) -> Self {
Self {
syntax_set,
syntax_index,
cache: None,
last_buffer_len: 0,
ts_language: None,
}
}
pub fn with_language(
syntax_set: Arc<SyntaxSet>,
syntax_index: usize,
ts_language: Option<Language>,
) -> Self {
Self {
syntax_set,
syntax_index,
cache: None,
last_buffer_len: 0,
ts_language,
}
}
pub fn language(&self) -> Option<&Language> {
self.ts_language.as_ref()
}
pub fn highlight_viewport(
&mut self,
buffer: &Buffer,
viewport_start: usize,
viewport_end: usize,
theme: &Theme,
context_bytes: usize,
) -> Vec<HighlightSpan> {
use syntect::parsing::{ParseState, ScopeStack};
if let Some(cache) = &self.cache {
if cache.range.start <= viewport_start
&& cache.range.end >= viewport_end
&& self.last_buffer_len == buffer.len()
{
return cache
.spans
.iter()
.filter(|span| {
span.range.start < viewport_end && span.range.end > viewport_start
})
.map(|span| HighlightSpan {
range: span.range.clone(),
color: span.category.color(theme),
})
.collect();
}
}
let parse_start = viewport_start.saturating_sub(context_bytes);
let parse_end = (viewport_end + context_bytes).min(buffer.len());
if parse_end <= parse_start || parse_end - parse_start > MAX_PARSE_BYTES {
return Vec::new();
}
let syntax = &self.syntax_set.syntaxes()[self.syntax_index];
let mut state = ParseState::new(syntax);
let mut spans = Vec::new();
let content = buffer.slice_bytes(parse_start..parse_end);
let content_str = match std::str::from_utf8(&content) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let mut current_offset = parse_start;
let mut current_scopes = ScopeStack::new();
for line in content_str.lines() {
let line_with_newline = if current_offset + line.len() < parse_end {
format!("{}\n", line)
} else {
line.to_string()
};
let ops = match state.parse_line(&line_with_newline, &self.syntax_set) {
Ok(ops) => ops,
Err(_) => continue, };
let mut char_offset = 0;
for (op_offset, op) in ops {
if op_offset > char_offset {
if let Some(category) = Self::scope_stack_to_category(¤t_scopes) {
let byte_start = current_offset + char_offset;
let byte_end = current_offset + op_offset;
if byte_start < byte_end {
spans.push(CachedSpan {
range: byte_start..byte_end,
category,
});
}
}
}
char_offset = op_offset;
let _ = current_scopes.apply(&op);
}
let line_len = line_with_newline.len();
if char_offset < line_len {
if let Some(category) = Self::scope_stack_to_category(¤t_scopes) {
spans.push(CachedSpan {
range: (current_offset + char_offset)..(current_offset + line_len),
category,
});
}
}
current_offset += line_len;
}
Self::merge_adjacent_spans(&mut spans);
self.cache = Some(TextMateCache {
range: parse_start..parse_end,
spans: spans.clone(),
});
self.last_buffer_len = buffer.len();
spans
.into_iter()
.filter(|span| span.range.start < viewport_end && span.range.end > viewport_start)
.map(|span| HighlightSpan {
range: span.range,
color: span.category.color(theme),
})
.collect()
}
fn scope_stack_to_category(
scopes: &syntect::parsing::ScopeStack,
) -> Option<crate::primitives::highlighter::HighlightCategory> {
use crate::primitives::textmate_highlighter::scope_to_category;
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_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>) {
if let Some(cache) = &self.cache {
if edit_range.start < cache.range.end && edit_range.end > cache.range.start {
self.cache = None;
}
}
}
pub fn invalidate_all(&mut self) {
self.cache = None;
}
pub fn syntax_name(&self) -> &str {
&self.syntax_set.syntaxes()[self.syntax_index].name
}
}
impl HighlightEngine {
pub fn for_file(path: &Path, registry: &GrammarRegistry) -> Self {
Self::for_file_with_preference(path, registry, HighlighterPreference::Auto)
}
pub fn for_file_with_preference(
path: &Path,
registry: &GrammarRegistry,
preference: HighlighterPreference,
) -> Self {
match preference {
HighlighterPreference::Auto | HighlighterPreference::TextMate => {
Self::textmate_for_file(path, registry)
}
HighlighterPreference::TreeSitter => {
if let Some(lang) = Language::from_path(path) {
if let Ok(highlighter) = Highlighter::new(lang) {
return Self::TreeSitter(highlighter);
}
}
Self::None
}
}
}
fn textmate_for_file(path: &Path, registry: &GrammarRegistry) -> Self {
let syntax_set = registry.syntax_set_arc();
let ts_language = Language::from_path(path);
if let Some(syntax) = registry.find_syntax_for_file(path) {
if let Some(index) = syntax_set
.syntaxes()
.iter()
.position(|s| s.name == syntax.name)
{
return Self::TextMate(TextMateEngine::with_language(
syntax_set,
index,
ts_language,
));
}
}
if let Some(lang) = ts_language {
if let Ok(highlighter) = Highlighter::new(lang) {
tracing::debug!(
"No TextMate grammar for {:?}, falling back to tree-sitter",
path.extension()
);
return Self::TreeSitter(highlighter);
}
}
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 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 syntax_name(&self) -> Option<&str> {
match self {
Self::TreeSitter(_) => None, Self::TextMate(h) => Some(h.syntax_name()),
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,
}
}
}
impl Default for HighlightEngine {
fn default() -> Self {
Self::None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_highlighter_preference_default() {
let pref = HighlighterPreference::default();
assert_eq!(pref, HighlighterPreference::Auto);
}
#[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();
let engine = HighlightEngine::for_file(Path::new("test.rs"), ®istry);
assert_eq!(engine.backend_name(), "textmate");
assert!(engine.language().is_some());
let engine = HighlightEngine::for_file(Path::new("test.py"), ®istry);
assert_eq!(engine.backend_name(), "textmate");
assert!(engine.language().is_some());
let engine = HighlightEngine::for_file(Path::new("test.js"), ®istry);
assert_eq!(engine.backend_name(), "textmate");
assert!(engine.language().is_some());
let engine = HighlightEngine::for_file(Path::new("test.ts"), ®istry);
assert_eq!(engine.backend_name(), "tree-sitter");
assert!(engine.language().is_some());
let engine = HighlightEngine::for_file(Path::new("test.tsx"), ®istry);
assert_eq!(engine.backend_name(), "tree-sitter");
assert!(engine.language().is_some());
}
#[test]
fn test_tree_sitter_explicit_preference() {
let registry = GrammarRegistry::load();
let engine = HighlightEngine::for_file_with_preference(
Path::new("test.rs"),
®istry,
HighlighterPreference::TreeSitter,
);
assert_eq!(engine.backend_name(), "tree-sitter");
}
#[test]
fn test_unknown_extension() {
let registry = GrammarRegistry::load();
let engine = HighlightEngine::for_file(Path::new("test.unknown_xyz_123"), ®istry);
let _ = engine.backend_name();
}
#[test]
fn test_highlight_viewport_empty_buffer_no_panic() {
let registry = GrammarRegistry::load();
let mut engine = HighlightEngine::for_file(Path::new("test.rs"), ®istry);
let buffer = Buffer::from_str("", 0);
let theme = Theme::default();
if let HighlightEngine::TextMate(ref mut tm) = engine {
let spans = tm.highlight_viewport(&buffer, 100, 200, &theme, 10);
assert!(spans.is_empty());
}
}
}