Skip to main content

harn_modules/
lib.rs

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