Skip to content

Commit 0e17bfe

Browse files
committed
fix: disambiguate colliding WIT import interface names
When multiple WIT packages export an interface with the same name (e.g. a:pkg/types and b:pkg/types), the generated Rust traits have duplicate associated types and getters, causing compilation to fail. Add collision detection that scans import declarations for duplicate interface names. When a collision is found, the full namespace path is prepended to produce unique names (e.g. APkgTypes vs BPkgTypes). Apply this to host, guest, and runtime type generation, and extend it to handle versioned import name collisions where two versioned imports share the same namespace path. Signed-off-by: James Sturtevant <jsturtevant@gmail.com>
1 parent c62cdc1 commit 0e17bfe

4 files changed

Lines changed: 362 additions & 25 deletions

File tree

src/hyperlight_component_util/src/emit.rs

Lines changed: 328 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,111 @@ limitations under the License.
1515
*/
1616

1717
//! A bunch of utilities used by the actual code emit functions
18-
use std::collections::{BTreeMap, BTreeSet, VecDeque};
18+
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
1919
use std::vec::Vec;
2020

2121
use proc_macro2::TokenStream;
2222
use quote::{format_ident, quote};
2323
use syn::Ident;
2424

25-
use crate::etypes::{BoundedTyvar, Defined, Handleable, ImportExport, TypeBound, Tyvar};
25+
use crate::etypes::{
26+
BoundedTyvar, Defined, ExternDecl, ExternDesc, Handleable, ImportExport, TypeBound, Tyvar,
27+
};
28+
29+
/// Import interface names that need generated trait member disambiguation.
30+
#[derive(Clone, Debug, Default)]
31+
pub struct ImportNameCollisions {
32+
short_names: HashSet<String>,
33+
qualified_names: HashSet<(Vec<String>, String)>,
34+
}
35+
impl ImportNameCollisions {
36+
pub fn is_empty(&self) -> bool {
37+
self.short_names.is_empty() && self.qualified_names.is_empty()
38+
}
39+
pub fn len(&self) -> usize {
40+
self.short_names.len()
41+
}
42+
pub fn contains(&self, name: &str) -> bool {
43+
self.short_names.contains(name)
44+
}
45+
fn qualified_contains(&self, wn: &WitName) -> bool {
46+
self.qualified_names.contains(&qualified_import_key(wn))
47+
}
48+
}
49+
50+
fn qualified_import_key(wn: &WitName) -> (Vec<String>, String) {
51+
(
52+
wn.namespaces.iter().map(|ns| ns.to_string()).collect(),
53+
wn.name.to_string(),
54+
)
55+
}
56+
57+
fn version_to_kebab(version: &[&str]) -> String {
58+
version
59+
.join("-")
60+
.chars()
61+
.map(|c| {
62+
if c.is_ascii_alphanumeric() {
63+
c.to_ascii_lowercase()
64+
} else {
65+
'-'
66+
}
67+
})
68+
.collect()
69+
}
70+
71+
/// Scan a list of import extern decls for interface name collisions.
72+
/// Returns import names that need disambiguating, tracking when the
73+
/// namespace-qualified name still collides and must include the version.
74+
pub fn find_colliding_import_names(imports: &[ExternDecl]) -> ImportNameCollisions {
75+
let mut short_counts = HashMap::<String, usize>::new();
76+
let mut qualified_counts = HashMap::<(Vec<String>, String), usize>::new();
77+
for ed in imports {
78+
if let ExternDesc::Instance(_) = &ed.desc {
79+
let wn = split_wit_name(ed.kebab_name);
80+
*short_counts.entry(wn.name.to_string()).or_default() += 1;
81+
*qualified_counts
82+
.entry(qualified_import_key(&wn))
83+
.or_default() += 1;
84+
}
85+
}
86+
let short_names = short_counts
87+
.into_iter()
88+
.filter(|(_, c)| *c > 1)
89+
.map(|(n, _)| n)
90+
.collect();
91+
let qualified_names = qualified_counts
92+
.into_iter()
93+
.filter(|(_, c)| *c > 1)
94+
.map(|(n, _)| n)
95+
.collect();
96+
ImportNameCollisions {
97+
short_names,
98+
qualified_names,
99+
}
100+
}
101+
102+
/// Get the disambiguated type and getter names for an import instance.
103+
/// If the interface name collides with another import, prepend the full
104+
/// kebab-joined namespace path to disambiguate
105+
/// (e.g. "types" from "wasi:http" becomes "WasiHttpTypes"/"wasi_http_types").
106+
pub fn import_member_names(wn: &WitName, collisions: &ImportNameCollisions) -> (Ident, Ident) {
107+
if collisions.contains(wn.name) {
108+
let prefix = if wn.namespaces.is_empty() {
109+
wn.name.to_string()
110+
} else {
111+
wn.namespaces.join("-")
112+
};
113+
let mut qualified = format!("{}-{}", prefix, wn.name);
114+
if collisions.qualified_contains(wn) && !wn._version.is_empty() {
115+
qualified.push_str("-v");
116+
qualified.push_str(&version_to_kebab(&wn._version));
117+
}
118+
(kebab_to_type(&qualified), kebab_to_getter(&qualified))
119+
} else {
120+
(kebab_to_type(wn.name), kebab_to_getter(wn.name))
121+
}
122+
}
26123

