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
8pub mod asset_paths;
9pub mod fingerprint;
10pub mod personas;
11mod stdlib;
12
13/// Kind of symbol that can be exported by a module.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum DefKind {
16    Function,
17    Pipeline,
18    Tool,
19    Skill,
20    Struct,
21    Enum,
22    Interface,
23    Type,
24    Variable,
25    Parameter,
26}
27
28/// A resolved definition site within a module.
29#[derive(Debug, Clone)]
30pub struct DefSite {
31    pub name: String,
32    pub file: PathBuf,
33    pub kind: DefKind,
34    pub span: Span,
35}
36
37/// Wildcard import resolution status for a single importing module.
38#[derive(Debug, Clone)]
39pub enum WildcardResolution {
40    /// Resolved all wildcard imports and can expose wildcard exports.
41    Resolved(HashSet<String>),
42    /// At least one wildcard import could not be resolved.
43    Unknown,
44}
45
46/// Parsed information for a set of module files.
47#[derive(Debug, Default)]
48pub struct ModuleGraph {
49    modules: HashMap<PathBuf, ModuleInfo>,
50}
51
52#[derive(Debug, Default)]
53struct ModuleInfo {
54    /// All declarations visible in this module (for local symbol lookup and
55    /// go-to-definition resolution).
56    declarations: HashMap<String, DefSite>,
57    /// Names exported by this module after re-export resolution. Equal to
58    /// [`own_exports`] union the keys of [`selective_re_exports`] union the
59    /// transitive exports of [`wildcard_re_export_paths`]. Populated in
60    /// `build()` after all modules are loaded.
61    exports: HashSet<String>,
62    /// Names declared locally and exported by this module — i.e. `pub fn`,
63    /// `pub struct`, etc., or every `fn` under the no-`pub fn` fallback.
64    own_exports: HashSet<String>,
65    /// Selective re-exports introduced by `pub import { name } from "..."`.
66    /// Maps the re-exported name to every canonical source module path it
67    /// could originate from. Multiple entries per name indicate a conflict
68    /// (`pub import { foo } from "a"` and `pub import { foo } from "b"`)
69    /// and are surfaced by [`ModuleGraph::re_export_conflicts`]. Lookup
70    /// callers (e.g. go-to-definition) follow the first recorded source.
71    selective_re_exports: HashMap<String, Vec<PathBuf>>,
72    /// Wildcard re-exports introduced by `pub import "..."`. Each entry is
73    /// the canonical path of a module whose entire public export surface
74    /// this module re-exports.
75    wildcard_re_export_paths: Vec<PathBuf>,
76    /// Names introduced by selective imports across this module.
77    selective_import_names: HashSet<String>,
78    /// Import references encountered in this file.
79    imports: Vec<ImportRef>,
80    /// True when at least one wildcard import could not be resolved.
81    has_unresolved_wildcard_import: bool,
82    /// True when at least one selective import could not be resolved
83    /// (importing file path missing). Prevents `imported_names_for_file`
84    /// from returning a partial answer when any import is broken.
85    has_unresolved_selective_import: bool,
86    /// Every `fn` declaration at module scope, used to implement the
87    /// fallback "no `pub fn` → export everything" rule that matches the
88    /// runtime loader's behavior.
89    fn_names: Vec<String>,
90    /// True when at least one `pub fn` appeared at module scope.
91    has_pub_fn: bool,
92    /// Top-level type-like declarations that can be imported into a caller's
93    /// static type environment.
94    type_declarations: Vec<SNode>,
95    /// Top-level callable declarations whose signatures can be imported into
96    /// a caller's static type environment.
97    callable_declarations: Vec<SNode>,
98}
99
100#[derive(Debug, Clone)]
101struct ImportRef {
102    raw_path: String,
103    path: Option<PathBuf>,
104    selective_names: Option<HashSet<String>>,
105}
106
107/// Public import edge summary for static module graph consumers.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct ModuleImport {
110    /// The import string as written in source.
111    pub raw_path: String,
112    /// Resolved module path when the import could be resolved.
113    pub resolved_path: Option<PathBuf>,
114    /// `None` for wildcard imports; otherwise the selected names.
115    pub selective_names: Option<Vec<String>>,
116}
117
118#[derive(Debug, Default, Deserialize)]
119struct PackageManifest {
120    #[serde(default)]
121    exports: HashMap<String, String>,
122}
123
124/// Return the source for a resolved module path.
125///
126/// Real paths are read from disk. `<std>/<module>` virtual paths are backed by
127/// the embedded stdlib source table, so callers can parse resolved stdlib
128/// modules without knowing about the stdlib mirror layout.
129pub fn read_module_source(path: &Path) -> Option<String> {
130    if let Some(stdlib_module) = stdlib_module_from_path(path) {
131        return stdlib::get_stdlib_source(stdlib_module).map(ToString::to_string);
132    }
133    std::fs::read_to_string(path).ok()
134}
135
136/// Build a module graph from a set of files.
137///
138/// Files referenced via `import` statements are loaded recursively so the
139/// graph contains every module reachable from the seed set. Cycles and
140/// already-loaded files are skipped via a visited set.
141pub fn build(files: &[PathBuf]) -> ModuleGraph {
142    let mut modules: HashMap<PathBuf, ModuleInfo> = HashMap::new();
143    let mut seen: HashSet<PathBuf> = HashSet::new();
144    let mut queue: VecDeque<PathBuf> = VecDeque::new();
145    for file in files {
146        let canonical = normalize_path(file);
147        if seen.insert(canonical.clone()) {
148            queue.push_back(canonical);
149        }
150    }
151    while let Some(path) = queue.pop_front() {
152        if modules.contains_key(&path) {
153            continue;
154        }
155        let module = load_module(&path);
156        // Enqueue resolved import targets so the whole reachable graph is
157        // discovered without the caller having to pre-walk imports.
158        //
159        // `resolve_import_path` returns paths as `base.join(import)` —
160        // i.e. with `..` segments preserved rather than collapsed. If we
161        // dedupe on those raw forms, two files that import each other
162        // across sibling dirs (`lib/context/` ↔ `lib/runtime/`) produce a
163        // different path spelling on every cycle — `.../context/../runtime/`,
164        // then `.../context/../runtime/../context/`, and so on — each of
165        // which is treated as a new file. The walk only terminates when
166        // `path.exists()` starts failing at the filesystem's `PATH_MAX`,
167        // which is 1024 on macOS but 4096 on Linux. Linux therefore
168        // re-parses the same handful of files thousands of times, balloons
169        // RSS into the multi-GB range, and gets SIGKILL'd by CI runners.
170        // Canonicalize once here so `seen` dedupes by the underlying file,
171        // not by its path spelling.
172        for import in &module.imports {
173            if let Some(import_path) = &import.path {
174                let canonical = normalize_path(import_path);
175                if seen.insert(canonical.clone()) {
176                    queue.push_back(canonical);
177                }
178            }
179        }
180        modules.insert(path, module);
181    }
182    resolve_re_exports(&mut modules);
183    ModuleGraph { modules }
184}
185
186/// Iteratively expand each module's `exports` set to include the transitive
187/// public surface of its `pub import "..."` re-export targets. Cycles are
188/// safe because the loop only adds names — once no module's set grows in a
189/// pass, the fixpoint is reached.
190fn resolve_re_exports(modules: &mut HashMap<PathBuf, ModuleInfo>) {
191    let keys: Vec<PathBuf> = modules.keys().cloned().collect();
192    loop {
193        let mut changed = false;
194        for path in &keys {
195            // Snapshot the wildcard target list and gather the union of
196            // their current exports without holding a mutable borrow.
197            let wildcard_paths = modules
198                .get(path)
199                .map(|m| m.wildcard_re_export_paths.clone())
200                .unwrap_or_default();
201            if wildcard_paths.is_empty() {
202                continue;
203            }
204            let mut additions: Vec<String> = Vec::new();
205            for src in &wildcard_paths {
206                let src_canonical = normalize_path(src);
207                if let Some(src_module) = modules.get(src).or_else(|| modules.get(&src_canonical)) {
208                    additions.extend(src_module.exports.iter().cloned());
209                }
210            }
211            if let Some(module) = modules.get_mut(path) {
212                for name in additions {
213                    if module.exports.insert(name) {
214                        changed = true;
215                    }
216                }
217            }
218        }
219        if !changed {
220            break;
221        }
222    }
223}
224
225/// Resolve an import string relative to the importing file.
226///
227/// Returns the path as-constructed (not canonicalized) so callers that
228/// compare against their own `PathBuf::join` result get matching values.
229/// The module graph canonicalizes internally via `normalize_path` when
230/// keying modules, so call-site canonicalization is not required for
231/// dedup.
232///
233/// `std/<module>` imports resolve to a virtual path (`<std>/<module>`)
234/// backed by the embedded stdlib sources in [`stdlib`]. This lets the
235/// module graph model stdlib symbols even though they have no on-disk
236/// location.
237pub fn resolve_import_path(current_file: &Path, import_path: &str) -> Option<PathBuf> {
238    if let Some(module) = import_path
239        .strip_prefix("std/")
240        .or_else(|| (import_path == "observability").then_some("observability"))
241    {
242        if stdlib::get_stdlib_source(module).is_some() {
243            return Some(stdlib::stdlib_virtual_path(module));
244        }
245        return None;
246    }
247
248    let base = current_file.parent().unwrap_or(Path::new("."));
249    let mut file_path = base.join(import_path);
250    if !file_path.exists() && file_path.extension().is_none() {
251        file_path.set_extension("harn");
252    }
253    if file_path.exists() {
254        return Some(file_path);
255    }
256
257    if let Some(path) = resolve_package_import(base, import_path) {
258        return Some(path);
259    }
260
261    None
262}
263
264fn resolve_package_import(base: &Path, import_path: &str) -> Option<PathBuf> {
265    for anchor in base.ancestors() {
266        let packages_root = anchor.join(".harn/packages");
267        if !packages_root.is_dir() {
268            if anchor.join(".git").exists() {
269                break;
270            }
271            continue;
272        }
273        if let Some(path) = resolve_from_packages_root(&packages_root, import_path) {
274            return Some(path);
275        }
276        if anchor.join(".git").exists() {
277            break;
278        }
279    }
280    None
281}
282
283fn resolve_from_packages_root(packages_root: &Path, import_path: &str) -> Option<PathBuf> {
284    let safe_import_path = safe_package_relative_path(import_path)?;
285    let package_name = package_name_from_relative_path(&safe_import_path)?;
286    let package_root = packages_root.join(package_name);
287
288    let pkg_path = packages_root.join(&safe_import_path);
289    if let Some(path) = finalize_package_target(&package_root, &pkg_path) {
290        return Some(path);
291    }
292
293    let export_name = export_name_from_relative_path(&safe_import_path)?;
294    let manifest_path = packages_root.join(package_name).join("harn.toml");
295    let manifest = read_package_manifest(&manifest_path)?;
296    let rel_path = manifest.exports.get(export_name)?;
297    let safe_export_path = safe_package_relative_path(rel_path)?;
298    finalize_package_target(&package_root, &package_root.join(safe_export_path))
299}
300
301fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
302    let content = std::fs::read_to_string(path).ok()?;
303    toml::from_str::<PackageManifest>(&content).ok()
304}
305
306fn safe_package_relative_path(raw: &str) -> Option<PathBuf> {
307    if raw.is_empty() || raw.contains('\\') {
308        return None;
309    }
310    let mut out = PathBuf::new();
311    let mut saw_component = false;
312    for component in Path::new(raw).components() {
313        match component {
314            Component::Normal(part) => {
315                saw_component = true;
316                out.push(part);
317            }
318            Component::CurDir => {}
319            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
320        }
321    }
322    saw_component.then_some(out)
323}
324
325fn package_name_from_relative_path(path: &Path) -> Option<&str> {
326    match path.components().next()? {
327        Component::Normal(name) => name.to_str(),
328        _ => None,
329    }
330}
331
332fn export_name_from_relative_path(path: &Path) -> Option<&str> {
333    let mut components = path.components();
334    components.next()?;
335    let rest = components.as_path();
336    if rest.as_os_str().is_empty() {
337        None
338    } else {
339        rest.to_str()
340    }
341}
342
343fn path_is_within(root: &Path, path: &Path) -> bool {
344    let Ok(root) = root.canonicalize() else {
345        return false;
346    };
347    let Ok(path) = path.canonicalize() else {
348        return false;
349    };
350    path == root || path.starts_with(root)
351}
352
353fn target_within_package_root(package_root: &Path, path: PathBuf) -> Option<PathBuf> {
354    path_is_within(package_root, &path).then_some(path)
355}
356
357fn finalize_package_target(package_root: &Path, path: &Path) -> Option<PathBuf> {
358    if path.is_dir() {
359        let lib = path.join("lib.harn");
360        if lib.exists() {
361            return target_within_package_root(package_root, lib);
362        }
363        return target_within_package_root(package_root, path.to_path_buf());
364    }
365    if path.exists() {
366        return target_within_package_root(package_root, path.to_path_buf());
367    }
368    if path.extension().is_none() {
369        let mut with_ext = path.to_path_buf();
370        with_ext.set_extension("harn");
371        if with_ext.exists() {
372            return target_within_package_root(package_root, with_ext);
373        }
374    }
375    None
376}
377
378impl ModuleGraph {
379    /// Sorted list of every module path discovered by [`build`]. Includes
380    /// `<std>/<name>` virtual paths for stdlib modules reached transitively.
381    /// Callers that want only real-disk modules can filter for paths whose
382    /// string form does not start with `<std>/`.
383    pub fn module_paths(&self) -> Vec<PathBuf> {
384        let mut paths: Vec<PathBuf> = self.modules.keys().cloned().collect();
385        paths.sort();
386        paths
387    }
388
389    /// True when `path` (or its canonical form) was discovered during the
390    /// module-graph walk.
391    pub fn contains_module(&self, path: &Path) -> bool {
392        self.modules.contains_key(path) || self.modules.contains_key(&normalize_path(path))
393    }
394
395    /// Collect every name used in selective imports from all files.
396    pub fn all_selective_import_names(&self) -> HashSet<&str> {
397        let mut names = HashSet::new();
398        for module in self.modules.values() {
399            for name in &module.selective_import_names {
400                names.insert(name.as_str());
401            }
402        }
403        names
404    }
405
406    /// Files that directly import `target`. Resolves `target` to a
407    /// canonical path before lookup so callers can pass either spelling.
408    pub fn importers_of(&self, target: &Path) -> Vec<PathBuf> {
409        let target = normalize_path(target);
410        let mut out: Vec<PathBuf> = self
411            .modules
412            .iter()
413            .filter(|(_, info)| {
414                info.imports.iter().any(|import| {
415                    import
416                        .path
417                        .as_ref()
418                        .is_some_and(|p| normalize_path(p) == target)
419                })
420            })
421            .map(|(path, _)| path.clone())
422            .collect();
423        out.sort();
424        out
425    }
426
427    /// Import edges declared by `file`, sorted by raw path and selected names.
428    pub fn imports_for_module(&self, file: &Path) -> Vec<ModuleImport> {
429        let file = normalize_path(file);
430        let Some(module) = self.modules.get(&file) else {
431            return Vec::new();
432        };
433        let mut imports: Vec<ModuleImport> = module
434            .imports
435            .iter()
436            .map(|import| {
437                let mut selective_names = import
438                    .selective_names
439                    .as_ref()
440                    .map(|names| names.iter().cloned().collect::<Vec<_>>());
441                if let Some(names) = selective_names.as_mut() {
442                    names.sort();
443                }
444                ModuleImport {
445                    raw_path: import.raw_path.clone(),
446                    resolved_path: import.path.as_ref().map(|path| normalize_path(path)),
447                    selective_names,
448                }
449            })
450            .collect();
451        imports.sort_by(|left, right| {
452            left.raw_path
453                .cmp(&right.raw_path)
454                .then_with(|| left.selective_names.cmp(&right.selective_names))
455                .then_with(|| left.resolved_path.cmp(&right.resolved_path))
456        });
457        imports
458    }
459
460    /// Exported symbol names for `file`, sorted alphabetically.
461    pub fn exports_for_module(&self, file: &Path) -> Vec<String> {
462        let file = normalize_path(file);
463        let Some(module) = self.modules.get(&file) else {
464            return Vec::new();
465        };
466        let mut exports: Vec<String> = module.exports.iter().cloned().collect();
467        exports.sort();
468        exports
469    }
470
471    /// Resolve wildcard imports for `file`.
472    ///
473    /// Returns `Unknown` when any wildcard import cannot be resolved, because
474    /// callers should conservatively disable wildcard-import-sensitive checks.
475    pub fn wildcard_exports_for(&self, file: &Path) -> WildcardResolution {
476        let file = normalize_path(file);
477        let Some(module) = self.modules.get(&file) else {
478            return WildcardResolution::Unknown;
479        };
480        if module.has_unresolved_wildcard_import {
481            return WildcardResolution::Unknown;
482        }
483
484        let mut names = HashSet::new();
485        for import in module
486            .imports
487            .iter()
488            .filter(|import| import.selective_names.is_none())
489        {
490            let Some(import_path) = &import.path else {
491                return WildcardResolution::Unknown;
492            };
493            let imported = self.modules.get(import_path).or_else(|| {
494                let normalized = normalize_path(import_path);
495                self.modules.get(&normalized)
496            });
497            let Some(imported) = imported else {
498                return WildcardResolution::Unknown;
499            };
500            names.extend(imported.exports.iter().cloned());
501        }
502        WildcardResolution::Resolved(names)
503    }
504
505    /// Collect every statically callable/referenceable name introduced into
506    /// `file` by its imports.
507    ///
508    /// Returns `Some` only when **every** import (wildcard or selective) in
509    /// `file` is fully resolvable via the graph. Returns `None` when any
510    /// import is unresolved, so callers can fall back to conservative
511    /// behavior instead of emitting spurious "undefined name" errors.
512    ///
513    /// The returned set contains:
514    /// - all public exports from wildcard-imported modules (transitively
515    ///   following `pub import` re-export chains), and
516    /// - selectively imported names that exist either as local
517    ///   declarations in their target module or as a re-exported name —
518    ///   matching what the VM accepts at runtime.
519    pub fn imported_names_for_file(&self, file: &Path) -> Option<HashSet<String>> {
520        let file = normalize_path(file);
521        let module = self.modules.get(&file)?;
522        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
523            return None;
524        }
525
526        let mut names = HashSet::new();
527        for import in &module.imports {
528            let import_path = import.path.as_ref()?;
529            let imported = self
530                .modules
531                .get(import_path)
532                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
533            match &import.selective_names {
534                None => {
535                    names.extend(imported.exports.iter().cloned());
536                }
537                Some(selective) => {
538                    for name in selective {
539                        if imported.declarations.contains_key(name)
540                            || imported.exports.contains(name)
541                        {
542                            names.insert(name.clone());
543                        }
544                    }
545                }
546            }
547        }
548        Some(names)
549    }
550
551    /// Collect type / struct / enum / interface declarations made visible to
552    /// `file` by its imports. Returns `None` when any import is unresolved so
553    /// callers can fall back to conservative behavior.
554    pub fn imported_type_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
555        let file = normalize_path(file);
556        let module = self.modules.get(&file)?;
557        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
558            return None;
559        }
560
561        let mut decls = Vec::new();
562        for import in &module.imports {
563            let import_path = import.path.as_ref()?;
564            let imported = self
565                .modules
566                .get(import_path)
567                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
568            let names_to_collect: Vec<String> = match &import.selective_names {
569                None => imported.exports.iter().cloned().collect(),
570                Some(selective) => {
571                    // A selectively imported fn whose signature references a
572                    // type alias declared in the same module ("options:
573                    // PickKeysOptions") needs that alias visible at the call
574                    // site too — otherwise the caller sees only a phantom
575                    // `Named("PickKeysOptions")` and skips contract checks.
576                    // Pull every exported type alias / struct / enum /
577                    // interface from the same module into scope to keep the
578                    // selective-import contract honest.
579                    let mut names: Vec<String> = selective.iter().cloned().collect();
580                    for ty_decl in &imported.type_declarations {
581                        if let Some(name) = type_decl_name(ty_decl) {
582                            if imported.own_exports.contains(name)
583                                && !names.iter().any(|n| n == name)
584                            {
585                                names.push(name.to_string());
586                            }
587                        }
588                    }
589                    names
590                }
591            };
592            for name in &names_to_collect {
593                let mut visited = HashSet::new();
594                if let Some(decl) = self.find_exported_type_decl(import_path, name, &mut visited) {
595                    decls.push(decl);
596                }
597            }
598        }
599        Some(decls)
600    }
601
602    /// Collect callable declarations made visible to `file` by its imports.
603    /// Only signatures are consumed by the type checker; imported bodies
604    /// remain owned by their defining modules.
605    pub fn imported_callable_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
606        let file = normalize_path(file);
607        let module = self.modules.get(&file)?;
608        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
609            return None;
610        }
611
612        let mut decls = Vec::new();
613        for import in &module.imports {
614            let import_path = import.path.as_ref()?;
615            let imported = self
616                .modules
617                .get(import_path)
618                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
619            let selective_import = import.selective_names.is_some();
620            let names_to_collect: Vec<String> = match &import.selective_names {
621                None => imported.exports.iter().cloned().collect(),
622                Some(selective) => selective.iter().cloned().collect(),
623            };
624            for name in &names_to_collect {
625                if selective_import || imported.own_exports.contains(name) {
626                    if let Some(decl) = imported
627                        .callable_declarations
628                        .iter()
629                        .find(|decl| callable_decl_name(decl) == Some(name.as_str()))
630                    {
631                        decls.push(decl.clone());
632                        continue;
633                    }
634                }
635                let mut visited = HashSet::new();
636                if let Some(decl) =
637                    self.find_exported_callable_decl(import_path, name, &mut visited)
638                {
639                    decls.push(decl);
640                }
641            }
642        }
643        Some(decls)
644    }
645
646    /// Walk a module's local type declarations and re-export chains to find
647    /// the SNode for an exported type/struct/enum/interface named `name`.
648    fn find_exported_type_decl(
649        &self,
650        path: &Path,
651        name: &str,
652        visited: &mut HashSet<PathBuf>,
653    ) -> Option<SNode> {
654        let canonical = normalize_path(path);
655        if !visited.insert(canonical.clone()) {
656            return None;
657        }
658        let module = self
659            .modules
660            .get(&canonical)
661            .or_else(|| self.modules.get(path))?;
662        for decl in &module.type_declarations {
663            if type_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
664                return Some(decl.clone());
665            }
666        }
667        if let Some(sources) = module.selective_re_exports.get(name) {
668            for source in sources {
669                if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
670                    return Some(decl);
671                }
672            }
673        }
674        for source in &module.wildcard_re_export_paths {
675            if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
676                return Some(decl);
677            }
678        }
679        None
680    }
681
682    fn find_exported_callable_decl(
683        &self,
684        path: &Path,
685        name: &str,
686        visited: &mut HashSet<PathBuf>,
687    ) -> Option<SNode> {
688        let canonical = normalize_path(path);
689        if !visited.insert(canonical.clone()) {
690            return None;
691        }
692        let module = self
693            .modules
694            .get(&canonical)
695            .or_else(|| self.modules.get(path))?;
696        for decl in &module.callable_declarations {
697            if callable_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
698                return Some(decl.clone());
699            }
700        }
701        if let Some(sources) = module.selective_re_exports.get(name) {
702            for source in sources {
703                if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
704                    return Some(decl);
705                }
706            }
707        }
708        for source in &module.wildcard_re_export_paths {
709            if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
710                return Some(decl);
711            }
712        }
713        None
714    }
715
716    /// Find the definition of `name` visible from `file`.
717    ///
718    /// Recurses through `pub import` re-export chains so go-to-definition
719    /// lands on the symbol's actual declaration site instead of the facade
720    /// module that forwarded it.
721    pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
722        let mut visited = HashSet::new();
723        self.definition_of_inner(file, name, &mut visited)
724    }
725
726    fn definition_of_inner(
727        &self,
728        file: &Path,
729        name: &str,
730        visited: &mut HashSet<PathBuf>,
731    ) -> Option<DefSite> {
732        let file = normalize_path(file);
733        if !visited.insert(file.clone()) {
734            return None;
735        }
736        let current = self.modules.get(&file)?;
737
738        if let Some(local) = current.declarations.get(name) {
739            return Some(local.clone());
740        }
741
742        // `pub import { name } from "..."` — follow the first recorded
743        // source. Conflicting re-exports surface separately as
744        // diagnostics; here we just pick a canonical destination so
745        // go-to-definition lands somewhere useful.
746        if let Some(sources) = current.selective_re_exports.get(name) {
747            for source in sources {
748                if let Some(def) = self.definition_of_inner(source, name, visited) {
749                    return Some(def);
750                }
751            }
752        }
753
754        // `pub import "..."` — chase each wildcard re-export source.
755        for source in &current.wildcard_re_export_paths {
756            if let Some(def) = self.definition_of_inner(source, name, visited) {
757                return Some(def);
758            }
759        }
760
761        // Private selective imports.
762        for import in &current.imports {
763            let Some(selective_names) = &import.selective_names else {
764                continue;
765            };
766            if !selective_names.contains(name) {
767                continue;
768            }
769            if let Some(path) = &import.path {
770                if let Some(def) = self.definition_of_inner(path, name, visited) {
771                    return Some(def);
772                }
773            }
774        }
775
776        // Private wildcard imports.
777        for import in &current.imports {
778            if import.selective_names.is_some() {
779                continue;
780            }
781            if let Some(path) = &import.path {
782                if let Some(def) = self.definition_of_inner(path, name, visited) {
783                    return Some(def);
784                }
785            }
786        }
787
788        None
789    }
790
791    /// Diagnostics for re-export conflicts inside `file`. Each diagnostic
792    /// names the conflicting symbol and the modules that contributed it,
793    /// so check-time errors can be precise.
794    pub fn re_export_conflicts(&self, file: &Path) -> Vec<ReExportConflict> {
795        let file = normalize_path(file);
796        let Some(module) = self.modules.get(&file) else {
797            return Vec::new();
798        };
799
800        // Build, for each re-exported name, the set of source modules it
801        // could resolve to. Names that resolve to more than one source are
802        // ambiguous and reported.
803        let mut sources: HashMap<String, Vec<PathBuf>> = HashMap::new();
804
805        for (name, srcs) in &module.selective_re_exports {
806            sources
807                .entry(name.clone())
808                .or_default()
809                .extend(srcs.iter().cloned());
810        }
811        for src in &module.wildcard_re_export_paths {
812            let canonical = normalize_path(src);
813            let Some(src_module) = self
814                .modules
815                .get(&canonical)
816                .or_else(|| self.modules.get(src))
817            else {
818                continue;
819            };
820            for name in &src_module.exports {
821                sources
822                    .entry(name.clone())
823                    .or_default()
824                    .push(canonical.clone());
825            }
826        }
827
828        // A re-export that collides with a locally exported declaration is
829        // also an error: the facade module cannot expose two different
830        // bindings under the same name.
831        for name in &module.own_exports {
832            if let Some(entry) = sources.get_mut(name) {
833                entry.push(file.clone());
834            }
835        }
836
837        let mut conflicts = Vec::new();
838        for (name, mut srcs) in sources {
839            srcs.sort();
840            srcs.dedup();
841            if srcs.len() > 1 {
842                conflicts.push(ReExportConflict {
843                    name,
844                    sources: srcs,
845                });
846            }
847        }
848        conflicts.sort_by(|a, b| a.name.cmp(&b.name));
849        conflicts
850    }
851}
852
853/// A duplicate or ambiguous re-export inside a single module. Reported by
854/// [`ModuleGraph::re_export_conflicts`].
855#[derive(Debug, Clone, PartialEq, Eq)]
856pub struct ReExportConflict {
857    pub name: String,
858    pub sources: Vec<PathBuf>,
859}
860
861fn load_module(path: &Path) -> ModuleInfo {
862    let Some(source) = read_module_source(path) else {
863        return ModuleInfo::default();
864    };
865    let mut lexer = harn_lexer::Lexer::new(&source);
866    let tokens = match lexer.tokenize() {
867        Ok(tokens) => tokens,
868        Err(_) => return ModuleInfo::default(),
869    };
870    let mut parser = Parser::new(tokens);
871    let program = match parser.parse() {
872        Ok(program) => program,
873        Err(_) => return ModuleInfo::default(),
874    };
875
876    let mut module = ModuleInfo::default();
877    for node in &program {
878        collect_module_info(path, node, &mut module);
879        collect_type_declarations(node, &mut module.type_declarations);
880        collect_callable_declarations(node, &mut module.callable_declarations);
881    }
882    // Fallback matching the VM loader: if the module declares no
883    // `pub fn`, every fn is implicitly exported.
884    if !module.has_pub_fn {
885        for name in &module.fn_names {
886            module.own_exports.insert(name.clone());
887        }
888    }
889    // Seed the transitive `exports` set from local exports plus selective
890    // re-export names. Wildcard re-exports are folded in by
891    // [`resolve_re_exports`] after every module has been loaded.
892    module.exports.extend(module.own_exports.iter().cloned());
893    module
894        .exports
895        .extend(module.selective_re_exports.keys().cloned());
896    module
897}
898
899/// Extract the stdlib module name when `path` is a `<std>/<name>`
900/// virtual path, otherwise `None`.
901fn stdlib_module_from_path(path: &Path) -> Option<&str> {
902    let s = path.to_str()?;
903    s.strip_prefix("<std>/")
904}
905
906fn collect_module_info(file: &Path, snode: &SNode, module: &mut ModuleInfo) {
907    match &snode.node {
908        Node::FnDecl {
909            name,
910            params,
911            is_pub,
912            ..
913        } => {
914            if *is_pub {
915                module.own_exports.insert(name.clone());
916                module.has_pub_fn = true;
917            }
918            module.fn_names.push(name.clone());
919            module.declarations.insert(
920                name.clone(),
921                decl_site(file, snode.span, name, DefKind::Function),
922            );
923            for param_name in params.iter().map(|param| param.name.clone()) {
924                module.declarations.insert(
925                    param_name.clone(),
926                    decl_site(file, snode.span, &param_name, DefKind::Parameter),
927                );
928            }
929        }
930        Node::Pipeline { name, is_pub, .. } => {
931            if *is_pub {
932                module.own_exports.insert(name.clone());
933            }
934            module.declarations.insert(
935                name.clone(),
936                decl_site(file, snode.span, name, DefKind::Pipeline),
937            );
938        }
939        Node::ToolDecl { name, is_pub, .. } => {
940            if *is_pub {
941                module.own_exports.insert(name.clone());
942            }
943            module.declarations.insert(
944                name.clone(),
945                decl_site(file, snode.span, name, DefKind::Tool),
946            );
947        }
948        Node::SkillDecl { name, is_pub, .. } => {
949            if *is_pub {
950                module.own_exports.insert(name.clone());
951            }
952            module.declarations.insert(
953                name.clone(),
954                decl_site(file, snode.span, name, DefKind::Skill),
955            );
956        }
957        Node::StructDecl { name, is_pub, .. } => {
958            if *is_pub {
959                module.own_exports.insert(name.clone());
960            }
961            module.declarations.insert(
962                name.clone(),
963                decl_site(file, snode.span, name, DefKind::Struct),
964            );
965        }
966        Node::EnumDecl { name, is_pub, .. } => {
967            if *is_pub {
968                module.own_exports.insert(name.clone());
969            }
970            module.declarations.insert(
971                name.clone(),
972                decl_site(file, snode.span, name, DefKind::Enum),
973            );
974        }
975        Node::InterfaceDecl { name, .. } => {
976            module.own_exports.insert(name.clone());
977            module.declarations.insert(
978                name.clone(),
979                decl_site(file, snode.span, name, DefKind::Interface),
980            );
981        }
982        Node::TypeDecl { name, .. } => {
983            module.own_exports.insert(name.clone());
984            module.declarations.insert(
985                name.clone(),
986                decl_site(file, snode.span, name, DefKind::Type),
987            );
988        }
989        Node::LetBinding { pattern, .. } | Node::VarBinding { pattern, .. } => {
990            for name in pattern_names(pattern) {
991                module.declarations.insert(
992                    name.clone(),
993                    decl_site(file, snode.span, &name, DefKind::Variable),
994                );
995            }
996        }
997        Node::ImportDecl { path, is_pub } => {
998            let import_path = resolve_import_path(file, path);
999            if import_path.is_none() {
1000                module.has_unresolved_wildcard_import = true;
1001            }
1002            if *is_pub {
1003                if let Some(resolved) = &import_path {
1004                    module
1005                        .wildcard_re_export_paths
1006                        .push(normalize_path(resolved));
1007                }
1008            }
1009            module.imports.push(ImportRef {
1010                raw_path: path.clone(),
1011                path: import_path,
1012                selective_names: None,
1013            });
1014        }
1015        Node::SelectiveImport {
1016            names,
1017            path,
1018            is_pub,
1019        } => {
1020            let import_path = resolve_import_path(file, path);
1021            if import_path.is_none() {
1022                module.has_unresolved_selective_import = true;
1023            }
1024            if *is_pub {
1025                if let Some(resolved) = &import_path {
1026                    let canonical = normalize_path(resolved);
1027                    for name in names {
1028                        module
1029                            .selective_re_exports
1030                            .entry(name.clone())
1031                            .or_default()
1032                            .push(canonical.clone());
1033                    }
1034                }
1035            }
1036            let names: HashSet<String> = names.iter().cloned().collect();
1037            module.selective_import_names.extend(names.iter().cloned());
1038            module.imports.push(ImportRef {
1039                raw_path: path.clone(),
1040                path: import_path,
1041                selective_names: Some(names),
1042            });
1043        }
1044        Node::AttributedDecl { inner, .. } => {
1045            collect_module_info(file, inner, module);
1046        }
1047        _ => {}
1048    }
1049}
1050
1051fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1052    match &snode.node {
1053        Node::TypeDecl { .. }
1054        | Node::StructDecl { .. }
1055        | Node::EnumDecl { .. }
1056        | Node::InterfaceDecl { .. } => decls.push(snode.clone()),
1057        Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
1058        _ => {}
1059    }
1060}
1061
1062fn collect_callable_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1063    match &snode.node {
1064        Node::FnDecl { .. } | Node::Pipeline { .. } | Node::ToolDecl { .. } => {
1065            decls.push(snode.clone())
1066        }
1067        Node::AttributedDecl { inner, .. } => collect_callable_declarations(inner, decls),
1068        _ => {}
1069    }
1070}
1071
1072fn type_decl_name(snode: &SNode) -> Option<&str> {
1073    match &snode.node {
1074        Node::TypeDecl { name, .. }
1075        | Node::StructDecl { name, .. }
1076        | Node::EnumDecl { name, .. }
1077        | Node::InterfaceDecl { name, .. } => Some(name.as_str()),
1078        _ => None,
1079    }
1080}
1081
1082fn callable_decl_name(snode: &SNode) -> Option<&str> {
1083    match &snode.node {
1084        Node::FnDecl { name, .. } | Node::Pipeline { name, .. } | Node::ToolDecl { name, .. } => {
1085            Some(name.as_str())
1086        }
1087        Node::AttributedDecl { inner, .. } => callable_decl_name(inner),
1088        _ => None,
1089    }
1090}
1091
1092fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
1093    DefSite {
1094        name: name.to_string(),
1095        file: file.to_path_buf(),
1096        kind,
1097        span,
1098    }
1099}
1100
1101fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
1102    match pattern {
1103        BindingPattern::Identifier(name) => vec![name.clone()],
1104        BindingPattern::Dict(fields) => fields
1105            .iter()
1106            .filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
1107            .collect(),
1108        BindingPattern::List(elements) => elements
1109            .iter()
1110            .map(|element| element.name.clone())
1111            .collect(),
1112        BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
1113    }
1114}
1115
1116fn normalize_path(path: &Path) -> PathBuf {
1117    if stdlib_module_from_path(path).is_some() {
1118        return path.to_path_buf();
1119    }
1120    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125    use super::*;
1126    use std::fs;
1127
1128    fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
1129        let path = dir.join(name);
1130        fs::write(&path, contents).unwrap();
1131        path
1132    }
1133
1134    #[test]
1135    fn importers_of_finds_direct_dependents() {
1136        let tmp = tempfile::tempdir().unwrap();
1137        let root = tmp.path();
1138        let leaf = write_file(root, "leaf.harn", "pub fn leaf() { 1 }\n");
1139        write_file(root, "a.harn", "import \"./leaf\"\nleaf()\n");
1140        write_file(root, "b.harn", "import { leaf } from \"./leaf\"\nleaf()\n");
1141        let entry = write_file(root, "entry.harn", "import \"./a\"\nimport \"./b\"\n");
1142
1143        let graph = build(std::slice::from_ref(&entry));
1144        let importers = graph.importers_of(&leaf);
1145        let names: Vec<String> = importers
1146            .iter()
1147            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1148            .collect();
1149        assert!(names.contains(&"a.harn".to_string()));
1150        assert!(names.contains(&"b.harn".to_string()));
1151        assert!(!names.contains(&"entry.harn".to_string()));
1152    }
1153
1154    #[test]
1155    fn recursive_build_loads_transitively_imported_modules() {
1156        let tmp = tempfile::tempdir().unwrap();
1157        let root = tmp.path();
1158        write_file(root, "leaf.harn", "pub fn leaf_fn() { 1 }\n");
1159        write_file(
1160            root,
1161            "mid.harn",
1162            "import \"./leaf\"\npub fn mid_fn() { leaf_fn() }\n",
1163        );
1164        let entry = write_file(root, "entry.harn", "import \"./mid\"\nmid_fn()\n");
1165
1166        let graph = build(std::slice::from_ref(&entry));
1167        let imported = graph
1168            .imported_names_for_file(&entry)
1169            .expect("entry imports should resolve");
1170        // Wildcard import of mid exposes mid_fn (pub) but not leaf_fn.
1171        assert!(imported.contains("mid_fn"));
1172        assert!(!imported.contains("leaf_fn"));
1173
1174        // The transitively loaded module is known to the graph even though
1175        // the seed only included entry.harn.
1176        let leaf_path = root.join("leaf.harn");
1177        assert!(graph.definition_of(&leaf_path, "leaf_fn").is_some());
1178    }
1179
1180    #[test]
1181    fn imported_names_returns_none_when_import_unresolved() {
1182        let tmp = tempfile::tempdir().unwrap();
1183        let root = tmp.path();
1184        let entry = write_file(root, "entry.harn", "import \"./does_not_exist\"\n");
1185
1186        let graph = build(std::slice::from_ref(&entry));
1187        assert!(graph.imported_names_for_file(&entry).is_none());
1188    }
1189
1190    #[test]
1191    fn selective_imports_contribute_only_requested_names() {
1192        let tmp = tempfile::tempdir().unwrap();
1193        let root = tmp.path();
1194        write_file(root, "util.harn", "pub fn a() { 1 }\npub fn b() { 2 }\n");
1195        let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
1196
1197        let graph = build(std::slice::from_ref(&entry));
1198        let imported = graph
1199            .imported_names_for_file(&entry)
1200            .expect("entry imports should resolve");
1201        assert!(imported.contains("a"));
1202        assert!(!imported.contains("b"));
1203    }
1204
1205    #[test]
1206    fn stdlib_imports_resolve_to_embedded_sources() {
1207        let tmp = tempfile::tempdir().unwrap();
1208        let root = tmp.path();
1209        let entry = write_file(root, "entry.harn", "import \"std/math\"\nclamp(5, 0, 10)\n");
1210
1211        let graph = build(std::slice::from_ref(&entry));
1212        let imported = graph
1213            .imported_names_for_file(&entry)
1214            .expect("std/math should resolve");
1215        // `clamp` is defined in stdlib_math.harn as `pub fn clamp(...)`.
1216        assert!(imported.contains("clamp"));
1217    }
1218
1219    #[test]
1220    fn stdlib_internal_imports_resolve_without_leaking_to_callers() {
1221        let tmp = tempfile::tempdir().unwrap();
1222        let root = tmp.path();
1223        let entry = write_file(
1224            root,
1225            "entry.harn",
1226            "import { process_run } from \"std/runtime\"\nprocess_run([\"echo\", \"ok\"])\n",
1227        );
1228
1229        let graph = build(std::slice::from_ref(&entry));
1230        let entry_imports = graph
1231            .imported_names_for_file(&entry)
1232            .expect("std/runtime should resolve");
1233        assert!(entry_imports.contains("process_run"));
1234        assert!(
1235            !entry_imports.contains("filter_nil"),
1236            "private std/runtime dependency leaked to caller"
1237        );
1238
1239        let runtime_path = stdlib::stdlib_virtual_path("runtime");
1240        let runtime_imports = graph
1241            .imported_names_for_file(&runtime_path)
1242            .expect("std/runtime internal imports should resolve");
1243        assert!(runtime_imports.contains("filter_nil"));
1244    }
1245
1246    #[test]
1247    fn runtime_stdlib_import_surface_resolves_to_embedded_sources() {
1248        let tmp = tempfile::tempdir().unwrap();
1249        let entry_path = write_file(tmp.path(), "entry.harn", "");
1250
1251        for source in harn_stdlib::STDLIB_SOURCES {
1252            let import_path = format!("std/{}", source.module);
1253            assert!(
1254                resolve_import_path(&entry_path, &import_path).is_some(),
1255                "{import_path} should resolve in the module graph"
1256            );
1257        }
1258    }
1259
1260    #[test]
1261    fn stdlib_imports_expose_type_declarations() {
1262        let tmp = tempfile::tempdir().unwrap();
1263        let root = tmp.path();
1264        let entry = write_file(
1265            root,
1266            "entry.harn",
1267            "import \"std/triggers\"\nlet provider = \"github\"\n",
1268        );
1269
1270        let graph = build(std::slice::from_ref(&entry));
1271        let decls = graph
1272            .imported_type_declarations_for_file(&entry)
1273            .expect("std/triggers type declarations should resolve");
1274        let names: HashSet<String> = decls
1275            .iter()
1276            .filter_map(type_decl_name)
1277            .map(ToString::to_string)
1278            .collect();
1279        assert!(names.contains("TriggerEvent"));
1280        assert!(names.contains("ProviderPayload"));
1281        assert!(names.contains("SignatureStatus"));
1282    }
1283
1284    #[test]
1285    fn stdlib_imports_expose_callable_declarations() {
1286        let tmp = tempfile::tempdir().unwrap();
1287        let root = tmp.path();
1288        let entry = write_file(
1289            root,
1290            "entry.harn",
1291            "import { select_from } from \"std/tui\"\nlet item = \"alpha\"\n",
1292        );
1293
1294        let graph = build(std::slice::from_ref(&entry));
1295        let decls = graph
1296            .imported_callable_declarations_for_file(&entry)
1297            .expect("std/tui callable declarations should resolve");
1298        let names: HashSet<String> = decls
1299            .iter()
1300            .filter_map(callable_decl_name)
1301            .map(ToString::to_string)
1302            .collect();
1303        assert!(names.contains("select_from"));
1304    }
1305
1306    #[test]
1307    fn package_export_map_resolves_declared_module() {
1308        let tmp = tempfile::tempdir().unwrap();
1309        let root = tmp.path();
1310        let packages = root.join(".harn/packages/acme/runtime");
1311        fs::create_dir_all(&packages).unwrap();
1312        fs::write(
1313            root.join(".harn/packages/acme/harn.toml"),
1314            "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1315        )
1316        .unwrap();
1317        fs::write(
1318            packages.join("capabilities.harn"),
1319            "pub fn exported_capability() { 1 }\n",
1320        )
1321        .unwrap();
1322        let entry = write_file(
1323            root,
1324            "entry.harn",
1325            "import \"acme/capabilities\"\nexported_capability()\n",
1326        );
1327
1328        let graph = build(std::slice::from_ref(&entry));
1329        let imported = graph
1330            .imported_names_for_file(&entry)
1331            .expect("package export should resolve");
1332        assert!(imported.contains("exported_capability"));
1333    }
1334
1335    #[test]
1336    fn package_direct_import_cannot_escape_packages_root() {
1337        let tmp = tempfile::tempdir().unwrap();
1338        let root = tmp.path();
1339        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1340        fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1341        let entry = write_file(root, "entry.harn", "");
1342
1343        let resolved = resolve_import_path(&entry, "acme/../../secret");
1344        assert!(resolved.is_none(), "package import escaped package root");
1345    }
1346
1347    #[test]
1348    fn package_export_map_cannot_escape_package_root() {
1349        let tmp = tempfile::tempdir().unwrap();
1350        let root = tmp.path();
1351        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1352        fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1353        fs::write(
1354            root.join(".harn/packages/acme/harn.toml"),
1355            "[exports]\nleak = \"../../secret.harn\"\n",
1356        )
1357        .unwrap();
1358        let entry = write_file(root, "entry.harn", "");
1359
1360        let resolved = resolve_import_path(&entry, "acme/leak");
1361        assert!(resolved.is_none(), "package export escaped package root");
1362    }
1363
1364    #[test]
1365    fn package_export_map_allows_symlinked_path_dependencies() {
1366        let tmp = tempfile::tempdir().unwrap();
1367        let root = tmp.path();
1368        let source = root.join("source-package");
1369        fs::create_dir_all(source.join("runtime")).unwrap();
1370        fs::write(
1371            source.join("harn.toml"),
1372            "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1373        )
1374        .unwrap();
1375        fs::write(
1376            source.join("runtime/capabilities.harn"),
1377            "pub fn exported_capability() { 1 }\n",
1378        )
1379        .unwrap();
1380        fs::create_dir_all(root.join(".harn/packages")).unwrap();
1381        #[cfg(unix)]
1382        std::os::unix::fs::symlink(&source, root.join(".harn/packages/acme")).unwrap();
1383        #[cfg(windows)]
1384        std::os::windows::fs::symlink_dir(&source, root.join(".harn/packages/acme")).unwrap();
1385        let entry = write_file(root, "entry.harn", "");
1386
1387        let resolved = resolve_import_path(&entry, "acme/capabilities")
1388            .expect("symlinked package export should resolve");
1389        assert!(resolved.ends_with("runtime/capabilities.harn"));
1390    }
1391
1392    #[test]
1393    fn package_imports_resolve_from_nested_package_module() {
1394        let tmp = tempfile::tempdir().unwrap();
1395        let root = tmp.path();
1396        fs::create_dir_all(root.join(".git")).unwrap();
1397        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1398        fs::create_dir_all(root.join(".harn/packages/shared")).unwrap();
1399        fs::write(
1400            root.join(".harn/packages/shared/lib.harn"),
1401            "pub fn shared_helper() { 1 }\n",
1402        )
1403        .unwrap();
1404        fs::write(
1405            root.join(".harn/packages/acme/lib.harn"),
1406            "import \"shared\"\npub fn use_shared() { shared_helper() }\n",
1407        )
1408        .unwrap();
1409        let entry = write_file(root, "entry.harn", "import \"acme\"\nuse_shared()\n");
1410
1411        let graph = build(std::slice::from_ref(&entry));
1412        let imported = graph
1413            .imported_names_for_file(&entry)
1414            .expect("nested package import should resolve");
1415        assert!(imported.contains("use_shared"));
1416        let acme_path = root.join(".harn/packages/acme/lib.harn");
1417        let acme_imports = graph
1418            .imported_names_for_file(&acme_path)
1419            .expect("package module imports should resolve");
1420        assert!(acme_imports.contains("shared_helper"));
1421    }
1422
1423    #[test]
1424    fn unknown_stdlib_import_is_unresolved() {
1425        let tmp = tempfile::tempdir().unwrap();
1426        let root = tmp.path();
1427        let entry = write_file(root, "entry.harn", "import \"std/does_not_exist\"\n");
1428
1429        let graph = build(std::slice::from_ref(&entry));
1430        assert!(
1431            graph.imported_names_for_file(&entry).is_none(),
1432            "unknown std module should fail resolution and disable strict check"
1433        );
1434    }
1435
1436    #[test]
1437    fn import_cycles_do_not_loop_forever() {
1438        let tmp = tempfile::tempdir().unwrap();
1439        let root = tmp.path();
1440        write_file(root, "a.harn", "import \"./b\"\npub fn a_fn() { 1 }\n");
1441        write_file(root, "b.harn", "import \"./a\"\npub fn b_fn() { 1 }\n");
1442        let entry = root.join("a.harn");
1443
1444        // Just ensuring this terminates and yields sensible names.
1445        let graph = build(std::slice::from_ref(&entry));
1446        let imported = graph
1447            .imported_names_for_file(&entry)
1448            .expect("cyclic imports still resolve to known exports");
1449        assert!(imported.contains("b_fn"));
1450    }
1451
1452    #[test]
1453    fn pub_import_selective_re_exports_named_symbols() {
1454        let tmp = tempfile::tempdir().unwrap();
1455        let root = tmp.path();
1456        write_file(
1457            root,
1458            "src.harn",
1459            "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1460        );
1461        write_file(root, "facade.harn", "pub import { alpha } from \"./src\"\n");
1462        let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1463
1464        let graph = build(std::slice::from_ref(&entry));
1465        let imported = graph
1466            .imported_names_for_file(&entry)
1467            .expect("entry should resolve");
1468        assert!(imported.contains("alpha"), "selective re-export missing");
1469        assert!(
1470            !imported.contains("beta"),
1471            "non-listed name leaked through facade"
1472        );
1473
1474        let facade_path = root.join("facade.harn");
1475        let def = graph
1476            .definition_of(&facade_path, "alpha")
1477            .expect("definition_of should chase re-export");
1478        assert!(def.file.ends_with("src.harn"));
1479    }
1480
1481    #[test]
1482    fn pub_import_wildcard_re_exports_full_surface() {
1483        let tmp = tempfile::tempdir().unwrap();
1484        let root = tmp.path();
1485        write_file(
1486            root,
1487            "src.harn",
1488            "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1489        );
1490        write_file(root, "facade.harn", "pub import \"./src\"\n");
1491        let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1492
1493        let graph = build(std::slice::from_ref(&entry));
1494        let imported = graph
1495            .imported_names_for_file(&entry)
1496            .expect("entry should resolve");
1497        assert!(imported.contains("alpha"));
1498        assert!(imported.contains("beta"));
1499    }
1500
1501    #[test]
1502    fn pub_import_chain_resolves_definition_to_origin() {
1503        let tmp = tempfile::tempdir().unwrap();
1504        let root = tmp.path();
1505        write_file(root, "inner.harn", "pub fn deep() { 1 }\n");
1506        write_file(
1507            root,
1508            "middle.harn",
1509            "pub import { deep } from \"./inner\"\n",
1510        );
1511        write_file(
1512            root,
1513            "outer.harn",
1514            "pub import { deep } from \"./middle\"\n",
1515        );
1516        let entry = write_file(
1517            root,
1518            "entry.harn",
1519            "import { deep } from \"./outer\"\ndeep()\n",
1520        );
1521
1522        let graph = build(std::slice::from_ref(&entry));
1523        let def = graph
1524            .definition_of(&entry, "deep")
1525            .expect("definition_of should follow chain");
1526        assert!(def.file.ends_with("inner.harn"));
1527
1528        let imported = graph
1529            .imported_names_for_file(&entry)
1530            .expect("entry should resolve");
1531        assert!(imported.contains("deep"));
1532    }
1533
1534    #[test]
1535    fn duplicate_pub_import_reports_re_export_conflict() {
1536        let tmp = tempfile::tempdir().unwrap();
1537        let root = tmp.path();
1538        write_file(root, "a.harn", "pub fn shared() { 1 }\n");
1539        write_file(root, "b.harn", "pub fn shared() { 2 }\n");
1540        let facade = write_file(
1541            root,
1542            "facade.harn",
1543            "pub import { shared } from \"./a\"\npub import { shared } from \"./b\"\n",
1544        );
1545
1546        let graph = build(std::slice::from_ref(&facade));
1547        let conflicts = graph.re_export_conflicts(&facade);
1548        assert_eq!(
1549            conflicts.len(),
1550            1,
1551            "expected exactly one re-export conflict, got {:?}",
1552            conflicts
1553        );
1554        assert_eq!(conflicts[0].name, "shared");
1555        assert_eq!(conflicts[0].sources.len(), 2);
1556    }
1557
1558    #[test]
1559    fn cross_directory_cycle_does_not_explode_module_count() {
1560        // Regression: two files in sibling directories that import each
1561        // other produced a fresh path spelling on every round-trip
1562        // (`../runtime/../context/../runtime/...`), and `build()`'s
1563        // `seen` set deduped on the raw spelling rather than the
1564        // canonical path. The walk only terminated when `PATH_MAX` was
1565        // hit — 1024 on macOS, 4096 on Linux — so Linux re-parsed the
1566        // same pair thousands of times until it ran out of memory.
1567        let tmp = tempfile::tempdir().unwrap();
1568        let root = tmp.path();
1569        let context = root.join("context");
1570        let runtime = root.join("runtime");
1571        fs::create_dir_all(&context).unwrap();
1572        fs::create_dir_all(&runtime).unwrap();
1573        write_file(
1574            &context,
1575            "a.harn",
1576            "import \"../runtime/b\"\npub fn a_fn() { 1 }\n",
1577        );
1578        write_file(
1579            &runtime,
1580            "b.harn",
1581            "import \"../context/a\"\npub fn b_fn() { 1 }\n",
1582        );
1583        let entry = context.join("a.harn");
1584
1585        let graph = build(std::slice::from_ref(&entry));
1586        // The graph should contain exactly the two real files, keyed by
1587        // their canonical paths. Pre-fix this was thousands of entries.
1588        assert_eq!(
1589            graph.modules.len(),
1590            2,
1591            "cross-directory cycle loaded {} modules, expected 2",
1592            graph.modules.len()
1593        );
1594        let imported = graph
1595            .imported_names_for_file(&entry)
1596            .expect("cyclic imports still resolve to known exports");
1597        assert!(imported.contains("b_fn"));
1598    }
1599}