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