Skip to main content

escriba_plugin/
lib.rs

1//! `escriba-plugin` — caixa-native plugin model for escriba.
2//!
3//! **An escriba plugin IS a caixa.** A plugin is a directory (a git
4//! repo with a `caixa.lisp` at its root — the pleme-io git-as-registry
5//! model) whose *escriba entry* is tatara-lisp declaring the plugin's
6//! keybinds / commands / options / highlights via `escriba-lisp`
7//! def-forms (and, later, imperative setup via `escriba-vm`). The
8//! user's rc DECLARES a plugin; escriba RESOLVES it to a plugin
9//! directory, LOADS its entry, and ACTIVATES it (applies the entry's
10//! def-forms to live `EditorState`) either eagerly or when an
11//! activation trigger fires.
12//!
13//! **Lineage-safe by design.** escriba reads the plugin's tatara-lisp
14//! with its OWN (`pleme-io/tatara-lisp`) parser; it deliberately does
15//! NOT depend on `caixa-core` (which is on the other tatara-lisp
16//! lineage) — that would re-introduce a two-lineage conflict. Plugin
17//! install/resolve uses the caixa git model (`feira` / git) out of
18//! band; this crate is the *consumer* side: discovery, load, and
19//! trigger-gated activation.
20
21use std::path::{Path, PathBuf};
22
23use escriba_config::PluginDecl;
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26
27pub mod forge;
28pub use forge::{CaixaArtifacts, ForgeError, emit_flake_nix, forge_plugin, write_plugin_caixa};
29
30/// When a plugin's setup is applied to the editor — the lazy.nvim
31/// activation model, typed.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub enum ActivationTrigger {
34    /// Apply at startup (eager).
35    Startup,
36    /// Apply when a buffer of this filetype is opened.
37    FileType(String),
38    /// Apply when this editor event fires (`BufWritePost`, …).
39    Event(String),
40    /// Apply when this command is first invoked (lazy command load).
41    Command(String),
42}
43
44impl ActivationTrigger {
45    /// Parse an `:ativar-em` entry. Accepts `"Startup"` / `"eager"`, or
46    /// a `"<Kind>: <arg>"` shape: `"FileType: lisp"`, `"Event:
47    /// BufWritePost"`, `"Command: Paredit"` (`ft`/`cmd` aliases too).
48    /// Returns `None` for an unrecognized shape so the caller can warn.
49    #[must_use]
50    pub fn parse(s: &str) -> Option<Self> {
51        let t = s.trim();
52        if t.eq_ignore_ascii_case("startup") || t.eq_ignore_ascii_case("eager") {
53            return Some(Self::Startup);
54        }
55        let (head, rest) = t.split_once(':')?;
56        let arg = rest.trim().to_string();
57        if arg.is_empty() {
58            return None;
59        }
60        match head.trim().to_ascii_lowercase().as_str() {
61            "filetype" | "ft" => Some(Self::FileType(arg)),
62            "event" => Some(Self::Event(arg)),
63            "command" | "cmd" => Some(Self::Command(arg)),
64            _ => None,
65        }
66    }
67}
68
69/// The conventional relative paths (priority order) where a plugin
70/// caixa keeps its escriba entry. First existing wins.
71pub const ENTRY_CANDIDATES: &[&str] =
72    &["escriba/plugin.lisp", "escriba.lisp", "plugin/escriba.lisp"];
73
74/// A loaded plugin caixa: identity + the tatara-lisp entry source +
75/// parsed activation triggers + on-disk root.
76#[derive(Debug, Clone)]
77pub struct PluginCaixa {
78    pub name: String,
79    pub version: String,
80    /// The tatara-lisp source of the plugin's escriba entry — applied
81    /// to `EditorState` (via `escriba-lisp`) on activation.
82    pub entry_src: String,
83    pub triggers: Vec<ActivationTrigger>,
84    pub root: PathBuf,
85}
86
87#[derive(Debug, Error)]
88pub enum PluginError {
89    #[error("plugin `{caixa}`: no escriba entry found under {root} (looked for {candidates:?})")]
90    EntryNotFound {
91        caixa: String,
92        root: String,
93        candidates: Vec<String>,
94    },
95    #[error("plugin `{caixa}`: io error reading {path}: {source}")]
96    Io {
97        caixa: String,
98        path: String,
99        source: std::io::Error,
100    },
101}
102
103impl PluginCaixa {
104    /// Load a plugin from its installed caixa directory. `ativar_em`
105    /// entries that don't parse are skipped (the caller can surface a
106    /// warning); a plugin with no parseable triggers is eager. The
107    /// entry tatara-lisp is read from the first existing
108    /// [`ENTRY_CANDIDATES`] path under `root`.
109    pub fn load(
110        name: &str,
111        version: &str,
112        ativar_em: &[String],
113        root: &Path,
114    ) -> Result<Self, PluginError> {
115        for cand in ENTRY_CANDIDATES {
116            let p = root.join(cand);
117            if p.exists() {
118                let entry_src = std::fs::read_to_string(&p).map_err(|e| PluginError::Io {
119                    caixa: name.to_string(),
120                    path: p.display().to_string(),
121                    source: e,
122                })?;
123                let triggers = ativar_em
124                    .iter()
125                    .filter_map(|s| ActivationTrigger::parse(s))
126                    .collect();
127                return Ok(Self {
128                    name: name.to_string(),
129                    version: version.to_string(),
130                    entry_src,
131                    triggers,
132                    root: root.to_path_buf(),
133                });
134            }
135        }
136        Err(PluginError::EntryNotFound {
137            caixa: name.to_string(),
138            root: root.display().to_string(),
139            candidates: ENTRY_CANDIDATES.iter().map(|s| (*s).to_string()).collect(),
140        })
141    }
142
143    /// Load from a declarative [`PluginDecl`] (`defplugin :caixa …
144    /// :versao … :ativar-em …`) rooted at `root` (typically
145    /// `<plugins_dir>/<caixa>`).
146    pub fn from_decl(decl: &PluginDecl, root: &Path) -> Result<Self, PluginError> {
147        Self::load(&decl.caixa, &decl.versao, &decl.ativar_em, root)
148    }
149
150    /// Eager iff there are no triggers, or an explicit [`Startup`]
151    /// trigger is present.
152    ///
153    /// [`Startup`]: ActivationTrigger::Startup
154    #[must_use]
155    pub fn is_eager(&self) -> bool {
156        self.triggers.is_empty() || self.triggers.contains(&ActivationTrigger::Startup)
157    }
158
159    /// Does an opened `filetype` match any `FileType` trigger?
160    #[must_use]
161    pub fn matches_filetype(&self, filetype: &str) -> bool {
162        self.triggers
163            .iter()
164            .any(|t| matches!(t, ActivationTrigger::FileType(f) if f == filetype))
165    }
166
167    /// Does `event` match any `Event` trigger?
168    #[must_use]
169    pub fn matches_event(&self, event: &str) -> bool {
170        self.triggers
171            .iter()
172            .any(|t| matches!(t, ActivationTrigger::Event(e) if e == event))
173    }
174
175    /// Does `command` match any `Command` trigger?
176    #[must_use]
177    pub fn matches_command(&self, command: &str) -> bool {
178        self.triggers
179            .iter()
180            .any(|t| matches!(t, ActivationTrigger::Command(c) if c == command))
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn parse_trigger_shapes() {
190        assert_eq!(
191            ActivationTrigger::parse("Startup"),
192            Some(ActivationTrigger::Startup)
193        );
194        assert_eq!(
195            ActivationTrigger::parse("eager"),
196            Some(ActivationTrigger::Startup)
197        );
198        assert_eq!(
199            ActivationTrigger::parse("FileType: lisp"),
200            Some(ActivationTrigger::FileType("lisp".into())),
201        );
202        assert_eq!(
203            ActivationTrigger::parse("ft: rust"),
204            Some(ActivationTrigger::FileType("rust".into())),
205        );
206        assert_eq!(
207            ActivationTrigger::parse("Event: BufWritePost"),
208            Some(ActivationTrigger::Event("BufWritePost".into())),
209        );
210        assert_eq!(
211            ActivationTrigger::parse("Command: Paredit"),
212            Some(ActivationTrigger::Command("Paredit".into())),
213        );
214        // Unrecognized shapes.
215        assert_eq!(ActivationTrigger::parse("Nonsense: x"), None);
216        assert_eq!(ActivationTrigger::parse("FileType:"), None);
217    }
218
219    /// Write a plugin caixa skeleton (caixa.lisp + entry) under a fresh
220    /// temp dir and return its root.
221    fn scratch_plugin(slug: &str, entry_rel: &str, entry_src: &str) -> PathBuf {
222        let root = std::env::temp_dir().join(format!("escriba-plugin-test-{slug}"));
223        let _ = std::fs::remove_dir_all(&root);
224        if let Some(parent) = root.join(entry_rel).parent() {
225            std::fs::create_dir_all(parent).unwrap();
226        }
227        std::fs::write(
228            root.join("caixa.lisp"),
229            format!("(defcaixa :nome \"{slug}\" :versao \"0.1.0\" :kind Biblioteca)\n"),
230        )
231        .unwrap();
232        std::fs::write(root.join(entry_rel), entry_src).unwrap();
233        root
234    }
235
236    #[test]
237    fn load_reads_entry_and_parses_triggers() {
238        let root = scratch_plugin(
239            "paredit",
240            "escriba/plugin.lisp",
241            r#"(defkeybind :mode "normal" :key "<A-f>" :action "forward-sexp")"#,
242        );
243        let p = PluginCaixa::load(
244            "escriba-paredit",
245            "^0.1",
246            &["FileType: lisp".to_string()],
247            &root,
248        )
249        .expect("plugin loads");
250        let _ = std::fs::remove_dir_all(&root);
251
252        assert_eq!(p.name, "escriba-paredit");
253        assert!(p.entry_src.contains("defkeybind"));
254        assert_eq!(p.triggers, vec![ActivationTrigger::FileType("lisp".into())]);
255        assert!(!p.is_eager(), "a FileType-triggered plugin is lazy");
256        assert!(p.matches_filetype("lisp"));
257        assert!(!p.matches_filetype("rust"));
258    }
259
260    #[test]
261    fn no_triggers_is_eager() {
262        let root = scratch_plugin(
263            "eagerplug",
264            "escriba.lisp",
265            "(defoption :name \"x\" :value \"1\")",
266        );
267        let p = PluginCaixa::load("eagerplug", "0.1", &[], &root).unwrap();
268        let _ = std::fs::remove_dir_all(&root);
269        assert!(p.is_eager());
270    }
271
272    #[test]
273    fn missing_entry_errors_with_candidates() {
274        let root = std::env::temp_dir().join("escriba-plugin-test-noentry");
275        let _ = std::fs::remove_dir_all(&root);
276        std::fs::create_dir_all(&root).unwrap();
277        std::fs::write(root.join("caixa.lisp"), "(defcaixa :nome \"x\")").unwrap();
278        let err = PluginCaixa::load("x", "0.1", &[], &root).unwrap_err();
279        let _ = std::fs::remove_dir_all(&root);
280        assert!(matches!(err, PluginError::EntryNotFound { .. }));
281    }
282
283    #[test]
284    fn from_decl_bridges_plugindecl() {
285        let root = scratch_plugin(
286            "fromdecl",
287            "escriba/plugin.lisp",
288            r#"(defcmd :name "hi" :action "editor.noop")"#,
289        );
290        let decl = PluginDecl {
291            caixa: "fromdecl".into(),
292            versao: "^0.2".into(),
293            ativar_em: vec!["Command: Hi".into()],
294        };
295        let p = PluginCaixa::from_decl(&decl, &root).unwrap();
296        let _ = std::fs::remove_dir_all(&root);
297        assert_eq!(p.version, "^0.2");
298        assert!(p.matches_command("Hi"));
299    }
300}