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