diff --git a/crates/wac-graph/src/graph.rs b/crates/wac-graph/src/graph.rs index 7948e5b..1b71b6f 100644 --- a/crates/wac-graph/src/graph.rs +++ b/crates/wac-graph/src/graph.rs @@ -1553,7 +1553,8 @@ impl<'a> CompositionGraphEncoder<'a> { // Populate the implicit argument map for (name, node) in implicit_imports { - let (kind, index) = encoded[name]; + let canonical = aggregator.canonical_import_name(name); + let (kind, index) = encoded[canonical]; state .implicit_args .entry(node) @@ -1563,7 +1564,8 @@ impl<'a> CompositionGraphEncoder<'a> { // Finally, populate the node indexes with the encoded explicit imports for (name, node_index) in explicit_imports { - let (_, encoded_index) = encoded[name]; + let canonical = aggregator.canonical_import_name(name); + let (_, encoded_index) = encoded[canonical]; state.node_indexes.insert(node_index, encoded_index); } diff --git a/crates/wac-graph/src/plug.rs b/crates/wac-graph/src/plug.rs index 6a6ae32..26356d1 100644 --- a/crates/wac-graph/src/plug.rs +++ b/crates/wac-graph/src/plug.rs @@ -1,4 +1,7 @@ -use crate::{types::SubtypeChecker, CompositionGraph, PackageId}; +use crate::{ + types::{are_semver_compatible, SubtypeChecker}, + CompositionGraph, PackageId, +}; use thiserror::Error; /// Represents an error that can occur when plugging components together. @@ -28,23 +31,38 @@ pub fn plug( let socket_instantiation = graph.instantiate(socket); for plug in plugs { - let mut plug_exports = Vec::new(); + // Collect matching (plug_export_name, socket_import_name) pairs. + // The names may differ when matched via semver compatibility. + let mut plug_exports: Vec<(String, String)> = Vec::new(); let mut cache = Default::default(); let mut checker = SubtypeChecker::new(&mut cache); for (name, plug_ty) in &graph.types()[graph[plug].ty()].exports { - if let Some(socket_ty) = graph.types()[graph[socket].ty()].imports.get(name) { + // Try exact name match first, then fall back to semver-compatible match + let matching_import = graph.types()[graph[socket].ty()] + .imports + .get(name) + .map(|ty| (name.clone(), ty)) + .or_else(|| { + graph.types()[graph[socket].ty()] + .imports + .iter() + .find(|(import_name, _)| are_semver_compatible(name, import_name)) + .map(|(import_name, ty)| (import_name.clone(), ty)) + }); + + if let Some((socket_name, socket_ty)) = matching_import { if checker .is_subtype(*plug_ty, graph.types(), *socket_ty, graph.types()) .is_ok() { - plug_exports.push(name.clone()); + plug_exports.push((name.clone(), socket_name)); } } } // Instantiate the plug component let mut plug_instantiation = None; - for plug_name in plug_exports { + for (plug_name, socket_name) in plug_exports { log::debug!("using export `{plug_name}` for plug"); let plug_instantiation = *plug_instantiation.get_or_insert_with(|| graph.instantiate(plug)); @@ -52,7 +70,7 @@ pub fn plug( .alias_instance_export(plug_instantiation, &plug_name) .map_err(|err| PlugError::GraphError { source: err.into() })?; graph - .set_instantiation_argument(socket_instantiation, &plug_name, export) + .set_instantiation_argument(socket_instantiation, &socket_name, export) .map_err(|err| PlugError::GraphError { source: err.into() })?; } } diff --git a/crates/wac-graph/tests/graphs/semver-highest-version-retained/encoded.wat b/crates/wac-graph/tests/graphs/semver-highest-version-retained/encoded.wat new file mode 100644 index 0000000..864ee08 --- /dev/null +++ b/crates/wac-graph/tests/graphs/semver-highest-version-retained/encoded.wat @@ -0,0 +1,47 @@ +(component + (type (;0;) + (instance + (export (;0;) "w" (type (sub resource))) + (type (;1;) (record (field "x" u32))) + (export (;2;) "x" (type (eq 1))) + (type (;3;) (func (param "x" 2))) + (export (;0;) "y" (func (type 3))) + (export (;4;) "z" (type (sub resource))) + ) + ) + (import "foo:bar/types@0.2.1" (instance (;0;) (type 0))) + (type (;1;) + (component + (type (;0;) + (instance + (export (;0;) "w" (type (sub resource))) + ) + ) + (import "foo:bar/types@0.2.1" (instance (;0;) (type 0))) + ) + ) + (import "unlocked-dep=" (component (;0;) (type 1))) + (instance (;1;) (instantiate 0 + (with "foo:bar/types@0.2.1" (instance 0)) + ) + ) + (type (;2;) + (component + (type (;0;) + (instance + (type (;0;) (record (field "x" u32))) + (export (;1;) "x" (type (eq 0))) + (type (;2;) (func (param "x" 1))) + (export (;0;) "y" (func (type 2))) + (export (;3;) "z" (type (sub resource))) + ) + ) + (import "foo:bar/types@0.2.0" (instance (;0;) (type 0))) + ) + ) + (import "unlocked-dep=" (component (;1;) (type 2))) + (instance (;2;) (instantiate 1 + (with "foo:bar/types@0.2.0" (instance 0)) + ) + ) +) diff --git a/crates/wac-graph/tests/graphs/semver-highest-version-retained/graph.json b/crates/wac-graph/tests/graphs/semver-highest-version-retained/graph.json new file mode 100644 index 0000000..2f1bcf3 --- /dev/null +++ b/crates/wac-graph/tests/graphs/semver-highest-version-retained/graph.json @@ -0,0 +1,22 @@ +{ + "packages": [ + { + "name": "test:higher", + "path": "higher.wat" + }, + { + "name": "test:lower", + "path": "lower.wat" + } + ], + "nodes": [ + { + "type": "instantiation", + "package": 0 + }, + { + "type": "instantiation", + "package": 1 + } + ] +} diff --git a/crates/wac-graph/tests/graphs/semver-highest-version-retained/higher.wat b/crates/wac-graph/tests/graphs/semver-highest-version-retained/higher.wat new file mode 100644 index 0000000..3152a60 --- /dev/null +++ b/crates/wac-graph/tests/graphs/semver-highest-version-retained/higher.wat @@ -0,0 +1,8 @@ +(component + (type + (instance + (export "w" (type (sub resource))) + ) + ) + (import "foo:bar/types@0.2.1" (instance (type 0))) +) diff --git a/crates/wac-graph/tests/graphs/semver-highest-version-retained/lower.wat b/crates/wac-graph/tests/graphs/semver-highest-version-retained/lower.wat new file mode 100644 index 0000000..724b70b --- /dev/null +++ b/crates/wac-graph/tests/graphs/semver-highest-version-retained/lower.wat @@ -0,0 +1,12 @@ +(component + (type + (instance + (type (record (field "x" u32))) + (export "x" (type (eq 0))) + (type (func (param "x" 1))) + (export "y" (func (type 2))) + (export "z" (type (sub resource))) + ) + ) + (import "foo:bar/types@0.2.0" (instance (type 0))) +) diff --git a/crates/wac-graph/tests/graphs/simple-versioned/bar.wat b/crates/wac-graph/tests/graphs/simple-versioned/bar.wat new file mode 100644 index 0000000..16ec128 --- /dev/null +++ b/crates/wac-graph/tests/graphs/simple-versioned/bar.wat @@ -0,0 +1,8 @@ +(component + (type + (instance + (export "w" (type (sub resource))) + ) + ) + (import "foo:bar/types@0.2.1" (instance (type 0))) +) \ No newline at end of file diff --git a/crates/wac-graph/tests/graphs/simple-versioned/encoded.wat b/crates/wac-graph/tests/graphs/simple-versioned/encoded.wat new file mode 100644 index 0000000..98f647c --- /dev/null +++ b/crates/wac-graph/tests/graphs/simple-versioned/encoded.wat @@ -0,0 +1,47 @@ +(component + (type (;0;) + (instance + (type (;0;) (record (field "x" u32))) + (export (;1;) "x" (type (eq 0))) + (type (;2;) (func (param "x" 1))) + (export (;0;) "y" (func (type 2))) + (export (;3;) "z" (type (sub resource))) + (export (;4;) "w" (type (sub resource))) + ) + ) + (import "foo:bar/types@0.2.1" (instance (;0;) (type 0))) + (type (;1;) + (component + (type (;0;) + (instance + (type (;0;) (record (field "x" u32))) + (export (;1;) "x" (type (eq 0))) + (type (;2;) (func (param "x" 1))) + (export (;0;) "y" (func (type 2))) + (export (;3;) "z" (type (sub resource))) + ) + ) + (import "foo:bar/types@0.2.0" (instance (;0;) (type 0))) + ) + ) + (import "unlocked-dep=" (component (;0;) (type 1))) + (instance (;1;) (instantiate 0 + (with "foo:bar/types@0.2.0" (instance 0)) + ) + ) + (type (;2;) + (component + (type (;0;) + (instance + (export (;0;) "w" (type (sub resource))) + ) + ) + (import "foo:bar/types@0.2.1" (instance (;0;) (type 0))) + ) + ) + (import "unlocked-dep=" (component (;1;) (type 2))) + (instance (;2;) (instantiate 1 + (with "foo:bar/types@0.2.1" (instance 0)) + ) + ) +) diff --git a/crates/wac-graph/tests/graphs/simple-versioned/foo.wat b/crates/wac-graph/tests/graphs/simple-versioned/foo.wat new file mode 100644 index 0000000..32fe608 --- /dev/null +++ b/crates/wac-graph/tests/graphs/simple-versioned/foo.wat @@ -0,0 +1,12 @@ +(component + (type + (instance + (type (record (field "x" u32))) + (export "x" (type (eq 0))) + (type (func (param "x" 1))) + (export "y" (func (type 2))) + (export "z" (type (sub resource))) + ) + ) + (import "foo:bar/types@0.2.0" (instance (type 0))) +) \ No newline at end of file diff --git a/crates/wac-graph/tests/graphs/simple-versioned/graph.json b/crates/wac-graph/tests/graphs/simple-versioned/graph.json new file mode 100644 index 0000000..d8a34a5 --- /dev/null +++ b/crates/wac-graph/tests/graphs/simple-versioned/graph.json @@ -0,0 +1,22 @@ +{ + "packages": [ + { + "name": "test:foo", + "path": "foo.wat" + }, + { + "name": "test:bar", + "path": "bar.wat" + } + ], + "nodes": [ + { + "type": "instantiation", + "package": 0 + }, + { + "type": "instantiation", + "package": 1 + } + ] +} diff --git a/crates/wac-graph/tests/graphs/versioned-transitive-usings/comp-a/deps/bar.wit b/crates/wac-graph/tests/graphs/versioned-transitive-usings/comp-a/deps/bar.wit new file mode 100644 index 0000000..76f1f32 --- /dev/null +++ b/crates/wac-graph/tests/graphs/versioned-transitive-usings/comp-a/deps/bar.wit @@ -0,0 +1,10 @@ +package foo:bar@0.2.0; + +interface types { + resource r; +} + +interface api { + use types.{r}; + do-something: func(x: r); +} diff --git a/crates/wac-graph/tests/graphs/versioned-transitive-usings/comp-a/world.wit b/crates/wac-graph/tests/graphs/versioned-transitive-usings/comp-a/world.wit new file mode 100644 index 0000000..67f4b31 --- /dev/null +++ b/crates/wac-graph/tests/graphs/versioned-transitive-usings/comp-a/world.wit @@ -0,0 +1,6 @@ +package test:a; + +world w { + import foo:bar/types@0.2.0; + import foo:bar/api@0.2.0; +} diff --git a/crates/wac-graph/tests/graphs/versioned-transitive-usings/comp-b/deps/bar.wit b/crates/wac-graph/tests/graphs/versioned-transitive-usings/comp-b/deps/bar.wit new file mode 100644 index 0000000..616a6ef --- /dev/null +++ b/crates/wac-graph/tests/graphs/versioned-transitive-usings/comp-b/deps/bar.wit @@ -0,0 +1,11 @@ +package foo:bar@0.2.1; + +interface types { + resource r; + resource s; +} + +interface api { + use types.{r}; + do-something: func(x: r); +} diff --git a/crates/wac-graph/tests/graphs/versioned-transitive-usings/comp-b/world.wit b/crates/wac-graph/tests/graphs/versioned-transitive-usings/comp-b/world.wit new file mode 100644 index 0000000..370aae6 --- /dev/null +++ b/crates/wac-graph/tests/graphs/versioned-transitive-usings/comp-b/world.wit @@ -0,0 +1,6 @@ +package test:b; + +world w { + import foo:bar/types@0.2.1; + import foo:bar/api@0.2.1; +} diff --git a/crates/wac-graph/tests/graphs/versioned-transitive-usings/encoded.wat b/crates/wac-graph/tests/graphs/versioned-transitive-usings/encoded.wat new file mode 100644 index 0000000..4c3b864 --- /dev/null +++ b/crates/wac-graph/tests/graphs/versioned-transitive-usings/encoded.wat @@ -0,0 +1,75 @@ +(component + (type (;0;) + (instance + (export (;0;) "r" (type (sub resource))) + (export (;1;) "s" (type (sub resource))) + ) + ) + (import "foo:bar/types@0.2.1" (instance (;0;) (type 0))) + (alias export 0 "r" (type (;1;))) + (type (;2;) + (instance + (alias outer 1 1 (type (;0;))) + (export (;1;) "r" (type (eq 0))) + (type (;2;) (own 1)) + (type (;3;) (func (param "x" 2))) + (export (;0;) "do-something" (func (type 3))) + ) + ) + (import "foo:bar/api@0.2.1" (instance (;1;) (type 2))) + (type (;3;) + (component + (type (;0;) + (instance + (export (;0;) "r" (type (sub resource))) + ) + ) + (import "foo:bar/types@0.2.0" (instance (;0;) (type 0))) + (alias export 0 "r" (type (;1;))) + (type (;2;) + (instance + (alias outer 1 1 (type (;0;))) + (export (;1;) "r" (type (eq 0))) + (type (;2;) (own 1)) + (type (;3;) (func (param "x" 2))) + (export (;0;) "do-something" (func (type 3))) + ) + ) + (import "foo:bar/api@0.2.0" (instance (;1;) (type 2))) + ) + ) + (import "unlocked-dep=" (component (;0;) (type 3))) + (instance (;2;) (instantiate 0 + (with "foo:bar/types@0.2.0" (instance 0)) + (with "foo:bar/api@0.2.0" (instance 1)) + ) + ) + (type (;4;) + (component + (type (;0;) + (instance + (export (;0;) "r" (type (sub resource))) + (export (;1;) "s" (type (sub resource))) + ) + ) + (import "foo:bar/types@0.2.1" (instance (;0;) (type 0))) + (alias export 0 "r" (type (;1;))) + (type (;2;) + (instance + (alias outer 1 1 (type (;0;))) + (export (;1;) "r" (type (eq 0))) + (type (;2;) (own 1)) + (type (;3;) (func (param "x" 2))) + (export (;0;) "do-something" (func (type 3))) + ) + ) + (import "foo:bar/api@0.2.1" (instance (;1;) (type 2))) + ) + ) + (import "unlocked-dep=" (component (;1;) (type 4))) + (instance (;3;) (instantiate 1 + (with "foo:bar/types@0.2.1" (instance 0)) + (with "foo:bar/api@0.2.1" (instance 1)) + ) + ) +) diff --git a/crates/wac-graph/tests/graphs/versioned-transitive-usings/graph.json b/crates/wac-graph/tests/graphs/versioned-transitive-usings/graph.json new file mode 100644 index 0000000..06a76f7 --- /dev/null +++ b/crates/wac-graph/tests/graphs/versioned-transitive-usings/graph.json @@ -0,0 +1,22 @@ +{ + "packages": [ + { + "name": "test:a", + "path": "comp-a" + }, + { + "name": "test:b", + "path": "comp-b" + } + ], + "nodes": [ + { + "type": "instantiation", + "package": 0 + }, + { + "type": "instantiation", + "package": 1 + } + ] +} diff --git a/crates/wac-types/src/aggregator.rs b/crates/wac-types/src/aggregator.rs index 808772d..b3f8d0d 100644 --- a/crates/wac-types/src/aggregator.rs +++ b/crates/wac-types/src/aggregator.rs @@ -1,4 +1,5 @@ use crate::{ + names::{alternate_lookup_key, are_semver_compatible}, DefinedType, DefinedTypeId, FuncType, FuncTypeId, Interface, InterfaceId, ItemKind, ModuleTypeId, Record, Resource, ResourceAlias, ResourceId, SubtypeChecker, Type, Types, UsedType, ValueType, Variant, World, WorldId, @@ -28,6 +29,9 @@ pub struct TypeAggregator { remapped: HashMap, /// A map of interface names to remapped interface id. interfaces: HashMap, + /// Maps import names that were superseded by a higher semver-compatible + /// version to the canonical (highest version) name. + name_redirects: HashMap, } impl TypeAggregator { @@ -46,6 +50,45 @@ impl TypeAggregator { self.imports.iter().map(|(n, k)| (n.as_str(), *k)) } + /// Returns the canonical (highest semver version) import name for the given name. + /// + /// If the name was superseded by a higher semver-compatible version, + /// the canonical name is returned. Otherwise, the name itself is returned. + pub fn canonical_import_name<'a>(&'a self, name: &'a str) -> &'a str { + self.name_redirects + .get(name) + .map(|s| s.as_str()) + .unwrap_or(name) + } + + /// Finds an import whose name is on a compatible semver track with `name`. + /// + /// Returns both the existing import name and its `ItemKind`. + fn find_semver_compatible_import(&self, name: &str) -> Option<(&str, ItemKind)> { + let (alt_key, _) = alternate_lookup_key(name)?; + for (existing_name, kind) in &self.imports { + if let Some((existing_alt, _)) = alternate_lookup_key(existing_name) { + if existing_alt == alt_key { + return Some((existing_name.as_str(), *kind)); + } + } + } + None + } + + /// Finds an interface whose name is on a compatible semver track with `name`. + fn find_semver_compatible_interface(&self, name: &str) -> Option { + let (alt_key, _) = alternate_lookup_key(name)?; + for (existing_name, id) in &self.interfaces { + if let Some((existing_alt, _)) = alternate_lookup_key(existing_name) { + if existing_alt == alt_key { + return Some(*id); + } + } + } + None + } + /// Aggregates a item kind from a specified type collection using the given /// import name. /// @@ -65,6 +108,33 @@ impl TypeAggregator { return Ok(self); } + // Check for a semver-compatible import (e.g., a:b/c@0.2.0 matching a:b/c@0.2.1) + if let Some((existing_name, existing_kind)) = self.find_semver_compatible_import(name) { + // Copy values before the mutable borrow in merge_item_kind + let existing_name = existing_name.to_string(); + self.merge_item_kind(existing_kind, types, kind, checker)?; + let (_, new_version) = alternate_lookup_key(name).unwrap(); + let (_, existing_version) = alternate_lookup_key(&existing_name).unwrap(); + + if new_version > existing_version { + // New version is higher: remove old entry, insert new name + let merged_kind = self.imports.shift_remove(&existing_name).unwrap(); + self.imports.insert(name.to_string(), merged_kind); + // Update any existing redirects that pointed to the old name + for redirect in self.name_redirects.values_mut() { + if *redirect == existing_name { + *redirect = name.to_string(); + } + } + self.name_redirects.insert(existing_name, name.to_string()); + } else { + // Existing version is higher or equal: redirect new name to existing + self.name_redirects.insert(name.to_string(), existing_name); + } + + return Ok(self); + } + let remapped = self.remap_item_kind(types, kind, checker)?; let prev = self.imports.insert(name.to_string(), remapped); assert!(prev.is_none()); @@ -166,8 +236,8 @@ impl TypeAggregator { .as_ref() .context("used type has no interface identifier")?; - // The interface names must match - if existing_interface != used_interface { + // The interface names must be on compatible semver tracks + if !are_semver_compatible(existing_interface, used_interface) { bail!("cannot merge used type `{name}` as it is expected to be from interface `{existing_interface}` but it is from interface `{used_interface}`"); } @@ -282,8 +352,8 @@ impl TypeAggregator { .as_ref() .context("used type has no interface identifier")?; - // The interface names must match - if existing_interface != used_interface { + // The interface names must be on compatible semver tracks + if !are_semver_compatible(existing_interface, used_interface) { bail!("cannot merge used type `{name}` as it is expected to be from interface `{existing_interface}` but it is from interface `{used_interface}`"); } @@ -643,11 +713,19 @@ impl TypeAggregator { checker: &mut SubtypeChecker, ) -> Result { // If we've seen this interface before, perform a merge - // This will ensure that there's only a singular definition of "named" interfaces + // This will ensure that there's only a singular definition of "named" interfaces, + // including interfaces on compatible semver tracks if let Some(name) = types[id].id.as_ref() { - if let Some(existing) = self.interfaces.get(name).copied() { + if let Some(existing) = self + .interfaces + .get(name) + .copied() + .or_else(|| self.find_semver_compatible_interface(name)) + { self.merge_interface(existing, types, id, checker) .with_context(|| format!("failed to merge interface `{name}`"))?; + // Also register this name so it can be looked up directly + self.interfaces.insert(name.clone(), existing); return Ok(existing); } } @@ -852,3 +930,538 @@ impl TypeAggregator { Ok(remapped) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + // Helper to create a simple interface with optional id and exports + fn make_interface( + types: &mut Types, + name: Option<&str>, + exports: Vec<(&str, ItemKind)>, + ) -> InterfaceId { + let interface = Interface { + id: name.map(|n| n.to_string()), + uses: IndexMap::new(), + exports: exports + .into_iter() + .map(|(n, k)| (n.to_string(), k)) + .collect(), + }; + types.add_interface(interface) + } + + // Helper to create a simple func type (no params, no result) + fn make_func_type(types: &mut Types) -> FuncTypeId { + types.add_func_type(FuncType { + params: IndexMap::new(), + result: None, + }) + } + + #[test] + fn semver_compatible_same_name() { + assert!(are_semver_compatible("a:b/c@0.2.0", "a:b/c@0.2.0")); + } + + #[test] + fn semver_compatible_patch_versions() { + assert!(are_semver_compatible("a:b/c@0.2.0", "a:b/c@0.2.1")); + assert!(are_semver_compatible("a:b/c@0.2.1", "a:b/c@0.2.0")); + assert!(are_semver_compatible("a:b/c@0.2.0", "a:b/c@0.2.3")); + } + + #[test] + fn semver_compatible_major_track() { + assert!(are_semver_compatible("a:b/c@1.0.0", "a:b/c@1.1.0")); + assert!(are_semver_compatible("a:b/c@1.0.0", "a:b/c@1.2.3")); + assert!(are_semver_compatible("a:b/c@2.0.0", "a:b/c@2.1.0")); + } + + #[test] + fn semver_incompatible_minor_versions() { + assert!(!are_semver_compatible("a:b/c@0.2.0", "a:b/c@0.3.0")); + } + + #[test] + fn semver_incompatible_major_versions() { + assert!(!are_semver_compatible("a:b/c@1.0.0", "a:b/c@2.0.0")); + } + + #[test] + fn semver_compatible_no_version() { + // Same name without version + assert!(are_semver_compatible("a:b/c", "a:b/c")); + // Different names without version + assert!(!are_semver_compatible("a:b/c", "a:b/d")); + // One with version, one without + assert!(!are_semver_compatible("a:b/c@1.0.0", "a:b/c")); + assert!(!are_semver_compatible("a:b/c", "a:b/c@1.0.0")); + } + + #[test] + fn semver_compatible_prerelease_rejected() { + assert!(!are_semver_compatible("a:b/c@1.0.0-rc.1", "a:b/c@1.0.0")); + assert!(!are_semver_compatible("a:b/c@0.2.0-pre", "a:b/c@0.2.0")); + } + + #[test] + fn semver_compatible_zero_patch_rejected() { + // 0.0.x versions are not compatible with anything + assert!(!are_semver_compatible("a:b/c@0.0.1", "a:b/c@0.0.2")); + } + + #[test] + fn aggregate_exact_match_merges() { + let mut types = Types::default(); + let func_id = make_func_type(&mut types); + let iface_id = make_interface( + &mut types, + Some("a:b/c@0.2.0"), + vec![("foo", ItemKind::Func(func_id))], + ); + + let mut cache = HashSet::new(); + let mut checker = SubtypeChecker::new(&mut cache); + + let agg = TypeAggregator::new(); + let agg = agg + .aggregate( + "a:b/c@0.2.0", + &types, + ItemKind::Instance(iface_id), + &mut checker, + ) + .unwrap(); + // Aggregate again with the same exact name + let agg = agg + .aggregate( + "a:b/c@0.2.0", + &types, + ItemKind::Instance(iface_id), + &mut checker, + ) + .unwrap(); + + // Should still have one import entry (exact match merges in place) + assert_eq!(agg.imports().count(), 1); + } + + #[test] + fn aggregate_semver_compatible_imports_merge() { + let mut types1 = Types::default(); + let func_id1 = make_func_type(&mut types1); + let iface_id1 = make_interface( + &mut types1, + Some("a:b/c@0.2.0"), + vec![("foo", ItemKind::Func(func_id1))], + ); + + let mut types2 = Types::default(); + let func_id2 = make_func_type(&mut types2); + let iface_id2 = make_interface( + &mut types2, + Some("a:b/c@0.2.1"), + vec![("foo", ItemKind::Func(func_id2))], + ); + + let mut cache = HashSet::new(); + let mut checker = SubtypeChecker::new(&mut cache); + + let agg = TypeAggregator::new(); + let agg = agg + .aggregate( + "a:b/c@0.2.0", + &types1, + ItemKind::Instance(iface_id1), + &mut checker, + ) + .unwrap(); + let agg = agg + .aggregate( + "a:b/c@0.2.1", + &types2, + ItemKind::Instance(iface_id2), + &mut checker, + ) + .unwrap(); + + // Only the highest version is retained in the imports + let items: Vec<_> = agg.imports().collect(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].0, "a:b/c@0.2.1"); + + // The lower version name redirects to the canonical (highest) name + assert_eq!(agg.canonical_import_name("a:b/c@0.2.0"), "a:b/c@0.2.1"); + assert_eq!(agg.canonical_import_name("a:b/c@0.2.1"), "a:b/c@0.2.1"); + } + + #[test] + fn aggregate_semver_incompatible_imports_stay_separate() { + let mut types1 = Types::default(); + let func_id1 = make_func_type(&mut types1); + let iface_id1 = make_interface( + &mut types1, + Some("a:b/c@0.2.0"), + vec![("foo", ItemKind::Func(func_id1))], + ); + + let mut types2 = Types::default(); + let func_id2 = make_func_type(&mut types2); + let iface_id2 = make_interface( + &mut types2, + Some("a:b/c@0.3.0"), + vec![("foo", ItemKind::Func(func_id2))], + ); + + let mut cache = HashSet::new(); + let mut checker = SubtypeChecker::new(&mut cache); + + let agg = TypeAggregator::new(); + let agg = agg + .aggregate( + "a:b/c@0.2.0", + &types1, + ItemKind::Instance(iface_id1), + &mut checker, + ) + .unwrap(); + let agg = agg + .aggregate( + "a:b/c@0.3.0", + &types2, + ItemKind::Instance(iface_id2), + &mut checker, + ) + .unwrap(); + + // Two separate imports on different semver tracks + let items: Vec<_> = agg.imports().collect(); + assert_eq!(items.len(), 2); + assert_ne!(items[0].1, items[1].1); + } + + #[test] + fn aggregate_semver_compatible_major_merge() { + let mut types1 = Types::default(); + let func_id1 = make_func_type(&mut types1); + let iface_id1 = make_interface( + &mut types1, + Some("a:b/c@1.0.0"), + vec![("foo", ItemKind::Func(func_id1))], + ); + + let mut types2 = Types::default(); + let func_id2 = make_func_type(&mut types2); + let iface_id2 = make_interface( + &mut types2, + Some("a:b/c@1.1.0"), + vec![("foo", ItemKind::Func(func_id2))], + ); + + let mut cache = HashSet::new(); + let mut checker = SubtypeChecker::new(&mut cache); + + let agg = TypeAggregator::new(); + let agg = agg + .aggregate( + "a:b/c@1.0.0", + &types1, + ItemKind::Instance(iface_id1), + &mut checker, + ) + .unwrap(); + let agg = agg + .aggregate( + "a:b/c@1.1.0", + &types2, + ItemKind::Instance(iface_id2), + &mut checker, + ) + .unwrap(); + + // Only the highest version is retained in the imports + let items: Vec<_> = agg.imports().collect(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].0, "a:b/c@1.1.0"); + + // The lower version name redirects to the canonical (highest) name + assert_eq!(agg.canonical_import_name("a:b/c@1.0.0"), "a:b/c@1.1.0"); + assert_eq!(agg.canonical_import_name("a:b/c@1.1.0"), "a:b/c@1.1.0"); + } + + #[test] + fn find_compatible_import_returns_match() { + let mut types = Types::default(); + let func_id = make_func_type(&mut types); + let iface_id = make_interface( + &mut types, + Some("a:b/c@0.2.0"), + vec![("foo", ItemKind::Func(func_id))], + ); + + let mut cache = HashSet::new(); + let mut checker = SubtypeChecker::new(&mut cache); + + let agg = TypeAggregator::new(); + let agg = agg + .aggregate( + "a:b/c@0.2.0", + &types, + ItemKind::Instance(iface_id), + &mut checker, + ) + .unwrap(); + + assert!(agg.find_semver_compatible_import("a:b/c@0.2.1").is_some()); + assert!(agg.find_semver_compatible_import("a:b/c@0.2.5").is_some()); + assert!(agg.find_semver_compatible_import("a:b/c@0.3.0").is_none()); + assert!(agg.find_semver_compatible_import("a:b/c@1.0.0").is_none()); + assert!(agg.find_semver_compatible_import("x:y/z@0.2.0").is_none()); + } + + #[test] + fn find_compatible_interface_returns_match() { + let mut types = Types::default(); + let func_id = make_func_type(&mut types); + let iface_id = make_interface( + &mut types, + Some("a:b/c@0.2.0"), + vec![("foo", ItemKind::Func(func_id))], + ); + + let mut cache = HashSet::new(); + let mut checker = SubtypeChecker::new(&mut cache); + + let agg = TypeAggregator::new(); + let agg = agg + .aggregate( + "a:b/c@0.2.0", + &types, + ItemKind::Instance(iface_id), + &mut checker, + ) + .unwrap(); + + assert!(agg + .find_semver_compatible_interface("a:b/c@0.2.1") + .is_some()); + assert!(agg + .find_semver_compatible_interface("a:b/c@0.3.0") + .is_none()); + } + + #[test] + fn merge_used_types_from_semver_compatible_interfaces() { + // Types1: interface "dep:pkg/types@0.2.0" used by "my:pkg/iface@1.0.0" + let mut types1 = Types::default(); + let func1 = make_func_type(&mut types1); + let dep_iface1 = make_interface( + &mut types1, + Some("dep:pkg/types@0.2.0"), + vec![("my-func", ItemKind::Func(func1))], + ); + let main_iface1 = { + let mut uses = IndexMap::new(); + uses.insert( + "my-used".to_string(), + UsedType { + interface: dep_iface1, + name: None, + }, + ); + let interface = Interface { + id: Some("my:pkg/iface@1.0.0".to_string()), + uses, + exports: IndexMap::new(), + }; + types1.add_interface(interface) + }; + + // Types2: interface "dep:pkg/types@0.2.1" (compatible) used by "my:pkg/iface@1.0.0" + let mut types2 = Types::default(); + let func2 = make_func_type(&mut types2); + let dep_iface2 = make_interface( + &mut types2, + Some("dep:pkg/types@0.2.1"), + vec![("my-func", ItemKind::Func(func2))], + ); + let main_iface2 = { + let mut uses = IndexMap::new(); + uses.insert( + "my-used".to_string(), + UsedType { + interface: dep_iface2, + name: None, + }, + ); + let interface = Interface { + id: Some("my:pkg/iface@1.0.0".to_string()), + uses, + exports: IndexMap::new(), + }; + types2.add_interface(interface) + }; + + let mut cache = HashSet::new(); + let mut checker = SubtypeChecker::new(&mut cache); + + let agg = TypeAggregator::new(); + let agg = agg + .aggregate( + "my:pkg/iface@1.0.0", + &types1, + ItemKind::Instance(main_iface1), + &mut checker, + ) + .unwrap(); + + // This should succeed because dep:pkg/types@0.2.0 and @0.2.1 are compatible + let _agg = agg + .aggregate( + "my:pkg/iface@1.0.0", + &types2, + ItemKind::Instance(main_iface2), + &mut checker, + ) + .unwrap(); + } + + #[test] + fn merge_used_types_from_semver_incompatible_interfaces_fails() { + // Types1: interface "dep:pkg/types@0.2.0" used by "my:pkg/iface@1.0.0" + let mut types1 = Types::default(); + let func1 = make_func_type(&mut types1); + let dep_iface1 = make_interface( + &mut types1, + Some("dep:pkg/types@0.2.0"), + vec![("my-func", ItemKind::Func(func1))], + ); + let main_iface1 = { + let mut uses = IndexMap::new(); + uses.insert( + "my-used".to_string(), + UsedType { + interface: dep_iface1, + name: None, + }, + ); + let interface = Interface { + id: Some("my:pkg/iface@1.0.0".to_string()), + uses, + exports: IndexMap::new(), + }; + types1.add_interface(interface) + }; + + // Types2: interface "dep:pkg/types@0.3.0" (INCOMPATIBLE) used by "my:pkg/iface@1.0.0" + let mut types2 = Types::default(); + let func2 = make_func_type(&mut types2); + let dep_iface2 = make_interface( + &mut types2, + Some("dep:pkg/types@0.3.0"), + vec![("my-func", ItemKind::Func(func2))], + ); + let main_iface2 = { + let mut uses = IndexMap::new(); + uses.insert( + "my-used".to_string(), + UsedType { + interface: dep_iface2, + name: None, + }, + ); + let interface = Interface { + id: Some("my:pkg/iface@1.0.0".to_string()), + uses, + exports: IndexMap::new(), + }; + types2.add_interface(interface) + }; + + let mut cache = HashSet::new(); + let mut checker = SubtypeChecker::new(&mut cache); + + let agg = TypeAggregator::new(); + let agg = agg + .aggregate( + "my:pkg/iface@1.0.0", + &types1, + ItemKind::Instance(main_iface1), + &mut checker, + ) + .unwrap(); + + // This should fail because dep:pkg/types@0.2.0 and @0.3.0 are incompatible + let result = agg.aggregate( + "my:pkg/iface@1.0.0", + &types2, + ItemKind::Instance(main_iface2), + &mut checker, + ); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("cannot merge used type"), + "unexpected error message: {err}" + ); + } + + #[test] + fn remap_interface_merges_semver_compatible() { + // First aggregate an interface at @0.2.0 + let mut types1 = Types::default(); + let func_id1 = make_func_type(&mut types1); + let iface1 = make_interface( + &mut types1, + Some("dep:pkg/iface@0.2.0"), + vec![("do-thing", ItemKind::Func(func_id1))], + ); + + // Second aggregate a different interface that depends on @0.2.1 of the same + let mut types2 = Types::default(); + let func_id2 = make_func_type(&mut types2); + let dep_iface2 = make_interface( + &mut types2, + Some("dep:pkg/iface@0.2.1"), + vec![("do-thing", ItemKind::Func(func_id2))], + ); + let wrapper = { + let mut exports = IndexMap::new(); + exports.insert("dep".to_string(), ItemKind::Instance(dep_iface2)); + let interface = Interface { + id: Some("wrap:pkg/wrapper@1.0.0".to_string()), + uses: IndexMap::new(), + exports, + }; + types2.add_interface(interface) + }; + + let mut cache = HashSet::new(); + let mut checker = SubtypeChecker::new(&mut cache); + + let agg = TypeAggregator::new(); + // First, aggregate the dep interface directly + let agg = agg + .aggregate( + "dep:pkg/iface@0.2.0", + &types1, + ItemKind::Instance(iface1), + &mut checker, + ) + .unwrap(); + + // Then aggregate a wrapper that references a compatible version. + // The remap_interface call inside should find and merge with the existing one. + let _agg = agg + .aggregate( + "wrap:pkg/wrapper@1.0.0", + &types2, + ItemKind::Instance(wrapper), + &mut checker, + ) + .unwrap(); + } +} diff --git a/crates/wac-types/src/names.rs b/crates/wac-types/src/names.rs index 13f4808..34874c9 100644 --- a/crates/wac-types/src/names.rs +++ b/crates/wac-types/src/names.rs @@ -197,7 +197,23 @@ impl NameMapIntern for NameMapNoIntern { /// This alternate lookup key is intended to serve the purpose where a /// semver-compatible definition can be located, if one is defined, at perhaps /// either a newer or an older version. -fn alternate_lookup_key(name: &str) -> Option<(&str, Version)> { +/// Returns true if two names are on compatible semver tracks. +/// +/// Two names are compatible if they are identical, or if they both have +/// semver versions with the same alternate lookup key. For example, +/// `a:b/c@0.2.0` and `a:b/c@0.2.1` are compatible (both on the `a:b/c@0.2` +/// track), while `a:b/c@0.2.0` and `a:b/c@0.3.0` are not. +pub fn are_semver_compatible(a: &str, b: &str) -> bool { + if a == b { + return true; + } + match (alternate_lookup_key(a), alternate_lookup_key(b)) { + (Some((key_a, _)), Some((key_b, _))) => key_a == key_b, + _ => false, + } +} + +pub(crate) fn alternate_lookup_key(name: &str) -> Option<(&str, Version)> { let at = name.find('@')?; let version_string = &name[at + 1..]; let version = Version::parse(version_string).ok()?;