Skip to main content

harn_modules/
lib.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::{Component, 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 safe_import_path = safe_package_relative_path(import_path)?;
195    let package_name = package_name_from_relative_path(&safe_import_path)?;
196    let package_root = packages_root.join(package_name);
197
198    let pkg_path = packages_root.join(&safe_import_path);
199    if let Some(path) = finalize_package_target(&package_root, &pkg_path) {
200        return Some(path);
201    }
202
203    let export_name = export_name_from_relative_path(&safe_import_path)?;
204    let manifest_path = packages_root.join(package_name).join("harn.toml");
205    let manifest = read_package_manifest(&manifest_path)?;
206    let rel_path = manifest.exports.get(export_name)?;
207    let safe_export_path = safe_package_relative_path(rel_path)?;
208    finalize_package_target(&package_root, &package_root.join(safe_export_path))
209}
210
211fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
212    let content = std::fs::read_to_string(path).ok()?;
213    toml::from_str::<PackageManifest>(&content).ok()
214}
215
216fn safe_package_relative_path(raw: &str) -> Option<PathBuf> {
217    if raw.is_empty() || raw.contains('\\') {
218        return None;
219    }
220    let mut out = PathBuf::new();
221    let mut saw_component = false;
222    for component in Path::new(raw).components() {
223        match component {
224            Component::Normal(part) => {
225                saw_component = true;
226                out.push(part);
227            }
228            Component::CurDir => {}
229            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
230        }
231    }
232    saw_component.then_some(out)
233}
234
235fn package_name_from_relative_path(path: &Path) -> Option<&str> {
236    match path.components().next()? {
237        Component::Normal(name) => name.to_str(),
238        _ => None,
239    }
240}
241
242fn export_name_from_relative_path(path: &Path) -> Option<&str> {
243    let mut components = path.components();
244    components.next()?;
245    let rest = components.as_path();
246    if rest.as_os_str().is_empty() {
247        None
248    } else {
249        rest.to_str()
250    }
251}
252
253fn path_is_within(root: &Path, path: &Path) -> bool {
254    let Ok(root) = root.canonicalize() else {
255        return false;
256    };
257    let Ok(path) = path.canonicalize() else {
258        return false;
259    };
260    path == root || path.starts_with(root)
261}
262
263fn target_within_package_root(package_root: &Path, path: PathBuf) -> Option<PathBuf> {
264    path_is_within(package_root, &path).then_some(path)
265}
266
267fn finalize_package_target(package_root: &Path, path: &Path) -> Option<PathBuf> {
268    if path.is_dir() {
269        let lib = path.join("lib.harn");
270        if lib.exists() {
271            return target_within_package_root(package_root, lib);
272        }
273        return target_within_package_root(package_root, path.to_path_buf());
274    }
275    if path.exists() {
276        return target_within_package_root(package_root, path.to_path_buf());
277    }
278    if path.extension().is_none() {
279        let mut with_ext = path.to_path_buf();
280        with_ext.set_extension("harn");
281        if with_ext.exists() {
282            return target_within_package_root(package_root, with_ext);
283        }
284    }
285    None
286}
287
288impl ModuleGraph {
289    /// Collect every name used in selective imports from all files.
290    pub fn all_selective_import_names(&self) -> HashSet<&str> {
291        let mut names = HashSet::new();
292        for module in self.modules.values() {
293            for name in &module.selective_import_names {
294                names.insert(name.as_str());
295            }
296        }
297        names
298    }
299
300    /// Resolve wildcard imports for `file`.
301    ///
302    /// Returns `Unknown` when any wildcard import cannot be resolved, because
303    /// callers should conservatively disable wildcard-import-sensitive checks.
304    pub fn wildcard_exports_for(&self, file: &Path) -> WildcardResolution {
305        let file = normalize_path(file);
306        let Some(module) = self.modules.get(&file) else {
307            return WildcardResolution::Unknown;
308        };
309        if module.has_unresolved_wildcard_import {
310            return WildcardResolution::Unknown;
311        }
312
313        let mut names = HashSet::new();
314        for import in module
315            .imports
316            .iter()
317            .filter(|import| import.selective_names.is_none())
318        {
319            let Some(import_path) = &import.path else {
320                return WildcardResolution::Unknown;
321            };
322            let imported = self.modules.get(import_path).or_else(|| {
323                let normalized = normalize_path(import_path);
324                self.modules.get(&normalized)
325            });
326            let Some(imported) = imported else {
327                return WildcardResolution::Unknown;
328            };
329            names.extend(imported.exports.iter().cloned());
330        }
331        WildcardResolution::Resolved(names)
332    }
333
334    /// Collect every statically callable/referenceable name introduced into
335    /// `file` by its imports.
336    ///
337    /// Returns `Some` only when **every** import (wildcard or selective) in
338    /// `file` is fully resolvable via the graph. Returns `None` when any
339    /// import is unresolved, so callers can fall back to conservative
340    /// behavior instead of emitting spurious "undefined name" errors.
341    ///
342    /// The returned set contains:
343    /// - all public exports from wildcard-imported modules, and
344    /// - selectively imported names that exist as declarations in their
345    ///   target module (unresolvable selective names are still included,
346    ///   but only if the target file itself resolved — if the file failed
347    ///   to parse, the caller will see `None`).
348    pub fn imported_names_for_file(&self, file: &Path) -> Option<HashSet<String>> {
349        let file = normalize_path(file);
350        let module = self.modules.get(&file)?;
351        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
352            return None;
353        }
354
355        let mut names = HashSet::new();
356        for import in &module.imports {
357            let import_path = import.path.as_ref()?;
358            let imported = self
359                .modules
360                .get(import_path)
361                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
362            match &import.selective_names {
363                None => {
364                    names.extend(imported.exports.iter().cloned());
365                }
366                Some(selective) => {
367                    for name in selective {
368                        if imported.declarations.contains_key(name) {
369                            names.insert(name.clone());
370                        }
371                    }
372                }
373            }
374        }
375        Some(names)
376    }
377
378    /// Collect type / struct / enum / interface declarations made visible to
379    /// `file` by its imports. Returns `None` when any import is unresolved so
380    /// callers can fall back to conservative behavior.
381    pub fn imported_type_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
382        let file = normalize_path(file);
383        let module = self.modules.get(&file)?;
384        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
385            return None;
386        }
387
388        let mut decls = Vec::new();
389        for import in &module.imports {
390            let import_path = import.path.as_ref()?;
391            let imported = self
392                .modules
393                .get(import_path)
394                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
395            match &import.selective_names {
396                None => {
397                    for decl in &imported.type_declarations {
398                        if let Some(name) = type_decl_name(decl) {
399                            if imported.exports.contains(name) {
400                                decls.push(decl.clone());
401                            }
402                        }
403                    }
404                }
405                Some(selective) => {
406                    for decl in &imported.type_declarations {
407                        if let Some(name) = type_decl_name(decl) {
408                            if selective.contains(name) {
409                                decls.push(decl.clone());
410                            }
411                        }
412                    }
413                }
414            }
415        }
416        Some(decls)
417    }
418
419    /// Find the definition of `name` visible from `file`.
420    pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
421        let file = normalize_path(file);
422        let current = self.modules.get(&file)?;
423
424        if let Some(local) = current.declarations.get(name) {
425            return Some(local.clone());
426        }
427
428        for import in &current.imports {
429            if let Some(selective_names) = &import.selective_names {
430                if !selective_names.contains(name) {
431                    continue;
432                }
433            } else {
434                continue;
435            }
436
437            if let Some(path) = &import.path {
438                if let Some(symbol) = self
439                    .modules
440                    .get(path)
441                    .or_else(|| self.modules.get(&normalize_path(path)))
442                    .and_then(|module| module.declarations.get(name))
443                {
444                    return Some(symbol.clone());
445                }
446            }
447        }
448
449        for import in &current.imports {
450            if import.selective_names.is_some() {
451                continue;
452            }
453            if let Some(path) = &import.path {
454                if let Some(symbol) = self
455                    .modules
456                    .get(path)
457                    .or_else(|| self.modules.get(&normalize_path(path)))
458                    .and_then(|module| module.declarations.get(name))
459                {
460                    return Some(symbol.clone());
461                }
462            }
463        }
464
465        None
466    }
467}
468
469fn load_module(path: &Path) -> ModuleInfo {
470    // `<std>/<name>` virtual paths map to the embedded stdlib source
471    // rather than a real file on disk.
472    let source = if let Some(stdlib_module) = stdlib_module_from_path(path) {
473        match stdlib::get_stdlib_source(stdlib_module) {
474            Some(src) => src.to_string(),
475            None => return ModuleInfo::default(),
476        }
477    } else {
478        match std::fs::read_to_string(path) {
479            Ok(src) => src,
480            Err(_) => return ModuleInfo::default(),
481        }
482    };
483    let mut lexer = harn_lexer::Lexer::new(&source);
484    let tokens = match lexer.tokenize() {
485        Ok(tokens) => tokens,
486        Err(_) => return ModuleInfo::default(),
487    };
488    let mut parser = Parser::new(tokens);
489    let program = match parser.parse() {
490        Ok(program) => program,
491        Err(_) => return ModuleInfo::default(),
492    };
493
494    let mut module = ModuleInfo::default();
495    for node in &program {
496        collect_module_info(path, node, &mut module);
497        collect_type_declarations(node, &mut module.type_declarations);
498    }
499    // Fallback matching the VM loader: if the module declares no
500    // `pub fn`, every fn is implicitly exported.
501    if !module.has_pub_fn {
502        for name in &module.fn_names {
503            module.exports.insert(name.clone());
504        }
505    }
506    module
507}
508
509/// Extract the stdlib module name when `path` is a `<std>/<name>`
510/// virtual path, otherwise `None`.
511fn stdlib_module_from_path(path: &Path) -> Option<&str> {
512    let s = path.to_str()?;
513    s.strip_prefix("<std>/")
514}
515
516fn collect_module_info(file: &Path, snode: &SNode, module: &mut ModuleInfo) {
517    match &snode.node {
518        Node::FnDecl {
519            name,
520            params,
521            is_pub,
522            ..
523        } => {
524            if *is_pub {
525                module.exports.insert(name.clone());
526                module.has_pub_fn = true;
527            }
528            module.fn_names.push(name.clone());
529            module.declarations.insert(
530                name.clone(),
531                decl_site(file, snode.span, name, DefKind::Function),
532            );
533            for param_name in params.iter().map(|param| param.name.clone()) {
534                module.declarations.insert(
535                    param_name.clone(),
536                    decl_site(file, snode.span, &param_name, DefKind::Parameter),
537                );
538            }
539        }
540        Node::Pipeline { name, is_pub, .. } => {
541            if *is_pub {
542                module.exports.insert(name.clone());
543            }
544            module.declarations.insert(
545                name.clone(),
546                decl_site(file, snode.span, name, DefKind::Pipeline),
547            );
548        }
549        Node::ToolDecl { name, is_pub, .. } => {
550            if *is_pub {
551                module.exports.insert(name.clone());
552            }
553            module.declarations.insert(
554                name.clone(),
555                decl_site(file, snode.span, name, DefKind::Tool),
556            );
557        }
558        Node::SkillDecl { name, is_pub, .. } => {
559            if *is_pub {
560                module.exports.insert(name.clone());
561            }
562            module.declarations.insert(
563                name.clone(),
564                decl_site(file, snode.span, name, DefKind::Skill),
565            );
566        }
567        Node::StructDecl { name, is_pub, .. } => {
568            if *is_pub {
569                module.exports.insert(name.clone());
570            }
571            module.declarations.insert(
572                name.clone(),
573                decl_site(file, snode.span, name, DefKind::Struct),
574            );
575        }
576        Node::EnumDecl { name, is_pub, .. } => {
577            if *is_pub {
578                module.exports.insert(name.clone());
579            }
580            module.declarations.insert(
581                name.clone(),
582                decl_site(file, snode.span, name, DefKind::Enum),
583            );
584        }
585        Node::InterfaceDecl { name, .. } => {
586            module.exports.insert(name.clone());
587            module.declarations.insert(
588                name.clone(),
589                decl_site(file, snode.span, name, DefKind::Interface),
590            );
591        }
592        Node::TypeDecl { name, .. } => {
593            module.exports.insert(name.clone());
594            module.declarations.insert(
595                name.clone(),
596                decl_site(file, snode.span, name, DefKind::Type),
597            );
598        }
599        Node::LetBinding { pattern, .. } | Node::VarBinding { pattern, .. } => {
600            for name in pattern_names(pattern) {
601                module.declarations.insert(
602                    name.clone(),
603                    decl_site(file, snode.span, &name, DefKind::Variable),
604                );
605            }
606        }
607        Node::ImportDecl { path } => {
608            let import_path = resolve_import_path(file, path);
609            if import_path.is_none() {
610                module.has_unresolved_wildcard_import = true;
611            }
612            module.imports.push(ImportRef {
613                path: import_path,
614                selective_names: None,
615            });
616        }
617        Node::SelectiveImport { names, path } => {
618            let import_path = resolve_import_path(file, path);
619            if import_path.is_none() {
620                module.has_unresolved_selective_import = true;
621            }
622            let names: HashSet<String> = names.iter().cloned().collect();
623            module.selective_import_names.extend(names.iter().cloned());
624            module.imports.push(ImportRef {
625                path: import_path,
626                selective_names: Some(names),
627            });
628        }
629        Node::AttributedDecl { inner, .. } => {
630            collect_module_info(file, inner, module);
631        }
632        _ => {}
633    }
634}
635
636fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
637    match &snode.node {
638        Node::TypeDecl { .. }
639        | Node::StructDecl { .. }
640        | Node::EnumDecl { .. }
641        | Node::InterfaceDecl { .. } => decls.push(snode.clone()),
642        Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
643        _ => {}
644    }
645}
646
647fn type_decl_name(snode: &SNode) -> Option<&str> {
648    match &snode.node {
649        Node::TypeDecl { name, .. }
650        | Node::StructDecl { name, .. }
651        | Node::EnumDecl { name, .. }
652        | Node::InterfaceDecl { name, .. } => Some(name.as_str()),
653        _ => None,
654    }
655}
656
657fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
658    DefSite {
659        name: name.to_string(),
660        file: file.to_path_buf(),
661        kind,
662        span,
663    }
664}
665
666fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
667    match pattern {
668        BindingPattern::Identifier(name) => vec![name.clone()],
669        BindingPattern::Dict(fields) => fields
670            .iter()
671            .filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
672            .collect(),
673        BindingPattern::List(elements) => elements
674            .iter()
675            .map(|element| element.name.clone())
676            .collect(),
677        BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
678    }
679}
680
681fn normalize_path(path: &Path) -> PathBuf {
682    if stdlib_module_from_path(path).is_some() {
683        return path.to_path_buf();
684    }
685    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use std::fs;
692
693    fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
694        let path = dir.join(name);
695        fs::write(&path, contents).unwrap();
696        path
697    }
698
699    #[test]
700    fn recursive_build_loads_transitively_imported_modules() {
701        let tmp = tempfile::tempdir().unwrap();
702        let root = tmp.path();
703        write_file(root, "leaf.harn", "pub fn leaf_fn() { 1 }\n");
704        write_file(
705            root,
706            "mid.harn",
707            "import \"./leaf\"\npub fn mid_fn() { leaf_fn() }\n",
708        );
709        let entry = write_file(root, "entry.harn", "import \"./mid\"\nmid_fn()\n");
710
711        let graph = build(std::slice::from_ref(&entry));
712        let imported = graph
713            .imported_names_for_file(&entry)
714            .expect("entry imports should resolve");
715        // Wildcard import of mid exposes mid_fn (pub) but not leaf_fn.
716        assert!(imported.contains("mid_fn"));
717        assert!(!imported.contains("leaf_fn"));
718
719        // The transitively loaded module is known to the graph even though
720        // the seed only included entry.harn.
721        let leaf_path = root.join("leaf.harn");
722        assert!(graph.definition_of(&leaf_path, "leaf_fn").is_some());
723    }
724
725    #[test]
726    fn imported_names_returns_none_when_import_unresolved() {
727        let tmp = tempfile::tempdir().unwrap();
728        let root = tmp.path();
729        let entry = write_file(root, "entry.harn", "import \"./does_not_exist\"\n");
730
731        let graph = build(std::slice::from_ref(&entry));
732        assert!(graph.imported_names_for_file(&entry).is_none());
733    }
734
735    #[test]
736    fn selective_imports_contribute_only_requested_names() {
737        let tmp = tempfile::tempdir().unwrap();
738        let root = tmp.path();
739        write_file(root, "util.harn", "pub fn a() { 1 }\npub fn b() { 2 }\n");
740        let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
741
742        let graph = build(std::slice::from_ref(&entry));
743        let imported = graph
744            .imported_names_for_file(&entry)
745            .expect("entry imports should resolve");
746        assert!(imported.contains("a"));
747        assert!(!imported.contains("b"));
748    }
749
750    #[test]
751    fn stdlib_imports_resolve_to_embedded_sources() {
752        let tmp = tempfile::tempdir().unwrap();
753        let root = tmp.path();
754        let entry = write_file(root, "entry.harn", "import \"std/math\"\nclamp(5, 0, 10)\n");
755
756        let graph = build(std::slice::from_ref(&entry));
757        let imported = graph
758            .imported_names_for_file(&entry)
759            .expect("std/math should resolve");
760        // `clamp` is defined in stdlib_math.harn as `pub fn clamp(...)`.
761        assert!(imported.contains("clamp"));
762    }
763
764    #[test]
765    fn runtime_stdlib_import_surface_resolves_to_embedded_sources() {
766        let tmp = tempfile::tempdir().unwrap();
767        let entry = write_file(tmp.path(), "entry.harn", "");
768
769        for (module, _) in stdlib::STDLIB_SOURCES {
770            let import_path = format!("std/{module}");
771            assert!(
772                resolve_import_path(&entry, &import_path).is_some(),
773                "{import_path} should resolve in the module graph"
774            );
775        }
776    }
777
778    #[test]
779    fn stdlib_imports_expose_type_declarations() {
780        let tmp = tempfile::tempdir().unwrap();
781        let root = tmp.path();
782        let entry = write_file(
783            root,
784            "entry.harn",
785            "import \"std/triggers\"\nlet provider = \"github\"\n",
786        );
787
788        let graph = build(std::slice::from_ref(&entry));
789        let decls = graph
790            .imported_type_declarations_for_file(&entry)
791            .expect("std/triggers type declarations should resolve");
792        let names: HashSet<String> = decls
793            .iter()
794            .filter_map(type_decl_name)
795            .map(ToString::to_string)
796            .collect();
797        assert!(names.contains("TriggerEvent"));
798        assert!(names.contains("ProviderPayload"));
799        assert!(names.contains("SignatureStatus"));
800    }
801
802    #[test]
803    fn package_export_map_resolves_declared_module() {
804        let tmp = tempfile::tempdir().unwrap();
805        let root = tmp.path();
806        let packages = root.join(".harn/packages/acme/runtime");
807        fs::create_dir_all(&packages).unwrap();
808        fs::write(
809            root.join(".harn/packages/acme/harn.toml"),
810            "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
811        )
812        .unwrap();
813        fs::write(
814            packages.join("capabilities.harn"),
815            "pub fn exported_capability() { 1 }\n",
816        )
817        .unwrap();
818        let entry = write_file(
819            root,
820            "entry.harn",
821            "import \"acme/capabilities\"\nexported_capability()\n",
822        );
823
824        let graph = build(std::slice::from_ref(&entry));
825        let imported = graph
826            .imported_names_for_file(&entry)
827            .expect("package export should resolve");
828        assert!(imported.contains("exported_capability"));
829    }
830
831    #[test]
832    fn package_direct_import_cannot_escape_packages_root() {
833        let tmp = tempfile::tempdir().unwrap();
834        let root = tmp.path();
835        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
836        fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
837        let entry = write_file(root, "entry.harn", "");
838
839        let resolved = resolve_import_path(&entry, "acme/../../secret");
840        assert!(resolved.is_none(), "package import escaped package root");
841    }
842
843    #[test]
844    fn package_export_map_cannot_escape_package_root() {
845        let tmp = tempfile::tempdir().unwrap();
846        let root = tmp.path();
847        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
848        fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
849        fs::write(
850            root.join(".harn/packages/acme/harn.toml"),
851            "[exports]\nleak = \"../../secret.harn\"\n",
852        )
853        .unwrap();
854        let entry = write_file(root, "entry.harn", "");
855
856        let resolved = resolve_import_path(&entry, "acme/leak");
857        assert!(resolved.is_none(), "package export escaped package root");
858    }
859
860    #[test]
861    fn package_export_map_allows_symlinked_path_dependencies() {
862        let tmp = tempfile::tempdir().unwrap();
863        let root = tmp.path();
864        let source = root.join("source-package");
865        fs::create_dir_all(source.join("runtime")).unwrap();
866        fs::write(
867            source.join("harn.toml"),
868            "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
869        )
870        .unwrap();
871        fs::write(
872            source.join("runtime/capabilities.harn"),
873            "pub fn exported_capability() { 1 }\n",
874        )
875        .unwrap();
876        fs::create_dir_all(root.join(".harn/packages")).unwrap();
877        #[cfg(unix)]
878        std::os::unix::fs::symlink(&source, root.join(".harn/packages/acme")).unwrap();
879        #[cfg(windows)]
880        std::os::windows::fs::symlink_dir(&source, root.join(".harn/packages/acme")).unwrap();
881        let entry = write_file(root, "entry.harn", "");
882
883        let resolved = resolve_import_path(&entry, "acme/capabilities")
884            .expect("symlinked package export should resolve");
885        assert!(resolved.ends_with("runtime/capabilities.harn"));
886    }
887
888    #[test]
889    fn package_imports_resolve_from_nested_package_module() {
890        let tmp = tempfile::tempdir().unwrap();
891        let root = tmp.path();
892        fs::create_dir_all(root.join(".git")).unwrap();
893        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
894        fs::create_dir_all(root.join(".harn/packages/shared")).unwrap();
895        fs::write(
896            root.join(".harn/packages/shared/lib.harn"),
897            "pub fn shared_helper() { 1 }\n",
898        )
899        .unwrap();
900        fs::write(
901            root.join(".harn/packages/acme/lib.harn"),
902            "import \"shared\"\npub fn use_shared() { shared_helper() }\n",
903        )
904        .unwrap();
905        let entry = write_file(root, "entry.harn", "import \"acme\"\nuse_shared()\n");
906
907        let graph = build(std::slice::from_ref(&entry));
908        let imported = graph
909            .imported_names_for_file(&entry)
910            .expect("nested package import should resolve");
911        assert!(imported.contains("use_shared"));
912        let acme_path = root.join(".harn/packages/acme/lib.harn");
913        let acme_imports = graph
914            .imported_names_for_file(&acme_path)
915            .expect("package module imports should resolve");
916        assert!(acme_imports.contains("shared_helper"));
917    }
918
919    #[test]
920    fn unknown_stdlib_import_is_unresolved() {
921        let tmp = tempfile::tempdir().unwrap();
922        let root = tmp.path();
923        let entry = write_file(root, "entry.harn", "import \"std/does_not_exist\"\n");
924
925        let graph = build(std::slice::from_ref(&entry));
926        assert!(
927            graph.imported_names_for_file(&entry).is_none(),
928            "unknown std module should fail resolution and disable strict check"
929        );
930    }
931
932    #[test]
933    fn import_cycles_do_not_loop_forever() {
934        let tmp = tempfile::tempdir().unwrap();
935        let root = tmp.path();
936        write_file(root, "a.harn", "import \"./b\"\npub fn a_fn() { 1 }\n");
937        write_file(root, "b.harn", "import \"./a\"\npub fn b_fn() { 1 }\n");
938        let entry = root.join("a.harn");
939
940        // Just ensuring this terminates and yields sensible names.
941        let graph = build(std::slice::from_ref(&entry));
942        let imported = graph
943            .imported_names_for_file(&entry)
944            .expect("cyclic imports still resolve to known exports");
945        assert!(imported.contains("b_fn"));
946    }
947
948    #[test]
949    fn cross_directory_cycle_does_not_explode_module_count() {
950        // Regression: two files in sibling directories that import each
951        // other produced a fresh path spelling on every round-trip
952        // (`../runtime/../context/../runtime/...`), and `build()`'s
953        // `seen` set deduped on the raw spelling rather than the
954        // canonical path. The walk only terminated when `PATH_MAX` was
955        // hit — 1024 on macOS, 4096 on Linux — so Linux re-parsed the
956        // same pair thousands of times until it ran out of memory.
957        let tmp = tempfile::tempdir().unwrap();
958        let root = tmp.path();
959        let context = root.join("context");
960        let runtime = root.join("runtime");
961        fs::create_dir_all(&context).unwrap();
962        fs::create_dir_all(&runtime).unwrap();
963        write_file(
964            &context,
965            "a.harn",
966            "import \"../runtime/b\"\npub fn a_fn() { 1 }\n",
967        );
968        write_file(
969            &runtime,
970            "b.harn",
971            "import \"../context/a\"\npub fn b_fn() { 1 }\n",
972        );
973        let entry = context.join("a.harn");
974
975        let graph = build(std::slice::from_ref(&entry));
976        // The graph should contain exactly the two real files, keyed by
977        // their canonical paths. Pre-fix this was thousands of entries.
978        assert_eq!(
979            graph.modules.len(),
980            2,
981            "cross-directory cycle loaded {} modules, expected 2",
982            graph.modules.len()
983        );
984        let imported = graph
985            .imported_names_for_file(&entry)
986            .expect("cyclic imports still resolve to known exports");
987        assert!(imported.contains("b_fn"));
988    }
989}