use std::collections::HashMap;
use std::ops::Range;
use serde::{Deserialize, Serialize};
use crate::renderers::html::HtmlEscaped;
use crate::scope::Scope;
use crate::themes::compiled::ThemeType;
use crate::themes::{Color, CompiledTheme, Style, ThemeVariant, scope_to_css_classes};
use crate::tokenizer::Token;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HighlightedText {
pub text: String,
pub style: ThemeVariant<Style>,
pub(crate) scopes: Vec<Scope>,
}
impl HighlightedText {
pub(crate) fn as_ansi(
&self,
theme: &ThemeVariant<&CompiledTheme>,
theme_type: Option<ThemeType>,
bg_color: Option<Color>,
f: &mut String,
) {
let s = self.text.as_str();
if self.scopes.is_empty() {
f.push_str(s);
return;
}
let (style, theme) = match (self.style, theme) {
(ThemeVariant::Single(style), ThemeVariant::Single(theme)) => (style, theme),
(
ThemeVariant::Dual {
dark: dark_style, ..
},
ThemeVariant::Dual {
dark: dark_theme, ..
},
) if theme_type == Some(ThemeType::Dark) => (dark_style, dark_theme),
(
ThemeVariant::Dual {
light: light_style, ..
},
ThemeVariant::Dual {
light: light_theme, ..
},
) if theme_type == Some(ThemeType::Light) => (light_style, light_theme),
_ => unreachable!(),
};
let default = &theme.default_style;
if style == *default {
f.push_str(s);
return;
}
f.push_str("\x1b[");
if style.foreground != default.foreground {
style.foreground.as_ansi_fg(f);
}
if style.background != default.background {
style.background.as_ansi_bg(f);
}
style.font_style.ansi_escapes(f);
f.push('m');
if let Some(bg) = bg_color {
f.push_str("\x1b[");
bg.as_ansi_bg(f);
f.push('m');
}
f.push_str(s);
f.push_str("\x1b[0m");
}
pub(crate) fn as_html(
&self,
theme: &ThemeVariant<&CompiledTheme>,
css_class_prefix: Option<&str>,
) -> String {
let escaped = HtmlEscaped(self.text.as_str());
if let Some(prefix) = css_class_prefix {
if self.scopes.is_empty() {
return format!("<span>{escaped}</span>");
}
let mut css_classes: Vec<String> = self
.scopes
.iter()
.flat_map(|scope| scope_to_css_classes(*scope, prefix))
.collect();
css_classes.sort();
css_classes.dedup();
return format!(
r#"<span class="{}">{escaped}</span>"#,
css_classes.join(" ").trim(),
);
}
match (&self.style, theme) {
(ThemeVariant::Single(style), ThemeVariant::Single(t)) => {
let default = &t.default_style;
if *style == *default {
return format!("<span>{escaped}</span>");
}
let mut css = String::with_capacity(30);
if style.foreground != default.foreground {
css.push_str(&style.foreground.as_css_color_property());
}
if style.background != default.background {
css.push_str(&style.background.as_css_bg_color_property());
}
for font_attr in style.font_style.css_attributes() {
css.push_str(font_attr);
}
format!(r#"<span style="{css}">{escaped}</span>"#)
}
(
ThemeVariant::Dual { light, dark },
ThemeVariant::Dual {
light: lt,
dark: dt,
},
) => {
let light_default = <.default_style;
let dark_default = &dt.default_style;
if *light == *light_default && *dark == *dark_default {
return format!("<span>{escaped}</span>");
}
let mut css = String::with_capacity(60);
if light.foreground != light_default.foreground
|| dark.foreground != dark_default.foreground
{
css.push_str(&Color::as_css_light_dark_color_property(
&light.foreground,
&dark.foreground,
));
}
if light.background != light_default.background
|| dark.background != dark_default.background
{
css.push_str(&Color::as_css_light_dark_bg_color_property(
&light.background,
&dark.background,
));
}
for font_attr in light.font_style.css_attributes() {
css.push_str(font_attr);
}
if css.is_empty() {
format!("<span>{escaped}</span>")
} else {
format!(r#"<span style="{css}">{escaped}</span>"#)
}
}
_ => unreachable!(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MergingOptions {
pub merge_whitespaces: bool,
pub merge_same_style_tokens: bool,
}
impl Default for MergingOptions {
fn default() -> Self {
Self {
merge_whitespaces: true,
merge_same_style_tokens: true,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct Highlighter<'r> {
themes: Vec<&'r CompiledTheme>, #[allow(clippy::type_complexity)]
cache: [HashMap<Vec<Scope>, (Style, Vec<Scope>)>; 2],
}
impl<'r> Highlighter<'r> {
pub(crate) fn new(theme: &'r CompiledTheme) -> Self {
Highlighter {
themes: vec![theme],
cache: [HashMap::new(), HashMap::new()],
}
}
pub(crate) fn new_dual(light_theme: &'r CompiledTheme, dark_theme: &'r CompiledTheme) -> Self {
Highlighter {
themes: vec![light_theme, dark_theme],
cache: [HashMap::new(), HashMap::new()],
}
}
fn match_scopes(&mut self, scopes: &[Scope]) -> (ThemeVariant<Style>, Vec<Scope>) {
match self.themes.len() {
1 => {
let (style, contributing) = self.match_scopes_for_theme(scopes, 0);
(ThemeVariant::Single(style), contributing)
}
2 => {
let (light_style, light_scopes) = self.match_scopes_for_theme(scopes, 0);
let (dark_style, dark_scopes) = self.match_scopes_for_theme(scopes, 1);
let mut contributing = light_scopes;
for scope in dark_scopes {
if !contributing.contains(&scope) {
contributing.push(scope);
}
}
(
ThemeVariant::Dual {
light: light_style,
dark: dark_style,
},
contributing,
)
}
_ => unreachable!("Highlighter supports only 1 or 2 themes"),
}
}
fn match_scopes_for_theme(
&mut self,
scopes: &[Scope],
theme_index: usize,
) -> (Style, Vec<Scope>) {
if let Some(cached) = self.cache[theme_index].get(scopes) {
return cached.clone();
}
let result = self.match_scopes_uncached_for_theme(scopes, theme_index);
self.cache[theme_index].insert(scopes.to_vec(), result.clone());
result
}
fn match_scopes_uncached_for_theme(
&self,
scopes: &[Scope],
theme_index: usize,
) -> (Style, Vec<Scope>) {
let theme = self.themes[theme_index];
let mut current_style = theme.default_style;
let mut fg_scope: Option<Scope> = None;
let mut bg_scope: Option<Scope> = None;
for i in 1..=scopes.len() {
let current_scope_path = &scopes[0..i];
for rule in &theme.rules {
if rule.selector.matches(current_scope_path) {
current_style = rule.style_modifier.apply_to(¤t_style);
if rule.style_modifier.foreground.is_some() {
fg_scope = Some(rule.selector.target_scope);
}
if rule.style_modifier.background.is_some() {
bg_scope = Some(rule.selector.target_scope);
}
}
}
}
let mut contributing_scopes = Vec::new();
if let Some(scope) = fg_scope {
contributing_scopes.push(scope);
}
if let Some(scope) = bg_scope
&& !contributing_scopes.contains(&scope)
{
contributing_scopes.push(scope);
}
(current_style, contributing_scopes)
}
pub fn highlight_tokens(
&mut self,
content: &str,
tokens: Vec<Vec<Token>>,
options: MergingOptions,
) -> Vec<Vec<HighlightedText>> {
let mut result = Vec::with_capacity(tokens.len());
let lines = content.split('\n').collect::<Vec<_>>();
for (line_tokens, line) in tokens.into_iter().zip(lines) {
if line_tokens.is_empty() {
result.push(Vec::new());
continue;
}
let mut line_result: Vec<(Range<usize>, ThemeVariant<Style>, Vec<Scope>)> = line_tokens
.into_iter()
.map(|x| {
let (style, contributing_scopes) = self.match_scopes(&x.scopes);
(x.span, style, contributing_scopes)
})
.collect();
if options.merge_whitespaces {
let num_tokens = line_result.len();
let mut merged: Vec<(Range<usize>, ThemeVariant<Style>, Vec<Scope>)> =
Vec::with_capacity(num_tokens);
let mut carry_on_range: Option<Range<usize>> = None;
for (idx, (span, theme_style, scopes)) in line_result.into_iter().enumerate() {
let could_merge = !theme_style.has_decoration();
let token_content = &line[span.clone()];
let is_whitespace_with_next = could_merge
&& token_content.chars().all(|c| c.is_whitespace())
&& idx + 1 < num_tokens;
if is_whitespace_with_next {
carry_on_range = Some(match carry_on_range {
Some(range) => range.start..span.end,
None => span.clone(),
});
} else {
if let Some(carried_range) = &carry_on_range {
if could_merge {
merged.push((carried_range.start..span.end, theme_style, scopes))
} else {
let ws_style = if self.themes.len() == 1 {
ThemeVariant::Single(Style::default())
} else {
ThemeVariant::Dual {
light: Style::default(),
dark: Style::default(),
}
};
merged.push((carried_range.clone(), ws_style, Vec::new()));
merged.push((span, theme_style, scopes));
}
carry_on_range = None;
} else {
merged.push((span, theme_style, scopes));
}
}
}
line_result = merged;
}
if options.merge_same_style_tokens && self.themes.len() == 1 {
let num_tokens = line_result.len();
let mut merged: Vec<(Range<usize>, ThemeVariant<Style>, Vec<Scope>)> =
Vec::with_capacity(num_tokens);
for (span, theme_style, scopes) in line_result {
if let Some((prev_span, prev_theme_style, prev_scopes)) = merged.last_mut() {
if &theme_style == prev_theme_style {
prev_span.end = span.end;
for scope in scopes {
if !prev_scopes.contains(&scope) {
prev_scopes.push(scope);
}
}
} else {
merged.push((span, theme_style, scopes));
}
} else {
merged.push((span, theme_style, scopes));
}
}
line_result = merged;
}
result.push(
line_result
.into_iter()
.map(|(span, style, scopes)| HighlightedText {
style,
text: line[span].to_string(),
scopes,
})
.collect(),
);
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scope::Scope;
use crate::themes::compiled::{CompiledThemeRule, StyleModifier, ThemeType};
use crate::themes::font_style::FontStyle;
use crate::themes::raw::{Colors, TokenColorRule, TokenColorSettings};
use crate::themes::selector::parse_selector;
use crate::themes::{Color, CompiledTheme, RawTheme};
use crate::tokenizer::Token;
use std::ops::Range;
use std::path::PathBuf;
fn scope(name: &str) -> Scope {
Scope::new(name)[0]
}
fn color(hex: &str) -> Color {
Color::from_hex(hex).unwrap()
}
fn token(start: usize, end: usize, scope_name: &str) -> Token {
Token {
span: Range { start, end },
scopes: vec![scope(scope_name)],
}
}
fn test_theme() -> CompiledTheme {
CompiledTheme {
name: "Test".to_string(),
theme_type: ThemeType::Dark,
default_style: Style {
foreground: color("#D4D4D4"),
background: color("#1E1E1E"),
font_style: FontStyle::default(),
},
line_number_foreground: None,
highlight_background_color: None,
rules: vec![
CompiledThemeRule {
selector: parse_selector("comment").unwrap(),
style_modifier: StyleModifier {
foreground: Some(color("#6A9955")),
background: None,
font_style: Some(FontStyle::ITALIC),
},
},
CompiledThemeRule {
selector: parse_selector("keyword").unwrap(),
style_modifier: StyleModifier {
foreground: Some(color("#569CD6")),
background: None,
font_style: Some(FontStyle::BOLD),
},
},
],
}
}
#[test]
fn test_match_scopes() {
let test_theme = test_theme();
let mut highlighter = Highlighter::new(&test_theme);
let (ThemeVariant::Single(comment_style), comment_scopes) =
highlighter.match_scopes(&[scope("comment")])
else {
unreachable!()
};
assert_eq!(comment_style.foreground, color("#6A9955"));
assert_eq!(comment_style.font_style, FontStyle::ITALIC);
assert!(!comment_scopes.is_empty());
let (ThemeVariant::Single(keyword_style), keyword_scopes) =
highlighter.match_scopes(&[scope("keyword")])
else {
unreachable!()
};
assert_eq!(keyword_style.foreground, color("#569CD6"));
assert_eq!(keyword_style.font_style, FontStyle::BOLD);
assert!(!keyword_scopes.is_empty());
let (unknown_style, unknown_scopes) = highlighter.match_scopes(&[scope("unknown")]);
assert_eq!(
unknown_style,
ThemeVariant::Single(highlighter.themes[0].default_style)
);
assert!(unknown_scopes.is_empty()); }
#[test]
fn test_highlight_tokens() {
let test_theme = test_theme();
let mut highlighter = Highlighter::new(&test_theme);
let tokens = vec![
vec![token(0, 2, "keyword"), token(3, 8, "unknown")],
vec![token(0, 2, "comment")],
];
let content = "if hello\n//";
let highlighted = highlighter.highlight_tokens(content, tokens, MergingOptions::default());
assert_eq!(highlighted.len(), 2);
assert_eq!(highlighted[0].len(), 2);
assert_eq!(highlighted[1].len(), 1);
assert_eq!(highlighted[0][0].text, "if");
let ThemeVariant::Single(s) = &highlighted[0][0].style else {
unreachable!()
};
assert_eq!(s.foreground, color("#569CD6"));
assert_eq!(highlighted[0][1].text, "hello");
let ThemeVariant::Single(s) = &highlighted[0][1].style else {
unreachable!()
};
assert_eq!(s.foreground, color("#D4D4D4"));
assert_eq!(highlighted[1][0].text, "//");
let ThemeVariant::Single(s) = &highlighted[1][0].style else {
unreachable!()
};
assert_eq!(s.foreground, color("#6A9955"));
}
#[test]
fn test_style_modifier_apply_to() {
let base = Style {
foreground: color("#FFFFFF"),
background: color("#000000"),
font_style: FontStyle::default(),
};
let modifier = StyleModifier {
foreground: Some(color("#FF0000")),
background: None,
font_style: Some(FontStyle::BOLD),
};
let result = modifier.apply_to(&base);
assert_eq!(result.foreground, color("#FF0000"));
assert_eq!(result.background, color("#000000")); assert_eq!(result.font_style, FontStyle::BOLD);
}
#[test]
fn test_theme_inheritance() {
let raw_theme = RawTheme {
name: "Inheritance Test".to_string(),
kind: Some("dark".to_string()),
colors: Colors {
foreground: "#D4D4D4".to_string(),
background: "#1E1E1E".to_string(),
highlight_background: None,
line_number_foreground: None,
},
token_colors: vec![
TokenColorRule {
scope: vec!["constant".to_string()],
settings: TokenColorSettings {
foreground: Some("#300000".to_string()),
background: None,
font_style: Some("italic".to_string()),
},
},
TokenColorRule {
scope: vec!["constant.numeric".to_string()],
settings: TokenColorSettings {
foreground: Some("#400000".to_string()),
background: None,
font_style: None, },
},
TokenColorRule {
scope: vec!["constant.numeric.hex".to_string()],
settings: TokenColorSettings {
foreground: None, background: None,
font_style: Some("bold".to_string()),
},
},
],
};
let inheritance_theme = CompiledTheme::from_raw_theme(raw_theme).unwrap();
let mut highlighter = Highlighter::new(&inheritance_theme);
let (ThemeVariant::Single(style), _scopes) = highlighter.match_scopes(&[scope("constant")])
else {
unreachable!()
};
assert_eq!(style.foreground, color("#300000"));
assert_eq!(style.font_style, FontStyle::ITALIC);
let (ThemeVariant::Single(style), _scopes) =
highlighter.match_scopes(&[scope("constant"), scope("constant.numeric")])
else {
unreachable!()
};
assert_eq!(style.foreground, color("#400000")); assert_eq!(style.font_style, FontStyle::ITALIC);
let (ThemeVariant::Single(style), _scopes) = highlighter.match_scopes(&[
scope("constant"),
scope("constant.numeric"),
scope("constant.numeric.hex"),
]) else {
unreachable!()
};
assert_eq!(style.foreground, color("#400000")); assert_eq!(style.font_style, FontStyle::BOLD); }
#[test]
fn test_real_world_theme_inheritance() {
let theme_path =
PathBuf::from("grammars-themes/packages/tm-themes/themes/vitesse-black.json");
let raw_theme = RawTheme::load_from_file(theme_path).unwrap();
let compiled_theme = CompiledTheme::from_raw_theme(raw_theme).unwrap();
let mut highlighter = Highlighter::new(&compiled_theme);
let token1_scopes = [
scope("text.aspnetcorerazor"),
scope("meta.element.structure.svg.html"),
scope("meta.element.object.svg.foreignObject.html"),
scope("meta.element.other.invalid.html"),
scope("meta.tag.other.invalid.start.html"),
scope("punctuation.definition.tag.begin.html"),
];
let (style1, _) = highlighter.match_scopes(&token1_scopes);
let token2_scopes = [
scope("text.aspnetcorerazor"),
scope("meta.element.structure.svg.html"),
scope("meta.element.object.svg.foreignObject.html"),
scope("meta.element.other.invalid.html"),
scope("meta.tag.other.invalid.start.html"),
scope("entity.name.tag.html"),
scope("invalid.illegal.unrecognized-tag.html"),
];
let (style2, _) = highlighter.match_scopes(&token2_scopes);
let token3_scopes = [
scope("text.aspnetcorerazor"),
scope("meta.element.structure.svg.html"),
scope("meta.element.object.svg.foreignObject.html"),
scope("meta.element.other.invalid.html"),
scope("meta.tag.other.invalid.start.html"),
scope("punctuation.definition.tag.end.html"),
];
let (style3, _) = highlighter.match_scopes(&token3_scopes);
assert_ne!(style1, ThemeVariant::Single(compiled_theme.default_style));
assert_ne!(style2, ThemeVariant::Single(compiled_theme.default_style));
assert_ne!(style3, ThemeVariant::Single(compiled_theme.default_style));
assert_ne!(style1, style2);
assert_ne!(style2, style3);
let ThemeVariant::Single(s1) = &style1 else {
unreachable!()
};
let ThemeVariant::Single(s2) = &style2 else {
unreachable!()
};
let ThemeVariant::Single(s3) = &style3 else {
unreachable!()
};
assert_ne!(s1.foreground, Color::BLACK);
assert_ne!(s2.foreground, Color::BLACK);
assert_ne!(s3.foreground, Color::BLACK);
let expected_pink = Color {
r: 253,
g: 174,
b: 183,
a: 255,
};
assert_eq!(
s2.foreground, expected_pink,
"Token 'p' should get pink color #FDAEB7 from invalid.illegal rule, got {:?}",
s2.foreground
);
println!("Token '<' style: {:?}", style1);
println!("Token 'p' style: {:?}", style2);
println!("Token '>' style: {:?}", style3);
}
#[test]
fn test_as_html_empty() {
let test_theme = test_theme();
let ht = HighlightedText {
text: "hello".to_string(),
style: ThemeVariant::Single(test_theme.default_style),
scopes: Vec::new(),
};
let res = ht.as_html(&ThemeVariant::Single(&test_theme), None);
insta::assert_snapshot!(res, @"<span>hello</span>");
}
#[test]
fn test_as_html_content_escape() {
let test_theme = test_theme();
let ht = HighlightedText {
text: "<script></script>".to_string(),
style: ThemeVariant::Single(test_theme.default_style),
scopes: Vec::new(),
};
let res = ht.as_html(&ThemeVariant::Single(&test_theme), None);
insta::assert_snapshot!(res, @"<span><script></script></span>");
}
#[test]
fn test_as_html_hex_fg_diff() {
let test_theme = test_theme();
let custom_style = Style {
foreground: color("#FFFF00"),
background: test_theme.default_style.background,
font_style: test_theme.default_style.font_style,
};
let ht = HighlightedText {
text: "hello".to_string(),
style: ThemeVariant::Single(custom_style),
scopes: Vec::new(),
};
let res = ht.as_html(&ThemeVariant::Single(&test_theme), None);
insta::assert_snapshot!(res, @r#"<span style="color: #FFFF00;">hello</span>"#);
}
#[test]
fn test_as_html_hex_bg_diff() {
let test_theme = test_theme();
let custom_style = Style {
foreground: test_theme.default_style.foreground,
background: color("#FFFF00"),
font_style: test_theme.default_style.font_style,
};
let ht = HighlightedText {
text: "hello".to_string(),
style: ThemeVariant::Single(custom_style),
scopes: Vec::new(),
};
let res = ht.as_html(&ThemeVariant::Single(&test_theme), None);
insta::assert_snapshot!(res, @r#"<span style="background-color: #FFFF00;">hello</span>"#);
}
#[test]
fn test_as_html_hex_fontstyle_diff() {
let test_theme = test_theme();
let custom_style = Style {
foreground: test_theme.default_style.foreground,
background: test_theme.default_style.background,
font_style: FontStyle::ITALIC,
};
let ht = HighlightedText {
text: "hello".to_string(),
style: ThemeVariant::Single(custom_style),
scopes: Vec::new(),
};
let res = ht.as_html(&ThemeVariant::Single(&test_theme), None);
insta::assert_snapshot!(res, @r#"<span style="font-style: italic;">hello</span>"#);
}
#[test]
fn test_as_html_hex_completely_different() {
let test_theme = test_theme();
let custom_style = Style {
foreground: color("#FFFF00"),
background: color("#FFFF00"),
font_style: FontStyle::ITALIC,
};
let ht = HighlightedText {
text: "hello".to_string(),
style: ThemeVariant::Single(custom_style),
scopes: Vec::new(),
};
let res = ht.as_html(&ThemeVariant::Single(&test_theme), None);
insta::assert_snapshot!(res, @r#"<span style="color: #FFFF00;background-color: #FFFF00;font-style: italic;">hello</span>"#);
}
#[test]
fn test_as_html_dual_both_default() {
let light = test_theme();
let dark = test_theme();
let ht = HighlightedText {
text: "hello".to_string(),
style: ThemeVariant::Dual {
light: light.default_style,
dark: dark.default_style,
},
scopes: Vec::new(),
};
let res = ht.as_html(
&ThemeVariant::Dual {
light: &light,
dark: &dark,
},
None,
);
insta::assert_snapshot!(res, @"<span>hello</span>");
}
#[test]
fn test_as_html_dual_fg_differs() {
let light = test_theme();
let dark = test_theme();
let ht = HighlightedText {
text: "hello".to_string(),
style: ThemeVariant::Dual {
light: Style {
foreground: color("#FF0000"),
..light.default_style
},
dark: Style {
foreground: color("#00FF00"),
..dark.default_style
},
},
scopes: Vec::new(),
};
let res = ht.as_html(
&ThemeVariant::Dual {
light: &light,
dark: &dark,
},
None,
);
insta::assert_snapshot!(res, @r#"<span style="color: light-dark(#FF0000, #00FF00);">hello</span>"#);
}
#[test]
fn test_as_html_dual_bg_differs() {
let light = test_theme();
let dark = test_theme();
let ht = HighlightedText {
text: "hello".to_string(),
style: ThemeVariant::Dual {
light: Style {
background: color("#FFFFFF"),
..light.default_style
},
dark: Style {
background: color("#000000"),
..dark.default_style
},
},
scopes: Vec::new(),
};
let res = ht.as_html(
&ThemeVariant::Dual {
light: &light,
dark: &dark,
},
None,
);
insta::assert_snapshot!(res, @r#"<span style="background-color: light-dark(#FFFFFF, #000000);">hello</span>"#);
}
#[test]
fn test_as_html_dual_both_differ() {
let light = test_theme();
let dark = test_theme();
let ht = HighlightedText {
text: "hello".to_string(),
style: ThemeVariant::Dual {
light: Style {
foreground: color("#FF0000"),
background: color("#FFFFFF"),
font_style: FontStyle::BOLD,
},
dark: Style {
foreground: color("#00FF00"),
background: color("#000000"),
font_style: FontStyle::BOLD,
},
},
scopes: Vec::new(),
};
let res = ht.as_html(
&ThemeVariant::Dual {
light: &light,
dark: &dark,
},
None,
);
insta::assert_snapshot!(res, @r#"<span style="color: light-dark(#FF0000, #00FF00);background-color: light-dark(#FFFFFF, #000000);font-weight: bold;">hello</span>"#);
}
#[test]
fn test_as_html_with_css_classes() {
let test_theme = test_theme();
let ht = HighlightedText {
text: "hello".to_string(),
style: ThemeVariant::Single(test_theme.default_style),
scopes: vec![scope("keyword"), scope("keyword.control")],
};
let res = ht.as_html(&ThemeVariant::Single(&test_theme), Some("g-"));
insta::assert_snapshot!(res, @r#"<span class="G k">hello</span>"#);
}
}