From 230d417f6807a86cdd99852fa9e9fdd7c9b8a977 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 21:22:48 +0000 Subject: [PATCH] refactor: extract world synthesis into nexum-world and de-hardcode the known table The capability table and per-module world synthesis move into a new plain nexum-world library carrying only the core nexum:host rows. Per-namespace rows come from the composition root's extensions.toml registry, parsed and passed in by the macro layer, so no host crate carries a downstream name; synthesis rejects a row that shadows a core capability or another registration. WIT packages resolve crate-locally (wit/deps, then wit/) with an ancestor fallback for the transitional monorepo layout. --- Cargo.lock | 10 +- Cargo.toml | 1 + crates/nexum-macros/Cargo.toml | 2 +- crates/nexum-macros/src/lib.rs | 98 +++--- crates/nexum-macros/src/world.rs | 334 ++---------------- crates/nexum-world/Cargo.toml | 16 + crates/nexum-world/src/lib.rs | 579 +++++++++++++++++++++++++++++++ extensions.toml | 13 + 8 files changed, 687 insertions(+), 366 deletions(-) create mode 100644 crates/nexum-world/Cargo.toml create mode 100644 crates/nexum-world/src/lib.rs create mode 100644 extensions.toml diff --git a/Cargo.lock b/Cargo.lock index 8f3c72a1..0b867ea8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3579,10 +3579,10 @@ dependencies = [ name = "nexum-macros" version = "0.1.0" dependencies = [ + "nexum-world", "proc-macro2", "quote", "syn 2.0.118", - "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -3699,6 +3699,14 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "nexum-world" +version = "0.1.0" +dependencies = [ + "tempfile", + "toml 1.1.2+spec-1.1.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/Cargo.toml b/Cargo.toml index 2519fd97..d4ee5eb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/nexum-tasks", "crates/nexum-venue-sdk", "crates/nexum-venue-test", + "crates/nexum-world", "crates/shepherd-backtest", "crates/shepherd-cow-host", "crates/shepherd-sdk", diff --git a/crates/nexum-macros/Cargo.toml b/crates/nexum-macros/Cargo.toml index ad74099f..ec7785bd 100644 --- a/crates/nexum-macros/Cargo.toml +++ b/crates/nexum-macros/Cargo.toml @@ -13,7 +13,7 @@ proc-macro = true workspace = true [dependencies] +nexum-world = { path = "../nexum-world" } proc-macro2.workspace = true quote.workspace = true syn = { workspace = true, features = ["full"] } -toml.workspace = true diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index 328f3090..72e243a0 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -22,8 +22,6 @@ mod intent_body; mod world; -use std::path::Path; - use proc_macro::TokenStream; use quote::quote; use syn::{DeriveInput, ImplItem, ItemImpl, Type}; @@ -87,8 +85,9 @@ const HANDLERS: [&str; 6] = [ /// The other non-obvious invariant: the wit-bindgen output (`Guest`, /// `Fault`, the `nexum::host::*` modules) lands at the module crate /// root, so the emitted glue and the handler bodies resolve those names -/// there; the WIT package directories are located by walking up from -/// `CARGO_MANIFEST_DIR`. Two corollaries: the consuming crate must +/// there; the WIT package directories resolve against the crate's own +/// `wit/` and `wit/deps/`, then the nearest ancestor carrying the +/// package. Two corollaries: the consuming crate must /// declare `wit-bindgen` as a direct dependency (the emitted /// `wit_bindgen::generate!` call resolves against the consumer's /// namespace), and the crate root must not shadow std prelude names @@ -175,7 +174,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { } let has = |name: &str| present.contains(&name); - let (manifest_path, module_world) = match derive_module_world() { + let (anchors, module_world) = match derive_module_world() { Ok(parts) => parts, Err(msg) => { return syn::Error::new(proc_macro2::Span::call_site(), msg) @@ -234,9 +233,10 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { let intent_status_arm = arm("on_intent_status", "IntentStatus"); quote! { - // Anchor a rebuild on the manifest: the emitted world is derived - // from it, so an edited [capabilities] must recompile the module. - const _: &[u8] = ::core::include_bytes!(#manifest_path); + // Anchor a rebuild on the manifest and the extension registry: + // the emitted world is derived from them, so an edit to either + // must recompile the module. + #(const _: &[u8] = ::core::include_bytes!(#anchors);)* wit_bindgen::generate!({ inline: #inline_world, @@ -483,9 +483,7 @@ fn is_plain_type(ty: &Type) -> bool { /// anchor). Shared by the module and venue worlds, which differ only in /// how they turn the declarations into a world. fn read_manifest_capabilities(attribute: &str) -> Result<(String, Vec), String> { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") - .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string())?; - let manifest_path = Path::new(&manifest_dir).join("module.toml"); + let manifest_path = manifest_dir()?.join("module.toml"); let text = std::fs::read_to_string(&manifest_path).map_err(|e| { format!( "could not read {} ({e}); {attribute} derives the component's WIT world from the \ @@ -498,13 +496,36 @@ fn read_manifest_capabilities(attribute: &str) -> Result<(String, Vec), Ok((manifest_path.to_string_lossy().into_owned(), declared)) } +/// The consuming crate's manifest directory, the root every crate-local +/// lookup starts from. +fn manifest_dir() -> Result { + std::env::var("CARGO_MANIFEST_DIR") + .map(std::path::PathBuf::from) + .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) +} + /// Read the consuming crate's `module.toml` and synthesize the -/// per-module world from its `[capabilities]` declarations. Returns the -/// manifest path (for the rebuild anchor) alongside the world. -fn derive_module_world() -> Result<(String, world::ModuleWorld), String> { +/// per-module world from its `[capabilities]` declarations plus the +/// extension rows registered in the nearest ancestor `extensions.toml`. +/// Returns the rebuild anchor paths (the manifest, then the registry +/// when one exists) alongside the world. +fn derive_module_world() -> Result<(Vec, world::ModuleWorld), String> { let (manifest_path, declared) = read_manifest_capabilities("#[nexum_sdk::module]")?; - let module_world = world::synthesize(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; - Ok((manifest_path, module_world)) + let mut anchors = vec![manifest_path.clone()]; + let extensions = match world::find_extensions_manifest(&manifest_dir()?) { + None => Vec::new(), + Some(registry) => { + let text = std::fs::read_to_string(®istry) + .map_err(|e| format!("could not read {}: {e}", registry.display()))?; + let rows = world::manifest_extensions(&text) + .map_err(|e| format!("{}: {e}", registry.display()))?; + anchors.push(registry.to_string_lossy().into_owned()); + rows + } + }; + let module_world = + world::synthesize(&declared, &extensions).map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((anchors, module_world)) } /// Read the consuming crate's `module.toml` and synthesize the @@ -518,39 +539,14 @@ fn derive_venue_world() -> Result<(String, world::ModuleWorld), String> { Ok((manifest_path, venue_world)) } -/// Locate the workspace `wit/` root (the ancestor directory whose `wit/` -/// contains the `nexum-host` package) and resolve each needed package -/// directory under it. -fn resolve_wit_packages(packages: &[&str]) -> Result, String> { - let manifest = std::env::var("CARGO_MANIFEST_DIR") - .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string())?; - let mut dir: Option<&Path> = Some(Path::new(&manifest)); - let root = loop { - let Some(cur) = dir else { - return Err(format!( - "could not find a `wit/` directory containing `nexum-host` in any ancestor \ - of {manifest}" - )); - }; - let wit = cur.join("wit"); - if wit.join("nexum-host").is_dir() { - break wit; - } - dir = cur.parent(); - }; - packages - .iter() - .map(|package| { - let path = root.join(package); - if path.is_dir() { - Ok(path.to_string_lossy().into_owned()) - } else { - Err(format!( - "declared capabilities need the `{package}` WIT package, but {} is not \ - a directory", - path.display() - )) - } - }) - .collect() +/// Resolve each needed WIT package directory crate-locally (vendored +/// `wit/deps/`, then own `wit/`), falling back through +/// ancestors for the transitional monorepo layout. +fn resolve_wit_packages(packages: &[String]) -> Result, String> { + Ok( + nexum_world::resolve_wit_packages(&manifest_dir()?, packages)? + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + ) } diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs index acae4eb1..82de0717 100644 --- a/crates/nexum-macros/src/world.rs +++ b/crates/nexum-macros/src/world.rs @@ -1,143 +1,11 @@ -//! Per-module world synthesis: turn the manifest's `[capabilities]` -//! declarations into an inline WIT world whose imports are exactly the -//! declared capability interfaces. -//! -//! The one non-obvious invariant: the capability table here must agree -//! with the runtime's capability registry (`nexum-runtime`'s manifest -//! enforcement) on both the capability names and the WIT interfaces they -//! map to. The runtime cross-checks a component's imports against the -//! manifest at load time; because this module derives the imports from -//! the same manifest, a component built through `#[nexum_sdk::module]` -//! passes that check by construction rather than by relying on the -//! toolchain eliding unused imports. +//! World wiring for the macros: the venue-adapter world synthesis. The +//! module world synthesis, the core capability table, and the extension +//! registry parsing (`extensions.toml`, the composition root's data) +//! live in `nexum-world`, so no crate here carries a downstream name. -use std::fmt::Write as _; - -/// One manifest capability and its world wiring. -struct Capability { - /// The name declared under `[capabilities].required` / `optional`. - name: &'static str, - /// The WIT import the declaration turns into, or `None` for - /// capabilities with no world import (`http` is granted through the - /// SDK's wasi:http client and the host allowlist, not the world). - import: Option<&'static str>, - /// WIT package directories (under the workspace `wit/` root) the - /// import needs on the resolve path, beyond `nexum-host`. - packages: &'static [&'static str], - /// The `bind_host_via_wit_bindgen!` capability ident carrying this - /// capability's host-adapter pieces, if the SDK has a trait seam - /// for it. - adapter: Option<&'static str>, -} - -/// Every capability the macro recognises, in emission order. Mirrors -/// the runtime's core registry plus the extension namespaces the -/// workspace ships (`videre:venue/client`, `shepherd:cow/cow-api`). -const KNOWN: &[Capability] = &[ - Capability { - name: "chain", - import: Some("nexum:host/chain@0.1.0"), - packages: &[], - adapter: Some("chain"), - }, - Capability { - name: "identity", - import: Some("nexum:host/identity@0.1.0"), - packages: &[], - adapter: None, - }, - Capability { - name: "local-store", - import: Some("nexum:host/local-store@0.1.0"), - packages: &[], - adapter: Some("local_store"), - }, - Capability { - name: "remote-store", - import: Some("nexum:host/remote-store@0.1.0"), - packages: &[], - adapter: None, - }, - Capability { - name: "messaging", - import: Some("nexum:host/messaging@0.1.0"), - packages: &[], - adapter: None, - }, - Capability { - name: "logging", - import: Some("nexum:host/logging@0.1.0"), - packages: &[], - adapter: Some("logging"), - }, - Capability { - name: "client", - import: Some("videre:venue/client@0.1.0"), - packages: &["videre-value-flow", "videre-types", "videre-venue"], - adapter: None, - }, - Capability { - name: "cow-api", - import: Some("shepherd:cow/cow-api@0.1.0"), - packages: &["shepherd-cow"], - adapter: None, - }, - Capability { - name: "http", - import: None, - packages: &[], - adapter: None, - }, -]; - -/// The synthesized world plus what the `generate!` call and the host -/// adapter need to go with it. -#[derive(Debug)] -pub struct ModuleWorld { - /// Inline WIT text defining `nexum:module-world/module`. - pub wit: String, - /// WIT package directories (relative to the workspace `wit/` root) - /// the resolve path must carry, in dependency order (a package - /// precedes its dependants). Always starts with the base set the - /// host `event` variant needs. - pub packages: Vec<&'static str>, - /// Capability idents to pass to `bind_host_via_wit_bindgen!`. - pub adapters: Vec<&'static str>, -} - -/// Extract the declared capability names (`required` then `optional`) -/// from the manifest text. A missing or malformed `[capabilities]` -/// section is an error: the emitted world is derived from it, so the -/// macro has nothing to build from without one. -pub fn manifest_capabilities(text: &str) -> Result, String> { - let value: toml::Table = text - .parse() - .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; - let caps = value.get("capabilities").ok_or_else(|| { - "module.toml has no [capabilities] section; the module/adapter macro derives the \ - component's WIT world from [capabilities].required/optional, so declare it (an empty \ - `required = []` is valid)" - .to_string() - })?; - let list = |key: &str| -> Result, String> { - match caps.get(key) { - None => Ok(Vec::new()), - Some(v) => v - .as_array() - .ok_or_else(|| format!("[capabilities].{key} must be an array of strings"))? - .iter() - .map(|item| { - item.as_str() - .map(str::to_owned) - .ok_or_else(|| format!("[capabilities].{key} must contain only strings")) - }) - .collect(), - } - }; - let mut names = list("required")?; - names.extend(list("optional")?); - Ok(names) -} +pub use nexum_world::{ + ModuleWorld, find_extensions_manifest, manifest_capabilities, manifest_extensions, synthesize, +}; /// Capabilities a venue adapter may import. A venue speaks one venue's /// protocol over scoped transport and nothing else: chain RPC, @@ -171,27 +39,30 @@ pub fn synthesize_venue(declared: &[String]) -> Result { // value-flow vocabulary they are expressed in) needs the videre // packages on the resolve path beyond the leaf host package, in // dependency order: a package precedes its dependants. - let mut packages = vec![ + let mut packages: Vec = [ "videre-value-flow", "videre-types", "nexum-host", "videre-venue", - ]; - for cap in KNOWN { + ] + .map(str::to_owned) + .into(); + for cap in nexum_world::CORE { if !declared.iter().any(|d| d == cap.name) { continue; } if let Some(import) = cap.import { - writeln!(imports, " import {import};").expect("write to String"); + imports.push_str(&format!(" import {import};\n")); } - // Accumulate any extra WIT packages a venue capability needs, exactly - // as `synthesize` does. All venue-permitted capabilities are - // packageless today, so this leaves the base set untouched; mirroring - // the loop keeps a future venue capability from silently failing to - // reach its package onto the resolve path. + // Accumulate any extra WIT packages a venue capability needs, + // exactly as the module synthesis does. All venue-permitted + // capabilities are packageless today, so this leaves the base set + // untouched; mirroring the loop keeps a future venue capability + // from silently failing to reach its package onto the resolve + // path. for package in cap.packages { - if !packages.contains(package) { - packages.push(package); + if !packages.iter().any(|p| p == package) { + packages.push((*package).to_owned()); } } } @@ -216,72 +87,10 @@ pub fn synthesize_venue(declared: &[String]) -> Result { }) } -/// Build the per-module world from the declared capability names -/// (required and optional alike: an optional capability must still be -/// importable, the host decides at load time whether to back or stub -/// it). Unknown names are a compile error so a typo cannot silently -/// drop an import. -pub fn synthesize(declared: &[String]) -> Result { - for name in declared { - if !KNOWN.iter().any(|c| c.name == name.as_str()) { - let known = KNOWN.iter().map(|c| c.name).collect::>().join(", "); - return Err(format!( - "unknown capability `{name}` in module.toml [capabilities]; expected one of: \ - {known}" - )); - } - } - - let mut imports = String::new(); - // `nexum:host` is a leaf package (the `event` variant carries an - // intent-status transition as opaque bytes), so the base resolve set - // is the host package alone; capability declarations append their - // own packages. Dependency order: each directory is parsed against - // the packages before it, so a package precedes its dependants. - let mut packages = vec!["nexum-host"]; - let mut adapters = Vec::new(); - for cap in KNOWN { - if !declared.iter().any(|d| d == cap.name) { - continue; - } - if let Some(import) = cap.import { - writeln!(imports, " import {import};").expect("write to String"); - } - for package in cap.packages { - if !packages.contains(package) { - packages.push(package); - } - } - if let Some(adapter) = cap.adapter { - adapters.push(adapter); - } - } - - let mut wit = String::from( - "package nexum:module-world;\n\nworld module {\n \ - use nexum:host/types@0.1.0.{config, event, fault};\n\n", - ); - wit.push_str(&imports); - wit.push_str( - "\n export init: func(config: config) -> result<_, fault>;\n \ - export on-event: func(event: event) -> result<_, fault>;\n}\n", - ); - - Ok(ModuleWorld { - wit, - packages, - adapters, - }) -} - #[cfg(test)] mod tests { use super::*; - /// The base package set every module world resolves against: - /// `nexum:host` is a leaf package, so it stands alone. - const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; - /// The package set every venue world resolves against: the exported /// adapter face pulls the videre vocabulary, in dependency order. const VENUE_PACKAGES: [&str; 4] = [ @@ -291,60 +100,6 @@ mod tests { "videre-venue", ]; - #[test] - fn logging_only_world_imports_logging_alone() { - let world = synthesize(&["logging".to_string()]).unwrap(); - assert!(world.wit.contains("import nexum:host/logging@0.1.0;")); - assert!(!world.wit.contains("import nexum:host/chain")); - assert!(!world.wit.contains("shepherd:cow")); - assert_eq!(world.packages, MODULE_PACKAGES); - assert_eq!(world.adapters, vec!["logging"]); - } - - #[test] - fn cow_api_pulls_the_shepherd_cow_package() { - let world = synthesize(&["logging".to_string(), "cow-api".to_string()]).unwrap(); - assert!(world.wit.contains("import shepherd:cow/cow-api@0.1.0;")); - assert_eq!(world.packages, vec!["nexum-host", "shepherd-cow"]); - } - - #[test] - fn client_pulls_the_videre_packages() { - let world = synthesize(&["client".to_string()]).unwrap(); - assert!(world.wit.contains("import videre:venue/client@0.1.0;")); - assert_eq!( - world.packages, - vec![ - "nexum-host", - "videre-value-flow", - "videre-types", - "videre-venue" - ] - ); - assert!(world.adapters.is_empty()); - } - - #[test] - fn http_declares_no_world_import() { - let world = synthesize(&["logging".to_string(), "http".to_string()]).unwrap(); - assert!(!world.wit.contains("wasi:http")); - assert_eq!(world.packages, MODULE_PACKAGES); - } - - #[test] - fn duplicate_declarations_emit_one_import() { - let world = synthesize(&["chain".to_string(), "chain".to_string()]).unwrap(); - assert_eq!(world.wit.matches("import nexum:host/chain").count(), 1); - assert_eq!(world.adapters, vec!["chain"]); - } - - #[test] - fn unknown_capability_is_rejected_with_the_known_list() { - let err = synthesize(&["telepathy".to_string()]).unwrap_err(); - assert!(err.contains("unknown capability `telepathy`")); - assert!(err.contains("logging")); - } - #[test] fn venue_world_exports_the_adapter_face() { let world = synthesize_venue(&["chain".to_string()]).unwrap(); @@ -400,51 +155,4 @@ mod tests { assert!(err.contains("venue adapter"), "message was: {err}"); } } - - #[test] - fn manifest_capabilities_reads_required_and_optional() { - let caps = manifest_capabilities( - r#" -[capabilities] -required = ["logging", "chain"] -optional = ["remote-store"] - -[capabilities.http] -allow = [] -"#, - ) - .unwrap(); - assert_eq!(caps, vec!["logging", "chain", "remote-store"]); - } - - #[test] - fn manifest_without_capabilities_section_is_an_error() { - let err = manifest_capabilities("[module]\nname = \"x\"\n").unwrap_err(); - assert!(err.contains("[capabilities]")); - } - - #[test] - fn manifest_with_non_string_capability_is_an_error() { - let err = manifest_capabilities("[capabilities]\nrequired = [1]\n").unwrap_err(); - assert!(err.contains("only strings")); - } - - #[test] - fn world_is_valid_wit_shape() { - // Not a full WIT parse (that is the module build's job); pin the - // structural pieces the runtime contract depends on. - let world = synthesize(&["logging".to_string()]).unwrap(); - assert!(world.wit.starts_with("package nexum:module-world;")); - assert!(world.wit.contains("world module {")); - assert!( - world - .wit - .contains("export init: func(config: config) -> result<_, fault>;") - ); - assert!( - world - .wit - .contains("export on-event: func(event: event) -> result<_, fault>;") - ); - } } diff --git a/crates/nexum-world/Cargo.toml b/crates/nexum-world/Cargo.toml new file mode 100644 index 00000000..088d377a --- /dev/null +++ b/crates/nexum-world/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "nexum-world" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Per-module WIT world synthesis: the core capability table, registry-driven extension rows, manifest parsing, and crate-local WIT package resolution." + +[lints] +workspace = true + +[dependencies] +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs new file mode 100644 index 00000000..2b39f860 --- /dev/null +++ b/crates/nexum-world/src/lib.rs @@ -0,0 +1,579 @@ +//! Per-module world synthesis: turn a manifest's `[capabilities]` +//! declarations into an inline WIT world whose imports are exactly the +//! declared capability interfaces. +//! +//! The one non-obvious invariant: the capability rows here must agree +//! with the runtime's capability registry (`nexum-runtime`'s manifest +//! enforcement) on both the capability names and the WIT interfaces they +//! map to. The runtime cross-checks a component's imports against the +//! manifest at load time; because the imports are derived from the same +//! manifest, a macro-built component passes that check by construction +//! rather than by relying on the toolchain eliding unused imports. +//! +//! The table here carries only the core `nexum:host` rows. Per-namespace +//! rows come from the composition root's `extensions.toml` registry +//! ([`manifest_extensions`]): the caller passes them to [`synthesize`], +//! so this crate carries no downstream name. + +use std::path::{Path, PathBuf}; + +/// One manifest capability and its world wiring. +pub struct Capability { + /// The name declared under `[capabilities].required` / `optional`. + pub name: &'static str, + /// The WIT import the declaration turns into, or `None` for + /// capabilities with no world import (`http` is granted through the + /// SDK's wasi:http client and the host allowlist, not the world). + pub import: Option<&'static str>, + /// WIT package directories the import needs on the resolve path, + /// beyond `nexum-host`. + pub packages: &'static [&'static str], + /// The `bind_host_via_wit_bindgen!` capability ident carrying this + /// capability's host-adapter pieces, if the SDK has a trait seam + /// for it. + pub adapter: Option<&'static str>, +} + +/// The core capability rows, in emission order. Mirrors the runtime's +/// core registry and nothing else; extension rows are the caller's. +pub const CORE: &[Capability] = &[ + Capability { + name: "chain", + import: Some("nexum:host/chain@0.1.0"), + packages: &[], + adapter: Some("chain"), + }, + Capability { + name: "identity", + import: Some("nexum:host/identity@0.1.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "local-store", + import: Some("nexum:host/local-store@0.1.0"), + packages: &[], + adapter: Some("local_store"), + }, + Capability { + name: "remote-store", + import: Some("nexum:host/remote-store@0.1.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "messaging", + import: Some("nexum:host/messaging@0.1.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "logging", + import: Some("nexum:host/logging@0.1.0"), + packages: &[], + adapter: Some("logging"), + }, + Capability { + name: "http", + import: None, + packages: &[], + adapter: None, + }, +]; + +/// One registered extension row: a per-namespace capability a +/// composition root declares in its `extensions.toml`. An extension +/// always has a WIT import and never a host-adapter ident (adapter +/// seams are core-only). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionRow { + /// The name modules declare under `[capabilities]`. + pub name: String, + /// The WIT import the declaration turns into. + pub import: String, + /// WIT package directories the import needs on the resolve path, + /// beyond `nexum-host`, in dependency order. + pub packages: Vec, +} + +/// The synthesized world plus what the `generate!` call and the host +/// adapter need to go with it. +#[derive(Debug)] +pub struct ModuleWorld { + /// Inline WIT text defining `nexum:module-world/module`. + pub wit: String, + /// WIT package directories the resolve path must carry, in + /// dependency order (a package precedes its dependants). Always + /// starts with the base set the host `event` variant needs. + pub packages: Vec, + /// Capability idents to pass to `bind_host_via_wit_bindgen!`. + pub adapters: Vec<&'static str>, +} + +/// Extract the declared capability names (`required` then `optional`) +/// from the manifest text. A missing or malformed `[capabilities]` +/// section is an error: the emitted world is derived from it, so the +/// synthesis has nothing to build from without one. +pub fn manifest_capabilities(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; + let caps = value.get("capabilities").ok_or_else(|| { + "module.toml has no [capabilities] section; the module/adapter macro derives the \ + component's WIT world from [capabilities].required/optional, so declare it (an empty \ + `required = []` is valid)" + .to_string() + })?; + let list = |key: &str| -> Result, String> { + match caps.get(key) { + None => Ok(Vec::new()), + Some(v) => v + .as_array() + .ok_or_else(|| format!("[capabilities].{key} must be an array of strings"))? + .iter() + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| format!("[capabilities].{key} must contain only strings")) + }) + .collect(), + } + }; + let mut names = list("required")?; + names.extend(list("optional")?); + Ok(names) +} + +/// Parse the registered extension rows from an `extensions.toml`. Each +/// `[extensions.]` table carries the WIT `import` the declaration +/// turns into and the extra `packages` its resolve path needs. A file +/// without an `[extensions]` section registers nothing. +pub fn manifest_extensions(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("extensions.toml is not valid TOML: {e}"))?; + let Some(extensions) = value.get("extensions") else { + return Ok(Vec::new()); + }; + let extensions = extensions + .as_table() + .ok_or_else(|| "[extensions] must be a table of `[extensions.]` rows".to_string())?; + extensions + .iter() + .map(|(name, row)| { + let row = row + .as_table() + .ok_or_else(|| format!("[extensions.{name}] must be a table"))?; + let import = row + .get("import") + .and_then(toml::Value::as_str) + .ok_or_else(|| format!("[extensions.{name}] must carry a string `import`"))? + .to_owned(); + let packages = match row.get("packages") { + None => Vec::new(), + Some(value) => value + .as_array() + .ok_or_else(|| { + format!("[extensions.{name}].packages must be an array of strings") + })? + .iter() + .map(|item| { + item.as_str().map(str::to_owned).ok_or_else(|| { + format!("[extensions.{name}].packages must contain only strings") + }) + }) + .collect::>()?, + }; + Ok(ExtensionRow { + name: name.clone(), + import, + packages, + }) + }) + .collect() +} + +/// Find the extension registry for a build rooted at `start`: the +/// nearest ancestor `extensions.toml`. `None` means no registered +/// extensions. +pub fn find_extensions_manifest(start: &Path) -> Option { + let mut dir = Some(start); + while let Some(cur) = dir { + let candidate = cur.join("extensions.toml"); + if candidate.is_file() { + return Some(candidate); + } + dir = cur.parent(); + } + None +} + +/// Build the per-module world from the declared capability names +/// (required and optional alike: an optional capability must still be +/// importable, the host decides at load time whether to back or stub +/// it). `extensions` carries the per-namespace rows of the registered +/// extensions, emitted after the core rows. Unknown names are an error +/// so a typo cannot silently drop an import; a registered name that +/// shadows a core row or another registration is an error so a +/// colliding registry cannot emit a duplicate import. +pub fn synthesize(declared: &[String], extensions: &[ExtensionRow]) -> Result { + for (idx, ext) in extensions.iter().enumerate() { + if CORE.iter().any(|c| c.name == ext.name) + || extensions[..idx].iter().any(|prior| prior.name == ext.name) + { + return Err(format!( + "extension capability `{}` collides with an already-registered capability; \ + names must be unique across the core table and the registered extensions", + ext.name + )); + } + } + + let known = || { + CORE.iter() + .map(|c| c.name) + .chain(extensions.iter().map(|e| e.name.as_str())) + }; + for name in declared { + if !known().any(|k| k == name.as_str()) { + let names = known().collect::>().join(", "); + return Err(format!( + "unknown capability `{name}` in module.toml [capabilities]; expected one of: \ + {names}" + )); + } + } + + let mut imports = String::new(); + // `nexum:host` is a leaf package (the `event` variant carries status + // transitions as opaque bytes), so the base resolve set + // is the host package alone; capability declarations append their + // own packages. Dependency order: each directory is parsed against + // the packages before it, so a package precedes its dependants. + let mut packages = vec!["nexum-host".to_owned()]; + let mut adapters = Vec::new(); + for cap in CORE { + if !declared.iter().any(|d| d == cap.name) { + continue; + } + if let Some(import) = cap.import { + imports.push_str(&format!(" import {import};\n")); + } + for package in cap.packages { + if !packages.iter().any(|p| p == package) { + packages.push((*package).to_owned()); + } + } + if let Some(adapter) = cap.adapter { + adapters.push(adapter); + } + } + for ext in extensions { + if !declared.contains(&ext.name) { + continue; + } + imports.push_str(&format!(" import {};\n", ext.import)); + for package in &ext.packages { + if !packages.contains(package) { + packages.push(package.clone()); + } + } + } + + let mut wit = String::from( + "package nexum:module-world;\n\nworld module {\n \ + use nexum:host/types@0.1.0.{config, event, fault};\n\n", + ); + wit.push_str(&imports); + wit.push_str( + "\n export init: func(config: config) -> result<_, fault>;\n \ + export on-event: func(event: event) -> result<_, fault>;\n}\n", + ); + + Ok(ModuleWorld { + wit, + packages, + adapters, + }) +} + +/// Resolve each WIT package directory for a component build rooted at +/// `start` (the consuming crate's manifest directory). A package +/// resolves crate-locally, vendored `wit/deps/` before own +/// `wit/`; a crate not carrying it falls back to the nearest +/// ancestor `wit/` that does (the transitional monorepo layout). +pub fn resolve_wit_packages>( + start: &Path, + packages: &[S], +) -> Result, String> { + packages + .iter() + .map(|package| { + let package = package.as_ref(); + resolve_wit_package(start, package).ok_or_else(|| { + format!( + "declared capabilities need the `{package}` WIT package, but neither \ + `wit/deps/{package}` nor `wit/{package}` exists under {} or any ancestor", + start.display() + ) + }) + }) + .collect() +} + +/// Find one package directory: crate-local `wit/deps/` then +/// `wit/`, walking up on a miss. +fn resolve_wit_package(start: &Path, package: &str) -> Option { + let mut dir = Some(start); + while let Some(cur) = dir { + let wit = cur.join("wit"); + for candidate in [wit.join("deps").join(package), wit.join(package)] { + if candidate.is_dir() { + return Some(candidate); + } + } + dir = cur.parent(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The base package set every module world resolves against: + /// `nexum:host` is a leaf package, so it stands alone. + const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; + + /// A stand-in extension row, as a registered extension would pass. + fn ext() -> Vec { + vec![ExtensionRow { + name: "acme".to_owned(), + import: "acme:ext/api@0.1.0".to_owned(), + packages: vec!["acme-ext".to_owned()], + }] + } + + #[test] + fn logging_only_world_imports_logging_alone() { + let world = synthesize(&["logging".to_string()], &[]).unwrap(); + assert!(world.wit.contains("import nexum:host/logging@0.1.0;")); + assert!(!world.wit.contains("import nexum:host/chain")); + assert_eq!(world.packages, MODULE_PACKAGES); + assert_eq!(world.adapters, vec!["logging"]); + } + + #[test] + fn extension_row_emits_its_import_and_packages() { + let world = synthesize(&["logging".to_string(), "acme".to_string()], &ext()).unwrap(); + assert!(world.wit.contains("import acme:ext/api@0.1.0;")); + assert_eq!(world.packages, vec!["nexum-host", "acme-ext"]); + } + + #[test] + fn undeclared_extension_row_stays_out_of_the_world() { + let world = synthesize(&["logging".to_string()], &ext()).unwrap(); + assert!(!world.wit.contains("acme")); + assert_eq!(world.packages, MODULE_PACKAGES); + } + + #[test] + fn extension_shadowing_a_core_name_is_rejected() { + let rows = vec![ExtensionRow { + name: "chain".to_owned(), + import: "acme:ext/chain@0.1.0".to_owned(), + packages: Vec::new(), + }]; + let err = synthesize(&["chain".to_string()], &rows).unwrap_err(); + assert!(err.contains("extension capability `chain` collides")); + } + + #[test] + fn duplicate_extension_registration_is_rejected() { + let mut rows = ext(); + rows.extend(ext()); + let err = synthesize(&[], &rows).unwrap_err(); + assert!(err.contains("extension capability `acme` collides")); + } + + #[test] + fn core_table_carries_no_extension_row() { + assert!( + CORE.iter() + .all(|c| c.import.is_none_or(|i| i.starts_with("nexum:host/"))) + ); + assert!(CORE.iter().all(|c| c.packages.is_empty())); + } + + #[test] + fn http_declares_no_world_import() { + let world = synthesize(&["logging".to_string(), "http".to_string()], &[]).unwrap(); + assert!(!world.wit.contains("wasi:http")); + assert_eq!(world.packages, MODULE_PACKAGES); + } + + #[test] + fn duplicate_declarations_emit_one_import() { + let world = synthesize(&["chain".to_string(), "chain".to_string()], &[]).unwrap(); + assert_eq!(world.wit.matches("import nexum:host/chain").count(), 1); + assert_eq!(world.adapters, vec!["chain"]); + } + + #[test] + fn unknown_capability_is_rejected_with_the_known_list() { + let err = synthesize(&["telepathy".to_string()], &ext()).unwrap_err(); + assert!(err.contains("unknown capability `telepathy`")); + assert!(err.contains("logging")); + assert!(err.contains("acme")); + } + + #[test] + fn manifest_extensions_reads_rows() { + let rows = manifest_extensions( + r#" +[extensions.acme] +import = "acme:ext/api@0.1.0" +packages = ["acme-base", "acme-ext"] + +[extensions.beta] +import = "beta:ext/api@0.1.0" +"#, + ) + .unwrap(); + assert_eq!(rows, { + let mut expected = ext(); + expected[0].packages = vec!["acme-base".to_owned(), "acme-ext".to_owned()]; + expected.push(ExtensionRow { + name: "beta".to_owned(), + import: "beta:ext/api@0.1.0".to_owned(), + packages: Vec::new(), + }); + expected + }); + } + + #[test] + fn manifest_without_extensions_section_registers_nothing() { + assert_eq!(manifest_extensions("").unwrap(), Vec::new()); + } + + #[test] + fn extension_row_without_an_import_is_an_error() { + let err = manifest_extensions("[extensions.acme]\npackages = []\n").unwrap_err(); + assert!(err.contains("[extensions.acme] must carry a string `import`")); + } + + #[test] + fn extension_row_with_non_string_package_is_an_error() { + let err = + manifest_extensions("[extensions.acme]\nimport = \"a:b/c@0.1.0\"\npackages = [1]\n") + .unwrap_err(); + assert!(err.contains("only strings")); + } + + #[test] + fn manifest_capabilities_reads_required_and_optional() { + let caps = manifest_capabilities( + r#" +[capabilities] +required = ["logging", "chain"] +optional = ["remote-store"] + +[capabilities.http] +allow = [] +"#, + ) + .unwrap(); + assert_eq!(caps, vec!["logging", "chain", "remote-store"]); + } + + #[test] + fn manifest_without_capabilities_section_is_an_error() { + let err = manifest_capabilities("[module]\nname = \"x\"\n").unwrap_err(); + assert!(err.contains("[capabilities]")); + } + + #[test] + fn manifest_with_non_string_capability_is_an_error() { + let err = manifest_capabilities("[capabilities]\nrequired = [1]\n").unwrap_err(); + assert!(err.contains("only strings")); + } + + #[test] + fn world_is_valid_wit_shape() { + // Not a full WIT parse (that is the module build's job); pin the + // structural pieces the runtime contract depends on. + let world = synthesize(&["logging".to_string()], &[]).unwrap(); + assert!(world.wit.starts_with("package nexum:module-world;")); + assert!(world.wit.contains("world module {")); + assert!( + world + .wit + .contains("export init: func(config: config) -> result<_, fault>;") + ); + assert!( + world + .wit + .contains("export on-event: func(event: event) -> result<_, fault>;") + ); + } + + #[test] + fn resolution_prefers_vendored_deps_over_own_wit() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/deps/pkg")).unwrap(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let paths = resolve_wit_packages(root, &["pkg"]).unwrap(); + assert_eq!(paths, vec![root.join("wit/deps/pkg")]); + } + + #[test] + fn resolution_falls_back_to_the_nearest_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(&leaf).unwrap(); + let paths = resolve_wit_packages(&leaf, &["pkg"]).unwrap(); + assert_eq!(paths, vec![root.join("wit/pkg")]); + } + + #[test] + fn crate_local_package_shadows_the_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(leaf.join("wit/deps/pkg")).unwrap(); + let paths = resolve_wit_packages(&leaf, &["pkg"]).unwrap(); + assert_eq!(paths, vec![leaf.join("wit/deps/pkg")]); + } + + #[test] + fn extension_registry_resolves_from_the_nearest_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("extensions.toml"), "").unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(&leaf).unwrap(); + assert_eq!( + find_extensions_manifest(&leaf), + Some(root.join("extensions.toml")) + ); + } + + #[test] + fn absent_extension_registry_is_none() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(find_extensions_manifest(dir.path()), None); + } + + #[test] + fn missing_package_names_the_paths_tried() { + let dir = tempfile::tempdir().unwrap(); + let err = resolve_wit_packages(dir.path(), &["pkg"]).unwrap_err(); + assert!(err.contains("`pkg` WIT package")); + assert!(err.contains("wit/deps/pkg")); + } +} diff --git a/extensions.toml b/extensions.toml new file mode 100644 index 00000000..6a280f02 --- /dev/null +++ b/extensions.toml @@ -0,0 +1,13 @@ +# Extension capability registry for this composition root: the +# per-namespace rows the module world synthesis emits beyond the core +# nexum:host table. Each row names the WIT import a `[capabilities]` +# declaration turns into and the package directories its resolve path +# needs, in dependency order. + +[extensions.client] +import = "videre:venue/client@0.1.0" +packages = ["videre-value-flow", "videre-types", "videre-venue"] + +[extensions.cow-api] +import = "shepherd:cow/cow-api@0.1.0" +packages = ["shepherd-cow"]