Learn how to implement a rope data structure from scratch and use it to build a memory-efficient undo-redo system for text editors.

Building an Efficient Undo-Redo Stack with Rope Data Structure


When building text editors or any application that handles large amounts of mutable text, two critical features stand out: efficient text manipulation and reliable undo-redo functionality. While implementing these features with simple strings works for small documents, performance degrades quickly as document size grows. Enter the rope data structure - a clever solution that makes both text editing and undo-redo operations efficient even for massive documents.

The 10,000-Foot View

A rope is a binary tree where each leaf node contains a short string fragment, and internal nodes contain metadata about their subtrees. Instead of storing text as one continuous string, a rope breaks it into smaller chunks distributed across the tree. This seemingly simple change unlocks powerful capabilities:

  • O(log n) insertions and deletions instead of O(n) for strings
  • Immutable operations that naturally support undo-redo
  • Memory efficiency through structural sharing
  • Better cache locality for large documents

Think of it like this: editing a 1MB text file stored as a string requires copying the entire megabyte for each change. With ropes, you only copy the affected nodes - typically just a few kilobytes.

Interactive Demo

Each internal node stores the total length of its left subtree, making it easy to navigate to any position in the text efficiently. When you insert text, instead of shifting everything over, you split nodes and rearrange pointers.

Try the interactive demo below to see how rope data structures work in real-time. Type in the text editor to see the rope structure update, and use the undo/redo buttons to navigate through the history:

Building Our Rope Implementation

Let’s implement a rope data structure step by step. We’ll start with the basic types and node structure, then build up to a full implementation with undo-redo support.

Core Types and Node Structure

First, we need to define what a rope node looks like. Each node is either a leaf containing actual text, or an internal node with two children:

// Maximum size for leaf nodes - keeping them small improves cache performance
const LEAF_SIZE = 32;

// A rope node can be either a leaf with text or an internal node with children
type RopeNode = LeafNode | InternalNode;

interface LeafNode {
  type: 'leaf';
  text: string;
}

interface InternalNode {
  type: 'internal';
  left: RopeNode;
  right: RopeNode;
  leftLength: number;  // Total length of all text in left subtree
}

// Helper to get the total length of a node
function getLength(node: RopeNode): number {
  if (node.type === 'leaf') {
    return node.text.length;
  }
  return node.leftLength + getLength(node.right);
}

The leftLength field in internal nodes is crucial - it tells us exactly how many characters are in the left subtree without traversing it. This enables O(log n) position lookups.

Creating Ropes from Strings

Now let’s implement the basic constructor that builds a balanced rope from a string:

class Rope {
  private root: RopeNode;

  constructor(text: string) {
    this.root = this.buildRope(text);
  }

  private buildRope(text: string): RopeNode {
    // Base case: create a leaf node for small strings
    if (text.length <= LEAF_SIZE) {
      return { type: 'leaf', text };
    }

    // Split the text roughly in half
    const mid = Math.floor(text.length / 2);
    const left = this.buildRope(text.slice(0, mid));
    const right = this.buildRope(text.slice(mid));

    // Create an internal node
    return {
      type: 'internal',
      left,
      right,
      leftLength: getLength(left)
    };
  }

  // Convert rope back to string for display
  toString(): string {
    return this.nodeToString(this.root);
  }

  private nodeToString(node: RopeNode): string {
    if (node.type === 'leaf') {
      return node.text;
    }
    return this.nodeToString(node.left) + this.nodeToString(node.right);
  }

  get length(): number {
    return getLength(this.root);
  }
}

This creates a balanced rope by recursively splitting the input string. The resulting tree has O(log n) height, ensuring efficient operations.

Before we can insert or delete text, we need to efficiently find positions within the rope:

class Rope {
  // ... previous code ...

  // Get character at specific index
  charAt(index: number): string {
    if (index < 0 || index >= this.length) {
      throw new RangeError('Index out of bounds');
    }
    return this.charAtNode(this.root, index);
  }

  private charAtNode(node: RopeNode, index: number): string {
    if (node.type === 'leaf') {
      return node.text[index];
    }

    // If index is in left subtree, go left
    if (index < node.leftLength) {
      return this.charAtNode(node.left, index);
    }
    
    // Otherwise, go right and adjust index
    return this.charAtNode(node.right, index - node.leftLength);
  }

  // Get substring from rope
  substring(start: number, end: number): string {
    if (start < 0) start = 0;
    if (end > this.length) end = this.length;
    if (start >= end) return '';

    return this.substringNode(this.root, start, end);
  }

