diff --git a/README.md b/README.md index a37b702c..bb073c10 100644 --- a/README.md +++ b/README.md @@ -171,10 +171,43 @@ For deployment, see | `--lang LANG` | No | `js` (default, emits `.js` + `.d.ts`) or `py` (emits `.py` + `.pyi` and `py.typed`) | | `--no-pyi` | No | With `--lang py`, emit implementation files without type stubs | | `--output DIR` | No | Output directory (default `./generated`) | +| `--shared-interface-members` | No | JS opt-in: reuse shared required-interface prototype descriptors instead of duplicating inherited member bodies in every concrete class | | `--dry-run` | No | Validate input, don't write files | For each WinRT class the codegen emits a typed wrapper, factory, interface registration, async + progress support, generic collections, structs, enums, delegates, and an `index.js` / `index.d.ts` that re-exports every emitted symbol. +### Shared interface members + +Large JavaScript projections can opt into shared inherited-interface +implementations while preserving the public declaration API and import paths: + +```powershell +dynwinrt-codegen generate ` + --winmd-list .winapp\winmds.txt ` + --class-name Microsoft.UI.Xaml.Controls.Button,Microsoft.UI.Xaml.Controls.TextBlock ` + --output .winapp\bindings ` + --shared-interface-members +``` + +The generated concrete prototypes receive the same method and accessor +descriptors from standalone shared interface prototypes. Overloaded or +conflicting members remain class-local, raw interface wrapper classes remain +available, and the public declaration API and import paths are preserved. Class +deep modules may replace inline required-interface declarations with equivalent +re-exports from canonical standalone interface declarations. Only required +interfaces already canonicalized as standalone shared wrappers participate; +one-off inline required interfaces remain class-local. Generation without this +flag remains byte-compatible with the default output. If a standalone +interface filename is ambiguous between distinct interface identities, none of +those identities participate in sharing and their inherited members remain +class-local. + +Focused validation: + +```powershell +cargo test -p dynwinrt-codegen --test shared_interface_members_test +``` + ## Local development — fix import paths in generated files Generated files import from `'@microsoft/dynwinrt'`. When iterating against a locally-built runtime, rewrite imports to the relative path: diff --git a/tools/dynwinrt-codegen/npm/README.md b/tools/dynwinrt-codegen/npm/README.md index d4e74b3a..a3f9d4af 100644 --- a/tools/dynwinrt-codegen/npm/README.md +++ b/tools/dynwinrt-codegen/npm/README.md @@ -56,8 +56,31 @@ npx dynwinrt-codegen generate \ | `--ref PATH` | Additional `.winmd` files for type resolution only (no code emitted) | | `--lang LANG` | `js` (default, emits `.js` + `.d.ts`) or `py` (Python) | | `--output DIR` | Output directory (default `./generated`) | +| `--shared-interface-members` | JS opt-in that shares inherited interface member descriptors across concrete classes | | `--dry-run` | Validate input, don't write files | +### Shared interface members + +```powershell +npx dynwinrt-codegen generate ` + --winmd-list .winapp\winmds.txt ` + --class-name Microsoft.UI.Xaml.Controls.Button,Microsoft.UI.Xaml.Controls.TextBlock ` + --output .winapp\bindings ` + --shared-interface-members +``` + +The generated concrete prototypes reuse method and accessor descriptors from +the standalone required-interface prototypes. Overloaded or conflicting +members remain class-local, and raw interface wrappers remain available. The +public declaration API and import paths are preserved. Class deep modules may +replace inline required-interface declarations with equivalent re-exports from +canonical standalone interface declarations. Only interfaces already emitted +as canonical standalone shared wrappers participate; one-off inline required +interfaces remain class-local. Generation without the flag remains +byte-compatible with the default output. Generation excludes every identity +behind a standalone interface filename that is ambiguous between distinct +interface identities; those inherited members remain class-local. + ## What gets generated For each WinRT class, the codegen emits: diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/ir.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/ir.rs index 919a0ce5..43a1f7a9 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/ir.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/ir.rs @@ -213,6 +213,11 @@ pub struct ProjectedImport { pub is_runtime_package: bool, } +pub struct ProjectedReExport { + pub name: String, + pub from: String, +} + /// Disposition of a required interface. pub enum RequiredIfaceDisposition { /// Imported from its own generated file @@ -257,18 +262,43 @@ pub struct ProjectedClass { pub doc: Option, pub members: Vec, pub required_ifaces: Vec, + /// Standalone shared sources imported by this class, including interfaces + /// whose descriptors remain class-local after conflict filtering. + pub shared_interface_sources: Vec, + /// Required-interface members whose implementation descriptors are copied + /// from a shared standalone interface prototype. + pub shared_interface_members: Vec, /// Static factory/static interface cache field declarations (JS only) pub static_cache_fields: Vec, /// Static factory/static interface accessor methods (JS only) pub static_accessors: Vec, } +pub struct ProjectedSharedInterfaceSource { + pub interface_name: String, + pub interface_identity: String, +} + +pub struct ProjectedSharedInterfaceMembers { + pub interface_name: String, + /// Normalized metadata identity expected from the standalone source file. + pub interface_identity: String, + /// Projection-level member keys used to suppress duplicate class bodies. + pub member_keys: Vec, + /// JavaScript property-key expressions copied from the interface prototype. + pub descriptor_keys: Vec, +} + pub struct ProjectedIface { pub name: String, pub doc: Option, pub iid_const: Option, pub has_static_from: bool, pub has_parameterized_cast: bool, + /// The interface prototype may be reused by concrete runtime classes. + pub shared_member_source: bool, + /// Normalized metadata identity stored on a shared member source. + pub interface_identity: String, pub members: Vec, pub is_delegate: bool, } @@ -295,6 +325,8 @@ pub struct ProjectedDelegate { pub struct ProjectedFile { pub name: String, pub imports: Vec, + /// Public symbols preserved from a canonical sibling module. + pub re_exports: Vec, /// IID constants (rendered as `const` in JS, `declare const` in DTS) pub iid_consts: Vec, /// Interface registration blocks (JS only) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs index edce23f4..bc06d201 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs @@ -23,6 +23,7 @@ use crate::types::{TypeKind, TypeMeta}; thread_local! { static RUNTIME_IMPORT_NAME: RefCell = RefCell::new("@microsoft/dynwinrt".into()); + static SHARED_INTERFACE_MEMBERS: RefCell = const { RefCell::new(false) }; } /// Set the runtime package import name used in generated JS/TS files. @@ -35,6 +36,125 @@ pub fn get_import_name() -> String { RUNTIME_IMPORT_NAME.with(|n| n.borrow().clone()) } +/// Opt into reusing standalone interface prototype descriptors from concrete +/// runtime classes instead of rendering inherited member bodies repeatedly. +pub fn set_shared_interface_members(enabled: bool) { + SHARED_INTERFACE_MEMBERS.with(|value| *value.borrow_mut() = enabled); +} + +/// Return whether concrete-class shared interface member projection is enabled. +pub fn shared_interface_members_enabled() -> bool { + SHARED_INTERFACE_MEMBERS.with(|value| *value.borrow()) +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct StandaloneInterfaceIdentity { + pub namespace: String, + pub name: String, + pub iid: String, +} + +impl StandaloneInterfaceIdentity { + pub fn source_marker(&self) -> String { + format!("{}.{}:{}", self.namespace, self.name, self.iid) + } +} + +pub fn standalone_interface_identity(iface: &InterfaceMeta) -> Option { + if iface.iid.is_empty() { + return None; + } + Some(StandaloneInterfaceIdentity { + namespace: iface.namespace.clone(), + name: iface.name.clone(), + iid: iface.iid.to_ascii_lowercase(), + }) +} + +#[derive(Clone, Debug)] +pub struct CanonicalInterfaceSource { + pub interface: InterfaceMeta, + pub identity: StandaloneInterfaceIdentity, + pub shared_member_source: bool, +} + +pub fn ambiguous_standalone_interface_names<'a>( + interfaces: impl IntoIterator, +) -> HashSet { + let mut identities_by_name: HashMap> = + HashMap::new(); + for iface in interfaces { + let Some(identity) = standalone_interface_identity(iface) else { + continue; + }; + identities_by_name + .entry(iface.name.clone()) + .or_default() + .insert(identity); + } + identities_by_name + .into_iter() + .filter_map(|(name, identities)| (identities.len() > 1).then_some(name)) + .collect() +} + +/// Build the flat interface-module plan using the legacy emission order, then +/// annotate only safe final winners as descriptor-sharing sources. +pub fn canonical_interface_sources( + interfaces: &[InterfaceMeta], + standalone_candidates: &[InterfaceMeta], + shared_member_candidates: &[InterfaceMeta], + class_names: &HashSet, + excluded_shared_source_names: &HashSet, + forced_shared_source_identities: &HashSet, +) -> Result, String> { + let mut shared_identities = shared_member_candidates + .iter() + .filter_map(standalone_interface_identity) + .collect::>(); + shared_identities.extend(forced_shared_source_identities.iter().cloned()); + let ambiguous_names = ambiguous_standalone_interface_names( + standalone_candidates + .iter() + .chain(interfaces) + .filter(|iface| !class_names.contains(&iface.name)), + ); + let mut sources: Vec = Vec::new(); + let mut source_by_name: HashMap = HashMap::new(); + + for iface in standalone_candidates.iter().chain(interfaces) { + if class_names.contains(&iface.name) { + continue; + } + let Some(identity) = standalone_interface_identity(iface) else { + continue; + }; + if let Some(existing_index) = source_by_name.get(&iface.name).copied() { + sources[existing_index] = CanonicalInterfaceSource { + interface: iface.clone(), + identity, + shared_member_source: false, + }; + continue; + } + + source_by_name.insert(iface.name.clone(), sources.len()); + sources.push(CanonicalInterfaceSource { + interface: iface.clone(), + identity, + shared_member_source: false, + }); + } + + for source in &mut sources { + source.shared_member_source = !ambiguous_names.contains(&source.interface.name) + && !excluded_shared_source_names.contains(&source.interface.name) + && shared_identities.contains(&source.identity); + } + sources.sort_by(|left, right| left.interface.name.cmp(&right.interface.name)); + Ok(sources) +} + use crate::codegen::winrt::shared::imports::{ NO_DEFERRED, collect_iface_type_imports, collect_type_imports, collect_used_generics_from_class, collect_used_generics_from_methods, fill_array_output_index, @@ -177,7 +297,57 @@ pub fn project_class( class: &ClassMeta, known_types: &HashSet, delegate_type_names: &HashSet, - shared_iids: &HashSet, + standalone_interface_iids: &HashSet, + delegate_sigs: &HashMap, + delegate_sig_refs: &HashMap>, + delegate_param_wraps: &HashMap>, +) -> ProjectedFile { + project_class_with_shared_member_sources( + class, + known_types, + delegate_type_names, + standalone_interface_iids, + &HashSet::new(), + delegate_sigs, + delegate_sig_refs, + delegate_param_wraps, + ) +} + +/// Project a class with explicit opt-in shared descriptor source identities in +/// addition to the legacy standalone-interface IID set. +pub fn project_class_with_shared_member_sources( + class: &ClassMeta, + known_types: &HashSet, + delegate_type_names: &HashSet, + standalone_interface_iids: &HashSet, + shared_member_source_identities: &HashSet, + delegate_sigs: &HashMap, + delegate_sig_refs: &HashMap>, + delegate_param_wraps: &HashMap>, +) -> ProjectedFile { + project_class_with_excluded_interface_imports( + class, + known_types, + delegate_type_names, + standalone_interface_iids, + shared_member_source_identities, + &HashSet::new(), + delegate_sigs, + delegate_sig_refs, + delegate_param_wraps, + ) +} + +/// Project a class while keeping legacy standalone-interface imports separate +/// from opt-in shared descriptor source identities. +pub fn project_class_with_excluded_interface_imports( + class: &ClassMeta, + known_types: &HashSet, + delegate_type_names: &HashSet, + standalone_interface_iids: &HashSet, + shared_member_source_identities: &HashSet, + excluded_interface_import_names: &HashSet, delegate_sigs: &HashMap, delegate_sig_refs: &HashMap>, delegate_param_wraps: &HashMap>, @@ -186,6 +356,7 @@ pub fn project_class( let winui_bootstrap = winui::resolve_application_bootstrap(class, known_types); let supports_unpackaged_xaml = winui_bootstrap.is_some_and(|bootstrap| bootstrap.supports_unpackaged_resources); + let share_interface_members = shared_interface_members_enabled(); // Collect delegate names only from interfaces of THIS class (not the entire batch) // for delegate imports; but also include global delegate_type_names for type filtering @@ -262,7 +433,15 @@ pub fn project_class( // Type imports let mut imported_names: HashSet = HashSet::new(); + let mut imported_interface_names: HashSet = HashSet::new(); + let mut shared_only_interface_imports: HashSet = HashSet::new(); let type_imports = collect_type_imports(class); + let local_required_interface_names = class + .required_interfaces + .iter() + .filter(|iface| excluded_interface_import_names.contains(&iface.name)) + .map(|iface| iface.name.as_str()) + .collect::>(); let mut sorted_imports: Vec<_> = type_imports.iter().collect(); sorted_imports .sort_by(|a, b| (&a.namespace, &a.name, &a.kind).cmp(&(&b.namespace, &b.name, &b.kind))); @@ -275,11 +454,16 @@ pub fn project_class( if r.name == class.name { continue; } + if r.kind == TypeKind::Interface && local_required_interface_names.contains(r.name.as_str()) + { + continue; + } if known_types.contains(&r.name) && !all_delegate_names.contains(&r.name) { imports.push(format_type_import_projected(&r.name, r.kind)); imported_names.insert(r.name.clone()); if r.kind == TypeKind::Interface { imported_names.insert(format!("IID_{}", r.name)); + imported_interface_names.insert(r.name.clone()); } } else if all_delegate_names.contains(&r.name) && delegate_sigs.contains_key(&r.name) @@ -310,19 +494,37 @@ pub fn project_class( } } + let needs_iclosable = class.name != "IClosable" + && class + .required_interfaces + .iter() + .any(|ri| ri.iid == ICLOSABLE_IID); + // Import shared required interfaces for req_iface in &class.required_interfaces { + let is_legacy_standalone = standalone_interface_iids.contains(&req_iface.iid); + let is_shared_member_source = share_interface_members + && standalone_interface_identity(req_iface) + .is_some_and(|identity| shared_member_source_identities.contains(&identity)); if req_iface.generic_piid.is_none() && !req_iface.iid.is_empty() - && shared_iids.contains(&req_iface.iid) + && (is_legacy_standalone || is_shared_member_source) + && !excluded_interface_import_names.contains(&req_iface.name) && !imported_names.contains(&req_iface.name) { + if is_shared_member_source + && !is_legacy_standalone + && !(needs_iclosable && req_iface.name == "IClosable") + { + shared_only_interface_imports.insert(req_iface.name.clone()); + } imports.push(format_type_import_projected( &req_iface.name, TypeKind::Interface, )); imported_names.insert(req_iface.name.clone()); imported_names.insert(format!("IID_{}", req_iface.name)); + imported_interface_names.insert(req_iface.name.clone()); } } @@ -343,18 +545,17 @@ pub fn project_class( // IClosable by name. Register the import here so the IID-const loop below // sees `IID_IClosable` in `imported_names` and skips declaring it, // avoiding a duplicate identifier in single-class emission. - let needs_iclosable = class.name != "IClosable" - && class - .required_interfaces - .iter() - .any(|ri| ri.iid == ICLOSABLE_IID); - if needs_iclosable && !imported_names.contains("IClosable") { + if needs_iclosable + && !excluded_interface_import_names.contains("IClosable") + && !imported_names.contains("IClosable") + { imports.push(format_type_import_projected( "IClosable", TypeKind::Interface, )); imported_names.insert("IClosable".into()); imported_names.insert("IID_IClosable".into()); + imported_interface_names.insert("IClosable".into()); } // IID consts(private, for class-internal use) @@ -878,6 +1079,9 @@ pub fn project_class( // Required interface inline wrappers let mut required_ifaces = Vec::new(); + let mut verified_shared_interface_sources = Vec::new(); + let mut shared_member_candidates: Vec<(String, String, String, Vec)> = Vec::new(); + let mut conflicting_shared_members = HashSet::new(); // Track names already on the main class to avoid conflicts let mut main_member_names: HashSet = members .iter() @@ -896,7 +1100,28 @@ pub fn project_class( if req_iface.iid.is_empty() { continue; } - let is_imported = imported_names.contains(&req_iface.name); + let is_imported = imported_interface_names.contains(&req_iface.name); + let is_shared_member_source = share_interface_members + && req_iface.generic_piid.is_none() + && standalone_interface_identity(req_iface) + .is_some_and(|identity| shared_member_source_identities.contains(&identity)) + && is_imported; + if is_shared_member_source { + let interface_identity = standalone_interface_identity(req_iface) + .expect("shared interface source must have an identity") + .source_marker(); + if !verified_shared_interface_sources.iter().any( + |source: &ProjectedSharedInterfaceSource| { + source.interface_name == req_iface.name + && source.interface_identity == interface_identity + }, + ) { + verified_shared_interface_sources.push(ProjectedSharedInterfaceSource { + interface_name: req_iface.name.clone(), + interface_identity, + }); + } + } let reg_var = format!("_{}", req_iface.name); let mut ri_members = Vec::new(); @@ -987,9 +1212,25 @@ pub fn project_class( let sig_key = format!("{}#{}", name, pm.params.len()); if main_member_names.insert(sig_key) { members.push(member.clone()); + if is_shared_member_source { + record_shared_member_candidate( + &mut shared_member_candidates, + &mut conflicting_shared_members, + req_iface, + member, + ); + } } } else if main_member_names.insert(name) { members.push(member.clone()); + if is_shared_member_source { + record_shared_member_candidate( + &mut shared_member_candidates, + &mut conflicting_shared_members, + req_iface, + member, + ); + } } } @@ -1012,15 +1253,70 @@ pub fn project_class( // Merge overloaded method names: rename `foo2`, `foo3` to `foo` when `foo` exists. // Must happen after flatten so required-interface methods are included. merge_overload_names(&mut members); + let mut final_descriptor_counts = HashMap::new(); + for member in &members { + for descriptor_key in shared_member_descriptor_keys(member) { + *final_descriptor_counts + .entry(descriptor_key) + .or_insert(0usize) += 1; + } + } + + let mut shared_interface_members: Vec = Vec::new(); + if share_interface_members { + for (member_key, interface_name, interface_identity, descriptor_keys) in + shared_member_candidates + { + if conflicting_shared_members.contains(&member_key) + || descriptor_keys.iter().any(|descriptor_key| { + final_descriptor_counts + .get(descriptor_key) + .is_some_and(|count| *count > 1) + }) + { + continue; + } + let group = shared_interface_members.iter_mut().find(|group| { + group.interface_name == interface_name + && group.interface_identity == interface_identity + }); + if let Some(group) = group { + if !group.member_keys.contains(&member_key) { + group.member_keys.push(member_key); + } + for descriptor_key in descriptor_keys { + if !group.descriptor_keys.contains(&descriptor_key) { + group.descriptor_keys.push(descriptor_key); + } + } + } else { + shared_interface_members.push(ProjectedSharedInterfaceMembers { + interface_name, + interface_identity, + member_keys: vec![member_key], + descriptor_keys, + }); + } + } + } // Check if _unwrap is used let needs_unwrap = check_needs_unwrap(&members, &required_ifaces); let doc = build_doc_info(class.doc.as_deref(), class.deprecated.as_deref(), None, &[]); + let mut re_exports = shared_only_interface_imports + .into_iter() + .map(|name| ProjectedReExport { + from: format!("./{}.js", name), + name, + }) + .collect::>(); + re_exports.sort_by(|left, right| left.name.cmp(&right.name)); ProjectedFile { name: class.name.clone(), imports, + re_exports, iid_consts, registrations, structs, @@ -1029,6 +1325,8 @@ pub fn project_class( doc, members, required_ifaces, + shared_interface_sources: verified_shared_interface_sources, + shared_interface_members, static_cache_fields, static_accessors, }], @@ -1048,6 +1346,28 @@ pub fn project_interface( delegate_sigs: &HashMap, delegate_sig_refs: &HashMap>, delegate_param_wraps: &HashMap>, +) -> ProjectedFile { + project_interface_with_shared_member_source( + iface, + known_types, + delegate_type_names, + delegate_sigs, + delegate_sig_refs, + delegate_param_wraps, + false, + ) +} + +/// Project an interface and optionally make its prototype a canonical source +/// for concrete-class shared member descriptors. +pub fn project_interface_with_shared_member_source( + iface: &InterfaceMeta, + known_types: &HashSet, + delegate_type_names: &HashSet, + delegate_sigs: &HashMap, + delegate_sig_refs: &HashMap>, + delegate_param_wraps: &HashMap>, + shared_member_source: bool, ) -> ProjectedFile { // Check if delegate let is_delegate = iface.methods.iter().any(|m| m.name == ".ctor") @@ -1173,6 +1493,12 @@ pub fn project_interface( // Members let iface_var = format!("_{}", iface.name); + let shared_member_source = shared_member_source && !iface.iid.is_empty(); + let obj_expr = if shared_member_source { + "__interfaceValue(this)" + } else { + "this._obj" + }; let mut members = Vec::new(); for method in &iface.methods { if should_skip_raw_collection_method(iface, &method.name) { @@ -1180,7 +1506,7 @@ pub fn project_interface( } if let Some(m) = project_instance_method( &iface_var, - "this._obj", + obj_expr, method, known_types, &delegate_names, @@ -1193,7 +1519,7 @@ pub fn project_interface( } // Collection helpers - project_collection_helpers(iface, known_types, &mut members, &mut imports, "this._obj"); + project_collection_helpers(iface, known_types, &mut members, &mut imports, obj_expr); // Static create() for IVector / IMap project_collection_create(iface, known_types, &mut members, &mut imports); @@ -1258,6 +1584,7 @@ pub fn project_interface( ProjectedFile { name: iface.name.clone(), imports, + re_exports: vec![], iid_consts, registrations, structs, @@ -1269,6 +1596,10 @@ pub fn project_interface( iid_const: None, // already in file-level iid_consts has_static_from: !iface.iid.is_empty(), has_parameterized_cast, + shared_member_source, + interface_identity: standalone_interface_identity(iface) + .map(|identity| identity.source_marker()) + .unwrap_or_default(), members, is_delegate: false, }], @@ -1305,6 +1636,7 @@ pub fn project_enum(en: &TypeMeta) -> Option { Some(ProjectedFile { name: name.clone(), imports: vec![], + re_exports: vec![], iid_consts: vec![], registrations: vec![], structs: vec![], @@ -1388,6 +1720,7 @@ pub fn project_delegate( ProjectedFile { name: iface.name.clone(), imports, + re_exports: vec![], iid_consts: vec![], registrations: vec![], structs: vec![], @@ -1411,6 +1744,119 @@ pub fn project_delegate( // Utility helpers // ====================================================================== +fn record_shared_member_candidate( + candidates: &mut Vec<(String, String, String, Vec)>, + conflicts: &mut HashSet, + interface: &InterfaceMeta, + member: &ProjectedMember, +) { + let Some(member_key) = shared_member_key(member) else { + return; + }; + if matches!( + member, + ProjectedMember::Method(ProjectedMethod { + overload_of: Some(_), + .. + }) + ) { + conflicts.insert(member_key); + return; + } + let descriptor_keys = shared_member_descriptor_keys(member); + if descriptor_keys.is_empty() { + return; + } + let Some(interface_identity) = standalone_interface_identity(interface) else { + return; + }; + let interface_identity = interface_identity.source_marker(); + if let Some((_, existing_interface, existing_identity, existing_descriptors)) = candidates + .iter_mut() + .find(|(existing_key, _, _, _)| existing_key == &member_key) + { + if existing_interface != &interface.name + || existing_identity != &interface_identity + || matches!(member, ProjectedMember::Method(_)) + { + conflicts.insert(member_key); + } else { + for descriptor_key in descriptor_keys { + if !existing_descriptors.contains(&descriptor_key) { + existing_descriptors.push(descriptor_key); + } + } + } + return; + } + candidates.push(( + member_key, + interface.name.clone(), + interface_identity, + descriptor_keys, + )); +} + +fn shared_member_key(member: &ProjectedMember) -> Option { + match member { + ProjectedMember::Method(method) => { + let has_numeric_suffix = method + .name + .chars() + .last() + .is_some_and(|character| character.is_ascii_digit()); + (!has_numeric_suffix).then(|| method.name.clone()) + } + ProjectedMember::Property(property) => Some(property.name.clone()), + ProjectedMember::Event(event) if !event.subscribe_name.is_empty() => { + Some(event.subscribe_name.clone()) + } + ProjectedMember::Symbol(symbol) => Some(symbol_dedup_key(&symbol.kind)), + _ => None, + } +} + +fn shared_member_descriptor_keys(member: &ProjectedMember) -> Vec { + let quoted = |name: &str| format!("'{name}'"); + match member { + ProjectedMember::Constructor(_) => vec![quoted("constructor")], + ProjectedMember::Method(method) => vec![quoted(&method.name)], + ProjectedMember::Property(property) => vec![quoted(&property.name)], + ProjectedMember::Event(event) => { + if !event.subscribe_name.is_empty() { + let event_name = event + .subscribe_name + .strip_prefix("on") + .unwrap_or(&event.subscribe_name); + let mut names = vec![ + quoted(&event.subscribe_name), + quoted(&format!("once{event_name}")), + ]; + if event.remove_vtable_index.is_some() { + names.push(quoted(&format!("off{event_name}"))); + } + names + } else if !event.unsubscribe_name.is_empty() { + vec![quoted(&event.unsubscribe_name)] + } else { + Vec::new() + } + } + ProjectedMember::Symbol(symbol) => vec![match &symbol.kind { + SymbolKind::ToString { .. } => quoted("toString"), + SymbolKind::ToPrimitive => "Symbol.toPrimitive".into(), + SymbolKind::ToStringTag { .. } => "Symbol.toStringTag".into(), + SymbolKind::Iterator { .. } => "Symbol.iterator".into(), + SymbolKind::CollectionLength => quoted("length"), + SymbolKind::CollectionAt { .. } => quoted("at"), + SymbolKind::CollectionToArray { .. } => quoted("toArray"), + SymbolKind::IteratorNext { .. } => quoted("next"), + }], + ProjectedMember::AsCast => vec![quoted("as")], + ProjectedMember::Close => vec![quoted("close")], + } +} + /// Returns a dedup key for a SymbolKind so flatten can detect duplicate symbols. pub fn symbol_dedup_key(kind: &SymbolKind) -> String { match kind { diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/declarations.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/declarations.rs index 27edbcee..6eed198d 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/declarations.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/declarations.rs @@ -102,6 +102,12 @@ pub fn render(file: &ProjectedFile) -> String { )); } } + for re_export in &file.re_exports { + out.push_str(&format!( + "export {{ {} }} from '{}';\n", + re_export.name, re_export.from, + )); + } if !out.ends_with('\n') || out.len() > 50 { out.push('\n'); } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/javascript/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/javascript/mod.rs index a7658161..c8cf78be 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/javascript/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/javascript/mod.rs @@ -33,7 +33,17 @@ pub fn render(file: &ProjectedFile) -> String { runtime_sources.insert(imp.from.clone()); } } - convert_to_cjs_with_lazy(&esm, &runtime_sources) + let mut cjs = convert_to_cjs_with_lazy(&esm, &runtime_sources); + for re_export in &file.re_exports { + if !cjs.ends_with('\n') { + cjs.push('\n'); + } + cjs.push_str(&format!( + "exports.{} = require('{}').{};\n", + re_export.name, re_export.from, re_export.name, + )); + } + cjs } /// Render a projected file as ESM JS (internal — post-processed to CJS by render()). @@ -113,6 +123,39 @@ fn render_esm(file: &ProjectedFile) -> String { } // Classes + let copies_shared_interface_members = file + .classes + .iter() + .any(|class| !class.shared_interface_members.is_empty()); + let verifies_shared_interface_sources = file + .classes + .iter() + .any(|class| !class.shared_interface_sources.is_empty()); + let is_shared_interface_source = file.ifaces.iter().any(|iface| iface.shared_member_source); + if verifies_shared_interface_sources || is_shared_interface_source { + out.push_str( + "const __sharedInterfaceMemberSource = Symbol.for('dynwinrt.sharedInterfaceMemberSource');\n\n", + ); + } + if verifies_shared_interface_sources { + out.push_str( + "const __verifyInterfaceSource = (source, identity) => {\n\ + if (source[__sharedInterfaceMemberSource] !== identity) throw new Error(`Interface ${source.name} is not a shared member source for ${identity}`);\n\ +};\n\n", + ); + } + if copies_shared_interface_members { + out.push_str( + "const __copyInterfaceMembers = (target, source, identity, keys) => {\n\ + __verifyInterfaceSource(source, identity);\n\ + for (const key of keys) {\n\ + const descriptor = Object.getOwnPropertyDescriptor(source.prototype, key);\n\ + if (descriptor === undefined) throw new Error(`Missing shared interface member ${String(key)}`);\n\ + Object.defineProperty(target.prototype, key, descriptor);\n\ + }\n\ +};\n\n", + ); + } for class in &file.classes { render_class_js(&mut out, class); @@ -251,6 +294,11 @@ fn render_class_js(out: &mut String, class: &ProjectedClass) { // Members — handle same-name overloads (from OverloadAttribute merging) out.push('\n'); + let shared_member_keys: std::collections::HashSet<&str> = class + .shared_interface_members + .iter() + .flat_map(|group| group.member_keys.iter().map(String::as_str)) + .collect(); let mut emitted_names: std::collections::HashSet = std::collections::HashSet::new(); // Group same-name methods within class.members let mut same_class_groups: std::collections::HashMap> = @@ -267,6 +315,12 @@ fn render_class_js(out: &mut String, class: &ProjectedClass) { } for (i, member) in class.members.iter().enumerate() { + if member_render_key(member) + .as_deref() + .is_some_and(|key| shared_member_keys.contains(key)) + { + continue; + } if let ProjectedMember::Method(method) = member { // Skip if already emitted as part of a group if !emitted_names.insert(method.name.clone()) { @@ -302,12 +356,41 @@ fn render_class_js(out: &mut String, class: &ProjectedClass) { render_member_js(out, member, &class.name); } out.push_str("}\n"); + for source in &class.shared_interface_sources { + let copied = class.shared_interface_members.iter().any(|shared| { + shared.interface_name == source.interface_name + && shared.interface_identity == source.interface_identity + }); + if !copied { + out.push_str(&format!( + "__verifyInterfaceSource({}, '{}');\n", + ref_marker(&source.interface_name), + source.interface_identity, + )); + } + } + for shared in &class.shared_interface_members { + out.push_str(&format!( + "__copyInterfaceMembers({}, {}, '{}', [{}]);\n", + class.name, + ref_marker(&shared.interface_name), + shared.interface_identity, + shared.descriptor_keys.join(", "), + )); + } } fn render_iface_js(out: &mut String, iface: &ProjectedIface, _file: &ProjectedFile) { if let Some(ref doc) = iface.doc { out.push_str(&render_jsdoc(doc, "")); } + if iface.shared_member_source { + out.push_str("const __interfaceInstances = new WeakSet();\n"); + out.push_str(&format!( + "const __interfaceValue = (value) => __interfaceInstances.has(value) ? value._obj : value._obj.cast(IID_{});\n", + iface.name + )); + } out.push_str(&format!("export class {} {{\n", iface.name)); out.push_str(" _obj;\n\n"); @@ -318,12 +401,14 @@ fn render_iface_js(out: &mut String, iface: &ProjectedIface, _file: &ProjectedFi " this._obj = obj.cast(IID_{});\n", iface.name )); - out.push_str(" }\n"); } else { out.push_str(" constructor(obj) {\n"); out.push_str(" this._obj = obj;\n"); - out.push_str(" }\n"); } + if iface.shared_member_source { + out.push_str(" __interfaceInstances.add(this);\n"); + } + out.push_str(" }\n"); // static from() if iface.has_static_from { @@ -344,6 +429,12 @@ fn render_iface_js(out: &mut String, iface: &ProjectedIface, _file: &ProjectedFi // so we just need the file.iid_consts to contain it out.push_str("}\n"); + if iface.shared_member_source { + out.push_str(&format!( + "Object.defineProperty({}, __sharedInterfaceMemberSource, {{ value: '{}' }});\n", + iface.name, iface.interface_identity, + )); + } } fn render_required_iface_js(out: &mut String, ri: &ProjectedRequiredIface) { @@ -363,6 +454,22 @@ fn render_required_iface_js(out: &mut String, ri: &ProjectedRequiredIface) { out.push_str("}\n"); } +fn member_render_key(member: &ProjectedMember) -> Option { + match member { + ProjectedMember::Method(method) => Some(method.name.clone()), + ProjectedMember::Property(property) => Some(property.name.clone()), + ProjectedMember::Event(event) if !event.subscribe_name.is_empty() => { + Some(event.subscribe_name.clone()) + } + ProjectedMember::Symbol(symbol) => { + Some(crate::codegen::winrt::javascript::project::symbol_dedup_key(&symbol.kind)) + } + ProjectedMember::Close => Some("close".into()), + ProjectedMember::AsCast => Some("as".into()), + _ => None, + } +} + fn render_member_js(out: &mut String, member: &ProjectedMember, _class_name: &str) { match member { ProjectedMember::Constructor(ctor) => { @@ -548,7 +655,10 @@ fn render_member_js(out: &mut String, member: &ProjectedMember, _class_name: &st ProjectedMember::Symbol(symbol) => { match &symbol.kind { SymbolKind::ToString { iface_name } => { - out.push_str(&format!(" toString() {{\n return {}.from(this._obj).toString();\n }}\n", iface_name)); + out.push_str(&format!( + " toString() {{\n return {}.from(this._obj).toString();\n }}\n", + ref_marker(iface_name), + )); } SymbolKind::ToPrimitive => { out.push_str(" [Symbol.toPrimitive](_hint) {\n return this.toString();\n }\n"); diff --git a/tools/dynwinrt-codegen/src/main.rs b/tools/dynwinrt-codegen/src/main.rs index bfe2b110..f91bbabc 100644 --- a/tools/dynwinrt-codegen/src/main.rs +++ b/tools/dynwinrt-codegen/src/main.rs @@ -125,6 +125,11 @@ enum Commands { #[arg(long, default_value = "@microsoft/dynwinrt", value_name = "NAME")] import_name: String, + /// Reuse standalone interface prototype implementations for inherited + /// concrete-class members. Opt-in while compatibility data is gathered. + #[arg(long)] + shared_interface_members: bool, + /// Validate metadata and resolve dependencies without writing files #[arg(long)] dry_run: bool, @@ -257,6 +262,7 @@ fn run() -> Result<(), String> { lang, output, import_name, + shared_interface_members, dry_run, pyi, no_pyi, @@ -264,6 +270,9 @@ fn run() -> Result<(), String> { if lang != "py" && (pyi || no_pyi) { return Err("--pyi and --no-pyi require --lang py".into()); } + if lang != "js" && shared_interface_members { + return Err("--shared-interface-members requires --lang js".into()); + } let pyi = lang == "py" && !no_pyi; // Collect winmd paths from --folder and/or --winmd let mut winmd_parts: Vec = Vec::new(); @@ -370,7 +379,14 @@ fn run() -> Result<(), String> { let output_dir = effective_output_dir.as_path(); if lang == "js" { project::set_import_name(&import_name); + project::set_shared_interface_members(shared_interface_members); } + let (metadata_excluded_shared_source_names, metadata_shared_source_identities) = + if lang == "js" && shared_interface_members { + loaded_metadata_interface_plan(&winmd) + } else { + (HashSet::new(), HashSet::new()) + }; if !dry_run { fs::create_dir_all(output_dir).map_err(|e| { format!("Failed to create output directory '{}': {}", output, e) @@ -466,6 +482,42 @@ fn run() -> Result<(), String> { )); } + winui::add_implicit_classes(&winmd, &mut classes); + let mut implicit_interfaces = Vec::new(); + winui::add_implicit_interfaces(&winmd, &classes, &mut implicit_interfaces); + let ( + mut excluded_shared_source_names, + mut shared_interface_source_identities, + reserved_non_interface_output_names, + ) = if lang == "js" && project::shared_interface_members_enabled() { + shared_interface_plan_for_batches( + &winmd, + &[(classes.clone(), implicit_interfaces.clone(), Vec::new())], + ) + } else { + (HashSet::new(), HashSet::new(), HashSet::new()) + }; + excluded_shared_source_names + .extend(metadata_excluded_shared_source_names.iter().cloned()); + shared_interface_source_identities.extend( + metadata_shared_source_identities + .iter() + .filter(|identity| !excluded_shared_source_names.contains(&identity.name)) + .cloned(), + ); + if lang == "js" && !dry_run && !classes.is_empty() { + preflight_js_generation_batch( + &winmd, + output_dir, + &classes, + &implicit_interfaces, + &[], + &excluded_shared_source_names, + &shared_interface_source_identities, + &reserved_non_interface_output_names, + )?; + } + // Classic COM occupies its own ESM subpackage so its symbols // cannot collide with or leak into the WinRT root barrel. if !com_interfaces.is_empty() || !com_coclasses.is_empty() { @@ -578,9 +630,6 @@ fn run() -> Result<(), String> { } } - winui::add_implicit_classes(&winmd, &mut classes); - let mut implicit_interfaces = Vec::new(); - winui::add_implicit_interfaces(&winmd, &classes, &mut implicit_interfaces); generate_for_types( &winmd, output_dir, @@ -591,6 +640,9 @@ fn run() -> Result<(), String> { &lang, pyi, &doc_table, + &excluded_shared_source_names, + &shared_interface_source_identities, + &reserved_non_interface_output_names, )?; // Write (or append to) the index file for the output directory @@ -639,6 +691,8 @@ fn run() -> Result<(), String> { )) } else { !class_names.contains(&interface.name) + && !reserved_non_interface_output_names + .contains(&interface.name) } }); all_enums.retain(|e| match e { @@ -707,10 +761,7 @@ fn run() -> Result<(), String> { } }; - let mut total_classes = 0usize; - let mut total_interfaces = 0usize; - let mut total_enums = 0usize; - + let mut namespace_batches = Vec::new(); for ns in &namespaces { if let Some(interface) = com_metadata::first_classic_com_interface_in_namespace(&winmd, ns) @@ -724,9 +775,48 @@ fn run() -> Result<(), String> { } let mut classes = meta::parse_namespace(&winmd, ns); let mut interfaces = meta::parse_interfaces(&winmd, ns); - let mut enums = meta::parse_enums(&winmd, ns); + let enums = meta::parse_enums(&winmd, ns); winui::add_implicit_classes(&winmd, &mut classes); winui::add_implicit_interfaces(&winmd, &classes, &mut interfaces); + namespace_batches.push((classes, interfaces, enums)); + } + let ( + mut excluded_shared_source_names, + mut shared_interface_source_identities, + reserved_non_interface_output_names, + ) = if lang == "js" && project::shared_interface_members_enabled() { + shared_interface_plan_for_batches(&winmd, &namespace_batches) + } else { + (HashSet::new(), HashSet::new(), HashSet::new()) + }; + excluded_shared_source_names + .extend(metadata_excluded_shared_source_names.iter().cloned()); + shared_interface_source_identities.extend( + metadata_shared_source_identities + .iter() + .filter(|identity| !excluded_shared_source_names.contains(&identity.name)) + .cloned(), + ); + if lang == "js" && !dry_run { + for (classes, interfaces, enums) in &namespace_batches { + preflight_js_generation_batch( + &winmd, + output_dir, + classes, + interfaces, + enums, + &excluded_shared_source_names, + &shared_interface_source_identities, + &reserved_non_interface_output_names, + )?; + } + } + + let mut total_classes = 0usize; + let mut total_interfaces = 0usize; + let mut total_enums = 0usize; + + for (mut classes, mut interfaces, mut enums) in namespace_batches { for c in classes.iter_mut() { doc_table.apply_to_class(c); } @@ -738,8 +828,18 @@ fn run() -> Result<(), String> { } let (nc, ni, ne) = generate_for_types( - &winmd, output_dir, classes, interfaces, enums, dry_run, &lang, pyi, + &winmd, + output_dir, + classes, + interfaces, + enums, + dry_run, + &lang, + pyi, &doc_table, + &excluded_shared_source_names, + &shared_interface_source_identities, + &reserved_non_interface_output_names, )?; total_classes += nc; total_interfaces += ni; @@ -794,6 +894,8 @@ fn run() -> Result<(), String> { )) } else { !class_names.contains(&interface.name) + && !reserved_non_interface_output_names + .contains(&interface.name) } }); all_enums.retain(|e| match e { @@ -854,6 +956,57 @@ fn run() -> Result<(), String> { Ok(()) } +struct RequiredInterfacePlans { + standalone_iids: HashSet, + standalone_interfaces: Vec, + shared_member_candidates: Vec, +} + +fn required_interface_plans<'a>( + classes: impl IntoIterator, +) -> RequiredInterfacePlans { + let mut legacy_counts: HashMap = HashMap::new(); + let mut identity_counts: HashMap< + project::StandaloneInterfaceIdentity, + (&meta::InterfaceMeta, usize), + > = HashMap::new(); + for class in classes { + for interface in &class.required_interfaces { + if interface.iid.is_empty() { + continue; + } + legacy_counts + .entry(interface.iid.clone()) + .and_modify(|(_, count)| *count += 1) + .or_insert((interface, 1)); + if let Some(identity) = project::standalone_interface_identity(interface) { + identity_counts + .entry(identity) + .and_modify(|(_, count)| *count += 1) + .or_insert((interface, 1)); + } + } + } + + RequiredInterfacePlans { + standalone_iids: legacy_counts + .iter() + .filter(|(_, (_, count))| *count >= 2) + .map(|(iid, _)| iid.clone()) + .collect(), + standalone_interfaces: legacy_counts + .into_values() + .filter(|(_, count)| *count >= 2) + .map(|(interface, _)| interface.clone()) + .collect(), + shared_member_candidates: identity_counts + .into_values() + .filter(|(_, count)| *count >= 2) + .map(|(interface, _)| interface.clone()) + .collect(), + } +} + /// Generate files for a set of types plus their transitive dependencies. /// When `dry_run` is true, all parsing/resolution runs but no files are written. fn generate_for_types( @@ -866,6 +1019,9 @@ fn generate_for_types( lang: &str, pyi: bool, doc_table: &DocTable, + excluded_shared_source_names: &HashSet, + forced_shared_source_identities: &HashSet, + reserved_non_interface_output_names: &HashSet, ) -> Result<(usize, usize, usize), String> { let deps = meta::resolve_dependencies(winmd, &classes, &interfaces, &enums); let mut all_classes = classes; @@ -956,35 +1112,60 @@ fn generate_for_types( .map(|i| i.name.clone()) .collect(); - let mut req_iface_count: HashMap = HashMap::new(); - for class in &all_classes { - for ri in &class.required_interfaces { - if ri.iid.is_empty() { - continue; - } - req_iface_count - .entry(ri.iid.clone()) - .and_modify(|(_, c)| *c += 1) - .or_insert((ri, 1)); - } - } - let shared_iids: HashSet = req_iface_count - .iter() - .filter(|(_, (_, count))| *count >= 2) - .map(|(iid, _)| iid.clone()) - .collect(); - - let shared_interfaces: Vec = req_iface_count - .iter() - .filter(|(_, (_, count))| *count >= 2) - .map(|(_, (iface, _))| (*iface).clone()) - .collect(); + let required_interface_plans = required_interface_plans(&all_classes); + let shared_iids = required_interface_plans.standalone_iids; + let shared_interfaces = required_interface_plans.standalone_interfaces; for iface in &shared_interfaces { known_types.insert(iface.name.clone()); } let (delegate_signatures, delegate_sig_refs, delegate_param_wraps) = project::build_delegate_signatures(&all_interfaces, &delegate_type_names, &known_types); + let shared_interface_members_enabled = + lang == "js" && project::shared_interface_members_enabled(); + let canonical_shared_interfaces = if shared_interface_members_enabled { + required_interface_plans.shared_member_candidates + } else { + Vec::new() + }; + for iface in &canonical_shared_interfaces { + known_types.insert(iface.name.clone()); + } + let mut effective_excluded_shared_source_names = excluded_shared_source_names.clone(); + let mut effective_reserved_non_interface_output_names = + reserved_non_interface_output_names.clone(); + for en in &all_enums { + if let TypeMeta::Enum { name, .. } = en + && !name.contains('<') + && !class_names_all.contains(name) + { + effective_excluded_shared_source_names.insert(name.clone()); + effective_reserved_non_interface_output_names.insert(name.clone()); + } + } + let empty_forced_shared_source_identities = HashSet::new(); + let effective_forced_shared_source_identities = if shared_interface_members_enabled { + forced_shared_source_identities + } else { + &empty_forced_shared_source_identities + }; + let interface_emission_plan = project::canonical_interface_sources( + &all_interfaces, + &shared_interfaces, + &canonical_shared_interfaces, + &class_names_all, + &effective_excluded_shared_source_names, + effective_forced_shared_source_identities, + )?; + let shared_interface_source_identities = if shared_interface_members_enabled { + interface_emission_plan + .iter() + .filter(|source| source.shared_member_source) + .map(|source| source.identity.clone()) + .collect::>() + } else { + HashSet::new() + }; if !dry_run { if lang == "py" { @@ -1003,23 +1184,125 @@ fn generate_for_types( generate_js_files( output_dir, &all_classes, - &all_interfaces, &all_enums, - &shared_interfaces, &known_types, &delegate_type_names, + &interface_emission_plan, &shared_iids, + &shared_interface_source_identities, + &effective_reserved_non_interface_output_names, &delegate_signatures, &delegate_sig_refs, &delegate_param_wraps, )?; } + drop(python_layout); } Ok((all_classes.len(), all_interfaces.len(), all_enums.len())) } +fn shared_interface_plan_for_batches( + winmd: &str, + batches: &[( + Vec, + Vec, + Vec, + )], +) -> ( + HashSet, + HashSet, + HashSet, +) { + let mut standalone_sources = Vec::new(); + let mut shared_source_identities = HashSet::new(); + let mut non_interface_writer_names = HashSet::new(); + for (classes, interfaces, enums) in batches { + let deps = meta::resolve_dependencies(winmd, classes, interfaces, enums); + let all_classes = classes.iter().chain(&deps.classes).collect::>(); + let class_names = all_classes + .iter() + .map(|class| class.name.clone()) + .collect::>(); + non_interface_writer_names.extend(class_names.iter().cloned()); + non_interface_writer_names.extend(enums.iter().chain(&deps.enums).filter_map( + |en| match en { + TypeMeta::Enum { name, .. } + if !name.contains('<') && !class_names.contains(name) => + { + Some(name.clone()) + } + _ => None, + }, + )); + standalone_sources.extend( + interfaces + .iter() + .chain(&deps.interfaces) + .filter(|iface| !class_names.contains(&iface.name)) + .cloned(), + ); + + let mut required_interface_count: HashMap< + project::StandaloneInterfaceIdentity, + (&meta::InterfaceMeta, usize), + > = HashMap::new(); + for class in all_classes { + for req_iface in &class.required_interfaces { + let Some(identity) = project::standalone_interface_identity(req_iface) else { + continue; + }; + required_interface_count + .entry(identity) + .and_modify(|(_, count)| *count += 1) + .or_insert((req_iface, 1)); + } + } + standalone_sources.extend( + required_interface_count + .into_values() + .filter(|(iface, count)| *count >= 2 && !class_names.contains(&iface.name)) + .map(|(iface, _)| { + shared_source_identities.insert( + project::standalone_interface_identity(iface) + .expect("counted required interface must have an identity"), + ); + iface.clone() + }), + ); + } + + let mut excluded_names = project::ambiguous_standalone_interface_names(&standalone_sources); + excluded_names.extend(non_interface_writer_names.iter().cloned()); + shared_source_identities.retain(|identity| !excluded_names.contains(&identity.name)); + ( + excluded_names, + shared_source_identities, + non_interface_writer_names, + ) +} + +fn loaded_metadata_interface_plan( + winmd: &str, +) -> ( + HashSet, + HashSet, +) { + let interfaces = meta::parse_all_interfaces_including_exclusive(winmd); + let excluded_names = project::ambiguous_standalone_interface_names(&interfaces); + let shared_source_identities = interfaces + .iter() + .filter(|iface| { + iface.generic_piid.is_none() + && !iface.methods.iter().any(|method| method.name == ".ctor") + && !excluded_names.contains(&iface.name) + }) + .filter_map(project::standalone_interface_identity) + .collect(); + (excluded_names, shared_source_identities) +} + fn python_type_identities( classes: &[meta::ClassMeta], interfaces: &[meta::InterfaceMeta], @@ -1073,15 +1356,225 @@ fn validate_unique_class_output_names(classes: &[meta::ClassMeta]) -> Result<(), Ok(()) } +fn generated_shared_interface_source_identity(contents: &str) -> Option<&str> { + const PREFIX: &str = ", __sharedInterfaceMemberSource, { value: '"; + const SUFFIX: &str = "' });"; + let start = contents.find(PREFIX)? + PREFIX.len(); + let end = contents[start..].find(SUFFIX)? + start; + Some(&contents[start..end]) +} + +fn plan_existing_shared_interface_sources( + output_dir: &Path, + sources: &[project::CanonicalInterfaceSource], + reserved_non_interface_output_names: &HashSet, +) -> Result, String> { + let mut preserved_source_names = HashSet::new(); + for source in sources { + let interface = &source.interface; + if reserved_non_interface_output_names.contains(&interface.name) { + continue; + } + let path = output_dir.join(format!("{}.js", interface.name)); + let existing = match fs::read_to_string(&path) { + Ok(existing) => existing, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!("Failed to read {}: {}", path.display(), error)); + } + }; + let Some(existing_identity) = generated_shared_interface_source_identity(&existing) else { + continue; + }; + let requested_identity = source.identity.source_marker(); + if source.shared_member_source && existing_identity != requested_identity { + return Err(format!( + "Refusing to overwrite shared interface source `{}`: existing generated identity \ + `{}` does not match requested identity `{}`. Use a separate output directory or \ + remove and regenerate the conflicting file.", + path.display(), + existing_identity, + requested_identity, + )); + } + if !source.shared_member_source { + preserved_source_names.insert(interface.name.clone()); + } + } + Ok(preserved_source_names) +} + +fn plan_unflagged_existing_shared_interface_sources<'a>( + output_dir: &Path, + interfaces: impl IntoIterator, +) -> Result, String> { + let mut preserved_identities = HashSet::new(); + for interface in interfaces { + let Some(requested_identity) = project::standalone_interface_identity(interface) else { + continue; + }; + let path = output_dir.join(format!("{}.js", interface.name)); + let existing = match fs::read_to_string(&path) { + Ok(existing) => existing, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!("Failed to read {}: {}", path.display(), error)); + } + }; + let Some(existing_identity) = generated_shared_interface_source_identity(&existing) else { + continue; + }; + let requested_marker = requested_identity.source_marker(); + if existing_identity != requested_marker { + return Err(format!( + "Refusing to overwrite shared interface source `{}`: existing generated identity \ + `{}` does not match requested identity `{}`. Use a separate output directory or \ + remove and regenerate the conflicting file.", + path.display(), + existing_identity, + requested_marker, + )); + } + preserved_identities.insert(requested_identity); + } + Ok(preserved_identities) +} + +fn reject_non_interface_overwrites_of_shared_sources<'a>( + output_dir: &Path, + names: impl IntoIterator, +) -> Result<(), String> { + let mut checked = HashSet::new(); + for name in names { + if !checked.insert(name) { + continue; + } + let path = output_dir.join(format!("{name}.js")); + let existing = match fs::read_to_string(&path) { + Ok(existing) => existing, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!("Failed to read {}: {}", path.display(), error)); + } + }; + let Some(existing_identity) = generated_shared_interface_source_identity(&existing) else { + continue; + }; + return Err(format!( + "Refusing to overwrite shared interface source `{}` with non-interface output `{}`: \ + existing generated identity is `{}`. Use a separate output directory or remove and \ + regenerate the conflicting file.", + path.display(), + name, + existing_identity, + )); + } + Ok(()) +} + +fn preflight_js_generation_batch( + winmd: &str, + output_dir: &Path, + classes: &[meta::ClassMeta], + interfaces: &[meta::InterfaceMeta], + enums: &[TypeMeta], + excluded_shared_source_names: &HashSet, + forced_shared_source_identities: &HashSet, + reserved_non_interface_output_names: &HashSet, +) -> Result<(), String> { + let deps = meta::resolve_dependencies(winmd, classes, interfaces, enums); + let all_classes = classes.iter().chain(&deps.classes).collect::>(); + let all_interfaces = interfaces + .iter() + .chain(&deps.interfaces) + .collect::>(); + let all_enums = enums.iter().chain(&deps.enums).collect::>(); + let class_names = all_classes + .iter() + .map(|class| class.name.clone()) + .collect::>(); + let enum_names = all_enums + .iter() + .filter_map(|typ| match typ { + TypeMeta::Enum { name, .. } if !name.contains('<') && !class_names.contains(name) => { + Some(name.as_str()) + } + _ => None, + }) + .collect::>(); + let mut effective_reserved_non_interface_output_names = + reserved_non_interface_output_names.clone(); + effective_reserved_non_interface_output_names + .extend(enum_names.iter().map(|name| (*name).to_string())); + reject_non_interface_overwrites_of_shared_sources( + output_dir, + all_classes + .iter() + .map(|class| class.name.as_str()) + .chain(enum_names.iter().copied()), + )?; + + let required_interface_plans = required_interface_plans(all_classes.iter().copied()); + let all_interfaces = all_interfaces.into_iter().cloned().collect::>(); + let interface_emission_plan = if project::shared_interface_members_enabled() { + let mut effective_excluded_names = excluded_shared_source_names.clone(); + effective_excluded_names.extend(all_enums.iter().filter_map(|typ| match typ { + TypeMeta::Enum { name, .. } if !name.contains('<') && !class_names.contains(name) => { + Some(name.clone()) + } + _ => None, + })); + project::canonical_interface_sources( + &all_interfaces, + &required_interface_plans.standalone_interfaces, + &required_interface_plans.shared_member_candidates, + &class_names, + &effective_excluded_names, + forced_shared_source_identities, + )? + } else { + project::canonical_interface_sources( + &all_interfaces, + &required_interface_plans.standalone_interfaces, + &[], + &class_names, + &HashSet::new(), + &HashSet::new(), + )? + }; + + if project::shared_interface_members_enabled() { + plan_existing_shared_interface_sources( + output_dir, + &interface_emission_plan, + &effective_reserved_non_interface_output_names, + )?; + } else { + plan_unflagged_existing_shared_interface_sources( + output_dir, + interface_emission_plan + .iter() + .map(|source| &source.interface) + .filter(|iface| { + !class_names.contains(&iface.name) + && !effective_reserved_non_interface_output_names.contains(&iface.name) + && !iface.iid.is_empty() + }), + )?; + } + Ok(()) +} + fn generate_js_files( output_dir: &Path, all_classes: &[meta::ClassMeta], - all_interfaces: &[meta::InterfaceMeta], all_enums: &[TypeMeta], - shared_interfaces: &[meta::InterfaceMeta], known_types: &HashSet, delegate_type_names: &HashSet, - shared_iids: &HashSet, + interface_emission_plan: &[project::CanonicalInterfaceSource], + standalone_interface_iids: &HashSet, + shared_member_source_identities: &HashSet, + reserved_non_interface_output_names: &HashSet, delegate_sigs: &HashMap, delegate_sig_refs: &HashMap>, delegate_param_wraps: &HashMap>, @@ -1111,39 +1604,71 @@ fn generate_js_files( !iface.iid.is_empty() } - for iface in shared_interfaces { - if class_names.contains(iface.name.as_str()) { - continue; + let enum_output_names = all_enums.iter().filter_map(|typ| match typ { + TypeMeta::Enum { name, .. } + if !name.contains('<') && !class_names.contains(name.as_str()) => + { + Some(name.as_str()) } - if !is_emittable_interface(iface) { + _ => None, + }); + reject_non_interface_overwrites_of_shared_sources( + output_dir, + all_classes + .iter() + .map(|class| class.name.as_str()) + .chain(enum_output_names), + )?; + + let shared_interface_members_enabled = project::shared_interface_members_enabled(); + let preserved_shared_source_names = if shared_interface_members_enabled { + plan_existing_shared_interface_sources( + output_dir, + interface_emission_plan, + reserved_non_interface_output_names, + )? + } else { + HashSet::new() + }; + let unflagged_preserved_shared_source_identities = if !shared_interface_members_enabled { + plan_unflagged_existing_shared_interface_sources( + output_dir, + interface_emission_plan + .iter() + .map(|source| &source.interface) + .filter(|iface| { + !class_names.contains(iface.name.as_str()) + && !reserved_non_interface_output_names.contains(&iface.name) + && is_emittable_interface(iface) + }), + )? + } else { + HashSet::new() + }; + + for source in interface_emission_plan { + let iface = &source.interface; + if reserved_non_interface_output_names.contains(&iface.name) { continue; } - let projected = project::project_interface( - iface, - known_types, - delegate_type_names, - delegate_sigs, - delegate_sig_refs, - delegate_param_wraps, - ); - let js = render_js::render(&projected); - let dts = render_dts::render(&projected); - emit(&iface.name, &js, &dts)?; - } - for iface in all_interfaces { - if class_names.contains(iface.name.as_str()) { + if preserved_shared_source_names.contains(&iface.name) { continue; } if !is_emittable_interface(iface) { continue; } - let projected = project::project_interface( + let preserve_shared_source = + project::standalone_interface_identity(iface).is_some_and(|identity| { + unflagged_preserved_shared_source_identities.contains(&identity) + }); + let projected = project::project_interface_with_shared_member_source( iface, known_types, delegate_type_names, delegate_sigs, delegate_sig_refs, delegate_param_wraps, + source.shared_member_source || preserve_shared_source, ); let js = render_js::render(&projected); let dts = render_dts::render(&projected); @@ -1190,11 +1715,13 @@ fn generate_js_files( if !class_is_usable(class) { continue; } - let projected = project::project_class( + let projected = project::project_class_with_excluded_interface_imports( class, known_types, delegate_type_names, - shared_iids, + standalone_interface_iids, + shared_member_source_identities, + reserved_non_interface_output_names, delegate_sigs, delegate_sig_refs, delegate_param_wraps, @@ -3269,6 +3796,7 @@ fn print_capabilities() { "input.winmd-list", "input.ref-list", "selector.namespace-class", + "layout.shared-interface-members", ] { println!("{}", capability); } @@ -3350,6 +3878,56 @@ mod tests { )) } + fn shared_interface_source( + namespace: &str, + name: &str, + iid: &str, + ) -> project::CanonicalInterfaceSource { + let interface = meta::InterfaceMeta { + namespace: namespace.into(), + name: name.into(), + iid: iid.into(), + ..Default::default() + }; + project::CanonicalInterfaceSource { + identity: project::standalone_interface_identity(&interface).unwrap(), + interface, + shared_member_source: true, + } + } + + fn generate_shared_interface_sources( + output: &Path, + sources: &[project::CanonicalInterfaceSource], + ) -> Result<(), String> { + let known_types = sources + .iter() + .map(|source| source.interface.name.clone()) + .collect(); + let shared_source_identities = sources + .iter() + .filter(|source| source.shared_member_source) + .map(|source| source.identity.clone()) + .collect(); + project::set_shared_interface_members(true); + let result = generate_js_files( + output, + &[], + &[], + &known_types, + &HashSet::new(), + sources, + &HashSet::new(), + &shared_source_identities, + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + project::set_shared_interface_members(false); + result + } + #[test] fn com_barrel_deduplicates_only_identical_pod_factories() { let descriptor = @@ -3455,6 +4033,206 @@ mod tests { .expect("identical metadata does not create an ambiguous output"); } + #[test] + fn incremental_shared_interface_same_identity_is_idempotent() { + let output = test_directory("shared-interface-same-identity"); + fs::create_dir_all(&output).unwrap(); + let source = + shared_interface_source("Contoso", "IValue", "11111111-1111-1111-1111-111111111111"); + + generate_shared_interface_sources(&output, std::slice::from_ref(&source)).unwrap(); + let first_js = fs::read(output.join("IValue.js")).unwrap(); + let first_dts = fs::read(output.join("IValue.d.ts")).unwrap(); + + generate_shared_interface_sources(&output, std::slice::from_ref(&source)).unwrap(); + + assert_eq!(fs::read(output.join("IValue.js")).unwrap(), first_js); + assert_eq!(fs::read(output.join("IValue.d.ts")).unwrap(), first_dts); + fs::remove_dir_all(output).unwrap(); + } + + #[test] + fn incremental_shared_interface_identity_mismatch_is_atomic() { + let output = test_directory("shared-interface-identity-mismatch"); + fs::create_dir_all(&output).unwrap(); + let original = + shared_interface_source("Contoso", "IValue", "11111111-1111-1111-1111-111111111111"); + generate_shared_interface_sources(&output, std::slice::from_ref(&original)).unwrap(); + let original_js = fs::read(output.join("IValue.js")).unwrap(); + let original_dts = fs::read(output.join("IValue.d.ts")).unwrap(); + + let preceding = + shared_interface_source("Contoso", "IOther", "22222222-2222-2222-2222-222222222222"); + let conflicting = + shared_interface_source("Fabrikam", "IValue", "33333333-3333-3333-3333-333333333333"); + let error = generate_shared_interface_sources(&output, &[preceding, conflicting]) + .expect_err("different flat-file identity must be rejected before writes"); + + assert!(error.contains("Refusing to overwrite shared interface source")); + assert!(error.contains("Contoso.IValue:11111111-1111-1111-1111-111111111111")); + assert!(error.contains("Fabrikam.IValue:33333333-3333-3333-3333-333333333333")); + assert_eq!(fs::read(output.join("IValue.js")).unwrap(), original_js); + assert_eq!(fs::read(output.join("IValue.d.ts")).unwrap(), original_dts); + assert!(!output.join("IOther.js").exists()); + assert!(!output.join("IOther.d.ts").exists()); + fs::remove_dir_all(output).unwrap(); + } + + #[test] + fn incremental_shared_interface_rejects_non_interface_overwrite() { + let output = test_directory("shared-interface-non-interface-overwrite"); + fs::create_dir_all(&output).unwrap(); + let original = + shared_interface_source("Contoso", "IValue", "11111111-1111-1111-1111-111111111111"); + generate_shared_interface_sources(&output, std::slice::from_ref(&original)).unwrap(); + let original_js = fs::read(output.join("IValue.js")).unwrap(); + let original_dts = fs::read(output.join("IValue.d.ts")).unwrap(); + + let error = + reject_non_interface_overwrites_of_shared_sources(&output, ["IOther", "IValue"]) + .expect_err("class or enum output must not replace a shared interface source"); + + assert!(error.contains("Refusing to overwrite shared interface source")); + assert!(error.contains("non-interface output `IValue`")); + assert_eq!(fs::read(output.join("IValue.js")).unwrap(), original_js); + assert_eq!(fs::read(output.join("IValue.d.ts")).unwrap(), original_dts); + assert!(!output.join("IOther.js").exists()); + fs::remove_dir_all(output).unwrap(); + } + + #[test] + fn incremental_ambiguous_interface_fallback_does_not_abort() { + let output = test_directory("shared-interface-ambiguous-fallback"); + fs::create_dir_all(&output).unwrap(); + let original = + shared_interface_source("Contoso", "IValue", "11111111-1111-1111-1111-111111111111"); + generate_shared_interface_sources(&output, std::slice::from_ref(&original)).unwrap(); + let original_js = fs::read(output.join("IValue.js")).unwrap(); + + let mut fallback = + shared_interface_source("Fabrikam", "IValue", "33333333-3333-3333-3333-333333333333"); + fallback.shared_member_source = false; + generate_shared_interface_sources(&output, std::slice::from_ref(&fallback)) + .expect("ambiguous class-local fallback must not be rejected"); + + assert_eq!(fs::read(output.join("IValue.js")).unwrap(), original_js); + fs::remove_dir_all(output).unwrap(); + } + + #[test] + fn legacy_standalone_layout_survives_nonshareable_identity_planning() { + let iid = "11111111-1111-1111-1111-111111111111"; + let first_interface = meta::InterfaceMeta { + name: "IValue".into(), + namespace: "Contoso".into(), + iid: iid.into(), + methods: vec![meta::MethodMeta { + name: "get_Value".into(), + raw_name: "get_Value".into(), + vtable_index: 6, + return_type: Some(TypeMeta::I32), + is_property_getter: true, + ..Default::default() + }], + ..Default::default() + }; + let mut second_interface = first_interface.clone(); + second_interface.namespace = "Fabrikam".into(); + let classes = vec![ + meta::ClassMeta { + name: "Widget".into(), + namespace: "Contoso".into(), + full_name: "Contoso.Widget".into(), + required_interfaces: vec![first_interface], + ..Default::default() + }, + meta::ClassMeta { + name: "Gadget".into(), + namespace: "Fabrikam".into(), + full_name: "Fabrikam.Gadget".into(), + required_interfaces: vec![second_interface], + ..Default::default() + }, + ]; + let required_interfaces = required_interface_plans(&classes); + assert_eq!( + required_interfaces.standalone_iids, + HashSet::from([iid.into()]) + ); + assert_eq!(required_interfaces.standalone_interfaces.len(), 1); + assert!(required_interfaces.shared_member_candidates.is_empty()); + + let class_names = classes + .iter() + .map(|class| class.name.clone()) + .collect::>(); + let excluded_shared_sources = HashSet::from(["IValue".into()]); + let emission_plan = project::canonical_interface_sources( + &[], + &required_interfaces.standalone_interfaces, + &required_interfaces.shared_member_candidates, + &class_names, + &excluded_shared_sources, + &HashSet::new(), + ) + .unwrap(); + assert_eq!(emission_plan.len(), 1); + assert_eq!(emission_plan[0].interface.name, "IValue"); + assert!(!emission_plan[0].shared_member_source); + + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!("legacy-standalone-layout-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let baseline = root.join("baseline"); + let shared = root.join("shared"); + fs::create_dir_all(&baseline).unwrap(); + fs::create_dir_all(&shared).unwrap(); + let known_types = HashSet::from(["Widget".into(), "Gadget".into(), "IValue".into()]); + for (output, enabled) in [(&baseline, false), (&shared, true)] { + project::set_shared_interface_members(enabled); + generate_js_files( + output, + &classes, + &[], + &known_types, + &HashSet::new(), + &emission_plan, + &required_interfaces.standalone_iids, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ) + .unwrap(); + } + project::set_shared_interface_members(false); + + for class_name in ["Widget", "Gadget"] { + let baseline_js = + fs::read_to_string(baseline.join(format!("{class_name}.js"))).unwrap(); + let shared_js = fs::read_to_string(shared.join(format!("{class_name}.js"))).unwrap(); + let baseline_dts = + fs::read_to_string(baseline.join(format!("{class_name}.d.ts"))).unwrap(); + let shared_dts = fs::read_to_string(shared.join(format!("{class_name}.d.ts"))).unwrap(); + assert_eq!(shared_js, baseline_js); + assert_eq!(shared_dts, baseline_dts); + assert!(shared_js.contains("require('./IValue.js')")); + assert!(!shared_js.contains("class IValue")); + assert!(!shared_dts.contains("export declare class IValue")); + } + assert_eq!( + fs::read(shared.join("IValue.js")).unwrap(), + fs::read(baseline.join("IValue.js")).unwrap() + ); + assert_eq!( + fs::read(shared.join("IValue.d.ts")).unwrap(), + fs::read(baseline.join("IValue.d.ts")).unwrap() + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn python_duplicate_short_names_use_namespace_facades() { let output = test_directory("namespace-facades"); diff --git a/tools/dynwinrt-codegen/src/meta.rs b/tools/dynwinrt-codegen/src/meta.rs index a05cc373..558c2e7e 100644 --- a/tools/dynwinrt-codegen/src/meta.rs +++ b/tools/dynwinrt-codegen/src/meta.rs @@ -259,14 +259,48 @@ pub fn parse_namespace(winmd_paths: &str, namespace: &str) -> Vec { /// Exclusive interfaces (prefixed with I and paired with a RuntimeClass) are skipped /// since they are implementation details. We only generate public-facing interfaces. pub fn parse_interfaces(winmd_paths: &str, namespace: &str) -> Vec { + parse_interfaces_with_exclusive(winmd_paths, namespace, false) +} + +/// Parse every non-generic interface in a namespace, including interfaces +/// marked ExclusiveTo for generation-wide identity planning. +pub fn parse_interfaces_including_exclusive( + winmd_paths: &str, + namespace: &str, +) -> Vec { + parse_interfaces_with_exclusive(winmd_paths, namespace, true) +} + +/// Parse every non-generic interface across the loaded metadata in one pass, +/// including interfaces marked ExclusiveTo. +pub fn parse_all_interfaces_including_exclusive(winmd_paths: &str) -> Vec { + let index = match load_index(winmd_paths) { + Some(idx) => idx, + None => return Vec::new(), + }; + parse_interfaces_from_index(&index, None, true) +} + +fn parse_interfaces_with_exclusive( + winmd_paths: &str, + namespace: &str, + include_exclusive: bool, +) -> Vec { let index = match load_index(winmd_paths) { Some(idx) => idx, None => return Vec::new(), }; + parse_interfaces_from_index(&index, Some(namespace), include_exclusive) +} +fn parse_interfaces_from_index( + index: &reader::Index, + namespace: Option<&str>, + include_exclusive: bool, +) -> Vec { let mut interfaces = Vec::new(); for def in index.all() { - if def.namespace() != namespace { + if namespace.is_some_and(|namespace| def.namespace() != namespace) { continue; } // Skip CLR projection types @@ -287,10 +321,10 @@ pub fn parse_interfaces(winmd_paths: &str, namespace: &str) -> Vec Option<(&str, &str)> { fn parse_interface(index: &reader::Index, namespace: &str, name: &str) -> Option { let def = index.get(namespace, name).next()?; - let iid = extract_iid(&def); - parse_interface_methods(index, &def, name, namespace, &iid, &[]) + parse_interface_def(index, &def) +} + +fn parse_interface_def(index: &reader::Index, def: &reader::TypeDef) -> Option { + let iid = extract_iid(def); + parse_interface_methods(index, def, def.name(), def.namespace(), &iid, &[]) } fn parse_interface_type( @@ -1626,6 +1664,100 @@ fn resolve_named_type( #[cfg(test)] mod tests { use super::*; + use windows_metadata::writer::{AttributeType, HasAttribute, MemberRefParent, TypeDefOrRef}; + + fn write_interface_winmd(path: &Path, assembly_name: &str, iid: (u32, u16, u16, [u8; 8])) { + let mut file = windows_metadata::writer::File::new(assembly_name); + let interface = file.TypeDef( + "Contoso.Duplicates", + "IRepeated", + TypeDefOrRef::default(), + windows_metadata::TypeAttributes::Public + | windows_metadata::TypeAttributes::Interface + | windows_metadata::TypeAttributes::Abstract + | windows_metadata::TypeAttributes::WindowsRuntime, + ); + let guid_attribute = file.TypeRef("Windows.Foundation.Metadata", "GuidAttribute"); + let constructor = file.MemberRef( + ".ctor", + &windows_metadata::Signature { + flags: windows_metadata::MethodCallAttributes::HASTHIS, + return_type: windows_metadata::Type::Void, + types: vec![ + windows_metadata::Type::U32, + windows_metadata::Type::U16, + windows_metadata::Type::U16, + windows_metadata::Type::U8, + windows_metadata::Type::U8, + windows_metadata::Type::U8, + windows_metadata::Type::U8, + windows_metadata::Type::U8, + windows_metadata::Type::U8, + windows_metadata::Type::U8, + windows_metadata::Type::U8, + ], + }, + MemberRefParent::TypeRef(guid_attribute), + ); + let mut values = vec![ + ("".into(), windows_metadata::Value::U32(iid.0)), + ("".into(), windows_metadata::Value::U16(iid.1)), + ("".into(), windows_metadata::Value::U16(iid.2)), + ]; + values.extend( + iid.3 + .into_iter() + .map(|value| ("".into(), windows_metadata::Value::U8(value))), + ); + file.Attribute( + HasAttribute::TypeDef(interface), + AttributeType::MemberRef(constructor), + &values, + ); + std::fs::write(path, file.into_stream()).unwrap(); + } + + #[test] + fn interface_enumeration_parses_each_duplicate_typedef_directly() { + let directory = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!( + "duplicate-interface-metadata-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&directory); + std::fs::create_dir_all(&directory).unwrap(); + let first = directory.join("First.winmd"); + let second = directory.join("Second.winmd"); + write_interface_winmd(&first, "First", (0x11111111, 0x1111, 0x1111, [0x11; 8])); + write_interface_winmd(&second, "Second", (0x22222222, 0x2222, 0x2222, [0x22; 8])); + + let paths = format!("{};{}", first.display(), second.display()); + let interfaces = parse_all_interfaces_including_exclusive(&paths) + .into_iter() + .filter(|interface| { + interface.namespace == "Contoso.Duplicates" && interface.name == "IRepeated" + }) + .collect::>(); + let mut iids = interfaces + .iter() + .map(|interface| interface.iid.clone()) + .collect::>(); + iids.sort(); + + assert_eq!( + iids, + vec![ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222", + ] + ); + assert_eq!( + crate::codegen::project::ambiguous_standalone_interface_names(&interfaces), + HashSet::from(["IRepeated".into()]) + ); + std::fs::remove_dir_all(directory).unwrap(); + } #[test] fn make_parameterized_name_single_arg() { diff --git a/tools/dynwinrt-codegen/tests/consistency_test.rs b/tools/dynwinrt-codegen/tests/consistency_test.rs index 9430cf80..33e73b7c 100644 --- a/tools/dynwinrt-codegen/tests/consistency_test.rs +++ b/tools/dynwinrt-codegen/tests/consistency_test.rs @@ -143,6 +143,21 @@ fn extract_exports(code: &str) -> Vec { .collect() } +fn repeated_required_interface_iids(classes: &[meta::ClassMeta]) -> HashSet { + let mut counts = HashMap::new(); + for class in classes { + for interface in &class.required_interfaces { + if !interface.iid.is_empty() { + *counts.entry(interface.iid.clone()).or_insert(0usize) += 1; + } + } + } + counts + .into_iter() + .filter_map(|(iid, count)| (count >= 2).then_some(iid)) + .collect() +} + /// Helper: build known_types, delegate_type_names, shared_iids from parsed metadata. fn setup_metadata( winmd: &str, @@ -189,7 +204,7 @@ fn setup_metadata( }) .map(|i| i.name.clone()) .collect(); - let shared_iids: HashSet = HashSet::new(); + let shared_iids = repeated_required_interface_iids(&all_classes); let (delegate_sigs, delegate_sig_refs, delegate_param_wraps) = project::build_delegate_signatures(&all_interfaces, &delegate_type_names, &known_types); @@ -436,7 +451,7 @@ fn js_dts_structural_consistency_user_watcher() { }) .map(|i| i.name.clone()) .collect(); - let shared_iids: HashSet = HashSet::new(); + let shared_iids = repeated_required_interface_iids(&all_classes); let (delegate_sigs, delegate_sig_refs, delegate_param_wraps) = project::build_delegate_signatures(&all_interfaces, &delegate_type_names, &known_types); diff --git a/tools/dynwinrt-codegen/tests/shared_interface_members_test.rs b/tools/dynwinrt-codegen/tests/shared_interface_members_test.rs new file mode 100644 index 00000000..ca4e30e2 --- /dev/null +++ b/tools/dynwinrt-codegen/tests/shared_interface_members_test.rs @@ -0,0 +1,1301 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use dynwinrt_codegen::codegen::{project, render_dts, render_js}; +use dynwinrt_codegen::meta::{ClassMeta, InterfaceMeta, MethodMeta, ParamDirection, ParamMeta}; +use dynwinrt_codegen::types::TypeMeta; + +const WINDOWS_WINMD: &str = + r"C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.26100.0\Windows.winmd"; + +fn value_interface() -> InterfaceMeta { + InterfaceMeta { + name: "IValue".into(), + namespace: "Contoso".into(), + iid: "11111111-1111-1111-1111-111111111111".into(), + methods: vec![ + MethodMeta { + name: "get_Value".into(), + raw_name: "get_Value".into(), + vtable_index: 6, + return_type: Some(TypeMeta::I32), + is_property_getter: true, + ..Default::default() + }, + MethodMeta { + name: "put_Value".into(), + raw_name: "put_Value".into(), + vtable_index: 7, + params: vec![ParamMeta { + name: "value".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }], + is_property_setter: true, + ..Default::default() + }, + ], + ..Default::default() + } +} + +fn items_control_interface() -> InterfaceMeta { + InterfaceMeta { + name: "IItemsControl".into(), + namespace: "Contoso.Controls".into(), + iid: "77777777-7777-7777-7777-777777777777".into(), + methods: vec![MethodMeta { + name: "get_Items".into(), + raw_name: "get_Items".into(), + vtable_index: 6, + return_type: Some(TypeMeta::Object), + is_property_getter: true, + ..Default::default() + }], + ..Default::default() + } +} + +fn widget_class(interface: &InterfaceMeta) -> ClassMeta { + ClassMeta { + name: "Widget".into(), + namespace: "Contoso".into(), + full_name: "Contoso.Widget".into(), + required_interfaces: vec![interface.clone()], + ..Default::default() + } +} + +fn shared_sources(interface: &InterfaceMeta) -> HashSet { + HashSet::from([project::standalone_interface_identity(interface).unwrap()]) +} + +fn snapshot_hash(contents: &str) -> u64 { + contents.bytes().fold(0xcbf29ce484222325, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3) + }) +} + +fn cli_test_directory(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!("{name}-{}", std::process::id())) +} + +fn run_codegen(output: &Path, class_names: &str, shared: bool) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")); + command.args([ + "generate", + "--winmd", + WINDOWS_WINMD, + "--namespace", + "Windows.Foundation", + "--class-name", + class_names, + "--output", + ]); + command.arg(output); + command.args(["--import-name", "./runtime.js"]); + if shared { + command.arg("--shared-interface-members"); + } + command.output().expect("run dynwinrt-codegen") +} + +fn write_runtime_stub(output: &Path) { + fs::write( + output.join("runtime.js"), + "\ +class DynWinRtMethodSig { addIn() { return this; } addOut() { return this; } }\n\ +const registration = { addMethod() { return this; }, method() { return {}; } };\n\ +const DynWinRtType = new Proxy({\n\ + registerInterface() { return registration; },\n\ + parameterized() { return { iid() { return 'iid'; } }; },\n\ +}, { get(target, key) { return target[key] ?? (() => ({})); } });\n\ +const callable = new Proxy({}, { get() { return () => ({}); } });\n\ +module.exports = {\n\ + DynWinRtType,\n\ + DynWinRtMethodSig,\n\ + DynWinRtValue: callable,\n\ + DynWinRtArray: callable,\n\ + DynWinRtDelegate: callable,\n\ + WinGuid: { parse(value) { return value; } },\n\ +};\n", + ) + .unwrap(); +} + +fn assert_node_script(output: &Path, name: &str, script: &str) { + if Command::new("node").arg("--version").output().is_err() { + eprintln!("Skipping generated module-load assertion: node is unavailable"); + return; + } + let script_path = output.join(name); + fs::write(&script_path, script).unwrap(); + let result = Command::new("node").arg(&script_path).output().unwrap(); + assert!( + result.status.success(), + "node failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr), + ); +} + +fn snapshot_directory(output: &Path) -> HashMap> { + fs::read_dir(output) + .unwrap() + .map(|entry| entry.unwrap()) + .filter(|entry| entry.file_type().unwrap().is_file()) + .map(|entry| { + ( + entry.file_name().to_string_lossy().into_owned(), + fs::read(entry.path()).unwrap(), + ) + }) + .collect() +} + +#[test] +fn cli_shared_uri_preserves_deep_raw_interface_exports() { + if !Path::new(WINDOWS_WINMD).exists() { + eprintln!("Skipping shared Uri CLI test: Windows.winmd not found"); + return; + } + + let root = cli_test_directory("shared-uri-deep-interface"); + let baseline = root.join("baseline"); + let shared = root.join("shared"); + let _ = fs::remove_dir_all(&root); + + let baseline_result = run_codegen(&baseline, "Uri", false); + assert!( + baseline_result.status.success(), + "{}", + String::from_utf8_lossy(&baseline_result.stderr), + ); + let shared_result = run_codegen(&shared, "Uri", true); + assert!( + shared_result.status.success(), + "{}", + String::from_utf8_lossy(&shared_result.stderr), + ); + + let baseline_js = fs::read_to_string(baseline.join("Uri.js")).unwrap(); + let baseline_dts = fs::read_to_string(baseline.join("Uri.d.ts")).unwrap(); + let shared_js = fs::read_to_string(shared.join("Uri.js")).unwrap(); + let shared_dts = fs::read_to_string(shared.join("Uri.d.ts")).unwrap(); + let canonical_js = fs::read_to_string(shared.join("IStringable.js")).unwrap(); + + assert!(baseline_js.contains("exports.IStringable = IStringable;")); + assert!(baseline_dts.contains("export declare class IStringable")); + assert!(shared_js.contains("exports.IStringable = require('./IStringable.js').IStringable;")); + assert!(shared_dts.contains("export { IStringable } from './IStringable.js';")); + assert!(shared_js.contains("return (__get_IStringable()).from(this._obj).toString();")); + assert!( + canonical_js + .contains("Windows.Foundation.IStringable:96369f54-8eb6-48f0-abce-c1b211e627c3") + ); + + write_runtime_stub(&shared); + assert_node_script( + &shared, + "verify-deep-interface.cjs", + "\ +const assert = require('node:assert/strict');\n\ +const deep = require('./Uri.js');\n\ +const canonical = require('./IStringable.js');\n\ +assert.equal(deep.IStringable, canonical.IStringable);\n", + ); + assert_node_script( + &shared, + "verify-deep-interface.mjs", + "\ +import assert from 'node:assert/strict';\n\ +import { IStringable as Deep } from './Uri.js';\n\ +import { IStringable as Canonical } from './IStringable.js';\n\ +assert.equal(Deep, Canonical);\n", + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn cli_mixed_flag_incremental_generation_preserves_shared_sources() { + if !Path::new(WINDOWS_WINMD).exists() { + eprintln!("Skipping mixed-flag CLI test: Windows.winmd not found"); + return; + } + + let output = cli_test_directory("mixed-flag-shared-source"); + let _ = fs::remove_dir_all(&output); + + let shared_result = run_codegen(&output, "Deferral,MemoryBuffer", true); + assert!( + shared_result.status.success(), + "{}", + String::from_utf8_lossy(&shared_result.stderr), + ); + write_runtime_stub(&output); + let deferral_before = fs::read(output.join("Deferral.js")).unwrap(); + let shared_iclosable = fs::read_to_string(output.join("IClosable.js")).unwrap(); + assert!( + shared_iclosable + .contains("Windows.Foundation.IClosable:30d5a829-7fa4-4026-83bb-d75bae4ea99e") + ); + assert!(shared_iclosable.contains("value._obj.cast(IID_IClosable)")); + assert_node_script( + &output, + "verify-deferral.cjs", + "Object.assign(globalThis, require('./runtime.js'));\nrequire('./Deferral.js');\n", + ); + + let unflagged_result = run_codegen(&output, "MemoryBuffer", false); + assert!( + unflagged_result.status.success(), + "{}", + String::from_utf8_lossy(&unflagged_result.stderr), + ); + assert_eq!( + fs::read(output.join("Deferral.js")).unwrap(), + deferral_before + ); + let preserved_iclosable = fs::read_to_string(output.join("IClosable.js")).unwrap(); + assert!( + preserved_iclosable + .contains("Windows.Foundation.IClosable:30d5a829-7fa4-4026-83bb-d75bae4ea99e") + ); + assert!(preserved_iclosable.contains("value._obj.cast(IID_IClosable)")); + assert_node_script( + &output, + "verify-deferral-after-unflagged.cjs", + "Object.assign(globalThis, require('./runtime.js'));\nrequire('./Deferral.js');\n", + ); + + let mismatched_iclosable = preserved_iclosable.replace( + "Windows.Foundation.IClosable:30d5a829-7fa4-4026-83bb-d75bae4ea99e", + "Fabrikam.IClosable:11111111-1111-1111-1111-111111111111", + ); + fs::write(output.join("IClosable.js"), mismatched_iclosable).unwrap(); + let before_rejection = snapshot_directory(&output); + let rejected = run_codegen(&output, "MemoryBuffer", false); + assert!(!rejected.status.success()); + assert!( + String::from_utf8_lossy(&rejected.stderr) + .contains("Refusing to overwrite shared interface source") + ); + assert_eq!(snapshot_directory(&output), before_rejection); + + fs::remove_dir_all(output).unwrap(); +} + +#[test] +fn default_projection_preserves_legacy_standalone_interface_imports() { + let interface = value_interface(); + let class = widget_class(&interface); + let known_types = HashSet::from(["Widget".into(), "IValue".into()]); + let standalone_interface_iids = HashSet::from([interface.iid.clone()]); + + project::set_shared_interface_members(false); + let projected = project::project_class( + &class, + &known_types, + &HashSet::new(), + &standalone_interface_iids, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + let separated = project::project_class_with_shared_member_sources( + &class, + &known_types, + &HashSet::new(), + &standalone_interface_iids, + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + let descriptor_only = project::project_class_with_shared_member_sources( + &class, + &known_types, + &HashSet::new(), + &HashSet::new(), + &shared_sources(&interface), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + + let interface_imports = projected + .imports + .iter() + .filter(|import| import.from == "./IValue.js") + .map(|import| (import.symbols.clone(), import.runtime_only, import.dts_only)) + .collect::>(); + assert_eq!( + interface_imports, + vec![(vec!["IID_IValue".into(), "IValue".into()], false, false)] + ); + assert!(projected.classes[0].required_ifaces.is_empty()); + assert_eq!(descriptor_only.classes[0].required_ifaces.len(), 1); + assert!( + descriptor_only + .imports + .iter() + .all(|import| import.from != "./IValue.js") + ); + assert_eq!(render_js::render(&projected), render_js::render(&separated)); + assert_eq!( + render_dts::render(&projected), + render_dts::render(&separated) + ); + + let class_js = render_js::render(&projected); + let class_dts = render_dts::render(&projected); + assert_eq!( + (snapshot_hash(&class_js), snapshot_hash(&class_dts)), + (14855624038810436456, 13331642975496716118), + "flag-off output changed from the origin/main compatibility snapshot" + ); + assert!(class_js.contains("require('./IValue.js')")); + assert!(class_js.contains("_IValue.method(6).invoke(this._obj.cast(IID_IValue)")); + assert!(!class_js.contains("__copyInterfaceMembers")); + assert!(!class_js.contains("export class IValue")); +} + +#[test] +fn opt_in_reuses_shared_interface_descriptors_without_changing_dts() { + let interface = value_interface(); + let class = widget_class(&interface); + let known_types = HashSet::from(["Widget".into(), "IValue".into()]); + let shared_iids = shared_sources(&interface); + + project::set_shared_interface_members(true); + let interface_file = project::project_interface_with_shared_member_source( + &interface, + &known_types, + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + true, + ); + let class_file = project::project_class_with_shared_member_sources( + &class, + &known_types, + &HashSet::new(), + &HashSet::new(), + &shared_iids, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + project::set_shared_interface_members(false); + + let interface_js = render_js::render(&interface_file); + let class_js = render_js::render(&class_file); + let class_dts = render_dts::render(&class_file); + + assert!(interface_js.contains("const __interfaceInstances = new WeakSet();")); + assert!(interface_js.contains("__interfaceValue(this)")); + assert!(interface_js.contains( + "Object.defineProperty(IValue, __sharedInterfaceMemberSource, { value: 'Contoso.IValue:11111111-1111-1111-1111-111111111111' });" + )); + assert!(class_js.contains( + "__copyInterfaceMembers(Widget, (__get_IValue()), 'Contoso.IValue:11111111-1111-1111-1111-111111111111', ['value']);" + )); + assert!(class_js.contains("source[__sharedInterfaceMemberSource] !== identity")); + assert!(!class_js.contains("_IValue.method(6).invoke(this._obj.cast(IID_IValue)")); + assert!(class_dts.contains("get value(): number;")); + assert!(class_dts.contains("set value(value: number);")); + + let default_class_js = render_js::render(&project::project_class( + &class, + &known_types, + &HashSet::new(), + &HashSet::from([interface.iid.clone()]), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + )); + assert!(!default_class_js.contains("__copyInterfaceMembers")); + assert!(default_class_js.contains("_IValue.method(6).invoke(this._obj.cast(IID_IValue)")); +} + +#[test] +fn canonical_interface_source_survives_equivalent_duplicate_emission() { + let shared = value_interface(); + let mut duplicate = shared.clone(); + duplicate.iid = shared.iid.to_ascii_uppercase(); + let sources = project::canonical_interface_sources( + &[duplicate.clone()], + std::slice::from_ref(&shared), + std::slice::from_ref(&shared), + &HashSet::new(), + &HashSet::new(), + &HashSet::new(), + ) + .unwrap(); + + assert_eq!(sources.len(), 1); + assert!(sources[0].shared_member_source); + assert_eq!(sources[0].interface.iid, duplicate.iid); + + project::set_shared_interface_members(true); + let interface_file = project::project_interface_with_shared_member_source( + &sources[0].interface, + &HashSet::from(["Widget".into(), "IValue".into()]), + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + sources[0].shared_member_source, + ); + let class_file = project::project_class_with_shared_member_sources( + &widget_class(&duplicate), + &HashSet::from(["Widget".into(), "IValue".into()]), + &HashSet::new(), + &HashSet::new(), + &HashSet::from([sources[0].identity.clone()]), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + project::set_shared_interface_members(false); + + assert!(render_js::render(&interface_file).contains("__interfaceValue(this)")); + assert!(render_js::render(&class_file).contains("__copyInterfaceMembers")); + assert_eq!( + render_dts::render(&interface_file), + render_dts::render(&project::project_interface( + &duplicate, + &HashSet::from(["Widget".into(), "IValue".into()]), + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + )) + ); +} + +#[test] +fn equivalent_cross_batch_emission_preserves_shared_source_marker() { + let shared = value_interface(); + let identity = project::standalone_interface_identity(&shared).unwrap(); + let forced_shared_sources = HashSet::from([identity]); + + let sources = project::canonical_interface_sources( + std::slice::from_ref(&shared), + &[], + &[], + &HashSet::new(), + &HashSet::new(), + &forced_shared_sources, + ) + .unwrap(); + assert_eq!(sources.len(), 1); + assert!(sources[0].shared_member_source); + + project::set_shared_interface_members(true); + let interface_file = project::project_interface_with_shared_member_source( + &sources[0].interface, + &HashSet::from(["IValue".into()]), + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + sources[0].shared_member_source, + ); + project::set_shared_interface_members(false); + assert!(render_js::render(&interface_file).contains("__interfaceValue(this)")); +} + +#[test] +fn non_interface_output_collision_disables_forced_shared_source() { + let interface = value_interface(); + let identity = project::standalone_interface_identity(&interface).unwrap(); + let sources = project::canonical_interface_sources( + std::slice::from_ref(&interface), + std::slice::from_ref(&interface), + std::slice::from_ref(&interface), + &HashSet::new(), + &HashSet::from(["IValue".into()]), + &HashSet::from([identity]), + ) + .unwrap(); + + assert_eq!(sources.len(), 1); + assert!(!sources[0].shared_member_source); + project::set_shared_interface_members(true); + let class_file = project::project_class( + &widget_class(&interface), + &HashSet::from(["Widget".into(), "IValue".into()]), + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + project::set_shared_interface_members(false); + let class_js = render_js::render(&class_file); + assert!(!class_js.contains("__copyInterfaceMembers")); + assert!(class_js.contains("_IValue.method(6).invoke(this._obj.cast(IID_IValue)")); +} + +#[test] +fn ambiguous_standalone_interface_identities_remain_class_local() { + let mut first = value_interface(); + first.methods.push(MethodMeta { + name: "GetPeer".into(), + raw_name: "GetPeer".into(), + vtable_index: 8, + return_type: Some(TypeMeta::Interface { + namespace: first.namespace.clone(), + name: first.name.clone(), + iid: first.iid.clone(), + }), + ..Default::default() + }); + let mut second = value_interface(); + second.namespace = "Fabrikam".into(); + second.iid = "88888888-8888-8888-8888-888888888888".into(); + second.methods.push(MethodMeta { + name: "GetPeer".into(), + raw_name: "GetPeer".into(), + vtable_index: 8, + return_type: Some(TypeMeta::Interface { + namespace: second.namespace.clone(), + name: second.name.clone(), + iid: second.iid.clone(), + }), + ..Default::default() + }); + + let ambiguous_names = project::ambiguous_standalone_interface_names([&first, &second]); + assert_eq!(ambiguous_names, HashSet::from(["IValue".into()])); + + let combined_sources = project::canonical_interface_sources( + &[first.clone(), second.clone()], + &[first.clone(), second.clone()], + &[first.clone(), second.clone()], + &HashSet::new(), + &HashSet::new(), + &HashSet::new(), + ) + .expect("ambiguous flat interface names must fall back to normal generation"); + assert_eq!(combined_sources.len(), 1); + assert!(!combined_sources[0].shared_member_source); + assert_eq!( + combined_sources[0].identity, + project::standalone_interface_identity(&second).unwrap() + ); + + let first_class = widget_class(&first); + let second_class = ClassMeta { + name: "Gadget".into(), + namespace: "Fabrikam".into(), + full_name: "Fabrikam.Gadget".into(), + required_interfaces: vec![second.clone()], + ..Default::default() + }; + let known_types = HashSet::from(["Widget".into(), "Gadget".into(), "IValue".into()]); + + project::set_shared_interface_members(true); + for (interface, class) in [(&first, &first_class), (&second, &second_class)] { + let sources = project::canonical_interface_sources( + std::slice::from_ref(interface), + std::slice::from_ref(interface), + std::slice::from_ref(interface), + &HashSet::new(), + &ambiguous_names, + &HashSet::new(), + ) + .expect("cross-batch ambiguity must fall back to normal generation"); + assert_eq!(sources.len(), 1); + assert!(!sources[0].shared_member_source); + let shared_source_identities = sources + .iter() + .filter(|source| source.shared_member_source) + .map(|source| source.identity.clone()) + .collect::>(); + assert!(shared_source_identities.is_empty()); + + let interface_file = project::project_interface_with_shared_member_source( + &sources[0].interface, + &known_types, + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + sources[0].shared_member_source, + ); + assert!(!render_js::render(&interface_file).contains("__interfaceValue")); + + let class_file = project::project_class_with_excluded_interface_imports( + class, + &known_types, + &HashSet::new(), + &HashSet::new(), + &shared_source_identities, + &ambiguous_names, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + assert_eq!(class_file.classes[0].required_ifaces.len(), 1); + assert!( + class_file + .iid_consts + .iter() + .any(|iid| { iid.name == "IID_IValue" && iid.rhs_expr.contains(&interface.iid) }) + ); + let class_js = render_js::render(&class_file); + assert!(!class_js.contains("__copyInterfaceMembers")); + assert!(!class_js.contains("require(\"./IValue.js\")"), "{class_js}"); + assert!( + class_js.contains("_IValue.method(6).invoke(this._obj.cast(IID_IValue)"), + "{class_js}" + ); + } + project::set_shared_interface_members(false); +} + +#[test] +fn nonshared_duplicate_interface_keeps_legacy_final_source() { + let first = value_interface(); + let mut second = first.clone(); + second.namespace = "Fabrikam".into(); + second.iid = "88888888-8888-8888-8888-888888888888".into(); + + let sources = project::canonical_interface_sources( + &[first, second.clone()], + &[], + &[], + &HashSet::new(), + &HashSet::new(), + &HashSet::new(), + ) + .unwrap(); + assert_eq!(sources.len(), 1); + assert!(!sources[0].shared_member_source); + assert_eq!( + sources[0].identity, + project::standalone_interface_identity(&second).unwrap() + ); +} + +#[test] +fn shared_interface_members_preserve_overload_dispatch_and_declarations() { + let interface = InterfaceMeta { + name: "IOverloaded".into(), + namespace: "Contoso".into(), + iid: "22222222-2222-2222-2222-222222222222".into(), + methods: vec![ + MethodMeta { + name: "DoThing".into(), + raw_name: "DoThing".into(), + vtable_index: 6, + params: vec![ParamMeta { + name: "value".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }], + ..Default::default() + }, + MethodMeta { + name: "DoThing2".into(), + raw_name: "DoThing".into(), + vtable_index: 7, + params: vec![ + ParamMeta { + name: "value".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }, + ParamMeta { + name: "other".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }, + ], + ..Default::default() + }, + ], + ..Default::default() + }; + let class = widget_class(&interface); + let known_types = HashSet::from(["Widget".into(), "IOverloaded".into()]); + let shared_iids = shared_sources(&interface); + + project::set_shared_interface_members(true); + let interface_js = render_js::render(&project::project_interface_with_shared_member_source( + &interface, + &known_types, + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + true, + )); + let class_file = project::project_class_with_shared_member_sources( + &class, + &known_types, + &HashSet::new(), + &HashSet::new(), + &shared_iids, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + project::set_shared_interface_members(false); + let class_js = render_js::render(&class_file); + let class_dts = render_dts::render(&class_file); + + assert!(interface_js.contains("doThing(value)"), "{interface_js}"); + assert!( + interface_js.contains("doThing2(value, other)"), + "{interface_js}" + ); + assert!(!class_js.contains("__copyInterfaceMembers")); + assert!(class_js.contains( + "__verifyInterfaceSource((__get_IOverloaded()), 'Contoso.IOverloaded:22222222-2222-2222-2222-222222222222');" + )); + assert!(class_js.contains("_doThing_1(value)"), "{class_js}"); + assert!(class_js.contains("_doThing_2(value, other)"), "{class_js}"); + assert!(class_js.contains("doThing(...args)"), "{class_js}"); + assert_eq!(class_dts.matches("doThing(").count(), 2); +} + +#[test] +fn shared_interface_members_preserve_cross_interface_overload_dispatch() { + let default_interface = InterfaceMeta { + name: "IWidget".into(), + namespace: "Contoso".into(), + iid: "33333333-3333-3333-3333-333333333333".into(), + methods: vec![MethodMeta { + name: "DoThing2".into(), + raw_name: "DoThing2".into(), + vtable_index: 6, + params: vec![ + ParamMeta { + name: "value".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }, + ParamMeta { + name: "other".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }, + ], + ..Default::default() + }], + ..Default::default() + }; + let required_interface = InterfaceMeta { + name: "IRequired".into(), + namespace: "Contoso".into(), + iid: "44444444-4444-4444-4444-444444444444".into(), + methods: vec![MethodMeta { + name: "DoThing".into(), + raw_name: "DoThing".into(), + vtable_index: 6, + params: vec![ParamMeta { + name: "value".into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }], + ..Default::default() + }], + ..Default::default() + }; + let class = ClassMeta { + default_interface: Some(default_interface), + required_interfaces: vec![required_interface.clone()], + ..widget_class(&required_interface) + }; + let known_types = HashSet::from(["Widget".into(), "IWidget".into(), "IRequired".into()]); + let shared_iids = shared_sources(&required_interface); + + project::set_shared_interface_members(true); + let class_file = project::project_class_with_shared_member_sources( + &class, + &known_types, + &HashSet::new(), + &HashSet::new(), + &shared_iids, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + project::set_shared_interface_members(false); + let class_js = render_js::render(&class_file); + let class_dts = render_dts::render(&class_file); + + assert!(!class_js.contains("__copyInterfaceMembers")); + assert!(class_js.contains( + "__verifyInterfaceSource((__get_IRequired()), 'Contoso.IRequired:44444444-4444-4444-4444-444444444444');" + )); + assert!(class_js.contains("_doThing_1(value)"), "{class_js}"); + assert!(class_js.contains("_doThing_2(value, other)"), "{class_js}"); + assert!(class_js.contains("doThing(...args)"), "{class_js}"); + assert_eq!(class_dts.matches("doThing(").count(), 2); +} + +#[test] +fn shared_interface_event_alias_conflicts_remain_class_local() { + let default_interface = InterfaceMeta { + name: "IWidget".into(), + namespace: "Contoso".into(), + iid: "55555555-5555-5555-5555-555555555555".into(), + methods: vec![MethodMeta { + name: "OnceChanged".into(), + raw_name: "OnceChanged".into(), + vtable_index: 6, + ..Default::default() + }], + ..Default::default() + }; + let required_interface = InterfaceMeta { + name: "IChanged".into(), + namespace: "Contoso".into(), + iid: "66666666-6666-6666-6666-666666666666".into(), + methods: vec![ + MethodMeta { + name: "add_Changed".into(), + raw_name: "add_Changed".into(), + vtable_index: 6, + params: vec![ParamMeta { + name: "handler".into(), + typ: TypeMeta::Object, + direction: ParamDirection::In, + }], + is_event_add: true, + ..Default::default() + }, + MethodMeta { + name: "remove_Changed".into(), + raw_name: "remove_Changed".into(), + vtable_index: 7, + params: vec![ParamMeta { + name: "token".into(), + typ: TypeMeta::I64, + direction: ParamDirection::In, + }], + is_event_remove: true, + ..Default::default() + }, + ], + ..Default::default() + }; + let class = ClassMeta { + default_interface: Some(default_interface), + required_interfaces: vec![required_interface.clone()], + ..widget_class(&required_interface) + }; + let known_types = HashSet::from(["Widget".into(), "IWidget".into(), "IChanged".into()]); + let shared_iids = shared_sources(&required_interface); + + project::set_shared_interface_members(true); + let class_file = project::project_class_with_shared_member_sources( + &class, + &known_types, + &HashSet::new(), + &HashSet::new(), + &shared_iids, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + project::set_shared_interface_members(false); + let class_js = render_js::render(&class_file); + + assert!(!class_js.contains("__copyInterfaceMembers")); + assert!(class_js.contains("onChanged(callback)"), "{class_js}"); + assert!( + class_js.contains("_IChanged.method(6).invoke(this._obj.cast(IID_IChanged)"), + "{class_js}" + ); +} + +#[test] +fn noncanonical_required_interfaces_remain_class_local() { + let interface = value_interface(); + let class = widget_class(&interface); + let known_types = HashSet::from(["Widget".into(), "IValue".into()]); + + project::set_shared_interface_members(true); + let class_file = project::project_class( + &class, + &known_types, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + project::set_shared_interface_members(false); + assert_eq!(class_file.classes[0].required_ifaces.len(), 1); + let interface_file = project::project_interface_with_shared_member_source( + &interface, + &known_types, + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + false, + ); + let class_js = render_js::render(&class_file); + let interface_js = render_js::render(&interface_file); + + assert!(!class_js.contains("__copyInterfaceMembers")); + assert!(class_js.contains("_IValue.method(6).invoke(this._obj.cast(IID_IValue)")); + assert!(!interface_js.contains("__interfaceValue")); +} + +#[test] +fn excluded_iclosable_uses_local_wrapper() { + let interface = InterfaceMeta { + name: "IClosable".into(), + namespace: "Windows.Foundation".into(), + iid: "30d5a829-7fa4-4026-83bb-d75bae4ea99e".into(), + methods: vec![MethodMeta { + name: "Close".into(), + raw_name: "Close".into(), + vtable_index: 6, + ..Default::default() + }], + ..Default::default() + }; + let class = widget_class(&interface); + + let class_file = project::project_class_with_excluded_interface_imports( + &class, + &HashSet::from(["Widget".into(), "IClosable".into()]), + &HashSet::new(), + &HashSet::new(), + &HashSet::new(), + &HashSet::from(["IClosable".into()]), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + assert_eq!(class_file.classes[0].required_ifaces.len(), 1); + assert!( + class_file + .iid_consts + .iter() + .any(|iid| iid.name == "IID_IClosable" && iid.rhs_expr.contains(&interface.iid)) + ); + let class_js = render_js::render(&class_file); + assert!( + !class_js.contains("require(\"./IClosable.js\")"), + "{class_js}" + ); + assert!(class_js.contains("close()"), "{class_js}"); +} + +#[test] +fn collection_getter_casts_concrete_view_and_rejects_invalid_sources() { + if Command::new("node").arg("--version").output().is_err() { + eprintln!("Skipping shared-interface runtime test: node is unavailable"); + return; + } + + let interface = items_control_interface(); + let class = ClassMeta { + name: "ListView".into(), + namespace: "Contoso.Controls".into(), + full_name: "Contoso.Controls.ListView".into(), + required_interfaces: vec![interface.clone()], + ..Default::default() + }; + let known_types = HashSet::from(["ListView".into(), "IItemsControl".into()]); + let shared_sources = shared_sources(&interface); + + project::set_import_name("./runtime.js"); + project::set_shared_interface_members(true); + let interface_js = render_js::render(&project::project_interface_with_shared_member_source( + &interface, + &known_types, + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + true, + )); + let unsafe_interface_js = + render_js::render(&project::project_interface_with_shared_member_source( + &interface, + &known_types, + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + false, + )); + let mut mismatched_interface = interface.clone(); + mismatched_interface.namespace = "Fabrikam.Controls".into(); + mismatched_interface.iid = "99999999-9999-9999-9999-999999999999".into(); + let mismatched_interface_js = + render_js::render(&project::project_interface_with_shared_member_source( + &mismatched_interface, + &known_types, + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + true, + )); + let class_js = render_js::render(&project::project_class_with_shared_member_sources( + &class, + &known_types, + &HashSet::new(), + &HashSet::new(), + &shared_sources, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + )); + project::set_shared_interface_members(false); + project::set_import_name("@microsoft/dynwinrt"); + + let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!("shared-collection-runtime-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join("IItemsControl.js"), &interface_js).unwrap(); + fs::write(directory.join("ListView.js"), class_js).unwrap(); + fs::write( + directory.join("lifetime.js"), + "\ + exports.castProjectedValueBorrowed = (value) => value;\n\ + exports.castProjectedValueOwned = (value) => value;\n\ + exports.trackProjectedValue = (value) => value;\n", + ) + .unwrap(); + fs::write( + directory.join("runtime.js"), + "\ + class DynWinRtMethodSig {\n\ + addIn() { return this; }\n\ + addOut() { return this; }\n\ + }\n\ + const DynWinRtType = {\n\ + registerInterface() {\n\ + return {\n\ + addMethod() { return this; },\n\ + method(index) { return { invoke: (obj, args) => obj.invoke(index, args) }; },\n\ + };\n\ + },\n\ + object() { return {}; },\n\ + };\n\ + const DynWinRtValue = {};\n\ + const DynWinRtArray = {};\n\ + const DynWinRtDelegate = {};\n\ + const WinGuid = { parse: (value) => value };\n\ + module.exports = { DynWinRtType, DynWinRtMethodSig, DynWinRtValue, DynWinRtArray, DynWinRtDelegate, WinGuid };\n", + ) + .unwrap(); + fs::write( + directory.join("test.js"), + "\ + const assert = require('node:assert/strict');\n\ + const { IItemsControl } = require('./IItemsControl.js');\n\ + const { ListView } = require('./ListView.js');\n\ + const items = { isNull: () => false, kind: 'ItemCollection' };\n\ + const missing = { isNull: () => true };\n\ + let casts = 0;\n\ + const interfaceValue = {\n\ + invoke(index) {\n\ + assert.equal(index, 6);\n\ + return items;\n\ + },\n\ + };\n\ + const raw = {\n\ + invoke(index) {\n\ + assert.equal(index, 6);\n\ + return missing;\n\ + },\n\ + cast() {\n\ + casts++;\n\ + return interfaceValue;\n\ + },\n\ + };\n\ + const list = Object.assign(Object.create(ListView.prototype), { _obj: raw });\n\ + const concreteDescriptor = Object.getOwnPropertyDescriptor(ListView.prototype, 'items');\n\ + const interfaceDescriptor = Object.getOwnPropertyDescriptor(IItemsControl.prototype, 'items');\n\ + assert.equal(concreteDescriptor.get, interfaceDescriptor.get);\n\ + assert.equal(list.items, items);\n\ + assert.equal(IItemsControl.from(raw).items, items);\n\ + assert.equal(casts, 2);\n", + ) + .unwrap(); + + let output = Command::new("node") + .arg("test.js") + .current_dir(&directory) + .output() + .unwrap(); + assert!( + output.status.success(), + "node failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + fs::write(directory.join("IItemsControl.js"), unsafe_interface_js).unwrap(); + let fail_closed = Command::new("node") + .args([ + "-e", + "require('node:assert/strict').throws(() => require('./ListView.js'), /not a shared member source/)", + ]) + .current_dir(&directory) + .output() + .unwrap(); + assert!( + fail_closed.status.success(), + "unmarked fail-closed node check failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&fail_closed.stdout), + String::from_utf8_lossy(&fail_closed.stderr), + ); + + fs::write(directory.join("IItemsControl.js"), mismatched_interface_js).unwrap(); + let mismatched_fail_closed = Command::new("node") + .args([ + "-e", + "require('node:assert/strict').throws(() => require('./ListView.js'), /not a shared member source/)", + ]) + .current_dir(&directory) + .output() + .unwrap(); + let _ = fs::remove_dir_all(&directory); + assert!( + mismatched_fail_closed.status.success(), + "mismatched fail-closed node check failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&mismatched_fail_closed.stdout), + String::from_utf8_lossy(&mismatched_fail_closed.stderr), + ); +} + +#[test] +fn shared_interface_descriptor_executes_for_raw_and_concrete_views() { + if Command::new("node").arg("--version").output().is_err() { + eprintln!("Skipping shared-interface runtime test: node is unavailable"); + return; + } + let interface = value_interface(); + let class = widget_class(&interface); + let known_types = HashSet::from(["Widget".into(), "IValue".into()]); + let shared_iids = shared_sources(&interface); + + project::set_import_name("./runtime.js"); + project::set_shared_interface_members(true); + let interface_js = render_js::render(&project::project_interface_with_shared_member_source( + &interface, + &known_types, + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + true, + )); + let class_js = render_js::render(&project::project_class_with_shared_member_sources( + &class, + &known_types, + &HashSet::new(), + &HashSet::new(), + &shared_iids, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + )); + project::set_shared_interface_members(false); + project::set_import_name("@microsoft/dynwinrt"); + + let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!("shared-interface-runtime-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join("IValue.js"), interface_js).unwrap(); + fs::write(directory.join("Widget.js"), class_js).unwrap(); + fs::write( + directory.join("lifetime.js"), + "\ +exports.castProjectedValueBorrowed = (value) => value;\n\ +exports.castProjectedValueOwned = (value) => value;\n\ +exports.trackProjectedValue = (value) => value;\n", + ) + .unwrap(); + fs::write( + directory.join("runtime.js"), + "\ +class DynWinRtMethodSig {\n\ + addIn() { return this; }\n\ + addOut() { return this; }\n\ +}\n\ +const DynWinRtType = {\n\ + registerInterface() {\n\ + return {\n\ + addMethod() { return this; },\n\ + method(index) { return { invoke: (obj, args) => obj.invoke(index, args) }; },\n\ + };\n\ + },\n\ + i32() { return {}; },\n\ +};\n\ +const DynWinRtValue = { i32: (value) => value };\n\ +const DynWinRtArray = {};\n\ +const DynWinRtDelegate = {};\n\ +const WinGuid = { parse: (value) => value };\n\ +module.exports = { DynWinRtType, DynWinRtMethodSig, DynWinRtValue, DynWinRtArray, DynWinRtDelegate, WinGuid };\n", + ) + .unwrap(); + fs::write( + directory.join("test.js"), + "\ +const assert = require('node:assert/strict');\n\ +const { IValue } = require('./IValue.js');\n\ +const { Widget } = require('./Widget.js');\n\ +let current = 41;\n\ +let casts = 0;\n\ +const interfaceValue = {\n\ + invoke(index, args) {\n\ + if (index === 6) return { toNumber: () => current };\n\ + if (index === 7) { current = args[0]; return undefined; }\n\ + throw new Error(`unexpected slot ${index}`);\n\ + },\n\ +};\n\ +const raw = { cast() { casts++; return interfaceValue; } };\n\ +const widget = Object.assign(Object.create(Widget.prototype), { _obj: raw });\n\ +const concreteDescriptor = Object.getOwnPropertyDescriptor(Widget.prototype, 'value');\n\ +const interfaceDescriptor = Object.getOwnPropertyDescriptor(IValue.prototype, 'value');\n\ +assert.equal(concreteDescriptor.get, interfaceDescriptor.get);\n\ +assert.equal(widget.value, 41);\n\ +widget.value = 52;\n\ +assert.equal(widget.value, 52);\n\ +const view = IValue.from(raw);\n\ +assert.equal(view.value, 52);\n\ +assert.equal(casts, 4);\n", + ) + .unwrap(); + + let output = Command::new("node") + .arg("test.js") + .current_dir(&directory) + .output() + .unwrap(); + let _ = fs::remove_dir_all(&directory); + assert!( + output.status.success(), + "node failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/tools/dynwinrt-codegen/tests/snapshot_test.rs b/tools/dynwinrt-codegen/tests/snapshot_test.rs index 1bdd1d50..2e39437e 100644 --- a/tools/dynwinrt-codegen/tests/snapshot_test.rs +++ b/tools/dynwinrt-codegen/tests/snapshot_test.rs @@ -20,6 +20,21 @@ use dynwinrt_codegen::types::TypeMeta; const WINDOWS_WINMD: &str = r"C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.26100.0\Windows.winmd"; +fn repeated_required_interface_iids(classes: &[meta::ClassMeta]) -> HashSet { + let mut counts = HashMap::new(); + for class in classes { + for interface in &class.required_interfaces { + if !interface.iid.is_empty() { + *counts.entry(interface.iid.clone()).or_insert(0usize) += 1; + } + } + } + counts + .into_iter() + .filter_map(|(iid, count)| (count >= 2).then_some(iid)) + .collect() +} + /// Generate TypeScript for Uri and compare every file against the snapshot. #[test] fn snapshot_uri_class() { @@ -60,7 +75,7 @@ fn snapshot_uri_class() { .map(|i| i.name.clone()) .collect(); - let shared_iids: HashSet = HashSet::new(); + let shared_iids = repeated_required_interface_iids(&all_classes); let (delegate_sigs, delegate_sig_refs, delegate_param_wraps) = project::build_delegate_signatures(&all_interfaces, &delegate_type_names, &known_types); @@ -370,7 +385,7 @@ fn ts_async_methods_emit_abort_signal_scaffolding() { }) .map(|i| i.name.clone()) .collect(); - let shared: HashSet = HashSet::new(); + let shared = repeated_required_interface_iids(&dw_all_classes); let (dw_delegate_sigs, dw_delegate_sig_refs, dw_delegate_param_wraps) = project::build_delegate_signatures(&dw_ifaces, &delegates, &known);