use crate::core::backend::GeneratedFile;
use crate::core::config::{Language, ResolvedCrateConfig, ScaffoldCargo, ScaffoldCargoEnvValue};
use crate::core::ir::ApiSurface;
mod languages;
pub(crate) mod naming;
mod template_env;
pub use languages::render_csharp_csproj;
#[derive(Debug, Default)]
pub(crate) struct WorkspacePackageInheritance {
pub version: bool,
pub readme: bool,
pub keywords: bool,
pub categories: bool,
pub license: bool,
}
pub(crate) fn detect_workspace_inheritance(workspace_root: Option<&std::path::Path>) -> WorkspacePackageInheritance {
let cargo_toml_path = workspace_root
.map(|r| r.join("Cargo.toml"))
.unwrap_or_else(|| std::path::PathBuf::from("Cargo.toml"));
let Ok(contents) = std::fs::read_to_string(&cargo_toml_path) else {
return WorkspacePackageInheritance::default();
};
let Ok(doc) = contents.parse::<toml::Value>() else {
return WorkspacePackageInheritance::default();
};
let Some(workspace) = doc.get("workspace") else {
return WorkspacePackageInheritance::default();
};
let pkg = workspace.get("package");
WorkspacePackageInheritance {
version: pkg.map(|p| p.get("version").is_some()).unwrap_or(false),
readme: pkg.map(|p| p.get("readme").is_some()).unwrap_or(false),
keywords: pkg.map(|p| p.get("keywords").is_some()).unwrap_or(false),
categories: pkg.map(|p| p.get("categories").is_some()).unwrap_or(false),
license: pkg.map(|p| p.get("license").is_some()).unwrap_or(false),
}
}
pub(crate) fn cargo_package_header(
name: &str,
version: &str,
edition: &str,
meta: &ScaffoldMeta,
ws: &WorkspacePackageInheritance,
) -> String {
let version_line = if ws.version {
"version.workspace = true".to_string()
} else {
format!("version = \"{version}\"")
};
let edition_line = format!("edition = \"{edition}\"");
let license_line = if ws.license {
"license.workspace = true".to_string()
} else {
format!("license = \"{}\"", meta.license)
};
let readme_line = if ws.readme {
"readme.workspace = true".to_string()
} else {
"readme = false".to_string()
};
let keywords_line = if ws.keywords {
"keywords.workspace = true".to_string()
} else if meta.keywords.is_empty() {
"keywords = []".to_string()
} else {
let quoted: Vec<String> = meta.keywords.iter().map(|k| format!("\"{k}\"")).collect();
format!("keywords = [{}]", quoted.join(", "))
};
let categories_line = if ws.categories {
"categories.workspace = true".to_string()
} else if meta.categories.is_empty() {
"categories = [\"text-processing\"]".to_string()
} else {
let quoted: Vec<String> = meta.categories.iter().map(|k| format!("\"{k}\"")).collect();
format!("categories = [{}]", quoted.join(", "))
};
let lines = vec![
"[package]".to_string(),
format!("name = \"{name}\""),
version_line,
edition_line,
license_line,
format!("description = \"{}\"", meta.description),
readme_line,
keywords_line,
categories_line,
];
lines.join("\n")
}
pub(crate) fn to_pep440(version: &str) -> String {
if let Some((base, pre)) = version.split_once('-') {
let pep = pre
.replace("alpha.", "a")
.replace("alpha", "a")
.replace("beta.", "b")
.replace("beta", "b")
.replace("rc.", "rc")
.replace('.', "");
format!("{base}{pep}")
} else {
version.to_string()
}
}
pub(crate) fn render_core_dep(crate_name: &str, rel_path: &str, features: &str, version: &str) -> String {
if version.is_empty() {
format!("{crate_name} = {{ path = \"{rel_path}\"{features} }}")
} else {
format!("{crate_name} = {{ version = \"{version}\", path = \"{rel_path}\"{features} }}")
}
}
pub(crate) fn render_extra_deps(config: &ResolvedCrateConfig, lang: Language) -> String {
let deps = config.extra_deps_for_language(lang);
if deps.is_empty() {
return String::new();
}
let member_versions = workspace_member_versions(config);
let mut lines: Vec<String> = deps
.iter()
.map(|(name, value)| match value {
toml::Value::String(version) => format!("{name} = \"{version}\""),
toml::Value::Table(table) => {
let needs_version = table.contains_key("path") && !table.contains_key("version");
if let (true, Some(member_version)) = (needs_version, member_versions.get(name)) {
let mut injected = table.clone();
injected.insert("version".to_string(), toml::Value::String(member_version.clone()));
format!("{name} = {}", toml::Value::Table(injected))
} else {
format!("{name} = {value}")
}
}
other => format!("{name} = {other}"),
})
.collect();
lines.sort();
lines.join("\n")
}
fn workspace_member_versions(config: &ResolvedCrateConfig) -> std::collections::BTreeMap<String, String> {
let Some(root) = config.workspace_root.as_deref() else {
return std::collections::BTreeMap::new();
};
match crate::publish::workspace::workspace_member_crates(root) {
Ok(members) => members.versions,
Err(_) => std::collections::BTreeMap::new(),
}
}
pub(crate) fn core_dep_features(config: &ResolvedCrateConfig, lang: Language) -> String {
let features = config.features_for_language(lang);
if features.is_empty() {
String::new()
} else {
let quoted: Vec<String> = features.iter().map(|f| format!("\"{f}\"")).collect();
format!(", features = [{}]", quoted.join(", "))
}
}
pub fn scaffold(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
) -> anyhow::Result<Vec<GeneratedFile>> {
let mut files = vec![];
for &lang in languages {
files.extend(scaffold_language(api, config, lang)?);
}
files.extend(scaffold_pre_commit_config(config, languages));
files.extend(scaffold_license_files(config, languages));
if !std::path::Path::new("rust-toolchain.toml").exists() {
let targets = if languages.contains(&Language::Wasm) {
"\ntargets = [\"wasm32-unknown-unknown\"]\n"
} else {
"\n"
};
files.push(GeneratedFile {
path: std::path::PathBuf::from("rust-toolchain.toml"),
content: format!(
"[toolchain]\nchannel = \"1.95\"\ncomponents = [\"rust-src\", \"rustfmt\", \"clippy\"]\n{targets}"
),
generated_header: false,
});
}
if let Some(cargo) = config.scaffold.as_ref().and_then(|s| s.cargo.as_ref()) {
files.push(GeneratedFile {
path: std::path::PathBuf::from(".cargo/config.toml"),
content: render_cargo_config(cargo),
generated_header: true,
});
} else if languages.contains(&Language::Wasm) && !std::path::Path::new(".cargo/config.toml").exists() {
files.push(GeneratedFile {
path: std::path::PathBuf::from(".cargo/config.toml"),
content: "[build]\nincremental = true\n\n[target.wasm32-unknown-unknown]\nrustflags = [\"-C\", \"target-feature=+bulk-memory\", \"--cfg\", \"getrandom_backend=\\\"wasm_js\\\"\"]\n\n[net]\ngit-fetch-with-cli = true\n\n[registries.crates-io]\nprotocol = \"sparse\"\n".to_string(),
generated_header: false,
});
}
if !std::path::Path::new(".typos.toml").exists() {
files.push(GeneratedFile {
path: std::path::PathBuf::from(".typos.toml"),
content: "[files]\nextend-exclude = [\"target/\", \".alef/\", \"*.lock\", \"*.min.js\"]\n\n[default.extend-words]\n# Add project-specific words here\n# crate_name = \"crate_name\"\n".to_string(),
generated_header: false,
});
}
files.extend(scaffold_gitattributes(config, languages));
Ok(files)
}
pub fn render_cargo_config(cargo: &ScaffoldCargo) -> String {
let mut out = String::new();
out.push_str("# This file is auto-generated by alef. DO NOT EDIT.\n");
out.push_str("# Re-generate with: alef scaffold\n");
out.push('\n');
out.push_str("[build]\nincremental = true\n");
if cargo.build_jobs > 0 {
out.push_str(&format!("jobs = {}\n", cargo.build_jobs));
}
out.push('\n');
out.push_str("[net]\ngit-fetch-with-cli = true\n\n");
out.push_str("[registries.crates-io]\nprotocol = \"sparse\"\n");
let t = &cargo.targets;
if t.macos_dynamic_lookup {
out.push_str(
"\n# Required for PyO3 / ext-php-rs cdylibs: Python and Zend C-API symbols are\n\
# resolved at runtime when the host loads the extension, not at link time.\n\
# macOS ld is strict and rejects unresolved symbols by default.\n\
[target.'cfg(target_os = \"macos\")']\n\
rustflags = [\"-C\", \"link-arg=-Wl,-undefined,dynamic_lookup\"]\n",
);
}
if t.x86_64_pc_windows_msvc {
out.push_str("\n[target.x86_64-pc-windows-msvc]\nlinker = \"rust-lld\"\n");
}
if t.i686_pc_windows_msvc {
out.push_str("\n[target.i686-pc-windows-msvc]\nlinker = \"rust-lld\"\n");
}
if t.aarch64_unknown_linux_gnu {
out.push_str("\n[target.aarch64-unknown-linux-gnu]\nlinker = \"aarch64-linux-gnu-gcc\"\n");
}
if t.x86_64_unknown_linux_musl {
out.push_str("\n[target.x86_64-unknown-linux-musl]\nlinker = \"musl-gcc\"\n");
}
if t.wasm32_unknown_unknown {
out.push_str(
"\n[target.wasm32-unknown-unknown]\n\
rustflags = [\"-C\", \"target-feature=+bulk-memory\", \"--cfg\", \"getrandom_backend=\\\"wasm_js\\\"\"]\n",
);
}
if !cargo.env.is_empty() {
out.push_str("\n[env]\n");
let mut keys: Vec<&String> = cargo.env.keys().collect();
keys.sort();
for key in keys {
let value = &cargo.env[key];
match value {
ScaffoldCargoEnvValue::Plain(s) => {
out.push_str(&template_env::render(
"cargo_env_plain.jinja",
minijinja::context! { key => key, value => escape_toml_string(s) },
));
}
ScaffoldCargoEnvValue::Structured { value, relative } => {
out.push_str(&template_env::render(
"cargo_env_structured.jinja",
minijinja::context! {
key => key,
value => escape_toml_string(value),
relative => relative,
},
));
}
}
}
}
out
}
fn escape_toml_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
pub struct ScaffoldMeta {
pub description: String,
pub license: String,
pub repository: String,
pub configured_repository: Option<String>,
pub homepage: String,
pub documentation: String,
pub issues: String,
pub funding: String,
pub authors: Vec<String>,
pub keywords: Vec<String>,
pub categories: Vec<String>,
}
pub fn scaffold_meta(config: &ResolvedCrateConfig) -> ScaffoldMeta {
let scaffold = config.scaffold.as_ref();
let package = config.package_metadata.as_ref();
let truncate = package.map(|p| p.truncate_registry_lists).unwrap_or(false);
let configured_repository = package
.and_then(|p| p.repository.clone())
.or_else(|| scaffold.and_then(|s| s.repository.clone()));
let mut keywords = package
.filter(|p| !p.keywords.is_empty())
.map(|p| p.keywords.clone())
.or_else(|| scaffold.map(|s| s.keywords.clone()))
.unwrap_or_default();
let mut categories = package.map(|p| p.categories.clone()).unwrap_or_default();
keywords.sort();
categories.sort();
if truncate {
keywords.truncate(5);
categories.truncate(5);
}
ScaffoldMeta {
description: package
.and_then(|p| p.description.clone())
.or_else(|| scaffold.and_then(|s| s.description.clone()))
.unwrap_or_else(|| format!("Bindings for {}", config.name)),
license: package
.and_then(|p| p.license.clone())
.or_else(|| scaffold.and_then(|s| s.license.clone()))
.unwrap_or_else(|| "MIT".to_string()),
repository: package
.and_then(|p| p.repository.clone())
.or_else(|| scaffold.and_then(|s| s.repository.clone()))
.unwrap_or_else(|| format!("https://example.invalid/{}", config.name)),
configured_repository,
homepage: package
.and_then(|p| p.homepage.clone())
.or_else(|| scaffold.and_then(|s| s.homepage.clone()))
.unwrap_or_default(),
documentation: package.and_then(|p| p.documentation.clone()).unwrap_or_default(),
issues: package.and_then(|p| p.issues.clone()).unwrap_or_default(),
funding: package.and_then(|p| p.funding.clone()).unwrap_or_default(),
authors: package
.filter(|p| !p.authors.is_empty())
.map(|p| p.authors.clone())
.or_else(|| scaffold.map(|s| s.authors.clone()))
.unwrap_or_default(),
keywords,
categories,
}
}
pub fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
pub fn parse_author(s: &str) -> (&str, &str) {
if let Some(start) = s.find('<') {
if let Some(end) = s.find('>') {
let name = s[..start].trim();
let email = &s[start + 1..end];
return (name, email);
}
}
(s.trim(), "")
}
pub(crate) fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
}
fn scaffold_license_files(config: &ResolvedCrateConfig, languages: &[Language]) -> Vec<GeneratedFile> {
let license_path = config
.workspace_root
.as_deref()
.map(|r| r.join("LICENSE"))
.unwrap_or_else(|| std::path::PathBuf::from("LICENSE"));
let license_content = match std::fs::read_to_string(&license_path) {
Ok(content) => content,
Err(_) => {
tracing::warn!(
"No LICENSE file found at {} — skipping LICENSE sync into package directories",
license_path.display()
);
return vec![];
}
};
let mut seen = std::collections::BTreeSet::new();
let mut files = vec![];
for &lang in languages {
match lang {
Language::Rust | Language::C | Language::Ffi | Language::Jni => continue,
_ => {}
}
let pkg_dir = config.package_dir(lang);
if seen.insert(pkg_dir.clone()) {
files.push(GeneratedFile {
path: std::path::PathBuf::from(format!("{pkg_dir}/LICENSE")),
content: license_content.clone(),
generated_header: false,
});
}
}
files
}
fn scaffold_gitattributes(config: &ResolvedCrateConfig, languages: &[Language]) -> Vec<GeneratedFile> {
let mut dirs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for &lang in languages {
match lang {
Language::Rust | Language::C => {}
Language::Ffi => {
dirs.insert(format!("crates/{}-ffi", config.name));
}
Language::Jni => {
dirs.insert(format!("crates/{}-jni", config.name));
}
Language::Python => {
dirs.insert(config.package_dir(lang));
dirs.insert(format!("crates/{}-py", config.name));
}
Language::Php => {
dirs.insert(config.package_dir(lang));
dirs.insert(format!("crates/{}-php", config.name));
}
Language::Kotlin => {
let dir = if let Some(k) = config.kotlin.as_ref() {
if k.mode.as_deref() == Some("kmp") || k.target == crate::core::config::KotlinTarget::Multiplatform
{
"packages/kotlin-mpp".to_string()
} else if k.target == crate::core::config::KotlinTarget::Native {
"packages/kotlin-native".to_string()
} else {
config.package_dir(lang)
}
} else {
config.package_dir(lang)
};
dirs.insert(dir);
}
Language::Node => {
let dir = config
.node
.as_ref()
.and_then(|c| c.crate_dir.as_ref())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("crates/{}-node", config.name));
dirs.insert(dir);
}
_ => {
dirs.insert(config.package_dir(lang));
}
}
}
let e2e_dir = config.e2e.as_ref().map(|e| e.output.as_str()).unwrap_or("e2e");
dirs.insert(e2e_dir.to_string());
let test_apps_dir = config
.e2e
.as_ref()
.map(|e| e.registry.output.as_str())
.unwrap_or("test_apps");
dirs.insert(test_apps_dir.to_string());
let mut content = String::from("# Generated by alef scaffold.\n");
for dir in dirs {
let dir = dir.trim_end_matches('/');
content.push_str(&format!("{dir}/** linguist-generated=true\n"));
}
vec![GeneratedFile {
path: std::path::PathBuf::from(".gitattributes"),
content,
generated_header: false,
}]
}
use languages::{
scaffold_csharp, scaffold_dart, scaffold_elixir, scaffold_elixir_cargo, scaffold_ffi, scaffold_gleam, scaffold_go,
scaffold_java, scaffold_jni, scaffold_kotlin, scaffold_node, scaffold_node_cargo, scaffold_php, scaffold_php_cargo,
scaffold_pre_commit_config, scaffold_python, scaffold_python_cargo, scaffold_r, scaffold_r_cargo, scaffold_ruby,
scaffold_ruby_cargo, scaffold_swift, scaffold_wasm, scaffold_zig,
};
fn scaffold_language(
api: &ApiSurface,
config: &ResolvedCrateConfig,
lang: Language,
) -> anyhow::Result<Vec<GeneratedFile>> {
match lang {
Language::Python => {
let mut files = scaffold_python(api, config)?;
files.extend(scaffold_python_cargo(api, config)?);
Ok(files)
}
Language::Node => {
let mut files = scaffold_node(api, config)?;
files.extend(scaffold_node_cargo(api, config)?);
Ok(files)
}
Language::Ffi => scaffold_ffi(api, config),
Language::Go => scaffold_go(api, config),
Language::Java => scaffold_java(api, config),
Language::Csharp => scaffold_csharp(api, config),
Language::Ruby => {
let mut files = scaffold_ruby(api, config)?;
files.extend(scaffold_ruby_cargo(api, config)?);
Ok(files)
}
Language::Php => {
let mut files = scaffold_php(api, config)?;
files.extend(scaffold_php_cargo(api, config)?);
Ok(files)
}
Language::Elixir => {
let mut files = scaffold_elixir(api, config)?;
files.extend(scaffold_elixir_cargo(api, config)?);
Ok(files)
}
Language::Wasm => scaffold_wasm(api, config),
Language::R => {
let mut files = scaffold_r(api, config)?;
files.extend(scaffold_r_cargo(api, config)?);
Ok(files)
}
Language::Rust | Language::C => Ok(vec![]), Language::Jni => scaffold_jni(api, config),
Language::Kotlin => scaffold_kotlin(api, config),
Language::KotlinAndroid => Ok(vec![]),
Language::Gleam => scaffold_gleam(api, config),
Language::Zig => scaffold_zig(api, config),
Language::Dart => scaffold_dart(api, config),
Language::Swift => scaffold_swift(api, config),
}
}
#[cfg(test)]
mod tests;