  private substringNode(node: RopeNode, start: number, end: number): string {
    if (node.type === 'leaf') {
      return node.text.substring(start, end);
    }

    const leftLen = node.leftLength;

    // Entire range is in left subtree
    if (end <= leftLen) {
      return this.substringNode(node.left, start, end);
    }

    // Entire range is in right subtree
    if (start >= leftLen) {
      return this.substringNode(node.right, start - leftLen, end - leftLen);
    }

    // Range spans both subtrees
    const leftPart = this.substringNode(node.left, start, leftLen);
    const rightPart = this.substringNode(node.right, 0, end - leftLen);
    return leftPart + rightPart;
  }
}

These navigation methods use the leftLength field to efficiently traverse to any position in O(log n) time.

Implementing Text Insertion

Here’s where ropes really shine. Instead of copying the entire string, we only need to modify the nodes along the path to the insertion point:

class Rope {
  // ... previous code ...

  // Insert text at position
  insert(index: number, text: string): Rope {
    if (index < 0 || index > this.length) {
      throw new RangeError('Index out of bounds');
    }

    const newRoot = this.insertNode(this.root, index, text);
    const newRope = Object.create(Rope.prototype);
    newRope.root = newRoot;
    return newRope;
  }

  private insertNode(node: RopeNode, index: number, text: string): RopeNode {
    // Inserting into a leaf node
    if (node.type === 'leaf') {
      const newText = node.text.slice(0, index) + text + node.text.slice(index);
      
      // If the result is still small enough, return a single leaf
      if (newText.length <= LEAF_SIZE) {
        return { type: 'leaf', text: newText };
      }

      // Otherwise, split into a balanced subtree
      return this.buildRope(newText);
    }

    // Inserting into internal node
    if (index <= node.leftLength) {
      // Insert into left subtree
      return {
        type: 'internal',
        left: this.insertNode(node.left, index, text),
        right: node.right,
        leftLength: node.leftLength + text.length
      };
    } else {
      // Insert into right subtree
      return {
        type: 'internal',
        left: node.left,
        right: this.insertNode(node.right, index - node.leftLength, text),
        leftLength: node.leftLength
      };
    }
  }
}

Notice that insert returns a new Rope instance - this immutability is key for implementing undo-redo. The original rope remains unchanged, and we can keep references to previous versions.

Implementing Text Deletion

Deletion follows a similar pattern to insertion:

class Rope {
  // ... previous code ...

  // Delete text from start to end
  delete(start: number, end: number): Rope {
    if (start < 0) start = 0;
    if (end > this.length) end = this.length;
    if (start >= end) return this;

    const newRoot = this.deleteNode(this.root, start, end);
    const newRope = Object.create(Rope.prototype);
    newRope.root = newRoot;
    return newRope;
  }

  private deleteNode(node: RopeNode, start: number, end: number): RopeNode | null {
    if (node.type === 'leaf') {
      const newText = node.text.slice(0, start) + node.text.slice(end);
      if (newText.length === 0) {
        return null; // Empty node, remove it
      }
      return { type: 'leaf', text: newText };
    }

    const leftLen = node.leftLength;

    // Deletion entirely in left subtree
    if (end <= leftLen) {
      const newLeft = this.deleteNode(node.left, start, end);
      if (!newLeft) return node.right; // Left subtree deleted entirely
      
      return {
        type: 'internal',
        left: newLeft,
        right: node.right,
        leftLength: getLength(newLeft)
      };
    }

    // Deletion entirely in right subtree
    if (start >= leftLen) {
      const newRight = this.deleteNode(node.right, start - leftLen, end - leftLen);
      if (!newRight) return node.left; // Right subtree deleted entirely
      
      return {
        type: 'internal',
        left: node.left,
        right: newRight,
        leftLength: leftLen
      };
    }

    // Deletion spans both subtrees
    const newLeft = this.deleteNode(node.left, start, leftLen);
    const newRight = this.deleteNode(node.right, 0, end - leftLen);

    if (!newLeft && !newRight) return null;
    if (!newLeft) return newRight;
    if (!newRight) return newLeft;

    return {
      type: 'internal',
      left: newLeft,
      right: newRight,
      leftLength: getLength(newLeft)
    };
  }
}

Building the Undo-Redo Stack

Now comes the beautiful part - because our rope operations are immutable, implementing undo-redo is remarkably simple:

interface Edit {
  type: 'insert' | 'delete';
  position: number;
  text?: string;
  length?: number;
  timestamp: number;
}

class TextEditor {
  private history: Rope[] = [];
  private currentIndex: number = -1;
  private edits: Edit[] = [];

  constructor(initialText: string = '') {
    const rope = new Rope(initialText);
    this.history.push(rope);
    this.currentIndex = 0;
  }

  get currentRope(): Rope {
    return this.history[this.currentIndex];
  }

