diff --git a/Cargo.lock b/Cargo.lock index df3dc12..525429a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,21 +25,31 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "decodal" -version = "0.1.0" +version = "0.1.1" dependencies = [ "regex", ] [[package]] name = "decodal-cli" -version = "0.1.0" +version = "0.1.1" dependencies = [ "decodal", ] +[[package]] +name = "decodal-derive" +version = "0.1.1" +dependencies = [ + "decodal", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "decodal-wasm" -version = "0.1.0" +version = "0.1.1" dependencies = [ "decodal", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index c4c78af..4c451ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,11 +3,12 @@ members = [ "crates/decodal-core", "crates/decodal-cli", "crates/decodal-wasm", + "crates/decodal-derive", ] resolver = "2" [workspace.package] -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version = "1.85" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 8db9ac1..d2b7429 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,35 @@ Embedded hosts should depend on `decodal` and provide imports with a `SourceLoad decodal = "0.1" ``` +## Derive support + +For embedded Rust applications, `decodal-derive` can generate a Decodal schema and typed decoder from a Rust struct. + +```toml +[dependencies] +decodal = "0.1" +decodal-derive = "0.1" +``` + +```rust +use decodal::{DecodalDecode, DecodalSchema, Engine}; +use decodal_derive::Decodal; + +#[derive(Decodal)] +struct Service { + name: String, + #[decodal(gt = 443, default = 8443)] + port: i64, + #[decodal(rename = "feature.enable", default = true)] + feature_enabled: bool, +} +``` + +The derive implements: + +- `DecodalSchema`, which produces a host schema for `Engine::bind_global` +- `DecodalDecode`, which converts materialized `Data` into the Rust struct + ## CLI A standalone CLI is kept in this repository as the `decodal-cli` workspace package. diff --git a/crates/decodal-cli/Cargo.toml b/crates/decodal-cli/Cargo.toml index acbc3a2..d71f434 100644 --- a/crates/decodal-cli/Cargo.toml +++ b/crates/decodal-cli/Cargo.toml @@ -20,4 +20,4 @@ default = [] regex = ["decodal/regex"] [dependencies] -decodal = { version = "0.1.0", path = "../decodal-core" } +decodal = { version = "0.1.1", path = "../decodal-core" } diff --git a/crates/decodal-core/src/constraints.rs b/crates/decodal-core/src/constraints.rs index 3ba6098..bfc7a1f 100644 --- a/crates/decodal-core/src/constraints.rs +++ b/crates/decodal-core/src/constraints.rs @@ -70,7 +70,10 @@ pub fn normalize_constraints( if matches!( primitive, - Some((PrimitiveType::String | PrimitiveType::Bool, _)) + Some(( + PrimitiveType::String | PrimitiveType::Bool | PrimitiveType::Array, + _ + )) ) && (lower.is_some() || upper.is_some()) { let mut diagnostic = Diagnostic::new( diff --git a/crates/decodal-core/src/embedding.rs b/crates/decodal-core/src/embedding.rs index 57654b3..e544441 100644 --- a/crates/decodal-core/src/embedding.rs +++ b/crates/decodal-core/src/embedding.rs @@ -63,6 +63,18 @@ impl HostValue { ) } + pub fn object_from_paths(fields: I) -> Self + where + I: IntoIterator, + N: Into, + { + let mut root = Vec::new(); + for (path, value) in fields { + insert_path(&mut root, &path.into(), value); + } + Self::Object(root) + } + pub fn string_type() -> Self { Self::abstract_with_constraint(Constraint::Type(PrimitiveType::String)) } @@ -79,6 +91,10 @@ impl HostValue { Self::abstract_with_constraint(Constraint::Type(PrimitiveType::Bool)) } + pub fn array_type() -> Self { + Self::abstract_with_constraint(Constraint::Type(PrimitiveType::Array)) + } + pub fn builtin_predicate(name: impl Into) -> Self { Self::abstract_with_constraint(Constraint::BuiltinPredicate(name.into())) } @@ -162,3 +178,38 @@ impl HostValue { self.default(Self::bool(value)) } } + +impl HostField { + pub fn new(name: impl Into, value: HostValue) -> Self { + Self { + name: name.into(), + value, + } + } +} + +fn insert_path(fields: &mut Vec, path: &str, value: HostValue) { + let mut parts = path.splitn(2, '.'); + let Some(head) = parts.next().filter(|part| !part.is_empty()) else { + return; + }; + if let Some(tail) = parts.next() { + if let Some(field) = fields.iter_mut().find(|field| field.name == head) { + if let HostValue::Object(children) = &mut field.value { + insert_path(children, tail, value); + } else { + let mut children = Vec::new(); + insert_path(&mut children, tail, value); + field.value = HostValue::Object(children); + } + } else { + let mut children = Vec::new(); + insert_path(&mut children, tail, value); + fields.push(HostField::new(head, HostValue::Object(children))); + } + } else if let Some(field) = fields.iter_mut().find(|field| field.name == head) { + field.value = value; + } else { + fields.push(HostField::new(head, value)); + } +} diff --git a/crates/decodal-core/src/eval.rs b/crates/decodal-core/src/eval.rs index dc52280..58e2271 100644 --- a/crates/decodal-core/src/eval.rs +++ b/crates/decodal-core/src/eval.rs @@ -1170,6 +1170,7 @@ fn primitive_type(name: &str) -> Option { "Int" => Some(PrimitiveType::Int), "Float" => Some(PrimitiveType::Float), "Bool" => Some(PrimitiveType::Bool), + "Array" => Some(PrimitiveType::Array), _ => None, } } @@ -1250,6 +1251,9 @@ fn value_matches_primitive(value: &RuntimeValue, primitive: PrimitiveType) -> bo ) | ( RuntimeValue::Concrete(ConcreteValue::Bool(_)), PrimitiveType::Bool + ) | ( + RuntimeValue::Concrete(ConcreteValue::Array(_)), + PrimitiveType::Array ) ) } diff --git a/crates/decodal-core/src/lib.rs b/crates/decodal-core/src/lib.rs index b81c058..f05f5a7 100644 --- a/crates/decodal-core/src/lib.rs +++ b/crates/decodal-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod module; pub mod parser; pub mod runtime; pub mod span; +pub mod typed; pub use ast::{Ast, BinaryOp, CompareOp, Expr, ExprId, Field, Literal, Param}; pub use constraints::normalize_constraints; @@ -23,6 +24,10 @@ pub use module::{EmptyLoader, LoadedSource, Module, SourceLoader}; pub use parser::{ParseOutput, Parser, SourceForm, parse_source, parse_source_with_source_id}; pub use runtime::{Constraint, Data, ExprRef, LiteralValue, ModuleId, PrimitiveType, RuntimeValue}; pub use span::{SourceId, Span}; +pub use typed::{ + DecodalDecode, DecodalSchema, DecodeError, DecodeResult, IntoHostValue, data_at_path, + decode_path, prefix_decode_error, +}; pub fn version() -> &'static str { env!("CARGO_PKG_VERSION") diff --git a/crates/decodal-core/src/runtime.rs b/crates/decodal-core/src/runtime.rs index 2366e8f..49d4311 100644 --- a/crates/decodal-core/src/runtime.rs +++ b/crates/decodal-core/src/runtime.rs @@ -79,6 +79,7 @@ pub enum PrimitiveType { Int, Float, Bool, + Array, } #[derive(Debug, Clone, PartialEq)] diff --git a/crates/decodal-core/src/typed.rs b/crates/decodal-core/src/typed.rs new file mode 100644 index 0000000..4c47a31 --- /dev/null +++ b/crates/decodal-core/src/typed.rs @@ -0,0 +1,227 @@ +use alloc::{format, string::String, vec::Vec}; + +use crate::{Data, HostValue}; + +pub trait DecodalSchema { + fn decodal_schema() -> HostValue; +} + +pub trait DecodalDecode: Sized { + fn decodal_decode(data: &Data) -> DecodeResult; +} + +pub trait IntoHostValue { + fn into_host_value(self) -> HostValue; +} + +pub type DecodeResult = core::result::Result; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodeError { + pub path: String, + pub message: String, +} + +impl DecodeError { + pub fn new(path: impl Into, message: impl Into) -> Self { + Self { + path: path.into(), + message: message.into(), + } + } + + pub fn at_type(path: &str, expected: &'static str) -> Self { + Self::new(path, format!("expected {expected}")) + } +} + +impl core::fmt::Display for DecodeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + if self.path.is_empty() { + write!(f, "{}", self.message) + } else { + write!(f, "{}: {}", self.path, self.message) + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for DecodeError {} + +pub fn decode_path(data: &Data, path: &str) -> DecodeResult { + let value = data_at_path(data, path).ok_or_else(|| DecodeError::new(path, "missing field"))?; + T::decodal_decode(value).map_err(|error| prefix_error(path, error)) +} + +pub fn data_at_path<'a>(data: &'a Data, path: &str) -> Option<&'a Data> { + let mut current = data; + for part in path.split('.') { + if part.is_empty() { + return None; + } + let Data::Object(fields) = current else { + return None; + }; + current = &fields.iter().find(|field| field.name == part)?.value; + } + Some(current) +} + +pub fn prefix_decode_error(path: &str, error: DecodeError) -> DecodeError { + prefix_error(path, error) +} + +fn prefix_error(path: &str, mut error: DecodeError) -> DecodeError { + if error.path.is_empty() { + error.path = String::from(path); + } else if !path.is_empty() { + error.path = format!("{path}.{}", error.path); + } + error +} + +impl DecodalSchema for String { + fn decodal_schema() -> HostValue { + HostValue::string_type() + } +} + +impl DecodalDecode for String { + fn decodal_decode(data: &Data) -> DecodeResult { + match data { + Data::String(value) => Ok(value.clone()), + _ => Err(DecodeError::at_type("", "String")), + } + } +} + +impl IntoHostValue for String { + fn into_host_value(self) -> HostValue { + HostValue::string(self) + } +} + +impl IntoHostValue for &str { + fn into_host_value(self) -> HostValue { + HostValue::string(self) + } +} + +macro_rules! impl_int_decode { + ($($ty:ty),* $(,)?) => { + $( + impl DecodalSchema for $ty { + fn decodal_schema() -> HostValue { + HostValue::int_type() + } + } + + impl DecodalDecode for $ty { + fn decodal_decode(data: &Data) -> DecodeResult { + match data { + Data::Int(value) => <$ty>::try_from(*value) + .map_err(|_| DecodeError::new("", "integer value is out of range")), + _ => Err(DecodeError::at_type("", "Int")), + } + } + } + + impl IntoHostValue for $ty { + fn into_host_value(self) -> HostValue { + HostValue::int(self as i64) + } + } + )* + }; +} + +impl_int_decode!(i8, i16, i32, i64, u8, u16, u32); + +impl DecodalSchema for f64 { + fn decodal_schema() -> HostValue { + HostValue::float_type() + } +} + +impl DecodalDecode for f64 { + fn decodal_decode(data: &Data) -> DecodeResult { + match data { + Data::Float(value) => Ok(*value), + Data::Int(value) => Ok(*value as f64), + _ => Err(DecodeError::at_type("", "Float")), + } + } +} + +impl IntoHostValue for f64 { + fn into_host_value(self) -> HostValue { + HostValue::float(self) + } +} + +impl DecodalSchema for f32 { + fn decodal_schema() -> HostValue { + HostValue::float_type() + } +} + +impl DecodalDecode for f32 { + fn decodal_decode(data: &Data) -> DecodeResult { + f64::decodal_decode(data).map(|value| value as f32) + } +} + +impl IntoHostValue for f32 { + fn into_host_value(self) -> HostValue { + HostValue::float(f64::from(self)) + } +} + +impl DecodalSchema for bool { + fn decodal_schema() -> HostValue { + HostValue::bool_type() + } +} + +impl DecodalDecode for bool { + fn decodal_decode(data: &Data) -> DecodeResult { + match data { + Data::Bool(value) => Ok(*value), + _ => Err(DecodeError::at_type("", "Bool")), + } + } +} + +impl IntoHostValue for bool { + fn into_host_value(self) -> HostValue { + HostValue::bool(self) + } +} + +impl DecodalSchema for Vec { + fn decodal_schema() -> HostValue { + HostValue::array_type() + } +} + +impl DecodalDecode for Vec { + fn decodal_decode(data: &Data) -> DecodeResult { + match data { + Data::Array(items) => items + .iter() + .enumerate() + .map(|(index, item)| { + T::decodal_decode(item) + .map_err(|error| prefix_error(&format!("[{index}]"), error)) + }) + .collect(), + _ => Err(DecodeError::at_type("", "Array")), + } + } +} + +impl IntoHostValue for Vec { + fn into_host_value(self) -> HostValue { + HostValue::array(self.into_iter().map(IntoHostValue::into_host_value)) + } +} diff --git a/crates/decodal-derive/Cargo.toml b/crates/decodal-derive/Cargo.toml new file mode 100644 index 0000000..a338807 --- /dev/null +++ b/crates/decodal-derive/Cargo.toml @@ -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" } diff --git a/crates/decodal-derive/src/lib.rs b/crates/decodal-derive/src/lib.rs new file mode 100644 index 0000000..6f1f5a5 --- /dev/null +++ b/crates/decodal-derive/src/lib.rs @@ -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 { + 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 { + Ok(Self { + #(#decode_fields),* + }) + } + } + }) +} + +#[derive(Default)] +struct FieldAttrs { + rename: String, + default: Option, + constraints: Vec, +} + +impl FieldAttrs { + fn from_attrs(attrs: &[syn::Attribute], fallback_name: &str) -> Result { + 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 { + 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 { + 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 + } + } +} diff --git a/crates/decodal-derive/tests/derive.rs b/crates/decodal-derive/tests/derive.rs new file mode 100644 index 0000000..31e9220 --- /dev/null +++ b/crates/decodal-derive/tests/derive.rs @@ -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, +} + +#[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"); +} diff --git a/crates/decodal-wasm/Cargo.toml b/crates/decodal-wasm/Cargo.toml index e227786..98a0c71 100644 --- a/crates/decodal-wasm/Cargo.toml +++ b/crates/decodal-wasm/Cargo.toml @@ -15,7 +15,7 @@ publish = false crate-type = ["cdylib", "rlib"] [dependencies] -decodal = { version = "0.1.0", path = "../decodal-core" } +decodal = { version = "0.1.1", path = "../decodal-core" } serde_json.workspace = true wasm-bindgen.workspace = true diff --git a/doc/manual/souce/design/embedding-api.md b/doc/manual/souce/design/embedding-api.md index 4bfe70e..eb1f7f2 100644 --- a/doc/manual/souce/design/embedding-api.md +++ b/doc/manual/souce/design/embedding-api.md @@ -17,7 +17,7 @@ let / function env ``` Module top-level bindings shadow prelude bindings. -Primitive type names such as `String`, `Int`, `Float`, and `Bool` are handled before environment lookup, so they are reserved and cannot be shadowed by host bindings. +Primitive type names such as `String`, `Int`, `Float`, `Bool`, and `Array` are handled before environment lookup, so they are reserved and cannot be shadowed by host bindings. ## Global bindings @@ -87,6 +87,47 @@ Concrete(Object { This matches the runtime model used for Decodal source-defined schema objects. +## Typed Rust integration + +Hosts can use `decodal-derive` to keep a Rust struct, the Decodal schema, and the decoded result in sync. +The derive implements two traits from the `decodal` crate: + +- `DecodalSchema`: builds a `HostValue` schema that can be passed to `Engine::bind_global`. +- `DecodalDecode`: decodes materialized `Data` back into the Rust type. + +```rust +use decodal::{DecodalDecode, DecodalSchema, EmptyLoader, Engine}; +use decodal_derive::Decodal; + +#[derive(Decodal)] +struct Service { + name: String, + + #[decodal(gt = 443, default = 8443)] + port: i64, + + #[decodal(rename = "feature.enable", default = true)] + feature_enabled: bool, +} + +let mut engine = Engine::new(EmptyLoader); +engine.bind_global("Service", Service::decodal_schema())?; + +let value = engine.eval_module(module)?; +let data = engine.materialize(&value)?; +let service = Service::decodal_decode(&data)?; +``` + +Supported field attributes are intentionally small: + +- `rename = "path.to.field"` +- `default` +- `default = value` +- numeric constraints: `gt`, `gte`, `lt`, `lte` + +The derive does not add host callbacks or reflection. +It only generates schema construction and typed decoding code. + ## SourceLoader and prelude together `SourceLoader` and host prelude bindings are independent mechanisms. diff --git a/doc/manual/souce/development.md b/doc/manual/souce/development.md index fa51c91..667b159 100644 --- a/doc/manual/souce/development.md +++ b/doc/manual/souce/development.md @@ -23,11 +23,13 @@ cargo run -q -p decodal-cli --features regex -- examples/regex/main.dcdl ## crates.io release The primary crates.io package is `decodal`, which contains the embeddable library. +`decodal-derive` provides optional derive macros for Rust struct integration. Workspace support crates such as `decodal-cli` and `decodal-wasm` are not published for 0.1.0. The project is dual licensed as `MIT OR Apache-2.0`. ```sh cargo publish -p decodal +cargo publish -p decodal-derive ``` Before publishing, run: @@ -37,6 +39,7 @@ cargo fmt --check cargo test cargo check -p decodal --no-default-features cargo publish -p decodal --dry-run +cargo publish -p decodal-derive --dry-run ``` ## Web site and playground diff --git a/doc/manual/souce/language/value/index.md b/doc/manual/souce/language/value/index.md index 6fe89f7..0967dc9 100644 --- a/doc/manual/souce/language/value/index.md +++ b/doc/manual/souce/language/value/index.md @@ -9,9 +9,10 @@ name = String; retry = Int default 3; ratio = Float; enable = Bool default true; +tags = Array; ``` -現在の primitive type は `String`、`Int`、`Float`、`Bool` である。 +現在の primitive type は `String`、`Int`、`Float`、`Bool`、`Array` である。 各型の個別仕様へのリンクは [Manual Index](../../index.md) に集約する。 primitive type と制約合成の詳細は [制約と default](../constraints-and-defaults.md) も参照する。 diff --git a/package.nix b/package.nix index 8a19bc5..1afa7f0 100644 --- a/package.nix +++ b/package.nix @@ -24,7 +24,7 @@ let in rustPlatform.buildRustPackage { pname = "decodal"; - version = "0.1.0"; + version = "0.1.1"; src = lib.cleanSourceWith { src = srcRoot; diff --git a/site/decodal-site/src/wasm/README.md b/site/decodal-site/src/wasm/README.md index 8db9ac1..d2b7429 100644 --- a/site/decodal-site/src/wasm/README.md +++ b/site/decodal-site/src/wasm/README.md @@ -20,6 +20,35 @@ Embedded hosts should depend on `decodal` and provide imports with a `SourceLoad decodal = "0.1" ``` +## Derive support + +For embedded Rust applications, `decodal-derive` can generate a Decodal schema and typed decoder from a Rust struct. + +```toml +[dependencies] +decodal = "0.1" +decodal-derive = "0.1" +``` + +```rust +use decodal::{DecodalDecode, DecodalSchema, Engine}; +use decodal_derive::Decodal; + +#[derive(Decodal)] +struct Service { + name: String, + #[decodal(gt = 443, default = 8443)] + port: i64, + #[decodal(rename = "feature.enable", default = true)] + feature_enabled: bool, +} +``` + +The derive implements: + +- `DecodalSchema`, which produces a host schema for `Engine::bind_global` +- `DecodalDecode`, which converts materialized `Data` into the Rust struct + ## CLI A standalone CLI is kept in this repository as the `decodal-cli` workspace package. diff --git a/site/decodal-site/src/wasm/decodal_wasm_bg.wasm b/site/decodal-site/src/wasm/decodal_wasm_bg.wasm index 377a9bc..b2f4a2f 100644 Binary files a/site/decodal-site/src/wasm/decodal_wasm_bg.wasm and b/site/decodal-site/src/wasm/decodal_wasm_bg.wasm differ diff --git a/site/decodal-site/src/wasm/package.json b/site/decodal-site/src/wasm/package.json index cf1987a..ec5ba71 100644 --- a/site/decodal-site/src/wasm/package.json +++ b/site/decodal-site/src/wasm/package.json @@ -2,7 +2,7 @@ "name": "decodal-wasm", "type": "module", "description": "WebAssembly wrapper for evaluating Decodal in browser playgrounds.", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT OR Apache-2.0", "repository": { "type": "git",