1use std::path::{Path, PathBuf};
26
27use escriba_lisp::{CatalogError, EscribaPluginSpec, emit_caixa_lisp};
28use thiserror::Error;
29
30#[derive(Debug, Clone)]
32pub struct CaixaArtifacts {
33 pub caixa_lisp: String,
35 pub entry_lisp: String,
37 pub flake_nix: String,
39 pub spec_source: String,
42}
43
44#[derive(Debug, Error)]
45pub enum ForgeError {
46 #[error("catalog source: {0}")]
47 Catalog(#[from] CatalogError),
48 #[error("io error writing {path}: {source}")]
49 Io {
50 path: String,
51 source: std::io::Error,
52 },
53}
54
55pub fn forge_plugin(source: &str) -> Result<(EscribaPluginSpec, CaixaArtifacts), ForgeError> {
62 let spec = escriba_lisp::read_catalog_meta(source)?;
63 let artifacts = CaixaArtifacts {
64 caixa_lisp: emit_caixa_lisp(&spec),
65 entry_lisp: emit_entry_lisp(&spec, source),
66 flake_nix: emit_flake_nix(&spec),
67 spec_source: source.to_string(),
68 };
69 Ok((spec, artifacts))
70}
71
72fn emit_entry_lisp(spec: &EscribaPluginSpec, source: &str) -> String {
77 let mut out = String::new();
78 out.push_str(";; GENERATED escriba entry — escriba LOADS + APPLIES this file.\n");
79 out.push_str(&format!(
80 ";; plugin: {} (v{})\n",
81 spec.name,
82 spec.effective_version()
83 ));
84 out.push_str(";; The (defescribaplugin …) manifest below is inert at apply time\n");
85 out.push_str(";; (escriba-lisp ignores it); it is kept so the entry round-trips.\n");
86 out.push_str(source.trim_start());
89 if !out.ends_with('\n') {
90 out.push('\n');
91 }
92 out
93}
94
95#[must_use]
103pub fn emit_flake_nix(spec: &EscribaPluginSpec) -> String {
104 let name = &spec.name;
105 let desc = spec.description.replace('"', "'");
106 format!(
107 r#"# GENERATED by `escriba plugin forge` from {name}.escribaplugin.lisp.
108# A pure-source escriba plugin caixa (:kind Biblioteca). Copied verbatim
109# into escriba's plugins dir; escriba loads escriba/plugin.lisp.
110{{
111 description = "escriba plugin caixa: {name} — {desc}";
112
113 inputs = {{
114 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
115 flake-utils.url = "github:numtide/flake-utils";
116 }};
117
118 outputs = {{ self, nixpkgs, flake-utils }}:
119 flake-utils.lib.eachDefaultSystem (system:
120 let pkgs = import nixpkgs {{ inherit system; }};
121 in {{
122 # The caixa, materialized: caixa.lisp + escriba/plugin.lisp.
123 packages.default = pkgs.runCommandLocal "{name}" {{ }} ''
124 mkdir -p "$out"
125 cp -r ${{self}}/caixa.lisp "$out/" 2>/dev/null || true
126 cp -r ${{self}}/escriba "$out/" 2>/dev/null || true
127 '';
128 }});
129}}
130"#
131 )
132}
133
134pub fn write_plugin_caixa(
138 spec: &EscribaPluginSpec,
139 artifacts: &CaixaArtifacts,
140 out: &Path,
141) -> Result<PathBuf, ForgeError> {
142 let root = out.join(&spec.name);
143 let escriba_dir = root.join("escriba");
144 mkdirs(&escriba_dir)?;
145
146 write_file(&root.join("caixa.lisp"), &artifacts.caixa_lisp)?;
147 write_file(&escriba_dir.join("plugin.lisp"), &artifacts.entry_lisp)?;
148 write_file(&root.join("flake.nix"), &artifacts.flake_nix)?;
149 write_file(
150 &root.join(format!("{}.escribaplugin.lisp", spec.name)),
151 &artifacts.spec_source,
152 )?;
153 Ok(root)
154}
155
156fn mkdirs(p: &Path) -> Result<(), ForgeError> {
157 std::fs::create_dir_all(p).map_err(|e| ForgeError::Io {
158 path: p.display().to_string(),
159 source: e,
160 })
161}
162
163fn write_file(p: &Path, contents: &str) -> Result<(), ForgeError> {
164 std::fs::write(p, contents).map_err(|e| ForgeError::Io {
165 path: p.display().to_string(),
166 source: e,
167 })
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 const SRC: &str = r##"
175 (defescribaplugin
176 :name "escriba-gitsigns"
177 :version "0.1.0"
178 :category "git"
179 :description "Git gutter signs, blame, hunks"
180 :blnvim-origin "lewis6991/gitsigns.nvim"
181 :ativar-em ("Event: BufReadPost"))
182
183 (defkeybind :mode "normal" :key "<leader>gb" :action "git.blame"
184 :description "git blame")
185 (defcmd :name "GitBlame" :description "toggle git blame" :action "git.blame")
186 (defhighlight :group "GitSignsAdd" :fg "#a9bb8c")
187 "##;
188
189 #[test]
190 fn forge_produces_reparseable_artifacts() {
191 let (spec, art) = forge_plugin(SRC).expect("forge succeeds");
192 assert_eq!(spec.name, "escriba-gitsigns");
193
194 let caixa_forms = tatara_lisp::read(&art.caixa_lisp).expect("caixa.lisp re-parses");
196 assert_eq!(caixa_forms.len(), 1);
197 assert!(art.caixa_lisp.contains("Biblioteca"));
198 assert!(art.caixa_lisp.contains("lewis6991/gitsigns.nvim"));
199
200 let plan = escriba_lisp::apply_source(&art.entry_lisp).expect("entry applies");
202 assert_eq!(plan.keybinds.len(), 1);
203 assert_eq!(plan.commands.len(), 1);
204 assert_eq!(plan.highlights.len(), 1);
205
206 assert!(art.flake_nix.contains("escriba-gitsigns"));
208 }
209
210 #[test]
211 fn write_materializes_full_caixa_tree() {
212 let (spec, art) = forge_plugin(SRC).unwrap();
213 let out = std::env::temp_dir().join("escriba-forge-test-out");
214 let _ = std::fs::remove_dir_all(&out);
215 let root = write_plugin_caixa(&spec, &art, &out).expect("write succeeds");
216
217 assert!(root.join("caixa.lisp").exists());
218 assert!(root.join("escriba/plugin.lisp").exists());
219 assert!(root.join("flake.nix").exists());
220 assert!(root.join("escriba-gitsigns.escribaplugin.lisp").exists());
221
222 let loaded = crate::PluginCaixa::load("escriba-gitsigns", "0.1.0", &[], &root)
224 .expect("forged caixa loads via PluginCaixa");
225 assert!(loaded.entry_src.contains("git.blame"));
226
227 let _ = std::fs::remove_dir_all(&out);
228 }
229
230 #[test]
231 fn re_forge_is_idempotent() {
232 let (_, a) = forge_plugin(SRC).unwrap();
235 let (_, b) = forge_plugin(SRC).unwrap();
236 assert_eq!(a.caixa_lisp, b.caixa_lisp);
237 assert_eq!(a.entry_lisp, b.entry_lisp);
238 assert_eq!(a.flake_nix, b.flake_nix);
239 }
240}