  get text(): string {
    return this.currentRope.toString();
  }

  insert(position: number, text: string): void {
    const newRope = this.currentRope.insert(position, text);
    
    // Remove any redo history
    this.history = this.history.slice(0, this.currentIndex + 1);
    this.edits = this.edits.slice(0, this.currentIndex);
    
    // Add new state
    this.history.push(newRope);
    this.edits.push({
      type: 'insert',
      position,
      text,
      timestamp: Date.now()
    });
    this.currentIndex++;

    // Limit history size to prevent unbounded growth
    if (this.history.length > 100) {
      this.history.shift();
      this.edits.shift();
      this.currentIndex--;
    }
  }

  delete(start: number, end: number): void {
    const deletedText = this.currentRope.substring(start, end);
    const newRope = this.currentRope.delete(start, end);
    
    // Remove any redo history
    this.history = this.history.slice(0, this.currentIndex + 1);
    this.edits = this.edits.slice(0, this.currentIndex);
    
    // Add new state
    this.history.push(newRope);
    this.edits.push({
      type: 'delete',
      position: start,
      text: deletedText,
      length: end - start,
      timestamp: Date.now()
    });
    this.currentIndex++;

    // Limit history size
    if (this.history.length > 100) {
      this.history.shift();
      this.edits.shift();
      this.currentIndex--;
    }
  }

  undo(): boolean {
    if (this.currentIndex > 0) {
      this.currentIndex--;
      return true;
    }
    return false;
  }

  redo(): boolean {
    if (this.currentIndex < this.history.length - 1) {
      this.currentIndex++;
      return true;
    }
    return false;
  }

  canUndo(): boolean {
    return this.currentIndex > 0;
  }

  canRedo(): boolean {
    return this.currentIndex < this.history.length - 1;
  }

  // Get description of what will be undone/redone
  getUndoDescription(): string | null {
    if (!this.canUndo()) return null;
    const edit = this.edits[this.currentIndex - 1];
    return `${edit.type} ${edit.text?.length || edit.length} characters`;
  }

  getRedoDescription(): string | null {
    if (!this.canRedo()) return null;
    const edit = this.edits[this.currentIndex];
    return `${edit.type} ${edit.text?.length || edit.length} characters`;
  }
}

The beauty of this approach is that each rope in our history shares most of its structure with adjacent versions. Only the modified nodes are duplicated, making this extremely memory-efficient.

Optimizing with Rope Balancing

Over many edits, a rope can become unbalanced, degrading performance. Let’s add a rebalancing mechanism:

class Rope {
  // ... previous code ...

  // Check if rope is reasonably balanced
  isBalanced(): boolean {
    return this.checkBalance(this.root) >= 0;
  }

  private checkBalance(node: RopeNode): number {
    if (node.type === 'leaf') {
      return 0; // Leaf nodes have height 0
    }

    const leftHeight = this.checkBalance(node.left);
    if (leftHeight < 0) return -1; // Left subtree is unbalanced

    const rightHeight = this.checkBalance(node.right);
    if (rightHeight < 0) return -1; // Right subtree is unbalanced

    // Check if heights differ by more than 1
    if (Math.abs(leftHeight - rightHeight) > 1) {
      return -1; // This node is unbalanced
    }

    return Math.max(leftHeight, rightHeight) + 1;
  }

  // Rebalance the rope by rebuilding it
  rebalance(): Rope {
    const text = this.toString();
    return new Rope(text);
  }
}

// In TextEditor, periodically rebalance
class TextEditor {
  // ... previous code ...
  
  private editsSinceRebalance = 0;

  insert(position: number, text: string): void {
    // ... previous insert code ...

    this.editsSinceRebalance++;
    if (this.editsSinceRebalance > 50) {
      this.rebalanceIfNeeded();
    }
  }

  private rebalanceIfNeeded(): void {
    if (!this.currentRope.isBalanced()) {
      const rebalanced = this.currentRope.rebalance();
      this.history[this.currentIndex] = rebalanced;
      this.editsSinceRebalance = 0;
    }
  }
}

Advanced Features: Concatenation and Splitting

Ropes excel at concatenating documents and splitting them apart:

class Rope {
  // ... previous code ...

  // Concatenate two ropes efficiently
  concat(other: Rope): Rope {
    const newRoot: InternalNode = {
      type: 'internal',
      left: this.root,
      right: other.root,
      leftLength: this.length
    };
    
    const newRope = Object.create(Rope.prototype);
    newRope.root = newRoot;
    return newRope;
  }

