use anyhow::{bail, Result};
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
pub fn render(src: &str, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let parsed = parse(src)?;
let mut written = render_dispatch(&parsed, out, force)?;
if !parsed.captured_files.is_empty() {
let restored = crate::file_capture::restore(out, &parsed.captured_files)?;
for p in restored { written.push(p); }
}
if !parsed.captured_symlinks.is_empty() {
let linked = crate::file_capture::restore_symlinks(out, &parsed.captured_symlinks)?;
for p in linked { written.push(p); }
}
if !parsed.captured_binaries.is_empty() {
let bin = crate::file_capture::restore_binaries(out, &parsed.captured_binaries)?;
for p in bin { written.push(p); }
}
if matches!(parsed.ecosystem.as_str(), "rust-single-crate" | "rust-workspace") {
let auto = out.join(".github").join("workflows").join("auto-release.yml");
if auto.exists() && !rust_has_bumpable_version(out) {
if rust_workspace_has_members(out) {
if let Some(yaml) = auto_release_yaml_for(&parsed.ecosystem, Some(out)) {
std::fs::write(&auto, yaml)?;
}
} else {
let _ = std::fs::remove_file(&auto);
written.retain(|p| *p != auto);
}
}
}
Ok(written)
}
fn render_dispatch(parsed: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
match parsed.ecosystem.as_str() {
"rust-single-crate" => render_rust_single(parsed, out, force),
"rust-workspace" => render_rust_workspace(parsed, out, force),
"npm" => render_npm(parsed, out, force),
"python" => render_python(parsed, out, force),
"helm" => render_helm(parsed, out, force),
"github-action" => render_github_action(parsed, out, force),
"nix-flake" => render_nix_flake(parsed, out, force),
"tlisp-library" => render_tlisp_library(parsed, out, force),
"go" => render_go(parsed, out, force),
"crystal" => render_crystal(parsed, out, force),
"dart" => render_dart(parsed, out, force),
"composer" => render_composer(parsed, out, force),
"julia" => render_julia(parsed, out, force),
"java-maven" => render_maven(parsed, out, force),
"dotnet-csproj" => render_csproj(parsed, out, force),
"ocaml-dune" => render_dune(parsed, out, force),
"java-gradle-kts" => render_gradle_kts(parsed, out, force),
"swift-spm" => render_swift_spm(parsed, out, force),
"elixir-mix" => render_elixir_mix(parsed, out, force),
"ruby-gem" => render_ruby_gem(parsed, out, force),
"zig" => render_zig(parsed, out, force),
"nim-nimble" => render_nim_nimble(parsed, out, force),
"scala-sbt" => render_sbt(parsed, out, force),
"clojure-deps" => render_clj_deps(parsed, out, force),
"r-description" => render_r(parsed, out, force),
"lua-rockspec" => render_rockspec(parsed, out, force),
"cpp-conan" => render_conan(parsed, out, force),
"python-conda" => render_conda(parsed, out, force),
"python-pipenv" => render_pipenv(parsed, out, force),
"python-pdm" => render_pdm(parsed, out, force),
"js-deno" => render_deno(parsed, out, force),
"js-pnpm" => render_pnpm(parsed, out, force),
"cpp-vcpkg" => render_vcpkg(parsed, out, force),
"cpp-meson" => render_meson(parsed, out, force),
"cpp-cmake" => render_cmake(parsed, out, force),
"fortran-fpm" => render_fpm(parsed, out, force),
"gleam" => render_gleam(parsed, out, force),
"ada-alire" => render_alire(parsed, out, force),
"haskell-cabal" => render_cabal(parsed, out, force),
"racket-info" => render_racket(parsed, out, force),
other => bail!(
"ecosystem '{other}' not supported. 33 supported: \
rust-single-crate, rust-workspace, npm, python, helm, \
github-action, go, crystal, dart, composer, julia, \
java-maven, dotnet-csproj, ocaml-dune, java-gradle-kts, \
swift-spm, elixir-mix, ruby-gem, zig, nim-nimble, \
scala-sbt, clojure-deps, r-description, lua-rockspec, cpp-conan, \
python-conda, python-pipenv, python-pdm, js-deno, js-pnpm, \
cpp-vcpkg, cpp-meson, fortran-fpm, gleam, ada-alire, \
haskell-cabal, racket-info."
),
}
}
fn render_go(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::lined_ast::Line;
let mut written = vec![];
fs::create_dir_all(out)?;
let gomod = out.join("go.mod");
if !gomod.exists() || force {
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let module_path = pkg.get("module-path").cloned()
.or_else(|| pkg.get("repository").cloned()
.map(|r| r.trim_start_matches("https://").trim_start_matches("http://").to_string()))
.unwrap_or_else(|| {
let mut p = String::from("github.com/pleme-io/");
p.push_str(&name);
p
});
let go_ver = pkg.get("go-version").cloned()
.or_else(|| c.supports_raw.as_deref()
.and_then(|r| read_keyword_in_block(r, ":go"))
.map(|v| v.trim_matches('"').trim_start_matches(">=").to_string()))
.unwrap_or_else(|| "1.22".to_string());
let mut lines: Vec<Line> = vec![
Line::Directive { keyword: "module".into(), args: vec![module_path] },
Line::Blank,
Line::Directive { keyword: "go".into(), args: vec![go_ver] },
];
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() {
let inner: Vec<Line> = deps.runtime.iter()
.filter_map(|d| {
let n = d.get("name")?;
let v = d.get("version")?;
Some(Line::Directive {
keyword: n.clone(),
args: vec![v.clone()],
})
})
.collect();
lines.push(Line::Blank);
lines.push(Line::Group { keyword: "require".into(), inner });
}
crate::ast::emit(&gomod, &crate::lined_ast::Lines(lines))?;
written.push(gomod);
}
let name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("go").write(out, &name, force)? {
written.push(f);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_crystal(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::yaml_ast::Value as YVal;
let mut written = vec![];
fs::create_dir_all(out)?;
let shard = out.join("shard.yml");
if !shard.exists() || force {
let pkg = &c.package;
let mut m = YVal::map();
if let Some(n) = pkg.get("name") { m.insert("name", YVal::s(n)); }
if let Some(v) = pkg.get("version") { m.insert("version", YVal::s(v)); }
if let Some(l) = pkg.get("license") { m.insert("license", YVal::s(l)); }
if let Some(authors) = pkg.get("authors") {
m.insert("authors", YVal::arr([YVal::s(authors)]));
}
if let Some(supports) = c.supports_raw.as_deref() {
if let Some(cr) = read_keyword_in_block(supports, ":crystal") {
m.insert("crystal", YVal::s(cr.trim_matches('"')));
}
}
crate::ast::emit(&shard, &m)?;
written.push(shard);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("crystal").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_dart(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::yaml_ast::Value as YVal;
let mut written = vec![];
fs::create_dir_all(out)?;
let pubspec = out.join("pubspec.yaml");
if !pubspec.exists() || force {
let pkg = &c.package;
let mut m = YVal::map();
if let Some(n) = pkg.get("name") { m.insert("name", YVal::s(n)); }
if let Some(v) = pkg.get("version") { m.insert("version", YVal::s(v)); }
if let Some(d) = pkg.get("description") { m.insert("description", YVal::s(d)); }
if let Some(r) = pkg.get("repository") { m.insert("repository", YVal::s(r)); }
if let Some(supports) = c.supports_raw.as_deref() {
if let Some(sdk) = read_keyword_in_block(supports, ":dart") {
let mut env = YVal::map();
env.insert("sdk", YVal::s(sdk.trim_matches('"')));
m.insert("environment", env);
}
}
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() {
let mut rt = YVal::map();
for d in &deps.runtime {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
rt.insert(n, YVal::s(v));
}
}
m.insert("dependencies", rt);
}
if !deps.dev.is_empty() {
let mut dv = YVal::map();
for d in &deps.dev {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
dv.insert(n, YVal::s(v));
}
}
m.insert("dev_dependencies", dv);
}
crate::ast::emit(&pubspec, &m)?;
written.push(pubspec);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("dart").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_composer(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::json_ast::Value as JVal;
let mut written = vec![];
fs::create_dir_all(out)?;
let composer = out.join("composer.json");
if !composer.exists() || force {
let pkg = &c.package;
let mut root = JVal::obj();
if let Some(n) = pkg.get("name") { root.insert("name", JVal::s(n)); }
if let Some(d) = pkg.get("description") { root.insert("description", JVal::s(d)); }
root.insert("type", JVal::s("library"));
if let Some(l) = pkg.get("license") { root.insert("license", JVal::s(l)); }
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
let php_req = c.supports_raw.as_deref()
.and_then(|r| read_keyword_in_block(r, ":php"))
.map(|v| v.trim_matches('"').to_string())
.unwrap_or_else(|| ">=8.2".to_string());
let mut require = JVal::obj();
require.insert("php", JVal::s(php_req));
for d in &deps.runtime {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
require.insert(n, JVal::s(v));
}
}
root.insert("require", require);
if !deps.dev.is_empty() {
let mut dev = JVal::obj();
for d in &deps.dev {
if let Some(n) = d.get("name") {
let v = d.get("version").cloned().unwrap_or_else(|| "*".to_string());
dev.insert(n, JVal::s(v));
}
}
root.insert("require-dev", dev);
}
crate::ast::emit(&composer, &root)?;
written.push(composer);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("composer").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">")
}
fn render_maven(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::xml_ast::Element;
let mut written = vec![];
fs::create_dir_all(out)?;
let pom = out.join("pom.xml");
if !pom.exists() || force {
let pkg = &c.package;
let group = pkg.get("group-id").cloned().unwrap_or_else(|| "io.pleme".to_string());
let artifact = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let mut project = Element::new("project")
.attr("xmlns", "http://maven.apache.org/POM/4.0.0")
.attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
.attr("xsi:schemaLocation", "http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd")
.child(Element::new("modelVersion").text("4.0.0"))
.child(Element::new("groupId").text(group))
.child(Element::new("artifactId").text(artifact))
.child(Element::new("version").text(version))
.child(Element::new("packaging").text("jar"));
if let Some(d) = pkg.get("description") {
project.push(Element::new("description").text(d));
}
if let Some(l) = pkg.get("license") {
let lic = Element::new("license").child(Element::new("name").text(l));
project.push(Element::new("licenses").child(lic));
}
if let Some(r) = pkg.get("repository") {
project.push(Element::new("scm").child(Element::new("url").text(r)));
}
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() || !deps.dev.is_empty() {
let mut dependencies = Element::new("dependencies");
for d in &deps.runtime {
if let Some(n) = d.get("name") {
let v = d.get("version").cloned().unwrap_or_default();
let g = d.get("group-id").cloned().unwrap_or_else(|| "org.example".to_string());
let mut dep = Element::new("dependency")
.child(Element::new("groupId").text(g))
.child(Element::new("artifactId").text(n));
if !v.is_empty() {
dep.push(Element::new("version").text(v));
}
dependencies.push(dep);
}
}
for d in &deps.dev {
if let Some(n) = d.get("name") {
let v = d.get("version").cloned().unwrap_or_default();
let g = d.get("group-id").cloned().unwrap_or_else(|| "org.example".to_string());
let mut dep = Element::new("dependency")
.child(Element::new("groupId").text(g))
.child(Element::new("artifactId").text(n));
if !v.is_empty() {
dep.push(Element::new("version").text(v));
}
dep.push(Element::new("scope").text("test"));
dependencies.push(dep);
}
}
project.push(dependencies);
}
crate::ast::emit(&pom, &project)?;
written.push(pom);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_csproj(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::xml_ast::Element;
let mut written = vec![];
fs::create_dir_all(out)?;
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let path = out.join(format!("{name}.csproj"));
if !path.exists() || force {
let target = c.supports_raw.as_deref()
.and_then(|r| read_keyword_in_block(r, ":dotnet"))
.map(|v| v.trim_matches('"').to_string())
.unwrap_or_else(|| "net8.0".to_string());
let output_type = if c.kind == "Binario" { "Exe" } else { "Library" };
let mut props = Element::new("PropertyGroup")
.child(Element::new("TargetFramework").text(target))
.child(Element::new("OutputType").text(output_type));
if let Some(v) = pkg.get("version") { props.push(Element::new("Version").text(v)); }
if let Some(a) = pkg.get("authors") { props.push(Element::new("Authors").text(a)); }
if let Some(d) = pkg.get("description") { props.push(Element::new("Description").text(d)); }
if let Some(l) = pkg.get("license") { props.push(Element::new("PackageLicenseExpression").text(l)); }
if let Some(r) = pkg.get("repository") { props.push(Element::new("RepositoryUrl").text(r)); }
let mut project = Element::new("Project")
.attr("Sdk", "Microsoft.NET.Sdk")
.child(props);
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() || !deps.dev.is_empty() {
let mut items = Element::new("ItemGroup");
for d in &deps.runtime {
if let Some(n) = d.get("name") {
let v = d.get("version").cloned().unwrap_or_default();
items.push(Element::new("PackageReference").attr("Include", n).attr("Version", v));
}
}
for d in &deps.dev {
if let Some(n) = d.get("name") {
let v = d.get("version").cloned().unwrap_or_default();
items.push(
Element::new("PackageReference")
.attr("Include", n).attr("Version", v).attr("PrivateAssets", "all"),
);
}
}
project.push(items);
}
crate::ast::emit(&path, &crate::xml_ast::ElementNoDecl(project))?;
written.push(path);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("dotnet-csproj").write(out, &pkg_name, force)? {
written.push(f);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_dune(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::sexp_ast::SExp;
let mut written = vec![];
fs::create_dir_all(out)?;
let dune = out.join("dune-project");
if !dune.exists() || force {
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let mut forms: Vec<SExp> = vec![
SExp::list([SExp::sym("lang"), SExp::sym("dune"), SExp::sym("3.14")]),
SExp::list([SExp::sym("name"), SExp::sym(&name)]),
];
if let Some(v) = pkg.get("version") {
forms.push(SExp::list([SExp::sym("version"), SExp::sym(v)]));
}
forms.push(SExp::list([SExp::sym("generate_opam_files"), SExp::sym("true")]));
if let Some(r) = pkg.get("repository") {
if r.contains("github.com") {
let gh = r.trim_start_matches("https://github.com/").trim_end_matches(".git");
forms.push(SExp::list([
SExp::sym("source"),
SExp::list([SExp::sym("github"), SExp::sym(gh)]),
]));
}
}
if let Some(l) = pkg.get("license") {
forms.push(SExp::list([SExp::sym("license"), SExp::sym(l)]));
}
forms.push(SExp::list([SExp::sym("authors"), SExp::str("pleme-io")]));
forms.push(SExp::list([SExp::sym("maintainers"), SExp::str("engineering@pleme.io")]));
let ocaml_req = c.supports_raw.as_deref()
.and_then(|r| read_keyword_in_block(r, ":ocaml"))
.map(|v| v.trim_matches('"').trim_start_matches(">=").to_string())
.unwrap_or_else(|| "4.14".to_string());
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
let mut depends: Vec<SExp> = vec![
SExp::sym("depends"),
SExp::list([
SExp::sym("ocaml"),
SExp::list([SExp::sym(">="), SExp::sym(&ocaml_req)]),
]),
SExp::sym("dune"),
];
for d in &deps.runtime {
if let Some(n) = d.get("name") { depends.push(SExp::sym(n)); }
}
for d in &deps.dev {
if let Some(n) = d.get("name") {
depends.push(SExp::list([SExp::sym(n), SExp::kw("with-test")]));
}
}
let mut pkg_form: Vec<SExp> = vec![
SExp::sym("package"),
SExp::list([SExp::sym("name"), SExp::sym(&name)]),
];
if let Some(d) = pkg.get("description") {
pkg_form.push(SExp::list([SExp::sym("synopsis"), SExp::str(d)]));
}
pkg_form.push(SExp::List(depends));
forms.push(SExp::List(pkg_form));
crate::ast::emit(&dune, &crate::sexp_ast::Forms(forms))?;
written.push(dune);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("ocaml-dune").write(out, &pkg_name, force)? {
written.push(f);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_gradle_kts(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let mut written = vec![];
fs::create_dir_all(out)?;
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let group = pkg.get("group-id").cloned().unwrap_or_else(|| "io.pleme".to_string());
let java_ver = c.supports_raw.as_deref()
.and_then(|r| read_keyword_in_block(r, ":java"))
.map(|v| v.trim_matches('"').trim_start_matches(">=").to_string())
.unwrap_or_else(|| "17".to_string());
use crate::kotlin_ast::{Expr, File as KFile, Stmt};
let settings = out.join("settings.gradle.kts");
if !settings.exists() || force {
let mut f = KFile::new();
f.push(Stmt::Assign { name: "rootProject.name".into(), value: Expr::s(&name) });
crate::ast::emit(&settings, &f)?;
written.push(settings);
}
let build = out.join("build.gradle.kts");
if !build.exists() || force {
let is_app = c.kind == "Binario";
let plugin_expr = if is_app {
Expr::ident("application")
} else {
Expr::backtick("java-library")
};
let mut f = KFile::new();
f.push(Stmt::Block {
name: "plugins".into(),
body: vec![Stmt::Expr(plugin_expr)],
inline: true,
});
f.push(Stmt::Blank);
f.push(Stmt::Assign { name: "group".into(), value: Expr::s(&group) });
f.push(Stmt::Assign { name: "version".into(), value: Expr::s(&version) });
f.push(Stmt::Blank);
f.push(Stmt::Block {
name: "repositories".into(),
body: vec![Stmt::Expr(Expr::call("mavenCentral", vec![]))],
inline: true,
});
f.push(Stmt::Blank);
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
let mut dep_body: Vec<Stmt> = Vec::new();
for d in &deps.runtime {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
let g = d.get("group-id").cloned().unwrap_or_else(|| "com.example".to_string());
let mut coord = g; coord.push(':'); coord.push_str(n); coord.push(':'); coord.push_str(v);
dep_body.push(Stmt::Expr(Expr::call("implementation", vec![Expr::s(coord)])));
}
}
for d in &deps.dev {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
let g = d.get("group-id").cloned().unwrap_or_else(|| "com.example".to_string());
let mut coord = g; coord.push(':'); coord.push_str(n); coord.push(':'); coord.push_str(v);
dep_body.push(Stmt::Expr(Expr::call("testImplementation", vec![Expr::s(coord)])));
}
}
f.push(Stmt::Block { name: "dependencies".into(), body: dep_body, inline: false });
f.push(Stmt::Blank);
let java_ver_int: i64 = java_ver.parse().unwrap_or(17);
let toolchain = Stmt::Block {
name: "toolchain".into(),
body: vec![Stmt::Assign {
name: "languageVersion".into(),
value: Expr::method(
Expr::ident("JavaLanguageVersion"), "of", vec![Expr::i(java_ver_int)],
),
}],
inline: true,
};
f.push(Stmt::Block { name: "java".into(), body: vec![toolchain], inline: true });
crate::ast::emit(&build, &f)?;
written.push(build);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("java-gradle-kts").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_swift_spm(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::swift_ast::{Expr, File as SFile, Stmt};
let mut written = vec![];
fs::create_dir_all(out)?;
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let pkg_swift = out.join("Package.swift");
if !pkg_swift.exists() || force {
let tools_ver = c.supports_raw.as_deref()
.and_then(|r| read_keyword_in_block(r, ":swift-tools"))
.map(|v| v.trim_matches('"').to_string())
.unwrap_or_else(|| "5.9".to_string());
let is_exe = c.kind == "Binario";
let platforms = Expr::arr([
Expr::call(".macOS", vec![Expr::pos(Expr::dot("v13"))]),
]);
let product_call = if is_exe { ".executable" } else { ".library" };
let products = Expr::arr([Expr::call(product_call, vec![
Expr::lbl("name", Expr::s(&name)),
Expr::lbl("targets", Expr::arr([Expr::s(&name)])),
])]);
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
let dep_items: Vec<Expr> = deps.runtime.iter().filter_map(|d| {
let n = d.get("name")?;
let v = d.get("version")?;
let url = d.get("repository").cloned().unwrap_or_else(|| {
let mut u = String::from("https://github.com/");
u.push_str(n); u.push('/'); u.push_str(n); u
});
Some(Expr::call(".package", vec![
Expr::lbl("url", Expr::s(url)),
Expr::lbl("from", Expr::s(v)),
]))
}).collect();
let target_kind = if is_exe { ".executableTarget" } else { ".target" };
let targets = Expr::arr([Expr::call(target_kind, vec![
Expr::lbl("name", Expr::s(&name)),
])]);
let mut package_args = vec![
Expr::lbl("name", Expr::s(&name)),
Expr::lbl("platforms", platforms),
Expr::lbl("products", products),
];
if !dep_items.is_empty() {
package_args.push(Expr::lbl("dependencies", Expr::arr(dep_items)));
}
package_args.push(Expr::lbl("targets", targets));
let mut f = SFile::new();
f.tools(tools_ver).import("PackageDescription");
f.push(Stmt::Let {
name: "package".into(),
value: Expr::call("Package", package_args),
});
crate::ast::emit(&pkg_swift, &f)?;
written.push(pkg_swift);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("swift-spm").write(out, &pkg_name, force)? {
written.push(f);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_elixir_mix(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::elixir_ast::{Expr, Module as EModule, Stmt};
let mut written = vec![];
fs::create_dir_all(out)?;
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let mix = out.join("mix.exs");
if !mix.exists() || force {
let module_suffix: String = name.replace('-', "_")
.split('_').map(|p| {
let mut chars = p.chars();
match chars.next() {
Some(c) => c.to_uppercase().chain(chars).collect(),
None => String::new(),
}
}).collect::<String>();
let mut module_name = module_suffix.clone();
module_name.push_str(".MixProject");
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let elixir_req = c.supports_raw.as_deref()
.and_then(|r| read_keyword_in_block(r, ":elixir"))
.map(|v| v.trim_matches('"').to_string())
.unwrap_or_else(|| "~> 1.16".to_string());
let desc = pkg.get("description").cloned().unwrap_or_default();
let license = pkg.get("license").cloned().unwrap_or_else(|| "MIT".to_string());
let app_atom = name.replace('-', "_");
let project_kw = Expr::kwlist(vec![
("app".into(), Expr::atom(&app_atom)),
("version".into(), Expr::s(version)),
("elixir".into(), Expr::s(elixir_req)),
("description".into(), Expr::s(desc)),
("package".into(), Expr::kwlist(vec![
("licenses".into(), Expr::list([Expr::s(license)])),
])),
("deps".into(), Expr::call("deps", vec![])),
]);
let app_kw = Expr::kwlist(vec![
("extra_applications".into(), Expr::list([Expr::atom("logger")])),
]);
let parsed_deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
let mut dep_items: Vec<Expr> = vec![];
for d in &parsed_deps.runtime {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
dep_items.push(Expr::tuple(vec![Expr::atom(n), Expr::s(v)]));
}
}
for d in &parsed_deps.dev {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
dep_items.push(Expr::tuple(vec![
Expr::atom(n),
Expr::s(v),
Expr::ident("only: :dev"),
Expr::ident("runtime: false"),
]));
}
}
let deps_list = Expr::list(dep_items);
let mut m = EModule::new(module_name);
m.push(Stmt::Use("Mix.Project".into()));
m.push(Stmt::Blank);
m.push(Stmt::Def { name: "project".into(), body: project_kw });
m.push(Stmt::Blank);
m.push(Stmt::Def { name: "application".into(), body: app_kw });
m.push(Stmt::Blank);
m.push(Stmt::Defp { name: "deps".into(), body: deps_list });
crate::ast::emit(&mix, &m)?;
written.push(mix);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("elixir-mix").write(out, &pkg_name, force)? {
written.push(f);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_ruby_gem(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::ruby_ast::{Block, Expr, Stmt};
let mut written = vec![];
fs::create_dir_all(out)?;
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let gemspec = out.join(format!("{name}.gemspec"));
if !gemspec.exists() || force {
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let desc = pkg.get("description").cloned().unwrap_or_default();
let license = pkg.get("license").cloned().unwrap_or_else(|| "MIT".to_string());
let homepage = pkg.get("homepage").or_else(|| pkg.get("repository"))
.cloned()
.unwrap_or_else(|| {
let mut h = String::from("https://github.com/pleme-io/");
h.push_str(&name);
h
});
let ruby_req = c.supports_raw.as_deref()
.and_then(|r| read_keyword_in_block(r, ":ruby"))
.map(|v| v.trim_matches('"').to_string())
.unwrap_or_else(|| ">= 3.0".to_string());
let assign = |attr: &str, value: Expr| Stmt::Assign {
receiver: "spec".to_string(),
attr: attr.to_string(),
value,
};
let method = |m: &str, args: Vec<Expr>| Stmt::MethodCall {
receiver: "spec".to_string(),
method: m.to_string(),
args,
};
let mut block = Block::new("Gem::Specification.new", "spec");
block.push(assign("name", Expr::s(&name)));
block.push(assign("version", Expr::s(version)));
block.push(assign("authors", Expr::arr([Expr::s("pleme-io")])));
block.push(assign("email", Expr::arr([Expr::s("engineering@pleme.io")])));
block.push(assign("summary", Expr::s(&desc)));
block.push(assign("description", Expr::s(&desc)));
block.push(assign("license", Expr::s(license)));
block.push(assign("homepage", Expr::s(homepage)));
block.push(assign("files", Expr::raw("Dir['lib/**/*.rb']")));
block.push(assign("required_ruby_version", Expr::s(ruby_req)));
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
for d in &deps.runtime {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
block.push(method("add_dependency", vec![Expr::s(n), Expr::s(v)]));
}
}
for d in &deps.dev {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
block.push(method("add_development_dependency",
vec![Expr::s(n), Expr::s(v)]));
}
}
crate::ast::emit(&gemspec, &block)?;
written.push(gemspec);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("ruby-gem").write(out, &pkg_name, force)? {
written.push(f);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_zig(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::zig_ast::{Expr, File as ZFile, Stmt};
let mut written = vec![];
fs::create_dir_all(out)?;
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let min_zig = c.supports_raw.as_deref()
.and_then(|r| read_keyword_in_block(r, ":zig"))
.map(|v| v.trim_matches('"').to_string())
.unwrap_or_else(|| "0.14.0".to_string());
let zon = out.join("build.zig.zon");
if !zon.exists() || force {
let safe_name = name.replace('-', "_");
let root = Expr::strct(vec![
("name".into(), Expr::dot(&safe_name)),
("version".into(), Expr::s(version.clone())),
("minimum_zig_version".into(), Expr::s(min_zig)),
("dependencies".into(), Expr::strct(vec![])),
("paths".into(), Expr::tup([
Expr::s("build.zig"),
Expr::s("build.zig.zon"),
Expr::s("src"),
])),
]);
std::fs::write(&zon, ZFile::render_zon(&root))?;
written.push(zon);
}
let build = out.join("build.zig");
if !build.exists() || force {
let is_exe = c.kind == "Binario";
let kind_fn = if is_exe { "addExecutable" } else { "addLibrary" };
let mut f = ZFile::new();
f.push(Stmt::Const {
name: "std".into(),
value: Expr::call("@import", vec![Expr::s("std")]),
});
f.push(Stmt::Blank);
let body = vec![
Stmt::Const {
name: "target".into(),
value: Expr::method(Expr::ident("b"), "standardTargetOptions",
vec![Expr::strct(vec![])]),
},
Stmt::Const {
name: "optimize".into(),
value: Expr::method(Expr::ident("b"), "standardOptimizeOption",
vec![Expr::strct(vec![])]),
},
Stmt::Const {
name: "artifact".into(),
value: Expr::method(Expr::ident("b"), kind_fn, vec![
Expr::strct(vec![
("name".into(), Expr::s(&name)),
("root_source_file".into(),
Expr::method(Expr::ident("b"), "path", vec![Expr::s("src/main.zig")])),
("target".into(), Expr::ident("target")),
("optimize".into(), Expr::ident("optimize")),
]),
]),
},
Stmt::Expr(Expr::method(Expr::ident("b"), "installArtifact",
vec![Expr::ident("artifact")])),
];
f.push(Stmt::Fn { sig: "pub fn build(b: *std.Build) void".into(), body });
crate::ast::emit(&build, &f)?;
written.push(build);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_nim_nimble(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::lined_ast::Line;
use crate::toml_ast::Value as TVal;
let mut written = vec![];
fs::create_dir_all(out)?;
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let nimble = out.join(format!("{name}.nimble"));
if !nimble.exists() || force {
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let desc = pkg.get("description").cloned().unwrap_or_default();
let license = pkg.get("license").cloned().unwrap_or_else(|| "MIT".to_string());
let nim_req = c.supports_raw.as_deref()
.and_then(|r| read_keyword_in_block(r, ":nim"))
.map(|v| v.trim_matches('"').trim_start_matches(">=").trim().to_string())
.unwrap_or_else(|| "2.0.0".to_string());
let mut lines: Vec<Line> = vec![
assign("version", &version),
assign("author", "pleme-io"),
assign("description", &desc),
assign("license", &license),
assign("srcDir", "src"),
];
if c.kind == "Binario" {
let arr_lit = TVal::arr([TVal::s(&name)]).render_inline();
let mut nim_array = String::from("@");
nim_array.push_str(&arr_lit);
lines.push(Line::Directive {
keyword: "bin".to_string(),
args: vec!["=".to_string(), nim_array],
});
}
lines.push(Line::Blank);
lines.push(requires_line("nim", &nim_req));
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
for d in &deps.runtime {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
lines.push(requires_line(n, v));
}
}
crate::ast::emit(&nimble, &crate::lined_ast::Lines(lines))?;
written.push(nimble);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("nim-nimble").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn assign(key: &str, value: &str) -> crate::lined_ast::Line {
use crate::lined_ast::Line;
use crate::toml_ast::Value as TVal;
Line::Directive {
keyword: key.to_string(),
args: vec!["=".to_string(), TVal::s(value).render_inline()],
}
}
fn requires_line(name: &str, ver: &str) -> crate::lined_ast::Line {
use crate::lined_ast::Line;
use crate::toml_ast::Value as TVal;
let mut constraint = String::from(name);
constraint.push_str(" >= ");
constraint.push_str(ver);
Line::Directive {
keyword: "requires".to_string(),
args: vec![TVal::s(constraint).render_inline()],
}
}
fn render_sbt(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::scala_ast::{Expr, File as SFile, Stmt};
let mut written = vec![];
fs::create_dir_all(out)?;
let pkg = &c.package;
let build = out.join("build.sbt");
if !build.exists() || force {
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let scala_ver = c.supports_raw.as_deref()
.and_then(|r| read_keyword_in_block(r, ":scala"))
.map(|v| v.trim_matches('"').to_string())
.unwrap_or_else(|| "3.4.0".to_string());
let mut f = SFile::new();
f.push(Stmt::Set { key: "name".into(), value: Expr::s(&name) });
f.push(Stmt::Set { key: "version".into(), value: Expr::s(version) });
f.push(Stmt::Set { key: "scalaVersion".into(), value: Expr::s(scala_ver) });
if let Some(d) = pkg.get("description") {
f.push(Stmt::Set { key: "description".into(), value: Expr::s(d) });
}
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() {
let items: Vec<Expr> = deps.runtime.iter().filter_map(|d| {
let n = d.get("name")?;
let v = d.get("version")?;
let g = d.get("group-id").cloned().unwrap_or_else(|| "org.example".to_string());
Some(Expr::infix("%", vec![
Expr::infix("%%", vec![Expr::s(g), Expr::s(n)]),
Expr::s(v),
]))
}).collect();
f.push(Stmt::Blank);
f.push(Stmt::Append {
key: "libraryDependencies".into(),
value: Expr::call("Seq", items),
});
}
crate::ast::emit(&build, &f)?;
written.push(build);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("scala-sbt").write(out, &pkg_name, force)? {
written.push(f);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_clj_deps(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::sexp_ast::SExp;
let mut written = vec![];
fs::create_dir_all(out)?;
let deps_edn = out.join("deps.edn");
if !deps_edn.exists() || force {
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
let mut deps_map: Vec<(SExp, SExp)> = vec![];
for d in &deps.runtime {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
let inner = SExp::map_([(SExp::kw("mvn/version"), SExp::str(v))]);
deps_map.push((SExp::sym(n), inner));
}
}
let root = SExp::map_([
(SExp::kw("paths"), SExp::vec_([SExp::str("src"), SExp::str("resources")])),
(SExp::kw("deps"), SExp::Map(deps_map)),
]);
crate::ast::emit(&deps_edn, &crate::sexp_ast::Forms(vec![root]))?;
written.push(deps_edn);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("clojure-deps").write(out, &pkg_name, force)? {
written.push(f);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_r(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::control_ast::Line;
let mut written = vec![];
fs::create_dir_all(out)?;
let desc = out.join("DESCRIPTION");
if !desc.exists() || force {
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let mut lines: Vec<Line> = vec![
Line::Field { key: "Package".into(), value: name },
Line::Field { key: "Version".into(), value: version },
Line::Field { key: "Type".into(), value: "Package".into() },
];
if let Some(d) = pkg.get("description") {
lines.push(Line::Field { key: "Title".into(), value: d.clone() });
lines.push(Line::Field { key: "Description".into(), value: d.clone() });
}
if let Some(l) = pkg.get("license") {
lines.push(Line::Field { key: "License".into(), value: l.clone() });
}
lines.push(Line::Field {
key: "Authors@R".into(),
value: r#"person("pleme-io", role = c("aut", "cre"))"#.into(),
});
lines.push(Line::Field { key: "Encoding".into(), value: "UTF-8".into() });
lines.push(Line::Field { key: "RoxygenNote".into(), value: "7.3.1".into() });
crate::ast::emit(&desc, &crate::control_ast::Document::from(1, lines))?;
written.push(desc);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("r-description").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_rockspec(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::lua_ast::{Expr, File as LFile, Stmt};
let mut written = vec![];
fs::create_dir_all(out)?;
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let rockspec = out.join(format!("{name}-{version}-1.rockspec"));
if !rockspec.exists() || force {
let mut source = Expr::table();
source.insert("url", Expr::s(""));
let mut description = Expr::table();
if let Some(d) = pkg.get("description") { description.insert("summary", Expr::s(d)); }
if let Some(l) = pkg.get("license") { description.insert("license", Expr::s(l)); }
let mut build = Expr::table();
build.insert("type", Expr::s("builtin"));
build.insert("modules", Expr::table());
let mut vstr = version.clone(); vstr.push_str("-1");
let mut f = LFile::new();
f.push(Stmt::Set { key: "package".into(), value: Expr::s(&name) });
f.push(Stmt::Set { key: "version".into(), value: Expr::s(vstr) });
f.push(Stmt::Set { key: "source".into(), value: source });
f.push(Stmt::Set { key: "description".into(), value: description });
f.push(Stmt::Set {
key: "dependencies".into(),
value: Expr::arr([Expr::s("lua >= 5.1")]),
});
f.push(Stmt::Set { key: "build".into(), value: build });
crate::ast::emit(&rockspec, &f)?;
written.push(rockspec);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("lua-rockspec").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_conan(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::python_ast::{Class, Expr, File as PFile, Stmt};
let mut written = vec![];
fs::create_dir_all(out)?;
let conanfile = out.join("conanfile.py");
if !conanfile.exists() || force {
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let class_name: String = name.replace('-', "_").split('_').map(|p| {
let mut chars = p.chars();
chars.next().map(|c| c.to_uppercase().chain(chars).collect::<String>()).unwrap_or_default()
}).collect::<String>() + "Conan";
let mut f = PFile::new();
f.push(Stmt::Import {
module: "conan".into(),
names: vec!["ConanFile".into()],
});
let mut cls = Class::new(class_name, vec!["ConanFile".into()]);
cls.push(Stmt::Assign { name: "name".into(), value: Expr::s(&name) });
cls.push(Stmt::Assign { name: "version".into(), value: Expr::s(version) });
if let Some(l) = pkg.get("license") {
cls.push(Stmt::Assign { name: "license".into(), value: Expr::s(l) });
}
cls.push(Stmt::Assign {
name: "settings".into(),
value: Expr::tuple(vec![
Expr::s("os"), Expr::s("compiler"),
Expr::s("build_type"), Expr::s("arch"),
]),
});
f.class(cls);
crate::ast::emit(&conanfile, &f)?;
written.push(conanfile);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("cpp-conan").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_conda(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::yaml_ast::Value as YVal;
let mut written = vec![];
fs::create_dir_all(out)?;
let meta = out.join("meta.yaml");
if !meta.exists() || force {
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let mut root = YVal::map();
let mut package = YVal::map();
package.insert("name", YVal::s(&name));
package.insert("version", YVal::s(version));
root.insert("package", package);
let mut source = YVal::map();
source.insert("path", YVal::s("."));
root.insert("source", source);
let mut build = YVal::map();
build.insert("noarch", YVal::s("python"));
build.insert("script", YVal::s("pip install . --no-deps"));
root.insert("build", build);
let mut requirements = YVal::map();
requirements.insert("host", YVal::arr([YVal::s("python"), YVal::s("pip")]));
requirements.insert("run", YVal::arr([YVal::s("python")]));
root.insert("requirements", requirements);
if let Some(d) = pkg.get("description") {
let mut about = YVal::map();
about.insert("summary", YVal::s(d));
root.insert("about", about);
}
crate::ast::emit(&meta, &root)?;
written.push(meta);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_pipenv(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::toml_ast::{Document, Value as TVal};
let mut written = vec![];
fs::create_dir_all(out)?;
let pipfile = out.join("Pipfile");
if !pipfile.exists() || force {
let mut doc = Document::new();
let source = doc.array_table("source");
source.key("name", TVal::s("pypi"));
source.key("url", TVal::s("https://pypi.org/simple"));
source.key("verify_ssl", TVal::b(true));
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
let packages = doc.table("packages");
for d in &deps.runtime {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
packages.key(n.clone(), TVal::s(v));
}
}
let dev_packages = doc.table("dev-packages");
for d in &deps.dev {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
dev_packages.key(n.clone(), TVal::s(v));
}
}
crate::ast::emit(&pipfile, &doc)?;
written.push(pipfile);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("python-pipenv").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_pdm(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
render_python(c, out, force)
}
fn render_deno(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::json_ast::Value as JVal;
let mut written = vec![];
fs::create_dir_all(out)?;
let deno_json = out.join("deno.json");
if !deno_json.exists() || force {
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let mut root = JVal::obj();
let mut scoped = String::from("@pleme-io/");
scoped.push_str(&name);
root.insert("name", JVal::s(scoped));
root.insert("version", JVal::s(version));
root.insert("exports", JVal::s("./mod.ts"));
let mut tasks = JVal::obj();
tasks.insert("test", JVal::s("deno test"));
root.insert("tasks", tasks);
root.insert("imports", JVal::obj());
crate::ast::emit(&deno_json, &root)?;
written.push(deno_json);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_pnpm(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::yaml_ast::Value as YVal;
let mut written = render_npm(c, out, force)?;
let pnpm_ws = out.join("pnpm-workspace.yaml");
if !pnpm_ws.exists() || force {
let mut root = YVal::map();
root.insert("packages", YVal::arr([YVal::s("packages/*")]));
crate::ast::emit(&pnpm_ws, &root)?;
written.push(pnpm_ws);
}
Ok(written)
}
fn render_vcpkg(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::json_ast::Value as JVal;
let mut written = vec![];
fs::create_dir_all(out)?;
let vcpkg = out.join("vcpkg.json");
if !vcpkg.exists() || force {
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let mut root = JVal::obj();
root.insert("name", JVal::s(name));
root.insert("version-string", JVal::s(version));
if let Some(d) = pkg.get("description") { root.insert("description", JVal::s(d)); }
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
let names: Vec<JVal> = deps.runtime.iter()
.filter_map(|d| d.get("name").cloned())
.map(JVal::s)
.collect();
root.insert("dependencies", JVal::Array(names));
crate::ast::emit(&vcpkg, &root)?;
written.push(vcpkg);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_cmake(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::cmake_ast::{Arg, File as CFile};
let mut written = vec![];
fs::create_dir_all(out)?;
let lists = out.join("CMakeLists.txt");
if !lists.exists() || force {
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let cmake_min = c.supports_raw.as_deref()
.and_then(|r| read_keyword_in_block(r, ":cmake"))
.map(|v| v.trim_matches('"').trim_start_matches(">=").to_string())
.unwrap_or_else(|| "3.20".to_string());
let mut f = CFile::new();
f.call("cmake_minimum_required", [Arg::ident("VERSION"), Arg::num(cmake_min)]);
f.call("project", [
Arg::ident(&name),
Arg::ident("VERSION"), Arg::num(version),
Arg::ident("LANGUAGES"), Arg::ident("CXX"),
]);
f.call("set", [Arg::ident("CMAKE_CXX_STANDARD"), Arg::num("20")]);
f.call("set", [Arg::ident("CMAKE_CXX_STANDARD_REQUIRED"), Arg::ident("ON")]);
let target_fn = if c.kind == "Binario" { "add_executable" } else { "add_library" };
f.call(target_fn, [Arg::ident(&name), Arg::ident("src/main.cpp")]);
crate::ast::emit(&lists, &f)?;
written.push(lists);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("cpp-cmake").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_meson(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::meson_ast::{Expr, File as MFile, Stmt};
let mut written = vec![];
fs::create_dir_all(out)?;
let build = out.join("meson.build");
if !build.exists() || force {
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let license = pkg.get("license").cloned().unwrap_or_else(|| "MIT".to_string());
let project_call = Expr::call("project", vec![
Expr::pos(Expr::s(&name)),
Expr::pos(Expr::s("c")),
Expr::pos(Expr::s("cpp")),
Expr::lbl("version", Expr::s(version)),
Expr::lbl("license", Expr::s(license)),
Expr::lbl("default_options", Expr::arr([Expr::s("cpp_std=c++20")])),
]);
let exe_call = Expr::call("executable", vec![
Expr::pos(Expr::s(&name)),
Expr::pos(Expr::s("src/main.cpp")),
]);
let mut f = MFile::new();
f.push(Stmt::Expr(project_call));
f.push(Stmt::Blank);
f.push(Stmt::Expr(exe_call));
crate::ast::emit(&build, &f)?;
written.push(build);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("cpp-meson").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_fpm(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::toml_ast::{Document, Value as TVal};
let mut written = vec![];
fs::create_dir_all(out)?;
let fpm = out.join("fpm.toml");
if !fpm.exists() || force {
let pkg = &c.package;
let mut doc = Document::new();
if let Some(n) = pkg.get("name") { doc.root_key("name", TVal::s(n)); }
if let Some(v) = pkg.get("version") { doc.root_key("version", TVal::s(v)); }
if let Some(l) = pkg.get("license") { doc.root_key("license", TVal::s(l)); }
let build = doc.table("build");
build.key("auto-executables", TVal::b(true));
build.key("auto-tests", TVal::b(true));
if let Some(d) = pkg.get("description") {
build.key("description", TVal::s(d));
}
crate::ast::emit(&fpm, &doc)?;
written.push(fpm);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_gleam(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::toml_ast::{Document, Value as TVal};
let mut written = vec![];
fs::create_dir_all(out)?;
let g = out.join("gleam.toml");
if !g.exists() || force {
let pkg = &c.package;
let mut doc = Document::new();
if let Some(n) = pkg.get("name") { doc.root_key("name", TVal::s(n)); }
if let Some(v) = pkg.get("version") { doc.root_key("version", TVal::s(v)); }
if let Some(l) = pkg.get("license") {
doc.root_key("licences", TVal::arr([TVal::s(l)]));
}
if let Some(d) = pkg.get("description") {
doc.root_key("description", TVal::s(d));
}
doc.table("dependencies").key("gleam_stdlib", TVal::s(">= 0.34.0 and < 2.0.0"));
doc.table("dev-dependencies").key("gleeunit", TVal::s(">= 1.0.0 and < 2.0.0"));
crate::ast::emit(&g, &doc)?;
written.push(g);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_alire(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::toml_ast::{Document, Value as TVal};
let mut written = vec![];
fs::create_dir_all(out)?;
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let a = out.join(format!("alire-{name}.toml"));
if !a.exists() || force {
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let mut doc = Document::new();
doc.root_key("name", TVal::s(&name));
doc.root_key("version", TVal::s(version));
if let Some(d) = pkg.get("description") { doc.root_key("description", TVal::s(d)); }
if let Some(l) = pkg.get("license") { doc.root_key("licenses", TVal::s(l)); }
doc.root_key("authors", TVal::arr([TVal::s("pleme-io")]));
doc.root_key("maintainers", TVal::arr([TVal::s("engineering@pleme.io")]));
doc.root_key("maintainers-logins", TVal::arr([TVal::s("pleme-io")]));
doc.array_table("depends-on").key("gnat", TVal::s(">=11"));
crate::ast::emit(&a, &doc)?;
written.push(a);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_cabal(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::control_ast::Line;
let mut written = vec![];
fs::create_dir_all(out)?;
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let cab = out.join(format!("{name}.cabal"));
if !cab.exists() || force {
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0.0".to_string());
let mut lines: Vec<Line> = vec![
Line::Field { key: "cabal-version".into(), value: "2.4".into() },
Line::Field { key: "name".into(), value: name },
Line::Field { key: "version".into(), value: version },
];
if let Some(d) = pkg.get("description") {
lines.push(Line::Field { key: "synopsis".into(), value: d.clone() });
lines.push(Line::Field { key: "description".into(), value: d.clone() });
}
if let Some(l) = pkg.get("license") {
lines.push(Line::Field { key: "license".into(), value: l.clone() });
}
lines.push(Line::Field { key: "author".into(), value: "pleme-io".into() });
lines.push(Line::Field { key: "maintainer".into(), value: "engineering@pleme.io".into() });
lines.push(Line::Field { key: "build-type".into(), value: "Simple".into() });
lines.push(Line::Blank);
lines.push(Line::Stanza {
header: "library".into(),
body: vec![
Line::Field { key: "exposed-modules".into(), value: "Lib".into() },
Line::Field { key: "hs-source-dirs".into(), value: "src".into() },
Line::Field { key: "build-depends".into(), value: "base >= 4.14 && < 5".into() },
Line::Field { key: "default-language".into(), value: "Haskell2010".into() },
],
});
crate::ast::emit(&cab, &crate::control_ast::Document::from(2, lines))?;
written.push(cab);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("haskell-cabal").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_racket(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::sexp_ast::SExp;
let mut written = vec![];
fs::create_dir_all(out)?;
let info = out.join("info.rkt");
if !info.exists() || force {
let pkg = &c.package;
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let version = pkg.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());
let mut forms: Vec<SExp> = vec![
SExp::Symbol("#lang info".to_string()),
SExp::list([SExp::sym("define"), SExp::sym("collection"), SExp::str(name)]),
SExp::list([SExp::sym("define"), SExp::sym("version"), SExp::str(version)]),
SExp::list([
SExp::sym("define"),
SExp::sym("deps"),
SExp::Symbol("'(\"base\")".to_string()),
]),
];
if let Some(d) = pkg.get("description") {
forms.push(SExp::list([SExp::sym("define"), SExp::sym("pkg-desc"), SExp::str(d)]));
}
forms.push(SExp::list([
SExp::sym("define"),
SExp::sym("pkg-authors"),
SExp::Symbol("'(pleme-io)".to_string()),
]));
crate::ast::emit(&info, &crate::sexp_ast::Forms(forms))?;
written.push(info);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_julia(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::toml_ast::{Document, Value};
let mut written = vec![];
fs::create_dir_all(out)?;
let project = out.join("Project.toml");
if !project.exists() || force {
let pkg = &c.package;
let mut doc = Document::new();
if let Some(n) = pkg.get("name") { doc.root_key("name", Value::s(n)); }
let uuid = pkg.get("uuid").cloned()
.unwrap_or_else(|| "00000000-0000-0000-0000-000000000000".to_string());
doc.root_key("uuid", Value::s(uuid));
if let Some(v) = pkg.get("version") { doc.root_key("version", Value::s(v)); }
if let Some(authors) = pkg.get("authors") {
doc.root_key("authors", Value::arr([Value::s(authors)]));
}
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() {
let deps_tbl = doc.table("deps");
for d in &deps.runtime {
if let (Some(n), Some(v)) = (d.get("name"), d.get("uuid")) {
deps_tbl.key(n, Value::s(v));
}
}
let compat = doc.table("compat");
if let Some(supports) = c.supports_raw.as_deref() {
if let Some(jv) = read_keyword_in_block(supports, ":julia") {
compat.key("julia", Value::s(jv.trim_matches('"')));
}
}
}
crate::ast::emit(&project, &doc)?;
written.push(project);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("julia").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
#[derive(Debug, Default)]
pub struct Caixa {
pub name: String,
pub kind: String,
pub ecosystem: String,
pub package: BTreeMap<String, String>,
pub package_lists: BTreeMap<String, Vec<String>>,
pub workflows: Vec<String>,
pub captured_files: Vec<crate::file_capture::CapturedFile>,
pub captured_symlinks: Vec<crate::file_capture::CapturedSymlink>,
pub captured_binaries: Vec<crate::file_capture::CapturedBinary>,
pub stacks: Vec<String>,
pub depends_on: Vec<String>,
pub exposes: Vec<String>,
pub publish_to_git: bool,
pub ci_config: BTreeMap<String, BTreeMap<String, String>>,
pub dependencies_raw: Option<String>,
pub features_raw: Option<String>,
pub profiles_raw: Option<String>,
pub scripts_raw: Option<String>,
pub sources_raw: Option<String>,
pub supports_raw: Option<String>,
pub artifacts_raw: Option<String>,
pub publish_raw: Option<String>,
pub nix_modules: bool,
pub deps_transitive: bool,
pub deps_sync_target: String,
}
fn strip_lisp_comment(line: &str) -> &str {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'"' {
i += 1;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() { i += 2; continue; }
if bytes[i] == b'"' { i += 1; break; }
i += 1;
}
continue;
}
if c == b';' && i + 1 < bytes.len() && bytes[i + 1] == b';' {
return &line[..i];
}
i += 1;
}
line
}
fn parse(src: &str) -> Result<Caixa> {
let cleaned: String = src
.lines()
.map(strip_lisp_comment)
.collect::<Vec<_>>()
.join("\n");
let cleaned = cleaned.trim();
if !cleaned.starts_with("(defcaixa") {
bail!("source does not start with (defcaixa ...)");
}
let after_keyword = &cleaned[("(defcaixa".len())..].trim_start();
let name_end = after_keyword
.find(|c: char| c.is_whitespace())
.unwrap_or(after_keyword.len());
let name = after_keyword[..name_end].to_string();
let body = &after_keyword[name_end..];
let mut out = Caixa {
name,
nix_modules: true,
deps_transitive: true,
deps_sync_target: "both".to_string(),
..Default::default()
};
out.kind = read_keyword(body, ":kind").unwrap_or_default();
out.ecosystem = read_keyword(body, ":ecosystem").unwrap_or_default();
if let Some(pkg_block) = read_dict_block(body, ":package") {
out.package = parse_dict(&pkg_block);
if let Some(cats) = read_vector_in_dict(&pkg_block, ":categories") {
out.package_lists.insert("categories".into(), cats);
}
if let Some(kws) = read_vector_in_dict(&pkg_block, ":keywords") {
out.package_lists.insert("keywords".into(), kws);
}
}
if let Some(files_block) = extract_vector_block(body, ":files") {
let dicts = parse_dep_vector(&files_block);
for d in dicts {
let path = d.get("path").cloned().unwrap_or_default();
let sha256 = d.get("sha256").cloned().unwrap_or_default();
let size: usize = d.get("size").and_then(|s| s.parse().ok()).unwrap_or(0);
let body_s = d.get("body").cloned().unwrap_or_default();
if !path.is_empty() {
out.captured_files.push(crate::file_capture::CapturedFile {
path, sha256, size, body: body_s,
});
}
}
}
if let Some(links_block) = extract_vector_block(body, ":symlinks") {
let dicts = parse_dep_vector(&links_block);
for d in dicts {
let path = d.get("path").cloned().unwrap_or_default();
let target = d.get("target").cloned().unwrap_or_default();
if !path.is_empty() && !target.is_empty() {
out.captured_symlinks.push(crate::file_capture::CapturedSymlink {
path, target,
});
}
}
}
if let Some(bin_block) = extract_vector_block(body, ":binaries") {
let dicts = parse_dep_vector(&bin_block);
for d in dicts {
let path = d.get("path").cloned().unwrap_or_default();
let sha256 = d.get("sha256").cloned().unwrap_or_default();
let size: usize = d.get("size").and_then(|s| s.parse().ok()).unwrap_or(0);
let b64 = d.get("base64").cloned().unwrap_or_default();
if !path.is_empty() && !b64.is_empty() {
out.captured_binaries.push(crate::file_capture::CapturedBinary {
path, sha256, size, base64: b64,
});
}
}
}
if let Some(wf) = read_vector(body, ":workflows") {
out.workflows = wf;
}
if let Some(stacks) = read_vector(body, ":stacks") {
out.stacks = stacks;
}
if let Some(deps) = read_vector(body, ":depends-on") {
out.depends_on = deps;
}
if let Some(exposes) = read_vector(body, ":exposes") {
out.exposes = exposes;
}
if let Some(pub_to_git) = read_keyword(body, ":publish-to-git") {
out.publish_to_git = pub_to_git == "true";
}
if let Some(nm) = read_keyword(body, ":nix-modules") {
out.nix_modules = nm != "false";
}
if let Some(t) = read_keyword(body, ":deps-transitive") {
out.deps_transitive = t != "false";
}
if let Some(s) = read_keyword(body, ":deps-sync-target") {
out.deps_sync_target = s;
}
out.dependencies_raw = read_dict_block(body, ":dependencies");
out.features_raw = read_dict_block(body, ":features");
out.profiles_raw = read_dict_block(body, ":profiles");
out.scripts_raw = read_dict_block(body, ":scripts");
out.sources_raw = read_dict_block(body, ":sources");
out.supports_raw = read_dict_block(body, ":supports");
out.artifacts_raw = read_dict_block(body, ":artifacts");
out.publish_raw = read_dict_block(body, ":publish");
if let Some(ci_block) = read_dict_block(body, ":ci-config") {
for sub_key in ["bump", "publish", "security", "validation"] {
if let Some(sub_block) = read_dict_block(&ci_block, &format!(":{sub_key}")) {
out.ci_config.insert(sub_key.into(), parse_dict(&sub_block));
}
}
}
Ok(out)
}
fn find_after_keyword<'a>(s: &'a str, kw: &str) -> Option<&'a str> {
let mut start = 0;
while let Some(i) = s[start..].find(kw) {
let abs = start + i;
let after_kw = &s[abs + kw.len()..];
if after_kw.starts_with(|c: char| c.is_whitespace()) {
return Some(after_kw.trim_start());
}
start = abs + kw.len();
}
None
}
fn read_keyword(s: &str, kw: &str) -> Option<String> {
let after = find_after_keyword(s, kw)?;
let end = after
.find(|c: char| c.is_whitespace() || c == ')' || c == '}')
.unwrap_or(after.len());
Some(after[..end].trim_matches(':').to_string())
}
fn read_dict_block(s: &str, kw: &str) -> Option<String> {
let after = find_after_keyword(s, kw)?;
let after = after.trim_start();
if !after.starts_with('{') {
return None;
}
let mut depth = 0;
let mut end = 0;
for (j, c) in after.char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = j + 1;
break;
}
}
_ => {}
}
}
if end == 0 {
return None;
}
Some(after[1..end - 1].to_string())
}
fn read_vector(s: &str, kw: &str) -> Option<Vec<String>> {
let after = find_after_keyword(s, kw)?;
if !after.starts_with('[') {
return None;
}
let mut depth = 0;
let mut end = 0;
for (j, c) in after.char_indices() {
match c {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
end = j + 1;
break;
}
}
_ => {}
}
}
if end == 0 {
return None;
}
let inner = &after[1..end - 1];
Some(parse_lisp_vector_items(inner))
}
fn parse_lisp_vector_items(inner: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = inner.as_bytes();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() { i += 1; }
if i >= bytes.len() { break; }
if bytes[i] == b'"' {
i += 1;
let start = i;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() { i += 2; continue; }
if bytes[i] == b'"' { break; }
i += 1;
}
let raw = &inner[start..i];
let mut u = String::with_capacity(raw.len());
let mut it = raw.chars().peekable();
while let Some(c) = it.next() {
if c == '\\' {
if let Some(&n) = it.peek() {
if n == '"' || n == '\\' { u.push(it.next().unwrap()); continue; }
if n == 'n' { it.next(); u.push('\n'); continue; }
}
}
u.push(c);
}
out.push(u);
if i < bytes.len() { i += 1; } } else {
let start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() { i += 1; }
let token = &inner[start..i];
out.push(token.trim_start_matches(':').to_string());
}
}
out.into_iter().filter(|s| !s.is_empty()).collect()
}
fn read_vector_in_dict(dict: &str, kw: &str) -> Option<Vec<String>> {
read_vector(dict, kw)
}
fn parse_dict(inner: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut chars = inner.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c == ':' {
let mut end = i + 1;
while end < inner.len() && !inner.as_bytes()[end].is_ascii_whitespace() {
end += 1;
}
let kw = inner[i + 1..end].to_string();
let mut val_start = end;
while val_start < inner.len() && inner.as_bytes()[val_start].is_ascii_whitespace() {
val_start += 1;
}
if val_start >= inner.len() {
break;
}
if inner.as_bytes()[val_start] == b'{' || inner.as_bytes()[val_start] == b'[' {
let mut depth = 0;
let open = inner.as_bytes()[val_start] as char;
let close = if open == '{' { '}' } else { ']' };
let mut p = val_start;
for (j, c2) in inner[val_start..].char_indices() {
match c2 {
c if c == open => depth += 1,
c if c == close => {
depth -= 1;
if depth == 0 {
p = val_start + j + 1;
break;
}
}
_ => {}
}
}
while let Some(&(k, _)) = chars.peek() {
if k >= p {
break;
}
chars.next();
}
continue;
}
if inner.as_bytes()[val_start] == b'"' {
let mut end = val_start + 1;
let bytes = inner.as_bytes();
while end < inner.len() {
if bytes[end] == b'\\' && end + 1 < inner.len() {
end += 2; continue;
}
if bytes[end] == b'"' { break; }
end += 1;
}
let raw = &inner[val_start + 1..end];
let mut unescaped = String::with_capacity(raw.len());
let mut it = raw.chars().peekable();
while let Some(c) = it.next() {
if c == '\\' {
if let Some(&next) = it.peek() {
if next == '"' || next == '\\' { unescaped.push(it.next().unwrap()); continue; }
if next == 'n' { it.next(); unescaped.push('\n'); continue; }
if next == 't' { it.next(); unescaped.push('\t'); continue; }
}
}
unescaped.push(c);
}
out.insert(kw, unescaped);
while let Some(&(k, _)) = chars.peek() {
if k > end {
break;
}
chars.next();
}
} else {
let mut end = val_start;
while end < inner.len() && !inner.as_bytes()[end].is_ascii_whitespace() {
end += 1;
}
out.insert(kw, inner[val_start..end].trim_start_matches(':').to_string());
while let Some(&(k, _)) = chars.peek() {
if k >= end {
break;
}
chars.next();
}
}
}
}
out
}
fn render_rust_single(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::toml_ast::{Document, Value as TVal};
let mut written = vec![];
fs::create_dir_all(out)?;
let wf_dir = out.join(".github/workflows");
fs::create_dir_all(&wf_dir)?;
let cargo_path = out.join("Cargo.toml");
if !cargo_path.exists() || force {
let pkg = &c.package;
let mut doc = Document::new();
{
let p = doc.table("package");
if let Some(n) = pkg.get("name") { p.key("name", TVal::s(n)); }
if let Some(v) = pkg.get("version") { p.key("version", TVal::s(v)); }
p.key("edition", TVal::s("2024"));
if let Some(supports) = c.supports_raw.as_deref() {
if let Some(rust_req) = read_keyword_in_block(supports, ":rust") {
let clean = rust_req.trim_matches('"').trim_start_matches(">=").to_string();
p.key("rust-version", TVal::s(clean));
}
}
if let Some(d) = pkg.get("description") { p.key("description", TVal::s(d)); }
if let Some(l) = pkg.get("license") { p.key("license", TVal::s(l)); }
if let Some(r) = pkg.get("repository") { p.key("repository", TVal::s(r)); }
if let Some(h) = pkg.get("homepage") { p.key("homepage", TVal::s(h)); }
p.key("readme", TVal::s("README.md"));
let authors = pkg.get("authors")
.cloned()
.unwrap_or_else(|| "pleme-io".to_string());
p.key("authors", TVal::arr([TVal::s(authors)]));
if let Some(cats) = c.package_lists.get("categories") {
p.key("categories", TVal::arr(cats.iter().map(TVal::s)));
}
if let Some(kws) = c.package_lists.get("keywords") {
p.key("keywords", TVal::arr(kws.iter().map(TVal::s)));
}
}
let typed_cargo = doc.render();
let mut cargo = typed_cargo;
if let Some(artifacts) = c.artifacts_raw.as_deref() {
for bin in extract_bin_entries(artifacts) {
cargo.push_str("\n[[bin]]\n");
if let Some(n) = bin.get("name") {
let _ = writeln!(cargo, "name = {}", TVal::s(n).render_inline());
}
if let Some(p) = bin.get("path") {
let _ = writeln!(cargo, "path = {}", TVal::s(p).render_inline());
}
}
}
if let Some(publish) = c.publish_raw.as_deref() {
let publish_fields = emit_publish_fields(publish);
if !publish_fields.is_empty() {
cargo.push_str(&publish_fields);
}
}
{
let mut lints_doc = Document::new();
lints_doc.table("lints.clippy").key("pedantic", TVal::s("warn"));
cargo.push('\n');
cargo.push_str(&lints_doc.render());
}
if let Some(profiles) = c.profiles_raw.as_deref() {
cargo.push_str(&emit_profiles_blocks(profiles));
}
if let Some(features) = c.features_raw.as_deref() {
let features_block = emit_features_block(features);
if !features_block.is_empty() {
cargo.push_str("\n[features]\n");
cargo.push_str(&features_block);
}
}
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
emit_typed_deps_section(
&mut cargo,
"dependencies",
derive_path_deps_from_depends_on(c, out)
.into_iter()
.chain(deps.runtime.iter().filter_map(emit_cargo_dep_typed)),
);
if !deps.dev.is_empty() {
emit_typed_deps_section(
&mut cargo,
"dev-dependencies",
deps.dev.iter().filter_map(emit_cargo_dep_typed),
);
}
if !deps.build.is_empty() {
emit_typed_deps_section(
&mut cargo,
"build-dependencies",
deps.build.iter().filter_map(emit_cargo_dep_typed),
);
}
fs::write(&cargo_path, cargo)?;
written.push(cargo_path);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("rust-single-crate").write(out, &pkg_name, force)? {
written.push(f);
}
write_pleme_io_release_toml(c, out, force, &mut written)?;
for wf in &c.workflows {
let path = wf_dir.join(format!("{wf}.yml"));
if path.exists() && !force {
continue;
}
let content: String = match wf.as_str() {
"auto-release" => match auto_release_yaml_for(&c.ecosystem, Some(out)) {
Some(y) => y,
None => continue,
},
"pre-merge-gate" => PRE_MERGE_GATE_SHIM.to_string(),
"security-gate" => SECURITY_GATE_SHIM.to_string(),
_ => continue,
};
fs::write(&path, content)?;
written.push(path);
}
emit_stacks_workflow(c, &wf_dir, force, &mut written)?;
if c.nix_modules {
emit_nix_module_trio(c, out, force, &mut written)?;
}
emit_deps_sync(c, out, force, &mut written)?;
emit_flake_nix(c, out, force, &mut written)?;
Ok(written)
}
fn render_ci_shims(c: &Caixa, out: &Path, force: bool, written: &mut Vec<PathBuf>) -> Result<()> {
let wf_dir = out.join(".github/workflows");
fs::create_dir_all(&wf_dir)?;
write_pleme_io_release_toml(c, out, force, written)?;
for wf in &c.workflows {
let path = wf_dir.join(format!("{wf}.yml"));
if path.exists() && !force {
continue;
}
let content: String = match wf.as_str() {
"auto-release" => match auto_release_yaml_for(&c.ecosystem, Some(out)) {
Some(y) => y,
None => continue,
},
"pre-merge-gate" => PRE_MERGE_GATE_SHIM.to_string(),
"security-gate" => SECURITY_GATE_SHIM.to_string(),
_ => continue,
};
fs::write(&path, content)?;
written.push(path);
}
emit_stacks_workflow(c, &wf_dir, force, written)?;
let out_dir = wf_dir.parent().unwrap().parent().unwrap().to_path_buf();
if c.nix_modules {
emit_nix_module_trio(c, &out_dir, force, written)?;
}
emit_deps_sync(c, &out_dir, force, written)?;
emit_flake_nix(c, &out_dir, force, written)?;
Ok(())
}
fn read_keyword_in_block(block: &str, kw: &str) -> Option<String> {
read_keyword(block, kw)
}
#[derive(Debug, Default)]
struct CaixaDeps {
runtime: Vec<BTreeMap<String, String>>,
dev: Vec<BTreeMap<String, String>>,
build: Vec<BTreeMap<String, String>>,
}
fn parse_deps_raw(raw: &str) -> CaixaDeps {
let mut out = CaixaDeps::default();
for kw in [":runtime", ":dev", ":build"] {
if let Some(vec_src) = extract_vector_block(raw, kw) {
let class_deps = parse_dep_vector(&vec_src);
match kw {
":runtime" => out.runtime = class_deps,
":dev" => out.dev = class_deps,
":build" => out.build = class_deps,
_ => {}
}
}
}
out
}
fn extract_vector_block(s: &str, kw: &str) -> Option<String> {
let after = find_after_keyword(s, kw)?;
if !after.starts_with('[') {
return None;
}
let bytes = after.as_bytes();
let mut depth = 0;
let mut end = 0;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'"' {
i += 1;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() { i += 2; continue; }
if bytes[i] == b'"' { i += 1; break; }
i += 1;
}
continue;
}
match c {
b'[' => depth += 1,
b']' => {
depth -= 1;
if depth == 0 {
end = i + 1;
break;
}
}
_ => {}
}
i += 1;
}
if end == 0 {
return None;
}
Some(after[1..end - 1].to_string())
}
fn parse_dep_vector(s: &str) -> Vec<BTreeMap<String, String>> {
let mut out = vec![];
let mut depth = 0;
let mut start = None;
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'"' {
i += 1;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() { i += 2; continue; }
if bytes[i] == b'"' { i += 1; break; }
i += 1;
}
continue;
}
match c {
b'{' => {
if depth == 0 {
start = Some(i + 1);
}
depth += 1;
}
b'}' => {
depth -= 1;
if depth == 0 {
if let Some(s_start) = start {
out.push(parse_dict(&s[s_start..i]));
start = None;
}
}
}
_ => {}
}
i += 1;
}
out
}
fn derive_path_deps_from_depends_on(
c: &Caixa, out: &Path,
) -> Vec<(String, crate::toml_ast::Value)> {
use crate::manifest_io::read_toml_string;
let mut entries = Vec::new();
for raw in &c.depends_on {
let Some(spec) = crate::caixa_deps::DepSpec::parse(raw) else { continue };
let working = out.join(".caixa-deps").join(&spec.safe_name).join("working");
let cargo_path = working.join("Cargo.toml");
if !cargo_path.is_file() { continue; }
let Ok(text) = std::fs::read_to_string(&cargo_path) else { continue };
let crate_name = read_toml_string(&text, "[package]", "name")
.or_else(|| read_toml_string(&text, "[workspace.package]", "name"))
.unwrap_or_else(|| spec.safe_name.clone());
let mut rel = String::from(".caixa-deps/");
rel.push_str(&spec.safe_name);
rel.push_str("/working");
let inline = crate::toml_ast::Value::inline_tbl([
("path".to_string(), crate::toml_ast::Value::s(rel)),
]);
entries.push((crate_name, inline));
}
entries
}
fn emit_typed_deps_section(
cargo: &mut String,
section: &str,
entries: impl IntoIterator<Item = (String, crate::toml_ast::Value)>,
) {
use crate::toml_ast::Document;
let mut doc = Document::new();
let tbl = doc.table(section);
let mut any = false;
for (k, v) in entries {
tbl.key(k, v);
any = true;
}
if !any { return; }
cargo.push('\n');
cargo.push_str(&doc.render());
}
fn emit_cargo_dep_typed(d: &BTreeMap<String, String>) -> Option<(String, crate::toml_ast::Value)> {
use crate::toml_ast::Value as TVal;
let name = d.get("name").cloned()?;
if name.is_empty() { return None; }
let version = d.get("version").cloned().unwrap_or_else(|| "*".to_string());
if let Some(feats_raw) = d.get("features") {
let feats: Vec<String> = feats_raw
.trim_start_matches('[')
.trim_end_matches(']')
.split_whitespace()
.map(|s| s.trim_matches('"').trim_start_matches(':').to_string())
.filter(|s| !s.is_empty())
.collect();
if !feats.is_empty() {
let feats_val = TVal::arr(feats.into_iter().map(TVal::s));
let inline = TVal::inline_tbl([
("version".to_string(), TVal::s(version)),
("features".to_string(), feats_val),
]);
return Some((name, inline));
}
}
Some((name, TVal::s(version)))
}
fn emit_features_block(features_raw: &str) -> String {
let mut out = String::new();
let mut chars = features_raw.char_indices().peekable();
let mut current_key: Option<String> = None;
while let Some((i, c)) = chars.next() {
if c == ':' {
let mut end = i + 1;
while end < features_raw.len() && !features_raw.as_bytes()[end].is_ascii_whitespace() {
end += 1;
}
current_key = Some(features_raw[i + 1..end].to_string());
while let Some(&(k, ch)) = chars.peek() {
if k <= end || ch.is_whitespace() {
chars.next();
} else {
break;
}
}
if let Some(&(_, ch)) = chars.peek() {
if ch == '[' {
let rest = &features_raw[end..];
if let Some(open) = rest.find('[') {
let mut depth = 0;
let mut close = 0;
for (j, c) in rest[open..].char_indices() {
match c {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
close = open + j + 1;
break;
}
}
_ => {}
}
}
if close > 0 {
let inner = &rest[open + 1..close - 1];
let items: Vec<String> = inner
.split_whitespace()
.map(|s| format!("\"{}\"", s.trim_matches('"').trim_start_matches(':')))
.filter(|s| s != "\"\"")
.collect();
if let Some(key) = ¤t_key {
out.push_str(&format!("{key} = [{}]\n", items.join(", ")));
}
while let Some(&(k, _)) = chars.peek() {
if k >= end + close {
break;
}
chars.next();
}
}
}
} else if ch == '{' {
let after = &features_raw[end..];
if let Some(open) = after.find('{') {
let mut depth = 0;
let mut close = 0;
for (j, c) in after[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
close = open + j + 1;
break;
}
}
_ => {}
}
}
if close > 0 {
let dict_inner = &after[open + 1..close - 1];
if let Some(adds_vec) = extract_vector_block(dict_inner, ":adds") {
let items: Vec<String> = adds_vec
.split_whitespace()
.map(|s| format!("\"{}\"", s.trim_matches('"').trim_start_matches(':')))
.filter(|s| s != "\"\"")
.collect();
if let Some(key) = ¤t_key {
out.push_str(&format!("{key} = [{}]\n", items.join(", ")));
}
}
let abs_close = end + close;
while let Some(&(k, _)) = chars.peek() {
if k >= abs_close {
break;
}
chars.next();
}
}
}
}
}
}
}
out
}
fn emit_profiles_blocks(profiles_raw: &str) -> String {
use crate::toml_ast::{Document, Value as TVal};
let mut doc = Document::new();
let mut wrote_any = false;
for profile_name in ["release", "dev", "bench", "test"] {
let mut kw = String::from(":");
kw.push_str(profile_name);
let Some(block) = read_dict_block(profiles_raw, &kw) else { continue };
let mut section = String::from("profile.");
section.push_str(profile_name);
let tbl = doc.table(section);
let fields = parse_dict(&block);
for (k, v) in &fields {
let val = if v == "true" {
TVal::b(true)
} else if v == "false" {
TVal::b(false)
} else if let Ok(n) = v.parse::<i64>() {
TVal::i(n)
} else {
TVal::s(v.clone())
};
tbl.key(k.clone(), val);
}
wrote_any = true;
}
if !wrote_any { return String::new(); }
let mut out = String::from("\n");
out.push_str(&doc.render());
out
}
fn emit_publish_fields(publish_raw: &str) -> String {
use crate::toml_ast::Value as TVal;
fn parse_lisp_strings(raw: &str) -> Vec<String> {
raw.split_whitespace()
.map(|s| s.trim_matches('"').to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn render_kv(key: &str, val: &TVal) -> String {
let mut out = String::from(key);
out.push_str(" = ");
out.push_str(&val.render_inline());
out.push('\n');
out
}
let mut out = String::new();
let fields = parse_dict(publish_raw);
if let Some(v) = fields.get("private") {
if v == "true" {
out.push_str(&render_kv("publish", &TVal::b(false)));
}
}
if let Some(incl) = extract_vector_block(publish_raw, ":files-include") {
let items = parse_lisp_strings(&incl);
if !items.is_empty() {
out.push_str(&render_kv(
"include",
&TVal::arr(items.into_iter().map(TVal::s)),
));
}
}
if let Some(excl) = extract_vector_block(publish_raw, ":files-exclude") {
let items = parse_lisp_strings(&excl);
if !items.is_empty() {
out.push_str(&render_kv(
"exclude",
&TVal::arr(items.into_iter().map(TVal::s)),
));
}
}
out
}
fn extract_bin_entries(artifacts: &str) -> Vec<BTreeMap<String, String>> {
extract_vector_block(artifacts, ":bin")
.map(|v| parse_dep_vector(&v))
.unwrap_or_default()
}
fn emit_nix_module_trio(
c: &Caixa,
out: &Path,
force: bool,
written: &mut Vec<PathBuf>,
) -> Result<()> {
let mod_dir = out.join("nix/modules");
fs::create_dir_all(&mod_dir)?;
let name = c.package.get("name").map(String::as_str).unwrap_or(&c.name);
let desc = c.package.get("description").cloned().unwrap_or_default();
let nixos = render_module_typed(ModuleScope::NixOS, name, &desc);
let darwin = render_module_typed(ModuleScope::Darwin, name, &desc);
let hm = render_module_typed(ModuleScope::HomeManager, name, &desc);
for (file, content) in [("nixos.nix", nixos), ("darwin.nix", darwin), ("home-manager.nix", hm)] {
let p = mod_dir.join(file);
if !p.exists() || force {
fs::write(&p, content)?;
written.push(p);
}
}
Ok(())
}
fn emit_deps_sync(
c: &Caixa,
out: &Path,
force: bool,
written: &mut Vec<PathBuf>,
) -> Result<()> {
if c.depends_on.is_empty() {
return Ok(());
}
let sync = c.deps_sync_target.as_str();
if matches!(sync, "nix" | "both") {
let nix_fragment = out.join("nix/caixa-deps.nix");
fs::create_dir_all(out.join("nix"))?;
if !nix_fragment.exists() || force {
let mut s = String::from("# Auto-generated from caixa :depends-on.\n");
s.push_str("# Splice into flake.nix `inputs = { ... };` block.\n{\n");
for dep in &c.depends_on {
let parts: Vec<&str> = dep.splitn(2, '@').collect();
let repo = parts[0];
let rev = parts.get(1).copied().unwrap_or("main");
let key = repo.rsplit('/').next().unwrap_or("dep").replace('-', "_");
let ident = sanitize_nix_ident(&key);
let mut url = String::from("github:");
url.push_str(repo);
url.push('/');
url.push_str(rev);
let url_lit = nix_string_lit(&url);
s.push_str(" ");
s.push_str(&ident);
s.push_str(" = { url = ");
s.push_str(&url_lit);
s.push_str("; flake = true; };\n");
}
s.push_str("}\n");
fs::write(&nix_fragment, s)?;
written.push(nix_fragment);
}
}
if matches!(sync, "local" | "both") {
let manifest = out.join(".caixa-deps").join("MANIFEST.txt");
fs::create_dir_all(out.join(".caixa-deps"))?;
if !manifest.exists() || force {
let mut s = String::from("# Auto-generated from caixa :depends-on.\n");
for dep in &c.depends_on {
s.push_str(dep);
s.push('\n');
}
if c.deps_transitive {
s.push_str("\n# :deps-transitive=true — caixa-deps-resolve recurses.\n");
}
fs::write(&manifest, s)?;
written.push(manifest);
}
}
Ok(())
}
fn emit_flake_nix(
c: &Caixa,
out: &Path,
force: bool,
written: &mut Vec<PathBuf>,
) -> Result<()> {
let path = out.join("flake.nix");
if path.exists() && !force {
return Ok(());
}
let name = c.package.get("name").map(String::as_str).unwrap_or(&c.name);
let mut inputs = String::from(
" nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n \
substrate = {\n url = \"github:pleme-io/substrate\";\n \
inputs.nixpkgs.follows = \"nixpkgs\";\n };\n \
flake-parts.url = \"github:hercules-ci/flake-parts\";\n",
);
for dep in &c.depends_on {
let parts: Vec<&str> = dep.splitn(2, '@').collect();
let repo = parts[0];
let rev = parts.get(1).copied().unwrap_or("main");
let key = repo.rsplit('/').next().unwrap_or("dep").replace('-', "_");
let ident = sanitize_nix_ident(&key);
let mut url = String::from("github:");
url.push_str(repo);
url.push('/');
url.push_str(rev);
let url_lit = nix_string_lit(&url);
inputs.push_str(" ");
inputs.push_str(&ident);
inputs.push_str(" = {\n url = ");
inputs.push_str(&url_lit);
inputs.push_str(";\n inputs.nixpkgs.follows = \"nixpkgs\";\n };\n");
}
let builder = match c.ecosystem.as_str() {
"rust-single-crate" => "substrate.lib.${system}.rustToolReleaseFlakeBuilder",
"rust-workspace" => "substrate.lib.${system}.rustWorkspaceReleaseFlakeBuilder",
"npm" => "substrate.lib.${system}.typescriptToolFlakeBuilder",
"python" => "substrate.lib.${system}.pythonToolFlakeBuilder",
"helm" => "substrate.lib.${system}.helmChartFlakeBuilder",
"github-action" => "substrate.lib.${system}.rustToolReleaseFlakeBuilder",
_ => "substrate.lib.${system}.rustToolReleaseFlakeBuilder",
};
let name_lit = nix_string_lit(name);
let mut desc = String::from(name);
desc.push_str(" — caixa-rendered Nix flake");
let desc_lit = nix_string_lit(&desc);
let mut content = String::new();
content.push_str("# flake.nix — auto-generated from ");
content.push_str(name);
content.push_str(".caixa.lisp\n# Edit caixa source + re-render via:\n");
content.push_str("# pleme-doc-gen caixa --source ");
content.push_str(name);
content.push_str(".caixa.lisp --out . --force\n{\n description = ");
content.push_str(&desc_lit);
content.push_str(";\n\n inputs = {\n");
content.push_str(&inputs);
content.push_str(" };\n\n");
content.push_str(
" outputs = inputs @ { self, nixpkgs, substrate, flake-parts, ... }:\n \
flake-parts.lib.mkFlake { inherit inputs; } {\n \
systems = [ \"aarch64-darwin\" \"x86_64-linux\" \"aarch64-linux\" ];\n \
perSystem = { pkgs, system, ... }: let\n \
builder = ",
);
content.push_str(builder);
content.push_str(";\n in {\n packages.default = builder {\n \
inherit pkgs;\n name = ");
content.push_str(&name_lit);
content.push_str(";\n src = ./.;\n };\n \
devShells.default = pkgs.mkShell {\n \
buildInputs = [ pkgs.git ];\n };\n };\n };\n}\n");
fs::write(&path, content)?;
written.push(path);
Ok(())
}
fn emit_stacks_workflow(
c: &Caixa,
wf_dir: &Path,
force: bool,
written: &mut Vec<PathBuf>,
) -> Result<()> {
if c.stacks.is_empty() {
return Ok(());
}
let stacks_path = wf_dir.join("pleme-stacks.yml");
if stacks_path.exists() && !force {
return Ok(());
}
use crate::yaml_ast::Value as YVal;
let mut on = YVal::map();
let mut push = YVal::map();
push.insert("branches", YVal::Array(vec![YVal::s("main")]));
on.insert("push", push);
let mut pr = YVal::map();
pr.insert("branches", YVal::Array(vec![YVal::s("main")]));
on.insert("pull_request", pr);
let mut perms = YVal::map();
perms.insert("contents", YVal::s("read"));
perms.insert("packages", YVal::s("write"));
perms.insert("id-token", YVal::s("write"));
let mut jobs = YVal::map();
for stack in &c.stacks {
let job_name = stack.replace('-', "_");
let mut uses = String::from("pleme-io/substrate/.github/workflows/");
uses.push_str(stack);
uses.push_str(".yml@main");
let mut job = YVal::map();
job.insert("uses", YVal::s(uses));
job.insert("secrets", YVal::s("inherit"));
jobs.insert(job_name, job);
}
let mut root = YVal::map();
root.insert("name", YVal::s("pleme-stacks"));
root.insert("on", on);
root.insert("permissions", perms);
root.insert("jobs", jobs);
fs::write(&stacks_path, crate::yaml_ast::render(&root))?;
written.push(stacks_path.to_path_buf());
Ok(())
}
fn render_npm(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::json_ast::Value;
let mut written = vec![];
fs::create_dir_all(out)?;
let pkg_json = out.join("package.json");
if !pkg_json.exists() || force {
let pkg = &c.package;
let mut json = Value::obj();
if let Some(n) = pkg.get("name") { json.insert("name", Value::s(n)); }
if let Some(v) = pkg.get("version") { json.insert("version", Value::s(v)); }
if let Some(d) = pkg.get("description") { json.insert("description", Value::s(d)); }
if let Some(l) = pkg.get("license") { json.insert("license", Value::s(l)); }
if let Some(r) = pkg.get("repository") {
let mut repo_obj = Value::obj();
repo_obj.insert("type", Value::s("git"));
repo_obj.insert("url", Value::s(r));
json.insert("repository", repo_obj);
}
if let Some(kws) = c.package_lists.get("keywords") {
json.insert("keywords", Value::arr(kws.iter().map(Value::s)));
}
if let Some(supports) = c.supports_raw.as_deref() {
if let Some(node_req) = read_keyword_in_block(supports, ":node") {
let mut engines = Value::obj();
engines.insert("node", Value::s(node_req.trim_matches('"')));
json.insert("engines", engines);
}
}
if let Some(publish) = c.publish_raw.as_deref() {
let fields = parse_dict(publish);
if fields.get("private").map(String::as_str) == Some("true") {
json.insert("private", Value::b(true));
}
if let Some(files_vec) = extract_vector_block(publish, ":files-include") {
let items: Vec<Value> = files_vec
.split_whitespace()
.map(|s| Value::s(s.trim_matches('"')))
.filter(|v| matches!(v, Value::String(s) if !s.is_empty()))
.collect();
if !items.is_empty() {
json.insert("files", Value::Array(items));
}
}
}
json.insert("main", Value::s("index.js"));
let mut scripts_obj = Value::obj();
if let Some(scripts) = c.scripts_raw.as_deref() {
let s_fields = parse_dict(scripts);
for (k, v) in &s_fields {
scripts_obj.insert(k.as_str(), Value::s(v));
}
}
if matches!(&scripts_obj, Value::Object(items) if items.is_empty()) {
scripts_obj.insert("test", Value::s("echo OK"));
}
json.insert("scripts", scripts_obj);
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
emit_npm_deps_block_ast(&mut json, "dependencies", &deps.runtime);
emit_npm_deps_block_ast(&mut json, "devDependencies", &deps.dev);
crate::ast::emit(&pkg_json, &json)?;
written.push(pkg_json);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("npm").write(out, &pkg_name, force)? {
written.push(f);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn emit_npm_deps_block_ast(
json: &mut crate::json_ast::Value,
key: &str,
deps: &[BTreeMap<String, String>],
) {
use crate::json_ast::Value;
if deps.is_empty() { return; }
let mut block = Value::obj();
for d in deps {
if let Some(name) = d.get("name") {
let ver = d.get("version").cloned().unwrap_or_else(|| "*".to_string());
block.insert(name.as_str(), Value::s(ver));
}
}
json.insert(key, block);
}
fn render_python(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::toml_ast::{Document, Value as TVal};
let mut written = vec![];
fs::create_dir_all(out)?;
let pyproj = out.join("pyproject.toml");
if !pyproj.exists() || force {
let pkg = &c.package;
let mut doc = Document::new();
{
let p = doc.table("project");
if let Some(n) = pkg.get("name") { p.key("name", TVal::s(n)); }
if let Some(v) = pkg.get("version") { p.key("version", TVal::s(v)); }
if let Some(d) = pkg.get("description") { p.key("description", TVal::s(d)); }
if let Some(l) = pkg.get("license") {
if looks_like_spdx_id(l) {
p.key("license", TVal::s(l));
} else {
let mut inline = std::collections::BTreeMap::new();
inline.insert("text".to_string(), TVal::s(l));
p.key("license", TVal::InlineTable(inline));
}
}
if let Some(supports) = c.supports_raw.as_deref() {
if let Some(py_req) = read_keyword_in_block(supports, ":python") {
let clean = py_req.trim_matches('"').to_string();
p.key("requires-python", TVal::s(clean));
}
}
if c.supports_raw.is_none() {
if let Some(rp) = pkg.get("requires-python") {
p.key("requires-python", TVal::s(rp));
}
}
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() {
let items: Vec<TVal> = deps.runtime.iter()
.filter_map(|d| {
let n = d.get("name")?;
let v = d.get("version").cloned().unwrap_or_default();
Some(if v.is_empty() {
TVal::s(n.clone())
} else {
let mut s = n.clone();
s.push_str(">=");
s.push_str(&v);
TVal::s(s)
})
})
.collect();
p.key("dependencies", TVal::Array(items));
}
}
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.dev.is_empty() {
let items: Vec<TVal> = deps.dev.iter()
.filter_map(|d| {
let n = d.get("name")?;
let v = d.get("version").cloned().unwrap_or_default();
Some(if v.is_empty() {
TVal::s(n.clone())
} else {
let mut s = n.clone();
s.push_str(">=");
s.push_str(&v);
TVal::s(s)
})
})
.collect();
doc.table("project.optional-dependencies").key("dev", TVal::Array(items));
}
if let Some(scripts) = c.scripts_raw.as_deref() {
let s_fields = parse_dict(scripts);
if !s_fields.is_empty() {
let scripts_t = doc.table("project.scripts");
for (k, v) in &s_fields {
scripts_t.key(k.clone(), TVal::s(v));
}
}
}
let bs = doc.table("build-system");
bs.key("requires", TVal::arr([TVal::s("hatchling")]));
bs.key("build-backend", TVal::s("hatchling.build"));
crate::ast::emit(&pyproj, &doc)?;
written.push(pyproj);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("python").write(out, &pkg_name, force)? {
written.push(f);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_helm(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::yaml_ast::Value;
let mut written = vec![];
fs::create_dir_all(out)?;
fs::create_dir_all(out.join("templates"))?;
let chart_yaml = out.join("Chart.yaml");
if !chart_yaml.exists() || force {
let pkg = &c.package;
let mut chart = Value::map();
chart.insert("apiVersion", Value::s("v2"));
if let Some(n) = pkg.get("name") { chart.insert("name", Value::s(n)); }
if let Some(d) = pkg.get("description") { chart.insert("description", Value::s(d)); }
chart.insert("type", Value::s(pkg.get("type").map(String::as_str).unwrap_or("application")));
if let Some(v) = pkg.get("version") { chart.insert("version", Value::s(v)); }
if let Some(av) = pkg.get("appVersion") { chart.insert("appVersion", Value::s(av)); }
crate::ast::emit(&chart_yaml, &chart)?;
written.push(chart_yaml);
}
let values = out.join("values.yaml");
if !values.exists() || force {
let body = "enabled: true\nreplicaCount: 1\nimage:\n repository: ghcr.io/pleme-io/app\n tag: latest\nnameOverride: \"\"\n";
fs::write(&values, body)?;
written.push(values);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("helm").write(out, &pkg_name, force)? {
written.push(f);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_rust_workspace(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::toml_ast::{Document, Value as TVal};
let mut written = vec![];
fs::create_dir_all(out)?;
let cargo = out.join("Cargo.toml");
if !cargo.exists() || force {
let ws = &c.package;
let mut doc = Document::new();
{
let w = doc.table("workspace");
if let Some(members) = c.package_lists.get("members") {
w.key("members", TVal::arr(members.iter().map(TVal::s)));
}
w.key("resolver", TVal::s("2"));
}
{
let p = doc.table("workspace.package");
if let Some(v) = ws.get("version") { p.key("version", TVal::s(v)); }
p.key("edition", TVal::s("2024"));
if let Some(l) = ws.get("license") { p.key("license", TVal::s(l)); }
if let Some(r) = ws.get("repository") { p.key("repository", TVal::s(r)); }
}
if ws.contains_key("name") {
let p = doc.table("package");
if let Some(n) = ws.get("name") { p.key("name", TVal::s(n)); }
if let Some(d) = ws.get("description") { p.key("description", TVal::s(d)); }
let inherit = || TVal::inline_tbl([("workspace", TVal::b(true))]);
p.key("version", inherit());
p.key("edition", inherit());
p.key("license", inherit());
p.key("repository", inherit());
if let Some(kws) = c.package_lists.get("keywords") {
p.key("keywords", TVal::arr(kws.iter().map(TVal::s)));
}
if let Some(cats) = c.package_lists.get("categories") {
p.key("categories", TVal::arr(cats.iter().map(TVal::s)));
}
}
crate::ast::emit(&cargo, &doc)?;
written.push(cargo);
}
let tests_dir = out.join("tests");
let smoke = tests_dir.join("smoke.rs");
if !smoke.exists() || force {
fs::create_dir_all(&tests_dir)?;
fs::write(&smoke,
"//! Workspace smoke test — auto-generated by caixa-forge.\n\
//! Keeps `cargo test` green at the workspace root so the\n\
//! auto-release pipeline's pre-merge-gate passes by default.\n\n\
#[test]\n\
fn workspace_smoke() {\n \
assert_eq!(2 + 2, 4);\n\
}\n")?;
written.push(smoke);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn looks_like_spdx_id(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& !s.contains(char::is_whitespace)
&& s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '+'))
}
fn sanitize_nix_ident(s: &str) -> String {
s.chars().map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' => c,
_ => '_',
}).collect()
}
fn render_nix_module_template(template: &str, name: &str, desc: &str) -> String {
let ident = sanitize_nix_ident(name);
let name_str = nix_string_lit(name);
let desc_str = nix_string_lit(desc);
template
.replace("{NAME_IDENT}", &ident)
.replace("{NAME_STR}", &name_str)
.replace("{DESC_STR}", &desc_str)
}
const NIXOS_MODULE_TPL: &str = "# nix/modules/nixos.nix — auto-generated from {NAME_IDENT}.caixa.lisp
# description: {DESC_STR}
{ config, lib, pkgs, ... }:
let
cfg = config.services.{NAME_IDENT};
in {
options.services.{NAME_IDENT} = {
enable = lib.mkEnableOption {NAME_STR};
package = lib.mkOption {
type = lib.types.package;
default = pkgs.{NAME_IDENT} or null;
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
};
}
";
const DARWIN_MODULE_TPL: &str = "# nix/modules/darwin.nix — auto-generated from {NAME_IDENT}.caixa.lisp
{ config, lib, pkgs, ... }:
let cfg = config.services.{NAME_IDENT}; in {
options.services.{NAME_IDENT} = {
enable = lib.mkEnableOption {NAME_STR};
package = lib.mkOption { type = lib.types.package; default = pkgs.{NAME_IDENT} or null; };
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
};
}
";
const HM_MODULE_TPL: &str = "# nix/modules/home-manager.nix — auto-generated from {NAME_IDENT}.caixa.lisp
{ config, lib, pkgs, ... }:
let cfg = config.programs.{NAME_IDENT}; in {
options.programs.{NAME_IDENT} = {
enable = lib.mkEnableOption {NAME_STR};
package = lib.mkOption { type = lib.types.package; default = pkgs.{NAME_IDENT} or null; };
};
config = lib.mkIf cfg.enable { home.packages = [ cfg.package ]; };
}
";
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum ModuleScope {
NixOS,
Darwin,
HomeManager,
}
fn render_module_typed(scope: ModuleScope, name: &str, desc: &str) -> String {
use crate::nix_ast::{LambdaParam, NixValue};
use std::collections::BTreeMap;
let ident = sanitize_nix_ident(name);
let parent = match scope {
ModuleScope::HomeManager => "programs",
_ => "services",
};
let cfg_binding = NixValue::AttrPath(vec![
"config".into(),
parent.into(),
ident.clone(),
]);
let mut let_bindings = BTreeMap::new();
let_bindings.insert("cfg".to_string(), cfg_binding);
let mk_enable = NixValue::Apply {
func: Box::new(NixValue::AttrPath(vec![
"lib".into(),
"mkEnableOption".into(),
])),
arg: Box::new(NixValue::s(name)),
};
let mut mk_option_arg = BTreeMap::new();
mk_option_arg.insert(
"type".to_string(),
NixValue::AttrPath(vec!["lib".into(), "types".into(), "package".into()]),
);
mk_option_arg.insert(
"default".to_string(),
NixValue::AttrOr {
set: Box::new(NixValue::ident("pkgs")),
attr: ident.clone(),
fallback: Box::new(NixValue::Null),
},
);
let mk_option = NixValue::Apply {
func: Box::new(NixValue::AttrPath(vec![
"lib".into(),
"mkOption".into(),
])),
arg: Box::new(NixValue::AttrSet(mk_option_arg)),
};
let mut option_body = BTreeMap::new();
option_body.insert("enable".to_string(), mk_enable);
option_body.insert("package".to_string(), mk_option);
let options_set = NixValue::AttrSet(option_body);
let pkg_list = NixValue::List(vec![NixValue::AttrPath(vec![
"cfg".into(),
"package".into(),
])]);
let packages_attr = match scope {
ModuleScope::HomeManager => "home.packages",
_ => "environment.systemPackages",
};
let mut config_body = BTreeMap::new();
config_body.insert(packages_attr.to_string(), pkg_list);
let config_apply = NixValue::Apply {
func: Box::new(NixValue::Apply {
func: Box::new(NixValue::AttrPath(vec!["lib".into(), "mkIf".into()])),
arg: Box::new(NixValue::AttrPath(vec!["cfg".into(), "enable".into()])),
}),
arg: Box::new(NixValue::AttrSet(config_body)),
};
let mut top = BTreeMap::new();
let options_path = format!("options.{parent}.{ident}");
top.insert(options_path, options_set);
top.insert("config".to_string(), config_apply);
let body = NixValue::Let {
bindings: let_bindings,
body: Box::new(NixValue::AttrSet(top)),
};
let lambda = NixValue::Lambda {
param: LambdaParam::Set {
fields: vec![
("config".into(), None),
("lib".into(), None),
("pkgs".into(), None),
],
wildcard: true,
bind_all: None,
},
body: Box::new(body),
};
let header = match scope {
ModuleScope::NixOS => "# nix/modules/nixos.nix — auto-generated typed module\n",
ModuleScope::Darwin => "# nix/modules/darwin.nix — auto-generated typed module\n",
ModuleScope::HomeManager => {
"# nix/modules/home-manager.nix — auto-generated typed module\n"
}
};
let mut out = String::from(header);
out.push_str("# description: ");
out.push_str(desc);
out.push('\n');
out.push_str(&lambda.to_string());
out.push('\n');
out
}
fn nix_string_lit(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'$' => out.push_str("\\$"),
'\n' => out.push_str("\\n"),
c => out.push(c),
}
}
out.push('"');
out
}
fn render_tlisp_library(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let mut written = vec![];
fs::create_dir_all(out)?;
let readme = out.join("README.md");
if !readme.exists() || force {
let name = c.package.get("name").map(String::as_str)
.unwrap_or_else(|| c.name.as_str());
let desc = c.package.get("description").map(String::as_str)
.unwrap_or("Tatara-lisp library");
fs::write(&readme, format!("# {name}\n\n{desc}\n\nEaten via pleme-doc-gen substrate; canonical .caixa.lisp at root.\n"))?;
written.push(readme);
}
write_auto_release_workflow(&c.ecosystem, out, force, &mut written)?;
Ok(written)
}
fn render_nix_flake(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let mut written = vec![];
fs::create_dir_all(out)?;
let flake = out.join("flake.nix");
if !flake.exists() || force {
let desc = c.package.get("description").map(String::as_str).unwrap_or("");
use crate::nix_ast::{LambdaParam, NixValue};
let mut inputs_set = std::collections::BTreeMap::new();
inputs_set.insert(
"nixpkgs.url".to_string(),
NixValue::s("github:NixOS/nixpkgs/nixos-unstable"),
);
let outputs_lambda = NixValue::Lambda {
param: LambdaParam::Set {
fields: vec![("self".into(), None), ("nixpkgs".into(), None)],
wildcard: false,
bind_all: None,
},
body: Box::new(NixValue::AttrSet(std::collections::BTreeMap::new())),
};
let mut flake_set = std::collections::BTreeMap::new();
flake_set.insert("description".to_string(), NixValue::s(desc));
flake_set.insert("inputs".to_string(), NixValue::AttrSet(inputs_set));
flake_set.insert("outputs".to_string(), outputs_lambda);
let mut s = NixValue::AttrSet(flake_set).to_string();
s.push('\n');
fs::write(&flake, s)?;
written.push(flake);
}
write_auto_release_workflow(&c.ecosystem, out, force, &mut written)?;
Ok(written)
}
fn write_pleme_io_release_toml(
c: &Caixa,
out: &Path,
force: bool,
written: &mut Vec<PathBuf>,
) -> Result<()> {
let cfg_path = out.join(".pleme-io-release.toml");
if cfg_path.exists() && !force { return Ok(()); }
use crate::toml_ast::{Document, Value as TVal};
let mut doc = Document::new();
for (section, fields) in &c.ci_config {
let t = doc.table(section.clone());
for (k, v) in fields {
match v.as_str() {
"true" => { t.key(k.clone(), TVal::b(true)); }
"false" => { t.key(k.clone(), TVal::b(false)); }
s => { t.key(k.clone(), TVal::s(s)); }
}
}
}
crate::ast::emit(&cfg_path, &doc)?;
written.push(cfg_path);
Ok(())
}
fn auto_release_workflow_for(ecosystem: &str) -> &'static str {
match ecosystem {
"rust-single-crate" => "cargo-auto-release.yml",
"rust-workspace" => "rust-auto-release.yml",
"npm" | "typescript" | "typescript-tool" => "npm-auto-release.yml",
"python" => "python-auto-release.yml",
"helm" | "helm-chart" => "helm-auto-release.yml",
"ansible-collection" => "ansible-collection-release.yml",
"ruby-gem" | "ruby" => "gem-release.yml",
"github-action" => "action-release.yml",
_ => "auto-release.yml",
}
}
fn auto_release_yaml_for(
ecosystem: &str,
out_dir: Option<&Path>,
) -> Option<String> {
let mut effective_workflow: Option<&'static str> = None;
if let Some(out) = out_dir {
if matches!(ecosystem, "rust-single-crate" | "rust-workspace") {
let has_version = rust_has_bumpable_version(out);
let has_members = rust_workspace_has_members(out);
if !has_version && !has_members {
return None;
}
if !has_version && has_members {
effective_workflow = Some("cargo-publish-each-member-auto-release.yml");
}
}
}
let workflow = effective_workflow.unwrap_or_else(|| auto_release_workflow_for(ecosystem));
use crate::yaml_ast::Value as YVal;
let mut on = YVal::map();
let mut push = YVal::map();
push.insert("branches", YVal::Array(vec![YVal::s("main")]));
on.insert("push", push);
let mut wd_inputs = YVal::map();
let mut bump = YVal::map();
bump.insert("description", YVal::s("patch | minor | major"));
bump.insert("required", YVal::b(false));
bump.insert("default", YVal::s("patch"));
wd_inputs.insert("bump-type", bump);
let mut wd = YVal::map();
wd.insert("inputs", wd_inputs);
on.insert("workflow_dispatch", wd);
let mut release = YVal::map();
let uses = String::from("pleme-io/substrate/.github/workflows/")
+ workflow + "@main";
release.insert("uses", YVal::s(uses));
release.insert("secrets", YVal::s("inherit"));
let mut jobs = YVal::map();
jobs.insert("release", release);
let mut root = YVal::map();
root.insert("name", YVal::s("auto-release"));
root.insert("on", on);
root.insert("jobs", jobs);
Some(crate::yaml_ast::render(&root))
}
fn write_auto_release_workflow(
ecosystem: &str,
out: &Path,
force: bool,
written: &mut Vec<PathBuf>,
) -> Result<()> {
let Some(yaml) = auto_release_yaml_for(ecosystem, Some(out)) else {
return Ok(());
};
let wf_dir = out.join(".github").join("workflows");
let auto = wf_dir.join("auto-release.yml");
if !auto.exists() || force {
fs::create_dir_all(&wf_dir)?;
fs::write(&auto, yaml)?;
written.push(auto);
}
Ok(())
}
fn rust_has_bumpable_version(out: &Path) -> bool {
let cargo = out.join("Cargo.toml");
let Ok(text) = std::fs::read_to_string(&cargo) else { return false; };
section_has_version(&text, "[package]")
|| section_has_version(&text, "[workspace.package]")
}
fn rust_workspace_has_members(out: &Path) -> bool {
let cargo = out.join("Cargo.toml");
let Ok(text) = std::fs::read_to_string(&cargo) else { return false; };
let mut in_workspace = false;
for raw in text.lines() {
let trimmed = raw.trim();
let is_section_header = trimmed.starts_with('[') && !trimmed.contains('=');
if is_section_header {
in_workspace = trimmed == "[workspace]";
continue;
}
if !in_workspace { continue; }
let without_comment = trimmed.split('#').next().unwrap_or("").trim();
if let Some((key, _)) = without_comment.split_once('=') {
if key.trim() == "members" { return true; }
}
}
false
}
fn section_has_version(text: &str, section: &str) -> bool {
let mut in_section = false;
for raw in text.lines() {
let trimmed = raw.trim();
let is_section_header = trimmed.starts_with('[') && !trimmed.contains('=');
if is_section_header {
in_section = trimmed == section;
continue;
}
if !in_section { continue; }
let without_comment = trimmed.split('#').next().unwrap_or("").trim();
if let Some((key, _)) = without_comment.split_once('=') {
if key.trim() == "version" { return true; }
}
}
false
}
fn tlisp_stdlib_url(c: &Caixa) -> String {
for raw in &c.depends_on {
let Some(spec) = crate::caixa_deps::DepSpec::parse(raw) else { continue };
if spec.repo.contains("tlisp-stdlib") {
return format!(
"https://raw.githubusercontent.com/{}/{}/{}/stdlib.tlisp",
spec.owner, spec.repo, spec.rev,
);
}
}
"https://raw.githubusercontent.com/pleme-io/caixa-tlisp-stdlib/main/stdlib.tlisp".to_string()
}
fn render_github_action(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let mut written = vec![];
fs::create_dir_all(out)?;
let action_yml = out.join("action.yml");
if !action_yml.exists() || force {
let pkg = &c.package;
let display_name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
use crate::yaml_ast::Value as YVal;
let icon = pkg.get("branding-icon").cloned().unwrap_or_else(|| "box".into());
let color = pkg.get("branding-color").cloned().unwrap_or_else(|| "green".into());
let mut branding = YVal::map();
branding.insert("icon", YVal::s(&icon));
branding.insert("color", YVal::s(&color));
let stdlib_url = tlisp_stdlib_url(c);
let src_step_run = format!("{{\n \
echo 'script<<TLISP_EOF'\n \
curl -sL {stdlib_url}\n \
echo\n \
cat ${{{{ github.action_path }}}}/run.tlisp\n \
echo 'TLISP_EOF'\n\
}} >> \"$GITHUB_OUTPUT\"");
let src_step_run = src_step_run.as_str();
let mut src_step = YVal::map();
src_step.insert("id", YVal::s("src"));
src_step.insert("shell", YVal::s("bash"));
src_step.insert("run", YVal::block(src_step_run));
let mut run_with = YVal::map();
run_with.insert("script", YVal::s("${{ steps.src.outputs.script }}"));
let mut run_step = YVal::map();
run_step.insert("id", YVal::s("run"));
run_step.insert("uses", YVal::s("pleme-io/actions/tatara-script@v1"));
run_step.insert("with", run_with);
let mut runs = YVal::map();
runs.insert("using", YVal::s("composite"));
runs.insert("steps", YVal::Array(vec![src_step, run_step]));
let mut root = YVal::map();
root.insert("name", YVal::s(&format!("pleme-io · {display_name}")));
if let Some(d) = pkg.get("description") { root.insert("description", YVal::s(d)); }
root.insert("branding", branding);
root.insert("inputs", YVal::map());
root.insert("outputs", YVal::map());
root.insert("runs", runs);
fs::write(&action_yml, crate::yaml_ast::render(&root))?;
written.push(action_yml);
}
let run_tlisp = out.join("run.tlisp");
if !run_tlisp.exists() || force {
fs::write(&run_tlisp, ";; run.tlisp — rendered from .caixa.lisp\n(log-info \"::warning::stub body\")\n(exit 0)\n")?;
written.push(run_tlisp);
}
let pkg_name = c.package.get("name").cloned().unwrap_or_else(|| c.name.clone());
for f in crate::green_ci::starter_for("github-action").write(out, &pkg_name, force)? { written.push(f); }
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
const PRE_MERGE_GATE_SHIM: &str = r#"name: pre-merge-gate
# Generated from caixa source. Edit caixa, re-render.
on:
pull_request:
branches: [main]
jobs:
gate:
uses: pleme-io/substrate/.github/workflows/pre-merge-gate.yml@main
secrets: inherit
"#;
const SECURITY_GATE_SHIM: &str = r#"name: security-gate
# Generated from caixa source. Edit caixa, re-render.
on:
pull_request:
branches: [main]
schedule:
- cron: '0 6 * * 1'
jobs:
gate:
uses: pleme-io/substrate/.github/workflows/security-gate.yml@main
secrets: inherit
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rust_library_caixa_renders() {
let src = r#"
(defcaixa my-lib
:kind :Biblioteca
:ecosystem :rust-single-crate
:package { :name "my-lib" :version "0.1.0" :description "A test lib." :license "MIT" }
:ci-config { :bump { :default-type "patch" :skip-when-no-source-changes true } }
:workflows [ :auto-release :pre-merge-gate :security-gate ])
"#;
let parsed = parse(src).unwrap();
assert_eq!(parsed.name, "my-lib");
assert_eq!(parsed.kind, "Biblioteca");
assert_eq!(parsed.ecosystem, "rust-single-crate");
assert_eq!(parsed.package.get("name").map(String::as_str), Some("my-lib"));
assert_eq!(parsed.package.get("version").map(String::as_str), Some("0.1.0"));
assert_eq!(parsed.workflows, vec!["auto-release", "pre-merge-gate", "security-gate"]);
assert!(parsed.ci_config.contains_key("bump"));
}
#[test]
fn parse_dict_honors_escaped_double_quotes() {
let inner = r#":description "Strip leading \"v\" from tag" :other "ok""#;
let d = parse_dict(inner);
assert_eq!(d.get("description").map(String::as_str),
Some("Strip leading \"v\" from tag"));
assert_eq!(d.get("other").map(String::as_str), Some("ok"));
}
#[test]
fn parse_dict_honors_escaped_backslash() {
let inner = r#":path "C:\\foo\\bar""#;
let d = parse_dict(inner);
assert_eq!(d.get("path").map(String::as_str), Some(r"C:\foo\bar"));
}
#[test]
fn parse_lisp_vector_items_preserves_strings_with_spaces() {
let items = parse_lisp_vector_items(r#""airbnb" "es6" "style guide" "lint""#);
assert_eq!(items, vec!["airbnb", "es6", "style guide", "lint"]);
}
#[test]
fn parse_lisp_vector_items_handles_mixed_keywords_and_strings() {
let items = parse_lisp_vector_items(r#":auto-release :pre-merge-gate "named string""#);
assert_eq!(items, vec!["auto-release", "pre-merge-gate", "named string"]);
}
#[test]
fn parse_lisp_vector_items_honors_escapes() {
let items = parse_lisp_vector_items(r#""he said \"hi\"" "ok""#);
assert_eq!(items, vec!["he said \"hi\"", "ok"]);
}
#[test]
fn sanitize_nix_ident_strips_unsafe_chars() {
assert_eq!(sanitize_nix_ident("foo-bar_baz"), "foo-bar_baz");
assert_eq!(sanitize_nix_ident("foo.bar"), "foo_bar");
assert_eq!(sanitize_nix_ident("foo bar"), "foo_bar");
assert_eq!(sanitize_nix_ident("foo$bar"), "foo_bar");
assert_eq!(sanitize_nix_ident("\"quoted\""), "_quoted_");
}
#[test]
fn render_nix_module_template_substitutes_typed_values() {
let tpl = "name = {NAME_IDENT}; lbl = {NAME_STR}; desc = {DESC_STR};\n";
let out = render_nix_module_template(tpl, "my-app", "An \"app\".");
assert!(out.contains("name = my-app;"));
assert!(out.contains("lbl = \"my-app\";"));
assert!(out.contains("desc = \"An \\\"app\\\".\";"));
}
#[test]
fn render_nix_module_template_rejects_identifier_injection() {
let tpl = "config.services.{NAME_IDENT} = {};";
let out = render_nix_module_template(tpl, "evil; }", "x");
assert!(out.contains("config.services.evil___"));
assert!(!out.contains("evil; }"));
}
}