use crate::decorations::{
compose_line_decorations, compose_line_decorations_into, rainbow_scope_index, Decoration,
LineSelection,
};
use crate::events::HighlightEvent;
use crate::highlights::HIGHLIGHT_NAMES;
use crate::languages::Language;
use crate::themes::{Style, TextDecoration, Theme, UnderlineStyle};
use std::fmt::Write as _;
use std::io::{self, Write};
use std::ops::RangeInclusive;
#[doc(hidden)]
pub use crate::decorations::SteppedLineRange;
pub fn span_inline_attrs(
language: Option<Language>,
scope: &str,
theme: Option<&Theme>,
include_highlights: bool,
) -> String {
let mut attrs = String::new();
if include_highlights {
let _ = write!(attrs, "data-highlight=\"{}\"", escape_attr(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
|| style.italic
|| has_decoration)
{
attrs.push(' ');
}
let css = style.css(" ");
if !css.is_empty() {
let _ = write!(attrs, "style=\"{}\"", escape_attr(&css));
}
}
}
attrs
}
pub fn span_inline(
text: &str,
language: Option<Language>,
scope: &str,
theme: Option<&Theme>,
include_highlights: bool,
) -> String {
let escaped = escape(text);
let attrs = span_inline_attrs(language, scope, theme, include_highlights);
format!("{}{}</span>", open_span(&attrs), escaped)
}
pub fn span_linked_attrs(scope: &str) -> String {
let class = scope_to_class(scope);
format!("class=\"{class}\"")
}
pub fn span_linked(text: &str, scope: &str) -> String {
let escaped = escape(text);
let class = scope_to_class(scope);
format!("<span class=\"{class}\">{escaped}</span>")
}
pub fn sanitize_theme_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect()
}
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",
}
}
fn sorted_theme_names(themes: &std::collections::HashMap<String, Theme>) -> Vec<&str> {
let mut names: Vec<&str> = themes.keys().map(String::as_str).collect();
names.sort_unstable();
names
}
fn push_default_theme_css_vars(
css_vars: &mut Vec<String>,
css_variable_prefix: &str,
theme_name: &str,
style: &Style,
) {
let sanitized = sanitize_theme_name(theme_name);
let font_style = if style.italic { "italic" } else { "normal" };
css_vars.push(format!(
"{css_variable_prefix}-{sanitized}-font-style:{font_style};"
));
let font_weight = if style.bold { "bold" } else { "normal" };
css_vars.push(format!(
"{css_variable_prefix}-{sanitized}-font-weight:{font_weight};"
));
css_vars.push(format!(
"{}-{}-text-decoration:{};",
css_variable_prefix,
sanitized,
text_decoration(&style.text_decoration)
));
}
fn push_theme_css_vars(
css_vars: &mut Vec<String>,
css_variable_prefix: &str,
theme_name: &str,
style: &Style,
) {
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!("{css_variable_prefix}-{sanitized}-bg:{bg};"));
}
let font_style = if style.italic { "italic" } else { "normal" };
css_vars.push(format!(
"{css_variable_prefix}-{sanitized}-font-style:{font_style};"
));
let font_weight = if style.bold { "bold" } else { "normal" };
css_vars.push(format!(
"{css_variable_prefix}-{sanitized}-font-weight:{font_weight};"
));
css_vars.push(format!(
"{}-{}-text-decoration:{};",
css_variable_prefix,
sanitized,
text_decoration(&style.text_decoration)
));
}
struct LightDarkProperty<'a> {
property: &'a str,
light: &'a str,
dark: &'a str,
initial: &'a str,
}
fn light_dark_properties<'a>(
light_style: &'a Style,
dark_style: &'a Style,
) -> [LightDarkProperty<'a>; 3] {
[
LightDarkProperty {
property: "font-weight",
light: if light_style.bold { "bold" } else { "normal" },
dark: if dark_style.bold { "bold" } else { "normal" },
initial: "normal",
},
LightDarkProperty {
property: "font-style",
light: if light_style.italic {
"italic"
} else {
"normal"
},
dark: if dark_style.italic {
"italic"
} else {
"normal"
},
initial: "normal",
},
LightDarkProperty {
property: "text-decoration",
light: text_decoration(&light_style.text_decoration),
dark: text_decoration(&dark_style.text_decoration),
initial: "none",
},
]
}
fn push_light_dark_inline_styles(
inline_styles: &mut Vec<String>,
css_vars: &mut Vec<String>,
css_variable_prefix: &str,
light_style: &Style,
dark_style: &Style,
) {
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 properties = light_dark_properties(light_style, dark_style);
for property in &properties {
if property.light == property.dark && property.light != property.initial {
inline_styles.push(format!("{}: {};", property.property, property.light));
}
}
let disputed: Vec<&LightDarkProperty<'_>> = properties
.iter()
.filter(|property| property.light != property.dark)
.collect();
for theme_name in ["dark", "light"] {
for property in &disputed {
let value = if theme_name == "dark" {
property.dark
} else {
property.light
};
css_vars.push(format!(
"{css_variable_prefix}-{theme_name}-{}:{value};",
property.property
));
}
}
}
fn push_default_inline_styles(inline_styles: &mut Vec<String>, style: &Style) {
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 style.italic {
inline_styles.push("font-style:italic;".to_string());
}
let decoration = text_decoration(&style.text_decoration);
if decoration != "none" {
inline_styles.push(format!("text-decoration:{decoration};"));
}
}
fn push_other_theme_css_vars(
css_vars: &mut Vec<String>,
themes: &std::collections::HashMap<String, Theme>,
style_of: StyleOf<'_>,
css_variable_prefix: &str,
skipped_theme: Option<&str>,
) {
for theme_name in sorted_theme_names(themes) {
if skipped_theme == Some(theme_name) {
continue;
}
if let Some(style) = style_of(&themes[theme_name]) {
push_theme_css_vars(css_vars, css_variable_prefix, theme_name, &style);
}
}
}
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,
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()
};
multi_themes_attrs(
scope,
&|theme| theme.get_style(&specialized_scope).cloned(),
themes,
default_theme,
css_variable_prefix,
include_highlights,
)
}
type StyleOf<'a> = &'a dyn Fn(&Theme) -> Option<Style>;
pub(crate) fn multi_themes_attrs(
label: &str,
style_of: StyleOf<'_>,
themes: &std::collections::HashMap<String, Theme>,
default_theme: Option<&str>,
css_variable_prefix: &str,
include_highlights: bool,
) -> String {
if themes.is_empty() {
return String::new();
}
let mut inline_styles = Vec::new();
let mut css_vars = Vec::new();
match default_theme {
Some("light-dark()") => {
push_light_dark_styles(
&mut inline_styles,
&mut css_vars,
themes,
style_of,
css_variable_prefix,
);
}
Some(default_name) => push_named_default_styles(
&mut inline_styles,
&mut css_vars,
themes,
style_of,
css_variable_prefix,
default_name,
),
None => {
push_other_theme_css_vars(&mut css_vars, themes, style_of, css_variable_prefix, None);
}
}
if inline_styles.is_empty() && css_vars.is_empty() {
return String::new();
}
render_style_attrs(&inline_styles, &css_vars, label, include_highlights)
}
fn push_light_dark_styles(
inline_styles: &mut Vec<String>,
css_vars: &mut Vec<String>,
themes: &std::collections::HashMap<String, Theme>,
style_of: StyleOf<'_>,
css_variable_prefix: &str,
) {
let (Some(light_theme), Some(dark_theme)) = (themes.get("light"), themes.get("dark")) else {
return;
};
let (Some(light_style), Some(dark_style)) = (style_of(light_theme), style_of(dark_theme))
else {
return;
};
push_light_dark_inline_styles(
inline_styles,
css_vars,
css_variable_prefix,
&light_style,
&dark_style,
);
}
fn push_named_default_styles(
inline_styles: &mut Vec<String>,
css_vars: &mut Vec<String>,
themes: &std::collections::HashMap<String, Theme>,
style_of: StyleOf<'_>,
css_variable_prefix: &str,
default_name: &str,
) {
let Some(default_theme_obj) = themes.get(default_name) else {
return;
};
if let Some(style) = style_of(default_theme_obj) {
push_default_inline_styles(inline_styles, &style);
push_default_theme_css_vars(css_vars, css_variable_prefix, default_name, &style);
}
push_other_theme_css_vars(
css_vars,
themes,
style_of,
css_variable_prefix,
Some(default_name),
);
}
fn render_style_attrs(
inline_styles: &[String],
css_vars: &[String],
scope: &str,
include_highlights: bool,
) -> String {
let mut attrs = String::new();
if include_highlights {
let _ = write!(attrs, "data-highlight=\"{}\" ", escape_attr(scope));
}
let mut style = String::new();
if !inline_styles.is_empty() {
style.push_str(&inline_styles.join(" "));
}
if !css_vars.is_empty() {
if !inline_styles.is_empty() {
style.push(' ');
}
style.push_str(&css_vars.join(" "));
}
let _ = write!(attrs, "style=\"{}\"", escape_attr(&style));
attrs
}
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,
include_highlights: bool,
) -> String {
let escaped = escape(text);
let attrs = span_multi_themes_attrs(
scope,
language,
themes,
default_theme,
css_variable_prefix,
include_highlights,
);
format!("{}{}</span>", open_span(&attrs), escaped)
}
const fn html_entity(byte: u8) -> Option<&'static str> {
match byte {
b'&' => Some("&"),
b'<' => Some("<"),
b'>' => Some(">"),
b'"' => Some("""),
b'\'' => Some("'"),
_ => None,
}
}
pub fn escape(text: &str) -> String {
let bytes = text.as_bytes();
let mut buf = String::with_capacity(text.len() + text.len() / 10);
let mut last = 0;
for (i, &b) in bytes.iter().enumerate() {
let Some(replacement) = html_entity(b) else {
continue;
};
buf.push_str(&text[last..i]);
buf.push_str(replacement);
last = i + 1;
}
if last == 0 {
return text.to_string();
}
buf.push_str(&text[last..]);
buf
}
pub(crate) fn write_escaped(output: &mut dyn Write, text: &str) -> io::Result<()> {
let mut last = 0;
for (index, &byte) in text.as_bytes().iter().enumerate() {
let Some(replacement) = html_entity(byte) else {
continue;
};
output.write_all(&text.as_bytes()[last..index])?;
output.write_all(replacement.as_bytes())?;
last = index + 1;
}
output.write_all(&text.as_bytes()[last..])
}
pub fn escape_attr(value: &str) -> String {
escape(value)
}
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 mut line = Vec::with_capacity(content.len() + 48);
let _ = LineTag::new(class_suffix, style, false, false, None).write(&mut line, line_number);
line.extend_from_slice(content.as_bytes());
line.extend_from_slice(b"</span>");
String::from_utf8(line).expect("the tag and its content are both UTF-8")
}
pub(crate) const LINE_NUMBER_CLASS: &str = "l-line-number";
pub(crate) const HIGHLIGHTED_LINE_NUMBER_CLASS: &str = "l-line-number-highlighted";
struct LineTag {
open: String,
gutter_open: Option<String>,
}
impl LineTag {
fn new(
class_suffix: Option<&str>,
style: Option<&str>,
numbered: bool,
highlighted: bool,
gutter_attrs: Option<&str>,
) -> Self {
let mut open = String::from("<span class=\"");
match class_suffix {
Some(suffix) => open.push_str(&escape_attr(&format!("l-line{suffix}"))),
None => open.push_str("l-line"),
}
open.push('"');
if let Some(style) = style {
let _ = write!(open, " style=\"{}\"", escape_attr(style));
}
open.push_str(" data-line=\"");
let gutter_open = numbered.then(|| {
let mut gutter = format!("<span class=\"{LINE_NUMBER_CLASS}");
if highlighted {
let _ = write!(gutter, " {HIGHLIGHTED_LINE_NUMBER_CLASS}");
}
gutter.push('"');
if let Some(attrs) = gutter_attrs.filter(|attrs| !attrs.is_empty()) {
gutter.push(' ');
gutter.push_str(attrs);
}
gutter.push_str(" aria-hidden=\"true\">");
gutter
});
Self { open, gutter_open }
}
fn write(&self, output: &mut dyn Write, line_number: usize) -> io::Result<()> {
output.write_all(self.open.as_bytes())?;
write!(output, "{line_number}\">")?;
if let Some(gutter_open) = &self.gutter_open {
write!(output, "{gutter_open}{line_number}</span>")?;
}
Ok(())
}
}
pub fn line_is_highlighted(lines: &[RangeInclusive<usize>], line_number: usize) -> bool {
lines.iter().any(|range| range.contains(&line_number))
}
pub fn highlight_line_class<'a>(
lines: &[RangeInclusive<usize>],
line_number: usize,
class: Option<&'a str>,
default_class: Option<&'a str>,
) -> Option<&'a str> {
if line_is_highlighted(lines, line_number) {
class.or(default_class)
} else {
None
}
}
pub fn scope_to_class(scope: &str) -> String {
crate::highlights::HIGHLIGHT_NAMES
.iter()
.position(|&s| s == scope)
.and_then(|idx| crate::highlights::CLASSES.get(idx))
.map_or_else(|| "l-text".to_string(), |class| format!("l-{class}"))
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AttrValue {
Value(String),
Present,
Absent,
}
impl AttrValue {
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Value(value) => Some(value),
Self::Present | Self::Absent => None,
}
}
}
impl From<String> for AttrValue {
fn from(value: String) -> Self {
Self::Value(value)
}
}
impl From<&str> for AttrValue {
fn from(value: &str) -> Self {
Self::Value(value.to_string())
}
}
impl From<bool> for AttrValue {
fn from(value: bool) -> Self {
if value {
Self::Present
} else {
Self::Absent
}
}
}
pub type HtmlAttrs = Vec<(String, AttrValue)>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum HtmlStructure {
#[default]
Block,
Inline,
}
#[must_use]
pub fn is_valid_attr_name(name: &str) -> bool {
!name.is_empty()
&& !name.chars().any(|character| {
character.is_whitespace()
|| character.is_control()
|| matches!(character, '"' | '\'' | '>' | '/' | '=')
})
}
fn merge_classes(current: &str, additional: &str) -> String {
let mut classes: Vec<&str> = Vec::new();
for candidate in current
.split_ascii_whitespace()
.chain(additional.split_ascii_whitespace())
{
if !classes.contains(&candidate) {
classes.push(candidate);
}
}
classes.join(" ")
}
fn append_style(current: &str, additional: &str) -> String {
let current = current.trim();
let additional = additional.trim();
match (current.is_empty(), additional.is_empty()) {
(true, true) => String::new(),
(true, false) => additional.to_string(),
(false, true) => current.to_string(),
(false, false) if current.ends_with(';') => format!("{current} {additional}"),
(false, false) => format!("{current}; {additional}"),
}
}
fn merged_value(name: &str, current: Option<&AttrValue>, authored: &AttrValue) -> AttrValue {
let Some(authored) = authored.as_str() else {
return AttrValue::Present;
};
let current = current.and_then(AttrValue::as_str).unwrap_or_default();
if name.eq_ignore_ascii_case("class") {
AttrValue::Value(merge_classes(current, authored))
} else if name.eq_ignore_ascii_case("style") {
AttrValue::Value(append_style(current, authored))
} else {
AttrValue::Value(authored.to_string())
}
}
pub(crate) fn merge_attrs(mut generated: HtmlAttrs, authored: &[(String, AttrValue)]) -> HtmlAttrs {
for (name, value) in authored {
let existing = generated
.iter()
.position(|(candidate, _)| candidate.eq_ignore_ascii_case(name));
if matches!(value, AttrValue::Absent) {
if let Some(index) = existing {
generated.remove(index);
}
continue;
}
let merged = merged_value(name, existing.map(|index| &generated[index].1), value);
match existing {
Some(index) => generated[index].1 = merged,
None if merged.as_str().is_some_and(str::is_empty) => {}
None => generated.push((name.clone(), merged)),
}
}
generated
}
pub fn open_tag(output: &mut dyn Write, name: &str, attrs: &HtmlAttrs) -> io::Result<()> {
write!(output, "<{name}")?;
for (attr_name, value) in attrs {
if !is_valid_attr_name(attr_name) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid HTML attribute name: {attr_name:?}"),
));
}
match value {
AttrValue::Value(value) => write!(output, " {attr_name}=\"{}\"", escape_attr(value))?,
AttrValue::Present => write!(output, " {attr_name}")?,
AttrValue::Absent => {}
}
}
output.write_all(b">")
}
pub fn pre_attrs(
pre_class: Option<&str>,
theme: Option<&Theme>,
attrs: &[(String, AttrValue)],
) -> HtmlAttrs {
let class = pre_class.map_or_else(|| "lumis".to_string(), |value| format!("lumis {value}"));
let mut generated = vec![("class".to_string(), AttrValue::Value(class))];
if let Some(style) = theme.and_then(|theme| theme.pre_style(" ")) {
generated.push(("style".to_string(), AttrValue::Value(style)));
}
merge_attrs(generated, attrs)
}
pub(crate) fn budget_attrs(
attrs: &HtmlAttrs,
exhausted: Option<crate::formatter::BudgetExhausted>,
) -> HtmlAttrs {
let Some(exhausted) = exhausted else {
return attrs.clone();
};
let mut attrs = attrs.clone();
attrs.push((
"data-lumis-budget".to_string(),
AttrValue::from(exhausted.as_str()),
));
attrs
}
pub(crate) fn write_pre_tag(
output: &mut dyn Write,
pre_class: Option<&str>,
theme: Option<&Theme>,
attrs: &[(String, AttrValue)],
) -> io::Result<()> {
open_tag(output, "pre", &pre_attrs(pre_class, theme, attrs))
}
pub fn open_pre_tag(
output: &mut dyn Write,
pre_class: Option<&str>,
theme: Option<&Theme>,
) -> io::Result<()> {
write_pre_tag(output, pre_class, theme, &[])
}
pub fn multi_themes_pre_attrs(
pre_class: Option<&str>,
themes: &std::collections::HashMap<String, Theme>,
default_theme: Option<&str>,
css_variable_prefix: &str,
attrs: &[(String, AttrValue)],
) -> HtmlAttrs {
let mut generated = vec![(
"class".to_string(),
AttrValue::Value(multi_themes_pre_classes(pre_class, themes)),
)];
let style = multi_themes_pre_style(themes, default_theme, css_variable_prefix);
if !style.is_empty() {
generated.push(("style".to_string(), AttrValue::Value(style)));
}
merge_attrs(generated, attrs)
}
pub(crate) fn write_multi_themes_pre_tag(
output: &mut dyn Write,
pre_class: Option<&str>,
themes: &std::collections::HashMap<String, Theme>,
default_theme: Option<&str>,
css_variable_prefix: &str,
attrs: &[(String, AttrValue)],
) -> io::Result<()> {
open_tag(
output,
"pre",
&multi_themes_pre_attrs(pre_class, themes, default_theme, css_variable_prefix, attrs),
)
}
pub fn open_multi_themes_pre_tag(
output: &mut dyn Write,
pre_class: Option<&str>,
themes: &std::collections::HashMap<String, Theme>,
default_theme: Option<&str>,
css_variable_prefix: &str,
) -> io::Result<()> {
write_multi_themes_pre_tag(
output,
pre_class,
themes,
default_theme,
css_variable_prefix,
&[],
)
}
fn multi_themes_pre_classes(
pre_class: Option<&str>,
themes: &std::collections::HashMap<String, Theme>,
) -> String {
let mut classes = vec!["lumis".to_string(), "lumis-themes".to_string()];
if let Some(pre_class) = pre_class {
classes.push(pre_class.to_string());
}
classes.extend(
sorted_theme_names(themes)
.into_iter()
.map(|key| themes[key].name.clone()),
);
merge_classes(&classes.join(" "), "")
}
fn push_normal_theme_vars(
styles: &mut Vec<String>,
css_variable_prefix: &str,
theme_name: &str,
theme: &Theme,
) {
let sanitized = sanitize_theme_name(theme_name);
if let Some(fg) = theme.fg() {
styles.push(format!("{css_variable_prefix}-{sanitized}:{fg};"));
}
if let Some(bg) = theme.bg() {
styles.push(format!("{css_variable_prefix}-{sanitized}-bg:{bg};"));
}
}
fn multi_themes_pre_style(
themes: &std::collections::HashMap<String, Theme>,
default_theme: Option<&str>,
css_variable_prefix: &str,
) -> String {
let mut styles = Vec::new();
match default_theme {
Some("light-dark()") => {
if let (Some(light), Some(dark)) = (themes.get("light"), themes.get("dark")) {
let light_fg = light.fg().unwrap_or("#000000");
let light_bg = light.bg().unwrap_or("#ffffff");
let dark_fg = dark.fg().unwrap_or("#ffffff");
let dark_bg = dark.bg().unwrap_or("#000000");
styles.push(format!("color: light-dark({light_fg}, {dark_fg});"));
styles.push(format!(
"background-color: light-dark({light_bg}, {dark_bg});"
));
}
}
Some(default_name) => {
if let Some(default_theme) = themes.get(default_name) {
if let Some(fg) = default_theme.fg() {
styles.push(format!("color:{fg};"));
}
if let Some(bg) = default_theme.bg() {
styles.push(format!("background-color:{bg};"));
}
}
for theme_name in sorted_theme_names(themes) {
if theme_name != default_name {
push_normal_theme_vars(
&mut styles,
css_variable_prefix,
theme_name,
&themes[theme_name],
);
}
}
}
None => {
for theme_name in sorted_theme_names(themes) {
push_normal_theme_vars(
&mut styles,
css_variable_prefix,
theme_name,
&themes[theme_name],
);
}
}
}
styles.join(" ")
}
pub fn code_attrs(lang: &Language, attrs: &[(String, AttrValue)]) -> HtmlAttrs {
merge_attrs(
vec![
(
"class".to_string(),
AttrValue::Value(format!("language-{}", lang.id_name())),
),
("translate".to_string(), AttrValue::Value("no".to_string())),
("tabindex".to_string(), AttrValue::Value("0".to_string())),
],
attrs,
)
}
pub(crate) fn write_code_tag(
output: &mut dyn Write,
lang: Language,
attrs: &[(String, AttrValue)],
) -> io::Result<()> {
open_tag(output, "code", &code_attrs(&lang, attrs))
}
pub fn open_code_tag(output: &mut dyn Write, lang: &Language) -> io::Result<()> {
write_code_tag(output, *lang, &[])
}
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)
}
pub fn append_fragment(lines: &mut Vec<String>, fragment: &str) {
let mut parts = fragment.split('\n').peekable();
while let Some(part) = parts.next() {
if let Some(current) = lines.last_mut() {
current.push_str(part);
}
if parts.peek().is_some() {
lines.push(String::new());
}
}
}
#[deprecated(since = "3.0.0", note = "use `escape(...)` instead")]
pub fn escape_fragment(text: &str) -> String {
escape(text)
}
pub fn open_span(attrs: &str) -> String {
if attrs.is_empty() {
"<span>".to_string()
} else {
format!("<span {attrs}>")
}
}
pub fn render_lines_from_events<T, F>(
source: &str,
events: &[HighlightEvent<'_, T>],
span_attrs: F,
) -> Vec<String>
where
F: Fn(usize, &str) -> String,
{
let mut lines = Vec::new();
let mut line = String::new();
let decoration_language = events
.iter()
.find_map(HighlightEvent::language)
.unwrap_or(Language::PlainText.id_name());
write_line_events(
&compose_line_decorations(source, events, &LineSelection::default()),
source,
decoration_language,
|fragment| match fragment {
LineFragment::OpenLine { .. } => {}
LineFragment::Close => lines.push(std::mem::take(&mut line)),
LineFragment::Text(text) => line.push_str(&escape(text)),
LineFragment::SpanOpen(scope_index, language) => {
line.push_str(&open_span(&span_attrs(scope_index, language)));
}
LineFragment::SpanClose => line.push_str("</span>"),
},
);
if source.ends_with('\n') {
lines.pop();
}
lines
}
pub(crate) const CODE_LAYOUT: &str =
"display: block; width: fit-content; min-width: 100%; overflow-wrap: anywhere;";
pub(crate) const LINE_LAYOUT: &str =
"display: inline-block; width: 100%; min-height: 1lh; vertical-align: top;";
pub(crate) fn unselectable_gutter(attrs: &str) -> String {
if attrs.contains("style=\"") {
attrs.replacen(
"style=\"",
"style=\"-webkit-user-select: none; user-select: none; ",
1,
)
} else {
format!("style=\"-webkit-user-select: none; user-select: none;\" {attrs}")
.trim_end()
.to_string()
}
}
pub(crate) enum LineFragment<'a> {
OpenLine { number: usize, highlighted: bool },
Close,
Text(&'a str),
SpanOpen(usize, &'a str),
SpanClose,
}
pub(crate) fn write_line_events<T, F>(
events: &[HighlightEvent<'_, T>],
source: &str,
decoration_language: &str,
mut on_fragment: F,
) where
F: FnMut(LineFragment<'_>),
{
let mut walk = LineEventWalk::new();
for event in events {
walk.push(event, source, decoration_language, &mut on_fragment);
}
}
pub(crate) struct LineEventWalk {
decorations: Vec<Decoration>,
}
impl LineEventWalk {
pub(crate) const fn new() -> Self {
Self {
decorations: Vec::new(),
}
}
pub(crate) fn push<T, F>(
&mut self,
event: &HighlightEvent<'_, T>,
source: &str,
decoration_language: &str,
on_fragment: &mut F,
) where
F: FnMut(LineFragment<'_>),
{
let decorations = &mut self.decorations;
match event {
HighlightEvent::DecorationStart { decoration } => {
decorations.push(*decoration);
match decoration {
Decoration::Line {
number,
highlighted,
} => {
on_fragment(LineFragment::OpenLine {
number: *number,
highlighted: *highlighted,
});
}
Decoration::RainbowBracket { depth } => on_fragment(LineFragment::SpanOpen(
rainbow_scope_index(*depth),
decoration_language,
)),
Decoration::DiffWord { kind } => on_fragment(LineFragment::SpanOpen(
kind.scope_index(),
decoration_language,
)),
}
}
HighlightEvent::DecorationEnd => match decorations.pop() {
Some(Decoration::Line { .. }) => on_fragment(LineFragment::Close),
Some(Decoration::RainbowBracket { .. } | Decoration::DiffWord { .. }) => {
on_fragment(LineFragment::SpanClose);
}
None => {}
},
HighlightEvent::Start {
scope_index,
language,
} => on_fragment(LineFragment::SpanOpen(*scope_index, language)),
HighlightEvent::End => on_fragment(LineFragment::SpanClose),
HighlightEvent::Source { start, end } => {
let text = source_slice(source, *start, *end);
on_fragment(LineFragment::Text(strip_line_ending(
text,
source.as_bytes().get(*end) == Some(&b'\n'),
)));
}
HighlightEvent::AnnotationStart { .. } | HighlightEvent::AnnotationEnd => {}
}
}
}
fn strip_line_ending(text: &str, followed_by_lf: bool) -> &str {
if let Some(content) = text.strip_suffix('\n') {
content.strip_suffix('\r').unwrap_or(content)
} else if followed_by_lf {
text.strip_suffix('\r').unwrap_or(text)
} else {
text
}
}
pub(crate) struct HtmlLines<'a> {
pub structure: HtmlStructure,
pub language: Language,
pub selection: &'a LineSelection,
pub numbered: bool,
pub line_number_attrs: Option<&'a str>,
pub highlighted_line_number_attrs: Option<&'a str>,
pub highlighted_class: Option<&'a str>,
pub highlighted_style: Option<&'a str>,
pub empty_style: Option<&'a str>,
}
pub(crate) fn write_html_lines<T>(
output: &mut dyn Write,
source: &str,
events: &[HighlightEvent<'_, T>],
lines: &HtmlLines<'_>,
span_attrs: &dyn Fn(usize, &str) -> String,
) -> io::Result<()> {
let plain_tag = LineTag::new(None, None, lines.numbered, false, lines.line_number_attrs);
let empty_tag = LineTag::new(
None,
lines.empty_style,
lines.numbered,
false,
lines.line_number_attrs,
);
let highlighted_tag = LineTag::new(
lines.highlighted_class,
lines.highlighted_style,
lines.numbered,
true,
lines.highlighted_line_number_attrs,
);
let mut attrs = SpanAttrCache::default();
let mut result = Ok(());
let mut walk = LineEventWalk::new();
let decoration_language = lines.language.id_name();
let wrapped = lines.structure == HtmlStructure::Block;
let line_count = source.lines().count().max(1);
let mut source_lines = source.lines();
let mut skip_line = false;
let mut on_fragment = |fragment: LineFragment<'_>| {
if let LineFragment::OpenLine { number, .. } = fragment {
skip_line = number > line_count;
}
if result.is_err() || skip_line {
return;
}
result = match fragment {
LineFragment::OpenLine {
number,
highlighted,
} => {
let empty = source_lines.next().unwrap_or_default().is_empty();
let tag = if highlighted {
&highlighted_tag
} else if empty {
&empty_tag
} else {
&plain_tag
};
let separator = if number > 1 { &b"\n"[..] } else { &[] };
output.write_all(separator).and_then(|()| {
if wrapped {
tag.write(output, number)
} else {
Ok(())
}
})
}
LineFragment::Text(text) => write_escaped(output, text),
LineFragment::SpanOpen(scope_index, language) => {
output.write_all(attrs.open_tag(scope_index, language, span_attrs))
}
LineFragment::Close if !wrapped => Ok(()),
LineFragment::Close | LineFragment::SpanClose => output.write_all(b"</span>"),
};
};
compose_line_decorations_into(source, events, lines.selection, &mut |event| {
walk.push(&event, source, decoration_language, &mut on_fragment);
});
result
}
pub(crate) fn write_html_spans<T>(
output: &mut dyn Write,
source: &str,
events: &[HighlightEvent<'_, T>],
language: Language,
span_attrs: &dyn Fn(usize, &str) -> String,
) -> io::Result<()> {
write_html_lines(
output,
source,
events,
&HtmlLines {
structure: HtmlStructure::Inline,
language,
selection: &LineSelection::default(),
numbered: false,
line_number_attrs: None,
highlighted_line_number_attrs: None,
highlighted_class: None,
highlighted_style: None,
empty_style: None,
},
span_attrs,
)
}
#[derive(Default)]
struct SpanAttrCache {
entries: Vec<Vec<(String, Vec<u8>)>>,
unknown: std::collections::HashMap<usize, Vec<(String, Vec<u8>)>>,
}
impl SpanAttrCache {
fn open_tag(
&mut self,
scope_index: usize,
language: &str,
span_attrs: &dyn Fn(usize, &str) -> String,
) -> &[u8] {
let languages = if scope_index < HIGHLIGHT_NAMES.len() {
if scope_index >= self.entries.len() {
self.entries.resize_with(scope_index + 1, Vec::new);
}
&mut self.entries[scope_index]
} else {
self.unknown.entry(scope_index).or_default()
};
let found = languages
.iter()
.position(|(known, _)| known == language)
.unwrap_or_else(|| {
languages.push((
language.to_string(),
open_span(&span_attrs(scope_index, language)).into_bytes(),
));
languages.len() - 1
});
&languages[found].1
}
}
fn source_slice(source: &str, start: usize, end: usize) -> &str {
let start = ceil_char_boundary(source, start.min(source.len()));
let end = floor_char_boundary(source, end.min(source.len())).max(start);
&source[start..end]
}
fn floor_char_boundary(source: &str, mut index: usize) -> usize {
while index > 0 && !source.is_char_boundary(index) {
index -= 1;
}
index
}
fn ceil_char_boundary(source: &str, mut index: usize) -> usize {
while index < source.len() && !source.is_char_boundary(index) {
index += 1;
}
index
}
#[deprecated(since = "3.0.0", note = "use `render_lines_from_events(...)` instead")]
pub fn render_events<T, F>(
source: &str,
events: &[crate::events::HighlightEvent<'_, T>],
attribute_callback: &F,
) -> (Vec<u8>, Vec<u32>)
where
F: Fn(usize, &str, &mut Vec<u8>),
{
let source = source.as_bytes();
let mut html = Vec::new();
let mut line_offsets = vec![0u32];
let mut highlight_stack: Vec<(usize, String)> = Vec::new();
for event in events {
match event {
crate::events::HighlightEvent::Start {
scope_index,
language,
} => {
let mut attrs = Vec::new();
attribute_callback(*scope_index, language, &mut attrs);
if attrs.is_empty() {
html.extend_from_slice(b"<span>");
} else {
html.extend_from_slice(b"<span ");
html.extend_from_slice(&attrs);
html.push(b'>');
}
highlight_stack.push((*scope_index, language.clone()));
}
crate::events::HighlightEvent::End => {
html.extend_from_slice(b"</span>");
highlight_stack.pop();
}
crate::events::HighlightEvent::Source { start, end } => {
let s = (*start).min(source.len());
let e = (*end).min(source.len()).max(s);
let text = &source[s..e];
for &byte in text {
if byte == b'\n' {
html.push(b'\n');
line_offsets.push(html.len() as u32);
} else {
match byte {
b'&' => html.extend_from_slice(b"&"),
b'<' => html.extend_from_slice(b"<"),
b'>' => html.extend_from_slice(b">"),
b'"' => html.extend_from_slice(b"""),
b'\'' => html.extend_from_slice(b"'"),
_ => html.push(byte),
}
}
}
}
crate::events::HighlightEvent::AnnotationStart { .. }
| crate::events::HighlightEvent::AnnotationEnd
| crate::events::HighlightEvent::DecorationStart { .. }
| crate::events::HighlightEvent::DecorationEnd => {}
}
}
(html, line_offsets)
}
#[deprecated(since = "3.0.0", note = "use `render_lines_from_events(...)` instead")]
pub fn lines_from_offsets<'a>(
html: &'a [u8],
line_offsets: &'a [u32],
) -> impl Iterator<Item = &'a str> + 'a {
line_offsets
.windows(2)
.map(move |w| {
let start = w[0] as usize;
let end = w[1] as usize;
std::str::from_utf8(&html[start..end]).unwrap_or("")
})
.chain(std::iter::once({
let last_offset = *line_offsets.last().unwrap_or(&0) as usize;
std::str::from_utf8(&html[last_offset..]).unwrap_or("")
}))
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_str_eq;
fn attr_value<'a>(tag: &'a str, attr: &str) -> &'a str {
let marker = format!(r#"{attr}=""#);
let start = tag.find(&marker).expect("missing attribute") + marker.len();
let end = tag[start..].find('"').expect("unterminated attribute");
&tag[start..start + end]
}
fn assert_classes(tag: &str, expected: &[&str]) {
let classes: std::collections::HashSet<&str> =
attr_value(tag, "class").split(' ').collect();
assert_eq!(classes.len(), expected.len());
for class in expected {
assert!(classes.contains(class), "missing class {class:?} in {tag}");
}
}
fn html_lines<T>(
source: &str,
events: &[HighlightEvent<'_, T>],
selection: &LineSelection,
numbered: bool,
highlighted_class: Option<&str>,
) -> String {
let mut output = Vec::new();
let lines = HtmlLines {
structure: HtmlStructure::Block,
language: Language::PlainText,
selection,
numbered,
line_number_attrs: None,
highlighted_line_number_attrs: None,
highlighted_class,
highlighted_style: None,
empty_style: None,
};
write_html_lines(&mut output, source, events, &lines, &|_, _| String::new()).unwrap();
String::from_utf8(output).expect("the formatter writes UTF-8")
}
#[test]
fn test_escape_all_entities() {
assert_eq!(escape("&<>\"'{}"), "&<>"'{}");
}
#[test]
fn test_source_slice_never_panics_on_caller_offsets() {
let source = "éx";
assert_eq!(source_slice(source, 0, 1), "", "end splits 'é'");
assert_eq!(source_slice(source, 1, 2), "", "start splits 'é'");
assert_eq!(source_slice(source, 1, 3), "x", "start splits 'é'");
assert_eq!(source_slice(source, 0, 2), "é");
assert_eq!(source_slice(source, 0, 3), "éx");
assert_eq!(source_slice(source, 0, 99), "éx", "end past the source");
assert_eq!(source_slice(source, 99, 99), "", "start past the source");
assert_eq!(source_slice(source, 3, 0), "", "reversed");
}
#[test]
fn test_render_lines_from_events_survives_a_split_character() {
let events: [crate::events::HighlightEvent<'_>; 1] =
[crate::events::HighlightEvent::Source { start: 0, end: 1 }];
assert_eq!(
render_lines_from_events("é", &events, |_, _| String::new()),
[""]
);
}
#[test]
fn render_lines_from_events_and_the_html_pass_split_alike() {
for source in ["", "one", "one\n", "one\ntwo", "one\r\ntwo", "one\rtwo"] {
let events: [HighlightEvent<'_, ()>; 1] = [HighlightEvent::Source {
start: 0,
end: source.len(),
}];
let lines = render_lines_from_events(source, &events, |_, _| String::new());
let html = html_lines(source, &events, &LineSelection::default(), false, None);
assert_str_eq!(
html,
lines
.iter()
.enumerate()
.map(|(index, line)| wrap_line(index + 1, line, None, None))
.collect::<Vec<_>>()
.join("\n")
);
}
}
#[test]
fn inline_spans_are_the_block_lines_without_their_elements() {
for source in [
"",
"one",
"one\n",
"one\ntwo",
"one\r\ntwo",
"one\rtwo",
"one\n\ntwo\n",
] {
let events: [HighlightEvent<'_, ()>; 3] = [
HighlightEvent::Start {
scope_index: 0,
language: "text".to_string(),
},
HighlightEvent::Source {
start: 0,
end: source.len(),
},
HighlightEvent::End,
];
let span_attrs = |_: usize, _: &str| "class=\"scope\"".to_string();
let mut html = Vec::new();
write_html_spans(&mut html, source, &events, Language::PlainText, &span_attrs).unwrap();
assert_str_eq!(
String::from_utf8(html).unwrap(),
render_lines_from_events(source, &events, span_attrs).join("\n")
);
}
}
#[test]
fn inline_spans_accept_a_scope_index_outside_the_known_scopes() {
let source = "ab";
let events: [HighlightEvent<'_, ()>; 6] = [
HighlightEvent::Start {
scope_index: usize::MAX,
language: "text".to_string(),
},
HighlightEvent::Source { start: 0, end: 1 },
HighlightEvent::End,
HighlightEvent::Start {
scope_index: HIGHLIGHT_NAMES.len(),
language: "text".to_string(),
},
HighlightEvent::Source { start: 1, end: 2 },
HighlightEvent::End,
];
let mut html = Vec::new();
write_html_spans(
&mut html,
source,
&events,
Language::PlainText,
&|index, _| format!("data-scope=\"{}\"", index == usize::MAX),
)
.unwrap();
assert_str_eq!(
String::from_utf8(html).unwrap(),
"<span data-scope=\"true\">a</span><span data-scope=\"false\">b</span>"
);
}
#[test]
fn inline_spans_close_a_scope_before_a_newline_and_reopen_it_after() {
let source = "a\r\nb\n";
let events: [HighlightEvent<'_, ()>; 3] = [
HighlightEvent::Start {
scope_index: 0,
language: "text".to_string(),
},
HighlightEvent::Source {
start: 0,
end: source.len(),
},
HighlightEvent::End,
];
let mut html = Vec::new();
write_html_spans(&mut html, source, &events, Language::PlainText, &|_, _| {
"class=\"scope\"".to_string()
})
.unwrap();
assert_str_eq!(
String::from_utf8(html).unwrap(),
"<span class=\"scope\">a</span>\n<span class=\"scope\">b</span>"
);
}
#[test]
fn html_lines_write_normalized_separators_between_line_spans() {
let source = "a\r\nb";
let events: [HighlightEvent<'_, ()>; 3] = [
HighlightEvent::Start {
scope_index: 0,
language: "text".to_string(),
},
HighlightEvent::Source {
start: 0,
end: source.len(),
},
HighlightEvent::End,
];
let mut html = Vec::new();
write_html_lines(
&mut html,
source,
&events,
&HtmlLines {
structure: HtmlStructure::Block,
language: Language::PlainText,
selection: &LineSelection::default(),
numbered: false,
line_number_attrs: None,
highlighted_line_number_attrs: None,
highlighted_class: None,
highlighted_style: None,
empty_style: None,
},
&|_, _| "class=\"scope\"".to_string(),
)
.unwrap();
assert_str_eq!(
String::from_utf8(html).unwrap(),
"<span class=\"l-line\" data-line=\"1\"><span class=\"scope\">a</span></span>\n<span class=\"l-line\" data-line=\"2\"><span class=\"scope\">b</span></span>"
);
}
#[test]
fn a_gutter_carries_the_number_data_line_does() {
let source = "one\ntwo";
let events: [HighlightEvent<'_, ()>; 1] = [HighlightEvent::Source {
start: 0,
end: source.len(),
}];
let selection = LineSelection::new(std::slice::from_ref(&(2..=2)), &[]);
let html = html_lines(source, &events, &selection, true, Some(" l-highlighted"));
assert_str_eq!(
html,
concat!(
r#"<span class="l-line" data-line="1">"#,
r#"<span class="l-line-number" aria-hidden="true">1</span>one"#,
"</span>\n",
r#"<span class="l-line l-highlighted" data-line="2">"#,
r#"<span class="l-line-number l-line-number-highlighted" aria-hidden="true">2</span>two"#,
"</span>",
)
);
}
#[test]
fn gutters_carry_their_theme_attributes() {
let source = "one\ntwo";
let events: [HighlightEvent<'_, ()>; 1] = [HighlightEvent::Source {
start: 0,
end: source.len(),
}];
let selection = LineSelection::new(std::slice::from_ref(&(2..=2)), &[]);
let mut output = Vec::new();
let lines = HtmlLines {
structure: HtmlStructure::Block,
language: Language::PlainText,
selection: &selection,
numbered: true,
line_number_attrs: Some(r#"style="color:#111111;""#),
highlighted_line_number_attrs: Some(r#"style="color:#eeeeee;""#),
highlighted_class: None,
highlighted_style: None,
empty_style: None,
};
write_html_lines(&mut output, source, &events, &lines, &|_, _| String::new()).unwrap();
let html = String::from_utf8(output).unwrap();
assert!(html.contains(
r#"<span class="l-line-number" style="color:#111111;" aria-hidden="true">1</span>"#
));
assert!(html.contains(
r#"<span class="l-line-number l-line-number-highlighted" style="color:#eeeeee;" aria-hidden="true">2</span>"#
));
}
#[test]
fn no_gutter_without_line_numbers() {
let events: [HighlightEvent<'_, ()>; 1] = [HighlightEvent::Source { start: 0, end: 1 }];
let html = html_lines("a", &events, &LineSelection::default(), false, None);
assert_str_eq!(html, "<span class=\"l-line\" data-line=\"1\">a</span>");
}
#[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_span_inline_without_attributes() {
assert_eq!(
span_inline("<b>", None, "string", None, false),
"<span><b></span>"
);
}
#[test]
fn test_span_multi_themes_without_attributes() {
assert_eq!(
span_multi_themes(
"<b>",
"string",
None,
&std::collections::HashMap::new(),
None,
"--lumis",
false,
),
"<span><b></span>"
);
}
#[test]
fn test_escape_braces_only() {
assert_eq!(escape_braces("fn() {}"), "fn() {}");
}
fn quote_bearing_theme() -> Theme {
crate::themes::from_json(
r##"{
"name": "quote-bearing",
"appearance": "dark",
"revision": "test",
"highlights": {
"normal": { "fg": "red\" onmouseover=\"alert(1)", "bg": "#000000" },
"keyword": { "fg": "red\" onmouseover=\"alert(1)" }
}
}"##,
)
.unwrap()
}
#[test]
fn test_escape_attr_matches_escape() {
assert_eq!(escape_attr("&<>\"'"), "&<>"'");
}
#[test]
fn test_escape_attr_keeps_quoted_css_readable() {
assert_eq!(
escape_attr("font-family: 'Fira Code';"),
"font-family: 'Fira Code';"
);
}
#[test]
fn test_open_pre_tag_escapes_caller_class() {
let mut output = Vec::new();
open_pre_tag(&mut output, Some(r#"x"><script>"#), None).unwrap();
assert_str_eq!(
String::from_utf8(output).unwrap(),
r#"<pre class="lumis x"><script>">"#
);
}
#[test]
fn test_open_pre_tag_escapes_theme_colors() {
let mut output = Vec::new();
open_pre_tag(&mut output, None, Some("e_bearing_theme())).unwrap();
assert_str_eq!(
String::from_utf8(output).unwrap(),
r#"<pre class="lumis" style="color: red" onmouseover="alert(1); background-color: #000000;">"#
);
}
#[test]
fn pre_attributes_union_classes_append_styles_and_escape_values() {
let theme = crate::themes::get("dracula").unwrap();
let attrs = vec![
("class".to_string(), "shorthand authored".into()),
("style".to_string(), "outline: 1px solid red".into()),
("id".to_string(), r#"sample" onmouseover="alert(1)"#.into()),
];
let mut output = Vec::new();
write_pre_tag(&mut output, Some("shorthand"), Some(&theme), &attrs).unwrap();
assert_str_eq!(
String::from_utf8(output).unwrap(),
concat!(
r#"<pre class="lumis shorthand authored" "#,
r#"style="color: #f8f8f2; background-color: #282a36; outline: 1px solid red" "#,
r#"id="sample" onmouseover="alert(1)">"#,
)
);
}
#[test]
fn code_attributes_union_classes_and_override_defaults() {
let attrs = vec![
("class".to_string(), "copyable language-plaintext".into()),
("translate".to_string(), "yes".into()),
("tabindex".to_string(), "-1".into()),
("data-copy".to_string(), "button".into()),
];
let mut output = Vec::new();
write_code_tag(&mut output, Language::PlainText, &attrs).unwrap();
assert_str_eq!(
String::from_utf8(output).unwrap(),
concat!(
r#"<code class="language-plaintext copyable" "#,
r#"translate="yes" tabindex="-1" data-copy="button">"#,
)
);
}
#[test]
fn boolean_attributes_render_bare_and_false_removes_a_default() {
let attrs = vec![
("inert".to_string(), true.into()),
("translate".to_string(), false.into()),
];
let mut output = Vec::new();
write_code_tag(&mut output, Language::PlainText, &attrs).unwrap();
assert_str_eq!(
String::from_utf8(output).unwrap(),
r#"<code class="language-plaintext" tabindex="0" inert>"#
);
}
#[test]
fn an_attribute_name_cannot_break_out_of_the_tag() {
for name in [
r#"x" onclick="alert(1)"#,
"x onclick=alert(1)",
"x=y",
"x/y",
"x>y",
"",
] {
let attrs = vec![(name.to_string(), "y".into())];
let error = open_tag(&mut Vec::new(), "pre", &attrs).expect_err("the name is rejected");
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
}
let valid = vec![("data-copy".to_string(), "y".into())];
assert!(open_tag(&mut Vec::new(), "pre", &valid).is_ok());
}
#[test]
fn multi_theme_classes_use_theme_names_in_key_order() {
let themes = [
(
"light".to_string(),
crate::themes::get("github_light").unwrap(),
),
("dark".to_string(), crate::themes::get("dracula").unwrap()),
(
"custom dark".to_string(),
crate::themes::get("github_light").unwrap(),
),
]
.into_iter()
.collect();
let attrs = multi_themes_pre_attrs(None, &themes, None, "--custom", &[]);
assert_eq!(
attrs[0].1,
AttrValue::Value("lumis lumis-themes github_light dracula".to_string())
);
}
#[test]
fn multi_theme_classes_preserve_caller_classes() {
let themes = [("dark".to_string(), crate::themes::get("dracula").unwrap())]
.into_iter()
.collect();
let attrs = multi_themes_pre_attrs(
Some("dark"),
&themes,
None,
"--lumis",
&[("class".to_string(), "light card".into())],
);
assert_eq!(
attrs[0].1,
AttrValue::Value("lumis lumis-themes dark dracula light card".to_string())
);
}
#[test]
fn a_pre_class_naming_a_theme_is_not_repeated() {
let mut themes = std::collections::HashMap::new();
themes.insert("dark".to_string(), crate::themes::get("dracula").unwrap());
let attrs = multi_themes_pre_attrs(Some("dracula"), &themes, None, "--lumis", &[]);
assert_eq!(
attrs[0].1,
AttrValue::Value("lumis lumis-themes dracula".to_string())
);
}
#[test]
fn test_open_multi_themes_pre_tag_escapes_caller_class() {
let mut themes = std::collections::HashMap::new();
themes.insert("dark".to_string(), quote_bearing_theme());
let mut output = Vec::new();
open_multi_themes_pre_tag(
&mut output,
Some(r#"x"><script>"#),
&themes,
Some("dark"),
"--lumis",
)
.unwrap();
let html = String::from_utf8(output).unwrap();
assert!(
html.starts_with(
r#"<pre class="lumis lumis-themes x"><script> quote-bearing""#
),
"unescaped class in {html}"
);
assert!(!html.contains("<script>"), "unescaped markup in {html}");
}
#[test]
fn test_wrap_line_escapes_caller_class_and_style() {
let result = wrap_line(
1,
"content",
Some(r#" x"><script>"#),
Some(r#"color: red" onmouseover="alert(1)"#),
);
assert_str_eq!(
result,
r#"<span class="l-line x"><script>" style="color: red" onmouseover="alert(1)" data-line="1">content</span>"#
);
}
#[test]
fn test_span_inline_escapes_theme_colors() {
assert_str_eq!(
span_inline("fn", None, "keyword", Some("e_bearing_theme()), false),
r#"<span style="color: red" onmouseover="alert(1);">fn</span>"#
);
}
#[test]
fn test_span_multi_themes_escapes_theme_colors() {
let mut themes = std::collections::HashMap::new();
themes.insert("dark".to_string(), quote_bearing_theme());
let result = span_multi_themes("fn", "keyword", None, &themes, None, "--lumis", false);
assert_str_eq!(
result,
r#"<span style="--lumis-dark:red" onmouseover="alert(1); --lumis-dark-font-style:normal; --lumis-dark-font-weight:normal; --lumis-dark-text-decoration:none;">fn</span>"#
);
}
#[test]
fn test_scope_to_class_keyword_conditional() {
assert_eq!(
scope_to_class("keyword.conditional"),
"l-keyword-conditional"
);
}
#[test]
fn test_scope_to_class_unknown_scope() {
assert_eq!(scope_to_class("unknown.scope.name"), "l-text");
}
fn light_dark_themes() -> std::collections::HashMap<String, Theme> {
let light = crate::themes::from_json(
r##"{
"name": "light",
"appearance": "light",
"revision": "test",
"highlights": {
"normal": { "fg": "#111111", "bg": "#ffffff" },
"keyword": { "fg": "#d73a49", "bold": true },
"comment": { "fg": "#6a737d", "italic": true },
"string": { "fg": "#032f62", "underline": true }
}
}"##,
)
.unwrap();
let dark = crate::themes::from_json(
r##"{
"name": "dark",
"appearance": "dark",
"revision": "test",
"highlights": {
"normal": { "fg": "#eeeeee", "bg": "#000000" },
"keyword": { "fg": "#ff7b72", "bold": true },
"comment": { "fg": "#8b949e" },
"string": { "fg": "#a5d6ff", "strikethrough": true }
}
}"##,
)
.unwrap();
std::collections::HashMap::from([("light".to_string(), light), ("dark".to_string(), dark)])
}
fn light_dark_style(scope: &str) -> String {
let attrs = span_multi_themes_attrs(
scope,
None,
&light_dark_themes(),
Some("light-dark()"),
"--lumis",
false,
);
attr_value(&attrs, "style").to_string()
}
fn css_declarations(style: &str) -> Vec<(&str, &str)> {
style
.split(';')
.map(str::trim)
.filter(|declaration| !declaration.is_empty())
.map(|declaration| {
let (property, value) = declaration
.split_once(':')
.unwrap_or_else(|| panic!("`{declaration}` is not a declaration"));
(property.trim(), value.trim())
})
.collect()
}
fn is_color(value: &str) -> bool {
value.starts_with('#') && value[1..].chars().all(|c| c.is_ascii_hexdigit())
}
fn assert_valid_declaration(property: &str, value: &str) {
let valid = match property {
_ if property.starts_with("--") => true,
"color" | "background-color" => {
is_color(value)
|| value
.strip_prefix("light-dark(")
.and_then(|rest| rest.strip_suffix(')'))
.is_some_and(|colors| colors.split(", ").all(is_color))
}
"font-weight" => matches!(value, "normal" | "bold"),
"font-style" => matches!(value, "normal" | "italic"),
"text-decoration" => value.split(' ').all(|keyword| {
matches!(
keyword,
"underline" | "line-through" | "wavy" | "double" | "dotted" | "dashed"
)
}),
_ => false,
};
assert!(valid, "browsers discard `{property}: {value};`");
}
#[test]
fn light_dark_emits_only_declarations_a_browser_keeps() {
for scope in ["normal", "keyword", "comment", "string"] {
for (property, value) in css_declarations(&light_dark_style(scope)) {
assert_valid_declaration(property, value);
}
}
}
#[test]
fn light_dark_writes_a_shared_non_color_property_as_a_plain_declaration() {
assert_str_eq!(
light_dark_style("keyword"),
"color: light-dark(#d73a49, #ff7b72); font-weight: bold;"
);
}
#[test]
fn light_dark_drops_a_non_color_property_both_themes_leave_at_its_initial_value() {
assert_str_eq!(
light_dark_style("normal"),
"color: light-dark(#111111, #eeeeee); background-color: light-dark(#ffffff, #000000);"
);
}
#[test]
fn light_dark_leaves_a_disputed_non_color_property_to_variables_alone() {
assert_str_eq!(
light_dark_style("comment"),
"color: light-dark(#6a737d, #8b949e); \
--lumis-dark-font-style:normal; --lumis-light-font-style:italic;"
);
assert_str_eq!(
light_dark_style("string"),
"color: light-dark(#032f62, #a5d6ff); \
--lumis-dark-text-decoration:line-through; --lumis-light-text-decoration:underline;"
);
}
#[test]
fn test_wrap_line_simple() {
let result = wrap_line(1, "content", None, None);
assert_str_eq!(
result,
r#"<span class="l-line" data-line="1">content</span>"#
);
}
#[test]
fn test_wrap_line_with_class() {
let result = wrap_line(5, "highlighted content", Some(" highlighted"), None);
assert_str_eq!(
result,
r#"<span class="l-line highlighted" data-line="5">highlighted content</span>"#
);
}
#[test]
fn test_wrap_line_with_style() {
let result = wrap_line(3, "styled", None, Some("color: red;"));
assert_str_eq!(
result,
r#"<span class="l-line" style="color: red;" data-line="3">styled</span>"#
);
}
#[test]
fn test_open_multi_themes_pre_tag_with_default_theme() {
let mut themes = std::collections::HashMap::new();
themes.insert(
"light".to_string(),
crate::themes::get("github_light").unwrap(),
);
let mut output = Vec::new();
open_multi_themes_pre_tag(
&mut output,
Some("custom-pre"),
&themes,
Some("light"),
"--lumis",
)
.unwrap();
let html = String::from_utf8(output).unwrap();
assert_classes(
&html,
&["lumis", "lumis-themes", "custom-pre", "github_light"],
);
assert_eq!(
attr_value(&html, "style"),
"color:#1f2328; background-color:#ffffff;"
);
}
#[test]
fn test_open_multi_themes_pre_tag_with_light_dark() {
let mut themes = std::collections::HashMap::new();
themes.insert(
"light".to_string(),
crate::themes::get("catppuccin_latte").unwrap(),
);
themes.insert(
"dark".to_string(),
crate::themes::get("catppuccin_mocha").unwrap(),
);
let mut output = Vec::new();
open_multi_themes_pre_tag(
&mut output,
Some("custom-pre"),
&themes,
Some("light-dark()"),
"--lumis",
)
.unwrap();
let html = String::from_utf8(output).unwrap();
assert_classes(
&html,
&[
"lumis",
"lumis-themes",
"custom-pre",
"catppuccin_latte",
"catppuccin_mocha",
],
);
assert_eq!(
attr_value(&html, "style"),
"color: light-dark(#4c4f69, #cdd6f4); background-color: light-dark(#eff1f5, #1e1e2e);"
);
}
}