Skip to main content

harn_modules/
lib.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::{Path, PathBuf};
3
4use harn_lexer::Span;
5use harn_parser::{BindingPattern, Node, Parser, SNode};
6use serde::Deserialize;
7
8mod stdlib;
9
10/// Kind of symbol that can be exported by a module.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum DefKind {
13    Function,
14    Pipeline,
15    Tool,
16    Skill,
17    Struct,
18    Enum,
19    Interface,
20    Type,
21    Variable,
22    Parameter,
23}
24
25/// A resolved definition site within a module.
26#[derive(Debug, Clone)]
27pub struct DefSite {
28    pub name: String,
29    pub file: PathBuf,
30    pub kind: DefKind,
31    pub span: Span,
32}
33
34/// Wildcard import resolution status for a single importing module.
35#[derive(Debug, Clone)]
36pub enum WildcardResolution {
37    /// Resolved all wildcard imports and can expose wildcard exports.
38    Resolved(HashSet<String>),
39    /// At least one wildcard import could not be resolved.
40    Unknown,
41}
42
43/// Parsed information for a set of module files.
44#[derive(Debug, Default)]
45pub struct ModuleGraph {
46    modules: HashMap<PathBuf, ModuleInfo>,
47}
48
49#[derive(Debug, Default)]
50struct ModuleInfo {
51    /// All declarations visible in this module (for local symbol lookup and
52    /// go-to-definition resolution).
53    declarations: HashMap<String, DefSite>,
54    /// Public exports exposed by wildcard imports.
55    exports: HashSet<String>,
56    /// Names introduced by selective imports across this module.
57    selective_import_names: HashSet<String>,
58    /// Import references encountered in this file.
59    imports: Vec<ImportRef>,
60    /// True when at least one wildcard import could not be resolved.
61    has_unresolved_wildcard_import: bool,
62    /// True when at least one selective import could not be resolved
63    /// (importing file path missing). Prevents `imported_names_for_file`
64    /// from returning a partial answer when any import is broken.
65    has_unresolved_selective_import: bool,
66    /// Every `fn` declaration at module scope, used to implement the
67    /// fallback "no `pub fn` → export everything" rule that matches the
68    /// runtime loader's behavior.
69    fn_names: Vec<String>,
70    /// True when at least one `pub fn` appeared at module scope.
71    has_pub_fn: bool,
72    /// Top-level type-like declarations that can be imported into a caller's
73    /// static type environment.
74    type_declarations: Vec<SNode>,
75}
76
77#[derive(Debug, Clone)]
78struct ImportRef {
79    path: Option<PathBuf>,
80    selective_names: Option<HashSet<String>>,
81}
82
83#[derive(Debug, Default, Deserialize)]
84struct PackageManifest {
85    #[serde(default)]
86    exports: HashMap<String, String>,
87}
88
89/// Build a module graph from a set of files.
90///
91/// Files referenced via `import` statements are loaded recursively so the
92/// graph contains every module reachable from the seed set. Cycles and
93/// already-loaded files are skipped via a visited set.
94pub fn build(files: &[PathBuf]) -> ModuleGraph {
95    let mut modules: HashMap<PathBuf, ModuleInfo> = HashMap::new();
96    let mut seen: HashSet<PathBuf> = HashSet::new();
97    let mut queue: VecDeque<PathBuf> = VecDeque::new();
98    for file in files {
99        let canonical = normalize_path(file);
100        if seen.insert(canonical.clone()) {
101            queue.push_back(canonical);
102        }
103    }
104    while let Some(path) = queue.pop_front() {
105        if modules.contains_key(&path) {
106            continue;
107        }
108        let module = load_module(&path);
109        // Enqueue resolved import targets so the whole reachable graph is
110        // discovered without the caller having to pre-walk imports.
111        //
112        // `resolve_import_path` returns paths as `base.join(import)` —
113        // i.e. with `..` segments preserved rather than collapsed. If we
114        // dedupe on those raw forms, two files that import each other
115        // across sibling dirs (`lib/context/` ↔ `lib/runtime/`) produce a
116        // different path spelling on every cycle — `.../context/../runtime/`,
117        // then `.../context/../runtime/../context/`, and so on — each of
118        // which is treated as a new file. The walk only terminates when
119        // `path.exists()` starts failing at the filesystem's `PATH_MAX`,
120        // which is 1024 on macOS but 4096 on Linux. Linux therefore
121        // re-parses the same handful of files thousands of times, balloons
122        // RSS into the multi-GB range, and gets SIGKILL'd by CI runners.
123        // Canonicalize once here so `seen` dedupes by the underlying file,
124        // not by its path spelling.
125        for import in &module.imports {
126            if let Some(import_path) = &import.path {
127                let canonical = normalize_path(import_path);
128                if seen.insert(canonical.clone()) {
129                    queue.push_back(canonical);
130                }
131            }
132        }
133        modules.insert(path, module);
134    }
135    ModuleGraph { modules }
136}
137
138/// Resolve an import string relative to the importing file.
139///
140/// Returns the path as-constructed (not canonicalized) so callers that
141/// compare against their own `PathBuf::join` result get matching values.
142/// The module graph canonicalizes internally via `normalize_path` when
143/// keying modules, so call-site canonicalization is not required for
144/// dedup.
145///
146/// `std/<module>` imports resolve to a virtual path (`<std>/<module>`)
147/// backed by the embedded stdlib sources in [`stdlib`]. This lets the
148/// module graph model stdlib symbols even though they have no on-disk
149/// location.
150pub fn resolve_import_path(current_file: &Path, import_path: &str) -> Option<PathBuf> {
151    if let Some(module) = import_path.strip_prefix("std/") {
152        if stdlib::get_stdlib_source(module).is_some() {
153            return Some(stdlib::stdlib_virtual_path(module));
154        }
155        return None;
156    }
157
158    let base = current_file.parent().unwrap_or(Path::new("."));
159    let mut file_path = base.join(import_path);
160    if !file_path.exists() && file_path.extension().is_none() {
161        file_path.set_extension("harn");
162    }
163    if file_path.exists() {
164        return Some(file_path);
165    }
166
167    if let Some(path) = resolve_package_import(base, import_path) {
168        return Some(path);
169    }
170
171    None
172}
173
174fn resolve_package_import(base: &Path, import_path: &str) -> Option<PathBuf> {
175    for anchor in base.ancestors() {
176        let packages_root = anchor.join(".harn/packages");
177        if !packages_root.is_dir() {
178            if anchor.join(".git").exists() {
179                break;
180            }
181            continue;
182        }
183        if let Some(path) = resolve_from_packages_root(&packages_root, import_path) {
184            return Some(path);
185        }
186        if anchor.join(".git").exists() {
187            break;
188        }
189    }
190    None
191}
192
193fn resolve_from_packages_root(packages_root: &Path, import_path: &str) -> Option<PathBuf> {
194    let pkg_path = packages_root.join(import_path);
195    if let Some(path) = finalize_package_target(&pkg_path) {
196        return Some(path);
197    }
198
199    let (package_name, export_name) = import_path.split_once('/')?;
200    let manifest_path = packages_root.join(package_name).join("harn.toml");
201    let manifest = read_package_manifest(&manifest_path)?;
202    let rel_path = manifest.exports.get(export_name)?;
203    finalize_package_target(&packages_root.join(package_name).join(rel_path))
204}
205
206fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
207    let content = std::fs::read_to_string(path).ok()?;
208    toml::from_str::<PackageManifest>(&content).ok()
209}
210
211fn finalize_package_target(path: &Path) -> Option<PathBuf> {
212    if path.is_dir() {
213        let lib = path.join("lib.harn");
214        if lib.exists() {
215            return Some(lib);
216        }
217        return Some(path.to_path_buf());
218    }
219    if path.exists() {
220        return Some(path.to_path_buf());
221    }
222    if path.extension().is_none() {
223        let mut with_ext = path.to_path_buf();
224        with_ext.set_extension("harn");
225        if with_ext.exists() {
226            return Some(with_ext);
227        }
228    }
229    None
230}
231
232impl ModuleGraph {
233    /// Collect every name used in selective imports from all files.
234    pub fn all_selective_import_names(&self) -> HashSet<&str> {
235        let mut names = HashSet::new();
236        for module in self.modules.values() {
237            for name in &module.selective_import_names {
238                names.insert(name.as_str());
239            }
240        }
241        names
242    }
243
244    /// Resolve wildcard imports for `file`.
245    ///
246    /// Returns `Unknown` when any wildcard import cannot be resolved, because
247    /// callers should conservatively disable wildcard-import-sensitive checks.
248    pub fn wildcard_exports_for(&self, file: &Path) -> WildcardResolution {
249        let file = normalize_path(file);
250        let Some(module) = self.modules.get(&file) else {
251            return WildcardResolution::Unknown;
252        };
253        if module.has_unresolved_wildcard_import {
254            return WildcardResolution::Unknown;
255        }
256
257        let mut names = HashSet::new();
258        for import in module
259            .imports
260            .iter()
261            .filter(|import| import.selective_names.is_none())
262        {
263            let Some(import_path) = &import.path else {
264                return WildcardResolution::Unknown;
265            };
266            let imported = self.modules.get(import_path).or_else(|| {
267                let normalized = normalize_path(import_path);
268                self.modules.get(&normalized)
269            });
270            let Some(imported) = imported else {
271                return WildcardResolution::Unknown;
272            };
273            names.extend(imported.exports.iter().cloned());
274        }
275        WildcardResolution::Resolved(names)
276    }
277
278    /// Collect every statically callable/referenceable name introduced into
279    /// `file` by its imports.
280    ///
281    /// Returns `Some` only when **every** import (wildcard or selective) in
282    /// `file` is fully resolvable via the graph. Returns `None` when any
283    /// import is unresolved, so callers can fall back to conservative
284    /// behavior instead of emitting spurious "undefined name" errors.
285    ///
286    /// The returned set contains:
287    /// - all public exports from wildcard-imported modules, and
288    /// - selectively imported names that exist as declarations in their
289    ///   target module (unresolvable selective names are still included,
290    ///   but only if the target file itself resolved — if the file failed
291    ///   to parse, the caller will see `None`).
292    pub fn imported_names_for_file(&self, file: &Path) -> Option<HashSet<String>> {
293        let file = normalize_path(file);
294        let module = self.modules.get(&file)?;
295        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
296            return None;
297        }
298
299        let mut names = HashSet::new();
300        for import in &module.imports {
301            let import_path = import.path.as_ref()?;
302            let imported = self
303                .modules
304                .get(import_path)
305                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
306            match &import.selective_names {
307                None => {
308                    names.extend(imported.exports.iter().cloned());
309                }
310                Some(selective) => {
311                    for name in selective {
312                        if imported.declarations.contains_key(name) {
313                            names.insert(name.clone());
314                        }
315                    }
316                }
317            }
318        }
319        Some(names)
320    }
321
322    /// Collect type / struct / enum / interface declarations made visible to
323    /// `file` by its imports. Returns `None` when any import is unresolved so
324    /// callers can fall back to conservative behavior.
325    pub fn imported_type_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
326        let file = normalize_path(file);
327        let module = self.modules.get(&file)?;
328        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
329            return None;
330        }
331
332        let mut decls = Vec::new();
333        for import in &module.imports {
334            let import_path = import.path.as_ref()?;
335            let imported = self
336                .modules
337                .get(import_path)
338                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
339            match &import.selective_names {
340                None => {
341                    for decl in &imported.type_declarations {
342                        if let Some(name) = type_decl_name(decl) {
343                            if imported.exports.contains(name) {
344                                decls.push(decl.clone());
345                            }
346                        }
347                    }
348                }
349                Some(selective) => {
350                    for decl in &imported.type_declarations {
351                        if let Some(name) = type_decl_name(decl) {
352                            if selective.contains(name) {
353                                decls.push(decl.clone());
354                            }
355                        }
356                    }
357                }
358            }
359        }
360        Some(decls)
361    }
362
363    /// Find the definition of `name` visible from `file`.
364    pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
365        let file = normalize_path(file);
366        let current = self.modules.get(&file)?;
367
368        if let Some(local) = current.declarations.get(name) {
369            return Some(local.clone());
370        }
371
372        for import in &current.imports {
373            if let Some(selective_names) = &import.selective_names {
374                if !selective_names.contains(name) {
375                    continue;
376                }
377            } else {
378                continue;
379            }
380
381            if let Some(path) = &import.path {
382                if let Some(symbol) = self
383                    .modules
384                    .get(path)
385                    .or_else(|| self.modules.get(&normalize_path(path)))
386                    .and_then(|module| module.declarations.get(name))
387                {
388                    return Some(symbol.clone());
389                }
390            }
391        }
392
393        for import in &current.imports {
394            if import.selective_names.is_some() {
395                continue;
396            }
397            if let Some(path) = &import.path {
398                if let Some(symbol) = self
399                    .modules
400                    .get(path)
401                    .or_else(|| self.modules.get(&normalize_path(path)))
402                    .and_then(|module| module.declarations.get(name))
403                {
404                    return Some(symbol.clone());
405                }
406            }
407        }
408
409        None
410    }
411}
412
413fn load_module(path: &Path) -> ModuleInfo {
414    // `<std>/<name>` virtual paths map to the embedded stdlib source
415    // rather than a real file on disk.
416    let source = if let Some(stdlib_module) = stdlib_module_from_path(path) {
417        match stdlib::get_stdlib_source(stdlib_module) {
418            Some(src) => src.to_string(),
419            None => return ModuleInfo::default(),
420        }
421    } else {
422        match std::fs::read_to_string(path) {
423            Ok(src) => src,
424            Err(_) => return ModuleInfo::default(),
425        }
426    };
427    let mut lexer = harn_lexer::Lexer::new(&source);
428    let tokens = match lexer.tokenize() {
429        Ok(tokens) => tokens,
430        Err(_) => return ModuleInfo::default(),
431    };
432    let mut parser = Parser::new(tokens);
433    let program = match parser.parse() {
434        Ok(program) => program,
435        Err(_) => return ModuleInfo::default(),
436    };
437
438    let mut module = ModuleInfo::default();
439    for node in &program {
440        collect_module_info(path, node, &mut module);
441        collect_type_declarations(node, &mut module.type_declarations);
442    }
443    // Fallback matching the VM loader: if the module declares no
444    // `pub fn`, every fn is implicitly exported.
445    if !module.has_pub_fn {
446        for name in &module.fn_names {
447            module.exports.insert(name.clone());
448        }
449    }
450    module
451}
452
453/// Extract the stdlib module name when `path` is a `<std>/<name>`
454/// virtual path, otherwise `None`.
455fn stdlib_module_from_path(path: &Path) -> Option<&str> {
456    let s = path.to_str()?;
457    s.strip_prefix("<std>/")
458}
459
460fn collect_module_info(file: &Path, snode: &SNode, module: &mut ModuleInfo) {
461    match &snode.node {
462        Node::FnDecl {
463            name,
464            params,
465            is_pub,
466            ..
467        } => {
468            if *is_pub {
469                module.exports.insert(name.clone());
470                module.has_pub_fn = true;
471            }
472            module.fn_names.push(name.clone());
473            module.declarations.insert(
474                name.clone(),
475                decl_site(file, snode.span, name, DefKind::Function),
476            );
477            for param_name in params.iter().map(|param| param.name.clone()) {
478                module.declarations.insert(
479                    param_name.clone(),
480                    decl_site(file, snode.span, &param_name, DefKind::Parameter),
481                );
482            }
483        }
484        Node::Pipeline { name, is_pub, .. } => {
485            if *is_pub {
486                module.exports.insert(name.clone());
487            }
488            module.declarations.insert(
489                name.clone(),
490                decl_site(file, snode.span, name, DefKind::Pipeline),
491            );
492        }
493        Node::ToolDecl { name, is_pub, .. } => {
494            if *is_pub {
495                module.exports.insert(name.clone());
496            }
497            module.declarations.insert(
498                name.clone(),
499                decl_site(file, snode.span, name, DefKind::Tool),
500            );
501        }
502        Node::SkillDecl { name, is_pub, .. } => {
503            if *is_pub {
504                module.exports.insert(name.clone());
505            }
506            module.declarations.insert(
507                name.clone(),
508                decl_site(file, snode.span, name, DefKind::Skill),
509            );
510        }
511        Node::StructDecl { name, is_pub, .. } => {
512            if *is_pub {
513                module.exports.insert(name.clone());
514            }
515            module.declarations.insert(
516                name.clone(),
517                decl_site(file, snode.span, name, DefKind::Struct),
518            );
519        }
520        Node::EnumDecl { name, is_pub, .. } => {
521            if *is_pub {
522                module.exports.insert(name.clone());
523            }
524            module.declarations.insert(
525                name.clone(),
526                decl_site(file, snode.span, name, DefKind::Enum),
527            );
528        }
529        Node::InterfaceDecl { name, .. } => {
530            module.exports.insert(name.clone());
531            module.declarations.insert(
532                name.clone(),
533                decl_site(file, snode.span, name, DefKind::Interface),
534            );
535        }
536        Node::TypeDecl { name, .. } => {
537            module.exports.insert(name.clone());
538            module.declarations.insert(
539                name.clone(),
540                decl_site(file, snode.span, name, DefKind::Type),
541            );
542        }
543        Node::LetBinding { pattern, .. } | Node::VarBinding { pattern, .. } => {
544            for name in pattern_names(pattern) {
545                module.declarations.insert(
546                    name.clone(),
547                    decl_site(file, snode.span, &name, DefKind::Variable),
548                );
549            }
550        }
551        Node::ImportDecl { path } => {
552            let import_path = resolve_import_path(file, path);
553            if import_path.is_none() {
554                module.has_unresolved_wildcard_import = true;
555            }
556            module.imports.push(ImportRef {
557                path: import_path,
558                selective_names: None,
559            });
560        }
561        Node::SelectiveImport { names, path } => {
562            let import_path = resolve_import_path(file, path);
563            if import_path.is_none() {
564                module.has_unresolved_selective_import = true;
565            }
566            let names: HashSet<String> = names.iter().cloned().collect();
567            module.selective_import_names.extend(names.iter().cloned());
568            module.imports.push(ImportRef {
569                path: import_path,
570                selective_names: Some(names),
571            });
572        }
573        Node::AttributedDecl { inner, .. } => {
574            collect_module_info(file, inner, module);
575        }
576        _ => {}
577    }
578}
579
580fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
581    match &snode.node {
582        Node::TypeDecl { .. }
583        | Node::StructDecl { .. }
584        | Node::EnumDecl { .. }
585        | Node::InterfaceDecl { .. } => decls.push(snode.clone()),
586        Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
587        _ => {}
588    }
589}
590
591fn type_decl_name(snode: &SNode) -> Option<&str> {
592    match &snode.node {
593        Node::TypeDecl { name, .. }
594        | Node::StructDecl { name, .. }
595        | Node::EnumDecl { name, .. }
596        | Node::InterfaceDecl { name, .. } => Some(name.as_str()),
597        _ => None,
598    }
599}
600
601fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
602    DefSite {
603        name: name.to_string(),
604        file: file.to_path_buf(),
605        kind,
606        span,
607    }
608}
609
610fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
611    match pattern {
612        BindingPattern::Identifier(name) => vec![name.clone()],
613        BindingPattern::Dict(fields) => fields
614            .iter()
615            .filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
616            .collect(),
617        BindingPattern::List(elements) => elements
618            .iter()
619            .map(|element| element.name.clone())
620            .collect(),
621        BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
622    }
623}
624
625fn normalize_path(path: &Path) -> PathBuf {
626    if stdlib_module_from_path(path).is_some() {
627        return path.to_path_buf();
628    }
629    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use std::fs;
636
637    fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
638        let path = dir.join(name);
639        fs::write(&path, contents).unwrap();
640        path
641    }
642
643    #[test]
644    fn recursive_build_loads_transitively_imported_modules() {
645        let tmp = tempfile::tempdir().unwrap();
646        let root = tmp.path();
647        write_file(root, "leaf.harn", "pub fn leaf_fn() { 1 }\n");
648        write_file(
649            root,
650            "mid.harn",
651            "import \"./leaf\"\npub fn mid_fn() { leaf_fn() }\n",
652        );
653        let entry = write_file(root, "entry.harn", "import \"./mid\"\nmid_fn()\n");
654
655        let graph = build(std::slice::from_ref(&entry));
656        let imported = graph
657            .imported_names_for_file(&entry)
658            .expect("entry imports should resolve");
659        // Wildcard import of mid exposes mid_fn (pub) but not leaf_fn.
660        assert!(imported.contains("mid_fn"));
661        assert!(!imported.contains("leaf_fn"));
662
663        // The transitively loaded module is known to the graph even though
664        // the seed only included entry.harn.
665        let leaf_path = root.join("leaf.harn");
666        assert!(graph.definition_of(&leaf_path, "leaf_fn").is_some());
667    }
668
669    #[test]
670    fn imported_names_returns_none_when_import_unresolved() {
671        let tmp = tempfile::tempdir().unwrap();
672        let root = tmp.path();
673        let entry = write_file(root, "entry.harn", "import \"./does_not_exist\"\n");
674
675        let graph = build(std::slice::from_ref(&entry));
676        assert!(graph.imported_names_for_file(&entry).is_none());
677    }
678
679    #[test]
680    fn selective_imports_contribute_only_requested_names() {
681        let tmp = tempfile::tempdir().unwrap();
682        let root = tmp.path();
683        write_file(root, "util.harn", "pub fn a() { 1 }\npub fn b() { 2 }\n");
684        let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
685
686        let graph = build(std::slice::from_ref(&entry));
687        let imported = graph
688            .imported_names_for_file(&entry)
689            .expect("entry imports should resolve");
690        assert!(imported.contains("a"));
691        assert!(!imported.contains("b"));
692    }
693
694    #[test]
695    fn stdlib_imports_resolve_to_embedded_sources() {
696        let tmp = tempfile::tempdir().unwrap();
697        let root = tmp.path();
698        let entry = write_file(root, "entry.harn", "import \"std/math\"\nclamp(5, 0, 10)\n");
699
700        let graph = build(std::slice::from_ref(&entry));
701        let imported = graph
702            .imported_names_for_file(&entry)
703            .expect("std/math should resolve");
704        // `clamp` is defined in stdlib_math.harn as `pub fn clamp(...)`.
705        assert!(imported.contains("clamp"));
706    }
707
708    #[test]
709    fn runtime_stdlib_import_surface_resolves_to_embedded_sources() {
710        let tmp = tempfile::tempdir().unwrap();
711        let entry = write_file(tmp.path(), "entry.harn", "");
712
713        for (module, _) in stdlib::STDLIB_SOURCES {
714            let import_path = format!("std/{module}");
715            assert!(
716                resolve_import_path(&entry, &import_path).is_some(),
717                "{import_path} should resolve in the module graph"
718            );
719        }
720    }
721
722    #[test]
723    fn stdlib_imports_expose_type_declarations() {
724        let tmp = tempfile::tempdir().unwrap();
725        let root = tmp.path();
726        let entry = write_file(
727            root,
728            "entry.harn",
729            "import \"std/triggers\"\nlet provider = \"github\"\n",
730        );
731
732        let graph = build(std::slice::from_ref(&entry));
733        let decls = graph
734            .imported_type_declarations_for_file(&entry)
735            .expect("std/triggers type declarations should resolve");
736        let names: HashSet<String> = decls
737            .iter()
738            .filter_map(type_decl_name)
739            .map(ToString::to_string)
740            .collect();
741        assert!(names.contains("TriggerEvent"));
742        assert!(names.contains("ProviderPayload"));
743        assert!(names.contains("SignatureStatus"));
744    }
745
746    #[test]
747    fn package_export_map_resolves_declared_module() {
748        let tmp = tempfile::tempdir().unwrap();
749        let root = tmp.path();
750        let packages = root.join(".harn/packages/acme/runtime");
751        fs::create_dir_all(&packages).unwrap();
752        fs::write(
753            root.join(".harn/packages/acme/harn.toml"),
754            "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
755        )
756        .unwrap();
757        fs::write(
758            packages.join("capabilities.harn"),
759            "pub fn exported_capability() { 1 }\n",
760        )
761        .unwrap();
762        let entry = write_file(
763            root,
764            "entry.harn",
765            "import \"acme/capabilities\"\nexported_capability()\n",
766        );
767
768        let graph = build(std::slice::from_ref(&entry));
769        let imported = graph
770            .imported_names_for_file(&entry)
771            .expect("package export should resolve");
772        assert!(imported.contains("exported_capability"));
773    }
774
775    #[test]
776    fn package_imports_resolve_from_nested_package_module() {
777        let tmp = tempfile::tempdir().unwrap();
778        let root = tmp.path();
779        fs::create_dir_all(root.join(".git")).unwrap();
780        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
781        fs::create_dir_all(root.join(".harn/packages/shared")).unwrap();
782        fs::write(
783            root.join(".harn/packages/shared/lib.harn"),
784            "pub fn shared_helper() { 1 }\n",
785        )
786        .unwrap();
787        fs::write(
788            root.join(".harn/packages/acme/lib.harn"),
789            "import \"shared\"\npub fn use_shared() { shared_helper() }\n",
790        )
791        .unwrap();
792        let entry = write_file(root, "entry.harn", "import \"acme\"\nuse_shared()\n");
793
794        let graph = build(std::slice::from_ref(&entry));
795        let imported = graph
796            .imported_names_for_file(&entry)
797            .expect("nested package import should resolve");
798        assert!(imported.contains("use_shared"));
799        let acme_path = root.join(".harn/packages/acme/lib.harn");
800        let acme_imports = graph
801            .imported_names_for_file(&acme_path)
802            .expect("package module imports should resolve");
803        assert!(acme_imports.contains("shared_helper"));
804    }
805
806    #[test]
807    fn unknown_stdlib_import_is_unresolved() {
808        let tmp = tempfile::tempdir().unwrap();
809        let root = tmp.path();
810        let entry = write_file(root, "entry.harn", "import \"std/does_not_exist\"\n");
811
812        let graph = build(std::slice::from_ref(&entry));
813        assert!(
814            graph.imported_names_for_file(&entry).is_none(),
815            "unknown std module should fail resolution and disable strict check"
816        );
817    }
818
819    #[test]
820    fn import_cycles_do_not_loop_forever() {
821        let tmp = tempfile::tempdir().unwrap();
822        let root = tmp.path();
823        write_file(root, "a.harn", "import \"./b\"\npub fn a_fn() { 1 }\n");
824        write_file(root, "b.harn", "import \"./a\"\npub fn b_fn() { 1 }\n");
825        let entry = root.join("a.harn");
826
827        // Just ensuring this terminates and yields sensible names.
828        let graph = build(std::slice::from_ref(&entry));
829        let imported = graph
830            .imported_names_for_file(&entry)
831            .expect("cyclic imports still resolve to known exports");
832        assert!(imported.contains("b_fn"));
833    }
834
835    #[test]
836    fn cross_directory_cycle_does_not_explode_module_count() {
837        // Regression: two files in sibling directories that import each
838        // other produced a fresh path spelling on every round-trip
839        // (`../runtime/../context/../runtime/...`), and `build()`'s
840        // `seen` set deduped on the raw spelling rather than the
841        // canonical path. The walk only terminated when `PATH_MAX` was
842        // hit — 1024 on macOS, 4096 on Linux — so Linux re-parsed the
843        // same pair thousands of times until it ran out of memory.
844        let tmp = tempfile::tempdir().unwrap();
845        let root = tmp.path();
846        let context = root.join("context");
847        let runtime = root.join("runtime");
848        fs::create_dir_all(&context).unwrap();
849        fs::create_dir_all(&runtime).unwrap();
850        write_file(
851            &context,
852            "a.harn",
853            "import \"../runtime/b\"\npub fn a_fn() { 1 }\n",
854        );
855        write_file(
856            &runtime,
857            "b.harn",
858            "import \"../context/a\"\npub fn b_fn() { 1 }\n",
859        );
860        let entry = context.join("a.harn");
861
862        let graph = build(std::slice::from_ref(&entry));
863        // The graph should contain exactly the two real files, keyed by
864        // their canonical paths. Pre-fix this was thousands of entries.
865        assert_eq!(
866            graph.modules.len(),
867            2,
868            "cross-directory cycle loaded {} modules, expected 2",
869            graph.modules.len()
870        );
871        let imported = graph
872            .imported_names_for_file(&entry)
873            .expect("cyclic imports still resolve to known exports");
874        assert!(imported.contains("b_fn"));
875    }
876}