Lightweight undo/redo system for draft buffer state management in editors.
The checkpoint system provides editor undo/redo functionality by saving lightweight snapshots of draft buffer state. Unlike traditional undo systems that store full buffer copies, checkpoints store only the buffer length (position), making them extremely memory-efficient.
Traditional Undo: Store full buffer copies
Checkpoint 1: "hello" (5 bytes + overhead)
Checkpoint 2: "hello world" (11 bytes + overhead)
Total: ~16 bytes + 2× overhead
Our Approach: Store only lengths
Checkpoint 1: 5 (8 bytes)
Checkpoint 2: 11 (8 bytes)
Total: 16 bytes (buffer content stored once)
Memory Savings: For 50 checkpoints averaging 20 chars:
Restoration: Truncate buffer to checkpoint position ($\mathcal{O}(1)$ operation).
| Component | Purpose | Size |
|---|---|---|
| Checkpoint | Single snapshot (buffer length) | 8 bytes |
| CheckpointStack | Manages undo history | 24 bytes + (8 bytes × count) |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Checkpoint {
position: usize, // Buffer length when checkpoint was created
}
Properties:
Memory Layout:
Checkpoint layout (64-bit system):
┌──────────────────┐
│ position: usize │ 8 bytes
└──────────────────┘
#[derive(Debug, Clone)]
pub struct CheckpointStack {
checkpoints: Vec<Checkpoint>, // Most recent at end
}
Properties:
\mathcal{O}(1)$ push/popMemory Layout:
CheckpointStack layout:
┌──────────────────────────────┐
│ Vec header │ 24 bytes (ptr, len, capacity)
├──────────────────────────────┤
│ Checkpoint 0 (position) │ 8 bytes
│ Checkpoint 1 (position) │ 8 bytes
│ Checkpoint 2 (position) │ 8 bytes
│ ... │ ...
└──────────────────────────────┘
Total: 24 + (8 × count) bytes
// From buffer
pub fn from_buffer(buffer: &DraftBuffer) -> Checkpoint;
// At specific position
pub fn at(position: usize) -> Checkpoint;
Complexity: $\mathcal{O}(1)$ - just reads buffer length.
Example:
use liblevenshtein::contextual::{DraftBuffer, Checkpoint};
let buffer = DraftBuffer::from_string("hello");
let checkpoint = Checkpoint::from_buffer(&buffer);
assert_eq!(checkpoint.position(), 5);
pub fn restore(&self, buffer: &mut DraftBuffer);
Behavior: Truncates buffer to checkpoint position.
Complexity: $\mathcal{O}(k)$ where k = (current_len - checkpoint_position).
Example:
let mut buffer = DraftBuffer::from_string("hello world");
let checkpoint = Checkpoint::at(5);
checkpoint.restore(&mut buffer);
assert_eq!(buffer.as_str(), "hello");
// Construction
pub fn new() -> CheckpointStack;
pub fn with_capacity(capacity: usize) -> CheckpointStack;
// Push operations
pub fn push(&mut self, checkpoint: Checkpoint);
pub fn push_from_buffer(&mut self, buffer: &DraftBuffer);
// Pop operation
pub fn pop(&mut self) -> Option<Checkpoint>;
// Peek (non-destructive)
pub fn peek(&self) -> Option<&Checkpoint>;
// Utility
pub fn len(&self) -> usize;
pub fn is_empty(&self) -> bool;
pub fn clear(&mut self);
All operations: $\mathcal{O}(1)$ except clear() which is $\mathcal{O}(n)$.
Initial State: ""
↓ insert('h')
State 1: "h" → checkpoint(1)
↓ insert('i')
State 2: "hi" → checkpoint(2)
↓ undo()
Restored: "h" (truncate to checkpoint 1)
Checkpoint Creation:
1. User types characters
2. At significant boundary (word end, pause), create checkpoint
3. Push checkpoint onto stack
Undo Operation:
1. Pop current state from stack (discard)
2. Peek at previous checkpoint
3. Restore buffer to checkpoint position
Example Flow:
Stack: []
Buffer: ""
// User types "h"
Buffer: "h"
checkpoint()
Stack: [Checkpoint(1)]
// User types "e"
Buffer: "he"
checkpoint()
Stack: [Checkpoint(1), Checkpoint(2)]
// User types "l"
Buffer: "hel"
checkpoint()
Stack: [Checkpoint(1), Checkpoint(2), Checkpoint(3)]
// User hits undo
pop() → Checkpoint(3) (discarded)
peek() → Checkpoint(2)
restore(Checkpoint(2))
Buffer: "he"
Stack: [Checkpoint(1), Checkpoint(2)]
To support redo, maintain two stacks:
struct UndoRedoManager {
undo_stack: CheckpointStack,
redo_stack: CheckpointStack,
}
Undo Operation:
1. Pop from undo_stack → current checkpoint
2. Push current checkpoint to redo_stack
3. Restore to previous checkpoint in undo_stack
Redo Operation:
1. Pop from redo_stack → checkpoint to restore
2. Push to undo_stack
3. Restore buffer to checkpoint
New Edit Operation (invalidates redo):
1. Clear redo_stack
2. Push new checkpoint to undo_stack
use liblevenshtein::contextual::{DraftBuffer, Checkpoint};
let mut buffer = DraftBuffer::new();
buffer.insert('h');
buffer.insert('e');
// Save checkpoint
let checkpoint = Checkpoint::from_buffer(&buffer);
assert_eq!(checkpoint.position(), 2);
// Continue typing
buffer.insert('l');
buffer.insert('l');
buffer.insert('o');
assert_eq!(buffer.as_str(), "hello");
// Restore to checkpoint
checkpoint.restore(&mut buffer);
assert_eq!(buffer.as_str(), "he");
use liblevenshtein::contextual::{DraftBuffer, CheckpointStack};
let mut buffer = DraftBuffer::new();
let mut stack = CheckpointStack::new();
// Start with empty checkpoint
stack.push_from_buffer(&buffer);
// Type "hello" with checkpoint after each character
for ch in "hello".chars() {
buffer.insert(ch);
stack.push_from_buffer(&buffer);
}
assert_eq!(buffer.as_str(), "hello");
assert_eq!(stack.len(), 6); // empty + 5 chars
// Undo twice
for _ in 0..2 {
stack.pop(); // Discard current
if let Some(checkpoint) = stack.peek() {
checkpoint.restore(&mut buffer);
}
}
assert_eq!(buffer.as_str(), "hel");
let mut buffer = DraftBuffer::new();
let mut stack = CheckpointStack::new();
// Checkpoint before starting
stack.push_from_buffer(&buffer);
// Type first word
for ch in "hello".chars() {
buffer.insert(ch);
}
stack.push_from_buffer(&buffer); // Checkpoint after word
// Type space
buffer.insert(' ');
// Type second word
for ch in "world".chars() {
buffer.insert(ch);
}
stack.push_from_buffer(&buffer); // Checkpoint after second word
assert_eq!(buffer.as_str(), "hello world");
// Undo once - removes "world"
stack.pop();
stack.peek().unwrap().restore(&mut buffer);
assert_eq!(buffer.as_str(), "hello");
// Undo again - removes "hello"
stack.pop();
stack.peek().unwrap().restore(&mut buffer);
assert_eq!(buffer.as_str(), "");
let mut buffer = DraftBuffer::new();
let mut stack = CheckpointStack::new();
// Function to checkpoint at word boundaries
fn should_checkpoint(ch: char, prev_ch: Option<char>) -> bool {
// Checkpoint after space (word boundary)
prev_ch.map_or(false, |p| p == ' ')
}
stack.push_from_buffer(&buffer); // Initial checkpoint
let text = "the quick brown fox";
let mut prev = None;
for ch in text.chars() {
if should_checkpoint(ch, prev) {
stack.push_from_buffer(&buffer);
}
buffer.insert(ch);
prev = Some(ch);
}
stack.push_from_buffer(&buffer); // Final checkpoint
// Now stack contains checkpoints at word boundaries
// ["", "the ", "the quick ", "the quick brown ", "the quick brown fox"]
let mut buffer = DraftBuffer::from_string("initial state");
let mut stack = CheckpointStack::new();
// Save before risky operation
stack.push_from_buffer(&buffer);
// Try operation
buffer.clear();
for ch in "new state".chars() {
buffer.insert(ch);
}
// Operation succeeded, keep changes
// (don't restore checkpoint)
// Later, undo if needed
if user_wants_undo {
stack.peek().unwrap().restore(&mut buffer);
// Back to "initial state"
}
let mut buffer = DraftBuffer::new();
let mut stack = CheckpointStack::new();
// Build undo history
let words = vec!["one", "two", "three", "four"];
stack.push_from_buffer(&buffer); // Empty
for word in &words {
for ch in word.chars() {
buffer.insert(ch);
}
buffer.insert(' ');
stack.push_from_buffer(&buffer);
}
assert_eq!(buffer.as_str(), "one two three four ");
// Undo to "one two three "
stack.pop();
stack.peek().unwrap().restore(&mut buffer);
assert_eq!(buffer.as_str(), "one two three ");
// Undo to "one two "
stack.pop();
stack.peek().unwrap().restore(&mut buffer);
assert_eq!(buffer.as_str(), "one two ");
// Undo to "one "
stack.pop();
stack.peek().unwrap().restore(&mut buffer);
assert_eq!(buffer.as_str(), "one ");
struct EditorState {
buffer: DraftBuffer,
undo_stack: CheckpointStack,
redo_stack: CheckpointStack,
}
impl EditorState {
fn new() -> Self {
Self {
buffer: DraftBuffer::new(),
undo_stack: CheckpointStack::new(),
redo_stack: CheckpointStack::new(),
}
}
fn checkpoint(&mut self) {
self.undo_stack.push_from_buffer(&self.buffer);
self.redo_stack.clear(); // New edit invalidates redo
}
fn undo(&mut self) -> bool {
if self.undo_stack.len() > 1 {
// Move current state to redo stack
let current = self.undo_stack.pop().unwrap();
self.redo_stack.push(current);
// Restore to previous state
if let Some(prev) = self.undo_stack.peek() {
prev.restore(&mut self.buffer);
return true;
}
}
false
}
fn redo(&mut self) -> bool {
if let Some(checkpoint) = self.redo_stack.pop() {
checkpoint.restore(&mut self.buffer);
self.undo_stack.push(checkpoint);
return true;
}
false
}
}
// Usage
let mut editor = EditorState::new();
editor.checkpoint(); // Empty
editor.buffer.insert('h');
editor.buffer.insert('i');
editor.checkpoint(); // "hi"
editor.undo(); // Back to ""
assert_eq!(editor.buffer.as_str(), "");
editor.redo(); // Forward to "hi"
assert_eq!(editor.buffer.as_str(), "hi");
| Operation | Time | Space | Notes |
|---|---|---|---|
Checkpoint::from_buffer() | $\mathcal{O}(1)$ | $\mathcal{O}(1)$ | Read buffer length |
Checkpoint::at() | $\mathcal{O}(1)$ | $\mathcal{O}(1)$ | Direct creation |
Checkpoint::restore() | $\mathcal{O}(k)$ | $\mathcal{O}(1)$ | k = chars removed |
CheckpointStack::new() | $\mathcal{O}(1)$ | $\mathcal{O}(1)$ | Empty Vec |
CheckpointStack::push() | $\mathcal{O}(1)$ amortized | $\mathcal{O}(1)$ | May trigger reallocation |
CheckpointStack::pop() | $\mathcal{O}(1)$ | $\mathcal{O}(1)$ | Decrements length |
CheckpointStack::peek() | $\mathcal{O}(1)$ | $\mathcal{O}(1)$ | Read last element |
CheckpointStack::clear() | $\mathcal{O}(1)$ | $\mathcal{O}(1)$ | Sets length to 0 |
Test Environment: Intel Xeon E5-2699 v3 @ 2.30GHz, Rust 1.75, release build
| Operation | Time (ns) | Throughput |
|---|---|---|
Checkpoint::from_buffer() | ~8 | 125M ops/sec |
Checkpoint::restore(10 chars) | ~25 | 40M ops/sec |
CheckpointStack::push() | ~12 | 83M ops/sec |
CheckpointStack::pop() | ~8 | 125M ops/sec |
CheckpointStack::peek() | ~5 | 200M ops/sec |
Key Observations:
Per-Checkpoint Overhead: 8 bytes (just a usize)
Checkpoint Stack:
| # Checkpoints | Memory (bytes) | Memory per Checkpoint |
|---|---|---|
| 10 | 24 + 80 = 104 | ~10 bytes |
| 50 | 24 + 400 = 424 | ~8.5 bytes |
| 100 | 24 + 800 = 824 | ~8.2 bytes |
Comparison with Full Buffer Copies:
| Approach | 50 Checkpoints (avg 20 chars) | Savings |
|---|---|---|
| Full copies | ~1,000 bytes | 0% |
| Position-only (ours) | ~400 bytes | 60% |
Conclusion: Extremely memory-efficient for typical undo histories.
Alternative Considered: Store full buffer snapshot per checkpoint.
// Alternative (rejected)
struct Checkpoint {
content: String, // Full copy!
}
Problems:
\mathcal{O}(\text{NM})$ memory\mathcal{O}(M)$ per checkpoint creationOur Approach: Store position, rely on buffer truncation.
// Current (chosen)
struct Checkpoint {
position: usize, // 8 bytes
}
Benefits:
\mathcal{O}(N)$ for N checkpoints (independent of buffer size)\mathcal{O}(1)$ per checkpoint\mathcal{O}(k)$ where k = chars removed (typically small)Trade-off: Cannot restore if buffer is mutated after checkpoint (e.g., insert in middle). Our use case (append-only typing) fits perfectly.
Comparison:
| Feature | Vec<Checkpoint> (chosen) | VecDeque<Checkpoint> |
|---|---|---|
| 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)$ |
| Memory | Lower | Higher (~24 bytes) |
| Contiguous | ✓ Yes | ✗ No |
Decision: Vec chosen because:
Strategy Options:
| Strategy | Checkpoints | Memory | Undo Granularity |
|---|---|---|---|
| Per-character | High | High | Single char |
| Per-word | Medium | Medium | Whole word |
| Per-line | Low | Low | Whole line |
| On-demand | Variable | Variable | User-controlled |
Recommendation: On-demand or per-word for best balance.
Example: Checkpoint on:
Checkpoint and CheckpointStack are not thread-safe by themselves.
In the engine, stacks are wrapped in Arc<Mutex<HashMap<ContextId, CheckpointStack>>>:
checkpoints: Arc<Mutex<HashMap<ContextId, CheckpointStack>>>,
Locking Strategy:
\mathcal{O}(1)$)\mathcal{O}(1)$ pop + $\mathcal{O}(k)$ restore)Included Tests (in src/contextual/checkpoint.rs):
test_checkpoint_from_buffer() - Checkpoint creationtest_checkpoint_at() - Direct position creationtest_checkpoint_restore() - Buffer restorationtest_checkpoint_stack_new() - Stack initializationtest_checkpoint_stack_push_pop() - Push/pop operationstest_checkpoint_stack_peek() - Non-destructive peektest_checkpoint_stack_from_buffer() - Buffer checkpointtest_checkpoint_stack_clear() - Stack clearingtest_undo_workflow() - Complete undo scenariotest_multiple_checkpoints() - Multi-level undoTest Coverage: ~100% of public API
Potential Properties (for future proptest integration):
// Property 1: Checkpoint + restore is idempotent
forall buffer B:
let cp = Checkpoint::from_buffer(&B);
cp.restore(&mut B);
cp.restore(&mut B); // Second restore has no effect
assert_eq!(B.len(), cp.position());
// Property 2: Pop after push returns same checkpoint
forall checkpoint C:
let mut stack = CheckpointStack::new();
stack.push(C);
assert_eq!(stack.pop(), Some(C));
// Property 3: Stack length matches push/pop operations
forall operations OPS:
let pushes = count(OPS, Push);
let pops = count(OPS, Pop);
assert_eq!(stack.len(), pushes - pops);
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 |