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
8pub mod asset_paths;
9mod stdlib;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum DefKind {
14 Function,
15 Pipeline,
16 Tool,
17 Skill,
18 Struct,
19 Enum,
20 Interface,
21 Type,
22 Variable,
23 Parameter,
24}
25
26#[derive(Debug, Clone)]
28pub struct DefSite {
29 pub name: String,
30 pub file: PathBuf,
31 pub kind: DefKind,
32 pub span: Span,
33}
34
35#[derive(Debug, Clone)]
37pub enum WildcardResolution {
38 Resolved(HashSet<String>),
40 Unknown,
42}
43
44#[derive(Debug, Default)]
46pub struct ModuleGraph {
47 modules: HashMap<PathBuf, ModuleInfo>,
48}
49
50#[derive(Debug, Default)]
51struct ModuleInfo {
52 declarations: HashMap<String, DefSite>,
55 exports: HashSet<String>,
60 own_exports: HashSet<String>,
63 selective_re_exports: HashMap<String, Vec<PathBuf>>,
70 wildcard_re_export_paths: Vec<PathBuf>,
74 selective_import_names: HashSet<String>,
76 imports: Vec<ImportRef>,
78 has_unresolved_wildcard_import: bool,
80 has_unresolved_selective_import: bool,
84 fn_names: Vec<String>,
88 has_pub_fn: bool,
90 type_declarations: Vec<SNode>,
93}
94
95#[derive(Debug, Clone)]
96struct ImportRef {
97 path: Option<PathBuf>,
98 selective_names: Option<HashSet<String>>,
99}
100
101#[derive(Debug, Default, Deserialize)]
102struct PackageManifest {
103 #[serde(default)]
104 exports: HashMap<String, String>,
105}
106
107pub fn build(files: &[PathBuf]) -> ModuleGraph {
113 let mut modules: HashMap<PathBuf, ModuleInfo> = HashMap::new();
114 let mut seen: HashSet<PathBuf> = HashSet::new();
115 let mut queue: VecDeque<PathBuf> = VecDeque::new();
116 for file in files {
117 let canonical = normalize_path(file);
118 if seen.insert(canonical.clone()) {
119 queue.push_back(canonical);
120 }
121 }
122 while let Some(path) = queue.pop_front() {
123 if modules.contains_key(&path) {
124 continue;
125 }
126 let module = load_module(&path);
127 for import in &module.imports {
144 if let Some(import_path) = &import.path {
145 let canonical = normalize_path(import_path);
146 if seen.insert(canonical.clone()) {
147 queue.push_back(canonical);
148 }
149 }
150 }
151 modules.insert(path, module);
152 }
153 resolve_re_exports(&mut modules);
154 ModuleGraph { modules }
155}
156
157fn resolve_re_exports(modules: &mut HashMap<PathBuf, ModuleInfo>) {
162 let keys: Vec<PathBuf> = modules.keys().cloned().collect();
163 loop {
164 let mut changed = false;
165 for path in &keys {
166 let wildcard_paths = modules
169 .get(path)
170 .map(|m| m.wildcard_re_export_paths.clone())
171 .unwrap_or_default();
172 if wildcard_paths.is_empty() {
173 continue;
174 }
175 let mut additions: Vec<String> = Vec::new();
176 for src in &wildcard_paths {
177 let src_canonical = normalize_path(src);
178 if let Some(src_module) = modules.get(src).or_else(|| modules.get(&src_canonical)) {
179 additions.extend(src_module.exports.iter().cloned());
180 }
181 }
182 if let Some(module) = modules.get_mut(path) {
183 for name in additions {
184 if module.exports.insert(name) {
185 changed = true;
186 }
187 }
188 }
189 }
190 if !changed {
191 break;
192 }
193 }
194}
195
196pub fn resolve_import_path(current_file: &Path, import_path: &str) -> Option<PathBuf> {
209 if let Some(module) = import_path.strip_prefix("std/") {
210 if stdlib::get_stdlib_source(module).is_some() {
211 return Some(stdlib::stdlib_virtual_path(module));
212 }
213 return None;
214 }
215
216 let base = current_file.parent().unwrap_or(Path::new("."));
217 let mut file_path = base.join(import_path);
218 if !file_path.exists() && file_path.extension().is_none() {
219 file_path.set_extension("harn");
220 }
221 if file_path.exists() {
222 return Some(file_path);
223 }
224
225 if let Some(path) = resolve_package_import(base, import_path) {
226 return Some(path);
227 }
228
229 None
230}
231
232fn resolve_package_import(base: &Path, import_path: &str) -> Option<PathBuf> {
233 for anchor in base.ancestors() {
234 let packages_root = anchor.join(".harn/packages");
235 if !packages_root.is_dir() {
236 if anchor.join(".git").exists() {
237 break;
238 }
239 continue;
240 }
241 if let Some(path) = resolve_from_packages_root(&packages_root, import_path) {
242 return Some(path);
243 }
244 if anchor.join(".git").exists() {
245 break;
246 }
247 }
248 None
249}
250
251fn resolve_from_packages_root(packages_root: &Path, import_path: &str) -> Option<PathBuf> {
252 let safe_import_path = safe_package_relative_path(import_path)?;
253 let package_name = package_name_from_relative_path(&safe_import_path)?;
254 let package_root = packages_root.join(package_name);
255
256 let pkg_path = packages_root.join(&safe_import_path);
257 if let Some(path) = finalize_package_target(&package_root, &pkg_path) {
258 return Some(path);
259 }
260
261 let export_name = export_name_from_relative_path(&safe_import_path)?;
262 let manifest_path = packages_root.join(package_name).join("harn.toml");
263 let manifest = read_package_manifest(&manifest_path)?;
264 let rel_path = manifest.exports.get(export_name)?;
265 let safe_export_path = safe_package_relative_path(rel_path)?;
266 finalize_package_target(&package_root, &package_root.join(safe_export_path))
267}
268
269fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
270 let content = std::fs::read_to_string(path).ok()?;
271 toml::from_str::<PackageManifest>(&content).ok()
272}
273
274fn safe_package_relative_path(raw: &str) -> Option<PathBuf> {
275 if raw.is_empty() || raw.contains('\\') {
276 return None;
277 }
278 let mut out = PathBuf::new();
279 let mut saw_component = false;
280 for component in Path::new(raw).components() {
281 match component {
282 Component::Normal(part) => {
283 saw_component = true;
284 out.push(part);
285 }
286 Component::CurDir => {}
287 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
288 }
289 }
290 saw_component.then_some(out)
291}
292
293fn package_name_from_relative_path(path: &Path) -> Option<&str> {
294 match path.components().next()? {
295 Component::Normal(name) => name.to_str(),
296 _ => None,
297 }
298}
299
300fn export_name_from_relative_path(path: &Path) -> Option<&str> {
301 let mut components = path.components();
302 components.next()?;
303 let rest = components.as_path();
304 if rest.as_os_str().is_empty() {
305 None
306 } else {
307 rest.to_str()
308 }
309}
310
311fn path_is_within(root: &Path, path: &Path) -> bool {
312 let Ok(root) = root.canonicalize() else {
313 return false;
314 };
315 let Ok(path) = path.canonicalize() else {
316 return false;
317 };
318 path == root || path.starts_with(root)
319}
320
321fn target_within_package_root(package_root: &Path, path: PathBuf) -> Option<PathBuf> {
322 path_is_within(package_root, &path).then_some(path)
323}
324
325fn finalize_package_target(package_root: &Path, path: &Path) -> Option<PathBuf> {
326 if path.is_dir() {
327 let lib = path.join("lib.harn");
328 if lib.exists() {
329 return target_within_package_root(package_root, lib);
330 }
331 return target_within_package_root(package_root, path.to_path_buf());
332 }
333 if path.exists() {
334 return target_within_package_root(package_root, path.to_path_buf());
335 }
336 if path.extension().is_none() {
337 let mut with_ext = path.to_path_buf();
338 with_ext.set_extension("harn");
339 if with_ext.exists() {
340 return target_within_package_root(package_root, with_ext);
341 }
342 }
343 None
344}
345
346impl ModuleGraph {
347 pub fn all_selective_import_names(&self) -> HashSet<&str> {
349 let mut names = HashSet::new();
350 for module in self.modules.values() {
351 for name in &module.selective_import_names {
352 names.insert(name.as_str());
353 }
354 }
355 names
356 }
357
358 pub fn wildcard_exports_for(&self, file: &Path) -> WildcardResolution {
363 let file = normalize_path(file);
364 let Some(module) = self.modules.get(&file) else {
365 return WildcardResolution::Unknown;
366 };
367 if module.has_unresolved_wildcard_import {
368 return WildcardResolution::Unknown;
369 }
370
371 let mut names = HashSet::new();
372 for import in module
373 .imports
374 .iter()
375 .filter(|import| import.selective_names.is_none())
376 {
377 let Some(import_path) = &import.path else {
378 return WildcardResolution::Unknown;
379 };
380 let imported = self.modules.get(import_path).or_else(|| {
381 let normalized = normalize_path(import_path);
382 self.modules.get(&normalized)
383 });
384 let Some(imported) = imported else {
385 return WildcardResolution::Unknown;
386 };
387 names.extend(imported.exports.iter().cloned());
388 }
389 WildcardResolution::Resolved(names)
390 }
391
392 pub fn imported_names_for_file(&self, file: &Path) -> Option<HashSet<String>> {
407 let file = normalize_path(file);
408 let module = self.modules.get(&file)?;
409 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
410 return None;
411 }
412
413 let mut names = HashSet::new();
414 for import in &module.imports {
415 let import_path = import.path.as_ref()?;
416 let imported = self
417 .modules
418 .get(import_path)
419 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
420 match &import.selective_names {
421 None => {
422 names.extend(imported.exports.iter().cloned());
423 }
424 Some(selective) => {
425 for name in selective {
426 if imported.declarations.contains_key(name)
427 || imported.exports.contains(name)
428 {
429 names.insert(name.clone());
430 }
431 }
432 }
433 }
434 }
435 Some(names)
436 }
437
438 pub fn imported_type_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
442 let file = normalize_path(file);
443 let module = self.modules.get(&file)?;
444 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
445 return None;
446 }
447
448 let mut decls = Vec::new();
449 for import in &module.imports {
450 let import_path = import.path.as_ref()?;
451 let imported = self
452 .modules
453 .get(import_path)
454 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
455 let names_to_collect: Vec<String> = match &import.selective_names {
456 None => imported.exports.iter().cloned().collect(),
457 Some(selective) => selective.iter().cloned().collect(),
458 };
459 for name in &names_to_collect {
460 let mut visited = HashSet::new();
461 if let Some(decl) = self.find_exported_type_decl(import_path, name, &mut visited) {
462 decls.push(decl);
463 }
464 }
465 }
466 Some(decls)
467 }
468
469 fn find_exported_type_decl(
472 &self,
473 path: &Path,
474 name: &str,
475 visited: &mut HashSet<PathBuf>,
476 ) -> Option<SNode> {
477 let canonical = normalize_path(path);
478 if !visited.insert(canonical.clone()) {
479 return None;
480 }
481 let module = self
482 .modules
483 .get(&canonical)
484 .or_else(|| self.modules.get(path))?;
485 for decl in &module.type_declarations {
486 if type_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
487 return Some(decl.clone());
488 }
489 }
490 if let Some(sources) = module.selective_re_exports.get(name) {
491 for source in sources {
492 if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
493 return Some(decl);
494 }
495 }
496 }
497 for source in &module.wildcard_re_export_paths {
498 if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
499 return Some(decl);
500 }
501 }
502 None
503 }
504
505 pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
511 let mut visited = HashSet::new();
512 self.definition_of_inner(file, name, &mut visited)
513 }
514
515 fn definition_of_inner(
516 &self,
517 file: &Path,
518 name: &str,
519 visited: &mut HashSet<PathBuf>,
520 ) -> Option<DefSite> {
521 let file = normalize_path(file);
522 if !visited.insert(file.clone()) {
523 return None;
524 }
525 let current = self.modules.get(&file)?;
526
527 if let Some(local) = current.declarations.get(name) {
528 return Some(local.clone());
529 }
530
531 if let Some(sources) = current.selective_re_exports.get(name) {
536 for source in sources {
537 if let Some(def) = self.definition_of_inner(source, name, visited) {
538 return Some(def);
539 }
540 }
541 }
542
543 for source in ¤t.wildcard_re_export_paths {
545 if let Some(def) = self.definition_of_inner(source, name, visited) {
546 return Some(def);
547 }
548 }
549
550 for import in ¤t.imports {
552 let Some(selective_names) = &import.selective_names else {
553 continue;
554 };
555 if !selective_names.contains(name) {
556 continue;
557 }
558 if let Some(path) = &import.path {
559 if let Some(def) = self.definition_of_inner(path, name, visited) {
560 return Some(def);
561 }
562 }
563 }
564
565 for import in ¤t.imports {
567 if import.selective_names.is_some() {
568 continue;
569 }
570 if let Some(path) = &import.path {
571 if let Some(def) = self.definition_of_inner(path, name, visited) {
572 return Some(def);
573 }
574 }
575 }
576
577 None
578 }
579
580 pub fn re_export_conflicts(&self, file: &Path) -> Vec<ReExportConflict> {
584 let file = normalize_path(file);
585 let Some(module) = self.modules.get(&file) else {
586 return Vec::new();
587 };
588
589 let mut sources: HashMap<String, Vec<PathBuf>> = HashMap::new();
593
594 for (name, srcs) in &module.selective_re_exports {
595 sources
596 .entry(name.clone())
597 .or_default()
598 .extend(srcs.iter().cloned());
599 }
600 for src in &module.wildcard_re_export_paths {
601 let canonical = normalize_path(src);
602 let Some(src_module) = self
603 .modules
604 .get(&canonical)
605 .or_else(|| self.modules.get(src))
606 else {
607 continue;
608 };
609 for name in &src_module.exports {
610 sources
611 .entry(name.clone())
612 .or_default()
613 .push(canonical.clone());
614 }
615 }
616
617 for name in &module.own_exports {
621 if let Some(entry) = sources.get_mut(name) {
622 entry.push(file.clone());
623 }
624 }
625
626 let mut conflicts = Vec::new();
627 for (name, mut srcs) in sources {
628 srcs.sort();
629 srcs.dedup();
630 if srcs.len() > 1 {
631 conflicts.push(ReExportConflict {
632 name,
633 sources: srcs,
634 });
635 }
636 }
637 conflicts.sort_by(|a, b| a.name.cmp(&b.name));
638 conflicts
639 }
640}
641
642#[derive(Debug, Clone, PartialEq, Eq)]
645pub struct ReExportConflict {
646 pub name: String,
647 pub sources: Vec<PathBuf>,
648}
649
650fn load_module(path: &Path) -> ModuleInfo {
651 let source = if let Some(stdlib_module) = stdlib_module_from_path(path) {
654 match stdlib::get_stdlib_source(stdlib_module) {
655 Some(src) => src.to_string(),
656 None => return ModuleInfo::default(),
657 }
658 } else {
659 match std::fs::read_to_string(path) {
660 Ok(src) => src,
661 Err(_) => return ModuleInfo::default(),
662 }
663 };
664 let mut lexer = harn_lexer::Lexer::new(&source);
665 let tokens = match lexer.tokenize() {
666 Ok(tokens) => tokens,
667 Err(_) => return ModuleInfo::default(),
668 };
669 let mut parser = Parser::new(tokens);
670 let program = match parser.parse() {
671 Ok(program) => program,
672 Err(_) => return ModuleInfo::default(),
673 };
674
675 let mut module = ModuleInfo::default();
676 for node in &program {
677 collect_module_info(path, node, &mut module);
678 collect_type_declarations(node, &mut module.type_declarations);
679 }
680 if !module.has_pub_fn {
683 for name in &module.fn_names {
684 module.own_exports.insert(name.clone());
685 }
686 }
687 module.exports.extend(module.own_exports.iter().cloned());
691 module
692 .exports
693 .extend(module.selective_re_exports.keys().cloned());
694 module
695}
696
697fn stdlib_module_from_path(path: &Path) -> Option<&str> {
700 let s = path.to_str()?;
701 s.strip_prefix("<std>/")
702}
703
704fn collect_module_info(file: &Path, snode: &SNode, module: &mut ModuleInfo) {
705 match &snode.node {
706 Node::FnDecl {
707 name,
708 params,
709 is_pub,
710 ..
711 } => {
712 if *is_pub {
713 module.own_exports.insert(name.clone());
714 module.has_pub_fn = true;
715 }
716 module.fn_names.push(name.clone());
717 module.declarations.insert(
718 name.clone(),
719 decl_site(file, snode.span, name, DefKind::Function),
720 );
721 for param_name in params.iter().map(|param| param.name.clone()) {
722 module.declarations.insert(
723 param_name.clone(),
724 decl_site(file, snode.span, ¶m_name, DefKind::Parameter),
725 );
726 }
727 }
728 Node::Pipeline { name, is_pub, .. } => {
729 if *is_pub {
730 module.own_exports.insert(name.clone());
731 }
732 module.declarations.insert(
733 name.clone(),
734 decl_site(file, snode.span, name, DefKind::Pipeline),
735 );
736 }
737 Node::ToolDecl { name, is_pub, .. } => {
738 if *is_pub {
739 module.own_exports.insert(name.clone());
740 }
741 module.declarations.insert(
742 name.clone(),
743 decl_site(file, snode.span, name, DefKind::Tool),
744 );
745 }
746 Node::SkillDecl { name, is_pub, .. } => {
747 if *is_pub {
748 module.own_exports.insert(name.clone());
749 }
750 module.declarations.insert(
751 name.clone(),
752 decl_site(file, snode.span, name, DefKind::Skill),
753 );
754 }
755 Node::StructDecl { name, is_pub, .. } => {
756 if *is_pub {
757 module.own_exports.insert(name.clone());
758 }
759 module.declarations.insert(
760 name.clone(),
761 decl_site(file, snode.span, name, DefKind::Struct),
762 );
763 }
764 Node::EnumDecl { name, is_pub, .. } => {
765 if *is_pub {
766 module.own_exports.insert(name.clone());
767 }
768 module.declarations.insert(
769 name.clone(),
770 decl_site(file, snode.span, name, DefKind::Enum),
771 );
772 }
773 Node::InterfaceDecl { name, .. } => {
774 module.own_exports.insert(name.clone());
775 module.declarations.insert(
776 name.clone(),
777 decl_site(file, snode.span, name, DefKind::Interface),
778 );
779 }
780 Node::TypeDecl { name, .. } => {
781 module.own_exports.insert(name.clone());
782 module.declarations.insert(
783 name.clone(),
784 decl_site(file, snode.span, name, DefKind::Type),
785 );
786 }
787 Node::LetBinding { pattern, .. } | Node::VarBinding { pattern, .. } => {
788 for name in pattern_names(pattern) {
789 module.declarations.insert(
790 name.clone(),
791 decl_site(file, snode.span, &name, DefKind::Variable),
792 );
793 }
794 }
795 Node::ImportDecl { path, is_pub } => {
796 let import_path = resolve_import_path(file, path);
797 if import_path.is_none() {
798 module.has_unresolved_wildcard_import = true;
799 }
800 if *is_pub {
801 if let Some(resolved) = &import_path {
802 module
803 .wildcard_re_export_paths
804 .push(normalize_path(resolved));
805 }
806 }
807 module.imports.push(ImportRef {
808 path: import_path,
809 selective_names: None,
810 });
811 }
812 Node::SelectiveImport {
813 names,
814 path,
815 is_pub,
816 } => {
817 let import_path = resolve_import_path(file, path);
818 if import_path.is_none() {
819 module.has_unresolved_selective_import = true;
820 }
821 if *is_pub {
822 if let Some(resolved) = &import_path {
823 let canonical = normalize_path(resolved);
824 for name in names {
825 module
826 .selective_re_exports
827 .entry(name.clone())
828 .or_default()
829 .push(canonical.clone());
830 }
831 }
832 }
833 let names: HashSet<String> = names.iter().cloned().collect();
834 module.selective_import_names.extend(names.iter().cloned());
835 module.imports.push(ImportRef {
836 path: import_path,
837 selective_names: Some(names),
838 });
839 }
840 Node::AttributedDecl { inner, .. } => {
841 collect_module_info(file, inner, module);
842 }
843 _ => {}
844 }
845}
846
847fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
848 match &snode.node {
849 Node::TypeDecl { .. }
850 | Node::StructDecl { .. }
851 | Node::EnumDecl { .. }
852 | Node::InterfaceDecl { .. } => decls.push(snode.clone()),
853 Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
854 _ => {}
855 }
856}
857
858fn type_decl_name(snode: &SNode) -> Option<&str> {
859 match &snode.node {
860 Node::TypeDecl { name, .. }
861 | Node::StructDecl { name, .. }
862 | Node::EnumDecl { name, .. }
863 | Node::InterfaceDecl { name, .. } => Some(name.as_str()),
864 _ => None,
865 }
866}
867
868fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
869 DefSite {
870 name: name.to_string(),
871 file: file.to_path_buf(),
872 kind,
873 span,
874 }
875}
876
877fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
878 match pattern {
879 BindingPattern::Identifier(name) => vec![name.clone()],
880 BindingPattern::Dict(fields) => fields
881 .iter()
882 .filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
883 .collect(),
884 BindingPattern::List(elements) => elements
885 .iter()
886 .map(|element| element.name.clone())
887 .collect(),
888 BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
889 }
890}
891
892fn normalize_path(path: &Path) -> PathBuf {
893 if stdlib_module_from_path(path).is_some() {
894 return path.to_path_buf();
895 }
896 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902 use std::fs;
903
904 fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
905 let path = dir.join(name);
906 fs::write(&path, contents).unwrap();
907 path
908 }
909
910 #[test]
911 fn recursive_build_loads_transitively_imported_modules() {
912 let tmp = tempfile::tempdir().unwrap();
913 let root = tmp.path();
914 write_file(root, "leaf.harn", "pub fn leaf_fn() { 1 }\n");
915 write_file(
916 root,
917 "mid.harn",
918 "import \"./leaf\"\npub fn mid_fn() { leaf_fn() }\n",
919 );
920 let entry = write_file(root, "entry.harn", "import \"./mid\"\nmid_fn()\n");
921
922 let graph = build(std::slice::from_ref(&entry));
923 let imported = graph
924 .imported_names_for_file(&entry)
925 .expect("entry imports should resolve");
926 assert!(imported.contains("mid_fn"));
928 assert!(!imported.contains("leaf_fn"));
929
930 let leaf_path = root.join("leaf.harn");
933 assert!(graph.definition_of(&leaf_path, "leaf_fn").is_some());
934 }
935
936 #[test]
937 fn imported_names_returns_none_when_import_unresolved() {
938 let tmp = tempfile::tempdir().unwrap();
939 let root = tmp.path();
940 let entry = write_file(root, "entry.harn", "import \"./does_not_exist\"\n");
941
942 let graph = build(std::slice::from_ref(&entry));
943 assert!(graph.imported_names_for_file(&entry).is_none());
944 }
945
946 #[test]
947 fn selective_imports_contribute_only_requested_names() {
948 let tmp = tempfile::tempdir().unwrap();
949 let root = tmp.path();
950 write_file(root, "util.harn", "pub fn a() { 1 }\npub fn b() { 2 }\n");
951 let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
952
953 let graph = build(std::slice::from_ref(&entry));
954 let imported = graph
955 .imported_names_for_file(&entry)
956 .expect("entry imports should resolve");
957 assert!(imported.contains("a"));
958 assert!(!imported.contains("b"));
959 }
960
961 #[test]
962 fn stdlib_imports_resolve_to_embedded_sources() {
963 let tmp = tempfile::tempdir().unwrap();
964 let root = tmp.path();
965 let entry = write_file(root, "entry.harn", "import \"std/math\"\nclamp(5, 0, 10)\n");
966
967 let graph = build(std::slice::from_ref(&entry));
968 let imported = graph
969 .imported_names_for_file(&entry)
970 .expect("std/math should resolve");
971 assert!(imported.contains("clamp"));
973 }
974
975 #[test]
976 fn runtime_stdlib_import_surface_resolves_to_embedded_sources() {
977 let tmp = tempfile::tempdir().unwrap();
978 let entry = write_file(tmp.path(), "entry.harn", "");
979
980 for (module, _) in stdlib::STDLIB_SOURCES {
981 let import_path = format!("std/{module}");
982 assert!(
983 resolve_import_path(&entry, &import_path).is_some(),
984 "{import_path} should resolve in the module graph"
985 );
986 }
987 }
988
989 #[test]
990 fn stdlib_imports_expose_type_declarations() {
991 let tmp = tempfile::tempdir().unwrap();
992 let root = tmp.path();
993 let entry = write_file(
994 root,
995 "entry.harn",
996 "import \"std/triggers\"\nlet provider = \"github\"\n",
997 );
998
999 let graph = build(std::slice::from_ref(&entry));
1000 let decls = graph
1001 .imported_type_declarations_for_file(&entry)
1002 .expect("std/triggers type declarations should resolve");
1003 let names: HashSet<String> = decls
1004 .iter()
1005 .filter_map(type_decl_name)
1006 .map(ToString::to_string)
1007 .collect();
1008 assert!(names.contains("TriggerEvent"));
1009 assert!(names.contains("ProviderPayload"));
1010 assert!(names.contains("SignatureStatus"));
1011 }
1012
1013 #[test]
1014 fn package_export_map_resolves_declared_module() {
1015 let tmp = tempfile::tempdir().unwrap();
1016 let root = tmp.path();
1017 let packages = root.join(".harn/packages/acme/runtime");
1018 fs::create_dir_all(&packages).unwrap();
1019 fs::write(
1020 root.join(".harn/packages/acme/harn.toml"),
1021 "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1022 )
1023 .unwrap();
1024 fs::write(
1025 packages.join("capabilities.harn"),
1026 "pub fn exported_capability() { 1 }\n",
1027 )
1028 .unwrap();
1029 let entry = write_file(
1030 root,
1031 "entry.harn",
1032 "import \"acme/capabilities\"\nexported_capability()\n",
1033 );
1034
1035 let graph = build(std::slice::from_ref(&entry));
1036 let imported = graph
1037 .imported_names_for_file(&entry)
1038 .expect("package export should resolve");
1039 assert!(imported.contains("exported_capability"));
1040 }
1041
1042 #[test]
1043 fn package_direct_import_cannot_escape_packages_root() {
1044 let tmp = tempfile::tempdir().unwrap();
1045 let root = tmp.path();
1046 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1047 fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1048 let entry = write_file(root, "entry.harn", "");
1049
1050 let resolved = resolve_import_path(&entry, "acme/../../secret");
1051 assert!(resolved.is_none(), "package import escaped package root");
1052 }
1053
1054 #[test]
1055 fn package_export_map_cannot_escape_package_root() {
1056 let tmp = tempfile::tempdir().unwrap();
1057 let root = tmp.path();
1058 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1059 fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1060 fs::write(
1061 root.join(".harn/packages/acme/harn.toml"),
1062 "[exports]\nleak = \"../../secret.harn\"\n",
1063 )
1064 .unwrap();
1065 let entry = write_file(root, "entry.harn", "");
1066
1067 let resolved = resolve_import_path(&entry, "acme/leak");
1068 assert!(resolved.is_none(), "package export escaped package root");
1069 }
1070
1071 #[test]
1072 fn package_export_map_allows_symlinked_path_dependencies() {
1073 let tmp = tempfile::tempdir().unwrap();
1074 let root = tmp.path();
1075 let source = root.join("source-package");
1076 fs::create_dir_all(source.join("runtime")).unwrap();
1077 fs::write(
1078 source.join("harn.toml"),
1079 "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1080 )
1081 .unwrap();
1082 fs::write(
1083 source.join("runtime/capabilities.harn"),
1084 "pub fn exported_capability() { 1 }\n",
1085 )
1086 .unwrap();
1087 fs::create_dir_all(root.join(".harn/packages")).unwrap();
1088 #[cfg(unix)]
1089 std::os::unix::fs::symlink(&source, root.join(".harn/packages/acme")).unwrap();
1090 #[cfg(windows)]
1091 std::os::windows::fs::symlink_dir(&source, root.join(".harn/packages/acme")).unwrap();
1092 let entry = write_file(root, "entry.harn", "");
1093
1094 let resolved = resolve_import_path(&entry, "acme/capabilities")
1095 .expect("symlinked package export should resolve");
1096 assert!(resolved.ends_with("runtime/capabilities.harn"));
1097 }
1098
1099 #[test]
1100 fn package_imports_resolve_from_nested_package_module() {
1101 let tmp = tempfile::tempdir().unwrap();
1102 let root = tmp.path();
1103 fs::create_dir_all(root.join(".git")).unwrap();
1104 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1105 fs::create_dir_all(root.join(".harn/packages/shared")).unwrap();
1106 fs::write(
1107 root.join(".harn/packages/shared/lib.harn"),
1108 "pub fn shared_helper() { 1 }\n",
1109 )
1110 .unwrap();
1111 fs::write(
1112 root.join(".harn/packages/acme/lib.harn"),
1113 "import \"shared\"\npub fn use_shared() { shared_helper() }\n",
1114 )
1115 .unwrap();
1116 let entry = write_file(root, "entry.harn", "import \"acme\"\nuse_shared()\n");
1117
1118 let graph = build(std::slice::from_ref(&entry));
1119 let imported = graph
1120 .imported_names_for_file(&entry)
1121 .expect("nested package import should resolve");
1122 assert!(imported.contains("use_shared"));
1123 let acme_path = root.join(".harn/packages/acme/lib.harn");
1124 let acme_imports = graph
1125 .imported_names_for_file(&acme_path)
1126 .expect("package module imports should resolve");
1127 assert!(acme_imports.contains("shared_helper"));
1128 }
1129
1130 #[test]
1131 fn unknown_stdlib_import_is_unresolved() {
1132 let tmp = tempfile::tempdir().unwrap();
1133 let root = tmp.path();
1134 let entry = write_file(root, "entry.harn", "import \"std/does_not_exist\"\n");
1135
1136 let graph = build(std::slice::from_ref(&entry));
1137 assert!(
1138 graph.imported_names_for_file(&entry).is_none(),
1139 "unknown std module should fail resolution and disable strict check"
1140 );
1141 }
1142
1143 #[test]
1144 fn import_cycles_do_not_loop_forever() {
1145 let tmp = tempfile::tempdir().unwrap();
1146 let root = tmp.path();
1147 write_file(root, "a.harn", "import \"./b\"\npub fn a_fn() { 1 }\n");
1148 write_file(root, "b.harn", "import \"./a\"\npub fn b_fn() { 1 }\n");
1149 let entry = root.join("a.harn");
1150
1151 let graph = build(std::slice::from_ref(&entry));
1153 let imported = graph
1154 .imported_names_for_file(&entry)
1155 .expect("cyclic imports still resolve to known exports");
1156 assert!(imported.contains("b_fn"));
1157 }
1158
1159 #[test]
1160 fn pub_import_selective_re_exports_named_symbols() {
1161 let tmp = tempfile::tempdir().unwrap();
1162 let root = tmp.path();
1163 write_file(
1164 root,
1165 "src.harn",
1166 "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1167 );
1168 write_file(root, "facade.harn", "pub import { alpha } from \"./src\"\n");
1169 let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1170
1171 let graph = build(std::slice::from_ref(&entry));
1172 let imported = graph
1173 .imported_names_for_file(&entry)
1174 .expect("entry should resolve");
1175 assert!(imported.contains("alpha"), "selective re-export missing");
1176 assert!(
1177 !imported.contains("beta"),
1178 "non-listed name leaked through facade"
1179 );
1180
1181 let facade_path = root.join("facade.harn");
1182 let def = graph
1183 .definition_of(&facade_path, "alpha")
1184 .expect("definition_of should chase re-export");
1185 assert!(def.file.ends_with("src.harn"));
1186 }
1187
1188 #[test]
1189 fn pub_import_wildcard_re_exports_full_surface() {
1190 let tmp = tempfile::tempdir().unwrap();
1191 let root = tmp.path();
1192 write_file(
1193 root,
1194 "src.harn",
1195 "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1196 );
1197 write_file(root, "facade.harn", "pub import \"./src\"\n");
1198 let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1199
1200 let graph = build(std::slice::from_ref(&entry));
1201 let imported = graph
1202 .imported_names_for_file(&entry)
1203 .expect("entry should resolve");
1204 assert!(imported.contains("alpha"));
1205 assert!(imported.contains("beta"));
1206 }
1207
1208 #[test]
1209 fn pub_import_chain_resolves_definition_to_origin() {
1210 let tmp = tempfile::tempdir().unwrap();
1211 let root = tmp.path();
1212 write_file(root, "inner.harn", "pub fn deep() { 1 }\n");
1213 write_file(
1214 root,
1215 "middle.harn",
1216 "pub import { deep } from \"./inner\"\n",
1217 );
1218 write_file(
1219 root,
1220 "outer.harn",
1221 "pub import { deep } from \"./middle\"\n",
1222 );
1223 let entry = write_file(
1224 root,
1225 "entry.harn",
1226 "import { deep } from \"./outer\"\ndeep()\n",
1227 );
1228
1229 let graph = build(std::slice::from_ref(&entry));
1230 let def = graph
1231 .definition_of(&entry, "deep")
1232 .expect("definition_of should follow chain");
1233 assert!(def.file.ends_with("inner.harn"));
1234
1235 let imported = graph
1236 .imported_names_for_file(&entry)
1237 .expect("entry should resolve");
1238 assert!(imported.contains("deep"));
1239 }
1240
1241 #[test]
1242 fn duplicate_pub_import_reports_re_export_conflict() {
1243 let tmp = tempfile::tempdir().unwrap();
1244 let root = tmp.path();
1245 write_file(root, "a.harn", "pub fn shared() { 1 }\n");
1246 write_file(root, "b.harn", "pub fn shared() { 2 }\n");
1247 let facade = write_file(
1248 root,
1249 "facade.harn",
1250 "pub import { shared } from \"./a\"\npub import { shared } from \"./b\"\n",
1251 );
1252
1253 let graph = build(std::slice::from_ref(&facade));
1254 let conflicts = graph.re_export_conflicts(&facade);
1255 assert_eq!(
1256 conflicts.len(),
1257 1,
1258 "expected exactly one re-export conflict, got {:?}",
1259 conflicts
1260 );
1261 assert_eq!(conflicts[0].name, "shared");
1262 assert_eq!(conflicts[0].sources.len(), 2);
1263 }
1264
1265 #[test]
1266 fn cross_directory_cycle_does_not_explode_module_count() {
1267 let tmp = tempfile::tempdir().unwrap();
1275 let root = tmp.path();
1276 let context = root.join("context");
1277 let runtime = root.join("runtime");
1278 fs::create_dir_all(&context).unwrap();
1279 fs::create_dir_all(&runtime).unwrap();
1280 write_file(
1281 &context,
1282 "a.harn",
1283 "import \"../runtime/b\"\npub fn a_fn() { 1 }\n",
1284 );
1285 write_file(
1286 &runtime,
1287 "b.harn",
1288 "import \"../context/a\"\npub fn b_fn() { 1 }\n",
1289 );
1290 let entry = context.join("a.harn");
1291
1292 let graph = build(std::slice::from_ref(&entry));
1293 assert_eq!(
1296 graph.modules.len(),
1297 2,
1298 "cross-directory cycle loaded {} modules, expected 2",
1299 graph.modules.len()
1300 );
1301 let imported = graph
1302 .imported_names_for_file(&entry)
1303 .expect("cyclic imports still resolve to known exports");
1304 assert!(imported.contains("b_fn"));
1305 }
1306}