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