Skip to main content

harn_modules/
lib.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use crate::package_imports::{acquire_package_snapshots, resolve_import_path_with_snapshots};
5use crate::package_snapshot::PackageSnapshot;
6use harn_lexer::Span;
7use harn_parser::{BindingPattern, Node, Parser, SNode};
8
9pub mod asset_paths;
10pub mod fingerprint;
11pub mod package_execution;
12mod package_imports;
13pub mod package_snapshot;
14pub mod personas;
15mod stdlib;
16
17pub use package_imports::{
18    resolve_import_path, resolve_import_path_with_guard, resolve_import_path_with_snapshot,
19};
20
21/// Kind of symbol that can be exported by a module.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum DefKind {
24    Function,
25    Pipeline,
26    Tool,
27    Skill,
28    Struct,
29    Enum,
30    Interface,
31    Type,
32    Variable,
33    Parameter,
34}
35
36/// A resolved definition site within a module.
37#[derive(Debug, Clone)]
38pub struct DefSite {
39    pub name: String,
40    pub file: PathBuf,
41    pub kind: DefKind,
42    pub span: Span,
43}
44
45/// Wildcard import resolution status for a single importing module.
46#[derive(Debug, Clone)]
47pub enum WildcardResolution {
48    /// Resolved all wildcard imports and can expose wildcard exports.
49    Resolved(HashSet<String>),
50    /// At least one wildcard import could not be resolved.
51    Unknown,
52}
53
54/// Parsed information for a set of module files.
55#[derive(Debug, Default)]
56pub struct ModuleGraph {
57    modules: HashMap<PathBuf, ModuleInfo>,
58    // Resolved definition/import paths remain valid for the graph lifetime.
59    _package_snapshots: Vec<PackageSnapshot>,
60}
61
62#[derive(Debug, Clone)]
63pub struct ParsedModuleSource {
64    pub source: String,
65    pub program: Vec<SNode>,
66}
67
68#[derive(Debug, Default)]
69pub struct ModuleGraphBuild {
70    pub graph: ModuleGraph,
71    pub parsed_sources: HashMap<PathBuf, ParsedModuleSource>,
72}
73
74#[derive(Debug, Default)]
75struct ModuleInfo {
76    /// All declarations visible in this module (for local symbol lookup and
77    /// go-to-definition resolution).
78    declarations: HashMap<String, DefSite>,
79    /// Names exported by this module after re-export resolution. Equal to
80    /// [`own_exports`] union the keys of [`selective_re_exports`] union the
81    /// transitive exports of [`wildcard_re_export_paths`]. Populated in
82    /// `build()` after all modules are loaded.
83    exports: HashSet<String>,
84    /// Names declared locally and exported by this module — i.e. `pub fn`,
85    /// `pub struct`, etc., or every `fn` under the no-`pub fn` fallback.
86    own_exports: HashSet<String>,
87    /// Selective re-exports introduced by `pub import { name } from "..."`.
88    /// Maps the re-exported name to every canonical source module path it
89    /// could originate from. Multiple entries per name indicate a conflict
90    /// (`pub import { foo } from "a"` and `pub import { foo } from "b"`)
91    /// and are surfaced by [`ModuleGraph::re_export_conflicts`]. Lookup
92    /// callers (e.g. go-to-definition) follow the first recorded source.
93    selective_re_exports: HashMap<String, Vec<PathBuf>>,
94    /// Wildcard re-exports introduced by `pub import "..."`. Each entry is
95    /// the canonical path of a module whose entire public export surface
96    /// this module re-exports.
97    wildcard_re_export_paths: Vec<PathBuf>,
98    /// Names introduced by selective imports across this module.
99    selective_import_names: HashSet<String>,
100    /// Import references encountered in this file.
101    imports: Vec<ImportRef>,
102    /// True when at least one wildcard import could not be resolved.
103    has_unresolved_wildcard_import: bool,
104    /// True when at least one selective import could not be resolved
105    /// (importing file path missing). Prevents `imported_names_for_file`
106    /// from returning a partial answer when any import is broken.
107    has_unresolved_selective_import: bool,
108    /// Top-level type-like declarations that can be imported into a caller's
109    /// static type environment.
110    type_declarations: Vec<SNode>,
111    /// Top-level callable declarations whose signatures can be imported into
112    /// a caller's static type environment.
113    callable_declarations: Vec<SNode>,
114    /// Set when this module's own source failed to lex or parse. The module is
115    /// still recorded in the graph (with an otherwise-empty surface) so that
116    /// importers can be told their target is broken — instead of silently
117    /// seeing zero exports and mislabeling the imported symbol as "undefined"
118    /// at the call site.
119    load_error: Option<ModuleLoadError>,
120}
121
122/// A lex/parse failure captured while loading a module into the graph.
123///
124/// Retained so that `harn check <consumer>` can surface the real error in an
125/// imported file rather than downgrading its exports to "undefined" at the
126/// consumer's call site.
127#[derive(Debug, Clone)]
128pub struct ModuleLoadError {
129    /// Rendered lex/parse error message (includes the failing line:column).
130    pub message: String,
131    /// Span of the failure within the imported module's own source.
132    pub span: Span,
133}
134
135/// A consumer import whose resolved target module failed to compile. Reported
136/// by [`ModuleGraph::import_compile_failures`].
137#[derive(Debug, Clone)]
138pub struct ImportCompileFailure {
139    /// The import path exactly as written in the consumer.
140    pub import_raw_path: String,
141    /// Span of the consumer's `import` statement.
142    pub import_span: Span,
143    /// Canonical path of the broken imported module.
144    pub module_path: PathBuf,
145    /// The imported module's real lex/parse error.
146    pub error: ModuleLoadError,
147}
148
149#[derive(Debug, Clone)]
150struct ImportRef {
151    raw_path: String,
152    path: Option<PathBuf>,
153    selective_names: Option<HashSet<String>>,
154    import_span: Span,
155}
156
157/// Public import edge summary for static module graph consumers.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct ModuleImport {
160    /// The import string as written in source.
161    pub raw_path: String,
162    /// Resolved module path when the import could be resolved.
163    pub resolved_path: Option<PathBuf>,
164    /// `None` for wildcard imports; otherwise the selected names.
165    pub selective_names: Option<Vec<String>>,
166}
167
168/// Return the source for a resolved module path.
169///
170/// Real paths are read from disk. `<std>/<module>` virtual paths are backed by
171/// the embedded stdlib source table, so callers can parse resolved stdlib
172/// modules without knowing about the stdlib mirror layout.
173pub fn read_module_source(path: &Path) -> Option<String> {
174    if let Some(stdlib_module) = stdlib_module_from_path(path) {
175        return stdlib::get_stdlib_source(stdlib_module).map(ToString::to_string);
176    }
177    std::fs::read_to_string(path).ok()
178}
179
180/// Build a module graph from a set of files.
181///
182/// Files referenced via `import` statements are loaded recursively so the
183/// graph contains every module reachable from the seed set. Cycles and
184/// already-loaded files are skipped via a visited set.
185pub fn build(files: &[PathBuf]) -> ModuleGraph {
186    build_inner(files, None).graph
187}
188
189/// Build a module graph while retaining parsed sources for the seed files.
190///
191/// Imported-only modules still participate in the graph, but their ASTs are
192/// dropped after graph extraction so callers do not pay extra peak memory for
193/// parsed sources they will not reuse.
194pub fn build_with_parsed_sources(files: &[PathBuf]) -> ModuleGraphBuild {
195    let parsed_source_targets = files.iter().map(|file| normalize_path(file)).collect();
196    build_inner(files, Some(&parsed_source_targets))
197}
198
199fn build_inner(
200    files: &[PathBuf],
201    parsed_source_targets: Option<&HashSet<PathBuf>>,
202) -> ModuleGraphBuild {
203    let package_snapshots = acquire_package_snapshots(files);
204    let mut modules: HashMap<PathBuf, ModuleInfo> = HashMap::new();
205    let mut parsed_sources: HashMap<PathBuf, ParsedModuleSource> = HashMap::new();
206    let mut seen: HashSet<PathBuf> = HashSet::new();
207    let mut wave: Vec<PathBuf> = Vec::new();
208    for file in files {
209        let canonical = normalize_path(file);
210        if seen.insert(canonical.clone()) {
211            wave.push(canonical);
212        }
213    }
214    // Breadth-first over import waves. Every path in a wave is new (the
215    // `seen` set dedupes before enqueue), and `load_module` is a pure
216    // read+lex+parse+extract, so each wave loads in parallel; discovery of
217    // the next wave stays sequential to keep the dedup deterministic. A
218    // whole-tree seed set arrives as one large first wave, which is where
219    // nearly all the parse work is, so the serial-BFS tail on deep import
220    // chains does not matter in practice.
221    while !wave.is_empty() {
222        let loaded = load_wave(&wave, &package_snapshots);
223        let mut next_wave: Vec<PathBuf> = Vec::new();
224        for (path, (module, parsed)) in wave.drain(..).zip(loaded) {
225            let retain_parsed_source =
226                parsed_source_targets.is_some_and(|targets| targets.contains(&path));
227            if retain_parsed_source {
228                if let Some(parsed) = parsed {
229                    parsed_sources.insert(path.clone(), parsed);
230                }
231            }
232            // Enqueue resolved import targets so the whole reachable graph is
233            // discovered without the caller having to pre-walk imports.
234            //
235            // `resolve_import_path` returns paths as `base.join(import)` —
236            // i.e. with `..` segments preserved rather than collapsed. If we
237            // dedupe on those raw forms, two files that import each other
238            // across sibling dirs (`lib/context/` ↔ `lib/runtime/`) produce a
239            // different path spelling on every cycle — `.../context/../runtime/`,
240            // then `.../context/../runtime/../context/`, and so on — each of
241            // which is treated as a new file. The walk only terminates when
242            // `path.exists()` starts failing at the filesystem's `PATH_MAX`,
243            // which is 1024 on macOS but 4096 on Linux. Linux therefore
244            // re-parses the same handful of files thousands of times, balloons
245            // RSS into the multi-GB range, and gets SIGKILL'd by CI runners.
246            // Canonicalize once here so `seen` dedupes by the underlying file,
247            // not by its path spelling.
248            for import in &module.imports {
249                if let Some(import_path) = &import.path {
250                    let canonical = normalize_path(import_path);
251                    if seen.insert(canonical.clone()) {
252                        next_wave.push(canonical);
253                    }
254                }
255            }
256            modules.insert(path, module);
257        }
258        wave = next_wave;
259    }
260    resolve_re_exports(&mut modules);
261    ModuleGraphBuild {
262        graph: ModuleGraph {
263            modules,
264            _package_snapshots: package_snapshots,
265        },
266        parsed_sources,
267    }
268}
269
270/// Environment override for the graph-build worker-pool size. `1` forces the
271/// serial walk; unset defaults to the machine's available parallelism.
272pub const MODULE_GRAPH_JOBS_ENV: &str = "HARN_MODULE_GRAPH_JOBS";
273
274/// Load one BFS wave of module paths, in parallel when the wave is large
275/// enough to pay for the threads. Results are index-aligned with `paths`.
276fn load_wave(
277    paths: &[PathBuf],
278    package_snapshots: &[PackageSnapshot],
279) -> Vec<(ModuleInfo, Option<ParsedModuleSource>)> {
280    const MIN_PARALLEL_WAVE: usize = 8;
281    let configured = std::env::var(MODULE_GRAPH_JOBS_ENV)
282        .ok()
283        .and_then(|value| value.parse::<usize>().ok())
284        .filter(|&jobs| jobs > 0);
285    let workers = configured
286        .unwrap_or_else(|| {
287            std::thread::available_parallelism()
288                .map(std::num::NonZeroUsize::get)
289                .unwrap_or(1)
290        })
291        .min(paths.len());
292    if workers <= 1 || paths.len() < MIN_PARALLEL_WAVE {
293        return paths
294            .iter()
295            .map(|path| load_module(path, package_snapshots))
296            .collect();
297    }
298    let next = std::sync::atomic::AtomicUsize::new(0);
299    let mut produced: Vec<(usize, (ModuleInfo, Option<ParsedModuleSource>))> =
300        std::thread::scope(|scope| {
301            let handles: Vec<_> = (0..workers)
302                .map(|_| {
303                    scope.spawn(|| {
304                        let mut local = Vec::new();
305                        loop {
306                            let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
307                            let Some(path) = paths.get(index) else {
308                                break;
309                            };
310                            local.push((index, load_module(path, package_snapshots)));
311                        }
312                        local
313                    })
314                })
315                .collect();
316            handles
317                .into_iter()
318                .flat_map(|handle| match handle.join() {
319                    Ok(local) => local,
320                    Err(panic) => std::panic::resume_unwind(panic),
321                })
322                .collect()
323        });
324    produced.sort_unstable_by_key(|(index, _)| *index);
325    produced.into_iter().map(|(_, loaded)| loaded).collect()
326}
327
328/// Iteratively expand each module's `exports` set to include the transitive
329/// public surface of its `pub import "..."` re-export targets. Cycles are
330/// safe because the loop only adds names — once no module's set grows in a
331/// pass, the fixpoint is reached.
332fn resolve_re_exports(modules: &mut HashMap<PathBuf, ModuleInfo>) {
333    let keys: Vec<PathBuf> = modules.keys().cloned().collect();
334    loop {
335        let mut changed = false;
336        for path in &keys {
337            // Snapshot the wildcard target list and gather the union of
338            // their current exports without holding a mutable borrow.
339            let wildcard_paths = modules
340                .get(path)
341                .map(|m| m.wildcard_re_export_paths.clone())
342                .unwrap_or_default();
343            if wildcard_paths.is_empty() {
344                continue;
345            }
346            let mut additions: Vec<String> = Vec::new();
347            for src in &wildcard_paths {
348                let src_canonical = normalize_path(src);
349                if let Some(src_module) = modules.get(src).or_else(|| modules.get(&src_canonical)) {
350                    additions.extend(src_module.exports.iter().cloned());
351                }
352            }
353            if let Some(module) = modules.get_mut(path) {
354                for name in additions {
355                    if module.exports.insert(name) {
356                        changed = true;
357                    }
358                }
359            }
360        }
361        if !changed {
362            break;
363        }
364    }
365}
366
367impl ModuleGraph {
368    /// Sorted list of every module path discovered by [`build`]. Includes
369    /// `<std>/<name>` virtual paths for stdlib modules reached transitively.
370    /// Callers that want only real-disk modules can filter for paths whose
371    /// string form does not start with `<std>/`.
372    pub fn module_paths(&self) -> Vec<PathBuf> {
373        let mut paths: Vec<PathBuf> = self.modules.keys().cloned().collect();
374        paths.sort();
375        paths
376    }
377
378    /// True when `path` (or its canonical form) was discovered during the
379    /// module-graph walk.
380    pub fn contains_module(&self, path: &Path) -> bool {
381        self.modules.contains_key(path) || self.modules.contains_key(&normalize_path(path))
382    }
383
384    /// Collect every name used in selective imports from all files.
385    pub fn all_selective_import_names(&self) -> HashSet<&str> {
386        let mut names = HashSet::new();
387        for module in self.modules.values() {
388            for name in &module.selective_import_names {
389                names.insert(name.as_str());
390            }
391        }
392        names
393    }
394
395    /// Files that directly import `target`. Resolves `target` to a
396    /// canonical path before lookup so callers can pass either spelling.
397    pub fn importers_of(&self, target: &Path) -> Vec<PathBuf> {
398        let target = normalize_path(target);
399        let mut out: Vec<PathBuf> = self
400            .modules
401            .iter()
402            .filter(|(_, info)| {
403                info.imports.iter().any(|import| {
404                    import
405                        .path
406                        .as_ref()
407                        .is_some_and(|p| normalize_path(p) == target)
408                })
409            })
410            .map(|(path, _)| path.clone())
411            .collect();
412        out.sort();
413        out
414    }
415
416    /// Import edges declared by `file`, sorted by raw path and selected names.
417    pub fn imports_for_module(&self, file: &Path) -> Vec<ModuleImport> {
418        let file = normalize_path(file);
419        let Some(module) = self.modules.get(&file) else {
420            return Vec::new();
421        };
422        let mut imports: Vec<ModuleImport> = module
423            .imports
424            .iter()
425            .map(|import| {
426                let mut selective_names = import
427                    .selective_names
428                    .as_ref()
429                    .map(|names| names.iter().cloned().collect::<Vec<_>>());
430                if let Some(names) = selective_names.as_mut() {
431                    names.sort();
432                }
433                ModuleImport {
434                    raw_path: import.raw_path.clone(),
435                    resolved_path: import.path.as_ref().map(|path| normalize_path(path)),
436                    selective_names,
437                }
438            })
439            .collect();
440        imports.sort_by(|left, right| {
441            left.raw_path
442                .cmp(&right.raw_path)
443                .then_with(|| left.selective_names.cmp(&right.selective_names))
444                .then_with(|| left.resolved_path.cmp(&right.resolved_path))
445        });
446        imports
447    }
448
449    /// Exported symbol names for `file`, sorted alphabetically.
450    pub fn exports_for_module(&self, file: &Path) -> Vec<String> {
451        let file = normalize_path(file);
452        let Some(module) = self.modules.get(&file) else {
453            return Vec::new();
454        };
455        let mut exports: Vec<String> = module.exports.iter().cloned().collect();
456        exports.sort();
457        exports
458    }
459
460    /// Resolve wildcard imports for `file`.
461    ///
462    /// Returns `Unknown` when any wildcard import cannot be resolved, because
463    /// callers should conservatively disable wildcard-import-sensitive checks.
464    pub fn wildcard_exports_for(&self, file: &Path) -> WildcardResolution {
465        let file = normalize_path(file);
466        let Some(module) = self.modules.get(&file) else {
467            return WildcardResolution::Unknown;
468        };
469        if module.has_unresolved_wildcard_import {
470            return WildcardResolution::Unknown;
471        }
472
473        let mut names = HashSet::new();
474        for import in module
475            .imports
476            .iter()
477            .filter(|import| import.selective_names.is_none())
478        {
479            let Some(import_path) = &import.path else {
480                return WildcardResolution::Unknown;
481            };
482            let imported = self.modules.get(import_path).or_else(|| {
483                let normalized = normalize_path(import_path);
484                self.modules.get(&normalized)
485            });
486            let Some(imported) = imported else {
487                return WildcardResolution::Unknown;
488            };
489            names.extend(imported.exports.iter().cloned());
490        }
491        WildcardResolution::Resolved(names)
492    }
493
494    /// Collect every statically callable/referenceable name introduced into
495    /// `file` by its imports.
496    ///
497    /// Returns `Some` only when **every** import (wildcard or selective) in
498    /// `file` is fully resolvable via the graph. Returns `None` when any
499    /// import is unresolved, so callers can fall back to conservative
500    /// behavior instead of emitting spurious "undefined name" errors.
501    ///
502    /// The returned set contains:
503    /// - all public exports from wildcard-imported modules (transitively
504    ///   following `pub import` re-export chains), and
505    /// - selectively imported names that the target module actually exports
506    ///   (its `pub` surface or re-exports) — matching what the VM accepts at
507    ///   runtime. A name that exists only privately in the target is NOT
508    ///   importable.
509    ///
510    /// Every import in `file` whose resolved target module failed to lex or
511    /// parse. Lets `harn check <file>` surface the real error inside the
512    /// imported module (anchored at the consumer's `import` statement) instead
513    /// of downgrading the imported symbols to "undefined" at their call sites.
514    #[must_use]
515    pub fn import_compile_failures(&self, file: &Path) -> Vec<ImportCompileFailure> {
516        let file = normalize_path(file);
517        let Some(module) = self.modules.get(&file) else {
518            return Vec::new();
519        };
520        let mut failures = Vec::new();
521        for import in &module.imports {
522            let Some(import_path) = &import.path else {
523                continue;
524            };
525            let Some(target) = self
526                .modules
527                .get(import_path)
528                .or_else(|| self.modules.get(&normalize_path(import_path)))
529            else {
530                continue;
531            };
532            if let Some(error) = &target.load_error {
533                failures.push(ImportCompileFailure {
534                    import_raw_path: import.raw_path.clone(),
535                    import_span: import.import_span,
536                    module_path: normalize_path(import_path),
537                    error: error.clone(),
538                });
539            }
540        }
541        failures
542    }
543
544    pub fn imported_names_for_file(&self, file: &Path) -> Option<HashSet<String>> {
545        let file = normalize_path(file);
546        let module = self.modules.get(&file)?;
547        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
548            return None;
549        }
550
551        let mut names = HashSet::new();
552        for import in &module.imports {
553            let import_path = import.path.as_ref()?;
554            let imported = self
555                .modules
556                .get(import_path)
557                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
558            // The target parsed nothing (lex/parse failure). Fall back to the
559            // conservative `None` answer so the cross-module undefined-name
560            // check stays silent — the real error is surfaced separately by
561            // `import_compile_failures`, not mislabeled as an undefined symbol
562            // at this consumer's call site.
563            if imported.load_error.is_some() {
564                return None;
565            }
566            match &import.selective_names {
567                None => {
568                    names.extend(imported.exports.iter().cloned());
569                }
570                Some(selective) => {
571                    // A selectively imported name is in scope when it exists in
572                    // the target module (as a declaration or a re-export). The
573                    // stricter "must be `pub`" check is reported precisely at
574                    // the import site by the `HARN-IMP-002` preflight scan
575                    // (`scan_selective_import_visibility`) and enforced at load
576                    // time, so it is intentionally *not* duplicated here —
577                    // otherwise a private import would surface both an
578                    // import-site and a redundant call-site error.
579                    for name in selective {
580                        if imported.declarations.contains_key(name)
581                            || imported.exports.contains(name)
582                        {
583                            names.insert(name.clone());
584                        }
585                    }
586                }
587            }
588        }
589        Some(names)
590    }
591
592    /// Collect type / struct / enum / interface declarations made visible to
593    /// `file` by its imports. Returns `None` when any import is unresolved so
594    /// callers can fall back to conservative behavior.
595    pub fn imported_type_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
596        let file = normalize_path(file);
597        let module = self.modules.get(&file)?;
598        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
599            return None;
600        }
601
602        let mut decls = Vec::new();
603        for import in &module.imports {
604            let import_path = import.path.as_ref()?;
605            let imported = self
606                .modules
607                .get(import_path)
608                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
609            // The target parsed nothing (lex/parse failure). Fall back to the
610            // conservative `None` answer so the cross-module undefined-name
611            // check stays silent — the real error is surfaced separately by
612            // `import_compile_failures`, not mislabeled as an undefined symbol
613            // at this consumer's call site.
614            if imported.load_error.is_some() {
615                return None;
616            }
617            let names_to_collect: Vec<String> = match &import.selective_names {
618                None => imported.exports.iter().cloned().collect(),
619                Some(selective) => selective.iter().cloned().collect(),
620            };
621            for name in &names_to_collect {
622                let mut visited = HashSet::new();
623                if let Some(decl) = self.find_exported_type_decl(import_path, name, &mut visited) {
624                    decls.push(decl);
625                }
626            }
627            // Every type alias / struct / enum / interface declared in the
628            // imported module is visible to the *typechecker*, `pub` or not:
629            // an imported fn's signature may reference a module-private alias
630            // ("options: PickKeysOptions"), and without its definition the
631            // caller sees only a phantom `Named(...)` and skips contract
632            // checks. This visibility is typing-only — name-level import
633            // privacy is still enforced by `non_exported_selective_imports`
634            // and the runtime loader, which reject importing a non-`pub`
635            // type by name.
636            for ty_decl in &imported.type_declarations {
637                if type_decl_name(ty_decl).is_some() {
638                    decls.push(ty_decl.clone());
639                }
640            }
641        }
642        Some(decls)
643    }
644
645    /// Collect callable declarations made visible to `file` by its imports.
646    /// Only signatures are consumed by the type checker; imported bodies
647    /// remain owned by their defining modules.
648    pub fn imported_callable_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
649        let file = normalize_path(file);
650        let module = self.modules.get(&file)?;
651        if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
652            return None;
653        }
654
655        let mut decls = Vec::new();
656        for import in &module.imports {
657            let import_path = import.path.as_ref()?;
658            let imported = self
659                .modules
660                .get(import_path)
661                .or_else(|| self.modules.get(&normalize_path(import_path)))?;
662            // The target parsed nothing (lex/parse failure). Fall back to the
663            // conservative `None` answer so the cross-module undefined-name
664            // check stays silent — the real error is surfaced separately by
665            // `import_compile_failures`, not mislabeled as an undefined symbol
666            // at this consumer's call site.
667            if imported.load_error.is_some() {
668                return None;
669            }
670            let selective_import = import.selective_names.is_some();
671            let names_to_collect: Vec<String> = match &import.selective_names {
672                None => imported.exports.iter().cloned().collect(),
673                Some(selective) => selective.iter().cloned().collect(),
674            };
675            for name in &names_to_collect {
676                if selective_import || imported.own_exports.contains(name) {
677                    if let Some(decl) = imported
678                        .callable_declarations
679                        .iter()
680                        .find(|decl| callable_decl_name(decl) == Some(name.as_str()))
681                    {
682                        decls.push(decl.clone());
683                        continue;
684                    }
685                }
686                let mut visited = HashSet::new();
687                if let Some(decl) =
688                    self.find_exported_callable_decl(import_path, name, &mut visited)
689                {
690                    decls.push(decl);
691                }
692            }
693        }
694        Some(decls)
695    }
696
697    /// Walk a module's local type declarations and re-export chains to find
698    /// the SNode for an exported type/struct/enum/interface named `name`.
699    fn find_exported_type_decl(
700        &self,
701        path: &Path,
702        name: &str,
703        visited: &mut HashSet<PathBuf>,
704    ) -> Option<SNode> {
705        let canonical = normalize_path(path);
706        if !visited.insert(canonical.clone()) {
707            return None;
708        }
709        let module = self
710            .modules
711            .get(&canonical)
712            .or_else(|| self.modules.get(path))?;
713        for decl in &module.type_declarations {
714            if type_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
715                return Some(decl.clone());
716            }
717        }
718        if let Some(sources) = module.selective_re_exports.get(name) {
719            for source in sources {
720                if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
721                    return Some(decl);
722                }
723            }
724        }
725        for source in &module.wildcard_re_export_paths {
726            if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
727                return Some(decl);
728            }
729        }
730        None
731    }
732
733    fn find_exported_callable_decl(
734        &self,
735        path: &Path,
736        name: &str,
737        visited: &mut HashSet<PathBuf>,
738    ) -> Option<SNode> {
739        let canonical = normalize_path(path);
740        if !visited.insert(canonical.clone()) {
741            return None;
742        }
743        let module = self
744            .modules
745            .get(&canonical)
746            .or_else(|| self.modules.get(path))?;
747        for decl in &module.callable_declarations {
748            if callable_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
749                return Some(decl.clone());
750            }
751        }
752        if let Some(sources) = module.selective_re_exports.get(name) {
753            for source in sources {
754                if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
755                    return Some(decl);
756                }
757            }
758        }
759        for source in &module.wildcard_re_export_paths {
760            if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
761                return Some(decl);
762            }
763        }
764        None
765    }
766
767    /// Find the definition of `name` visible from `file`.
768    ///
769    /// Recurses through `pub import` re-export chains so go-to-definition
770    /// lands on the symbol's actual declaration site instead of the facade
771    /// module that forwarded it.
772    pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
773        let mut visited = HashSet::new();
774        self.definition_of_inner(file, name, &mut visited)
775    }
776
777    /// Sorted names of every declaration recorded for `file` (functions,
778    /// pipelines, tools, structs, ...). Used by the check-result cache to
779    /// key the cross-file lint-exemption subset that applies to this file.
780    pub fn declared_names_for_file(&self, file: &Path) -> Option<Vec<&str>> {
781        let module = self.modules.get(&normalize_path(file))?;
782        let mut names: Vec<&str> = module.declarations.keys().map(String::as_str).collect();
783        names.sort_unstable();
784        Some(names)
785    }
786
787    fn definition_of_inner(
788        &self,
789        file: &Path,
790        name: &str,
791        visited: &mut HashSet<PathBuf>,
792    ) -> Option<DefSite> {
793        let file = normalize_path(file);
794        if !visited.insert(file.clone()) {
795            return None;
796        }
797        let current = self.modules.get(&file)?;
798
799        if let Some(local) = current.declarations.get(name) {
800            return Some(local.clone());
801        }
802
803        // `pub import { name } from "..."` — follow the first recorded
804        // source. Conflicting re-exports surface separately as
805        // diagnostics; here we just pick a canonical destination so
806        // go-to-definition lands somewhere useful.
807        if let Some(sources) = current.selective_re_exports.get(name) {
808            for source in sources {
809                if let Some(def) = self.definition_of_inner(source, name, visited) {
810                    return Some(def);
811                }
812            }
813        }
814
815        // `pub import "..."` — chase each wildcard re-export source.
816        for source in &current.wildcard_re_export_paths {
817            if let Some(def) = self.definition_of_inner(source, name, visited) {
818                return Some(def);
819            }
820        }
821
822        // Private selective imports.
823        for import in &current.imports {
824            let Some(selective_names) = &import.selective_names else {
825                continue;
826            };
827            if !selective_names.contains(name) {
828                continue;
829            }
830            if let Some(path) = &import.path {
831                if let Some(def) = self.definition_of_inner(path, name, visited) {
832                    return Some(def);
833                }
834            }
835        }
836
837        // Private wildcard imports.
838        for import in &current.imports {
839            if import.selective_names.is_some() {
840                continue;
841            }
842            if let Some(path) = &import.path {
843                if let Some(def) = self.definition_of_inner(path, name, visited) {
844                    return Some(def);
845                }
846            }
847        }
848
849        None
850    }
851
852    /// Diagnostics for re-export conflicts inside `file`. Each diagnostic
853    /// names the conflicting symbol and the modules that contributed it,
854    /// so check-time errors can be precise.
855    pub fn re_export_conflicts(&self, file: &Path) -> Vec<ReExportConflict> {
856        let file = normalize_path(file);
857        let Some(module) = self.modules.get(&file) else {
858            return Vec::new();
859        };
860
861        // Build, for each re-exported name, the set of source modules it
862        // could resolve to. Names that resolve to more than one source are
863        // ambiguous and reported.
864        let mut sources: HashMap<String, Vec<PathBuf>> = HashMap::new();
865
866        for (name, srcs) in &module.selective_re_exports {
867            sources
868                .entry(name.clone())
869                .or_default()
870                .extend(srcs.iter().cloned());
871        }
872        for src in &module.wildcard_re_export_paths {
873            let canonical = normalize_path(src);
874            let Some(src_module) = self
875                .modules
876                .get(&canonical)
877                .or_else(|| self.modules.get(src))
878            else {
879                continue;
880            };
881            for name in &src_module.exports {
882                sources
883                    .entry(name.clone())
884                    .or_default()
885                    .push(canonical.clone());
886            }
887        }
888
889        // A re-export that collides with a locally exported declaration is
890        // also an error: the facade module cannot expose two different
891        // bindings under the same name.
892        for name in &module.own_exports {
893            if let Some(entry) = sources.get_mut(name) {
894                entry.push(file.clone());
895            }
896        }
897
898        let mut conflicts = Vec::new();
899        for (name, mut srcs) in sources {
900            srcs.sort();
901            srcs.dedup();
902            if srcs.len() > 1 {
903                conflicts.push(ReExportConflict {
904                    name,
905                    sources: srcs,
906                });
907            }
908        }
909        conflicts.sort_by(|a, b| a.name.cmp(&b.name));
910        conflicts
911    }
912
913    /// Selective imports in `file` that name a symbol the target module
914    /// declares but does not export — a non-`pub` function in a module that
915    /// has opted into explicit exports by marking at least one function `pub`.
916    ///
917    /// Such names are private: importing them by name is no more valid than a
918    /// wildcard import reaching them, and matches the strict visibility of
919    /// TypeScript, Rust, and Go. This is the single source of truth for that
920    /// determination — the CLI maps the result onto import spans and emits
921    /// `HARN-IMP-002`, and the runtime loader enforces the same rule. A module
922    /// that marks nothing `pub` exports nothing, so selectively importing any
923    /// name it declares is flagged.
924    pub fn non_exported_selective_imports(&self, file: &Path) -> Vec<NonExportedImport> {
925        let file = normalize_path(file);
926        let Some(module) = self.modules.get(&file) else {
927            return Vec::new();
928        };
929
930        let mut out = Vec::new();
931        for import in &module.imports {
932            let Some(selective) = &import.selective_names else {
933                continue;
934            };
935            let Some(import_path) = &import.path else {
936                continue;
937            };
938            let Some(target) = self
939                .modules
940                .get(import_path)
941                .or_else(|| self.modules.get(&normalize_path(import_path)))
942            else {
943                continue;
944            };
945            for name in selective {
946                // Declared in the target but absent from its export surface
947                // (and not a re-export, which lives in `exports`, not
948                // `declarations`).
949                if target.declarations.contains_key(name) && !target.exports.contains(name) {
950                    out.push(NonExportedImport {
951                        name: name.clone(),
952                        module: import.raw_path.clone(),
953                    });
954                }
955            }
956        }
957        out.sort_by(|a, b| (&a.name, &a.module).cmp(&(&b.name, &b.module)));
958        out.dedup();
959        out
960    }
961}
962
963/// A duplicate or ambiguous re-export inside a single module. Reported by
964/// [`ModuleGraph::re_export_conflicts`].
965#[derive(Debug, Clone, PartialEq, Eq)]
966pub struct ReExportConflict {
967    pub name: String,
968    pub sources: Vec<PathBuf>,
969}
970
971/// A selective import of a name the target module declares but does not
972/// export. Reported by [`ModuleGraph::non_exported_selective_imports`].
973#[derive(Debug, Clone, PartialEq, Eq)]
974pub struct NonExportedImport {
975    /// The non-exported name the import requested.
976    pub name: String,
977    /// The module path exactly as written in the import statement.
978    pub module: String,
979}
980
981fn load_module(
982    path: &Path,
983    package_snapshots: &[PackageSnapshot],
984) -> (ModuleInfo, Option<ParsedModuleSource>) {
985    let Some(source) = read_module_source(path) else {
986        return (ModuleInfo::default(), None);
987    };
988    let mut lexer = harn_lexer::Lexer::new(&source);
989    let tokens = match lexer.tokenize() {
990        Ok(tokens) => tokens,
991        Err(error) => {
992            let module = ModuleInfo {
993                load_error: Some(ModuleLoadError {
994                    message: error.to_string(),
995                    span: error.span(),
996                }),
997                ..ModuleInfo::default()
998            };
999            return (module, None);
1000        }
1001    };
1002    let mut parser = Parser::new(tokens);
1003    let program = match parser.parse() {
1004        Ok(program) => program,
1005        Err(error) => {
1006            let module = ModuleInfo {
1007                load_error: Some(ModuleLoadError {
1008                    message: error.to_string(),
1009                    span: error.span(),
1010                }),
1011                ..ModuleInfo::default()
1012            };
1013            return (module, None);
1014        }
1015    };
1016
1017    let mut module = ModuleInfo::default();
1018    for node in &program {
1019        collect_module_info(path, node, &mut module, package_snapshots);
1020        collect_type_declarations(node, &mut module.type_declarations);
1021        collect_callable_declarations(node, &mut module.callable_declarations);
1022    }
1023    // Seed the transitive `exports` set from local exports plus selective
1024    // re-export names. Wildcard re-exports are folded in by
1025    // [`resolve_re_exports`] after every module has been loaded.
1026    module.exports.extend(module.own_exports.iter().cloned());
1027    module
1028        .exports
1029        .extend(module.selective_re_exports.keys().cloned());
1030    let parsed = ParsedModuleSource { source, program };
1031    (module, Some(parsed))
1032}
1033
1034/// Extract the stdlib module name when `path` is a `<std>/<name>`
1035/// virtual path, otherwise `None`.
1036fn stdlib_module_from_path(path: &Path) -> Option<&str> {
1037    let s = path.to_str()?;
1038    s.strip_prefix("<std>/")
1039}
1040
1041fn collect_module_info(
1042    file: &Path,
1043    snode: &SNode,
1044    module: &mut ModuleInfo,
1045    package_snapshots: &[PackageSnapshot],
1046) {
1047    match &snode.node {
1048        Node::FnDecl {
1049            name,
1050            params,
1051            is_pub,
1052            ..
1053        } => {
1054            if *is_pub {
1055                module.own_exports.insert(name.clone());
1056            }
1057            module.declarations.insert(
1058                name.clone(),
1059                decl_site(file, snode.span, name, DefKind::Function),
1060            );
1061            for param_name in params.iter().map(|param| param.name.clone()) {
1062                module.declarations.insert(
1063                    param_name.clone(),
1064                    decl_site(file, snode.span, &param_name, DefKind::Parameter),
1065                );
1066            }
1067        }
1068        Node::Pipeline { name, is_pub, .. } => {
1069            if *is_pub {
1070                module.own_exports.insert(name.clone());
1071            }
1072            module.declarations.insert(
1073                name.clone(),
1074                decl_site(file, snode.span, name, DefKind::Pipeline),
1075            );
1076        }
1077        Node::ToolDecl { name, is_pub, .. } => {
1078            if *is_pub {
1079                module.own_exports.insert(name.clone());
1080            }
1081            module.declarations.insert(
1082                name.clone(),
1083                decl_site(file, snode.span, name, DefKind::Tool),
1084            );
1085        }
1086        Node::SkillDecl { name, is_pub, .. } => {
1087            if *is_pub {
1088                module.own_exports.insert(name.clone());
1089            }
1090            module.declarations.insert(
1091                name.clone(),
1092                decl_site(file, snode.span, name, DefKind::Skill),
1093            );
1094        }
1095        Node::StructDecl { name, is_pub, .. } => {
1096            if *is_pub {
1097                module.own_exports.insert(name.clone());
1098            }
1099            module.declarations.insert(
1100                name.clone(),
1101                decl_site(file, snode.span, name, DefKind::Struct),
1102            );
1103        }
1104        Node::EnumDecl { name, is_pub, .. } => {
1105            if *is_pub {
1106                module.own_exports.insert(name.clone());
1107            }
1108            module.declarations.insert(
1109                name.clone(),
1110                decl_site(file, snode.span, name, DefKind::Enum),
1111            );
1112        }
1113        Node::InterfaceDecl { name, .. } => {
1114            module.own_exports.insert(name.clone());
1115            module.declarations.insert(
1116                name.clone(),
1117                decl_site(file, snode.span, name, DefKind::Interface),
1118            );
1119        }
1120        Node::TypeDecl { name, is_pub, .. } => {
1121            if *is_pub {
1122                module.own_exports.insert(name.clone());
1123            }
1124            module.declarations.insert(
1125                name.clone(),
1126                decl_site(file, snode.span, name, DefKind::Type),
1127            );
1128        }
1129        Node::LetBinding {
1130            pattern, is_pub, ..
1131        }
1132        | Node::ConstBinding {
1133            pattern, is_pub, ..
1134        } => {
1135            for name in pattern_names(pattern) {
1136                // A top-level `pub const`/`pub let` exports its (identifier)
1137                // binding as part of the module's public value surface, on the
1138                // same footing as `pub fn`.
1139                if *is_pub {
1140                    module.own_exports.insert(name.clone());
1141                }
1142                module.declarations.insert(
1143                    name.clone(),
1144                    decl_site(file, snode.span, &name, DefKind::Variable),
1145                );
1146            }
1147        }
1148        Node::ImportDecl { path, is_pub } => {
1149            let import_path = resolve_import_path_with_snapshots(file, path, package_snapshots);
1150            if import_path.is_none() {
1151                module.has_unresolved_wildcard_import = true;
1152            }
1153            if *is_pub {
1154                if let Some(resolved) = &import_path {
1155                    module
1156                        .wildcard_re_export_paths
1157                        .push(normalize_path(resolved));
1158                }
1159            }
1160            module.imports.push(ImportRef {
1161                raw_path: path.clone(),
1162                path: import_path,
1163                selective_names: None,
1164                import_span: snode.span,
1165            });
1166        }
1167        Node::SelectiveImport {
1168            names,
1169            path,
1170            is_pub,
1171        } => {
1172            let import_path = resolve_import_path_with_snapshots(file, path, package_snapshots);
1173            if import_path.is_none() {
1174                module.has_unresolved_selective_import = true;
1175            }
1176            if *is_pub {
1177                if let Some(resolved) = &import_path {
1178                    let canonical = normalize_path(resolved);
1179                    for name in names {
1180                        module
1181                            .selective_re_exports
1182                            .entry(name.clone())
1183                            .or_default()
1184                            .push(canonical.clone());
1185                    }
1186                }
1187            }
1188            let names: HashSet<String> = names.iter().cloned().collect();
1189            module.selective_import_names.extend(names.iter().cloned());
1190            module.imports.push(ImportRef {
1191                raw_path: path.clone(),
1192                path: import_path,
1193                selective_names: Some(names),
1194                import_span: snode.span,
1195            });
1196        }
1197        Node::AttributedDecl { inner, .. } => {
1198            collect_module_info(file, inner, module, package_snapshots);
1199        }
1200        _ => {}
1201    }
1202}
1203
1204fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1205    match &snode.node {
1206        Node::TypeDecl { .. }
1207        | Node::StructDecl { .. }
1208        | Node::EnumDecl { .. }
1209        | Node::InterfaceDecl { .. } => decls.push(snode.clone()),
1210        Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
1211        _ => {}
1212    }
1213}
1214
1215fn collect_callable_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1216    match &snode.node {
1217        Node::FnDecl { .. } | Node::Pipeline { .. } | Node::ToolDecl { .. } => {
1218            decls.push(snode.clone());
1219        }
1220        Node::AttributedDecl { inner, .. } => collect_callable_declarations(inner, decls),
1221        _ => {}
1222    }
1223}
1224
1225fn type_decl_name(snode: &SNode) -> Option<&str> {
1226    match &snode.node {
1227        Node::TypeDecl { name, .. }
1228        | Node::StructDecl { name, .. }
1229        | Node::EnumDecl { name, .. }
1230        | Node::InterfaceDecl { name, .. } => Some(name.as_str()),
1231        _ => None,
1232    }
1233}
1234
1235fn callable_decl_name(snode: &SNode) -> Option<&str> {
1236    match &snode.node {
1237        Node::FnDecl { name, .. } | Node::Pipeline { name, .. } | Node::ToolDecl { name, .. } => {
1238            Some(name.as_str())
1239        }
1240        Node::AttributedDecl { inner, .. } => callable_decl_name(inner),
1241        _ => None,
1242    }
1243}
1244
1245fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
1246    DefSite {
1247        name: name.to_string(),
1248        file: file.to_path_buf(),
1249        kind,
1250        span,
1251    }
1252}
1253
1254fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
1255    match pattern {
1256        BindingPattern::Identifier(name) => vec![name.clone()],
1257        BindingPattern::Dict(fields) => fields
1258            .iter()
1259            .filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
1260            .collect(),
1261        BindingPattern::List(elements) => elements
1262            .iter()
1263            .map(|element| element.name.clone())
1264            .collect(),
1265        BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
1266    }
1267}
1268
1269fn normalize_path(path: &Path) -> PathBuf {
1270    canonical_path(path)
1271}
1272
1273/// Canonicalize `path`, memoized process-wide.
1274///
1275/// Module-graph construction and every per-file graph query canonicalize
1276/// paths to dedupe import-edge spellings, and the check preflight scan
1277/// canonicalizes each visited module per checked file. `Path::canonicalize`
1278/// resolves every component through the kernel, so a whole-tree `harn check`
1279/// used to spend the bulk of its wall clock in path-resolution syscalls
1280/// (`getattrlist` dominated system time). One positive-result memo removes
1281/// the `O(files x import closure)` repetition; failed canonicalizations are
1282/// not memoized (mirroring the bytecode cache's `canonicalize_cached`) so a
1283/// file that appears later still resolves correctly in long-lived processes.
1284/// `<std>/` virtual paths pass through untouched.
1285pub fn canonical_path(path: &Path) -> PathBuf {
1286    use std::sync::OnceLock;
1287    if stdlib_module_from_path(path).is_some() {
1288        return path.to_path_buf();
1289    }
1290    static MEMO: OnceLock<std::sync::Mutex<HashMap<PathBuf, PathBuf>>> = OnceLock::new();
1291    let memo = MEMO.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
1292    if let Some(hit) = memo
1293        .lock()
1294        .expect("canonical path memo lock poisoned")
1295        .get(path)
1296        .cloned()
1297    {
1298        return hit;
1299    }
1300    match path.canonicalize() {
1301        Ok(canonical) => {
1302            memo.lock()
1303                .expect("canonical path memo lock poisoned")
1304                .insert(path.to_path_buf(), canonical.clone());
1305            canonical
1306        }
1307        Err(_) => path.to_path_buf(),
1308    }
1309}
1310
1311#[cfg(test)]
1312#[path = "tests.rs"]
1313mod tests;