jsonforge 0.1.0

A Rust procedural macro for generating JSON schema validators from Rust types
Documentation
mod codegen;
mod error;
mod input;
mod rename;
mod schema;

use std::path::PathBuf;

use proc_macro::TokenStream;
use syn::parse_macro_input;

use crate::{
    error::ForgeError,
    input::{AttrInput, MacroInput},
    schema::{apply_rename, infer_top_level},
};

/// Generate Rust data structures from a JSON file on disk.
///
/// # Usage
///
/// ```rust,ignore
/// json_forge! {
///     path     = "relative/path/to/data.json",  // relative to CARGO_MANIFEST_DIR
///     name     = "MyEntry",                      // name for the generated struct
///     vis      = pub,                            // struct visibility (default: pub)
///     data_vis = pub(crate),                     // data visibility  (default: same as vis)
///     embedded = false,                          // override embedded mode
/// }
/// ```
///
/// ## Visibility
///
/// Both `vis` and `data_vis` accept `pub`, `pub(crate)`, `pub(super)`, `pub(self)`, or
/// `private` (no visibility modifier).  When `data_vis` is omitted it defaults to `vis`.
///
/// ## Embedded mode
///
/// Without the `embedded` feature (or `embedded = false`), this emits:
/// - A struct `MyEntry { … }` with owned field types (`String`, `Vec<T>`, etc.).
/// - A `const MY_ENTRY_JSON: &str` containing the raw JSON via `include_str!`.
///
/// With the `embedded` feature enabled (or `embedded = true`), this emits:
/// - A struct `MyEntry { … }` with `&'static str` / `&'static [T]` fields.
/// - A `static MY_ENTRY: ::phf::Map<&'static str, MyEntry>` (for top-level objects)
///   or a fixed-length array static (for top-level arrays).
///
/// `embedded = true` requires the `embedded` Cargo feature; `embedded = false` forces
/// runtime mode even when the feature is active.
///
/// ## Consumer note for embedded mode
///
/// The generated code references `::phf::Map`.  Add `phf = "0.13"` to the dependencies
/// of the crate that invokes the macro.
#[proc_macro]
pub fn json_forge(input: TokenStream) -> TokenStream {
    let MacroInput {
        path,
        name,
        vis,
        data_vis,
        embedded,
        rename_all,
    } = parse_macro_input!(input as MacroInput);

    // Resolve the data visibility: falls back to the struct visibility.
    let data_vis = data_vis.as_ref().unwrap_or(&vis);

    // When the feature is off, `embedded = true` is a hard error at the call site.
    #[cfg(not(feature = "embedded"))]
    if embedded == Some(true) {
        return ForgeError::call_site(
            "`embedded = true` requires the `embedded` Cargo feature to be enabled",
        )
        .to_compile_error()
        .into();
    }

    let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") {
        Ok(v) => PathBuf::from(v),
        Err(_) => {
            return ForgeError::call_site("CARGO_MANIFEST_DIR env var is not set")
                .to_compile_error()
                .into();
        },
    };

    let path_str = path.value();
    let abs_path = manifest_dir.join(&path_str);

    let json_text = match std::fs::read_to_string(&abs_path) {
        Ok(t) => t,
        Err(e) => {
            return ForgeError::spanned(
                path.span(),
                format!("failed to read `{}`: {e}", abs_path.display()),
            )
            .to_compile_error()
            .into();
        },
    };

    let root: serde_json::Value = match serde_json::from_str(&json_text) {
        Ok(v) => v,
        Err(e) => {
            return ForgeError::spanned(path.span(), format!("JSON parse error: {e}"))
                .to_compile_error()
                .into();
        },
    };

    let top_level = match infer_top_level(&root) {
        Ok(t) => t,
        Err(e) => return e.to_compile_error().into(),
    };
    let top_level = match rename_all {
        Some(rule) => apply_rename(top_level, rule),
        None => top_level,
    };

    // Route to the appropriate codegen backend.
    // Use embedded when: the feature is active AND the user has not overridden to `false`.
    #[cfg(feature = "embedded")]
    if embedded != Some(false) {
        return match codegen::embedded::generate(&vis, data_vis, &name, &top_level, &root) {
            Ok(ts) => ts.into(),
            Err(e) => e.to_compile_error().into(),
        };
    }

    let _ = embedded; // In non-embedded builds only `embedded = true` was validated above.
    codegen::runtime::generate(&vis, data_vis, &name, &top_level, &path_str).into()
}

