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