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) => selective.iter().cloned().collect(),
614            };
615            for name in &names_to_collect {
616                let mut visited = HashSet::new();
617                if let Some(decl) = self.find_exported_type_decl(import_path, name, &mut visited) {
618                    decls.push(decl);
619                }
620            }
621            // Every type alias / struct / enum / interface declared in the
622            // imported module is visible to the *typechecker*, `pub` or not:
623            // an imported fn's signature may reference a module-private alias
624            // ("options: PickKeysOptions"), and without its definition the
625            // caller sees only a phantom `Named(...)` and skips contract
626            // checks. This visibility is typing-only — name-level import
627            // privacy is still enforced by `non_exported_selective_imports`
628            // and the runtime loader, which reject importing a non-`pub`
629            // type by name.
630            for ty_decl in &imported.type_declarations {
631                if type_decl_name(ty_decl).is_some() {
632                    decls.push(ty_decl.clone());
633                }
634            }
635        }
636        Some(decls)
637    }
638
639    /// Collect callable declarations made visible to `file` by its imports.
640    /// Only signatures are consumed by the type checker; imported bodies
641    /// remain owned by their defining modules.
642    pub fn imported_callable_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
643        let file = normalize_path(file);
644        let module = self.modules.get(&file)?;
645        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
646            return None;
647        }
648
649        let mut decls = Vec::new();
650        for import in &module.imports {
651            let import_path = import.path.as_ref()?;
652            let imported = self
653                .modules
654                .get(import_path)
655                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
656            let selective_import = import.selective_names.is_some();
657            let names_to_collect: Vec<String> = match &import.selective_names {
658                None => imported.exports.iter().cloned().collect(),
659                Some(selective) => selective.iter().cloned().collect(),
660            };
661            for name in &names_to_collect {
662                if selective_import || imported.own_exports.contains(name) {
663                    if let Some(decl) = imported
664                        .callable_declarations
665                        .iter()
666                        .find(|decl| callable_decl_name(decl) == Some(name.as_str()))
667                    {
668                        decls.push(decl.clone());
669                        continue;
670                    }
671                }
672                let mut visited = HashSet::new();
673                if let Some(decl) =
674                    self.find_exported_callable_decl(import_path, name, &mut visited)
675                {
676                    decls.push(decl);
677                }
678            }
679        }
680        Some(decls)
681    }
682
683    /// Walk a module's local type declarations and re-export chains to find
684    /// the SNode for an exported type/struct/enum/interface named `name`.
685    fn find_exported_type_decl(
686        &self,
687        path: &Path,
688        name: &str,
689        visited: &mut HashSet<PathBuf>,
690    ) -> Option<SNode> {
691        let canonical = normalize_path(path);
692        if !visited.insert(canonical.clone()) {
693            return None;
694        }
695        let module = self
696            .modules
697            .get(&canonical)
698            .or_else(|| self.modules.get(path))?;
699        for decl in &module.type_declarations {
700            if type_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
701                return Some(decl.clone());
702            }
703        }
704        if let Some(sources) = module.selective_re_exports.get(name) {
705            for source in sources {
706                if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
707                    return Some(decl);
708                }
709            }
710        }
711        for source in &module.wildcard_re_export_paths {
712            if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
713                return Some(decl);
714            }
715        }
716        None
717    }
718
719    fn find_exported_callable_decl(
720        &self,
721        path: &Path,
722        name: &str,
723        visited: &mut HashSet<PathBuf>,
724    ) -> Option<SNode> {
725        let canonical = normalize_path(path);
726        if !visited.insert(canonical.clone()) {
727            return None;
728        }
729        let module = self
730            .modules
731            .get(&canonical)
732            .or_else(|| self.modules.get(path))?;
733        for decl in &module.callable_declarations {
734            if callable_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
735                return Some(decl.clone());
736            }
737        }
738        if let Some(sources) = module.selective_re_exports.get(name) {
739            for source in sources {
740                if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
741                    return Some(decl);
742                }
743            }
744        }
745        for source in &module.wildcard_re_export_paths {
746            if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
747                return Some(decl);
748            }
749        }
750        None
751    }
752
753    /// Find the definition of `name` visible from `file`.
754    ///
755    /// Recurses through `pub import` re-export chains so go-to-definition
756    /// lands on the symbol's actual declaration site instead of the facade
757    /// module that forwarded it.
758    pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
759        let mut visited = HashSet::new();
760        self.definition_of_inner(file, name, &mut visited)
761    }
762
763    fn definition_of_inner(
764        &self,
765        file: &Path,
766        name: &str,
767        visited: &mut HashSet<PathBuf>,
768    ) -> Option<DefSite> {
769        let file = normalize_path(file);
770        if !visited.insert(file.clone()) {
771            return None;
772        }
773        let current = self.modules.get(&file)?;
774
775        if let Some(local) = current.declarations.get(name) {
776            return Some(local.clone());
777        }
778
779        // `pub import { name } from "..."` — follow the first recorded
780        // source. Conflicting re-exports surface separately as
781        // diagnostics; here we just pick a canonical destination so
782        // go-to-definition lands somewhere useful.
783        if let Some(sources) = current.selective_re_exports.get(name) {
784            for source in sources {
785                if let Some(def) = self.definition_of_inner(source, name, visited) {
786                    return Some(def);
787                }
788            }
789        }
790
791        // `pub import "..."` — chase each wildcard re-export source.
792        for source in &current.wildcard_re_export_paths {
793            if let Some(def) = self.definition_of_inner(source, name, visited) {
794                return Some(def);
795            }
796        }
797
798        // Private selective imports.
799        for import in &current.imports {
800            let Some(selective_names) = &import.selective_names else {
801                continue;
802            };
803            if !selective_names.contains(name) {
804                continue;
805            }
806            if let Some(path) = &import.path {
807                if let Some(def) = self.definition_of_inner(path, name, visited) {
808                    return Some(def);
809                }
810            }
811        }
812
813        // Private wildcard imports.
814        for import in &current.imports {
815            if import.selective_names.is_some() {
816                continue;
817            }
818            if let Some(path) = &import.path {
819                if let Some(def) = self.definition_of_inner(path, name, visited) {
820                    return Some(def);
821                }
822            }
823        }
824
825        None
826    }
827
828    /// Diagnostics for re-export conflicts inside `file`. Each diagnostic
829    /// names the conflicting symbol and the modules that contributed it,
830    /// so check-time errors can be precise.
831    pub fn re_export_conflicts(&self, file: &Path) -> Vec<ReExportConflict> {
832        let file = normalize_path(file);
833        let Some(module) = self.modules.get(&file) else {
834            return Vec::new();
835        };
836
837        // Build, for each re-exported name, the set of source modules it
838        // could resolve to. Names that resolve to more than one source are
839        // ambiguous and reported.
840        let mut sources: HashMap<String, Vec<PathBuf>> = HashMap::new();
841
842        for (name, srcs) in &module.selective_re_exports {
843            sources
844                .entry(name.clone())
845                .or_default()
846                .extend(srcs.iter().cloned());
847        }
848        for src in &module.wildcard_re_export_paths {
849            let canonical = normalize_path(src);
850            let Some(src_module) = self
851                .modules
852                .get(&canonical)
853                .or_else(|| self.modules.get(src))
854            else {
855                continue;
856            };
857            for name in &src_module.exports {
858                sources
859                    .entry(name.clone())
860                    .or_default()
861                    .push(canonical.clone());
862            }
863        }
864
865        // A re-export that collides with a locally exported declaration is
866        // also an error: the facade module cannot expose two different
867        // bindings under the same name.
868        for name in &module.own_exports {
869            if let Some(entry) = sources.get_mut(name) {
870                entry.push(file.clone());
871            }
872        }
873
874        let mut conflicts = Vec::new();
875        for (name, mut srcs) in sources {
876            srcs.sort();
877            srcs.dedup();
878            if srcs.len() > 1 {
879                conflicts.push(ReExportConflict {
880                    name,
881                    sources: srcs,
882                });
883            }
884        }
885        conflicts.sort_by(|a, b| a.name.cmp(&b.name));
886        conflicts
887    }
888
889    /// Selective imports in `file` that name a symbol the target module
890    /// declares but does not export — a non-`pub` function in a module that
891    /// has opted into explicit exports by marking at least one function `pub`.
892    ///
893    /// Such names are private: importing them by name is no more valid than a
894    /// wildcard import reaching them, and matches the strict visibility of
895    /// TypeScript, Rust, and Go. This is the single source of truth for that
896    /// determination — the CLI maps the result onto import spans and emits
897    /// `HARN-IMP-002`, and the runtime loader enforces the same rule. A module
898    /// that marks nothing `pub` exports nothing, so selectively importing any
899    /// name it declares is flagged.
900    pub fn non_exported_selective_imports(&self, file: &Path) -> Vec<NonExportedImport> {
901        let file = normalize_path(file);
902        let Some(module) = self.modules.get(&file) else {
903            return Vec::new();
904        };
905
906        let mut out = Vec::new();
907        for import in &module.imports {
908            let Some(selective) = &import.selective_names else {
909                continue;
910            };
911            let Some(import_path) = &import.path else {
912                continue;
913            };
914            let Some(target) = self
915                .modules
916                .get(import_path)
917                .or_else(|| self.modules.get(&normalize_path(import_path)))
918            else {
919                continue;
920            };
921            for name in selective {
922                // Declared in the target but absent from its export surface
923                // (and not a re-export, which lives in `exports`, not
924                // `declarations`).
925                if target.declarations.contains_key(name) && !target.exports.contains(name) {
926                    out.push(NonExportedImport {
927                        name: name.clone(),
928                        module: import.raw_path.clone(),
929                    });
930                }
931            }
932        }
933        out.sort_by(|a, b| (&a.name, &a.module).cmp(&(&b.name, &b.module)));
934        out.dedup();
935        out
936    }
937}
938
939/// A duplicate or ambiguous re-export inside a single module. Reported by
940/// [`ModuleGraph::re_export_conflicts`].
941#[derive(Debug, Clone, PartialEq, Eq)]
942pub struct ReExportConflict {
943    pub name: String,
944    pub sources: Vec<PathBuf>,
945}
946
947/// A selective import of a name the target module declares but does not
948/// export. Reported by [`ModuleGraph::non_exported_selective_imports`].
949#[derive(Debug, Clone, PartialEq, Eq)]
950pub struct NonExportedImport {
951    /// The non-exported name the import requested.
952    pub name: String,
953    /// The module path exactly as written in the import statement.
954    pub module: String,
955}
956
957fn load_module(path: &Path) -> (ModuleInfo, Option<ParsedModuleSource>) {
958    let Some(source) = read_module_source(path) else {
959        return (ModuleInfo::default(), None);
960    };
961    let mut lexer = harn_lexer::Lexer::new(&source);
962    let tokens = match lexer.tokenize() {
963        Ok(tokens) => tokens,
964        Err(_) => return (ModuleInfo::default(), None),
965    };
966    let mut parser = Parser::new(tokens);
967    let program = match parser.parse() {
968        Ok(program) => program,
969        Err(_) => return (ModuleInfo::default(), None),
970    };
971
972    let mut module = ModuleInfo::default();
973    for node in &program {
974        collect_module_info(path, node, &mut module);
975        collect_type_declarations(node, &mut module.type_declarations);
976        collect_callable_declarations(node, &mut module.callable_declarations);
977    }
978    // Seed the transitive `exports` set from local exports plus selective
979    // re-export names. Wildcard re-exports are folded in by
980    // [`resolve_re_exports`] after every module has been loaded.
981    module.exports.extend(module.own_exports.iter().cloned());
982    module
983        .exports
984        .extend(module.selective_re_exports.keys().cloned());
985    let parsed = ParsedModuleSource { source, program };
986    (module, Some(parsed))
987}
988
989/// Extract the stdlib module name when `path` is a `<std>/<name>`
990/// virtual path, otherwise `None`.
991fn stdlib_module_from_path(path: &Path) -> Option<&str> {
992    let s = path.to_str()?;
993    s.strip_prefix("<std>/")
994}
995
996fn collect_module_info(file: &Path, snode: &SNode, module: &mut ModuleInfo) {
997    match &snode.node {
998        Node::FnDecl {
999            name,
1000            params,
1001            is_pub,
1002            ..
1003        } => {
1004            if *is_pub {
1005                module.own_exports.insert(name.clone());
1006            }
1007            module.declarations.insert(
1008                name.clone(),
1009                decl_site(file, snode.span, name, DefKind::Function),
1010            );
1011            for param_name in params.iter().map(|param| param.name.clone()) {
1012                module.declarations.insert(
1013                    param_name.clone(),
1014                    decl_site(file, snode.span, &param_name, DefKind::Parameter),
1015                );
1016            }
1017        }
1018        Node::Pipeline { name, is_pub, .. } => {
1019            if *is_pub {
1020                module.own_exports.insert(name.clone());
1021            }
1022            module.declarations.insert(
1023                name.clone(),
1024                decl_site(file, snode.span, name, DefKind::Pipeline),
1025            );
1026        }
1027        Node::ToolDecl { name, is_pub, .. } => {
1028            if *is_pub {
1029                module.own_exports.insert(name.clone());
1030            }
1031            module.declarations.insert(
1032                name.clone(),
1033                decl_site(file, snode.span, name, DefKind::Tool),
1034            );
1035        }
1036        Node::SkillDecl { name, is_pub, .. } => {
1037            if *is_pub {
1038                module.own_exports.insert(name.clone());
1039            }
1040            module.declarations.insert(
1041                name.clone(),
1042                decl_site(file, snode.span, name, DefKind::Skill),
1043            );
1044        }
1045        Node::StructDecl { name, is_pub, .. } => {
1046            if *is_pub {
1047                module.own_exports.insert(name.clone());
1048            }
1049            module.declarations.insert(
1050                name.clone(),
1051                decl_site(file, snode.span, name, DefKind::Struct),
1052            );
1053        }
1054        Node::EnumDecl { name, is_pub, .. } => {
1055            if *is_pub {
1056                module.own_exports.insert(name.clone());
1057            }
1058            module.declarations.insert(
1059                name.clone(),
1060                decl_site(file, snode.span, name, DefKind::Enum),
1061            );
1062        }
1063        Node::InterfaceDecl { name, .. } => {
1064            module.own_exports.insert(name.clone());
1065            module.declarations.insert(
1066                name.clone(),
1067                decl_site(file, snode.span, name, DefKind::Interface),
1068            );
1069        }
1070        Node::TypeDecl { name, is_pub, .. } => {
1071            if *is_pub {
1072                module.own_exports.insert(name.clone());
1073            }
1074            module.declarations.insert(
1075                name.clone(),
1076                decl_site(file, snode.span, name, DefKind::Type),
1077            );
1078        }
1079        Node::LetBinding { pattern, .. } | Node::VarBinding { pattern, .. } => {
1080            for name in pattern_names(pattern) {
1081                module.declarations.insert(
1082                    name.clone(),
1083                    decl_site(file, snode.span, &name, DefKind::Variable),
1084                );
1085            }
1086        }
1087        Node::ImportDecl { path, is_pub } => {
1088            let import_path = resolve_import_path(file, path);
1089            if import_path.is_none() {
1090                module.has_unresolved_wildcard_import = true;
1091            }
1092            if *is_pub {
1093                if let Some(resolved) = &import_path {
1094                    module
1095                        .wildcard_re_export_paths
1096                        .push(normalize_path(resolved));
1097                }
1098            }
1099            module.imports.push(ImportRef {
1100                raw_path: path.clone(),
1101                path: import_path,
1102                selective_names: None,
1103            });
1104        }
1105        Node::SelectiveImport {
1106            names,
1107            path,
1108            is_pub,
1109        } => {
1110            let import_path = resolve_import_path(file, path);
1111            if import_path.is_none() {
1112                module.has_unresolved_selective_import = true;
1113            }
1114            if *is_pub {
1115                if let Some(resolved) = &import_path {
1116                    let canonical = normalize_path(resolved);
1117                    for name in names {
1118                        module
1119                            .selective_re_exports
1120                            .entry(name.clone())
1121                            .or_default()
1122                            .push(canonical.clone());
1123                    }
1124                }
1125            }
1126            let names: HashSet<String> = names.iter().cloned().collect();
1127            module.selective_import_names.extend(names.iter().cloned());
1128            module.imports.push(ImportRef {
1129                raw_path: path.clone(),
1130                path: import_path,
1131                selective_names: Some(names),
1132            });
1133        }
1134        Node::AttributedDecl { inner, .. } => {
1135            collect_module_info(file, inner, module);
1136        }
1137        _ => {}
1138    }
1139}
1140
1141fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1142    match &snode.node {
1143        Node::TypeDecl { .. }
1144        | Node::StructDecl { .. }
1145        | Node::EnumDecl { .. }
1146        | Node::InterfaceDecl { .. } => decls.push(snode.clone()),
1147        Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
1148        _ => {}
1149    }
1150}
1151
1152fn collect_callable_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1153    match &snode.node {
1154        Node::FnDecl { .. } | Node::Pipeline { .. } | Node::ToolDecl { .. } => {
1155            decls.push(snode.clone());
1156        }
1157        Node::AttributedDecl { inner, .. } => collect_callable_declarations(inner, decls),
1158        _ => {}
1159    }
1160}
1161
1162fn type_decl_name(snode: &SNode) -> Option<&str> {
1163    match &snode.node {
1164        Node::TypeDecl { name, .. }
1165        | Node::StructDecl { name, .. }
1166        | Node::EnumDecl { name, .. }
1167        | Node::InterfaceDecl { name, .. } => Some(name.as_str()),
1168        _ => None,
1169    }
1170}
1171
1172fn callable_decl_name(snode: &SNode) -> Option<&str> {
1173    match &snode.node {
1174        Node::FnDecl { name, .. } | Node::Pipeline { name, .. } | Node::ToolDecl { name, .. } => {
1175            Some(name.as_str())
1176        }
1177        Node::AttributedDecl { inner, .. } => callable_decl_name(inner),
1178        _ => None,
1179    }
1180}
1181
1182fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
1183    DefSite {
1184        name: name.to_string(),
1185        file: file.to_path_buf(),
1186        kind,
1187        span,
1188    }
1189}
1190
1191fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
1192    match pattern {
1193        BindingPattern::Identifier(name) => vec![name.clone()],
1194        BindingPattern::Dict(fields) => fields
1195            .iter()
1196            .filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
1197            .collect(),
1198        BindingPattern::List(elements) => elements
1199            .iter()
1200            .map(|element| element.name.clone())
1201            .collect(),
1202        BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
1203    }
1204}
1205
1206fn normalize_path(path: &Path) -> PathBuf {
1207    if stdlib_module_from_path(path).is_some() {
1208        return path.to_path_buf();
1209    }
1210    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215    use super::*;
1216    use std::fs;
1217
1218    fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
1219        let path = dir.join(name);
1220        fs::write(&path, contents).unwrap();
1221        path
1222    }
1223
1224    #[test]
1225    fn importers_of_finds_direct_dependents() {
1226        let tmp = tempfile::tempdir().unwrap();
1227        let root = tmp.path();
1228        let leaf = write_file(root, "leaf.harn", "pub fn leaf() { 1 }\n");
1229        write_file(root, "a.harn", "import \"./leaf\"\nleaf()\n");
1230        write_file(root, "b.harn", "import { leaf } from \"./leaf\"\nleaf()\n");
1231        let entry = write_file(root, "entry.harn", "import \"./a\"\nimport \"./b\"\n");
1232
1233        let graph = build(std::slice::from_ref(&entry));
1234        let importers = graph.importers_of(&leaf);
1235        let names: Vec<String> = importers
1236            .iter()
1237            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1238            .collect();
1239        assert!(names.contains(&"a.harn".to_string()));
1240        assert!(names.contains(&"b.harn".to_string()));
1241        assert!(!names.contains(&"entry.harn".to_string()));
1242    }
1243
1244    #[test]
1245    fn recursive_build_loads_transitively_imported_modules() {
1246        let tmp = tempfile::tempdir().unwrap();
1247        let root = tmp.path();
1248        write_file(root, "leaf.harn", "pub fn leaf_fn() { 1 }\n");
1249        write_file(
1250            root,
1251            "mid.harn",
1252            "import \"./leaf\"\npub fn mid_fn() { leaf_fn() }\n",
1253        );
1254        let entry = write_file(root, "entry.harn", "import \"./mid\"\nmid_fn()\n");
1255
1256        let graph = build(std::slice::from_ref(&entry));
1257        let imported = graph
1258            .imported_names_for_file(&entry)
1259            .expect("entry imports should resolve");
1260        // Wildcard import of mid exposes mid_fn (pub) but not leaf_fn.
1261        assert!(imported.contains("mid_fn"));
1262        assert!(!imported.contains("leaf_fn"));
1263
1264        // The transitively loaded module is known to the graph even though
1265        // the seed only included entry.harn.
1266        let leaf_path = root.join("leaf.harn");
1267        assert!(graph.definition_of(&leaf_path, "leaf_fn").is_some());
1268    }
1269
1270    #[test]
1271    fn imported_names_returns_none_when_import_unresolved() {
1272        let tmp = tempfile::tempdir().unwrap();
1273        let root = tmp.path();
1274        let entry = write_file(root, "entry.harn", "import \"./does_not_exist\"\n");
1275
1276        let graph = build(std::slice::from_ref(&entry));
1277        assert!(graph.imported_names_for_file(&entry).is_none());
1278    }
1279
1280    #[test]
1281    fn selective_imports_contribute_only_requested_names() {
1282        let tmp = tempfile::tempdir().unwrap();
1283        let root = tmp.path();
1284        write_file(root, "util.harn", "pub fn a() { 1 }\npub fn b() { 2 }\n");
1285        let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
1286
1287        let graph = build(std::slice::from_ref(&entry));
1288        let imported = graph
1289            .imported_names_for_file(&entry)
1290            .expect("entry imports should resolve");
1291        assert!(imported.contains("a"));
1292        assert!(!imported.contains("b"));
1293    }
1294
1295    #[test]
1296    fn non_exported_selective_import_is_flagged_when_module_has_pub() {
1297        let tmp = tempfile::tempdir().unwrap();
1298        let root = tmp.path();
1299        write_file(root, "lib.harn", "pub fn api() { 1 }\nfn helper() { 2 }\n");
1300        let entry = write_file(root, "entry.harn", "import { helper } from \"./lib\"\n");
1301
1302        let graph = build(std::slice::from_ref(&entry));
1303        let offenders = graph.non_exported_selective_imports(&entry);
1304        assert_eq!(offenders.len(), 1);
1305        assert_eq!(offenders[0].name, "helper");
1306        assert_eq!(offenders[0].module, "./lib");
1307
1308        // Importing the `pub` name is fine.
1309        let entry_ok = write_file(root, "entry_ok.harn", "import { api } from \"./lib\"\n");
1310        let graph_ok = build(std::slice::from_ref(&entry_ok));
1311        assert!(graph_ok
1312            .non_exported_selective_imports(&entry_ok)
1313            .is_empty());
1314    }
1315
1316    #[test]
1317    fn selective_import_from_zero_pub_module_is_flagged() {
1318        let tmp = tempfile::tempdir().unwrap();
1319        let root = tmp.path();
1320        // A module with no `pub` markers exports nothing — Harn has no
1321        // "public-by-default" fallback — so selectively importing any of its
1322        // functions is flagged just like importing a private name.
1323        write_file(root, "util.harn", "fn a() { 1 }\nfn b() { 2 }\n");
1324        let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
1325
1326        let graph = build(std::slice::from_ref(&entry));
1327        let offenders = graph.non_exported_selective_imports(&entry);
1328        assert_eq!(offenders.len(), 1);
1329        assert_eq!(offenders[0].name, "a");
1330        assert_eq!(offenders[0].module, "./util");
1331    }
1332
1333    #[test]
1334    fn stdlib_imports_resolve_to_embedded_sources() {
1335        let tmp = tempfile::tempdir().unwrap();
1336        let root = tmp.path();
1337        let entry = write_file(root, "entry.harn", "import \"std/math\"\nclamp(5, 0, 10)\n");
1338
1339        let graph = build(std::slice::from_ref(&entry));
1340        let imported = graph
1341            .imported_names_for_file(&entry)
1342            .expect("std/math should resolve");
1343        // `clamp` is defined in stdlib_math.harn as `pub fn clamp(...)`.
1344        assert!(imported.contains("clamp"));
1345    }
1346
1347    #[test]
1348    fn stdlib_internal_imports_resolve_without_leaking_to_callers() {
1349        let tmp = tempfile::tempdir().unwrap();
1350        let root = tmp.path();
1351        let entry = write_file(
1352            root,
1353            "entry.harn",
1354            "import { process_run } from \"std/runtime\"\nprocess_run([\"echo\", \"ok\"])\n",
1355        );
1356
1357        let graph = build(std::slice::from_ref(&entry));
1358        let entry_imports = graph
1359            .imported_names_for_file(&entry)
1360            .expect("std/runtime should resolve");
1361        assert!(entry_imports.contains("process_run"));
1362        assert!(
1363            !entry_imports.contains("filter_nil"),
1364            "private std/runtime dependency leaked to caller"
1365        );
1366
1367        let runtime_path = stdlib::stdlib_virtual_path("runtime");
1368        let runtime_imports = graph
1369            .imported_names_for_file(&runtime_path)
1370            .expect("std/runtime internal imports should resolve");
1371        assert!(runtime_imports.contains("filter_nil"));
1372    }
1373
1374    #[test]
1375    fn runtime_stdlib_import_surface_resolves_to_embedded_sources() {
1376        let tmp = tempfile::tempdir().unwrap();
1377        let entry_path = write_file(tmp.path(), "entry.harn", "");
1378
1379        for source in harn_stdlib::STDLIB_SOURCES {
1380            let import_path = format!("std/{}", source.module);
1381            assert!(
1382                resolve_import_path(&entry_path, &import_path).is_some(),
1383                "{import_path} should resolve in the module graph"
1384            );
1385        }
1386    }
1387
1388    #[test]
1389    fn stdlib_imports_expose_type_declarations() {
1390        let tmp = tempfile::tempdir().unwrap();
1391        let root = tmp.path();
1392        let entry = write_file(
1393            root,
1394            "entry.harn",
1395            "import \"std/triggers\"\nlet provider = \"github\"\n",
1396        );
1397
1398        let graph = build(std::slice::from_ref(&entry));
1399        let decls = graph
1400            .imported_type_declarations_for_file(&entry)
1401            .expect("std/triggers type declarations should resolve");
1402        let names: HashSet<String> = decls
1403            .iter()
1404            .filter_map(type_decl_name)
1405            .map(ToString::to_string)
1406            .collect();
1407        assert!(names.contains("TriggerEvent"));
1408        assert!(names.contains("ProviderPayload"));
1409        assert!(names.contains("SignatureStatus"));
1410    }
1411
1412    #[test]
1413    fn stdlib_imports_expose_callable_declarations() {
1414        let tmp = tempfile::tempdir().unwrap();
1415        let root = tmp.path();
1416        let entry = write_file(
1417            root,
1418            "entry.harn",
1419            "import { select_from } from \"std/tui\"\nlet item = \"alpha\"\n",
1420        );
1421
1422        let graph = build(std::slice::from_ref(&entry));
1423        let decls = graph
1424            .imported_callable_declarations_for_file(&entry)
1425            .expect("std/tui callable declarations should resolve");
1426        let names: HashSet<String> = decls
1427            .iter()
1428            .filter_map(callable_decl_name)
1429            .map(ToString::to_string)
1430            .collect();
1431        assert!(names.contains("select_from"));
1432    }
1433
1434    #[test]
1435    fn package_export_map_resolves_declared_module() {
1436        let tmp = tempfile::tempdir().unwrap();
1437        let root = tmp.path();
1438        let packages = root.join(".harn/packages/acme/runtime");
1439        fs::create_dir_all(&packages).unwrap();
1440        fs::write(
1441            root.join(".harn/packages/acme/harn.toml"),
1442            "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1443        )
1444        .unwrap();
1445        fs::write(
1446            packages.join("capabilities.harn"),
1447            "pub fn exported_capability() { 1 }\n",
1448        )
1449        .unwrap();
1450        let entry = write_file(
1451            root,
1452            "entry.harn",
1453            "import \"acme/capabilities\"\nexported_capability()\n",
1454        );
1455
1456        let graph = build(std::slice::from_ref(&entry));
1457        let imported = graph
1458            .imported_names_for_file(&entry)
1459            .expect("package export should resolve");
1460        assert!(imported.contains("exported_capability"));
1461    }
1462
1463    #[test]
1464    fn package_direct_import_cannot_escape_packages_root() {
1465        let tmp = tempfile::tempdir().unwrap();
1466        let root = tmp.path();
1467        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1468        fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1469        let entry = write_file(root, "entry.harn", "");
1470
1471        let resolved = resolve_import_path(&entry, "acme/../../secret");
1472        assert!(resolved.is_none(), "package import escaped package root");
1473    }
1474
1475    #[test]
1476    fn package_export_map_cannot_escape_package_root() {
1477        let tmp = tempfile::tempdir().unwrap();
1478        let root = tmp.path();
1479        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1480        fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1481        fs::write(
1482            root.join(".harn/packages/acme/harn.toml"),
1483            "[exports]\nleak = \"../../secret.harn\"\n",
1484        )
1485        .unwrap();
1486        let entry = write_file(root, "entry.harn", "");
1487
1488        let resolved = resolve_import_path(&entry, "acme/leak");
1489        assert!(resolved.is_none(), "package export escaped package root");
1490    }
1491
1492    #[test]
1493    fn package_export_map_allows_symlinked_path_dependencies() {
1494        let tmp = tempfile::tempdir().unwrap();
1495        let root = tmp.path();
1496        let source = root.join("source-package");
1497        fs::create_dir_all(source.join("runtime")).unwrap();
1498        fs::write(
1499            source.join("harn.toml"),
1500            "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1501        )
1502        .unwrap();
1503        fs::write(
1504            source.join("runtime/capabilities.harn"),
1505            "pub fn exported_capability() { 1 }\n",
1506        )
1507        .unwrap();
1508        fs::create_dir_all(root.join(".harn/packages")).unwrap();
1509        #[cfg(unix)]
1510        std::os::unix::fs::symlink(&source, root.join(".harn/packages/acme")).unwrap();
1511        #[cfg(windows)]
1512        std::os::windows::fs::symlink_dir(&source, root.join(".harn/packages/acme")).unwrap();
1513        let entry = write_file(root, "entry.harn", "");
1514
1515        let resolved = resolve_import_path(&entry, "acme/capabilities")
1516            .expect("symlinked package export should resolve");
1517        assert!(resolved.ends_with("runtime/capabilities.harn"));
1518    }
1519
1520    #[test]
1521    fn package_imports_resolve_from_nested_package_module() {
1522        let tmp = tempfile::tempdir().unwrap();
1523        let root = tmp.path();
1524        fs::create_dir_all(root.join(".git")).unwrap();
1525        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1526        fs::create_dir_all(root.join(".harn/packages/shared")).unwrap();
1527        fs::write(
1528            root.join(".harn/packages/shared/lib.harn"),
1529            "pub fn shared_helper() { 1 }\n",
1530        )
1531        .unwrap();
1532        fs::write(
1533            root.join(".harn/packages/acme/lib.harn"),
1534            "import \"shared\"\npub fn use_shared() { shared_helper() }\n",
1535        )
1536        .unwrap();
1537        let entry = write_file(root, "entry.harn", "import \"acme\"\nuse_shared()\n");
1538
1539        let graph = build(std::slice::from_ref(&entry));
1540        let imported = graph
1541            .imported_names_for_file(&entry)
1542            .expect("nested package import should resolve");
1543        assert!(imported.contains("use_shared"));
1544        let acme_path = root.join(".harn/packages/acme/lib.harn");
1545        let acme_imports = graph
1546            .imported_names_for_file(&acme_path)
1547            .expect("package module imports should resolve");
1548        assert!(acme_imports.contains("shared_helper"));
1549    }
1550
1551    #[test]
1552    fn unknown_stdlib_import_is_unresolved() {
1553        let tmp = tempfile::tempdir().unwrap();
1554        let root = tmp.path();
1555        let entry = write_file(root, "entry.harn", "import \"std/does_not_exist\"\n");
1556
1557        let graph = build(std::slice::from_ref(&entry));
1558        assert!(
1559            graph.imported_names_for_file(&entry).is_none(),
1560            "unknown std module should fail resolution and disable strict check"
1561        );
1562    }
1563
1564    #[test]
1565    fn import_cycles_do_not_loop_forever() {
1566        let tmp = tempfile::tempdir().unwrap();
1567        let root = tmp.path();
1568        write_file(root, "a.harn", "import \"./b\"\npub fn a_fn() { 1 }\n");
1569        write_file(root, "b.harn", "import \"./a\"\npub fn b_fn() { 1 }\n");
1570        let entry = root.join("a.harn");
1571
1572        // Just ensuring this terminates and yields sensible names.
1573        let graph = build(std::slice::from_ref(&entry));
1574        let imported = graph
1575            .imported_names_for_file(&entry)
1576            .expect("cyclic imports still resolve to known exports");
1577        assert!(imported.contains("b_fn"));
1578    }
1579
1580    #[test]
1581    fn pub_import_selective_re_exports_named_symbols() {
1582        let tmp = tempfile::tempdir().unwrap();
1583        let root = tmp.path();
1584        write_file(
1585            root,
1586            "src.harn",
1587            "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1588        );
1589        write_file(root, "facade.harn", "pub import { alpha } from \"./src\"\n");
1590        let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1591
1592        let graph = build(std::slice::from_ref(&entry));
1593        let imported = graph
1594            .imported_names_for_file(&entry)
1595            .expect("entry should resolve");
1596        assert!(imported.contains("alpha"), "selective re-export missing");
1597        assert!(
1598            !imported.contains("beta"),
1599            "non-listed name leaked through facade"
1600        );
1601
1602        let facade_path = root.join("facade.harn");
1603        let def = graph
1604            .definition_of(&facade_path, "alpha")
1605            .expect("definition_of should chase re-export");
1606        assert!(def.file.ends_with("src.harn"));
1607    }
1608
1609    #[test]
1610    fn pub_import_wildcard_re_exports_full_surface() {
1611        let tmp = tempfile::tempdir().unwrap();
1612        let root = tmp.path();
1613        write_file(
1614            root,
1615            "src.harn",
1616            "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1617        );
1618        write_file(root, "facade.harn", "pub import \"./src\"\n");
1619        let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1620
1621        let graph = build(std::slice::from_ref(&entry));
1622        let imported = graph
1623            .imported_names_for_file(&entry)
1624            .expect("entry should resolve");
1625        assert!(imported.contains("alpha"));
1626        assert!(imported.contains("beta"));
1627    }
1628
1629    #[test]
1630    fn pub_import_chain_resolves_definition_to_origin() {
1631        let tmp = tempfile::tempdir().unwrap();
1632        let root = tmp.path();
1633        write_file(root, "inner.harn", "pub fn deep() { 1 }\n");
1634        write_file(
1635            root,
1636            "middle.harn",
1637            "pub import { deep } from \"./inner\"\n",
1638        );
1639        write_file(
1640            root,
1641            "outer.harn",
1642            "pub import { deep } from \"./middle\"\n",
1643        );
1644        let entry = write_file(
1645            root,
1646            "entry.harn",
1647            "import { deep } from \"./outer\"\ndeep()\n",
1648        );
1649
1650        let graph = build(std::slice::from_ref(&entry));
1651        let def = graph
1652            .definition_of(&entry, "deep")
1653            .expect("definition_of should follow chain");
1654        assert!(def.file.ends_with("inner.harn"));
1655
1656        let imported = graph
1657            .imported_names_for_file(&entry)
1658            .expect("entry should resolve");
1659        assert!(imported.contains("deep"));
1660    }
1661
1662    #[test]
1663    fn duplicate_pub_import_reports_re_export_conflict() {
1664        let tmp = tempfile::tempdir().unwrap();
1665        let root = tmp.path();
1666        write_file(root, "a.harn", "pub fn shared() { 1 }\n");
1667        write_file(root, "b.harn", "pub fn shared() { 2 }\n");
1668        let facade = write_file(
1669            root,
1670            "facade.harn",
1671            "pub import { shared } from \"./a\"\npub import { shared } from \"./b\"\n",
1672        );
1673
1674        let graph = build(std::slice::from_ref(&facade));
1675        let conflicts = graph.re_export_conflicts(&facade);
1676        assert_eq!(
1677            conflicts.len(),
1678            1,
1679            "expected exactly one re-export conflict, got {conflicts:?}"
1680        );
1681        assert_eq!(conflicts[0].name, "shared");
1682        assert_eq!(conflicts[0].sources.len(), 2);
1683    }
1684
1685    #[test]
1686    fn cross_directory_cycle_does_not_explode_module_count() {
1687        // Regression: two files in sibling directories that import each
1688        // other produced a fresh path spelling on every round-trip
1689        // (`../runtime/../context/../runtime/...`), and `build()`'s
1690        // `seen` set deduped on the raw spelling rather than the
1691        // canonical path. The walk only terminated when `PATH_MAX` was
1692        // hit — 1024 on macOS, 4096 on Linux — so Linux re-parsed the
1693        // same pair thousands of times until it ran out of memory.
1694        let tmp = tempfile::tempdir().unwrap();
1695        let root = tmp.path();
1696        let context = root.join("context");
1697        let runtime = root.join("runtime");
1698        fs::create_dir_all(&context).unwrap();
1699        fs::create_dir_all(&runtime).unwrap();
1700        write_file(
1701            &context,
1702            "a.harn",
1703            "import \"../runtime/b\"\npub fn a_fn() { 1 }\n",
1704        );
1705        write_file(
1706            &runtime,
1707            "b.harn",
1708            "import \"../context/a\"\npub fn b_fn() { 1 }\n",
1709        );
1710        let entry = context.join("a.harn");
1711
1712        let graph = build(std::slice::from_ref(&entry));
1713        // The graph should contain exactly the two real files, keyed by
1714        // their canonical paths. Pre-fix this was thousands of entries.
1715        assert_eq!(
1716            graph.modules.len(),
1717            2,
1718            "cross-directory cycle loaded {} modules, expected 2",
1719            graph.modules.len()
1720        );
1721        let imported = graph
1722            .imported_names_for_file(&entry)
1723            .expect("cyclic imports still resolve to known exports");
1724        assert!(imported.contains("b_fn"));
1725    }
1726}