use crate::config::Config;
use ratatui::style::{Color as TuiColor, Modifier, Style};
use ratatui::text::Span;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::OnceLock;
use syntect::{
easy::HighlightLines,
highlighting::{Color, FontStyle, Style as SynStyle, Theme, ThemeSet},
parsing::{ParseState, Scope, ScopeStack, SyntaxReference, SyntaxSet},
util::LinesWithEndings,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SyntaxSide {
Old,
New,
}
#[derive(Clone, Debug)]
pub struct SyntaxSpan {
pub text: String,
pub style: Style,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct EnclosingScopeRange {
pub(crate) start_line: usize,
pub(crate) end_line: usize,
pub(crate) definition_line: usize,
pub(crate) definition: String,
}
pub(crate) fn enclosing_scope_definition(
ranges: &[EnclosingScopeRange],
line_index: usize,
) -> Option<&str> {
let idx = ranges.partition_point(|range| range.start_line <= line_index);
ranges
.get(idx.saturating_sub(1))
.filter(|range| range.start_line <= line_index && line_index < range.end_line)
.map(|range| range.definition.as_str())
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct SyntaxDebugStats {
pub(crate) requests: usize,
pub(crate) rendered_hits: usize,
pub(crate) rendered_misses: usize,
pub(crate) highlight_lines: usize,
pub(crate) cached_lines: usize,
pub(crate) warm_lines: usize,
}
#[derive(Clone, Debug)]
pub struct SyntaxCache {
old: SyntaxStore,
new: SyntaxStore,
epoch: u64,
}
#[derive(Clone, Debug)]
enum SyntaxStore {
Full(FullSyntaxCache),
Lazy(Box<LazySyntaxCache>),
}
#[derive(Clone, Debug)]
struct FullSyntaxCache {
lines: Vec<Vec<SyntaxSpan>>,
rendered: Vec<Option<Vec<Span<'static>>>>,
}
#[derive(Clone, Debug)]
struct LazySyntaxCache {
syntax_set: std::sync::Arc<SyntaxSet>,
theme: std::sync::Arc<Theme>,
plain: TuiColor,
lines: Vec<String>,
spans: Vec<Option<Vec<SyntaxSpan>>>,
rendered: Vec<Option<Vec<Span<'static>>>>,
checkpoints: Vec<Option<(syntect::highlighting::HighlightState, ParseState)>>,
chunk_states: Vec<Option<ChunkProgress>>,
warm_progress: Option<WarmProgress>,
stride: usize,
}
const MAX_LAZY_SYNTAX_BYTES: usize = 512 * 1024;
const MAX_SCOPE_HINT_BYTES: usize = MAX_LAZY_SYNTAX_BYTES;
const SYNTAX_CHECKPOINT_STRIDE: usize = 200;
#[cfg(test)]
const MAX_SYNC_SYNTAX_LINES: usize = 200;
#[cfg(not(test))]
const MAX_SYNC_SYNTAX_LINES: usize = 5_000;
#[derive(Clone, Debug)]
struct ChunkProgress {
next_line: usize,
state: (syntect::highlighting::HighlightState, ParseState),
}
#[derive(Clone, Debug)]
struct WarmProgress {
next_line: usize,
next_chunk: usize,
target_chunk: usize,
state: (syntect::highlighting::HighlightState, ParseState),
}
struct SyntaxDebugCounters {
requests: AtomicUsize,
rendered_hits: AtomicUsize,
rendered_misses: AtomicUsize,
highlight_lines: AtomicUsize,
cached_lines: AtomicUsize,
warm_lines: AtomicUsize,
}
impl Default for SyntaxDebugCounters {
fn default() -> Self {
Self {
requests: AtomicUsize::new(0),
rendered_hits: AtomicUsize::new(0),
rendered_misses: AtomicUsize::new(0),
highlight_lines: AtomicUsize::new(0),
cached_lines: AtomicUsize::new(0),
warm_lines: AtomicUsize::new(0),
}
}
}
struct EmbeddedTmTheme {
name: &'static str,
data: &'static [u8],
}
const EMBEDDED_TMTHEMES: &[EmbeddedTmTheme] = &[
EmbeddedTmTheme {
name: "aura",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/aura-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "ayu",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/ayu-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "catppuccin",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/catppuccin-mocha.tmTheme"
)),
},
EmbeddedTmTheme {
name: "catppuccin-mocha",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/catppuccin-mocha.tmTheme"
)),
},
EmbeddedTmTheme {
name: "catppuccin-frappe",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/catppuccin-frappe.tmTheme"
)),
},
EmbeddedTmTheme {
name: "catppuccin-macchiato",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/catppuccin-macchiato.tmTheme"
)),
},
EmbeddedTmTheme {
name: "catppuccin-latte",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/catppuccin-latte.tmTheme"
)),
},
EmbeddedTmTheme {
name: "cobalt2",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/cobalt2-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "dracula",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/dracula-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "everforest",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/everforest-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "everforest-light",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/everforest-light.tmTheme"
)),
},
EmbeddedTmTheme {
name: "flexoki",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/flexoki-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "flexoki-light",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/flexoki-light.tmTheme"
)),
},
EmbeddedTmTheme {
name: "github",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/github-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "github-light",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/github-light.tmTheme"
)),
},
EmbeddedTmTheme {
name: "gruvbox",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/gruvbox-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "gruvbox-light",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/gruvbox-light.tmTheme"
)),
},
EmbeddedTmTheme {
name: "kanagawa",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/kanagawa-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "material",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/material-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "monokai",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/monokai-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "nightowl",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/nightowl-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "nightowl-light",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/nightowl-light.tmTheme"
)),
},
EmbeddedTmTheme {
name: "nord",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/nord-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "one-dark",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/one-dark-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "one-dark-light",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/one-dark-light.tmTheme"
)),
},
EmbeddedTmTheme {
name: "palenight",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/palenight-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "rosepine",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/rosepine-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "rosepine-dawn",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/rosepine-dawn.tmTheme"
)),
},
EmbeddedTmTheme {
name: "solarized",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/solarized-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "solarized-light",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/solarized-light.tmTheme"
)),
},
EmbeddedTmTheme {
name: "synthwave84",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/synthwave84-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "tokyonight",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/tokyonight-dark.tmTheme"
)),
},
EmbeddedTmTheme {
name: "tokyonight-day",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/tokyonight-day.tmTheme"
)),
},
EmbeddedTmTheme {
name: "zenburn",
data: include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/themes/syntax/zenburn-dark.tmTheme"
)),
},
];
#[derive(Clone)]
pub struct SyntaxEngine {
syntax_set: std::sync::Arc<SyntaxSet>,
theme: std::sync::Arc<Theme>,
plain: TuiColor,
}
impl SyntaxEngine {
pub(crate) fn shares_syntax_with(&self, other: &Self) -> bool {
std::sync::Arc::ptr_eq(&self.syntax_set, &other.syntax_set)
}
pub fn new(syntax_theme: &str, light_mode: bool) -> Self {
let syntax_set = std::sync::Arc::new(syntax_set_with_dirs(config_syntax_dirs()));
let (syntax_theme, plain) = resolve_syntax_theme(syntax_theme, light_mode);
Self {
syntax_set,
theme: std::sync::Arc::new(syntax_theme),
plain,
}
}
pub fn highlight(&self, content: &str, file_name: &str) -> Vec<Vec<SyntaxSpan>> {
let syntax = self.syntax_for_file(file_name);
let mut highlighter = HighlightLines::new(syntax, self.theme.as_ref());
let mut out = Vec::new();
for line in LinesWithEndings::from(content) {
let ranges = highlighter
.highlight_line(line, &self.syntax_set)
.unwrap_or_default();
out.push(ranges_to_spans(ranges, self.plain));
}
if out.is_empty() {
out.push(vec![SyntaxSpan {
text: String::new(),
style: Style::default().fg(self.plain),
}]);
}
out
}
pub fn highlight_by_token(&self, content: &str, token: &str) -> Vec<Vec<SyntaxSpan>> {
let syntax = self
.syntax_set
.find_syntax_by_token(token)
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
let mut highlighter = HighlightLines::new(syntax, self.theme.as_ref());
let mut out = Vec::new();
for line in LinesWithEndings::from(content) {
let ranges = highlighter
.highlight_line(line, &self.syntax_set)
.unwrap_or_default();
out.push(ranges_to_spans(ranges, self.plain));
}
if out.is_empty() {
out.push(vec![SyntaxSpan {
text: String::new(),
style: Style::default().fg(self.plain),
}]);
}
out
}
pub fn collect_scopes(&self, content: &str, file_name: &str) -> BTreeMap<String, usize> {
let syntax = self.syntax_for_file(file_name);
let mut state = ParseState::new(syntax);
let mut stack = ScopeStack::new();
let mut counts = BTreeMap::new();
for line in LinesWithEndings::from(content) {
let ops = state.parse_line(line, &self.syntax_set).unwrap_or_default();
for (_, op) in &ops {
stack.apply(op).ok();
for scope in stack.scopes.iter() {
*counts.entry(scope.to_string()).or_insert(0) += 1;
}
}
}
counts
}
pub fn syntax_name_for_file(&self, file_name: &str) -> &str {
&self.syntax_for_file(file_name).name
}
pub fn has_syntax_for_file(&self, file_name: &str) -> bool {
!std::ptr::eq(
self.syntax_for_file(file_name),
self.syntax_set.find_syntax_plain_text(),
)
}
pub fn syntax_ref(&self, file_name: &str) -> SyntaxReference {
self.syntax_for_file(file_name).clone()
}
pub fn syntax_set(&self) -> std::sync::Arc<SyntaxSet> {
self.syntax_set.clone()
}
pub fn theme(&self) -> &Theme {
self.theme.as_ref()
}
pub fn theme_arc(&self) -> std::sync::Arc<Theme> {
self.theme.clone()
}
pub fn plain(&self) -> TuiColor {
self.plain
}
pub fn scopes_for_line(
&self,
content: &str,
file_name: &str,
line_index: usize,
) -> Vec<String> {
let syntax = self.syntax_for_file(file_name);
let mut state = ParseState::new(syntax);
let mut stack = ScopeStack::new();
let mut scopes: BTreeSet<String> = BTreeSet::new();
for (idx, line) in LinesWithEndings::from(content).enumerate() {
let ops = state.parse_line(line, &self.syntax_set).unwrap_or_default();
if idx == line_index {
for (_, op) in ops {
stack.apply(&op).ok();
for scope in stack.scopes.iter() {
scopes.insert(scope.to_string());
}
}
break;
}
for (_, op) in ops {
stack.apply(&op).ok();
}
}
scopes.into_iter().collect()
}
pub(crate) fn enclosing_scope_ranges(
&self,
content: &str,
file_name: &str,
) -> Vec<EnclosingScopeRange> {
let syntax = self.syntax_for_file(file_name);
if syntax.name == "Plain Text" || content.len() > MAX_SCOPE_HINT_BYTES {
return Vec::new();
}
if syntax.name.to_ascii_lowercase().contains("markdown") {
return markdown_heading_ranges(content, syntax, &self.syntax_set);
}
let mut state = ParseState::new(syntax);
let mut stack = ScopeStack::new();
let mut persistent = Vec::<ActiveNamedScope>::new();
let mut indented = Vec::<IndentedNamedScope>::new();
let mut ranges = Vec::<EnclosingScopeRange>::new();
let mut order = 0usize;
for (line_index, line) in LinesWithEndings::from(content).enumerate() {
let trimmed = line.trim();
let indent = indentation_width(line);
if !trimmed.is_empty() {
while indented.last().is_some_and(|scope| indent <= scope.indent) {
indented.pop();
}
}
let before = stack.scopes.clone();
let Ok(ops) = state.parse_line(line, &self.syntax_set) else {
return Vec::new();
};
let mut has_definition = false;
for (idx, (position, op)) in ops.iter().enumerate() {
if stack.apply(op).is_err() {
return Vec::new();
}
let end = ops
.get(idx + 1)
.map(|(next, _)| *next)
.unwrap_or(line.len());
if end <= *position || !stack_has_named_definition(&stack) {
continue;
}
let text = line[*position..end].trim();
if !text.is_empty() {
has_definition = true;
}
}
persistent.retain(|scope| {
stack.scopes.len() >= scope.prefix.len()
&& stack.scopes[..scope.prefix.len()] == scope.prefix
});
if has_definition {
order = order.wrapping_add(1);
let definition = line.trim_end_matches(['\r', '\n']).trim_start().to_string();
let common = before
.iter()
.zip(&stack.scopes)
.take_while(|(left, right)| left == right)
.count();
let definition_depth = stack
.scopes
.iter()
.enumerate()
.skip(common)
.filter(|(_, scope)| scope_is_definition_scope(&scope.to_string()))
.map(|(idx, _)| idx + 1)
.next_back();
let depth = definition_depth.or_else(|| {
stack
.scopes
.iter()
.enumerate()
.skip(common)
.filter(|(_, scope)| scope_is_definition_body(&scope.to_string()))
.map(|(idx, _)| idx + 1)
.next_back()
});
if let Some(depth) = depth {
persistent.retain(|scope| scope.prefix.len() < depth);
persistent.push(ActiveNamedScope {
prefix: stack.scopes[..depth].to_vec(),
definition,
definition_line: line_index,
order,
});
} else {
indented.push(IndentedNamedScope {
indent,
definition,
definition_line: line_index,
order,
});
}
}
let definition = persistent
.iter()
.map(|scope| {
(
scope.order,
scope.definition_line,
scope.definition.as_str(),
)
})
.chain(indented.iter().map(|scope| {
(
scope.order,
scope.definition_line,
scope.definition.as_str(),
)
}))
.max_by_key(|(scope_order, _, _)| *scope_order)
.map(|(_, definition_line, definition)| (definition_line, definition));
if let Some((definition_line, definition)) = definition {
let extends_last = ranges.last().is_some_and(|last| {
last.end_line == line_index && last.definition_line == definition_line
});
if extends_last {
ranges.last_mut().expect("scope range exists").end_line += 1;
} else {
ranges.push(EnclosingScopeRange {
start_line: line_index,
end_line: line_index + 1,
definition_line,
definition: definition.to_string(),
});
}
}
}
ranges
}
fn syntax_for_file(&self, file_name: &str) -> &SyntaxReference {
self.syntax_set
.find_syntax_for_file(file_name)
.ok()
.flatten()
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text())
}
}
#[derive(Clone, Debug)]
struct MarkdownHeading {
level: usize,
definition_line: usize,
definition: String,
}
fn markdown_heading_ranges(
content: &str,
syntax: &SyntaxReference,
syntax_set: &SyntaxSet,
) -> Vec<EnclosingScopeRange> {
let mut state = ParseState::new(syntax);
let mut stack = ScopeStack::new();
let mut headings = Vec::<MarkdownHeading>::new();
let mut ranges = Vec::<EnclosingScopeRange>::new();
for (line_index, line) in LinesWithEndings::from(content).enumerate() {
let Ok(ops) = state.parse_line(line, syntax_set) else {
return Vec::new();
};
let mut is_heading = false;
for (_, op) in ops {
if stack.apply(&op).is_err() {
return Vec::new();
}
is_heading |= stack_has_markdown_heading(&stack);
}
if is_heading {
let definition = line.trim_end_matches(['\r', '\n']).trim_start().to_string();
let level = definition.chars().take_while(|ch| *ch == '#').count();
if level > 0 {
while headings
.last()
.is_some_and(|heading| heading.level >= level)
{
headings.pop();
}
headings.push(MarkdownHeading {
level,
definition_line: line_index,
definition,
});
}
}
if let Some(heading) = headings.last() {
let extends_last = ranges.last().is_some_and(|last| {
last.end_line == line_index && last.definition_line == heading.definition_line
});
if extends_last {
ranges.last_mut().expect("heading range exists").end_line += 1;
} else {
ranges.push(EnclosingScopeRange {
start_line: line_index,
end_line: line_index + 1,
definition_line: heading.definition_line,
definition: heading.definition.clone(),
});
}
}
}
ranges
}
fn stack_has_markdown_heading(stack: &ScopeStack) -> bool {
let mut heading = false;
let mut section = false;
for scope in &stack.scopes {
let scope = scope.to_string();
heading |= scope.starts_with("markup.heading");
section |= scope.starts_with("entity.name.section");
}
heading && section
}
#[derive(Clone, Debug)]
struct ActiveNamedScope {
prefix: Vec<Scope>,
definition: String,
definition_line: usize,
order: usize,
}
#[derive(Clone, Debug)]
struct IndentedNamedScope {
indent: usize,
definition: String,
definition_line: usize,
order: usize,
}
#[derive(Clone, Copy)]
enum DefinitionKind {
Function,
Class,
Type,
Section,
Namespace,
}
fn stack_has_named_definition(stack: &ScopeStack) -> bool {
let Some((entity_idx, kind)) =
stack
.scopes
.iter()
.enumerate()
.rev()
.find_map(|(idx, scope)| {
definition_kind_for_entity(&scope.to_string()).map(|kind| (idx, kind))
})
else {
return false;
};
stack.scopes[..entity_idx]
.iter()
.rev()
.map(ToString::to_string)
.find(|scope| scope.starts_with("meta."))
.is_some_and(|scope| meta_matches_definition(&scope, kind))
}
fn definition_kind_for_entity(scope: &str) -> Option<DefinitionKind> {
if !scope.starts_with("entity.name.") {
return None;
}
if scope.contains(".function") || scope.contains(".method") {
Some(DefinitionKind::Function)
} else if scope.contains(".class") {
Some(DefinitionKind::Class)
} else if scope.contains(".type")
|| scope.contains(".struct")
|| scope.contains(".enum")
|| scope.contains(".trait")
|| scope.contains(".interface")
|| scope.contains(".impl")
{
Some(DefinitionKind::Type)
} else if scope.contains(".section") || scope.contains(".heading") {
Some(DefinitionKind::Section)
} else if scope.contains(".namespace") || scope.contains(".module") {
Some(DefinitionKind::Namespace)
} else {
None
}
}
fn meta_matches_definition(scope: &str, kind: DefinitionKind) -> bool {
match kind {
DefinitionKind::Function => {
(scope.starts_with("meta.function") || scope.contains("method"))
&& !scope.contains("call")
}
DefinitionKind::Class => scope.starts_with("meta.class") || scope.starts_with("meta.type"),
DefinitionKind::Type => {
scope.starts_with("meta.type")
|| scope.starts_with("meta.struct")
|| scope.starts_with("meta.enum")
|| scope.starts_with("meta.trait")
|| scope.starts_with("meta.interface")
|| scope.starts_with("meta.impl")
|| scope.starts_with("meta.class")
}
DefinitionKind::Section => {
scope.starts_with("meta.section") || scope.starts_with("meta.heading")
}
DefinitionKind::Namespace => {
scope.starts_with("meta.namespace") || scope.starts_with("meta.module")
}
}
}
fn scope_is_definition_scope(scope: &str) -> bool {
((scope.starts_with("meta.function") || scope.contains("method"))
&& !scope.contains("call")
&& !scope.contains("parameters"))
|| scope.starts_with("meta.class")
|| scope.starts_with("meta.type")
|| scope.starts_with("meta.struct")
|| scope.starts_with("meta.enum")
|| scope.starts_with("meta.trait")
|| scope.starts_with("meta.interface")
|| scope.starts_with("meta.impl")
|| scope.starts_with("meta.section")
|| scope.starts_with("meta.heading")
|| scope.starts_with("meta.namespace")
|| scope.starts_with("meta.module")
}
fn indentation_width(line: &str) -> usize {
line.chars()
.take_while(|ch| ch.is_whitespace() && *ch != '\n' && *ch != '\r')
.fold(0, |column, ch| {
if ch == '\t' {
(column / 8 + 1) * 8
} else {
column + 1
}
})
}
fn scope_is_definition_body(scope: &str) -> bool {
scope.starts_with("meta.block")
|| scope.starts_with("meta.group.braces")
|| scope.starts_with("meta.brace.curly")
}
fn resolve_syntax_theme(theme_name: &str, light_mode: bool) -> (Theme, TuiColor) {
let (mut ansi_theme, ansi_plain) = load_ansi_theme();
strip_theme_backgrounds(&mut ansi_theme);
ensure_foreground(&mut ansi_theme, ansi_plain);
let mut candidates = Vec::new();
if is_explicit_variant(theme_name) {
candidates.push(theme_name.to_string());
} else if light_mode {
candidates.extend(light_variants(theme_name));
candidates.push(theme_name.to_string());
candidates.extend(dark_variants(theme_name));
} else {
candidates.extend(dark_variants(theme_name));
candidates.push(theme_name.to_string());
candidates.extend(light_variants(theme_name));
}
for candidate in candidates {
if let Some(mut theme) = load_theme_candidate(&candidate) {
strip_theme_backgrounds(&mut theme);
let plain = theme.settings.foreground.map(to_tui).unwrap_or(ansi_plain);
ensure_foreground(&mut theme, plain);
return (theme, plain);
}
}
(ansi_theme, ansi_plain)
}
fn load_theme_candidate(name: &str) -> Option<Theme> {
if name.ends_with(".tmTheme") {
return load_tmtheme(name);
}
load_embedded_syntax_theme(name).or_else(|| load_tmtheme(name))
}
fn load_tmtheme(name: &str) -> Option<Theme> {
let path = resolve_tmtheme_path(name);
if !path.exists() {
return None;
}
ThemeSet::get_theme(path).ok()
}
fn resolve_tmtheme_path(name: &str) -> PathBuf {
let with_extension = if name
.rsplit('.')
.next()
.is_some_and(|ext| ext.eq_ignore_ascii_case("tmtheme"))
{
None
} else {
Some(format!("{name}.tmTheme"))
};
let path = Path::new(name);
if path.is_absolute() || name.contains(std::path::MAIN_SEPARATOR) {
return path.to_path_buf();
}
for dir in config_theme_dirs() {
let candidate = dir.join(name);
if candidate.exists() {
return candidate;
}
if let Some(with_extension) = with_extension.as_deref() {
let candidate = dir.join(with_extension);
if candidate.exists() {
return candidate;
}
}
}
path.to_path_buf()
}
fn config_theme_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(config_path) = Config::config_path() {
if let Some(parent) = config_path.parent() {
dirs.push(parent.join("themes"));
}
}
if let Some(config_dir) = dirs::config_dir() {
let themes = config_dir.join("oyo").join("themes");
if !dirs.contains(&themes) {
dirs.push(themes);
}
}
dirs
}
fn config_syntax_dirs() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(config_path) = Config::config_path() {
if let Some(parent) = config_path.parent() {
paths.push(parent.join("syntaxes"));
}
}
if let Some(config_dir) = dirs::config_dir() {
let path = config_dir.join("oyo").join("syntaxes");
if !paths.contains(&path) {
paths.push(path);
}
}
paths
}
fn syntax_set_with_dirs(paths: Vec<PathBuf>) -> SyntaxSet {
let mut builder = two_face::syntax::extra_newlines().into_builder();
for path in paths {
if path.is_dir() {
let _ = builder.add_from_folder(path, true);
}
}
builder.build()
}
pub fn list_syntax_themes() -> Vec<String> {
let mut names = BTreeSet::new();
for item in EMBEDDED_TMTHEMES {
names.insert(item.name.to_string());
}
for theme_dir in config_theme_dirs() {
if let Ok(entries) = fs::read_dir(theme_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("tmTheme"))
{
if let Some(stem) = path.file_stem().and_then(|n| n.to_str()) {
names.insert(stem.to_string());
if let Some(base) = strip_variant_suffix(stem) {
names.insert(base);
}
}
}
}
}
}
names.into_iter().collect()
}
fn load_embedded_syntax_theme(name: &str) -> Option<Theme> {
load_embedded_tmtheme(name).or_else(|| load_two_face_theme(name))
}
fn load_embedded_tmtheme(name: &str) -> Option<Theme> {
let needle = normalize_theme_key(name);
for item in EMBEDDED_TMTHEMES {
if normalize_theme_key(item.name) == needle {
let mut cursor = Cursor::new(item.data);
return ThemeSet::load_from_reader(&mut cursor).ok();
}
}
None
}
fn load_two_face_theme(name: &str) -> Option<Theme> {
let needle = normalize_theme_key(name);
let embedded = two_face::theme::EmbeddedLazyThemeSet::theme_names()
.iter()
.copied()
.find(|theme| normalize_theme_key(theme.as_name()) == needle)?;
Some(two_face::theme::extra().get(embedded).clone())
}
fn load_ansi_theme() -> (Theme, TuiColor) {
let theme = load_two_face_theme("ansi").unwrap_or_default();
let plain = theme
.settings
.foreground
.map(to_tui)
.unwrap_or(TuiColor::White);
(theme, plain)
}
fn strip_theme_backgrounds(theme: &mut Theme) {
theme.settings.background = None;
for item in &mut theme.scopes {
item.style.background = None;
}
}
fn ensure_foreground(theme: &mut Theme, plain: TuiColor) {
if theme.settings.foreground.is_none() {
theme.settings.foreground = Some(to_syntect(plain));
}
}
fn light_variants(name: &str) -> Vec<String> {
if name.ends_with(".tmTheme") {
let path = Path::new(name);
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let base = path.parent().unwrap_or_else(|| Path::new(""));
return ["-light", "_light", ".light"]
.iter()
.map(|suffix| base.join(format!("{stem}{suffix}.tmTheme")))
.map(|p| p.to_string_lossy().to_string())
.collect();
}
return Vec::new();
}
let lower = name.to_ascii_lowercase();
let mut variants = Vec::new();
match lower.as_str() {
"catppuccin" => variants.push("catppuccin-latte".to_string()),
"rosepine" => variants.push("rosepine-dawn".to_string()),
"tokyonight" => variants.push("tokyonight-day".to_string()),
_ => {}
}
variants.extend([
format!("{name}-light"),
format!("{name}_light"),
format!("{name}.light"),
]);
variants
}
fn dark_variants(name: &str) -> Vec<String> {
if name.ends_with(".tmTheme") {
let path = Path::new(name);
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let base = path.parent().unwrap_or_else(|| Path::new(""));
return ["-dark", "_dark", ".dark"]
.iter()
.map(|suffix| base.join(format!("{stem}{suffix}.tmTheme")))
.map(|p| p.to_string_lossy().to_string())
.collect();
}
return Vec::new();
}
[
format!("{name}-dark"),
format!("{name}_dark"),
format!("{name}.dark"),
]
.to_vec()
}
fn is_explicit_variant(name: &str) -> bool {
if name.ends_with(".tmTheme") {
return true;
}
let lower = name.to_ascii_lowercase();
lower.ends_with("-light")
|| lower.ends_with("_light")
|| lower.ends_with(".light")
|| lower.ends_with("-dark")
|| lower.ends_with("_dark")
|| lower.ends_with(".dark")
}
fn strip_variant_suffix(stem: &str) -> Option<String> {
let lower = stem.to_ascii_lowercase();
for suffix in ["-light", "_light", ".light", "-dark", "_dark", ".dark"] {
if lower.ends_with(suffix) {
return Some(stem[..stem.len().saturating_sub(suffix.len())].to_string());
}
}
None
}
fn normalize_theme_key(value: &str) -> String {
value
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
impl SyntaxCache {
pub fn new(
engine: &SyntaxEngine,
old: &str,
new: &str,
file_name: &str,
force_lazy: bool,
) -> Self {
let max_len = old.len().max(new.len());
let lazy = force_lazy || max_len > MAX_LAZY_SYNTAX_BYTES;
if lazy {
let old = LazySyntaxCache::new(engine, old, file_name);
let new = LazySyntaxCache::new(engine, new, file_name);
Self {
old: SyntaxStore::Lazy(Box::new(old)),
new: SyntaxStore::Lazy(Box::new(new)),
epoch: 0,
}
} else {
let old = engine.highlight(old, file_name);
let new = engine.highlight(new, file_name);
Self {
old: SyntaxStore::Full(FullSyntaxCache {
rendered: vec![None; old.len()],
lines: old,
}),
new: SyntaxStore::Full(FullSyntaxCache {
rendered: vec![None; new.len()],
lines: new,
}),
epoch: 0,
}
}
}
pub fn rendered_spans(
&mut self,
side: SyntaxSide,
line_index: usize,
) -> Option<Vec<Span<'static>>> {
self.rendered_spans_with_cancel(side, line_index, None)
}
pub(crate) fn rendered_spans_with_cancel(
&mut self,
side: SyntaxSide,
line_index: usize,
cancelled: Option<&AtomicBool>,
) -> Option<Vec<Span<'static>>> {
syntax_debug_request();
let is_cancelled = || cancelled.is_some_and(|flag| flag.load(Ordering::Relaxed));
let cancelled = cancelled.map(|_| &is_cancelled as &dyn Fn() -> bool);
match side {
SyntaxSide::Old => rendered_spans_for_store(&mut self.old, line_index, cancelled),
SyntaxSide::New => rendered_spans_for_store(&mut self.new, line_index, cancelled),
}
}
pub fn cached_rendered_spans(
&self,
side: SyntaxSide,
line_index: usize,
) -> Option<Vec<Span<'static>>> {
let store = match side {
SyntaxSide::Old => &self.old,
SyntaxSide::New => &self.new,
};
match store {
SyntaxStore::Full(cache) => cache
.rendered
.get(line_index)
.and_then(Option::as_ref)
.cloned()
.or_else(|| {
cache
.lines
.get(line_index)
.map(|spans| syntax_spans_to_ratatui(spans))
}),
SyntaxStore::Lazy(cache) => cache
.rendered
.get(line_index)
.and_then(Option::as_ref)
.cloned()
.or_else(|| {
cache
.spans
.get(line_index)
.and_then(Option::as_ref)
.map(|spans| syntax_spans_to_ratatui(spans))
}),
}
}
pub(crate) fn warm_checkpoints(&mut self, max_lines: usize) -> usize {
if max_lines == 0 {
return 0;
}
let new_pending_before = warm_pending_for_store(&self.new);
let old_pending_before = warm_pending_for_store(&self.old);
let mut remaining = max_lines;
if new_pending_before || old_pending_before {
if new_pending_before {
remaining =
remaining.saturating_sub(warm_checkpoints_for_store(&mut self.new, remaining));
}
if old_pending_before {
remaining =
remaining.saturating_sub(warm_checkpoints_for_store(&mut self.old, remaining));
}
} else {
remaining =
remaining.saturating_sub(warm_checkpoints_for_store(&mut self.new, remaining));
remaining =
remaining.saturating_sub(warm_checkpoints_for_store(&mut self.old, remaining));
}
if new_pending_before && !warm_pending_for_store(&self.new) {
self.bump_epoch();
}
if old_pending_before && !warm_pending_for_store(&self.old) {
self.bump_epoch();
}
max_lines - remaining
}
pub(crate) fn warm_pending(&self) -> bool {
warm_pending_for_store(&self.old) || warm_pending_for_store(&self.new)
}
pub(crate) fn epoch(&self) -> u64 {
self.epoch
}
fn bump_epoch(&mut self) {
self.epoch = self.epoch.wrapping_add(1);
}
pub(crate) fn set_warmup_targets(
&mut self,
old: Option<crate::app::WarmupRange>,
new: Option<crate::app::WarmupRange>,
) {
set_warmup_target_for_store(&mut self.old, old);
set_warmup_target_for_store(&mut self.new, new);
}
}
fn rendered_spans_for_store(
store: &mut SyntaxStore,
line_index: usize,
cancelled: Option<&dyn Fn() -> bool>,
) -> Option<Vec<Span<'static>>> {
match store {
SyntaxStore::Full(cache) => {
if line_index >= cache.lines.len() {
return None;
}
if let Some(rendered) = cache.rendered.get(line_index).and_then(|v| v.as_ref()) {
syntax_debug_rendered_hit();
return Some(rendered.clone());
}
syntax_debug_rendered_miss();
let spans = cache.lines.get(line_index)?;
let rendered = syntax_spans_to_ratatui(spans);
cache.rendered[line_index] = Some(rendered.clone());
Some(rendered)
}
SyntaxStore::Lazy(cache) => cache.rendered_spans_with_cancel(line_index, cancelled),
}
}
fn warm_checkpoints_for_store(store: &mut SyntaxStore, max_lines: usize) -> usize {
match store {
SyntaxStore::Full(_) => 0,
SyntaxStore::Lazy(cache) => cache.warm_checkpoints(max_lines),
}
}
fn warm_pending_for_store(store: &SyntaxStore) -> bool {
match store {
SyntaxStore::Full(_) => false,
SyntaxStore::Lazy(cache) => cache.warm_pending(),
}
}
fn set_warmup_target_for_store(store: &mut SyntaxStore, range: Option<crate::app::WarmupRange>) {
match store {
SyntaxStore::Full(_) => {}
SyntaxStore::Lazy(cache) => match range {
Some(range) => cache.set_warmup_target(range.start, range.end),
None => cache.clear_warmup_target(),
},
}
}
impl LazySyntaxCache {
fn new(engine: &SyntaxEngine, content: &str, file_name: &str) -> Self {
let syntax = engine.syntax_ref(file_name);
let lines: Vec<String> = LinesWithEndings::from(content)
.map(|line| line.to_string())
.collect();
let mut lines = if lines.is_empty() {
vec![String::new()]
} else {
lines
};
if lines.len() == 1 && lines[0].is_empty() {
lines[0].push_str("");
}
let stride = SYNTAX_CHECKPOINT_STRIDE.max(1);
let checkpoint_len = lines.len().saturating_sub(1) / stride + 1;
let mut checkpoints = vec![None; checkpoint_len];
let highlighter = HighlightLines::new(&syntax, engine.theme());
checkpoints[0] = Some(highlighter.state());
Self {
syntax_set: engine.syntax_set(),
theme: engine.theme_arc(),
plain: engine.plain(),
spans: vec![None; lines.len()],
rendered: vec![None; lines.len()],
lines,
checkpoints,
chunk_states: vec![None; checkpoint_len],
warm_progress: None,
stride,
}
}
#[cfg(test)]
fn spans(&mut self, line_index: usize) -> Option<&[SyntaxSpan]> {
self.spans_with_cancel(line_index, None)
}
fn spans_with_cancel(
&mut self,
line_index: usize,
cancelled: Option<&dyn Fn() -> bool>,
) -> Option<&[SyntaxSpan]> {
if line_index >= self.lines.len() {
return None;
}
let needs_fill = self
.spans
.get(line_index)
.map(|spans| spans.is_none())
.unwrap_or(true);
if !needs_fill {
return self.spans.get(line_index).and_then(|s| s.as_deref());
}
let chunk = line_index / self.stride;
let state = self.ensure_checkpoint(chunk, cancelled)?;
let chunk_start = chunk * self.stride;
let chunk_end = ((chunk + 1) * self.stride).min(self.lines.len());
let progress = self
.chunk_states
.get_mut(chunk)
.and_then(|slot| slot.take());
let (mut next_line, mut highlighter) = if let Some(progress) = progress {
(
progress.next_line,
HighlightLines::from_state(self.theme.as_ref(), progress.state.0, progress.state.1),
)
} else {
(
chunk_start,
HighlightLines::from_state(self.theme.as_ref(), state.0, state.1),
)
};
if next_line < chunk_start {
next_line = chunk_start;
}
let lines = &self.lines;
let spans = &mut self.spans;
let to_process = line_index.saturating_sub(next_line).saturating_add(1);
if to_process > 0 {
syntax_debug_highlight_lines(to_process);
syntax_debug_cached_lines(to_process);
}
for idx in next_line..=line_index {
if cancelled.is_some_and(|is_cancelled| is_cancelled()) {
return None;
}
let line = &lines[idx];
let ranges = highlighter
.highlight_line(line, &self.syntax_set)
.unwrap_or_default();
spans[idx] = Some(ranges_to_spans(ranges, self.plain));
}
let next_line = (line_index + 1).min(chunk_end);
let state = highlighter.state();
if next_line == chunk_end && self.checkpoints.len() > chunk + 1 {
self.checkpoints[chunk + 1] = Some(state);
} else if let Some(slot) = self.chunk_states.get_mut(chunk) {
*slot = Some(ChunkProgress { next_line, state });
}
self.spans.get(line_index).and_then(|s| s.as_deref())
}
#[cfg(test)]
fn rendered_spans(&mut self, line_index: usize) -> Option<Vec<Span<'static>>> {
self.rendered_spans_with_cancel(line_index, None)
}
fn rendered_spans_with_cancel(
&mut self,
line_index: usize,
cancelled: Option<&dyn Fn() -> bool>,
) -> Option<Vec<Span<'static>>> {
if line_index >= self.lines.len() {
return None;
}
if let Some(rendered) = self.rendered.get(line_index).and_then(|v| v.as_ref()) {
syntax_debug_rendered_hit();
return Some(rendered.clone());
}
syntax_debug_rendered_miss();
let spans = self.spans_with_cancel(line_index, cancelled)?;
let rendered = syntax_spans_to_ratatui(spans);
self.rendered[line_index] = Some(rendered.clone());
Some(rendered)
}
fn warm_checkpoints(&mut self, max_lines: usize) -> usize {
if max_lines == 0 || self.checkpoints.len() <= 1 {
return 0;
}
let mut progress = self.warm_progress.take();
if progress.is_none() {
let Some(state) = self.checkpoints.first().and_then(|c| c.clone()) else {
return 0;
};
progress = Some(WarmProgress {
next_line: 0,
next_chunk: 1,
target_chunk: self.checkpoints.len().saturating_sub(1),
state,
});
}
let mut progress = progress.expect("warm progress");
let target_chunk = progress
.target_chunk
.min(self.checkpoints.len().saturating_sub(1));
if progress.next_chunk > target_chunk {
self.warm_progress = None;
return 0;
}
while progress.next_chunk <= target_chunk {
let Some(state) = self
.checkpoints
.get(progress.next_chunk)
.and_then(|c| c.clone())
else {
break;
};
progress.state = state;
progress.next_line = progress.next_chunk.saturating_mul(self.stride);
progress.next_chunk += 1;
}
if progress.next_chunk > target_chunk {
self.warm_progress = None;
return 0;
}
let mut highlighter =
HighlightLines::from_state(self.theme.as_ref(), progress.state.0, progress.state.1);
let mut processed = 0usize;
let line_len = self.lines.len();
while processed < max_lines && progress.next_chunk <= target_chunk {
let chunk_end = (progress.next_chunk * self.stride).min(line_len);
if progress.next_line >= chunk_end {
let (highlight_state, parse_state) = highlighter.state();
if let Some(slot) = self.checkpoints.get_mut(progress.next_chunk) {
*slot = Some((highlight_state.clone(), parse_state.clone()));
}
highlighter =
HighlightLines::from_state(self.theme.as_ref(), highlight_state, parse_state);
progress.next_chunk += 1;
progress.next_line = chunk_end;
continue;
}
let line = &self.lines[progress.next_line];
let _ = highlighter.highlight_line(line, &self.syntax_set);
progress.next_line = progress.next_line.saturating_add(1);
processed += 1;
}
progress.state = highlighter.state();
if progress.next_chunk > target_chunk {
self.warm_progress = None;
} else {
self.warm_progress = Some(progress);
}
syntax_debug_warm_lines(processed);
processed
}
fn warm_pending(&self) -> bool {
self.warm_progress.is_some()
}
fn clear_warmup_target(&mut self) {
self.warm_progress = None;
}
fn set_warmup_target(&mut self, start: usize, end: usize) {
if self.checkpoints.is_empty() || self.lines.is_empty() {
return;
}
let last_line = self.lines.len().saturating_sub(1);
let mut start = start.min(last_line);
let mut end = end.min(last_line);
if start > end {
std::mem::swap(&mut start, &mut end);
}
let target_chunk = end / self.stride;
if target_chunk >= self.checkpoints.len() {
return;
}
if self.checkpoints[target_chunk].is_some() {
self.warm_progress = None;
return;
}
let mut start_chunk = start / self.stride;
while start_chunk > 0 && self.checkpoints[start_chunk].is_none() {
start_chunk = start_chunk.saturating_sub(1);
}
let Some(state) = self.checkpoints.get(start_chunk).and_then(|c| c.clone()) else {
return;
};
if let Some(existing) = self.warm_progress.as_ref() {
if existing.target_chunk == target_chunk {
return;
}
}
self.warm_progress = Some(WarmProgress {
next_line: start_chunk * self.stride,
next_chunk: start_chunk.saturating_add(1),
target_chunk,
state,
});
}
fn ensure_checkpoint(
&mut self,
chunk: usize,
cancelled: Option<&dyn Fn() -> bool>,
) -> Option<(syntect::highlighting::HighlightState, ParseState)> {
if let Some(state) = self.checkpoints.get(chunk).and_then(|c| c.clone()) {
return Some(state);
}
if self.checkpoints.is_empty() {
return None;
}
let mut start_chunk = chunk;
while start_chunk > 0
&& self
.checkpoints
.get(start_chunk)
.and_then(|c| c.as_ref())
.is_none()
{
start_chunk = start_chunk.saturating_sub(1);
}
let state = self.checkpoints.get(start_chunk).and_then(|c| c.clone())?;
let mut line_idx = start_chunk * self.stride;
let chunk_start = (chunk * self.stride).min(self.lines.len());
let lines_to_process = chunk_start.saturating_sub(line_idx);
if cancelled.is_none() && lines_to_process > MAX_SYNC_SYNTAX_LINES {
self.warm_progress = Some(WarmProgress {
next_line: line_idx,
next_chunk: start_chunk.saturating_add(1),
target_chunk: chunk,
state,
});
return None;
}
let mut highlighter = HighlightLines::from_state(self.theme.as_ref(), state.0, state.1);
for next_chunk in (start_chunk + 1)..=chunk {
let chunk_end = (next_chunk * self.stride).min(self.lines.len());
let checkpoint_lines = chunk_end.saturating_sub(line_idx);
if checkpoint_lines > 0 {
syntax_debug_highlight_lines(checkpoint_lines);
}
for idx in line_idx..chunk_end {
if cancelled.is_some_and(|is_cancelled| is_cancelled()) {
return None;
}
let line = &self.lines[idx];
let _ = highlighter.highlight_line(line, &self.syntax_set);
}
let state = highlighter.state();
if let Some(slot) = self.checkpoints.get_mut(next_chunk) {
*slot = Some(state.clone());
}
if next_chunk == chunk {
return Some(state);
}
highlighter = HighlightLines::from_state(self.theme.as_ref(), state.0, state.1);
line_idx = chunk_end;
}
self.checkpoints.get(chunk).and_then(|c| c.clone())
}
}
fn ranges_to_spans(ranges: Vec<(SynStyle, &str)>, plain: TuiColor) -> Vec<SyntaxSpan> {
let mut spans = Vec::new();
for (style, text) in ranges {
let text = text.strip_suffix('\n').unwrap_or(text);
let text = text.strip_suffix('\r').unwrap_or(text);
if text.is_empty() {
continue;
}
spans.push(SyntaxSpan {
text: text.to_string(),
style: syntect_style_to_tui(style),
});
}
if spans.is_empty() {
spans.push(SyntaxSpan {
text: String::new(),
style: Style::default().fg(plain),
});
}
spans
}
fn syntax_spans_to_ratatui(spans: &[SyntaxSpan]) -> Vec<Span<'static>> {
spans
.iter()
.map(|span| Span::styled(span.text.clone(), span.style))
.collect()
}
fn syntax_debug_enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| std::env::var_os("OYO_DEBUG_VIEW").is_some())
}
fn syntax_debug_counters() -> &'static SyntaxDebugCounters {
static COUNTERS: OnceLock<SyntaxDebugCounters> = OnceLock::new();
COUNTERS.get_or_init(SyntaxDebugCounters::default)
}
pub(crate) fn syntax_debug_reset() {
if !syntax_debug_enabled() {
return;
}
let counters = syntax_debug_counters();
counters.requests.store(0, Ordering::Relaxed);
counters.rendered_hits.store(0, Ordering::Relaxed);
counters.rendered_misses.store(0, Ordering::Relaxed);
counters.highlight_lines.store(0, Ordering::Relaxed);
counters.cached_lines.store(0, Ordering::Relaxed);
}
pub(crate) fn syntax_debug_stats() -> Option<SyntaxDebugStats> {
if !syntax_debug_enabled() {
return None;
}
let counters = syntax_debug_counters();
Some(SyntaxDebugStats {
requests: counters.requests.load(Ordering::Relaxed),
rendered_hits: counters.rendered_hits.load(Ordering::Relaxed),
rendered_misses: counters.rendered_misses.load(Ordering::Relaxed),
highlight_lines: counters.highlight_lines.load(Ordering::Relaxed),
cached_lines: counters.cached_lines.load(Ordering::Relaxed),
warm_lines: counters.warm_lines.swap(0, Ordering::Relaxed),
})
}
fn syntax_debug_request() {
if !syntax_debug_enabled() {
return;
}
let counters = syntax_debug_counters();
counters.requests.fetch_add(1, Ordering::Relaxed);
}
fn syntax_debug_rendered_hit() {
if !syntax_debug_enabled() {
return;
}
let counters = syntax_debug_counters();
counters.rendered_hits.fetch_add(1, Ordering::Relaxed);
}
fn syntax_debug_rendered_miss() {
if !syntax_debug_enabled() {
return;
}
let counters = syntax_debug_counters();
counters.rendered_misses.fetch_add(1, Ordering::Relaxed);
}
fn syntax_debug_highlight_lines(count: usize) {
if count == 0 || !syntax_debug_enabled() {
return;
}
let counters = syntax_debug_counters();
counters.highlight_lines.fetch_add(count, Ordering::Relaxed);
}
fn syntax_debug_cached_lines(count: usize) {
if count == 0 || !syntax_debug_enabled() {
return;
}
let counters = syntax_debug_counters();
counters.cached_lines.fetch_add(count, Ordering::Relaxed);
}
fn syntax_debug_warm_lines(count: usize) {
if count == 0 || !syntax_debug_enabled() {
return;
}
let counters = syntax_debug_counters();
counters.warm_lines.fetch_add(count, Ordering::Relaxed);
}
fn syntect_style_to_tui(style: SynStyle) -> Style {
let mut out = Style::default().fg(to_tui(style.foreground));
if style.font_style.contains(FontStyle::BOLD) {
out = out.add_modifier(Modifier::BOLD);
}
if style.font_style.contains(FontStyle::ITALIC) {
out = out.add_modifier(Modifier::ITALIC);
}
if style.font_style.contains(FontStyle::UNDERLINE) {
out = out.add_modifier(Modifier::UNDERLINED);
}
out
}
fn to_syntect(color: TuiColor) -> Color {
match color {
TuiColor::Rgb(r, g, b) => Color { r, g, b, a: 0xFF },
TuiColor::Black => Color {
r: 0,
g: 0,
b: 0,
a: 0xFF,
},
TuiColor::Red => Color {
r: 205,
g: 0,
b: 0,
a: 0xFF,
},
TuiColor::Green => Color {
r: 0,
g: 205,
b: 0,
a: 0xFF,
},
TuiColor::Yellow => Color {
r: 205,
g: 205,
b: 0,
a: 0xFF,
},
TuiColor::Blue => Color {
r: 0,
g: 0,
b: 238,
a: 0xFF,
},
TuiColor::Magenta => Color {
r: 205,
g: 0,
b: 205,
a: 0xFF,
},
TuiColor::Cyan => Color {
r: 0,
g: 205,
b: 205,
a: 0xFF,
},
TuiColor::Gray => Color {
r: 229,
g: 229,
b: 229,
a: 0xFF,
},
TuiColor::DarkGray => Color {
r: 127,
g: 127,
b: 127,
a: 0xFF,
},
TuiColor::LightRed => Color {
r: 255,
g: 0,
b: 0,
a: 0xFF,
},
TuiColor::LightGreen => Color {
r: 0,
g: 255,
b: 0,
a: 0xFF,
},
TuiColor::LightYellow => Color {
r: 255,
g: 255,
b: 0,
a: 0xFF,
},
TuiColor::LightBlue => Color {
r: 92,
g: 92,
b: 255,
a: 0xFF,
},
TuiColor::LightMagenta => Color {
r: 255,
g: 0,
b: 255,
a: 0xFF,
},
TuiColor::LightCyan => Color {
r: 0,
g: 255,
b: 255,
a: 0xFF,
},
TuiColor::White => Color {
r: 255,
g: 255,
b: 255,
a: 0xFF,
},
_ => Color {
r: 255,
g: 255,
b: 255,
a: 0xFF,
},
}
}
fn to_tui(color: Color) -> TuiColor {
TuiColor::Rgb(color.r, color.g, color.b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_sublime_syntax_overrides_builtin_extension() {
let dir = std::env::temp_dir().join(format!(
"oyo-custom-syntax-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("custom-rust.sublime-syntax"),
"%YAML 1.2\n---\nname: Custom Rust\nfile_extensions: [rs]\nscope: source.custom-rust\ncontexts:\n main:\n - match: '\\bTODO\\b'\n scope: keyword.control.custom-rust\n",
)
.unwrap();
let syntax_set = syntax_set_with_dirs(vec![dir.clone()]);
assert_eq!(
syntax_set.find_syntax_by_extension("rs").unwrap().name,
"Custom Rust"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn enclosing_scope_ranges_choose_innermost_definition() {
let engine = SyntaxEngine::new("aura", false);
let source = "const TOP: i32 = 1;\nfn outer() {\n let x = 1;\n fn inner() {\n let y = 2;\n }\n let z = x;\n}\n";
let ranges = engine.enclosing_scope_ranges(source, "sample.rs");
assert_eq!(enclosing_scope_definition(&ranges, 0), None);
assert_eq!(enclosing_scope_definition(&ranges, 2), Some("fn outer() {"));
assert_eq!(enclosing_scope_definition(&ranges, 4), Some("fn inner() {"));
assert_eq!(enclosing_scope_definition(&ranges, 6), Some("fn outer() {"));
assert_eq!(enclosing_scope_definition(&ranges, 7), None);
}
#[test]
fn enclosing_scope_ranges_name_rust_impl_blocks() {
let engine = SyntaxEngine::new("aura", false);
let source = "struct Outer;\nimpl Outer {\n const VALUE: i32 = 1;\n}\n";
let ranges = engine.enclosing_scope_ranges(source, "sample.rs");
assert_eq!(enclosing_scope_definition(&ranges, 2), Some("impl Outer {"));
assert_eq!(enclosing_scope_definition(&ranges, 3), None);
}
#[test]
fn enclosing_scope_ranges_follow_nested_javascript_blocks() {
let engine = SyntaxEngine::new("aura", false);
let source = "class Outer {\n method() {\n function inner() {\n return 1;\n }\n return inner();\n }\n}\n";
let ranges = engine.enclosing_scope_ranges(source, "sample.js");
assert_eq!(
enclosing_scope_definition(&ranges, 0),
Some("class Outer {")
);
assert_eq!(enclosing_scope_definition(&ranges, 1), Some("method() {"));
assert_eq!(
enclosing_scope_definition(&ranges, 3),
Some("function inner() {")
);
assert_eq!(enclosing_scope_definition(&ranges, 5), Some("method() {"));
assert_eq!(enclosing_scope_definition(&ranges, 7), None);
}
#[test]
fn indentation_width_uses_eight_column_tab_stops() {
assert_eq!(indentation_width("\tvalue"), 8);
assert_eq!(indentation_width(" value"), 8);
assert_eq!(indentation_width("\t value"), 12);
}
#[test]
fn enclosing_scope_ranges_use_syntect_names_for_indented_code() {
let engine = SyntaxEngine::new("aura", false);
let source = "class Outer:\n def method(self):\n def inner():\n return 1\n return inner()\nvalue = 1\n";
let ranges = engine.enclosing_scope_ranges(source, "sample.py");
assert_eq!(enclosing_scope_definition(&ranges, 0), Some("class Outer:"));
assert_eq!(
enclosing_scope_definition(&ranges, 1),
Some("def method(self):")
);
assert_eq!(enclosing_scope_definition(&ranges, 3), Some("def inner():"));
assert_eq!(
enclosing_scope_definition(&ranges, 4),
Some("def method(self):")
);
assert_eq!(enclosing_scope_definition(&ranges, 5), None);
}
#[test]
fn enclosing_scope_ranges_follow_nested_markdown_headings() {
let engine = SyntaxEngine::new("aura", false);
let source = "preamble\n## Foo\nbody\n### Bar\nnested\n## Next\nnext body\n";
let ranges = engine.enclosing_scope_ranges(source, "README.md");
assert_eq!(enclosing_scope_definition(&ranges, 0), None);
assert_eq!(enclosing_scope_definition(&ranges, 2), Some("## Foo"));
assert_eq!(enclosing_scope_definition(&ranges, 4), Some("### Bar"));
assert_eq!(enclosing_scope_definition(&ranges, 6), Some("## Next"));
}
#[test]
fn enclosing_scope_ranges_do_not_treat_calls_as_definitions() {
let engine = SyntaxEngine::new("aura", false);
let source = "outer();\nconst value = factory();\n";
assert!(engine
.enclosing_scope_ranges(source, "sample.js")
.is_empty());
}
#[test]
fn enclosing_scope_ranges_are_empty_for_plain_text() {
let engine = SyntaxEngine::new("aura", false);
assert!(engine
.enclosing_scope_ranges("fn not_really_code() {\n}\n", "notes.txt")
.is_empty());
}
#[test]
fn enclosing_scope_ranges_skip_huge_files() {
let engine = SyntaxEngine::new("aura", false);
let source = "x".repeat(MAX_SCOPE_HINT_BYTES + 1);
assert!(engine
.enclosing_scope_ranges(&source, "sample.rs")
.is_empty());
}
#[test]
fn interrupted_prefix_and_chunk_resume_with_full_multiline_context() {
let engine = SyntaxEngine::new("ansi", false);
let source = format!(
"/*\n{}*/\nlet value = 1;\n",
"comment context\n".repeat(6_500)
);
let expected = engine.highlight(&source, "file.rs");
for target in [150, 6_200] {
let mut cache = LazySyntaxCache::new(&engine, &source, "file.rs");
let remaining = std::cell::Cell::new(75usize);
let cancel = || {
let left = remaining.get();
remaining.set(left.saturating_sub(1));
left == 0
};
assert!(cache
.rendered_spans_with_cancel(target, Some(&cancel))
.is_none());
let keep_going = || false;
let actual = cache
.rendered_spans_with_cancel(target, Some(&keep_going))
.unwrap();
assert_eq!(actual, syntax_spans_to_ratatui(&expected[target]));
}
}
#[test]
fn lazy_cache_only_fills_requested_lines() {
let engine = SyntaxEngine::new("aura", false);
let content = "alpha\nbeta\ngamma\n";
let mut cache = LazySyntaxCache::new(&engine, content, "sample.rs");
assert!(cache.spans[0].is_none());
let _ = cache.spans(0).expect("expected spans for line 0");
assert!(cache.spans[0].is_some());
assert!(cache.spans[1].is_none());
}
#[test]
fn lazy_cache_advances_within_chunk() {
let engine = SyntaxEngine::new("aura", false);
let content = "one\ntwo\nthree\nfour\nfive\n";
let mut cache = LazySyntaxCache::new(&engine, content, "sample.rs");
let _ = cache.spans(0).expect("expected spans for line 0");
let _ = cache.spans(3).expect("expected spans for line 3");
assert!(cache.spans[1].is_some());
assert!(cache.spans[2].is_some());
assert!(cache.spans[3].is_some());
}
#[test]
fn lazy_cache_checkpointing_skips_intermediate_spans() {
let engine = SyntaxEngine::new("aura", false);
let mut content = String::new();
for idx in 0..500 {
content.push_str(&format!("line {idx}\n"));
}
let mut cache = LazySyntaxCache::new(&engine, &content, "sample.rs");
let _ = cache.warm_checkpoints(1_000);
let _ = cache.spans(450).expect("expected spans for line 450");
assert!(cache.spans[0].is_none());
assert!(cache.spans[199].is_none());
assert!(cache.spans[399].is_none());
assert!(cache.spans[400].is_some());
}
#[test]
fn lazy_cache_rendered_spans_cache_single_line() {
let engine = SyntaxEngine::new("aura", false);
let content = "alpha\nbeta\ngamma\n";
let mut cache = LazySyntaxCache::new(&engine, content, "sample.rs");
assert!(cache.rendered[0].is_none());
let spans = cache.rendered_spans(0).expect("expected rendered spans");
assert!(!spans.is_empty());
assert!(cache.rendered[0].is_some());
assert!(cache.rendered[1].is_none());
}
#[test]
fn lazy_cache_warmup_fills_checkpoints_without_spans() {
let engine = SyntaxEngine::new("aura", false);
let mut content = String::new();
for idx in 0..450 {
content.push_str(&format!("line {idx}\n"));
}
let mut cache = LazySyntaxCache::new(&engine, &content, "sample.rs");
assert!(cache.checkpoints.get(1).and_then(|c| c.as_ref()).is_none());
let processed = cache.warm_checkpoints(300);
assert!(processed > 0);
assert!(cache.checkpoints.get(1).and_then(|c| c.as_ref()).is_some());
assert!(cache.spans[0].is_none());
assert!(cache.rendered[0].is_none());
}
#[test]
fn lazy_cache_warmup_progresses_across_calls() {
let engine = SyntaxEngine::new("aura", false);
let mut content = String::new();
for idx in 0..260 {
content.push_str(&format!("line {idx}\n"));
}
let mut cache = LazySyntaxCache::new(&engine, &content, "sample.rs");
let processed = cache.warm_checkpoints(50);
assert!(processed > 0);
assert!(cache.checkpoints.get(1).and_then(|c| c.as_ref()).is_none());
let _ = cache.warm_checkpoints(200);
assert!(cache.checkpoints.get(1).and_then(|c| c.as_ref()).is_some());
}
#[test]
fn lazy_cache_defers_large_checkpoint_and_warms() {
let engine = SyntaxEngine::new("aura", false);
let mut content = String::new();
for idx in 0..600 {
content.push_str(&format!("line {idx}\n"));
}
let mut cache = LazySyntaxCache::new(&engine, &content, "sample.rs");
assert!(cache.spans(450).is_none());
for _ in 0..20 {
let _ = cache.warm_checkpoints(200);
}
assert!(cache.spans(450).is_some());
}
#[test]
fn lazy_cache_warmup_target_prioritizes_range() {
let engine = SyntaxEngine::new("aura", false);
let mut content = String::new();
for idx in 0..800 {
content.push_str(&format!("line {idx}\n"));
}
let mut cache = LazySyntaxCache::new(&engine, &content, "sample.rs");
cache.set_warmup_target(600, 650);
for _ in 0..20 {
let _ = cache.warm_checkpoints(200);
}
assert!(cache.spans(650).is_some());
}
#[test]
fn syntax_cache_warmup_prefers_pending_store() {
let engine = SyntaxEngine::new("aura", false);
let mut old_content = String::new();
let mut new_content = String::new();
for idx in 0..800 {
old_content.push_str(&format!("old {idx}\n"));
new_content.push_str(&format!("new {idx}\n"));
}
let mut cache = SyntaxCache::new(&engine, &old_content, &new_content, "sample.rs", true);
cache.set_warmup_targets(
None,
Some(crate::app::WarmupRange {
start: 600,
end: 650,
}),
);
match (&cache.old, &cache.new) {
(SyntaxStore::Lazy(old), SyntaxStore::Lazy(new)) => {
assert!(old.warm_progress.is_none());
assert!(new.warm_progress.is_some());
}
_ => panic!("expected lazy caches"),
}
let processed = cache.warm_checkpoints(50);
assert!(processed > 0);
match (&cache.old, &cache.new) {
(SyntaxStore::Lazy(old), SyntaxStore::Lazy(new)) => {
assert!(
old.warm_progress.is_none(),
"old store should not start warming when only new is pending"
);
assert!(new.warm_progress.is_some());
}
_ => panic!("expected lazy caches"),
}
}
#[test]
fn syntax_cache_epoch_bumps_when_warmup_completes() {
let engine = SyntaxEngine::new("aura", false);
let mut content = String::new();
for idx in 0..800 {
content.push_str(&format!("line {idx}\n"));
}
let mut cache = SyntaxCache::new(&engine, &content, &content, "sample.rs", true);
cache.set_warmup_targets(
None,
Some(crate::app::WarmupRange {
start: 600,
end: 650,
}),
);
assert!(cache.warm_pending());
let epoch_before = cache.epoch();
for _ in 0..20 {
cache.warm_checkpoints(500);
if !cache.warm_pending() {
break;
}
}
assert!(!cache.warm_pending());
assert!(
cache.epoch() > epoch_before,
"epoch should advance when warmup completes"
);
}
}