Skip to content

feat: Add support for implements syntax#12698

Draft
ricochet wants to merge 1 commit intobytecodealliance:mainfrom
ricochet:implements
Draft

feat: Add support for implements syntax#12698
ricochet wants to merge 1 commit intobytecodealliance:mainfrom
ricochet:implements

Conversation

@ricochet
Copy link
Contributor

@ricochet ricochet commented Feb 28, 2026

DO NOT MERGE until wasm-tools release with
bytecodealliance/wasm-tools#2453
Points wasm-tools to PR branch wasmparser-implements

Add support for the component model [implements=<I>]L
(spec PR #613),
which allows components to import/export the same
interface multiple times under different plain names.

A component can import the same interface twice under different labels,
each bound to a distinct host implementation:

import primary: wasi:keyvalue/store;
import secondary: wasi:keyvalue/store;

Guest code sees two separate namespaces with identical shapes:

let val = primary::get("my-key");       // calls the primary store
let val = secondary::get("my-key");     // calls the secondary store

Host Import-side codegen: shared trait + label-parameterized add_to_linker

For imports, wit-bindgen generates one Host trait per interface (not per
label). The add_to_linker function takes a name: &str parameter so the
same trait implementation can be registered under different instance labels.
Duplicate implements imports don't generate separate modules — only the
first import produces bindings.

struct PrimaryBackend;
impl primary::Host for PrimaryBackend {
    fn get(&mut self, key: String) -> String {
        self.primary_db.get(&key).cloned().unwrap_or_default()
    }
}

struct SecondaryBackend;
impl primary::Host for SecondaryBackend {
    fn get(&mut self, key: String) -> String {
        self.secondary_db.get(&key).cloned().unwrap_or_default()
    }
}

// Same add_to_linker, different labels and host_getter closures
primary::add_to_linker(&mut linker, "primary", |s| &mut s.primary)?;
primary::add_to_linker(&mut linker, "secondary", |s| &mut s.secondary)?;

Export-side codegen: per-label modules with shared types

For exports, each label gets its own module with fresh Guest/GuestIndices
types but re-exports shared interface types from the first module via
pub use super::{first}::*.

Runtime name resolution

The linker supports registering by plain label without knowing the annotation:

// Component imports [implements=<wasi:keyvalue/store>]primary
// but the host just registers "primary" — label fallback handles it
linker.root().instance("primary")?.func_wrap("get", /* ... */)?;

Users can also register to the linker with the full encoded implements name:

linker
    .root()
    .instance("[implements=<wasi:keyvalue/store>]primary")?
    .func_wrap("get", |_, (key,): (String,)| Ok((String::new(),)))?;

Semver matching works inside the implements annotation, just like
regular interface imports:

// Host provides v1.0.1
linker
    .root()
    .instance("[implements=<wasi:keyvalue/store@1.0.1>]primary")?
    .func_wrap("get", |_, (key,): (String,)| Ok((String::new(),)))?;

// Component requests v1.0.0, matches via semver
let component = Component::new(&engine, r#"(component
    (type $store (instance
        (export "get" (func (param "key" string) (result string)))
    ))
    (import "[implements=<wasi:keyvalue/store@1.0.0>]primary" (instance (type $store)))
)"#)?;
linker.instantiate(&mut store, &component)?; // works, 1.0.1 is semver-compatible with 1.0.0

Changes

Runtime name resolution

  • Add three-tier lookup in NameMap::get: exact → semver → label fallback
  • Add implements_label_key() helper for extracting plain labels from
    [implements=<I>]L
  • Add unit tests for all lookup tiers

Import codegen (crates/wit-bindgen/src/lib.rs)

  • Track first-seen implements imports per InterfaceId
  • One Host trait per interface; generate_add_to_linker takes
    named: bool — when true, emits name: &str parameter instead of
    hardcoding the instance name
  • Duplicate implements imports: just record the label in
    implements_labels, no module generation
  • world_add_to_linker: iterate over implements_labels to emit one
    add_to_linker call per label, passing label as name argument
  • Guard populate_world_and_interface_options with entry() to avoid
    overwriting link options for duplicate interfaces

Export codegen (crates/wit-bindgen/src/lib.rs)

  • Duplicate exports: re-export types via pub use super::{first}::*,
    generate fresh Guest/GuestIndices, plus regenerate resource wrapper
    structs to reference the local Guest type
  • Use name_world_key_with_item for export instance name lookups

@cfallin
Copy link
Member

cfallin commented Mar 1, 2026

I'm excited to see this happening after reading through WebAssembly/component-model#287 -- thanks!

A question on the host-side API design: the PR description says

From the host, wit-bindgen generates a separate Host trait per label:

This is somewhat surprising to me as, at least naively, I'd expect one WIT interface to correspond to one Rust trait; rather than statically generating new Rust traits per separate import of an interface. This is sort of getting back to the issue I described here, but in host form: if I have a generic implementation (say of wasi-http's various interfaces) I might have to write a whole set of newtype shims on top to label them as "the first HTTP" or "the second HTTP". From a perspective of dependency injection and interfaces (even fully statically resolved ones like C++ templates or ML modules) I'd expect to be able to programmatically plug in various (say) HTTP-shaped things into the N static HTTP slots just like I'd pass (strongly typed) function parameters.

