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