27124
/// A representation of a trait definition that we will eventually
28125
/// emit. This is used to allow easily adding onto the trait each time
@@ -284,6 +381,11 @@ pub struct State<'a, 'b> {
284381
pub is_wasmtime_guest: bool,
285382
/// Are we working on an export or an import of the component type?
286383
pub is_export: bool,
384+
/// Set of interface names that collide across different packages
385+
/// (e.g. "types" appears in both wasi:filesystem/types and wasi:http/types).
386+
/// When a name is in this set, the parent namespace is prepended to
387+
/// disambiguate the trait member name.
388+
pub colliding_import_names: ImportNameCollisions,
287389
}
288390

289391
/// Create a State with all of its &mut references pointing to
@@ -336,6 +438,7 @@ impl<'a, 'b> State<'a, 'b> {
336438
is_guest,
337439
is_wasmtime_guest,
338440
is_export: false,
441+
colliding_import_names: ImportNameCollisions::default(),
339442
}
340443
}
341444
pub fn clone<'c>(&'c mut self) -> State<'c, 'b> {
@@ -357,6 +460,7 @@ impl<'a, 'b> State<'a, 'b> {
357460
is_guest: self.is_guest,
358461
is_wasmtime_guest: self.is_wasmtime_guest,
359462
is_export: self.is_export,
463+
colliding_import_names: self.colliding_import_names.clone(),
360464
}
361465
}
362466
/// Obtain a reference to the [`Mod`] that we are currently
@@ -437,10 +541,12 @@ impl<'a, 'b> State<'a, 'b> {
437541
/// variable, given its absolute index (i.e. ignoring
438542
/// [`State::var_offset`])
439543
pub fn noff_var_id(&self, n: u32) -> Ident {
440-
let Some(n) = self.bound_vars[n as usize].origin.last_name() else {
544+
let Some(name) = self.bound_vars[n as usize].origin.last_name() else {
441545
panic!("missing origin on tyvar in rust emit")
442546
};
443-
kebab_to_type(n)
547+
let wn = split_wit_name(name);
548+
let (tn, _) = import_member_names(&wn, &self.colliding_import_names);
549+
tn
444550
}
445551
/// Copy the state, changing it to emit into the helper module of
446552
/// the current trait
@@ -803,3 +909,221 @@ pub fn kebab_to_fn(n: &str) -> FnName {
803909
}
804910
FnName::Plain(kebab_to_snake(n))
805911
}
912+
913+
#[cfg(test)]
914+
mod tests {
915+
use super::*;
916+
use crate::etypes::{ExternDecl, ExternDesc, Instance};
917+
918+
/// Helper to build a minimal `ExternDecl` whose desc is an Instance.
919+
fn instance_decl(kebab_name: &str) -> ExternDecl<'_> {
920+
ExternDecl {
921+
kebab_name,
922+
desc: ExternDesc::Instance(Instance {
923+
exports: Vec::new(),
924+
}),
925+
}
926+
}
927+
928+
/// Helper to build a minimal `ExternDecl` whose desc is a Func (not an Instance).
929+
fn func_decl(kebab_name: &str) -> ExternDecl<'_> {
930+
ExternDecl {
931+
kebab_name,
932+
desc: ExternDesc::Func(crate::etypes::Func {
933+
params: Vec::new(),
934+
result: None,
935+
}),
936+
}
937+
}
938+
939+
// --- split_wit_name tests ---
940+
941+
#[test]
942+
fn split_wit_name_simple() {
943+
let wn = split_wit_name("my-interface");
944+
assert_eq!(wn.name, "my-interface");
945+
assert!(wn.namespaces.is_empty());
946+
}
947+
948+
#[test]
949+
fn split_wit_name_with_package() {
950+
let wn = split_wit_name("wasi:http/types");
951+
assert_eq!(wn.name, "types");
952+
assert_eq!(wn.namespaces, vec!["wasi", "http"]);
953+
}
954+
955+
#[test]
956+
fn split_wit_name_with_version() {
957+
let wn = split_wit_name("wasi:http/types@0.2.0");
958+
assert_eq!(wn.name, "types");
959+
assert_eq!(wn.namespaces, vec!["wasi", "http"]);
960+
}
961+
962+
#[test]
963+
fn split_wit_name_nested_package() {
964+
let wn = split_wit_name("wasi:filesystem/types");
965+
assert_eq!(wn.name, "types");
966+
assert_eq!(wn.namespaces, vec!["wasi", "filesystem"]);
967+
}
968+
969+
// --- find_colliding_import_names tests ---
970+
971+
#[test]
972+
fn no_collisions_with_distinct_names() {
973+
let imports = vec![
974+
instance_decl("wasi:http/types"),
975+
instance_decl("wasi:filesystem/preopens"),
976+
];
977+
let collisions = find_colliding_import_names(&imports);
978+
assert!(collisions.is_empty());
979+
}
980+
981+
#[test]
982+
fn detects_collision_on_same_short_name() {
983+
let imports = vec![
984+
instance_decl("wasi:http/types"),
985+
instance_decl("wasi:filesystem/types"),
986+
];
987+
let collisions = find_colliding_import_names(&imports);
988+
assert_eq!(collisions.len(), 1);
989+
assert!(collisions.contains("types"));
990+
}
991+
992+
#[test]
993+
fn no_collision_for_non_instance_decls() {
994+
let imports = vec![instance_decl("wasi:http/types"), func_decl("types")];
995+
let collisions = find_colliding_import_names(&imports);
996+
assert!(collisions.is_empty());
997+
}
998+
999+
#[test]
1000+
fn multiple_collisions() {
1001+
let imports = vec![
1002+
instance_decl("a:foo/types"),
1003+
instance_decl("b:bar/types"),
1004+
instance_decl("a:foo/handler"),
1005+
instance_decl("c:baz/handler"),
1006+
];
1007+
let collisions = find_colliding_import_names(&imports);
1008+
assert_eq!(collisions.len(), 2);
1009+
assert!(collisions.contains("types"));
1010+
assert!(collisions.contains("handler"));
1011+
}
1012+
1013+
#[test]
1014+
fn single_import_no_collision() {
1015+
let imports = vec![instance_decl("wasi:http/types")];
1016+
let collisions = find_colliding_import_names(&imports);
1017+
assert!(collisions.is_empty());
1018+
}
1019+
1020+
#[test]
1021+
fn empty_imports_no_collision() {
1022+
let collisions = find_colliding_import_names(&[]);
1023+
assert!(collisions.is_empty());
1024+
}
1025+
1026+
// --- import_member_names tests ---
1027+
1028+
#[test]
1029+
fn no_collision_uses_short_name() {
1030+
let wn = split_wit_name("wasi:http/types");
1031+
let collisions = ImportNameCollisions::default();
1032+
let (ty, getter) = import_member_names(&wn, &collisions);
1033+
assert_eq!(ty.to_string(), "Types");
1034+
assert_eq!(getter.to_string(), "r#types");
1035+
}
1036+
1037+
#[test]
1038+
fn collision_prepends_parent_namespace() {
1039+
let wn = split_wit_name("wasi:http/types");
1040+
let collisions = find_colliding_import_names(&[
1041+
instance_decl("wasi:http/types"),
1042+
instance_decl("wasi:filesystem/types"),
1043+
]);
1044+
let (ty, getter) = import_member_names(&wn, &collisions);
1045+
assert_eq!(ty.to_string(), "WasiHttpTypes");
1046+
assert_eq!(getter.to_string(), "r#wasi_http_types");
1047+
}
1048+
1049+
#[test]
1050+
fn collision_different_parents_produce_different_names() {
1051+
let collisions = find_colliding_import_names(&[
1052+
instance_decl("wasi:http/types"),
1053+
instance_decl("wasi:filesystem/types"),
1054+
]);
1055+
1056+
let wn_http = split_wit_name("wasi:http/types");
1057+
let (ty_http, getter_http) = import_member_names(&wn_http, &collisions);
1058+
1059+
let wn_fs = split_wit_name("wasi:filesystem/types");
1060+
let (ty_fs, getter_fs) = import_member_names(&wn_fs, &collisions);
1061+
1062+
assert_eq!(ty_http.to_string(), "WasiHttpTypes");
1063+
assert_eq!(ty_fs.to_string(), "WasiFilesystemTypes");
1064+
assert_ne!(ty_http.to_string(), ty_fs.to_string());
1065+
assert_ne!(getter_http.to_string(), getter_fs.to_string());
1066+
}
1067+
1068+
#[test]
1069+
fn collision_same_parent_different_package_produces_different_names() {
1070+
let collisions = find_colliding_import_names(&[
1071+
instance_decl("a:pkg/types"),
1072+
instance_decl("b:pkg/types"),
1073+
]);
1074+
1075+
let wn_a = split_wit_name("a:pkg/types");
1076+
let (ty_a, _) = import_member_names(&wn_a, &collisions);
1077+
1078+
let wn_b = split_wit_name("b:pkg/types");
1079+
let (ty_b, _) = import_member_names(&wn_b, &collisions);
1080+
1081+
assert_eq!(ty_a.to_string(), "APkgTypes");
1082+
assert_eq!(ty_b.to_string(), "BPkgTypes");
1083+
assert_ne!(ty_a.to_string(), ty_b.to_string());
1084+
}
1085+
1086+
#[test]
1087+
fn collision_simple_name_uses_name_as_parent() {
1088+
let wn = split_wit_name("types");
1089+
let collisions =
1090+
find_colliding_import_names(&[instance_decl("types"), instance_decl("pkg:types")]);
1091+
let (ty, getter) = import_member_names(&wn, &collisions);
1092+
// When there are no namespaces, the name itself is used as prefix
1093+
assert_eq!(ty.to_string(), "TypesTypes");
1094+
assert_eq!(getter.to_string(), "r#types_types");
1095+
}
1096+
1097+
#[test]
1098+
fn versioned_collision_adds_version_after_namespace() {
1099+
let collisions = find_colliding_import_names(&[
1100+
instance_decl("a:pkg/types@1.0.0"),
1101+
instance_decl("a:pkg/types@2.0.0"),
1102+
]);
1103+
1104+
let wn_v1 = split_wit_name("a:pkg/types@1.0.0");
1105+
let (ty_v1, getter_v1) = import_member_names(&wn_v1, &collisions);
1106+
1107+
let wn_v2 = split_wit_name("a:pkg/types@2.0.0");
1108+
let (ty_v2, getter_v2) = import_member_names(&wn_v2, &collisions);
1109+
1110+
assert_eq!(ty_v1.to_string(), "APkgTypesV100");
1111+
assert_eq!(ty_v2.to_string(), "APkgTypesV200");
1112+
assert_eq!(getter_v1.to_string(), "r#a_pkg_types_v1_0_0");
1113+
assert_eq!(getter_v2.to_string(), "r#a_pkg_types_v2_0_0");
1114+
}
1115+
1116+
#[test]
1117+
fn version_is_not_added_when_namespace_disambiguates() {
1118+
let collisions = find_colliding_import_names(&[
1119+
instance_decl("a:pkg/types@1.0.0"),
1120+
instance_decl("b:pkg/types@1.0.0"),
1121+
]);
1122+
1123+
let wn = split_wit_name("a:pkg/types@1.0.0");
1124+
let (ty, getter) = import_member_names(&wn, &collisions);
1125+
1126+
assert_eq!(ty.to_string(), "APkgTypes");
1127+
assert_eq!(getter.to_string(), "r#a_pkg_types");
1128+
}
1129+
}

0 commit comments

Comments
 (0)