Skip to main content

shape_runtime/
module_exports.rs

1//! Runtime module export bindings for Shape extensions.
2//!
3//! This module defines the in-process representation used by VM/LSP/CLI after
4//! a plugin has been loaded through the ABI capability interfaces.
5//!
6//! ## ABI policy (ADR-006 §2.7.5)
7//!
8//! Per ADR-006 §2.7.5, this module enforces the cross-crate ABI split:
9//!
10//! - [`RawCallableInvoker::invoke`] — **stable extension contract**.
11//!   Stays on raw `&u64` / `&[u64]` so CFFI extensions don't recompile
12//!   when the runtime's internal carrier shape changes. Conversion to/
13//!   from [`KindedSlot`] happens **inside `shape-runtime` at the
14//!   boundary** (the VM-side `invoke_callable` adapter constructs
15//!   `KindedSlot`s from raw bits + the typed registry's `NativeKind`s
16//!   before runtime-tier dispatch and unpacks back to `u64` for the
17//!   extension call).
18//! - [`ModuleFn`] (internal Rust trait object) — **migrates to
19//!   `KindedSlot`**. Lives entirely inside `shape-runtime` with no
20//!   recompilation concern.
21//! - [`FrameInfo`] (internal carrier) — **migrates to `KindedSlot`**.
22//!   The pre-existing manual `Clone` / `Drop` calling
23//!   `value_word_drop::vw_clone` / `vw_drop_slice` collapses to default:
24//!   `KindedSlot::Drop` / `Clone` carry the refcount discipline now,
25//!   so `Vec<KindedSlot>` push / pop / clone preserve the WB2.4 retain
26//!   semantics by construction.
27
28use crate::type_schema::{TypeSchema, TypeSchemaRegistry};
29use shape_value::KindedSlot;
30use std::collections::HashMap;
31use std::ffi::c_void;
32use std::sync::Arc;
33
34/// Raw callable invoker as a function pointer + opaque context.
35///
36/// This is the `Send`-safe, `'static`-safe form of `invoke_callable` that
37/// extensions (e.g., CFFI) can store in long-lived structs like callback
38/// userdata. The context pointer is valid for the duration of the
39/// originating module function call.
40///
41/// Per ADR-006 §2.7.5 the raw-bits signature `(*mut c_void, &u64, &[u64])
42/// -> Result<u64, String>` is the **stable extension contract** — it
43/// does not migrate to [`KindedSlot`]. The conversion to [`KindedSlot`]
44/// happens inside the runtime-side adapter that constructs this invoker
45/// (the adapter reads the parallel `NativeKind` from the typed registry
46/// and builds a `KindedSlot` for runtime-tier dispatch before unpacking
47/// back to `u64` for the extension call).
48#[derive(Clone, Copy)]
49pub struct RawCallableInvoker {
50    pub ctx: *mut c_void,
51    pub invoke: unsafe fn(*mut c_void, &u64, &[u64]) -> Result<u64, String>,
52}
53
54impl RawCallableInvoker {
55    /// Invoke a Shape callable through this raw invoker.
56    ///
57    /// # Safety
58    /// The caller must ensure `self.ctx` is still valid (i.e., the originating
59    /// VM module call is still on the stack).
60    pub unsafe fn call(&self, callable: &u64, args: &[u64]) -> Result<u64, String> {
61        unsafe { (self.invoke)(self.ctx, callable, args) }
62    }
63}
64
65/// Information about a single VM call frame, captured at a point in time.
66///
67/// **Refcount discipline via [`KindedSlot`].** Per ADR-006 §2.7 the
68/// `locals` / `upvalues` / `args` vectors hold [`KindedSlot`]s — the
69/// GENERIC_CARRIER vector form. `KindedSlot`'s explicit `Drop` and
70/// `Clone` impls dispatch on `NativeKind` to retire / bump heap
71/// refcounts, so `Vec<KindedSlot>` push / pop / clone preserve the
72/// WB2.4 / WB2.5 retain-on-read invariant by construction. The manual
73/// `Clone` / `Drop` impls calling `vw_clone` / `vw_drop_slice` that
74/// pre-bulldozer code carried are no longer needed.
75#[derive(Debug, Clone)]
76pub struct FrameInfo {
77    pub function_id: Option<u16>,
78    pub function_name: String,
79    pub blob_hash: Option<[u8; 32]>,
80    pub local_ip: usize,
81    pub locals: Vec<KindedSlot>,
82    pub upvalues: Option<Vec<KindedSlot>>,
83    pub args: Vec<KindedSlot>,
84}
85
86/// Trait providing read access to VM state for state module functions.
87pub trait VmStateAccessor: Send + Sync {
88    fn current_frame(&self) -> Option<FrameInfo>;
89    fn all_frames(&self) -> Vec<FrameInfo>;
90    fn caller_frame(&self) -> Option<FrameInfo>;
91    fn current_args(&self) -> Vec<KindedSlot>;
92    fn current_locals(&self) -> Vec<(String, KindedSlot)>;
93    fn module_bindings(&self) -> Vec<(String, KindedSlot)>;
94    /// Total instruction count at the time of capture. Default impl for compat.
95    fn instruction_count(&self) -> usize {
96        0
97    }
98}
99
100/// Execution context available to module functions during a VM call.
101///
102/// The VM constructs this before each module function dispatch and passes
103/// it by reference.
104pub struct ModuleContext<'a> {
105    /// Type schema registry — lookup types by name or ID.
106    pub schemas: &'a TypeSchemaRegistry,
107
108    /// Invoke a Shape callable (function/closure) from host code.
109    pub invoke_callable:
110        Option<&'a dyn Fn(&KindedSlot, &[KindedSlot]) -> Result<KindedSlot, String>>,
111
112    /// Raw invoker for extensions that need to capture a callable invoker
113    /// beyond the borrow lifetime (e.g., CFFI callback userdata).
114    /// Valid only for the duration of the current module function call.
115    pub raw_invoker: Option<RawCallableInvoker>,
116
117    /// Content-addressed function hashes indexed by function ID.
118    /// Provided by the VM when content-addressed metadata is available.
119    /// Uses raw `[u8; 32]` to avoid a dependency on `shape-vm`'s `FunctionHash`.
120    pub function_hashes: Option<&'a [Option<[u8; 32]>]>,
121
122    /// Read-only access to VM state (call frames, locals, etc.).
123    /// Provided by the VM when state introspection is needed.
124    pub vm_state: Option<&'a dyn VmStateAccessor>,
125
126    /// Permissions granted to the current execution context.
127    /// When `Some`, module functions check this before performing I/O.
128    /// When `None`, all operations are allowed (backwards compatible).
129    pub granted_permissions: Option<shape_abi_v1::PermissionSet>,
130
131    /// Scope constraints for the current execution context.
132    /// Narrows permissions to specific paths, hosts, etc.
133    pub scope_constraints: Option<shape_abi_v1::ScopeConstraints>,
134
135    /// Callback for `state.resume()` to request full VM state restoration.
136    /// The module function stores the snapshot; the dispatch loop applies it
137    /// after the current instruction completes.
138    pub set_pending_resume: Option<&'a dyn Fn(KindedSlot)>,
139
140    /// Callback for `state.resume_frame()` to request mid-function resume.
141    /// Stores (ip_offset, locals) so the dispatch loop can override the
142    /// call frame set up by invoke_callable.
143    pub set_pending_frame_resume: Option<&'a dyn Fn(usize, Vec<KindedSlot>)>,
144}
145
146/// Check whether the current execution context has a required permission.
147///
148/// If `granted_permissions` is `None`, all operations are allowed (backwards
149/// compatible with code that predates the permission system). If `Some`, the
150/// specific permission must be present in the set.
151pub fn check_permission(
152    ctx: &ModuleContext,
153    permission: shape_abi_v1::Permission,
154) -> Result<(), String> {
155    if let Some(ref granted) = ctx.granted_permissions {
156        if !granted.contains(&permission) {
157            return Err(format!(
158                "Permission denied: {} ({})",
159                permission.description(),
160                permission.name()
161            ));
162        }
163    }
164    Ok(())
165}
166
167/// Check permission and enforce filesystem path scope constraints.
168///
169/// After verifying the base permission (`FsRead`, `FsWrite`, or `FsScoped`),
170/// checks `ScopeConstraints::allowed_paths` when present. If the scope
171/// constraints list paths, the target path must match at least one (prefix
172/// match). An empty `allowed_paths` list means all paths are permitted.
173pub fn check_fs_permission(
174    ctx: &ModuleContext,
175    permission: shape_abi_v1::Permission,
176    path: &str,
177) -> Result<(), String> {
178    check_permission(ctx, permission)?;
179
180    if let Some(ref constraints) = ctx.scope_constraints {
181        if !constraints.allowed_paths.is_empty() {
182            let target = std::path::Path::new(path);
183            let allowed = constraints.allowed_paths.iter().any(|pattern| {
184                // Support glob-style prefix matching: "/data/**" matches
185                // anything under /data/, and "/tmp/*" matches direct children.
186                let pattern = pattern.trim_end_matches("**").trim_end_matches('*');
187                let prefix = std::path::Path::new(pattern.trim_end_matches('/'));
188                target.starts_with(prefix)
189            });
190            if !allowed {
191                return Err(format!(
192                    "Scope constraint denied: path '{}' is not in allowed paths",
193                    path
194                ));
195            }
196        }
197    }
198    Ok(())
199}
200
201/// Check permission and enforce network host scope constraints.
202///
203/// After verifying the base permission (`NetConnect`, `NetListen`, or
204/// `NetScoped`), checks `ScopeConstraints::allowed_hosts` when present.
205/// If the scope constraints list hosts, the target address must match at
206/// least one (supports `host:port` and `*.domain.com` wildcards).
207pub fn check_net_permission(
208    ctx: &ModuleContext,
209    permission: shape_abi_v1::Permission,
210    address: &str,
211) -> Result<(), String> {
212    check_permission(ctx, permission)?;
213
214    if let Some(ref constraints) = ctx.scope_constraints {
215        if !constraints.allowed_hosts.is_empty() {
216            // Extract host (and optional port) from the address.
217            let target_host = address.split(':').next().unwrap_or(address);
218            let allowed = constraints.allowed_hosts.iter().any(|pattern| {
219                let pattern_host = pattern.split(':').next().unwrap_or(pattern);
220                // Wildcard: *.example.com matches sub.example.com
221                if let Some(suffix) = pattern_host.strip_prefix("*.") {
222                    target_host.ends_with(suffix) && target_host.len() > suffix.len()
223                } else {
224                    // Exact host match (port part is ignored for scope check)
225                    target_host == pattern_host
226                }
227            });
228            if !allowed {
229                return Err(format!(
230                    "Scope constraint denied: address '{}' is not in allowed hosts",
231                    address
232                ));
233            }
234        }
235    }
236    Ok(())
237}
238
239/// A module function callable from Shape (synchronous).
240///
241/// Per ADR-006 §2.7.5 (cross-crate ABI policy), this internal Rust trait
242/// object migrates to [`KindedSlot`] — it lives entirely inside
243/// `shape-runtime` with no cross-crate ABI concern. Stable extension
244/// contracts (`RawCallableInvoker::invoke`) stay on raw bits.
245///
246/// Takes a slice of `KindedSlot` arguments plus a `ModuleContext` that
247/// provides access to the type schema registry and a callable invoker.
248/// The function must be `Send + Sync` for thread safety.
249pub type ModuleFn = Arc<
250    dyn for<'ctx> Fn(&[KindedSlot], &ModuleContext<'ctx>) -> Result<KindedSlot, String>
251        + Send
252        + Sync,
253>;
254
255/// One entry in the VM's per-process module-function table
256/// (`module_fn_table`), indexed by positional `u32` id.
257///
258/// Phase 4c.4: the legacy `ModuleFn` ABI escape hatch was deleted. All
259/// stdlib and test fixtures route through the typed registry.
260///
261/// - [`Self::Typed`]: synchronous typed-return native function. The
262///   body returns [`crate::typed_module_exports::TypedReturn`] directly;
263///   the dispatch boundary projects the typed return into a kinded slot
264///   per ADR-006 §2.7 — no round-trip through a synthesized runtime
265///   value.
266/// - [`Self::TypedAsync`]: async typed-return native function. The body
267///   returns a future of `TypedReturn`; the synchronous dispatch path
268///   blocks on the future and applies the same kind-threaded projection
269///   at the boundary.
270#[derive(Clone)]
271pub enum ModuleFnEntry {
272    Typed(crate::typed_module_exports::TypedModuleFunction),
273    TypedAsync(crate::typed_module_exports::TypedModuleAsyncFunction),
274}
275
276/// Visibility policy for one extension export.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum ModuleExportVisibility {
279    /// Normal module API: available in runtime + comptime contexts.
280    Public,
281    /// Only callable from comptime contexts.
282    ComptimeOnly,
283    /// Internal helper: callable but hidden from normal user-facing discovery.
284    Internal,
285}
286
287impl Default for ModuleExportVisibility {
288    fn default() -> Self {
289        Self::Public
290    }
291}
292
293/// Schema for a single parameter of a module function.
294/// Used by LSP for completions and by validation for type checking.
295#[derive(Debug, Clone)]
296pub struct ModuleParam {
297    pub name: String,
298    pub type_name: String,
299    pub required: bool,
300    pub description: String,
301    pub default_snippet: Option<String>,
302    pub allowed_values: Option<Vec<String>>,
303    pub nested_params: Option<Vec<ModuleParam>>,
304}
305
306impl Default for ModuleParam {
307    fn default() -> Self {
308        Self {
309            name: String::new(),
310            type_name: "any".to_string(),
311            required: false,
312            description: String::new(),
313            default_snippet: None,
314            allowed_values: None,
315            nested_params: None,
316        }
317    }
318}
319
320/// Schema for a module function — describes parameters and return type.
321/// Used by LSP for completions, hover, and signature help.
322#[derive(Debug, Clone)]
323pub struct ModuleFunction {
324    pub description: String,
325    pub params: Vec<ModuleParam>,
326    pub return_type: Option<String>,
327}
328
329/// Bundled module artifact from an extension.
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct ModuleArtifact {
332    /// Import path for this module (e.g. "duckdb", "duckdb.query")
333    pub module_path: String,
334    /// Optional Shape source payload.
335    pub source: Option<String>,
336    /// Optional precompiled payload (opaque host format).
337    pub compiled: Option<Vec<u8>>,
338}
339
340/// A Rust-implemented module exposed via `<name>`.
341#[derive(Clone)]
342pub struct ModuleExports {
343    /// Module name (e.g., "csv", "json", "duckdb")
344    pub name: String,
345    /// Human-readable description of this module
346    pub description: String,
347    /// Function schemas for LSP + validation: name → schema
348    pub schemas: HashMap<String, ModuleFunction>,
349    /// Export visibility controls: name → visibility.
350    pub export_visibility: HashMap<String, ModuleExportVisibility>,
351    /// Shape source files bundled with this extension.
352    /// Compiled and merged with core stdlib at startup.
353    /// Vec of (filename, source_code) pairs.
354    ///
355    /// Legacy compatibility field. New code should use `module_artifacts`.
356    pub shape_sources: Vec<(String, String)>,
357    /// Bundled module artifacts (source/compiled/both).
358    pub module_artifacts: Vec<ModuleArtifact>,
359    /// Method intrinsics for fast dispatch on typed Objects.
360    /// Outer key: type name (e.g., "DuckDbQuery")
361    /// Inner key: method name (e.g., "build_sql")
362    /// Dispatched BEFORE callable-property and UFCS fallback.
363    pub method_intrinsics: HashMap<String, HashMap<String, ModuleFn>>,
364    /// Type schemas to register in the VM's runtime TypeSchemaRegistry.
365    /// Extensions can use this to declare types that the runtime can use
366    /// for TypedObject creation and field validation.
367    pub type_schemas: Vec<TypeSchema>,
368    /// Typed-return ABI registry (Phase 4b).
369    ///
370    /// Authoritative registry for native-module function bodies. Every
371    /// export here declares its return type via
372    /// [`crate::typed_module_exports::TypedReturn`] / [`crate::typed_module_exports::ConcreteType`];
373    /// the kind-threaded marshal layer projects the typed return into
374    /// a kinded slot at the dispatch boundary inside the VM, not in
375    /// the body (ADR-006 §2.7). Phase 4c.4 deleted the legacy
376    /// `exports`/`async_exports` `ModuleFn` parallel registry — every
377    /// callable function body lives here.
378    pub typed_exports: crate::typed_module_exports::TypedModuleExports,
379}
380
381impl ModuleExports {
382    /// Create a new extension module.
383    pub fn new(name: impl Into<String>) -> Self {
384        Self {
385            name: name.into(),
386            description: String::new(),
387            schemas: HashMap::new(),
388            export_visibility: HashMap::new(),
389            shape_sources: Vec::new(),
390            module_artifacts: Vec::new(),
391            method_intrinsics: HashMap::new(),
392            type_schemas: Vec::new(),
393            typed_exports: crate::typed_module_exports::TypedModuleExports::new(),
394        }
395    }
396
397    /// Mutable access to the typed-return registry. Used by
398    /// [`crate::typed_module_exports::register_typed_function`] to record
399    /// the typed-body entry.
400    pub fn typed_exports_mut(
401        &mut self,
402    ) -> &mut crate::typed_module_exports::TypedModuleExports {
403        &mut self.typed_exports
404    }
405
406    /// Read-only access to the typed-return registry.
407    pub fn typed_exports(&self) -> &crate::typed_module_exports::TypedModuleExports {
408        &self.typed_exports
409    }
410
411    /// Register only the LSP/validation schema and visibility for an
412    /// exported name. The actual function body lives in `typed_exports`
413    /// and is dispatched directly via `ModuleFnEntry::Typed` /
414    /// `ModuleFnEntry::TypedAsync` — see
415    /// `register_typed_function`/`register_typed_async_function` and the
416    /// test-only `register_test_function*` helpers.
417    pub fn add_schema_only(
418        &mut self,
419        name: impl Into<String>,
420        schema: ModuleFunction,
421    ) -> &mut Self {
422        let name = name.into();
423        self.schemas.insert(name.clone(), schema);
424        self.export_visibility.entry(name).or_default();
425        self
426    }
427
428    /// Set visibility for one export name.
429    pub fn set_export_visibility(
430        &mut self,
431        name: impl Into<String>,
432        visibility: ModuleExportVisibility,
433    ) -> &mut Self {
434        self.export_visibility.insert(name.into(), visibility);
435        self
436    }
437
438    /// Resolve visibility for one export (defaults to Public).
439    pub fn export_visibility(&self, name: &str) -> ModuleExportVisibility {
440        self.export_visibility
441            .get(name)
442            .copied()
443            .unwrap_or_default()
444    }
445
446    /// Return true when the export can be called in the current compiler mode.
447    pub fn is_export_available(&self, name: &str, comptime_mode: bool) -> bool {
448        match self.export_visibility(name) {
449            ModuleExportVisibility::Public => true,
450            ModuleExportVisibility::ComptimeOnly => comptime_mode,
451            ModuleExportVisibility::Internal => true,
452        }
453    }
454
455    /// Return true when the export should appear in user-facing completion/hover surfaces.
456    pub fn is_export_public_surface(&self, name: &str, comptime_mode: bool) -> bool {
457        match self.export_visibility(name) {
458            ModuleExportVisibility::Public => true,
459            ModuleExportVisibility::ComptimeOnly => comptime_mode,
460            ModuleExportVisibility::Internal => false,
461        }
462    }
463
464    /// List exports available for the requested mode (sync + async).
465    pub fn export_names_available(&self, comptime_mode: bool) -> Vec<&str> {
466        self.export_names()
467            .into_iter()
468            .filter(|name| self.is_export_available(name, comptime_mode))
469            .collect()
470    }
471
472    /// List user-facing exports for completion/hover (sync + async).
473    pub fn export_names_public_surface(&self, comptime_mode: bool) -> Vec<&str> {
474        self.export_names()
475            .into_iter()
476            .filter(|name| self.is_export_public_surface(name, comptime_mode))
477            .collect()
478    }
479
480    /// Bundle a Shape source file with this extension.
481    /// The source will be compiled and merged with stdlib at startup.
482    pub fn add_shape_source(&mut self, filename: &str, source: &str) -> &mut Self {
483        self.module_artifacts.push(ModuleArtifact {
484            module_path: filename.to_string(),
485            source: Some(source.to_string()),
486            compiled: None,
487        });
488        self.shape_sources
489            .push((filename.to_string(), source.to_string()));
490        self
491    }
492
493    /// Register a bundled module artifact (source/compiled/both).
494    pub fn add_shape_artifact(
495        &mut self,
496        module_path: impl Into<String>,
497        source: Option<String>,
498        compiled: Option<Vec<u8>>,
499    ) -> &mut Self {
500        self.module_artifacts.push(ModuleArtifact {
501            module_path: module_path.into(),
502            source,
503            compiled,
504        });
505        self
506    }
507
508    /// Register a method intrinsic for fast dispatch on typed Objects.
509    /// Called before callable-property and UFCS fallback in handle_object_method().
510    pub fn add_intrinsic<F>(&mut self, type_name: &str, method_name: &str, f: F) -> &mut Self
511    where
512        F: for<'ctx> Fn(&[KindedSlot], &ModuleContext<'ctx>) -> Result<KindedSlot, String>
513            + Send
514            + Sync
515            + 'static,
516    {
517        self.method_intrinsics
518            .entry(type_name.to_string())
519            .or_default()
520            .insert(method_name.to_string(), Arc::new(f));
521        self
522    }
523
524    /// Register a type schema that the VM will add to its runtime registry.
525    /// Returns the schema ID for reference.
526    pub fn add_type_schema(&mut self, schema: TypeSchema) -> crate::type_schema::SchemaId {
527        let id = schema.id;
528        self.type_schemas.push(schema);
529        id
530    }
531
532    /// Check if this module exports a given name (sync or async).
533    pub fn has_export(&self, name: &str) -> bool {
534        self.typed_exports.functions.contains_key(name)
535            || self.typed_exports.async_functions.contains_key(name)
536    }
537
538    // `invoke_export` and `TypedReturn::into_value_word()` are deleted.
539    // The replacement is the Phase 2b kind-threaded marshal layer that
540    // projects `TypedReturn` directly into a typed slot without round-
541    // tripping through a synthesized runtime value.
542
543    /// Check if a function is async.
544    pub fn is_async(&self, name: &str) -> bool {
545        self.typed_exports.async_functions.contains_key(name)
546    }
547
548    /// Get the schema for an exported function.
549    pub fn get_schema(&self, name: &str) -> Option<&ModuleFunction> {
550        self.schemas.get(name)
551    }
552
553    /// List all export names (sync + async).
554    pub fn export_names(&self) -> Vec<&str> {
555        let mut names: Vec<&str> = self
556            .typed_exports
557            .functions
558            .keys()
559            .chain(self.typed_exports.async_functions.keys())
560            .map(|s| s.as_str())
561            .collect();
562        names.sort_unstable();
563        names.dedup();
564        names
565    }
566
567    /// Convert this module's schema to a `ParsedModuleSchema` for the semantic
568    /// analyzer, mirroring the conversion in `BytecodeExecutor::module_schemas()`.
569    pub fn to_parsed_schema(&self) -> crate::extensions::ParsedModuleSchema {
570        let functions = self
571            .schemas
572            .iter()
573            .filter(|(name, _)| self.is_export_public_surface(name, false))
574            .map(|(name, schema)| crate::extensions::ParsedModuleFunction {
575                name: name.clone(),
576                description: schema.description.clone(),
577                params: schema.params.iter().map(|p| p.type_name.clone()).collect(),
578                return_type: schema.return_type.clone(),
579            })
580            .collect();
581        crate::extensions::ParsedModuleSchema {
582            module_name: self.name.clone(),
583            functions,
584            artifacts: Vec::new(),
585        }
586    }
587
588    /// Return `ParsedModuleSchema` entries for all shipped native stdlib modules.
589    /// Used during engine initialization to make these globals visible at compile time.
590    pub fn stdlib_module_schemas() -> Vec<crate::extensions::ParsedModuleSchema> {
591        crate::stdlib::all_stdlib_modules()
592            .into_iter()
593            .map(|m| m.to_parsed_schema())
594            .collect()
595    }
596}
597
598impl std::fmt::Debug for ModuleExports {
599    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
600        f.debug_struct("ModuleExports")
601            .field("name", &self.name)
602            .field("description", &self.description)
603            .field(
604                "typed_exports",
605                &self
606                    .typed_exports
607                    .functions
608                    .keys()
609                    .chain(self.typed_exports.async_functions.keys())
610                    .collect::<Vec<_>>(),
611            )
612            .field("schemas", &self.schemas.keys().collect::<Vec<_>>())
613            .field(
614                "shape_sources",
615                &self
616                    .shape_sources
617                    .iter()
618                    .map(|(f, _)| f)
619                    .collect::<Vec<_>>(),
620            )
621            .field(
622                "method_intrinsics",
623                &self.method_intrinsics.keys().collect::<Vec<_>>(),
624            )
625            .finish()
626    }
627}
628
629/// Registry of all extension modules.
630///
631/// Created at startup and populated from loaded plugin capabilities.
632/// Lookup is by canonical path only (e.g. `"std::core::json"`).
633#[derive(Default)]
634pub struct ModuleExportRegistry {
635    modules: HashMap<String, ModuleExports>,
636}
637
638impl ModuleExportRegistry {
639    /// Create a new empty registry.
640    pub fn new() -> Self {
641        Self {
642            modules: HashMap::new(),
643        }
644    }
645
646    /// Register a extension module.
647    pub fn register(&mut self, module: ModuleExports) {
648        let canonical = module.name.clone();
649        self.modules.insert(canonical, module);
650    }
651
652    /// Get a module by canonical name.
653    pub fn get(&self, name: &str) -> Option<&ModuleExports> {
654        self.modules.get(name)
655    }
656
657    /// Check if a module exists by canonical name.
658    pub fn has(&self, name: &str) -> bool {
659        self.get(name).is_some()
660    }
661
662    /// List all registered module names.
663    pub fn module_names(&self) -> Vec<&str> {
664        self.modules.keys().map(|s| s.as_str()).collect()
665    }
666
667    /// Get all registered modules.
668    pub fn modules(&self) -> &HashMap<String, ModuleExports> {
669        &self.modules
670    }
671}
672
673impl std::fmt::Debug for ModuleExportRegistry {
674    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
675        f.debug_struct("ModuleExportRegistry")
676            .field("modules", &self.modules.keys().collect::<Vec<_>>())
677            .finish()
678    }
679}
680
681#[cfg(test)]
682#[path = "module_exports_tests.rs"]
683mod tests;