Skip to main content

escriba_config/
lib.rs

1//! `escriba-config` — tatara-lisp editor config. Every top-level config
2//! form is a TataraDomain: `defescriba` / `defkeymap` / `defcommand` /
3//! `defplugin` / `defmajor-mode` / `defminor-mode`.
4
5extern crate self as escriba_config;
6
7use serde::{Deserialize, Serialize};
8use tatara_lisp::DeriveTataraDomain;
9
10#[derive(
11    DeriveTataraDomain,
12    Serialize,
13    Deserialize,
14    schemars::JsonSchema,
15    Debug,
16    Clone,
17    PartialEq,
18    Default,
19)]
20#[serde(rename_all = "camelCase")]
21#[tatara(keyword = "defescriba")]
22pub struct EscribaConfig {
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub tema: Option<String>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub numeros_linha: Option<bool>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub numeros_relativos: Option<bool>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub largura_tab: Option<i64>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub quebra_suave: Option<bool>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub mostrar_statusline: Option<bool>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub mostrar_tabbar: Option<bool>,
37}
38
39#[derive(
40    DeriveTataraDomain, Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq,
41)]
42#[serde(rename_all = "camelCase")]
43#[tatara(keyword = "defkeymap")]
44pub struct KeymapDecl {
45    pub modo: String,
46    pub tecla: String,
47    pub comando: String,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub descricao: Option<String>,
50}
51
52#[derive(
53    DeriveTataraDomain, Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq,
54)]
55#[serde(rename_all = "camelCase")]
56#[tatara(keyword = "defcommand")]
57pub struct CommandDecl {
58    pub nome: String,
59    pub descricao: String,
60    #[serde(default)]
61    pub args: Vec<String>,
62}
63
64#[derive(
65    DeriveTataraDomain, Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq,
66)]
67#[serde(rename_all = "camelCase")]
68#[tatara(keyword = "defplugin")]
69pub struct PluginDecl {
70    pub caixa: String,
71    pub versao: String,
72    #[serde(default)]
73    pub ativar_em: Vec<String>,
74}
75
76#[derive(
77    DeriveTataraDomain, Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq,
78)]
79#[serde(rename_all = "camelCase")]
80#[tatara(keyword = "defmajor-mode")]
81pub struct MajorMode {
82    pub nome: String,
83    #[serde(default)]
84    pub extensoes: Vec<String>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub estrutural_lisp: Option<bool>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub tamanho_indent: Option<i64>,
89}
90
91#[derive(
92    DeriveTataraDomain, Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq,
93)]
94#[serde(rename_all = "camelCase")]
95#[tatara(keyword = "defminor-mode")]
96pub struct MinorMode {
97    pub nome: String,
98    #[serde(default)]
99    pub hooks: Vec<String>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub descricao: Option<String>,
102}
103
104impl EscribaConfig {
105    pub fn from_lisp(src: &str) -> Result<Self, tatara_lisp::LispError> {
106        use tatara_lisp::domain::TataraDomain;
107        let forms = tatara_lisp::read(src)?;
108        let first = forms
109            .first()
110            .ok_or_else(|| tatara_lisp::LispError::Compile {
111                form: "defescriba".into(),
112                message: "empty config".into(),
113            })?;
114        Self::compile_from_sexp(first)
115    }
116
117    /// Claim escriba's `def…` keywords in the process-wide tatara-lisp
118    /// registry.
119    ///
120    /// Returns the FIRST collision rather than swallowing it. tatara-lisp
121    /// 0.3.14 made `register` fallible for a reason worth restating: one
122    /// keyword belongs to one type per process, and a refusal means some
123    /// other type is already answering to a `def…` form escriba believes it
124    /// owns. Discarding that result — which this function did — leaves the
125    /// editor parsing operator config with the WRONG domain handler and no
126    /// indication anywhere that it happened. Re-registering the same type is
127    /// idempotent upstream, so a repeat call is still `Ok`.
128    pub fn register_all() -> Result<(), tatara_lisp::domain::KeywordCollision> {
129        tatara_lisp::domain::register::<Self>()?;
130        tatara_lisp::domain::register::<KeymapDecl>()?;
131        tatara_lisp::domain::register::<CommandDecl>()?;
132        tatara_lisp::domain::register::<PluginDecl>()?;
133        tatara_lisp::domain::register::<MajorMode>()?;
134        tatara_lisp::domain::register::<MinorMode>()?;
135        Ok(())
136    }
137}
138
139// ── shikumi::TieredConfig — fleet-wide tier model (M-166 backfill) ──
140//
141// Operators reach via:
142//   ESCRIBA_TIER=bare escriba ...
143//   ESCRIBA_TIER=default escriba ...
144//
145// Prior migrations: tatara, zoekt-mcp, kindling, ayatsuri, kenshi,
146// taimen. See `shikumi/src/tiered.rs` for the trait contract.
147//
148// bare() is the all-None zero-opinion floor. prescribed_default() now
149// mirrors the shipped `configs/blnvim-defaults.lisp` baseline (theme +
150// numbers + tab-width 2 + statusline) so `escriba config-show default`
151// reports what actually boots. The `.lisp` remains the load-bearing
152// prescription (it carries the keymaps/modes/highlights this 7-field
153// struct cannot express); this struct is the operator-facing summary.
154
155impl shikumi::TieredConfig for EscribaConfig {
156    /// Tier 0 — bare: zero-opinion floor. Every field None.
157    fn bare() -> Self {
158        Self {
159            tema: None,
160            numeros_linha: None,
161            numeros_relativos: None,
162            largura_tab: None,
163            quebra_suave: None,
164            mostrar_statusline: None,
165            mostrar_tabbar: None,
166        }
167    }
168
169    /// Tier 2 — prescribed: the curated defaults that ship today. These
170    /// MIRROR the load-bearing `configs/blnvim-defaults.lisp` baseline the
171    /// editor actually boots (line numbers + relative numbers on; tab width
172    /// 2; no soft wrap; statusline on; tabbar off — `showtabline=0`, blnvim
173    /// parity). The `.lisp` remains the load-bearing prescription; this keeps
174    /// `escriba config-show default` honest about what ships.
175    ///
176    /// **The theme is DERIVED, not spelled.** It used to read `"vellum"`
177    /// while `configs/blnvim-defaults.lisp` declared `(deftheme :preset
178    /// "nord")` and the paint path resolved `FleetTheme::prescribed_default()`
179    /// — so `config-show default` named a theme the editor never booted with.
180    /// Sourcing it from ishou means the fleet moving its prescribed theme
181    /// moves this too, with no edit here and no window where they disagree.
182    fn prescribed_default() -> Self {
183        Self {
184            tema: Some(
185                ishou_tokens::FleetTheme::prescribed_default()
186                    .preset_name()
187                    .to_string(),
188            ),
189            numeros_linha: Some(true),
190            numeros_relativos: Some(true),
191            largura_tab: Some(2),
192            quebra_suave: Some(false),
193            mostrar_statusline: Some(true),
194            mostrar_tabbar: Some(false),
195        }
196    }
197}
198
199#[cfg(test)]
200mod tiered_tests {
201    use super::*;
202    use shikumi::{ConfigTier, TieredConfig};
203
204    #[test]
205    fn escriba_config_bare_is_zero_opinion() {
206        let b = <EscribaConfig as TieredConfig>::bare();
207        assert!(b.tema.is_none());
208        assert!(b.numeros_linha.is_none());
209        assert!(b.numeros_relativos.is_none());
210        assert!(b.largura_tab.is_none());
211        assert!(b.quebra_suave.is_none());
212        assert!(b.mostrar_statusline.is_none());
213        assert!(b.mostrar_tabbar.is_none());
214    }
215
216    #[test]
217    fn escriba_config_prescribed_mirrors_blnvim_baseline() {
218        // The prescribed default mirrors the shipped blnvim-defaults.lisp so
219        // `escriba config-show default` reflects the real boot baseline.
220        let p = <EscribaConfig as TieredConfig>::prescribed_default();
221        // Asserted against the FLEET, not a literal. A literal here is what
222        // let this report `vellum` for as long as it did: the string was
223        // pinned by a test, so the drift looked deliberate.
224        assert_eq!(
225            p.tema.as_deref(),
226            Some(ishou_tokens::FleetTheme::prescribed_default().preset_name()),
227        );
228        assert_eq!(p.numeros_linha, Some(true));
229        assert_eq!(p.numeros_relativos, Some(true));
230        assert_eq!(p.largura_tab, Some(2));
231        assert_eq!(p.quebra_suave, Some(false));
232        assert_eq!(p.mostrar_statusline, Some(true));
233        assert_eq!(p.mostrar_tabbar, Some(false));
234        // Prescribed differs from the all-None bare floor.
235        let bare = <EscribaConfig as TieredConfig>::bare();
236        assert_ne!(p, bare);
237    }
238
239    #[test]
240    fn escriba_config_resolve_tier_dispatches() {
241        // Bare is zero-opinion; Default pins the fleet-prescribed theme.
242        let bare = <EscribaConfig as TieredConfig>::resolve_tier(ConfigTier::Bare);
243        let default = <EscribaConfig as TieredConfig>::resolve_tier(ConfigTier::Default);
244        assert_eq!(bare, <EscribaConfig as TieredConfig>::bare());
245        assert_eq!(
246            default,
247            <EscribaConfig as TieredConfig>::prescribed_default()
248        );
249        assert_eq!(
250            default.tema.as_deref(),
251            Some(ishou_tokens::FleetTheme::prescribed_default().preset_name()),
252        );
253    }
254
255    /// The three places escriba states a default theme must state the SAME
256    /// one: this tiered config, the shipped `configs/blnvim-defaults.lisp`,
257    /// and the paint path's `ChromePalette::prescribed()`.
258    ///
259    /// They disagreed. `config-show default` said `vellum`, the lisp said
260    /// `nord`, and the screen showed Nord — a report that was wrong about
261    /// the editor it describes. Nothing compared them, so nothing caught it.
262    #[test]
263    fn every_statement_of_the_default_theme_agrees() {
264        let fleet = ishou_tokens::FleetTheme::prescribed_default();
265        let from_config = <EscribaConfig as TieredConfig>::prescribed_default()
266            .tema
267            .expect("the prescribed tier names a theme");
268        assert_eq!(
269            from_config,
270            fleet.preset_name(),
271            "the tiered config must name the fleet-prescribed theme",
272        );
273
274        // And the shipped lisp — the load-bearing prescription — must declare
275        // it too. Read from the file the binary bakes in, so an edit there
276        // that forgets this file fails HERE.
277        let lisp = include_str!("../../escriba/configs/blnvim-defaults.lisp");
278        let declared = lisp
279            .lines()
280            .find_map(|l| {
281                let l = l.trim();
282                l.strip_prefix("(deftheme :preset ")
283                    .map(|r| r.trim_end_matches(')').trim().trim_matches('"').to_string())
284            })
285            .expect("the shipped defaults declare a theme");
286        assert_eq!(
287            declared,
288            fleet.preset_name(),
289            "configs/blnvim-defaults.lisp declares a different theme than the \
290             one escriba reports as its default",
291        );
292    }
293
294    #[test]
295    fn escriba_config_diff_against_self_is_empty() {
296        // The diff machinery: a value diffed against itself produces
297        // an empty diff.
298        let p = <EscribaConfig as TieredConfig>::prescribed_default();
299        assert!(p.diff_against(&p).is_empty_diff());
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn parses_defescriba() {
309        let src = r#"(defescriba :tema "nord" :numeros-linha #t :largura-tab 2)"#;
310        let c = EscribaConfig::from_lisp(src).unwrap();
311        assert_eq!(c.tema.as_deref(), Some("nord"));
312        assert_eq!(c.numeros_linha, Some(true));
313        assert_eq!(c.largura_tab, Some(2));
314    }
315
316    #[test]
317    fn parses_defkeymap() {
318        use tatara_lisp::domain::TataraDomain;
319        let forms = tatara_lisp::read(
320            r#"(defkeymap :modo "Normal" :tecla "<leader>w" :comando "save" :descricao "save")"#,
321        )
322        .unwrap();
323        let k = KeymapDecl::compile_from_sexp(&forms[0]).unwrap();
324        assert_eq!(k.modo, "Normal");
325        assert_eq!(k.comando, "save");
326    }
327
328    #[test]
329    fn parses_defmajor_mode_with_structural_lisp() {
330        use tatara_lisp::domain::TataraDomain;
331        let forms = tatara_lisp::read(
332            r#"(defmajor-mode :nome "lisp" :extensoes ("lisp" "el" "clj") :estrutural-lisp #t)"#,
333        )
334        .unwrap();
335        let m = MajorMode::compile_from_sexp(&forms[0]).unwrap();
336        assert_eq!(m.nome, "lisp");
337        assert_eq!(m.estrutural_lisp, Some(true));
338    }
339
340    #[test]
341    fn register_all_populates_registry() {
342        EscribaConfig::register_all().expect("escriba's own keywords must not collide");
343        let kws = tatara_lisp::domain::registered_keywords();
344        for keyword in [
345            "defescriba",
346            "defkeymap",
347            "defcommand",
348            "defplugin",
349            "defmajor-mode",
350            "defminor-mode",
351        ] {
352            assert!(kws.contains(&keyword), "missing keyword: {keyword}");
353        }
354    }
355
356    #[test]
357    fn registering_twice_is_idempotent_not_a_collision() {
358        // The registry is process-wide and other tests in this binary also
359        // register. If a repeat call reported a collision, startup would fail
360        // for a program that merely initialised twice.
361        EscribaConfig::register_all().expect("first");
362        EscribaConfig::register_all().expect("a repeat call is idempotent");
363    }
364
365    /// The RED RUN for the collision path: a deliberately-broken input — a
366    /// second type claiming a keyword escriba already owns — must be refused
367    /// and NAMED.
368    ///
369    /// Without this the fallible signature would be decoration: nothing else
370    /// in the suite ever produces an `Err`, so a `register` that silently
371    /// started returning `Ok` on collision would go unnoticed.
372    #[test]
373    fn a_second_type_claiming_an_escriba_keyword_is_refused() {
374        use tatara_lisp::domain::TataraDomain;
375
376        #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
377        struct Impostor {
378            nome: String,
379        }
380        impl TataraDomain for Impostor {
381            const KEYWORD: &'static str = "defkeymap"; // already KeymapDecl's
382            fn compile_from_args(
383                _args: &[tatara_lisp::Sexp],
384            ) -> Result<Self, tatara_lisp::LispError> {
385                Ok(Self {
386                    nome: String::new(),
387                })
388            }
389        }
390
391        EscribaConfig::register_all().expect("escriba's own keywords register");
392        let err = tatara_lisp::domain::register::<Impostor>()
393            .expect_err("a different type must NOT be allowed to take `defkeymap`");
394        assert_eq!(err.keyword, "defkeymap");
395        assert!(
396            err.challenger.contains("Impostor"),
397            "the refusal must name who was turned away: {err}",
398        );
399    }
400}