/// Validate a user-defined struct against a JSON file and embed its data.
///
/// Attach this attribute to a struct that already derives `serde::Deserialize`.
/// The macro reads the JSON file at compile time, verifies every value in it
/// matches the declared field types, and emits a companion static data item.
///
/// # Usage
///
/// ```rust,ignore
/// #[json_forge(path = "data/content_types.json")]
/// #[derive(Clone, Copy)]
/// pub struct ContentTypeEntry {
///     pub mime_type: Option<&'static str>,
///     pub extensions: &'static [&'static str],
///     pub is_text: bool,
/// }
/// ```
///
/// ## Parameters
///
/// | Key | Description |
/// |-----|-------------|
/// | `path` | JSON file path relative to `CARGO_MANIFEST_DIR` (required) |
/// | `data_vis` | Visibility of the emitted static/const (default: same as the struct) |
/// | `embedded` | `true`/`false` override; defaults to the `embedded` feature flag |
///
/// ## Generated items
///
/// The struct is re-emitted verbatim.  A companion data item is appended:
///
/// | JSON shape | Embedded | Item |
/// |------------|----------|------|
/// | Object of objects | yes | `static NAME: ::phf::Map<&str, Struct>` |
/// | Array | yes | `static NAME_DATA: [Struct; N]` |
/// | Single object | yes | `static NAME: Struct` |
/// | Any | no | `const NAME_JSON: &'static str` (inlined JSON text) |
#[proc_macro_attribute]
pub fn json_forge_typed(attr: TokenStream, item: TokenStream) -> TokenStream {
    let attr_input = match syn::parse::<AttrInput>(attr) {
        Ok(a) => a,
        Err(e) => return e.to_compile_error().into(),
    };
    let struct_item = match syn::parse::<syn::ItemStruct>(item) {
        Ok(s) => s,
        Err(e) => return e.to_compile_error().into(),
    };

    let AttrInput {
        path,
        data_vis,
        embedded,
        rename_all,
    } = attr_input;

    // Resolve data_vis: fall back to the struct's own visibility.
    let struct_vis = &struct_item.vis;
    let data_vis = data_vis.as_ref().unwrap_or(struct_vis);

    // Embedded mode validation (same rules as the function-like macro).
    #[cfg(not(feature = "embedded"))]
    if embedded == Some(true) {
        return ForgeError::call_site(
            "`embedded = true` requires the `embedded` Cargo feature to be enabled",
        )
        .to_compile_error()
        .into();
    }

    let manifest_dir = match std::env::var("CARGO_MANIFEST_DIR") {
        Ok(v) => PathBuf::from(v),
        Err(_) => {
            return ForgeError::call_site("CARGO_MANIFEST_DIR env var is not set")
                .to_compile_error()
                .into();
        },
    };

    let path_str = path.value();
    let abs_path = manifest_dir.join(&path_str);

    let json_text = match std::fs::read_to_string(&abs_path) {
        Ok(t) => t,
        Err(e) => {
            return ForgeError::spanned(
                path.span(),
                format!("failed to read `{}`: {e}", abs_path.display()),
            )
            .to_compile_error()
            .into();
        },
    };

    let root: serde_json::Value = match serde_json::from_str(&json_text) {
        Ok(v) => v,
        Err(e) => {
            return ForgeError::spanned(path.span(), format!("JSON parse error: {e}"))
                .to_compile_error()
                .into();
        },
    };

    // Determine effective embedded mode.
    #[cfg(feature = "embedded")]
    let use_embedded = embedded != Some(false);
    #[cfg(not(feature = "embedded"))]
    let use_embedded = false;

    match codegen::user_defined::generate_with_text(
        &struct_item,
        data_vis,
        &root,
        &json_text,
        use_embedded,
        rename_all,
    ) {
        Ok(ts) => ts.into(),
        Err(e) => e.to_compile_error().into(),
    }
}