keymap_core/cmd.rs
1//! Command name → action index with front-prefix completion.
2//!
3//! Ex-command input (`:wq`, `:w file`) decomposes into three stages, none of
4//! which require a new library type for *execution*: `:` is an ordinary
5//! [`Keymap::get`](crate::Keymap::get) hit, text accumulates in a caller-owned
6//! line buffer, and Enter dispatches through a `FnMut(&str) -> Option<A>` — see
7//! `examples/ex_command.rs`.
8//!
9//! What a caller-owned name table *cannot* give cheaply is the **discovery
10//! layer**: `:w<Tab>` completions (front-prefix enumeration) and a full command
11//! listing for a palette. [`CommandIndex`] is that one type — a name-keyed
12//! `BTreeMap` whose [`complete`](CommandIndex::complete) method is the primary
13//! differentiator.
14//!
15//! The design deliberately avoids a closed result enum: `:w` can be both an
16//! exact command *and* the prefix of `:wq` and `:wqa`, so [`get`] and
17//! [`complete`] are two orthogonal pure functions rather than a merged
18//! discriminant. Normalisation (trim, case-fold) and dispatch are the caller's
19//! responsibility; the index stores names byte-for-byte as bound.
20//!
21//! [`get`]: CommandIndex::get
22//! [`complete`]: CommandIndex::complete
23
24use std::collections::BTreeMap;
25use std::ops::Bound;
26
27/// A name-to-action index with front-prefix completion.
28///
29/// Stores an arbitrary number of named commands, each mapped to a
30/// caller-defined action `A`. The primary use case is a vim-style command
31/// palette: bind names with [`bind`](Self::bind), look up an exact name with
32/// [`get`](Self::get), and enumerate candidates for a partial name with
33/// [`complete`](Self::complete).
34///
35/// **Case sensitivity and whitespace** are the caller's concern: the index
36/// stores names byte-for-byte as given. Trim and case-fold before calling
37/// `bind` / `get` / `complete` if uniformity is desired.
38///
39/// **Lexicographic order is a specification**, not just an implementation
40/// detail. [`complete`](Self::complete) and [`iter`](Self::iter) both return
41/// results in ascending byte order (the order of the underlying `BTreeMap`),
42/// so callers may rely on this for stable, deterministic output.
43#[derive(Debug, Clone)]
44pub struct CommandIndex<A> {
45 map: BTreeMap<String, A>,
46}
47
48impl<A> Default for CommandIndex<A> {
49 fn default() -> Self {
50 CommandIndex {
51 map: BTreeMap::new(),
52 }
53 }
54}
55
56impl<A> CommandIndex<A> {
57 /// Creates an empty index.
58 #[must_use]
59 pub fn new() -> Self {
60 CommandIndex::default()
61 }
62
63 /// Binds `name` to `action`, returning the action previously bound to that
64 /// name, if any (last-wins — same semantic as [`Keymap::bind`](crate::Keymap::bind)).
65 ///
66 /// The name is stored byte-for-byte; no trimming or case-folding is applied.
67 ///
68 /// ```
69 /// use keymap_core::cmd::CommandIndex;
70 ///
71 /// let mut idx: CommandIndex<&str> = CommandIndex::new();
72 /// assert_eq!(idx.bind(":w", "write"), None);
73 /// // Rebinding returns the old action.
74 /// assert_eq!(idx.bind(":w", "write-force"), Some("write"));
75 /// assert_eq!(idx.get(":w"), Some(&"write-force"));
76 /// ```
77 pub fn bind(&mut self, name: impl Into<String>, action: A) -> Option<A> {
78 self.map.insert(name.into(), action)
79 }
80
81 /// Returns the action bound to `name`, or [`None`] if no exact match exists.
82 ///
83 /// This is a **complete-match** lookup. For prefix enumeration (e.g.
84 /// `:w<Tab>`), use [`complete`](Self::complete).
85 ///
86 /// ```
87 /// use keymap_core::cmd::CommandIndex;
88 ///
89 /// let mut idx: CommandIndex<u8> = CommandIndex::new();
90 /// idx.bind(":w", 1);
91 /// idx.bind(":wq", 2);
92 ///
93 /// assert_eq!(idx.get(":w"), Some(&1));
94 /// assert_eq!(idx.get(":wq"), Some(&2));
95 /// // Prefix is not an exact match.
96 /// assert_eq!(idx.get(":"), None);
97 /// ```
98 #[must_use]
99 pub fn get(&self, name: &str) -> Option<&A> {
100 self.map.get(name)
101 }
102
103 /// Enumerates every command whose name starts with `prefix`, in
104 /// **lexicographic (byte) order**.
105 ///
106 /// An empty `prefix` is equivalent to [`iter`](Self::iter) — it yields
107 /// every bound command. Lexicographic order is a **specification**: callers
108 /// may rely on it for stable, deterministic output.
109 ///
110 /// Note that a name can be both an exact match for one call *and* a prefix
111 /// for another: `:w` completes under the prefix `:` and is itself exact
112 /// under `get(":w")`. There is no closed result enum merging these two
113 /// cases.
114 ///
115 /// ```
116 /// use keymap_core::cmd::CommandIndex;
117 ///
118 /// let mut idx: CommandIndex<u8> = CommandIndex::new();
119 /// idx.bind(":w", 1);
120 /// idx.bind(":wq", 2);
121 /// idx.bind(":wqa", 3);
122 /// idx.bind(":q", 4);
123 ///
124 /// // Prefix ":w" matches ":w", ":wq", ":wqa" in lexicographic order.
125 /// let completions: Vec<(&str, &u8)> = idx.complete(":w").collect();
126 /// assert_eq!(completions, vec![(":w", &1), (":wq", &2), (":wqa", &3)]);
127 ///
128 /// // Empty prefix enumerates everything.
129 /// assert_eq!(idx.complete("").count(), 4);
130 /// ```
131 pub fn complete<'a>(&'a self, prefix: &str) -> impl Iterator<Item = (&'a str, &'a A)> {
132 // BTreeMap::range on a string map: the lower bound is the prefix itself;
133 // the upper bound is the first string that is lexicographically greater
134 // than every string with the given prefix — obtained by incrementing the
135 // last byte. If the prefix is empty or incrementing would overflow, there
136 // is no upper bound.
137 let start = Bound::Included(prefix.to_owned());
138 let end = next_prefix(prefix).map_or(Bound::Unbounded, Bound::Excluded);
139
140 self.map.range((start, end)).map(|(k, v)| (k.as_str(), v))
141 }
142
143 /// Iterates over every `(name, action)` pair in **lexicographic (byte)
144 /// order**.
145 ///
146 /// This is the discovery dual of [`complete`](Self::complete) (with an
147 /// empty prefix) and mirrors [`Keymap::iter`](crate::Keymap::iter) in
148 /// purpose.
149 ///
150 /// ```
151 /// use keymap_core::cmd::CommandIndex;
152 ///
153 /// let mut idx: CommandIndex<u8> = CommandIndex::new();
154 /// idx.bind(":q", 2);
155 /// idx.bind(":w", 1);
156 ///
157 /// let all: Vec<(&str, &u8)> = idx.iter().collect();
158 /// // Lexicographic order: ":q" before ":w".
159 /// assert_eq!(all, vec![(":q", &2), (":w", &1)]);
160 /// ```
161 pub fn iter(&self) -> impl Iterator<Item = (&str, &A)> {
162 self.map.iter().map(|(k, v)| (k.as_str(), v))
163 }
164
165 /// Returns the number of commands in the index.
166 #[must_use]
167 pub fn len(&self) -> usize {
168 self.map.len()
169 }
170
171 /// Returns `true` if the index contains no commands.
172 #[must_use]
173 pub fn is_empty(&self) -> bool {
174 self.map.is_empty()
175 }
176}
177
178/// Returns the lexicographically smallest string that is strictly greater than
179/// every string with the given prefix, or `None` if no such string exists
180/// (empty prefix, or the prefix ends with `u8::MAX` bytes).
181///
182/// Used as the exclusive upper bound for the `BTreeMap::range` call in
183/// [`CommandIndex::complete`].
184fn next_prefix(prefix: &str) -> Option<String> {
185 if prefix.is_empty() {
186 return None;
187 }
188 let mut bytes = prefix.as_bytes().to_owned();
189 // Walk backwards to find the last byte that can be incremented.
190 for i in (0..bytes.len()).rev() {
191 if bytes[i] < u8::MAX {
192 bytes[i] += 1;
193 bytes.truncate(i + 1);
194 // The resulting bytes may not be valid UTF-8 (e.g. if we incremented
195 // into a multi-byte sequence boundary), but BTreeMap<String, _> uses
196 // the byte comparison of Ord<String>, and Rust's String Ord delegates
197 // to the byte comparison of str, so constructing via from_utf8_lossy
198 // would change the sort key. Instead we use from_utf8 and, if it
199 // fails, fall back to Unbounded — which is safe (returns a superset).
200 return String::from_utf8(bytes).ok();
201 }
202 }
203 None
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 // ---- bind / get ----------------------------------------------------
211
212 #[test]
213 fn bind_last_wins_returns_old_value() {
214 let mut idx: CommandIndex<&str> = CommandIndex::new();
215 assert_eq!(idx.bind(":w", "write"), None);
216 assert_eq!(idx.bind(":w", "write-bang"), Some("write"));
217 assert_eq!(idx.get(":w"), Some(&"write-bang"));
218 }
219
220 #[test]
221 fn get_exact_match_only() {
222 let mut idx: CommandIndex<u8> = CommandIndex::new();
223 idx.bind(":w", 1);
224 idx.bind(":wq", 2);
225 assert_eq!(idx.get(":w"), Some(&1));
226 assert_eq!(idx.get(":wq"), Some(&2));
227 // Prefix is not an exact match.
228 assert_eq!(idx.get(":"), None);
229 // Unknown name.
230 assert_eq!(idx.get(":x"), None);
231 }
232
233 #[test]
234 fn get_returns_none_on_empty_index() {
235 let idx: CommandIndex<u8> = CommandIndex::new();
236 assert_eq!(idx.get(":w"), None);
237 }
238
239 // ---- complete ------------------------------------------------------
240
241 #[test]
242 fn complete_empty_prefix_yields_all_in_lex_order() {
243 let mut idx: CommandIndex<u8> = CommandIndex::new();
244 idx.bind(":wq", 2);
245 idx.bind(":w", 1);
246 idx.bind(":q", 3);
247 let names: Vec<&str> = idx.complete("").map(|(n, _)| n).collect();
248 assert_eq!(names, vec![":q", ":w", ":wq"]);
249 }
250
251 #[test]
252 fn complete_prefix_returns_matching_subset_in_lex_order() {
253 let mut idx: CommandIndex<u8> = CommandIndex::new();
254 idx.bind(":w", 1);
255 idx.bind(":wq", 2);
256 idx.bind(":wqa", 3);
257 idx.bind(":q", 4);
258
259 let names: Vec<&str> = idx.complete(":w").map(|(n, _)| n).collect();
260 assert_eq!(names, vec![":w", ":wq", ":wqa"]);
261 }
262
263 #[test]
264 fn complete_exact_name_as_prefix_is_included() {
265 // ":w" is both an exact command and a prefix for ":wq"; both come back.
266 let mut idx: CommandIndex<u8> = CommandIndex::new();
267 idx.bind(":w", 1);
268 idx.bind(":wq", 2);
269
270 let result: Vec<(&str, &u8)> = idx.complete(":w").collect();
271 assert_eq!(result, vec![(":w", &1), (":wq", &2)]);
272 }
273
274 #[test]
275 fn complete_no_match_yields_empty() {
276 let mut idx: CommandIndex<u8> = CommandIndex::new();
277 idx.bind(":w", 1);
278 assert_eq!(idx.complete(":x").count(), 0);
279 }
280
281 #[test]
282 fn complete_yields_name_and_action_pairs() {
283 let mut idx: CommandIndex<u8> = CommandIndex::new();
284 idx.bind(":w", 1);
285 idx.bind(":wq", 2);
286
287 let result: Vec<(&str, &u8)> = idx.complete(":w").collect();
288 assert_eq!(result, [(":w", &1), (":wq", &2)]);
289 }
290
291 #[test]
292 fn complete_utf8_names_lex_order() {
293 // Non-ASCII names: byte order determines lexicographic sort.
294 let mut idx: CommandIndex<u8> = CommandIndex::new();
295 idx.bind("écrire", 1);
296 idx.bind("éditer", 2);
297 idx.bind("effacer", 3);
298
299 // "éc" < "éd" < "ef" in byte order (all start with 0xc3 0xa9 = 'é', then diverge)
300 // actually "écrire" starts 0xc3,0xa9,0x63 and "éditer" starts 0xc3,0xa9,0x64 and
301 // "effacer" starts 0x65,0x66 — "effacer" is ASCII 'e' which is 0x65 < 0xc3.
302 // So lex order: "effacer" < "écrire" < "éditer".
303 let all: Vec<&str> = idx.iter().map(|(n, _)| n).collect();
304 assert_eq!(all, vec!["effacer", "écrire", "éditer"]);
305
306 // Complete on "é" (bytes 0xc3 0xa9) matches "écrire" and "éditer".
307 let completions: Vec<&str> = idx.complete("é").map(|(n, _)| n).collect();
308 assert_eq!(completions, vec!["écrire", "éditer"]);
309 }
310
311 // ---- iter ----------------------------------------------------------
312
313 #[test]
314 fn iter_lexicographic_order() {
315 let mut idx: CommandIndex<u8> = CommandIndex::new();
316 idx.bind(":wq", 2);
317 idx.bind(":q", 3);
318 idx.bind(":w", 1);
319 let all: Vec<(&str, &u8)> = idx.iter().collect();
320 assert_eq!(all, vec![(":q", &3), (":w", &1), (":wq", &2)]);
321 }
322
323 #[test]
324 fn iter_empty_index_is_empty() {
325 let idx: CommandIndex<u8> = CommandIndex::new();
326 assert_eq!(idx.iter().count(), 0);
327 }
328
329 // ---- len / is_empty ------------------------------------------------
330
331 #[test]
332 fn len_and_is_empty() {
333 let mut idx: CommandIndex<u8> = CommandIndex::new();
334 assert!(idx.is_empty());
335 assert_eq!(idx.len(), 0);
336 idx.bind(":w", 1);
337 assert!(!idx.is_empty());
338 assert_eq!(idx.len(), 1);
339 }
340
341 // ---- next_prefix helper --------------------------------------------
342
343 #[test]
344 fn next_prefix_increments_last_byte() {
345 assert_eq!(next_prefix(":w"), Some(":x".to_string()));
346 }
347
348 #[test]
349 fn next_prefix_empty_returns_none() {
350 assert_eq!(next_prefix(""), None);
351 }
352
353 #[test]
354 fn next_prefix_all_max_bytes_returns_none() {
355 // All bytes are u8::MAX (0xFF): no byte can be incremented, so the
356 // function returns None.
357 // U+FFFF encodes as [0xEF, 0xBF, 0xBF] in UTF-8, all of which are < u8::MAX,
358 // so that would not reach the None path. Use a single DEL byte (0x7f) instead:
359 // 0x7f + 1 = 0x80, which is not a valid UTF-8 leading byte.
360 let raw_ff = std::str::from_utf8(&[0x7f]).unwrap(); // 0x7f is valid ASCII
361 // 0x7f + 1 = 0x80, which is not a valid UTF-8 leading byte → from_utf8 fails
362 // → next_prefix returns None (safe fallback to Unbounded range).
363 assert_eq!(next_prefix(raw_ff), None);
364 }
365
366 /// When `next_prefix` returns `None` (non-UTF-8 boundary), `complete` falls
367 /// back to an unbounded upper range and returns a superset — no panic, no
368 /// missing results.
369 #[test]
370 fn next_prefix_non_utf8_boundary_complete_returns_superset() {
371 let mut idx: CommandIndex<u8> = CommandIndex::new();
372 // Bind names that start with DEL (0x7f), which is a valid single-byte
373 // ASCII character (the highest ASCII byte). Its next_prefix increments to
374 // 0x80, which is invalid UTF-8 → fallback to Unbounded.
375 let del_a = std::str::from_utf8(&[0x7f, b'a']).unwrap(); // "\x7fa"
376 let del_b = std::str::from_utf8(&[0x7f, b'b']).unwrap(); // "\x7fb"
377 let unrelated = "zzzz";
378 idx.bind(del_a, 1);
379 idx.bind(del_b, 2);
380 idx.bind(unrelated, 3);
381
382 // Prefix is a single DEL byte: next_prefix("\x7f") → None (0x7f+1 = 0x80,
383 // not valid UTF-8) → Unbounded upper → complete returns all entries ≥ "\x7f",
384 // which includes both "\x7fa" and "\x7fb" but also "zzzz" (superset is ok).
385 let prefix = std::str::from_utf8(&[0x7f]).unwrap();
386 let results: Vec<(&str, &u8)> = idx.complete(prefix).collect();
387
388 // Must include both del_a and del_b (no results dropped).
389 assert!(
390 results.iter().any(|(n, _)| *n == del_a),
391 "del_a must appear in superset: {results:?}"
392 );
393 assert!(
394 results.iter().any(|(n, _)| *n == del_b),
395 "del_b must appear in superset: {results:?}"
396 );
397 // Must not panic: the function completed without error.
398 }
399}