use anyhow::{bail, Result};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
pub fn render(src: &str, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let parsed = parse(src)?;
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),
"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),
"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>> {
let mut written = vec![];
fs::create_dir_all(out)?;
let gomod = out.join("go.mod");
if !gomod.exists() || force {
let pkg = &c.package;
let mut s = String::new();
let name = pkg.get("name").cloned().unwrap_or_else(|| c.name.clone());
let module_path = pkg.get("repository").cloned()
.map(|r| r.trim_start_matches("https://").trim_start_matches("http://").to_string())
.unwrap_or_else(|| format!("github.com/pleme-io/{name}"));
s.push_str(&format!("module {module_path}\n\n"));
let go_ver = 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());
s.push_str(&format!("go {go_ver}\n"));
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() {
s.push_str("\nrequire (\n");
for d in &deps.runtime {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
s.push_str(&format!("\t{n} {v}\n"));
}
}
s.push_str(")\n");
}
fs::write(&gomod, s)?;
written.push(gomod);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_crystal(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 s = String::new();
if let Some(n) = pkg.get("name") { s.push_str(&format!("name: {n}\n")); }
if let Some(v) = pkg.get("version") { s.push_str(&format!("version: {v}\n")); }
if let Some(l) = pkg.get("license") { s.push_str(&format!("license: {l}\n")); }
if let Some(authors) = pkg.get("authors") {
s.push_str(&format!("authors:\n - {authors}\n"));
}
if let Some(supports) = c.supports_raw.as_deref() {
if let Some(cr) = read_keyword_in_block(supports, ":crystal") {
s.push_str(&format!("crystal: \"{}\"\n", cr.trim_matches('"')));
}
}
fs::write(&shard, s)?;
written.push(shard);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_dart(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 s = String::new();
if let Some(n) = pkg.get("name") { s.push_str(&format!("name: {n}\n")); }
if let Some(v) = pkg.get("version") { s.push_str(&format!("version: {v}\n")); }
if let Some(d) = pkg.get("description") { s.push_str(&format!("description: {d}\n")); }
if let Some(r) = pkg.get("repository") { s.push_str(&format!("repository: {r}\n")); }
if let Some(supports) = c.supports_raw.as_deref() {
if let Some(sdk) = read_keyword_in_block(supports, ":dart") {
s.push_str(&format!("environment:\n sdk: '{}'\n", sdk.trim_matches('"')));
}
}
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() {
s.push_str("dependencies:\n");
for d in &deps.runtime {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
s.push_str(&format!(" {n}: {v}\n"));
}
}
}
if !deps.dev.is_empty() {
s.push_str("dev_dependencies:\n");
for d in &deps.dev {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
s.push_str(&format!(" {n}: {v}\n"));
}
}
}
fs::write(&pubspec, s)?;
written.push(pubspec);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_composer(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 json = String::from("{\n");
if let Some(n) = pkg.get("name") { json.push_str(&format!(" \"name\": \"{n}\",\n")); }
if let Some(d) = pkg.get("description") { json.push_str(&format!(" \"description\": \"{d}\",\n")); }
json.push_str(" \"type\": \"library\",\n");
if let Some(l) = pkg.get("license") { json.push_str(&format!(" \"license\": \"{l}\",\n")); }
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());
json.push_str(" \"require\": {\n");
json.push_str(&format!(" \"php\": \"{php_req}\""));
for d in &deps.runtime {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
json.push_str(&format!(",\n \"{n}\": \"{v}\""));
}
}
json.push_str("\n }");
if !deps.dev.is_empty() {
json.push_str(",\n \"require-dev\": {\n");
let entries: Vec<String> = deps.dev.iter()
.filter_map(|d| {
let n = d.get("name")?;
let v = d.get("version").cloned().unwrap_or_else(|| "*".to_string());
Some(format!(" \"{n}\": \"{v}\""))
})
.collect();
json.push_str(&entries.join(",\n"));
json.push_str("\n }");
}
json.push_str("\n}\n");
fs::write(&composer, json)?;
written.push(composer);
}
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>> {
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 xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n");
xml.push_str(" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n");
xml.push_str(" xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n");
xml.push_str(" <modelVersion>4.0.0</modelVersion>\n");
xml.push_str(&format!(" <groupId>{}</groupId>\n", xml_escape(&group)));
xml.push_str(&format!(" <artifactId>{}</artifactId>\n", xml_escape(&artifact)));
xml.push_str(&format!(" <version>{}</version>\n", xml_escape(&version)));
xml.push_str(" <packaging>jar</packaging>\n");
if let Some(d) = pkg.get("description") {
xml.push_str(&format!(" <description>{}</description>\n", xml_escape(d)));
}
if let Some(l) = pkg.get("license") {
xml.push_str(" <licenses>\n");
xml.push_str(&format!(" <license><name>{}</name></license>\n", xml_escape(l)));
xml.push_str(" </licenses>\n");
}
if let Some(r) = pkg.get("repository") {
xml.push_str(" <scm>\n");
xml.push_str(&format!(" <url>{}</url>\n", xml_escape(r)));
xml.push_str(" </scm>\n");
}
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() || !deps.dev.is_empty() {
xml.push_str(" <dependencies>\n");
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());
xml.push_str(" <dependency>\n");
xml.push_str(&format!(" <groupId>{}</groupId>\n", xml_escape(&g)));
xml.push_str(&format!(" <artifactId>{}</artifactId>\n", xml_escape(n)));
if !v.is_empty() {
xml.push_str(&format!(" <version>{}</version>\n", xml_escape(&v)));
}
xml.push_str(" </dependency>\n");
}
}
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());
xml.push_str(" <dependency>\n");
xml.push_str(&format!(" <groupId>{}</groupId>\n", xml_escape(&g)));
xml.push_str(&format!(" <artifactId>{}</artifactId>\n", xml_escape(n)));
if !v.is_empty() {
xml.push_str(&format!(" <version>{}</version>\n", xml_escape(&v)));
}
xml.push_str(" <scope>test</scope>\n");
xml.push_str(" </dependency>\n");
}
}
xml.push_str(" </dependencies>\n");
}
xml.push_str("</project>\n");
fs::write(&pom, xml)?;
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>> {
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 mut xml = String::from("<Project Sdk=\"Microsoft.NET.Sdk\">\n");
xml.push_str(" <PropertyGroup>\n");
xml.push_str(&format!(" <TargetFramework>{}</TargetFramework>\n", xml_escape(&target)));
let output_type = if c.kind == "Binario" { "Exe" } else { "Library" };
xml.push_str(&format!(" <OutputType>{output_type}</OutputType>\n"));
if let Some(v) = pkg.get("version") {
xml.push_str(&format!(" <Version>{}</Version>\n", xml_escape(v)));
}
if let Some(a) = pkg.get("authors") {
xml.push_str(&format!(" <Authors>{}</Authors>\n", xml_escape(a)));
}
if let Some(d) = pkg.get("description") {
xml.push_str(&format!(" <Description>{}</Description>\n", xml_escape(d)));
}
if let Some(l) = pkg.get("license") {
xml.push_str(&format!(" <PackageLicenseExpression>{}</PackageLicenseExpression>\n", xml_escape(l)));
}
if let Some(r) = pkg.get("repository") {
xml.push_str(&format!(" <RepositoryUrl>{}</RepositoryUrl>\n", xml_escape(r)));
}
xml.push_str(" </PropertyGroup>\n");
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() || !deps.dev.is_empty() {
xml.push_str(" <ItemGroup>\n");
for d in &deps.runtime {
if let Some(n) = d.get("name") {
let v = d.get("version").cloned().unwrap_or_default();
xml.push_str(&format!(" <PackageReference Include=\"{}\" Version=\"{}\" />\n",
xml_escape(n), xml_escape(&v)));
}
}
for d in &deps.dev {
if let Some(n) = d.get("name") {
let v = d.get("version").cloned().unwrap_or_default();
xml.push_str(&format!(" <PackageReference Include=\"{}\" Version=\"{}\" PrivateAssets=\"all\" />\n",
xml_escape(n), xml_escape(&v)));
}
}
xml.push_str(" </ItemGroup>\n");
}
xml.push_str("</Project>\n");
fs::write(&path, xml)?;
written.push(path);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_dune(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 s = String::from("(lang dune 3.14)\n");
s.push_str(&format!("(name {name})\n"));
if let Some(v) = pkg.get("version") {
s.push_str(&format!("(version {v})\n"));
}
s.push_str("(generate_opam_files true)\n");
if let Some(r) = pkg.get("repository") {
let gh = r.trim_start_matches("https://github.com/").trim_end_matches(".git");
if r.contains("github.com") {
s.push_str(&format!("(source (github {gh}))\n"));
}
}
if let Some(l) = pkg.get("license") {
s.push_str(&format!("(license {l})\n"));
}
s.push_str("(authors \"pleme-io\")\n");
s.push_str("(maintainers \"engineering@pleme.io\")\n");
s.push_str(&format!("(package\n (name {name})\n"));
if let Some(d) = pkg.get("description") {
s.push_str(&format!(" (synopsis \"{}\")\n", d.replace('"', "\\\"")));
}
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(""));
s.push_str(" (depends\n");
s.push_str(&format!(" (ocaml (>= {ocaml_req}))\n"));
s.push_str(" dune\n");
for d in &deps.runtime {
if let Some(n) = d.get("name") {
s.push_str(&format!(" {n}\n"));
}
}
for d in &deps.dev {
if let Some(n) = d.get("name") {
s.push_str(&format!(" ({n} :with-test)\n"));
}
}
s.push_str(" )\n)\n");
fs::write(&dune, s)?;
written.push(dune);
}
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());
let settings = out.join("settings.gradle.kts");
if !settings.exists() || force {
fs::write(&settings, format!("rootProject.name = \"{name}\"\n"))?;
written.push(settings);
}
let build = out.join("build.gradle.kts");
if !build.exists() || force {
let is_app = c.kind == "Binario";
let plugin = if is_app { "application" } else { "`java-library`" };
let mut s = format!("plugins {{ {plugin} }}\n\n");
s.push_str(&format!("group = \"{group}\"\nversion = \"{version}\"\n\n"));
s.push_str("repositories { mavenCentral() }\n\n");
s.push_str("dependencies {\n");
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")) {
let g = d.get("group-id").cloned().unwrap_or_else(|| "com.example".to_string());
s.push_str(&format!(" implementation(\"{g}:{n}:{v}\")\n"));
}
}
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());
s.push_str(&format!(" testImplementation(\"{g}:{n}:{v}\")\n"));
}
}
s.push_str("}\n\n");
s.push_str(&format!("java {{ toolchain {{ languageVersion = JavaLanguageVersion.of({java_ver}) }} }}\n"));
fs::write(&build, s)?;
written.push(build);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_swift_spm(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 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 mut s = format!("// swift-tools-version:{tools_ver}\nimport PackageDescription\n\nlet package = Package(\n name: \"{name}\",\n");
s.push_str(" platforms: [.macOS(.v13)],\n");
if is_exe {
s.push_str(&format!(" products: [.executable(name: \"{name}\", targets: [\"{name}\"])],\n"));
} else {
s.push_str(&format!(" products: [.library(name: \"{name}\", targets: [\"{name}\"])],\n"));
}
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() {
s.push_str(" dependencies: [\n");
for d in &deps.runtime {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
let url = d.get("repository").cloned()
.unwrap_or_else(|| format!("https://github.com/{n}/{n}"));
s.push_str(&format!(" .package(url: \"{url}\", from: \"{v}\"),\n"));
}
}
s.push_str(" ],\n");
}
let target_kind = if is_exe { "executableTarget" } else { "target" };
s.push_str(&format!(" targets: [\n .{target_kind}(name: \"{name}\"),\n ]\n)\n"));
fs::write(&pkg_swift, s)?;
written.push(pkg_swift);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_elixir_mix(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 mix = out.join("mix.exs");
if !mix.exists() || force {
let module = format!("{}.MixProject", 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 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 mut s = format!("defmodule {module} do\n use Mix.Project\n\n def project, do: [\n");
s.push_str(&format!(" app: :{},\n", name.replace('-', "_")));
s.push_str(&format!(" version: \"{version}\",\n"));
s.push_str(&format!(" elixir: \"{elixir_req}\",\n"));
s.push_str(&format!(" description: \"{desc}\",\n"));
s.push_str(&format!(" package: [licenses: [\"{license}\"]],\n"));
s.push_str(" deps: deps()\n ]\n\n");
s.push_str(" def application, do: [extra_applications: [:logger]]\n\n");
s.push_str(" defp deps, do: [\n");
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")) {
s.push_str(&format!(" {{:{n}, \"{v}\"}},\n"));
}
}
for d in &deps.dev {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
s.push_str(&format!(" {{:{n}, \"{v}\", only: :dev, runtime: false}},\n"));
}
}
s.push_str(" ]\nend\n");
fs::write(&mix, s)?;
written.push(mix);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_ruby_gem(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 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(|| format!("https://github.com/pleme-io/{name}"));
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 mut s = String::from("Gem::Specification.new do |spec|\n");
s.push_str(&format!(" spec.name = '{name}'\n"));
s.push_str(&format!(" spec.version = '{version}'\n"));
s.push_str(" spec.authors = ['pleme-io']\n");
s.push_str(" spec.email = ['engineering@pleme.io']\n");
s.push_str(&format!(" spec.summary = '{desc}'\n"));
s.push_str(&format!(" spec.description = '{desc}'\n"));
s.push_str(&format!(" spec.license = '{license}'\n"));
s.push_str(&format!(" spec.homepage = '{homepage}'\n"));
s.push_str(" spec.files = Dir['lib/**/*.rb']\n");
s.push_str(&format!(" spec.required_ruby_version = '{ruby_req}'\n"));
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")) {
s.push_str(&format!(" spec.add_dependency '{n}', '{v}'\n"));
}
}
for d in &deps.dev {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
s.push_str(&format!(" spec.add_development_dependency '{n}', '{v}'\n"));
}
}
s.push_str("end\n");
fs::write(&gemspec, s)?;
written.push(gemspec);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_zig(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 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 mut s = format!(".{{\n .name = .{},\n .version = \"{version}\",\n .minimum_zig_version = \"{min_zig}\",\n",
name.replace('-', "_"));
s.push_str(" .dependencies = .{},\n");
s.push_str(" .paths = .{ \"build.zig\", \"build.zig.zon\", \"src\" },\n}\n");
fs::write(&zon, s)?;
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 s = String::from("const std = @import(\"std\");\n\npub fn build(b: *std.Build) void {\n");
s.push_str(" const target = b.standardTargetOptions(.{});\n");
s.push_str(" const optimize = b.standardOptimizeOption(.{});\n");
s.push_str(&format!(" const artifact = b.{kind_fn}(.{{\n"));
s.push_str(&format!(" .name = \"{name}\",\n"));
s.push_str(" .root_source_file = b.path(\"src/main.zig\"),\n");
s.push_str(" .target = target,\n .optimize = optimize,\n });\n");
s.push_str(" b.installArtifact(artifact);\n}\n");
fs::write(&build, s)?;
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>> {
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 s = format!("version = \"{version}\"\n");
s.push_str("author = \"pleme-io\"\n");
s.push_str(&format!("description = \"{desc}\"\n"));
s.push_str(&format!("license = \"{license}\"\n"));
s.push_str("srcDir = \"src\"\n");
if c.kind == "Binario" {
s.push_str(&format!("bin = @[\"{name}\"]\n"));
}
s.push_str(&format!("\nrequires \"nim >= {nim_req}\"\n"));
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")) {
s.push_str(&format!("requires \"{n} >= {v}\"\n"));
}
}
fs::write(&nimble, s)?;
written.push(nimble);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_sbt(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 s = format!("name := \"{name}\"\nversion := \"{version}\"\nscalaVersion := \"{scala_ver}\"\n");
if let Some(d) = pkg.get("description") {
s.push_str(&format!("description := \"{}\"\n", d.replace('"', "\\\"")));
}
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() {
s.push_str("\nlibraryDependencies ++= Seq(\n");
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(|| "org.example".to_string());
s.push_str(&format!(" \"{g}\" %% \"{n}\" % \"{v}\",\n"));
}
}
s.push_str(")\n");
}
fs::write(&build, s)?;
written.push(build);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_clj_deps(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
let mut written = vec![];
fs::create_dir_all(out)?;
let deps_edn = out.join("deps.edn");
if !deps_edn.exists() || force {
let _ = c.package.len();
let mut s = String::from("{:paths [\"src\" \"resources\"]\n :deps {\n");
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")) {
s.push_str(&format!(" {n} {{:mvn/version \"{v}\"}}\n"));
}
}
s.push_str(" }}\n");
fs::write(&deps_edn, s)?;
written.push(deps_edn);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_r(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 s = format!("Package: {name}\nVersion: {version}\nType: Package\n");
if let Some(d) = pkg.get("description") {
s.push_str(&format!("Title: {d}\nDescription: {d}\n"));
}
if let Some(l) = pkg.get("license") {
s.push_str(&format!("License: {l}\n"));
}
s.push_str("Authors@R: person(\"pleme-io\", role = c(\"aut\", \"cre\"))\n");
s.push_str("Encoding: UTF-8\nRoxygenNote: 7.3.1\n");
fs::write(&desc, s)?;
written.push(desc);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_rockspec(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 rockspec = out.join(format!("{name}-{version}-1.rockspec"));
if !rockspec.exists() || force {
let mut s = format!("package = \"{name}\"\nversion = \"{version}-1\"\n");
s.push_str("source = { url = \"\" }\n");
s.push_str("description = {\n");
if let Some(d) = pkg.get("description") { s.push_str(&format!(" summary = \"{d}\",\n")); }
if let Some(l) = pkg.get("license") { s.push_str(&format!(" license = \"{l}\"\n")); }
s.push_str("}\ndependencies = { \"lua >= 5.1\" }\n");
s.push_str("build = { type = \"builtin\", modules = {} }\n");
fs::write(&rockspec, s)?;
written.push(rockspec);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_conan(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 = 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>();
let mut s = String::from("from conan import ConanFile\n\n");
s.push_str(&format!("class {class_name}Conan(ConanFile):\n"));
s.push_str(&format!(" name = \"{name}\"\n version = \"{version}\"\n"));
if let Some(l) = pkg.get("license") { s.push_str(&format!(" license = \"{l}\"\n")); }
s.push_str(" settings = \"os\", \"compiler\", \"build_type\", \"arch\"\n");
fs::write(&conanfile, s)?;
written.push(conanfile);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_conda(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 s = format!("package:\n name: {name}\n version: \"{version}\"\nsource:\n path: .\n");
s.push_str("build:\n noarch: python\n script: pip install . --no-deps\nrequirements:\n host:\n - python\n - pip\n run:\n - python\n");
if let Some(d) = pkg.get("description") { s.push_str(&format!("about:\n summary: {d}\n")); }
fs::write(&meta, s)?;
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>> {
let mut written = vec![];
fs::create_dir_all(out)?;
let pipfile = out.join("Pipfile");
if !pipfile.exists() || force {
let mut s = String::from("[[source]]\nname = \"pypi\"\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\n\n[packages]\n");
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")) {
s.push_str(&format!("{n} = \"{v}\"\n"));
}
}
s.push_str("\n[dev-packages]\n");
for d in &deps.dev {
if let (Some(n), Some(v)) = (d.get("name"), d.get("version")) {
s.push_str(&format!("{n} = \"{v}\"\n"));
}
}
fs::write(&pipfile, s)?;
written.push(pipfile);
}
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>> {
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 s = format!("{{\n \"name\": \"@pleme-io/{name}\",\n \"version\": \"{version}\",\n \"exports\": \"./mod.ts\",\n");
s.push_str(" \"tasks\": { \"test\": \"deno test\" },\n \"imports\": {}\n}\n");
fs::write(&deno_json, s)?;
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>> {
let mut written = render_npm(c, out, force)?;
let pnpm_ws = out.join("pnpm-workspace.yaml");
if !pnpm_ws.exists() || force {
fs::write(&pnpm_ws, "packages:\n - 'packages/*'\n")?;
written.push(pnpm_ws);
}
Ok(written)
}
fn render_vcpkg(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 s = format!("{{\n \"name\": \"{name}\",\n \"version-string\": \"{version}\",\n");
if let Some(d) = pkg.get("description") { s.push_str(&format!(" \"description\": \"{d}\",\n")); }
s.push_str(" \"dependencies\": [\n");
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
let entries: Vec<String> = deps.runtime.iter()
.filter_map(|d| d.get("name").cloned())
.map(|n| format!(" \"{n}\""))
.collect();
s.push_str(&entries.join(",\n"));
s.push_str("\n ]\n}\n");
fs::write(&vcpkg, s)?;
written.push(vcpkg);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_meson(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 mut s = format!("project('{name}', 'c', 'cpp',\n version: '{version}',\n license: '{license}',\n");
s.push_str(" default_options: ['cpp_std=c++20'])\n\n");
s.push_str(&format!("executable('{name}', 'src/main.cpp')\n"));
fs::write(&build, s)?;
written.push(build);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_fpm(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 s = String::new();
if let Some(n) = pkg.get("name") { s.push_str(&format!("name = \"{n}\"\n")); }
if let Some(v) = pkg.get("version") { s.push_str(&format!("version = \"{v}\"\n")); }
if let Some(l) = pkg.get("license") { s.push_str(&format!("license = \"{l}\"\n")); }
if let Some(d) = pkg.get("description") { s.push_str(&format!("\n[build]\nauto-executables = true\nauto-tests = true\n\n# {d}\n")); }
fs::write(&fpm, s)?;
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>> {
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 s = String::new();
if let Some(n) = pkg.get("name") { s.push_str(&format!("name = \"{n}\"\n")); }
if let Some(v) = pkg.get("version") { s.push_str(&format!("version = \"{v}\"\n")); }
if let Some(l) = pkg.get("license") { s.push_str(&format!("licences = [\"{l}\"]\n")); }
if let Some(d) = pkg.get("description") { s.push_str(&format!("description = \"{d}\"\n")); }
s.push_str("\n[dependencies]\ngleam_stdlib = \">= 0.34.0 and < 2.0.0\"\n\n[dev-dependencies]\ngleeunit = \">= 1.0.0 and < 2.0.0\"\n");
fs::write(&g, s)?;
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>> {
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 s = format!("name = \"{name}\"\nversion = \"{version}\"\n");
if let Some(d) = pkg.get("description") { s.push_str(&format!("description = \"{d}\"\n")); }
if let Some(l) = pkg.get("license") { s.push_str(&format!("licenses = \"{l}\"\n")); }
s.push_str("authors = [\"pleme-io\"]\nmaintainers = [\"engineering@pleme.io\"]\nmaintainers-logins = [\"pleme-io\"]\n");
s.push_str("\n[[depends-on]]\ngnat = \">=11\"\n");
fs::write(&a, s)?;
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>> {
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 s = format!("cabal-version: 2.4\nname: {name}\nversion: {version}\n");
if let Some(d) = pkg.get("description") { s.push_str(&format!("synopsis: {d}\ndescription: {d}\n")); }
if let Some(l) = pkg.get("license") { s.push_str(&format!("license: {l}\n")); }
s.push_str("author: pleme-io\nmaintainer: engineering@pleme.io\nbuild-type: Simple\n\n");
s.push_str("library\n exposed-modules: Lib\n hs-source-dirs: src\n build-depends: base >= 4.14 && < 5\n default-language: Haskell2010\n");
fs::write(&cab, s)?;
written.push(cab);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_racket(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 s = format!("#lang info\n(define collection \"{name}\")\n(define version \"{version}\")\n(define deps '(\"base\"))\n");
if let Some(d) = pkg.get("description") { s.push_str(&format!("(define pkg-desc \"{d}\")\n")); }
s.push_str("(define pkg-authors '(pleme-io))\n");
fs::write(&info, s)?;
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('"')));
}
}
}
fs::write(&project, doc.render())?;
written.push(project);
}
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 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 parse(src: &str) -> Result<Caixa> {
let cleaned: String = src
.lines()
.map(|l| match l.find(";;") {
Some(i) => &l[..i],
None => l,
})
.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(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(
inner
.split_whitespace()
.map(|w| w.trim_matches('"').trim_start_matches(':').to_string())
.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;
while end < inner.len() && inner.as_bytes()[end] != b'"' {
end += 1;
}
out.insert(kw, inner[val_start + 1..end].to_string());
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>> {
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 cargo = String::from("[package]\n");
let push_str = |s: &mut String, k: &str, v: &str| {
s.push_str(&format!("{k} = \"{v}\"\n"));
};
if let Some(n) = pkg.get("name") { push_str(&mut cargo, "name", n); }
if let Some(v) = pkg.get("version") { push_str(&mut cargo, "version", v); }
cargo.push_str("edition = \"2024\"\n");
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(">=");
push_str(&mut cargo, "rust-version", clean);
}
}
if let Some(d) = pkg.get("description") { push_str(&mut cargo, "description", d); }
if let Some(l) = pkg.get("license") { push_str(&mut cargo, "license", l); }
if let Some(r) = pkg.get("repository") { push_str(&mut cargo, "repository", r); }
if let Some(h) = pkg.get("homepage") { push_str(&mut cargo, "homepage", h); }
push_str(&mut cargo, "readme", "README.md");
if let Some(a) = pkg.get("authors") {
cargo.push_str(&format!("authors = [\"{a}\"]\n"));
} else {
cargo.push_str("authors = [\"pleme-io\"]\n");
}
if let Some(cats) = c.package_lists.get("categories") {
let joined: Vec<String> = cats.iter().map(|s| format!("\"{s}\"")).collect();
cargo.push_str(&format!("categories = [{}]\n", joined.join(", ")));
}
if let Some(kws) = c.package_lists.get("keywords") {
let joined: Vec<String> = kws.iter().map(|s| format!("\"{s}\"")).collect();
cargo.push_str(&format!("keywords = [{}]\n", joined.join(", ")));
}
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") { push_str(&mut cargo, "name", n); }
if let Some(p) = bin.get("path") { push_str(&mut cargo, "path", p); }
}
}
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);
}
}
cargo.push_str("\n[lints.clippy]\npedantic = \"warn\"\n");
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(""));
cargo.push_str("\n[dependencies]\n");
for d in &deps.runtime {
cargo.push_str(&emit_cargo_dep(d));
}
if !deps.dev.is_empty() {
cargo.push_str("\n[dev-dependencies]\n");
for d in &deps.dev {
cargo.push_str(&emit_cargo_dep(d));
}
}
if !deps.build.is_empty() {
cargo.push_str("\n[build-dependencies]\n");
for d in &deps.build {
cargo.push_str(&emit_cargo_dep(d));
}
}
fs::write(&cargo_path, cargo)?;
written.push(cargo_path);
}
let cfg_path = out.join(".pleme-io-release.toml");
if !cfg_path.exists() || force {
let mut cfg = String::from("# Generated by pleme-doc-gen caixa\n");
for (section, fields) in &c.ci_config {
cfg.push_str(&format!("[{section}]\n"));
for (k, v) in fields {
let v = if matches!(v.as_str(), "true" | "false") {
v.clone()
} else {
format!("\"{v}\"")
};
cfg.push_str(&format!("{k} = {v}\n"));
}
cfg.push('\n');
}
fs::write(&cfg_path, cfg)?;
written.push(cfg_path);
}
for wf in &c.workflows {
let path = wf_dir.join(format!("{wf}.yml"));
if path.exists() && !force {
continue;
}
let content = match wf.as_str() {
"auto-release" => AUTO_RELEASE_SHIM,
"pre-merge-gate" => PRE_MERGE_GATE_SHIM,
"security-gate" => SECURITY_GATE_SHIM,
_ => 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)?;
let cfg_path = out.join(".pleme-io-release.toml");
if !cfg_path.exists() || force {
let mut cfg = String::from("# Generated by pleme-doc-gen caixa\n");
for (section, fields) in &c.ci_config {
cfg.push_str(&format!("[{section}]\n"));
for (k, v) in fields {
let v = if matches!(v.as_str(), "true" | "false") {
v.clone()
} else {
format!("\"{v}\"")
};
cfg.push_str(&format!("{k} = {v}\n"));
}
cfg.push('\n');
}
fs::write(&cfg_path, cfg)?;
written.push(cfg_path);
}
for wf in &c.workflows {
let path = wf_dir.join(format!("{wf}.yml"));
if path.exists() && !force {
continue;
}
let content = match wf.as_str() {
"auto-release" => AUTO_RELEASE_SHIM,
"pre-merge-gate" => PRE_MERGE_GATE_SHIM,
"security-gate" => SECURITY_GATE_SHIM,
_ => 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 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 parse_dep_vector(s: &str) -> Vec<BTreeMap<String, String>> {
let mut out = vec![];
let mut depth = 0;
let mut start = None;
for (i, c) in s.char_indices() {
match c {
'{' => {
if depth == 0 {
start = Some(i + 1);
}
depth += 1;
}
'}' => {
depth -= 1;
if depth == 0 {
if let Some(s_start) = start {
out.push(parse_dict(&s[s_start..i]));
start = None;
}
}
}
_ => {}
}
}
out
}
fn emit_cargo_dep(d: &BTreeMap<String, String>) -> String {
let name = d.get("name").cloned().unwrap_or_default();
let version = d.get("version").cloned().unwrap_or_else(|| "*".to_string());
if name.is_empty() {
return String::new();
}
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 joined: Vec<String> = feats.iter().map(|f| format!("\"{f}\"")).collect();
return format!("{name} = {{ version = \"{version}\", features = [{}] }}\n", joined.join(", "));
}
}
format!("{name} = \"{version}\"\n")
}
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 {
let mut out = String::new();
for profile_name in ["release", "dev", "bench", "test"] {
let kw = format!(":{profile_name}");
if let Some(block) = read_dict_block(profiles_raw, &kw) {
out.push_str(&format!("\n[profile.{profile_name}]\n"));
let fields = parse_dict(&block);
for (k, v) in &fields {
let val = if matches!(v.as_str(), "true" | "false")
|| v.parse::<i64>().is_ok()
{
v.clone()
} else {
format!("\"{v}\"")
};
out.push_str(&format!("{k} = {val}\n"));
}
}
}
out
}
fn emit_publish_fields(publish_raw: &str) -> String {
let mut out = String::new();
let fields = parse_dict(publish_raw);
if let Some(v) = fields.get("private") {
if v == "true" {
out.push_str("publish = false\n");
}
}
if let Some(incl) = extract_vector_block(publish_raw, ":files-include") {
let items: Vec<String> = incl
.split_whitespace()
.map(|s| format!("\"{}\"", s.trim_matches('"')))
.filter(|s| s != "\"\"")
.collect();
if !items.is_empty() {
out.push_str(&format!("include = [{}]\n", items.join(", ")));
}
}
if let Some(excl) = extract_vector_block(publish_raw, ":files-exclude") {
let items: Vec<String> = excl
.split_whitespace()
.map(|s| format!("\"{}\"", s.trim_matches('"')))
.filter(|s| s != "\"\"")
.collect();
if !items.is_empty() {
out.push_str(&format!("exclude = [{}]\n", items.join(", ")));
}
}
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.name;
let desc = c.package.get("description").cloned().unwrap_or_default();
let nixos = format!(
"# nix/modules/nixos.nix — auto-generated from {name}.caixa.lisp
# {desc}
{{ config, lib, pkgs, ... }}:
let
cfg = config.services.{name};
in {{
options.services.{name} = {{
enable = lib.mkEnableOption \"{name}\";
package = lib.mkOption {{
type = lib.types.package;
default = pkgs.{name} or null;
}};
}};
config = lib.mkIf cfg.enable {{
environment.systemPackages = [ cfg.package ];
}};
}}
"
);
let darwin = format!(
"# nix/modules/darwin.nix — auto-generated from {name}.caixa.lisp
{{ config, lib, pkgs, ... }}:
let cfg = config.services.{name}; in {{
options.services.{name} = {{
enable = lib.mkEnableOption \"{name}\";
package = lib.mkOption {{ type = lib.types.package; default = pkgs.{name} or null; }};
}};
config = lib.mkIf cfg.enable {{
environment.systemPackages = [ cfg.package ];
}};
}}
"
);
let hm = format!(
"# nix/modules/home-manager.nix — auto-generated from {name}.caixa.lisp
{{ config, lib, pkgs, ... }}:
let cfg = config.programs.{name}; in {{
options.programs.{name} = {{
enable = lib.mkEnableOption \"{name}\";
package = lib.mkOption {{ type = lib.types.package; default = pkgs.{name} or null; }};
}};
config = lib.mkIf cfg.enable {{ home.packages = [ cfg.package ]; }};
}}
"
);
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('-', "_");
s.push_str(&format!(
" {key} = {{ url = \"github:{repo}/{rev}\"; 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(&format!("{dep}\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.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('-', "_");
inputs.push_str(&format!(
" {key} = {{\n url = \"github:{repo}/{rev}\";\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 content = format!(
"# flake.nix — auto-generated from {name}.caixa.lisp
# Edit caixa source + re-render via:
# pleme-doc-gen caixa --source {name}.caixa.lisp --out . --force
{{
description = \"{name} — caixa-rendered Nix flake\";
inputs = {{
{inputs} }};
outputs = inputs @ {{ self, nixpkgs, substrate, flake-parts, ... }}:
flake-parts.lib.mkFlake {{ inherit inputs; }} {{
systems = [ \"aarch64-darwin\" \"x86_64-linux\" \"aarch64-linux\" ];
perSystem = {{ pkgs, system, ... }}: let
builder = {builder};
in {{
packages.default = builder {{
inherit pkgs;
name = \"{name}\";
src = ./.;
}};
devShells.default = pkgs.mkShell {{
buildInputs = [ pkgs.git ];
}};
}};
}};
}}
"
);
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(());
}
let mut content = String::from(
"name: pleme-stacks\n# Auto-generated from caixa :stacks slot.\n\n",
);
content.push_str("on:\n push: { branches: [main] }\n pull_request: { branches: [main] }\n\n");
content.push_str("permissions:\n contents: read\n packages: write\n id-token: write\n\n");
content.push_str("jobs:\n");
for stack in &c.stacks {
let job_name = stack.replace('-', "_");
content.push_str(&format!(" {job_name}:\n"));
content.push_str(&format!(
" uses: pleme-io/substrate/.github/workflows/{stack}.yml@main\n"
));
content.push_str(" secrets: inherit\n");
}
fs::write(&stacks_path, content)?;
written.push(stacks_path.to_path_buf());
Ok(())
}
fn render_npm(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
use crate::json_ast::{render as render_json, 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);
fs::write(&pkg_json, render_json(&json))?;
written.push(pkg_json);
}
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 emit_npm_deps_block(json: &mut String, key: &str, deps: &[BTreeMap<String, String>]) {
if deps.is_empty() {
return;
}
let entries: Vec<String> = deps.iter()
.filter_map(|d| {
let name = d.get("name")?;
let ver = d.get("version").cloned().unwrap_or_else(|| "*".to_string());
Some(format!(" \"{name}\": \"{ver}\""))
})
.collect();
if !entries.is_empty() {
json.push_str(&format!(" \"{key}\": {{\n{}\n }},\n", entries.join(",\n")));
}
}
fn render_python(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 toml = String::from("[project]\n");
if let Some(n) = pkg.get("name") { toml.push_str(&format!("name = \"{n}\"\n")); }
if let Some(v) = pkg.get("version") { toml.push_str(&format!("version = \"{v}\"\n")); }
if let Some(d) = pkg.get("description") { toml.push_str(&format!("description = \"{d}\"\n")); }
if let Some(l) = pkg.get("license") {
toml.push_str(&format!("license = {{ text = \"{l}\" }}\n"));
}
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('"');
toml.push_str(&format!("requires-python = \"{clean}\"\n"));
}
}
if c.supports_raw.is_none() {
if let Some(rp) = pkg.get("requires-python") {
toml.push_str(&format!("requires-python = \"{rp}\"\n"));
}
}
let deps = parse_deps_raw(c.dependencies_raw.as_deref().unwrap_or(""));
if !deps.runtime.is_empty() {
let items: Vec<String> = deps.runtime.iter()
.filter_map(|d| {
let n = d.get("name")?;
let v = d.get("version").cloned().unwrap_or_default();
if v.is_empty() {
Some(format!("\"{n}\""))
} else {
Some(format!("\"{n}>={v}\""))
}
})
.collect();
toml.push_str(&format!("dependencies = [\n {},\n]\n", items.join(",\n ")));
}
if !deps.dev.is_empty() {
let items: Vec<String> = deps.dev.iter()
.filter_map(|d| {
let n = d.get("name")?;
let v = d.get("version").cloned().unwrap_or_default();
if v.is_empty() {
Some(format!("\"{n}\""))
} else {
Some(format!("\"{n}>={v}\""))
}
})
.collect();
toml.push_str(&format!("\n[project.optional-dependencies]\ndev = [\n {},\n]\n", items.join(",\n ")));
}
if let Some(scripts) = c.scripts_raw.as_deref() {
let s_fields = parse_dict(scripts);
if !s_fields.is_empty() {
toml.push_str("\n[project.scripts]\n");
for (k, v) in &s_fields {
toml.push_str(&format!("{k} = \"{v}\"\n"));
}
}
}
toml.push_str("\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n");
fs::write(&pyproj, toml)?;
written.push(pyproj);
}
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::{render as render_yaml, 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)); }
fs::write(&chart_yaml, render_yaml(&chart))?;
written.push(chart_yaml);
}
let values = out.join("values.yaml");
if !values.exists() || force {
fs::write(&values, "# values.yaml — fill in per the chart's templates\n")?;
written.push(values);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
fn render_rust_workspace(c: &Caixa, out: &Path, force: bool) -> Result<Vec<PathBuf>> {
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 toml = String::from("[workspace]\n");
if let Some(members) = c.package_lists.get("members") {
let joined: Vec<String> = members.iter().map(|m| format!("\"{m}\"")).collect();
toml.push_str(&format!("members = [{}]\n", joined.join(", ")));
}
toml.push_str("resolver = \"2\"\n\n[workspace.package]\n");
if let Some(v) = ws.get("version") { toml.push_str(&format!("version = \"{v}\"\n")); }
toml.push_str("edition = \"2024\"\n");
if let Some(l) = ws.get("license") { toml.push_str(&format!("license = \"{l}\"\n")); }
if let Some(r) = ws.get("repository") { toml.push_str(&format!("repository = \"{r}\"\n")); }
fs::write(&cargo, toml)?;
written.push(cargo);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
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 mut yml = format!("name: 'pleme-io · {}'\n", c.name);
if let Some(d) = pkg.get("description") { yml.push_str(&format!("description: '{d}'\n")); }
yml.push_str("branding: { icon: 'box', color: 'green' }\n\n");
yml.push_str("inputs: {}\noutputs: {}\n\n");
yml.push_str("runs:\n using: composite\n steps:\n");
yml.push_str(" - id: src\n shell: bash\n run: |\n");
yml.push_str(" {\n echo 'script<<TLISP_EOF'\n");
yml.push_str(" curl -sL https://raw.githubusercontent.com/pleme-io/actions/main/_tlisp-stdlib/stdlib.tlisp\n");
yml.push_str(" echo\n cat ${{ github.action_path }}/run.tlisp\n");
yml.push_str(" echo 'TLISP_EOF'\n } >> \"$GITHUB_OUTPUT\"\n");
yml.push_str(" - id: run\n uses: pleme-io/actions/tatara-script@v1\n");
yml.push_str(" with:\n script: ${{ steps.src.outputs.script }}\n");
fs::write(&action_yml, yml)?;
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);
}
render_ci_shims(c, out, force, &mut written)?;
Ok(written)
}
const AUTO_RELEASE_SHIM: &str = r#"name: auto-release
# Generated from caixa source. Edit caixa, re-render.
on:
push:
branches: [main]
workflow_dispatch:
inputs:
bump-type:
description: "patch | minor | major"
default: patch
jobs:
release:
uses: pleme-io/substrate/.github/workflows/auto-release.yml@main
with:
bump-type: ${{ inputs.bump-type || 'patch' }}
secrets: inherit
"#;
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"));
}
}