1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub enum ActivationTrigger {
34 Startup,
36 FileType(String),
38 Event(String),
40 Command(String),
42}
43
44impl ActivationTrigger {
45 #[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
69pub const ENTRY_CANDIDATES: &[&str] =
72 &["escriba/plugin.lisp", "escriba.lisp", "plugin/escriba.lisp"];
73
74#[derive(Debug, Clone)]
77pub struct PluginCaixa {
78 pub name: String,
79 pub version: String,
80 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 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 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 #[must_use]
155 pub fn is_eager(&self) -> bool {
156 self.triggers.is_empty() || self.triggers.contains(&ActivationTrigger::Startup)
157 }
158
159 #[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 #[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 #[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 assert_eq!(ActivationTrigger::parse("Nonsense: x"), None);
216 assert_eq!(ActivationTrigger::parse("FileType:"), None);
217 }
218
219 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}