8.8 KiB
Embedding API
Decodal core can be embedded without giving the core crate access to a filesystem.
The host supplies imports through ImportLoader 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.
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, plus the top range Unknown, 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.
use decodal::{EmptyLoader, Engine, Value};
let mut engine = Engine::new(EmptyLoader);
engine.bind_global(
"Service",
Value::object([
("name", Value::string_type()),
("port", Value::int_type().gt(443).default_int(8443)?),
("enabled", Value::bool_type().default_bool(true)?),
]),
)?;
A user source can then refer to Service without importing it.
{
name = "api";
port = 9443;
} as Service
Value
Value is the public builder-facing value representation for embedding.
It keeps host code from constructing internal ThunkId or ObjectValue values directly.
Value =
String
Int
Float
Bool
Array(Vec<Value>)
ArrayRange { item, constraints, default }
MapRange { value, constraints, default }
Object { fields, rest: Option<Value> }
Range { constraints, default }
When a value is bound by the host, the engine internalizes it into RuntimeValue and allocates value thunks for object fields, array items, and defaults.
Value::array_of(item) builds an array constraint with a required element schema.
There is no Value constructor for an unconstrained abstract array.
Value::map_of(value) builds a map constraint whose arbitrary object field values must satisfy value.
BTreeMap<String, T> and, with std, HashMap<String, T> implement DecodalSchema, DecodalDecode, and IntoValue using this representation.
Value::unknown() builds the top abstract range. Value::object_with_rest(fields, rest) builds an object with named fields and a range for all remaining fields.
Value::object_with_rest(
[("enabled", Value::bool_type().default_bool(true)?)],
Value::unknown(),
)
Objects containing ranges
A host-provided schema object is represented as a concrete object structure whose fields may contain ranges.
Value::object([
("name", Value::string_type()),
("port", Value::int_type().gt(443).default_int(8443)?),
])
Conceptually this becomes:
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.
Typed Rust integration
Hosts can enable the derive feature on decodal 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 aValueschema that can be passed toEngine::bind_global.DecodalDecode: decodes materializedDataback into the Rust type.
An explicitly marked map field can receive the open portion of an object.
use std::collections::BTreeMap;
use decodal::{Data, Decodal};
#[derive(Decodal)]
struct OpenConfig {
enabled: bool,
#[decodal(rest)]
extra: BTreeMap<String, Data>,
}
The schema generated for extra is ...Unknown. A typed receiver such as BTreeMap<String, String> generates ...String instead. During decode, named top-level fields are excluded and all remaining fields are collected into the map.
Only one #[decodal(rest)] field is allowed. It must implement DecodalRest; the core provides implementations for BTreeMap<String, T> and, with std, HashMap<String, T>. rename, default, and field constraints cannot be combined with rest. Without a rest receiver, a derived struct remains a closed object range.
use decodal::{Decodal, DecodalDecode, DecodalSchema, EmptyLoader, Engine};
#[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"defaultdefault = 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.
ImportLoader and prelude together
ImportLoader and host prelude bindings are independent mechanisms.
- Use
ImportLoaderwhen user sources should explicitly import host-provided sources or values. - 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.
Shared host environment
An embedded application can implement HostEnvironment to keep loader creation and global binding setup in one place.
Both production evaluation and semantic editor tooling create their engines from this environment, preventing the editor from drifting onto a separate validation path.
use decodal::{Engine, HostEnvironment};
struct AppEnvironment;
impl HostEnvironment for AppEnvironment {
type Loader = ContentLoader;
fn create_loader(&self) -> Self::Loader {
ContentLoader::new()
}
fn configure_engine(
&self,
engine: &mut Engine<Self::Loader>,
) -> decodal::Result<()> {
engine.bind_global("Site", site_schema())?;
Ok(())
}
}
let environment = AppEnvironment;
let mut engine = environment.create_engine()?;
The semantic service accepts the same environment by value:
use decodal_language_service::LanguageService;
let service = LanguageService::new(&environment);
let analysis = service.analyze("site.dcdl", "site.dcdl", source);
Each analysis uses a fresh engine and runs the normal parse, evaluate, and materialize pipeline. An environment may create loaders backed by shared filesystem, database, or editor-overlay state when repeated analysis needs a current workspace snapshot.
The LSP adapter constructs that environment after receiving the client's initialization parameters:
use decodal_lsp::{LspEnvironment, run_stdio};
impl LspEnvironment for AppEnvironment {}
run_stdio(|initialize| {
let _ = initialize;
Ok(AppEnvironment)
})?;
LspEnvironment adds document lifecycle hooks on top of HostEnvironment.
A run_stdio environment factory always receives the client's InitializeParams; the host decides whether to use its workspace folders, initialization options, and capabilities or ignore them.
A host that keeps unsaved buffers in shared state can update them from open_document, change_document, and close_document; every subsequent diagnostic pass creates the normal import loader from that updated environment.
Synchronized non-Decodal documents are passed through these hooks but are not evaluated as Decodal roots, so a loader can parse an unsaved Markdown file into Value and immediately revalidate the open Decodal documents that import it.
Structured imports
ImportLoader::load returns either LoadedImport::Source or LoadedImport::Value.
The value variant carries a Value, allowing a host to parse non-Decodal resources such as Markdown into an application-specific structure.
import "./post.md"
-> host parses content
-> LoadedImport::Value {
key: "content/post.md",
value: { frontmatter: {...}, body: "..." }
}
-> Engine internalizes Value
-> normal composition and materialization
The core does not select content types or bundle Markdown/frontmatter parsers. The loader owns path resolution, media or extension dispatch, parsing rules, and parse diagnostics. The stable loader key is also used to cache structured imports.
When a structured value fails a Decodal constraint, the diagnostic keeps the Decodal constraint span and identifies the imported value by its stable key and logical value path, such as content/post.md and frontmatter.draft.
Value does not need source spans: syntax diagnostics for the external format remain the loader's responsibility, while cross-value validation reports semantic provenance.
load is the single import hook: loaders dispatch by extension, media type, or another host-defined rule and return the appropriate variant directly.