Skip to main content

harn_modules/
lib.rs

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