jsonforge 0.1.0

A Rust procedural macro for generating JSON schema validators from Rust types
Documentation
use proc_macro2::Span;
use syn::Ident;

/// Rust keywords that require `r#` escaping when used as identifiers.
const KEYWORDS: &[&str] = &[
    "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
    "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
    "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "union",
    "unsafe", "use", "where", "while",
];

/// Convert a JSON key into a valid Rust identifier.
///
/// Rules:
/// - Replace any character that is not `[a-zA-Z0-9_]` with `_`.
/// - Prefix with `_` if the first character is a digit.
/// - Wrap with `r#` if the result is a reserved keyword.
pub fn sanitize_ident(key: &str) -> String {
    if key.is_empty() {
        return "_empty".to_owned();
    }

    let mut out = String::with_capacity(key.len());
    let mut chars = key.chars().peekable();

    // First character: must not be a digit.
    let first = chars.next().unwrap();
    if first.is_ascii_digit() {
        out.push('_');
        out.push(first);
    } else if first.is_alphanumeric() || first == '_' {
        out.push(first);
    } else {
        out.push('_');
    }

    for ch in chars {
        if ch.is_alphanumeric() || ch == '_' {
            out.push(ch);
        } else {
            out.push('_');
        }
    }

    // Handle reserved keywords.
    if KEYWORDS.contains(&out.as_str()) {
        format!("r#{out}")
    } else {
        out
    }
}

/// Convert a PascalCase name to SCREAMING_SNAKE_CASE.
pub fn to_screaming_snake(name: &str) -> String {
    let mut out = String::new();
    for (i, ch) in name.char_indices() {
        if ch.is_uppercase() {
            if i > 0 {
                out.push('_');
            }
            out.push(ch);
        } else {
            out.push(ch.to_ascii_uppercase());
        }
    }
    out
}

/// Convert a PascalCase or camelCase string to `snake_case`.
///
/// - `Name` → `name`
/// - `ID` → `id`
/// - `XMLParser` → `xml_parser`
/// - `myField` → `my_field`
pub fn to_snake_case(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 4);
    let chars: Vec<char> = s.chars().collect();
    for (i, &c) in chars.iter().enumerate() {
        if c.is_ascii_uppercase() {
            if i > 0 {
                let prev = chars[i - 1];
                let next_lower = chars
                    .get(i + 1)
                    .map(|c| c.is_ascii_lowercase())
                    .unwrap_or(false);
                if prev.is_ascii_lowercase() || (prev.is_ascii_uppercase() && next_lower) {
                    out.push('_');
                }
            }
            out.push(c.to_ascii_lowercase());
        } else {
            out.push(c);
        }
    }
    // Sanitise: replace non-ident chars with _
    out.chars()
        .map(|c| {
            if c.is_alphanumeric() || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect()
}

/// Convert a `snake_case` string to `PascalCase`.
///
/// - `name` → `Name`
/// - `my_field` → `MyField`
/// - `id` → `Id`
pub fn to_pascal_case(s: &str) -> String {
    s.split('_')
        .filter(|seg| !seg.is_empty())
        .map(|seg| {
            let mut chars = seg.chars();
            match chars.next() {
                None => String::new(),
                Some(first) => {
                    let mut word = String::new();
                    word.push(first.to_ascii_uppercase());
                    word.push_str(chars.as_str());
                    word
                },
            }
        })
        .collect()
}

/// Convert a `snake_case` string to `camelCase`.
///
/// - `my_field` → `myField`
/// - `id` → `id`
pub fn to_camel_case(s: &str) -> String {
    let pascal = to_pascal_case(s);
    let mut chars = pascal.chars();
    match chars.next() {
        None => String::new(),
        Some(first) => {
            let mut out = String::new();
            out.push(first.to_ascii_lowercase());
            out.push_str(chars.as_str());
            out
        },
    }
}

/// Create a [`syn::Ident`] from a sanitised string at the call-site span.
pub fn make_ident(name: &str) -> Ident {
    // r# identifiers cannot be constructed with Ident::new; strip prefix and use raw ident.
    if let Some(stripped) = name.strip_prefix("r#") {
        Ident::new_raw(stripped, Span::call_site())
    } else {
        Ident::new(name, Span::call_site())
    }
}