I don't know if this means that there needs to be an add_to_linker variant that takes a label, or something like that? I haven't read through this PR or tried to sketch a full design delta, apologies; just reacting to the lack of "WIT interface == Rust interface" and the implications that might have.

@ricochet
Copy link
Contributor Author

ricochet commented Mar 1, 2026

You're raising a good point. There are a few different ways to handle the Rust-side trait story for implements, and they sit on a real tradeoff axis.

With what I have in draft, each labeled import gets its own Host trait in its own module. Types are shared across labels (via pub use), but the traits are distinct. The mental for this case is that each label implies significant behavioral differences. The reason I opted for this is that there is a key reason a world may have opted to use an implements label, like one cache for remote session management and the other is a local cache. In my use-case, I have a host that connects to many different types of stores and desire the ergonomics of N impl blocks on one type.

The use-case where I really don't like this approach is the VFS one, where it's different contained filesystem volume mounts, and I will want to treat them all exactly the same. In a lot of ways, I see implements as a first step towards wit templates, and for that, this is a poor fit.

Rust doesn't allow implementing the same trait twice on the same type with different behavior. So the design choice is really about which usage pattern gets us to the most ergonomic path.

Some alternatives considering a world that imports the same key-value store interface twice; one backed by Redis, one by Memcached:

interface store {
    get: func(key: string) -> option<string>;
    set: func(key: string, value: string);
}

world my-cache {
    import hot-cache: store;    // backed by memcached
    import durable: store;      // backed by redis
}

With Option A (current PR):

// hot_cache::Host and durable::Host are separate traits with identical signatures
impl hot_cache::Host for MyState {
    fn get(&mut self, key: String) -> Option<String> { self.memcached.get(&key) }
    fn set(&mut self, key: String, value: String) { self.memcached.set(&key, &value); }
}
impl durable::Host for MyState {
    fn get(&mut self, key: String) -> Option<String> { self.redis.get(&key) }
    fn set(&mut self, key: String, value: String) { self.redis.set(&key, &value); }
}

hot_cache::add_to_linker(&mut linker, |s| s)?;
durable::add_to_linker(&mut linker, |s| s)?;

Option B: Shared trait, label-parameterized registration

One Host trait per interface. Each label's add_to_linker takes a label name (or wraps it), and the user provides separate values/types that all impl the same trait.

// Library types implement store::Host directly, no shims, so we could easily share a wasmtime-keyvalue-backend
// RedisBackend: impl store::Host { ... }
// MemcachedBackend: impl store::Host { ... }

struct MyState {
    hot_cache: MemcachedBackend,
    durable: RedisBackend,
}

store::add_to_linker(&mut linker, "hot-cache", |s: &mut MyState| &mut s.hot_cache)?;
store::add_to_linker(&mut linker, "durable", |s: &mut MyState| &mut s.durable)?;

What I like about this is that it's easy to reason about, "interfaces are shapes, labels are binding sites"

