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