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