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}