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) => {
471                    // A selectively imported fn whose signature references a
472                    // type alias declared in the same module ("options:
473                    // PickKeysOptions") needs that alias visible at the call
474                    // site too — otherwise the caller sees only a phantom
475                    // `Named("PickKeysOptions")` and skips contract checks.
476                    // Pull every exported type alias / struct / enum /
477                    // interface from the same module into scope to keep the
478                    // selective-import contract honest.
479                    let mut names: Vec<String> = selective.iter().cloned().collect();
480                    for ty_decl in &imported.type_declarations {
481                        if let Some(name) = type_decl_name(ty_decl) {
482                            if imported.own_exports.contains(name)
483                                && !names.iter().any(|n| n == name)
484                            {
485                                names.push(name.to_string());
486                            }
487                        }
488                    }
489                    names
490                }
491            };
492            for name in &names_to_collect {
493                let mut visited = HashSet::new();
494                if let Some(decl) = self.find_exported_type_decl(import_path, name, &mut visited) {
495                    decls.push(decl);
496                }
497            }
498        }
499        Some(decls)
500    }
501
502    /// Walk a module's local type declarations and re-export chains to find
503    /// the SNode for an exported type/struct/enum/interface named `name`.
504    fn find_exported_type_decl(
505        &self,
506        path: &Path,
507        name: &str,
508        visited: &mut HashSet<PathBuf>,
509    ) -> Option<SNode> {
510        let canonical = normalize_path(path);
511        if !visited.insert(canonical.clone()) {
512            return None;
513        }
514        let module = self
515            .modules
516            .get(&canonical)
517            .or_else(|| self.modules.get(path))?;
518        for decl in &module.type_declarations {
519            if type_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
520                return Some(decl.clone());
521            }
522        }
523        if let Some(sources) = module.selective_re_exports.get(name) {
524            for source in sources {
525                if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
526                    return Some(decl);
527                }
528            }
529        }
530        for source in &module.wildcard_re_export_paths {
531            if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
532                return Some(decl);
533            }
534        }
535        None
536    }
537
538    /// Find the definition of `name` visible from `file`.
539    ///
540    /// Recurses through `pub import` re-export chains so go-to-definition
541    /// lands on the symbol's actual declaration site instead of the facade
542    /// module that forwarded it.
543    pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
544        let mut visited = HashSet::new();
545        self.definition_of_inner(file, name, &mut visited)
546    }
547
548    fn definition_of_inner(
549        &self,
550        file: &Path,
551        name: &str,
552        visited: &mut HashSet<PathBuf>,
553    ) -> Option<DefSite> {
554        let file = normalize_path(file);
555        if !visited.insert(file.clone()) {
556            return None;
557        }
558        let current = self.modules.get(&file)?;
559
560        if let Some(local) = current.declarations.get(name) {
561            return Some(local.clone());
562        }
563
564        // `pub import { name } from "..."` — follow the first recorded
565        // source. Conflicting re-exports surface separately as
566        // diagnostics; here we just pick a canonical destination so
567        // go-to-definition lands somewhere useful.
568        if let Some(sources) = current.selective_re_exports.get(name) {
569            for source in sources {
570                if let Some(def) = self.definition_of_inner(source, name, visited) {
571                    return Some(def);
572                }
573            }
574        }
575
576        // `pub import "..."` — chase each wildcard re-export source.
577        for source in &current.wildcard_re_export_paths {
578            if let Some(def) = self.definition_of_inner(source, name, visited) {
579                return Some(def);
580            }
581        }
582
583        // Private selective imports.
584        for import in &current.imports {
585            let Some(selective_names) = &import.selective_names else {
586                continue;
587            };
588            if !selective_names.contains(name) {
589                continue;
590            }
591            if let Some(path) = &import.path {
592                if let Some(def) = self.definition_of_inner(path, name, visited) {
593                    return Some(def);
594                }
595            }
596        }
597
598        // Private wildcard imports.
599        for import in &current.imports {
600            if import.selective_names.is_some() {
601                continue;
602            }
603            if let Some(path) = &import.path {
604                if let Some(def) = self.definition_of_inner(path, name, visited) {
605                    return Some(def);
606                }
607            }
608        }
609
610        None
611    }
612
613    /// Diagnostics for re-export conflicts inside `file`. Each diagnostic
614    /// names the conflicting symbol and the modules that contributed it,
615    /// so check-time errors can be precise.
616    pub fn re_export_conflicts(&self, file: &Path) -> Vec<ReExportConflict> {
617        let file = normalize_path(file);
618        let Some(module) = self.modules.get(&file) else {
619            return Vec::new();
620        };
621
622        // Build, for each re-exported name, the set of source modules it
623        // could resolve to. Names that resolve to more than one source are
624        // ambiguous and reported.
625        let mut sources: HashMap<String, Vec<PathBuf>> = HashMap::new();
626
627        for (name, srcs) in &module.selective_re_exports {
628            sources
629                .entry(name.clone())
630                .or_default()
631                .extend(srcs.iter().cloned());
632        }
633        for src in &module.wildcard_re_export_paths {
634            let canonical = normalize_path(src);
635            let Some(src_module) = self
636                .modules
637                .get(&canonical)
638                .or_else(|| self.modules.get(src))
639            else {
640                continue;
641            };
642            for name in &src_module.exports {
643                sources
644                    .entry(name.clone())
645                    .or_default()
646                    .push(canonical.clone());
647            }
648        }
649
650        // A re-export that collides with a locally exported declaration is
651        // also an error: the facade module cannot expose two different
652        // bindings under the same name.
653        for name in &module.own_exports {
654            if let Some(entry) = sources.get_mut(name) {
655                entry.push(file.clone());
656            }
657        }
658
659        let mut conflicts = Vec::new();
660        for (name, mut srcs) in sources {
661            srcs.sort();
662            srcs.dedup();
663            if srcs.len() > 1 {
664                conflicts.push(ReExportConflict {
665                    name,
666                    sources: srcs,
667                });
668            }
669        }
670        conflicts.sort_by(|a, b| a.name.cmp(&b.name));
671        conflicts
672    }
673}
674
675/// A duplicate or ambiguous re-export inside a single module. Reported by
676/// [`ModuleGraph::re_export_conflicts`].
677#[derive(Debug, Clone, PartialEq, Eq)]
678pub struct ReExportConflict {
679    pub name: String,
680    pub sources: Vec<PathBuf>,
681}
682
683fn load_module(path: &Path) -> ModuleInfo {
684    let Some(source) = read_module_source(path) else {
685        return ModuleInfo::default();
686    };
687    let mut lexer = harn_lexer::Lexer::new(&source);
688    let tokens = match lexer.tokenize() {
689        Ok(tokens) => tokens,
690        Err(_) => return ModuleInfo::default(),
691    };
692    let mut parser = Parser::new(tokens);
693    let program = match parser.parse() {
694        Ok(program) => program,
695        Err(_) => return ModuleInfo::default(),
696    };
697
698    let mut module = ModuleInfo::default();
699    for node in &program {
700        collect_module_info(path, node, &mut module);
701        collect_type_declarations(node, &mut module.type_declarations);
702    }
703    // Fallback matching the VM loader: if the module declares no
704    // `pub fn`, every fn is implicitly exported.
705    if !module.has_pub_fn {
706        for name in &module.fn_names {
707            module.own_exports.insert(name.clone());
708        }
709    }
710    // Seed the transitive `exports` set from local exports plus selective
711    // re-export names. Wildcard re-exports are folded in by
712    // [`resolve_re_exports`] after every module has been loaded.
713    module.exports.extend(module.own_exports.iter().cloned());
714    module
715        .exports
716        .extend(module.selective_re_exports.keys().cloned());
717    module
718}
719
720/// Extract the stdlib module name when `path` is a `<std>/<name>`
721/// virtual path, otherwise `None`.
722fn stdlib_module_from_path(path: &Path) -> Option<&str> {
723    let s = path.to_str()?;
724    s.strip_prefix("<std>/")
725}
726
727fn collect_module_info(file: &Path, snode: &SNode, module: &mut ModuleInfo) {
728    match &snode.node {
729        Node::FnDecl {
730            name,
731            params,
732            is_pub,
733            ..
734        } => {
735            if *is_pub {
736                module.own_exports.insert(name.clone());
737                module.has_pub_fn = true;
738            }
739            module.fn_names.push(name.clone());
740            module.declarations.insert(
741                name.clone(),
742                decl_site(file, snode.span, name, DefKind::Function),
743            );
744            for param_name in params.iter().map(|param| param.name.clone()) {
745                module.declarations.insert(
746                    param_name.clone(),
747                    decl_site(file, snode.span, &param_name, DefKind::Parameter),
748                );
749            }
750        }
751        Node::Pipeline { name, is_pub, .. } => {
752            if *is_pub {
753                module.own_exports.insert(name.clone());
754            }
755            module.declarations.insert(
756                name.clone(),
757                decl_site(file, snode.span, name, DefKind::Pipeline),
758            );
759        }
760        Node::ToolDecl { name, is_pub, .. } => {
761            if *is_pub {
762                module.own_exports.insert(name.clone());
763            }
764            module.declarations.insert(
765                name.clone(),
766                decl_site(file, snode.span, name, DefKind::Tool),
767            );
768        }
769        Node::SkillDecl { name, is_pub, .. } => {
770            if *is_pub {
771                module.own_exports.insert(name.clone());
772            }
773            module.declarations.insert(
774                name.clone(),
775                decl_site(file, snode.span, name, DefKind::Skill),
776            );
777        }
778        Node::StructDecl { name, is_pub, .. } => {
779            if *is_pub {
780                module.own_exports.insert(name.clone());
781            }
782            module.declarations.insert(
783                name.clone(),
784                decl_site(file, snode.span, name, DefKind::Struct),
785            );
786        }
787        Node::EnumDecl { name, is_pub, .. } => {
788            if *is_pub {
789                module.own_exports.insert(name.clone());
790            }
791            module.declarations.insert(
792                name.clone(),
793                decl_site(file, snode.span, name, DefKind::Enum),
794            );
795        }
796        Node::InterfaceDecl { name, .. } => {
797            module.own_exports.insert(name.clone());
798            module.declarations.insert(
799                name.clone(),
800                decl_site(file, snode.span, name, DefKind::Interface),
801            );
802        }
803        Node::TypeDecl { name, .. } => {
804            module.own_exports.insert(name.clone());
805            module.declarations.insert(
806                name.clone(),
807                decl_site(file, snode.span, name, DefKind::Type),
808            );
809        }
810        Node::LetBinding { pattern, .. } | Node::VarBinding { pattern, .. } => {
811            for name in pattern_names(pattern) {
812                module.declarations.insert(
813                    name.clone(),
814                    decl_site(file, snode.span, &name, DefKind::Variable),
815                );
816            }
817        }
818        Node::ImportDecl { path, is_pub } => {
819            let import_path = resolve_import_path(file, path);
820            if import_path.is_none() {
821                module.has_unresolved_wildcard_import = true;
822            }
823            if *is_pub {
824                if let Some(resolved) = &import_path {
825                    module
826                        .wildcard_re_export_paths
827                        .push(normalize_path(resolved));
828                }
829            }
830            module.imports.push(ImportRef {
831                path: import_path,
832                selective_names: None,
833            });
834        }
835        Node::SelectiveImport {
836            names,
837            path,
838            is_pub,
839        } => {
840            let import_path = resolve_import_path(file, path);
841            if import_path.is_none() {
842                module.has_unresolved_selective_import = true;
843            }
844            if *is_pub {
845                if let Some(resolved) = &import_path {
846                    let canonical = normalize_path(resolved);
847                    for name in names {
848                        module
849                            .selective_re_exports
850                            .entry(name.clone())
851                            .or_default()
852                            .push(canonical.clone());
853                    }
854                }
855            }
856            let names: HashSet<String> = names.iter().cloned().collect();
857            module.selective_import_names.extend(names.iter().cloned());
858            module.imports.push(ImportRef {
859                path: import_path,
860                selective_names: Some(names),
861            });
862        }
863        Node::AttributedDecl { inner, .. } => {
864            collect_module_info(file, inner, module);
865        }
866        _ => {}
867    }
868}
869
870fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
871    match &snode.node {
872        Node::TypeDecl { .. }
873        | Node::StructDecl { .. }
874        | Node::EnumDecl { .. }
875        | Node::InterfaceDecl { .. } => decls.push(snode.clone()),
876        Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
877        _ => {}
878    }
879}
880
881fn type_decl_name(snode: &SNode) -> Option<&str> {
882    match &snode.node {
883        Node::TypeDecl { name, .. }
884        | Node::StructDecl { name, .. }
885        | Node::EnumDecl { name, .. }
886        | Node::InterfaceDecl { name, .. } => Some(name.as_str()),
887        _ => None,
888    }
889}
890
891fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
892    DefSite {
893        name: name.to_string(),
894        file: file.to_path_buf(),
895        kind,
896        span,
897    }
898}
899
900fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
901    match pattern {
902        BindingPattern::Identifier(name) => vec![name.clone()],
903        BindingPattern::Dict(fields) => fields
904            .iter()
905            .filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
906            .collect(),
907        BindingPattern::List(elements) => elements
908            .iter()
909            .map(|element| element.name.clone())
910            .collect(),
911        BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
912    }
913}
914
915fn normalize_path(path: &Path) -> PathBuf {
916    if stdlib_module_from_path(path).is_some() {
917        return path.to_path_buf();
918    }
919    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925    use std::fs;
926
927    fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
928        let path = dir.join(name);
929        fs::write(&path, contents).unwrap();
930        path
931    }
932
933    #[test]
934    fn recursive_build_loads_transitively_imported_modules() {
935        let tmp = tempfile::tempdir().unwrap();
936        let root = tmp.path();
937        write_file(root, "leaf.harn", "pub fn leaf_fn() { 1 }\n");
938        write_file(
939            root,
940            "mid.harn",
941            "import \"./leaf\"\npub fn mid_fn() { leaf_fn() }\n",
942        );
943        let entry = write_file(root, "entry.harn", "import \"./mid\"\nmid_fn()\n");
944
945        let graph = build(std::slice::from_ref(&entry));
946        let imported = graph
947            .imported_names_for_file(&entry)
948            .expect("entry imports should resolve");
949        // Wildcard import of mid exposes mid_fn (pub) but not leaf_fn.
950        assert!(imported.contains("mid_fn"));
951        assert!(!imported.contains("leaf_fn"));
952
953        // The transitively loaded module is known to the graph even though
954        // the seed only included entry.harn.
955        let leaf_path = root.join("leaf.harn");
956        assert!(graph.definition_of(&leaf_path, "leaf_fn").is_some());
957    }
958
959    #[test]
960    fn imported_names_returns_none_when_import_unresolved() {
961        let tmp = tempfile::tempdir().unwrap();
962        let root = tmp.path();
963        let entry = write_file(root, "entry.harn", "import \"./does_not_exist\"\n");
964
965        let graph = build(std::slice::from_ref(&entry));
966        assert!(graph.imported_names_for_file(&entry).is_none());
967    }
968
969    #[test]
970    fn selective_imports_contribute_only_requested_names() {
971        let tmp = tempfile::tempdir().unwrap();
972        let root = tmp.path();
973        write_file(root, "util.harn", "pub fn a() { 1 }\npub fn b() { 2 }\n");
974        let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
975
976        let graph = build(std::slice::from_ref(&entry));
977        let imported = graph
978            .imported_names_for_file(&entry)
979            .expect("entry imports should resolve");
980        assert!(imported.contains("a"));
981        assert!(!imported.contains("b"));
982    }
983
984    #[test]
985    fn stdlib_imports_resolve_to_embedded_sources() {
986        let tmp = tempfile::tempdir().unwrap();
987        let root = tmp.path();
988        let entry = write_file(root, "entry.harn", "import \"std/math\"\nclamp(5, 0, 10)\n");
989
990        let graph = build(std::slice::from_ref(&entry));
991        let imported = graph
992            .imported_names_for_file(&entry)
993            .expect("std/math should resolve");
994        // `clamp` is defined in stdlib_math.harn as `pub fn clamp(...)`.
995        assert!(imported.contains("clamp"));
996    }
997
998    #[test]
999    fn stdlib_internal_imports_resolve_without_leaking_to_callers() {
1000        let tmp = tempfile::tempdir().unwrap();
1001        let root = tmp.path();
1002        let entry = write_file(
1003            root,
1004            "entry.harn",
1005            "import { process_run } from \"std/runtime\"\nprocess_run([\"echo\", \"ok\"])\n",
1006        );
1007
1008        let graph = build(std::slice::from_ref(&entry));
1009        let entry_imports = graph
1010            .imported_names_for_file(&entry)
1011            .expect("std/runtime should resolve");
1012        assert!(entry_imports.contains("process_run"));
1013        assert!(
1014            !entry_imports.contains("filter_nil"),
1015            "private std/runtime dependency leaked to caller"
1016        );
1017
1018        let runtime_path = stdlib::stdlib_virtual_path("runtime");
1019        let runtime_imports = graph
1020            .imported_names_for_file(&runtime_path)
1021            .expect("std/runtime internal imports should resolve");
1022        assert!(runtime_imports.contains("filter_nil"));
1023    }
1024
1025    #[test]
1026    fn runtime_stdlib_import_surface_resolves_to_embedded_sources() {
1027        let tmp = tempfile::tempdir().unwrap();
1028        let entry_path = write_file(tmp.path(), "entry.harn", "");
1029
1030        for source in harn_stdlib::STDLIB_SOURCES {
1031            let import_path = format!("std/{}", source.module);
1032            assert!(
1033                resolve_import_path(&entry_path, &import_path).is_some(),
1034                "{import_path} should resolve in the module graph"
1035            );
1036        }
1037    }
1038
1039    #[test]
1040    fn stdlib_imports_expose_type_declarations() {
1041        let tmp = tempfile::tempdir().unwrap();
1042        let root = tmp.path();
1043        let entry = write_file(
1044            root,
1045            "entry.harn",
1046            "import \"std/triggers\"\nlet provider = \"github\"\n",
1047        );
1048
1049        let graph = build(std::slice::from_ref(&entry));
1050        let decls = graph
1051            .imported_type_declarations_for_file(&entry)
1052            .expect("std/triggers type declarations should resolve");
1053        let names: HashSet<String> = decls
1054            .iter()
1055            .filter_map(type_decl_name)
1056            .map(ToString::to_string)
1057            .collect();
1058        assert!(names.contains("TriggerEvent"));
1059        assert!(names.contains("ProviderPayload"));
1060        assert!(names.contains("SignatureStatus"));
1061    }
1062
1063    #[test]
1064    fn package_export_map_resolves_declared_module() {
1065        let tmp = tempfile::tempdir().unwrap();
1066        let root = tmp.path();
1067        let packages = root.join(".harn/packages/acme/runtime");
1068        fs::create_dir_all(&packages).unwrap();
1069        fs::write(
1070            root.join(".harn/packages/acme/harn.toml"),
1071            "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1072        )
1073        .unwrap();
1074        fs::write(
1075            packages.join("capabilities.harn"),
1076            "pub fn exported_capability() { 1 }\n",
1077        )
1078        .unwrap();
1079        let entry = write_file(
1080            root,
1081            "entry.harn",
1082            "import \"acme/capabilities\"\nexported_capability()\n",
1083        );
1084
1085        let graph = build(std::slice::from_ref(&entry));
1086        let imported = graph
1087            .imported_names_for_file(&entry)
1088            .expect("package export should resolve");
1089        assert!(imported.contains("exported_capability"));
1090    }
1091
1092    #[test]
1093    fn package_direct_import_cannot_escape_packages_root() {
1094        let tmp = tempfile::tempdir().unwrap();
1095        let root = tmp.path();
1096        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1097        fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1098        let entry = write_file(root, "entry.harn", "");
1099
1100        let resolved = resolve_import_path(&entry, "acme/../../secret");
1101        assert!(resolved.is_none(), "package import escaped package root");
1102    }
1103
1104    #[test]
1105    fn package_export_map_cannot_escape_package_root() {
1106        let tmp = tempfile::tempdir().unwrap();
1107        let root = tmp.path();
1108        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1109        fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1110        fs::write(
1111            root.join(".harn/packages/acme/harn.toml"),
1112            "[exports]\nleak = \"../../secret.harn\"\n",
1113        )
1114        .unwrap();
1115        let entry = write_file(root, "entry.harn", "");
1116
1117        let resolved = resolve_import_path(&entry, "acme/leak");
1118        assert!(resolved.is_none(), "package export escaped package root");
1119    }
1120
1121    #[test]
1122    fn package_export_map_allows_symlinked_path_dependencies() {
1123        let tmp = tempfile::tempdir().unwrap();
1124        let root = tmp.path();
1125        let source = root.join("source-package");
1126        fs::create_dir_all(source.join("runtime")).unwrap();
1127        fs::write(
1128            source.join("harn.toml"),
1129            "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1130        )
1131        .unwrap();
1132        fs::write(
1133            source.join("runtime/capabilities.harn"),
1134            "pub fn exported_capability() { 1 }\n",
1135        )
1136        .unwrap();
1137        fs::create_dir_all(root.join(".harn/packages")).unwrap();
1138        #[cfg(unix)]
1139        std::os::unix::fs::symlink(&source, root.join(".harn/packages/acme")).unwrap();
1140        #[cfg(windows)]
1141        std::os::windows::fs::symlink_dir(&source, root.join(".harn/packages/acme")).unwrap();
1142        let entry = write_file(root, "entry.harn", "");
1143
1144        let resolved = resolve_import_path(&entry, "acme/capabilities")
1145            .expect("symlinked package export should resolve");
1146        assert!(resolved.ends_with("runtime/capabilities.harn"));
1147    }
1148
1149    #[test]
1150    fn package_imports_resolve_from_nested_package_module() {
1151        let tmp = tempfile::tempdir().unwrap();
1152        let root = tmp.path();
1153        fs::create_dir_all(root.join(".git")).unwrap();
1154        fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1155        fs::create_dir_all(root.join(".harn/packages/shared")).unwrap();
1156        fs::write(
1157            root.join(".harn/packages/shared/lib.harn"),
1158            "pub fn shared_helper() { 1 }\n",
1159        )
1160        .unwrap();
1161        fs::write(
1162            root.join(".harn/packages/acme/lib.harn"),
1163            "import \"shared\"\npub fn use_shared() { shared_helper() }\n",
1164        )
1165        .unwrap();
1166        let entry = write_file(root, "entry.harn", "import \"acme\"\nuse_shared()\n");
1167
1168        let graph = build(std::slice::from_ref(&entry));
1169        let imported = graph
1170            .imported_names_for_file(&entry)
1171            .expect("nested package import should resolve");
1172        assert!(imported.contains("use_shared"));
1173        let acme_path = root.join(".harn/packages/acme/lib.harn");
1174        let acme_imports = graph
1175            .imported_names_for_file(&acme_path)
1176            .expect("package module imports should resolve");
1177        assert!(acme_imports.contains("shared_helper"));
1178    }
1179
1180    #[test]
1181    fn unknown_stdlib_import_is_unresolved() {
1182        let tmp = tempfile::tempdir().unwrap();
1183        let root = tmp.path();
1184        let entry = write_file(root, "entry.harn", "import \"std/does_not_exist\"\n");
1185
1186        let graph = build(std::slice::from_ref(&entry));
1187        assert!(
1188            graph.imported_names_for_file(&entry).is_none(),
1189            "unknown std module should fail resolution and disable strict check"
1190        );
1191    }
1192
1193    #[test]
1194    fn import_cycles_do_not_loop_forever() {
1195        let tmp = tempfile::tempdir().unwrap();
1196        let root = tmp.path();
1197        write_file(root, "a.harn", "import \"./b\"\npub fn a_fn() { 1 }\n");
1198        write_file(root, "b.harn", "import \"./a\"\npub fn b_fn() { 1 }\n");
1199        let entry = root.join("a.harn");
1200
1201        // Just ensuring this terminates and yields sensible names.
1202        let graph = build(std::slice::from_ref(&entry));
1203        let imported = graph
1204            .imported_names_for_file(&entry)
1205            .expect("cyclic imports still resolve to known exports");
1206        assert!(imported.contains("b_fn"));
1207    }
1208
1209    #[test]
1210    fn pub_import_selective_re_exports_named_symbols() {
1211        let tmp = tempfile::tempdir().unwrap();
1212        let root = tmp.path();
1213        write_file(
1214            root,
1215            "src.harn",
1216            "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1217        );
1218        write_file(root, "facade.harn", "pub import { alpha } from \"./src\"\n");
1219        let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1220
1221        let graph = build(std::slice::from_ref(&entry));
1222        let imported = graph
1223            .imported_names_for_file(&entry)
1224            .expect("entry should resolve");
1225        assert!(imported.contains("alpha"), "selective re-export missing");
1226        assert!(
1227            !imported.contains("beta"),
1228            "non-listed name leaked through facade"
1229        );
1230
1231        let facade_path = root.join("facade.harn");
1232        let def = graph
1233            .definition_of(&facade_path, "alpha")
1234            .expect("definition_of should chase re-export");
1235        assert!(def.file.ends_with("src.harn"));
1236    }
1237
1238    #[test]
1239    fn pub_import_wildcard_re_exports_full_surface() {
1240        let tmp = tempfile::tempdir().unwrap();
1241        let root = tmp.path();
1242        write_file(
1243            root,
1244            "src.harn",
1245            "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1246        );
1247        write_file(root, "facade.harn", "pub import \"./src\"\n");
1248        let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1249
1250        let graph = build(std::slice::from_ref(&entry));
1251        let imported = graph
1252            .imported_names_for_file(&entry)
1253            .expect("entry should resolve");
1254        assert!(imported.contains("alpha"));
1255        assert!(imported.contains("beta"));
1256    }
1257
1258    #[test]
1259    fn pub_import_chain_resolves_definition_to_origin() {
1260        let tmp = tempfile::tempdir().unwrap();
1261        let root = tmp.path();
1262        write_file(root, "inner.harn", "pub fn deep() { 1 }\n");
1263        write_file(
1264            root,
1265            "middle.harn",
1266            "pub import { deep } from \"./inner\"\n",
1267        );
1268        write_file(
1269            root,
1270            "outer.harn",
1271            "pub import { deep } from \"./middle\"\n",
1272        );
1273        let entry = write_file(
1274            root,
1275            "entry.harn",
1276            "import { deep } from \"./outer\"\ndeep()\n",
1277        );
1278
1279        let graph = build(std::slice::from_ref(&entry));
1280        let def = graph
1281            .definition_of(&entry, "deep")
1282            .expect("definition_of should follow chain");
1283        assert!(def.file.ends_with("inner.harn"));
1284
1285        let imported = graph
1286            .imported_names_for_file(&entry)
1287            .expect("entry should resolve");
1288        assert!(imported.contains("deep"));
1289    }
1290
1291    #[test]
1292    fn duplicate_pub_import_reports_re_export_conflict() {
1293        let tmp = tempfile::tempdir().unwrap();
1294        let root = tmp.path();
1295        write_file(root, "a.harn", "pub fn shared() { 1 }\n");
1296        write_file(root, "b.harn", "pub fn shared() { 2 }\n");
1297        let facade = write_file(
1298            root,
1299            "facade.harn",
1300            "pub import { shared } from \"./a\"\npub import { shared } from \"./b\"\n",
1301        );
1302
1303        let graph = build(std::slice::from_ref(&facade));
1304        let conflicts = graph.re_export_conflicts(&facade);
1305        assert_eq!(
1306            conflicts.len(),
1307            1,
1308            "expected exactly one re-export conflict, got {:?}",
1309            conflicts
1310        );
1311        assert_eq!(conflicts[0].name, "shared");
1312        assert_eq!(conflicts[0].sources.len(), 2);
1313    }
1314
1315    #[test]
1316    fn cross_directory_cycle_does_not_explode_module_count() {
1317        // Regression: two files in sibling directories that import each
1318        // other produced a fresh path spelling on every round-trip
1319        // (`../runtime/../context/../runtime/...`), and `build()`'s
1320        // `seen` set deduped on the raw spelling rather than the
1321        // canonical path. The walk only terminated when `PATH_MAX` was
1322        // hit — 1024 on macOS, 4096 on Linux — so Linux re-parsed the
1323        // same pair thousands of times until it ran out of memory.
1324        let tmp = tempfile::tempdir().unwrap();
1325        let root = tmp.path();
1326        let context = root.join("context");
1327        let runtime = root.join("runtime");
1328        fs::create_dir_all(&context).unwrap();
1329        fs::create_dir_all(&runtime).unwrap();
1330        write_file(
1331            &context,
1332            "a.harn",
1333            "import \"../runtime/b\"\npub fn a_fn() { 1 }\n",
1334        );
1335        write_file(
1336            &runtime,
1337            "b.harn",
1338            "import \"../context/a\"\npub fn b_fn() { 1 }\n",
1339        );
1340        let entry = context.join("a.harn");
1341
1342        let graph = build(std::slice::from_ref(&entry));
1343        // The graph should contain exactly the two real files, keyed by
1344        // their canonical paths. Pre-fix this was thousands of entries.
1345        assert_eq!(
1346            graph.modules.len(),
1347            2,
1348            "cross-directory cycle loaded {} modules, expected 2",
1349            graph.modules.len()
1350        );
1351        let imported = graph
1352            .imported_names_for_file(&entry)
1353            .expect("cyclic imports still resolve to known exports");
1354        assert!(imported.contains("b_fn"));
1355    }
1356}