jsslint-core 1.0.1

Core rule engine for the JSS style checker (spec 018 Rust port).
Documentation
//! Codegen: reads `specs/003-jss-rule-catalogue/catalogue.yaml` (the same
//! source of truth `tools/generate_catalogue_data.py` uses for the Python
//! `_catalogue_data.py`) and emits a compile-time Rust table of rule
//! metadata to `$OUT_DIR/catalogue_data.rs`, included by `src/catalogue.rs`.
//!
//! Keeping this a build-time step (rather than checked-in generated
//! source, as the Python side does) means catalogue.yaml never drifts
//! from the compiled binary — editing the YAML and rebuilding is the
//! only way to change rule metadata.

use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::PathBuf;

#[derive(serde::Deserialize)]
struct CatalogueDoc {
    categories: Vec<String>,
    rules: Vec<RawRule>,
}

#[derive(serde::Deserialize)]
struct RawRule {
    rule_id: String,
    category: String,
    severity: String,
    description: String,
    authority: String,
    authority_ref: String,
    explanation: String,
    inspects: Vec<String>,
    auto_fixable: bool,
    #[serde(default)]
    confidence: Option<String>,
    #[serde(default)]
    guide_section: Option<String>,
    #[serde(default)]
    guide_url: Option<String>,
}

#[derive(serde::Deserialize)]
struct LatexSpecsDoc {
    macros: BTreeMap<String, String>,
    verbatim_macros: Vec<String>,
    environments: BTreeMap<String, String>,
    verbatim_environments: Vec<String>,
    /// Already longest-first from the generator; preserve that order.
    specials: Vec<String>,
}

/// Walks upward from `start` looking for a directory that contains
/// `specs/003-jss-rule-catalogue/catalogue.yaml`. The canonical crate at
/// `<repo>/rust/jsslint-core` finds it two levels up; a vendored copy
/// (e.g. the R binding, which ships its own copy of the catalogue data
/// since it can't reach outside its package directory) finds it further
/// up, wherever that copy was placed. Bounded so a missing catalogue
/// fails fast instead of walking to the filesystem root.
fn find_repo_root(start: &std::path::Path) -> PathBuf {
    let marker = "specs/003-jss-rule-catalogue/catalogue.yaml";
    let mut dir = start;
    for _ in 0..8 {
        if dir.join(marker).is_file() {
            return dir.to_path_buf();
        }
        match dir.parent() {
            Some(parent) => dir = parent,
            None => break,
        }
    }
    panic!("could not find {marker} above {}", start.display());
}

