use proc_macro2::{TokenStream, TokenTree};
use quote::{ToTokens, quote};
use syn::spanned::Spanned;
use crate::diagnostic::{MacroError, MacroErrorCode};
#[derive(Debug)]
pub struct FieldShapeData {
pub name: String,
pub ty: String,
pub validates: Vec<String>,
}
pub fn collect_field_shapes(
item_struct: &syn::ItemStruct,
) -> Result<Vec<FieldShapeData>, MacroError> {
let syn::Fields::Named(named) = &item_struct.fields else {
return Err(MacroError::new(
MacroErrorCode::ArcM002,
item_struct.fields.span(),
"field-shape extraction requires a struct with named fields",
));
};
Ok(named
.named
.iter()
.map(|field| FieldShapeData {
name: field
.ident
.as_ref()
.map(ToString::to_string)
.unwrap_or_default(),
ty: clean_token_stream(&field.ty.to_token_stream()),
validates: extract_validate_rules(&field.attrs),
})
.collect())
}
pub fn emit_field_shape_slice(fields: &[FieldShapeData]) -> TokenStream {
let entries = fields.iter().map(|f| {
let name = &f.name;
let ty = &f.ty;
let validates = &f.validates;
quote! {
::arcature::FieldShape {
name: #name,
ty: #ty,
validates: &[#(#validates),*],
}
}
});
quote! { &[#(#entries),*] }
}
fn extract_validate_rules(attrs: &[syn::Attribute]) -> Vec<String> {
attrs
.iter()
.filter(|attr| attr.path().is_ident("validate"))
.filter_map(|attr| match &attr.meta {
syn::Meta::List(meta_list) => Some(clean_token_stream(&meta_list.tokens)),
_ => None,
})
.filter(|rule| !rule.is_empty())
.collect()
}
fn clean_token_stream(ts: &TokenStream) -> String {
let mut out = String::new();
let mut prev_ident = false;
let mut prev_was_comma = false;
for token in ts.clone() {
let s = match &token {
TokenTree::Group(group) => {
let (open, close) = match group.delimiter() {
proc_macro2::Delimiter::Parenthesis => ("(", ")"),
proc_macro2::Delimiter::Brace => ("{", "}"),
proc_macro2::Delimiter::Bracket => ("[", "]"),
proc_macro2::Delimiter::None => ("", ""),
};
let inner = clean_token_stream(&group.stream());
format!("{open}{inner}{close}")
}
_ => token.to_string(),
};
let cur_ident = s
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '_');
let cur_is_close = s.starts_with([')', ']', '}']);
if cur_ident && prev_ident {
out.push(' ');
}
if prev_was_comma && !cur_is_close {
out.push(' ');
}
out.push_str(&s);
prev_ident = cur_ident;
prev_was_comma = s.ends_with(',');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use syn::{ItemStruct, Type};
fn parse_struct(tokens: TokenStream) -> ItemStruct {
syn::parse2(tokens).expect("struct should parse")
}
fn clean_type(s: &str) -> String {
let ty: Type = syn::parse_str(s).expect("type should parse");
clean_token_stream(&ty.to_token_stream())
}
#[test]
fn clean_renders_simple_generics() {
assert_eq!(clean_type("String"), "String");
assert_eq!(clean_type("Option<String>"), "Option<String>");
assert_eq!(clean_type("Vec<i64>"), "Vec<i64>");
}
#[test]
fn clean_renders_nested_generics() {
assert_eq!(clean_type("Option<Vec<String>>"), "Option<Vec<String>>");
assert_eq!(clean_type("HashMap<String, i64>"), "HashMap<String, i64>");
}
#[test]
fn clean_collapses_path_separators() {
assert_eq!(clean_type("std::string::String"), "std::string::String");
}
#[test]
fn collect_extracts_request_field_shapes() {
let item = parse_struct(quote! {
pub struct StoreLinkRequest {
#[validate(url)]
pub url: String,
#[validate(length(min = 1, max = 120))]
pub title: String,
pub description: Option<String>,
}
});
let shapes = collect_field_shapes(&item).expect("collect");
assert_eq!(shapes.len(), 3);
assert_eq!(shapes[0].name, "url");
assert_eq!(shapes[0].ty, "String");
assert_eq!(shapes[0].validates, vec!["url"]);
assert_eq!(shapes[1].validates, vec!["length(min=1, max=120)"]);
assert_eq!(shapes[2].ty, "Option<String>");
assert!(shapes[2].validates.is_empty());
}
#[test]
fn collect_extracts_resource_field_shapes_without_validates() {
let item = parse_struct(quote! {
pub struct LinkResource {
pub id: String,
pub tags: Vec<String>,
}
});
let shapes = collect_field_shapes(&item).expect("collect");
assert_eq!(shapes.len(), 2);
assert_eq!(shapes[0].ty, "String");
assert!(shapes[0].validates.is_empty());
assert_eq!(shapes[1].ty, "Vec<String>");
}
#[test]
fn collect_rejects_tuple_struct() {
let item = parse_struct(quote! { pub struct Wrapper(pub String); });
let err = collect_field_shapes(&item).unwrap_err();
assert_eq!(err.code(), MacroErrorCode::ArcM002);
}
#[test]
fn emit_slice_renders_field_shape_consts() {
let shapes = vec![
FieldShapeData {
name: "url".into(),
ty: "String".into(),
validates: vec!["url".into()],
},
FieldShapeData {
name: "description".into(),
ty: "Option<String>".into(),
validates: vec![],
},
];
let s = emit_field_shape_slice(&shapes).to_string();
assert!(s.contains("FieldShape"), "got: {s}");
assert!(s.contains("\"url\""), "got: {s}");
assert!(s.contains("\"Option<String>\""), "got: {s}");
assert!(s.contains("validates"), "got: {s}");
}
}