zalo 0.3.3

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;
use crate::themes::selector::Parent;

/// 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,
    with_scope: bool,
) -> 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, with_scope);
        }
    }

    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,
    with_scope: bool,
) {
    let css_selector = if with_scope {
        let mut css_classes = scope_to_css_classes(rule.selector.target_scope, prefix, shortener);

        // Also include parent scopes in the selector to increase specificity and avoid collisions.
        for parent in &rule.selector.parent_scopes {
            let parent_scope = match parent {
                Parent::Anywhere(s) | Parent::Direct(s) => *s,
            };
            css_classes.extend(scope_to_css_classes(parent_scope, prefix, shortener));
        }

        css_classes.sort_by(|a, b| compare_css_classes(a, b));
        css_classes.dedup();

        css_classes
            .into_iter()
            .map(|class| format!(".{}", class))
            .collect::<Vec<String>>()
            .join("")
    } else {
        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_by(|a, b| compare_css_classes(a, b));
    css_classes.dedup();
    css_classes
}

fn compare_css_classes(a: &str, b: &str) -> std::cmp::Ordering {
    // Sort by:
    // 1. Length (shorter first)
    // 2. if lengths are equal, compare chars in the order: a-z, then A-Z, then underscore, then hyphen
    let len_ord = a.len().cmp(&b.len());
    if len_ord != std::cmp::Ordering::Equal {
        return len_ord;
    }

    let rank = |c: char| match c {
        'a'..='z' => c as u8 - b'a',
        'A'..='Z' => 26 + (c as u8 - b'A'),
        '_' => 52,
        '-' => 53,
        _ => 255,
    };

    a.chars().map(rank).cmp(b.chars().map(rank))
}

/// 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_compare_css_classes() {
        use std::cmp::Ordering;

        // Helper to check ordering
        fn check(a: &str, b: &str, expected: Ordering) {
            assert_eq!(
                compare_css_classes(a, b),
                expected,
                "Comparing '{}' and '{}'",
                a,
                b
            );
            // Symmetry check
            assert_eq!(
                compare_css_classes(b, a),
                expected.reverse(),
                "Symmetry: Comparing '{}' and '{}'",
                b,
                a
            );
        }

        // 1. Length (shorter first)
        check("a", "aa", Ordering::Less);
        check("abc", "ab", Ordering::Greater);
        check("", "a", Ordering::Less);

        // 2. Equal length
        // a-z
        check("a", "b", Ordering::Less);
        check("z", "a", Ordering::Greater);
        check("abc", "abd", Ordering::Less);

        // a-z vs A-Z (a-z comes first)
        check("z", "A", Ordering::Less); // z(25) < A(26)
        check("a", "A", Ordering::Less);

        // A-Z
        check("A", "B", Ordering::Less);
        check("Z", "A", Ordering::Greater);

        // A-Z vs _ (A-Z comes first)
        check("Z", "_", Ordering::Less); // Z(51) < _(52)
        check("A", "_", Ordering::Less);

        // _ vs - (_ comes first)
        check("_", "-", Ordering::Less); // _(52) < -(53)
        check("-", "_", Ordering::Greater);

        // Mixed strings of same length
        // "aa" vs "aA" -> 'a' < 'A'
        check("aa", "aA", Ordering::Less);
        // "a_" vs "a-" -> '_' < '-'
        check("a_", "a-", Ordering::Less);
        // "ab" vs "aa"
        check("ab", "aa", Ordering::Greater);
    }

    #[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, true));
    }
}