use proc_macro2::TokenStream;
use quote::quote;
use syn::spanned::Spanned;
use syn::{GenericArgument, PathArguments, Type, TypePath};
use crate::diagnostic::{MacroError, MacroErrorCode};
pub fn map_field(field_name: &str, ty: &Type) -> Result<TokenStream, MacroError> {
match ty {
Type::Path(type_path) if is_path_ident(type_path, "Option") => {
map_option(field_name, type_path)
}
Type::Path(type_path) if is_path_ident(type_path, "Vec") => map_vec(field_name, type_path),
Type::Path(type_path) => Ok(map_path(field_name, type_path)),
_ => Err(MacroError::new(
MacroErrorCode::ArcM002,
ty.span(),
format!(
"#[page]/#[resource] field `{field_name}` has an unsupported type; \
use a named struct, a primitive, Option<T>, or Vec<T>"
),
)),
}
}
fn map_option(field_name: &str, type_path: &TypePath) -> Result<TokenStream, MacroError> {
let inner = single_generic_arg(type_path)?;
let Type::Path(inner_path) = inner else {
return Err(MacroError::new(
MacroErrorCode::ArcM002,
inner.span(),
format!(
"#[page]/#[resource] field `{field_name}` has an unsupported Option<T> inner type"
),
));
};
Ok(match primitive_contract_call(inner_path) {
Some(prim) => quote! { .optional(#field_name, #prim) },
None => {
let inner_ty = &inner_path.path;
quote! { .nested_optional::<#inner_ty>(#field_name) }
}
})
}
fn map_vec(field_name: &str, type_path: &TypePath) -> Result<TokenStream, MacroError> {
let inner = single_generic_arg(type_path)?;
let Type::Path(inner_path) = inner else {
return Err(MacroError::new(
MacroErrorCode::ArcM002,
inner.span(),
format!(
"#[page]/#[resource] field `{field_name}` has an unsupported Vec<T> inner type"
),
));
};
Ok(match primitive_contract_call(inner_path) {
Some(prim) => quote! {
.required(
#field_name,
::arcature::inertia::ContractType::array(#prim),
)
},
None => {
let inner_ty = &inner_path.path;
quote! { .nested_array::<#inner_ty>(#field_name) }
}
})
}
fn map_path(field_name: &str, type_path: &TypePath) -> TokenStream {
match primitive_contract_call(type_path) {
Some(prim) => quote! { .required(#field_name, #prim) },
None => {
let ty = &type_path.path;
quote! { .nested::<#ty>(#field_name) }
}
}
}
fn single_generic_arg(type_path: &TypePath) -> Result<&Type, MacroError> {
let last = type_path.path.segments.last().ok_or_else(|| {
MacroError::new(
MacroErrorCode::ArcM002,
type_path.span(),
"empty path in #[page]/#[resource] field type",
)
})?;
let PathArguments::AngleBracketed(args) = &last.arguments else {
return Err(MacroError::new(
MacroErrorCode::ArcM002,
last.arguments.span(),
"expected generic arguments in #[page]/#[resource] field type",
));
};
match args.args.first() {
Some(GenericArgument::Type(ty)) => Ok(ty),
Some(arg) => Err(MacroError::new(
MacroErrorCode::ArcM002,
arg.span(),
"expected a type argument in #[page]/#[resource] field type",
)),
None => Err(MacroError::new(
MacroErrorCode::ArcM002,
args.span(),
"expected one generic argument in #[page]/#[resource] field type",
)),
}
}
fn is_path_ident(type_path: &TypePath, name: &str) -> bool {
type_path
.path
.segments
.last()
.is_some_and(|segment| segment.ident == name)
}
fn primitive_contract_call(type_path: &TypePath) -> Option<TokenStream> {
let name = type_path.path.segments.last()?.ident.to_string();
match name.as_str() {
"String" | "str" | "char" | "Url" => {
Some(quote! { ::arcature::inertia::ContractType::string() })
}
"bool" => Some(quote! { ::arcature::inertia::ContractType::boolean() }),
"u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "i8" | "i16" | "i32" | "i64" | "i128"
| "isize" | "f32" | "f64" => Some(quote! { ::arcature::inertia::ContractType::number() }),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn map(field: &str, ty_str: &str) -> Result<String, MacroError> {
let ty: Type = syn::parse_str(ty_str).expect("test type string should parse");
map_field(field, &ty).map(|tokens| tokens.to_string())
}
#[test]
fn string_field_is_a_required_string() {
let s = map("name", "String").unwrap();
assert!(s.contains("required"), "got: {s}");
assert!(s.contains("string ()"), "got: {s}");
}
#[test]
fn bool_field_is_a_required_boolean() {
let s = map("active", "bool").unwrap();
assert!(s.contains("boolean ()"), "got: {s}");
}
#[test]
fn integer_and_float_fields_are_required_numbers() {
assert!(map("count", "i64").unwrap().contains("number ()"));
assert!(map("ratio", "f32").unwrap().contains("number ()"));
}
#[test]
fn option_of_primitive_is_an_optional_prop() {
let s = map("bio", "Option<String>").unwrap();
assert!(s.contains("optional"), "got: {s}");
assert!(!s.contains("nested_optional"), "got: {s}");
}
#[test]
fn option_of_named_type_is_nested_optional() {
let s = map("avatar", "Option<AvatarResource>").unwrap();
assert!(
s.contains("nested_optional :: < AvatarResource >"),
"got: {s}"
);
}
#[test]
fn vec_of_primitive_is_a_required_array() {
let s = map("tags", "Vec<String>").unwrap();
assert!(s.contains("array"), "got: {s}");
assert!(!s.contains("nested_array"), "got: {s}");
}
#[test]
fn vec_of_named_type_is_nested_array() {
let s = map("posts", "Vec<PostResource>").unwrap();
assert!(s.contains("nested_array :: < PostResource >"), "got: {s}");
}
#[test]
fn named_type_is_nested_and_requires_client_data() {
let s = map("author", "UserResource").unwrap();
assert!(s.contains("nested :: < UserResource >"), "got: {s}");
}
#[test]
fn qualified_paths_resolve_by_their_last_segment() {
assert!(
map("name", "std::string::String")
.unwrap()
.contains("string ()")
);
assert!(
map("bio", "std::option::Option<String>")
.unwrap()
.contains("optional")
);
}
#[test]
fn reference_types_are_rejected() {
let err = map("name", "&'static str").unwrap_err();
assert_eq!(err.code(), MacroErrorCode::ArcM002);
}
#[test]
fn tuple_types_are_rejected() {
let err = map("pair", "(i64, i64)").unwrap_err();
assert_eq!(err.code(), MacroErrorCode::ArcM002);
}
#[test]
fn option_without_a_type_argument_is_rejected() {
let err = map("bio", "Option").unwrap_err();
assert_eq!(err.code(), MacroErrorCode::ArcM002);
}
}