Add host prelude embedding API

This commit is contained in:
Keisuke Hirata 2026-06-16 11:06:56 +09:00
parent 6316939438
commit e23b31da46
No known key found for this signature in database
7 changed files with 427 additions and 3 deletions

View File

@ -0,0 +1,38 @@
use decodal_core::{Data, EmptyLoader, Engine, HostValue};
fn main() -> decodal_core::Result<()> {
let mut engine = Engine::new(EmptyLoader);
engine.bind_global(
"Service",
HostValue::object([
("name", HostValue::string_type()),
("port", HostValue::int_type().gt(443).default_int(8443)?),
("enabled", HostValue::bool_type().default_bool(true)?),
]),
)?;
let module = engine.add_root_source(
"embedded-main",
"embedded-main",
r#"
Service & {
name = "api";
port = 9443;
}
"#,
)?;
let value = engine.eval_module(module)?;
let data = engine.materialize(&value)?;
if let Data::Object(fields) = data {
assert_eq!(fields[0].value, Data::String(String::from("api")));
assert_eq!(fields[1].value, Data::Int(9443));
assert_eq!(fields[2].value, Data::Bool(true));
} else {
panic!("expected object");
}
Ok(())
}

View File

@ -0,0 +1,164 @@
use alloc::{boxed::Box, string::String, vec::Vec};
use crate::runtime::{Constraint, LiteralValue, PrimitiveType};
use crate::{CompareOp, Diagnostic, DiagnosticKind, Result, Span};
#[derive(Debug, Clone, PartialEq)]
pub enum HostValue {
String(String),
Int(i64),
Float(f64),
Bool(bool),
Array(Vec<HostValue>),
Object(Vec<HostField>),
Abstract {
constraints: Vec<Constraint>,
default: Option<Box<HostValue>>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct HostField {
pub name: String,
pub value: HostValue,
}
impl HostValue {
pub fn string(value: impl Into<String>) -> Self {
Self::String(value.into())
}
pub fn int(value: i64) -> Self {
Self::Int(value)
}
pub fn float(value: f64) -> Self {
Self::Float(value)
}
pub fn bool(value: bool) -> Self {
Self::Bool(value)
}
pub fn array<I>(items: I) -> Self
where
I: IntoIterator<Item = HostValue>,
{
Self::Array(items.into_iter().collect())
}
pub fn object<I, N>(fields: I) -> Self
where
I: IntoIterator<Item = (N, HostValue)>,
N: Into<String>,
{
Self::Object(
fields
.into_iter()
.map(|(name, value)| HostField {
name: name.into(),
value,
})
.collect(),
)
}
pub fn string_type() -> Self {
Self::abstract_with_constraint(Constraint::Type(PrimitiveType::String))
}
pub fn int_type() -> Self {
Self::abstract_with_constraint(Constraint::Type(PrimitiveType::Int))
}
pub fn float_type() -> Self {
Self::abstract_with_constraint(Constraint::Type(PrimitiveType::Float))
}
pub fn bool_type() -> Self {
Self::abstract_with_constraint(Constraint::Type(PrimitiveType::Bool))
}
pub fn builtin_predicate(name: impl Into<String>) -> Self {
Self::abstract_with_constraint(Constraint::BuiltinPredicate(name.into()))
}
pub fn abstract_with_constraint(constraint: Constraint) -> Self {
Self::Abstract {
constraints: alloc::vec![constraint],
default: None,
}
}
pub fn with_constraint(mut self, constraint: Constraint) -> Self {
match &mut self {
Self::Abstract { constraints, .. } => constraints.push(constraint),
_ => {
self = Self::Abstract {
constraints: alloc::vec![constraint],
default: Some(Box::new(self)),
};
}
}
self
}
pub fn gt(self, value: i64) -> Self {
self.with_constraint(Constraint::Compare(CompareOp::Gt, LiteralValue::Int(value)))
}
pub fn gte(self, value: i64) -> Self {
self.with_constraint(Constraint::Compare(
CompareOp::Gte,
LiteralValue::Int(value),
))
}
pub fn lt(self, value: i64) -> Self {
self.with_constraint(Constraint::Compare(CompareOp::Lt, LiteralValue::Int(value)))
}
pub fn lte(self, value: i64) -> Self {
self.with_constraint(Constraint::Compare(
CompareOp::Lte,
LiteralValue::Int(value),
))
}
pub fn default(self, value: HostValue) -> Result<Self> {
match self {
Self::Abstract {
constraints,
default: None,
} => Ok(Self::Abstract {
constraints,
default: Some(Box::new(value)),
}),
Self::Abstract { .. } => Err(Diagnostic::new(
DiagnosticKind::DefaultConflict,
Span::default(),
"host value already has a default",
)),
concrete => Ok(Self::Abstract {
constraints: Vec::new(),
default: Some(Box::new(concrete)),
}),
}
}
pub fn default_string(self, value: impl Into<String>) -> Result<Self> {
self.default(Self::string(value))
}
pub fn default_int(self, value: i64) -> Result<Self> {
self.default(Self::int(value))
}
pub fn default_float(self, value: f64) -> Result<Self> {
self.default(Self::float(value))
}
pub fn default_bool(self, value: bool) -> Result<Self> {
self.default(Self::bool(value))
}
}

View File

@ -4,6 +4,7 @@ use crate::{
ExprId, SourceForm, SourceId, Span,
ast::{Ast, BinaryOp, CompareOp, Expr, Field, Literal},
diagnostic::{Diagnostic, DiagnosticKind, Result},
embedding::HostValue,
module::{EmptyLoader, LoadedSource, Module, SourceLoader},
parse_source_with_source_id,
runtime::{
@ -15,6 +16,7 @@ use crate::{
pub struct Engine<L = EmptyLoader> {
loader: L,
prelude_env: EnvId,
modules: Vec<Module>,
thunks: Vec<Thunk>,
envs: Vec<Env>,
@ -39,12 +41,29 @@ impl<L: SourceLoader> Engine<L> {
pub fn new(loader: L) -> Self {
Self {
loader,
prelude_env: EnvId(0),
modules: Vec::new(),
thunks: Vec::new(),
envs: Vec::new(),
envs: vec![Env {
parent: None,
bindings: Vec::new(),
}],
}
}
pub fn bind_global(&mut self, name: impl Into<String>, value: HostValue) -> Result<ThunkId> {
let value = self.internalize_host_value(value)?;
let thunk = self.add_value_thunk(value);
self.bind(self.prelude_env, name.into(), thunk);
Ok(thunk)
}
pub fn bind_global_runtime(&mut self, name: impl Into<String>, value: RuntimeValue) -> ThunkId {
let thunk = self.add_value_thunk(value);
self.bind(self.prelude_env, name.into(), thunk);
thunk
}
pub fn add_root_source(
&mut self,
key: impl Into<String>,
@ -136,7 +155,7 @@ impl<L: SourceLoader> Engine<L> {
source_form: SourceForm,
) -> ModuleId {
let module = ModuleId(self.modules.len() as u32);
let root_env = self.new_env(None);
let root_env = self.new_env(Some(self.prelude_env));
let root_thunk = if source_form == SourceForm::Fields {
if let Expr::Object(fields) = ast.get(root).expr.clone() {
let object = self
@ -699,6 +718,61 @@ impl<L: SourceLoader> Engine<L> {
}
}
fn internalize_host_value(&mut self, value: HostValue) -> Result<RuntimeValue> {
match value {
HostValue::String(value) => Ok(RuntimeValue::Concrete(ConcreteValue::String(value))),
HostValue::Int(value) => Ok(RuntimeValue::Concrete(ConcreteValue::Int(value))),
HostValue::Float(value) => Ok(RuntimeValue::Concrete(ConcreteValue::Float(value))),
HostValue::Bool(value) => Ok(RuntimeValue::Concrete(ConcreteValue::Bool(value))),
HostValue::Array(items) => {
let mut thunks = Vec::new();
for item in items {
let value = self.internalize_host_value(item)?;
thunks.push(self.add_value_thunk(value));
}
Ok(RuntimeValue::Concrete(ConcreteValue::Array(thunks)))
}
HostValue::Object(fields) => {
let mut object = ObjectValue { fields: Vec::new() };
for field in fields {
if object
.fields
.iter()
.any(|existing| existing.name == field.name)
{
return Err(Diagnostic::new(
DiagnosticKind::Conflict,
Span::default(),
format!("duplicate host object field `{}`", field.name),
));
}
let value = self.internalize_host_value(field.value)?;
let value = self.add_value_thunk(value);
object.fields.push(ObjectField {
name: field.name,
value,
});
}
Ok(RuntimeValue::Concrete(ConcreteValue::Object(object)))
}
HostValue::Abstract {
constraints,
default,
} => {
let default = if let Some(default) = default {
let value = self.internalize_host_value(*default)?;
Some(self.add_value_thunk(value))
} else {
None
};
Ok(RuntimeValue::Abstract(AbstractValue {
constraints,
default,
}))
}
}
}
fn force(&mut self, id: ThunkId) -> Result<RuntimeValue> {
let index = id.0 as usize;
match self.thunks[index].state.clone() {
@ -1045,4 +1119,51 @@ mod tests {
assert_eq!(fields[1].name, "result");
assert!(matches!(fields[1].value, Data::Object(_)));
}
#[test]
fn host_prelude_provides_abstract_object() {
let mut engine = Engine::new(EmptyLoader);
engine
.bind_global(
"Service",
HostValue::object([
("name", HostValue::string_type()),
(
"port",
HostValue::int_type().gt(443).default_int(8443).unwrap(),
),
(
"enabled",
HostValue::bool_type().default_bool(true).unwrap(),
),
]),
)
.unwrap();
let module = engine
.add_root_source(
"main",
"main",
r#"Service & { name = "api"; port = 9443; }"#,
)
.unwrap();
let value = engine.eval_module(module).unwrap();
let data = engine.materialize(&value).unwrap();
let Data::Object(fields) = data else { panic!() };
assert_eq!(fields[0].value, Data::String(String::from("api")));
assert_eq!(fields[1].value, Data::Int(9443));
assert_eq!(fields[2].value, Data::Bool(true));
}
#[test]
fn module_binding_shadows_host_prelude() {
let mut engine = Engine::new(EmptyLoader);
engine.bind_global("Service", HostValue::int(1)).unwrap();
let module = engine
.add_root_source("main", "main", r#"Service = "local"; result = Service;"#)
.unwrap();
let value = engine.eval_module(module).unwrap();
let data = engine.materialize(&value).unwrap();
let Data::Object(fields) = data else { panic!() };
assert_eq!(fields[1].value, Data::String(String::from("local")));
}
}

View File

@ -4,6 +4,7 @@ extern crate alloc;
pub mod ast;
pub mod diagnostic;
pub mod embedding;
pub mod eval;
pub mod lexer;
pub mod module;
@ -13,11 +14,12 @@ pub mod span;
pub use ast::{Ast, BinaryOp, CompareOp, Expr, ExprId, Field, Literal, Param};
pub use diagnostic::{Diagnostic, DiagnosticKind, Result};
pub use embedding::{HostField, HostValue};
pub use eval::Engine;
pub use lexer::{Lexer, Token, TokenKind};
pub use module::{EmptyLoader, LoadedSource, Module, SourceLoader};
pub use parser::{ParseOutput, Parser, SourceForm, parse_source, parse_source_with_source_id};
pub use runtime::{Data, ExprRef, ModuleId, RuntimeValue};
pub use runtime::{Constraint, Data, ExprRef, LiteralValue, ModuleId, PrimitiveType, RuntimeValue};
pub use span::{SourceId, Span};
pub fn version() -> &'static str {

View File

@ -0,0 +1,97 @@
# Embedding API
Decodal core can be embedded without giving the core crate access to a filesystem.
The host supplies imported sources through `SourceLoader` and may also provide global bindings through the host prelude API.
## Host prelude
`Engine` owns a prelude environment.
Bindings in this environment are visible from every module loaded by the engine.
```text
prelude env
module root env
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.
## Global bindings
The host can bind values before adding or evaluating user sources.
```rust
use decodal_core::{EmptyLoader, Engine, HostValue};
let mut engine = Engine::new(EmptyLoader);
engine.bind_global(
"Service",
HostValue::object([
("name", HostValue::string_type()),
("port", HostValue::int_type().gt(443).default_int(8443)?),
("enabled", HostValue::bool_type().default_bool(true)?),
]),
)?;
```
A user source can then refer to `Service` without importing it.
```dcdl
Service & {
name = "api";
port = 9443;
}
```
## HostValue
`HostValue` is the public builder-facing value representation for embedding.
It keeps host code from constructing internal `ThunkId` or `ObjectValue` values directly.
```text
HostValue =
String
Int
Float
Bool
Array(Vec<HostValue>)
Object(Vec<HostField>)
Abstract { constraints, default }
```
When a host value is bound, the engine internalizes it into `RuntimeValue` and allocates value thunks for object fields, array items, and defaults.
## Abstract host objects
A host-provided schema object is represented as a concrete object structure whose fields may contain abstract values.
```rust
HostValue::object([
("name", HostValue::string_type()),
("port", HostValue::int_type().gt(443).default_int(8443)?),
])
```
Conceptually this becomes:
```text
Concrete(Object {
name -> Thunk(Abstract { constraints: [String], default: none })
port -> Thunk(Abstract { constraints: [Int, > 443], default: 8443 })
})
```
This matches the runtime model used for Decodal source-defined schema objects.
## SourceLoader and prelude together
`SourceLoader` and host prelude bindings are independent mechanisms.
- Use `SourceLoader` when user sources should explicitly import host-provided modules.
- Use prelude bindings when host-provided schemas or constants should be globally available.
Both mechanisms share the same runtime evaluator, thunk model, and materialization rules.

View File

@ -15,3 +15,4 @@ bytecode VM や JIT ではなく、AST を demand-driven に評価すること
3. [Thunk and Lazy Evaluation](./thunk-and-lazy-evaluation.md)
4. [Composition and Materialization](./composition-and-materialization.md)
5. [Diagnostics and Fallback](./diagnostics-and-fallback.md)
6. [Embedding API](./embedding-api.md)

View File

@ -41,4 +41,5 @@ Decodal は Deferred Constraint Data Language、略称 DCDL のプロジェク
3. [Thunk and Lazy Evaluation](./design/thunk-and-lazy-evaluation.md)
4. [Composition and Materialization](./design/composition-and-materialization.md)
5. [Diagnostics and Fallback](./design/diagnostics-and-fallback.md)
6. [Embedding API](./design/embedding-api.md)
4. [Open Issues](./open-issues.md)