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).or_else(|| {
190                // The supplied snapshots cover the files the caller set out to
191                // process, and `resolve_package_import` only consults a
192                // snapshot whose project root CONTAINS the importing file. A
193                // path dependency is installed as a symlink to its source
194                // rather than a copy, so a module reached through one
195                // canonicalizes to a location outside every supplied snapshot,
196                // and its own package imports resolve to nothing. That was
197                // invisible until something asked a dependency's module for
198                // its imports.
199                //
200                // Fall back to the importing file's own nearest project root,
201                // which is the context that actually owns that module and the
202                // one the single-file path in `resolve_import_path` has always
203                // used. The two resolvers agreeing is the point: the same
204                // import resolved one way when checked directly and another
205                // way when reached through a consumer.
206                //
207                // Only reached once the supplied snapshots have already failed,
208                // so the ancestor walk this costs is paid on unresolved
209                // imports rather than on the hot path.
210                resolve_with_nearest_snapshot(current_file, import_path)
211            })
212        }
213    }
214}
215
216/// Resolve a package import against the snapshot nearest the importing file,
217/// retaining it only when it answered.
218fn resolve_with_nearest_snapshot(current_file: &Path, import_path: &str) -> Option<PathBuf> {
219    let snapshots = PackageSnapshot::acquire_nearest(current_file)
220        .ok()
221        .flatten()
222        .into_iter()
223        .collect::<Vec<_>>();
224    let resolved = resolve_package_import(current_file, import_path, &snapshots);
225    if resolved.is_some() {
226        for snapshot in snapshots {
227            snapshot.retain_for_process();
228        }
229    }
230    resolved
231}
232
233pub fn resolve_import_path_with_snapshot(
234    current_file: &Path,
235    import_path: &str,
236    package_snapshot: &PackageSnapshot,
237) -> Option<PathBuf> {
238    match resolve_local_import(current_file, import_path) {
239        LocalResolution::Resolved(path) => Some(path),
240        LocalResolution::Rejected => None,
241        // An explicit snapshot is caller-owned resolution authority. Unlike
242        // lazy discovery it also covers generation-owned path-package
243        // symlinks whose canonical source is outside the project root.
244        LocalResolution::NotPackage => {
245            resolve_from_packages_root(package_snapshot.packages_root(), import_path)
246        }
247    }
248}
249
250pub fn resolve_import_path_with_guard(
251    current_file: &Path,
252    import_path: &str,
253    guard: &PackageExecutionGuard,
254) -> Result<Option<PathBuf>, PackageExecutionError> {
255    guard.validate_import_path(current_file, import_path)?;
256    match resolve_local_import(current_file, import_path) {
257        LocalResolution::Resolved(path) => Ok(Some(path)),
258        LocalResolution::Rejected => Ok(None),
259        LocalResolution::NotPackage => resolve_from_packages_root_with_guard(
260            guard.snapshot().packages_root(),
261            import_path,
262            guard,
263        ),
264    }
265}
266
267/// Acquire one snapshot per DISTINCT project root among `files`.
268///
269/// Dedupe on the root before acquiring, not after. Acquiring is the expensive
270/// half — canonicalize, two shared flocks, two TOML parses, and a re-read plus
271/// SHA256 of the lockfile — so acquiring per file and discarding the duplicates
272/// made a whole-tree build pay it once per FILE. Every real invocation resolves
273/// many files under a single root, so all but one of those was thrown away.
274pub(crate) fn acquire_package_snapshots(files: &[PathBuf]) -> Vec<PackageSnapshot> {
275    let mut walked_roots = HashSet::new();
276    let mut canonical_roots = HashSet::new();
277    let mut snapshots = Vec::new();
278    for file in files {
279        // Cheap: a handful of stats up the ancestors.
280        let Some(root) = PackageSnapshot::nearest_project_root(file) else {
281            continue;
282        };
283        if !walked_roots.insert(root.clone()) {
284            continue;
285        }
286        // Expensive: reached at most once per distinct walked root.
287        let Ok(Some(snapshot)) = PackageSnapshot::acquire(&root) else {
288            continue;
289        };
290        // `acquire` canonicalizes, so two walked roots that differ only by
291        // symlink can still land on one real root. Dedupe on the canonical
292        // root as the original did, or such a tree would get two snapshots
293        // where it used to get one.
294        if canonical_roots.insert(snapshot.project_root().to_path_buf()) {
295            snapshots.push(snapshot);
296        }
297    }
298    snapshots
299}
300
301fn resolve_package_import(
302    current_file: &Path,
303    import_path: &str,
304    package_snapshots: &[PackageSnapshot],
305) -> Option<PathBuf> {
306    let current_file = canonicalize_with_existing_parent(current_file);
307    package_snapshots
308        .iter()
309        .filter(|snapshot| current_file.starts_with(snapshot.project_root()))
310        .max_by_key(|snapshot| snapshot.project_root().components().count())
311        .and_then(|snapshot| resolve_from_packages_root(snapshot.packages_root(), import_path))
312}
313
314fn canonicalize_with_existing_parent(path: &Path) -> PathBuf {
315    path.canonicalize().unwrap_or_else(|_| {
316        path.parent()
317            .and_then(|parent| parent.canonicalize().ok())
318            .and_then(|parent| path.file_name().map(|name| parent.join(name)))
319            .unwrap_or_else(|| path.to_path_buf())
320    })
321}
322
323fn resolve_from_packages_root(packages_root: &Path, import_path: &str) -> Option<PathBuf> {
324    let safe_import_path = safe_package_relative_path(import_path)?;
325    let package_name = package_name_from_relative_path(&safe_import_path)?;
326    let package_root = packages_root.join(package_name);
327
328    let direct_path = packages_root.join(&safe_import_path);
329    if let Some(path) = finalize_package_target(&package_root, &direct_path) {
330        return Some(path);
331    }
332
333    let export_name = export_name_from_relative_path(&safe_import_path)?;
334    let manifest = read_package_manifest(&package_root.join("harn.toml"))?;
335    let safe_export_path = safe_package_relative_path(manifest.exports.get(export_name)?)?;
336    finalize_package_target(&package_root, &package_root.join(safe_export_path))
337}
338
339fn resolve_from_packages_root_with_guard(
340    packages_root: &Path,
341    import_path: &str,
342    guard: &PackageExecutionGuard,
343) -> Result<Option<PathBuf>, PackageExecutionError> {
344    let Some(safe_import_path) = safe_package_relative_path(import_path) else {
345        return Ok(None);
346    };
347    let Some(package_name) = package_name_from_relative_path(&safe_import_path) else {
348        return Ok(None);
349    };
350    let package_root = packages_root.join(package_name);
351    let direct_path = packages_root.join(&safe_import_path);
352    if let Some(path) = finalize_package_target(&package_root, &direct_path) {
353        return Ok(Some(path));
354    }
355
356    let Some(export_name) = export_name_from_relative_path(&safe_import_path) else {
357        return Ok(None);
358    };
359    let manifest_path = package_root.join("harn.toml");
360    let bytes = guard.verify_entry_source(&manifest_path)?;
361    let source = std::str::from_utf8(&bytes).map_err(|error| {
362        PackageExecutionError::Invalid(format!(
363            "package manifest {} is not valid UTF-8: {error}",
364            manifest_path.display()
365        ))
366    })?;
367    let manifest = toml::from_str::<PackageManifest>(source).map_err(|error| {
368        PackageExecutionError::Invalid(format!(
369            "failed to parse package exports from {}: {error}",
370            manifest_path.display()
371        ))
372    })?;
373    let Some(export_path) = manifest.exports.get(export_name) else {
374        return Ok(None);
375    };
376    let Some(safe_export_path) = safe_package_relative_path(export_path) else {
377        return Ok(None);
378    };
379    Ok(finalize_package_target(
380        &package_root,
381        &package_root.join(safe_export_path),
382    ))
383}
384
385fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
386    let content = std::fs::read_to_string(path).ok()?;
387    toml::from_str(&content).ok()
388}
389
390fn safe_package_relative_path(raw: &str) -> Option<PathBuf> {
391    if raw.is_empty() || raw.contains('\\') {
392        return None;
393    }
394    let mut out = PathBuf::new();
395    let mut saw_component = false;
396    for component in Path::new(raw).components() {
397        match component {
398            Component::Normal(part) => {
399                saw_component = true;
400                out.push(part);
401            }
402            Component::CurDir => {}
403            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
404        }
405    }
406    saw_component.then_some(out)
407}
408
409pub(super) fn package_alias_from_import(raw: &str) -> Option<String> {
410    let path = safe_package_relative_path(raw)?;
411    package_name_from_relative_path(&path).map(ToString::to_string)
412}
413
414fn package_name_from_relative_path(path: &Path) -> Option<&str> {
415    match path.components().next()? {
416        Component::Normal(name) => name.to_str(),
417        _ => None,
418    }
419}
420
421fn export_name_from_relative_path(path: &Path) -> Option<&str> {
422    let mut components = path.components();
423    components.next()?;
424    let rest = components.as_path();
425    if rest.as_os_str().is_empty() {
426        None
427    } else {
428        rest.to_str()
429    }
430}
431
432fn target_within_package_root(package_root: &Path, path: PathBuf) -> Option<PathBuf> {
433    let root = package_root.canonicalize().ok()?;
434    let canonical = path.canonicalize().ok()?;
435    (canonical == root || canonical.starts_with(&root)).then_some(path)
436}
437
438fn finalize_package_target(package_root: &Path, path: &Path) -> Option<PathBuf> {
439    if path.is_dir() {
440        let lib = path.join("lib.harn");
441        // A namespace directory is a module only when it has an entry file.
442        // Otherwise the caller must continue to the manifest's export map.
443        // Returning the directory here masks e.g. exports.lib="lib/main.harn".
444        return lib
445            .is_file()
446            .then(|| target_within_package_root(package_root, lib))
447            .flatten();
448    }
449    if path.is_file() {
450        return target_within_package_root(package_root, path.to_path_buf());
451    }
452    if path.extension().is_none() {
453        let mut with_extension = path.to_path_buf();
454        with_extension.set_extension("harn");
455        if with_extension.is_file() {
456            return target_within_package_root(package_root, with_extension);
457        }
458    }
459    None
460}
461
462#[cfg(test)]
463mod package_target_tests {
464    use super::resolve_from_packages_root;
465
466    #[test]
467    fn export_alias_resolves_past_a_directory_without_a_module_entry() {
468        let root = tempfile::tempdir().unwrap();
469        let package = root.path().join("example");
470        std::fs::create_dir_all(package.join("lib")).unwrap();
471        let entry = package.join("lib/main.harn");
472        std::fs::write(&entry, "pub fn answer() -> int { return 42 }\n").unwrap();
473        std::fs::write(
474            package.join("harn.toml"),
475            "[exports]\nlib = \"lib/main.harn\"\n",
476        )
477        .unwrap();
478
479        let resolved = resolve_from_packages_root(root.path(), "example/lib")
480            .expect("declared export must resolve");
481        assert_eq!(resolved, entry);
482        assert!(super::super::read_module_source(&resolved)
483            .unwrap()
484            .contains("answer"));
485    }
486
487    #[test]
488    fn directory_entry_stays_a_module_but_a_bare_directory_does_not() {
489        let root = tempfile::tempdir().unwrap();
490        let directory = root.path().join("example/namespace");
491        std::fs::create_dir_all(&directory).unwrap();
492        assert_eq!(
493            resolve_from_packages_root(root.path(), "example/namespace"),
494            None
495        );
496
497        let entry = directory.join("lib.harn");
498        std::fs::write(&entry, "pub fn answer() -> int { return 42 }\n").unwrap();
499        assert_eq!(
500            resolve_from_packages_root(root.path(), "example/namespace"),
501            Some(entry)
502        );
503    }
504}
505
506#[cfg(test)]
507mod unresolved_package_alias_tests {
508    use std::fs;
509
510    use super::unresolved_package_alias;
511
512    /// The whole point of the helper is that it fires ONLY for a specifier no
513    /// local rule could ever have resolved. A helper that answered `Some` for
514    /// a mistyped sibling would replace one misleading error with another.
515    #[test]
516    fn a_bare_specifier_names_its_package() {
517        let dir = tempfile::tempdir().expect("temp dir");
518        let importer = dir.path().join("flight-tools.harn");
519        fs::write(&importer, "").expect("write importer");
520
521        assert_eq!(
522            unresolved_package_alias(&importer, "some-connector/default"),
523            Some("some-connector".to_string()),
524            "a bare specifier that no package provides names the package"
525        );
526        assert_eq!(
527            unresolved_package_alias(&importer, "some-connector"),
528            Some("some-connector".to_string()),
529            "a bare specifier with no export path still names the package"
530        );
531    }
532
533    #[test]
534    fn a_relative_import_is_never_reported_as_a_package() {
535        let dir = tempfile::tempdir().expect("temp dir");
536        let importer = dir.path().join("flight-tools.harn");
537        fs::write(&importer, "").expect("write importer");
538        fs::write(dir.path().join("sibling.harn"), "").expect("write sibling");
539
540        assert_eq!(
541            unresolved_package_alias(&importer, "./sibling"),
542            None,
543            "a sibling that resolves is not a missing package"
544        );
545        assert_eq!(
546            unresolved_package_alias(&importer, "./typo"),
547            None,
548            "a mistyped relative import is a missing FILE and must keep the path error"
549        );
550        assert_eq!(
551            unresolved_package_alias(&importer, "../typo"),
552            None,
553            "a parent-relative miss is a missing file too"
554        );
555    }
556
557    #[test]
558    fn the_standard_library_is_never_reported_as_a_package() {
559        let dir = tempfile::tempdir().expect("temp dir");
560        let importer = dir.path().join("flight-tools.harn");
561        fs::write(&importer, "").expect("write importer");
562
563        assert_eq!(
564            unresolved_package_alias(&importer, "std/testing"),
565            None,
566            "a real stdlib module resolves locally"
567        );
568        assert_eq!(
569            unresolved_package_alias(&importer, "std/not-a-real-module"),
570            None,
571            "an unknown stdlib module must not fall through to a package name, \
572             or a package could shadow the standard library in the error text too"
573        );
574    }
575}