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#[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
27pub(super) enum LocalResolution {
34 Resolved(PathBuf),
36 Rejected,
39 NotPackage,
41}
42
43impl ModuleGraph {
44 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 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
89pub(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
129pub 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
149pub 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 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 resolve_with_nearest_snapshot(current_file, import_path)
211 })
212 }
213 }
214}
215
216fn 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 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
267pub(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 let Some(root) = PackageSnapshot::nearest_project_root(file) else {
281 continue;
282 };
283 if !walked_roots.insert(root.clone()) {
284 continue;
285 }
286 let Ok(Some(snapshot)) = PackageSnapshot::acquire(&root) else {
288 continue;
289 };
290 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 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 #[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}