From e23b31da46e9781249e7b50f5974b6c1e04a0c4f Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 16 Jun 2026 11:06:56 +0900 Subject: [PATCH] Add host prelude embedding API --- crates/decodal-core/examples/host_prelude.rs | 38 +++++ crates/decodal-core/src/embedding.rs | 164 +++++++++++++++++++ crates/decodal-core/src/eval.rs | 125 +++++++++++++- crates/decodal-core/src/lib.rs | 4 +- doc/manual/souce/design/embedding-api.md | 97 +++++++++++ doc/manual/souce/design/index.md | 1 + doc/manual/souce/index.md | 1 + 7 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 crates/decodal-core/examples/host_prelude.rs create mode 100644 crates/decodal-core/src/embedding.rs create mode 100644 doc/manual/souce/design/embedding-api.md diff --git a/crates/decodal-core/examples/host_prelude.rs b/crates/decodal-core/examples/host_prelude.rs new file mode 100644 index 0000000..640bcbe --- /dev/null +++ b/crates/decodal-core/examples/host_prelude.rs @@ -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(()) +} diff --git a/crates/decodal-core/src/embedding.rs b/crates/decodal-core/src/embedding.rs new file mode 100644 index 0000000..57654b3 --- /dev/null +++ b/crates/decodal-core/src/embedding.rs @@ -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), + Object(Vec), + Abstract { + constraints: Vec, + default: Option>, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct HostField { + pub name: String, + pub value: HostValue, +} + +impl HostValue { + pub fn string(value: impl Into) -> 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(items: I) -> Self + where + I: IntoIterator, + { + Self::Array(items.into_iter().collect()) + } + + pub fn object(fields: I) -> Self + where + I: IntoIterator, + N: Into, + { + 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) -> 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 { + 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) -> Result { + self.default(Self::string(value)) + } + + pub fn default_int(self, value: i64) -> Result { + self.default(Self::int(value)) + } + + pub fn default_float(self, value: f64) -> Result { + self.default(Self::float(value)) + } + + pub fn default_bool(self, value: bool) -> Result { + self.default(Self::bool(value)) + } +} diff --git a/crates/decodal-core/src/eval.rs b/crates/decodal-core/src/eval.rs index 770ab56..d4ed2f7 100644 --- a/crates/decodal-core/src/eval.rs +++ b/crates/decodal-core/src/eval.rs @@ -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 { loader: L, + prelude_env: EnvId, modules: Vec, thunks: Vec, envs: Vec, @@ -39,12 +41,29 @@ impl Engine { 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, value: HostValue) -> Result { + 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, 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, @@ -136,7 +155,7 @@ impl Engine { 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 Engine { } } + fn internalize_host_value(&mut self, value: HostValue) -> Result { + 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 { 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"))); + } } diff --git a/crates/decodal-core/src/lib.rs b/crates/decodal-core/src/lib.rs index 542d985..cfc4ea1 100644 --- a/crates/decodal-core/src/lib.rs +++ b/crates/decodal-core/src/lib.rs @@ -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 { diff --git a/doc/manual/souce/design/embedding-api.md b/doc/manual/souce/design/embedding-api.md new file mode 100644 index 0000000..9d39e2f --- /dev/null +++ b/doc/manual/souce/design/embedding-api.md @@ -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) + Object(Vec) + 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. diff --git a/doc/manual/souce/design/index.md b/doc/manual/souce/design/index.md index 01f4563..31ae7c6 100644 --- a/doc/manual/souce/design/index.md +++ b/doc/manual/souce/design/index.md @@ -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) diff --git a/doc/manual/souce/index.md b/doc/manual/souce/index.md index 4e01a2e..385281a 100644 --- a/doc/manual/souce/index.md +++ b/doc/manual/souce/index.md @@ -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)