pub mod highlighter;
pub mod injection;
pub mod parser_pool;
pub mod themes;
pub use highlighter::{
apply_line_highlights, collect_line_highlights, collect_line_highlights_with_injections,
CstParseResult, Highlighter, LineHighlights,
};
pub use parser_pool::ParserPool;
pub use themes::ThemeStyleCache;
use std::io::Cursor;
use std::sync::OnceLock;
use lasso::Rodeo;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
use xdg::BaseDirectories;
use crate::app::InternedSpan;
static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
const DRACULA_THEME: &[u8] = include_bytes!("../../themes/Dracula.tmTheme");
pub fn syntax_set() -> &'static SyntaxSet {
SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines)
}
pub fn theme_set() -> &'static ThemeSet {
THEME_SET.get_or_init(load_all_themes)
}
fn load_all_themes() -> ThemeSet {
let mut themes = ThemeSet::load_defaults();
if let Ok(theme) = ThemeSet::load_from_reader(&mut Cursor::new(DRACULA_THEME)) {
themes.themes.insert("Dracula".to_string(), theme);
}
if let Ok(base_dirs) = BaseDirectories::with_prefix("octorus") {
let user_themes_dir = base_dirs.get_config_home().join("themes");
if user_themes_dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(&user_themes_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "tmTheme") {
if let Ok(theme) = ThemeSet::get_theme(&path) {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
themes.themes.insert(name.to_string(), theme);
}
}
}
}
}
}
}
themes
}
pub fn available_themes() -> Vec<&'static str> {
theme_set().themes.keys().map(|s| s.as_str()).collect()
}
pub fn syntax_for_file(filename: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
let ext = std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())?;
syntax_set().find_syntax_by_extension(ext)
}
pub fn get_theme(name: &str) -> &'static syntect::highlighting::Theme {
let themes = &theme_set().themes;
if let Some(theme) = themes.get(name) {
return theme;
}
let name_lower = name.to_lowercase();
for (key, theme) in themes.iter() {
if key.to_lowercase() == name_lower {
return theme;
}
}
themes
.get("base16-ocean.dark")
.or_else(|| themes.values().next())
.expect("syntect default themes should never be empty")
}
pub fn highlight_code_line(
code: &str,
highlighter: &mut HighlightLines<'_>,
interner: &mut Rodeo,
) -> Vec<InternedSpan> {
match highlighter.highlight_line(code, syntax_set()) {
Ok(ranges) => ranges
.into_iter()
.map(|(style, text)| {
InternedSpan {
content: interner.get_or_intern(text),
style: convert_syntect_style(&style),
}
})
.collect(),
Err(_e) => {
#[cfg(debug_assertions)]
eprintln!("Highlight error: {_e:?}");
vec![InternedSpan {
content: interner.get_or_intern(code),
style: Style::default(),
}]
}
}
}
pub fn highlight_code_line_legacy(
code: &str,
highlighter: &mut HighlightLines<'_>,
) -> Vec<Span<'static>> {
match highlighter.highlight_line(code, syntax_set()) {
Ok(ranges) => ranges
.into_iter()
.map(|(style, text)| Span::styled(text.to_string(), convert_syntect_style(&style)))
.collect(),
Err(_e) => {
#[cfg(debug_assertions)]
eprintln!("Highlight error: {_e:?}");
vec![Span::raw(code.to_string())]
}
}
}
pub fn convert_syntect_style(style: &syntect::highlighting::Style) -> Style {
let mut ratatui_style = Style::default();
if style.foreground.a > 0 {
ratatui_style = ratatui_style.fg(Color::Rgb(
style.foreground.r,
style.foreground.g,
style.foreground.b,
));
}
if style
.font_style
.contains(syntect::highlighting::FontStyle::BOLD)
{
ratatui_style = ratatui_style.add_modifier(Modifier::BOLD);
}
if style
.font_style
.contains(syntect::highlighting::FontStyle::ITALIC)
{
ratatui_style = ratatui_style.add_modifier(Modifier::ITALIC);
}
if style
.font_style
.contains(syntect::highlighting::FontStyle::UNDERLINE)
{
ratatui_style = ratatui_style.add_modifier(Modifier::UNDERLINED);
}
ratatui_style
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_syntax_for_file_known_extension() {
assert!(syntax_for_file("main.rs").is_some());
assert!(syntax_for_file("script.py").is_some());
assert!(syntax_for_file("main.go").is_some());
assert!(syntax_for_file("index.js").is_some());
assert!(syntax_for_file("style.css").is_some());
assert!(syntax_for_file("index.html").is_some());
assert!(
syntax_for_file("index.ts").is_some(),
"TypeScript should be supported"
);
assert!(
syntax_for_file("app.tsx").is_some(),
"TSX should be supported"
);
assert!(
syntax_for_file("app.vue").is_some(),
"Vue should be supported"
);
assert!(
syntax_for_file("config.toml").is_some(),
"TOML should be supported"
);
assert!(
syntax_for_file("style.scss").is_some(),
"SCSS should be supported"
);
assert!(
syntax_for_file("App.svelte").is_some(),
"Svelte should be supported"
);
assert!(
syntax_for_file("src/app.rs").is_some(),
"src/app.rs should have syntax"
);
assert!(syntax_for_file("src/ui/diff_view.rs").is_some());
assert!(syntax_for_file("src/components/Button.vue").is_some());
}
#[test]
fn test_syntax_for_file_unknown_extension() {
assert!(syntax_for_file("file.unknown_ext_xyz").is_none());
}
#[test]
fn test_syntax_for_file_no_extension() {
assert!(syntax_for_file("Makefile").is_none());
assert!(syntax_for_file("README").is_none());
}
#[test]
fn test_get_theme_existing() {
let theme = get_theme("base16-ocean.dark");
assert!(!theme.scopes.is_empty() || theme.settings.background.is_some());
}
#[test]
fn test_get_theme_fallback() {
let theme = get_theme("non_existent_theme_xyz");
assert!(!theme.scopes.is_empty() || theme.settings.background.is_some());
}
#[test]
fn test_highlight_code_line_rust() {
let syntax = syntax_for_file("test.rs").unwrap();
let theme = get_theme("base16-ocean.dark");
let mut highlighter = HighlightLines::new(syntax, theme);
let spans = highlight_code_line_legacy("let app = Self {", &mut highlighter);
assert!(!spans.is_empty());
let let_span = spans.iter().find(|s| s.content.as_ref() == "let");
assert!(let_span.is_some(), "Should have a span for 'let'");
let let_style = let_span.unwrap().style;
assert!(let_style.fg.is_some(), "'let' should have foreground color");
assert!(
let_style.bg.is_none(),
"'let' should NOT have background color"
);
}
#[test]
fn test_highlight_code_line_empty() {
let syntax = syntax_for_file("test.rs").unwrap();
let theme = get_theme("base16-ocean.dark");
let mut highlighter = HighlightLines::new(syntax, theme);
let spans = highlight_code_line_legacy("", &mut highlighter);
assert!(spans.is_empty() || (spans.len() == 1 && spans[0].content.is_empty()));
}
#[test]
fn test_bundled_dracula_theme() {
let theme = get_theme("Dracula");
assert!(!theme.scopes.is_empty(), "Dracula should have scopes");
}
#[test]
fn test_available_themes_includes_defaults_and_bundled() {
let themes = available_themes();
assert!(
themes.contains(&"base16-ocean.dark"),
"Should include base16-ocean.dark"
);
assert!(themes.contains(&"Dracula"), "Should include Dracula");
}
#[test]
fn test_highlight_with_dracula() {
let syntax = syntax_for_file("test.rs").unwrap();
let theme = get_theme("Dracula");
let mut highlighter = HighlightLines::new(syntax, theme);
let spans = highlight_code_line_legacy("fn main() {", &mut highlighter);
assert!(!spans.is_empty());
let fn_span = spans.iter().find(|s| s.content.as_ref() == "fn");
assert!(fn_span.is_some(), "Should have a span for 'fn'");
assert!(
fn_span.unwrap().style.fg.is_some(),
"'fn' should have foreground color"
);
}
#[test]
fn test_get_theme_case_insensitive() {
let theme1 = get_theme("Dracula");
let theme2 = get_theme("dracula");
let theme3 = get_theme("DRACULA");
assert!(!theme1.scopes.is_empty());
assert!(!theme2.scopes.is_empty());
assert!(!theme3.scopes.is_empty());
assert_eq!(theme1.scopes.len(), theme2.scopes.len());
assert_eq!(theme1.scopes.len(), theme3.scopes.len());
}
#[test]
fn test_highlight_code_line_typescript() {
let syntax = syntax_for_file("test.ts").unwrap();
let theme = get_theme("base16-ocean.dark");
let mut highlighter = HighlightLines::new(syntax, theme);
let spans = highlight_code_line_legacy("const foo: string = 'bar';", &mut highlighter);
assert!(!spans.is_empty());
let const_span = spans.iter().find(|s| s.content.as_ref() == "const");
assert!(const_span.is_some(), "Should have a span for 'const'");
assert!(
const_span.unwrap().style.fg.is_some(),
"'const' should have foreground color"
);
}
#[test]
fn test_highlight_code_line_vue() {
let syntax = syntax_for_file("test.vue").unwrap();
let theme = get_theme("Dracula");
let mut highlighter = HighlightLines::new(syntax, theme);
let spans = highlight_code_line_legacy("<template>", &mut highlighter);
assert!(!spans.is_empty());
}
#[test]
fn test_highlight_code_line_with_interner() {
let syntax = syntax_for_file("test.rs").unwrap();
let theme = get_theme("base16-ocean.dark");
let mut highlighter = HighlightLines::new(syntax, theme);
let mut interner = Rodeo::default();
let spans = highlight_code_line("let app = Self {", &mut highlighter, &mut interner);
assert!(!spans.is_empty());
for span in &spans {
let text = interner.resolve(&span.content);
assert!(!text.is_empty() || spans.len() == 1);
}
}
#[test]
fn test_interner_deduplication() {
let syntax = syntax_for_file("test.rs").unwrap();
let theme = get_theme("base16-ocean.dark");
let mut highlighter = HighlightLines::new(syntax, theme);
let mut interner = Rodeo::default();
let spans1 = highlight_code_line("let x = 1;", &mut highlighter, &mut interner);
let spans2 = highlight_code_line("let y = 2;", &mut highlighter, &mut interner);
let let_spur1 = spans1
.iter()
.find(|s| interner.resolve(&s.content) == "let")
.map(|s| s.content);
let let_spur2 = spans2
.iter()
.find(|s| interner.resolve(&s.content) == "let")
.map(|s| s.content);
assert!(let_spur1.is_some(), "First line should contain 'let'");
assert!(let_spur2.is_some(), "Second line should contain 'let'");
assert_eq!(
let_spur1, let_spur2,
"Both 'let' tokens should have the same Spur (interned)"
);
}
}