use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;
use crate::plugin::is_known_category;
use crate::sexp::Sexp;
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[tatara(keyword = "defescribaplugin")]
pub struct EscribaPluginSpec {
pub name: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub category: String,
#[serde(default)]
pub blnvim_origin: String,
#[serde(default)]
pub ativar_em: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub priority: i32,
}
impl EscribaPluginSpec {
#[must_use]
pub fn effective_version(&self) -> &str {
if self.version.is_empty() {
"0.1.0"
} else {
&self.version
}
}
#[must_use]
pub fn is_eager(&self) -> bool {
self.ativar_em.is_empty()
|| self
.ativar_em
.iter()
.any(|t| t.trim().eq_ignore_ascii_case("startup") || t.trim().eq_ignore_ascii_case("eager"))
}
#[must_use]
pub fn etiquetas(&self) -> Vec<String> {
let mut out = vec!["escriba-plugin".to_string()];
let mut push = |s: &str| {
if !s.is_empty() && !out.iter().any(|x| x == s) {
out.push(s.to_string());
}
};
push(&self.category);
push(&self.blnvim_origin);
for t in &self.tags {
push(t);
}
out
}
}
#[must_use]
pub fn emit_caixa_lisp(spec: &EscribaPluginSpec) -> String {
let nome = Sexp::str(spec.name.clone());
let versao = Sexp::str(spec.effective_version().to_string());
let descricao = Sexp::str(spec.description.clone());
let etiquetas = Sexp::str_list(spec.etiquetas());
let header = "(defcaixa";
let lines = [
format!(" {} {}", Sexp::kw("nome"), nome),
format!(" {} {}", Sexp::kw("versao"), versao),
format!(" {} {}", Sexp::kw("kind"), Sexp::sym("Biblioteca")),
format!(" {} {}", Sexp::kw("descricao"), descricao),
format!(" {} {}", Sexp::kw("etiquetas"), etiquetas),
];
let mut out = String::from(
";; GENERATED by `escriba plugin forge` from the catalog source.\n\
;; Edit the `.escribaplugin.lisp` catalog source, never this file.\n",
);
out.push_str(header);
out.push(' ');
out.push_str(lines[0].trim_start());
for line in &lines[1..] {
out.push('\n');
out.push_str(line);
}
out.push_str(")\n");
out
}
#[must_use]
pub fn emit_defplugin_descriptor(spec: &EscribaPluginSpec) -> String {
let mut items = vec![
Sexp::sym("defplugin"),
Sexp::kw("name"),
Sexp::str(spec.name.clone()),
];
if !spec.description.is_empty() {
items.push(Sexp::kw("description"));
items.push(Sexp::str(spec.description.clone()));
}
if !spec.category.is_empty() {
items.push(Sexp::kw("category"));
items.push(Sexp::str(spec.category.clone()));
}
for trig in &spec.ativar_em {
if let Some((kind, arg)) = trig.split_once(':') {
let arg = arg.trim();
match kind.trim().to_ascii_lowercase().as_str() {
"event" => {
items.push(Sexp::kw("on-event"));
items.push(Sexp::str(arg.to_string()));
}
"command" | "cmd" => {
items.push(Sexp::kw("on-command"));
items.push(Sexp::str(arg.to_string()));
}
"filetype" | "ft" => {
items.push(Sexp::kw("on-filetype"));
items.push(Sexp::str(arg.to_string()));
}
_ => {}
}
}
}
if spec.priority != 0 {
items.push(Sexp::kw("priority"));
items.push(Sexp::sym(spec.priority.to_string()));
}
if !spec.is_eager() {
items.push(Sexp::kw("lazy"));
items.push(Sexp::sym("#t"));
}
Sexp::list(items).render()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CatalogError {
MissingMeta,
MultipleMeta(usize),
EmptyName,
InvalidName(String),
Parse(String),
}
impl std::fmt::Display for CatalogError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CatalogError::MissingMeta => {
f.write_str("catalog source has no (defescribaplugin …) manifest form")
}
CatalogError::MultipleMeta(n) => {
write!(f, "catalog source has {n} (defescribaplugin …) forms — expected exactly 1")
}
CatalogError::EmptyName => f.write_str("(defescribaplugin …) has empty :name"),
CatalogError::InvalidName(n) => write!(
f,
"(defescribaplugin …) :name `{n}` is not a clean slug \
(allowed: letters, digits, `-`, `_`)"
),
CatalogError::Parse(e) => write!(f, "catalog parse error: {e}"),
}
}
}
impl std::error::Error for CatalogError {}
pub fn read_catalog_meta(src: &str) -> Result<EscribaPluginSpec, CatalogError> {
let specs: Vec<EscribaPluginSpec> =
tatara_lisp::compile_typed(src).map_err(|e| CatalogError::Parse(e.to_string()))?;
match specs.len() {
0 => Err(CatalogError::MissingMeta),
1 => {
let spec = specs.into_iter().next().expect("len == 1");
if spec.name.is_empty() {
return Err(CatalogError::EmptyName);
}
if !spec
.name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err(CatalogError::InvalidName(spec.name));
}
Ok(spec)
}
n => Err(CatalogError::MultipleMeta(n)),
}
}
#[must_use]
pub fn category_is_canonical(spec: &EscribaPluginSpec) -> bool {
spec.category.is_empty() || is_known_category(&spec.category)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
(defescribaplugin
:name "escriba-oil"
:version "0.2.0"
:category "files"
:description "Edit the filesystem like a buffer"
:blnvim-origin "stevearc/oil.nvim"
:ativar-em ("Command: Oil"))
(defkeybind :mode "normal" :key "<leader>e" :action "files.open")
(defcmd :name "Oil" :description "file explorer" :action "files.open")
"#;
#[test]
fn reads_meta_and_ignores_entry_forms() {
let spec = read_catalog_meta(SAMPLE).expect("one meta form");
assert_eq!(spec.name, "escriba-oil");
assert_eq!(spec.version, "0.2.0");
assert_eq!(spec.category, "files");
assert_eq!(spec.ativar_em, vec!["Command: Oil"]);
assert!(!spec.is_eager(), "a Command-triggered plugin is lazy");
}
#[test]
fn apply_source_ignores_the_meta_form() {
let plan = crate::apply_source(SAMPLE).expect("entry applies");
assert_eq!(plan.keybinds.len(), 1);
assert_eq!(plan.commands.len(), 1);
assert_eq!(plan.keybinds[0].action, "files.open");
}
#[test]
fn missing_meta_errors() {
let err = read_catalog_meta("(defkeybind :mode \"normal\" :key \"x\" :action \"y\")")
.expect_err("no meta");
assert_eq!(err, CatalogError::MissingMeta);
}
#[test]
fn rejects_non_slug_name() {
for bad in [
r#"(defescribaplugin :name "bad name")"#,
r#"(defescribaplugin :name "bad/slash")"#,
r#"(defescribaplugin :name "x.y")"#,
] {
let err = read_catalog_meta(bad).expect_err("non-slug name rejected");
assert!(matches!(err, CatalogError::InvalidName(_)), "for {bad}");
}
assert!(read_catalog_meta(r#"(defescribaplugin :name "escriba-ok_1")"#).is_ok());
}
#[test]
fn multiple_meta_errors() {
let src = r#"
(defescribaplugin :name "a")
(defescribaplugin :name "b")
"#;
let err = read_catalog_meta(src).expect_err("two metas");
assert!(matches!(err, CatalogError::MultipleMeta(2)));
}
#[test]
fn emit_caixa_lisp_re_parses_as_defcaixa() {
let spec = read_catalog_meta(SAMPLE).unwrap();
let manifest = emit_caixa_lisp(&spec);
let forms = tatara_lisp::read(&manifest).expect("emitted caixa.lisp re-parses");
assert_eq!(forms.len(), 1);
assert!(manifest.contains("Biblioteca"));
assert!(manifest.contains("escriba-oil"));
assert!(manifest.contains("stevearc/oil.nvim"));
}
#[test]
fn etiquetas_dedup_and_order() {
let spec = EscribaPluginSpec {
name: "x".into(),
version: String::new(),
description: String::new(),
category: "files".into(),
blnvim_origin: "stevearc/oil.nvim".into(),
ativar_em: vec![],
tags: vec!["files".into(), "extra".into()],
priority: 0,
};
assert_eq!(
spec.etiquetas(),
vec!["escriba-plugin", "files", "stevearc/oil.nvim", "extra"],
);
}
#[test]
fn emit_defplugin_descriptor_lowers_triggers() {
let spec = read_catalog_meta(SAMPLE).unwrap();
let desc = emit_defplugin_descriptor(&spec);
let plan = crate::apply_source(&desc).unwrap();
assert_eq!(plan.plugins.len(), 1);
assert_eq!(plan.plugins[0].name, "escriba-oil");
assert_eq!(plan.plugins[0].on_command, "Oil");
assert!(plan.plugins[0].lazy);
}
#[test]
fn effective_version_defaults() {
let mut spec = read_catalog_meta(SAMPLE).unwrap();
spec.version = String::new();
assert_eq!(spec.effective_version(), "0.1.0");
}
}