Character-level text buffer for incremental typing with efficient insertion and deletion (backspace) operations.
DraftBuffer is a character-level text buffer optimized for incremental typing scenarios. It supports:
\mathcal{O}(1)$ character insertion (append)\mathcal{O}(1)$ character deletion (backspace)char, not bytes)Problem: String operations in Rust are byte-oriented, but users think in characters.
let text = "café";
assert_eq!(text.len(), 5); // 5 bytes (é is 2 bytes)
assert_eq!(text.chars().count(), 4); // 4 characters
// Removing last byte corrupts UTF-8:
let bad = &text[..4]; // "caf" + invalid byte ❌
// Character-level is correct:
let chars: Vec<char> = text.chars().collect();
let correct = &chars[..3]; // ['c', 'a', 'f'] ✓
Solution: DraftBuffer uses VecDeque<char> for correct character semantics.
| Use Case | Benefit |
|---|---|
| Code Editor | Character-by-character identifier entry |
| Autocomplete | Build query string incrementally |
| REPL | Track partial expressions as user types |
| Undo/Redo | Checkpoint buffer state at word boundaries |
pub struct DraftBuffer {
/// Character storage (VecDeque for efficient push/pop on both ends)
chars: VecDeque<char>,
}
Why VecDeque<char> instead of String?
| Consideration | String | VecDeque<char> (chosen) |
|---|---|---|
| Character-level delete | Requires byte boundary checks | ✓ $\mathcal{O}(1)$ pop_back() |
| Unicode correctness | Easy to break (byte-level) | ✓ Guaranteed correct |
| Memory efficiency | ✓ Compact (UTF-8) | ✗ 4 bytes per char |
| Insertion/deletion | Reallocation for shrink | ✓ $\mathcal{O}(1)$ amortized |
| String conversion | ✓ Zero-copy | ✗ $\mathcal{O}(n)$ collect |
Trade-off Decision: Correctness and $\mathcal{O}(1)$ operations outweigh memory overhead for typical identifier lengths (5-20 chars = 20-80 bytes vs 5-20 bytes).
VecDeque<char> layout (simplified):
┌──────────────────────────────────┐
│ capacity: 16 │ 8 bytes (usize)
│ start: 0 │ 8 bytes (usize)
│ length: 5 │ 8 bytes (usize)
├──────────────────────────────────┤
│ buffer: ['h', 'e', 'l', 'l', 'o']│ 64 bytes (16 × 4)
│ [_, _, _, _, _, ...] │
└──────────────────────────────────┘
Total: 24 bytes (header) + 64 bytes (capacity) = 88 bytes
Growth Strategy: VecDeque doubles capacity when full (2x, 4x, 8x, ...).
// Empty buffer
pub fn new() -> Self;
// With initial capacity (avoids reallocation)
pub fn with_capacity(capacity: usize) -> Self;
// From existing string
pub fn from_string(s: &str) -> Self;
Complexity: All $\mathcal{O}(n)$ for from_string (must iterate characters), $\mathcal{O}(1)$ otherwise.
Example:
use liblevenshtein::contextual::DraftBuffer;
// Empty buffer
let mut buffer = DraftBuffer::new();
// Pre-allocated (typical identifier length)
let mut buffer2 = DraftBuffer::with_capacity(32);
// From existing string
let mut buffer3 = DraftBuffer::from_string("hello");
assert_eq!(buffer3.as_str(), "hello");
pub fn insert(&mut self, ch: char);
Behavior: Appends character to end of buffer.
Complexity: $\mathcal{O}(1)$ amortized (may trigger reallocation).
Example:
let mut buffer = DraftBuffer::new();
buffer.insert('h');
buffer.insert('i');
assert_eq!(buffer.as_str(), "hi");
Multi-character insertion:
// Insert string character-by-character
for ch in "hello".chars() {
buffer.insert(ch);
}
assert_eq!(buffer.as_str(), "hello");
pub fn delete(&mut self) -> Option<char>;
Behavior: Removes and returns last character.
Returns:
Some(ch) if character was deletedNone if buffer was emptyComplexity: $\mathcal{O}(1)$ - no allocation, just decrements length.
Example:
let mut buffer = DraftBuffer::from_string("test");
assert_eq!(buffer.delete(), Some('t')); // "tes"
assert_eq!(buffer.delete(), Some('s')); // "te"
assert_eq!(buffer.as_str(), "te");
pub fn as_str(&self) -> String;
pub fn as_bytes(&self) -> Vec<u8>;
Complexity: $\mathcal{O}(n)$ - must collect characters into String.
Note: Not zero-copy due to character-level storage.
Example:
let buffer = DraftBuffer::from_string("test");
let s: String = buffer.as_str();
assert_eq!(s, "test");
let bytes: Vec<u8> = buffer.as_bytes();
assert_eq!(bytes, b"test");
pub fn truncate(&mut self, len: usize);
Behavior: Truncates buffer to len characters (no-op if len >= current length).
Complexity: $\mathcal{O}(k)$ where k = (current_len - len).
Use Case: Restore to checkpoint position.
Example:
let mut buffer = DraftBuffer::from_string("hello world");
buffer.truncate(5);
assert_eq!(buffer.as_str(), "hello");
pub fn clear(&mut self);
Behavior: Removes all characters (sets length to 0).
Complexity: $\mathcal{O}(1)$ - does not deallocate.
Example:
let mut buffer = DraftBuffer::from_string("test");
buffer.clear();
assert!(buffer.is_empty());
pub fn len(&self) -> usize;
pub fn is_empty(&self) -> bool;
Complexity: $\mathcal{O}(1)$.
Example:
let buffer = DraftBuffer::from_string("hello");
assert_eq!(buffer.len(), 5);
assert!(!buffer.is_empty());
Multi-Byte Characters:
| Character | UTF-8 Bytes | char Size |
|---|---|---|
| 'a' (ASCII) | 1 byte | 4 bytes |
| 'é' (Latin) | 2 bytes | 4 bytes |
| '世' (CJK) | 3 bytes | 4 bytes |
| '😀' (Emoji) | 4 bytes | 4 bytes |
Problem with Byte-Level Operations:
let mut s = String::from("café");
s.pop(); // Removes 1 byte, but é is 2 bytes
// Result: "caf�" (invalid UTF-8) ❌
Solution with DraftBuffer:
let mut buffer = DraftBuffer::from_string("café");
buffer.delete(); // Removes 1 character ('é')
assert_eq!(buffer.as_str(), "caf"); // Valid UTF-8 ✓
let mut buffer = DraftBuffer::new();
buffer.insert('😀'); // 4-byte UTF-8 sequence
buffer.insert('🚀'); // 4-byte UTF-8 sequence
assert_eq!(buffer.len(), 2); // 2 characters, not 8 bytes
assert_eq!(buffer.as_str(), "😀🚀");
buffer.delete(); // Remove '🚀'
assert_eq!(buffer.as_str(), "😀");
let mut buffer = DraftBuffer::from_string("世界");
assert_eq!(buffer.len(), 2); // 2 characters, not 6 bytes
buffer.delete(); // Remove '界'
assert_eq!(buffer.as_str(), "世");
let mut buffer = DraftBuffer::new();
buffer.insert('H'); // 1-byte
buffer.insert('é'); // 2-byte
buffer.insert('世'); // 3-byte
buffer.insert('😀'); // 4-byte
assert_eq!(buffer.len(), 4); // 4 characters
assert_eq!(buffer.as_str().len(), 10); // 10 bytes total
// All deletions work correctly
assert_eq!(buffer.delete(), Some('😀'));
assert_eq!(buffer.delete(), Some('世'));
assert_eq!(buffer.delete(), Some('é'));
assert_eq!(buffer.delete(), Some('H'));
Note: DraftBuffer operates on Unicode scalar values (char), not grapheme clusters.
Grapheme cluster = user-perceived character (may be multiple chars):
let flag = "🇺🇸"; // 2 chars: '🇺' + '🇸'
let buffer = DraftBuffer::from_string(flag);
assert_eq!(buffer.len(), 2); // 2 chars, 1 grapheme cluster
// Deleting once removes one char (breaks the flag)
buffer.delete();
assert_eq!(buffer.as_str(), "🇺"); // Incomplete flag
For grapheme-aware operations, use the unicode-segmentation crate with a custom buffer implementation.
use liblevenshtein::contextual::DraftBuffer;
let mut buffer = DraftBuffer::new();
// User types "function"
for ch in "function".chars() {
buffer.insert(ch);
println!("Draft: '{}'", buffer.as_str());
}
// Output:
// Draft: 'f'
// Draft: 'fu'
// Draft: 'fun'
// Draft: 'func'
// Draft: 'funct'
// Draft: 'functi'
// Draft: 'functio'
// Draft: 'function'
assert_eq!(buffer.as_str(), "function");
let mut buffer = DraftBuffer::from_string("hello");
// User hits backspace 2 times
buffer.delete(); // Remove 'o'
buffer.delete(); // Remove 'l'
assert_eq!(buffer.as_str(), "hel");
assert_eq!(buffer.len(), 3);
let mut buffer = DraftBuffer::new();
// User types "functoin" (typo)
for ch in "functoin".chars() {
buffer.insert(ch);
}
// User notices typo, backspaces 3 characters
buffer.delete(); // Remove 'n'
buffer.delete(); // Remove 'i'
buffer.delete(); // Remove 'o'
// User types correct ending
buffer.insert('i');
buffer.insert('o');
buffer.insert('n');
assert_eq!(buffer.as_str(), "function");
let mut buffer = DraftBuffer::new();
// User types "prin"
for ch in "prin".chars() {
buffer.insert(ch);
}
// Autocomplete suggests "print", user accepts
// Clear and insert full word
buffer.clear();
for ch in "print".chars() {
buffer.insert(ch);
}
assert_eq!(buffer.as_str(), "print");
let mut buffer = DraftBuffer::from_string("hello");
// Save checkpoint (current length)
let checkpoint = buffer.len();
// User types more
buffer.insert(' ');
buffer.insert('w');
buffer.insert('o');
buffer.insert('r');
buffer.insert('l');
buffer.insert('d');
assert_eq!(buffer.as_str(), "hello world");
// Undo to checkpoint
buffer.truncate(checkpoint);
assert_eq!(buffer.as_str(), "hello");
let mut buffer = DraftBuffer::new();
// User types emoji
buffer.insert('H');
buffer.insert('i');
buffer.insert(' ');
buffer.insert('😀');
assert_eq!(buffer.len(), 4);
assert_eq!(buffer.as_str(), "Hi 😀");
// Backspace removes emoji
buffer.delete();
assert_eq!(buffer.as_str(), "Hi ");
// From string
let buffer1 = DraftBuffer::from_string("test");
let buffer2 = DraftBuffer::from(String::from("test"));
let buffer3: DraftBuffer = "test".into();
// To string
let buffer = DraftBuffer::from_string("hello");
let s: String = buffer.as_str();
assert_eq!(s, "hello");
// Display trait
println!("{}", buffer); // Prints: hello
| Operation | Time Complexity | Allocates? | Notes |
|---|---|---|---|
new() | $\mathcal{O}(1)$ | Yes (header) | Minimal allocation |
with_capacity(n) | $\mathcal{O}(1)$ | Yes (n chars) | Pre-allocates buffer |
from_string(s) | $\mathcal{O}(n)$ | Yes | Must iterate characters |
insert(ch) | $\mathcal{O}(1)$ amortized | Rare | 2x growth when full |
delete() | $\mathcal{O}(1)$ | No | Just decrements length |
as_str() | $\mathcal{O}(n)$ | Yes | Collects into String |
clear() | $\mathcal{O}(1)$ | No | Sets length to 0 |
truncate(len) | $\mathcal{O}(k)$ | No | k = chars removed |
len() | $\mathcal{O}(1)$ | No | Field access |
is_empty() | $\mathcal{O}(1)$ | No | Length check |
Test Environment: Intel Xeon E5-2699 v3 @ 2.30GHz, Rust 1.75, release build
| Operation | Time (ns) | Throughput |
|---|---|---|
insert('a') | ~10 | 100M ops/sec |
insert('😀') | ~10 | 100M ops/sec |
delete() | ~8 | 125M ops/sec |
as_str() (10 chars) | ~35 | 29M ops/sec |
as_str() (100 chars) | ~280 | 3.6M ops/sec |
clear() | ~3 | 333M ops/sec |
truncate(n) | ~5 + 2n | Varies |
Key Observations:
as_str() linear in length (expected for collection)Per-Buffer Overhead:
Growth Pattern:
| Insertions | Capacity | Memory (bytes) | Waste |
|---|---|---|---|
| 1 | 4 | 24 + 16 = 40 | 12 bytes |
| 5 | 8 | 24 + 32 = 56 | 12 bytes |
| 9 | 16 | 24 + 64 = 88 | 28 bytes |
| 17 | 32 | 24 + 128 = 152 | 60 bytes |
Typical Identifier (10 chars):
Conclusion: Minimal memory footprint for typical use (<100 bytes per buffer).
Comparison:
| Feature | Vec<char> | VecDeque<char> (chosen) |
|---|---|---|
| Push back | ✓ $\mathcal{O}(1)$ | ✓ $\mathcal{O}(1)$ |
| Pop back | ✓ $\mathcal{O}(1)$ | ✓ $\mathcal{O}(1)$ |
| Push front | ✗ $\mathcal{O}(n)$ | ✓ $\mathcal{O}(1)$ |
| Pop front | ✗ $\mathcal{O}(n)$ | ✓ $\mathcal{O}(1)$ |
| Indexing | ✓ $\mathcal{O}(1)$ | ✓ $\mathcal{O}(1)$ |
| Contiguous memory | ✓ Yes | ✗ No (ring buffer) |
| Memory overhead | Lower | Higher (~24 bytes) |
Decision: VecDeque chosen for:
\mathcal{O}(1)$ operations on both endsCurrent Usage: Only uses push_back()/pop_back(), but VecDeque provides flexibility for future enhancements (e.g., cursor-based editing).
VecDeque Growth:
truncate() doesn't deallocate)Example:
let mut buffer = DraftBuffer::new(); // capacity = 4
for i in 0..5 {
buffer.insert('a'); // Insert #5 triggers 2x growth to 8
}
// Current state:
// capacity: 8
// length: 5
// memory: 24 + (4 × 8) = 56 bytes
Memory Considerations:
Alternative Considered: Store as UTF-8 String, convert to Vec<char> on demand.
Rejected Because:
| Operation | UTF-8 String | Vec (chosen) |
|---|---|---|
| Insert | ✓ $\mathcal{O}(1)$ append | ✓ $\mathcal{O}(1)$ push |
| Delete | ✗ $\mathcal{O}(n)$ find boundary | ✓ $\mathcal{O}(1)$ pop |
| Length | ✗ $\mathcal{O}(n)$ count chars | ✓ $\mathcal{O}(1)$ field |
| To String | ✓ $\mathcal{O}(1)$ clone | ✗ $\mathcal{O}(n)$ collect |
Decision: Optimize for insertion/deletion (hot path) at expense of string conversion (cold path).
DraftBuffer is not Sync or Send by default due to VecDeque.
However, in the engine, buffers are wrapped in Arc<Mutex<HashMap<ContextId, DraftBuffer>>>, providing:
Mutex (no data races)Arc (multiple thread references)Included Tests (in src/contextual/draft_buffer.rs):
test_new() - Empty buffer creationtest_insert() - Character insertiontest_delete() - Character deletiontest_delete_empty() - Delete from empty buffertest_from_str() - String conversiontest_clear() - Buffer clearingtest_truncate() - Truncationtest_truncate_longer() - Truncate with len > currenttest_unicode() - Emoji and CJK characterstest_as_bytes() - UTF-8 byte conversiontest_display() - Display traittest_from_string() - From traittest_with_capacity() - Capacity pre-allocationtest_incremental_typing() - Realistic typing scenarioTest Coverage: ~100% of public API
Potential Properties (for future proptest integration):
// Property 1: Insert then delete returns to original state
forall buffer B, char C:
let orig = B.as_str();
B.insert(C);
B.delete();
assert_eq!(B.as_str(), orig);
// Property 2: Length is sum of insertions minus deletions
forall operations OPS:
let inserts = count(OPS, Insert);
let deletes = count(OPS, Delete);
assert_eq!(buffer.len(), inserts - deletes);
// Property 3: Truncate is idempotent
forall buffer B, length N:
B.truncate(N);
B.truncate(N);
assert_eq!(B.len(), min(original_len, N));
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |