use std::path::Path;
use std::str::FromStr;
use syntect::easy::HighlightLines;
use syntect::highlighting::{
Color, FontStyle, ScopeSelectors, StyleModifier, Theme as SyntectTheme, ThemeItem, ThemeSet,
ThemeSettings,
};
use syntect::parsing::{SyntaxReference, SyntaxSet};
use crate::render_backend::{color_from_hex, Rgba};
use crate::theme::Theme;
#[derive(Debug, Clone)]
pub struct HighlightSpan {
pub text: String,
pub fg: Rgba,
pub bold: bool,
pub italic: bool,
}
pub struct Highlighter {
syntax_set: SyntaxSet,
theme: SyntectTheme,
}
impl Highlighter {
#[must_use]
pub fn new() -> Self {
let syntax_set = SyntaxSet::load_defaults_newlines();
let theme_set = ThemeSet::load_defaults();
let theme = theme_set
.themes
.get("base16-ocean.dark")
.cloned()
.unwrap_or_else(|| theme_set.themes.values().next().unwrap().clone());
Self { syntax_set, theme }
}
#[must_use]
pub fn with_theme(theme_name: &str) -> Self {
let syntax_set = SyntaxSet::load_defaults_newlines();
let theme_set = ThemeSet::load_defaults();
let theme = theme_set
.themes
.get(theme_name)
.cloned()
.unwrap_or_else(|| {
theme_set
.themes
.get("base16-ocean.dark")
.cloned()
.unwrap_or_else(|| theme_set.themes.values().next().unwrap().clone())
});
Self { syntax_set, theme }
}
#[must_use]
pub fn from_ui_theme(theme: &Theme) -> Self {
let syntax_set = SyntaxSet::load_defaults_newlines();
let theme = syntect_theme_from_ui_theme(theme);
Self { syntax_set, theme }
}
fn syntax_for_path(&self, path: &str) -> Option<&SyntaxReference> {
let path = Path::new(path);
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if let Some(syntax) = self.syntax_set.find_syntax_by_extension(ext) {
return Some(syntax);
}
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
match name {
"Makefile" | "makefile" | "GNUmakefile" => {
return self.syntax_set.find_syntax_by_extension("make");
}
"Dockerfile" => {
return self.syntax_set.find_syntax_by_extension("dockerfile");
}
"Cargo.toml" | "Cargo.lock" => {
return self.syntax_set.find_syntax_by_extension("toml");
}
_ => {}
}
}
None
}
pub fn highlight_line(&self, line: &str, file_path: &str) -> Option<Vec<HighlightSpan>> {
let syntax = self.syntax_for_path(file_path)?;
let mut highlighter = HighlightLines::new(syntax, &self.theme);
let ranges = highlighter.highlight_line(line, &self.syntax_set).ok()?;
Some(
ranges
.into_iter()
.map(|(style, text)| HighlightSpan {
text: text.to_string(),
fg: syntect_color_to_rgba(style.foreground),
bold: style.font_style.contains(FontStyle::BOLD),
italic: style.font_style.contains(FontStyle::ITALIC),
})
.collect(),
)
}
#[must_use]
pub fn for_file(&self, file_path: &str) -> Option<FileHighlighter<'_>> {
let syntax = self.syntax_for_path(file_path)?;
Some(FileHighlighter {
highlighter: HighlightLines::new(syntax, &self.theme),
syntax_set: &self.syntax_set,
})
}
#[must_use]
pub fn for_fence_info(&self, fence_info: Option<&str>) -> Option<FileHighlighter<'_>> {
let language = fence_info?.split_whitespace().next()?;
let lower = language.to_ascii_lowercase();
let path_hint = match lower.as_str() {
"rust" | "rs" => "snippet.rs".to_string(),
"python" | "py" => "snippet.py".to_string(),
"javascript" | "js" => "snippet.js".to_string(),
"typescript" | "ts" => "snippet.ts".to_string(),
"tsx" => "snippet.tsx".to_string(),
"jsx" => "snippet.jsx".to_string(),
"json" => "snippet.json".to_string(),
"toml" => "snippet.toml".to_string(),
"yaml" | "yml" => "snippet.yaml".to_string(),
"shell" | "sh" | "bash" | "zsh" => "snippet.sh".to_string(),
"diff" | "patch" => "snippet.diff".to_string(),
"html" => "snippet.html".to_string(),
"css" => "snippet.css".to_string(),
"sql" => "snippet.sql".to_string(),
"markdown" | "md" => "snippet.md".to_string(),
_ => format!("snippet.{lower}"),
};
self.for_file(&path_hint)
}
#[must_use]
pub fn available_themes() -> Vec<&'static str> {
vec![
"base16-ocean.dark",
"base16-eighties.dark",
"base16-mocha.dark",
"base16-ocean.light",
"InspiredGitHub",
"Solarized (dark)",
"Solarized (light)",
]
}
}
impl Default for Highlighter {
fn default() -> Self {
Self::new()
}
}
fn syntect_theme_from_ui_theme(theme: &Theme) -> SyntectTheme {
SyntectTheme {
name: Some(format!("{}-syntax", theme.name)),
author: None,
settings: ThemeSettings {
foreground: Some(rgba_to_syntect_color(theme.foreground)),
background: Some(rgba_to_syntect_color(theme.background)),
caret: Some(rgba_to_syntect_color(theme.cursor)),
..ThemeSettings::default()
},
scopes: vec![
scope_item("comment", theme.syntax.comment, Some(FontStyle::ITALIC)),
scope_item(
"keyword, storage.modifier",
theme.syntax.keyword,
Some(FontStyle::BOLD),
),
scope_item(
"entity.name.function, support.function, meta.function-call, variable.function",
theme.syntax.function,
None,
),
scope_item(
"entity.name.type, storage.type, support.type",
theme.syntax.type_name,
None,
),
scope_item("string", theme.syntax.string, None),
scope_item("constant.numeric", theme.syntax.number, None),
scope_item("keyword.operator", theme.syntax.operator, None),
scope_item("punctuation", theme.syntax.punctuation, None),
scope_item(
"constant.language, constant.character.escape, constant.other",
theme.syntax.constant,
None,
),
scope_item(
"entity.other.attribute-name, meta.attribute, punctuation.definition.attribute",
theme.syntax.attribute,
None,
),
scope_item("variable, support.variable", theme.syntax.variable, None),
],
}
}
fn scope_item(selector: &str, color: Rgba, font_style: Option<FontStyle>) -> ThemeItem {
ThemeItem {
scope: ScopeSelectors::from_str(selector).expect("valid syntax scope selector"),
style: StyleModifier {
foreground: Some(rgba_to_syntect_color(color)),
background: None,
font_style,
},
}
}
fn rgba_to_syntect_color(color: Rgba) -> Color {
Color {
r: (color.r * 255.0).round() as u8,
g: (color.g * 255.0).round() as u8,
b: (color.b * 255.0).round() as u8,
a: (color.a * 255.0).round() as u8,
}
}
pub struct FileHighlighter<'a> {
highlighter: HighlightLines<'a>,
syntax_set: &'a SyntaxSet,
}
impl FileHighlighter<'_> {
pub fn highlight_line(&mut self, line: &str) -> Vec<HighlightSpan> {
self.highlighter
.highlight_line(line, self.syntax_set)
.map_or_else(
|_| {
vec![HighlightSpan {
text: line.to_string(),
fg: Rgba::WHITE,
bold: false,
italic: false,
}]
},
|ranges| {
ranges
.into_iter()
.map(|(style, text)| HighlightSpan {
text: text.to_string(),
fg: syntect_color_to_rgba(style.foreground),
bold: style.font_style.contains(FontStyle::BOLD),
italic: style.font_style.contains(FontStyle::ITALIC),
})
.collect()
},
)
}
}
fn syntect_color_to_rgba(color: Color) -> Rgba {
Rgba::new(
f32::from(color.r) / 255.0,
f32::from(color.g) / 255.0,
f32::from(color.b) / 255.0,
f32::from(color.a) / 255.0,
)
}
#[derive(Debug, Clone)]
pub struct SyntaxColors {
pub keyword: Rgba,
pub function: Rgba,
pub type_name: Rgba,
pub string: Rgba,
pub number: Rgba,
pub comment: Rgba,
pub operator: Rgba,
pub punctuation: Rgba,
pub variable: Rgba,
pub constant: Rgba,
pub attribute: Rgba,
}
impl Default for SyntaxColors {
fn default() -> Self {
Self::tokyo_night()
}
}
impl SyntaxColors {
#[must_use]
pub fn tokyo_night() -> Self {
Self {
keyword: color_from_hex("#bb9af7").unwrap_or(Rgba::WHITE), function: color_from_hex("#7aa2f7").unwrap_or(Rgba::WHITE), type_name: color_from_hex("#2ac3de").unwrap_or(Rgba::WHITE), string: color_from_hex("#9ece6a").unwrap_or(Rgba::WHITE), number: color_from_hex("#ff9e64").unwrap_or(Rgba::WHITE), comment: color_from_hex("#565f89").unwrap_or(Rgba::WHITE), operator: color_from_hex("#89ddff").unwrap_or(Rgba::WHITE), punctuation: color_from_hex("#a9b1d6").unwrap_or(Rgba::WHITE), variable: color_from_hex("#c0caf5").unwrap_or(Rgba::WHITE), constant: color_from_hex("#ff9e64").unwrap_or(Rgba::WHITE), attribute: color_from_hex("#bb9af7").unwrap_or(Rgba::WHITE), }
}
#[must_use]
pub fn light() -> Self {
Self {
keyword: color_from_hex("#5c21a5").unwrap_or(Rgba::BLACK), function: color_from_hex("#0550ae").unwrap_or(Rgba::BLACK), type_name: color_from_hex("#0969da").unwrap_or(Rgba::BLACK), string: color_from_hex("#0a3069").unwrap_or(Rgba::BLACK), number: color_from_hex("#953800").unwrap_or(Rgba::BLACK), comment: color_from_hex("#6e7781").unwrap_or(Rgba::BLACK), operator: color_from_hex("#0550ae").unwrap_or(Rgba::BLACK), punctuation: color_from_hex("#24292f").unwrap_or(Rgba::BLACK), variable: color_from_hex("#24292f").unwrap_or(Rgba::BLACK), constant: color_from_hex("#953800").unwrap_or(Rgba::BLACK), attribute: color_from_hex("#5c21a5").unwrap_or(Rgba::BLACK), }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_highlight_rust() {
let highlighter = Highlighter::new();
let spans = highlighter
.highlight_line("fn main() {", "test.rs")
.expect("Should highlight Rust");
assert!(!spans.is_empty());
assert!(spans.len() > 1);
}
#[test]
fn test_highlight_python() {
let highlighter = Highlighter::new();
let spans = highlighter
.highlight_line("def hello():", "test.py")
.expect("Should highlight Python");
assert!(!spans.is_empty());
}
#[test]
fn test_file_highlighter_state() {
let highlighter = Highlighter::new();
let mut file_hl = highlighter
.for_file("test.rs")
.expect("Should get highlighter");
let spans1 = file_hl.highlight_line("let s = \"hello");
let spans2 = file_hl.highlight_line("world\";");
assert!(!spans1.is_empty());
assert!(!spans2.is_empty());
}
#[test]
fn test_highlight_has_different_colors() {
let highlighter = Highlighter::new();
let spans = highlighter
.highlight_line("let x = 42;", "test.rs")
.expect("Should highlight");
for span in &spans {
eprintln!(
"'{}' -> ({:.2}, {:.2}, {:.2})",
span.text, span.fg.r, span.fg.g, span.fg.b
);
}
assert!(spans.len() >= 2, "Should have multiple spans");
let let_span = spans.iter().find(|s| s.text.trim() == "let");
let num_span = spans.iter().find(|s| s.text.trim() == "42");
if let (Some(let_s), Some(num_s)) = (let_span, num_span) {
assert!(
let_s.fg != num_s.fg,
"Keyword and number should have different colors: let={:?}, 42={:?}",
let_s.fg,
num_s.fg
);
}
}
#[test]
fn test_fence_info_maps_common_languages() {
let highlighter = Highlighter::new();
let mut file_hl = highlighter
.for_fence_info(Some("rust"))
.expect("Should resolve rust fence");
let spans = file_hl.highlight_line("fn main() {}");
assert!(!spans.is_empty());
}
}