Add typed Decodal derive support
This commit is contained in:
parent
87fef44c68
commit
b8404df047
16
Cargo.lock
generated
16
Cargo.lock
generated
|
|
@ -25,21 +25,31 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decodal"
|
name = "decodal"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decodal-cli"
|
name = "decodal-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"decodal",
|
"decodal",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "decodal-derive"
|
||||||
|
version = "0.1.1"
|
||||||
|
dependencies = [
|
||||||
|
"decodal",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decodal-wasm"
|
name = "decodal-wasm"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"decodal",
|
"decodal",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ members = [
|
||||||
"crates/decodal-core",
|
"crates/decodal-core",
|
||||||
"crates/decodal-cli",
|
"crates/decodal-cli",
|
||||||
"crates/decodal-wasm",
|
"crates/decodal-wasm",
|
||||||
|
"crates/decodal-derive",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -20,6 +20,35 @@ Embedded hosts should depend on `decodal` and provide imports with a `SourceLoad
|
||||||
decodal = "0.1"
|
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
|
## CLI
|
||||||
|
|
||||||
A standalone CLI is kept in this repository as the `decodal-cli` workspace package.
|
A standalone CLI is kept in this repository as the `decodal-cli` workspace package.
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,4 @@ default = []
|
||||||
regex = ["decodal/regex"]
|
regex = ["decodal/regex"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
decodal = { version = "0.1.0", path = "../decodal-core" }
|
decodal = { version = "0.1.1", path = "../decodal-core" }
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,10 @@ pub fn normalize_constraints(
|
||||||
|
|
||||||
if matches!(
|
if matches!(
|
||||||
primitive,
|
primitive,
|
||||||
Some((PrimitiveType::String | PrimitiveType::Bool, _))
|
Some((
|
||||||
|
PrimitiveType::String | PrimitiveType::Bool | PrimitiveType::Array,
|
||||||
|
_
|
||||||
|
))
|
||||||
) && (lower.is_some() || upper.is_some())
|
) && (lower.is_some() || upper.is_some())
|
||||||
{
|
{
|
||||||
let mut diagnostic = Diagnostic::new(
|
let mut diagnostic = Diagnostic::new(
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,18 @@ impl HostValue {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn object_from_paths<I, N>(fields: I) -> Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = (N, HostValue)>,
|
||||||
|
N: Into<String>,
|
||||||
|
{
|
||||||
|
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 {
|
pub fn string_type() -> Self {
|
||||||
Self::abstract_with_constraint(Constraint::Type(PrimitiveType::String))
|
Self::abstract_with_constraint(Constraint::Type(PrimitiveType::String))
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +91,10 @@ impl HostValue {
|
||||||
Self::abstract_with_constraint(Constraint::Type(PrimitiveType::Bool))
|
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<String>) -> Self {
|
pub fn builtin_predicate(name: impl Into<String>) -> Self {
|
||||||
Self::abstract_with_constraint(Constraint::BuiltinPredicate(name.into()))
|
Self::abstract_with_constraint(Constraint::BuiltinPredicate(name.into()))
|
||||||
}
|
}
|
||||||
|
|
@ -162,3 +178,38 @@ impl HostValue {
|
||||||
self.default(Self::bool(value))
|
self.default(Self::bool(value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl HostField {
|
||||||
|
pub fn new(name: impl Into<String>, value: HostValue) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_path(fields: &mut Vec<HostField>, 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1170,6 +1170,7 @@ fn primitive_type(name: &str) -> Option<PrimitiveType> {
|
||||||
"Int" => Some(PrimitiveType::Int),
|
"Int" => Some(PrimitiveType::Int),
|
||||||
"Float" => Some(PrimitiveType::Float),
|
"Float" => Some(PrimitiveType::Float),
|
||||||
"Bool" => Some(PrimitiveType::Bool),
|
"Bool" => Some(PrimitiveType::Bool),
|
||||||
|
"Array" => Some(PrimitiveType::Array),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1250,6 +1251,9 @@ fn value_matches_primitive(value: &RuntimeValue, primitive: PrimitiveType) -> bo
|
||||||
) | (
|
) | (
|
||||||
RuntimeValue::Concrete(ConcreteValue::Bool(_)),
|
RuntimeValue::Concrete(ConcreteValue::Bool(_)),
|
||||||
PrimitiveType::Bool
|
PrimitiveType::Bool
|
||||||
|
) | (
|
||||||
|
RuntimeValue::Concrete(ConcreteValue::Array(_)),
|
||||||
|
PrimitiveType::Array
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ pub mod module;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod span;
|
pub mod span;
|
||||||
|
pub mod typed;
|
||||||
|
|
||||||
pub use ast::{Ast, BinaryOp, CompareOp, Expr, ExprId, Field, Literal, Param};
|
pub use ast::{Ast, BinaryOp, CompareOp, Expr, ExprId, Field, Literal, Param};
|
||||||
pub use constraints::normalize_constraints;
|
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 parser::{ParseOutput, Parser, SourceForm, parse_source, parse_source_with_source_id};
|
||||||
pub use runtime::{Constraint, Data, ExprRef, LiteralValue, ModuleId, PrimitiveType, RuntimeValue};
|
pub use runtime::{Constraint, Data, ExprRef, LiteralValue, ModuleId, PrimitiveType, RuntimeValue};
|
||||||
pub use span::{SourceId, Span};
|
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 {
|
pub fn version() -> &'static str {
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ pub enum PrimitiveType {
|
||||||
Int,
|
Int,
|
||||||
Float,
|
Float,
|
||||||
Bool,
|
Bool,
|
||||||
|
Array,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
|
|
||||||
227
crates/decodal-core/src/typed.rs
Normal file
227
crates/decodal-core/src/typed.rs
Normal file
|
|
@ -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<Self>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IntoHostValue {
|
||||||
|
fn into_host_value(self) -> HostValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type DecodeResult<T> = core::result::Result<T, DecodeError>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DecodeError {
|
||||||
|
pub path: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecodeError {
|
||||||
|
pub fn new(path: impl Into<String>, message: impl Into<String>) -> 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<T: DecodalDecode>(data: &Data, path: &str) -> DecodeResult<T> {
|
||||||
|
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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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<T> DecodalSchema for Vec<T> {
|
||||||
|
fn decodal_schema() -> HostValue {
|
||||||
|
HostValue::array_type()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: DecodalDecode> DecodalDecode for Vec<T> {
|
||||||
|
fn decodal_decode(data: &Data) -> DecodeResult<Self> {
|
||||||
|
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<T: IntoHostValue> IntoHostValue for Vec<T> {
|
||||||
|
fn into_host_value(self) -> HostValue {
|
||||||
|
HostValue::array(self.into_iter().map(IntoHostValue::into_host_value))
|
||||||
|
}
|
||||||
|
}
|
||||||
22
crates/decodal-derive/Cargo.toml
Normal file
22
crates/decodal-derive/Cargo.toml
Normal file
|
|
@ -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" }
|
||||||
212
crates/decodal-derive/src/lib.rs
Normal file
212
crates/decodal-derive/src/lib.rs
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
crates/decodal-derive/tests/derive.rs
Normal file
79
crates/decodal-derive/tests/derive.rs
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ publish = false
|
||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
decodal = { version = "0.1.0", path = "../decodal-core" }
|
decodal = { version = "0.1.1", path = "../decodal-core" }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
wasm-bindgen.workspace = true
|
wasm-bindgen.workspace = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ let / function env
|
||||||
```
|
```
|
||||||
|
|
||||||
Module top-level bindings shadow prelude bindings.
|
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
|
## Global bindings
|
||||||
|
|
||||||
|
|
@ -87,6 +87,47 @@ Concrete(Object {
|
||||||
|
|
||||||
This matches the runtime model used for Decodal source-defined schema objects.
|
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 prelude together
|
||||||
|
|
||||||
`SourceLoader` and host prelude bindings are independent mechanisms.
|
`SourceLoader` and host prelude bindings are independent mechanisms.
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,13 @@ cargo run -q -p decodal-cli --features regex -- examples/regex/main.dcdl
|
||||||
## crates.io release
|
## crates.io release
|
||||||
|
|
||||||
The primary crates.io package is `decodal`, which contains the embeddable library.
|
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.
|
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`.
|
The project is dual licensed as `MIT OR Apache-2.0`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo publish -p decodal
|
cargo publish -p decodal
|
||||||
|
cargo publish -p decodal-derive
|
||||||
```
|
```
|
||||||
|
|
||||||
Before publishing, run:
|
Before publishing, run:
|
||||||
|
|
@ -37,6 +39,7 @@ cargo fmt --check
|
||||||
cargo test
|
cargo test
|
||||||
cargo check -p decodal --no-default-features
|
cargo check -p decodal --no-default-features
|
||||||
cargo publish -p decodal --dry-run
|
cargo publish -p decodal --dry-run
|
||||||
|
cargo publish -p decodal-derive --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
## Web site and playground
|
## Web site and playground
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@ name = String;
|
||||||
retry = Int default 3;
|
retry = Int default 3;
|
||||||
ratio = Float;
|
ratio = Float;
|
||||||
enable = Bool default true;
|
enable = Bool default true;
|
||||||
|
tags = Array;
|
||||||
```
|
```
|
||||||
|
|
||||||
現在の primitive type は `String`、`Int`、`Float`、`Bool` である。
|
現在の primitive type は `String`、`Int`、`Float`、`Bool`、`Array` である。
|
||||||
各型の個別仕様へのリンクは [Manual Index](../../index.md) に集約する。
|
各型の個別仕様へのリンクは [Manual Index](../../index.md) に集約する。
|
||||||
|
|
||||||
primitive type と制約合成の詳細は [制約と default](../constraints-and-defaults.md) も参照する。
|
primitive type と制約合成の詳細は [制約と default](../constraints-and-defaults.md) も参照する。
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ let
|
||||||
in
|
in
|
||||||
rustPlatform.buildRustPackage {
|
rustPlatform.buildRustPackage {
|
||||||
pname = "decodal";
|
pname = "decodal";
|
||||||
version = "0.1.0";
|
version = "0.1.1";
|
||||||
|
|
||||||
src = lib.cleanSourceWith {
|
src = lib.cleanSourceWith {
|
||||||
src = srcRoot;
|
src = srcRoot;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,35 @@ Embedded hosts should depend on `decodal` and provide imports with a `SourceLoad
|
||||||
decodal = "0.1"
|
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
|
## CLI
|
||||||
|
|
||||||
A standalone CLI is kept in this repository as the `decodal-cli` workspace package.
|
A standalone CLI is kept in this repository as the `decodal-cli` workspace package.
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "decodal-wasm",
|
"name": "decodal-wasm",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "WebAssembly wrapper for evaluating Decodal in browser playgrounds.",
|
"description": "WebAssembly wrapper for evaluating Decodal in browser playgrounds.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user