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)
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 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
228pub(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 let Some(root) = PackageSnapshot::nearest_project_root(file) else {
242 continue;
243 };
244 if !walked_roots.insert(root.clone()) {
245 continue;
246 }
247 let Ok(Some(snapshot)) = PackageSnapshot::acquire(&root) else {
249 continue;
250 };
251 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 #[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}