Skip to main content

harn_modules/
lib.rs

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