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