1use std::collections::{HashMap, HashSet};
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 wave: Vec<PathBuf> = Vec::new();
169 for file in files {
170 let canonical = normalize_path(file);
171 if seen.insert(canonical.clone()) {
172 wave.push(canonical);
173 }
174 }
175 while !wave.is_empty() {
183 let loaded = load_wave(&wave);
184 let mut next_wave: Vec<PathBuf> = Vec::new();
185 for (path, (module, parsed)) in wave.drain(..).zip(loaded) {
186 let retain_parsed_source =
187 parsed_source_targets.is_some_and(|targets| targets.contains(&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 next_wave.push(canonical);
214 }
215 }
216 }
217 modules.insert(path, module);
218 }
219 wave = next_wave;
220 }
221 resolve_re_exports(&mut modules);
222 ModuleGraphBuild {
223 graph: ModuleGraph { modules },
224 parsed_sources,
225 }
226}
227
228pub const MODULE_GRAPH_JOBS_ENV: &str = "HARN_MODULE_GRAPH_JOBS";
231
232fn load_wave(paths: &[PathBuf]) -> Vec<(ModuleInfo, Option<ParsedModuleSource>)> {
235 const MIN_PARALLEL_WAVE: usize = 8;
236 let configured = std::env::var(MODULE_GRAPH_JOBS_ENV)
237 .ok()
238 .and_then(|value| value.parse::<usize>().ok())
239 .filter(|&jobs| jobs > 0);
240 let workers = configured
241 .unwrap_or_else(|| {
242 std::thread::available_parallelism()
243 .map(std::num::NonZeroUsize::get)
244 .unwrap_or(1)
245 })
246 .min(paths.len());
247 if workers <= 1 || paths.len() < MIN_PARALLEL_WAVE {
248 return paths.iter().map(|path| load_module(path)).collect();
249 }
250 let next = std::sync::atomic::AtomicUsize::new(0);
251 let mut produced: Vec<(usize, (ModuleInfo, Option<ParsedModuleSource>))> =
252 std::thread::scope(|scope| {
253 let handles: Vec<_> = (0..workers)
254 .map(|_| {
255 scope.spawn(|| {
256 let mut local = Vec::new();
257 loop {
258 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
259 let Some(path) = paths.get(index) else {
260 break;
261 };
262 local.push((index, load_module(path)));
263 }
264 local
265 })
266 })
267 .collect();
268 handles
269 .into_iter()
270 .flat_map(|handle| match handle.join() {
271 Ok(local) => local,
272 Err(panic) => std::panic::resume_unwind(panic),
273 })
274 .collect()
275 });
276 produced.sort_unstable_by_key(|(index, _)| *index);
277 produced.into_iter().map(|(_, loaded)| loaded).collect()
278}
279
280fn resolve_re_exports(modules: &mut HashMap<PathBuf, ModuleInfo>) {
285 let keys: Vec<PathBuf> = modules.keys().cloned().collect();
286 loop {
287 let mut changed = false;
288 for path in &keys {
289 let wildcard_paths = modules
292 .get(path)
293 .map(|m| m.wildcard_re_export_paths.clone())
294 .unwrap_or_default();
295 if wildcard_paths.is_empty() {
296 continue;
297 }
298 let mut additions: Vec<String> = Vec::new();
299 for src in &wildcard_paths {
300 let src_canonical = normalize_path(src);
301 if let Some(src_module) = modules.get(src).or_else(|| modules.get(&src_canonical)) {
302 additions.extend(src_module.exports.iter().cloned());
303 }
304 }
305 if let Some(module) = modules.get_mut(path) {
306 for name in additions {
307 if module.exports.insert(name) {
308 changed = true;
309 }
310 }
311 }
312 }
313 if !changed {
314 break;
315 }
316 }
317}
318
319pub fn resolve_import_path(current_file: &Path, import_path: &str) -> Option<PathBuf> {
332 if let Some(module) = import_path
333 .strip_prefix("std/")
334 .or_else(|| (import_path == "observability").then_some("observability"))
335 {
336 if stdlib::get_stdlib_source(module).is_some() {
337 return Some(stdlib::stdlib_virtual_path(module));
338 }
339 return None;
340 }
341
342 let base = current_file.parent().unwrap_or(Path::new("."));
343 let mut file_path = base.join(import_path);
344 if !file_path.exists() && file_path.extension().is_none() {
345 file_path.set_extension("harn");
346 }
347 if file_path.exists() {
348 return Some(file_path);
349 }
350
351 if let Some(path) = resolve_package_import(base, import_path) {
352 return Some(path);
353 }
354
355 None
356}
357
358fn resolve_package_import(base: &Path, import_path: &str) -> Option<PathBuf> {
359 for anchor in base.ancestors() {
360 let packages_root = anchor.join(".harn/packages");
361 if !packages_root.is_dir() {
362 if anchor.join(".git").exists() {
363 break;
364 }
365 continue;
366 }
367 if let Some(path) = resolve_from_packages_root(&packages_root, import_path) {
368 return Some(path);
369 }
370 if anchor.join(".git").exists() {
371 break;
372 }
373 }
374 None
375}
376
377fn resolve_from_packages_root(packages_root: &Path, import_path: &str) -> Option<PathBuf> {
378 let safe_import_path = safe_package_relative_path(import_path)?;
379 let package_name = package_name_from_relative_path(&safe_import_path)?;
380 let package_root = packages_root.join(package_name);
381
382 let pkg_path = packages_root.join(&safe_import_path);
383 if let Some(path) = finalize_package_target(&package_root, &pkg_path) {
384 return Some(path);
385 }
386
387 let export_name = export_name_from_relative_path(&safe_import_path)?;
388 let manifest_path = packages_root.join(package_name).join("harn.toml");
389 let manifest = read_package_manifest(&manifest_path)?;
390 let rel_path = manifest.exports.get(export_name)?;
391 let safe_export_path = safe_package_relative_path(rel_path)?;
392 finalize_package_target(&package_root, &package_root.join(safe_export_path))
393}
394
395fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
396 let content = std::fs::read_to_string(path).ok()?;
397 toml::from_str::<PackageManifest>(&content).ok()
398}
399
400fn safe_package_relative_path(raw: &str) -> Option<PathBuf> {
401 if raw.is_empty() || raw.contains('\\') {
402 return None;
403 }
404 let mut out = PathBuf::new();
405 let mut saw_component = false;
406 for component in Path::new(raw).components() {
407 match component {
408 Component::Normal(part) => {
409 saw_component = true;
410 out.push(part);
411 }
412 Component::CurDir => {}
413 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
414 }
415 }
416 saw_component.then_some(out)
417}
418
419fn package_name_from_relative_path(path: &Path) -> Option<&str> {
420 match path.components().next()? {
421 Component::Normal(name) => name.to_str(),
422 _ => None,
423 }
424}
425
426fn export_name_from_relative_path(path: &Path) -> Option<&str> {
427 let mut components = path.components();
428 components.next()?;
429 let rest = components.as_path();
430 if rest.as_os_str().is_empty() {
431 None
432 } else {
433 rest.to_str()
434 }
435}
436
437fn path_is_within(root: &Path, path: &Path) -> bool {
438 let Ok(root) = root.canonicalize() else {
439 return false;
440 };
441 let Ok(path) = path.canonicalize() else {
442 return false;
443 };
444 path == root || path.starts_with(root)
445}
446
447fn target_within_package_root(package_root: &Path, path: PathBuf) -> Option<PathBuf> {
448 path_is_within(package_root, &path).then_some(path)
449}
450
451fn finalize_package_target(package_root: &Path, path: &Path) -> Option<PathBuf> {
452 if path.is_dir() {
453 let lib = path.join("lib.harn");
454 if lib.exists() {
455 return target_within_package_root(package_root, lib);
456 }
457 return target_within_package_root(package_root, path.to_path_buf());
458 }
459 if path.exists() {
460 return target_within_package_root(package_root, path.to_path_buf());
461 }
462 if path.extension().is_none() {
463 let mut with_ext = path.to_path_buf();
464 with_ext.set_extension("harn");
465 if with_ext.exists() {
466 return target_within_package_root(package_root, with_ext);
467 }
468 }
469 None
470}
471
472impl ModuleGraph {
473 pub fn module_paths(&self) -> Vec<PathBuf> {
478 let mut paths: Vec<PathBuf> = self.modules.keys().cloned().collect();
479 paths.sort();
480 paths
481 }
482
483 pub fn contains_module(&self, path: &Path) -> bool {
486 self.modules.contains_key(path) || self.modules.contains_key(&normalize_path(path))
487 }
488
489 pub fn all_selective_import_names(&self) -> HashSet<&str> {
491 let mut names = HashSet::new();
492 for module in self.modules.values() {
493 for name in &module.selective_import_names {
494 names.insert(name.as_str());
495 }
496 }
497 names
498 }
499
500 pub fn importers_of(&self, target: &Path) -> Vec<PathBuf> {
503 let target = normalize_path(target);
504 let mut out: Vec<PathBuf> = self
505 .modules
506 .iter()
507 .filter(|(_, info)| {
508 info.imports.iter().any(|import| {
509 import
510 .path
511 .as_ref()
512 .is_some_and(|p| normalize_path(p) == target)
513 })
514 })
515 .map(|(path, _)| path.clone())
516 .collect();
517 out.sort();
518 out
519 }
520
521 pub fn imports_for_module(&self, file: &Path) -> Vec<ModuleImport> {
523 let file = normalize_path(file);
524 let Some(module) = self.modules.get(&file) else {
525 return Vec::new();
526 };
527 let mut imports: Vec<ModuleImport> = module
528 .imports
529 .iter()
530 .map(|import| {
531 let mut selective_names = import
532 .selective_names
533 .as_ref()
534 .map(|names| names.iter().cloned().collect::<Vec<_>>());
535 if let Some(names) = selective_names.as_mut() {
536 names.sort();
537 }
538 ModuleImport {
539 raw_path: import.raw_path.clone(),
540 resolved_path: import.path.as_ref().map(|path| normalize_path(path)),
541 selective_names,
542 }
543 })
544 .collect();
545 imports.sort_by(|left, right| {
546 left.raw_path
547 .cmp(&right.raw_path)
548 .then_with(|| left.selective_names.cmp(&right.selective_names))
549 .then_with(|| left.resolved_path.cmp(&right.resolved_path))
550 });
551 imports
552 }
553
554 pub fn exports_for_module(&self, file: &Path) -> Vec<String> {
556 let file = normalize_path(file);
557 let Some(module) = self.modules.get(&file) else {
558 return Vec::new();
559 };
560 let mut exports: Vec<String> = module.exports.iter().cloned().collect();
561 exports.sort();
562 exports
563 }
564
565 pub fn wildcard_exports_for(&self, file: &Path) -> WildcardResolution {
570 let file = normalize_path(file);
571 let Some(module) = self.modules.get(&file) else {
572 return WildcardResolution::Unknown;
573 };
574 if module.has_unresolved_wildcard_import {
575 return WildcardResolution::Unknown;
576 }
577
578 let mut names = HashSet::new();
579 for import in module
580 .imports
581 .iter()
582 .filter(|import| import.selective_names.is_none())
583 {
584 let Some(import_path) = &import.path else {
585 return WildcardResolution::Unknown;
586 };
587 let imported = self.modules.get(import_path).or_else(|| {
588 let normalized = normalize_path(import_path);
589 self.modules.get(&normalized)
590 });
591 let Some(imported) = imported else {
592 return WildcardResolution::Unknown;
593 };
594 names.extend(imported.exports.iter().cloned());
595 }
596 WildcardResolution::Resolved(names)
597 }
598
599 pub fn imported_names_for_file(&self, file: &Path) -> Option<HashSet<String>> {
615 let file = normalize_path(file);
616 let module = self.modules.get(&file)?;
617 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
618 return None;
619 }
620
621 let mut names = HashSet::new();
622 for import in &module.imports {
623 let import_path = import.path.as_ref()?;
624 let imported = self
625 .modules
626 .get(import_path)
627 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
628 match &import.selective_names {
629 None => {
630 names.extend(imported.exports.iter().cloned());
631 }
632 Some(selective) => {
633 for name in selective {
642 if imported.declarations.contains_key(name)
643 || imported.exports.contains(name)
644 {
645 names.insert(name.clone());
646 }
647 }
648 }
649 }
650 }
651 Some(names)
652 }
653
654 pub fn imported_type_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
658 let file = normalize_path(file);
659 let module = self.modules.get(&file)?;
660 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
661 return None;
662 }
663
664 let mut decls = Vec::new();
665 for import in &module.imports {
666 let import_path = import.path.as_ref()?;
667 let imported = self
668 .modules
669 .get(import_path)
670 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
671 let names_to_collect: Vec<String> = match &import.selective_names {
672 None => imported.exports.iter().cloned().collect(),
673 Some(selective) => selective.iter().cloned().collect(),
674 };
675 for name in &names_to_collect {
676 let mut visited = HashSet::new();
677 if let Some(decl) = self.find_exported_type_decl(import_path, name, &mut visited) {
678 decls.push(decl);
679 }
680 }
681 for ty_decl in &imported.type_declarations {
691 if type_decl_name(ty_decl).is_some() {
692 decls.push(ty_decl.clone());
693 }
694 }
695 }
696 Some(decls)
697 }
698
699 pub fn imported_callable_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
703 let file = normalize_path(file);
704 let module = self.modules.get(&file)?;
705 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
706 return None;
707 }
708
709 let mut decls = Vec::new();
710 for import in &module.imports {
711 let import_path = import.path.as_ref()?;
712 let imported = self
713 .modules
714 .get(import_path)
715 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
716 let selective_import = import.selective_names.is_some();
717 let names_to_collect: Vec<String> = match &import.selective_names {
718 None => imported.exports.iter().cloned().collect(),
719 Some(selective) => selective.iter().cloned().collect(),
720 };
721 for name in &names_to_collect {
722 if selective_import || imported.own_exports.contains(name) {
723 if let Some(decl) = imported
724 .callable_declarations
725 .iter()
726 .find(|decl| callable_decl_name(decl) == Some(name.as_str()))
727 {
728 decls.push(decl.clone());
729 continue;
730 }
731 }
732 let mut visited = HashSet::new();
733 if let Some(decl) =
734 self.find_exported_callable_decl(import_path, name, &mut visited)
735 {
736 decls.push(decl);
737 }
738 }
739 }
740 Some(decls)
741 }
742
743 fn find_exported_type_decl(
746 &self,
747 path: &Path,
748 name: &str,
749 visited: &mut HashSet<PathBuf>,
750 ) -> Option<SNode> {
751 let canonical = normalize_path(path);
752 if !visited.insert(canonical.clone()) {
753 return None;
754 }
755 let module = self
756 .modules
757 .get(&canonical)
758 .or_else(|| self.modules.get(path))?;
759 for decl in &module.type_declarations {
760 if type_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
761 return Some(decl.clone());
762 }
763 }
764 if let Some(sources) = module.selective_re_exports.get(name) {
765 for source in sources {
766 if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
767 return Some(decl);
768 }
769 }
770 }
771 for source in &module.wildcard_re_export_paths {
772 if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
773 return Some(decl);
774 }
775 }
776 None
777 }
778
779 fn find_exported_callable_decl(
780 &self,
781 path: &Path,
782 name: &str,
783 visited: &mut HashSet<PathBuf>,
784 ) -> Option<SNode> {
785 let canonical = normalize_path(path);
786 if !visited.insert(canonical.clone()) {
787 return None;
788 }
789 let module = self
790 .modules
791 .get(&canonical)
792 .or_else(|| self.modules.get(path))?;
793 for decl in &module.callable_declarations {
794 if callable_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
795 return Some(decl.clone());
796 }
797 }
798 if let Some(sources) = module.selective_re_exports.get(name) {
799 for source in sources {
800 if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
801 return Some(decl);
802 }
803 }
804 }
805 for source in &module.wildcard_re_export_paths {
806 if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
807 return Some(decl);
808 }
809 }
810 None
811 }
812
813 pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
819 let mut visited = HashSet::new();
820 self.definition_of_inner(file, name, &mut visited)
821 }
822
823 pub fn declared_names_for_file(&self, file: &Path) -> Option<Vec<&str>> {
827 let module = self.modules.get(&normalize_path(file))?;
828 let mut names: Vec<&str> = module.declarations.keys().map(String::as_str).collect();
829 names.sort_unstable();
830 Some(names)
831 }
832
833 fn definition_of_inner(
834 &self,
835 file: &Path,
836 name: &str,
837 visited: &mut HashSet<PathBuf>,
838 ) -> Option<DefSite> {
839 let file = normalize_path(file);
840 if !visited.insert(file.clone()) {
841 return None;
842 }
843 let current = self.modules.get(&file)?;
844
845 if let Some(local) = current.declarations.get(name) {
846 return Some(local.clone());
847 }
848
849 if let Some(sources) = current.selective_re_exports.get(name) {
854 for source in sources {
855 if let Some(def) = self.definition_of_inner(source, name, visited) {
856 return Some(def);
857 }
858 }
859 }
860
861 for source in ¤t.wildcard_re_export_paths {
863 if let Some(def) = self.definition_of_inner(source, name, visited) {
864 return Some(def);
865 }
866 }
867
868 for import in ¤t.imports {
870 let Some(selective_names) = &import.selective_names else {
871 continue;
872 };
873 if !selective_names.contains(name) {
874 continue;
875 }
876 if let Some(path) = &import.path {
877 if let Some(def) = self.definition_of_inner(path, name, visited) {
878 return Some(def);
879 }
880 }
881 }
882
883 for import in ¤t.imports {
885 if import.selective_names.is_some() {
886 continue;
887 }
888 if let Some(path) = &import.path {
889 if let Some(def) = self.definition_of_inner(path, name, visited) {
890 return Some(def);
891 }
892 }
893 }
894
895 None
896 }
897
898 pub fn re_export_conflicts(&self, file: &Path) -> Vec<ReExportConflict> {
902 let file = normalize_path(file);
903 let Some(module) = self.modules.get(&file) else {
904 return Vec::new();
905 };
906
907 let mut sources: HashMap<String, Vec<PathBuf>> = HashMap::new();
911
912 for (name, srcs) in &module.selective_re_exports {
913 sources
914 .entry(name.clone())
915 .or_default()
916 .extend(srcs.iter().cloned());
917 }
918 for src in &module.wildcard_re_export_paths {
919 let canonical = normalize_path(src);
920 let Some(src_module) = self
921 .modules
922 .get(&canonical)
923 .or_else(|| self.modules.get(src))
924 else {
925 continue;
926 };
927 for name in &src_module.exports {
928 sources
929 .entry(name.clone())
930 .or_default()
931 .push(canonical.clone());
932 }
933 }
934
935 for name in &module.own_exports {
939 if let Some(entry) = sources.get_mut(name) {
940 entry.push(file.clone());
941 }
942 }
943
944 let mut conflicts = Vec::new();
945 for (name, mut srcs) in sources {
946 srcs.sort();
947 srcs.dedup();
948 if srcs.len() > 1 {
949 conflicts.push(ReExportConflict {
950 name,
951 sources: srcs,
952 });
953 }
954 }
955 conflicts.sort_by(|a, b| a.name.cmp(&b.name));
956 conflicts
957 }
958
959 pub fn non_exported_selective_imports(&self, file: &Path) -> Vec<NonExportedImport> {
971 let file = normalize_path(file);
972 let Some(module) = self.modules.get(&file) else {
973 return Vec::new();
974 };
975
976 let mut out = Vec::new();
977 for import in &module.imports {
978 let Some(selective) = &import.selective_names else {
979 continue;
980 };
981 let Some(import_path) = &import.path else {
982 continue;
983 };
984 let Some(target) = self
985 .modules
986 .get(import_path)
987 .or_else(|| self.modules.get(&normalize_path(import_path)))
988 else {
989 continue;
990 };
991 for name in selective {
992 if target.declarations.contains_key(name) && !target.exports.contains(name) {
996 out.push(NonExportedImport {
997 name: name.clone(),
998 module: import.raw_path.clone(),
999 });
1000 }
1001 }
1002 }
1003 out.sort_by(|a, b| (&a.name, &a.module).cmp(&(&b.name, &b.module)));
1004 out.dedup();
1005 out
1006 }
1007}
1008
1009#[derive(Debug, Clone, PartialEq, Eq)]
1012pub struct ReExportConflict {
1013 pub name: String,
1014 pub sources: Vec<PathBuf>,
1015}
1016
1017#[derive(Debug, Clone, PartialEq, Eq)]
1020pub struct NonExportedImport {
1021 pub name: String,
1023 pub module: String,
1025}
1026
1027fn load_module(path: &Path) -> (ModuleInfo, Option<ParsedModuleSource>) {
1028 let Some(source) = read_module_source(path) else {
1029 return (ModuleInfo::default(), None);
1030 };
1031 let mut lexer = harn_lexer::Lexer::new(&source);
1032 let tokens = match lexer.tokenize() {
1033 Ok(tokens) => tokens,
1034 Err(_) => return (ModuleInfo::default(), None),
1035 };
1036 let mut parser = Parser::new(tokens);
1037 let program = match parser.parse() {
1038 Ok(program) => program,
1039 Err(_) => return (ModuleInfo::default(), None),
1040 };
1041
1042 let mut module = ModuleInfo::default();
1043 for node in &program {
1044 collect_module_info(path, node, &mut module);
1045 collect_type_declarations(node, &mut module.type_declarations);
1046 collect_callable_declarations(node, &mut module.callable_declarations);
1047 }
1048 module.exports.extend(module.own_exports.iter().cloned());
1052 module
1053 .exports
1054 .extend(module.selective_re_exports.keys().cloned());
1055 let parsed = ParsedModuleSource { source, program };
1056 (module, Some(parsed))
1057}
1058
1059fn stdlib_module_from_path(path: &Path) -> Option<&str> {
1062 let s = path.to_str()?;
1063 s.strip_prefix("<std>/")
1064}
1065
1066fn collect_module_info(file: &Path, snode: &SNode, module: &mut ModuleInfo) {
1067 match &snode.node {
1068 Node::FnDecl {
1069 name,
1070 params,
1071 is_pub,
1072 ..
1073 } => {
1074 if *is_pub {
1075 module.own_exports.insert(name.clone());
1076 }
1077 module.declarations.insert(
1078 name.clone(),
1079 decl_site(file, snode.span, name, DefKind::Function),
1080 );
1081 for param_name in params.iter().map(|param| param.name.clone()) {
1082 module.declarations.insert(
1083 param_name.clone(),
1084 decl_site(file, snode.span, ¶m_name, DefKind::Parameter),
1085 );
1086 }
1087 }
1088 Node::Pipeline { name, is_pub, .. } => {
1089 if *is_pub {
1090 module.own_exports.insert(name.clone());
1091 }
1092 module.declarations.insert(
1093 name.clone(),
1094 decl_site(file, snode.span, name, DefKind::Pipeline),
1095 );
1096 }
1097 Node::ToolDecl { name, is_pub, .. } => {
1098 if *is_pub {
1099 module.own_exports.insert(name.clone());
1100 }
1101 module.declarations.insert(
1102 name.clone(),
1103 decl_site(file, snode.span, name, DefKind::Tool),
1104 );
1105 }
1106 Node::SkillDecl { name, is_pub, .. } => {
1107 if *is_pub {
1108 module.own_exports.insert(name.clone());
1109 }
1110 module.declarations.insert(
1111 name.clone(),
1112 decl_site(file, snode.span, name, DefKind::Skill),
1113 );
1114 }
1115 Node::StructDecl { name, is_pub, .. } => {
1116 if *is_pub {
1117 module.own_exports.insert(name.clone());
1118 }
1119 module.declarations.insert(
1120 name.clone(),
1121 decl_site(file, snode.span, name, DefKind::Struct),
1122 );
1123 }
1124 Node::EnumDecl { name, is_pub, .. } => {
1125 if *is_pub {
1126 module.own_exports.insert(name.clone());
1127 }
1128 module.declarations.insert(
1129 name.clone(),
1130 decl_site(file, snode.span, name, DefKind::Enum),
1131 );
1132 }
1133 Node::InterfaceDecl { name, .. } => {
1134 module.own_exports.insert(name.clone());
1135 module.declarations.insert(
1136 name.clone(),
1137 decl_site(file, snode.span, name, DefKind::Interface),
1138 );
1139 }
1140 Node::TypeDecl { name, is_pub, .. } => {
1141 if *is_pub {
1142 module.own_exports.insert(name.clone());
1143 }
1144 module.declarations.insert(
1145 name.clone(),
1146 decl_site(file, snode.span, name, DefKind::Type),
1147 );
1148 }
1149 Node::LetBinding { pattern, .. } | Node::ConstBinding { pattern, .. } => {
1150 for name in pattern_names(pattern) {
1151 module.declarations.insert(
1152 name.clone(),
1153 decl_site(file, snode.span, &name, DefKind::Variable),
1154 );
1155 }
1156 }
1157 Node::ImportDecl { path, is_pub } => {
1158 let import_path = resolve_import_path(file, path);
1159 if import_path.is_none() {
1160 module.has_unresolved_wildcard_import = true;
1161 }
1162 if *is_pub {
1163 if let Some(resolved) = &import_path {
1164 module
1165 .wildcard_re_export_paths
1166 .push(normalize_path(resolved));
1167 }
1168 }
1169 module.imports.push(ImportRef {
1170 raw_path: path.clone(),
1171 path: import_path,
1172 selective_names: None,
1173 });
1174 }
1175 Node::SelectiveImport {
1176 names,
1177 path,
1178 is_pub,
1179 } => {
1180 let import_path = resolve_import_path(file, path);
1181 if import_path.is_none() {
1182 module.has_unresolved_selective_import = true;
1183 }
1184 if *is_pub {
1185 if let Some(resolved) = &import_path {
1186 let canonical = normalize_path(resolved);
1187 for name in names {
1188 module
1189 .selective_re_exports
1190 .entry(name.clone())
1191 .or_default()
1192 .push(canonical.clone());
1193 }
1194 }
1195 }
1196 let names: HashSet<String> = names.iter().cloned().collect();
1197 module.selective_import_names.extend(names.iter().cloned());
1198 module.imports.push(ImportRef {
1199 raw_path: path.clone(),
1200 path: import_path,
1201 selective_names: Some(names),
1202 });
1203 }
1204 Node::AttributedDecl { inner, .. } => {
1205 collect_module_info(file, inner, module);
1206 }
1207 _ => {}
1208 }
1209}
1210
1211fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1212 match &snode.node {
1213 Node::TypeDecl { .. }
1214 | Node::StructDecl { .. }
1215 | Node::EnumDecl { .. }
1216 | Node::InterfaceDecl { .. } => decls.push(snode.clone()),
1217 Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
1218 _ => {}
1219 }
1220}
1221
1222fn collect_callable_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1223 match &snode.node {
1224 Node::FnDecl { .. } | Node::Pipeline { .. } | Node::ToolDecl { .. } => {
1225 decls.push(snode.clone());
1226 }
1227 Node::AttributedDecl { inner, .. } => collect_callable_declarations(inner, decls),
1228 _ => {}
1229 }
1230}
1231
1232fn type_decl_name(snode: &SNode) -> Option<&str> {
1233 match &snode.node {
1234 Node::TypeDecl { name, .. }
1235 | Node::StructDecl { name, .. }
1236 | Node::EnumDecl { name, .. }
1237 | Node::InterfaceDecl { name, .. } => Some(name.as_str()),
1238 _ => None,
1239 }
1240}
1241
1242fn callable_decl_name(snode: &SNode) -> Option<&str> {
1243 match &snode.node {
1244 Node::FnDecl { name, .. } | Node::Pipeline { name, .. } | Node::ToolDecl { name, .. } => {
1245 Some(name.as_str())
1246 }
1247 Node::AttributedDecl { inner, .. } => callable_decl_name(inner),
1248 _ => None,
1249 }
1250}
1251
1252fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
1253 DefSite {
1254 name: name.to_string(),
1255 file: file.to_path_buf(),
1256 kind,
1257 span,
1258 }
1259}
1260
1261fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
1262 match pattern {
1263 BindingPattern::Identifier(name) => vec![name.clone()],
1264 BindingPattern::Dict(fields) => fields
1265 .iter()
1266 .filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
1267 .collect(),
1268 BindingPattern::List(elements) => elements
1269 .iter()
1270 .map(|element| element.name.clone())
1271 .collect(),
1272 BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
1273 }
1274}
1275
1276fn normalize_path(path: &Path) -> PathBuf {
1277 canonical_path(path)
1278}
1279
1280pub fn canonical_path(path: &Path) -> PathBuf {
1293 use std::sync::OnceLock;
1294 if stdlib_module_from_path(path).is_some() {
1295 return path.to_path_buf();
1296 }
1297 static MEMO: OnceLock<std::sync::Mutex<HashMap<PathBuf, PathBuf>>> = OnceLock::new();
1298 let memo = MEMO.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
1299 if let Some(hit) = memo
1300 .lock()
1301 .expect("canonical path memo lock poisoned")
1302 .get(path)
1303 .cloned()
1304 {
1305 return hit;
1306 }
1307 match path.canonicalize() {
1308 Ok(canonical) => {
1309 memo.lock()
1310 .expect("canonical path memo lock poisoned")
1311 .insert(path.to_path_buf(), canonical.clone());
1312 canonical
1313 }
1314 Err(_) => path.to_path_buf(),
1315 }
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320 use super::*;
1321 use std::fs;
1322
1323 fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
1324 let path = dir.join(name);
1325 fs::write(&path, contents).unwrap();
1326 path
1327 }
1328
1329 #[test]
1330 fn wave_parallel_build_matches_serial_semantics() {
1331 let tmp = tempfile::tempdir().unwrap();
1336 let root = tmp.path();
1337 write_file(root, "shared.harn", "pub fn shared_fn() { 1 }\n");
1338 let seeds: Vec<PathBuf> = (0..12)
1339 .map(|i| {
1340 write_file(
1341 root,
1342 &format!("mod{i}.harn"),
1343 &format!(
1344 "import {{ shared_fn }} from \"./shared\"\npub fn f{i}() {{ shared_fn() }}\n"
1345 ),
1346 )
1347 })
1348 .collect();
1349
1350 let graph = build(&seeds);
1351 for seed in &seeds {
1352 let names = graph
1353 .imported_names_for_file(seed)
1354 .expect("seed imports should resolve");
1355 assert!(names.contains("shared_fn"));
1356 }
1357 let importers = graph.importers_of(&root.join("shared.harn"));
1358 assert_eq!(importers.len(), seeds.len());
1359 }
1360
1361 #[test]
1362 fn importers_of_finds_direct_dependents() {
1363 let tmp = tempfile::tempdir().unwrap();
1364 let root = tmp.path();
1365 let leaf = write_file(root, "leaf.harn", "pub fn leaf() { 1 }\n");
1366 write_file(root, "a.harn", "import \"./leaf\"\nleaf()\n");
1367 write_file(root, "b.harn", "import { leaf } from \"./leaf\"\nleaf()\n");
1368 let entry = write_file(root, "entry.harn", "import \"./a\"\nimport \"./b\"\n");
1369
1370 let graph = build(std::slice::from_ref(&entry));
1371 let importers = graph.importers_of(&leaf);
1372 let names: Vec<String> = importers
1373 .iter()
1374 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1375 .collect();
1376 assert!(names.contains(&"a.harn".to_string()));
1377 assert!(names.contains(&"b.harn".to_string()));
1378 assert!(!names.contains(&"entry.harn".to_string()));
1379 }
1380
1381 #[test]
1382 fn recursive_build_loads_transitively_imported_modules() {
1383 let tmp = tempfile::tempdir().unwrap();
1384 let root = tmp.path();
1385 write_file(root, "leaf.harn", "pub fn leaf_fn() { 1 }\n");
1386 write_file(
1387 root,
1388 "mid.harn",
1389 "import \"./leaf\"\npub fn mid_fn() { leaf_fn() }\n",
1390 );
1391 let entry = write_file(root, "entry.harn", "import \"./mid\"\nmid_fn()\n");
1392
1393 let graph = build(std::slice::from_ref(&entry));
1394 let imported = graph
1395 .imported_names_for_file(&entry)
1396 .expect("entry imports should resolve");
1397 assert!(imported.contains("mid_fn"));
1399 assert!(!imported.contains("leaf_fn"));
1400
1401 let leaf_path = root.join("leaf.harn");
1404 assert!(graph.definition_of(&leaf_path, "leaf_fn").is_some());
1405 }
1406
1407 #[test]
1408 fn imported_names_returns_none_when_import_unresolved() {
1409 let tmp = tempfile::tempdir().unwrap();
1410 let root = tmp.path();
1411 let entry = write_file(root, "entry.harn", "import \"./does_not_exist\"\n");
1412
1413 let graph = build(std::slice::from_ref(&entry));
1414 assert!(graph.imported_names_for_file(&entry).is_none());
1415 }
1416
1417 #[test]
1418 fn selective_imports_contribute_only_requested_names() {
1419 let tmp = tempfile::tempdir().unwrap();
1420 let root = tmp.path();
1421 write_file(root, "util.harn", "pub fn a() { 1 }\npub fn b() { 2 }\n");
1422 let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
1423
1424 let graph = build(std::slice::from_ref(&entry));
1425 let imported = graph
1426 .imported_names_for_file(&entry)
1427 .expect("entry imports should resolve");
1428 assert!(imported.contains("a"));
1429 assert!(!imported.contains("b"));
1430 }
1431
1432 #[test]
1433 fn non_exported_selective_import_is_flagged_when_module_has_pub() {
1434 let tmp = tempfile::tempdir().unwrap();
1435 let root = tmp.path();
1436 write_file(root, "lib.harn", "pub fn api() { 1 }\nfn helper() { 2 }\n");
1437 let entry = write_file(root, "entry.harn", "import { helper } from \"./lib\"\n");
1438
1439 let graph = build(std::slice::from_ref(&entry));
1440 let offenders = graph.non_exported_selective_imports(&entry);
1441 assert_eq!(offenders.len(), 1);
1442 assert_eq!(offenders[0].name, "helper");
1443 assert_eq!(offenders[0].module, "./lib");
1444
1445 let entry_ok = write_file(root, "entry_ok.harn", "import { api } from \"./lib\"\n");
1447 let graph_ok = build(std::slice::from_ref(&entry_ok));
1448 assert!(graph_ok
1449 .non_exported_selective_imports(&entry_ok)
1450 .is_empty());
1451 }
1452
1453 #[test]
1454 fn selective_import_from_zero_pub_module_is_flagged() {
1455 let tmp = tempfile::tempdir().unwrap();
1456 let root = tmp.path();
1457 write_file(root, "util.harn", "fn a() { 1 }\nfn b() { 2 }\n");
1461 let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
1462
1463 let graph = build(std::slice::from_ref(&entry));
1464 let offenders = graph.non_exported_selective_imports(&entry);
1465 assert_eq!(offenders.len(), 1);
1466 assert_eq!(offenders[0].name, "a");
1467 assert_eq!(offenders[0].module, "./util");
1468 }
1469
1470 #[test]
1471 fn stdlib_imports_resolve_to_embedded_sources() {
1472 let tmp = tempfile::tempdir().unwrap();
1473 let root = tmp.path();
1474 let entry = write_file(root, "entry.harn", "import \"std/math\"\nclamp(5, 0, 10)\n");
1475
1476 let graph = build(std::slice::from_ref(&entry));
1477 let imported = graph
1478 .imported_names_for_file(&entry)
1479 .expect("std/math should resolve");
1480 assert!(imported.contains("clamp"));
1482 }
1483
1484 #[test]
1485 fn stdlib_internal_imports_resolve_without_leaking_to_callers() {
1486 let tmp = tempfile::tempdir().unwrap();
1487 let root = tmp.path();
1488 let entry = write_file(
1489 root,
1490 "entry.harn",
1491 "import { process_run } from \"std/runtime\"\nprocess_run([\"echo\", \"ok\"])\n",
1492 );
1493
1494 let graph = build(std::slice::from_ref(&entry));
1495 let entry_imports = graph
1496 .imported_names_for_file(&entry)
1497 .expect("std/runtime should resolve");
1498 assert!(entry_imports.contains("process_run"));
1499 assert!(
1500 !entry_imports.contains("filter_nil"),
1501 "private std/runtime dependency leaked to caller"
1502 );
1503
1504 let runtime_path = stdlib::stdlib_virtual_path("runtime");
1505 let runtime_imports = graph
1506 .imported_names_for_file(&runtime_path)
1507 .expect("std/runtime internal imports should resolve");
1508 assert!(runtime_imports.contains("filter_nil"));
1509 }
1510
1511 #[test]
1512 fn runtime_stdlib_import_surface_resolves_to_embedded_sources() {
1513 let tmp = tempfile::tempdir().unwrap();
1514 let entry_path = write_file(tmp.path(), "entry.harn", "");
1515
1516 for source in harn_stdlib::STDLIB_SOURCES {
1517 let import_path = format!("std/{}", source.module);
1518 assert!(
1519 resolve_import_path(&entry_path, &import_path).is_some(),
1520 "{import_path} should resolve in the module graph"
1521 );
1522 }
1523 }
1524
1525 #[test]
1526 fn stdlib_imports_expose_type_declarations() {
1527 let tmp = tempfile::tempdir().unwrap();
1528 let root = tmp.path();
1529 let entry = write_file(
1530 root,
1531 "entry.harn",
1532 "import \"std/triggers\"\nlet provider = \"github\"\n",
1533 );
1534
1535 let graph = build(std::slice::from_ref(&entry));
1536 let decls = graph
1537 .imported_type_declarations_for_file(&entry)
1538 .expect("std/triggers type declarations should resolve");
1539 let names: HashSet<String> = decls
1540 .iter()
1541 .filter_map(type_decl_name)
1542 .map(ToString::to_string)
1543 .collect();
1544 assert!(names.contains("TriggerEvent"));
1545 assert!(names.contains("ProviderPayload"));
1546 assert!(names.contains("SignatureStatus"));
1547 }
1548
1549 #[test]
1550 fn stdlib_imports_expose_callable_declarations() {
1551 let tmp = tempfile::tempdir().unwrap();
1552 let root = tmp.path();
1553 let entry = write_file(
1554 root,
1555 "entry.harn",
1556 "import { select_from } from \"std/tui\"\nlet item = \"alpha\"\n",
1557 );
1558
1559 let graph = build(std::slice::from_ref(&entry));
1560 let decls = graph
1561 .imported_callable_declarations_for_file(&entry)
1562 .expect("std/tui callable declarations should resolve");
1563 let names: HashSet<String> = decls
1564 .iter()
1565 .filter_map(callable_decl_name)
1566 .map(ToString::to_string)
1567 .collect();
1568 assert!(names.contains("select_from"));
1569 }
1570
1571 #[test]
1572 fn stdlib_llm_catalog_exposes_routing_routes() {
1573 let tmp = tempfile::tempdir().unwrap();
1574 let root = tmp.path();
1575 let entry = write_file(
1576 root,
1577 "entry.harn",
1578 "import { routing_routes } from \"std/llm/catalog\"\nrouting_routes()\n",
1579 );
1580
1581 let graph = build(std::slice::from_ref(&entry));
1582 let imported = graph
1583 .imported_names_for_file(&entry)
1584 .expect("std/llm/catalog should resolve");
1585 assert!(imported.contains("routing_routes"));
1586 let decls = graph
1587 .imported_callable_declarations_for_file(&entry)
1588 .expect("std/llm/catalog callable declarations should resolve");
1589 let names: HashSet<String> = decls
1590 .iter()
1591 .filter_map(callable_decl_name)
1592 .map(ToString::to_string)
1593 .collect();
1594 assert!(names.contains("routing_routes"));
1595 }
1596
1597 #[test]
1598 fn package_export_map_resolves_declared_module() {
1599 let tmp = tempfile::tempdir().unwrap();
1600 let root = tmp.path();
1601 let packages = root.join(".harn/packages/acme/runtime");
1602 fs::create_dir_all(&packages).unwrap();
1603 fs::write(
1604 root.join(".harn/packages/acme/harn.toml"),
1605 "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1606 )
1607 .unwrap();
1608 fs::write(
1609 packages.join("capabilities.harn"),
1610 "pub fn exported_capability() { 1 }\n",
1611 )
1612 .unwrap();
1613 let entry = write_file(
1614 root,
1615 "entry.harn",
1616 "import \"acme/capabilities\"\nexported_capability()\n",
1617 );
1618
1619 let graph = build(std::slice::from_ref(&entry));
1620 let imported = graph
1621 .imported_names_for_file(&entry)
1622 .expect("package export should resolve");
1623 assert!(imported.contains("exported_capability"));
1624 }
1625
1626 #[test]
1627 fn package_direct_import_cannot_escape_packages_root() {
1628 let tmp = tempfile::tempdir().unwrap();
1629 let root = tmp.path();
1630 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1631 fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1632 let entry = write_file(root, "entry.harn", "");
1633
1634 let resolved = resolve_import_path(&entry, "acme/../../secret");
1635 assert!(resolved.is_none(), "package import escaped package root");
1636 }
1637
1638 #[test]
1639 fn package_export_map_cannot_escape_package_root() {
1640 let tmp = tempfile::tempdir().unwrap();
1641 let root = tmp.path();
1642 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1643 fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1644 fs::write(
1645 root.join(".harn/packages/acme/harn.toml"),
1646 "[exports]\nleak = \"../../secret.harn\"\n",
1647 )
1648 .unwrap();
1649 let entry = write_file(root, "entry.harn", "");
1650
1651 let resolved = resolve_import_path(&entry, "acme/leak");
1652 assert!(resolved.is_none(), "package export escaped package root");
1653 }
1654
1655 #[test]
1656 fn package_export_map_allows_symlinked_path_dependencies() {
1657 let tmp = tempfile::tempdir().unwrap();
1658 let root = tmp.path();
1659 let source = root.join("source-package");
1660 fs::create_dir_all(source.join("runtime")).unwrap();
1661 fs::write(
1662 source.join("harn.toml"),
1663 "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1664 )
1665 .unwrap();
1666 fs::write(
1667 source.join("runtime/capabilities.harn"),
1668 "pub fn exported_capability() { 1 }\n",
1669 )
1670 .unwrap();
1671 fs::create_dir_all(root.join(".harn/packages")).unwrap();
1672 #[cfg(unix)]
1673 std::os::unix::fs::symlink(&source, root.join(".harn/packages/acme")).unwrap();
1674 #[cfg(windows)]
1675 std::os::windows::fs::symlink_dir(&source, root.join(".harn/packages/acme")).unwrap();
1676 let entry = write_file(root, "entry.harn", "");
1677
1678 let resolved = resolve_import_path(&entry, "acme/capabilities")
1679 .expect("symlinked package export should resolve");
1680 assert!(resolved.ends_with("runtime/capabilities.harn"));
1681 }
1682
1683 #[test]
1684 fn package_imports_resolve_from_nested_package_module() {
1685 let tmp = tempfile::tempdir().unwrap();
1686 let root = tmp.path();
1687 fs::create_dir_all(root.join(".git")).unwrap();
1688 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1689 fs::create_dir_all(root.join(".harn/packages/shared")).unwrap();
1690 fs::write(
1691 root.join(".harn/packages/shared/lib.harn"),
1692 "pub fn shared_helper() { 1 }\n",
1693 )
1694 .unwrap();
1695 fs::write(
1696 root.join(".harn/packages/acme/lib.harn"),
1697 "import \"shared\"\npub fn use_shared() { shared_helper() }\n",
1698 )
1699 .unwrap();
1700 let entry = write_file(root, "entry.harn", "import \"acme\"\nuse_shared()\n");
1701
1702 let graph = build(std::slice::from_ref(&entry));
1703 let imported = graph
1704 .imported_names_for_file(&entry)
1705 .expect("nested package import should resolve");
1706 assert!(imported.contains("use_shared"));
1707 let acme_path = root.join(".harn/packages/acme/lib.harn");
1708 let acme_imports = graph
1709 .imported_names_for_file(&acme_path)
1710 .expect("package module imports should resolve");
1711 assert!(acme_imports.contains("shared_helper"));
1712 }
1713
1714 #[test]
1715 fn unknown_stdlib_import_is_unresolved() {
1716 let tmp = tempfile::tempdir().unwrap();
1717 let root = tmp.path();
1718 let entry = write_file(root, "entry.harn", "import \"std/does_not_exist\"\n");
1719
1720 let graph = build(std::slice::from_ref(&entry));
1721 assert!(
1722 graph.imported_names_for_file(&entry).is_none(),
1723 "unknown std module should fail resolution and disable strict check"
1724 );
1725 }
1726
1727 #[test]
1728 fn import_cycles_do_not_loop_forever() {
1729 let tmp = tempfile::tempdir().unwrap();
1730 let root = tmp.path();
1731 write_file(root, "a.harn", "import \"./b\"\npub fn a_fn() { 1 }\n");
1732 write_file(root, "b.harn", "import \"./a\"\npub fn b_fn() { 1 }\n");
1733 let entry = root.join("a.harn");
1734
1735 let graph = build(std::slice::from_ref(&entry));
1737 let imported = graph
1738 .imported_names_for_file(&entry)
1739 .expect("cyclic imports still resolve to known exports");
1740 assert!(imported.contains("b_fn"));
1741 }
1742
1743 #[test]
1744 fn pub_import_selective_re_exports_named_symbols() {
1745 let tmp = tempfile::tempdir().unwrap();
1746 let root = tmp.path();
1747 write_file(
1748 root,
1749 "src.harn",
1750 "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1751 );
1752 write_file(root, "facade.harn", "pub import { alpha } from \"./src\"\n");
1753 let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1754
1755 let graph = build(std::slice::from_ref(&entry));
1756 let imported = graph
1757 .imported_names_for_file(&entry)
1758 .expect("entry should resolve");
1759 assert!(imported.contains("alpha"), "selective re-export missing");
1760 assert!(
1761 !imported.contains("beta"),
1762 "non-listed name leaked through facade"
1763 );
1764
1765 let facade_path = root.join("facade.harn");
1766 let def = graph
1767 .definition_of(&facade_path, "alpha")
1768 .expect("definition_of should chase re-export");
1769 assert!(def.file.ends_with("src.harn"));
1770 }
1771
1772 #[test]
1773 fn pub_import_wildcard_re_exports_full_surface() {
1774 let tmp = tempfile::tempdir().unwrap();
1775 let root = tmp.path();
1776 write_file(
1777 root,
1778 "src.harn",
1779 "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1780 );
1781 write_file(root, "facade.harn", "pub import \"./src\"\n");
1782 let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1783
1784 let graph = build(std::slice::from_ref(&entry));
1785 let imported = graph
1786 .imported_names_for_file(&entry)
1787 .expect("entry should resolve");
1788 assert!(imported.contains("alpha"));
1789 assert!(imported.contains("beta"));
1790 }
1791
1792 #[test]
1793 fn pub_import_chain_resolves_definition_to_origin() {
1794 let tmp = tempfile::tempdir().unwrap();
1795 let root = tmp.path();
1796 write_file(root, "inner.harn", "pub fn deep() { 1 }\n");
1797 write_file(
1798 root,
1799 "middle.harn",
1800 "pub import { deep } from \"./inner\"\n",
1801 );
1802 write_file(
1803 root,
1804 "outer.harn",
1805 "pub import { deep } from \"./middle\"\n",
1806 );
1807 let entry = write_file(
1808 root,
1809 "entry.harn",
1810 "import { deep } from \"./outer\"\ndeep()\n",
1811 );
1812
1813 let graph = build(std::slice::from_ref(&entry));
1814 let def = graph
1815 .definition_of(&entry, "deep")
1816 .expect("definition_of should follow chain");
1817 assert!(def.file.ends_with("inner.harn"));
1818
1819 let imported = graph
1820 .imported_names_for_file(&entry)
1821 .expect("entry should resolve");
1822 assert!(imported.contains("deep"));
1823 }
1824
1825 #[test]
1826 fn duplicate_pub_import_reports_re_export_conflict() {
1827 let tmp = tempfile::tempdir().unwrap();
1828 let root = tmp.path();
1829 write_file(root, "a.harn", "pub fn shared() { 1 }\n");
1830 write_file(root, "b.harn", "pub fn shared() { 2 }\n");
1831 let facade = write_file(
1832 root,
1833 "facade.harn",
1834 "pub import { shared } from \"./a\"\npub import { shared } from \"./b\"\n",
1835 );
1836
1837 let graph = build(std::slice::from_ref(&facade));
1838 let conflicts = graph.re_export_conflicts(&facade);
1839 assert_eq!(
1840 conflicts.len(),
1841 1,
1842 "expected exactly one re-export conflict, got {conflicts:?}"
1843 );
1844 assert_eq!(conflicts[0].name, "shared");
1845 assert_eq!(conflicts[0].sources.len(), 2);
1846 }
1847
1848 #[test]
1849 fn cross_directory_cycle_does_not_explode_module_count() {
1850 let tmp = tempfile::tempdir().unwrap();
1858 let root = tmp.path();
1859 let context = root.join("context");
1860 let runtime = root.join("runtime");
1861 fs::create_dir_all(&context).unwrap();
1862 fs::create_dir_all(&runtime).unwrap();
1863 write_file(
1864 &context,
1865 "a.harn",
1866 "import \"../runtime/b\"\npub fn a_fn() { 1 }\n",
1867 );
1868 write_file(
1869 &runtime,
1870 "b.harn",
1871 "import \"../context/a\"\npub fn b_fn() { 1 }\n",
1872 );
1873 let entry = context.join("a.harn");
1874
1875 let graph = build(std::slice::from_ref(&entry));
1876 assert_eq!(
1879 graph.modules.len(),
1880 2,
1881 "cross-directory cycle loaded {} modules, expected 2",
1882 graph.modules.len()
1883 );
1884 let imported = graph
1885 .imported_names_for_file(&entry)
1886 .expect("cyclic imports still resolve to known exports");
1887 assert!(imported.contains("b_fn"));
1888 }
1889}