use crate::languages::Language;
use crate::themes::Theme;
use std::io::{self, Write};
pub fn span_inline(
text: &str,
language: Option<Language>,
scope: &str,
theme: Option<&Theme>,
italic: bool,
include_highlights: bool,
) -> String {
let escaped = escape(text);
let attrs = span_inline_attrs(language, scope, theme, italic, include_highlights);
if attrs.is_empty() {
escaped
} else {
format!("<span {}>{}</span>", attrs, escaped)
}
}
pub fn span_inline_attrs(
language: Option<Language>,
scope: &str,
theme: Option<&Theme>,
italic: bool,
include_highlights: bool,
) -> String {
let mut attrs = String::new();
if include_highlights {
attrs.push_str(&format!("data-highlight=\"{}\"", scope));
}
if let Some(theme) = theme {
let specialized_scope = if let Some(lang) = language {
format!("{}.{}", scope, lang.id_name())
} else {
scope.to_string()
};
if let Some(style) = theme.get_style(&specialized_scope) {
let has_decoration = style.text_decoration.underline != UnderlineStyle::None
|| style.text_decoration.strikethrough;
if include_highlights
&& (style.fg.is_some()
|| style.bg.is_some()
|| style.bold
|| (italic && style.italic)
|| has_decoration)
{
attrs.push(' ');
}
let css = style.css(italic, " ");
if !css.is_empty() {
attrs.push_str(&format!("style=\"{}\"", css));
}
}
}
attrs
}
pub fn span_linked(text: &str, scope: &str) -> String {
let escaped = escape(text);
let class = scope_to_class(scope);
format!("<span class=\"{}\">{}</span>", class, escaped)
}
pub fn span_linked_attrs(scope: &str) -> String {
let class = scope_to_class(scope);
format!("class=\"{}\"", class)
}
pub fn sanitize_theme_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect()
}
use crate::themes::{TextDecoration, UnderlineStyle};
pub fn text_decoration(td: &TextDecoration) -> &'static str {
match (td.underline, td.strikethrough) {
(UnderlineStyle::None, false) => "none",
(UnderlineStyle::None, true) => "line-through",
(UnderlineStyle::Solid, false) => "underline",
(UnderlineStyle::Solid, true) => "underline line-through",
(UnderlineStyle::Wavy, false) => "underline wavy",
(UnderlineStyle::Wavy, true) => "underline wavy line-through",
(UnderlineStyle::Double, false) => "underline double",
(UnderlineStyle::Double, true) => "underline double line-through",
(UnderlineStyle::Dotted, false) => "underline dotted",
(UnderlineStyle::Dotted, true) => "underline dotted line-through",
(UnderlineStyle::Dashed, false) => "underline dashed",
(UnderlineStyle::Dashed, true) => "underline dashed line-through",
}
}
pub fn span_multi_themes_attrs(
scope: &str,
language: Option<Language>,
themes: &std::collections::HashMap<String, Theme>,
default_theme: Option<&str>,
css_variable_prefix: &str,
italic: bool,
include_highlights: bool,
) -> String {
if themes.is_empty() {
return String::new();
}
let specialized_scope = if let Some(lang) = language {
format!("{}.{}", scope, lang.id_name())
} else {
scope.to_string()
};
let mut inline_styles = Vec::new();
let mut css_vars = Vec::new();
if let Some(default_name) = default_theme {
if default_name == "light-dark()" {
if let (Some(light_theme), Some(dark_theme)) = (themes.get("light"), themes.get("dark"))
{
if let (Some(light_style), Some(dark_style)) = (
light_theme.get_style(&specialized_scope),
dark_theme.get_style(&specialized_scope),
) {
if let (Some(light_fg), Some(dark_fg)) = (&light_style.fg, &dark_style.fg) {
inline_styles
.push(format!("color: light-dark({}, {});", light_fg, dark_fg));
}
if let (Some(light_bg), Some(dark_bg)) = (&light_style.bg, &dark_style.bg) {
inline_styles.push(format!(
"background-color: light-dark({}, {});",
light_bg, dark_bg
));
}
let light_weight = if light_style.bold { "bold" } else { "normal" };
let dark_weight = if dark_style.bold { "bold" } else { "normal" };
inline_styles.push(format!(
"font-weight: light-dark({}, {});",
light_weight, dark_weight
));
if italic {
let light_style_val = if light_style.italic {
"italic"
} else {
"normal"
};
let dark_style_val = if dark_style.italic {
"italic"
} else {
"normal"
};
inline_styles.push(format!(
"font-style: light-dark({}, {});",
light_style_val, dark_style_val
));
}
let light_decoration = text_decoration(&light_style.text_decoration);
let dark_decoration = text_decoration(&dark_style.text_decoration);
inline_styles.push(format!(
"text-decoration: light-dark({}, {});",
light_decoration, dark_decoration
));
}
}
} else if let Some(default_theme_obj) = themes.get(default_name) {
if let Some(style) = default_theme_obj.get_style(&specialized_scope) {
if let Some(fg) = &style.fg {
inline_styles.push(format!("color:{};", fg));
}
if let Some(bg) = &style.bg {
inline_styles.push(format!("background-color:{};", bg));
}
if style.bold {
inline_styles.push("font-weight:bold;".to_string());
}
if italic && style.italic {
inline_styles.push("font-style:italic;".to_string());
}
let td_css = text_decoration(&style.text_decoration);
if td_css != "none" {
inline_styles.push(format!("text-decoration:{};", td_css));
}
let sanitized = sanitize_theme_name(default_name);
let font_style = if style.italic { "italic" } else { "normal" };
css_vars.push(format!(
"{}-{}-font-style:{};",
css_variable_prefix, sanitized, font_style
));
let font_weight = if style.bold { "bold" } else { "normal" };
css_vars.push(format!(
"{}-{}-font-weight:{};",
css_variable_prefix, sanitized, font_weight
));
let text_dec = text_decoration(&style.text_decoration);
css_vars.push(format!(
"{}-{}-text-decoration:{};",
css_variable_prefix, sanitized, text_dec
));
}
for (theme_name, theme) in themes.iter() {
if theme_name != default_name {
if let Some(style) = theme.get_style(&specialized_scope) {
let sanitized = sanitize_theme_name(theme_name);
if let Some(fg) = &style.fg {
css_vars.push(format!("{}-{}:{};", css_variable_prefix, sanitized, fg));
}
if let Some(bg) = &style.bg {
css_vars
.push(format!("{}-{}-bg:{};", css_variable_prefix, sanitized, bg));
}
let font_style = if style.italic { "italic" } else { "normal" };
css_vars.push(format!(
"{}-{}-font-style:{};",
css_variable_prefix, sanitized, font_style
));
let font_weight = if style.bold { "bold" } else { "normal" };
css_vars.push(format!(
"{}-{}-font-weight:{};",
css_variable_prefix, sanitized, font_weight
));
let text_dec = text_decoration(&style.text_decoration);
css_vars.push(format!(
"{}-{}-text-decoration:{};",
css_variable_prefix, sanitized, text_dec
));
}
}
}
}
} else {
for (theme_name, theme) in themes.iter() {
if let Some(style) = theme.get_style(&specialized_scope) {
let sanitized = sanitize_theme_name(theme_name);
if let Some(fg) = &style.fg {
css_vars.push(format!("{}-{}: {};", css_variable_prefix, sanitized, fg));
}
if let Some(bg) = &style.bg {
css_vars.push(format!("{}-{}-bg: {};", css_variable_prefix, sanitized, bg));
}
let font_style = if style.italic { "italic" } else { "normal" };
css_vars.push(format!(
"{}-{}-font-style: {};",
css_variable_prefix, sanitized, font_style
));
let font_weight = if style.bold { "bold" } else { "normal" };
css_vars.push(format!(
"{}-{}-font-weight: {};",
css_variable_prefix, sanitized, font_weight
));
let text_dec = text_decoration(&style.text_decoration);
css_vars.push(format!(
"{}-{}-text-decoration: {};",
css_variable_prefix, sanitized, text_dec
));
}
}
}
if inline_styles.is_empty() && css_vars.is_empty() {
return String::new();
}
let mut attrs = String::new();
if include_highlights {
attrs.push_str(&format!("data-highlight=\"{}\" ", scope));
}
attrs.push_str("style=\"");
if !inline_styles.is_empty() {
attrs.push_str(&inline_styles.join(" "));
}
if !css_vars.is_empty() {
if !inline_styles.is_empty() {
attrs.push(' ');
}
attrs.push_str(&css_vars.join(" "));
}
attrs.push('"');
attrs
}
#[allow(clippy::too_many_arguments)]
pub fn span_multi_themes(
text: &str,
scope: &str,
language: Option<Language>,
themes: &std::collections::HashMap<String, Theme>,
default_theme: Option<&str>,
css_variable_prefix: &str,
italic: bool,
include_highlights: bool,
) -> String {
let escaped = escape(text);
if themes.is_empty() {
return escaped;
}
let attrs = span_multi_themes_attrs(
scope,
language,
themes,
default_theme,
css_variable_prefix,
italic,
include_highlights,
);
if attrs.is_empty() {
escaped
} else {
format!("<span {}>{}</span>", attrs, escaped)
}
}
pub fn escape(text: &str) -> String {
let mut buf = String::with_capacity(text.len() + text.len() / 10);
for c in text.chars() {
match c {
'&' => buf.push_str("&"),
'<' => buf.push_str("<"),
'>' => buf.push_str(">"),
'"' => buf.push_str("""),
'\'' => buf.push_str("'"),
'{' => buf.push_str("{"),
'}' => buf.push_str("}"),
_ => buf.push(c),
}
}
buf
}
pub fn escape_braces(text: &str) -> String {
text.replace('{', "{").replace('}', "}")
}
pub fn wrap_line(
line_number: usize,
content: &str,
class_suffix: Option<&str>,
style: Option<&str>,
) -> String {
let class_attr = if let Some(suffix) = class_suffix {
format!("line{}", suffix)
} else {
"line".to_string()
};
let style_attr = if let Some(s) = style {
format!(" style=\"{}\"", s)
} else {
String::new()
};
format!(
"<div class=\"{}\"{}data-line=\"{}\">{}</div>",
class_attr,
if style.is_some() {
format!("{} ", style_attr)
} else {
" ".to_string()
},
line_number,
content
)
}
pub fn scope_to_class(scope: &str) -> &str {
lumis_core::highlights::HIGHLIGHT_NAMES
.iter()
.position(|&s| s == scope)
.and_then(|idx| lumis_core::highlights::CLASSES.get(idx))
.copied()
.unwrap_or("text")
}
pub fn open_pre_tag(
output: &mut dyn Write,
pre_class: Option<&str>,
theme: Option<&Theme>,
) -> io::Result<()> {
let class = if let Some(pre_class) = pre_class {
format!("lumis {pre_class}")
} else {
"lumis".to_string()
};
write!(
output,
"<pre class=\"{}\"{}>",
class,
theme
.and_then(|theme| theme.pre_style(" "))
.map(|pre_style| format!(" style=\"{pre_style}\""))
.unwrap_or_default(),
)
}
pub fn open_code_tag(output: &mut dyn Write, lang: &Language) -> io::Result<()> {
write!(
output,
"<code class=\"language-{}\" translate=\"no\" tabindex=\"0\">",
lang.id_name()
)
}
pub fn close_code_tag(output: &mut dyn Write) -> io::Result<()> {
output.write_all(b"</code>")
}
pub fn close_pre_tag(output: &mut dyn Write) -> io::Result<()> {
output.write_all(b"</pre>")
}
pub fn closing_tags(output: &mut dyn Write) -> io::Result<()> {
close_code_tag(output)?;
close_pre_tag(output)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_str_eq;
#[test]
fn test_escape_all_entities() {
assert_eq!(
escape("&<>\"'{}"),
"&<>"'{}"
);
}
#[test]
fn test_escape_preserves_normal_text() {
assert_eq!(escape("hello world"), "hello world");
}
#[test]
fn test_escape_mixed_content() {
assert_eq!(
escape("fn main() { println!(\"<html>\"); }"),
"fn main() { println!("<html>"); }"
);
}
#[test]
fn test_escape_empty_string() {
assert_eq!(escape(""), "");
}
#[test]
fn test_escape_braces_only() {
assert_eq!(escape_braces("fn() {}"), "fn() {}");
}
#[test]
fn test_escape_braces_preserves_other_chars() {
assert_eq!(
escape_braces("fn main() { let x = 42; }"),
"fn main() { let x = 42; }"
);
}
#[test]
fn test_escape_braces_no_braces() {
assert_eq!(escape_braces("hello world"), "hello world");
}
#[test]
fn test_escape_braces_empty_string() {
assert_eq!(escape_braces(""), "");
}
#[test]
fn test_scope_to_class_keyword_conditional() {
assert_eq!(scope_to_class("keyword.conditional"), "keyword-conditional");
}
#[test]
fn test_scope_to_class_string_escape() {
assert_eq!(scope_to_class("string.escape"), "string-escape");
}
#[test]
fn test_scope_to_class_function_method_call() {
assert_eq!(
scope_to_class("function.method.call"),
"function-method-call"
);
}
#[test]
fn test_scope_to_class_comment_documentation() {
assert_eq!(
scope_to_class("comment.documentation"),
"comment-documentation"
);
}
#[test]
fn test_scope_to_class_unknown_scope() {
assert_eq!(scope_to_class("unknown.scope.name"), "text");
}
#[test]
fn test_scope_to_class_simple_scope() {
assert_eq!(scope_to_class("keyword"), "keyword");
}
#[test]
fn test_wrap_line_simple() {
let result = wrap_line(1, "content", None, None);
assert_str_eq!(result, r#"<div class="line" data-line="1">content</div>"#);
}
#[test]
fn test_wrap_line_with_class() {
let result = wrap_line(5, "highlighted content", Some(" highlighted"), None);
assert_str_eq!(
result,
r#"<div class="line highlighted" data-line="5">highlighted content</div>"#
);
}
#[test]
fn test_wrap_line_with_style() {
let result = wrap_line(3, "styled", None, Some("color: red;"));
assert_str_eq!(
result,
r#"<div class="line" style="color: red;" data-line="3">styled</div>"#
);
}
#[test]
fn test_wrap_line_with_class_and_style() {
let result = wrap_line(
10,
"both",
Some(" custom-class"),
Some("background: yellow;"),
);
assert_str_eq!(
result,
r#"<div class="line custom-class" style="background: yellow;" data-line="10">both</div>"#
);
}
#[test]
fn test_wrap_line_empty_content() {
let result = wrap_line(1, "", None, None);
assert_str_eq!(result, r#"<div class="line" data-line="1"></div>"#);
}
#[test]
fn test_span_inline_with_theme_and_scope() {
let theme = crate::themes::get("dracula").unwrap();
let result = span_inline(
"fn",
Some(Language::Rust),
"keyword",
Some(&theme),
false,
true,
);
assert_str_eq!(
result,
r#"<span data-highlight="keyword" style="color: #ff79c6;">fn</span>"#
);
}
#[test]
fn test_span_inline_no_theme() {
let result = span_inline("text", None, "text", None, false, false);
assert_str_eq!(result, "text");
}
#[test]
fn test_span_linked() {
let result = span_linked("fn", "keyword.function");
assert_str_eq!(result, r#"<span class="keyword-function">fn</span>"#);
}
}