Skip to main content

escriba_runtime/
plugin_host.rs

1//! `PluginHost` — runtime lazy activation for USER plugin caixas.
2//!
3//! The bundled default plugin catalog is applied eagerly at boot (the
4//! binary merges it into the default plan). This host serves the OTHER
5//! case: a user's lazy plugins installed in the plugins dir, declared
6//! with `(defplugin :caixa … :ativar-em ("Command: Foo"))`. Such a
7//! plugin's escriba entry is NOT applied until its trigger fires — the
8//! lazy.nvim model, typed.
9//!
10//! The host stores each lazy plugin's entry source + its triggers. When
11//! the editor runs a command / opens a filetype / fires an event, the
12//! matching not-yet-activated plugins have their entries applied to live
13//! [`EditorState`](crate::EditorState) through the SAME escriba-lisp
14//! apply paths a user rc uses. Activation is one-shot (a plugin's entry
15//! is applied at most once).
16//!
17//! This closes the gap where `escriba-plugin`'s `PluginCaixa` loader
18//! could parse triggers but nothing in the runtime ever fired them.
19
20/// A lazy-load trigger — the runtime-native projection of
21/// `escriba_plugin::ActivationTrigger` (the binary parses the caixa's
22/// `:ativar-em` strings and registers these). `Startup` plugins are
23/// applied eagerly by the binary, never registered here.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum LazyTrigger {
26    /// Activate when a buffer of this filetype opens.
27    FileType(String),
28    /// Activate when this editor event fires.
29    Event(String),
30    /// Activate when this command is first invoked.
31    Command(String),
32}
33
34/// One registered lazy plugin: identity, triggers, its escriba entry
35/// source, and whether it has activated yet.
36#[derive(Debug, Clone)]
37struct LazyPlugin {
38    name: String,
39    triggers: Vec<LazyTrigger>,
40    entry_src: String,
41    activated: bool,
42}
43
44/// Holds the editor's registered lazy plugins and decides which to
45/// activate when a trigger fires. The returned entry sources are
46/// applied by [`EditorState`](crate::EditorState) (which owns the
47/// keymap / command registry / option store).
48#[derive(Debug, Clone, Default)]
49pub struct PluginHost {
50    plugins: Vec<LazyPlugin>,
51}
52
53impl PluginHost {
54    /// Register a lazy plugin. `triggers` empty ⇒ the plugin would be
55    /// eager; the binary applies those at boot and never registers them
56    /// here, but an empty-trigger registration is harmless (it simply
57    /// never activates).
58    pub fn register(
59        &mut self,
60        name: impl Into<String>,
61        triggers: Vec<LazyTrigger>,
62        entry_src: impl Into<String>,
63    ) {
64        self.plugins.push(LazyPlugin {
65            name: name.into(),
66            triggers,
67            entry_src: entry_src.into(),
68            activated: false,
69        });
70    }
71
72    /// How many registered plugins have not activated yet.
73    #[must_use]
74    pub fn pending(&self) -> usize {
75        self.plugins.iter().filter(|p| !p.activated).count()
76    }
77
78    /// Names of the registered lazy plugins (for `plugin list` / tests).
79    pub fn names(&self) -> impl Iterator<Item = &str> {
80        self.plugins.iter().map(|p| p.name.as_str())
81    }
82
83    /// Mark + drain the entry sources of every not-yet-activated plugin
84    /// whose triggers match `want`. The caller applies the returned
85    /// sources to live editor state.
86    fn take_matching(&mut self, want: &LazyTrigger) -> Vec<String> {
87        let mut out = Vec::new();
88        for p in &mut self.plugins {
89            if !p.activated && p.triggers.iter().any(|t| t == want) {
90                p.activated = true;
91                out.push(p.entry_src.clone());
92            }
93        }
94        out
95    }
96
97    /// Entry sources to apply when `command` is first invoked.
98    pub fn pending_for_command(&mut self, command: &str) -> Vec<String> {
99        self.take_matching(&LazyTrigger::Command(command.to_string()))
100    }
101
102    /// Entry sources to apply when a buffer of `filetype` opens.
103    pub fn pending_for_filetype(&mut self, filetype: &str) -> Vec<String> {
104        self.take_matching(&LazyTrigger::FileType(filetype.to_string()))
105    }
106
107    /// Entry sources to apply when `event` fires.
108    pub fn pending_for_event(&mut self, event: &str) -> Vec<String> {
109        self.take_matching(&LazyTrigger::Event(event.to_string()))
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn host() -> PluginHost {
118        let mut h = PluginHost::default();
119        h.register(
120            "user-trouble",
121            vec![LazyTrigger::Command("Trouble".into())],
122            r#"(defkeybind :mode "normal" :key "<leader>xx" :action "trouble.toggle")"#,
123        );
124        h.register(
125            "user-markdown",
126            vec![LazyTrigger::FileType("markdown".into())],
127            r#"(defoption :name "md" :value "on")"#,
128        );
129        h
130    }
131
132    #[test]
133    fn command_trigger_returns_entry_once() {
134        let mut h = host();
135        assert_eq!(h.pending(), 2);
136        let first = h.pending_for_command("Trouble");
137        assert_eq!(first.len(), 1);
138        assert!(first[0].contains("trouble.toggle"));
139        assert_eq!(h.pending(), 1, "activated plugin is no longer pending");
140        // Second fire of the same command yields nothing (one-shot).
141        assert!(h.pending_for_command("Trouble").is_empty());
142    }
143
144    #[test]
145    fn filetype_trigger_isolated_from_command() {
146        let mut h = host();
147        assert!(h.pending_for_command("Other").is_empty());
148        let md = h.pending_for_filetype("markdown");
149        assert_eq!(md.len(), 1);
150        assert!(md[0].contains(":name \"md\""));
151        assert_eq!(h.pending(), 1);
152    }
153
154    #[test]
155    fn non_matching_trigger_activates_nothing() {
156        let mut h = host();
157        assert!(h.pending_for_event("BufWritePre").is_empty());
158        assert_eq!(h.pending(), 2);
159    }
160}