command-trie
A compact radix trie for command-line tab completion and longest-prefix matching. Designed for the build-once / query-many lifecycle of a line editor or REPL dispatcher.
- No dependencies.
#![warn(missing_docs)].Send + Sync. - Frozen trie lives in four contiguous heap allocations (nodes, label bytes, child-id lists, and a parallel one-byte-per-edge index for the lookup hot path) for cache-friendly traversal.
u16-indexed slabs cap the trie at ~32,767 entries — plenty for any realistic CLI — in exchange for a tighter memory layout.- Lookups are non-allocating; only methods that materialize keys allocate.
- Arbitrary UTF-8 keys: any
&stris accepted; radix splits are char-aligned so labels are always valid UTF-8 (no normalization, no case folding, no grapheme awareness).
Install
[]
= "1"
Quick start
The crate is shaped around a build-once / query-many workload:
- Construct a [
CommandTrieBuilder],insertyour commands. - Call [
CommandTrieBuilder::build] to freeze it into a compact, read-only [CommandTrie]. - Query the [
CommandTrie] repeatedly for exact lookup, longest-prefix match against an input line, and completion enumeration.
use CommandTrieBuilder;
let mut b = new;
b.insert;
b.insert;
b.insert;
let trie = b.build;
// Exact lookup.
assert_eq!;
// Dispatch: split a typed line at the longest known command.
assert_eq!;
// Tab completion: longest unambiguous extension of a typed prefix.
assert_eq!;
assert_eq!;
assert_eq!;
Fish-style TAB handling
subtrie returns a view over every entry sharing a prefix and tells the
line editor exactly what to splice into the buffer:
# use CommandTrieBuilder;
# let mut b = new;
# b.insert; b.insert; b.insert;
# let trie = b.build;
let sub = trie.subtrie.unwrap;
assert_eq!; // splice this on TAB
assert!; // and stop prompting
let sub = trie.subtrie.unwrap;
assert_eq!; // already at a branch point
assert!; // show the menu instead
UTF-8
Keys are arbitrary &str. The builder splits edge labels on char
boundaries, so every label is itself valid UTF-8 and iteration order is
byte-lexicographic (which equals code-point order for valid UTF-8).
The crate is deliberately byte-oriented beyond that: there is no
Unicode normalization (café and cafe\u{0301} are different keys),
no case folding, and no grapheme-cluster awareness. Size and
length limits — the u16-indexed offsets and the ~32,767 entry cap —
are measured in bytes, not chars, so multi-byte keys consume more of the
budget than ASCII ones.
The empty key "" is legal; it associates a value with the trie root
and behaves like any other entry under iteration and longest-prefix match.
API surface
Build phase — [CommandTrieBuilder<T>]:
insert(&str, T) -> Option<T>remove(&str) -> Option<T>(re-merges passthroughs)get/contains/len/is_empty/clearFromIterator<(K, T)>andExtend<(K, T)>forK: AsRef<str>build() -> CommandTrie<T>
Query phase — [CommandTrie<T>]:
get/contains/len/is_emptylongest_prefix_match(&str) -> Option<(&str, &T)>contains_prefix(&str) -> boolcompletion_prefix(&str) -> Option<String>(longest unambiguous extension)count_completions(&str) -> usizecompletions(&str) -> Vec<(String, &T)>andfor_each_completion(&str, FnMut)subtrie(&str) -> Option<SubTrie<'_, T>>iter() / for_each(FnMut)— alphabetical traversal
SubTrie<'_, T> adds common_prefix, extension, is_unique, value,
unique_value, len, iter, for_each.
See examples/tab_completion.rs
and examples/command_dispatch.rs.
Performance vs radix_trie
Measured on a 64-entry git-style command corpus (Apple Silicon, release,
criterion 0.8). Full numbers in
benches/comparison.rs.
Lookups and prefix queries are several times faster than radix_trie:
| operation | command-trie | radix_trie |
|---|---|---|
get (short hit, "rm") |
12.5 ns | 35.9 ns |
get (long hit, "verify-commit") |
18.9 ns | 74.2 ns |
get (miss) |
3.6 ns | 29.8 ns |
count_completions("co") |
21.6 ns | 158.4 ns |
count_completions("r") |
30.3 ns | 424.3 ns |
Heap footprint (measured with dhat, see
examples/alloc_profile.rs):
| library | resident heap | allocations |
|---|---|---|
| command-trie | 2.7 KB | 4 |
radix_trie |
25.2 KB | 166 |
The four allocations are the four frozen slabs; radix_trie does one
heap allocation per node. At ~2.9× the raw key+value payload, the frozen
trie is close to the floor for a non-compressing trie.
At scale, the u16-indexed slabs keep the per-entry cost low: a corpus of
~32,000 realistic command-style keys (avg ~16 bytes, u32 values) lands at
~711 KB resident — about 1.16× the raw key+value payload — still in four
allocations.
This crate trades flexibility for these wins: the trie is immutable
after build() and the public API is read-mostly.
If you need a general-purpose mutable trie, prefer radix_trie.
Scope
This crate is intentionally read-mostly. The frozen trie exposes no
mutation surface; the builder exposes only insert / remove / clear.
There is no iter_mut / get_mut / keys / values.
The frozen trie's internal offsets are u16, which caps the trie at
roughly 32,767 entries (worst case: 2N + 1 nodes per N entries,
bounded by u16::MAX = 65,535). CommandTrieBuilder::build panics if a
larger trie is constructed. This is well above the size of any plausible
command set and buys a noticeably tighter memory layout.
License
BSD-2-Clause. See LICENSE.