use crate::element::{Component, Element};
use crate::style::{Color, Modifier, Style};
use std::sync::LazyLock;
use syntect::easy::HighlightLines;
use syntect::highlighting::{self, ThemeSet};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SyntaxTheme {
Monokai,
#[default]
OceanDark,
OceanLight,
EightiesDark,
SolarizedDark,
SolarizedLight,
InspiredGitHub,
}
impl SyntaxTheme {
fn theme_name(&self) -> &'static str {
match self {
SyntaxTheme::Monokai => "base16-monokai.dark",
SyntaxTheme::OceanDark => "base16-ocean.dark",
SyntaxTheme::OceanLight => "base16-ocean.light",
SyntaxTheme::EightiesDark => "base16-eighties.dark",
SyntaxTheme::SolarizedDark => "Solarized (dark)",
SyntaxTheme::SolarizedLight => "Solarized (light)",
SyntaxTheme::InspiredGitHub => "InspiredGitHub",
}
}
fn get_theme<'a>(&self, ts: &'a ThemeSet) -> &'a highlighting::Theme {
ts.themes
.get(self.theme_name())
.or_else(|| ts.themes.values().next())
.expect("No themes available")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LineNumberStyle {
#[default]
None,
Simple,
Padded,
WithSeparator,
}
#[derive(Debug, Clone)]
pub struct SyntaxHighlightProps {
pub code: String,
pub language: Option<String>,
pub theme: SyntaxTheme,
pub line_numbers: LineNumberStyle,
pub start_line: usize,
pub line_number_color: Option<Color>,
pub bg_color: Option<Color>,
pub trim_trailing: bool,
pub max_width: usize,
}
impl Default for SyntaxHighlightProps {
fn default() -> Self {
Self {
code: String::new(),
language: None,
theme: SyntaxTheme::Monokai,
line_numbers: LineNumberStyle::None,
start_line: 1,
line_number_color: Some(Color::DarkGray),
bg_color: None,
trim_trailing: true,
max_width: 0,
}
}
}
impl SyntaxHighlightProps {
pub fn new(code: impl Into<String>) -> Self {
Self {
code: code.into(),
..Default::default()
}
}
#[must_use]
pub fn language(mut self, lang: impl Into<String>) -> Self {
self.language = Some(lang.into());
self
}
#[must_use]
pub fn theme(mut self, theme: SyntaxTheme) -> Self {
self.theme = theme;
self
}
#[must_use]
pub fn line_numbers(mut self, style: LineNumberStyle) -> Self {
self.line_numbers = style;
self
}
#[must_use]
pub fn with_line_numbers(mut self) -> Self {
self.line_numbers = LineNumberStyle::WithSeparator;
self
}
#[must_use]
pub fn start_line(mut self, line: usize) -> Self {
self.start_line = line.max(1);
self
}
#[must_use]
pub fn line_number_color(mut self, color: Color) -> Self {
self.line_number_color = Some(color);
self
}
#[must_use]
pub fn max_width(mut self, width: usize) -> Self {
self.max_width = width;
self
}
#[must_use]
pub fn bg_color(mut self, color: Color) -> Self {
self.bg_color = Some(color);
self
}
}
fn syntect_to_blaeck_color(c: highlighting::Color) -> Color {
Color::Rgb(c.r, c.g, c.b)
}
fn syntect_to_blaeck_style(style: highlighting::Style) -> Style {
let mut s = Style::new().fg(syntect_to_blaeck_color(style.foreground));
if style.font_style.contains(highlighting::FontStyle::BOLD) {
s = s.add_modifier(Modifier::BOLD);
}
if style.font_style.contains(highlighting::FontStyle::ITALIC) {
s = s.add_modifier(Modifier::ITALIC);
}
if style
.font_style
.contains(highlighting::FontStyle::UNDERLINE)
{
s = s.add_modifier(Modifier::UNDERLINED);
}
s
}
pub struct SyntaxHighlight;
impl Component for SyntaxHighlight {
type Props = SyntaxHighlightProps;
fn render(props: &Self::Props) -> Element {
if props.code.is_empty() {
return Element::Empty;
}
let ps = &*SYNTAX_SET;
let ts = &*THEME_SET;
let syntax = if let Some(ref lang) = props.language {
ps.find_syntax_by_token(lang)
.or_else(|| ps.find_syntax_by_extension(lang))
} else {
ps.find_syntax_by_first_line(&props.code)
}
.unwrap_or_else(|| ps.find_syntax_plain_text());
let theme = props.theme.get_theme(ts);
let mut highlighter = HighlightLines::new(syntax, theme);
let mut lines: Vec<Element> = Vec::new();
let total_lines = props.code.lines().count();
let end_line = props.start_line + total_lines;
let line_num_width = end_line.to_string().len();
for (i, line) in LinesWithEndings::from(&props.code).enumerate() {
let line_num = props.start_line + i;
let mut line_segments: Vec<Element> = Vec::new();
let with_bg = |mut style: Style| -> Style {
if let Some(bg) = props.bg_color {
style = style.bg(bg);
}
style
};
match props.line_numbers {
LineNumberStyle::None => {}
LineNumberStyle::Simple => {
let num_str = format!("{} ", line_num);
let style = with_bg(if let Some(color) = props.line_number_color {
Style::new().fg(color)
} else {
Style::new().add_modifier(Modifier::DIM)
});
line_segments.push(Element::styled_text(&num_str, style));
}
LineNumberStyle::Padded => {
let num_str = format!("{:>width$} ", line_num, width = line_num_width);
let style = with_bg(if let Some(color) = props.line_number_color {
Style::new().fg(color)
} else {
Style::new().add_modifier(Modifier::DIM)
});
line_segments.push(Element::styled_text(&num_str, style));
}
LineNumberStyle::WithSeparator => {
let num_str = format!("{:>width$} │ ", line_num, width = line_num_width);
let style = with_bg(if let Some(color) = props.line_number_color {
Style::new().fg(color)
} else {
Style::new().add_modifier(Modifier::DIM)
});
line_segments.push(Element::styled_text(&num_str, style));
}
}
let highlighted = highlighter.highlight_line(line, ps);
match highlighted {
Ok(ranges) => {
let ranges: Vec<_> = ranges.into_iter().collect();
let range_count = ranges.len();
for (idx, (style, text)) in ranges.into_iter().enumerate() {
let mut text = text.to_string();
if text.ends_with('\n') {
text.pop();
}
if text.ends_with('\r') {
text.pop();
}
if props.trim_trailing && idx == range_count - 1 {
text = text.trim_end().to_string();
}
if !text.is_empty() {
let blaeck_style = with_bg(syntect_to_blaeck_style(style));
line_segments.push(Element::styled_text(&text, blaeck_style));
}
}
}
Err(_) => {
let mut text = line.to_string();
if text.ends_with('\n') {
text.pop();
}
if props.trim_trailing {
text = text.trim_end().to_string();
}
line_segments.push(Element::styled_text(&text, with_bg(Style::new())));
}
}
if line_segments.is_empty() {
lines.push(Element::text(""));
} else if line_segments.len() == 1 {
lines.push(line_segments.remove(0));
} else {
lines.push(Element::Fragment(line_segments));
}
}
if lines.is_empty() {
Element::Empty
} else if lines.len() == 1 {
lines.remove(0)
} else {
Element::Fragment(lines)
}
}
}
pub fn syntax_highlight(code: &str, language: &str) -> Element {
SyntaxHighlight::render(&SyntaxHighlightProps::new(code).language(language))
}
pub fn syntax_highlight_with_lines(code: &str, language: &str) -> Element {
SyntaxHighlight::render(
&SyntaxHighlightProps::new(code)
.language(language)
.with_line_numbers(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_syntax_theme_names() {
assert_eq!(SyntaxTheme::Monokai.theme_name(), "base16-monokai.dark");
assert_eq!(SyntaxTheme::OceanDark.theme_name(), "base16-ocean.dark");
}
#[test]
fn test_syntax_props_new() {
let props = SyntaxHighlightProps::new("let x = 1;");
assert_eq!(props.code, "let x = 1;");
assert!(props.language.is_none());
}
#[test]
fn test_syntax_props_builder() {
let props = SyntaxHighlightProps::new("code")
.language("rust")
.theme(SyntaxTheme::OceanDark)
.with_line_numbers()
.start_line(10);
assert_eq!(props.language, Some("rust".to_string()));
assert_eq!(props.theme, SyntaxTheme::OceanDark);
assert_eq!(props.line_numbers, LineNumberStyle::WithSeparator);
assert_eq!(props.start_line, 10);
}
#[test]
fn test_syntax_render_empty() {
let props = SyntaxHighlightProps::new("");
let elem = SyntaxHighlight::render(&props);
assert!(elem.is_empty());
}
#[test]
fn test_syntax_render_rust() {
let code = "fn main() {}";
let props = SyntaxHighlightProps::new(code).language("rust");
let elem = SyntaxHighlight::render(&props);
assert!(elem.is_fragment() || elem.is_text());
}
#[test]
fn test_syntax_render_with_line_numbers() {
let code = "line 1\nline 2\nline 3";
let props = SyntaxHighlightProps::new(code).with_line_numbers();
let elem = SyntaxHighlight::render(&props);
assert!(elem.is_fragment());
}
#[test]
fn test_syntax_helper() {
let elem = syntax_highlight("let x = 1;", "rust");
assert!(elem.is_fragment() || elem.is_text());
}
#[test]
fn test_syntax_helper_with_lines() {
let elem = syntax_highlight_with_lines("let x = 1;", "rust");
assert!(elem.is_fragment() || elem.is_text());
}
#[test]
fn test_syntect_to_blaeck_color() {
let c = highlighting::Color {
r: 255,
g: 128,
b: 64,
a: 255,
};
let color = syntect_to_blaeck_color(c);
assert_eq!(color, Color::Rgb(255, 128, 64));
}
}