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;
14
15use serde::{Deserialize, Serialize};
16
17use crate::chunk::{CachedChunk, CachedCompiledFunction};
18use crate::value::VmError;
19
20/// A single `import`-style declaration inside a module. Re-resolved at
21/// instantiation time so that the cached artifact does not bake in
22/// stale resolved paths.
23#[derive(Clone, Debug, Serialize, Deserialize)]
24pub struct ModuleImportSpec {
25    pub path: String,
26    pub selected_names: Option<Vec<String>>,
27    pub is_pub: bool,
28}
29
30/// Serializable compile artifact for one `.harn` module. The runtime
31/// turns this into a loaded module by replaying [`init_chunk`](Self::init_chunk)
32/// into a fresh env, minting closures for each entry in
33/// [`functions`](Self::functions), and re-issuing every nested
34/// [`imports`](Self::imports).
35#[derive(Clone, Debug, Serialize, Deserialize)]
36pub struct ModuleArtifact {
37    pub imports: Vec<ModuleImportSpec>,
38    pub init_chunk: Option<CachedChunk>,
39    pub functions: BTreeMap<String, CachedCompiledFunction>,
40    pub public_names: HashSet<String>,
41    /// Names of top-level `pub const` / `pub let` value bindings. Their values
42    /// are not known at compile time (they are produced by replaying
43    /// [`init_chunk`](Self::init_chunk)); the runtime reads each name out of
44    /// the instantiated module env and binds it into importers. Disjoint from
45    /// [`functions`](Self::functions) and [`public_type_names`](Self::public_type_names).
46    pub public_value_names: HashSet<String>,
47    /// Names of `pub type` aliases. Type aliases are erased at runtime — they
48    /// carry no value of their own — but importers may still name them in a
49    /// selective `import { T } from "..."` (for annotations and
50    /// schema-as-type use), so the loader must accept these names.
51    pub public_type_names: HashSet<String>,
52    /// JSON-Schema lowering (serialized as canonical JSON text) for each
53    /// `pub type` alias whose body can be expressed as a schema. The loader
54    /// binds the imported name to this dict so expression-position uses such
55    /// as `output_schema: ImportedAlias` see the same value a local alias
56    /// lowers to at compile time. Subset of [`public_type_names`](Self::public_type_names).
57    pub public_type_schemas: BTreeMap<String, String>,
58}
59
60/// Compile a parsed `.harn` module into the serializable artifact shape.
61/// Pure compilation — no I/O, no execution. Used by both the runtime
62/// import path (`crates/harn-vm/src/vm/modules.rs`) and the
63/// `harn precompile` CLI subcommand.
64pub fn compile_module_artifact(
65    program: &[harn_parser::SNode],
66    module_source_file: Option<String>,
67) -> Result<ModuleArtifact, VmError> {
68    let imports = program
69        .iter()
70        .filter_map(|node| match &node.node {
71            harn_parser::Node::ImportDecl { path, is_pub } => Some(ModuleImportSpec {
72                path: path.clone(),
73                selected_names: None,
74                is_pub: *is_pub,
75            }),
76            harn_parser::Node::SelectiveImport {
77                names,
78                path,
79                is_pub,
80            } => Some(ModuleImportSpec {
81                path: path.clone(),
82                selected_names: Some(names.clone()),
83                is_pub: *is_pub,
84            }),
85            _ => None,
86        })
87        .collect();
88
89    let init_nodes: Vec<harn_parser::SNode> = program
90        .iter()
91        .filter(|sn| {
92            let inner = match &sn.node {
93                harn_parser::Node::AttributedDecl { inner, .. } => inner.as_ref(),
94                _ => sn,
95            };
96            matches!(
97                &inner.node,
98                harn_parser::Node::LetBinding { .. } | harn_parser::Node::ConstBinding { .. }
99            )
100        })
101        .cloned()
102        .collect();
103    let init_chunk = if init_nodes.is_empty() {
104        None
105    } else {
106        let mut compiler = crate::Compiler::new();
107        compiler.collect_type_aliases(program);
108        Some(
109            compiler
110                .compile(&init_nodes)
111                .map_err(|e| VmError::Runtime(format!("Import init compile error: {e}")))?
112                .freeze_for_cache(),
113        )
114    };
115
116    let mut functions = BTreeMap::new();
117    let mut public_names = HashSet::new();
118    let mut public_value_names = HashSet::new();
119    let mut public_type_names = HashSet::new();
120    for node in program {
121        let inner = match &node.node {
122            harn_parser::Node::AttributedDecl { inner, .. } => inner.as_ref(),
123            _ => node,
124        };
125        if let harn_parser::Node::TypeDecl {
126            name, is_pub: true, ..
127        } = &inner.node
128        {
129            public_type_names.insert(name.clone());
130            continue;
131        }
132        // `pub const` / `pub let`: record the exported value-binding names. The
133        // value itself is produced when the init chunk is replayed at
134        // instantiation time; the runtime reads it out of the module env then.
135        if let harn_parser::Node::ConstBinding {
136            pattern,
137            is_pub: true,
138            ..
139        }
140        | harn_parser::Node::LetBinding {
141            pattern,
142            is_pub: true,
143            ..
144        } = &inner.node
145        {
146            collect_binding_identifier_names(pattern, &mut public_value_names);
147            continue;
148        }
149        let harn_parser::Node::FnDecl {
150            name,
151            type_params,
152            params,
153            body,
154            is_pub,
155            ..
156        } = &inner.node
157        else {
158            continue;
159        };
160
161        let mut compiler = crate::Compiler::new();
162        compiler.collect_type_aliases(program);
163        let func_chunk = compiler
164            .compile_fn_body(type_params, params, body, module_source_file.clone())
165            .map_err(|e| VmError::Runtime(format!("Import compile error: {e}")))?;
166        functions.insert(name.clone(), func_chunk.freeze_for_cache());
167        if *is_pub {
168            public_names.insert(name.clone());
169        }
170    }
171
172    let public_type_schemas = crate::Compiler::lower_public_type_schemas(program)
173        .into_iter()
174        .map(|(name, schema)| (name, crate::stdlib::json::vm_value_to_json(&schema)))
175        .collect();
176
177    Ok(ModuleArtifact {
178        imports,
179        init_chunk,
180        functions,
181        public_names,
182        public_value_names,
183        public_type_names,
184        public_type_schemas,
185    })
186}
187
188/// Collect every plain-identifier name bound by a binding pattern (recursing
189/// into list/dict/pair destructures). Used to enumerate the value names a
190/// `pub const` / `pub let` contributes to a module's public surface.
191fn collect_binding_identifier_names(
192    pattern: &harn_parser::BindingPattern,
193    out: &mut HashSet<String>,
194) {
195    use harn_parser::BindingPattern;
196    match pattern {
197        BindingPattern::Identifier(name) => {
198            out.insert(name.clone());
199        }
200        BindingPattern::Pair(first, second) => {
201            out.insert(first.clone());
202            out.insert(second.clone());
203        }
204        BindingPattern::List(elements) => {
205            for element in elements {
206                out.insert(element.name.clone());
207            }
208        }
209        BindingPattern::Dict(fields) => {
210            for field in fields {
211                out.insert(field.alias.clone().unwrap_or_else(|| field.key.clone()));
212            }
213        }
214    }
215}
216
217/// Lex + parse + [`compile_module_artifact`] in one call. Used when the
218/// caller already has the raw source bytes and wants the artifact in one
219/// step.
220pub fn compile_module_artifact_from_source(
221    source_path: &Path,
222    source: &str,
223) -> Result<ModuleArtifact, VmError> {
224    let mut lexer = harn_lexer::Lexer::new(source);
225    let tokens = lexer.tokenize().map_err(|e| {
226        VmError::Runtime(format!(
227            "Import lex error in {}: {e}",
228            source_path.display()
229        ))
230    })?;
231    let mut parser = harn_parser::Parser::new(tokens);
232    let program = parser.parse().map_err(|e| {
233        VmError::Runtime(format!(
234            "Import parse error in {}: {e}",
235            source_path.display()
236        ))
237    })?;
238    compile_module_artifact(&program, Some(source_path.display().to_string()))
239}
240
241#[cfg(test)]
242mod tests {
243    use std::path::Path;
244
245    use harn_lexer::Lexer;
246    use harn_parser::Parser;
247
248    use super::{compile_module_artifact, compile_module_artifact_from_source};
249    use crate::chunk::Constant;
250
251    #[test]
252    fn module_init_schema_of_uses_full_program_aliases() {
253        let source = r"
254pub type Item = {id: string}
255const ITEM_SCHEMA: Schema<Item> = schema_of(Item)
256";
257        let mut lexer = Lexer::new(source);
258        let tokens = lexer.tokenize().unwrap();
259        let mut parser = Parser::new(tokens);
260        let program = parser.parse().unwrap();
261        let artifact = compile_module_artifact(&program, None).unwrap();
262        let constants = &artifact.init_chunk.expect("init chunk").constants;
263        let strings = constants
264            .iter()
265            .filter_map(|constant| match constant {
266                Constant::String(value) => Some(value.as_str()),
267                _ => None,
268            })
269            .collect::<Vec<_>>();
270        assert!(strings.contains(&"id"), "{strings:?}");
271        assert!(!strings.contains(&"Item"), "{strings:?}");
272    }
273
274    #[test]
275    fn type_only_modules_export_schemas_without_init_bytecode() {
276        let source = r"
277pub type UserShape = {name: string, active?: bool}
278pub type UserList = list<UserShape>
279";
280
281        let artifact =
282            compile_module_artifact_from_source(Path::new("<test>/schemas.harn"), source)
283                .expect("module compiles");
284
285        assert!(
286            artifact.init_chunk.is_none(),
287            "erased type aliases must not inflate module init bytecode"
288        );
289        assert!(artifact.public_type_names.contains("UserShape"));
290        assert!(artifact.public_type_names.contains("UserList"));
291        assert!(artifact.public_type_schemas.contains_key("UserShape"));
292        assert!(artifact.public_type_schemas.contains_key("UserList"));
293    }
294}