rs-macros 0.1.0

Rust macros for generating JSON schemas
Documentation
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Attribute, Data, DeriveInput, Fields, Meta};

#[proc_macro_derive(JsonSchemaOneOf)]
pub fn derive_json_schema_one_of(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);

    let name = &input.ident;
    let enum_name_str = name.to_string();

    // Parse the enum data
    let data = match &input.data {
        Data::Enum(data) => data,
        _ => {
            return syn::Error::new_spanned(
                &input.ident,
                "JsonSchemaOneOf can only be derived for enums",
            )
            .to_compile_error()
            .into();
        }
    };

    // Check for unit variants only (no fields)
    for variant in &data.variants {
        match &variant.fields {
            Fields::Unit => {}
            _ => {
                return syn::Error::new_spanned(
                    &variant.ident,
                    "JsonSchemaOneOf only supports unit variants (no fields)",
                )
                .to_compile_error()
                .into();
            }
        }
    }

    // Extract serde rename_all if present
    let rename_case = extract_serde_rename_all(&input.attrs); // Generate const values for each variant
    let const_variants = data.variants.iter().map(|variant| {
        let variant_name = variant.ident.to_string();
        let json_name = apply_rename_case(&variant_name, &rename_case);

        quote! {
            schemars::schema::Schema::Object(schemars::schema::SchemaObject {
                const_value: Some(serde_json::Value::String(#json_name.to_string())),
                metadata: Some(Box::new(schemars::schema::Metadata {
                    title: Some(#variant_name.to_string()),
                    ..Default::default()
                })),
                ..Default::default()
            })
        }
    });

    let expanded = quote! {
        impl schemars::JsonSchema for #name {
            fn schema_name() -> String {
                #enum_name_str.to_owned()
            }

            fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
                use schemars::schema::*;

                let mut schema = SchemaObject::default();
                schema.subschemas().one_of = Some(vec![
                    #(#const_variants,)*
                ]);

                schemars::schema::Schema::Object(schema)
            }
        }
    };

    TokenStream::from(expanded)
}

/// Extract the serde rename_all case style from attributes
fn extract_serde_rename_all(attrs: &[Attribute]) -> Option<String> {
    for attr in attrs {
        if attr.path().is_ident("serde") {
            if let Meta::List(meta_list) = &attr.meta {
                // Convert TokenStream to string and parse it
                let tokens_str = meta_list.tokens.to_string();
                if tokens_str.contains("rename_all") {
                    // Parse rename_all = "camelCase"
                    if let Some(start) = tokens_str.find('"') {
                        if let Some(end) = tokens_str.rfind('"') {
                            if start < end {
                                return Some(tokens_str[start + 1..end].to_string());
                            }
                        }
                    }
                }
            }
        }
    }
    None
}

/// Apply serde rename case transformation to a variant name
fn apply_rename_case(name: &str, rename_case: &Option<String>) -> String {
    match rename_case.as_deref() {
        Some("camelCase") => to_camel_case(name),
        Some("snake_case") => to_snake_case(name),
        Some("kebab-case") => to_kebab_case(name),
        Some("SCREAMING_SNAKE_CASE") => to_screaming_snake_case(name),
        Some("PascalCase") => to_pascal_case(name),
        Some("SCREAMING-KEBAB-CASE") => to_screaming_kebab_case(name),
        _ => name.to_string(),
    }
}

/// Convert PascalCase to camelCase
fn to_camel_case(s: &str) -> String {
    if s.is_empty() {
        return s.to_string();
    }

    let mut result = String::new();
    let mut chars = s.chars();

    // First character to lowercase
    if let Some(first) = chars.next() {
        result.push(first.to_lowercase().next().unwrap_or(first));
    }

    // Rest of the string unchanged
    result.extend(chars);
    result
}

/// Convert PascalCase to snake_case
fn to_snake_case(s: &str) -> String {
    let mut result = String::new();
    let chars: Vec<char> = s.chars().collect();

    for (i, &c) in chars.iter().enumerate() {
        if c.is_uppercase() {
            // Add underscore before uppercase letter if:
            // 1. Not the first character
            // 2. Previous character was lowercase, OR
            // 3. Next character is lowercase (handles consecutive caps like "XMLHttp" -> "xml_http")
            if i > 0 {
                let prev_was_lower = chars[i - 1].is_lowercase();
                let next_is_lower = i + 1 < chars.len() && chars[i + 1].is_lowercase();

                if prev_was_lower || next_is_lower {
                    result.push('_');
                }
            }
            result.push(c.to_lowercase().next().unwrap_or(c));
        } else {
            result.push(c);
        }
    }

    result
}

/// Convert PascalCase to kebab-case
fn to_kebab_case(s: &str) -> String {
    to_snake_case(s).replace('_', "-")
}

/// Convert PascalCase to SCREAMING_SNAKE_CASE
fn to_screaming_snake_case(s: &str) -> String {
    to_snake_case(s).to_uppercase()
}

/// Convert to PascalCase (already in PascalCase, so return as-is)
fn to_pascal_case(s: &str) -> String {
    s.to_string()
}

/// Convert PascalCase to SCREAMING-KEBAB-CASE
fn to_screaming_kebab_case(s: &str) -> String {
    to_kebab_case(s).to_uppercase()
}

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

    #[test]
    fn test_camel_case() {
        assert_eq!(to_camel_case("MessageOne"), "messageOne");
        assert_eq!(to_camel_case("MessageTwo"), "messageTwo");
        assert_eq!(to_camel_case("A"), "a");
        assert_eq!(to_camel_case(""), "");
    }

    #[test]
    fn test_snake_case() {
        assert_eq!(to_snake_case("MessageOne"), "message_one");
        assert_eq!(to_snake_case("MessageTwo"), "message_two");
        assert_eq!(to_snake_case("XMLHttpRequest"), "xml_http_request");
    }

    #[test]
    fn test_kebab_case() {
        assert_eq!(to_kebab_case("MessageOne"), "message-one");
        assert_eq!(to_kebab_case("MessageTwo"), "message-two");
    }

    #[test]
    fn test_screaming_snake_case() {
        assert_eq!(to_screaming_snake_case("MessageOne"), "MESSAGE_ONE");
        assert_eq!(to_screaming_snake_case("MessageTwo"), "MESSAGE_TWO");
    }
}