Option C: Shared trait, per-label convenience modules

Generate one shared Host trait in the first module. Subsequent label modules re-export it (no shadowing) and provide thin add_to_linker wrappers with the label baked in:

// Generated: shared trait
mod store { pub trait Host { ... } }

// Generated: per-label wrappers with the label baked in
mod hot_cache {
    pub use super::store::*;  // includes Host, not shadowed
    pub fn add_to_linker<T, D: Host>(...) -> Result<()> {
        store::add_to_linker(linker, "hot-cache", get)
    }
}
mod durable {
    pub use super::store::*;
    pub fn add_to_linker<T, D: Host>(...) -> Result<()> {
        store::add_to_linker(linker, "durable", get)
    }
}

// User code: library types work directly:
struct MyState {
    hot_cache: MemcachedBackend,   // impl store::Host
    durable: RedisBackend,                   // impl store::Host
}

hot_cache::add_to_linker(&mut linker, |s: &mut MyState| &mut s.hot_cache)?;
durable::add_to_linker(&mut linker, |s: &mut MyState| &mut s.durable)?;

I like that a per-label add_to_linker means no stringly-typed labels, although I'm not overly concerned that we require type checking here when pre_instantiate guarantees imports are fulfilled. The only downside I see with this is that both options B and C require separate types.

The change to do either of these options is fairly contained, and I am very open to any of these approaches. Are there other options I haven't considered?

@cfallin
Copy link
Member

cfallin commented Mar 1, 2026

Thanks for sketching out the decision-space you've considered a bit more!

I think that Option B is the clear answer at least to me; a few reasons:

  • One has to consider the separation between provider code (the implementation of an interface) and a particular world. For example the wasi-http crate exports its add_to_linker; it knows nothing about how some particular embedding might choose to define a world that accepts two instances of its interface. So we can't bake that distinction into the traits themselves (option A) nor have statically different add_to_linkers (option C).

    At least the way I think about it, I see the "lego-block plumbing" philosophy of component composition writ large reflected down into the world-definition space here: one should be able to define a world that imports N interfaces (some of the same type) and exports M interfaces, and wire up a bunch of different modules on the host side that came from different places, plugging them in in just one place that does that wiring. Thinking of what has to be defined in each separable module hopefully crystalizes some of the choices (?).

  • I also see this as kind of analogous to the core Wasm function import/export universe. Interfaces today are distinguished only by their type; one can only have one of each type. That'd be akin to saying that one can only import one function of signature (param i32) (result i32). We use stringly-typed names to allow there to be more than one, so in that sense I don't see it as worse that we would use textual labels to name the specific slots we plug instances of interfaces into, either.


I do think there is an interesting question about how to build a more statically-typed API around this. Taking the "world composition site must be separate from interfaces" requirement again, I think it would have to look something like defining a type for a world that is a builder for a linker, and takes the properly typed thing for each named (or default unnamed) interface that is imported. Something like

world example-world {
  import first: my-interface;
  import second: my-interface;
}

becoming

struct ExampleWorldBuilder { ... }
impl ExampleWorldBuilder {
  fn new() -> Self { ... }
  fn first(&mut self, impl_: impl MyInterfaceBuilder) { ... }
  fn second(...) { }
  fn build(self, linker: &mut Linker) { ... }
}
trait MyInterfaceBuilder { fn add_to_linker(...); }

or something like that, and then MyInterfaceBuilder is provided by wit-bindgen in the same way add_to_linker is today -- details elided wrt generics for store-data types, HasData magic and all that. (I have a vague sense that a naive impl of the trait method add_to_linker will run into HKT issues but maybe parameterizing the trait could get around it.)

One could use this strategy with today's worlds (without named interface instances) as well -- then you'd have fn http(...), fn filesystem(...), etc for a standard WASI world. I like this better than today's "litany of add_to_linker calls" approach -- the ability to forget one of those calls is a kind of dynamism/lack of static checking, too.

In any case, that seems like a much higher bar / stretch goal, and as mentioned above I don't see the "label string" approach as fundamentally worse than setting up a core Wasm environment with string names for provided functions, so that (Option B) is probably good enough for now!

