oj_compiler 0.0.2

Fused per-file pipeline
Documentation
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Raphael Amorim

//! JSON module imports (Vite-compatible). `import data from './x.json'`:
//! default export is the parsed value; top-level object keys that are valid
//! JS identifiers also become named exports (`import { foo } from './x.json'`).
//! Raw JSON text is itself a valid JS expression, so it is emitted directly.

use crate::CompileError;

/// Top-level object keys usable as `export const <name>` (valid identifiers,
/// not reserved). Returns empty for non-objects.
fn named_keys(value: &serde_json::Value) -> Vec<String> {
    let Some(obj) = value.as_object() else { return Vec::new() };
    obj.keys().filter(|k| is_safe_export_name(k)).cloned().collect()
}

fn is_safe_export_name(name: &str) -> bool {
    const RESERVED: &[&str] = &[
        "default", "class", "const", "let", "var", "function", "return", "import", "export",
        "new", "delete", "void", "typeof", "in", "of", "do", "if", "else", "switch", "case",
        "for", "while", "break", "continue", "this", "super", "null", "true", "false", "enum",
        "await", "yield",
    ];
    if RESERVED.contains(&name) {
        return false;
    }
    let mut chars = name.chars();
    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$')
        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
}

fn parse(source: &str, url: &str) -> Result<serde_json::Value, CompileError> {
    serde_json::from_str(source).map_err(|e| CompileError::Parse {
        path: std::path::PathBuf::from(url),
        message: format!("invalid JSON: {e}"),
    })
}

/// Unbundled dev: a standalone ESM module.
pub fn to_esm(source: &str, url: &str) -> Result<String, CompileError> {
    let value = parse(source, url)?;
    let raw = source.trim();
    let mut out = format!("const __oj_json = {raw};\nexport default __oj_json;\n");
    for key in named_keys(&value) {
        out.push_str(&format!("export const {key} = __oj_json[{key:?}];\n"));
    }
    Ok(out)
}

/// Bundle mode: a registry-factory body (getters installed before the body).
pub fn to_factory_body(source: &str, url: &str) -> Result<String, CompileError> {
    let value = parse(source, url)?;
    let raw = source.trim();
    let mut getters = vec!["\"default\": () => __oj_json".to_string()];
    for key in named_keys(&value) {
        getters.push(format!("{key:?}: () => __oj_json[{key:?}]"));
    }
    Ok(format!(
        "var __oj_json = {raw};\n__oj_esm(__oj_exports, {{ {} }});\n",
        getters.join(", ")
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn esm_default_and_named_exports() {
        let out = to_esm(r#"{"name":"oj","version":2,"is-kebab":1}"#, "/x.json").unwrap();
        assert!(out.contains("export default __oj_json"));
        assert!(out.contains(r#"export const name = __oj_json["name"]"#), "{out}");
        assert!(out.contains("export const version ="), "{out}");
        // Non-identifier and reserved keys are not named exports.
        assert!(!out.contains("is-kebab ="), "kebab key must be skipped: {out}");
    }

    #[test]
    fn array_and_scalar_have_only_default() {
        let arr = to_esm("[1, 2, 3]", "/a.json").unwrap();
        assert!(arr.contains("const __oj_json = [1, 2, 3]"));
        assert_eq!(arr.matches("export ").count(), 1, "only default: {arr}");
    }

    #[test]
    fn reserved_key_default_is_skipped() {
        let out = to_esm(r#"{"default":1,"ok":2}"#, "/r.json").unwrap();
        assert!(!out.contains("export const default"), "{out}");
        assert!(out.contains("export const ok ="), "{out}");
    }

    #[test]
    fn factory_body_installs_getters() {
        let out = to_factory_body(r#"{"a":1}"#, "/f.json").unwrap();
        assert!(out.contains("var __oj_json = {\"a\":1}"), "{out}");
        assert!(out.contains(r#""default": () => __oj_json"#), "{out}");
        assert!(out.contains(r#""a": () => __oj_json["a"]"#), "{out}");
    }

    #[test]
    fn invalid_json_errors() {
        assert!(to_esm("{ not json", "/bad.json").is_err());
    }
}