From 3f570ae8a88f1859c13516a26840bcf93dbca409 Mon Sep 17 00:00:00 2001 From: Bailey Hayes Date: Sat, 28 Feb 2026 20:26:00 -0500 Subject: [PATCH] feat(transpile): support `implements` Add support for transpiling components that use the `implements` feature, which allows a world to import/export the same interface multiple times under different plain names (e.g., `import primary: store; import backup: store;`). - Add `parse_implements_name()` and `find_world_key()` helpers for matching `[implements=]label` binary names to world items - Rewrite `initialize()` to build import/export maps from binary names - Strip `[implements=<...>]` prefix in `lower_import()` so each labeled instance gets its own JS import specifier - Fix TypeScript generation to use the plain label as the export alias - Fix `exports_interface()` to find implements items keyed by Name --- Cargo.lock | 131 +++-- Cargo.toml | 13 +- crates/js-component-bindgen/Cargo.toml | 3 + .../src/transpile_bindgen.rs | 465 ++++++++++++++++-- crates/js-component-bindgen/src/ts_bindgen.rs | 17 +- packages/jco/test/api.js | 77 +++ 6 files changed, 626 insertions(+), 80 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f4f979dd3..882b7a19c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,7 +202,16 @@ version = "0.129.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "041e02398f3c7ea0b9be704418a237384615732e21a727182a5a94405b7674b8" dependencies = [ - "cranelift-entity", + "cranelift-entity 0.129.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "cranelift-bitset" +version = "0.129.0" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core 42.0.0", ] [[package]] @@ -213,7 +222,7 @@ checksum = "2e8e36a88d22763171cd63a819805ff0c3934eda9a3037ae24de515bf7309f7b" dependencies = [ "serde", "serde_derive", - "wasmtime-internal-core", + "wasmtime-internal-core 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -225,11 +234,11 @@ dependencies = [ "bumpalo", "cranelift-assembler-x64", "cranelift-bforest", - "cranelift-bitset", + "cranelift-bitset 0.129.0 (registry+https://github.com/rust-lang/crates.io-index)", "cranelift-codegen-meta", "cranelift-codegen-shared", "cranelift-control", - "cranelift-entity", + "cranelift-entity 0.129.0 (registry+https://github.com/rust-lang/crates.io-index)", "cranelift-isle", "gimli", "hashbrown 0.15.5", @@ -244,7 +253,7 @@ dependencies = [ "sha2", "smallvec", "target-lexicon", - "wasmtime-internal-core", + "wasmtime-internal-core 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -275,16 +284,26 @@ dependencies = [ "arbitrary", ] +[[package]] +name = "cranelift-entity" +version = "0.129.0" +dependencies = [ + "cranelift-bitset 0.129.0", + "serde", + "serde_derive", + "wasmtime-internal-core 42.0.0", +] + [[package]] name = "cranelift-entity" version = "0.129.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e30cc7555fd36897f14f34fabe8ce1d21fccbea81ea2cc36181a39209539611f" dependencies = [ - "cranelift-bitset", + "cranelift-bitset 0.129.0 (registry+https://github.com/rust-lang/crates.io-index)", "serde", "serde_derive", - "wasmtime-internal-core", + "wasmtime-internal-core 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -591,6 +610,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -799,7 +820,8 @@ dependencies = [ "semver", "wasm-encoder 0.245.1", "wasmparser 0.245.1", - "wasmtime-environ", + "wasmtime-environ 42.0.0", + "wat", "wit-bindgen-core", "wit-component", "wit-parser 0.245.1", @@ -811,7 +833,7 @@ version = "1.10.0" dependencies = [ "anyhow", "js-component-bindgen", - "wasmtime-environ", + "wasmtime-environ 42.0.0", "wat", "wit-bindgen", ] @@ -1011,10 +1033,10 @@ version = "42.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d9aab4545a6857fb8b29eb07d38930c26fd40b52dfa8804512292883080fd7c" dependencies = [ - "cranelift-bitset", + "cranelift-bitset 0.129.0 (registry+https://github.com/rust-lang/crates.io-index)", "log", "pulley-macros", - "wasmtime-internal-core", + "wasmtime-internal-core 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1415,8 +1437,6 @@ dependencies = [ [[package]] name = "wasm-encoder" version = "0.245.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" dependencies = [ "leb128fmt", "wasmparser 0.245.1", @@ -1425,8 +1445,6 @@ dependencies = [ [[package]] name = "wasm-metadata" version = "0.245.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da55e60097e8b37b475a0fa35c3420dd71d9eb7bd66109978ab55faf56a57efb" dependencies = [ "anyhow", "auditable-serde", @@ -1472,13 +1490,12 @@ dependencies = [ [[package]] name = "wasmparser" version = "0.245.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" dependencies = [ "bitflags 2.10.0", "hashbrown 0.16.1", "indexmap", "semver", + "serde", ] [[package]] @@ -1495,8 +1512,6 @@ dependencies = [ [[package]] name = "wasmprinter" version = "0.245.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8" dependencies = [ "anyhow", "termcolor", @@ -1526,10 +1541,10 @@ dependencies = [ "serde_derive", "target-lexicon", "wasmparser 0.244.0", - "wasmtime-environ", + "wasmtime-environ 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "wasmtime-internal-component-macro", - "wasmtime-internal-component-util", - "wasmtime-internal-core", + "wasmtime-internal-component-util 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "wasmtime-internal-core 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "wasmtime-internal-cranelift", "wasmtime-internal-fiber", "wasmtime-internal-jit-debug", @@ -1539,6 +1554,31 @@ dependencies = [ "wasmtime-internal-winch", ] +[[package]] +name = "wasmtime-environ" +version = "42.0.0" +dependencies = [ + "anyhow", + "cranelift-bitset 0.129.0", + "cranelift-entity 0.129.0", + "gimli", + "hashbrown 0.15.5", + "indexmap", + "log", + "object", + "postcard", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", + "wasmprinter 0.245.1", + "wasmtime-internal-component-util 42.0.0", + "wasmtime-internal-core 42.0.0", +] + [[package]] name = "wasmtime-environ" version = "42.0.0" @@ -1546,8 +1586,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17168055ea3cab4cdb572fd198bff0d8d18b43a2cb4250c98c3a4bba910bdf88" dependencies = [ "anyhow", - "cranelift-bitset", - "cranelift-entity", + "cranelift-bitset 0.129.0 (registry+https://github.com/rust-lang/crates.io-index)", + "cranelift-entity 0.129.0 (registry+https://github.com/rust-lang/crates.io-index)", "gimli", "hashbrown 0.15.5", "indexmap", @@ -1562,8 +1602,8 @@ dependencies = [ "wasm-encoder 0.244.0", "wasmparser 0.244.0", "wasmprinter 0.244.0", - "wasmtime-internal-component-util", - "wasmtime-internal-core", + "wasmtime-internal-component-util 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "wasmtime-internal-core 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1576,17 +1616,28 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.108", - "wasmtime-internal-component-util", + "wasmtime-internal-component-util 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "wasmtime-internal-wit-bindgen", "wit-parser 0.244.0", ] +[[package]] +name = "wasmtime-internal-component-util" +version = "42.0.0" + [[package]] name = "wasmtime-internal-component-util" version = "42.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3faa42ac5b144e799b1f7c7ff509df3388089acaba44e10f7706de471f27e3e6" +[[package]] +name = "wasmtime-internal-core" +version = "42.0.0" +dependencies = [ + "libm", +] + [[package]] name = "wasmtime-internal-core" version = "42.0.0" @@ -1605,7 +1656,7 @@ dependencies = [ "cfg-if", "cranelift-codegen", "cranelift-control", - "cranelift-entity", + "cranelift-entity 0.129.0 (registry+https://github.com/rust-lang/crates.io-index)", "cranelift-frontend", "cranelift-native", "gimli", @@ -1617,8 +1668,8 @@ dependencies = [ "target-lexicon", "thiserror", "wasmparser 0.244.0", - "wasmtime-environ", - "wasmtime-internal-core", + "wasmtime-environ 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "wasmtime-internal-core 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", ] @@ -1633,7 +1684,7 @@ dependencies = [ "cfg-if", "libc", "rustix", - "wasmtime-environ", + "wasmtime-environ 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "wasmtime-internal-versioned-export-macros", "windows-sys", ] @@ -1656,7 +1707,7 @@ checksum = "622a4af0a8fa39d74efed9a0c596bd85e27d9188408619a57eb44d0af35e4469" dependencies = [ "cfg-if", "libc", - "wasmtime-internal-core", + "wasmtime-internal-core 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "windows-sys", ] @@ -1670,7 +1721,7 @@ dependencies = [ "cranelift-codegen", "log", "object", - "wasmtime-environ", + "wasmtime-environ 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1696,7 +1747,7 @@ dependencies = [ "object", "target-lexicon", "wasmparser 0.244.0", - "wasmtime-environ", + "wasmtime-environ 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "wasmtime-internal-cranelift", "winch-codegen", ] @@ -1717,8 +1768,6 @@ dependencies = [ [[package]] name = "wast" version = "245.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cf1149285569120b8ce39db8b465e8a2b55c34cbb586bd977e43e2bc7300bf" dependencies = [ "bumpalo", "leb128fmt", @@ -1730,8 +1779,6 @@ dependencies = [ [[package]] name = "wat" version = "1.245.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd48d1679b6858988cb96b154dda0ec5bbb09275b71db46057be37332d5477be" dependencies = [ "wast", ] @@ -1783,8 +1830,8 @@ dependencies = [ "target-lexicon", "thiserror", "wasmparser 0.244.0", - "wasmtime-environ", - "wasmtime-internal-core", + "wasmtime-environ 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "wasmtime-internal-core 42.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "wasmtime-internal-cranelift", ] @@ -1858,8 +1905,6 @@ dependencies = [ [[package]] name = "wit-component" version = "0.245.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4894f10d2d5cbc17c77e91f86a1e48e191a788da4425293b55c98b44ba3fcac9" dependencies = [ "anyhow", "bitflags 2.10.0", @@ -1931,8 +1976,6 @@ dependencies = [ [[package]] name = "wit-parser" version = "0.245.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330698718e82983499419494dd1e3d7811a457a9bf9f69734e8c5f07a2547929" dependencies = [ "anyhow", "hashbrown 0.16.1", diff --git a/Cargo.toml b/Cargo.toml index 6eac1449c..d1f534d94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] resolver = "2" +exclude = ["submodules"] default-members = [ "crates/js-component-bindgen", "crates/js-component-bindgen-component", @@ -50,7 +51,7 @@ wasm-encoder = { version = "0.245.1", default-features = false } wasm-metadata = { version = "0.245.1", default-features = false } wasmparser = { version = "0.245.1", default-features = false } wasmprinter = { version = "0.245.1", default-features = false } -wasmtime-environ = { version = "42.0.0", features= [ "component-model", "compile" ] } +wasmtime-environ = { path = "submodules/wasmtime/crates/environ", features= [ "component-model", "compile" ] } wat = { version = "1.245.1", default-features = false } wit-bindgen = { version = "0.53.1", default-features = false } wit-bindgen-core = { version = "0.53.1", default-features = false } @@ -58,3 +59,13 @@ wit-component = { version = "0.245.1", features = ["dummy-module"] } wit-parser = { version = "0.245.1", default-features = false } js-component-bindgen = { version = "1.15.0", path = "./crates/js-component-bindgen" } + +[patch.crates-io] +wasmparser = { path = "submodules/wasm-tools/crates/wasmparser" } +wasm-encoder = { path = "submodules/wasm-tools/crates/wasm-encoder" } +wasm-metadata = { path = "submodules/wasm-tools/crates/wasm-metadata" } +wasmprinter = { path = "submodules/wasm-tools/crates/wasmprinter" } +wat = { path = "submodules/wasm-tools/crates/wat" } +wast = { path = "submodules/wasm-tools/crates/wast" } +wit-component = { path = "submodules/wasm-tools/crates/wit-component" } +wit-parser = { path = "submodules/wasm-tools/crates/wit-parser" } diff --git a/crates/js-component-bindgen/Cargo.toml b/crates/js-component-bindgen/Cargo.toml index ad0e98d35..9ef443108 100644 --- a/crates/js-component-bindgen/Cargo.toml +++ b/crates/js-component-bindgen/Cargo.toml @@ -36,3 +36,6 @@ wasmtime-environ = { workspace = true, features = ['component-model'] } wit-bindgen-core = { workspace = true } wit-component = { workspace = true } wit-parser = { workspace = true } + +[dev-dependencies] +wat = { workspace = true, features = ["component-model"] } diff --git a/crates/js-component-bindgen/src/transpile_bindgen.rs b/crates/js-component-bindgen/src/transpile_bindgen.rs index e4bbf55eb..bc68908e1 100644 --- a/crates/js-component-bindgen/src/transpile_bindgen.rs +++ b/crates/js-component-bindgen/src/transpile_bindgen.rs @@ -604,40 +604,63 @@ impl<'a> ManagesIntrinsics for Instantiator<'a, '_> { impl<'a> Instantiator<'a, '_> { fn initialize(&mut self) { - // Populate reverse map from import and export names to world items - for (key, _) in &self.resolve.worlds[self.world].imports { - let name = &self.resolve.name_world_key(key); - self.imports.insert(name.to_string(), key.clone()); + // Populate reverse maps from binary import/export names to world items. + // + // We build these maps by matching binary names from component.import_types + // and component.exports against the world's imports/exports. This avoids + // computing binary names from the Resolve, which may use a different + // package name than the binary component (e.g. when decode() synthesizes + // a package for components without embedded WIT metadata). + let world = &self.resolve.worlds[self.world]; + for (_idx, (import_name, _ty)) in self.component.import_types.iter() { + if let Some(key) = find_world_key(self.resolve, world.imports.iter(), import_name) { + self.imports.insert(import_name.to_string(), key.clone()); + } } - for (key, _) in &self.resolve.worlds[self.world].exports { - let name = &self.resolve.name_world_key(key); - self.exports.insert(name.to_string(), key.clone()); + for (export_name, _idx) in self.component.exports.raw_iter() { + let export_name_str: &str = export_name.as_ref(); + if let Some(key) = find_world_key(self.resolve, world.exports.iter(), export_name_str) { + self.exports + .insert(export_name_str.to_string(), key.clone()); + } } - // Populate reverse map from TypeId to ResourceIndex - // Populate the resource type to resource index map + // Build reverse maps (WorldKey -> binary name) for efficient lookup below. + let import_key_to_name: HashMap = self + .imports + .iter() + .map(|(n, k)| (k.clone(), n.clone())) + .collect(); + let export_key_to_name: HashMap = self + .exports + .iter() + .map(|(n, k)| (k.clone(), n.clone())) + .collect(); + + // Populate reverse map from TypeId to ResourceIndex. + // Also need to handle world items that don't appear in component.import_types + // (e.g. non-resource types). for (key, item) in &self.resolve.worlds[self.world].imports { - let name = &self.resolve.name_world_key(key); + let Some(name) = import_key_to_name.get(key).cloned() else { + // Interface/Function world items without a corresponding + // binary import can occur with `implements` items — the + // world may declare items that aren't present as binary + // component imports. Safe to skip since we only need + // resource type mappings here. + if let WorldItem::Type { id, .. } = item { + assert!(!matches!( + self.resolve.types[*id].kind, + TypeDefKind::Resource + )); + } + continue; + }; let Some((_, (_, import))) = self .component .import_types .iter() - .find(|(_, (impt_name, _))| impt_name == name) + .find(|(_, (impt_name, _))| *impt_name == name) else { - match item { - WorldItem::Interface { .. } => { - unreachable!("unexpected interface in import types during initialization") - } - WorldItem::Function(_) => { - unreachable!("unexpected function in import types during initialization") - } - WorldItem::Type { id, .. } => { - assert!(!matches!( - self.resolve.types[*id].kind, - TypeDefKind::Resource - )) - } - } continue; }; match item { @@ -676,12 +699,14 @@ impl<'a> Instantiator<'a, '_> { self.exports_resource_types = self.imports_resource_types.clone(); for (key, item) in &self.resolve.worlds[self.world].exports { - let name = &self.resolve.name_world_key(key); + let Some(name) = export_key_to_name.get(key).cloned() else { + continue; + }; let (_, export_idx) = self .component .exports .raw_iter() - .find(|(expt_name, _)| *expt_name == name) + .find(|(expt_name, _)| expt_name.as_str() == name) .unwrap(); let export = &self.component.export_items[*export_idx]; match item { @@ -2734,8 +2759,16 @@ impl<'a> Instantiator<'a, '_> { let (import_name, _) = &self.component.import_types[*import_index]; let world_key = &self.imports[import_name]; + // For implements items, the import_name has the [implements=<...>] + // prefix. Strip it to get the plain label for use as the JS import + // specifier. + let import_name: &str = match parse_implements_name(import_name) { + Some((_, label)) => label, + None => import_name, + }; + // Determine the name of the function - let (func, func_name, iface_name) = + let (func, func_name, iface_name): (&Function, &str, Option<&str>) = match &self.resolve.worlds[self.world].imports[world_key] { WorldItem::Function(func) => { assert_eq!(path.len(), 0); @@ -2747,8 +2780,11 @@ impl<'a> Instantiator<'a, '_> { let func = &iface.functions[&path[0]]; ( func, - &path[0], - Some(iface.name.as_deref().unwrap_or_else(|| import_name)), + path[0].as_str(), + // For implements items, import_name is already the + // plain label (shadowed above), so each labeled + // instance gets its own JS import specifier. + Some(iface.name.as_deref().unwrap_or(import_name)), ) } WorldItem::Type { .. } => unreachable!("unexpected imported world item type"), @@ -3855,6 +3891,12 @@ impl<'a> Instantiator<'a, '_> { let export = &self.component.export_items[*export_idx]; let world_key = &self.exports[export_name]; let item = &self.resolve.worlds[self.world].exports[world_key]; + // For implements items, strip the [implements=<...>] prefix to + // get the plain label for use as the JS export name. + let export_name: &str = match parse_implements_name(export_name) { + Some((_, label)) => label, + None => export_name, + }; let mut export_resource_map = ResourceMap::new(); match export { Export::LiftedFunction { @@ -4020,7 +4062,7 @@ impl<'a> Instantiator<'a, '_> { options: &CanonicalOptions, func: &Function, _func_ty_idx: &TypeFuncIndex, - export_name: &String, + export_name: &str, export_resource_map: &ResourceMap, ) { // Determine whether the function should be generated as async @@ -4231,7 +4273,7 @@ impl<'a> Instantiator<'a, '_> { CallType::AsyncStandard } }, - iface_name: iface_name.map(|v| v.as_str()), + iface_name, callee: &callee, opts: options, func, @@ -4400,7 +4442,113 @@ fn map_import(map: &Option>, impt: &str) -> (String, Opt (impt_sans_version.to_string(), None) } +/// Find the `WorldKey` in a world's import/export map that corresponds to a +/// binary import/export name. +/// +/// Resolution follows a tiered approach: +/// +/// For `[implements=]label` binary names: +/// Tier 1: Match `WorldKey::Name(label)` with `implements: Some(_)` +/// Tier 2: Fall back to `WorldKey::Name(label)` without implements constraint +/// +/// For standard binary names (e.g., `ns:pkg/iface@0.2.0`): +/// Tier 1: Exact match via `resolve.name_world_key(key) == binary_name` +/// Tier 2: Semver-compatible match using `semver_find_world_key()` +fn find_world_key<'a>( + resolve: &Resolve, + world_items: impl Iterator, + binary_name: &str, +) -> Option { + let items: Vec<_> = world_items.collect(); + + if let Some((_iface_id, label)) = parse_implements_name(binary_name) { + // Tier 1: match by label with implements constraint + let tier1 = items.iter().find(|(k, item)| { + matches!( + (k, item), + (WorldKey::Name(l), WorldItem::Interface { implements: Some(_), .. }) + if l == label + ) + }); + if let Some((k, _)) = tier1 { + return Some((*k).clone()); + } + + // Tier 2: label fallback — match plain label without implements constraint + items + .iter() + .find(|(k, _)| matches!(k, WorldKey::Name(l) if l == label)) + .map(|(k, _)| (*k).clone()) + } else { + // Tier 1: exact match + let exact = items + .iter() + .find(|(k, _)| resolve.name_world_key(k) == binary_name); + if let Some((k, _)) = exact { + return Some((*k).clone()); + } + + // Tier 2: semver-compatible match + semver_find_world_key(resolve, &items, binary_name) + } +} + +/// Semver-compatible world key lookup. If `binary_name` has a version like +/// `ns:pkg/iface@0.2.0`, find a world item whose key has a semver-compatible +/// version (e.g., `ns:pkg/iface@0.2.1`). Prefers the highest compatible version. +fn semver_find_world_key( + resolve: &Resolve, + items: &[(&WorldKey, &WorldItem)], + binary_name: &str, +) -> Option { + let at = binary_name.find('@')?; + let import_ver_str = &binary_name[at + 1..]; + let (import_compat, _) = semver_compat_key(import_ver_str)?; + let import_base = &binary_name[..at]; + + let mut best: Option<(WorldKey, Version)> = None; + for (k, _) in items { + let key_name = resolve.name_world_key(k); + let Some(key_at) = key_name.find('@') else { + continue; + }; + let key_base = &key_name[..key_at]; + if key_base != import_base { + continue; + } + let key_ver_str = &key_name[key_at + 1..]; + if let Some((key_compat, key_ver)) = semver_compat_key(key_ver_str) + && key_compat == import_compat + { + match &best { + Some((_, prev_ver)) if key_ver <= *prev_ver => {} + _ => best = Some(((*k).clone(), key_ver)), + } + } + } + best.map(|(k, _)| k) +} + +/// Parse `[implements=]label` into `Some((interface_id, label))`. +/// +/// Returns `None` if the name does not use the implements encoding. +pub fn parse_implements_name(name: &str) -> Option<(&str, &str)> { + let rest = name.strip_prefix("[implements=<")?; + let end = rest.find(">]")?; + let iface_id = &rest[..end]; + let label = &rest[end + 2..]; + if iface_id.is_empty() || label.is_empty() { + return None; + } + Some((iface_id, label)) +} + pub fn parse_world_key(name: &str) -> Option<(&str, &str, &str)> { + // Handle [implements=]label format by parsing the embedded + // interface ID + if let Some((iface_id, _label)) = parse_implements_name(name) { + return parse_world_key(iface_id); + } let registry_idx = name.find(':')?; let ns = &name[0..registry_idx]; match name.rfind('/') { @@ -5283,4 +5431,257 @@ mod tests { fn test_parse_mapping_empty() { assert_eq!(parse_mapping(""), ("".into(), None)); } + + #[test] + fn test_parse_implements_name_basic() { + assert_eq!( + parse_implements_name("[implements=]label"), + Some(("ns:pkg/iface", "label")) + ); + } + + #[test] + fn test_parse_implements_name_with_version() { + assert_eq!( + parse_implements_name("[implements=]my-label"), + Some(("ns:pkg/iface@1.0.0", "my-label")) + ); + } + + #[test] + fn test_parse_implements_name_not_implements() { + assert_eq!(parse_implements_name("ns:pkg/iface"), None); + assert_eq!(parse_implements_name("plain-name"), None); + assert_eq!(parse_implements_name(""), None); + } + + #[test] + fn test_parse_implements_name_empty_parts() { + // Empty iface_id or label should return None + assert_eq!(parse_implements_name("[implements=<>]label"), None); + assert_eq!(parse_implements_name("[implements=]"), None); + } + + #[test] + fn test_parse_world_key_with_implements() { + // parse_world_key should strip the implements prefix and parse the inner iface ID + assert_eq!( + parse_world_key("[implements=]label"), + Some(("ns", "pkg", "iface")) + ); + } + + #[test] + fn test_parse_world_key_with_implements_versioned() { + // parse_world_key strips the version from the interface name + assert_eq!( + parse_world_key("[implements=]my-label"), + Some(("ns", "pkg", "iface")) + ); + } + + /// Helper: allocate a dummy interface in a Resolve and return its id. + fn dummy_interface(resolve: &mut Resolve) -> wit_parser::InterfaceId { + resolve.interfaces.alloc(wit_parser::Interface { + name: None, + types: Default::default(), + functions: Default::default(), + docs: Default::default(), + stability: wit_parser::Stability::Unknown, + package: None, + span: Default::default(), + clone_of: None, + }) + } + + #[test] + fn test_find_world_key_implements_tier1() { + // Tier 1: [implements=]label matches WorldKey::Name("label") with implements: Some(_) + let mut resolve = Resolve::default(); + let iface_id = dummy_interface(&mut resolve); + let items: Vec<(WorldKey, WorldItem)> = vec![( + WorldKey::Name("primary".into()), + WorldItem::Interface { + id: iface_id, + stability: wit_parser::Stability::Unknown, + implements: Some(iface_id), + span: Default::default(), + }, + )]; + let refs: Vec<_> = items.iter().map(|(k, v)| (k, v)).collect(); + let result = find_world_key( + &resolve, + refs.into_iter(), + "[implements=]primary", + ); + assert_eq!(result, Some(WorldKey::Name("primary".into()))); + } + + #[test] + fn test_find_world_key_implements_label_fallback() { + // Tier 2: [implements=]label falls back to WorldKey::Name("label") without implements + let mut resolve = Resolve::default(); + let iface_id = dummy_interface(&mut resolve); + let items: Vec<(WorldKey, WorldItem)> = vec![( + WorldKey::Name("primary".into()), + WorldItem::Interface { + id: iface_id, + stability: wit_parser::Stability::Unknown, + implements: None, + span: Default::default(), + }, + )]; + let refs: Vec<_> = items.iter().map(|(k, v)| (k, v)).collect(); + let result = find_world_key( + &resolve, + refs.into_iter(), + "[implements=]primary", + ); + assert_eq!(result, Some(WorldKey::Name("primary".into()))); + } + + #[test] + fn test_find_world_key_semver_compat() { + // Tier 2: ns:pkg/iface@0.2.0 matches world item ns:pkg/iface@0.2.1 via semver + let resolve = Resolve::default(); + let items: Vec<(WorldKey, WorldItem)> = vec![( + WorldKey::Name("ns:pkg/iface@0.2.1".into()), + WorldItem::Function(Function { + name: "f".into(), + kind: FunctionKind::Freestanding, + params: vec![], + result: None, + docs: Default::default(), + stability: wit_parser::Stability::Unknown, + span: Default::default(), + }), + )]; + let refs: Vec<_> = items.iter().map(|(k, v)| (k, v)).collect(); + let result = find_world_key(&resolve, refs.into_iter(), "ns:pkg/iface@0.2.0"); + assert_eq!(result, Some(WorldKey::Name("ns:pkg/iface@0.2.1".into()))); + } + + #[test] + fn test_find_world_key_semver_no_cross_major() { + // ns:pkg/iface@1.0.0 does NOT match ns:pkg/iface@2.0.0 + let resolve = Resolve::default(); + let items: Vec<(WorldKey, WorldItem)> = vec![( + WorldKey::Name("ns:pkg/iface@2.0.0".into()), + WorldItem::Function(Function { + name: "f".into(), + kind: FunctionKind::Freestanding, + params: vec![], + result: None, + docs: Default::default(), + stability: wit_parser::Stability::Unknown, + span: Default::default(), + }), + )]; + let refs: Vec<_> = items.iter().map(|(k, v)| (k, v)).collect(); + let result = find_world_key(&resolve, refs.into_iter(), "ns:pkg/iface@1.0.0"); + assert_eq!(result, None); + } + + /// WAT fixture: a component with two `[implements=<...>]` imports of the + /// same interface under different labels ("primary" and "backup"). + #[cfg(feature = "transpile-bindgen")] + const IMPLEMENTS_WAT: &str = r#" +(component + (type $store-instance (instance + (type $set-type (func (param "key" string) (param "value" string))) + (export "set" (func (type $set-type))) + )) + (import "[implements=]primary" (instance $primary (type $store-instance))) + (import "[implements=]backup" (instance $backup (type $store-instance))) + (core module $mem_mod + (memory (export "memory") 1) + (func (export "cabi_realloc") (param i32 i32 i32 i32) (result i32) i32.const 0) + ) + (core instance $mem (instantiate $mem_mod)) + (core func $primary-set (canon lower (func $primary "set") + (memory $mem "memory") (realloc (func $mem "cabi_realloc")))) + (core func $backup-set (canon lower (func $backup "set") + (memory $mem "memory") (realloc (func $mem "cabi_realloc")))) + (core module $m + (import "env" "memory" (memory 1)) + (import "primary" "set" (func $primary_set (param i32 i32 i32 i32))) + (import "backup" "set" (func $backup_set (param i32 i32 i32 i32))) + (func (export "run") (result i32) + (call $primary_set (i32.const 0) (i32.const 1) (i32.const 0) (i32.const 1)) + (call $backup_set (i32.const 0) (i32.const 1) (i32.const 0) (i32.const 1)) + i32.const 0 + ) + (func (export "cabi_post_run") (param i32)) + ) + (core instance $inst (instantiate $m + (with "env" (instance (export "memory" (memory $mem "memory")))) + (with "primary" (instance (export "set" (func $primary-set)))) + (with "backup" (instance (export "set" (func $backup-set)))) + )) + (type $run-type (func (result string))) + (func $run (type $run-type) (canon lift (core func $inst "run") (memory $mem "memory") (post-return (func $inst "cabi_post_run")))) + (export "run" (func $run)) +) + "#; + + #[cfg(feature = "transpile-bindgen")] + #[test] + fn test_transpile_implements_component() { + let component = wat::parse_str(IMPLEMENTS_WAT).expect("failed to parse WAT"); + + // Transpile the component + let opts = crate::TranspileOpts { + name: "multi-store".into(), + no_typescript: false, + instantiation: None, + map: None, + no_nodejs_compat: false, + base64_cutoff: 5000, + tla_compat: false, + valid_lifting_optimization: false, + tracing: false, + no_namespaced_exports: false, + multi_memory: true, + import_bindings: None, + guest: false, + async_mode: None, + }; + + let result = crate::transpile(&component, opts).expect("failed to transpile"); + + // Verify imports include "primary" and "backup" + assert!( + result.imports.iter().any(|i| i == "primary"), + "Expected 'primary' in imports, got: {:?}", + result.imports + ); + assert!( + result.imports.iter().any(|i| i == "backup"), + "Expected 'backup' in imports, got: {:?}", + result.imports + ); + + // Verify the JS file references both import names + let js_file = result + .files + .iter() + .find(|(name, _)| name == "multi-store.js") + .expect("missing JS file"); + let js_source = String::from_utf8(js_file.1.clone()).unwrap(); + assert!( + js_source.contains("primary"), + "Generated JS should reference 'primary'" + ); + assert!( + js_source.contains("backup"), + "Generated JS should reference 'backup'" + ); + + // Verify the "run" export exists + assert!( + result.exports.iter().any(|(name, _)| name == "run"), + "Expected 'run' in exports, got: {:?}", + result.exports + ); + } } diff --git a/crates/js-component-bindgen/src/ts_bindgen.rs b/crates/js-component-bindgen/src/ts_bindgen.rs index a5a6584fd..f60ac3de2 100644 --- a/crates/js-component-bindgen/src/ts_bindgen.rs +++ b/crates/js-component-bindgen/src/ts_bindgen.rs @@ -460,9 +460,16 @@ impl TsBindgen { } else { // Generate a type-only export (`export type *` instead of `export *`). // so that users can use the interface types, even though there is no runtime code. + // + // For implements items (where a plain label like "primary" maps to + // a named interface like "ns:pkg/store"), use the label as the + // export alias so each instance gets a distinct name. let id_name = resolve.id_of(id).unwrap_or_else(|| name.to_string()); let import_path = self.generate_interface( - &id_name, + // For implements items, both labels resolve to the same interface + // and share a single .d.ts file via generate_interface's dedup. + // The label is only used as the UpperCamelCase export alias. + name, resolve, id, files, @@ -471,7 +478,7 @@ impl TsBindgen { uwriteln!( self.src, "export type * as {} from '{import_path}'; // import {}", - id_name.to_upper_camel_case(), + name.to_upper_camel_case(), id_name ); } @@ -1371,10 +1378,14 @@ trait ResolveExt { impl ResolveExt for Resolve { fn exports_interface(&self, iface_id: InterfaceId) -> bool { for (_world_id, world) in self.worlds.iter() { - for (world_key, _world_item) in world.exports.iter() { + for (world_key, world_item) in world.exports.iter() { if matches!(world_key, WorldKey::Interface(iid) if *iid == iface_id) { return true; } + // Also check implements items where key is Name but item has the interface + if matches!(world_item, WorldItem::Interface { id, .. } if *id == iface_id) { + return true; + } } } false diff --git a/packages/jco/test/api.js b/packages/jco/test/api.js index fab21c123..e14b1263a 100644 --- a/packages/jco/test/api.js +++ b/packages/jco/test/api.js @@ -226,6 +226,83 @@ suite("API", () => { assert.ok(optimizedComponent.byteLength < flavorfulWasmBytes.byteLength); }); + // Shared WAT fixture: a component with two [implements=<...>] imports of the + // same interface under different labels ("primary" and "backup"). + const implementsWat = ` +(component + (type $store-instance (instance + (type $set-type (func (param "key" string) (param "value" string))) + (export "set" (func (type $set-type))) + )) + (import "[implements=]primary" (instance $primary (type $store-instance))) + (import "[implements=]backup" (instance $backup (type $store-instance))) + (core module $mem_mod + (memory (export "memory") 1) + (func (export "cabi_realloc") (param i32 i32 i32 i32) (result i32) i32.const 0) + ) + (core instance $mem (instantiate $mem_mod)) + (core func $primary-set (canon lower (func $primary "set") + (memory $mem "memory") (realloc (func $mem "cabi_realloc")))) + (core func $backup-set (canon lower (func $backup "set") + (memory $mem "memory") (realloc (func $mem "cabi_realloc")))) + (core module $m + (import "env" "memory" (memory 1)) + (import "primary" "set" (func $primary_set (param i32 i32 i32 i32))) + (import "backup" "set" (func $backup_set (param i32 i32 i32 i32))) + (func (export "run") (result i32) + (call $primary_set (i32.const 0) (i32.const 1) (i32.const 0) (i32.const 1)) + (call $backup_set (i32.const 0) (i32.const 1) (i32.const 0) (i32.const 1)) + i32.const 0 + ) + (func (export "cabi_post_run") (param i32)) + ) + (core instance $inst (instantiate $m + (with "env" (instance (export "memory" (memory $mem "memory")))) + (with "primary" (instance (export "set" (func $primary-set)))) + (with "backup" (instance (export "set" (func $backup-set)))) + )) + (type $run-type (func (result string))) + (func $run (type $run-type) (canon lift (core func $inst "run") (memory $mem "memory") (post-return (func $inst "cabi_post_run")))) + (export "run" (func $run)) +) +`; + + test("Implements - transpile component with multiple instances of same interface", async () => { + const component = await parse(implementsWat); + + const name = "multi-store"; + const { files, imports, exports } = await transpile(component, { name }); + + // Should have separate import specifiers for "primary" and "backup" + assert.ok(imports.includes("primary"), `Expected "primary" in imports, got: ${JSON.stringify(imports)}`); + assert.ok(imports.includes("backup"), `Expected "backup" in imports, got: ${JSON.stringify(imports)}`); + + // Verify the generated JS source references both import names + const source = Buffer.from(files[name + ".js"]).toString(); + assert.ok(source.includes("primary"), "Generated JS should reference 'primary' import"); + assert.ok(source.includes("backup"), "Generated JS should reference 'backup' import"); + + // Should have the "run" export + assert.ok(exports.some(([exportName]) => exportName === "run"), `Expected "run" in exports, got: ${JSON.stringify(exports)}`); + }); + + test("Implements - type generation for implements items", async () => { + const component = await parse(implementsWat); + + const name = "multi-store"; + const { files } = await transpile(component, { name }); + + // Check that .d.ts files are generated for the implements items + const dtsFile = files[name + ".d.ts"]; + assert.ok(dtsFile, "Should generate a .d.ts file"); + const dtsSource = Buffer.from(dtsFile).toString(); + + // Both "primary" and "backup" should appear in the type definitions + // (as upper camel case: Primary, Backup) + assert.ok(dtsSource.includes("Primary"), "Type definitions should reference 'Primary'"); + assert.ok(dtsSource.includes("Backup"), "Type definitions should reference 'Backup'"); + }); + test("Transpile & Optimize & Minify", async () => { const name = "flavorful"; const { files, imports, exports } = await transpile(flavorfulWasmBytes, {