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