jsonforge 0.1.0

A Rust procedural macro for generating JSON schema validators from Rust types
Documentation
use proc_macro2::TokenStream;
use quote::quote;
use serde_json::Value;
use syn::{Fields, Ident, ItemStruct, Visibility};

use crate::error::{ForgeError, ForgeResult};
use crate::schema::user_type::{FieldType, classify, validate};

use crate::rename::RenameRule;

use super::util::{make_ident, to_screaming_snake};

#[cfg(feature = "embedded")]
use proc_macro2::Literal;

// ---------------------------------------------------------------------------
// Field introspection helpers
// ---------------------------------------------------------------------------

/// A parsed representation of one struct field.
#[cfg_attr(not(feature = "embedded"), allow(dead_code))]
struct FieldInfo<'a> {
    ident: &'a Ident,
    json_key: String,
    ft: FieldType,
}

fn named_fields(item: &ItemStruct, rename: Option<RenameRule>) -> ForgeResult<Vec<FieldInfo<'_>>> {
    let named = match &item.fields {
        Fields::Named(f) => f,
        _ => {
            return Err(ForgeError::call_site(
                "`#[json_forge]` only supports structs with named fields",
            ));
        },
    };

    named
        .named
        .iter()
        .map(|f| {
            let ident = f.ident.as_ref().unwrap();
            let ft = classify(&f.ty);
            if ft == FieldType::Opaque {
                return Err(ForgeError::call_site(format!(
                    "field `{}` has a type that `json_forge` cannot map to a JSON value; \
                     supported types: `bool`, integer primitives, `f32`/`f64`, `String`, \
                     `&'static str`, `Vec<T>`, `&'static [T]`, `Option<T>`",
                    ident
                )));
            }
            let json_key = match rename {
                Some(rule) => rule.rust_name_to_json_key(&ident.to_string()),
                None => ident.to_string(),
            };
            Ok(FieldInfo {
                ident,
                json_key,
                ft,
            })
        })
        .collect()
}

// ---------------------------------------------------------------------------
// Validate + build a struct literal from one JSON object (embedded only)
// ---------------------------------------------------------------------------

