Skip to main content

harn_modules/
lib.rs

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