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