fn main() {
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
    let repo_root = find_repo_root(&manifest_dir);
    let catalogue_path = repo_root.join("specs/003-jss-rule-catalogue/catalogue.yaml");
    let terms_path = repo_root.join("specs/003-jss-rule-catalogue/terms.json");

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

    // src/terms.rs embeds this via include_str!(concat!(env!("OUT_DIR"), ...))
    // rather than a relative path, since the distance from src/terms.rs to
    // specs/003-jss-rule-catalogue differs between the canonical crate
    // location and a vendored copy.
    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
    fs::copy(&terms_path, out_dir.join("terms.json"))
        .unwrap_or_else(|e| panic!("failed to copy {}: {e}", terms_path.display()));

    let yaml_src = fs::read_to_string(&catalogue_path)
        .unwrap_or_else(|e| panic!("failed to read {}: {e}", catalogue_path.display()));
    let doc: CatalogueDoc = serde_yaml::from_str(&yaml_src)
        .unwrap_or_else(|e| panic!("failed to parse {}: {e}", catalogue_path.display()));
    let categories = doc.categories;

    // Deterministic (alphabetical by rule_id) so codegen output is stable
    // across runs regardless of catalogue.yaml's on-disk rule order.
    let mut by_id: BTreeMap<String, RawRule> = BTreeMap::new();
    for rule in doc.rules {
        by_id.insert(rule.rule_id.clone(), rule);
    }

    let mut out = String::new();
    out.push_str(
        "// AUTO-GENERATED by build.rs from specs/003-jss-rule-catalogue/catalogue.yaml.\n",
    );
    out.push_str("// Do not edit by hand.\n\n");
    out.push_str("pub static RULES: &[RuleMeta] = &[\n");
    for (rule_id, rule) in &by_id {
        out.push_str("    RuleMeta {\n");
        out.push_str(&format!("        rule_id: {:?},\n", rule_id));
        out.push_str(&format!("        category: {:?},\n", rule.category));
        out.push_str(&format!(
            "        severity: {},\n",
            severity_variant(&rule.severity)
        ));
        out.push_str(&format!(
            "        message_template: {:?},\n",
            rule.description
        ));
        out.push_str(&format!("        authority: {:?},\n", rule.authority));
        out.push_str(&format!(
            "        authority_ref: {:?},\n",
            rule.authority_ref
        ));
        out.push_str(&format!("        explanation: {:?},\n", rule.explanation));
        out.push_str(&format!(
            "        inspects: &[{}],\n",
            rule.inspects
                .iter()
                .map(|s| format!("{:?}", s))
                .collect::<Vec<_>>()
                .join(", ")
        ));
        out.push_str(&format!("        auto_fixable: {},\n", rule.auto_fixable));
        out.push_str(&format!(
            "        confidence: {:?},\n",
            rule.confidence.as_deref().unwrap_or("high")
        ));
        out.push_str(&format!(
            "        guide_section: {:?},\n",
            rule.guide_section.as_deref().unwrap_or("")
        ));
        out.push_str(&format!(
            "        guide_url: {},\n",
            match &rule.guide_url {
                Some(url) => format!("Some({:?})", url),
                None => "None".to_string(),
            }
        ));
        out.push_str("    },\n");
    }
    out.push_str("];\n\n");

    out.push_str("// Rollout order, from catalogue.yaml's top-level `categories` field.\n");
    out.push_str("pub static CATEGORIES: &[&str] = &[\n");
    for cat in &categories {
        out.push_str(&format!("    {cat:?},\n"));
    }
    out.push_str("];\n");

    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
    fs::write(out_dir.join("catalogue_data.rs"), out).expect("write catalogue_data.rs");

    // terms.json is embedded verbatim via include_str! at the call site
    // (src/terms.rs) and parsed at first use with serde_json — small
    // enough that build-time codegen would be overkill.

    // --- LaTeX macro/environment argspecs (spec 018 Phase 1) ----------
    let specs_path = repo_root.join("specs/003-jss-rule-catalogue/latex-macro-specs.json");
    println!("cargo:rerun-if-changed={}", specs_path.display());
    let specs_src = fs::read_to_string(&specs_path)
        .unwrap_or_else(|e| panic!("failed to read {}: {e}", specs_path.display()));
    let specs: LatexSpecsDoc = serde_json::from_str(&specs_src)
        .unwrap_or_else(|e| panic!("failed to parse {}: {e}", specs_path.display()));

    let mut specs_out = String::new();
    specs_out.push_str(
        "// AUTO-GENERATED by build.rs from specs/003-jss-rule-catalogue/latex-macro-specs.json.\n",
    );
    specs_out.push_str("// Do not edit by hand.\n\n");
    specs_out.push_str("pub static MACROS: &[(&str, &str)] = &[\n");
    for (name, argspec) in &specs.macros {
        specs_out.push_str(&format!("    ({name:?}, {argspec:?}),\n"));
    }
    specs_out.push_str("];\n\n");
    specs_out.push_str("pub static VERBATIM_MACROS: &[&str] = &[\n");
    for name in &specs.verbatim_macros {
        specs_out.push_str(&format!("    {name:?},\n"));
    }
    specs_out.push_str("];\n\n");
    specs_out.push_str("pub static ENVIRONMENTS: &[(&str, &str)] = &[\n");
    for (name, argspec) in &specs.environments {
        specs_out.push_str(&format!("    ({name:?}, {argspec:?}),\n"));
    }
    specs_out.push_str("];\n\n");
    specs_out.push_str("pub static VERBATIM_ENVIRONMENTS: &[&str] = &[\n");
    for name in &specs.verbatim_environments {
        specs_out.push_str(&format!("    {name:?},\n"));
    }
    specs_out.push_str("];\n\n");
    specs_out
        .push_str("// Longest-first, so literal-prefix matching prefers \"---\" over \"--\".\n");
    specs_out.push_str("pub static SPECIALS: &[&str] = &[\n");
    for name in &specs.specials {
        specs_out.push_str(&format!("    {name:?},\n"));
    }
    specs_out.push_str("];\n");

    fs::write(out_dir.join("latex_specs_data.rs"), specs_out).expect("write latex_specs_data.rs");
}

fn severity_variant(s: &str) -> &'static str {
    match s {
        "error" => "Severity::Error",
        "warning" => "Severity::Warning",
        "info" => "Severity::Info",
        other => panic!("unknown severity {other:?} in catalogue.yaml"),
    }
}