Skip to main content

harn_vm/vm/
modules.rs

1use std::collections::{BTreeMap, HashSet};
2use std::future::Future;
3use std::hash::{Hash, Hasher};
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6use std::sync::{Arc, Mutex, OnceLock};
7
8use crate::bytecode_cache;
9use crate::chunk::{Chunk, CompiledFunction};
10use crate::module_artifact::{compile_module_artifact_from_source, ModuleArtifact};
11use crate::value::{ModuleFunctionRegistry, VmClosure, VmEnv, VmError, VmValue};
12
13use super::{ScopeSpan, Vm};
14
15static STDLIB_MODULE_ARTIFACT_CACHE: OnceLock<Mutex<BTreeMap<String, Arc<ModuleArtifact>>>> =
16    OnceLock::new();
17
18fn stdlib_module_artifact_cache() -> &'static Mutex<BTreeMap<String, Arc<ModuleArtifact>>> {
19    STDLIB_MODULE_ARTIFACT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new()))
20}
21
22#[cfg(test)]
23fn reset_stdlib_module_artifact_cache() {
24    stdlib_module_artifact_cache().lock().unwrap().clear();
25}
26
27#[cfg(test)]
28fn stdlib_module_artifact_cache_ptr(module: &str, source: &str) -> Option<usize> {
29    let key = stdlib_artifact_cache_key(module, source);
30    stdlib_module_artifact_cache()
31        .lock()
32        .unwrap()
33        .get(&key)
34        .map(|artifact| Arc::as_ptr(artifact) as usize)
35}
36
37#[derive(Clone)]
38pub(crate) struct LoadedModule {
39    pub(crate) functions: BTreeMap<String, Arc<VmClosure>>,
40    pub(crate) public_names: HashSet<String>,
41    /// Evaluated values of exported `pub const` / `pub let` bindings, read out
42    /// of the instantiated module env after the init chunk ran. Importers bind
43    /// these by value (like every other cross-module value). Disjoint from
44    /// `functions` and `public_type_names`.
45    pub(crate) public_values: BTreeMap<String, VmValue>,
46    /// Names of `pub type` aliases (and re-exported ones). Erased at runtime:
47    /// selective imports may name them, but they bind no value of their own.
48    pub(crate) public_type_names: HashSet<String>,
49    /// Decoded JSON-Schema dict for each `pub type` alias that lowers to a
50    /// schema. Importers bind the alias name to this value so
51    /// expression-position uses (`output_schema: ImportedAlias`) work.
52    pub(crate) public_type_schemas: BTreeMap<String, VmValue>,
53    pub(crate) _module_functions: crate::value::ModuleFunctionRegistry,
54    pub(crate) _module_state: crate::value::ModuleState,
55}
56
57/// An import whose target module was still mid-load (an import cycle) when the
58/// importing module reached it. The target's function closures don't exist yet
59/// at that point, so the binding can't happen inline. We record it here and
60/// resolve it once both modules are fully loaded — see
61/// [`Vm::flush_deferred_cyclic_imports`].
62#[derive(Clone, Debug)]
63pub(crate) struct DeferredCyclicImport {
64    /// Canonical path of the module that issued the import.
65    pub(crate) importer: PathBuf,
66    /// Canonical path of the cyclically-imported target module.
67    pub(crate) target: PathBuf,
68    /// Selectively-imported names, or `None` for a wildcard/side-effect import.
69    pub(crate) selected_names: Option<Vec<String>>,
70}
71
72pub fn resolve_module_import_path(base: &Path, path: &str) -> PathBuf {
73    let synthetic_current_file = base.join("__harn_import_base__.harn");
74    if let Some(resolved) = harn_modules::resolve_import_path(&synthetic_current_file, path) {
75        return resolved;
76    }
77
78    let mut file_path = base.join(path);
79
80    if !file_path.exists() && file_path.extension().is_none() {
81        file_path.set_extension("harn");
82    }
83
84    file_path
85}
86
87fn stdlib_artifact_cache_key(module: &str, source: &str) -> String {
88    let mut hasher = std::collections::hash_map::DefaultHasher::new();
89    module.hash(&mut hasher);
90    source.hash(&mut hasher);
91    format!("{module}:{:016x}", hasher.finish())
92}
93
94fn stdlib_module_artifact(
95    module: &str,
96    synthetic: &Path,
97    source: &'static str,
98) -> Result<Arc<ModuleArtifact>, VmError> {
99    let key = stdlib_artifact_cache_key(module, source);
100    {
101        let cache = stdlib_module_artifact_cache().lock().unwrap();
102        if let Some(cached) = cache.get(&key) {
103            return Ok(Arc::clone(cached));
104        }
105    }
106
107    // Stdlib modules are embedded in the binary so their content cannot
108    // legitimately change between processes; that means the disk cache
109    // for stdlib can use a synthetic source_path. The harn_version field
110    // of the cache key gates correctness across releases.
111    let lookup = bytecode_cache::load_module(synthetic, source);
112    let artifact = if let Some(artifact) = lookup.artifact {
113        artifact
114    } else {
115        let compiled = compile_module_artifact_from_source(synthetic, source)?;
116        if let Err(err) = bytecode_cache::store_module(&lookup.key, &compiled) {
117            if std::env::var_os("HARN_BYTECODE_CACHE_DEBUG").is_some() {
118                eprintln!("[harn] stdlib module cache write skipped for {module}: {err}");
119            }
120        }
121        compiled
122    };
123
124    let compiled = Arc::new(artifact);
125    let mut cache = stdlib_module_artifact_cache().lock().unwrap();
126    if let Some(cached) = cache.get(&key) {
127        return Ok(Arc::clone(cached));
128    }
129    cache.insert(key, Arc::clone(&compiled));
130    Ok(compiled)
131}
132
133impl Vm {
134    async fn load_module_from_source(
135        &mut self,
136        synthetic: PathBuf,
137        source: &str,
138    ) -> Result<LoadedModule, VmError> {
139        if let Some(loaded) = self.module_cache.get(&synthetic).cloned() {
140            return Ok(loaded);
141        }
142        Arc::make_mut(&mut self.source_cache).insert(synthetic.clone(), source.to_string());
143
144        let artifact = compile_module_artifact_from_source(&synthetic, source)?;
145
146        self.imported_paths.push(synthetic.clone());
147        let loaded = self.instantiate_module(None, &artifact).await?;
148        self.imported_paths.pop();
149        Arc::make_mut(&mut self.module_cache).insert(synthetic, loaded.clone());
150        Ok(loaded)
151    }
152
153    async fn load_stdlib_module_from_source(
154        &mut self,
155        module: &str,
156        synthetic: PathBuf,
157        source: &'static str,
158    ) -> Result<LoadedModule, VmError> {
159        if let Some(loaded) = self.module_cache.get(&synthetic).cloned() {
160            return Ok(loaded);
161        }
162        Arc::make_mut(&mut self.source_cache).insert(synthetic.clone(), source.to_string());
163
164        let artifact = stdlib_module_artifact(module, &synthetic, source)?;
165        self.imported_paths.push(synthetic.clone());
166        let loaded = self.instantiate_stdlib_module(artifact.as_ref()).await?;
167        self.imported_paths.pop();
168        Arc::make_mut(&mut self.module_cache).insert(synthetic, loaded.clone());
169        Ok(loaded)
170    }
171
172    async fn instantiate_stdlib_module(
173        &mut self,
174        artifact: &ModuleArtifact,
175    ) -> Result<LoadedModule, VmError> {
176        self.instantiate_module(None, artifact).await
177    }
178
179    /// Instantiate a previously-compiled [`ModuleArtifact`] into a
180    /// [`LoadedModule`]. Re-runs nested imports, replays the init chunk
181    /// into a fresh module env, mints a [`VmClosure`] for each compiled
182    /// function (stamped with `module_source_dir` so imports from inside
183    /// those functions resolve against the originating file), and
184    /// applies the re-export pass. Used by both stdlib and user-import
185    /// code paths.
186    async fn instantiate_module(
187        &mut self,
188        module_source_dir: Option<PathBuf>,
189        artifact: &ModuleArtifact,
190    ) -> Result<LoadedModule, VmError> {
191        let caller_env = self.env.clone();
192        let old_source_dir = self.source_dir.clone();
193        self.env = VmEnv::new();
194        self.source_dir = module_source_dir.clone();
195
196        for import in &artifact.imports {
197            self.execute_import(&import.path, import.selected_names.as_deref())
198                .await?;
199        }
200
201        let module_state: crate::value::ModuleState = {
202            let mut init_env = self.env.clone();
203            if let Some(init_chunk) = &artifact.init_chunk {
204                let fresh_init_chunk = Chunk::from_cached(init_chunk);
205                let saved_env = std::mem::replace(&mut self.env, init_env);
206                let saved_frames = std::mem::take(&mut self.frames);
207                let saved_handlers = std::mem::take(&mut self.exception_handlers);
208                let saved_iterators = std::mem::take(&mut self.iterators);
209                let saved_deadlines = std::mem::take(&mut self.deadlines);
210                // STEP_STACK / PERSONA_STACK are thread-locals shared with
211                // the calling frame. Emptying `self.frames` above means
212                // any `prune_below_frame(0)` triggered while the init
213                // chunk's bytecode runs — including the inevitable
214                // frame-pop prune at end-of-chunk — would wipe active
215                // steps owned by the *caller* (e.g., a `@step`-decorated
216                // function whose body lazily imports a module). Snapshot
217                // the persona/step context here and restore it after init
218                // so module loading is invisible to the step-tracking
219                // surface.
220                let active_context = crate::step_runtime::take_active_context();
221                let init_result = self.run_chunk(std::sync::Arc::new(fresh_init_chunk)).await;
222                crate::step_runtime::restore_active_context(active_context);
223                init_env = std::mem::replace(&mut self.env, saved_env);
224                self.frames = saved_frames;
225                self.exception_handlers = saved_handlers;
226                self.iterators = saved_iterators;
227                self.deadlines = saved_deadlines;
228                init_result?;
229            }
230            Arc::new(crate::value::VmMutex::new(init_env))
231        };
232
233        let module_env = self.env.clone();
234        let registry: ModuleFunctionRegistry =
235            Arc::new(crate::value::VmMutex::new(BTreeMap::new()));
236        let mut functions: BTreeMap<String, Arc<VmClosure>> = BTreeMap::new();
237        let mut public_names = artifact.public_names.clone();
238        // `pub const` / `pub let`: the init chunk already ran into `module_state`,
239        // so the bound values are live there. Read each exported value name out
240        // and publish it so importers can bind it. Add the names to
241        // `public_names` too, so the selective/wildcard export machinery treats
242        // them as part of the public surface (validation, re-export lists).
243        let mut public_values: BTreeMap<String, VmValue> = BTreeMap::new();
244        {
245            let state = module_state.lock();
246            for name in &artifact.public_value_names {
247                if let Some(value) = state.get(name) {
248                    public_values.insert(name.clone(), value);
249                    public_names.insert(name.clone());
250                }
251            }
252        }
253        let mut public_type_names = artifact.public_type_names.clone();
254        let mut public_type_schemas: BTreeMap<String, VmValue> = artifact
255            .public_type_schemas
256            .iter()
257            .filter_map(|(name, json)| {
258                let parsed = serde_json::from_str::<serde_json::Value>(json).ok()?;
259                Some((name.clone(), crate::schema::json_to_vm_value(&parsed)))
260            })
261            .collect();
262
263        for (name, compiled) in &artifact.functions {
264            let closure = Arc::new(VmClosure {
265                func: Arc::new(CompiledFunction::from_cached(compiled)),
266                env: module_env.clone(),
267                source_dir: module_source_dir.clone(),
268                module_functions: Some(Arc::downgrade(&registry)),
269                module_state: Some(Arc::downgrade(&module_state)),
270                retained_module_scope: None,
271            });
272            registry.lock().insert(name.clone(), Arc::clone(&closure));
273            self.env
274                .define(name, VmValue::Closure(Arc::clone(&closure)), false)?;
275            module_state
276                .lock()
277                .define(name, VmValue::Closure(Arc::clone(&closure)), false)?;
278            functions.insert(name.clone(), Arc::clone(&closure));
279        }
280
281        for import in artifact.imports.iter().filter(|import| import.is_pub) {
282            let cache_key = self.cache_key_for_import(&import.path);
283            let Some(loaded) = self.module_cache.get(&cache_key).cloned() else {
284                // A plain `import`/`import {...}` across a cycle is bound late
285                // by `flush_deferred_cyclic_imports`, but a `pub import`
286                // re-export has to publish the names into *this* module's
287                // public surface right now — and the target is still mid-load,
288                // so its surface does not exist yet. Name the cycle explicitly
289                // instead of the misleading "was not loaded".
290                if self.imported_paths.contains(&cache_key) {
291                    return Err(VmError::Runtime(format!(
292                        "Re-export error: cannot `pub import` from '{}' because it forms an \
293                         import cycle with this module (its public surface is still being \
294                         built). Use a plain `import` here, or re-export from a module that is \
295                         not part of the cycle.",
296                        import.path
297                    )));
298                }
299                return Err(VmError::Runtime(format!(
300                    "Re-export error: imported module '{}' was not loaded",
301                    import.path
302                )));
303            };
304            let names_to_reexport: Vec<String> = match &import.selected_names {
305                Some(names) => names.clone(),
306                // A wildcard `pub import` re-exports exactly the target's `pub`
307                // surface (functions and erased `pub type` aliases). A module
308                // with no `pub` declarations exports nothing.
309                None => loaded
310                    .public_names
311                    .iter()
312                    .chain(loaded.public_type_names.iter())
313                    .cloned()
314                    .collect(),
315            };
316            for name in names_to_reexport {
317                let Some(closure) = loaded.functions.get(&name) else {
318                    // `pub const` / `pub let` values carry no closure: re-export
319                    // the value directly.
320                    if let Some(value) = loaded.public_values.get(&name) {
321                        public_values.insert(name.clone(), value.clone());
322                        public_names.insert(name);
323                        continue;
324                    }
325                    // `pub type` aliases are erased at runtime: re-export the
326                    // name (and its schema lowering, when present) for
327                    // importers, with no closure to bind.
328                    if loaded.public_type_names.contains(&name) {
329                        if let Some(schema) = loaded.public_type_schemas.get(&name) {
330                            public_type_schemas.insert(name.clone(), schema.clone());
331                        }
332                        public_type_names.insert(name);
333                        continue;
334                    }
335                    return Err(VmError::Runtime(format!(
336                        "Re-export error: '{name}' is not exported by '{}'",
337                        import.path
338                    )));
339                };
340                if let Some(existing) = functions.get(&name) {
341                    if !Arc::ptr_eq(existing, closure) {
342                        return Err(VmError::Runtime(format!(
343                            "Re-export collision: '{name}' is defined here and also \
344                             re-exported from '{}'",
345                            import.path
346                        )));
347                    }
348                }
349                functions.insert(name.clone(), Arc::clone(closure));
350                public_names.insert(name);
351            }
352        }
353
354        self.env = caller_env;
355        self.source_dir = old_source_dir;
356
357        Ok(LoadedModule {
358            functions,
359            public_names,
360            public_values,
361            public_type_names,
362            public_type_schemas,
363            _module_functions: registry,
364            _module_state: module_state,
365        })
366    }
367
368    fn export_loaded_module(
369        &mut self,
370        module_path: &Path,
371        loaded: &LoadedModule,
372        selected_names: Option<&[String]>,
373    ) -> Result<(), VmError> {
374        let module_name = module_path.display().to_string();
375        let export_names: Vec<String> = if let Some(names) = selected_names {
376            // Selective imports may only name symbols the module marks `pub`.
377            // A module with no `pub` functions exports nothing — matching every
378            // strict-visibility language (TypeScript, Rust, Go) and removing
379            // the old footgun where adding the first `pub` silently turned every
380            // other (previously importable) function private to callers.
381            for name in names {
382                if !loaded.public_names.contains(name) && !loaded.public_type_names.contains(name) {
383                    let hint = if loaded.functions.contains_key(name) {
384                        " — it is defined there but not `pub`; mark it `pub` to export it"
385                    } else {
386                        ""
387                    };
388                    return Err(VmError::Runtime(format!(
389                        "Import error: '{name}' is not exported by {module_name}{hint}"
390                    )));
391                }
392            }
393            names.to_vec()
394        } else {
395            // Wildcard import brings in exactly the module's `pub` surface,
396            // including erased `pub type` aliases.
397            loaded
398                .public_names
399                .iter()
400                .chain(loaded.public_type_names.iter())
401                .cloned()
402                .collect()
403        };
404
405        for name in export_names {
406            // `pub type` aliases are erased at runtime: the import is valid
407            // (the type checker consumed it). When the alias lowers to a JSON
408            // schema, bind the name to that dict so expression-position uses
409            // (`output_schema: ImportedAlias`, `schema_is(x, ImportedAlias)`)
410            // behave like a locally declared alias; otherwise bind nothing.
411            if loaded.public_type_names.contains(&name) && !loaded.functions.contains_key(&name) {
412                if let Some(schema) = loaded.public_type_schemas.get(&name) {
413                    self.env.define(&name, schema.clone(), false)?;
414                }
415                continue;
416            }
417            // `pub const` / `pub let` values: bind by value.
418            if let Some(value) = loaded.public_values.get(&name) {
419                if self.env.get(&name).is_some() {
420                    return Err(VmError::Runtime(format!(
421                        "Import collision: '{name}' is already defined when importing \
422                         {module_name}. Use selective imports to disambiguate: \
423                         import {{ {name} }} from \"...\""
424                    )));
425                }
426                self.env.define(&name, value.clone(), false)?;
427                continue;
428            }
429            let Some(closure) = loaded.functions.get(&name) else {
430                return Err(VmError::Runtime(format!(
431                    "Import error: '{name}' is not defined in {module_name}"
432                )));
433            };
434            if let Some(VmValue::Closure(_)) = self.env.get(&name) {
435                return Err(VmError::Runtime(format!(
436                    "Import collision: '{name}' is already defined when importing {module_name}. \
437                     Use selective imports to disambiguate: import {{ {name} }} from \"...\""
438                )));
439            }
440            self.env
441                .define(&name, VmValue::Closure(Arc::clone(closure)), false)?;
442        }
443        Ok(())
444    }
445
446    /// Execute an import, reading and running the file's declarations.
447    pub(super) fn execute_import<'a>(
448        &'a mut self,
449        path: &'a str,
450        selected_names: Option<&'a [String]>,
451    ) -> Pin<Box<dyn Future<Output = Result<(), VmError>> + Send + 'a>> {
452        Box::pin(async move {
453            let _import_span = ScopeSpan::new(crate::tracing::SpanKind::Import, path.to_string());
454
455            let stdlib_module = path
456                .strip_prefix("std/")
457                .or_else(|| (path == "observability").then_some("observability"));
458            if let Some(module) = stdlib_module {
459                if let Some(source) = crate::stdlib_modules::get_stdlib_source(module) {
460                    let synthetic = PathBuf::from(format!("<stdlib>/{module}.harn"));
461                    if self.imported_paths.contains(&synthetic) {
462                        return Ok(());
463                    }
464                    let loaded = self
465                        .load_stdlib_module_from_source(module, synthetic.clone(), source)
466                        .await?;
467                    self.export_loaded_module(&synthetic, &loaded, selected_names)?;
468                    return Ok(());
469                }
470                return Err(VmError::Runtime(format!(
471                    "Unknown stdlib module: std/{module}"
472                )));
473            }
474
475            let base = self
476                .source_dir
477                .clone()
478                .unwrap_or_else(|| PathBuf::from("."));
479            let file_path = resolve_module_import_path(&base, path);
480
481            let canonical = file_path
482                .canonicalize()
483                .unwrap_or_else(|_| file_path.clone());
484            if self.imported_paths.contains(&canonical) {
485                // Import cycle: `canonical` is still mid-load (it sits on the
486                // import stack), so its function closures don't exist yet and
487                // we cannot bind the requested names inline. Record the import
488                // and resolve it once both modules finish loading — otherwise
489                // whichever module happens to close the cycle silently loses
490                // these bindings and fails with `Undefined builtin` at call
491                // time, in a load-order-dependent way.
492                if let Some(importer) = self.imported_paths.last().cloned() {
493                    if importer != canonical {
494                        self.deferred_cyclic_imports.push(DeferredCyclicImport {
495                            importer,
496                            target: canonical.clone(),
497                            selected_names: selected_names.map(<[String]>::to_vec),
498                        });
499                    }
500                }
501                return Ok(());
502            }
503            if let Some(loaded) = self.module_cache.get(&canonical).cloned() {
504                return self.export_loaded_module(&canonical, &loaded, selected_names);
505            }
506            self.imported_paths.push(canonical.clone());
507
508            let source = std::fs::read_to_string(&file_path).map_err(|e| {
509                // Name the resolution base: relative imports resolve against the
510                // importing file's dir (or CWD when unset), so an error that
511                // prints only the joined path leaves the author guessing which
512                // base was used.
513                VmError::Runtime(format!(
514                    "Import error: cannot read '{}' (resolved '{path}' relative to {}): {e}",
515                    file_path.display(),
516                    base.display()
517                ))
518            })?;
519            Arc::make_mut(&mut self.source_cache).insert(canonical.clone(), source.clone());
520            Arc::make_mut(&mut self.source_cache).insert(file_path.clone(), source.clone());
521
522            // Disk cache first: hits skip parse + compile for the imported
523            // module's whole function pool, not just the entry pipeline.
524            let lookup = bytecode_cache::load_module(&file_path, &source);
525            let artifact = if let Some(artifact) = lookup.artifact {
526                artifact
527            } else {
528                let compiled = compile_module_artifact_from_source(&file_path, &source)?;
529                if let Err(err) = bytecode_cache::store_module(&lookup.key, &compiled) {
530                    if std::env::var_os("HARN_BYTECODE_CACHE_DEBUG").is_some() {
531                        eprintln!(
532                            "[harn] module cache write skipped for {}: {err}",
533                            file_path.display()
534                        );
535                    }
536                }
537                compiled
538            };
539
540            let module_source_dir = file_path.parent().map(|p| p.to_path_buf());
541            let loaded = self
542                .instantiate_module(module_source_dir, &artifact)
543                .await?;
544            self.imported_paths.pop();
545            Arc::make_mut(&mut self.module_cache).insert(canonical.clone(), loaded.clone());
546            self.export_loaded_module(&canonical, &loaded, selected_names)?;
547
548            // Once the import stack fully unwinds, every module reachable from
549            // this top-level import is cached, so any deferred cyclic imports
550            // can now bind against fully-loaded modules.
551            if self.imported_paths.is_empty() {
552                self.flush_deferred_cyclic_imports()?;
553            }
554
555            Ok(())
556        })
557    }
558
559    /// Bind imports that were deferred because their target module was still
560    /// mid-load (an import cycle). By the time the import stack has unwound,
561    /// both the importing and target modules are fully instantiated and cached,
562    /// so we can resolve the requested names against the target and define them
563    /// into the importer's shared, mutable `module_state`. That env is the one
564    /// every closure from the importing module consults (after its local env)
565    /// at call time, so the late binding becomes visible without needing to
566    /// rewrite the closures' captured lexical snapshots.
567    fn flush_deferred_cyclic_imports(&mut self) -> Result<(), VmError> {
568        if self.deferred_cyclic_imports.is_empty() {
569            return Ok(());
570        }
571        let deferred = std::mem::take(&mut self.deferred_cyclic_imports);
572        let mut still_pending = Vec::new();
573        for import in deferred {
574            let (Some(importer), Some(target)) = (
575                self.module_cache.get(&import.importer).cloned(),
576                self.module_cache.get(&import.target).cloned(),
577            ) else {
578                // One endpoint is not cached yet (a lazy import inside a
579                // function body can defer before the other side loads). Keep
580                // it for a later flush.
581                still_pending.push(import);
582                continue;
583            };
584
585            let export_names: Vec<String> = match &import.selected_names {
586                Some(names) => names.clone(),
587                None if !target.public_names.is_empty() => {
588                    target.public_names.iter().cloned().collect()
589                }
590                None => target.functions.keys().cloned().collect(),
591            };
592
593            let mut module_state = importer._module_state.lock();
594            for name in export_names {
595                // A real local declaration (or an already-bound non-cyclic
596                // import) wins over the cyclic re-binding.
597                if module_state.get(&name).is_some() {
598                    continue;
599                }
600                if let Some(closure) = target.functions.get(&name) {
601                    module_state.define(&name, VmValue::Closure(Arc::clone(closure)), false)?;
602                } else if let Some(value) = target.public_values.get(&name) {
603                    // `pub const` / `pub let` imported across a cycle.
604                    module_state.define(&name, value.clone(), false)?;
605                } else {
606                    return Err(VmError::Runtime(format!(
607                        "Import error: '{name}' is not defined in {}",
608                        import.target.display()
609                    )));
610                }
611            }
612        }
613        self.deferred_cyclic_imports = still_pending;
614        Ok(())
615    }
616
617    /// Return the path key that `execute_import` would use to cache the
618    /// LoadedModule for this import string. Used by the re-export pass to
619    /// look up the already-loaded source module after `execute_import`
620    /// has populated [`Vm::module_cache`].
621    fn cache_key_for_import(&self, path: &str) -> PathBuf {
622        if let Some(module) = path
623            .strip_prefix("std/")
624            .or_else(|| (path == "observability").then_some("observability"))
625        {
626            return PathBuf::from(format!("<stdlib>/{module}.harn"));
627        }
628        let base = self
629            .source_dir
630            .clone()
631            .unwrap_or_else(|| PathBuf::from("."));
632        let file_path = resolve_module_import_path(&base, path);
633        file_path.canonicalize().unwrap_or(file_path)
634    }
635
636    /// Load a module file and return the exported function closures that
637    /// would be visible to a wildcard import.
638    pub async fn load_module_exports(
639        &mut self,
640        path: &Path,
641    ) -> Result<BTreeMap<String, Arc<VmClosure>>, VmError> {
642        let path_str = path.to_string_lossy().into_owned();
643        self.execute_import(&path_str, None).await?;
644
645        let mut file_path = if path.is_absolute() {
646            path.to_path_buf()
647        } else {
648            self.source_dir
649                .clone()
650                .unwrap_or_else(|| PathBuf::from("."))
651                .join(path)
652        };
653        if !file_path.exists() && file_path.extension().is_none() {
654            file_path.set_extension("harn");
655        }
656
657        let canonical = file_path
658            .canonicalize()
659            .unwrap_or_else(|_| file_path.clone());
660        let loaded = self.module_cache.get(&canonical).cloned().ok_or_else(|| {
661            VmError::Runtime(format!(
662                "Import error: failed to cache loaded module '{}'",
663                canonical.display()
664            ))
665        })?;
666
667        let export_names: Vec<String> = if loaded.public_names.is_empty() {
668            loaded.functions.keys().cloned().collect()
669        } else {
670            loaded.public_names.iter().cloned().collect()
671        };
672
673        let mut exports = BTreeMap::new();
674        for name in export_names {
675            let Some(closure) = loaded.functions.get(&name) else {
676                return Err(VmError::Runtime(format!(
677                    "Import error: exported function '{name}' is missing from {}",
678                    canonical.display()
679                )));
680            };
681            exports.insert(name, Arc::clone(closure));
682        }
683
684        Ok(exports)
685    }
686
687    /// Load synthetic source keyed by a synthetic module path and return
688    /// the exported function closures that a wildcard import would expose.
689    pub async fn load_module_exports_from_source(
690        &mut self,
691        source_key: impl Into<PathBuf>,
692        source: &str,
693    ) -> Result<BTreeMap<String, Arc<VmClosure>>, VmError> {
694        let synthetic = source_key.into();
695        let loaded = self
696            .load_module_from_source(synthetic.clone(), source)
697            .await?;
698        let export_names: Vec<String> = if loaded.public_names.is_empty() {
699            loaded.functions.keys().cloned().collect()
700        } else {
701            loaded.public_names.iter().cloned().collect()
702        };
703
704        let mut exports = BTreeMap::new();
705        for name in export_names {
706            let Some(closure) = loaded.functions.get(&name) else {
707                return Err(VmError::Runtime(format!(
708                    "Import error: exported function '{name}' is missing from {}",
709                    synthetic.display()
710                )));
711            };
712            exports.insert(name, Arc::clone(closure));
713        }
714
715        Ok(exports)
716    }
717
718    /// Load a module by import path (`std/foo`, relative module path, or
719    /// package import) and return the exported function closures that a
720    /// wildcard import would expose.
721    pub async fn load_module_exports_from_import(
722        &mut self,
723        import_path: &str,
724    ) -> Result<BTreeMap<String, Arc<VmClosure>>, VmError> {
725        self.execute_import(import_path, None).await?;
726
727        if let Some(module) = import_path
728            .strip_prefix("std/")
729            .or_else(|| (import_path == "observability").then_some("observability"))
730        {
731            let synthetic = PathBuf::from(format!("<stdlib>/{module}.harn"));
732            let loaded = self.module_cache.get(&synthetic).cloned().ok_or_else(|| {
733                VmError::Runtime(format!(
734                    "Import error: failed to cache loaded module '{}'",
735                    synthetic.display()
736                ))
737            })?;
738            let mut exports = BTreeMap::new();
739            let export_names: Vec<String> = if loaded.public_names.is_empty() {
740                loaded.functions.keys().cloned().collect()
741            } else {
742                loaded.public_names.iter().cloned().collect()
743            };
744            for name in export_names {
745                let Some(closure) = loaded.functions.get(&name) else {
746                    return Err(VmError::Runtime(format!(
747                        "Import error: exported function '{name}' is missing from {}",
748                        synthetic.display()
749                    )));
750                };
751                exports.insert(name, Arc::clone(closure));
752            }
753            return Ok(exports);
754        }
755
756        let base = self
757            .source_dir
758            .clone()
759            .unwrap_or_else(|| PathBuf::from("."));
760        let file_path = resolve_module_import_path(&base, import_path);
761        self.load_module_exports(&file_path).await
762    }
763}
764
765#[cfg(test)]
766mod tests {
767
768    use std::sync::{Mutex, MutexGuard, OnceLock};
769
770    use super::*;
771
772    static CACHE_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
773
774    fn cache_test_guard() -> MutexGuard<'static, ()> {
775        CACHE_TEST_LOCK
776            .get_or_init(|| Mutex::new(()))
777            .lock()
778            .unwrap()
779    }
780
781    fn cached_stdlib_module_ptr(module: &str) -> Option<usize> {
782        let source = harn_stdlib::get_stdlib_source(module).expect("stdlib module source exists");
783        stdlib_module_artifact_cache_ptr(module, source)
784    }
785
786    #[test]
787    fn module_function_can_use_local_type_alias_as_schema_value() {
788        let runtime = tokio::runtime::Builder::new_current_thread()
789            .enable_all()
790            .build()
791            .expect("runtime builds");
792
793        let result = runtime.block_on(async {
794            let mut vm = Vm::new();
795            crate::stdlib::register_vm_stdlib(&mut vm);
796            let loaded = vm
797                .load_module_from_source(
798                    PathBuf::from("<test>/schema_alias_module.harn"),
799                    r#"
800fn accepts_schema(schema) {
801  return schema_report({name: "Ada"}, schema).ok
802}
803
804type UserShape = {name: string}
805
806pub fn works() {
807  return accepts_schema(UserShape)
808}
809"#,
810                )
811                .await
812                .expect("module loads");
813            let closure = Arc::clone(loaded.functions.get("works").expect("works export exists"));
814            vm.call_closure_pub(&closure, &[])
815                .await
816                .expect("module closure executes")
817        });
818
819        assert!(matches!(result, VmValue::Bool(true)), "{result:?}");
820    }
821
822    #[test]
823    fn stdlib_artifact_cache_reuses_compilation_with_fresh_vm_state() {
824        let _guard = cache_test_guard();
825        reset_stdlib_module_artifact_cache();
826        let runtime = tokio::runtime::Builder::new_current_thread()
827            .enable_all()
828            .build()
829            .expect("runtime builds");
830
831        let (first_exports, second_exports, first_state_weak, second_state_weak) = runtime
832            .block_on(async {
833                let mut first_vm = Vm::new();
834                let first_exports = first_vm
835                    .load_module_exports_from_import("std/agent/prompts")
836                    .await
837                    .expect("first stdlib import succeeds");
838                let first_state = first_exports
839                    .get("render_agent_prompt")
840                    .expect("first export exists")
841                    .module_state()
842                    .expect("first module state stays live while VM owns module");
843                let first_state_weak = Arc::downgrade(&first_state);
844                let first_state_ptr = Arc::as_ptr(&first_state);
845
846                let mut second_vm = Vm::new();
847                let second_exports = second_vm
848                    .load_module_exports_from_import("std/agent/prompts")
849                    .await
850                    .expect("second stdlib import succeeds");
851                let second_state = second_exports
852                    .get("render_agent_prompt")
853                    .expect("second export exists")
854                    .module_state()
855                    .expect("second module state stays live while VM owns module");
856                let second_state_weak = Arc::downgrade(&second_state);
857
858                assert_ne!(first_state_ptr, Arc::as_ptr(&second_state));
859                (
860                    first_exports,
861                    second_exports,
862                    first_state_weak,
863                    second_state_weak,
864                )
865            });
866        let first_cached =
867            cached_stdlib_module_ptr("agent/prompts").expect("first import cached stdlib artifact");
868        assert_eq!(
869            cached_stdlib_module_ptr("agent/prompts"),
870            Some(first_cached)
871        );
872
873        let first = first_exports
874            .get("render_agent_prompt")
875            .expect("first export exists");
876        let second = second_exports
877            .get("render_agent_prompt")
878            .expect("second export exists");
879
880        assert!(!Arc::ptr_eq(first, second));
881        assert!(!Arc::ptr_eq(&first.func, &second.func));
882        assert!(!Arc::ptr_eq(&first.func.chunk, &second.func.chunk));
883        assert!(first.module_state().is_none());
884        assert!(second.module_state().is_none());
885        assert!(first_state_weak.upgrade().is_none());
886        assert!(second_state_weak.upgrade().is_none());
887    }
888
889    #[test]
890    fn stdlib_artifact_cache_is_process_wide_across_threads() {
891        let _guard = cache_test_guard();
892        reset_stdlib_module_artifact_cache();
893
894        let handle = std::thread::spawn(|| {
895            let runtime = tokio::runtime::Builder::new_current_thread()
896                .enable_all()
897                .build()
898                .expect("runtime builds");
899            runtime.block_on(async {
900                let mut vm = Vm::new();
901                vm.load_module_exports_from_import("std/agent/prompts")
902                    .await
903                    .expect("thread stdlib import succeeds");
904            });
905        });
906        handle.join().expect("thread joins");
907        let thread_cached = cached_stdlib_module_ptr("agent/prompts")
908            .expect("thread import cached stdlib artifact");
909
910        let runtime = tokio::runtime::Builder::new_current_thread()
911            .enable_all()
912            .build()
913            .expect("runtime builds");
914        runtime.block_on(async {
915            let mut vm = Vm::new();
916            vm.load_module_exports_from_import("std/agent/prompts")
917                .await
918                .expect("main-thread stdlib import succeeds");
919        });
920        assert_eq!(
921            cached_stdlib_module_ptr("agent/prompts"),
922            Some(thread_cached)
923        );
924    }
925
926    #[test]
927    fn module_closures_release_state_after_vm_drop() {
928        let runtime = tokio::runtime::Builder::new_current_thread()
929            .enable_all()
930            .build()
931            .expect("runtime builds");
932
933        let (closure_weak, registry_weak, state_weak) = runtime.block_on(async {
934            let mut vm = Vm::new();
935            let loaded = vm
936                .load_module_from_source(
937                    PathBuf::from("<test>/module_cycle.harn"),
938                    r#"
939let payload = "x" * 1024
940
941pub fn touch() {
942  return len(payload)
943}
944"#,
945                )
946                .await
947                .expect("module loads");
948            let closure = Arc::clone(loaded.functions.get("touch").expect("touch export exists"));
949            let closure_weak = Arc::downgrade(&closure);
950            let registry_weak = Arc::downgrade(&loaded._module_functions);
951            let state_weak = Arc::downgrade(&loaded._module_state);
952
953            drop(closure);
954            drop(loaded);
955            drop(vm);
956
957            (closure_weak, registry_weak, state_weak)
958        });
959
960        assert!(
961            closure_weak.upgrade().is_none(),
962            "module closure should drop with its VM"
963        );
964        assert!(
965            registry_weak.upgrade().is_none(),
966            "module function registry should drop with its VM"
967        );
968        assert!(
969            state_weak.upgrade().is_none(),
970            "module state should drop with its VM"
971        );
972    }
973}