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    if import_path.starts_with("./") || import_path.starts_with("../") {
105        if let Some(module) = super::stdlib::relative_stdlib_module(current_file, import_path) {
106            return LocalResolution::Resolved(super::stdlib::stdlib_virtual_path(&module));
107        }
108        if super::stdlib::is_stdlib_virtual_path(current_file) {
109            return LocalResolution::Rejected;
110        }
111    }
112
113    let base = current_file.parent().unwrap_or(Path::new("."));
114    let mut file_path = base.join(import_path);
115    if !file_path.exists() && file_path.extension().is_none() {
116        file_path.set_extension("harn");
117    }
118    if file_path.exists() {
119        return LocalResolution::Resolved(file_path);
120    }
121
122    if import_path.starts_with("./") || import_path.starts_with("../") {
123        return LocalResolution::Rejected;
124    }
125
126    LocalResolution::NotPackage
127}
128
129/// The package alias a failed import names, when only an installed package
130/// could ever have resolved it.
131///
132/// A bare specifier that no package provides is joined onto the importing
133/// file's directory by every fallback in this crate, so the path a reader is
134/// finally shown is a guess assembled from the relative traversal of the whole
135/// import chain. It names a file that does not exist and never could, and it
136/// points at the pipeline tree rather than at the uninstalled dependency. This
137/// hands the error site the one fact that explains it.
138///
139/// `resolve_local_import` remains the owner of the classification, so a
140/// sibling file or a standard-library module can never be reported as a
141/// missing package.
142pub fn unresolved_package_alias(current_file: &Path, import_path: &str) -> Option<String> {
143    match resolve_local_import(current_file, import_path) {
144        LocalResolution::NotPackage => package_alias_from_import(import_path),
145        LocalResolution::Resolved(_) | LocalResolution::Rejected => None,
146    }
147}
148
149/// Resolve an import string relative to the importing file.
150///
151/// Returns the path as constructed so callers can compare it with their own
152/// `PathBuf::join` result. The module graph canonicalizes its internal keys.
153pub fn resolve_import_path(current_file: &Path, import_path: &str) -> Option<PathBuf> {
154    match resolve_local_import(current_file, import_path) {
155        LocalResolution::Resolved(path) => Some(path),
156        LocalResolution::Rejected => None,
157        // Only a package import needs a generation lease, so only a package
158        // import pays for one. Acquiring it before the stdlib and relative
159        // checks made every `std/...` and every sibling import — nearly all of
160        // them — walk its ancestors stat-ing for a package pointer, then open,
161        // flock and parse it, and then discard the snapshot unused. That is
162        // pure syscall cost on the hottest path in the module graph.
163        LocalResolution::NotPackage => {
164            let snapshots = PackageSnapshot::acquire_nearest(current_file)
165                .ok()
166                .flatten()
167                .into_iter()
168                .collect::<Vec<_>>();
169            let resolved = resolve_package_import(current_file, import_path, &snapshots);
170            if resolved.is_some() {
171                for snapshot in snapshots {
172                    snapshot.retain_for_process();
173                }
174            }
175            resolved
176        }
177    }
178}
179
180pub(crate) fn resolve_import_path_with_snapshots(
181    current_file: &Path,
182    import_path: &str,
183    package_snapshots: &[PackageSnapshot],
184) -> Option<PathBuf> {
185    match resolve_local_import(current_file, import_path) {
186        LocalResolution::Resolved(path) => Some(path),
187        LocalResolution::Rejected => None,
188        LocalResolution::NotPackage => {
189            resolve_package_import(current_file, import_path, package_snapshots)
190        }
191    }
192}
193
194pub fn resolve_import_path_with_snapshot(
195    current_file: &Path,
196    import_path: &str,
197    package_snapshot: &PackageSnapshot,
198) -> Option<PathBuf> {
199    match resolve_local_import(current_file, import_path) {
200        LocalResolution::Resolved(path) => Some(path),
201        LocalResolution::Rejected => None,
202        // An explicit snapshot is caller-owned resolution authority. Unlike
203        // lazy discovery it also covers generation-owned path-package
204        // symlinks whose canonical source is outside the project root.
205        LocalResolution::NotPackage => {
206            resolve_from_packages_root(package_snapshot.packages_root(), import_path)
207        }
208    }
209}
210
211pub fn resolve_import_path_with_guard(
212    current_file: &Path,
213    import_path: &str,
214    guard: &PackageExecutionGuard,
215) -> Result<Option<PathBuf>, PackageExecutionError> {
216    guard.validate_import_path(current_file, import_path)?;
217    match resolve_local_import(current_file, import_path) {
218        LocalResolution::Resolved(path) => Ok(Some(path)),
219        LocalResolution::Rejected => Ok(None),
220        LocalResolution::NotPackage => resolve_from_packages_root_with_guard(
221            guard.snapshot().packages_root(),
222            import_path,
223            guard,
224        ),
225    }
226}
227
228/// Acquire one snapshot per DISTINCT project root among `files`.
229///
230/// Dedupe on the root before acquiring, not after. Acquiring is the expensive
231/// half — canonicalize, two shared flocks, two TOML parses, and a re-read plus
232/// SHA256 of the lockfile — so acquiring per file and discarding the duplicates
233/// made a whole-tree build pay it once per FILE. Every real invocation resolves
234/// many files under a single root, so all but one of those was thrown away.
235pub(crate) fn acquire_package_snapshots(files: &[PathBuf]) -> Vec<PackageSnapshot> {
236    let mut walked_roots = HashSet::new();
237    let mut canonical_roots = HashSet::new();
238    let mut snapshots = Vec::new();
239    for file in files {
240        // Cheap: a handful of stats up the ancestors.
241        let Some(root) = PackageSnapshot::nearest_project_root(file) else {
242            continue;
243        };
244        if !walked_roots.insert(root.clone()) {
245            continue;
246        }
247        // Expensive: reached at most once per distinct walked root.
248        let Ok(Some(snapshot)) = PackageSnapshot::acquire(&root) else {
249            continue;
250        };
251        // `acquire` canonicalizes, so two walked roots that differ only by
252        // symlink can still land on one real root. Dedupe on the canonical
253        // root as the original did, or such a tree would get two snapshots
254        // where it used to get one.
255        if canonical_roots.insert(snapshot.project_root().to_path_buf()) {
256            snapshots.push(snapshot);
257        }
258    }
259    snapshots
260}
261
262fn resolve_package_import(
263    current_file: &Path,
264    import_path: &str,
265    package_snapshots: &[PackageSnapshot],
266) -> Option<PathBuf> {
267    let current_file = canonicalize_with_existing_parent(current_file);
268    package_snapshots
269        .iter()
270        .filter(|snapshot| current_file.starts_with(snapshot.project_root()))
271        .max_by_key(|snapshot| snapshot.project_root().components().count())
272        .and_then(|snapshot| resolve_from_packages_root(snapshot.packages_root(), import_path))
273}
274
275fn canonicalize_with_existing_parent(path: &Path) -> PathBuf {
276    path.canonicalize().unwrap_or_else(|_| {
277        path.parent()
278            .and_then(|parent| parent.canonicalize().ok())
279            .and_then(|parent| path.file_name().map(|name| parent.join(name)))
280            .unwrap_or_else(|| path.to_path_buf())
281    })
282}
283
284fn resolve_from_packages_root(packages_root: &Path, import_path: &str) -> Option<PathBuf> {
285    let safe_import_path = safe_package_relative_path(import_path)?;
286    let package_name = package_name_from_relative_path(&safe_import_path)?;
287    let package_root = packages_root.join(package_name);
288
289    let direct_path = packages_root.join(&safe_import_path);
290    if let Some(path) = finalize_package_target(&package_root, &direct_path) {
291        return Some(path);
292    }
293
294    let export_name = export_name_from_relative_path(&safe_import_path)?;
295    let manifest = read_package_manifest(&package_root.join("harn.toml"))?;
296    let safe_export_path = safe_package_relative_path(manifest.exports.get(export_name)?)?;
297    finalize_package_target(&package_root, &package_root.join(safe_export_path))
298}
299
300fn resolve_from_packages_root_with_guard(
301    packages_root: &Path,
302    import_path: &str,
303    guard: &PackageExecutionGuard,
304) -> Result<Option<PathBuf>, PackageExecutionError> {
305    let Some(safe_import_path) = safe_package_relative_path(import_path) else {
306        return Ok(None);
307    };
308    let Some(package_name) = package_name_from_relative_path(&safe_import_path) else {
309        return Ok(None);
310    };
311    let package_root = packages_root.join(package_name);
312    let direct_path = packages_root.join(&safe_import_path);
313    if let Some(path) = finalize_package_target(&package_root, &direct_path) {
314        return Ok(Some(path));
315    }
316
317    let Some(export_name) = export_name_from_relative_path(&safe_import_path) else {
318        return Ok(None);
319    };
320    let manifest_path = package_root.join("harn.toml");
321    let bytes = guard.verify_entry_source(&manifest_path)?;
322    let source = std::str::from_utf8(&bytes).map_err(|error| {
323        PackageExecutionError::Invalid(format!(
324            "package manifest {} is not valid UTF-8: {error}",
325            manifest_path.display()
326        ))
327    })?;
328    let manifest = toml::from_str::<PackageManifest>(source).map_err(|error| {
329        PackageExecutionError::Invalid(format!(
330            "failed to parse package exports from {}: {error}",
331            manifest_path.display()
332        ))
333    })?;
334    let Some(export_path) = manifest.exports.get(export_name) else {
335        return Ok(None);
336    };
337    let Some(safe_export_path) = safe_package_relative_path(export_path) else {
338        return Ok(None);
339    };
340    Ok(finalize_package_target(
341        &package_root,
342        &package_root.join(safe_export_path),
343    ))
344}
345
346fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
347    let content = std::fs::read_to_string(path).ok()?;
348    toml::from_str(&content).ok()
349}
350
351fn safe_package_relative_path(raw: &str) -> Option<PathBuf> {
352    if raw.is_empty() || raw.contains('\\') {
353        return None;
354    }
355    let mut out = PathBuf::new();
356    let mut saw_component = false;
357    for component in Path::new(raw).components() {
358        match component {
359            Component::Normal(part) => {
360                saw_component = true;
361                out.push(part);
362            }
363            Component::CurDir => {}
364            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
365        }
366    }
367    saw_component.then_some(out)
368}
369
370pub(super) fn package_alias_from_import(raw: &str) -> Option<String> {
371    let path = safe_package_relative_path(raw)?;
372    package_name_from_relative_path(&path).map(ToString::to_string)
373}
374
375fn package_name_from_relative_path(path: &Path) -> Option<&str> {
376    match path.components().next()? {
377        Component::Normal(name) => name.to_str(),
378        _ => None,
379    }
380}
381
382fn export_name_from_relative_path(path: &Path) -> Option<&str> {
383    let mut components = path.components();
384    components.next()?;
385    let rest = components.as_path();
386    if rest.as_os_str().is_empty() {
387        None
388    } else {
389        rest.to_str()
390    }
391}
392
393fn target_within_package_root(package_root: &Path, path: PathBuf) -> Option<PathBuf> {
394    let root = package_root.canonicalize().ok()?;
395    let canonical = path.canonicalize().ok()?;
396    (canonical == root || canonical.starts_with(&root)).then_some(path)
397}
398
399fn finalize_package_target(package_root: &Path, path: &Path) -> Option<PathBuf> {
400    if path.is_dir() {
401        let lib = path.join("lib.harn");
402        return if lib.exists() {
403            target_within_package_root(package_root, lib)
404        } else {
405            target_within_package_root(package_root, path.to_path_buf())
406        };
407    }
408    if path.exists() {
409        return target_within_package_root(package_root, path.to_path_buf());
410    }
411    if path.extension().is_none() {
412        let mut with_extension = path.to_path_buf();
413        with_extension.set_extension("harn");
414        if with_extension.exists() {
415            return target_within_package_root(package_root, with_extension);
416        }
417    }
418    None
419}
420
421#[cfg(test)]
422mod unresolved_package_alias_tests {
423    use std::fs;
424
425    use super::unresolved_package_alias;
426
427    /// The whole point of the helper is that it fires ONLY for a specifier no
428    /// local rule could ever have resolved. A helper that answered `Some` for
429    /// a mistyped sibling would replace one misleading error with another.
430    #[test]
431    fn a_bare_specifier_names_its_package() {
432        let dir = tempfile::tempdir().expect("temp dir");
433        let importer = dir.path().join("flight-tools.harn");
434        fs::write(&importer, "").expect("write importer");
435
436        assert_eq!(
437            unresolved_package_alias(&importer, "some-connector/default"),
438            Some("some-connector".to_string()),
439            "a bare specifier that no package provides names the package"
440        );
441        assert_eq!(
442            unresolved_package_alias(&importer, "some-connector"),
443            Some("some-connector".to_string()),
444            "a bare specifier with no export path still names the package"
445        );
446    }
447
448    #[test]
449    fn a_relative_import_is_never_reported_as_a_package() {
450        let dir = tempfile::tempdir().expect("temp dir");
451        let importer = dir.path().join("flight-tools.harn");
452        fs::write(&importer, "").expect("write importer");
453        fs::write(dir.path().join("sibling.harn"), "").expect("write sibling");
454
455        assert_eq!(
456            unresolved_package_alias(&importer, "./sibling"),
457            None,
458            "a sibling that resolves is not a missing package"
459        );
460        assert_eq!(
461            unresolved_package_alias(&importer, "./typo"),
462            None,
463            "a mistyped relative import is a missing FILE and must keep the path error"
464        );
465        assert_eq!(
466            unresolved_package_alias(&importer, "../typo"),
467            None,
468            "a parent-relative miss is a missing file too"
469        );
470    }
471
472    #[test]
473    fn the_standard_library_is_never_reported_as_a_package() {
474        let dir = tempfile::tempdir().expect("temp dir");
475        let importer = dir.path().join("flight-tools.harn");
476        fs::write(&importer, "").expect("write importer");
477
478        assert_eq!(
479            unresolved_package_alias(&importer, "std/testing"),
480            None,
481            "a real stdlib module resolves locally"
482        );
483        assert_eq!(
484            unresolved_package_alias(&importer, "std/not-a-real-module"),
485            None,
486            "an unknown stdlib module must not fall through to a package name, \
487             or a package could shadow the standard library in the error text too"
488        );
489    }
490}