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;
9pub mod fingerprint;
10pub mod personas;
11mod stdlib;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum DefKind {
16 Function,
17 Pipeline,
18 Tool,
19 Skill,
20 Struct,
21 Enum,
22 Interface,
23 Type,
24 Variable,
25 Parameter,
26}
27
28#[derive(Debug, Clone)]
30pub struct DefSite {
31 pub name: String,
32 pub file: PathBuf,
33 pub kind: DefKind,
34 pub span: Span,
35}
36
37#[derive(Debug, Clone)]
39pub enum WildcardResolution {
40 Resolved(HashSet<String>),
42 Unknown,
44}
45
46#[derive(Debug, Default)]
48pub struct ModuleGraph {
49 modules: HashMap<PathBuf, ModuleInfo>,
50}
51
52#[derive(Debug, Default)]
53struct ModuleInfo {
54 declarations: HashMap<String, DefSite>,
57 exports: HashSet<String>,
62 own_exports: HashSet<String>,
65 selective_re_exports: HashMap<String, Vec<PathBuf>>,
72 wildcard_re_export_paths: Vec<PathBuf>,
76 selective_import_names: HashSet<String>,
78 imports: Vec<ImportRef>,
80 has_unresolved_wildcard_import: bool,
82 has_unresolved_selective_import: bool,
86 fn_names: Vec<String>,
90 has_pub_fn: bool,
92 type_declarations: Vec<SNode>,
95 callable_declarations: Vec<SNode>,
98}
99
100#[derive(Debug, Clone)]
101struct ImportRef {
102 raw_path: String,
103 path: Option<PathBuf>,
104 selective_names: Option<HashSet<String>>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct ModuleImport {
110 pub raw_path: String,
112 pub resolved_path: Option<PathBuf>,
114 pub selective_names: Option<Vec<String>>,
116}
117
118#[derive(Debug, Default, Deserialize)]
119struct PackageManifest {
120 #[serde(default)]
121 exports: HashMap<String, String>,
122}
123
124pub fn read_module_source(path: &Path) -> Option<String> {
130 if let Some(stdlib_module) = stdlib_module_from_path(path) {
131 return stdlib::get_stdlib_source(stdlib_module).map(ToString::to_string);
132 }
133 std::fs::read_to_string(path).ok()
134}
135
136pub fn build(files: &[PathBuf]) -> ModuleGraph {
142 let mut modules: HashMap<PathBuf, ModuleInfo> = HashMap::new();
143 let mut seen: HashSet<PathBuf> = HashSet::new();
144 let mut queue: VecDeque<PathBuf> = VecDeque::new();
145 for file in files {
146 let canonical = normalize_path(file);
147 if seen.insert(canonical.clone()) {
148 queue.push_back(canonical);
149 }
150 }
151 while let Some(path) = queue.pop_front() {
152 if modules.contains_key(&path) {
153 continue;
154 }
155 let module = load_module(&path);
156 for import in &module.imports {
173 if let Some(import_path) = &import.path {
174 let canonical = normalize_path(import_path);
175 if seen.insert(canonical.clone()) {
176 queue.push_back(canonical);
177 }
178 }
179 }
180 modules.insert(path, module);
181 }
182 resolve_re_exports(&mut modules);
183 ModuleGraph { modules }
184}
185
186fn resolve_re_exports(modules: &mut HashMap<PathBuf, ModuleInfo>) {
191 let keys: Vec<PathBuf> = modules.keys().cloned().collect();
192 loop {
193 let mut changed = false;
194 for path in &keys {
195 let wildcard_paths = modules
198 .get(path)
199 .map(|m| m.wildcard_re_export_paths.clone())
200 .unwrap_or_default();
201 if wildcard_paths.is_empty() {
202 continue;
203 }
204 let mut additions: Vec<String> = Vec::new();
205 for src in &wildcard_paths {
206 let src_canonical = normalize_path(src);
207 if let Some(src_module) = modules.get(src).or_else(|| modules.get(&src_canonical)) {
208 additions.extend(src_module.exports.iter().cloned());
209 }
210 }
211 if let Some(module) = modules.get_mut(path) {
212 for name in additions {
213 if module.exports.insert(name) {
214 changed = true;
215 }
216 }
217 }
218 }
219 if !changed {
220 break;
221 }
222 }
223}
224
225pub fn resolve_import_path(current_file: &Path, import_path: &str) -> Option<PathBuf> {
238 if let Some(module) = import_path
239 .strip_prefix("std/")
240 .or_else(|| (import_path == "observability").then_some("observability"))
241 {
242 if stdlib::get_stdlib_source(module).is_some() {
243 return Some(stdlib::stdlib_virtual_path(module));
244 }
245 return None;
246 }
247
248 let base = current_file.parent().unwrap_or(Path::new("."));
249 let mut file_path = base.join(import_path);
250 if !file_path.exists() && file_path.extension().is_none() {
251 file_path.set_extension("harn");
252 }
253 if file_path.exists() {
254 return Some(file_path);
255 }
256
257 if let Some(path) = resolve_package_import(base, import_path) {
258 return Some(path);
259 }
260
261 None
262}
263
264fn resolve_package_import(base: &Path, import_path: &str) -> Option<PathBuf> {
265 for anchor in base.ancestors() {
266 let packages_root = anchor.join(".harn/packages");
267 if !packages_root.is_dir() {
268 if anchor.join(".git").exists() {
269 break;
270 }
271 continue;
272 }
273 if let Some(path) = resolve_from_packages_root(&packages_root, import_path) {
274 return Some(path);
275 }
276 if anchor.join(".git").exists() {
277 break;
278 }
279 }
280 None
281}
282
283fn resolve_from_packages_root(packages_root: &Path, import_path: &str) -> Option<PathBuf> {
284 let safe_import_path = safe_package_relative_path(import_path)?;
285 let package_name = package_name_from_relative_path(&safe_import_path)?;
286 let package_root = packages_root.join(package_name);
287
288 let pkg_path = packages_root.join(&safe_import_path);
289 if let Some(path) = finalize_package_target(&package_root, &pkg_path) {
290 return Some(path);
291 }
292
293 let export_name = export_name_from_relative_path(&safe_import_path)?;
294 let manifest_path = packages_root.join(package_name).join("harn.toml");
295 let manifest = read_package_manifest(&manifest_path)?;
296 let rel_path = manifest.exports.get(export_name)?;
297 let safe_export_path = safe_package_relative_path(rel_path)?;
298 finalize_package_target(&package_root, &package_root.join(safe_export_path))
299}
300
301fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
302 let content = std::fs::read_to_string(path).ok()?;
303 toml::from_str::<PackageManifest>(&content).ok()
304}
305
306fn safe_package_relative_path(raw: &str) -> Option<PathBuf> {
307 if raw.is_empty() || raw.contains('\\') {
308 return None;
309 }
310 let mut out = PathBuf::new();
311 let mut saw_component = false;
312 for component in Path::new(raw).components() {
313 match component {
314 Component::Normal(part) => {
315 saw_component = true;
316 out.push(part);
317 }
318 Component::CurDir => {}
319 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
320 }
321 }
322 saw_component.then_some(out)
323}
324
325fn package_name_from_relative_path(path: &Path) -> Option<&str> {
326 match path.components().next()? {
327 Component::Normal(name) => name.to_str(),
328 _ => None,
329 }
330}
331
332fn export_name_from_relative_path(path: &Path) -> Option<&str> {
333 let mut components = path.components();
334 components.next()?;
335 let rest = components.as_path();
336 if rest.as_os_str().is_empty() {
337 None
338 } else {
339 rest.to_str()
340 }
341}
342
343fn path_is_within(root: &Path, path: &Path) -> bool {
344 let Ok(root) = root.canonicalize() else {
345 return false;
346 };
347 let Ok(path) = path.canonicalize() else {
348 return false;
349 };
350 path == root || path.starts_with(root)
351}
352
353fn target_within_package_root(package_root: &Path, path: PathBuf) -> Option<PathBuf> {
354 path_is_within(package_root, &path).then_some(path)
355}
356
357fn finalize_package_target(package_root: &Path, path: &Path) -> Option<PathBuf> {
358 if path.is_dir() {
359 let lib = path.join("lib.harn");
360 if lib.exists() {
361 return target_within_package_root(package_root, lib);
362 }
363 return target_within_package_root(package_root, path.to_path_buf());
364 }
365 if path.exists() {
366 return target_within_package_root(package_root, path.to_path_buf());
367 }
368 if path.extension().is_none() {
369 let mut with_ext = path.to_path_buf();
370 with_ext.set_extension("harn");
371 if with_ext.exists() {
372 return target_within_package_root(package_root, with_ext);
373 }
374 }
375 None
376}
377
378impl ModuleGraph {
379 pub fn module_paths(&self) -> Vec<PathBuf> {
384 let mut paths: Vec<PathBuf> = self.modules.keys().cloned().collect();
385 paths.sort();
386 paths
387 }
388
389 pub fn contains_module(&self, path: &Path) -> bool {
392 self.modules.contains_key(path) || self.modules.contains_key(&normalize_path(path))
393 }
394
395 pub fn all_selective_import_names(&self) -> HashSet<&str> {
397 let mut names = HashSet::new();
398 for module in self.modules.values() {
399 for name in &module.selective_import_names {
400 names.insert(name.as_str());
401 }
402 }
403 names
404 }
405
406 pub fn importers_of(&self, target: &Path) -> Vec<PathBuf> {
409 let target = normalize_path(target);
410 let mut out: Vec<PathBuf> = self
411 .modules
412 .iter()
413 .filter(|(_, info)| {
414 info.imports.iter().any(|import| {
415 import
416 .path
417 .as_ref()
418 .is_some_and(|p| normalize_path(p) == target)
419 })
420 })
421 .map(|(path, _)| path.clone())
422 .collect();
423 out.sort();
424 out
425 }
426
427 pub fn imports_for_module(&self, file: &Path) -> Vec<ModuleImport> {
429 let file = normalize_path(file);
430 let Some(module) = self.modules.get(&file) else {
431 return Vec::new();
432 };
433 let mut imports: Vec<ModuleImport> = module
434 .imports
435 .iter()
436 .map(|import| {
437 let mut selective_names = import
438 .selective_names
439 .as_ref()
440 .map(|names| names.iter().cloned().collect::<Vec<_>>());
441 if let Some(names) = selective_names.as_mut() {
442 names.sort();
443 }
444 ModuleImport {
445 raw_path: import.raw_path.clone(),
446 resolved_path: import.path.as_ref().map(|path| normalize_path(path)),
447 selective_names,
448 }
449 })
450 .collect();
451 imports.sort_by(|left, right| {
452 left.raw_path
453 .cmp(&right.raw_path)
454 .then_with(|| left.selective_names.cmp(&right.selective_names))
455 .then_with(|| left.resolved_path.cmp(&right.resolved_path))
456 });
457 imports
458 }
459
460 pub fn exports_for_module(&self, file: &Path) -> Vec<String> {
462 let file = normalize_path(file);
463 let Some(module) = self.modules.get(&file) else {
464 return Vec::new();
465 };
466 let mut exports: Vec<String> = module.exports.iter().cloned().collect();
467 exports.sort();
468 exports
469 }
470
471 pub fn wildcard_exports_for(&self, file: &Path) -> WildcardResolution {
476 let file = normalize_path(file);
477 let Some(module) = self.modules.get(&file) else {
478 return WildcardResolution::Unknown;
479 };
480 if module.has_unresolved_wildcard_import {
481 return WildcardResolution::Unknown;
482 }
483
484 let mut names = HashSet::new();
485 for import in module
486 .imports
487 .iter()
488 .filter(|import| import.selective_names.is_none())
489 {
490 let Some(import_path) = &import.path else {
491 return WildcardResolution::Unknown;
492 };
493 let imported = self.modules.get(import_path).or_else(|| {
494 let normalized = normalize_path(import_path);
495 self.modules.get(&normalized)
496 });
497 let Some(imported) = imported else {
498 return WildcardResolution::Unknown;
499 };
500 names.extend(imported.exports.iter().cloned());
501 }
502 WildcardResolution::Resolved(names)
503 }
504
505 pub fn imported_names_for_file(&self, file: &Path) -> Option<HashSet<String>> {
520 let file = normalize_path(file);
521 let module = self.modules.get(&file)?;
522 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
523 return None;
524 }
525
526 let mut names = HashSet::new();
527 for import in &module.imports {
528 let import_path = import.path.as_ref()?;
529 let imported = self
530 .modules
531 .get(import_path)
532 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
533 match &import.selective_names {
534 None => {
535 names.extend(imported.exports.iter().cloned());
536 }
537 Some(selective) => {
538 for name in selective {
539 if imported.declarations.contains_key(name)
540 || imported.exports.contains(name)
541 {
542 names.insert(name.clone());
543 }
544 }
545 }
546 }
547 }
548 Some(names)
549 }
550
551 pub fn imported_type_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
555 let file = normalize_path(file);
556 let module = self.modules.get(&file)?;
557 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
558 return None;
559 }
560
561 let mut decls = Vec::new();
562 for import in &module.imports {
563 let import_path = import.path.as_ref()?;
564 let imported = self
565 .modules
566 .get(import_path)
567 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
568 let names_to_collect: Vec<String> = match &import.selective_names {
569 None => imported.exports.iter().cloned().collect(),
570 Some(selective) => {
571 let mut names: Vec<String> = selective.iter().cloned().collect();
580 for ty_decl in &imported.type_declarations {
581 if let Some(name) = type_decl_name(ty_decl) {
582 if imported.own_exports.contains(name)
583 && !names.iter().any(|n| n == name)
584 {
585 names.push(name.to_string());
586 }
587 }
588 }
589 names
590 }
591 };
592 for name in &names_to_collect {
593 let mut visited = HashSet::new();
594 if let Some(decl) = self.find_exported_type_decl(import_path, name, &mut visited) {
595 decls.push(decl);
596 }
597 }
598 }
599 Some(decls)
600 }
601
602 pub fn imported_callable_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
606 let file = normalize_path(file);
607 let module = self.modules.get(&file)?;
608 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
609 return None;
610 }
611
612 let mut decls = Vec::new();
613 for import in &module.imports {
614 let import_path = import.path.as_ref()?;
615 let imported = self
616 .modules
617 .get(import_path)
618 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
619 let selective_import = import.selective_names.is_some();
620 let names_to_collect: Vec<String> = match &import.selective_names {
621 None => imported.exports.iter().cloned().collect(),
622 Some(selective) => selective.iter().cloned().collect(),
623 };
624 for name in &names_to_collect {
625 if selective_import || imported.own_exports.contains(name) {
626 if let Some(decl) = imported
627 .callable_declarations
628 .iter()
629 .find(|decl| callable_decl_name(decl) == Some(name.as_str()))
630 {
631 decls.push(decl.clone());
632 continue;
633 }
634 }
635 let mut visited = HashSet::new();
636 if let Some(decl) =
637 self.find_exported_callable_decl(import_path, name, &mut visited)
638 {
639 decls.push(decl);
640 }
641 }
642 }
643 Some(decls)
644 }
645
646 fn find_exported_type_decl(
649 &self,
650 path: &Path,
651 name: &str,
652 visited: &mut HashSet<PathBuf>,
653 ) -> Option<SNode> {
654 let canonical = normalize_path(path);
655 if !visited.insert(canonical.clone()) {
656 return None;
657 }
658 let module = self
659 .modules
660 .get(&canonical)
661 .or_else(|| self.modules.get(path))?;
662 for decl in &module.type_declarations {
663 if type_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
664 return Some(decl.clone());
665 }
666 }
667 if let Some(sources) = module.selective_re_exports.get(name) {
668 for source in sources {
669 if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
670 return Some(decl);
671 }
672 }
673 }
674 for source in &module.wildcard_re_export_paths {
675 if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
676 return Some(decl);
677 }
678 }
679 None
680 }
681
682 fn find_exported_callable_decl(
683 &self,
684 path: &Path,
685 name: &str,
686 visited: &mut HashSet<PathBuf>,
687 ) -> Option<SNode> {
688 let canonical = normalize_path(path);
689 if !visited.insert(canonical.clone()) {
690 return None;
691 }
692 let module = self
693 .modules
694 .get(&canonical)
695 .or_else(|| self.modules.get(path))?;
696 for decl in &module.callable_declarations {
697 if callable_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
698 return Some(decl.clone());
699 }
700 }
701 if let Some(sources) = module.selective_re_exports.get(name) {
702 for source in sources {
703 if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
704 return Some(decl);
705 }
706 }
707 }
708 for source in &module.wildcard_re_export_paths {
709 if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
710 return Some(decl);
711 }
712 }
713 None
714 }
715
716 pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
722 let mut visited = HashSet::new();
723 self.definition_of_inner(file, name, &mut visited)
724 }
725
726 fn definition_of_inner(
727 &self,
728 file: &Path,
729 name: &str,
730 visited: &mut HashSet<PathBuf>,
731 ) -> Option<DefSite> {
732 let file = normalize_path(file);
733 if !visited.insert(file.clone()) {
734 return None;
735 }
736 let current = self.modules.get(&file)?;
737
738 if let Some(local) = current.declarations.get(name) {
739 return Some(local.clone());
740 }
741
742 if let Some(sources) = current.selective_re_exports.get(name) {
747 for source in sources {
748 if let Some(def) = self.definition_of_inner(source, name, visited) {
749 return Some(def);
750 }
751 }
752 }
753
754 for source in ¤t.wildcard_re_export_paths {
756 if let Some(def) = self.definition_of_inner(source, name, visited) {
757 return Some(def);
758 }
759 }
760
761 for import in ¤t.imports {
763 let Some(selective_names) = &import.selective_names else {
764 continue;
765 };
766 if !selective_names.contains(name) {
767 continue;
768 }
769 if let Some(path) = &import.path {
770 if let Some(def) = self.definition_of_inner(path, name, visited) {
771 return Some(def);
772 }
773 }
774 }
775
776 for import in ¤t.imports {
778 if import.selective_names.is_some() {
779 continue;
780 }
781 if let Some(path) = &import.path {
782 if let Some(def) = self.definition_of_inner(path, name, visited) {
783 return Some(def);
784 }
785 }
786 }
787
788 None
789 }
790
791 pub fn re_export_conflicts(&self, file: &Path) -> Vec<ReExportConflict> {
795 let file = normalize_path(file);
796 let Some(module) = self.modules.get(&file) else {
797 return Vec::new();
798 };
799
800 let mut sources: HashMap<String, Vec<PathBuf>> = HashMap::new();
804
805 for (name, srcs) in &module.selective_re_exports {
806 sources
807 .entry(name.clone())
808 .or_default()
809 .extend(srcs.iter().cloned());
810 }
811 for src in &module.wildcard_re_export_paths {
812 let canonical = normalize_path(src);
813 let Some(src_module) = self
814 .modules
815 .get(&canonical)
816 .or_else(|| self.modules.get(src))
817 else {
818 continue;
819 };
820 for name in &src_module.exports {
821 sources
822 .entry(name.clone())
823 .or_default()
824 .push(canonical.clone());
825 }
826 }
827
828 for name in &module.own_exports {
832 if let Some(entry) = sources.get_mut(name) {
833 entry.push(file.clone());
834 }
835 }
836
837 let mut conflicts = Vec::new();
838 for (name, mut srcs) in sources {
839 srcs.sort();
840 srcs.dedup();
841 if srcs.len() > 1 {
842 conflicts.push(ReExportConflict {
843 name,
844 sources: srcs,
845 });
846 }
847 }
848 conflicts.sort_by(|a, b| a.name.cmp(&b.name));
849 conflicts
850 }
851}
852
853#[derive(Debug, Clone, PartialEq, Eq)]
856pub struct ReExportConflict {
857 pub name: String,
858 pub sources: Vec<PathBuf>,
859}
860
861fn load_module(path: &Path) -> ModuleInfo {
862 let Some(source) = read_module_source(path) else {
863 return ModuleInfo::default();
864 };
865 let mut lexer = harn_lexer::Lexer::new(&source);
866 let tokens = match lexer.tokenize() {
867 Ok(tokens) => tokens,
868 Err(_) => return ModuleInfo::default(),
869 };
870 let mut parser = Parser::new(tokens);
871 let program = match parser.parse() {
872 Ok(program) => program,
873 Err(_) => return ModuleInfo::default(),
874 };
875
876 let mut module = ModuleInfo::default();
877 for node in &program {
878 collect_module_info(path, node, &mut module);
879 collect_type_declarations(node, &mut module.type_declarations);
880 collect_callable_declarations(node, &mut module.callable_declarations);
881 }
882 if !module.has_pub_fn {
885 for name in &module.fn_names {
886 module.own_exports.insert(name.clone());
887 }
888 }
889 module.exports.extend(module.own_exports.iter().cloned());
893 module
894 .exports
895 .extend(module.selective_re_exports.keys().cloned());
896 module
897}
898
899fn stdlib_module_from_path(path: &Path) -> Option<&str> {
902 let s = path.to_str()?;
903 s.strip_prefix("<std>/")
904}
905
906fn collect_module_info(file: &Path, snode: &SNode, module: &mut ModuleInfo) {
907 match &snode.node {
908 Node::FnDecl {
909 name,
910 params,
911 is_pub,
912 ..
913 } => {
914 if *is_pub {
915 module.own_exports.insert(name.clone());
916 module.has_pub_fn = true;
917 }
918 module.fn_names.push(name.clone());
919 module.declarations.insert(
920 name.clone(),
921 decl_site(file, snode.span, name, DefKind::Function),
922 );
923 for param_name in params.iter().map(|param| param.name.clone()) {
924 module.declarations.insert(
925 param_name.clone(),
926 decl_site(file, snode.span, ¶m_name, DefKind::Parameter),
927 );
928 }
929 }
930 Node::Pipeline { name, is_pub, .. } => {
931 if *is_pub {
932 module.own_exports.insert(name.clone());
933 }
934 module.declarations.insert(
935 name.clone(),
936 decl_site(file, snode.span, name, DefKind::Pipeline),
937 );
938 }
939 Node::ToolDecl { name, is_pub, .. } => {
940 if *is_pub {
941 module.own_exports.insert(name.clone());
942 }
943 module.declarations.insert(
944 name.clone(),
945 decl_site(file, snode.span, name, DefKind::Tool),
946 );
947 }
948 Node::SkillDecl { name, is_pub, .. } => {
949 if *is_pub {
950 module.own_exports.insert(name.clone());
951 }
952 module.declarations.insert(
953 name.clone(),
954 decl_site(file, snode.span, name, DefKind::Skill),
955 );
956 }
957 Node::StructDecl { name, is_pub, .. } => {
958 if *is_pub {
959 module.own_exports.insert(name.clone());
960 }
961 module.declarations.insert(
962 name.clone(),
963 decl_site(file, snode.span, name, DefKind::Struct),
964 );
965 }
966 Node::EnumDecl { name, is_pub, .. } => {
967 if *is_pub {
968 module.own_exports.insert(name.clone());
969 }
970 module.declarations.insert(
971 name.clone(),
972 decl_site(file, snode.span, name, DefKind::Enum),
973 );
974 }
975 Node::InterfaceDecl { name, .. } => {
976 module.own_exports.insert(name.clone());
977 module.declarations.insert(
978 name.clone(),
979 decl_site(file, snode.span, name, DefKind::Interface),
980 );
981 }
982 Node::TypeDecl { name, .. } => {
983 module.own_exports.insert(name.clone());
984 module.declarations.insert(
985 name.clone(),
986 decl_site(file, snode.span, name, DefKind::Type),
987 );
988 }
989 Node::LetBinding { pattern, .. } | Node::VarBinding { pattern, .. } => {
990 for name in pattern_names(pattern) {
991 module.declarations.insert(
992 name.clone(),
993 decl_site(file, snode.span, &name, DefKind::Variable),
994 );
995 }
996 }
997 Node::ImportDecl { path, is_pub } => {
998 let import_path = resolve_import_path(file, path);
999 if import_path.is_none() {
1000 module.has_unresolved_wildcard_import = true;
1001 }
1002 if *is_pub {
1003 if let Some(resolved) = &import_path {
1004 module
1005 .wildcard_re_export_paths
1006 .push(normalize_path(resolved));
1007 }
1008 }
1009 module.imports.push(ImportRef {
1010 raw_path: path.clone(),
1011 path: import_path,
1012 selective_names: None,
1013 });
1014 }
1015 Node::SelectiveImport {
1016 names,
1017 path,
1018 is_pub,
1019 } => {
1020 let import_path = resolve_import_path(file, path);
1021 if import_path.is_none() {
1022 module.has_unresolved_selective_import = true;
1023 }
1024 if *is_pub {
1025 if let Some(resolved) = &import_path {
1026 let canonical = normalize_path(resolved);
1027 for name in names {
1028 module
1029 .selective_re_exports
1030 .entry(name.clone())
1031 .or_default()
1032 .push(canonical.clone());
1033 }
1034 }
1035 }
1036 let names: HashSet<String> = names.iter().cloned().collect();
1037 module.selective_import_names.extend(names.iter().cloned());
1038 module.imports.push(ImportRef {
1039 raw_path: path.clone(),
1040 path: import_path,
1041 selective_names: Some(names),
1042 });
1043 }
1044 Node::AttributedDecl { inner, .. } => {
1045 collect_module_info(file, inner, module);
1046 }
1047 _ => {}
1048 }
1049}
1050
1051fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1052 match &snode.node {
1053 Node::TypeDecl { .. }
1054 | Node::StructDecl { .. }
1055 | Node::EnumDecl { .. }
1056 | Node::InterfaceDecl { .. } => decls.push(snode.clone()),
1057 Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
1058 _ => {}
1059 }
1060}
1061
1062fn collect_callable_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1063 match &snode.node {
1064 Node::FnDecl { .. } | Node::Pipeline { .. } | Node::ToolDecl { .. } => {
1065 decls.push(snode.clone())
1066 }
1067 Node::AttributedDecl { inner, .. } => collect_callable_declarations(inner, decls),
1068 _ => {}
1069 }
1070}
1071
1072fn type_decl_name(snode: &SNode) -> Option<&str> {
1073 match &snode.node {
1074 Node::TypeDecl { name, .. }
1075 | Node::StructDecl { name, .. }
1076 | Node::EnumDecl { name, .. }
1077 | Node::InterfaceDecl { name, .. } => Some(name.as_str()),
1078 _ => None,
1079 }
1080}
1081
1082fn callable_decl_name(snode: &SNode) -> Option<&str> {
1083 match &snode.node {
1084 Node::FnDecl { name, .. } | Node::Pipeline { name, .. } | Node::ToolDecl { name, .. } => {
1085 Some(name.as_str())
1086 }
1087 Node::AttributedDecl { inner, .. } => callable_decl_name(inner),
1088 _ => None,
1089 }
1090}
1091
1092fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
1093 DefSite {
1094 name: name.to_string(),
1095 file: file.to_path_buf(),
1096 kind,
1097 span,
1098 }
1099}
1100
1101fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
1102 match pattern {
1103 BindingPattern::Identifier(name) => vec![name.clone()],
1104 BindingPattern::Dict(fields) => fields
1105 .iter()
1106 .filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
1107 .collect(),
1108 BindingPattern::List(elements) => elements
1109 .iter()
1110 .map(|element| element.name.clone())
1111 .collect(),
1112 BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
1113 }
1114}
1115
1116fn normalize_path(path: &Path) -> PathBuf {
1117 if stdlib_module_from_path(path).is_some() {
1118 return path.to_path_buf();
1119 }
1120 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125 use super::*;
1126 use std::fs;
1127
1128 fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
1129 let path = dir.join(name);
1130 fs::write(&path, contents).unwrap();
1131 path
1132 }
1133
1134 #[test]
1135 fn importers_of_finds_direct_dependents() {
1136 let tmp = tempfile::tempdir().unwrap();
1137 let root = tmp.path();
1138 let leaf = write_file(root, "leaf.harn", "pub fn leaf() { 1 }\n");
1139 write_file(root, "a.harn", "import \"./leaf\"\nleaf()\n");
1140 write_file(root, "b.harn", "import { leaf } from \"./leaf\"\nleaf()\n");
1141 let entry = write_file(root, "entry.harn", "import \"./a\"\nimport \"./b\"\n");
1142
1143 let graph = build(std::slice::from_ref(&entry));
1144 let importers = graph.importers_of(&leaf);
1145 let names: Vec<String> = importers
1146 .iter()
1147 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1148 .collect();
1149 assert!(names.contains(&"a.harn".to_string()));
1150 assert!(names.contains(&"b.harn".to_string()));
1151 assert!(!names.contains(&"entry.harn".to_string()));
1152 }
1153
1154 #[test]
1155 fn recursive_build_loads_transitively_imported_modules() {
1156 let tmp = tempfile::tempdir().unwrap();
1157 let root = tmp.path();
1158 write_file(root, "leaf.harn", "pub fn leaf_fn() { 1 }\n");
1159 write_file(
1160 root,
1161 "mid.harn",
1162 "import \"./leaf\"\npub fn mid_fn() { leaf_fn() }\n",
1163 );
1164 let entry = write_file(root, "entry.harn", "import \"./mid\"\nmid_fn()\n");
1165
1166 let graph = build(std::slice::from_ref(&entry));
1167 let imported = graph
1168 .imported_names_for_file(&entry)
1169 .expect("entry imports should resolve");
1170 assert!(imported.contains("mid_fn"));
1172 assert!(!imported.contains("leaf_fn"));
1173
1174 let leaf_path = root.join("leaf.harn");
1177 assert!(graph.definition_of(&leaf_path, "leaf_fn").is_some());
1178 }
1179
1180 #[test]
1181 fn imported_names_returns_none_when_import_unresolved() {
1182 let tmp = tempfile::tempdir().unwrap();
1183 let root = tmp.path();
1184 let entry = write_file(root, "entry.harn", "import \"./does_not_exist\"\n");
1185
1186 let graph = build(std::slice::from_ref(&entry));
1187 assert!(graph.imported_names_for_file(&entry).is_none());
1188 }
1189
1190 #[test]
1191 fn selective_imports_contribute_only_requested_names() {
1192 let tmp = tempfile::tempdir().unwrap();
1193 let root = tmp.path();
1194 write_file(root, "util.harn", "pub fn a() { 1 }\npub fn b() { 2 }\n");
1195 let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
1196
1197 let graph = build(std::slice::from_ref(&entry));
1198 let imported = graph
1199 .imported_names_for_file(&entry)
1200 .expect("entry imports should resolve");
1201 assert!(imported.contains("a"));
1202 assert!(!imported.contains("b"));
1203 }
1204
1205 #[test]
1206 fn stdlib_imports_resolve_to_embedded_sources() {
1207 let tmp = tempfile::tempdir().unwrap();
1208 let root = tmp.path();
1209 let entry = write_file(root, "entry.harn", "import \"std/math\"\nclamp(5, 0, 10)\n");
1210
1211 let graph = build(std::slice::from_ref(&entry));
1212 let imported = graph
1213 .imported_names_for_file(&entry)
1214 .expect("std/math should resolve");
1215 assert!(imported.contains("clamp"));
1217 }
1218
1219 #[test]
1220 fn stdlib_internal_imports_resolve_without_leaking_to_callers() {
1221 let tmp = tempfile::tempdir().unwrap();
1222 let root = tmp.path();
1223 let entry = write_file(
1224 root,
1225 "entry.harn",
1226 "import { process_run } from \"std/runtime\"\nprocess_run([\"echo\", \"ok\"])\n",
1227 );
1228
1229 let graph = build(std::slice::from_ref(&entry));
1230 let entry_imports = graph
1231 .imported_names_for_file(&entry)
1232 .expect("std/runtime should resolve");
1233 assert!(entry_imports.contains("process_run"));
1234 assert!(
1235 !entry_imports.contains("filter_nil"),
1236 "private std/runtime dependency leaked to caller"
1237 );
1238
1239 let runtime_path = stdlib::stdlib_virtual_path("runtime");
1240 let runtime_imports = graph
1241 .imported_names_for_file(&runtime_path)
1242 .expect("std/runtime internal imports should resolve");
1243 assert!(runtime_imports.contains("filter_nil"));
1244 }
1245
1246 #[test]
1247 fn runtime_stdlib_import_surface_resolves_to_embedded_sources() {
1248 let tmp = tempfile::tempdir().unwrap();
1249 let entry_path = write_file(tmp.path(), "entry.harn", "");
1250
1251 for source in harn_stdlib::STDLIB_SOURCES {
1252 let import_path = format!("std/{}", source.module);
1253 assert!(
1254 resolve_import_path(&entry_path, &import_path).is_some(),
1255 "{import_path} should resolve in the module graph"
1256 );
1257 }
1258 }
1259
1260 #[test]
1261 fn stdlib_imports_expose_type_declarations() {
1262 let tmp = tempfile::tempdir().unwrap();
1263 let root = tmp.path();
1264 let entry = write_file(
1265 root,
1266 "entry.harn",
1267 "import \"std/triggers\"\nlet provider = \"github\"\n",
1268 );
1269
1270 let graph = build(std::slice::from_ref(&entry));
1271 let decls = graph
1272 .imported_type_declarations_for_file(&entry)
1273 .expect("std/triggers type declarations should resolve");
1274 let names: HashSet<String> = decls
1275 .iter()
1276 .filter_map(type_decl_name)
1277 .map(ToString::to_string)
1278 .collect();
1279 assert!(names.contains("TriggerEvent"));
1280 assert!(names.contains("ProviderPayload"));
1281 assert!(names.contains("SignatureStatus"));
1282 }
1283
1284 #[test]
1285 fn stdlib_imports_expose_callable_declarations() {
1286 let tmp = tempfile::tempdir().unwrap();
1287 let root = tmp.path();
1288 let entry = write_file(
1289 root,
1290 "entry.harn",
1291 "import { select_from } from \"std/tui\"\nlet item = \"alpha\"\n",
1292 );
1293
1294 let graph = build(std::slice::from_ref(&entry));
1295 let decls = graph
1296 .imported_callable_declarations_for_file(&entry)
1297 .expect("std/tui callable declarations should resolve");
1298 let names: HashSet<String> = decls
1299 .iter()
1300 .filter_map(callable_decl_name)
1301 .map(ToString::to_string)
1302 .collect();
1303 assert!(names.contains("select_from"));
1304 }
1305
1306 #[test]
1307 fn package_export_map_resolves_declared_module() {
1308 let tmp = tempfile::tempdir().unwrap();
1309 let root = tmp.path();
1310 let packages = root.join(".harn/packages/acme/runtime");
1311 fs::create_dir_all(&packages).unwrap();
1312 fs::write(
1313 root.join(".harn/packages/acme/harn.toml"),
1314 "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1315 )
1316 .unwrap();
1317 fs::write(
1318 packages.join("capabilities.harn"),
1319 "pub fn exported_capability() { 1 }\n",
1320 )
1321 .unwrap();
1322 let entry = write_file(
1323 root,
1324 "entry.harn",
1325 "import \"acme/capabilities\"\nexported_capability()\n",
1326 );
1327
1328 let graph = build(std::slice::from_ref(&entry));
1329 let imported = graph
1330 .imported_names_for_file(&entry)
1331 .expect("package export should resolve");
1332 assert!(imported.contains("exported_capability"));
1333 }
1334
1335 #[test]
1336 fn package_direct_import_cannot_escape_packages_root() {
1337 let tmp = tempfile::tempdir().unwrap();
1338 let root = tmp.path();
1339 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1340 fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1341 let entry = write_file(root, "entry.harn", "");
1342
1343 let resolved = resolve_import_path(&entry, "acme/../../secret");
1344 assert!(resolved.is_none(), "package import escaped package root");
1345 }
1346
1347 #[test]
1348 fn package_export_map_cannot_escape_package_root() {
1349 let tmp = tempfile::tempdir().unwrap();
1350 let root = tmp.path();
1351 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1352 fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1353 fs::write(
1354 root.join(".harn/packages/acme/harn.toml"),
1355 "[exports]\nleak = \"../../secret.harn\"\n",
1356 )
1357 .unwrap();
1358 let entry = write_file(root, "entry.harn", "");
1359
1360 let resolved = resolve_import_path(&entry, "acme/leak");
1361 assert!(resolved.is_none(), "package export escaped package root");
1362 }
1363
1364 #[test]
1365 fn package_export_map_allows_symlinked_path_dependencies() {
1366 let tmp = tempfile::tempdir().unwrap();
1367 let root = tmp.path();
1368 let source = root.join("source-package");
1369 fs::create_dir_all(source.join("runtime")).unwrap();
1370 fs::write(
1371 source.join("harn.toml"),
1372 "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1373 )
1374 .unwrap();
1375 fs::write(
1376 source.join("runtime/capabilities.harn"),
1377 "pub fn exported_capability() { 1 }\n",
1378 )
1379 .unwrap();
1380 fs::create_dir_all(root.join(".harn/packages")).unwrap();
1381 #[cfg(unix)]
1382 std::os::unix::fs::symlink(&source, root.join(".harn/packages/acme")).unwrap();
1383 #[cfg(windows)]
1384 std::os::windows::fs::symlink_dir(&source, root.join(".harn/packages/acme")).unwrap();
1385 let entry = write_file(root, "entry.harn", "");
1386
1387 let resolved = resolve_import_path(&entry, "acme/capabilities")
1388 .expect("symlinked package export should resolve");
1389 assert!(resolved.ends_with("runtime/capabilities.harn"));
1390 }
1391
1392 #[test]
1393 fn package_imports_resolve_from_nested_package_module() {
1394 let tmp = tempfile::tempdir().unwrap();
1395 let root = tmp.path();
1396 fs::create_dir_all(root.join(".git")).unwrap();
1397 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1398 fs::create_dir_all(root.join(".harn/packages/shared")).unwrap();
1399 fs::write(
1400 root.join(".harn/packages/shared/lib.harn"),
1401 "pub fn shared_helper() { 1 }\n",
1402 )
1403 .unwrap();
1404 fs::write(
1405 root.join(".harn/packages/acme/lib.harn"),
1406 "import \"shared\"\npub fn use_shared() { shared_helper() }\n",
1407 )
1408 .unwrap();
1409 let entry = write_file(root, "entry.harn", "import \"acme\"\nuse_shared()\n");
1410
1411 let graph = build(std::slice::from_ref(&entry));
1412 let imported = graph
1413 .imported_names_for_file(&entry)
1414 .expect("nested package import should resolve");
1415 assert!(imported.contains("use_shared"));
1416 let acme_path = root.join(".harn/packages/acme/lib.harn");
1417 let acme_imports = graph
1418 .imported_names_for_file(&acme_path)
1419 .expect("package module imports should resolve");
1420 assert!(acme_imports.contains("shared_helper"));
1421 }
1422
1423 #[test]
1424 fn unknown_stdlib_import_is_unresolved() {
1425 let tmp = tempfile::tempdir().unwrap();
1426 let root = tmp.path();
1427 let entry = write_file(root, "entry.harn", "import \"std/does_not_exist\"\n");
1428
1429 let graph = build(std::slice::from_ref(&entry));
1430 assert!(
1431 graph.imported_names_for_file(&entry).is_none(),
1432 "unknown std module should fail resolution and disable strict check"
1433 );
1434 }
1435
1436 #[test]
1437 fn import_cycles_do_not_loop_forever() {
1438 let tmp = tempfile::tempdir().unwrap();
1439 let root = tmp.path();
1440 write_file(root, "a.harn", "import \"./b\"\npub fn a_fn() { 1 }\n");
1441 write_file(root, "b.harn", "import \"./a\"\npub fn b_fn() { 1 }\n");
1442 let entry = root.join("a.harn");
1443
1444 let graph = build(std::slice::from_ref(&entry));
1446 let imported = graph
1447 .imported_names_for_file(&entry)
1448 .expect("cyclic imports still resolve to known exports");
1449 assert!(imported.contains("b_fn"));
1450 }
1451
1452 #[test]
1453 fn pub_import_selective_re_exports_named_symbols() {
1454 let tmp = tempfile::tempdir().unwrap();
1455 let root = tmp.path();
1456 write_file(
1457 root,
1458 "src.harn",
1459 "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1460 );
1461 write_file(root, "facade.harn", "pub import { alpha } from \"./src\"\n");
1462 let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1463
1464 let graph = build(std::slice::from_ref(&entry));
1465 let imported = graph
1466 .imported_names_for_file(&entry)
1467 .expect("entry should resolve");
1468 assert!(imported.contains("alpha"), "selective re-export missing");
1469 assert!(
1470 !imported.contains("beta"),
1471 "non-listed name leaked through facade"
1472 );
1473
1474 let facade_path = root.join("facade.harn");
1475 let def = graph
1476 .definition_of(&facade_path, "alpha")
1477 .expect("definition_of should chase re-export");
1478 assert!(def.file.ends_with("src.harn"));
1479 }
1480
1481 #[test]
1482 fn pub_import_wildcard_re_exports_full_surface() {
1483 let tmp = tempfile::tempdir().unwrap();
1484 let root = tmp.path();
1485 write_file(
1486 root,
1487 "src.harn",
1488 "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1489 );
1490 write_file(root, "facade.harn", "pub import \"./src\"\n");
1491 let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1492
1493 let graph = build(std::slice::from_ref(&entry));
1494 let imported = graph
1495 .imported_names_for_file(&entry)
1496 .expect("entry should resolve");
1497 assert!(imported.contains("alpha"));
1498 assert!(imported.contains("beta"));
1499 }
1500
1501 #[test]
1502 fn pub_import_chain_resolves_definition_to_origin() {
1503 let tmp = tempfile::tempdir().unwrap();
1504 let root = tmp.path();
1505 write_file(root, "inner.harn", "pub fn deep() { 1 }\n");
1506 write_file(
1507 root,
1508 "middle.harn",
1509 "pub import { deep } from \"./inner\"\n",
1510 );
1511 write_file(
1512 root,
1513 "outer.harn",
1514 "pub import { deep } from \"./middle\"\n",
1515 );
1516 let entry = write_file(
1517 root,
1518 "entry.harn",
1519 "import { deep } from \"./outer\"\ndeep()\n",
1520 );
1521
1522 let graph = build(std::slice::from_ref(&entry));
1523 let def = graph
1524 .definition_of(&entry, "deep")
1525 .expect("definition_of should follow chain");
1526 assert!(def.file.ends_with("inner.harn"));
1527
1528 let imported = graph
1529 .imported_names_for_file(&entry)
1530 .expect("entry should resolve");
1531 assert!(imported.contains("deep"));
1532 }
1533
1534 #[test]
1535 fn duplicate_pub_import_reports_re_export_conflict() {
1536 let tmp = tempfile::tempdir().unwrap();
1537 let root = tmp.path();
1538 write_file(root, "a.harn", "pub fn shared() { 1 }\n");
1539 write_file(root, "b.harn", "pub fn shared() { 2 }\n");
1540 let facade = write_file(
1541 root,
1542 "facade.harn",
1543 "pub import { shared } from \"./a\"\npub import { shared } from \"./b\"\n",
1544 );
1545
1546 let graph = build(std::slice::from_ref(&facade));
1547 let conflicts = graph.re_export_conflicts(&facade);
1548 assert_eq!(
1549 conflicts.len(),
1550 1,
1551 "expected exactly one re-export conflict, got {:?}",
1552 conflicts
1553 );
1554 assert_eq!(conflicts[0].name, "shared");
1555 assert_eq!(conflicts[0].sources.len(), 2);
1556 }
1557
1558 #[test]
1559 fn cross_directory_cycle_does_not_explode_module_count() {
1560 let tmp = tempfile::tempdir().unwrap();
1568 let root = tmp.path();
1569 let context = root.join("context");
1570 let runtime = root.join("runtime");
1571 fs::create_dir_all(&context).unwrap();
1572 fs::create_dir_all(&runtime).unwrap();
1573 write_file(
1574 &context,
1575 "a.harn",
1576 "import \"../runtime/b\"\npub fn a_fn() { 1 }\n",
1577 );
1578 write_file(
1579 &runtime,
1580 "b.harn",
1581 "import \"../context/a\"\npub fn b_fn() { 1 }\n",
1582 );
1583 let entry = context.join("a.harn");
1584
1585 let graph = build(std::slice::from_ref(&entry));
1586 assert_eq!(
1589 graph.modules.len(),
1590 2,
1591 "cross-directory cycle loaded {} modules, expected 2",
1592 graph.modules.len()
1593 );
1594 let imported = graph
1595 .imported_names_for_file(&entry)
1596 .expect("cyclic imports still resolve to known exports");
1597 assert!(imported.contains("b_fn"));
1598 }
1599}