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 load_error: Option<ModuleLoadError>,
110}
111
112#[derive(Debug, Clone)]
118pub struct ModuleLoadError {
119 pub message: String,
121 pub span: Span,
123}
124
125#[derive(Debug, Clone)]
128pub struct ImportCompileFailure {
129 pub import_raw_path: String,
131 pub import_span: Span,
133 pub module_path: PathBuf,
135 pub error: ModuleLoadError,
137}
138
139#[derive(Debug, Clone)]
140struct ImportRef {
141 raw_path: String,
142 path: Option<PathBuf>,
143 selective_names: Option<HashSet<String>>,
144 import_span: Span,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct ModuleImport {
150 pub raw_path: String,
152 pub resolved_path: Option<PathBuf>,
154 pub selective_names: Option<Vec<String>>,
156}
157
158#[derive(Debug, Default, Deserialize)]
159struct PackageManifest {
160 #[serde(default)]
161 exports: HashMap<String, String>,
162}
163
164pub fn read_module_source(path: &Path) -> Option<String> {
170 if let Some(stdlib_module) = stdlib_module_from_path(path) {
171 return stdlib::get_stdlib_source(stdlib_module).map(ToString::to_string);
172 }
173 std::fs::read_to_string(path).ok()
174}
175
176pub fn build(files: &[PathBuf]) -> ModuleGraph {
182 build_inner(files, None).graph
183}
184
185pub fn build_with_parsed_sources(files: &[PathBuf]) -> ModuleGraphBuild {
191 let parsed_source_targets = files.iter().map(|file| normalize_path(file)).collect();
192 build_inner(files, Some(&parsed_source_targets))
193}
194
195fn build_inner(
196 files: &[PathBuf],
197 parsed_source_targets: Option<&HashSet<PathBuf>>,
198) -> ModuleGraphBuild {
199 let mut modules: HashMap<PathBuf, ModuleInfo> = HashMap::new();
200 let mut parsed_sources: HashMap<PathBuf, ParsedModuleSource> = HashMap::new();
201 let mut seen: HashSet<PathBuf> = HashSet::new();
202 let mut wave: Vec<PathBuf> = Vec::new();
203 for file in files {
204 let canonical = normalize_path(file);
205 if seen.insert(canonical.clone()) {
206 wave.push(canonical);
207 }
208 }
209 while !wave.is_empty() {
217 let loaded = load_wave(&wave);
218 let mut next_wave: Vec<PathBuf> = Vec::new();
219 for (path, (module, parsed)) in wave.drain(..).zip(loaded) {
220 let retain_parsed_source =
221 parsed_source_targets.is_some_and(|targets| targets.contains(&path));
222 if retain_parsed_source {
223 if let Some(parsed) = parsed {
224 parsed_sources.insert(path.clone(), parsed);
225 }
226 }
227 for import in &module.imports {
244 if let Some(import_path) = &import.path {
245 let canonical = normalize_path(import_path);
246 if seen.insert(canonical.clone()) {
247 next_wave.push(canonical);
248 }
249 }
250 }
251 modules.insert(path, module);
252 }
253 wave = next_wave;
254 }
255 resolve_re_exports(&mut modules);
256 ModuleGraphBuild {
257 graph: ModuleGraph { modules },
258 parsed_sources,
259 }
260}
261
262pub const MODULE_GRAPH_JOBS_ENV: &str = "HARN_MODULE_GRAPH_JOBS";
265
266fn load_wave(paths: &[PathBuf]) -> Vec<(ModuleInfo, Option<ParsedModuleSource>)> {
269 const MIN_PARALLEL_WAVE: usize = 8;
270 let configured = std::env::var(MODULE_GRAPH_JOBS_ENV)
271 .ok()
272 .and_then(|value| value.parse::<usize>().ok())
273 .filter(|&jobs| jobs > 0);
274 let workers = configured
275 .unwrap_or_else(|| {
276 std::thread::available_parallelism()
277 .map(std::num::NonZeroUsize::get)
278 .unwrap_or(1)
279 })
280 .min(paths.len());
281 if workers <= 1 || paths.len() < MIN_PARALLEL_WAVE {
282 return paths.iter().map(|path| load_module(path)).collect();
283 }
284 let next = std::sync::atomic::AtomicUsize::new(0);
285 let mut produced: Vec<(usize, (ModuleInfo, Option<ParsedModuleSource>))> =
286 std::thread::scope(|scope| {
287 let handles: Vec<_> = (0..workers)
288 .map(|_| {
289 scope.spawn(|| {
290 let mut local = Vec::new();
291 loop {
292 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
293 let Some(path) = paths.get(index) else {
294 break;
295 };
296 local.push((index, load_module(path)));
297 }
298 local
299 })
300 })
301 .collect();
302 handles
303 .into_iter()
304 .flat_map(|handle| match handle.join() {
305 Ok(local) => local,
306 Err(panic) => std::panic::resume_unwind(panic),
307 })
308 .collect()
309 });
310 produced.sort_unstable_by_key(|(index, _)| *index);
311 produced.into_iter().map(|(_, loaded)| loaded).collect()
312}
313
314fn resolve_re_exports(modules: &mut HashMap<PathBuf, ModuleInfo>) {
319 let keys: Vec<PathBuf> = modules.keys().cloned().collect();
320 loop {
321 let mut changed = false;
322 for path in &keys {
323 let wildcard_paths = modules
326 .get(path)
327 .map(|m| m.wildcard_re_export_paths.clone())
328 .unwrap_or_default();
329 if wildcard_paths.is_empty() {
330 continue;
331 }
332 let mut additions: Vec<String> = Vec::new();
333 for src in &wildcard_paths {
334 let src_canonical = normalize_path(src);
335 if let Some(src_module) = modules.get(src).or_else(|| modules.get(&src_canonical)) {
336 additions.extend(src_module.exports.iter().cloned());
337 }
338 }
339 if let Some(module) = modules.get_mut(path) {
340 for name in additions {
341 if module.exports.insert(name) {
342 changed = true;
343 }
344 }
345 }
346 }
347 if !changed {
348 break;
349 }
350 }
351}
352
353pub fn resolve_import_path(current_file: &Path, import_path: &str) -> Option<PathBuf> {
366 if let Some(module) = import_path
367 .strip_prefix("std/")
368 .or_else(|| (import_path == "observability").then_some("observability"))
369 {
370 if stdlib::get_stdlib_source(module).is_some() {
371 return Some(stdlib::stdlib_virtual_path(module));
372 }
373 return None;
374 }
375
376 let base = current_file.parent().unwrap_or(Path::new("."));
377 let mut file_path = base.join(import_path);
378 if !file_path.exists() && file_path.extension().is_none() {
379 file_path.set_extension("harn");
380 }
381 if file_path.exists() {
382 return Some(file_path);
383 }
384
385 if let Some(path) = resolve_package_import(base, import_path) {
386 return Some(path);
387 }
388
389 None
390}
391
392fn resolve_package_import(base: &Path, import_path: &str) -> Option<PathBuf> {
393 for anchor in base.ancestors() {
394 let packages_root = anchor.join(".harn/packages");
395 if !packages_root.is_dir() {
396 if anchor.join(".git").exists() {
397 break;
398 }
399 continue;
400 }
401 if let Some(path) = resolve_from_packages_root(&packages_root, import_path) {
402 return Some(path);
403 }
404 if anchor.join(".git").exists() {
405 break;
406 }
407 }
408 None
409}
410
411fn resolve_from_packages_root(packages_root: &Path, import_path: &str) -> Option<PathBuf> {
412 let safe_import_path = safe_package_relative_path(import_path)?;
413 let package_name = package_name_from_relative_path(&safe_import_path)?;
414 let package_root = packages_root.join(package_name);
415
416 let pkg_path = packages_root.join(&safe_import_path);
417 if let Some(path) = finalize_package_target(&package_root, &pkg_path) {
418 return Some(path);
419 }
420
421 let export_name = export_name_from_relative_path(&safe_import_path)?;
422 let manifest_path = packages_root.join(package_name).join("harn.toml");
423 let manifest = read_package_manifest(&manifest_path)?;
424 let rel_path = manifest.exports.get(export_name)?;
425 let safe_export_path = safe_package_relative_path(rel_path)?;
426 finalize_package_target(&package_root, &package_root.join(safe_export_path))
427}
428
429fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
430 let content = std::fs::read_to_string(path).ok()?;
431 toml::from_str::<PackageManifest>(&content).ok()
432}
433
434fn safe_package_relative_path(raw: &str) -> Option<PathBuf> {
435 if raw.is_empty() || raw.contains('\\') {
436 return None;
437 }
438 let mut out = PathBuf::new();
439 let mut saw_component = false;
440 for component in Path::new(raw).components() {
441 match component {
442 Component::Normal(part) => {
443 saw_component = true;
444 out.push(part);
445 }
446 Component::CurDir => {}
447 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
448 }
449 }
450 saw_component.then_some(out)
451}
452
453fn package_name_from_relative_path(path: &Path) -> Option<&str> {
454 match path.components().next()? {
455 Component::Normal(name) => name.to_str(),
456 _ => None,
457 }
458}
459
460fn export_name_from_relative_path(path: &Path) -> Option<&str> {
461 let mut components = path.components();
462 components.next()?;
463 let rest = components.as_path();
464 if rest.as_os_str().is_empty() {
465 None
466 } else {
467 rest.to_str()
468 }
469}
470
471fn path_is_within(root: &Path, path: &Path) -> bool {
472 let Ok(root) = root.canonicalize() else {
473 return false;
474 };
475 let Ok(path) = path.canonicalize() else {
476 return false;
477 };
478 path == root || path.starts_with(root)
479}
480
481fn target_within_package_root(package_root: &Path, path: PathBuf) -> Option<PathBuf> {
482 path_is_within(package_root, &path).then_some(path)
483}
484
485fn finalize_package_target(package_root: &Path, path: &Path) -> Option<PathBuf> {
486 if path.is_dir() {
487 let lib = path.join("lib.harn");
488 if lib.exists() {
489 return target_within_package_root(package_root, lib);
490 }
491 return target_within_package_root(package_root, path.to_path_buf());
492 }
493 if path.exists() {
494 return target_within_package_root(package_root, path.to_path_buf());
495 }
496 if path.extension().is_none() {
497 let mut with_ext = path.to_path_buf();
498 with_ext.set_extension("harn");
499 if with_ext.exists() {
500 return target_within_package_root(package_root, with_ext);
501 }
502 }
503 None
504}
505
506impl ModuleGraph {
507 pub fn module_paths(&self) -> Vec<PathBuf> {
512 let mut paths: Vec<PathBuf> = self.modules.keys().cloned().collect();
513 paths.sort();
514 paths
515 }
516
517 pub fn contains_module(&self, path: &Path) -> bool {
520 self.modules.contains_key(path) || self.modules.contains_key(&normalize_path(path))
521 }
522
523 pub fn all_selective_import_names(&self) -> HashSet<&str> {
525 let mut names = HashSet::new();
526 for module in self.modules.values() {
527 for name in &module.selective_import_names {
528 names.insert(name.as_str());
529 }
530 }
531 names
532 }
533
534 pub fn importers_of(&self, target: &Path) -> Vec<PathBuf> {
537 let target = normalize_path(target);
538 let mut out: Vec<PathBuf> = self
539 .modules
540 .iter()
541 .filter(|(_, info)| {
542 info.imports.iter().any(|import| {
543 import
544 .path
545 .as_ref()
546 .is_some_and(|p| normalize_path(p) == target)
547 })
548 })
549 .map(|(path, _)| path.clone())
550 .collect();
551 out.sort();
552 out
553 }
554
555 pub fn imports_for_module(&self, file: &Path) -> Vec<ModuleImport> {
557 let file = normalize_path(file);
558 let Some(module) = self.modules.get(&file) else {
559 return Vec::new();
560 };
561 let mut imports: Vec<ModuleImport> = module
562 .imports
563 .iter()
564 .map(|import| {
565 let mut selective_names = import
566 .selective_names
567 .as_ref()
568 .map(|names| names.iter().cloned().collect::<Vec<_>>());
569 if let Some(names) = selective_names.as_mut() {
570 names.sort();
571 }
572 ModuleImport {
573 raw_path: import.raw_path.clone(),
574 resolved_path: import.path.as_ref().map(|path| normalize_path(path)),
575 selective_names,
576 }
577 })
578 .collect();
579 imports.sort_by(|left, right| {
580 left.raw_path
581 .cmp(&right.raw_path)
582 .then_with(|| left.selective_names.cmp(&right.selective_names))
583 .then_with(|| left.resolved_path.cmp(&right.resolved_path))
584 });
585 imports
586 }
587
588 pub fn exports_for_module(&self, file: &Path) -> Vec<String> {
590 let file = normalize_path(file);
591 let Some(module) = self.modules.get(&file) else {
592 return Vec::new();
593 };
594 let mut exports: Vec<String> = module.exports.iter().cloned().collect();
595 exports.sort();
596 exports
597 }
598
599 pub fn wildcard_exports_for(&self, file: &Path) -> WildcardResolution {
604 let file = normalize_path(file);
605 let Some(module) = self.modules.get(&file) else {
606 return WildcardResolution::Unknown;
607 };
608 if module.has_unresolved_wildcard_import {
609 return WildcardResolution::Unknown;
610 }
611
612 let mut names = HashSet::new();
613 for import in module
614 .imports
615 .iter()
616 .filter(|import| import.selective_names.is_none())
617 {
618 let Some(import_path) = &import.path else {
619 return WildcardResolution::Unknown;
620 };
621 let imported = self.modules.get(import_path).or_else(|| {
622 let normalized = normalize_path(import_path);
623 self.modules.get(&normalized)
624 });
625 let Some(imported) = imported else {
626 return WildcardResolution::Unknown;
627 };
628 names.extend(imported.exports.iter().cloned());
629 }
630 WildcardResolution::Resolved(names)
631 }
632
633 #[must_use]
654 pub fn import_compile_failures(&self, file: &Path) -> Vec<ImportCompileFailure> {
655 let file = normalize_path(file);
656 let Some(module) = self.modules.get(&file) else {
657 return Vec::new();
658 };
659 let mut failures = Vec::new();
660 for import in &module.imports {
661 let Some(import_path) = &import.path else {
662 continue;
663 };
664 let Some(target) = self
665 .modules
666 .get(import_path)
667 .or_else(|| self.modules.get(&normalize_path(import_path)))
668 else {
669 continue;
670 };
671 if let Some(error) = &target.load_error {
672 failures.push(ImportCompileFailure {
673 import_raw_path: import.raw_path.clone(),
674 import_span: import.import_span,
675 module_path: normalize_path(import_path),
676 error: error.clone(),
677 });
678 }
679 }
680 failures
681 }
682
683 pub fn imported_names_for_file(&self, file: &Path) -> Option<HashSet<String>> {
684 let file = normalize_path(file);
685 let module = self.modules.get(&file)?;
686 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
687 return None;
688 }
689
690 let mut names = HashSet::new();
691 for import in &module.imports {
692 let import_path = import.path.as_ref()?;
693 let imported = self
694 .modules
695 .get(import_path)
696 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
697 if imported.load_error.is_some() {
703 return None;
704 }
705 match &import.selective_names {
706 None => {
707 names.extend(imported.exports.iter().cloned());
708 }
709 Some(selective) => {
710 for name in selective {
719 if imported.declarations.contains_key(name)
720 || imported.exports.contains(name)
721 {
722 names.insert(name.clone());
723 }
724 }
725 }
726 }
727 }
728 Some(names)
729 }
730
731 pub fn imported_type_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
735 let file = normalize_path(file);
736 let module = self.modules.get(&file)?;
737 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
738 return None;
739 }
740
741 let mut decls = Vec::new();
742 for import in &module.imports {
743 let import_path = import.path.as_ref()?;
744 let imported = self
745 .modules
746 .get(import_path)
747 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
748 if imported.load_error.is_some() {
754 return None;
755 }
756 let names_to_collect: Vec<String> = match &import.selective_names {
757 None => imported.exports.iter().cloned().collect(),
758 Some(selective) => selective.iter().cloned().collect(),
759 };
760 for name in &names_to_collect {
761 let mut visited = HashSet::new();
762 if let Some(decl) = self.find_exported_type_decl(import_path, name, &mut visited) {
763 decls.push(decl);
764 }
765 }
766 for ty_decl in &imported.type_declarations {
776 if type_decl_name(ty_decl).is_some() {
777 decls.push(ty_decl.clone());
778 }
779 }
780 }
781 Some(decls)
782 }
783
784 pub fn imported_callable_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
788 let file = normalize_path(file);
789 let module = self.modules.get(&file)?;
790 if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
791 return None;
792 }
793
794 let mut decls = Vec::new();
795 for import in &module.imports {
796 let import_path = import.path.as_ref()?;
797 let imported = self
798 .modules
799 .get(import_path)
800 .or_else(|| self.modules.get(&normalize_path(import_path)))?;
801 if imported.load_error.is_some() {
807 return None;
808 }
809 let selective_import = import.selective_names.is_some();
810 let names_to_collect: Vec<String> = match &import.selective_names {
811 None => imported.exports.iter().cloned().collect(),
812 Some(selective) => selective.iter().cloned().collect(),
813 };
814 for name in &names_to_collect {
815 if selective_import || imported.own_exports.contains(name) {
816 if let Some(decl) = imported
817 .callable_declarations
818 .iter()
819 .find(|decl| callable_decl_name(decl) == Some(name.as_str()))
820 {
821 decls.push(decl.clone());
822 continue;
823 }
824 }
825 let mut visited = HashSet::new();
826 if let Some(decl) =
827 self.find_exported_callable_decl(import_path, name, &mut visited)
828 {
829 decls.push(decl);
830 }
831 }
832 }
833 Some(decls)
834 }
835
836 fn find_exported_type_decl(
839 &self,
840 path: &Path,
841 name: &str,
842 visited: &mut HashSet<PathBuf>,
843 ) -> Option<SNode> {
844 let canonical = normalize_path(path);
845 if !visited.insert(canonical.clone()) {
846 return None;
847 }
848 let module = self
849 .modules
850 .get(&canonical)
851 .or_else(|| self.modules.get(path))?;
852 for decl in &module.type_declarations {
853 if type_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
854 return Some(decl.clone());
855 }
856 }
857 if let Some(sources) = module.selective_re_exports.get(name) {
858 for source in sources {
859 if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
860 return Some(decl);
861 }
862 }
863 }
864 for source in &module.wildcard_re_export_paths {
865 if let Some(decl) = self.find_exported_type_decl(source, name, visited) {
866 return Some(decl);
867 }
868 }
869 None
870 }
871
872 fn find_exported_callable_decl(
873 &self,
874 path: &Path,
875 name: &str,
876 visited: &mut HashSet<PathBuf>,
877 ) -> Option<SNode> {
878 let canonical = normalize_path(path);
879 if !visited.insert(canonical.clone()) {
880 return None;
881 }
882 let module = self
883 .modules
884 .get(&canonical)
885 .or_else(|| self.modules.get(path))?;
886 for decl in &module.callable_declarations {
887 if callable_decl_name(decl) == Some(name) && module.own_exports.contains(name) {
888 return Some(decl.clone());
889 }
890 }
891 if let Some(sources) = module.selective_re_exports.get(name) {
892 for source in sources {
893 if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
894 return Some(decl);
895 }
896 }
897 }
898 for source in &module.wildcard_re_export_paths {
899 if let Some(decl) = self.find_exported_callable_decl(source, name, visited) {
900 return Some(decl);
901 }
902 }
903 None
904 }
905
906 pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
912 let mut visited = HashSet::new();
913 self.definition_of_inner(file, name, &mut visited)
914 }
915
916 pub fn declared_names_for_file(&self, file: &Path) -> Option<Vec<&str>> {
920 let module = self.modules.get(&normalize_path(file))?;
921 let mut names: Vec<&str> = module.declarations.keys().map(String::as_str).collect();
922 names.sort_unstable();
923 Some(names)
924 }
925
926 fn definition_of_inner(
927 &self,
928 file: &Path,
929 name: &str,
930 visited: &mut HashSet<PathBuf>,
931 ) -> Option<DefSite> {
932 let file = normalize_path(file);
933 if !visited.insert(file.clone()) {
934 return None;
935 }
936 let current = self.modules.get(&file)?;
937
938 if let Some(local) = current.declarations.get(name) {
939 return Some(local.clone());
940 }
941
942 if let Some(sources) = current.selective_re_exports.get(name) {
947 for source in sources {
948 if let Some(def) = self.definition_of_inner(source, name, visited) {
949 return Some(def);
950 }
951 }
952 }
953
954 for source in ¤t.wildcard_re_export_paths {
956 if let Some(def) = self.definition_of_inner(source, name, visited) {
957 return Some(def);
958 }
959 }
960
961 for import in ¤t.imports {
963 let Some(selective_names) = &import.selective_names else {
964 continue;
965 };
966 if !selective_names.contains(name) {
967 continue;
968 }
969 if let Some(path) = &import.path {
970 if let Some(def) = self.definition_of_inner(path, name, visited) {
971 return Some(def);
972 }
973 }
974 }
975
976 for import in ¤t.imports {
978 if import.selective_names.is_some() {
979 continue;
980 }
981 if let Some(path) = &import.path {
982 if let Some(def) = self.definition_of_inner(path, name, visited) {
983 return Some(def);
984 }
985 }
986 }
987
988 None
989 }
990
991 pub fn re_export_conflicts(&self, file: &Path) -> Vec<ReExportConflict> {
995 let file = normalize_path(file);
996 let Some(module) = self.modules.get(&file) else {
997 return Vec::new();
998 };
999
1000 let mut sources: HashMap<String, Vec<PathBuf>> = HashMap::new();
1004
1005 for (name, srcs) in &module.selective_re_exports {
1006 sources
1007 .entry(name.clone())
1008 .or_default()
1009 .extend(srcs.iter().cloned());
1010 }
1011 for src in &module.wildcard_re_export_paths {
1012 let canonical = normalize_path(src);
1013 let Some(src_module) = self
1014 .modules
1015 .get(&canonical)
1016 .or_else(|| self.modules.get(src))
1017 else {
1018 continue;
1019 };
1020 for name in &src_module.exports {
1021 sources
1022 .entry(name.clone())
1023 .or_default()
1024 .push(canonical.clone());
1025 }
1026 }
1027
1028 for name in &module.own_exports {
1032 if let Some(entry) = sources.get_mut(name) {
1033 entry.push(file.clone());
1034 }
1035 }
1036
1037 let mut conflicts = Vec::new();
1038 for (name, mut srcs) in sources {
1039 srcs.sort();
1040 srcs.dedup();
1041 if srcs.len() > 1 {
1042 conflicts.push(ReExportConflict {
1043 name,
1044 sources: srcs,
1045 });
1046 }
1047 }
1048 conflicts.sort_by(|a, b| a.name.cmp(&b.name));
1049 conflicts
1050 }
1051
1052 pub fn non_exported_selective_imports(&self, file: &Path) -> Vec<NonExportedImport> {
1064 let file = normalize_path(file);
1065 let Some(module) = self.modules.get(&file) else {
1066 return Vec::new();
1067 };
1068
1069 let mut out = Vec::new();
1070 for import in &module.imports {
1071 let Some(selective) = &import.selective_names else {
1072 continue;
1073 };
1074 let Some(import_path) = &import.path else {
1075 continue;
1076 };
1077 let Some(target) = self
1078 .modules
1079 .get(import_path)
1080 .or_else(|| self.modules.get(&normalize_path(import_path)))
1081 else {
1082 continue;
1083 };
1084 for name in selective {
1085 if target.declarations.contains_key(name) && !target.exports.contains(name) {
1089 out.push(NonExportedImport {
1090 name: name.clone(),
1091 module: import.raw_path.clone(),
1092 });
1093 }
1094 }
1095 }
1096 out.sort_by(|a, b| (&a.name, &a.module).cmp(&(&b.name, &b.module)));
1097 out.dedup();
1098 out
1099 }
1100}
1101
1102#[derive(Debug, Clone, PartialEq, Eq)]
1105pub struct ReExportConflict {
1106 pub name: String,
1107 pub sources: Vec<PathBuf>,
1108}
1109
1110#[derive(Debug, Clone, PartialEq, Eq)]
1113pub struct NonExportedImport {
1114 pub name: String,
1116 pub module: String,
1118}
1119
1120fn load_module(path: &Path) -> (ModuleInfo, Option<ParsedModuleSource>) {
1121 let Some(source) = read_module_source(path) else {
1122 return (ModuleInfo::default(), None);
1123 };
1124 let mut lexer = harn_lexer::Lexer::new(&source);
1125 let tokens = match lexer.tokenize() {
1126 Ok(tokens) => tokens,
1127 Err(error) => {
1128 let module = ModuleInfo {
1129 load_error: Some(ModuleLoadError {
1130 message: error.to_string(),
1131 span: error.span(),
1132 }),
1133 ..ModuleInfo::default()
1134 };
1135 return (module, None);
1136 }
1137 };
1138 let mut parser = Parser::new(tokens);
1139 let program = match parser.parse() {
1140 Ok(program) => program,
1141 Err(error) => {
1142 let module = ModuleInfo {
1143 load_error: Some(ModuleLoadError {
1144 message: error.to_string(),
1145 span: error.span(),
1146 }),
1147 ..ModuleInfo::default()
1148 };
1149 return (module, None);
1150 }
1151 };
1152
1153 let mut module = ModuleInfo::default();
1154 for node in &program {
1155 collect_module_info(path, node, &mut module);
1156 collect_type_declarations(node, &mut module.type_declarations);
1157 collect_callable_declarations(node, &mut module.callable_declarations);
1158 }
1159 module.exports.extend(module.own_exports.iter().cloned());
1163 module
1164 .exports
1165 .extend(module.selective_re_exports.keys().cloned());
1166 let parsed = ParsedModuleSource { source, program };
1167 (module, Some(parsed))
1168}
1169
1170fn stdlib_module_from_path(path: &Path) -> Option<&str> {
1173 let s = path.to_str()?;
1174 s.strip_prefix("<std>/")
1175}
1176
1177fn collect_module_info(file: &Path, snode: &SNode, module: &mut ModuleInfo) {
1178 match &snode.node {
1179 Node::FnDecl {
1180 name,
1181 params,
1182 is_pub,
1183 ..
1184 } => {
1185 if *is_pub {
1186 module.own_exports.insert(name.clone());
1187 }
1188 module.declarations.insert(
1189 name.clone(),
1190 decl_site(file, snode.span, name, DefKind::Function),
1191 );
1192 for param_name in params.iter().map(|param| param.name.clone()) {
1193 module.declarations.insert(
1194 param_name.clone(),
1195 decl_site(file, snode.span, ¶m_name, DefKind::Parameter),
1196 );
1197 }
1198 }
1199 Node::Pipeline { name, is_pub, .. } => {
1200 if *is_pub {
1201 module.own_exports.insert(name.clone());
1202 }
1203 module.declarations.insert(
1204 name.clone(),
1205 decl_site(file, snode.span, name, DefKind::Pipeline),
1206 );
1207 }
1208 Node::ToolDecl { name, is_pub, .. } => {
1209 if *is_pub {
1210 module.own_exports.insert(name.clone());
1211 }
1212 module.declarations.insert(
1213 name.clone(),
1214 decl_site(file, snode.span, name, DefKind::Tool),
1215 );
1216 }
1217 Node::SkillDecl { name, is_pub, .. } => {
1218 if *is_pub {
1219 module.own_exports.insert(name.clone());
1220 }
1221 module.declarations.insert(
1222 name.clone(),
1223 decl_site(file, snode.span, name, DefKind::Skill),
1224 );
1225 }
1226 Node::StructDecl { name, is_pub, .. } => {
1227 if *is_pub {
1228 module.own_exports.insert(name.clone());
1229 }
1230 module.declarations.insert(
1231 name.clone(),
1232 decl_site(file, snode.span, name, DefKind::Struct),
1233 );
1234 }
1235 Node::EnumDecl { name, is_pub, .. } => {
1236 if *is_pub {
1237 module.own_exports.insert(name.clone());
1238 }
1239 module.declarations.insert(
1240 name.clone(),
1241 decl_site(file, snode.span, name, DefKind::Enum),
1242 );
1243 }
1244 Node::InterfaceDecl { name, .. } => {
1245 module.own_exports.insert(name.clone());
1246 module.declarations.insert(
1247 name.clone(),
1248 decl_site(file, snode.span, name, DefKind::Interface),
1249 );
1250 }
1251 Node::TypeDecl { name, is_pub, .. } => {
1252 if *is_pub {
1253 module.own_exports.insert(name.clone());
1254 }
1255 module.declarations.insert(
1256 name.clone(),
1257 decl_site(file, snode.span, name, DefKind::Type),
1258 );
1259 }
1260 Node::LetBinding {
1261 pattern, is_pub, ..
1262 }
1263 | Node::ConstBinding {
1264 pattern, is_pub, ..
1265 } => {
1266 for name in pattern_names(pattern) {
1267 if *is_pub {
1271 module.own_exports.insert(name.clone());
1272 }
1273 module.declarations.insert(
1274 name.clone(),
1275 decl_site(file, snode.span, &name, DefKind::Variable),
1276 );
1277 }
1278 }
1279 Node::ImportDecl { path, is_pub } => {
1280 let import_path = resolve_import_path(file, path);
1281 if import_path.is_none() {
1282 module.has_unresolved_wildcard_import = true;
1283 }
1284 if *is_pub {
1285 if let Some(resolved) = &import_path {
1286 module
1287 .wildcard_re_export_paths
1288 .push(normalize_path(resolved));
1289 }
1290 }
1291 module.imports.push(ImportRef {
1292 raw_path: path.clone(),
1293 path: import_path,
1294 selective_names: None,
1295 import_span: snode.span,
1296 });
1297 }
1298 Node::SelectiveImport {
1299 names,
1300 path,
1301 is_pub,
1302 } => {
1303 let import_path = resolve_import_path(file, path);
1304 if import_path.is_none() {
1305 module.has_unresolved_selective_import = true;
1306 }
1307 if *is_pub {
1308 if let Some(resolved) = &import_path {
1309 let canonical = normalize_path(resolved);
1310 for name in names {
1311 module
1312 .selective_re_exports
1313 .entry(name.clone())
1314 .or_default()
1315 .push(canonical.clone());
1316 }
1317 }
1318 }
1319 let names: HashSet<String> = names.iter().cloned().collect();
1320 module.selective_import_names.extend(names.iter().cloned());
1321 module.imports.push(ImportRef {
1322 raw_path: path.clone(),
1323 path: import_path,
1324 selective_names: Some(names),
1325 import_span: snode.span,
1326 });
1327 }
1328 Node::AttributedDecl { inner, .. } => {
1329 collect_module_info(file, inner, module);
1330 }
1331 _ => {}
1332 }
1333}
1334
1335fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1336 match &snode.node {
1337 Node::TypeDecl { .. }
1338 | Node::StructDecl { .. }
1339 | Node::EnumDecl { .. }
1340 | Node::InterfaceDecl { .. } => decls.push(snode.clone()),
1341 Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
1342 _ => {}
1343 }
1344}
1345
1346fn collect_callable_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
1347 match &snode.node {
1348 Node::FnDecl { .. } | Node::Pipeline { .. } | Node::ToolDecl { .. } => {
1349 decls.push(snode.clone());
1350 }
1351 Node::AttributedDecl { inner, .. } => collect_callable_declarations(inner, decls),
1352 _ => {}
1353 }
1354}
1355
1356fn type_decl_name(snode: &SNode) -> Option<&str> {
1357 match &snode.node {
1358 Node::TypeDecl { name, .. }
1359 | Node::StructDecl { name, .. }
1360 | Node::EnumDecl { name, .. }
1361 | Node::InterfaceDecl { name, .. } => Some(name.as_str()),
1362 _ => None,
1363 }
1364}
1365
1366fn callable_decl_name(snode: &SNode) -> Option<&str> {
1367 match &snode.node {
1368 Node::FnDecl { name, .. } | Node::Pipeline { name, .. } | Node::ToolDecl { name, .. } => {
1369 Some(name.as_str())
1370 }
1371 Node::AttributedDecl { inner, .. } => callable_decl_name(inner),
1372 _ => None,
1373 }
1374}
1375
1376fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
1377 DefSite {
1378 name: name.to_string(),
1379 file: file.to_path_buf(),
1380 kind,
1381 span,
1382 }
1383}
1384
1385fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
1386 match pattern {
1387 BindingPattern::Identifier(name) => vec![name.clone()],
1388 BindingPattern::Dict(fields) => fields
1389 .iter()
1390 .filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
1391 .collect(),
1392 BindingPattern::List(elements) => elements
1393 .iter()
1394 .map(|element| element.name.clone())
1395 .collect(),
1396 BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
1397 }
1398}
1399
1400fn normalize_path(path: &Path) -> PathBuf {
1401 canonical_path(path)
1402}
1403
1404pub fn canonical_path(path: &Path) -> PathBuf {
1417 use std::sync::OnceLock;
1418 if stdlib_module_from_path(path).is_some() {
1419 return path.to_path_buf();
1420 }
1421 static MEMO: OnceLock<std::sync::Mutex<HashMap<PathBuf, PathBuf>>> = OnceLock::new();
1422 let memo = MEMO.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
1423 if let Some(hit) = memo
1424 .lock()
1425 .expect("canonical path memo lock poisoned")
1426 .get(path)
1427 .cloned()
1428 {
1429 return hit;
1430 }
1431 match path.canonicalize() {
1432 Ok(canonical) => {
1433 memo.lock()
1434 .expect("canonical path memo lock poisoned")
1435 .insert(path.to_path_buf(), canonical.clone());
1436 canonical
1437 }
1438 Err(_) => path.to_path_buf(),
1439 }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444 use super::*;
1445 use std::fs;
1446
1447 fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
1448 let path = dir.join(name);
1449 fs::write(&path, contents).unwrap();
1450 path
1451 }
1452
1453 #[test]
1454 fn wave_parallel_build_matches_serial_semantics() {
1455 let tmp = tempfile::tempdir().unwrap();
1460 let root = tmp.path();
1461 write_file(root, "shared.harn", "pub fn shared_fn() { 1 }\n");
1462 let seeds: Vec<PathBuf> = (0..12)
1463 .map(|i| {
1464 write_file(
1465 root,
1466 &format!("mod{i}.harn"),
1467 &format!(
1468 "import {{ shared_fn }} from \"./shared\"\npub fn f{i}() {{ shared_fn() }}\n"
1469 ),
1470 )
1471 })
1472 .collect();
1473
1474 let graph = build(&seeds);
1475 for seed in &seeds {
1476 let names = graph
1477 .imported_names_for_file(seed)
1478 .expect("seed imports should resolve");
1479 assert!(names.contains("shared_fn"));
1480 }
1481 let importers = graph.importers_of(&root.join("shared.harn"));
1482 assert_eq!(importers.len(), seeds.len());
1483 }
1484
1485 #[test]
1486 fn pub_const_and_let_are_exported() {
1487 let tmp = tempfile::tempdir().unwrap();
1488 let root = tmp.path();
1489 write_file(
1490 root,
1491 "consts.harn",
1492 "pub const MAX = 3\npub let SEED = 7\nconst PRIVATE = 9\n",
1493 );
1494 let consumer = write_file(
1495 root,
1496 "use.harn",
1497 "import { MAX, SEED } from \"./consts\"\nMAX\n",
1498 );
1499
1500 let graph = build(std::slice::from_ref(&consumer));
1501 let names = graph
1502 .imported_names_for_file(&consumer)
1503 .expect("imports resolve");
1504 assert!(names.contains("MAX"), "pub const should be importable");
1505 assert!(names.contains("SEED"), "pub let should be importable");
1506 let consts_exports = graph.exports_for_module(&root.join("consts.harn"));
1508 assert!(consts_exports.contains(&"MAX".to_string()));
1509 assert!(consts_exports.contains(&"SEED".to_string()));
1510 assert!(!consts_exports.contains(&"PRIVATE".to_string()));
1511 }
1512
1513 #[test]
1514 fn import_compile_failures_point_at_broken_module() {
1515 let tmp = tempfile::tempdir().unwrap();
1516 let root = tmp.path();
1517 write_file(
1519 root,
1520 "lib.harn",
1521 "pub fn ok() { 1 }\npub fn broken( {\n 2\n}\n",
1522 );
1523 let consumer = write_file(
1524 root,
1525 "main.harn",
1526 "import { ok } from \"./lib\"\npipeline test(task) { ok() }\n",
1527 );
1528
1529 let graph = build(std::slice::from_ref(&consumer));
1530 let failures = graph.import_compile_failures(&consumer);
1531 assert_eq!(failures.len(), 1, "the broken import should be reported");
1532 assert_eq!(failures[0].import_raw_path, "./lib");
1533 assert!(
1534 failures[0]
1535 .module_path
1536 .to_string_lossy()
1537 .ends_with("lib.harn"),
1538 "failure must name the imported module, not the consumer"
1539 );
1540
1541 assert!(
1544 graph.imported_names_for_file(&consumer).is_none(),
1545 "a broken import target should suppress the call-site undefined check"
1546 );
1547 }
1548
1549 #[test]
1550 fn importers_of_finds_direct_dependents() {
1551 let tmp = tempfile::tempdir().unwrap();
1552 let root = tmp.path();
1553 let leaf = write_file(root, "leaf.harn", "pub fn leaf() { 1 }\n");
1554 write_file(root, "a.harn", "import \"./leaf\"\nleaf()\n");
1555 write_file(root, "b.harn", "import { leaf } from \"./leaf\"\nleaf()\n");
1556 let entry = write_file(root, "entry.harn", "import \"./a\"\nimport \"./b\"\n");
1557
1558 let graph = build(std::slice::from_ref(&entry));
1559 let importers = graph.importers_of(&leaf);
1560 let names: Vec<String> = importers
1561 .iter()
1562 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1563 .collect();
1564 assert!(names.contains(&"a.harn".to_string()));
1565 assert!(names.contains(&"b.harn".to_string()));
1566 assert!(!names.contains(&"entry.harn".to_string()));
1567 }
1568
1569 #[test]
1570 fn recursive_build_loads_transitively_imported_modules() {
1571 let tmp = tempfile::tempdir().unwrap();
1572 let root = tmp.path();
1573 write_file(root, "leaf.harn", "pub fn leaf_fn() { 1 }\n");
1574 write_file(
1575 root,
1576 "mid.harn",
1577 "import \"./leaf\"\npub fn mid_fn() { leaf_fn() }\n",
1578 );
1579 let entry = write_file(root, "entry.harn", "import \"./mid\"\nmid_fn()\n");
1580
1581 let graph = build(std::slice::from_ref(&entry));
1582 let imported = graph
1583 .imported_names_for_file(&entry)
1584 .expect("entry imports should resolve");
1585 assert!(imported.contains("mid_fn"));
1587 assert!(!imported.contains("leaf_fn"));
1588
1589 let leaf_path = root.join("leaf.harn");
1592 assert!(graph.definition_of(&leaf_path, "leaf_fn").is_some());
1593 }
1594
1595 #[test]
1596 fn imported_names_returns_none_when_import_unresolved() {
1597 let tmp = tempfile::tempdir().unwrap();
1598 let root = tmp.path();
1599 let entry = write_file(root, "entry.harn", "import \"./does_not_exist\"\n");
1600
1601 let graph = build(std::slice::from_ref(&entry));
1602 assert!(graph.imported_names_for_file(&entry).is_none());
1603 }
1604
1605 #[test]
1606 fn selective_imports_contribute_only_requested_names() {
1607 let tmp = tempfile::tempdir().unwrap();
1608 let root = tmp.path();
1609 write_file(root, "util.harn", "pub fn a() { 1 }\npub fn b() { 2 }\n");
1610 let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
1611
1612 let graph = build(std::slice::from_ref(&entry));
1613 let imported = graph
1614 .imported_names_for_file(&entry)
1615 .expect("entry imports should resolve");
1616 assert!(imported.contains("a"));
1617 assert!(!imported.contains("b"));
1618 }
1619
1620 #[test]
1621 fn non_exported_selective_import_is_flagged_when_module_has_pub() {
1622 let tmp = tempfile::tempdir().unwrap();
1623 let root = tmp.path();
1624 write_file(root, "lib.harn", "pub fn api() { 1 }\nfn helper() { 2 }\n");
1625 let entry = write_file(root, "entry.harn", "import { helper } from \"./lib\"\n");
1626
1627 let graph = build(std::slice::from_ref(&entry));
1628 let offenders = graph.non_exported_selective_imports(&entry);
1629 assert_eq!(offenders.len(), 1);
1630 assert_eq!(offenders[0].name, "helper");
1631 assert_eq!(offenders[0].module, "./lib");
1632
1633 let entry_ok = write_file(root, "entry_ok.harn", "import { api } from \"./lib\"\n");
1635 let graph_ok = build(std::slice::from_ref(&entry_ok));
1636 assert!(graph_ok
1637 .non_exported_selective_imports(&entry_ok)
1638 .is_empty());
1639 }
1640
1641 #[test]
1642 fn selective_import_from_zero_pub_module_is_flagged() {
1643 let tmp = tempfile::tempdir().unwrap();
1644 let root = tmp.path();
1645 write_file(root, "util.harn", "fn a() { 1 }\nfn b() { 2 }\n");
1649 let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
1650
1651 let graph = build(std::slice::from_ref(&entry));
1652 let offenders = graph.non_exported_selective_imports(&entry);
1653 assert_eq!(offenders.len(), 1);
1654 assert_eq!(offenders[0].name, "a");
1655 assert_eq!(offenders[0].module, "./util");
1656 }
1657
1658 #[test]
1659 fn stdlib_imports_resolve_to_embedded_sources() {
1660 let tmp = tempfile::tempdir().unwrap();
1661 let root = tmp.path();
1662 let entry = write_file(root, "entry.harn", "import \"std/math\"\nclamp(5, 0, 10)\n");
1663
1664 let graph = build(std::slice::from_ref(&entry));
1665 let imported = graph
1666 .imported_names_for_file(&entry)
1667 .expect("std/math should resolve");
1668 assert!(imported.contains("clamp"));
1670 }
1671
1672 #[test]
1673 fn stdlib_internal_imports_resolve_without_leaking_to_callers() {
1674 let tmp = tempfile::tempdir().unwrap();
1675 let root = tmp.path();
1676 let entry = write_file(
1677 root,
1678 "entry.harn",
1679 "import { process_run } from \"std/runtime\"\nprocess_run([\"echo\", \"ok\"])\n",
1680 );
1681
1682 let graph = build(std::slice::from_ref(&entry));
1683 let entry_imports = graph
1684 .imported_names_for_file(&entry)
1685 .expect("std/runtime should resolve");
1686 assert!(entry_imports.contains("process_run"));
1687 assert!(
1688 !entry_imports.contains("filter_nil"),
1689 "private std/runtime dependency leaked to caller"
1690 );
1691
1692 let runtime_path = stdlib::stdlib_virtual_path("runtime");
1693 let runtime_imports = graph
1694 .imported_names_for_file(&runtime_path)
1695 .expect("std/runtime internal imports should resolve");
1696 assert!(runtime_imports.contains("filter_nil"));
1697 }
1698
1699 #[test]
1700 fn runtime_stdlib_import_surface_resolves_to_embedded_sources() {
1701 let tmp = tempfile::tempdir().unwrap();
1702 let entry_path = write_file(tmp.path(), "entry.harn", "");
1703
1704 for source in harn_stdlib::STDLIB_SOURCES {
1705 let import_path = format!("std/{}", source.module);
1706 assert!(
1707 resolve_import_path(&entry_path, &import_path).is_some(),
1708 "{import_path} should resolve in the module graph"
1709 );
1710 }
1711 }
1712
1713 #[test]
1714 fn stdlib_imports_expose_type_declarations() {
1715 let tmp = tempfile::tempdir().unwrap();
1716 let root = tmp.path();
1717 let entry = write_file(
1718 root,
1719 "entry.harn",
1720 "import \"std/triggers\"\nlet provider = \"github\"\n",
1721 );
1722
1723 let graph = build(std::slice::from_ref(&entry));
1724 let decls = graph
1725 .imported_type_declarations_for_file(&entry)
1726 .expect("std/triggers type declarations should resolve");
1727 let names: HashSet<String> = decls
1728 .iter()
1729 .filter_map(type_decl_name)
1730 .map(ToString::to_string)
1731 .collect();
1732 assert!(names.contains("TriggerEvent"));
1733 assert!(names.contains("ProviderPayload"));
1734 assert!(names.contains("SignatureStatus"));
1735 }
1736
1737 #[test]
1738 fn stdlib_imports_expose_callable_declarations() {
1739 let tmp = tempfile::tempdir().unwrap();
1740 let root = tmp.path();
1741 let entry = write_file(
1742 root,
1743 "entry.harn",
1744 "import { select_from } from \"std/tui\"\nlet item = \"alpha\"\n",
1745 );
1746
1747 let graph = build(std::slice::from_ref(&entry));
1748 let decls = graph
1749 .imported_callable_declarations_for_file(&entry)
1750 .expect("std/tui callable declarations should resolve");
1751 let names: HashSet<String> = decls
1752 .iter()
1753 .filter_map(callable_decl_name)
1754 .map(ToString::to_string)
1755 .collect();
1756 assert!(names.contains("select_from"));
1757 }
1758
1759 #[test]
1760 fn stdlib_llm_catalog_exposes_routing_routes() {
1761 let tmp = tempfile::tempdir().unwrap();
1762 let root = tmp.path();
1763 let entry = write_file(
1764 root,
1765 "entry.harn",
1766 "import { routing_routes } from \"std/llm/catalog\"\nrouting_routes()\n",
1767 );
1768
1769 let graph = build(std::slice::from_ref(&entry));
1770 let imported = graph
1771 .imported_names_for_file(&entry)
1772 .expect("std/llm/catalog should resolve");
1773 assert!(imported.contains("routing_routes"));
1774 let decls = graph
1775 .imported_callable_declarations_for_file(&entry)
1776 .expect("std/llm/catalog callable declarations should resolve");
1777 let names: HashSet<String> = decls
1778 .iter()
1779 .filter_map(callable_decl_name)
1780 .map(ToString::to_string)
1781 .collect();
1782 assert!(names.contains("routing_routes"));
1783 }
1784
1785 #[test]
1786 fn package_export_map_resolves_declared_module() {
1787 let tmp = tempfile::tempdir().unwrap();
1788 let root = tmp.path();
1789 let packages = root.join(".harn/packages/acme/runtime");
1790 fs::create_dir_all(&packages).unwrap();
1791 fs::write(
1792 root.join(".harn/packages/acme/harn.toml"),
1793 "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1794 )
1795 .unwrap();
1796 fs::write(
1797 packages.join("capabilities.harn"),
1798 "pub fn exported_capability() { 1 }\n",
1799 )
1800 .unwrap();
1801 let entry = write_file(
1802 root,
1803 "entry.harn",
1804 "import \"acme/capabilities\"\nexported_capability()\n",
1805 );
1806
1807 let graph = build(std::slice::from_ref(&entry));
1808 let imported = graph
1809 .imported_names_for_file(&entry)
1810 .expect("package export should resolve");
1811 assert!(imported.contains("exported_capability"));
1812 }
1813
1814 #[test]
1815 fn package_direct_import_cannot_escape_packages_root() {
1816 let tmp = tempfile::tempdir().unwrap();
1817 let root = tmp.path();
1818 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1819 fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1820 let entry = write_file(root, "entry.harn", "");
1821
1822 let resolved = resolve_import_path(&entry, "acme/../../secret");
1823 assert!(resolved.is_none(), "package import escaped package root");
1824 }
1825
1826 #[test]
1827 fn package_export_map_cannot_escape_package_root() {
1828 let tmp = tempfile::tempdir().unwrap();
1829 let root = tmp.path();
1830 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1831 fs::write(root.join("secret.harn"), "pub fn leaked() { 1 }\n").unwrap();
1832 fs::write(
1833 root.join(".harn/packages/acme/harn.toml"),
1834 "[exports]\nleak = \"../../secret.harn\"\n",
1835 )
1836 .unwrap();
1837 let entry = write_file(root, "entry.harn", "");
1838
1839 let resolved = resolve_import_path(&entry, "acme/leak");
1840 assert!(resolved.is_none(), "package export escaped package root");
1841 }
1842
1843 #[test]
1844 fn package_export_map_allows_symlinked_path_dependencies() {
1845 let tmp = tempfile::tempdir().unwrap();
1846 let root = tmp.path();
1847 let source = root.join("source-package");
1848 fs::create_dir_all(source.join("runtime")).unwrap();
1849 fs::write(
1850 source.join("harn.toml"),
1851 "[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
1852 )
1853 .unwrap();
1854 fs::write(
1855 source.join("runtime/capabilities.harn"),
1856 "pub fn exported_capability() { 1 }\n",
1857 )
1858 .unwrap();
1859 fs::create_dir_all(root.join(".harn/packages")).unwrap();
1860 #[cfg(unix)]
1861 std::os::unix::fs::symlink(&source, root.join(".harn/packages/acme")).unwrap();
1862 #[cfg(windows)]
1863 std::os::windows::fs::symlink_dir(&source, root.join(".harn/packages/acme")).unwrap();
1864 let entry = write_file(root, "entry.harn", "");
1865
1866 let resolved = resolve_import_path(&entry, "acme/capabilities")
1867 .expect("symlinked package export should resolve");
1868 assert!(resolved.ends_with("runtime/capabilities.harn"));
1869 }
1870
1871 #[test]
1872 fn package_imports_resolve_from_nested_package_module() {
1873 let tmp = tempfile::tempdir().unwrap();
1874 let root = tmp.path();
1875 fs::create_dir_all(root.join(".git")).unwrap();
1876 fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
1877 fs::create_dir_all(root.join(".harn/packages/shared")).unwrap();
1878 fs::write(
1879 root.join(".harn/packages/shared/lib.harn"),
1880 "pub fn shared_helper() { 1 }\n",
1881 )
1882 .unwrap();
1883 fs::write(
1884 root.join(".harn/packages/acme/lib.harn"),
1885 "import \"shared\"\npub fn use_shared() { shared_helper() }\n",
1886 )
1887 .unwrap();
1888 let entry = write_file(root, "entry.harn", "import \"acme\"\nuse_shared()\n");
1889
1890 let graph = build(std::slice::from_ref(&entry));
1891 let imported = graph
1892 .imported_names_for_file(&entry)
1893 .expect("nested package import should resolve");
1894 assert!(imported.contains("use_shared"));
1895 let acme_path = root.join(".harn/packages/acme/lib.harn");
1896 let acme_imports = graph
1897 .imported_names_for_file(&acme_path)
1898 .expect("package module imports should resolve");
1899 assert!(acme_imports.contains("shared_helper"));
1900 }
1901
1902 #[test]
1903 fn unknown_stdlib_import_is_unresolved() {
1904 let tmp = tempfile::tempdir().unwrap();
1905 let root = tmp.path();
1906 let entry = write_file(root, "entry.harn", "import \"std/does_not_exist\"\n");
1907
1908 let graph = build(std::slice::from_ref(&entry));
1909 assert!(
1910 graph.imported_names_for_file(&entry).is_none(),
1911 "unknown std module should fail resolution and disable strict check"
1912 );
1913 }
1914
1915 #[test]
1916 fn import_cycles_do_not_loop_forever() {
1917 let tmp = tempfile::tempdir().unwrap();
1918 let root = tmp.path();
1919 write_file(root, "a.harn", "import \"./b\"\npub fn a_fn() { 1 }\n");
1920 write_file(root, "b.harn", "import \"./a\"\npub fn b_fn() { 1 }\n");
1921 let entry = root.join("a.harn");
1922
1923 let graph = build(std::slice::from_ref(&entry));
1925 let imported = graph
1926 .imported_names_for_file(&entry)
1927 .expect("cyclic imports still resolve to known exports");
1928 assert!(imported.contains("b_fn"));
1929 }
1930
1931 #[test]
1932 fn pub_import_selective_re_exports_named_symbols() {
1933 let tmp = tempfile::tempdir().unwrap();
1934 let root = tmp.path();
1935 write_file(
1936 root,
1937 "src.harn",
1938 "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1939 );
1940 write_file(root, "facade.harn", "pub import { alpha } from \"./src\"\n");
1941 let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1942
1943 let graph = build(std::slice::from_ref(&entry));
1944 let imported = graph
1945 .imported_names_for_file(&entry)
1946 .expect("entry should resolve");
1947 assert!(imported.contains("alpha"), "selective re-export missing");
1948 assert!(
1949 !imported.contains("beta"),
1950 "non-listed name leaked through facade"
1951 );
1952
1953 let facade_path = root.join("facade.harn");
1954 let def = graph
1955 .definition_of(&facade_path, "alpha")
1956 .expect("definition_of should chase re-export");
1957 assert!(def.file.ends_with("src.harn"));
1958 }
1959
1960 #[test]
1961 fn pub_import_wildcard_re_exports_full_surface() {
1962 let tmp = tempfile::tempdir().unwrap();
1963 let root = tmp.path();
1964 write_file(
1965 root,
1966 "src.harn",
1967 "pub fn alpha() { 1 }\npub fn beta() { 2 }\n",
1968 );
1969 write_file(root, "facade.harn", "pub import \"./src\"\n");
1970 let entry = write_file(root, "entry.harn", "import \"./facade\"\nalpha()\n");
1971
1972 let graph = build(std::slice::from_ref(&entry));
1973 let imported = graph
1974 .imported_names_for_file(&entry)
1975 .expect("entry should resolve");
1976 assert!(imported.contains("alpha"));
1977 assert!(imported.contains("beta"));
1978 }
1979
1980 #[test]
1981 fn pub_import_chain_resolves_definition_to_origin() {
1982 let tmp = tempfile::tempdir().unwrap();
1983 let root = tmp.path();
1984 write_file(root, "inner.harn", "pub fn deep() { 1 }\n");
1985 write_file(
1986 root,
1987 "middle.harn",
1988 "pub import { deep } from \"./inner\"\n",
1989 );
1990 write_file(
1991 root,
1992 "outer.harn",
1993 "pub import { deep } from \"./middle\"\n",
1994 );
1995 let entry = write_file(
1996 root,
1997 "entry.harn",
1998 "import { deep } from \"./outer\"\ndeep()\n",
1999 );
2000
2001 let graph = build(std::slice::from_ref(&entry));
2002 let def = graph
2003 .definition_of(&entry, "deep")
2004 .expect("definition_of should follow chain");
2005 assert!(def.file.ends_with("inner.harn"));
2006
2007 let imported = graph
2008 .imported_names_for_file(&entry)
2009 .expect("entry should resolve");
2010 assert!(imported.contains("deep"));
2011 }
2012
2013 #[test]
2014 fn duplicate_pub_import_reports_re_export_conflict() {
2015 let tmp = tempfile::tempdir().unwrap();
2016 let root = tmp.path();
2017 write_file(root, "a.harn", "pub fn shared() { 1 }\n");
2018 write_file(root, "b.harn", "pub fn shared() { 2 }\n");
2019 let facade = write_file(
2020 root,
2021 "facade.harn",
2022 "pub import { shared } from \"./a\"\npub import { shared } from \"./b\"\n",
2023 );
2024
2025 let graph = build(std::slice::from_ref(&facade));
2026 let conflicts = graph.re_export_conflicts(&facade);
2027 assert_eq!(
2028 conflicts.len(),
2029 1,
2030 "expected exactly one re-export conflict, got {conflicts:?}"
2031 );
2032 assert_eq!(conflicts[0].name, "shared");
2033 assert_eq!(conflicts[0].sources.len(), 2);
2034 }
2035
2036 #[test]
2037 fn cross_directory_cycle_does_not_explode_module_count() {
2038 let tmp = tempfile::tempdir().unwrap();
2046 let root = tmp.path();
2047 let context = root.join("context");
2048 let runtime = root.join("runtime");
2049 fs::create_dir_all(&context).unwrap();
2050 fs::create_dir_all(&runtime).unwrap();
2051 write_file(
2052 &context,
2053 "a.harn",
2054 "import \"../runtime/b\"\npub fn a_fn() { 1 }\n",
2055 );
2056 write_file(
2057 &runtime,
2058 "b.harn",
2059 "import \"../context/a\"\npub fn b_fn() { 1 }\n",
2060 );
2061 let entry = context.join("a.harn");
2062
2063 let graph = build(std::slice::from_ref(&entry));
2064 assert_eq!(
2067 graph.modules.len(),
2068 2,
2069 "cross-directory cycle loaded {} modules, expected 2",
2070 graph.modules.len()
2071 );
2072 let imported = graph
2073 .imported_names_for_file(&entry)
2074 .expect("cyclic imports still resolve to known exports");
2075 assert!(imported.contains("b_fn"));
2076 }
2077}