use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use crate::errors::Diagnostic;
#[derive(Debug)]
pub struct Project {
pub root: PathBuf,
pub entries: Vec<PathBuf>,
pub files: Vec<PathBuf>,
pub dependencies: HashMap<PathBuf, Vec<PathBuf>>,
pub compile_order: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub enum EntrySource {
Explicit(PathBuf),
Convention(PathBuf),
}
#[derive(Debug)]
pub enum ProjectError {
NoEntryPoint { root: PathBuf },
EntryNotFound { path: PathBuf },
CircularImport { cycle: Vec<PathBuf> },
ImportNotFound { from: PathBuf, import_path: String },
Io(std::io::Error),
}
impl std::fmt::Display for ProjectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProjectError::NoEntryPoint { root } => {
write!(
f,
"No entry point found in {}\n\n\
Create src/main.hk or specify a file:\n\
\n\
horkos compile src/infrastructure.hk -o terraform/\n",
root.display()
)
}
ProjectError::EntryNotFound { path } => {
write!(f, "Entry point not found: {}", path.display())
}
ProjectError::CircularImport { cycle } => {
let cycle_str: Vec<_> = cycle.iter().map(|p| p.display().to_string()).collect();
write!(f, "Circular import detected:\n {}", cycle_str.join(" → "))
}
ProjectError::ImportNotFound { from, import_path } => {
write!(
f,
"Cannot resolve import '{}' from {}",
import_path,
from.display()
)
}
ProjectError::Io(e) => write!(f, "IO error: {}", e),
}
}
}
impl std::error::Error for ProjectError {}
impl From<std::io::Error> for ProjectError {
fn from(e: std::io::Error) -> Self {
ProjectError::Io(e)
}
}
impl Project {
pub fn discover(
root: impl AsRef<Path>,
explicit_entry: Option<impl AsRef<Path>>,
) -> Result<Self, ProjectError> {
let root = root.as_ref().to_path_buf();
let entry = Self::find_entry_point(&root, explicit_entry)?;
let mut files = Vec::new();
let mut dependencies = HashMap::new();
Self::discover_files_recursive(&entry, &root, &mut files, &mut dependencies)?;
let compile_order = Self::topological_sort(&files, &dependencies)?;
Ok(Project {
root,
entries: vec![entry],
files,
dependencies,
compile_order,
})
}
fn find_entry_point(
root: &Path,
explicit: Option<impl AsRef<Path>>,
) -> Result<PathBuf, ProjectError> {
if let Some(path) = explicit {
let path = path.as_ref();
let full_path = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
if full_path.exists() {
return Ok(full_path.canonicalize()?);
}
return Err(ProjectError::EntryNotFound { path: full_path });
}
let main_path = root.join("src/main.hk");
if main_path.exists() {
return Ok(main_path.canonicalize()?);
}
let main_root = root.join("main.hk");
if main_root.exists() {
return Ok(main_root.canonicalize()?);
}
Err(ProjectError::NoEntryPoint {
root: root.to_path_buf(),
})
}
fn discover_files_recursive(
file: &Path,
project_root: &Path,
files: &mut Vec<PathBuf>,
dependencies: &mut HashMap<PathBuf, Vec<PathBuf>>,
) -> Result<(), ProjectError> {
let canonical = file.canonicalize()?;
if files.contains(&canonical) {
return Ok(());
}
files.push(canonical.clone());
let source = std::fs::read_to_string(&canonical)?;
let imports = Self::extract_imports(&source);
let mut deps = Vec::new();
for import_path in imports {
if import_path.ends_with(".tf") {
continue;
}
let resolved = Self::resolve_import(&import_path, &canonical, project_root)?;
deps.push(resolved.clone());
Self::discover_files_recursive(&resolved, project_root, files, dependencies)?;
}
dependencies.insert(canonical, deps);
Ok(())
}
fn extract_imports(source: &str) -> Vec<String> {
let mut imports = Vec::new();
for line in source.lines() {
let line = line.trim();
if line.starts_with("import ") {
if let Some(start) = line.find('"') {
if let Some(end) = line[start + 1..].find('"') {
let path = &line[start + 1..start + 1 + end];
imports.push(path.to_string());
}
}
}
}
imports
}
fn resolve_import(
import_path: &str,
from_file: &Path,
project_root: &Path,
) -> Result<PathBuf, ProjectError> {
let from_dir = from_file.parent().unwrap_or(project_root);
if import_path.starts_with("./") || import_path.starts_with("../") {
let resolved = from_dir.join(import_path);
if resolved.exists() {
return Ok(resolved.canonicalize()?);
}
let with_ext = from_dir.join(format!("{}.hk", import_path.trim_end_matches(".hk")));
if with_ext.exists() {
return Ok(with_ext.canonicalize()?);
}
return Err(ProjectError::ImportNotFound {
from: from_file.to_path_buf(),
import_path: import_path.to_string(),
});
}
let from_src = project_root.join("src").join(import_path);
if from_src.exists() {
return Ok(from_src.canonicalize()?);
}
let from_root = project_root.join(import_path);
if from_root.exists() {
return Ok(from_root.canonicalize()?);
}
Err(ProjectError::ImportNotFound {
from: from_file.to_path_buf(),
import_path: import_path.to_string(),
})
}
fn topological_sort(
files: &[PathBuf],
dependencies: &HashMap<PathBuf, Vec<PathBuf>>,
) -> Result<Vec<PathBuf>, ProjectError> {
let mut in_degree: HashMap<PathBuf, usize> = HashMap::new();
let mut reverse_deps: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
for file in files {
in_degree.entry(file.clone()).or_insert(0);
reverse_deps.entry(file.clone()).or_default();
}
for (file, deps) in dependencies {
for dep in deps {
if files.contains(dep) {
*in_degree.entry(file.clone()).or_insert(0) += 1;
reverse_deps
.entry(dep.clone())
.or_default()
.push(file.clone());
}
}
}
let mut queue: VecDeque<PathBuf> = in_degree
.iter()
.filter(|(_, deg)| **deg == 0)
.map(|(f, _)| f.clone())
.collect();
let mut order = Vec::new();
while let Some(file) = queue.pop_front() {
order.push(file.clone());
if let Some(dependents) = reverse_deps.get(&file) {
for dependent in dependents {
if let Some(deg) = in_degree.get_mut(dependent) {
*deg -= 1;
if *deg == 0 {
queue.push_back(dependent.clone());
}
}
}
}
}
if order.len() != files.len() {
let in_cycle: Vec<_> = files
.iter()
.filter(|f| !order.contains(f))
.cloned()
.collect();
return Err(ProjectError::CircularImport { cycle: in_cycle });
}
Ok(order)
}
pub fn files_to_compile(&self) -> &[PathBuf] {
&self.compile_order
}
pub fn is_entry(&self, file: &Path) -> bool {
self.entries.iter().any(|e| e == file)
}
}
pub fn compile_project(
root: impl AsRef<Path>,
entry: Option<impl AsRef<Path>>,
output_dir: impl AsRef<Path>,
) -> Result<(), Vec<Diagnostic>> {
let root = root.as_ref();
let output_dir = output_dir.as_ref();
let project =
Project::discover(root, entry).map_err(|e| vec![Diagnostic::error(e.to_string())])?;
std::fs::create_dir_all(output_dir).map_err(|e| {
vec![Diagnostic::error(format!(
"Failed to create output directory: {}",
e
))]
})?;
let mut globals = crate::GlobalSymbolTable::new();
for file in project.dependencies.keys() {
for import_path in
Project::extract_imports(&std::fs::read_to_string(file).unwrap_or_default())
{
if import_path.ends_with(".hk") {
if let Ok(resolved) = Project::resolve_import(&import_path, file, &project.root) {
globals.register_import_path(&import_path, &resolved);
}
}
}
}
let mut all_hcl = String::new();
let mut all_overrides = Vec::new();
let options = crate::CompileOptions::default();
for file in project.files_to_compile() {
let source = std::fs::read_to_string(file).map_err(|e| {
vec![Diagnostic::error(format!(
"Failed to read {}: {}",
file.display(),
e
))]
})?;
let filename = file.to_string_lossy();
let (hcl, typed_ast, overrides) =
crate::compile_and_extract(&source, &filename, &options, Some(&globals))?;
all_overrides.extend(overrides);
let exports = crate::extract_exports(&typed_ast, file);
globals.register(exports);
all_hcl.push_str(&format!(
"# =============================================================================\n\
# Generated from: {}\n\
# =============================================================================\n\n",
file.strip_prefix(root).unwrap_or(file).display()
));
all_hcl.push_str(&hcl);
all_hcl.push_str("\n\n");
}
for override_info in &all_overrides {
eprintln!(
" \x1b[36minfo\x1b[0m: {} disabled for {} (recommended: {})",
override_info.param_name, override_info.resource_name, override_info.recommended
);
}
let output_file = output_dir.join("main.tf");
std::fs::write(&output_file, &all_hcl).map_err(|e| {
vec![Diagnostic::error(format!(
"Failed to write {}: {}",
output_file.display(),
e
))]
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_project() -> TempDir {
let dir = TempDir::new().unwrap();
let src = dir.path().join("src");
fs::create_dir_all(&src).unwrap();
fs::write(
src.join("main.hk"),
r#"
import "./network/vpc.hk" as vpc
val sg = Network.createSecurityGroup(vpc: vpc.mainVpc, name: "web")
"#,
)
.unwrap();
let network = src.join("network");
fs::create_dir_all(&network).unwrap();
fs::write(
network.join("vpc.hk"),
r#"
val mainVpc = Network.createVpc("main", cidr: "10.0.0.0/16")
"#,
)
.unwrap();
dir
}
#[test]
fn test_find_entry_point_convention() {
let dir = create_test_project();
let entry = Project::find_entry_point(dir.path(), None::<&Path>).unwrap();
assert!(entry.ends_with("main.hk"));
}
#[test]
fn test_find_entry_point_explicit() {
let dir = create_test_project();
let entry = Project::find_entry_point(dir.path(), Some("src/network/vpc.hk")).unwrap();
assert!(entry.ends_with("vpc.hk"));
}
#[test]
fn test_extract_imports() {
let source = r#"
import "./network/vpc.hk" as vpc
import "legacy.tf" as legacy
val x = 42
import "./storage/s3.hk" as s3
"#;
let imports = Project::extract_imports(source);
assert_eq!(
imports,
vec!["./network/vpc.hk", "legacy.tf", "./storage/s3.hk",]
);
}
#[test]
fn test_discover_project() {
let dir = create_test_project();
let project = Project::discover(dir.path(), None::<&Path>).unwrap();
assert_eq!(project.files.len(), 2);
assert_eq!(project.compile_order.len(), 2);
let vpc_idx = project
.compile_order
.iter()
.position(|p| p.ends_with("vpc.hk"))
.unwrap();
let main_idx = project
.compile_order
.iter()
.position(|p| p.ends_with("main.hk"))
.unwrap();
assert!(
vpc_idx < main_idx,
"vpc.hk should be compiled before main.hk"
);
}
#[test]
fn test_circular_import_detected() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("src");
fs::create_dir_all(&src).unwrap();
fs::write(src.join("main.hk"), r#"import "./a.hk" as a"#).unwrap();
fs::write(src.join("a.hk"), r#"import "./b.hk" as b"#).unwrap();
fs::write(src.join("b.hk"), r#"import "./a.hk" as a"#).unwrap();
let result = Project::discover(dir.path(), None::<&Path>);
assert!(matches!(result, Err(ProjectError::CircularImport { .. })));
}
}