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