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