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(&mut self, name: impl Into<String>, triggers: Vec<LazyTrigger>, entry_src: impl Into<String>) {
59        self.plugins.push(LazyPlugin {
60            name: name.into(),
61            triggers,
62            entry_src: entry_src.into(),
63            activated: false,
64        });
65    }
66
67    /// How many registered plugins have not activated yet.
68    #[must_use]
69    pub fn pending(&self) -> usize {
70        self.plugins.iter().filter(|p| !p.activated).count()
71    }
72
73    /// Names of the registered lazy plugins (for `plugin list` / tests).
74    pub fn names(&self) -> impl Iterator<Item = &str> {
75        self.plugins.iter().map(|p| p.name.as_str())
76    }
77
78    /// Mark + drain the entry sources of every not-yet-activated plugin
79    /// whose triggers match `want`. The caller applies the returned
80    /// sources to live editor state.
81    fn take_matching(&mut self, want: &LazyTrigger) -> Vec<String> {
82        let mut out = Vec::new();
83        for p in &mut self.plugins {
84            if !p.activated && p.triggers.iter().any(|t| t == want) {
85                p.activated = true;
86                out.push(p.entry_src.clone());
87            }
88        }
89        out
90    }
91
92    /// Entry sources to apply when `command` is first invoked.
93    pub fn pending_for_command(&mut self, command: &str) -> Vec<String> {
94        self.take_matching(&LazyTrigger::Command(command.to_string()))
95    }
96
97    /// Entry sources to apply when a buffer of `filetype` opens.
98    pub fn pending_for_filetype(&mut self, filetype: &str) -> Vec<String> {
99        self.take_matching(&LazyTrigger::FileType(filetype.to_string()))
100    }
101
102    /// Entry sources to apply when `event` fires.
103    pub fn pending_for_event(&mut self, event: &str) -> Vec<String> {
104        self.take_matching(&LazyTrigger::Event(event.to_string()))
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn host() -> PluginHost {
113        let mut h = PluginHost::default();
114        h.register(
115            "user-trouble",
116            vec![LazyTrigger::Command("Trouble".into())],
117            r#"(defkeybind :mode "normal" :key "<leader>xx" :action "trouble.toggle")"#,
118        );
119        h.register(
120            "user-markdown",
121            vec![LazyTrigger::FileType("markdown".into())],
122            r#"(defoption :name "md" :value "on")"#,
123        );
124        h
125    }
126
127    #[test]
128    fn command_trigger_returns_entry_once() {
129        let mut h = host();
130        assert_eq!(h.pending(), 2);
131        let first = h.pending_for_command("Trouble");
132        assert_eq!(first.len(), 1);
133        assert!(first[0].contains("trouble.toggle"));
134        assert_eq!(h.pending(), 1, "activated plugin is no longer pending");
135        // Second fire of the same command yields nothing (one-shot).
136        assert!(h.pending_for_command("Trouble").is_empty());
137    }
138
139    #[test]
140    fn filetype_trigger_isolated_from_command() {
141        let mut h = host();
142        assert!(h.pending_for_command("Other").is_empty());
143        let md = h.pending_for_filetype("markdown");
144        assert_eq!(md.len(), 1);
145        assert!(md[0].contains(":name \"md\""));
146        assert_eq!(h.pending(), 1);
147    }
148
149    #[test]
150    fn non_matching_trigger_activates_nothing() {
151        let mut h = host();
152        assert!(h.pending_for_event("BufWritePre").is_empty());
153        assert_eq!(h.pending(), 2);
154    }
155}