Skip to main content

hjkl_ex/
registry.rs

1use crate::effect::ExEffect;
2use hjkl_engine::Host;
3
4/// The kinds of argument an ex command accepts.
5#[derive(Copy, Clone, Debug, Eq, PartialEq)]
6pub enum ArgKind {
7    None,
8    Path,
9    Buffer,
10    Setting,
11    Register,
12    Mark,
13    Raw,
14}
15
16/// Type alias for a handler fn. Generic over `H` so a single `Registry<H>`
17/// instance can serve any concrete host.
18///
19/// Returning `None` lets a handler opt out of a particular invocation — the
20/// dispatcher then treats the command as unhandled and the umbrella's caller
21/// falls back to its legacy ex path. This is how Phase 2a's bare-name
22/// commands defer their `<path>`-arg variants to Phase 2b without a hard
23/// `Unknown` error: e.g. `:w` returns `Some(Save)` but `:w foo` returns
24/// `None` so `apps/hjkl::dispatch_ex` lets the legacy `SaveAs` arm handle it.
25pub type ExHandler<H> = fn(
26    &mut hjkl_engine::Editor<hjkl_buffer::Buffer, H>,
27    &str,
28    Option<crate::range::LineRange>,
29) -> Option<ExEffect>;
30
31/// A single registered ex command.
32pub struct ExCommand<H: Host> {
33    /// Canonical full name (e.g. `"quit"`).
34    pub name: &'static str,
35    /// Alternate full names (e.g. `["quit!"]`).
36    pub aliases: &'static [&'static str],
37    /// What kind of argument this command takes.
38    pub arg_kind: ArgKind,
39    /// Minimum prefix length for vim-style abbreviation (1 → `:q` resolves to `:quit`).
40    pub min_prefix: usize,
41    /// Handler called with the editor and args (text after the command name, trimmed).
42    pub run: ExHandler<H>,
43}
44
45/// Registry of ex commands for a concrete host type `H`.
46pub struct Registry<H: Host> {
47    cmds: Vec<ExCommand<H>>,
48}
49
50impl<H: Host> Registry<H> {
51    pub fn new() -> Self {
52        Self { cmds: Vec::new() }
53    }
54
55    /// Register a command. Returns `&mut Self` for chaining.
56    pub fn add(&mut self, cmd: ExCommand<H>) -> &mut Self {
57        self.cmds.push(cmd);
58        self
59    }
60
61    /// Resolve `name` to a registered command.
62    ///
63    /// Priority:
64    /// 1. Exact match against `cmd.name`
65    /// 2. Exact match against any alias in `cmd.aliases`
66    /// 3. Unambiguous prefix match against `cmd.name` (input length >= `min_prefix`)
67    pub fn resolve(&self, name: &str) -> Option<&ExCommand<H>> {
68        if name.is_empty() {
69            return None;
70        }
71        // 1. Exact name match
72        if let Some(cmd) = self.cmds.iter().find(|c| c.name == name) {
73            return Some(cmd);
74        }
75        // 2. Exact alias match
76        if let Some(cmd) = self.cmds.iter().find(|c| c.aliases.contains(&name)) {
77            return Some(cmd);
78        }
79        // 3. Prefix match
80        let candidates: Vec<&ExCommand<H>> = self
81            .cmds
82            .iter()
83            .filter(|c| c.name.starts_with(name) && name.len() >= c.min_prefix)
84            .collect();
85        if candidates.len() == 1 {
86            Some(candidates[0])
87        } else {
88            None
89        }
90    }
91
92    /// Iterate over all registered commands.
93    pub fn iter(&self) -> impl Iterator<Item = &ExCommand<H>> {
94        self.cmds.iter()
95    }
96}
97
98impl<H: Host> Default for Registry<H> {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104// ── Host-side registry ────────────────────────────────────────────────────────
105//
106// `HostCmd<Ctx>` is agnostic of the editor stack.  `apps/hjkl` supplies its
107// `App` type as `Ctx`.  Commands here can mutate any application state, not
108// just the editor.
109//
110// Range support is intentionally omitted for Phase 4 — the host-side commands
111// migrating in 4b–4e (tab/window/picker/mapping ops) don't accept ranges.
112
113/// Application-side ex command.  `Ctx` is opaque to hjkl-ex — `apps/hjkl`
114/// supplies its `App` type.  Commands here can mutate any application state,
115/// not just the editor.
116pub trait HostCmd<Ctx>: Send + Sync {
117    fn name(&self) -> &'static str;
118    fn aliases(&self) -> &'static [&'static str] {
119        &[]
120    }
121    fn min_prefix(&self) -> usize {
122        1
123    }
124    fn arg_kind(&self) -> ArgKind {
125        ArgKind::None
126    }
127    /// Returns `Some(effect)` to claim the invocation, `None` to defer.
128    fn run(&self, ctx: &mut Ctx, args: &str) -> Option<crate::effect::ExEffect>;
129}
130
131/// Registry of host-level ex commands, generic over an opaque context type.
132pub struct HostRegistry<Ctx> {
133    cmds: Vec<Box<dyn HostCmd<Ctx>>>,
134}
135
136impl<Ctx> HostRegistry<Ctx> {
137    pub fn new() -> Self {
138        Self { cmds: Vec::new() }
139    }
140
141    /// Register a command.  Returns `&mut Self` for chaining.
142    pub fn add(&mut self, cmd: Box<dyn HostCmd<Ctx>>) -> &mut Self {
143        self.cmds.push(cmd);
144        self
145    }
146
147    /// Resolve `name` to a registered host command.
148    ///
149    /// Priority:
150    /// 1. Exact match against `cmd.name()`
151    /// 2. Exact match against any alias in `cmd.aliases()`
152    /// 3. Unambiguous prefix match against `cmd.name()` (input length >= `min_prefix()`)
153    pub fn resolve(&self, name: &str) -> Option<&dyn HostCmd<Ctx>> {
154        if name.is_empty() {
155            return None;
156        }
157        // 1. Exact name match
158        if let Some(c) = self.cmds.iter().find(|c| c.name() == name) {
159            return Some(c.as_ref());
160        }
161        // 2. Exact alias match
162        if let Some(c) = self.cmds.iter().find(|c| c.aliases().contains(&name)) {
163            return Some(c.as_ref());
164        }
165        // 3. Unambiguous prefix match
166        let candidates: Vec<&dyn HostCmd<Ctx>> = self
167            .cmds
168            .iter()
169            .filter(|c| c.name().starts_with(name) && name.len() >= c.min_prefix())
170            .map(|c| c.as_ref())
171            .collect();
172        if candidates.len() == 1 {
173            Some(candidates[0])
174        } else {
175            None
176        }
177    }
178
179    /// Iterate over all registered host commands.
180    pub fn iter(&self) -> impl Iterator<Item = &dyn HostCmd<Ctx>> {
181        self.cmds.iter().map(|c| c.as_ref())
182    }
183}
184
185impl<Ctx> Default for HostRegistry<Ctx> {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191#[cfg(test)]
192mod host_registry_tests {
193    use super::*;
194    use crate::effect::ExEffect;
195
196    struct TestCtx {
197        value: i32,
198    }
199
200    struct IncrCmd;
201    impl HostCmd<TestCtx> for IncrCmd {
202        fn name(&self) -> &'static str {
203            "increment"
204        }
205        fn aliases(&self) -> &'static [&'static str] {
206            &["incr"]
207        }
208        fn min_prefix(&self) -> usize {
209            3
210        }
211        fn run(&self, ctx: &mut TestCtx, _args: &str) -> Option<ExEffect> {
212            ctx.value += 1;
213            Some(ExEffect::Ok)
214        }
215    }
216
217    struct ArgCmd;
218    impl HostCmd<TestCtx> for ArgCmd {
219        fn name(&self) -> &'static str {
220            "argcmd"
221        }
222        fn min_prefix(&self) -> usize {
223            6
224        }
225        fn run(&self, _ctx: &mut TestCtx, args: &str) -> Option<ExEffect> {
226            if args.is_empty() {
227                None
228            } else {
229                Some(ExEffect::Info(args.to_string()))
230            }
231        }
232    }
233
234    fn make_registry() -> HostRegistry<TestCtx> {
235        let mut reg = HostRegistry::new();
236        reg.add(Box::new(IncrCmd));
237        reg.add(Box::new(ArgCmd));
238        reg
239    }
240
241    #[test]
242    fn resolve_exact_name() {
243        let reg = make_registry();
244        assert!(reg.resolve("increment").is_some());
245        assert!(reg.resolve("argcmd").is_some());
246    }
247
248    #[test]
249    fn resolve_exact_alias() {
250        let reg = make_registry();
251        assert!(reg.resolve("incr").is_some());
252    }
253
254    #[test]
255    fn resolve_prefix() {
256        let reg = make_registry();
257        // "inc" meets min_prefix=3 for "increment" and is unambiguous
258        assert!(reg.resolve("inc").is_some());
259        assert_eq!(reg.resolve("inc").unwrap().name(), "increment");
260    }
261
262    #[test]
263    fn resolve_prefix_too_short() {
264        let reg = make_registry();
265        // "in" is shorter than min_prefix=3 for "increment"
266        assert!(reg.resolve("in").is_none());
267    }
268
269    #[test]
270    fn resolve_unknown_returns_none() {
271        let reg = make_registry();
272        assert!(reg.resolve("nonexistent").is_none());
273        assert!(reg.resolve("").is_none());
274    }
275
276    #[test]
277    fn run_mutates_context() {
278        let reg = make_registry();
279        let mut ctx = TestCtx { value: 0 };
280        let cmd = reg.resolve("increment").unwrap();
281        let eff = cmd.run(&mut ctx, "");
282        assert_eq!(eff, Some(ExEffect::Ok));
283        assert_eq!(ctx.value, 1);
284    }
285
286    #[test]
287    fn run_returns_none_to_defer() {
288        let reg = make_registry();
289        let mut ctx = TestCtx { value: 0 };
290        let cmd = reg.resolve("argcmd").unwrap();
291        // no args → defers
292        let eff = cmd.run(&mut ctx, "");
293        assert!(eff.is_none());
294        // with args → claims
295        let eff2 = cmd.run(&mut ctx, "hello");
296        assert_eq!(eff2, Some(ExEffect::Info("hello".to_string())));
297    }
298
299    #[test]
300    fn iter_yields_all_commands() {
301        let reg = make_registry();
302        let names: Vec<&str> = reg.iter().map(|c| c.name()).collect();
303        assert!(names.contains(&"increment"));
304        assert!(names.contains(&"argcmd"));
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::effect::ExEffect;
312    use hjkl_engine::{DefaultHost, Editor};
313
314    fn noop_handler(
315        _editor: &mut Editor<hjkl_buffer::Buffer, DefaultHost>,
316        _args: &str,
317        _range: Option<crate::range::LineRange>,
318    ) -> Option<ExEffect> {
319        Some(ExEffect::Ok)
320    }
321
322    fn make_registry() -> Registry<DefaultHost> {
323        let mut reg = Registry::new();
324        reg.add(ExCommand {
325            name: "quit",
326            aliases: &["quit!"],
327            arg_kind: ArgKind::None,
328            min_prefix: 1,
329            run: noop_handler,
330        });
331        reg.add(ExCommand {
332            name: "write",
333            aliases: &[],
334            arg_kind: ArgKind::Path,
335            min_prefix: 1,
336            run: noop_handler,
337        });
338        reg
339    }
340
341    #[test]
342    fn resolve_exact_name() {
343        let reg = make_registry();
344        assert!(reg.resolve("quit").is_some());
345        assert!(reg.resolve("write").is_some());
346    }
347
348    #[test]
349    fn resolve_exact_alias() {
350        let reg = make_registry();
351        assert!(reg.resolve("quit!").is_some());
352    }
353
354    #[test]
355    fn resolve_prefix() {
356        let reg = make_registry();
357        // "q" is a valid prefix (min_prefix=1) and unambiguous among registered cmds
358        assert!(reg.resolve("q").is_some());
359        assert!(reg.resolve("w").is_some());
360    }
361
362    #[test]
363    fn resolve_prefix_too_short() {
364        let mut reg = Registry::<DefaultHost>::new();
365        reg.add(ExCommand {
366            name: "quit",
367            aliases: &[],
368            arg_kind: ArgKind::None,
369            min_prefix: 2,
370            run: noop_handler,
371        });
372        // "q" is shorter than min_prefix=2, should not resolve
373        assert!(reg.resolve("q").is_none());
374        // "qu" meets min_prefix=2
375        assert!(reg.resolve("qu").is_some());
376    }
377
378    #[test]
379    fn resolve_unknown_returns_none() {
380        let reg = make_registry();
381        assert!(reg.resolve("nonexistent").is_none());
382        assert!(reg.resolve("").is_none());
383    }
384
385    #[test]
386    fn add_command_works() {
387        let mut reg = Registry::<DefaultHost>::new();
388        reg.add(ExCommand {
389            name: "test",
390            aliases: &[],
391            arg_kind: ArgKind::Raw,
392            min_prefix: 2,
393            run: noop_handler,
394        });
395        assert!(reg.resolve("test").is_some());
396        assert!(reg.resolve("te").is_some());
397        assert!(reg.resolve("t").is_none());
398    }
399}