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