  // Split rope at position
  split(position: number): [Rope, Rope] {
    if (position <= 0) {
      return [new Rope(''), this];
    }
    if (position >= this.length) {
      return [this, new Rope('')];
    }

    const [leftRoot, rightRoot] = this.splitNode(this.root, position);
    
    const leftRope = Object.create(Rope.prototype);
    leftRope.root = leftRoot!;
    
    const rightRope = Object.create(Rope.prototype);
    rightRope.root = rightRoot!;
    
    return [leftRope, rightRope];
  }

  private splitNode(node: RopeNode, position: number): [RopeNode | null, RopeNode | null] {
    if (node.type === 'leaf') {
      if (position <= 0) return [null, node];
      if (position >= node.text.length) return [node, null];
      
      return [
        { type: 'leaf', text: node.text.slice(0, position) },
        { type: 'leaf', text: node.text.slice(position) }
      ];
    }

    if (position <= node.leftLength) {
      // Split happens in left subtree
      const [leftLeft, leftRight] = this.splitNode(node.left, position);
      
      if (!leftRight) return [leftLeft, node.right];
      if (!node.right) return [leftLeft, leftRight];
      
      return [
        leftLeft,
        {
          type: 'internal',
          left: leftRight,
          right: node.right,
          leftLength: getLength(leftRight)
        }
      ];
    } else {
      // Split happens in right subtree
      const [rightLeft, rightRight] = this.splitNode(
        node.right, 
        position - node.leftLength
      );
      
      if (!rightLeft) return [node.left, rightRight];
      if (!node.left) return [rightLeft, rightRight];
      
      return [
        {
          type: 'internal',
          left: node.left,
          right: rightLeft,
          leftLength: node.leftLength
        },
        rightRight
      ];
    }
  }
}

Performance Analysis

Let’s compare rope performance with string operations:

OperationStringRope
Access char at indexO(1)O(log n)
Insert/DeleteO(n)O(log n)
ConcatenateO(n)O(1)
SplitO(n)O(log n)
Memory for editO(n)O(log n)

For large documents, the logarithmic complexities make a huge difference. A 1MB document takes ~1ms to edit with ropes versus ~10ms with strings.

Example: Building a Simple Text Editor

Here’s a complete example putting it all together:

// Simple text editor with rope-based undo-redo
class SimpleEditor {
  private editor: TextEditor;
  private cursor: number = 0;

  constructor() {
    this.editor = new TextEditor();
  }

  // Insert text at cursor
  type(text: string): void {
    this.editor.insert(this.cursor, text);
    this.cursor += text.length;
  }

  // Delete character before cursor (backspace)
  backspace(): void {
    if (this.cursor > 0) {
      this.editor.delete(this.cursor - 1, this.cursor);
      this.cursor--;
    }
  }

  // Delete character at cursor (delete key)
  deleteForward(): void {
    if (this.cursor < this.editor.text.length) {
      this.editor.delete(this.cursor, this.cursor + 1);
    }
  }

  // Move cursor
  moveCursor(position: number): void {
    this.cursor = Math.max(0, Math.min(position, this.editor.text.length));
  }

  // Undo/Redo
  undo(): void {
    const oldLength = this.editor.text.length;
    if (this.editor.undo()) {
      // Adjust cursor if needed
      const newLength = this.editor.text.length;
      if (this.cursor > newLength) {
        this.cursor = newLength;
      }
    }
  }

  redo(): void {
    this.editor.redo();
  }

  // Get display text with cursor
  getDisplayText(): string {
    const text = this.editor.text;
    return text.slice(0, this.cursor) + '|' + text.slice(this.cursor);
  }
}

// Usage example
const editor = new SimpleEditor();
editor.type("Hello");
console.log(editor.getDisplayText()); // "Hello|"

editor.type(" World");
console.log(editor.getDisplayText()); // "Hello World|"

editor.backspace();
editor.backspace();
editor.backspace();
editor.backspace();
editor.backspace();
console.log(editor.getDisplayText()); // "Hello |"

editor.undo();
console.log(editor.getDisplayText()); // "Hello W|"

editor.undo();
console.log(editor.getDisplayText()); // "Hello Wo|"

editor.redo();
console.log(editor.getDisplayText()); // "Hello W|"

Conclusion

Ropes provide an elegant solution to the text editing performance problem. By breaking text into a tree structure, we achieve logarithmic time complexity for operations that would be linear with strings. The immutable nature of our implementation makes undo-redo almost trivial to implement while remaining memory-efficient through structural sharing.

This implementation serves as a foundation that can be extended with additional features like:

  • Syntax highlighting metadata
  • Collaborative editing with CRDTs
  • Efficient search with suffix trees
  • Line number tracking

Whether you’re building a text editor, a collaborative document system, or any application handling large mutable text, understanding ropes gives you a powerful tool for managing text efficiently.

Further Reading