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] = &["escriba/plugin.lisp", "escriba.lisp", "plugin/escriba.lisp"];
72
73#[derive(Debug, Clone)]
76pub struct PluginCaixa {
77 pub name: String,
78 pub version: String,
79 pub entry_src: String,
82 pub triggers: Vec<ActivationTrigger>,
83 pub root: PathBuf,
84}
85
86#[derive(Debug, Error)]
87pub enum PluginError {
88 #[error("plugin `{caixa}`: no escriba entry found under {root} (looked for {candidates:?})")]
89 EntryNotFound {
90 caixa: String,
91 root: String,
92 candidates: Vec<String>,
93 },
94 #[error("plugin `{caixa}`: io error reading {path}: {source}")]
95 Io {
96 caixa: String,
97 path: String,
98 source: std::io::Error,
99 },
100}
101
102impl PluginCaixa {
103 pub fn load(
109 name: &str,
110 version: &str,
111 ativar_em: &[String],
112 root: &Path,
113 ) -> Result<Self, PluginError> {
114 for cand in ENTRY_CANDIDATES {
115 let p = root.join(cand);
116 if p.exists() {
117 let entry_src = std::fs::read_to_string(&p).map_err(|e| PluginError::Io {
118 caixa: name.to_string(),
119 path: p.display().to_string(),
120 source: e,
121 })?;
122 let triggers = ativar_em
123 .iter()
124 .filter_map(|s| ActivationTrigger::parse(s))
125 .collect();
126 return Ok(Self {
127 name: name.to_string(),
128 version: version.to_string(),
129 entry_src,
130 triggers,
131 root: root.to_path_buf(),
132 });
133 }
134 }
135 Err(PluginError::EntryNotFound {
136 caixa: name.to_string(),
137 root: root.display().to_string(),
138 candidates: ENTRY_CANDIDATES.iter().map(|s| (*s).to_string()).collect(),
139 })
140 }
141
142 pub fn from_decl(decl: &PluginDecl, root: &Path) -> Result<Self, PluginError> {
146 Self::load(&decl.caixa, &decl.versao, &decl.ativar_em, root)
147 }
148
149 #[must_use]
154 pub fn is_eager(&self) -> bool {
155 self.triggers.is_empty() || self.triggers.contains(&ActivationTrigger::Startup)
156 }
157
158 #[must_use]
160 pub fn matches_filetype(&self, filetype: &str) -> bool {
161 self.triggers
162 .iter()
163 .any(|t| matches!(t, ActivationTrigger::FileType(f) if f == filetype))
164 }
165
166 #[must_use]
168 pub fn matches_event(&self, event: &str) -> bool {
169 self.triggers
170 .iter()
171 .any(|t| matches!(t, ActivationTrigger::Event(e) if e == event))
172 }
173
174 #[must_use]
176 pub fn matches_command(&self, command: &str) -> bool {
177 self.triggers
178 .iter()
179 .any(|t| matches!(t, ActivationTrigger::Command(c) if c == command))
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn parse_trigger_shapes() {
189 assert_eq!(ActivationTrigger::parse("Startup"), Some(ActivationTrigger::Startup));
190 assert_eq!(ActivationTrigger::parse("eager"), Some(ActivationTrigger::Startup));
191 assert_eq!(
192 ActivationTrigger::parse("FileType: lisp"),
193 Some(ActivationTrigger::FileType("lisp".into())),
194 );
195 assert_eq!(
196 ActivationTrigger::parse("ft: rust"),
197 Some(ActivationTrigger::FileType("rust".into())),
198 );
199 assert_eq!(
200 ActivationTrigger::parse("Event: BufWritePost"),
201 Some(ActivationTrigger::Event("BufWritePost".into())),
202 );
203 assert_eq!(
204 ActivationTrigger::parse("Command: Paredit"),
205 Some(ActivationTrigger::Command("Paredit".into())),
206 );
207 assert_eq!(ActivationTrigger::parse("Nonsense: x"), None);
209 assert_eq!(ActivationTrigger::parse("FileType:"), None);
210 }
211
212 fn scratch_plugin(slug: &str, entry_rel: &str, entry_src: &str) -> PathBuf {
215 let root = std::env::temp_dir().join(format!("escriba-plugin-test-{slug}"));
216 let _ = std::fs::remove_dir_all(&root);
217 if let Some(parent) = root.join(entry_rel).parent() {
218 std::fs::create_dir_all(parent).unwrap();
219 }
220 std::fs::write(
221 root.join("caixa.lisp"),
222 format!(
223 "(defcaixa :nome \"{slug}\" :versao \"0.1.0\" :kind Biblioteca)\n"
224 ),
225 )
226 .unwrap();
227 std::fs::write(root.join(entry_rel), entry_src).unwrap();
228 root
229 }
230
231 #[test]
232 fn load_reads_entry_and_parses_triggers() {
233 let root = scratch_plugin(
234 "paredit",
235 "escriba/plugin.lisp",
236 r#"(defkeybind :mode "normal" :key "<A-f>" :action "forward-sexp")"#,
237 );
238 let p = PluginCaixa::load(
239 "escriba-paredit",
240 "^0.1",
241 &["FileType: lisp".to_string()],
242 &root,
243 )
244 .expect("plugin loads");
245 let _ = std::fs::remove_dir_all(&root);
246
247 assert_eq!(p.name, "escriba-paredit");
248 assert!(p.entry_src.contains("defkeybind"));
249 assert_eq!(p.triggers, vec![ActivationTrigger::FileType("lisp".into())]);
250 assert!(!p.is_eager(), "a FileType-triggered plugin is lazy");
251 assert!(p.matches_filetype("lisp"));
252 assert!(!p.matches_filetype("rust"));
253 }
254
255 #[test]
256 fn no_triggers_is_eager() {
257 let root = scratch_plugin("eagerplug", "escriba.lisp", "(defoption :name \"x\" :value \"1\")");
258 let p = PluginCaixa::load("eagerplug", "0.1", &[], &root).unwrap();
259 let _ = std::fs::remove_dir_all(&root);
260 assert!(p.is_eager());
261 }
262
263 #[test]
264 fn missing_entry_errors_with_candidates() {
265 let root = std::env::temp_dir().join("escriba-plugin-test-noentry");
266 let _ = std::fs::remove_dir_all(&root);
267 std::fs::create_dir_all(&root).unwrap();
268 std::fs::write(root.join("caixa.lisp"), "(defcaixa :nome \"x\")").unwrap();
269 let err = PluginCaixa::load("x", "0.1", &[], &root).unwrap_err();
270 let _ = std::fs::remove_dir_all(&root);
271 assert!(matches!(err, PluginError::EntryNotFound { .. }));
272 }
273
274 #[test]
275 fn from_decl_bridges_plugindecl() {
276 let root = scratch_plugin(
277 "fromdecl",
278 "escriba/plugin.lisp",
279 r#"(defcmd :name "hi" :action "editor.noop")"#,
280 );
281 let decl = PluginDecl {
282 caixa: "fromdecl".into(),
283 versao: "^0.2".into(),
284 ativar_em: vec!["Command: Hi".into()],
285 };
286 let p = PluginCaixa::from_decl(&decl, &root).unwrap();
287 let _ = std::fs::remove_dir_all(&root);
288 assert_eq!(p.version, "^0.2");
289 assert!(p.matches_command("Hi"));
290 }
291}