Skip to main content

harn_modules/
package_imports.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Component, Path, PathBuf};
3
4use serde::Deserialize;
5
6use crate::package_snapshot::PackageSnapshot;
7
8#[derive(Debug, Default, Deserialize)]
9struct PackageManifest {
10    #[serde(default)]
11    exports: HashMap<String, String>,
12}
13
14/// How far an import resolves without consulting installed packages.
15///
16/// The distinction between `Rejected` and `NotPackage` is load-bearing: a
17/// `std/` import that names no real stdlib module resolves to nothing and must
18/// NOT fall through to package resolution, or a package could shadow the
19/// standard library.
20enum LocalResolution {
21    /// Resolved without touching installed packages.
22    Resolved(PathBuf),
23    /// Owned by the stdlib namespace but not a real module. Resolution ends.
24    Rejected,
25    /// Not a stdlib or relative import; only packages can resolve it.
26    NotPackage,
27}
28
29/// Resolve everything that does not require a package snapshot.
30///
31/// Sole owner of the stdlib and relative-path import rules, so the lazy and
32/// pre-acquired entry points below cannot drift apart on what counts as local.
33fn resolve_local_import(current_file: &Path, import_path: &str) -> LocalResolution {
34    if let Some(module) = import_path
35        .strip_prefix("std/")
36        .or_else(|| (import_path == "observability").then_some("observability"))
37    {
38        return match super::stdlib::get_stdlib_source(module) {
39            Some(_) => LocalResolution::Resolved(super::stdlib::stdlib_virtual_path(module)),
40            None => LocalResolution::Rejected,
41        };
42    }
43
44    let base = current_file.parent().unwrap_or(Path::new("."));
45    let mut file_path = base.join(import_path);
46    if !file_path.exists() && file_path.extension().is_none() {
47        file_path.set_extension("harn");
48    }
49    if file_path.exists() {
50        return LocalResolution::Resolved(file_path);
51    }
52
53    LocalResolution::NotPackage
54}
55
56/// Resolve an import string relative to the importing file.
57///
58/// Returns the path as constructed so callers can compare it with their own
59/// `PathBuf::join` result. The module graph canonicalizes its internal keys.
60pub fn resolve_import_path(current_file: &Path, import_path: &str) -> Option<PathBuf> {
61    match resolve_local_import(current_file, import_path) {
62        LocalResolution::Resolved(path) => Some(path),
63        LocalResolution::Rejected => None,
64        // Only a package import needs a generation lease, so only a package
65        // import pays for one. Acquiring it before the stdlib and relative
66        // checks made every `std/...` and every sibling import — nearly all of
67        // them — walk its ancestors stat-ing for a package pointer, then open,
68        // flock and parse it, and then discard the snapshot unused. That is
69        // pure syscall cost on the hottest path in the module graph.
70        LocalResolution::NotPackage => {
71            let snapshots = PackageSnapshot::acquire_nearest(current_file)
72                .ok()
73                .flatten()
74                .into_iter()
75                .collect::<Vec<_>>();
76            resolve_package_import(current_file, import_path, &snapshots)
77        }
78    }
79}
80
81pub(crate) fn resolve_import_path_with_snapshots(
82    current_file: &Path,
83    import_path: &str,
84    package_snapshots: &[PackageSnapshot],
85) -> Option<PathBuf> {
86    match resolve_local_import(current_file, import_path) {
87        LocalResolution::Resolved(path) => Some(path),
88        LocalResolution::Rejected => None,
89        LocalResolution::NotPackage => {
90            resolve_package_import(current_file, import_path, package_snapshots)
91        }
92    }
93}
94
95/// Acquire one snapshot per DISTINCT project root among `files`.
96///
97/// Dedupe on the root before acquiring, not after. Acquiring is the expensive
98/// half — canonicalize, two shared flocks, two TOML parses, and a re-read plus
99/// SHA256 of the lockfile — so acquiring per file and discarding the duplicates
100/// made a whole-tree build pay it once per FILE. Every real invocation resolves
101/// many files under a single root, so all but one of those was thrown away.
102pub(crate) fn acquire_package_snapshots(files: &[PathBuf]) -> Vec<PackageSnapshot> {
103    let mut walked_roots = HashSet::new();
104    let mut canonical_roots = HashSet::new();
105    let mut snapshots = Vec::new();
106    for file in files {
107        // Cheap: a handful of stats up the ancestors.
108        let Some(root) = PackageSnapshot::nearest_project_root(file) else {
109            continue;
110        };
111        if !walked_roots.insert(root.clone()) {
112            continue;
113        }
114        // Expensive: reached at most once per distinct walked root.
115        let Ok(Some(snapshot)) = PackageSnapshot::acquire(&root) else {
116            continue;
117        };
118        // `acquire` canonicalizes, so two walked roots that differ only by
119        // symlink can still land on one real root. Dedupe on the canonical
120        // root as the original did, or such a tree would get two snapshots
121        // where it used to get one.
122        if canonical_roots.insert(snapshot.project_root().to_path_buf()) {
123            snapshots.push(snapshot);
124        }
125    }
126    snapshots
127}
128
129fn resolve_package_import(
130    current_file: &Path,
131    import_path: &str,
132    package_snapshots: &[PackageSnapshot],
133) -> Option<PathBuf> {
134    let current_file = canonicalize_with_existing_parent(current_file);
135    package_snapshots
136        .iter()
137        .filter(|snapshot| current_file.starts_with(snapshot.project_root()))
138        .max_by_key(|snapshot| snapshot.project_root().components().count())
139        .and_then(|snapshot| resolve_from_packages_root(snapshot.packages_root(), import_path))
140}
141
142fn canonicalize_with_existing_parent(path: &Path) -> PathBuf {
143    path.canonicalize().unwrap_or_else(|_| {
144        path.parent()
145            .and_then(|parent| parent.canonicalize().ok())
146            .and_then(|parent| path.file_name().map(|name| parent.join(name)))
147            .unwrap_or_else(|| path.to_path_buf())
148    })
149}
150
151fn resolve_from_packages_root(packages_root: &Path, import_path: &str) -> Option<PathBuf> {
152    let safe_import_path = safe_package_relative_path(import_path)?;
153    let package_name = package_name_from_relative_path(&safe_import_path)?;
154    let package_root = packages_root.join(package_name);
155
156    let direct_path = packages_root.join(&safe_import_path);
157    if let Some(path) = finalize_package_target(&package_root, &direct_path) {
158        return Some(path);
159    }
160
161    let export_name = export_name_from_relative_path(&safe_import_path)?;
162    let manifest = read_package_manifest(&package_root.join("harn.toml"))?;
163    let safe_export_path = safe_package_relative_path(manifest.exports.get(export_name)?)?;
164    finalize_package_target(&package_root, &package_root.join(safe_export_path))
165}
166
167fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
168    let content = std::fs::read_to_string(path).ok()?;
169    toml::from_str(&content).ok()
170}
171
172fn safe_package_relative_path(raw: &str) -> Option<PathBuf> {
173    if raw.is_empty() || raw.contains('\\') {
174        return None;
175    }
176    let mut out = PathBuf::new();
177    let mut saw_component = false;
178    for component in Path::new(raw).components() {
179        match component {
180            Component::Normal(part) => {
181                saw_component = true;
182                out.push(part);
183            }
184            Component::CurDir => {}
185            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
186        }
187    }
188    saw_component.then_some(out)
189}
190
191fn package_name_from_relative_path(path: &Path) -> Option<&str> {
192    match path.components().next()? {
193        Component::Normal(name) => name.to_str(),
194        _ => None,
195    }
196}
197
198fn export_name_from_relative_path(path: &Path) -> Option<&str> {
199    let mut components = path.components();
200    components.next()?;
201    let rest = components.as_path();
202    if rest.as_os_str().is_empty() {
203        None
204    } else {
205        rest.to_str()
206    }
207}
208
209fn target_within_package_root(package_root: &Path, path: PathBuf) -> Option<PathBuf> {
210    let root = package_root.canonicalize().ok()?;
211    let canonical = path.canonicalize().ok()?;
212    (canonical == root || canonical.starts_with(&root)).then_some(path)
213}
214
215fn finalize_package_target(package_root: &Path, path: &Path) -> Option<PathBuf> {
216    if path.is_dir() {
217        let lib = path.join("lib.harn");
218        return if lib.exists() {
219            target_within_package_root(package_root, lib)
220        } else {
221            target_within_package_root(package_root, path.to_path_buf())
222        };
223    }
224    if path.exists() {
225        return target_within_package_root(package_root, path.to_path_buf());
226    }
227    if path.extension().is_none() {
228        let mut with_extension = path.to_path_buf();
229        with_extension.set_extension("harn");
230        if with_extension.exists() {
231            return target_within_package_root(package_root, with_extension);
232        }
233    }
234    None
235}