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