Skip to main content

harn_vm/
module_artifact.rs

1//! Serializable shape of a compiled `.harn` module — the unit the
2//! on-disk module cache stores.
3//!
4//! A module is anything `import` can name: a stdlib file (`std/foo`) or
5//! a user file on disk. The artifact captures **only** the result of
6//! the parse + compile pipeline; instantiation (running the `init`
7//! chunk, creating closures bound to a fresh module env, and applying
8//! re-exports) happens fresh per process and is not cached. This split
9//! lets the cache short-circuit the expensive parse+compile while still
10//! producing the per-process state the runtime needs.
11
12use std::collections::{BTreeMap, HashSet};
13use std::path::{Path, PathBuf};
14use std::sync::{Mutex, OnceLock};
15
16use harn_modules::{public_declarations, DefKind};
17use serde::{Deserialize, Serialize};
18
19use crate::chunk::{CachedChunk, CachedCompiledFunction};
20use crate::value::VmError;
21
22type ImportedEnumCache = BTreeMap<PathBuf, ([u8; 32], Vec<String>)>;
23
24fn imported_enum_cache() -> &'static Mutex<ImportedEnumCache> {
25    static CACHE: OnceLock<Mutex<ImportedEnumCache>> = OnceLock::new();
26    CACHE.get_or_init(|| Mutex::new(BTreeMap::new()))
27}
28
29/// A single `import`-style declaration inside a module. Re-resolved at
30/// instantiation time so that the cached artifact does not bake in
31/// stale resolved paths.
32#[derive(Debug, Serialize, Deserialize)]
33pub struct ModuleImportSpec {
34    pub path: String,
35    pub selected_names: Option<Vec<String>>,
36    /// When set, bind `import * as alias from path` as a namespace dict
37    /// instead of flattening exports into the caller.
38    #[serde(default)]
39    pub namespace_alias: Option<String>,
40    pub is_pub: bool,
41}
42
43/// Serializable compile artifact for one `.harn` module. The runtime
44/// turns this into a loaded module by replaying [`init_chunk`](Self::init_chunk)
45/// into a fresh env, minting closures for each entry in
46/// [`functions`](Self::functions), and re-issuing every nested
47/// [`imports`](Self::imports).
48#[derive(Debug, Serialize, Deserialize)]
49pub struct ModuleArtifact {
50    pub imports: Vec<ModuleImportSpec>,
51    /// Cached bytecode that materializes exported type aliases after imports
52    /// are bound and before value initialization runs.
53    pub type_schema_init_chunk: Option<CachedChunk>,
54    pub init_chunk: Option<CachedChunk>,
55    pub functions: BTreeMap<String, CachedCompiledFunction>,
56    /// The public declaration contract shared with `harn-modules`. Each name
57    /// carries its source declaration kind so the loader can choose a closure,
58    /// initialized value, schema, or type-only projection without maintaining
59    /// a second AST export table.
60    pub public_exports: BTreeMap<String, DefKind>,
61    /// Public declarations whose runtime value is produced by replaying
62    /// [`init_chunk`](Self::init_chunk), rather than the precompiled function
63    /// table. This includes bindings, enums, tools, skills, and eval packs.
64    pub public_value_names: HashSet<String>,
65    /// Names of erased public type declarations (`type` and `interface`). They
66    /// carry no runtime value of their own, but importers may still name them
67    /// in selective imports. Public structs and enums are excluded because
68    /// they export runtime constructors/namespaces.
69    pub public_type_names: HashSet<String>,
70}
71
72impl ModuleArtifact {
73    /// Bind relocatable cached bytecode to the source path used by this load.
74    ///
75    /// Module artifacts may move beside their source (`harn precompile`) or
76    /// inside a package. Source paths are diagnostic/debug context, not a
77    /// compilation input, so deserialize once and stamp every nested chunk at
78    /// the load boundary instead of duplicating otherwise-identical artifacts.
79    pub(crate) fn bind_source_file(&mut self, source_path: &Path) {
80        let source_file = source_path.display().to_string();
81        if let Some(chunk) = &mut self.type_schema_init_chunk {
82            bind_chunk_source_file(chunk, &source_file);
83        }
84        if let Some(chunk) = &mut self.init_chunk {
85            bind_chunk_source_file(chunk, &source_file);
86        }
87        for function in self.functions.values_mut() {
88            bind_chunk_source_file(&mut function.chunk, &source_file);
89        }
90    }
91}
92
93fn bind_chunk_source_file(chunk: &mut CachedChunk, source_file: &str) {
94    chunk.source_file = Some(source_file.to_string());
95    for function in &mut chunk.functions {
96        bind_chunk_source_file(&mut function.chunk, source_file);
97    }
98}
99
100/// Compile a parsed `.harn` module into the serializable artifact shape.
101/// Pure compilation — no I/O, no execution. Used by both the runtime
102/// import path (`crates/harn-vm/src/vm/modules.rs`) and the
103/// `harn precompile` CLI subcommand.
104pub fn compile_module_artifact(
105    program: &[harn_parser::SNode],
106    module_source_file: Option<String>,
107) -> Result<ModuleArtifact, VmError> {
108    let imported_enum_candidates = module_source_file
109        .as_deref()
110        .filter(|_| needs_imported_enum_candidates(program))
111        .and_then(|path| {
112            harn_modules::build(&[Path::new(path).to_path_buf()])
113                .imported_names_by_kind_for_file(Path::new(path), DefKind::Enum)
114        })
115        .unwrap_or_default();
116    compile_module_artifact_with_imported_enums(
117        program,
118        module_source_file,
119        &imported_enum_candidates.into_iter().collect::<Vec<_>>(),
120    )
121}
122
123fn compile_module_artifact_with_imported_enums(
124    program: &[harn_parser::SNode],
125    module_source_file: Option<String>,
126    imported_enum_candidates: &[String],
127) -> Result<ModuleArtifact, VmError> {
128    let imports = program
129        .iter()
130        .filter_map(|node| match &node.node {
131            harn_parser::Node::ImportDecl { path, is_pub } => Some(ModuleImportSpec {
132                path: path.clone(),
133                selected_names: None,
134                namespace_alias: None,
135                is_pub: *is_pub,
136            }),
137            harn_parser::Node::SelectiveImport {
138                names,
139                path,
140                is_pub,
141            } => Some(ModuleImportSpec {
142                path: path.clone(),
143                selected_names: Some(names.clone()),
144                namespace_alias: None,
145                is_pub: *is_pub,
146            }),
147            harn_parser::Node::NamespaceImport {
148                alias,
149                path,
150                is_pub,
151            } => Some(ModuleImportSpec {
152                path: path.clone(),
153                selected_names: None,
154                namespace_alias: Some(alias.clone()),
155                is_pub: *is_pub,
156            }),
157            _ => None,
158        })
159        .collect();
160
161    let init_nodes: Vec<harn_parser::SNode> = program
162        .iter()
163        .filter(|sn| {
164            let inner = match &sn.node {
165                harn_parser::Node::AttributedDecl { inner, .. } => inner.as_ref(),
166                _ => sn,
167            };
168            matches!(
169                &inner.node,
170                harn_parser::Node::LetBinding { .. }
171                    | harn_parser::Node::ConstBinding { .. }
172                    // Only public enums need a runtime namespace in an
173                    // imported module. Private enum construction lowers
174                    // directly to `BuildEnum`, just like local construction,
175                    // so materializing a private namespace only adds cold
176                    // module-init work and closures that can never be
177                    // imported.
178                    | harn_parser::Node::EnumDecl { is_pub: true, .. }
179                    | harn_parser::Node::ToolDecl { .. }
180                    | harn_parser::Node::SkillDecl { .. }
181                    | harn_parser::Node::EvalPackDecl { .. }
182            )
183        })
184        .cloned()
185        .collect();
186    let init_chunk = if init_nodes.is_empty() {
187        None
188    } else {
189        let compiler = crate::Compiler::new();
190        Some(
191            compiler
192                .compile_module_init(program, &init_nodes, imported_enum_candidates)
193                .map_err(|e| VmError::Runtime(format!("Import init compile error: {e}")))?
194                .freeze_for_cache(),
195        )
196    };
197
198    let public_exports: BTreeMap<String, DefKind> = program
199        .iter()
200        .flat_map(public_declarations)
201        .map(|export| (export.name, export.kind))
202        .collect();
203    let public_value_names = public_exports
204        .iter()
205        .filter(|(_, kind)| {
206            matches!(
207                kind,
208                DefKind::Variable
209                    | DefKind::Enum
210                    | DefKind::Tool
211                    | DefKind::Skill
212                    | DefKind::EvalPack
213            )
214        })
215        .map(|(name, _)| name.clone())
216        .collect();
217    let public_type_names = public_exports
218        .iter()
219        .filter(|(_, kind)| !kind.has_runtime_value())
220        .map(|(name, _)| name.clone())
221        .collect();
222
223    let mut functions = BTreeMap::new();
224    for node in program {
225        let inner = match &node.node {
226            harn_parser::Node::AttributedDecl { inner, .. } => inner.as_ref(),
227            _ => node,
228        };
229        if let harn_parser::Node::StructDecl { name, fields, .. } = &inner.node {
230            // Struct constructors are ordinary module callables. Keeping
231            // them in the artifact function table avoids replaying the
232            // declaration through the module-init chunk while preserving
233            // private struct use and public constructor imports.
234            let constructor = crate::Compiler::new()
235                .compile_struct_constructor(name, fields)
236                .map_err(|error| VmError::Runtime(format!("Import compile error: {error}")))?;
237            functions.insert(name.clone(), constructor.freeze_for_cache());
238            continue;
239        }
240        if let harn_parser::Node::Pipeline {
241            name,
242            params,
243            body,
244            extends,
245            ..
246        } = &inner.node
247        {
248            let mut compiler = crate::Compiler::new();
249            compiler.add_imported_enum_candidates(imported_enum_candidates.iter().cloned());
250            let pipeline = compiler
251                .compile_pipeline_callable(program, name, params, body, extends.as_deref())
252                .map_err(|error| VmError::Runtime(format!("Import compile error: {error}")))?;
253            functions.insert(name.clone(), pipeline.freeze_for_cache());
254            continue;
255        }
256        let harn_parser::Node::FnDecl {
257            name,
258            type_params,
259            params,
260            body,
261            ..
262        } = &inner.node
263        else {
264            continue;
265        };
266
267        let mut compiler = crate::Compiler::new();
268        compiler.add_imported_enum_candidates(imported_enum_candidates.iter().cloned());
269        compiler.prepare_module_context(program);
270        let func_chunk = compiler
271            .compile_fn_body(type_params, params, body, module_source_file.clone())
272            .map_err(|e| VmError::Runtime(format!("Import compile error: {e}")))?;
273        functions.insert(name.clone(), func_chunk.freeze_for_cache());
274    }
275
276    let type_schema_init_chunk =
277        crate::Compiler::compile_public_type_schema_initializers(program, module_source_file)
278            .map_err(|error| VmError::Runtime(format!("Import schema compile error: {error}")))?
279            .map(|chunk| chunk.freeze_for_cache());
280
281    Ok(ModuleArtifact {
282        imports,
283        type_schema_init_chunk,
284        init_chunk,
285        functions,
286        public_exports,
287        public_value_names,
288        public_type_names,
289    })
290}
291
292/// Lex + parse + [`compile_module_artifact`] in one call. Used when the
293/// caller already has the raw source bytes and wants the artifact in one
294/// step.
295pub fn compile_module_artifact_from_source(
296    source_path: &Path,
297    source: &str,
298) -> Result<ModuleArtifact, VmError> {
299    let program = parse_module_source(source_path, source)?;
300    let imported_enum_candidates =
301        imported_enum_candidates_for_program(source_path, source, &program);
302    compile_module_artifact_with_imported_enums(
303        &program,
304        Some(source_path.display().to_string()),
305        &imported_enum_candidates,
306    )
307}
308
309/// Resolve imported enum names only for modules whose match patterns can use
310/// them. Ordinary property access is runtime lookup and does not need a graph
311/// walk; avoiding it keeps uncached module compilation independent of the
312/// size of unrelated import closures.
313fn imported_enum_candidates_for_program(
314    source_path: &Path,
315    source: &str,
316    program: &[harn_parser::SNode],
317) -> Vec<String> {
318    if !needs_imported_enum_candidates(program) {
319        return Vec::new();
320    }
321    let source_hash = *blake3::hash(source.as_bytes()).as_bytes();
322    let cache_key = harn_modules::canonical_path(source_path);
323    let cacheable = is_immutable_stdlib_path(source_path);
324    if cacheable {
325        if let Some((_cached_hash, candidates)) = imported_enum_cache()
326            .lock()
327            .expect("imported enum cache lock poisoned")
328            .get(&cache_key)
329            .filter(|(cached_hash, _)| *cached_hash == source_hash)
330        {
331            return candidates.clone();
332        }
333    }
334
335    // A graph walk is needed to resolve wildcard and re-exported enums, but
336    // the result describes every module in that closure. Publish all those
337    // projections at once so loading a large stdlib does not rebuild the same
338    // reachable graph once per module artifact.
339    let graph = harn_modules::build_with_source(source_path, source);
340    if !cacheable {
341        return sorted_imported_enum_candidates(&graph, source_path);
342    }
343    let mut projections = Vec::new();
344    for path in graph.module_paths() {
345        let module_source = if path == cache_key {
346            Some(source.to_string())
347        } else {
348            harn_modules::read_module_source(&path).or_else(|| std::fs::read_to_string(&path).ok())
349        };
350        let Some(module_source) = module_source else {
351            continue;
352        };
353        let candidates = sorted_imported_enum_candidates(&graph, &path);
354        projections.push((
355            path,
356            (
357                *blake3::hash(module_source.as_bytes()).as_bytes(),
358                candidates,
359            ),
360        ));
361    }
362    let mut cache = imported_enum_cache()
363        .lock()
364        .expect("imported enum cache lock poisoned");
365    for (path, projection) in projections {
366        if is_immutable_stdlib_path(&path) {
367            cache.insert(path, projection);
368        }
369    }
370    cache
371        .get(&cache_key)
372        .filter(|(cached_hash, _)| *cached_hash == source_hash)
373        .map(|(_, candidates)| candidates.clone())
374        .unwrap_or_default()
375}
376
377fn sorted_imported_enum_candidates(
378    graph: &harn_modules::ModuleGraph,
379    source_path: &Path,
380) -> Vec<String> {
381    let mut candidates = graph
382        .imported_names_by_kind_for_file(source_path, DefKind::Enum)
383        .unwrap_or_default()
384        .into_iter()
385        .collect::<Vec<_>>();
386    candidates.sort_unstable();
387    candidates
388}
389
390fn is_immutable_stdlib_path(path: &Path) -> bool {
391    path.to_str()
392        .is_some_and(|path| path.starts_with("<stdlib>/") || path.starts_with("<std>/"))
393}
394
395fn needs_imported_enum_candidates(program: &[harn_parser::SNode]) -> bool {
396    harn_parser::visit::contains_identifier_enum_pattern(program)
397}
398
399fn parse_module_source(
400    source_path: &Path,
401    source: &str,
402) -> Result<Vec<harn_parser::SNode>, VmError> {
403    let mut lexer = harn_lexer::Lexer::new(source);
404    let tokens = lexer.tokenize().map_err(|e| {
405        VmError::Runtime(format!(
406            "Import lex error in {}: {e}",
407            source_path.display()
408        ))
409    })?;
410    let mut parser = harn_parser::Parser::new(tokens);
411    parser.parse().map_err(|e| {
412        VmError::Runtime(format!(
413            "Import parse error in {}: {e}",
414            source_path.display()
415        ))
416    })
417}
418
419/// Parse and compile a source-backed module when the caller already has the
420/// module graph's typed enum-import projection. This keeps precompile/pack
421/// from rebuilding the graph separately for the entry chunk and module
422/// artifact.
423pub fn compile_module_artifact_from_source_with_imported_enums(
424    source_path: &Path,
425    source: &str,
426    imported_enum_candidates: impl IntoIterator<Item = String>,
427) -> Result<ModuleArtifact, VmError> {
428    let program = parse_module_source(source_path, source)?;
429    let imported_enum_candidates = imported_enum_candidates.into_iter().collect::<Vec<_>>();
430    compile_module_artifact_with_imported_enums(
431        &program,
432        Some(source_path.display().to_string()),
433        &imported_enum_candidates,
434    )
435}
436
437#[cfg(test)]
438mod tests {
439    use std::path::Path;
440
441    use harn_lexer::Lexer;
442    use harn_parser::Parser;
443
444    use super::{
445        compile_module_artifact, compile_module_artifact_from_source,
446        needs_imported_enum_candidates, parse_module_source,
447    };
448    use crate::chunk::Constant;
449
450    #[test]
451    fn module_init_schema_of_uses_full_program_aliases() {
452        let source = r"
453pub type Item = {id: string}
454const ITEM_SCHEMA: Schema<Item> = schema_of(Item)
455";
456        let mut lexer = Lexer::new(source);
457        let tokens = lexer.tokenize().unwrap();
458        let mut parser = Parser::new(tokens);
459        let program = parser.parse().unwrap();
460        let artifact = compile_module_artifact(&program, None).unwrap();
461        let constants = &artifact.init_chunk.expect("init chunk").constants;
462        let strings = constants
463            .iter()
464            .filter_map(|constant| match constant {
465                Constant::String(value) => Some(value.as_str()),
466                _ => None,
467            })
468            .collect::<Vec<_>>();
469        assert!(strings.contains(&"id"), "{strings:?}");
470        assert!(!strings.contains(&"Item"), "{strings:?}");
471    }
472
473    #[test]
474    fn type_only_modules_use_a_separate_schema_initializer() {
475        let source = r"
476pub type UserShape = {name: string, active?: bool}
477pub type UserList = list<UserShape>
478";
479
480        let artifact =
481            compile_module_artifact_from_source(Path::new("<test>/schemas.harn"), source)
482                .expect("module compiles");
483
484        assert!(
485            artifact.init_chunk.is_none(),
486            "erased type aliases must not inflate module init bytecode"
487        );
488        assert!(artifact.public_type_names.contains("UserShape"));
489        assert!(artifact.public_type_names.contains("UserList"));
490        assert!(artifact.type_schema_init_chunk.is_some());
491    }
492
493    #[test]
494    fn schema_initializer_keeps_imported_alias_lookup_and_source() {
495        let source = r#"
496import { External } from "./external"
497pub type Wrapped = {value: External}
498"#;
499        let source_path = Path::new("<test>/wrapped.harn");
500        let artifact =
501            compile_module_artifact_from_source(source_path, source).expect("module compiles");
502        let chunk = artifact.type_schema_init_chunk.expect("schema initializer");
503        assert_eq!(chunk.source_file.as_deref(), Some("<test>/wrapped.harn"));
504        assert!(chunk
505            .constants
506            .iter()
507            .any(|constant| matches!(constant, Constant::String(value) if value == "External")));
508    }
509
510    #[test]
511    fn imported_enum_graph_lookup_is_lazy_for_plain_modules() {
512        let plain = parse_module_source(
513            Path::new("<test>/plain.harn"),
514            r#"
515import { helper } from "./support"
516pub fn run() -> int { return helper(1) }
517"#,
518        )
519        .expect("plain module parses");
520        assert!(!needs_imported_enum_candidates(&plain));
521
522        let qualified = parse_module_source(
523            Path::new("<test>/qualified.harn"),
524            r#"
525import { Status } from "./status"
526pub fn run(value: Status) {
527  match value {
528    Status.Ready -> { return 1 }
529    _ -> { return 0 }
530  }
531}
532"#,
533        )
534        .expect("qualified module parses");
535        assert!(needs_imported_enum_candidates(&qualified));
536    }
537
538    #[test]
539    fn private_declarations_do_not_expand_module_init() {
540        let artifact = compile_module_artifact_from_source(
541            Path::new("<test>/private-declarations.harn"),
542            r"
543enum PrivateStatus { Ready }
544struct PrivateConfig { value: int }
545pub fn run() { return PrivateStatus.Ready }
546",
547        )
548        .expect("private declarations compile");
549
550        assert!(artifact.init_chunk.is_none());
551        assert!(artifact.functions.contains_key("PrivateConfig"));
552        assert!(!artifact.public_exports.contains_key("PrivateStatus"));
553        assert!(!artifact.public_exports.contains_key("PrivateConfig"));
554    }
555}