use alef_core::backend::GeneratedFile;
use alef_core::config::{Language, ResolvedCrateConfig};
use alef_core::hash;
use alef_core::ir::ApiSurface;
use anyhow::Context as _;
use rayon::prelude::*;
use std::path::Path;
use tracing::{debug, info};
use crate::cache;
use crate::registry;
pub fn generate(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
clean: bool,
) -> anyhow::Result<Vec<(Language, Vec<GeneratedFile>)>> {
let has_ffi = languages.contains(&Language::Ffi);
for &lang in languages {
if (lang == Language::Go || lang == Language::Java || lang == Language::Csharp) && !has_ffi {
tracing::warn!(
"Language {:?} requires FFI to be in the languages list for proper code generation",
lang
);
}
}
let ir_json = serde_json::to_string(api)?;
let config_toml =
toml::to_string(config).with_context(|| "failed to serialize resolved crate config for cache key")?;
let to_generate: Vec<_> = languages
.par_iter()
.filter_map(|&lang| {
let lang_str = lang.to_string();
let lang_hash = cache::compute_lang_hash(&ir_json, &lang_str, &config_toml);
if !clean && cache::is_lang_cached(&config.name, &lang_str, &lang_hash) {
debug!(" {}: cached, skipping", lang_str);
return None;
}
Some((lang, lang_str, lang_hash))
})
.collect();
let results: Vec<(Language, Vec<GeneratedFile>)> = to_generate
.par_iter()
.map(|(lang, lang_str, lang_hash)| {
let backend = registry::get_backend(*lang);
info!(" {}: generating...", lang_str);
let files = backend
.generate_bindings(api, config)
.with_context(|| format!("failed to generate bindings for {lang_str}"))?;
let base_dir = std::env::current_dir().unwrap_or_default();
let output_paths: Vec<std::path::PathBuf> = files.iter().map(|f| base_dir.join(&f.path)).collect();
cache::write_lang_hash(&config.name, lang_str, lang_hash, &output_paths)
.with_context(|| format!("failed to write language hash for {lang_str}"))?;
Ok((*lang, files))
})
.collect::<anyhow::Result<_>>()?;
Ok(results)
}
pub fn generate_stubs(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
) -> anyhow::Result<Vec<(Language, Vec<GeneratedFile>)>> {
let results: Vec<(Language, Vec<GeneratedFile>)> = languages
.par_iter()
.map(|&lang| {
let backend = registry::get_backend(lang);
let files = backend.generate_type_stubs(api, config)?;
Ok((lang, files))
})
.collect::<anyhow::Result<Vec<_>>>()?
.into_iter()
.filter(|(_, files)| !files.is_empty())
.collect();
Ok(results)
}
pub fn generate_public_api(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
) -> anyhow::Result<Vec<(Language, Vec<GeneratedFile>)>> {
let results: Vec<(Language, Vec<GeneratedFile>)> = languages
.par_iter()
.map(|&lang| {
let backend = registry::get_backend(lang);
let files = backend.generate_public_api(api, config)?;
Ok((lang, files))
})
.collect::<anyhow::Result<Vec<_>>>()?
.into_iter()
.filter(|(_, files)| !files.is_empty())
.collect();
Ok(results)
}
pub fn write_files(files: &[(Language, Vec<GeneratedFile>)], base_dir: &Path) -> anyhow::Result<usize> {
let dirs: std::collections::BTreeSet<_> = files
.iter()
.flat_map(|(_, lang_files)| lang_files.iter())
.filter_map(|f| base_dir.join(&f.path).parent().map(|p| p.to_path_buf()))
.collect();
for dir in &dirs {
std::fs::create_dir_all(dir).with_context(|| format!("failed to create directory {}", dir.display()))?;
}
let all_files: Vec<_> = files.iter().flat_map(|(_, lang_files)| lang_files.iter()).collect();
all_files.par_iter().try_for_each(|file| -> anyhow::Result<()> {
let full_path = base_dir.join(&file.path);
let normalized = normalize_content(&file.path, &file.content);
std::fs::write(&full_path, &normalized)
.with_context(|| format!("failed to write generated file {}", full_path.display()))?;
debug!(" wrote: {}", full_path.display());
Ok(())
})?;
Ok(all_files.len())
}
pub fn finalize_hashes(
paths: &std::collections::HashSet<std::path::PathBuf>,
sources_hash: &str,
) -> anyhow::Result<usize> {
let updated: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
paths.par_iter().try_for_each(|path| -> anyhow::Result<()> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return Ok(()),
};
let has_marker = content
.lines()
.take(10)
.any(|line| line.contains("auto-generated by alef"));
if !has_marker {
return Ok(());
}
let stripped = hash::strip_hash_line(&content);
let file_hash = hash::compute_file_hash(sources_hash, &stripped);
let final_content = hash::inject_hash_line(&stripped, &file_hash);
if final_content == content {
return Ok(());
}
std::fs::write(path, &final_content)
.with_context(|| format!("failed to finalize hash for {}", path.display()))?;
updated.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(())
})?;
Ok(updated.into_inner())
}
pub fn diff_files(files: &[(Language, Vec<GeneratedFile>)], base_dir: &Path) -> anyhow::Result<Vec<String>> {
let all_items: Vec<_> = files
.iter()
.flat_map(|(lang, lang_files)| lang_files.iter().map(move |f| (*lang, f)))
.collect();
let diffs: Vec<String> = all_items
.par_iter()
.filter_map(|(lang, file)| {
let full_path = base_dir.join(&file.path);
let existing = std::fs::read_to_string(&full_path).unwrap_or_default();
let is_rust = file.path.extension().is_some_and(|ext| ext == "rs");
let generated = normalize_content(&file.path, &file.content);
let on_disk = if is_rust {
format_rust_content(&existing)
} else {
existing
};
let on_disk_body = hash::strip_hash_line(&on_disk);
if normalize_whitespace(&on_disk_body) != normalize_whitespace(&generated) {
Some(format!("[{lang}] {}", file.path.display()))
} else {
None
}
})
.collect();
Ok(diffs)
}
pub fn normalize_content(path: &Path, content: &str) -> String {
let pre = if path.extension().is_some_and(|ext| ext == "rs") {
format_rust_content(content)
} else {
content.to_string()
};
normalize_whitespace(&pre)
}
fn normalize_whitespace(content: &str) -> String {
let mut result = String::with_capacity(content.len());
let mut blank_count = 0;
for line in content.lines() {
let trimmed = line.trim_end();
if trimmed.is_empty() {
blank_count += 1;
if blank_count <= 2 {
result.push('\n');
}
} else {
blank_count = 0;
result.push_str(trimmed);
result.push('\n');
}
}
while result.ends_with("\n\n") {
result.pop();
}
if !result.ends_with('\n') {
result.push('\n');
}
result
}
pub fn scaffold(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
) -> anyhow::Result<Vec<GeneratedFile>> {
alef_scaffold::scaffold(api, config, languages)
}
pub fn readme(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
) -> anyhow::Result<Vec<GeneratedFile>> {
alef_readme::generate_readmes(api, config, languages)
}
pub fn write_scaffold_files(files: &[GeneratedFile], base_dir: &Path) -> anyhow::Result<usize> {
write_scaffold_files_with_overwrite(files, base_dir, false)
}
pub fn write_scaffold_files_with_overwrite(
files: &[GeneratedFile],
base_dir: &Path,
overwrite: bool,
) -> anyhow::Result<usize> {
let mut count = 0;
for file in files {
let full_path = base_dir.join(&file.path);
if !overwrite && full_path.exists() {
debug!(" skipped (already exists): {}", full_path.display());
continue;
}
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
let normalized = normalize_content(&full_path, &file.content);
std::fs::write(&full_path, &normalized)
.with_context(|| format!("failed to write generated file {}", full_path.display()))?;
count += 1;
debug!(" wrote: {}", full_path.display());
}
Ok(count)
}
pub fn sweep_orphans(
roots: &[std::path::PathBuf],
keep: &std::collections::HashSet<std::path::PathBuf>,
) -> anyhow::Result<usize> {
fn is_alef_owned(path: &std::path::Path) -> bool {
let Ok(file) = std::fs::File::open(path) else {
return false;
};
use std::io::{BufRead, BufReader};
let reader = BufReader::new(file);
for (idx, line) in reader.lines().enumerate() {
if idx >= 10 {
break;
}
if let Ok(line) = line
&& line.contains("auto-generated by alef")
{
return true;
}
}
false
}
let mut removed = 0usize;
let mut touched_dirs: std::collections::BTreeSet<std::path::PathBuf> = std::collections::BTreeSet::new();
for root in roots {
if !root.exists() {
continue;
}
let mut stack = vec![root.clone()];
while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(it) => it,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if file_type.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if matches!(
name,
".git"
| "target"
| "node_modules"
| "vendor"
| "_build"
| "deps"
| ".venv"
| "venv"
| "build"
| "dist"
| "Pods"
) {
continue;
}
stack.push(path);
continue;
}
if !file_type.is_file() {
continue;
}
if keep.contains(&path) {
continue;
}
if !is_alef_owned(&path) {
continue;
}
if let Err(err) = std::fs::remove_file(&path) {
debug!(" sweep skip (remove failed): {} ({err})", path.display());
continue;
}
debug!(" swept orphan: {}", path.display());
if let Some(parent) = path.parent() {
touched_dirs.insert(parent.to_path_buf());
}
removed += 1;
}
}
}
let mut dirs: Vec<_> = touched_dirs.into_iter().collect();
dirs.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
for dir in dirs {
let _ = std::fs::remove_dir(&dir);
}
if removed > 0 {
info!("Swept {removed} orphan generated file(s)");
}
Ok(removed)
}
pub fn format_rust_content(content: &str) -> String {
use std::io::Write;
use std::process::{Command, Stdio};
let config_dir = std::env::current_dir().unwrap_or_default();
let mut child = match Command::new("rustfmt")
.arg("--edition")
.arg("2024")
.arg("--config-path")
.arg(&config_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => {
debug!("rustfmt not available: {e}");
return content.to_string();
}
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(content.as_bytes());
}
match child.wait_with_output() {
Ok(output) if output.status.success() => {
String::from_utf8(output.stdout).unwrap_or_else(|_| content.to_string())
}
Ok(output) => {
debug!("rustfmt failed: {}", String::from_utf8_lossy(&output.stderr));
content.to_string()
}
Err(e) => {
debug!("rustfmt process error: {e}");
content.to_string()
}
}
}
#[cfg(test)]
mod write_scaffold_normalize_tests {
use super::*;
use alef_core::backend::GeneratedFile;
use std::path::PathBuf;
fn make_file(name: &str, content: &str) -> GeneratedFile {
GeneratedFile {
path: PathBuf::from(name),
content: content.to_owned(),
generated_header: false,
}
}
#[test]
fn test_scaffold_write_normalizes_trailing_whitespace_and_newline() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
let content = "line one \nline two\n\n";
let files = vec![make_file("out.py", content)];
write_scaffold_files_with_overwrite(&files, base, true).expect("write ok");
let written = std::fs::read_to_string(base.join("out.py")).expect("read ok");
assert_eq!(
written, "line one\nline two\n",
"trailing whitespace must be stripped and single newline ensured"
);
}
#[test]
fn test_scaffold_write_adds_missing_trailing_newline() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
let files = vec![make_file("out.gleam", "pub fn main() {}")];
write_scaffold_files_with_overwrite(&files, base, true).expect("write ok");
let written = std::fs::read_to_string(base.join("out.gleam")).expect("read ok");
assert!(
written.ends_with('\n'),
"file must end with newline, got: {:?}",
written
);
}
#[test]
fn test_scaffold_write_does_not_add_double_trailing_newline() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
let files = vec![make_file("out.zig", "const x = 1;\n")];
write_scaffold_files_with_overwrite(&files, base, true).expect("write ok");
let written = std::fs::read_to_string(base.join("out.zig")).expect("read ok");
assert!(!written.ends_with("\n\n"), "must not have double trailing newline");
assert!(written.ends_with('\n'));
}
#[test]
fn test_normalize_content_strips_trailing_whitespace_when_rustfmt_fails() {
let path = PathBuf::from("packages/r/src/rust/src/lib.rs");
let content = "extendr_module! {\n fn convert(\n \n title: String = \"\",\n );\n}\n";
let normalized = normalize_content(&path, content);
for (i, line) in normalized.lines().enumerate() {
assert_eq!(
line.trim_end(),
line,
"line {i} has trailing whitespace after normalize: {line:?}"
);
}
assert!(normalized.ends_with('\n'), "must end with newline");
}
#[test]
fn test_sweep_orphans_removes_only_alef_marked_files_outside_keep_set() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
let nested = base.join("e2e/elixir/test");
std::fs::create_dir_all(&nested).expect("mkdir");
let alef_marker = "# This file is auto-generated by alef — DO NOT EDIT.\n# alef:hash:abc\n";
let kept = nested.join("keep_test.exs");
let orphan = nested.join("orphan_test.exs");
let user_owned = nested.join("user_helper.exs");
std::fs::write(&kept, format!("{alef_marker}defmodule Keep do\nend\n")).unwrap();
std::fs::write(&orphan, format!("{alef_marker}defmodule Orphan do\nend\n")).unwrap();
std::fs::write(&user_owned, "defmodule UserHelper do\nend\n").unwrap();
let mut keep = std::collections::HashSet::new();
keep.insert(kept.clone());
let removed = sweep_orphans(&[base.to_path_buf()], &keep).expect("sweep ok");
assert_eq!(removed, 1, "should remove exactly one orphan");
assert!(kept.exists(), "kept alef-marked file must remain");
assert!(!orphan.exists(), "orphan alef-marked file must be removed");
assert!(user_owned.exists(), "user-owned (no marker) file must remain");
}
#[test]
fn test_sweep_orphans_skips_dependency_directories() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
let alef_marker = "// auto-generated by alef\n// alef:hash:def\n";
for skip_dir in ["target", "node_modules", "_build", "vendor"] {
let nested = base.join(skip_dir).join("nested");
std::fs::create_dir_all(&nested).expect("mkdir");
std::fs::write(nested.join("orphan.rs"), alef_marker).unwrap();
}
let keep: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
let removed = sweep_orphans(&[base.to_path_buf()], &keep).expect("sweep ok");
assert_eq!(removed, 0, "must not descend into dependency directories");
}
}