Skip to main content

escriba_plugin/
forge.rs

1//! `escriba-plugin::forge` — emit a plugin caixa's published artifacts
2//! from ONE typed catalog source.
3//!
4//! This is the generation half of the plugin substrate (Pillar 12 —
5//! generation over composition). A catalog source
6//! (`<name>.escribaplugin.lisp`) is one `(defescribaplugin …)` manifest
7//! form followed by the plugin's escriba entry def-forms (see
8//! [`escriba_lisp::catalog`]). [`forge_plugin`] reads the manifest and
9//! produces a [`CaixaArtifacts`] bundle; [`write_plugin_caixa`]
10//! materializes it into `<out>/<name>/…` as a complete, installable
11//! caixa directory:
12//!
13//! ```text
14//! <out>/<name>/
15//! ├── <name>.escribaplugin.lisp   ← THE SPEC (persisted next to output)
16//! ├── caixa.lisp                  ← generated :kind Biblioteca manifest
17//! ├── escriba/plugin.lisp         ← the escriba entry (what escriba loads)
18//! └── flake.nix                   ← minimal nix packaging
19//! ```
20//!
21//! Persisting the spec into the output dir makes re-rendering idempotent
22//! and drift-detectable via `git diff` (CLOSED-LOOP MASS-SYNTHESIS rule
23//! #3): force a re-forge, diff, and a clean tree proves determinism.
24
25use std::path::{Path, PathBuf};
26
27use escriba_lisp::{CatalogError, EscribaPluginSpec, emit_caixa_lisp};
28use thiserror::Error;
29
30/// The four published artifacts of a plugin caixa, as strings.
31#[derive(Debug, Clone)]
32pub struct CaixaArtifacts {
33    /// The `:kind Biblioteca` manifest (`caixa.lisp`).
34    pub caixa_lisp: String,
35    /// The escriba entry escriba loads + applies (`escriba/plugin.lisp`).
36    pub entry_lisp: String,
37    /// Minimal nix packaging (`flake.nix`).
38    pub flake_nix: String,
39    /// The catalog source verbatim — persisted alongside the output for
40    /// round-trip auditability.
41    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
55/// Forge a plugin caixa's artifacts from one catalog source string.
56///
57/// The entry is the WHOLE source (the `defescribaplugin` manifest form
58/// is inert at apply time — `escriba_lisp::apply_source` ignores it), so
59/// nothing is lost and the source stays the single home for both the
60/// manifest and the entry def-forms.
61pub 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
72/// Render the escriba entry — a small generated header plus the catalog
73/// source. The `defescribaplugin` manifest form remains (inert at apply
74/// time) so the entry stays a faithful, re-forgeable projection of the
75/// source rather than a lossy strip.
76fn 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    // Ensure the source ends with a newline so the header comment and
87    // the first form are on separate lines.
88    out.push_str(source.trim_start());
89    if !out.ends_with('\n') {
90        out.push('\n');
91    }
92    out
93}
94
95/// Render a minimal, real `flake.nix` for a standalone plugin caixa: a
96/// pure-source package that copies the caixa tree into `$out` so it can
97/// be fetched + materialized into escriba's plugins dir. Follows the
98/// pleme-io flake-input rule (`inputs.nixpkgs.follows`-friendly); in the
99/// fleet, plugin caixas are consumed as `flake = false` source by the
100/// escribamourne distribution, so these per-plugin pins never compound
101/// into the closure.
102#[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
134/// Materialize a forged plugin caixa into `<out>/<name>/…`. Creates the
135/// directory tree and writes all four artifacts. Overwrites existing
136/// files (so a re-forge is idempotent).
137pub 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        // caixa.lisp re-parses + carries kind + provenance.
195        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        // entry applies to an ApplyPlan with the expected def-forms.
201        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        // flake.nix mentions the plugin name + is non-empty.
207        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        // The materialized caixa loads through the real PluginCaixa loader.
223        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        // Determinism: forging the same source twice yields byte-identical
233        // artifacts (CLOSED-LOOP MASS-SYNTHESIS — drift-detectable).
234        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}