Add typed Decodal derive support
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "decodal-derive"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
readme.workspace = true
|
||||
description = "Derive macro for generating Decodal schemas and typed decoders from Rust structs."
|
||||
keywords = ["decodal", "derive", "proc-macro", "schema"]
|
||||
categories = ["config", "development-tools::procedural-macro-helpers"]
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full"] }
|
||||
|
||||
[dev-dependencies]
|
||||
decodal = { version = "0.1", path = "../decodal-core" }
|
||||
@@ -0,0 +1,212 @@
|
||||
use proc_macro::TokenStream;
|
||||
use proc_macro2::TokenStream as TokenStream2;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{
|
||||
Data, DeriveInput, Expr, Fields, LitStr, Result, Type, parse_macro_input, spanned::Spanned,
|
||||
};
|
||||
|
||||
#[proc_macro_derive(Decodal, attributes(decodal))]
|
||||
pub fn derive_decodal(input: TokenStream) -> TokenStream {
|
||||
match expand_decodal(parse_macro_input!(input as DeriveInput)) {
|
||||
Ok(tokens) => tokens.into(),
|
||||
Err(error) => error.to_compile_error().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_decodal(input: DeriveInput) -> Result<TokenStream2> {
|
||||
let name = input.ident;
|
||||
let fields = match input.data {
|
||||
Data::Struct(data) => match data.fields {
|
||||
Fields::Named(fields) => fields.named,
|
||||
_ => {
|
||||
return Err(syn::Error::new(
|
||||
data.struct_token.span(),
|
||||
"Decodal derive supports structs with named fields only",
|
||||
));
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return Err(syn::Error::new(
|
||||
name.span(),
|
||||
"Decodal derive supports structs only",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let mut schema_fields = Vec::new();
|
||||
let mut decode_fields = Vec::new();
|
||||
|
||||
for field in fields {
|
||||
let ident = field.ident.expect("named field");
|
||||
let ty = field.ty;
|
||||
let attrs = FieldAttrs::from_attrs(&field.attrs, &ident.to_string())?;
|
||||
let path = attrs.rename.clone();
|
||||
let schema_value = schema_expr(&ty, &attrs)?;
|
||||
let decode_value = decode_expr(&ty, &path, &attrs);
|
||||
|
||||
schema_fields.push(quote! {
|
||||
(#path, { #schema_value })
|
||||
});
|
||||
decode_fields.push(quote! {
|
||||
#ident: #decode_value
|
||||
});
|
||||
}
|
||||
|
||||
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
|
||||
|
||||
Ok(quote! {
|
||||
impl #impl_generics ::decodal::DecodalSchema for #name #ty_generics #where_clause {
|
||||
fn decodal_schema() -> ::decodal::HostValue {
|
||||
::decodal::HostValue::object_from_paths([
|
||||
#(#schema_fields),*
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
impl #impl_generics ::decodal::DecodalDecode for #name #ty_generics #where_clause {
|
||||
fn decodal_decode(data: &::decodal::Data) -> ::decodal::DecodeResult<Self> {
|
||||
Ok(Self {
|
||||
#(#decode_fields),*
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FieldAttrs {
|
||||
rename: String,
|
||||
default: Option<DefaultAttr>,
|
||||
constraints: Vec<ConstraintAttr>,
|
||||
}
|
||||
|
||||
impl FieldAttrs {
|
||||
fn from_attrs(attrs: &[syn::Attribute], fallback_name: &str) -> Result<Self> {
|
||||
let mut output = Self {
|
||||
rename: fallback_name.to_string(),
|
||||
..Self::default()
|
||||
};
|
||||
|
||||
for attr in attrs {
|
||||
if !attr.path().is_ident("decodal") {
|
||||
continue;
|
||||
}
|
||||
attr.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("rename") {
|
||||
let value: LitStr = meta.value()?.parse()?;
|
||||
output.rename = value.value();
|
||||
return Ok(());
|
||||
}
|
||||
if meta.path.is_ident("default") {
|
||||
if meta.input.peek(syn::Token![=]) {
|
||||
let expr: Expr = meta.value()?.parse()?;
|
||||
output.default = Some(DefaultAttr::Expr(expr));
|
||||
} else {
|
||||
output.default = Some(DefaultAttr::Default);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(kind) = ConstraintKind::from_path(&meta.path) {
|
||||
let expr: Expr = meta.value()?.parse()?;
|
||||
output.constraints.push(ConstraintAttr { kind, expr });
|
||||
return Ok(());
|
||||
}
|
||||
Err(meta.error("unsupported decodal attribute"))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
enum DefaultAttr {
|
||||
Default,
|
||||
Expr(Expr),
|
||||
}
|
||||
|
||||
struct ConstraintAttr {
|
||||
kind: ConstraintKind,
|
||||
expr: Expr,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ConstraintKind {
|
||||
Gt,
|
||||
Gte,
|
||||
Lt,
|
||||
Lte,
|
||||
}
|
||||
|
||||
impl ConstraintKind {
|
||||
fn from_path(path: &syn::Path) -> Option<Self> {
|
||||
if path.is_ident("gt") {
|
||||
Some(Self::Gt)
|
||||
} else if path.is_ident("gte") {
|
||||
Some(Self::Gte)
|
||||
} else if path.is_ident("lt") {
|
||||
Some(Self::Lt)
|
||||
} else if path.is_ident("lte") {
|
||||
Some(Self::Lte)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn method(self) -> syn::Ident {
|
||||
match self {
|
||||
Self::Gt => format_ident!("gt"),
|
||||
Self::Gte => format_ident!("gte"),
|
||||
Self::Lt => format_ident!("lt"),
|
||||
Self::Lte => format_ident!("lte"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn schema_expr(ty: &Type, attrs: &FieldAttrs) -> Result<TokenStream2> {
|
||||
let mut tokens = quote! {
|
||||
let mut schema = <#ty as ::decodal::DecodalSchema>::decodal_schema();
|
||||
};
|
||||
|
||||
for constraint in &attrs.constraints {
|
||||
let method = constraint.kind.method();
|
||||
let expr = &constraint.expr;
|
||||
tokens.extend(quote! {
|
||||
schema = schema.#method(#expr);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(default) = &attrs.default {
|
||||
let default_expr = match default {
|
||||
DefaultAttr::Default => quote! { <#ty as ::core::default::Default>::default() },
|
||||
DefaultAttr::Expr(expr) => quote! { (#expr) },
|
||||
};
|
||||
tokens.extend(quote! {
|
||||
schema = schema
|
||||
.default(::decodal::IntoHostValue::into_host_value(#default_expr))
|
||||
.expect("Decodal derive generated a valid default");
|
||||
});
|
||||
}
|
||||
|
||||
tokens.extend(quote! { schema });
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
fn decode_expr(ty: &Type, path: &str, attrs: &FieldAttrs) -> TokenStream2 {
|
||||
let missing = if let Some(default) = &attrs.default {
|
||||
match default {
|
||||
DefaultAttr::Default => quote! { <#ty as ::core::default::Default>::default() },
|
||||
DefaultAttr::Expr(expr) => quote! { ::core::convert::Into::into(#expr) },
|
||||
}
|
||||
} else {
|
||||
quote! { return Err(::decodal::DecodeError::new(#path, "missing field")); }
|
||||
};
|
||||
|
||||
quote! {
|
||||
if let Some(value) = ::decodal::data_at_path(data, #path) {
|
||||
<#ty as ::decodal::DecodalDecode>::decodal_decode(value)
|
||||
.map_err(|error| ::decodal::prefix_decode_error(#path, error))?
|
||||
} else {
|
||||
#missing
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use decodal::{Data, DecodalDecode, DecodalSchema, EmptyLoader, Engine};
|
||||
use decodal_derive::Decodal;
|
||||
|
||||
#[derive(Debug, PartialEq, Decodal)]
|
||||
struct Service {
|
||||
name: String,
|
||||
#[decodal(gt = 443, default = 8443)]
|
||||
port: i64,
|
||||
#[decodal(default = true, rename = "feature.enable")]
|
||||
feature_enabled: bool,
|
||||
#[decodal(default)]
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_schema_and_decode() {
|
||||
let mut engine = Engine::new(EmptyLoader);
|
||||
engine
|
||||
.bind_global("Service", Service::decodal_schema())
|
||||
.unwrap();
|
||||
let module = engine
|
||||
.add_root_source(
|
||||
"test",
|
||||
"test",
|
||||
r#"
|
||||
Service & {
|
||||
name = "api";
|
||||
port = 9443;
|
||||
feature.enable = false;
|
||||
tags = ["web", "prod"];
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let value = engine.eval_module(module).unwrap();
|
||||
let data = engine.materialize(&value).unwrap();
|
||||
let service = Service::decodal_decode(&data).unwrap();
|
||||
assert_eq!(
|
||||
service,
|
||||
Service {
|
||||
name: "api".into(),
|
||||
port: 9443,
|
||||
feature_enabled: false,
|
||||
tags: vec!["web".into(), "prod".into()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_available_in_schema() {
|
||||
let mut engine = Engine::new(EmptyLoader);
|
||||
engine
|
||||
.bind_global("Service", Service::decodal_schema())
|
||||
.unwrap();
|
||||
let module = engine
|
||||
.add_root_source(
|
||||
"test",
|
||||
"test",
|
||||
r#"
|
||||
Service & {
|
||||
name = "api";
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let value = engine.eval_module(module).unwrap();
|
||||
let data = engine.materialize(&value).unwrap();
|
||||
let service = Service::decodal_decode(&data).unwrap();
|
||||
assert_eq!(service.port, 8443);
|
||||
assert!(service.feature_enabled);
|
||||
assert!(service.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_reports_field_path() {
|
||||
let data = Data::Object(vec![]);
|
||||
let error = Service::decodal_decode(&data).unwrap_err();
|
||||
assert_eq!(error.path, "name");
|
||||
}
|
||||
Reference in New Issue
Block a user