1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::{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 pkg_path = packages_root.join(import_path);
195 if let Some(path) = finalize_package_target(&pkg_path) {
196 return Some(path);
197 }
198
199 let (package_name, export_name) = import_path.split_once('/')?;
200 let manifest_path = packages_root.join(package_name).join("harn.toml");
201 let manifest = read_package_manifest(&manifest_path)?;
202 let rel_path = manifest.exports.get(export_name)?;
203 finalize_package_target(&packages_root.join(package_name).join(rel_path))
204}
205
206fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
207 let content = std::fs::read_to_string(path).ok()?;
208 toml::from_str::<PackageManifest>(&content).ok()
209}
210
211fn finalize_package_target(path: &Path) -> Option<PathBuf> {
212 if path.is_dir() {
213 let lib = path.join("lib.harn");
214 if lib.exists() {
215 return Some(lib);
216 }
217 return Some(path.to_path_buf());
218 }
219 if path.exists() {
220 return Some(path.to_path_buf());
221 }
222 if path.extension().is_none() {
223 let mut with_ext = path.to_path_buf();
224 with_ext.set_extension("harn");
225 if with_ext.exists() {
226 return Some(with_ext);
227 }
228 }
229 None
230}
231
232impl ModuleGraph {
233 pub fn all_selective_import_names(&self) -> HashSet<&str> {
235 let mut names = HashSet::new();
236 for module in self.modules.values() {
237 for name in &module.selective_import_names {
238 names.insert(name.as_str());
239 }
240 }
241 names
242 }
243
244 pub fn wildcard_exports_for(&self, file: &Path) -> WildcardResolution {
249 let file = normalize_path(file);
250 let Some(module) = self.modules.get(&file) else {
251 return WildcardResolution::Unknown;
252 };
253 if module.has_unresolved_wildcard_import {
254 return WildcardResolution::Unknown;
255 }
256
257 let mut names = HashSet::new();
258 for import in module
259 .imports
260 .iter()
261 .filter(|import| import.selective_names.is_none())
262 {
263 let Some(import_path) = &import.path else {
264 return WildcardResolution::Unknown;
265 };
266 let imported = self.modules.get(import_path).or_else(|| {
267 let normalized = normalize_path(import_path);
268 self.modules.get(&normalized)
269 });
270 let Some(imported) = imported else {
271 return WildcardResolution::Unknown;
272 };
273 names.extend(imported.exports.iter().cloned());
274 }
275 WildcardResolution::Resolved(names)
276 }
277
278 pub fn imported_names_for_file(&self, file: &Path) -> Option<HashSet<String>> {
293 let file = normalize_path(file);
294 let module = self.modules.get(&file)?;
295 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
296 return None;
297 }
298
299 let mut names = HashSet::new();
300 for import in &module.imports {
301 let import_path = import.path.as_ref()?;
302 let imported = self
303 .modules
304 .get(import_path)
305 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
306 match &import.selective_names {
307 None => {
308 names.extend(imported.exports.iter().cloned());
309 }
310 Some(selective) => {
311 for name in selective {
312 if imported.declarations.contains_key(name) {
313 names.insert(name.clone());
314 }
315 }
316 }
317 }
318 }
319 Some(names)
320 }
321
322 pub fn imported_type_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
326 let file = normalize_path(file);
327 let module = self.modules.get(&file)?;
328 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
329 return None;
330 }
331
332 let mut decls = Vec::new();
333 for import in &module.imports {
334 let import_path = import.path.as_ref()?;
335 let imported = self
336 .modules
337 .get(import_path)
338 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
339 match &import.selective_names {
340 None => {
341 for decl in &imported.type_declarations {
342 if let Some(name) = type_decl_name(decl) {
343 if imported.exports.contains(name) {
344 decls.push(decl.clone());
345 }
346 }
347 }
348 }
349 Some(selective) => {
350 for decl in &imported.type_declarations {
351 if let Some(name) = type_decl_name(decl) {
352 if selective.contains(name) {
353 decls.push(decl.clone());
354 }
355 }
356 }
357 }
358 }
359 }
360 Some(decls)
361 }
362
363 pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
365 let file = normalize_path(file);
366 let current = self.modules.get(&file)?;
367
368 if let Some(local) = current.declarations.get(name) {
369 return Some(local.clone());
370 }
371
372 for import in ¤t.imports {
373 if let Some(selective_names) = &import.selective_names {
374 if !selective_names.contains(name) {
375 continue;
376 }
377 } else {
378 continue;
379 }
380
381 if let Some(path) = &import.path {
382 if let Some(symbol) = self
383 .modules
384 .get(path)
385 .or_else(|| self.modules.get(&normalize_path(path)))
386 .and_then(|module| module.declarations.get(name))
387 {
388 return Some(symbol.clone());
389 }
390 }
391 }
392
393 for import in ¤t.imports {
394 if import.selective_names.is_some() {
395 continue;
396 }
397 if let Some(path) = &import.path {
398 if let Some(symbol) = self
399 .modules
400 .get(path)
401 .or_else(|| self.modules.get(&normalize_path(path)))
402 .and_then(|module| module.declarations.get(name))
403 {
404 return Some(symbol.clone());
405 }
406 }
407 }
408
409 None
410 }
411}
412
413fn load_module(path: &Path) -> ModuleInfo {
414 let source = if let Some(stdlib_module) = stdlib_module_from_path(path) {
417 match stdlib::get_stdlib_source(stdlib_module) {
418 Some(src) => src.to_string(),
419 None => return ModuleInfo::default(),
420 }
421 } else {
422 match std::fs::read_to_string(path) {
423 Ok(src) => src,
424 Err(_) => return ModuleInfo::default(),
425 }
426 };
427 let mut lexer = harn_lexer::Lexer::new(&source);
428 let tokens = match lexer.tokenize() {
429 Ok(tokens) => tokens,
430 Err(_) => return ModuleInfo::default(),
431 };
432 let mut parser = Parser::new(tokens);
433 let program = match parser.parse() {
434 Ok(program) => program,
435 Err(_) => return ModuleInfo::default(),
436 };
437
438 let mut module = ModuleInfo::default();
439 for node in &program {
440 collect_module_info(path, node, &mut module);
441 collect_type_declarations(node, &mut module.type_declarations);
442 }
443 if !module.has_pub_fn {
446 for name in &module.fn_names {
447 module.exports.insert(name.clone());
448 }
449 }
450 module
451}
452
453fn stdlib_module_from_path(path: &Path) -> Option<&str> {
456 let s = path.to_str()?;
457 s.strip_prefix("<std>/")
458}
459
460fn collect_module_info(file: &Path, snode: &SNode, module: &mut ModuleInfo) {
461 match &snode.node {
462 Node::FnDecl {
463 name,
464 params,
465 is_pub,
466 ..
467 } => {
468 if *is_pub {
469 module.exports.insert(name.clone());
470 module.has_pub_fn = true;
471 }
472 module.fn_names.push(name.clone());
473 module.declarations.insert(
474 name.clone(),
475 decl_site(file, snode.span, name, DefKind::Function),
476 );
477 for param_name in params.iter().map(|param| param.name.clone()) {
478 module.declarations.insert(
479 param_name.clone(),
480 decl_site(file, snode.span, ¶m_name, DefKind::Parameter),
481 );
482 }
483 }
484 Node::Pipeline { name, is_pub, .. } => {
485 if *is_pub {
486 module.exports.insert(name.clone());
487 }
488 module.declarations.insert(
489 name.clone(),
490 decl_site(file, snode.span, name, DefKind::Pipeline),
491 );
492 }
493 Node::ToolDecl { name, is_pub, .. } => {
494 if *is_pub {
495 module.exports.insert(name.clone());
496 }
497 module.declarations.insert(
498 name.clone(),
499 decl_site(file, snode.span, name, DefKind::Tool),
500 );
501 }
502 Node::SkillDecl { name, is_pub, .. } => {
503 if *is_pub {
504 module.exports.insert(name.clone());
505 }
506 module.declarations.insert(
507 name.clone(),
508 decl_site(file, snode.span, name, DefKind::Skill),
509 );
510 }
511 Node::StructDecl { name, is_pub, .. } => {
512 if *is_pub {
513 module.exports.insert(name.clone());
514 }
515 module.declarations.insert(
516 name.clone(),
517 decl_site(file, snode.span, name, DefKind::Struct),
518 );
519 }
520 Node::EnumDecl { name, is_pub, .. } => {
521 if *is_pub {
522 module.exports.insert(name.clone());
523 }
524 module.declarations.insert(
525 name.clone(),
526 decl_site(file, snode.span, name, DefKind::Enum),
527 );
528 }
529 Node::InterfaceDecl { name, .. } => {
530 module.exports.insert(name.clone());
531 module.declarations.insert(
532 name.clone(),
533 decl_site(file, snode.span, name, DefKind::Interface),
534 );
535 }
536 Node::TypeDecl { name, .. } => {
537 module.exports.insert(name.clone());
538 module.declarations.insert(
539 name.clone(),
540 decl_site(file, snode.span, name, DefKind::Type),
541 );
542 }
543 Node::LetBinding { pattern, .. } | Node::VarBinding { pattern, .. } => {
544 for name in pattern_names(pattern) {
545 module.declarations.insert(
546 name.clone(),
547 decl_site(file, snode.span, &name, DefKind::Variable),
548 );
549 }
550 }
551 Node::ImportDecl { path } => {
552 let import_path = resolve_import_path(file, path);
553 if import_path.is_none() {
554 module.has_unresolved_wildcard_import = true;
555 }
556 module.imports.push(ImportRef {
557 path: import_path,
558 selective_names: None,
559 });
560 }
561 Node::SelectiveImport { names, path } => {
562 let import_path = resolve_import_path(file, path);
563 if import_path.is_none() {
564 module.has_unresolved_selective_import = true;
565 }
566 let names: HashSet<String> = names.iter().cloned().collect();
567 module.selective_import_names.extend(names.iter().cloned());
568 module.imports.push(ImportRef {
569 path: import_path,
570 selective_names: Some(names),
571 });
572 }
573 Node::AttributedDecl { inner, .. } => {
574 collect_module_info(file, inner, module);
575 }
576 _ => {}
577 }
578}
579
580fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
581 match &snode.node {
582 Node::TypeDecl { .. }
583 | Node::StructDecl { .. }
584 | Node::EnumDecl { .. }
585 | Node::InterfaceDecl { .. } => decls.push(snode.clone()),
586 Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
587 _ => {}
588 }
589}
590
591fn type_decl_name(snode: &SNode) -> Option<&str> {
592 match &snode.node {
593 Node::TypeDecl { name, .. }
594 | Node::StructDecl { name, .. }
595 | Node::EnumDecl { name, .. }
596 | Node::InterfaceDecl { name, .. } => Some(name.as_str()),
597 _ => None,
598 }
599}
600
601fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
602 DefSite {
603 name: name.to_string(),
604 file: file.to_path_buf(),
605 kind,
606 span,
607 }
608}
609
610fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
611 match pattern {
612 BindingPattern::Identifier(name) => vec![name.clone()],
613 BindingPattern::Dict(fields) => fields
614 .iter()
615 .filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
616 .collect(),
617 BindingPattern::List(elements) => elements
618 .iter()
619 .map(|element| element.name.clone())
620 .collect(),
621 BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
622 }
623}
624
625fn normalize_path(path: &Path) -> PathBuf {
626 if stdlib_module_from_path(path).is_some() {
627 return path.to_path_buf();
628 }
629 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use std::fs;
636
637 fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
638 let path = dir.join(name);
639 fs::write(&path, contents).unwrap();
640 path
641 }
642
643 #[test]
644 fn recursive_build_loads_transitively_imported_modules() {
645 let tmp = tempfile::tempdir().unwrap();
646 let root = tmp.path();
647 write_file(root, "leaf.harn", "pub fn leaf_fn() { 1 }\n");
648 write_file(
649 root,
650 "mid.harn",
651 "import \"./leaf\"\npub fn mid_fn() { leaf_fn() }\n",
652 );
653 let entry = write_file(root, "entry.harn", "import \"./mid\"\nmid_fn()\n");
654
655 let graph = build(std::slice::from_ref(&entry));
656 let imported = graph
657 .imported_names_for_file(&entry)
658 .expect("entry imports should resolve");
659 assert!(imported.contains("mid_fn"));
661 assert!(!imported.contains("leaf_fn"));
662
663 let leaf_path = root.join("leaf.harn");
666 assert!(graph.definition_of(&leaf_path, "leaf_fn").is_some());
667 }
668
669 #[test]
670 fn imported_names_returns_none_when_import_unresolved() {
671 let tmp = tempfile::tempdir().unwrap();
672 let root = tmp.path();
673 let entry = write_file(root, "entry.harn", "import \"./does_not_exist\"\n");
674
675 let graph = build(std::slice::from_ref(&entry));
676 assert!(graph.imported_names_for_file(&entry).is_none());
677 }
678
679 #[test]
680 fn selective_imports_contribute_only_requested_names() {
681 let tmp = tempfile::tempdir().unwrap();
682 let root = tmp.path();
683 write_file(root, "util.harn", "pub fn a() { 1 }\npub fn b() { 2 }\n");
684 let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
685
686 let graph = build(std::slice::from_ref(&entry));
687 let imported = graph
688 .imported_names_for_file(&entry)
689 .expect("entry imports should resolve");
690 assert!(imported.contains("a"));
691 assert!(!imported.contains("b"));
692 }
693
694 #[test]
695 fn stdlib_imports_resolve_to_embedded_sources() {
696 let tmp = tempfile::tempdir().unwrap();
697 let root = tmp.path();
698 let entry = write_file(root, "entry.harn", "import \"std/math\"\nclamp(5, 0, 10)\n");
699
700 let graph = build(std::slice::from_ref(&entry));
701 let imported = graph
702 .imported_names_for_file(&entry)
703 .expect("std/math should resolve");
704 assert!(imported.contains("clamp"));
706 }
707
708 #[test]
709 fn runtime_stdlib_import_surface_resolves_to_embedded_sources() {
710 let tmp = tempfile::tempdir().unwrap();
711 let entry = write_file(tmp.path(), "entry.harn", "");
712
713 for (module, _) in stdlib::STDLIB_SOURCES {
714 let import_path = format!("std/{module}");
715 assert!(
716 resolve_import_path(&entry, &import_path).is_some(),
717 "{import_path} should resolve in the module graph"
718 );
719 }
720 }
721
722 #[test]
723 fn stdlib_imports_expose_type_declarations() {
724 let tmp = tempfile::tempdir().unwrap();
725 let root = tmp.path();
726 let entry = write_file(
727 root,
728 "entry.harn",
729 "import \"std/triggers\"\nlet provider = \"github\"\n",
730 );
731
732 let graph = build(std::slice::from_ref(&entry));
733 let decls = graph
734 .imported_type_declarations_for_file(&entry)
735 .expect("std/triggers type declarations should resolve");
736 let names: HashSet<String> = decls
737 .iter()
738 .filter_map(type_decl_name)
739 .map(ToString::to_string)
740 .collect();
741 assert!(names.contains("TriggerEvent"));
742 assert!(names.contains("ProviderPayload"));
743 assert!(names.contains("SignatureStatus"));
744 }
745
746 #[test]
747 fn package_export_map_resolves_declared_module() {
748 let tmp = tempfile::tempdir().unwrap();
749 let root = tmp.path();
750 let packages = root.join(".harn/packages/acme/runtime");
751 fs::create_dir_all(&packages).unwrap();
752 fs::write(
753 root.join(".harn/packages/acme/harn.toml"),
754 "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
755 )
756 .unwrap();
757 fs::write(
758 packages.join("capabilities.harn"),
759 "pub fn exported_capability() { 1 }\n",
760 )
761 .unwrap();
762 let entry = write_file(
763 root,
764 "entry.harn",
765 "import \"acme/capabilities\"\nexported_capability()\n",
766 );
767
768 let graph = build(std::slice::from_ref(&entry));
769 let imported = graph
770 .imported_names_for_file(&entry)
771 .expect("package export should resolve");
772 assert!(imported.contains("exported_capability"));
773 }
774
775 #[test]
776 fn package_imports_resolve_from_nested_package_module() {
777 let tmp = tempfile::tempdir().unwrap();
778 let root = tmp.path();
779 fs::create_dir_all(root.join(".git")).unwrap();
780 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
781 fs::create_dir_all(root.join(".harn/packages/shared")).unwrap();
782 fs::write(
783 root.join(".harn/packages/shared/lib.harn"),
784 "pub fn shared_helper() { 1 }\n",
785 )
786 .unwrap();
787 fs::write(
788 root.join(".harn/packages/acme/lib.harn"),
789 "import \"shared\"\npub fn use_shared() { shared_helper() }\n",
790 )
791 .unwrap();
792 let entry = write_file(root, "entry.harn", "import \"acme\"\nuse_shared()\n");
793
794 let graph = build(std::slice::from_ref(&entry));
795 let imported = graph
796 .imported_names_for_file(&entry)
797 .expect("nested package import should resolve");
798 assert!(imported.contains("use_shared"));
799 let acme_path = root.join(".harn/packages/acme/lib.harn");
800 let acme_imports = graph
801 .imported_names_for_file(&acme_path)
802 .expect("package module imports should resolve");
803 assert!(acme_imports.contains("shared_helper"));
804 }
805
806 #[test]
807 fn unknown_stdlib_import_is_unresolved() {
808 let tmp = tempfile::tempdir().unwrap();
809 let root = tmp.path();
810 let entry = write_file(root, "entry.harn", "import \"std/does_not_exist\"\n");
811
812 let graph = build(std::slice::from_ref(&entry));
813 assert!(
814 graph.imported_names_for_file(&entry).is_none(),
815 "unknown std module should fail resolution and disable strict check"
816 );
817 }
818
819 #[test]
820 fn import_cycles_do_not_loop_forever() {
821 let tmp = tempfile::tempdir().unwrap();
822 let root = tmp.path();
823 write_file(root, "a.harn", "import \"./b\"\npub fn a_fn() { 1 }\n");
824 write_file(root, "b.harn", "import \"./a\"\npub fn b_fn() { 1 }\n");
825 let entry = root.join("a.harn");
826
827 let graph = build(std::slice::from_ref(&entry));
829 let imported = graph
830 .imported_names_for_file(&entry)
831 .expect("cyclic imports still resolve to known exports");
832 assert!(imported.contains("b_fn"));
833 }
834
835 #[test]
836 fn cross_directory_cycle_does_not_explode_module_count() {
837 let tmp = tempfile::tempdir().unwrap();
845 let root = tmp.path();
846 let context = root.join("context");
847 let runtime = root.join("runtime");
848 fs::create_dir_all(&context).unwrap();
849 fs::create_dir_all(&runtime).unwrap();
850 write_file(
851 &context,
852 "a.harn",
853 "import \"../runtime/b\"\npub fn a_fn() { 1 }\n",
854 );
855 write_file(
856 &runtime,
857 "b.harn",
858 "import \"../context/a\"\npub fn b_fn() { 1 }\n",
859 );
860 let entry = context.join("a.harn");
861
862 let graph = build(std::slice::from_ref(&entry));
863 assert_eq!(
866 graph.modules.len(),
867 2,
868 "cross-directory cycle loaded {} modules, expected 2",
869 graph.modules.len()
870 );
871 let imported = graph
872 .imported_names_for_file(&entry)
873 .expect("cyclic imports still resolve to known exports");
874 assert!(imported.contains("b_fn"));
875 }
876}