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