Skip to main content

harn_modules/
lib.rs

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