1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::{Component, Path, PathBuf};
3
4use harn_lexer::Span;
5use harn_parser::{BindingPattern, Node, Parser, SNode};
6use serde::Deserialize;
7
8mod stdlib;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum DefKind {
13 Function,
14 Pipeline,
15 Tool,
16 Skill,
17 Struct,
18 Enum,
19 Interface,
20 Type,
21 Variable,
22 Parameter,
23}
24
25#[derive(Debug, Clone)]
27pub struct DefSite {
28 pub name: String,
29 pub file: PathBuf,
30 pub kind: DefKind,
31 pub span: Span,
32}
33
34#[derive(Debug, Clone)]
36pub enum WildcardResolution {
37 Resolved(HashSet<String>),
39 Unknown,
41}
42
43#[derive(Debug, Default)]
45pub struct ModuleGraph {
46 modules: HashMap<PathBuf, ModuleInfo>,
47}
48
49#[derive(Debug, Default)]
50struct ModuleInfo {
51 declarations: HashMap<String, DefSite>,
54 exports: HashSet<String>,
56 selective_import_names: HashSet<String>,
58 imports: Vec<ImportRef>,
60 has_unresolved_wildcard_import: bool,
62 has_unresolved_selective_import: bool,
66 fn_names: Vec<String>,
70 has_pub_fn: bool,
72 type_declarations: Vec<SNode>,
75}
76
77#[derive(Debug, Clone)]
78struct ImportRef {
79 path: Option<PathBuf>,
80 selective_names: Option<HashSet<String>>,
81}
82
83#[derive(Debug, Default, Deserialize)]
84struct PackageManifest {
85 #[serde(default)]
86 exports: HashMap<String, String>,
87}
88
89pub fn build(files: &[PathBuf]) -> ModuleGraph {
95 let mut modules: HashMap<PathBuf, ModuleInfo> = HashMap::new();
96 let mut seen: HashSet<PathBuf> = HashSet::new();
97 let mut queue: VecDeque<PathBuf> = VecDeque::new();
98 for file in files {
99 let canonical = normalize_path(file);
100 if seen.insert(canonical.clone()) {
101 queue.push_back(canonical);
102 }
103 }
104 while let Some(path) = queue.pop_front() {
105 if modules.contains_key(&path) {
106 continue;
107 }
108 let module = load_module(&path);
109 for import in &module.imports {
126 if let Some(import_path) = &import.path {
127 let canonical = normalize_path(import_path);
128 if seen.insert(canonical.clone()) {
129 queue.push_back(canonical);
130 }
131 }
132 }
133 modules.insert(path, module);
134 }
135 ModuleGraph { modules }
136}
137
138pub fn resolve_import_path(current_file: &Path, import_path: &str) -> Option<PathBuf> {
151 if let Some(module) = import_path.strip_prefix("std/") {
152 if stdlib::get_stdlib_source(module).is_some() {
153 return Some(stdlib::stdlib_virtual_path(module));
154 }
155 return None;
156 }
157
158 let base = current_file.parent().unwrap_or(Path::new("."));
159 let mut file_path = base.join(import_path);
160 if !file_path.exists() && file_path.extension().is_none() {
161 file_path.set_extension("harn");
162 }
163 if file_path.exists() {
164 return Some(file_path);
165 }
166
167 if let Some(path) = resolve_package_import(base, import_path) {
168 return Some(path);
169 }
170
171 None
172}
173
174fn resolve_package_import(base: &Path, import_path: &str) -> Option<PathBuf> {
175 for anchor in base.ancestors() {
176 let packages_root = anchor.join(".harn/packages");
177 if !packages_root.is_dir() {
178 if anchor.join(".git").exists() {
179 break;
180 }
181 continue;
182 }
183 if let Some(path) = resolve_from_packages_root(&packages_root, import_path) {
184 return Some(path);
185 }
186 if anchor.join(".git").exists() {
187 break;
188 }
189 }
190 None
191}
192
193fn resolve_from_packages_root(packages_root: &Path, import_path: &str) -> Option<PathBuf> {
194 let safe_import_path = safe_package_relative_path(import_path)?;
195 let package_name = package_name_from_relative_path(&safe_import_path)?;
196 let package_root = packages_root.join(package_name);
197
198 let pkg_path = packages_root.join(&safe_import_path);
199 if let Some(path) = finalize_package_target(&package_root, &pkg_path) {
200 return Some(path);
201 }
202
203 let export_name = export_name_from_relative_path(&safe_import_path)?;
204 let manifest_path = packages_root.join(package_name).join("harn.toml");
205 let manifest = read_package_manifest(&manifest_path)?;
206 let rel_path = manifest.exports.get(export_name)?;
207 let safe_export_path = safe_package_relative_path(rel_path)?;
208 finalize_package_target(&package_root, &package_root.join(safe_export_path))
209}
210
211fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
212 let content = std::fs::read_to_string(path).ok()?;
213 toml::from_str::<PackageManifest>(&content).ok()
214}
215
216fn safe_package_relative_path(raw: &str) -> Option<PathBuf> {
217 if raw.is_empty() || raw.contains('\\') {
218 return None;
219 }
220 let mut out = PathBuf::new();
221 let mut saw_component = false;
222 for component in Path::new(raw).components() {
223 match component {
224 Component::Normal(part) => {
225 saw_component = true;
226 out.push(part);
227 }
228 Component::CurDir => {}
229 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
230 }
231 }
232 saw_component.then_some(out)
233}
234
235fn package_name_from_relative_path(path: &Path) -> Option<&str> {
236 match path.components().next()? {
237 Component::Normal(name) => name.to_str(),
238 _ => None,
239 }
240}
241
242fn export_name_from_relative_path(path: &Path) -> Option<&str> {
243 let mut components = path.components();
244 components.next()?;
245 let rest = components.as_path();
246 if rest.as_os_str().is_empty() {
247 None
248 } else {
249 rest.to_str()
250 }
251}
252
253fn path_is_within(root: &Path, path: &Path) -> bool {
254 let Ok(root) = root.canonicalize() else {
255 return false;
256 };
257 let Ok(path) = path.canonicalize() else {
258 return false;
259 };
260 path == root || path.starts_with(root)
261}
262
263fn target_within_package_root(package_root: &Path, path: PathBuf) -> Option<PathBuf> {
264 path_is_within(package_root, &path).then_some(path)
265}
266
267fn finalize_package_target(package_root: &Path, path: &Path) -> Option<PathBuf> {
268 if path.is_dir() {
269 let lib = path.join("lib.harn");
270 if lib.exists() {
271 return target_within_package_root(package_root, lib);
272 }
273 return target_within_package_root(package_root, path.to_path_buf());
274 }
275 if path.exists() {
276 return target_within_package_root(package_root, path.to_path_buf());
277 }
278 if path.extension().is_none() {
279 let mut with_ext = path.to_path_buf();
280 with_ext.set_extension("harn");
281 if with_ext.exists() {
282 return target_within_package_root(package_root, with_ext);
283 }
284 }
285 None
286}
287
288impl ModuleGraph {
289 pub fn all_selective_import_names(&self) -> HashSet<&str> {
291 let mut names = HashSet::new();
292 for module in self.modules.values() {
293 for name in &module.selective_import_names {
294 names.insert(name.as_str());
295 }
296 }
297 names
298 }
299
300 pub fn wildcard_exports_for(&self, file: &Path) -> WildcardResolution {
305 let file = normalize_path(file);
306 let Some(module) = self.modules.get(&file) else {
307 return WildcardResolution::Unknown;
308 };
309 if module.has_unresolved_wildcard_import {
310 return WildcardResolution::Unknown;
311 }
312
313 let mut names = HashSet::new();
314 for import in module
315 .imports
316 .iter()
317 .filter(|import| import.selective_names.is_none())
318 {
319 let Some(import_path) = &import.path else {
320 return WildcardResolution::Unknown;
321 };
322 let imported = self.modules.get(import_path).or_else(|| {
323 let normalized = normalize_path(import_path);
324 self.modules.get(&normalized)
325 });
326 let Some(imported) = imported else {
327 return WildcardResolution::Unknown;
328 };
329 names.extend(imported.exports.iter().cloned());
330 }
331 WildcardResolution::Resolved(names)
332 }
333
334 pub fn imported_names_for_file(&self, file: &Path) -> Option<HashSet<String>> {
349 let file = normalize_path(file);
350 let module = self.modules.get(&file)?;
351 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
352 return None;
353 }
354
355 let mut names = HashSet::new();
356 for import in &module.imports {
357 let import_path = import.path.as_ref()?;
358 let imported = self
359 .modules
360 .get(import_path)
361 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
362 match &import.selective_names {
363 None => {
364 names.extend(imported.exports.iter().cloned());
365 }
366 Some(selective) => {
367 for name in selective {
368 if imported.declarations.contains_key(name) {
369 names.insert(name.clone());
370 }
371 }
372 }
373 }
374 }
375 Some(names)
376 }
377
378 pub fn imported_type_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
382 let file = normalize_path(file);
383 let module = self.modules.get(&file)?;
384 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
385 return None;
386 }
387
388 let mut decls = Vec::new();
389 for import in &module.imports {
390 let import_path = import.path.as_ref()?;
391 let imported = self
392 .modules
393 .get(import_path)
394 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
395 match &import.selective_names {
396 None => {
397 for decl in &imported.type_declarations {
398 if let Some(name) = type_decl_name(decl) {
399 if imported.exports.contains(name) {
400 decls.push(decl.clone());
401 }
402 }
403 }
404 }
405 Some(selective) => {
406 for decl in &imported.type_declarations {
407 if let Some(name) = type_decl_name(decl) {
408 if selective.contains(name) {
409 decls.push(decl.clone());
410 }
411 }
412 }
413 }
414 }
415 }
416 Some(decls)
417 }
418
419 pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
421 let file = normalize_path(file);
422 let current = self.modules.get(&file)?;
423
424 if let Some(local) = current.declarations.get(name) {
425 return Some(local.clone());
426 }
427
428 for import in ¤t.imports {
429 if let Some(selective_names) = &import.selective_names {
430 if !selective_names.contains(name) {
431 continue;
432 }
433 } else {
434 continue;
435 }
436
437 if let Some(path) = &import.path {
438 if let Some(symbol) = self
439 .modules
440 .get(path)
441 .or_else(|| self.modules.get(&normalize_path(path)))
442 .and_then(|module| module.declarations.get(name))
443 {
444 return Some(symbol.clone());
445 }
446 }
447 }
448
449 for import in ¤t.imports {
450 if import.selective_names.is_some() {
451 continue;
452 }
453 if let Some(path) = &import.path {
454 if let Some(symbol) = self
455 .modules
456 .get(path)
457 .or_else(|| self.modules.get(&normalize_path(path)))
458 .and_then(|module| module.declarations.get(name))
459 {
460 return Some(symbol.clone());
461 }
462 }
463 }
464
465 None
466 }
467}
468
469fn load_module(path: &Path) -> ModuleInfo {
470 let source = if let Some(stdlib_module) = stdlib_module_from_path(path) {
473 match stdlib::get_stdlib_source(stdlib_module) {
474 Some(src) => src.to_string(),
475 None => return ModuleInfo::default(),
476 }
477 } else {
478 match std::fs::read_to_string(path) {
479 Ok(src) => src,
480 Err(_) => return ModuleInfo::default(),
481 }
482 };
483 let mut lexer = harn_lexer::Lexer::new(&source);
484 let tokens = match lexer.tokenize() {
485 Ok(tokens) => tokens,
486 Err(_) => return ModuleInfo::default(),
487 };
488 let mut parser = Parser::new(tokens);
489 let program = match parser.parse() {
490 Ok(program) => program,
491 Err(_) => return ModuleInfo::default(),
492 };
493
494 let mut module = ModuleInfo::default();
495 for node in &program {
496 collect_module_info(path, node, &mut module);
497 collect_type_declarations(node, &mut module.type_declarations);
498 }
499 if !module.has_pub_fn {
502 for name in &module.fn_names {
503 module.exports.insert(name.clone());
504 }
505 }
506 module
507}
508
509fn stdlib_module_from_path(path: &Path) -> Option<&str> {
512 let s = path.to_str()?;
513 s.strip_prefix("<std>/")
514}
515
516fn collect_module_info(file: &Path, snode: &SNode, module: &mut ModuleInfo) {
517 match &snode.node {
518 Node::FnDecl {
519 name,
520 params,
521 is_pub,
522 ..
523 } => {
524 if *is_pub {
525 module.exports.insert(name.clone());
526 module.has_pub_fn = true;
527 }
528 module.fn_names.push(name.clone());
529 module.declarations.insert(
530 name.clone(),
531 decl_site(file, snode.span, name, DefKind::Function),
532 );
533 for param_name in params.iter().map(|param| param.name.clone()) {
534 module.declarations.insert(
535 param_name.clone(),
536 decl_site(file, snode.span, ¶m_name, DefKind::Parameter),
537 );
538 }
539 }
540 Node::Pipeline { name, is_pub, .. } => {
541 if *is_pub {
542 module.exports.insert(name.clone());
543 }
544 module.declarations.insert(
545 name.clone(),
546 decl_site(file, snode.span, name, DefKind::Pipeline),
547 );
548 }
549 Node::ToolDecl { name, is_pub, .. } => {
550 if *is_pub {
551 module.exports.insert(name.clone());
552 }
553 module.declarations.insert(
554 name.clone(),
555 decl_site(file, snode.span, name, DefKind::Tool),
556 );
557 }
558 Node::SkillDecl { name, is_pub, .. } => {
559 if *is_pub {
560 module.exports.insert(name.clone());
561 }
562 module.declarations.insert(
563 name.clone(),
564 decl_site(file, snode.span, name, DefKind::Skill),
565 );
566 }
567 Node::StructDecl { name, is_pub, .. } => {
568 if *is_pub {
569 module.exports.insert(name.clone());
570 }
571 module.declarations.insert(
572 name.clone(),
573 decl_site(file, snode.span, name, DefKind::Struct),
574 );
575 }
576 Node::EnumDecl { name, is_pub, .. } => {
577 if *is_pub {
578 module.exports.insert(name.clone());
579 }
580 module.declarations.insert(
581 name.clone(),
582 decl_site(file, snode.span, name, DefKind::Enum),
583 );
584 }
585 Node::InterfaceDecl { name, .. } => {
586 module.exports.insert(name.clone());
587 module.declarations.insert(
588 name.clone(),
589 decl_site(file, snode.span, name, DefKind::Interface),
590 );
591 }
592 Node::TypeDecl { name, .. } => {
593 module.exports.insert(name.clone());
594 module.declarations.insert(
595 name.clone(),
596 decl_site(file, snode.span, name, DefKind::Type),
597 );
598 }
599 Node::LetBinding { pattern, .. } | Node::VarBinding { pattern, .. } => {
600 for name in pattern_names(pattern) {
601 module.declarations.insert(
602 name.clone(),
603 decl_site(file, snode.span, &name, DefKind::Variable),
604 );
605 }
606 }
607 Node::ImportDecl { path } => {
608 let import_path = resolve_import_path(file, path);
609 if import_path.is_none() {
610 module.has_unresolved_wildcard_import = true;
611 }
612 module.imports.push(ImportRef {
613 path: import_path,
614 selective_names: None,
615 });
616 }
617 Node::SelectiveImport { names, path } => {
618 let import_path = resolve_import_path(file, path);
619 if import_path.is_none() {
620 module.has_unresolved_selective_import = true;
621 }
622 let names: HashSet<String> = names.iter().cloned().collect();
623 module.selective_import_names.extend(names.iter().cloned());
624 module.imports.push(ImportRef {
625 path: import_path,
626 selective_names: Some(names),
627 });
628 }
629 Node::AttributedDecl { inner, .. } => {
630 collect_module_info(file, inner, module);
631 }
632 _ => {}
633 }
634}
635
636fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
637 match &snode.node {
638 Node::TypeDecl { .. }
639 | Node::StructDecl { .. }
640 | Node::EnumDecl { .. }
641 | Node::InterfaceDecl { .. } => decls.push(snode.clone()),
642 Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
643 _ => {}
644 }
645}
646
647fn type_decl_name(snode: &SNode) -> Option<&str> {
648 match &snode.node {
649 Node::TypeDecl { name, .. }
650 | Node::StructDecl { name, .. }
651 | Node::EnumDecl { name, .. }
652 | Node::InterfaceDecl { name, .. } => Some(name.as_str()),
653 _ => None,
654 }
655}
656
657fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
658 DefSite {
659 name: name.to_string(),
660 file: file.to_path_buf(),
661 kind,
662 span,
663 }
664}
665
666fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
667 match pattern {
668 BindingPattern::Identifier(name) => vec![name.clone()],
669 BindingPattern::Dict(fields) => fields
670 .iter()
671 .filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
672 .collect(),
673 BindingPattern::List(elements) => elements
674 .iter()
675 .map(|element| element.name.clone())
676 .collect(),
677 BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
678 }
679}
680
681fn normalize_path(path: &Path) -> PathBuf {
682 if stdlib_module_from_path(path).is_some() {
683 return path.to_path_buf();
684 }
685 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use std::fs;
692
693 fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
694 let path = dir.join(name);
695 fs::write(&path, contents).unwrap();
696 path
697 }
698
699 #[test]
700 fn recursive_build_loads_transitively_imported_modules() {
701 let tmp = tempfile::tempdir().unwrap();
702 let root = tmp.path();
703 write_file(root, "leaf.harn", "pub fn leaf_fn() { 1 }\n");
704 write_file(
705 root,
706 "mid.harn",
707 "import \"./leaf\"\npub fn mid_fn() { leaf_fn() }\n",
708 );
709 let entry = write_file(root, "entry.harn", "import \"./mid\"\nmid_fn()\n");
710
711 let graph = build(std::slice::from_ref(&entry));
712 let imported = graph
713 .imported_names_for_file(&entry)
714 .expect("entry imports should resolve");
715 assert!(imported.contains("mid_fn"));
717 assert!(!imported.contains("leaf_fn"));
718
719 let leaf_path = root.join("leaf.harn");
722 assert!(graph.definition_of(&leaf_path, "leaf_fn").is_some());
723 }
724
725 #[test]
726 fn imported_names_returns_none_when_import_unresolved() {
727 let tmp = tempfile::tempdir().unwrap();
728 let root = tmp.path();
729 let entry = write_file(root, "entry.harn", "import \"./does_not_exist\"\n");
730
731 let graph = build(std::slice::from_ref(&entry));
732 assert!(graph.imported_names_for_file(&entry).is_none());
733 }
734
735 #[test]
736 fn selective_imports_contribute_only_requested_names() {
737 let tmp = tempfile::tempdir().unwrap();
738 let root = tmp.path();
739 write_file(root, "util.harn", "pub fn a() { 1 }\npub fn b() { 2 }\n");
740 let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
741
742 let graph = build(std::slice::from_ref(&entry));
743 let imported = graph
744 .imported_names_for_file(&entry)
745 .expect("entry imports should resolve");
746 assert!(imported.contains("a"));
747 assert!(!imported.contains("b"));
748 }
749
750 #[test]
751 fn stdlib_imports_resolve_to_embedded_sources() {
752 let tmp = tempfile::tempdir().unwrap();
753 let root = tmp.path();
754 let entry = write_file(root, "entry.harn", "import \"std/math\"\nclamp(5, 0, 10)\n");
755
756 let graph = build(std::slice::from_ref(&entry));
757 let imported = graph
758 .imported_names_for_file(&entry)
759 .expect("std/math should resolve");
760 assert!(imported.contains("clamp"));
762 }
763
764 #[test]
765 fn runtime_stdlib_import_surface_resolves_to_embedded_sources() {
766 let tmp = tempfile::tempdir().unwrap();
767 let entry = write_file(tmp.path(), "entry.harn", "");
768
769 for (module, _) in stdlib::STDLIB_SOURCES {
770 let import_path = format!("std/{module}");
771 assert!(
772 resolve_import_path(&entry, &import_path).is_some(),
773 "{import_path} should resolve in the module graph"
774 );
775 }
776 }
777
778 #[test]
779 fn stdlib_imports_expose_type_declarations() {
780 let tmp = tempfile::tempdir().unwrap();
781 let root = tmp.path();
782 let entry = write_file(
783 root,
784 "entry.harn",
785 "import \"std/triggers\"\nlet provider = \"github\"\n",
786 );
787
788 let graph = build(std::slice::from_ref(&entry));
789 let decls = graph
790 .imported_type_declarations_for_file(&entry)
791 .expect("std/triggers type declarations should resolve");
792 let names: HashSet<String> = decls
793 .iter()
794 .filter_map(type_decl_name)
795 .map(ToString::to_string)
796 .collect();
797 assert!(names.contains("TriggerEvent"));
798 assert!(names.contains("ProviderPayload"));
799 assert!(names.contains("SignatureStatus"));
800 }
801
802 #[test]
803 fn package_export_map_resolves_declared_module() {
804 let tmp = tempfile::tempdir().unwrap();
805 let root = tmp.path();
806 let packages = root.join(".harn/packages/acme/runtime");
807 fs::create_dir_all(&packages).unwrap();
808 fs::write(
809 root.join(".harn/packages/acme/harn.toml"),
810 "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
811 )
812 .unwrap();
813 fs::write(
814 packages.join("capabilities.harn"),
815 "pub fn exported_capability() { 1 }\n",
816 )
817 .unwrap();
818 let entry = write_file(
819 root,
820 "entry.harn",
821 "import \"acme/capabilities\"\nexported_capability()\n",
822 );
823
824 let graph = build(std::slice::from_ref(&entry));
825 let imported = graph
826 .imported_names_for_file(&entry)
827 .expect("package export should resolve");
828 assert!(imported.contains("exported_capability"));
829 }
830
831 #[test]
832 fn package_direct_import_cannot_escape_packages_root() {
833 let tmp = tempfile::tempdir().unwrap();
834 let root = tmp.path();
835 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
836 fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
837 let entry = write_file(root, "entry.harn", "");
838
839 let resolved = resolve_import_path(&entry, "acme/../../secret");
840 assert!(resolved.is_none(), "package import escaped package root");
841 }
842
843 #[test]
844 fn package_export_map_cannot_escape_package_root() {
845 let tmp = tempfile::tempdir().unwrap();
846 let root = tmp.path();
847 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
848 fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
849 fs::write(
850 root.join(".harn/packages/acme/harn.toml"),
851 "[exports]\nleak = \"../../secret.harn\"\n",
852 )
853 .unwrap();
854 let entry = write_file(root, "entry.harn", "");
855
856 let resolved = resolve_import_path(&entry, "acme/leak");
857 assert!(resolved.is_none(), "package export escaped package root");
858 }
859
860 #[test]
861 fn package_export_map_allows_symlinked_path_dependencies() {
862 let tmp = tempfile::tempdir().unwrap();
863 let root = tmp.path();
864 let source = root.join("source-package");
865 fs::create_dir_all(source.join("runtime")).unwrap();
866 fs::write(
867 source.join("harn.toml"),
868 "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
869 )
870 .unwrap();
871 fs::write(
872 source.join("runtime/capabilities.harn"),
873 "pub fn exported_capability() { 1 }\n",
874 )
875 .unwrap();
876 fs::create_dir_all(root.join(".harn/packages")).unwrap();
877 #[cfg(unix)]
878 std::os::unix::fs::symlink(&source, root.join(".harn/packages/acme")).unwrap();
879 #[cfg(windows)]
880 std::os::windows::fs::symlink_dir(&source, root.join(".harn/packages/acme")).unwrap();
881 let entry = write_file(root, "entry.harn", "");
882
883 let resolved = resolve_import_path(&entry, "acme/capabilities")
884 .expect("symlinked package export should resolve");
885 assert!(resolved.ends_with("runtime/capabilities.harn"));
886 }
887
888 #[test]
889 fn package_imports_resolve_from_nested_package_module() {
890 let tmp = tempfile::tempdir().unwrap();
891 let root = tmp.path();
892 fs::create_dir_all(root.join(".git")).unwrap();
893 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
894 fs::create_dir_all(root.join(".harn/packages/shared")).unwrap();
895 fs::write(
896 root.join(".harn/packages/shared/lib.harn"),
897 "pub fn shared_helper() { 1 }\n",
898 )
899 .unwrap();
900 fs::write(
901 root.join(".harn/packages/acme/lib.harn"),
902 "import \"shared\"\npub fn use_shared() { shared_helper() }\n",
903 )
904 .unwrap();
905 let entry = write_file(root, "entry.harn", "import \"acme\"\nuse_shared()\n");
906
907 let graph = build(std::slice::from_ref(&entry));
908 let imported = graph
909 .imported_names_for_file(&entry)
910 .expect("nested package import should resolve");
911 assert!(imported.contains("use_shared"));
912 let acme_path = root.join(".harn/packages/acme/lib.harn");
913 let acme_imports = graph
914 .imported_names_for_file(&acme_path)
915 .expect("package module imports should resolve");
916 assert!(acme_imports.contains("shared_helper"));
917 }
918
919 #[test]
920 fn unknown_stdlib_import_is_unresolved() {
921 let tmp = tempfile::tempdir().unwrap();
922 let root = tmp.path();
923 let entry = write_file(root, "entry.harn", "import \"std/does_not_exist\"\n");
924
925 let graph = build(std::slice::from_ref(&entry));
926 assert!(
927 graph.imported_names_for_file(&entry).is_none(),
928 "unknown std module should fail resolution and disable strict check"
929 );
930 }
931
932 #[test]
933 fn import_cycles_do_not_loop_forever() {
934 let tmp = tempfile::tempdir().unwrap();
935 let root = tmp.path();
936 write_file(root, "a.harn", "import \"./b\"\npub fn a_fn() { 1 }\n");
937 write_file(root, "b.harn", "import \"./a\"\npub fn b_fn() { 1 }\n");
938 let entry = root.join("a.harn");
939
940 let graph = build(std::slice::from_ref(&entry));
942 let imported = graph
943 .imported_names_for_file(&entry)
944 .expect("cyclic imports still resolve to known exports");
945 assert!(imported.contains("b_fn"));
946 }
947
948 #[test]
949 fn cross_directory_cycle_does_not_explode_module_count() {
950 let tmp = tempfile::tempdir().unwrap();
958 let root = tmp.path();
959 let context = root.join("context");
960 let runtime = root.join("runtime");
961 fs::create_dir_all(&context).unwrap();
962 fs::create_dir_all(&runtime).unwrap();
963 write_file(
964 &context,
965 "a.harn",
966 "import \"../runtime/b\"\npub fn a_fn() { 1 }\n",
967 );
968 write_file(
969 &runtime,
970 "b.harn",
971 "import \"../context/a\"\npub fn b_fn() { 1 }\n",
972 );
973 let entry = context.join("a.harn");
974
975 let graph = build(std::slice::from_ref(&entry));
976 assert_eq!(
979 graph.modules.len(),
980 2,
981 "cross-directory cycle loaded {} modules, expected 2",
982 graph.modules.len()
983 );
984 let imported = graph
985 .imported_names_for_file(&entry)
986 .expect("cyclic imports still resolve to known exports");
987 assert!(imported.contains("b_fn"));
988 }
989}