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