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