use ratatui::{
style::{Color, Modifier, Style},
text::Line,
};
use std::sync::{Mutex, OnceLock};
use syntect::{
easy::HighlightLines, highlighting::ThemeSet, parsing::SyntaxSet, util::LinesWithEndings,
};
use crate::theme::Theme;
static SYNTAX_SET: std::sync::OnceLock<SyntaxSet> = std::sync::OnceLock::new();
static THEME_SET: std::sync::OnceLock<ThemeSet> = std::sync::OnceLock::new();
fn init_syntax() {
SYNTAX_SET.get_or_init(|| {
SyntaxSet::load_defaults_newlines()
});
THEME_SET.get_or_init(|| {
ThemeSet::load_defaults()
});
}
fn map_syntect_color(sc: syntect::highlighting::Color, th: &Theme) -> Color {
let r = sc.r;
let g = sc.g;
let b = sc.b;
let max_rgb = r.max(g).max(b);
let min_rgb = r.min(g).min(b);
let avg_rgb = (u16::from(r) + u16::from(g) + u16::from(b)) / 3;
if max_rgb < 30 {
return th.text;
}
if r > 200 && g > 200 && b > 200 {
return th.text;
}
if g > r + 20 && g > b + 20 && g > 60 {
return th.green;
}
if ((r > 120 && b > 120 && g < r.min(b) + 40) || (r > 150 && b > 150)) && max_rgb > 60 {
return th.mauve;
}
if b > r + 20 && b > g + 20 && b > 60 {
return th.sapphire;
}
if r > 150 && g > 120 && b < 120 && max_rgb > 60 {
return th.yellow;
}
if r > g + 30 && r > b + 30 && r > 60 {
return th.red;
}
if max_rgb > 40 && max_rgb < 200 && (max_rgb - min_rgb) < 50 {
return th.subtext0;
}
if max_rgb < 100 {
if b > r + 10 && b > g + 10 {
return th.sapphire; }
if g > r + 10 && g > b + 10 {
return th.green; }
if r > g + 10 && r > b + 10 && b < 50 {
return th.yellow; }
if r > 80 && b > 80 && g < 60 {
return th.mauve; }
return th.subtext0;
}
if avg_rgb > 100 && avg_rgb < 180 {
return th.sapphire;
}
Color::Rgb(r, g, b)
}
#[derive(Clone)]
struct PkgbHighlightCache {
text: String,
lines: Vec<Line<'static>>,
}
static PKGB_CACHE: OnceLock<Mutex<Option<PkgbHighlightCache>>> = OnceLock::new();
fn cache_lock() -> &'static Mutex<Option<PkgbHighlightCache>> {
PKGB_CACHE.get_or_init(|| Mutex::new(None))
}
pub fn highlight_pkgbuild(text: &str, th: &Theme) -> Vec<Line<'static>> {
init_syntax();
let syntax_set = SYNTAX_SET.get().expect("syntax set should be initialized");
let theme_set = THEME_SET.get().expect("theme set should be initialized");
let syntax = syntax_set
.find_syntax_by_extension("sh")
.or_else(|| syntax_set.find_syntax_by_extension("bash"))
.or_else(|| syntax_set.find_syntax_by_name("Bash"))
.unwrap_or_else(|| syntax_set.find_syntax_plain_text());
let theme = theme_set
.themes
.get("InspiredGitHub")
.or_else(|| theme_set.themes.values().next())
.expect("at least one theme should be available");
if let Ok(cache_guard) = cache_lock().lock()
&& let Some(cache) = cache_guard.as_ref()
&& cache.text == text
{
return cache.lines.clone();
}
let old_cache = cache_lock().lock().ok().and_then(|c| c.clone());
let new_lines_raw: Vec<String> = LinesWithEndings::from(text).map(str::to_string).collect();
let old_lines_raw: Vec<String> = old_cache
.as_ref()
.map(|c| {
LinesWithEndings::from(c.text.as_str())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let prefix_len = new_lines_raw
.iter()
.zip(&old_lines_raw)
.take_while(|(a, b)| a == b)
.count();
let mut highlighter = HighlightLines::new(syntax, theme);
let mut highlighted_lines: Vec<Line<'static>> = Vec::with_capacity(new_lines_raw.len());
for line in new_lines_raw.iter().take(prefix_len) {
match highlighter.highlight_line(line, syntax_set) {
Ok(highlighted_line) => {
if let Some(cache) = old_cache.as_ref()
&& cache.lines.len() > highlighted_lines.len()
{
highlighted_lines.push(cache.lines[highlighted_lines.len()].clone());
} else {
highlighted_lines.push(to_ratatui_line(&highlighted_line, th, line));
}
}
Err(_) => highlighted_lines.push(Line::from(line.clone())),
}
}
for line in new_lines_raw.iter().skip(prefix_len) {
match highlighter.highlight_line(line, syntax_set) {
Ok(highlighted_line) => {
highlighted_lines.push(to_ratatui_line(&highlighted_line, th, line));
}
Err(_) => highlighted_lines.push(Line::from(line.clone())),
}
}
if let Ok(mut cache_guard) = cache_lock().lock() {
*cache_guard = Some(PkgbHighlightCache {
text: text.to_string(),
lines: highlighted_lines.clone(),
});
}
highlighted_lines
}
fn to_ratatui_line(
highlighted_line: &[(syntect::highlighting::Style, &str)],
th: &Theme,
fallback: &str,
) -> Line<'static> {
let mut spans = Vec::new();
for (style, text) in highlighted_line {
let color = map_syntect_color(style.foreground, th);
let mut ratatui_style = Style::default().fg(color);
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);
}
spans.push(ratatui::text::Span::styled(
(*text).to_string(),
ratatui_style,
));
}
if spans.is_empty() {
spans.push(ratatui::text::Span::raw(fallback.to_string()));
}
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::theme;
#[test]
fn highlight_pkgbuild_cache_hit() {
reset_cache();
let th = theme();
let pkgbuild = "pkgname=test\npkgver=1\n";
let first = highlight_pkgbuild(pkgbuild, &th);
let second = highlight_pkgbuild(pkgbuild, &th);
assert_eq!(first.len(), second.len());
assert_eq!(first[0].to_string(), second[0].to_string());
}
#[test]
fn highlight_pkgbuild_incremental_appends() {
reset_cache();
let th = theme();
let base = "pkgname=test\npkgver=1\n";
let appended = "pkgname=test\npkgver=1\n# comment\n";
let first = highlight_pkgbuild(base, &th);
let second = highlight_pkgbuild(appended, &th);
assert_eq!(second.len(), 3);
assert_eq!(first.len(), 2);
}
#[test]
fn test_highlight_pkgbuild_empty() {
reset_cache();
let th = theme();
let lines = highlight_pkgbuild("", &th);
assert!(lines.is_empty() || lines.len() == 1);
}
#[test]
fn test_highlight_pkgbuild_basic() {
reset_cache();
let th = theme();
let pkgbuild = r"pkgname=test
pkgver=1.0.0
# This is a comment
depends=('bash')
";
let lines = highlight_pkgbuild(pkgbuild, &th);
assert!(!lines.is_empty());
}
fn reset_cache() {
if let Ok(mut guard) = cache_lock().lock() {
*guard = None;
}
}
}