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