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