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            let resolved = resolve_package_import(current_file, import_path, &snapshots);
78            if resolved.is_some() {
79                for snapshot in snapshots {
80                    snapshot.retain_for_process();
81                }
82            }
83            resolved
84        }
85    }
86}
87
88pub(crate) fn resolve_import_path_with_snapshots(
89    current_file: &Path,
90    import_path: &str,
91    package_snapshots: &[PackageSnapshot],
92) -> Option<PathBuf> {
93    match resolve_local_import(current_file, import_path) {
94        LocalResolution::Resolved(path) => Some(path),
95        LocalResolution::Rejected => None,
96        LocalResolution::NotPackage => {
97            resolve_package_import(current_file, import_path, package_snapshots)
98        }
99    }
100}
101
102pub fn resolve_import_path_with_snapshot(
103    current_file: &Path,
104    import_path: &str,
105    package_snapshot: &PackageSnapshot,
106) -> Option<PathBuf> {
107    match resolve_local_import(current_file, import_path) {
108        LocalResolution::Resolved(path) => Some(path),
109        LocalResolution::Rejected => None,
110        // An explicit snapshot is caller-owned resolution authority. Unlike
111        // lazy discovery it also covers generation-owned path-package
112        // symlinks whose canonical source is outside the project root.
113        LocalResolution::NotPackage => {
114            resolve_from_packages_root(package_snapshot.packages_root(), import_path)
115        }
116    }
117}
118
119pub fn resolve_import_path_with_guard(
120    current_file: &Path,
121    import_path: &str,
122    guard: &PackageExecutionGuard,
123) -> Result<Option<PathBuf>, PackageExecutionError> {
124    guard.validate_import_path(current_file, import_path)?;
125    match resolve_local_import(current_file, import_path) {
126        LocalResolution::Resolved(path) => Ok(Some(path)),
127        LocalResolution::Rejected => Ok(None),
128        LocalResolution::NotPackage => resolve_from_packages_root_with_guard(
129            guard.snapshot().packages_root(),
130            import_path,
131            guard,
132        ),
133    }
134}
135
136/// Acquire one snapshot per DISTINCT project root among `files`.
137///
138/// Dedupe on the root before acquiring, not after. Acquiring is the expensive
139/// half — canonicalize, two shared flocks, two TOML parses, and a re-read plus
140/// SHA256 of the lockfile — so acquiring per file and discarding the duplicates
141/// made a whole-tree build pay it once per FILE. Every real invocation resolves
142/// many files under a single root, so all but one of those was thrown away.
143pub(crate) fn acquire_package_snapshots(files: &[PathBuf]) -> Vec<PackageSnapshot> {
144    let mut walked_roots = HashSet::new();
145    let mut canonical_roots = HashSet::new();
146    let mut snapshots = Vec::new();
147    for file in files {
148        // Cheap: a handful of stats up the ancestors.
149        let Some(root) = PackageSnapshot::nearest_project_root(file) else {
150            continue;
151        };
152        if !walked_roots.insert(root.clone()) {
153            continue;
154        }
155        // Expensive: reached at most once per distinct walked root.
156        let Ok(Some(snapshot)) = PackageSnapshot::acquire(&root) else {
157            continue;
158        };
159        // `acquire` canonicalizes, so two walked roots that differ only by
160        // symlink can still land on one real root. Dedupe on the canonical
161        // root as the original did, or such a tree would get two snapshots
162        // where it used to get one.
163        if canonical_roots.insert(snapshot.project_root().to_path_buf()) {
164            snapshots.push(snapshot);
165        }
166    }
167    snapshots
168}
169
170fn resolve_package_import(
171    current_file: &Path,
172    import_path: &str,
173    package_snapshots: &[PackageSnapshot],
174) -> Option<PathBuf> {
175    let current_file = canonicalize_with_existing_parent(current_file);
176    package_snapshots
177        .iter()
178        .filter(|snapshot| current_file.starts_with(snapshot.project_root()))
179        .max_by_key(|snapshot| snapshot.project_root().components().count())
180        .and_then(|snapshot| resolve_from_packages_root(snapshot.packages_root(), import_path))
181}
182
183fn canonicalize_with_existing_parent(path: &Path) -> PathBuf {
184    path.canonicalize().unwrap_or_else(|_| {
185        path.parent()
186            .and_then(|parent| parent.canonicalize().ok())
187            .and_then(|parent| path.file_name().map(|name| parent.join(name)))
188            .unwrap_or_else(|| path.to_path_buf())
189    })
190}
191
192fn resolve_from_packages_root(packages_root: &Path, import_path: &str) -> Option<PathBuf> {
193    let safe_import_path = safe_package_relative_path(import_path)?;
194    let package_name = package_name_from_relative_path(&safe_import_path)?;
195    let package_root = packages_root.join(package_name);
196
197    let direct_path = packages_root.join(&safe_import_path);
198    if let Some(path) = finalize_package_target(&package_root, &direct_path) {
199        return Some(path);
200    }
201
202    let export_name = export_name_from_relative_path(&safe_import_path)?;
203    let manifest = read_package_manifest(&package_root.join("harn.toml"))?;
204    let safe_export_path = safe_package_relative_path(manifest.exports.get(export_name)?)?;
205    finalize_package_target(&package_root, &package_root.join(safe_export_path))
206}
207
208fn resolve_from_packages_root_with_guard(
209    packages_root: &Path,
210    import_path: &str,
211    guard: &PackageExecutionGuard,
212) -> Result<Option<PathBuf>, PackageExecutionError> {
213    let Some(safe_import_path) = safe_package_relative_path(import_path) else {
214        return Ok(None);
215    };
216    let Some(package_name) = package_name_from_relative_path(&safe_import_path) else {
217        return Ok(None);
218    };
219    let package_root = packages_root.join(package_name);
220    let direct_path = packages_root.join(&safe_import_path);
221    if let Some(path) = finalize_package_target(&package_root, &direct_path) {
222        return Ok(Some(path));
223    }
224
225    let Some(export_name) = export_name_from_relative_path(&safe_import_path) else {
226        return Ok(None);
227    };
228    let manifest_path = package_root.join("harn.toml");
229    let bytes = guard.verify_entry_source(&manifest_path)?;
230    let source = std::str::from_utf8(&bytes).map_err(|error| {
231        PackageExecutionError::Invalid(format!(
232            "package manifest {} is not valid UTF-8: {error}",
233            manifest_path.display()
234        ))
235    })?;
236    let manifest = toml::from_str::<PackageManifest>(source).map_err(|error| {
237        PackageExecutionError::Invalid(format!(
238            "failed to parse package exports from {}: {error}",
239            manifest_path.display()
240        ))
241    })?;
242    let Some(export_path) = manifest.exports.get(export_name) else {
243        return Ok(None);
244    };
245    let Some(safe_export_path) = safe_package_relative_path(export_path) else {
246        return Ok(None);
247    };
248    Ok(finalize_package_target(
249        &package_root,
250        &package_root.join(safe_export_path),
251    ))
252}
253
254fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
255    let content = std::fs::read_to_string(path).ok()?;
256    toml::from_str(&content).ok()
257}
258
259fn safe_package_relative_path(raw: &str) -> Option<PathBuf> {
260    if raw.is_empty() || raw.contains('\\') {
261        return None;
262    }
263    let mut out = PathBuf::new();
264    let mut saw_component = false;
265    for component in Path::new(raw).components() {
266        match component {
267            Component::Normal(part) => {
268                saw_component = true;
269                out.push(part);
270            }
271            Component::CurDir => {}
272            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
273        }
274    }
275    saw_component.then_some(out)
276}
277
278fn package_name_from_relative_path(path: &Path) -> Option<&str> {
279    match path.components().next()? {
280        Component::Normal(name) => name.to_str(),
281        _ => None,
282    }
283}
284
285fn export_name_from_relative_path(path: &Path) -> Option<&str> {
286    let mut components = path.components();
287    components.next()?;
288    let rest = components.as_path();
289    if rest.as_os_str().is_empty() {
290        None
291    } else {
292        rest.to_str()
293    }
294}
295
296fn target_within_package_root(package_root: &Path, path: PathBuf) -> Option<PathBuf> {
297    let root = package_root.canonicalize().ok()?;
298    let canonical = path.canonicalize().ok()?;
299    (canonical == root || canonical.starts_with(&root)).then_some(path)
300}
301
302fn finalize_package_target(package_root: &Path, path: &Path) -> Option<PathBuf> {
303    if path.is_dir() {
304        let lib = path.join("lib.harn");
305        return if lib.exists() {
306            target_within_package_root(package_root, lib)
307        } else {
308            target_within_package_root(package_root, path.to_path_buf())
309        };
310    }
311    if path.exists() {
312        return target_within_package_root(package_root, path.to_path_buf());
313    }
314    if path.extension().is_none() {
315        let mut with_extension = path.to_path_buf();
316        with_extension.set_extension("harn");
317        if with_extension.exists() {
318            return target_within_package_root(package_root, with_extension);
319        }
320    }
321    None
322}