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