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