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