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