zalo 0.2.52

A code highlighter giving the same output as VSCode
Documentation
use std::fmt::Write;

use crate::customization::IdentifierShortener;
use crate::scope::{EMPTY_ATOM_NUMBER, MAX_ATOMS_IN_SCOPE, Scope, lock_global_scope_repo};
use crate::themes::compiled::CompiledTheme;

/// Generates the CSS content for a textmate theme
///
/// This is used with the HTML renderer, typically to switch highlighting scheme in light/dark
/// mode which is something that cannot be done inline.
pub fn generate_css(theme: &CompiledTheme, prefix: &str, shortener: IdentifierShortener) -> String {
    let mut css = String::new();

    // Add header comment
    writeln!(css, "/*").unwrap();
    writeln!(css, " * theme \"{}\" generated by zalo", theme.name).unwrap();
    writeln!(css, " */").unwrap();
    writeln!(css).unwrap();

    // // Generate base code class with default theme colors
    // writeln!(css, ".{prefix}code {{").unwrap();
    // writeln!(
    //     css,
    //     "  {}",
    //     theme.default_style.foreground.as_css_color_property()
    // )
    // .unwrap();
    // writeln!(
    //     css,
    //     "  {}",
    //     theme.default_style.background.as_css_bg_color_property()
    // )
    // .unwrap();
    // writeln!(css, "}}").unwrap();
    // writeln!(css).unwrap();

    // if let Some(ref highlight_bg) = theme.highlight_background_color {
    //     writeln!(css, ".{prefix}hl {{").unwrap();
    //     writeln!(css, "  {}", highlight_bg.as_css_bg_color_property()).unwrap();
    //     writeln!(css, "}}").unwrap();
    //     writeln!(css).unwrap();
    // }

    // // Generate line number color class if theme has one
    // if let Some(ref line_number_fg) = theme.line_number_foreground {
    //     writeln!(css, ".z-ln {{").unwrap();
    //     writeln!(css, "  {}", line_number_fg.as_css_color_property()).unwrap();
    //     writeln!(css, "}}").unwrap();
    //     writeln!(css).unwrap();
    // }

    // Generate CSS for each theme rule
    for rule in &theme.rules {
        if rule.style_modifier.has_properties() {
            generate_rule_css(&mut css, rule, prefix, shortener);
        }
    }

    css
}

/// Generate CSS rule for a single theme rule
fn generate_rule_css(
    css: &mut String,
    rule: &crate::themes::compiled::CompiledThemeRule,
    prefix: &str,
    shortener: IdentifierShortener,
) {
    let css_selector = scope_to_css_selector(rule.selector.target_scope, prefix, false, shortener);
    let original_len = css.len();
    let mut is_empty = true;

    write!(css, "{css_selector} {{").unwrap();

    if let Some(fg) = rule.style_modifier.foreground {
        write!(css, " {}", fg.as_css_color_property()).unwrap();
        is_empty = false;
    }
    if let Some(bg) = rule.style_modifier.background {
        write!(css, " {}", bg.as_css_bg_color_property()).unwrap();
        is_empty = false;
    }
    if let Some(font_style) = rule.style_modifier.font_style {
        let attrs = font_style.css_attributes();
        if !attrs.is_empty() {
            write!(css, " {}", attrs.join("")).unwrap();
            is_empty = false;
        }
    }
    if is_empty {
        // No properties were added, remove the rule
        css.truncate(original_len);
    } else {
        writeln!(css, " }}").unwrap();
    }
}

/// Convert a scope to CSS selector or class string with prefixed classes
/// e.g. "keyword.operator" -> ".g-keyword.g-operator" if as_class=false
/// and "g-keyword g-operator" if as_class=true
pub fn scope_to_css_selector(
    scope: Scope,
    prefix: &str,
    as_class: bool,
    shortener: IdentifierShortener,
) -> String {
    let css_classes = scope_to_css_classes(scope, prefix, shortener);
    if as_class {
        css_classes.join(" ")
    } else {
        css_classes
            .into_iter()
            .map(|class| format!(".{}", class))
            .collect::<Vec<String>>()
            .join("")
    }
}

pub fn scope_to_css_classes(
    scope: Scope,
    prefix: &str,
    shortener: IdentifierShortener,
) -> Vec<String> {
    let mut css_classes = Vec::new();
    let repo = lock_global_scope_repo();

    for i in 0..MAX_ATOMS_IN_SCOPE {
        let atom_number = scope.atom_at(i);

        match atom_number {
            0 => break, // No more atoms
            n if n == EMPTY_ATOM_NUMBER => {
                // Skip empty atoms - they shouldn't appear in CSS selectors
                continue;
            }
            n => {
                let atom_str = repo.atom_number_to_str(n);

                let class = escape_css_identifier(atom_str, prefix, shortener);

                css_classes.push(class);
            }
        }
    }

    css_classes.sort();
    css_classes.dedup();
    css_classes
}

/// Escape special characters in CSS identifiers, namely the class names
fn escape_css_identifier(identifier: &str, prefix: &str, shortener: IdentifierShortener) -> String {
    let identifier =
        identifier
            .chars()
            .fold(String::with_capacity(identifier.len()), |mut output, c| {
                if c.is_ascii_alphabetic()
                    || c == '-'
                    || c == '_'
                    || (!output.is_empty() && c.is_ascii_digit())
                {
                    output.push(c);
                } else {
                    // Escape special characters as hex
                    write!(output, "\\{:x} ", c as u32).unwrap();
                }
                output
            });

    shortener(&identifier, prefix)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::customization::shorten_identifier;
    use crate::scope::Scope;
    use crate::themes::RawTheme;
    use insta::assert_snapshot;

    #[test]
    fn test_escape_css_identifier() {
        assert_eq!(
            escape_css_identifier("keyword", "g-", shorten_identifier),
            "k"
        );
        assert_eq!(
            escape_css_identifier("meta-function", "g-", shorten_identifier),
            "g-meta-function"
        );
        assert_eq!(
            escape_css_identifier("git_gutter", "g-", shorten_identifier),
            "g-git_gutter"
        );
        // Special characters should be escaped
        assert_eq!(
            escape_css_identifier("c++", "g-", shorten_identifier),
            "g-c\\2b \\2b "
        );
    }

    #[test]
    fn test_scope_to_css_selector() {
        let scope = Scope::new("keyword.operator")[0];
        let selector = scope_to_css_selector(scope, "g-", false, shorten_identifier);
        assert_eq!(selector, ".k.o");

        let simple_scope = Scope::new("comment")[0];
        let simple_selector = scope_to_css_selector(simple_scope, "g-", false, shorten_identifier);
        assert_eq!(simple_selector, ".c");
    }

    #[test]
    fn test_scope_to_css_class() {
        let scope = Scope::new("keyword.operator")[0];
        let class = scope_to_css_selector(scope, "g-", true, shorten_identifier);
        assert_eq!(class, "k o");

        let simple_scope = Scope::new("comment")[0];
        let simple_class = scope_to_css_selector(simple_scope, "g-", true, shorten_identifier);
        assert_eq!(simple_class, "c");
    }

    #[test]
    fn can_generate_css_for_theme() {
        let theme = RawTheme::load_from_file(
            "grammars-themes/packages/tm-themes/themes/vitesse-black.json",
        )
        .unwrap()
        .compile()
        .unwrap();
        assert_snapshot!(generate_css(&theme, "g-", shorten_identifier));
    }
}