#[cfg(feature = "embedded")]
fn validate_and_build_literal(
    name: &Ident,
    fields: &[FieldInfo<'_>],
    obj: &serde_json::Map<String, Value>,
    entry_ctx: &str,
) -> ForgeResult<TokenStream> {
    let mut field_tokens = Vec::with_capacity(fields.len());

    for f in fields {
        let json_val = obj.get(&f.json_key).unwrap_or(&Value::Null);
        let ctx = format!("{entry_ctx}.{}", f.json_key);

        // If the field is non-optional and the key is missing, error.
        if obj.get(&f.json_key).is_none() && !matches!(f.ft, FieldType::Optional(_)) {
            return Err(ForgeError::call_site(format!(
                "{ctx}: key `{}` is missing in the JSON and the field is not `Option<T>`",
                f.json_key
            )));
        }

        validate(json_val, &f.ft, &ctx)?;

        let val_ts = value_to_tokens(json_val, &f.ft)?;
        let ident = f.ident;
        field_tokens.push(quote! { #ident: #val_ts });
    }

    Ok(quote! { #name { #(#field_tokens,)* } })
}

// ---------------------------------------------------------------------------
// Value → token literal
// ---------------------------------------------------------------------------
/// Render any JSON value as a Rust literal / expression matching `ty`.
#[cfg(feature = "embedded")]
fn value_to_tokens(val: &Value, ft: &FieldType) -> ForgeResult<TokenStream> {
    match (val, ft) {
        (Value::Null, FieldType::Optional(_)) => Ok(quote! { ::core::option::Option::None }),
        (_, FieldType::Optional(inner)) => {
            let inner_ts = value_to_tokens(val, inner)?;
            Ok(quote! { ::core::option::Option::Some(#inner_ts) })
        },
        (Value::String(s), FieldType::StaticStr) => Ok(quote! { #s }),
        (Value::String(s), FieldType::OwnedString) => Ok(quote! { #s.to_owned() }),
        (Value::Bool(b), FieldType::Bool) => Ok(quote! { #b }),
        (Value::Number(n), FieldType::Int) => {
            let v = n.as_i64().unwrap_or_default();
            Ok(quote! { #v })
        },
        (Value::Number(n), FieldType::Float) => {
            let v = n.as_f64().unwrap_or_default();
            Ok(quote! { #v })
        },
        (Value::Array(arr), FieldType::StaticSlice(inner)) => {
            let items: Vec<TokenStream> = arr
                .iter()
                .map(|v| value_to_tokens(v, inner))
                .collect::<ForgeResult<_>>()?;
            Ok(quote! { &[#(#items),*] })
        },
        (Value::Array(arr), FieldType::OwnedVec(inner)) => {
            let items: Vec<TokenStream> = arr
                .iter()
                .map(|v| value_to_tokens(v, inner))
                .collect::<ForgeResult<_>>()?;
            Ok(quote! { vec![#(#items),*] })
        },
        _ => Err(ForgeError::call_site(format!(
            "cannot convert JSON `{val}` to the declared type"
        ))),
    }
}

// ---------------------------------------------------------------------------
// Embedded: PHF map
// ---------------------------------------------------------------------------

#[cfg(feature = "embedded")]
fn emit_phf_map(
    name: &Ident,
    data_vis: &Visibility,
    fields: &[FieldInfo<'_>],
    root: &Value,
) -> ForgeResult<TokenStream> {
    use phf_generator::generate_hash;

    let obj = root.as_object().unwrap();
    let keys: Vec<&str> = obj.keys().map(String::as_str).collect();
    let values: Vec<&Value> = obj.values().collect();

    let hash_state = generate_hash(&keys);
    let phf_key = Literal::u64_suffixed(hash_state.key);

    let disps: Vec<TokenStream> = hash_state
        .disps
        .iter()
        .map(|&(d1, d2)| {
            let d1 = Literal::u32_suffixed(d1);
            let d2 = Literal::u32_suffixed(d2);
            quote! { (#d1, #d2) }
        })
        .collect();

    let entries: Vec<TokenStream> = hash_state
        .map
        .iter()
        .map(|&idx| {
            let k = keys[idx];
            let v = values[idx];
            let obj_map = v.as_object().ok_or_else(|| {
                ForgeError::call_site(format!("entry `{k}` is not a JSON object"))
            })?;
            let lit = validate_and_build_literal(name, fields, obj_map, k)?;
            Ok(quote! { (#k, #lit) })
        })
        .collect::<ForgeResult<_>>()?;

    let static_name = make_ident(&to_screaming_snake(&name.to_string()));

    Ok(quote! {
        #data_vis static #static_name: ::phf::Map<&'static str, #name> = ::phf::Map {
            key: #phf_key,
            disps: &[#(#disps),*],
            entries: &[#(#entries),*],
        };
    })
}

// ---------------------------------------------------------------------------
// Embedded: fixed-length array
// ---------------------------------------------------------------------------

#[cfg(feature = "embedded")]
fn emit_static_array(
    name: &Ident,
    data_vis: &Visibility,
    fields: &[FieldInfo<'_>],
    root: &Value,
) -> ForgeResult<TokenStream> {
    let arr = root.as_array().unwrap();
    let len = arr.len();

    let items: Vec<TokenStream> = arr
        .iter()
        .enumerate()
        .map(|(i, v)| {
            let obj = v
                .as_object()
                .ok_or_else(|| ForgeError::call_site(format!("array[{i}] is not a JSON object")))?;
            validate_and_build_literal(name, fields, obj, &format!("[{i}]"))
        })
        .collect::<ForgeResult<_>>()?;

    let static_name = make_ident(&format!("{}_DATA", to_screaming_snake(&name.to_string())));

    Ok(quote! {
        #data_vis static #static_name: [#name; #len] = [#(#items),*];
    })
}

// ---------------------------------------------------------------------------
// Embedded: single static
// ---------------------------------------------------------------------------

#[cfg(feature = "embedded")]
fn emit_single_static(
    name: &Ident,
    data_vis: &Visibility,
    fields: &[FieldInfo<'_>],
    root: &Value,
) -> ForgeResult<TokenStream> {
    let obj = root.as_object().unwrap();
    let instance = validate_and_build_literal(name, fields, obj, "<root>")?;
    let static_name = make_ident(&to_screaming_snake(&name.to_string()));

    Ok(quote! {
        #data_vis static #static_name: #name = #instance;
    })
}

// ---------------------------------------------------------------------------
// Runtime: inline const (the JSON text is inlined as a &'static str)
// ---------------------------------------------------------------------------

// The attribute macro has already read and validated the JSON text, so we
// inline it as a string literal rather than using include_str!. This gives
// compile-time verification with a self-contained output.

fn inline_json_const(name: &Ident, data_vis: &Visibility, json_text: &str) -> TokenStream {
    let const_name = make_ident(&format!("{}_JSON", to_screaming_snake(&name.to_string())));
    quote! {
        #data_vis const #const_name: &'static str = #json_text;
    }
}

/// Validate the JSON data against the user's struct and emit the companion data item.
///
/// `use_embedded` is always `false` when the `embedded` Cargo feature is disabled;
/// `lib.rs` enforces this before calling here.
pub fn generate_with_text(
    struct_item: &ItemStruct,
    data_vis: &Visibility,
    root: &Value,
    json_text: &str,
    use_embedded: bool,
    rename_all: Option<RenameRule>,
) -> ForgeResult<TokenStream> {
    let fields = named_fields(struct_item, rename_all)?;
    let name = &struct_item.ident;
    // In non-embedded builds use_embedded is always false; silence unused warning.
    #[cfg(not(feature = "embedded"))]
    let _ = use_embedded;

    let data_ts: TokenStream = match root {
        // ── Object-of-objects → PHF map / JSON const ──────────────────────
        Value::Object(map) if map.values().all(|v| v.is_object()) && map.len() > 1 => {
            for (k, v) in map {
                let obj = v.as_object().unwrap();
                for f in &fields {
                    let jv = obj.get(&f.json_key).unwrap_or(&Value::Null);
                    if obj.get(&f.json_key).is_none() && !matches!(f.ft, FieldType::Optional(_)) {
                        return Err(ForgeError::call_site(format!(
                            "entry `{k}`: key `{}` is missing and the field is not `Option<T>`",
                            f.json_key
                        )));
                    }
                    validate(jv, &f.ft, &format!("{k}.{}", f.json_key))?;
                }
            }
            #[cfg(feature = "embedded")]
            if use_embedded {
                let embed_ts = emit_phf_map(name, data_vis, &fields, root)?;
                return Ok(quote! { #struct_item #embed_ts });
            }
            inline_json_const(name, data_vis, json_text)
        },
        // ── Top-level array → fixed array / JSON const ────────────────────
        Value::Array(arr) => {
            for (i, v) in arr.iter().enumerate() {
                let obj = v.as_object().ok_or_else(|| {
                    ForgeError::call_site(format!("array[{i}] is not a JSON object"))
                })?;
                for f in &fields {
                    let jv = obj.get(&f.json_key).unwrap_or(&Value::Null);
                    validate(jv, &f.ft, &format!("[{i}].{}", f.json_key))?;
                }
            }
            #[cfg(feature = "embedded")]
            if use_embedded {
                let embed_ts = emit_static_array(name, data_vis, &fields, root)?;
                return Ok(quote! { #struct_item #embed_ts });
            }
            inline_json_const(name, data_vis, json_text)
        },
        // ── Single top-level object → single static / JSON const ──────────
        Value::Object(map) => {
            for f in &fields {
                let jv = map.get(&f.json_key).unwrap_or(&Value::Null);
                validate(jv, &f.ft, &format!("<root>.{}", f.json_key))?;
            }
            #[cfg(feature = "embedded")]
            if use_embedded {
                let embed_ts = emit_single_static(name, data_vis, &fields, root)?;
                return Ok(quote! { #struct_item #embed_ts });
            }
            inline_json_const(name, data_vis, json_text)
        },
        _ => {
            return Err(ForgeError::call_site(
                "top-level JSON must be an object or array",
            ));
        },
    };

    Ok(quote! { #struct_item #data_ts })
}