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