use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use harn_lexer::Span;
use harn_parser::{BindingPattern, Node, Parser, SNode};
use serde::Deserialize;
mod stdlib;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DefKind {
Function,
Pipeline,
Tool,
Skill,
Struct,
Enum,
Interface,
Type,
Variable,
Parameter,
}
#[derive(Debug, Clone)]
pub struct DefSite {
pub name: String,
pub file: PathBuf,
pub kind: DefKind,
pub span: Span,
}
#[derive(Debug, Clone)]
pub enum WildcardResolution {
Resolved(HashSet<String>),
Unknown,
}
#[derive(Debug, Default)]
pub struct ModuleGraph {
modules: HashMap<PathBuf, ModuleInfo>,
}
#[derive(Debug, Default)]
struct ModuleInfo {
declarations: HashMap<String, DefSite>,
exports: HashSet<String>,
selective_import_names: HashSet<String>,
imports: Vec<ImportRef>,
has_unresolved_wildcard_import: bool,
has_unresolved_selective_import: bool,
fn_names: Vec<String>,
has_pub_fn: bool,
type_declarations: Vec<SNode>,
}
#[derive(Debug, Clone)]
struct ImportRef {
path: Option<PathBuf>,
selective_names: Option<HashSet<String>>,
}
#[derive(Debug, Default, Deserialize)]
struct PackageManifest {
#[serde(default)]
exports: HashMap<String, String>,
}
pub fn build(files: &[PathBuf]) -> ModuleGraph {
let mut modules: HashMap<PathBuf, ModuleInfo> = HashMap::new();
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut queue: VecDeque<PathBuf> = VecDeque::new();
for file in files {
let canonical = normalize_path(file);
if seen.insert(canonical.clone()) {
queue.push_back(canonical);
}
}
while let Some(path) = queue.pop_front() {
if modules.contains_key(&path) {
continue;
}
let module = load_module(&path);
for import in &module.imports {
if let Some(import_path) = &import.path {
let canonical = normalize_path(import_path);
if seen.insert(canonical.clone()) {
queue.push_back(canonical);
}
}
}
modules.insert(path, module);
}
ModuleGraph { modules }
}
pub fn resolve_import_path(current_file: &Path, import_path: &str) -> Option<PathBuf> {
if let Some(module) = import_path.strip_prefix("std/") {
if stdlib::get_stdlib_source(module).is_some() {
return Some(stdlib::stdlib_virtual_path(module));
}
return None;
}
let base = current_file.parent().unwrap_or(Path::new("."));
let mut file_path = base.join(import_path);
if !file_path.exists() && file_path.extension().is_none() {
file_path.set_extension("harn");
}
if file_path.exists() {
return Some(file_path);
}
if let Some(path) = resolve_package_import(base, import_path) {
return Some(path);
}
None
}
fn resolve_package_import(base: &Path, import_path: &str) -> Option<PathBuf> {
for anchor in base.ancestors() {
let packages_root = anchor.join(".harn/packages");
if !packages_root.is_dir() {
if anchor.join(".git").exists() {
break;
}
continue;
}
if let Some(path) = resolve_from_packages_root(&packages_root, import_path) {
return Some(path);
}
if anchor.join(".git").exists() {
break;
}
}
None
}
fn resolve_from_packages_root(packages_root: &Path, import_path: &str) -> Option<PathBuf> {
let pkg_path = packages_root.join(import_path);
if let Some(path) = finalize_package_target(&pkg_path) {
return Some(path);
}
let (package_name, export_name) = import_path.split_once('/')?;
let manifest_path = packages_root.join(package_name).join("harn.toml");
let manifest = read_package_manifest(&manifest_path)?;
let rel_path = manifest.exports.get(export_name)?;
finalize_package_target(&packages_root.join(package_name).join(rel_path))
}
fn read_package_manifest(path: &Path) -> Option<PackageManifest> {
let content = std::fs::read_to_string(path).ok()?;
toml::from_str::<PackageManifest>(&content).ok()
}
fn finalize_package_target(path: &Path) -> Option<PathBuf> {
if path.is_dir() {
let lib = path.join("lib.harn");
if lib.exists() {
return Some(lib);
}
return Some(path.to_path_buf());
}
if path.exists() {
return Some(path.to_path_buf());
}
if path.extension().is_none() {
let mut with_ext = path.to_path_buf();
with_ext.set_extension("harn");
if with_ext.exists() {
return Some(with_ext);
}
}
None
}
impl ModuleGraph {
pub fn all_selective_import_names(&self) -> HashSet<&str> {
let mut names = HashSet::new();
for module in self.modules.values() {
for name in &module.selective_import_names {
names.insert(name.as_str());
}
}
names
}
pub fn wildcard_exports_for(&self, file: &Path) -> WildcardResolution {
let file = normalize_path(file);
let Some(module) = self.modules.get(&file) else {
return WildcardResolution::Unknown;
};
if module.has_unresolved_wildcard_import {
return WildcardResolution::Unknown;
}
let mut names = HashSet::new();
for import in module
.imports
.iter()
.filter(|import| import.selective_names.is_none())
{
let Some(import_path) = &import.path else {
return WildcardResolution::Unknown;
};
let imported = self.modules.get(import_path).or_else(|| {
let normalized = normalize_path(import_path);
self.modules.get(&normalized)
});
let Some(imported) = imported else {
return WildcardResolution::Unknown;
};
names.extend(imported.exports.iter().cloned());
}
WildcardResolution::Resolved(names)
}
pub fn imported_names_for_file(&self, file: &Path) -> Option<HashSet<String>> {
let file = normalize_path(file);
let module = self.modules.get(&file)?;
if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
return None;
}
let mut names = HashSet::new();
for import in &module.imports {
let import_path = import.path.as_ref()?;
let imported = self
.modules
.get(import_path)
.or_else(|| self.modules.get(&normalize_path(import_path)))?;
match &import.selective_names {
None => {
names.extend(imported.exports.iter().cloned());
}
Some(selective) => {
for name in selective {
if imported.declarations.contains_key(name) {
names.insert(name.clone());
}
}
}
}
}
Some(names)
}
pub fn imported_type_declarations_for_file(&self, file: &Path) -> Option<Vec<SNode>> {
let file = normalize_path(file);
let module = self.modules.get(&file)?;
if module.has_unresolved_wildcard_import || module.has_unresolved_selective_import {
return None;
}
let mut decls = Vec::new();
for import in &module.imports {
let import_path = import.path.as_ref()?;
let imported = self
.modules
.get(import_path)
.or_else(|| self.modules.get(&normalize_path(import_path)))?;
match &import.selective_names {
None => {
for decl in &imported.type_declarations {
if let Some(name) = type_decl_name(decl) {
if imported.exports.contains(name) {
decls.push(decl.clone());
}
}
}
}
Some(selective) => {
for decl in &imported.type_declarations {
if let Some(name) = type_decl_name(decl) {
if selective.contains(name) {
decls.push(decl.clone());
}
}
}
}
}
}
Some(decls)
}
pub fn definition_of(&self, file: &Path, name: &str) -> Option<DefSite> {
let file = normalize_path(file);
let current = self.modules.get(&file)?;
if let Some(local) = current.declarations.get(name) {
return Some(local.clone());
}
for import in ¤t.imports {
if let Some(selective_names) = &import.selective_names {
if !selective_names.contains(name) {
continue;
}
} else {
continue;
}
if let Some(path) = &import.path {
if let Some(symbol) = self
.modules
.get(path)
.or_else(|| self.modules.get(&normalize_path(path)))
.and_then(|module| module.declarations.get(name))
{
return Some(symbol.clone());
}
}
}
for import in ¤t.imports {
if import.selective_names.is_some() {
continue;
}
if let Some(path) = &import.path {
if let Some(symbol) = self
.modules
.get(path)
.or_else(|| self.modules.get(&normalize_path(path)))
.and_then(|module| module.declarations.get(name))
{
return Some(symbol.clone());
}
}
}
None
}
}
fn load_module(path: &Path) -> ModuleInfo {
let source = if let Some(stdlib_module) = stdlib_module_from_path(path) {
match stdlib::get_stdlib_source(stdlib_module) {
Some(src) => src.to_string(),
None => return ModuleInfo::default(),
}
} else {
match std::fs::read_to_string(path) {
Ok(src) => src,
Err(_) => return ModuleInfo::default(),
}
};
let mut lexer = harn_lexer::Lexer::new(&source);
let tokens = match lexer.tokenize() {
Ok(tokens) => tokens,
Err(_) => return ModuleInfo::default(),
};
let mut parser = Parser::new(tokens);
let program = match parser.parse() {
Ok(program) => program,
Err(_) => return ModuleInfo::default(),
};
let mut module = ModuleInfo::default();
for node in &program {
collect_module_info(path, node, &mut module);
collect_type_declarations(node, &mut module.type_declarations);
}
if !module.has_pub_fn {
for name in &module.fn_names {
module.exports.insert(name.clone());
}
}
module
}
fn stdlib_module_from_path(path: &Path) -> Option<&str> {
let s = path.to_str()?;
s.strip_prefix("<std>/")
}
fn collect_module_info(file: &Path, snode: &SNode, module: &mut ModuleInfo) {
match &snode.node {
Node::FnDecl {
name,
params,
is_pub,
..
} => {
if *is_pub {
module.exports.insert(name.clone());
module.has_pub_fn = true;
}
module.fn_names.push(name.clone());
module.declarations.insert(
name.clone(),
decl_site(file, snode.span, name, DefKind::Function),
);
for param_name in params.iter().map(|param| param.name.clone()) {
module.declarations.insert(
param_name.clone(),
decl_site(file, snode.span, ¶m_name, DefKind::Parameter),
);
}
}
Node::Pipeline { name, is_pub, .. } => {
if *is_pub {
module.exports.insert(name.clone());
}
module.declarations.insert(
name.clone(),
decl_site(file, snode.span, name, DefKind::Pipeline),
);
}
Node::ToolDecl { name, is_pub, .. } => {
if *is_pub {
module.exports.insert(name.clone());
}
module.declarations.insert(
name.clone(),
decl_site(file, snode.span, name, DefKind::Tool),
);
}
Node::SkillDecl { name, is_pub, .. } => {
if *is_pub {
module.exports.insert(name.clone());
}
module.declarations.insert(
name.clone(),
decl_site(file, snode.span, name, DefKind::Skill),
);
}
Node::StructDecl { name, is_pub, .. } => {
if *is_pub {
module.exports.insert(name.clone());
}
module.declarations.insert(
name.clone(),
decl_site(file, snode.span, name, DefKind::Struct),
);
}
Node::EnumDecl { name, is_pub, .. } => {
if *is_pub {
module.exports.insert(name.clone());
}
module.declarations.insert(
name.clone(),
decl_site(file, snode.span, name, DefKind::Enum),
);
}
Node::InterfaceDecl { name, .. } => {
module.exports.insert(name.clone());
module.declarations.insert(
name.clone(),
decl_site(file, snode.span, name, DefKind::Interface),
);
}
Node::TypeDecl { name, .. } => {
module.exports.insert(name.clone());
module.declarations.insert(
name.clone(),
decl_site(file, snode.span, name, DefKind::Type),
);
}
Node::LetBinding { pattern, .. } | Node::VarBinding { pattern, .. } => {
for name in pattern_names(pattern) {
module.declarations.insert(
name.clone(),
decl_site(file, snode.span, &name, DefKind::Variable),
);
}
}
Node::ImportDecl { path } => {
let import_path = resolve_import_path(file, path);
if import_path.is_none() {
module.has_unresolved_wildcard_import = true;
}
module.imports.push(ImportRef {
path: import_path,
selective_names: None,
});
}
Node::SelectiveImport { names, path } => {
let import_path = resolve_import_path(file, path);
if import_path.is_none() {
module.has_unresolved_selective_import = true;
}
let names: HashSet<String> = names.iter().cloned().collect();
module.selective_import_names.extend(names.iter().cloned());
module.imports.push(ImportRef {
path: import_path,
selective_names: Some(names),
});
}
Node::AttributedDecl { inner, .. } => {
collect_module_info(file, inner, module);
}
_ => {}
}
}
fn collect_type_declarations(snode: &SNode, decls: &mut Vec<SNode>) {
match &snode.node {
Node::TypeDecl { .. }
| Node::StructDecl { .. }
| Node::EnumDecl { .. }
| Node::InterfaceDecl { .. } => decls.push(snode.clone()),
Node::AttributedDecl { inner, .. } => collect_type_declarations(inner, decls),
_ => {}
}
}
fn type_decl_name(snode: &SNode) -> Option<&str> {
match &snode.node {
Node::TypeDecl { name, .. }
| Node::StructDecl { name, .. }
| Node::EnumDecl { name, .. }
| Node::InterfaceDecl { name, .. } => Some(name.as_str()),
_ => None,
}
}
fn decl_site(file: &Path, span: Span, name: &str, kind: DefKind) -> DefSite {
DefSite {
name: name.to_string(),
file: file.to_path_buf(),
kind,
span,
}
}
fn pattern_names(pattern: &BindingPattern) -> Vec<String> {
match pattern {
BindingPattern::Identifier(name) => vec![name.clone()],
BindingPattern::Dict(fields) => fields
.iter()
.filter_map(|field| field.alias.as_ref().or(Some(&field.key)).cloned())
.collect(),
BindingPattern::List(elements) => elements
.iter()
.map(|element| element.name.clone())
.collect(),
BindingPattern::Pair(a, b) => vec![a.clone(), b.clone()],
}
}
fn normalize_path(path: &Path) -> PathBuf {
if stdlib_module_from_path(path).is_some() {
return path.to_path_buf();
}
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
let path = dir.join(name);
fs::write(&path, contents).unwrap();
path
}
#[test]
fn recursive_build_loads_transitively_imported_modules() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_file(root, "leaf.harn", "pub fn leaf_fn() { 1 }\n");
write_file(
root,
"mid.harn",
"import \"./leaf\"\npub fn mid_fn() { leaf_fn() }\n",
);
let entry = write_file(root, "entry.harn", "import \"./mid\"\nmid_fn()\n");
let graph = build(std::slice::from_ref(&entry));
let imported = graph
.imported_names_for_file(&entry)
.expect("entry imports should resolve");
assert!(imported.contains("mid_fn"));
assert!(!imported.contains("leaf_fn"));
let leaf_path = root.join("leaf.harn");
assert!(graph.definition_of(&leaf_path, "leaf_fn").is_some());
}
#[test]
fn imported_names_returns_none_when_import_unresolved() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let entry = write_file(root, "entry.harn", "import \"./does_not_exist\"\n");
let graph = build(std::slice::from_ref(&entry));
assert!(graph.imported_names_for_file(&entry).is_none());
}
#[test]
fn selective_imports_contribute_only_requested_names() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_file(root, "util.harn", "pub fn a() { 1 }\npub fn b() { 2 }\n");
let entry = write_file(root, "entry.harn", "import { a } from \"./util\"\n");
let graph = build(std::slice::from_ref(&entry));
let imported = graph
.imported_names_for_file(&entry)
.expect("entry imports should resolve");
assert!(imported.contains("a"));
assert!(!imported.contains("b"));
}
#[test]
fn stdlib_imports_resolve_to_embedded_sources() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let entry = write_file(root, "entry.harn", "import \"std/math\"\nclamp(5, 0, 10)\n");
let graph = build(std::slice::from_ref(&entry));
let imported = graph
.imported_names_for_file(&entry)
.expect("std/math should resolve");
assert!(imported.contains("clamp"));
}
#[test]
fn stdlib_imports_expose_type_declarations() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let entry = write_file(
root,
"entry.harn",
"import \"std/triggers\"\nlet provider = \"github\"\n",
);
let graph = build(std::slice::from_ref(&entry));
let decls = graph
.imported_type_declarations_for_file(&entry)
.expect("std/triggers type declarations should resolve");
let names: HashSet<String> = decls
.iter()
.filter_map(type_decl_name)
.map(ToString::to_string)
.collect();
assert!(names.contains("TriggerEvent"));
assert!(names.contains("ProviderPayload"));
assert!(names.contains("SignatureStatus"));
}
#[test]
fn package_export_map_resolves_declared_module() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let packages = root.join(".harn/packages/acme/runtime");
fs::create_dir_all(&packages).unwrap();
fs::write(
root.join(".harn/packages/acme/harn.toml"),
"[exports]\ncapabilities = \"runtime/capabilities.harn\"\n",
)
.unwrap();
fs::write(
packages.join("capabilities.harn"),
"pub fn exported_capability() { 1 }\n",
)
.unwrap();
let entry = write_file(
root,
"entry.harn",
"import \"acme/capabilities\"\nexported_capability()\n",
);
let graph = build(std::slice::from_ref(&entry));
let imported = graph
.imported_names_for_file(&entry)
.expect("package export should resolve");
assert!(imported.contains("exported_capability"));
}
#[test]
fn package_imports_resolve_from_nested_package_module() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".git")).unwrap();
fs::create_dir_all(root.join(".harn/packages/acme")).unwrap();
fs::create_dir_all(root.join(".harn/packages/shared")).unwrap();
fs::write(
root.join(".harn/packages/shared/lib.harn"),
"pub fn shared_helper() { 1 }\n",
)
.unwrap();
fs::write(
root.join(".harn/packages/acme/lib.harn"),
"import \"shared\"\npub fn use_shared() { shared_helper() }\n",
)
.unwrap();
let entry = write_file(root, "entry.harn", "import \"acme\"\nuse_shared()\n");
let graph = build(std::slice::from_ref(&entry));
let imported = graph
.imported_names_for_file(&entry)
.expect("nested package import should resolve");
assert!(imported.contains("use_shared"));
let acme_path = root.join(".harn/packages/acme/lib.harn");
let acme_imports = graph
.imported_names_for_file(&acme_path)
.expect("package module imports should resolve");
assert!(acme_imports.contains("shared_helper"));
}
#[test]
fn unknown_stdlib_import_is_unresolved() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let entry = write_file(root, "entry.harn", "import \"std/does_not_exist\"\n");
let graph = build(std::slice::from_ref(&entry));
assert!(
graph.imported_names_for_file(&entry).is_none(),
"unknown std module should fail resolution and disable strict check"
);
}
#[test]
fn import_cycles_do_not_loop_forever() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_file(root, "a.harn", "import \"./b\"\npub fn a_fn() { 1 }\n");
write_file(root, "b.harn", "import \"./a\"\npub fn b_fn() { 1 }\n");
let entry = root.join("a.harn");
let graph = build(std::slice::from_ref(&entry));
let imported = graph
.imported_names_for_file(&entry)
.expect("cyclic imports still resolve to known exports");
assert!(imported.contains("b_fn"));
}
#[test]
fn cross_directory_cycle_does_not_explode_module_count() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let context = root.join("context");
let runtime = root.join("runtime");
fs::create_dir_all(&context).unwrap();
fs::create_dir_all(&runtime).unwrap();
write_file(
&context,
"a.harn",
"import \"../runtime/b\"\npub fn a_fn() { 1 }\n",
);
write_file(
&runtime,
"b.harn",
"import \"../context/a\"\npub fn b_fn() { 1 }\n",
);
let entry = context.join("a.harn");
let graph = build(std::slice::from_ref(&entry));
assert_eq!(
graph.modules.len(),
2,
"cross-directory cycle loaded {} modules, expected 2",
graph.modules.len()
);
let imported = graph
.imported_names_for_file(&entry)
.expect("cyclic imports still resolve to known exports");
assert!(imported.contains("b_fn"));
}
}