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