nerdle-proc-macro 1.0.0

The macro crate for the nerdle Nerd Font macro library
Documentation
use std::{
    collections::{BTreeMap, BTreeSet},
    env, fs,
    path::{Path, PathBuf},
};

use serde::Deserialize;

#[derive(Deserialize)]
struct GlyphRecord {
    char: String,
    code: String,
}

fn main() {
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("missing manifest dir"));
    let glyphs_path = manifest_dir.join("glyphs.json");

    println!("cargo:rerun-if-changed={}", glyphs_path.display());

    let glyphs = load_glyphs(&glyphs_path);
    validate_glyphs(&glyphs);
    let generated = render_lookup_source(&glyphs);
    let out_path = PathBuf::from(env::var("OUT_DIR").expect("missing OUT_DIR")).join("icons.rs");

    fs::write(out_path, generated).expect("failed to write generated icons source");
}

fn load_glyphs(path: &Path) -> BTreeMap<String, GlyphRecord> {
    let contents = fs::read_to_string(path).expect("failed to read glyphs.json");
    let json: serde_json::Value =
        serde_json::from_str(&contents).expect("failed to parse glyphs.json");
    let object = json
        .as_object()
        .expect("glyphs.json must contain a top-level object");

    object
        .iter()
        .filter_map(|(name, value)| {
            let parsed = serde_json::from_value::<GlyphRecord>(value.clone()).ok()?;
            Some((name.clone(), parsed))
        })
        .collect()
}

fn validate_glyphs(glyphs: &BTreeMap<String, GlyphRecord>) {
    let mut normalized_names = BTreeMap::<String, BTreeSet<String>>::new();

    for (name, glyph) in glyphs {
        let codepoint = parse_codepoint(name, &glyph.code);
        let expected_char = char::from_u32(codepoint)
            .unwrap_or_else(|| panic!("invalid Unicode scalar value for {name}: 0x{codepoint:X}"));
        let actual_char = parse_glyph_char(name, &glyph.char);

        assert_eq!(
            actual_char, expected_char,
            "glyph snapshot mismatch for {name}: char {:?} does not match code {}",
            glyph.char, glyph.code
        );

        let canonical_name = name.replace('_', "-");
        let alias_name = format!("nf-{canonical_name}");

        record_normalized_name(&mut normalized_names, name, &canonical_name);
        record_normalized_name(&mut normalized_names, name, &alias_name);
    }

    for (normalized, originals) in normalized_names {
        if originals.len() > 1 {
            panic!(
                "normalized glyph name collision for {normalized}: {}",
                originals.into_iter().collect::<Vec<_>>().join(", ")
            );
        }
    }
}

fn parse_codepoint(name: &str, code: &str) -> u32 {
    u32::from_str_radix(code, 16).unwrap_or_else(|_| panic!("invalid codepoint for {name}: {code}"))
}

fn parse_glyph_char(name: &str, glyph: &str) -> char {
    let mut chars = glyph.chars();
    let ch = chars
        .next()
        .unwrap_or_else(|| panic!("missing glyph character for {name}"));

    assert!(
        chars.next().is_none(),
        "glyph character for {name} must contain exactly one Unicode scalar value"
    );

    ch
}

fn record_normalized_name(
    normalized_names: &mut BTreeMap<String, BTreeSet<String>>,
    source_name: &str,
    candidate: &str,
) {
    let normalized = normalize_icon_name(candidate);
    normalized_names
        .entry(normalized)
        .or_default()
        .insert(source_name.to_string());
}

fn normalize_icon_name(raw: &str) -> String {
    let mut normalized = String::with_capacity(raw.len());
    let mut last_was_separator = false;

    for ch in raw.trim().chars() {
        if matches!(ch, ' ' | '_' | '-') {
            if !normalized.is_empty() && !last_was_separator {
                normalized.push('-');
            }
            last_was_separator = true;
            continue;
        }

        normalized.extend(ch.to_lowercase());
        last_was_separator = false;
    }

    while normalized.ends_with('-') {
        normalized.pop();
    }

    normalized
}

fn render_lookup_source(glyphs: &BTreeMap<String, GlyphRecord>) -> String {
    let mut output =
        String::from("fn lookup_icon(name: &str) -> Option<u32> {\n    match name {\n");

    for (name, glyph) in glyphs {
        let hyphen_name = name.replace('_', "-");
        let alias_name = format!("nf-{hyphen_name}");
        let codepoint = parse_codepoint(name, &glyph.code);

        output.push_str(&format!(
            "        {:?} => Some(0x{:x}),\n",
            hyphen_name, codepoint
        ));
        output.push_str(&format!(
            "        {:?} => Some(0x{:x}),\n",
            alias_name, codepoint
        ));
    }

    output.push_str("        _ => None,\n    }\n}\n");
    output
}