DO NOT MERGE until wasm-tools release with
bytecodealliance/wasm-tools#2453
Points wasm-tools to PR branch  `wasmparser-implements`

Add support for the component model `[implements=<I>]L`
(spec PR [bytecodealliance#613](WebAssembly/component-model#613)),
which allows components to import/export the same
interface multiple times under different plain names.

A component can import the same interface twice under different labels,
each bound to a distinct host implementation:

```wit
import primary: wasi:keyvalue/store;
import secondary: wasi:keyvalue/store;
```

Guest code sees two separate namespaces with identical shapes:

```rust
let val = primary::get("my-key");       // calls the primary store
let val = secondary::get("my-key");     // calls the secondary store
```

Host Import-side codegen: shared trait + label-parameterized add_to_linker

For imports, wit-bindgen generates one Host trait per interface (not per
label). The add_to_linker function takes a name: &str parameter so the
same trait implementation can be registered under different instance labels.
Duplicate implements imports don't generate separate modules — only the
first import produces bindings.

```rust
struct PrimaryBackend;
impl primary::Host for PrimaryBackend {
    fn get(&mut self, key: String) -> String {
        self.primary_db.get(&key).cloned().unwrap_or_default()
    }
}

struct SecondaryBackend;
impl primary::Host for SecondaryBackend {
    fn get(&mut self, key: String) -> String {
        self.secondary_db.get(&key).cloned().unwrap_or_default()
    }
}

// Same add_to_linker, different labels and host_getter closures
primary::add_to_linker(&mut linker, "primary", |s| &mut s.primary)?;
primary::add_to_linker(&mut linker, "secondary", |s| &mut s.secondary)?;
```

Export-side codegen: per-label modules with shared types

For exports, each label gets its own module with fresh Guest/GuestIndices
types but re-exports shared interface types from the first module via
`pub use super::{first}::*`.

Runtime name resolution

The linker supports registering by plain label without knowing the annotation:

```rust
// Component imports [implements=<wasi:keyvalue/store>]primary
// but the host just registers "primary" — label fallback handles it
linker.root().instance("primary")?.func_wrap("get", /* ... */)?;

Users can also register to the linker with the full encoded implements name:

linker
    .root()
    .instance("[implements=<wasi:keyvalue/store>]primary")?
    .func_wrap("get", |_, (key,): (String,)| Ok((String::new(),)))?;
```

Semver matching works inside the implements annotation, just like
regular interface imports:

```rust
// Host provides v1.0.1
linker
    .root()
    .instance("[implements=<wasi:keyvalue/store@1.0.1>]primary")?
    .func_wrap("get", |_, (key,): (String,)| Ok((String::new(),)))?;

// Component requests v1.0.0, matches via semver
let component = Component::new(&engine, r#"(component
    (type $store (instance
        (export "get" (func (param "key" string) (result string)))
    ))
    (import "[implements=<wasi:keyvalue/store@1.0.0>]primary" (instance (type $store)))
)"#)?;
linker.instantiate(&mut store, &component)?; // works, 1.0.1 is semver-compatible with 1.0.0
```

- Add three-tier lookup in NameMap::get: exact → semver → label fallback
- Add implements_label_key() helper for extracting plain labels from
  `[implements=<I>]L`
- Add unit tests for all lookup tiers

- Track first-seen implements imports per `InterfaceId`
- One `Host` trait per interface; `generate_add_to_linker` takes
  `named: bool` — when true, emits `name: &str` parameter instead of
  hardcoding the instance name
- Duplicate `implements` imports: just record the label in
  `implements_labels`, no module generation
- `world_add_to_linker`: iterate over `implements_labels` to emit one
  `add_to_linker` call per label, passing label as name argument
- Guard `populate_world_and_interface_options` with `entry()` to avoid
  overwriting link options for duplicate interfaces

- Duplicate exports: re-export types via `pub use super::{first}::*`,
  generate fresh `Guest`/`GuestIndices`, plus regenerate resource wrapper
  structs to reference the local `Guest` type
- Use `name_world_key_with_item` for export instance name lookups
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants