Ropes:   An Alternative to Strings
1 Introduction
2 Quick Start
3 Concepts
3.1 Leaves, Nodes, and Balance
3.2 Count and Width
3.3 Cursors
3.4 The gen:  ropeable Interface
3.5 Content-Based Equality and Hashing
4 Generic Rope Operations
4.1 Ropes
rope
rope-leaf
rope-node
rope-count
rope-width
rope-length
rope-depth
rope-empty?
rope-balanced?
rope-flatten
4.2 The gen:  ropeable Interface
gen:  ropeable
ropeable?
rope-leaf-ctor
rope-node-ctor
4.2.1 Raw Chunk Operations
raw?
raw-limit
raw-empty
raw-count
raw-width
raw-slice
raw-append
raw-ref
4.2.2 Rope Construction and Editing
make-rope-leaf
make-rope-node
make-empty-rope
rope-concat
rope-append1
rope-append
rope-split
rope-offset-index
rope-splice
rope-slice
raw->rope
rope->raw
raw-compare
rope-compare-with
rope-compare
rope=?
rope<?
rope<=?
rope>?
rope>=?
4.3 Cursors
cursor
cursor-at-end?
cursor-peek
cursor-advance
cursor-drop
cursor-take
rope->cursor
cursor->rope
4.4 Cursor-Based Rope Operations
rope-foldl
rope-foldr
5 String Ropes
string-rope-leaf
string-rope-node
string-rope?
string-raw?
string-raw-limit
string-raw-empty
string-raw-count
string-raw-width
string-raw-slice
string-raw-ref
string-raw-append
make-string-rope-leaf
empty-string-rope
string->rope
rope->string
string-rope-append1
string-rope-append
string-rope-split
string-rope-offset-index
string-rope-splice
string-rope-slice
string-rope-compare
string-rope=?
string-rope<?
string-rope<=?
string-rope>?
string-rope>=?
string-rope-ci-compare
string-rope-ci<?
string-rope-ci<=?
string-rope-ci>?
string-rope-ci>=?
string-cursor-at-end?
string-cursor-peek
string-cursor-advance
string-cursor-drop
string-cursor-take
string-rope->cursor
cursor->string-rope
string-rope-foldl
string-rope-foldr
in-string-rope
open-input-string-rope
6 Byte Ropes
bytes-rope-leaf
bytes-rope-node
bytes-rope?
bytes-raw?
bytes-raw-limit
bytes-raw-empty
bytes-raw-count
bytes-raw-width
bytes-raw-slice
bytes-raw-ref
bytes-raw-append
make-bytes-rope-leaf
empty-bytes-rope
bytes->rope
rope->bytes
bytes-rope-append1
bytes-rope-append
bytes-rope-split
bytes-rope-offset-index
bytes-rope-splice
bytes-rope-slice
bytes-rope-compare
bytes-rope=?
bytes-rope<?
bytes-rope<=?
bytes-rope>?
bytes-rope>=?
bytes-cursor-at-end?
bytes-cursor-peek
bytes-cursor-advance
bytes-cursor-drop
bytes-cursor-take
bytes-rope->cursor
cursor->bytes-rope
bytes-rope-foldl
bytes-rope-foldr
in-bytes-rope
open-input-bytes-rope
7 Defining New Rope Types
define-rope-type
rope-type-out
rope-type-out/  contract
8 Performance Summary
9 Measured Performance
9.1 Case study:   optimizing equal?
9.2

Ropes: An Alternative to Strings🔗ℹ

Eric Griffis <dedbox@gmail.com>

 (require rope) package: rope

A rope is a tree of small string (or byte, or vector, or other) chunks. It supports the same operations as a string, but where a string of length n takes O(n) time to split, splice, or concatenate, a balanced rope does the same work in O(log n). This library provides the rope data structure, a generic protocol for building ropes out of any chunkable data, and ready-made instances for strings and byte strings.

Based on the paper by Boehm, Atkinson, and Plass.

    1 Introduction

    2 Quick Start

    3 Concepts

      3.1 Leaves, Nodes, and Balance

      3.2 Count and Width

      3.3 Cursors

      3.4 The gen:ropeable Interface

      3.5 Content-Based Equality and Hashing

    4 Generic Rope Operations

      4.1 Ropes

      4.2 The gen:ropeable Interface

        4.2.1 Raw Chunk Operations

        4.2.2 Rope Construction and Editing

      4.3 Cursors

      4.4 Cursor-Based Rope Operations

    5 String Ropes

    6 Byte Ropes

    7 Defining New Rope Types

    8 Performance Summary

    9 Measured Performance

      9.1 Case study: optimizing equal?

1 Introduction🔗ℹ

Racket strings are immutable, fixed-length vectors of characters. That makes them cheap to read and share, but expensive to edit. Inserting a character in the middle of a string of length n means allocating and copying a new string of length n, because substring and string-append both have to walk the whole thing. A text editor that repeatedly inserts and deletes characters in a large buffer this way pays O(n) per keystroke, and O(n2) (or worse) over the course of editing the whole document.

Ropes solve this by representing a long sequence as a balanced binary tree of short chunks rather than as a flat array. Each leaf holds a chunk small enough to slice or copy cheaply. Each internal node caches the combined length of its subtrees, so that length queries are O(1) and a split or concatenation only has to touch O(log n) nodes along a single root-to-leaf path. Hence, splicing new content into a rope costs O(log n) to find the split points plus the cost of building a subtree for the inserted text, rather than O(n) to rebuild the whole sequence.

Asymptotic complexity is only half the story—real-world performance matters, too. On the benchmark suite bundled with this package (see Measured Performance), a splice or slice into a rope of 32,768 characters completes in microseconds, regardless of where in the rope it happens. Appending two ropes typically costs about 100 nanoseconds. Because ropes compare whole chunks at once rather than one element at a time, equal? on two content-identical ropes completes in a few hundred microseconds. Recomputing equal-hash-code on a rope that has already been hashed costs under 60 nanoseconds independent of its size, because hashes are memoized as a composable polynomial hash.

This library separates the tree structure from what it holds. The rope type itself only knows about leaves and nodes—it has no idea whether a leaf’s payload is a string, a byte string, or something else entirely. A generic interface, gen:ropeable, describes the handful of operations a chunk type needs to support (slice, append, count, and so on), and the define-rope-type macro turns any such description into a complete, efficient, type-specific rope implementation. The library includes support for string ropes and byte string ropes out of the box, and Defining New Rope Types walks through building a new rope type from scratch.

2 Quick Start🔗ℹ

The most common way to use this library is through rope/string, which treats a rope as a persistent, efficiently editable stand-in for a string. You can build one with string->rope and get an ordinary string back with rope->string:

> (define greeting (string->rope "Hello!"))
> (rope-length greeting)

6

> (rope->string greeting)

"Hello!"

Editing a rope never touches its argument; every operation returns a new rope that shares structure with the old one. string-rope-splice replaces a run of characters with a new string, and inserting text is just the special case where nothing is replaced:

> (define with-name (string-rope-splice greeting 5 0 " World"))
> (rope->string with-name)

"Hello World!"

; the original is untouched
> (rope->string greeting)

"Hello!"

Deleting text is just splicing in the empty string.

> (define bare (string-rope-splice with-name 5 7 ""))
> (rope->string bare)

"Hello"

Building up a large document out of pieces is a matter of appending ropes, which is also O(log n):

> (define doc
    (string-rope-append
     (string->rope "It was the best of times, ")
     (string->rope "it was the worst of times.")))
> (rope->string doc)

"It was the best of times, it was the worst of times."

Ropes are also sequences of their elements, so a rope of characters works with the various for forms via in-string-rope:

> (for/list ([ch (in-string-rope doc)]
             #:when (char-upper-case? ch))
    ch)

'(#\I)

Everything above works identically for byte strings with rope/bytes in place of rope/string.

3 Concepts🔗ℹ

3.1 Leaves, Nodes, and Balance🔗ℹ

A rope is either a rope-leaf, holding one chunk of raw data directly, or a rope-node, holding a left and a right sub-rope. Both variants cache their element count and width (see below), which is what makes rope-length an O(1) operation instead of a tree walk.

Without careful management, repeated splitting and appending can produce a tree that degenerates into a linked list, at which point operations that ought to be O(log n) would become O(n) again. Following Boehm, Atkinson, and Plass, this library keeps every rope balanced by maintaining the invariant that a rope of depth d always has at least Fib(d + 2) leaves, where Fib is the Fibonacci sequence. rope-append1 checks this invariant after every naive concatenation and, when it is violated, rebuilds the offending subtree from scratch in O(n) time. Because a rebuild is only triggered when the tree has drifted measurably out of balance, this happens at most O(log n) times over the course of O(n) edits, making rope-append1 amortized O(log n).

3.2 Count and Width🔗ℹ

Every rope tracks two numbers: its count, the number of elements it holds, and its width, the total extent of those elements along some other axis. For the string and byte-string ropes in this library, every element occupies exactly one unit of width—a character is one character wide, and a byte is one byte wide—so rope-count and rope-width always agree. rope-length is simply an alias for the latter.

The two numbers diverge for a chunk type whose elements have variable extent. For example, in a rope over a sequence of on-screen glyphs, each glyph is a single element that may occupy a variable number of display columns. rope-offset-index exists for exactly this situation: given a rope and a width, such as a column number, it locates the count-based element index at that position in O(log n) time. For the built-in types this is a straightforward binary search, but the machinery is there for chunk types that need it.

3.3 Cursors🔗ℹ

A cursor is a position within a rope, represented as the raw chunk currently being read, an offset within that chunk, and a rope of everything after it. Cursors support O(1) cursor-peek and cursor-advance except when crossing from one leaf into the next, which is itself amortized O(1) over the length of a leaf. cursor-drop skips k elements, and cursor-take extracts the next k elements as a rope. Both run in O(log n) time, independent of k, by splitting the rope rather than visiting each element.

Cursors are the mechanism behind rope-foldl and rope-foldr, which fold over one or more ropes in lockstep, and behind sequence constructors such as in-string-rope.

3.4 The gen:ropeable Interface🔗ℹ

Everything above is implemented once, generically, in terms of a small interface, gen:ropeable, that describes how to work with a raw chunk. A chunk type implements gen:ropeable by supplying:

Every other operation in Generic Rope Operationssplitting, splicing, slicing, balancing, cursors, folds—comes for free as a fallback implementation once those pieces are in place. You will rarely call these generic operations directly; define-rope-type (Defining New Rope Types) uses them to generate a complete, type-specific API like the one rope/string exposes for strings.

3.5 Content-Based Equality and Hashing🔗ℹ

Every rope produced by define-rope-type implements gen:equal+hashequal? compares the sequence of elements two ropes denote, not their tree shape, so a rope built by one sequence of appends/splices is equal? to a differently-shaped rope holding the same content. equal-hash-code and equal-secondary-hash-code agree accordingly.

Internally, this is a composable polynomial (Rabin–Karp style) rolling hash. Each node caches ⟨hash, baseⁿ⟩ for its subtree in a weak, eq?-keyed table, so combining two already-hashed subtrees into a parent is O(1). Hashing a freshly built rope of n elements is O(n); hashing it again, or hashing any rope sharing structure with one already hashed, is O(1) amortized. equal? itself takes an eq? fast path; on mismatch, it falls back to an O(n) cursor-based walk that never requires the two ropes’ leaf boundaries to align.

> (define a (string->rope "hello world"))
> (define b (string-rope-append (string->rope "hello ") (string->rope "world")))
> (equal? a b)

#t

> (equal? (equal-hash-code a) (equal-hash-code b))

#t

4 Generic Rope Operations🔗ℹ

 (require rope/rope) package: rope

This module defines the rope tree structure, the cursor type, and the gen:ropeable interface that connects the two to a particular chunk representation. Most programs will use a type-specific module such as rope/string instead of calling these generic operations directly; this reference is here for readers implementing a new chunk type, and to document what the type-specific operations reduce to underneath.

4.1 Ropes🔗ℹ

struct

(struct rope ())

The common supertype of rope-leaf and rope-node. A rope value is always one or the other; there are no other instances.

struct

(struct rope-leaf rope (count width raw))

  count : exact-nonnegative-integer?
  width : exact-nonnegative-integer?
  raw : any/c
A rope holding a single chunk of raw data. count and width are the chunk’s count and width; raw is the chunk itself, whose type depends on the gen:ropeable implementation that produced it.

struct

(struct rope-node rope (count width left right))

  count : exact-nonnegative-integer?
  width : exact-nonnegative-integer?
  left : rope?
  right : rope?
A rope formed by concatenating left and right. count and width are the sums of the corresponding fields of left and right, cached to avoid recomputation on every query.

procedure

(rope-count rope)  exact-nonnegative-integer?

  rope : rope?
Returns the total count of ropethe number of elements it holds, summed across every leaf.

procedure

(rope-width rope)  exact-nonnegative-integer?

  rope : rope?
Returns the total width of rope.

procedure

(rope-length rope)  exact-nonnegative-integer?

  rope : rope?
An alias for rope-width, provided for symmetry with string-length and bytes-length.

procedure

(rope-depth rope)  exact-nonnegative-integer?

  rope : rope?
Returns the height of rope’s tree: 0 for a leaf, and one more than the deeper of its two children for a node. Constant time, since every node could in principle cache its own depth, though the current implementation recomputes it by walking down the tree.

procedure

(rope-empty? rope)  boolean?

  rope : rope?
Returns #t if rope holds no elements.

Examples:

procedure

(rope-balanced? rope)  boolean?

  rope : rope?
Returns #t if rope’s count is at least the Fibonacci bound for its depth, per Boehm, Atkinson, and Plass. Ropes produced by this library’s own operations are always balanced; this predicate exists mainly as an internal invariant check.

procedure

(rope-flatten rope)  list?

  rope : rope?
Returns the list of raw chunks held in rope’s leaves, in left-to-right order. Runs in time proportional to the number of leaves.

4.2 The gen:ropeable Interface🔗ℹ

A generic interface, in the sense of define-generics, that a chunk type implements in order to obtain a complete rope implementation. Most programs interact with this interface only indirectly, through define-rope-type; see Defining New Rope Types.

procedure

(ropeable? v)  boolean?

  v : any/c
Returns #t if v is an instance of some type implementing gen:ropeable.

procedure

(rope-leaf-ctor ropeable)  procedure?

  ropeable : ropeable?
Returns the constructor ropeable uses to build leaf nodes: a procedure of three arguments, count, width, and raw, returning a rope-leaf?.

procedure

(rope-node-ctor ropeable)  procedure?

  ropeable : ropeable?
Returns the constructor ropeable uses to build internal nodes: a procedure of four arguments, count, width, left, and right, returning a rope-node?.

4.2.1 Raw Chunk Operations🔗ℹ

These describe the raw, non-rope representation that a gen:ropeable instance is built around.

procedure

(raw? ropeable v)  boolean?

  ropeable : ropeable?
  v : any/c
Returns #t if v is a valid raw chunk for ropeable’s type.

procedure

(raw-limit ropeable)  exact-nonnegative-integer?

  ropeable : ropeable?
Returns the largest count a single leaf is allowed to hold for ropeable’s type. Both rope/string and rope/bytes use 512.

procedure

(raw-empty ropeable)  any/c

  ropeable : ropeable?
Constructs an empty raw chunk for ropeable’s type, e.g., "" for strings and #"" for byte strings.

procedure

(raw-count ropeable raw)  exact-nonnegative-integer?

  ropeable : ropeable?
  raw : any/c
Returns the count of raw, i.e, how many elements it holds.

procedure

(raw-width ropeable raw)  exact-nonnegative-integer?

  ropeable : ropeable?
  raw : any/c
Returns the width of raw. For strings and byte strings this coincides with raw-count; see Count and Width.

procedure

(raw-slice ropeable raw start end)  any/c

  ropeable : ropeable?
  raw : any/c
  start : exact-nonnegative-integer?
  end : exact-nonnegative-integer?
Returns the sub-chunk of raw from element start (inclusive) to end (exclusive), analogous to substring.

procedure

(raw-append ropeable raw ...)  any/c

  ropeable : ropeable?
  raw : any/c
Concatenates any number of raw chunks into one, analogous to string-append.

procedure

(raw-ref ropeable raw pos)  any/c

  ropeable : ropeable?
  raw : any/c
  pos : exact-nonnegative-integer?
Returns the element of raw at position pos, analogous to string-ref.

4.2.2 Rope Construction and Editing🔗ℹ

These are the same operations exposed under type-specific names by rope/string and rope/bytes; see those sections for usage examples. Each takes an explicit gen:ropeable instance identifying which chunk type’s rules to follow.

procedure

(make-rope-leaf ropeable raw)  rope-leaf?

  ropeable : ropeable?
  raw : any/c
Wraps raw in a leaf, computing its count and width along the way.

procedure

(make-rope-node ropeable left right)  rope-node?

  ropeable : ropeable?
  left : rope?
  right : rope?
Joins left and right into a node, without checking or repairing balance. Most callers want rope-append1 instead.

procedure

(make-empty-rope ropeable)  rope?

  ropeable : ropeable?
Returns an empty rope: a leaf wrapping (raw-empty ropeable).

procedure

(rope-concat ropeable left right)  rope?

  ropeable : ropeable?
  left : rope?
  right : rope?
Naively joins left and right into a node in O(1) time, without checking balance. Repeated use can produce an unbalanced tree; see rope-append1.

procedure

(rope-append1 ropeable left right)  rope?

  ropeable : ropeable?
  left : rope?
  right : rope?
Joins left and right, rebuilding the result from scratch if doing so naively would violate the balance invariant. Amortized O(log n).

procedure

(rope-append ropeable rope ...)  rope?

  ropeable : ropeable?
  rope : rope?
Joins any number of ropes left to right by repeated rope-append1. Amortized O(log n) per argument.

procedure

(rope-split ropeable rope i)  
rope? rope?
  ropeable : ropeable?
  rope : rope?
  i : exact-nonnegative-integer?
Splits rope into the elements before index i and the elements from i onward, returning both as ropes. Amortized O(log n): one descent to the split point, plus one rope-append1 per level on the way back up.

procedure

(rope-offset-index ropeable rope ofs)

  exact-nonnegative-integer?
  ropeable : ropeable?
  rope : rope?
  ofs : exact-nonnegative-integer?
Returns the count-based index of the element containing width offset ofs. See Count and Width. O(log n).

procedure

(rope-splice ropeable    
  rope    
  start    
  old-len    
  new-raw)  rope?
  ropeable : ropeable?
  rope : rope?
  start : exact-nonnegative-integer?
  old-len : exact-nonnegative-integer?
  new-raw : any/c
Replaces the old-len elements of rope beginning at start with the contents of new-raw, returning the result. O(log n + m), where m is the count of new-raw.

procedure

(rope-slice ropeable rope start len)  rope?

  ropeable : ropeable?
  rope : rope?
  start : exact-nonnegative-integer?
  len : exact-nonnegative-integer?
Extracts the len elements of rope beginning at start. Amortized O(log n + k), where k is the number of leaves spanned by the extracted range.

procedure

(raw->rope ropeable raw)  rope?

  ropeable : ropeable?
  raw : any/c
Builds a balanced rope out of raw from the bottom up, splitting it into leaves no larger than (raw-limit ropeable). O(n).

procedure

(rope->raw ropeable rope)  any/c

  ropeable : ropeable?
  rope : rope?
Flattens rope back into a single raw chunk, by appending its leaves left to right. Time proportional to the number of leaves.

procedure

(raw-compare ropeable raw1 raw2)  (or/c '< '= '>)

  ropeable : ropeable?
  raw1 : any/c
  raw2 : any/c
Returns the lexicographic ordering of raw1 against raw2. This operation is optional: if a gen:ropeable instance omits this method, the comparison operations will raise an error when invoked on it.

procedure

(rope-compare-with ropeable    
  cmp-proc    
  rope1    
  rope2)  (or/c '< '= '>)
  ropeable : ropeable?
  cmp-proc : procedure?
  rope1 : rope?
  rope2 : rope?
Like rope-compare, but uses cmp-proc in place of raw-compare to order corresponding chunks, enabling alternate orderings.

procedure

(rope-compare ropeable rope1 rope2)  (or/c '< '= '>)

  ropeable : ropeable?
  rope1 : rope?
  rope2 : rope?
Compares rope1 and rope2 lexicographically by element, without flattening either. O(log n + d) amortized, where d is the number of elements scanned before the first difference.

procedure

(rope=? ropeable rope1 rope2)  boolean?

  ropeable : ropeable?
  rope1 : rope?
  rope2 : rope?
Returns #t iff rope-compare reports '= for rope1 and rope2. O(log n + d) amortized, same as rope-compare.

Note this is not the same check as equal? on two ropes: this comparator-based check requires the instantiating type to have supplied #:compare to define-rope-type, and does not benefit from the memoized content-hash caching described in Content-Based Equality and Hashing. Prefer equal? for a plain equality test; use rope=? when you already need rope-compare’s ordering (e.g. alongside rope<?) and want the equal-case answer from the same family of operations.

procedure

(rope<? ropeable rope1 rope2)  boolean?

  ropeable : ropeable?
  rope1 : rope?
  rope2 : rope?

procedure

(rope<=? ropeable rope1 rope2)  boolean?

  ropeable : ropeable?
  rope1 : rope?
  rope2 : rope?

procedure

(rope>? ropeable rope1 rope2)  boolean?

  ropeable : ropeable?
  rope1 : rope?
  rope2 : rope?

procedure

(rope>=? ropeable rope1 rope2)  boolean?

  ropeable : ropeable?
  rope1 : rope?
  rope2 : rope?

4.3 Cursors🔗ℹ

struct

(struct cursor (raw pos after))

  raw : any/c
  pos : exact-nonnegative-integer?
  after : rope?
A position within a rope. raw is the chunk currently being read, pos is an index into it, and after is a rope of everything following that chunk. Most programs use the type-specific cursor operations provided by rope/string or rope/bytes rather than constructing a cursor directly.

procedure

(cursor-at-end? ropeable cursor)  boolean?

  ropeable : ropeable?
  cursor : cursor?
Returns #t if cursor has no more elements to read. Constant time.

procedure

(cursor-peek ropeable cursor)  any/c

  ropeable : ropeable?
  cursor : cursor?
Returns the element under cursor, or #f if cursor is at the end. Constant time, except when crossing from one leaf into the next, which is amortized constant time over the leaf.

procedure

(cursor-advance ropeable cursor)  cursor?

  ropeable : ropeable?
  cursor : cursor?
Returns a cursor advanced one element past cursor. Same complexity as cursor-peek.

procedure

(cursor-drop ropeable cursor k)  cursor?

  ropeable : ropeable?
  cursor : cursor?
  k : exact-nonnegative-integer?
Returns a cursor advanced k elements past cursor, by splitting the remainder of the rope rather than stepping one element at a time. O(log n), independent of k.

procedure

(cursor-take ropeable cursor k)  rope?

  ropeable : ropeable?
  cursor : cursor?
  k : exact-nonnegative-integer?
Returns a rope of the k elements starting at cursor, by splitting the remainder of the rope rather than stepping one element at a time. O(log n), independent of k.

procedure

(rope->cursor ropeable rope)  cursor?

  ropeable : ropeable?
  rope : rope?
Returns a cursor positioned at the first element of rope. O(log n).

procedure

(cursor->rope ropeable cursor)  rope?

  ropeable : ropeable?
  cursor : cursor?
Reconstitutes cursor and everything after it as a single rope. Used internally to implement cursor-drop without visiting each skipped element.

4.4 Cursor-Based Rope Operations🔗ℹ

procedure

(rope-foldl ropeable proc init rope ...+)  any/c

  ropeable : ropeable?
  proc : procedure?
  init : any/c
  rope : rope?
Folds proc left to right over the elements of one or more ropes in lockstep, the way foldl does over lists. Every rope argument must have the same count. proc is applied as (proc acc elem ...), where the elems are the corresponding elements of each rope at the current position and acc is the accumulated result so far. O(n).

procedure

(rope-foldr ropeable proc init rope ...+)  any/c

  ropeable : ropeable?
  proc : procedure?
  init : any/c
  rope : rope?
Like rope-foldl, but folds right to left.

5 String Ropes🔗ℹ

 (require rope/string) package: rope

A rope specialized for string? chunks, generated by define-rope-type (Defining New Rope Types). Every operation below behaves exactly like its generic counterpart in rope/rope, specialized to strings and with no need to supply a ropeable instance explicitly.

Example:
> (define r (string->rope "supercalifragilisticexpialidocious"))
> (rope-length r)

34

> (rope-depth r)

0

> (rope->string (string-rope-slice r 5 4))

"cali"

struct

(struct string-rope-leaf rope-leaf (count width raw))

  count : exact-nonnegative-integer?
  width : exact-nonnegative-integer?
  raw : string?
A leaf of a string rope, holding one string of at most (string-raw-limit) characters.

struct

(struct string-rope-node rope-node (count width left right))

  count : exact-nonnegative-integer?
  width : exact-nonnegative-integer?
  left : string-rope?
  right : string-rope?
An internal node of a string rope.

procedure

(string-rope? v)  boolean?

  v : any/c
Returns #t if v is a string-rope-leaf? or string-rope-node?.

procedure

(string-raw? v)  boolean?

  v : any/c
An alias for string?.

Returns the maximum number of characters a single leaf may hold: 512.

procedure

(string-raw-empty)  string?

Returns "".

procedure

(string-raw-count raw)  exact-nonnegative-integer?

  raw : string?
An alias for string-length.

procedure

(string-raw-width raw)  exact-nonnegative-integer?

  raw : string?
An alias for string-length.

procedure

(string-raw-slice raw start end)  string?

  raw : string?
  start : exact-nonnegative-integer?
  end : exact-nonnegative-integer?
An alias for substring.

procedure

(string-raw-ref raw pos)  char?

  raw : string?
  pos : exact-nonnegative-integer?
An alias for string-ref.

procedure

(string-raw-append raw ...)  string?

  raw : string?
An alias for string-append.

procedure

(make-string-rope-leaf raw)  string-rope-leaf?

  raw : string?
Wraps raw directly in a leaf, without checking it against (string-raw-limit). Prefer string->rope unless you know raw is already short enough.

The empty string rope.

procedure

(string->rope str)  string-rope?

  str : string?
Builds a balanced rope out of str. O(n).

Example:
> (string->rope "a balanced tree of text")

(string-rope-leaf 23 23 "a balanced tree of text")

procedure

(rope->string rope)  string?

  rope : string-rope?
Flattens rope into an ordinary string. Time proportional to the number of leaves.

procedure

(string-rope-append1 left right)  string-rope?

  left : string-rope?
  right : string-rope?
Joins two string ropes, repairing balance if needed. Amortized O(log n).

procedure

(string-rope-append rope ...)  string-rope?

  rope : string-rope?
Joins any number of string ropes left to right.

Example:
> (rope->string
   (string-rope-append (string->rope "one ")
                       (string->rope "two ")
                       (string->rope "three")))

"one two three"

Splits rope at character index i. Amortized O(log n).

Returns the character index at offset ofs. Since count and width coincide for strings, this is simply ofs itself, modulo range checking; the operation exists chiefly for parity with rope-offset-index.

procedure

(string-rope-splice rope    
  start    
  old-len    
  new-str)  string-rope?
  rope : string-rope?
  start : exact-nonnegative-integer?
  old-len : exact-nonnegative-integer?
  new-str : string?
Replaces old-len characters of rope beginning at start with new-str. O(log n + m), where m is (string-length new-str).

Example:
> (define r (string->rope "a tree of text"))
> (rope->string (string-rope-splice r 2 4 "rope"))

"a rope of text"

procedure

(string-rope-slice rope start len)  string-rope?

  rope : string-rope?
  start : exact-nonnegative-integer?
  len : exact-nonnegative-integer?
Extracts len characters of rope beginning at start, analogous to substring.

procedure

(string-rope-compare rope1 rope2)  (or/c '< '= '>)

  rope1 : string-rope?
  rope2 : string-rope?

procedure

(string-rope=? rope1 rope2)  boolean?

  rope1 : string-rope?
  rope2 : string-rope?
See rope=?.

procedure

(string-rope<? rope1 rope2)  boolean?

  rope1 : string-rope?
  rope2 : string-rope?

procedure

(string-rope<=? rope1 rope2)  boolean?

  rope1 : string-rope?
  rope2 : string-rope?

procedure

(string-rope>? rope1 rope2)  boolean?

  rope1 : string-rope?
  rope2 : string-rope?

procedure

(string-rope>=? rope1 rope2)  boolean?

  rope1 : string-rope?
  rope2 : string-rope?
Versions of string<?/string<=?/string>?/ string>=? for ropes, without flattening either argument.

procedure

(string-rope-ci-compare rope1 rope2)  (or/c '< '= '>)

  rope1 : string-rope?
  rope2 : string-rope?
Like string-rope-compare, but case-folds each comparison, as string-ci<?/string-ci=? do.

procedure

(string-rope-ci<? rope1 rope2)  boolean?

  rope1 : string-rope?
  rope2 : string-rope?

procedure

(string-rope-ci<=? rope1 rope2)  boolean?

  rope1 : string-rope?
  rope2 : string-rope?

procedure

(string-rope-ci>? rope1 rope2)  boolean?

  rope1 : string-rope?
  rope2 : string-rope?

procedure

(string-rope-ci>=? rope1 rope2)  boolean?

  rope1 : string-rope?
  rope2 : string-rope?
Case-insensitive versions of the above.

procedure

(string-cursor-at-end? cursor)  boolean?

  cursor : cursor?

procedure

(string-cursor-peek cursor)  (or/c #f char?)

  cursor : cursor?

procedure

(string-cursor-advance cursor)  cursor?

  cursor : cursor?

procedure

(string-cursor-drop cursor k)  cursor?

  cursor : cursor?
  k : exact-nonnegative-integer?

procedure

(string-cursor-take cursor k)  string-rope?

  cursor : cursor?
  k : exact-nonnegative-integer?

procedure

(string-rope->cursor rope)  cursor?

  rope : string-rope?

procedure

(cursor->string-rope cursor)  string-rope?

  cursor : cursor?

procedure

(string-rope-foldl proc init rope ...+)  any/c

  proc : procedure?
  init : any/c
  rope : string-rope?

Example:
> (string-rope-foldl (λ (acc ch) (cons ch acc)) '() (string->rope "abc"))

'(#\c #\b #\a)

procedure

(string-rope-foldr proc init rope ...+)  any/c

  proc : procedure?
  init : any/c
  rope : string-rope?

procedure

(in-string-rope rope)  sequence?

  rope : string-rope?
Returns a sequence of the characters of rope, in order. Recognized by for and related forms as a specially optimized sequence, but also usable as an ordinary sequence value.

Example:
> (for/list ([ch (in-string-rope (string->rope "rope"))]) ch)

'(#\r #\o #\p #\e)

procedure

(open-input-string-rope rope)  input-port?

  rope : string-rope?
Returns an input port that reads the characters of rope, UTF-8 encoded, without first flattening rope into a single string. Each read of k bytes costs O(k); reading the whole rope costs O(n) overall, since a character is a small, constant number of bytes.

Examples:
> (define in (open-input-string-rope (string->rope "line one\nline two")))
> (read-line in)

"line one"

6 Byte Ropes🔗ℹ

 (require rope/bytes) package: rope

A rope specialized for bytes? chunks, generated by define-rope-type in exactly the same way as rope/string. Every operation below is the byte-string version of the corresponding string-rope operation described in the previous section.

struct

(struct bytes-rope-leaf rope-leaf (count width raw))

  count : exact-nonnegative-integer?
  width : exact-nonnegative-integer?
  raw : bytes?
A leaf of a byte rope, holding a byte string of at most (bytes-raw-limit) bytes.

struct

(struct bytes-rope-node rope-node (count width left right))

  count : exact-nonnegative-integer?
  width : exact-nonnegative-integer?
  left : bytes-rope?
  right : bytes-rope?
An internal node of a byte rope.

procedure

(bytes-rope? v)  boolean?

  v : any/c
Returns #t if v is a bytes-rope-leaf? or bytes-rope-node?.

procedure

(bytes-raw? v)  boolean?

  v : any/c
An alias for bytes?.
Returns the maximum number of bytes a single leaf may hold: 512.

procedure

(bytes-raw-empty)  bytes?

Returns #"".

procedure

(bytes-raw-count raw)  exact-nonnegative-integer?

  raw : bytes?
An alias for bytes-length.

procedure

(bytes-raw-width raw)  exact-nonnegative-integer?

  raw : bytes?
An alias for bytes-length.

procedure

(bytes-raw-slice raw start end)  bytes?

  raw : bytes?
  start : exact-nonnegative-integer?
  end : exact-nonnegative-integer?
An alias for subbytes.

procedure

(bytes-raw-ref raw pos)  byte?

  raw : bytes?
  pos : exact-nonnegative-integer?
An alias for bytes-ref.

procedure

(bytes-raw-append raw ...)  bytes?

  raw : bytes?
An alias for bytes-append.

procedure

(make-bytes-rope-leaf raw)  bytes-rope-leaf?

  raw : bytes?
Wraps raw directly in a leaf, without checking it against (bytes-raw-limit). Prefer bytes->rope unless you know raw is already short enough.

The empty byte rope.

procedure

(bytes->rope bstr)  bytes-rope?

  bstr : bytes?
Builds a balanced rope out of bstr. O(n).

procedure

(rope->bytes rope)  bytes?

  rope : bytes-rope?
Flattens rope into an ordinary byte string.

procedure

(bytes-rope-append1 left right)  bytes-rope?

  left : bytes-rope?
  right : bytes-rope?
Joins two byte ropes, repairing balance if needed.

procedure

(bytes-rope-append rope ...)  bytes-rope?

  rope : bytes-rope?
Joins any number of byte ropes left to right.

procedure

(bytes-rope-split rope i)  
bytes-rope? bytes-rope?
  rope : bytes-rope?
  i : exact-nonnegative-integer?
Splits rope at byte index i.

Returns the byte index at offset ofs; as with string-rope-offset-index, count and width coincide for byte ropes.

procedure

(bytes-rope-splice rope    
  start    
  old-len    
  new-bstr)  bytes-rope?
  rope : bytes-rope?
  start : exact-nonnegative-integer?
  old-len : exact-nonnegative-integer?
  new-bstr : bytes?
Replaces old-len bytes of rope beginning at start with new-bstr.

Example:
> (define r (bytes->rope #"hello world"))
> (rope->bytes (bytes-rope-splice r 6 5 #"racket"))

#"hello racket"

procedure

(bytes-rope-slice rope start len)  bytes-rope?

  rope : bytes-rope?
  start : exact-nonnegative-integer?
  len : exact-nonnegative-integer?
Extracts len bytes of rope beginning at start, analogous to subbytes.

procedure

(bytes-rope-compare rope1 rope2)  (or/c '< '= '>)

  rope1 : bytes-rope?
  rope2 : bytes-rope?

procedure

(bytes-rope=? rope1 rope2)  boolean?

  rope1 : bytes-rope?
  rope2 : bytes-rope?
See rope=?.

procedure

(bytes-rope<? rope1 rope2)  boolean?

  rope1 : bytes-rope?
  rope2 : bytes-rope?

procedure

(bytes-rope<=? rope1 rope2)  boolean?

  rope1 : bytes-rope?
  rope2 : bytes-rope?

procedure

(bytes-rope>? rope1 rope2)  boolean?

  rope1 : bytes-rope?
  rope2 : bytes-rope?

procedure

(bytes-rope>=? rope1 rope2)  boolean?

  rope1 : bytes-rope?
  rope2 : bytes-rope?
Versions of bytes<?//bytes>? for ropes, without flattening either argument.

procedure

(bytes-cursor-at-end? cursor)  boolean?

  cursor : cursor?

procedure

(bytes-cursor-peek cursor)  (or/c #f byte?)

  cursor : cursor?

procedure

(bytes-cursor-advance cursor)  cursor?

  cursor : cursor?

procedure

(bytes-cursor-drop cursor k)  cursor?

  cursor : cursor?
  k : exact-nonnegative-integer?

procedure

(bytes-cursor-take cursor k)  bytes-rope?

  cursor : cursor?
  k : exact-nonnegative-integer?

procedure

(bytes-rope->cursor rope)  cursor?

  rope : bytes-rope?

procedure

(cursor->bytes-rope cursor)  bytes-rope?

  cursor : cursor?

procedure

(bytes-rope-foldl proc init rope ...+)  any/c

  proc : procedure?
  init : any/c
  rope : bytes-rope?

procedure

(bytes-rope-foldr proc init rope ...+)  any/c

  proc : procedure?
  init : any/c
  rope : bytes-rope?

procedure

(in-bytes-rope rope)  sequence?

  rope : bytes-rope?
Returns a sequence of the bytes of rope, in order.

procedure

(open-input-bytes-rope rope)  input-port?

  rope : bytes-rope?
Returns an input port that reads the bytes of rope directly, without first flattening it into a single byte string.

7 Defining New Rope Types🔗ℹ

 (require rope/define-rope-type) package: rope

syntax

(define-rope-type type raw? raw-limit raw-empty raw-count raw-width
                   raw-slice raw-append raw-ref)
 
  raw? : (any/c . -> . boolean?)
  raw-limit : (-> exact-nonnegative-integer?)
  raw-empty : (-> any/c)
  raw-count : (any/c . -> . exact-nonnegative-integer?)
  raw-width : (any/c . -> . exact-nonnegative-integer?)
  raw-slice : 
(any/c exact-nonnegative-integer?
        exact-nonnegative-integer? . -> . any/c)
  raw-append : (list? . -> . any/c)
  raw-ref : (any/c exact-nonnegative-integer? . -> . any/c)
Generates a complete, efficient rope implementation specialized to a chunk type named type, analogous to the ones this library defines for string and bytes. type is an identifier used as a prefix for every generated binding; the other arguments are expressions supplying the pieces of the gen:ropeable interface described in The gen:ropeable Interface:

  • raw? is a one-argument predicate identifying valid chunks;

  • raw-limit is a zero-argument procedure returning the maximum count of a single leaf;

  • raw-empty is a zero-argument procedure constructing an empty chunk;

  • raw-count and raw-width are one-argument procedures returning a chunk’s count and width;

  • raw-slice is a three-argument procedure, taking a chunk followed by start and end indices, and returning a sub-chunk;

  • raw-append is a one-argument procedure that takes a list of chunks and returns their concatenation; and

  • raw-ref is a two-argument procedure that takes a chunk and an index and returns a single element.

Given these, define-rope-type defines, among others, the following bindings, where type stands for the identifier bound to type:

  • structs type-rope-leaf and type-rope-node, both subtypes of rope, and a predicate type-rope?;

  • type-raw?, type-raw-limit, type-raw-empty, type-raw-count, type-raw-width, type-raw-slice, type-raw-append, and type-raw-ref, each specialized versions of the procedure supplied above;

  • make-_type-rope-leaf, make-empty-_type-rope, type-rope-append1, type-rope-append, type-rope-split, type-rope-offset-index, type-rope-splice, type-rope-slice, type->-rope, and rope->_type, the specialized construction and editing operations from Generic Rope Operations;

  • type-rope-compare, type-rope-compare-with, type-rope=?, type-rope<?, type-rope<=?, type-rope>?, and type-rope>=?, the specialized comparison operations (raises at call time unless #:compare was supplied to define-rope-type);

  • type-cursor-at-end?, type-cursor-peek, type-cursor-advance, type-cursor-drop, type-cursor-take, type-rope->cursor, and cursor->_type-rope, the specialized cursor operations; and

  • type-rope-foldl, type-rope-foldr, and in-_type-rope, the specialized fold and sequence operations.

define-rope-type does not provide any of these bindings; it only defines them in the enclosing module. A module that uses define-rope-type is expected to re-export the bindings it wants to make public, typically via rope-type-out or rope-type-out/contract (below).

syntax

(rope-type-out type)

A provide sub-form. Re-exports every public binding define-rope-type introduces for typethe structs, predicates, raw operations, rope operations, cursor operations, folds, and in-_type-ropeunder their generated names, with no contracts attached. Implementation plumbing (type-rope-gen, the leaf/node constructor selectors, and in-_type-rope-runtime) is excluded; none of it is meant to be called directly.

Use this inside a trusted module where paying for contract checks at every rope operation isn’t worthwhile.

syntax

(rope-type-out/contract type maybe-raw maybe-element)

 
maybe-raw = 
  | #:raw raw-contract-expr
     
maybe-element = 
  | #:element element-contract-expr
 
  raw-contract-expr : contract?
  element-contract-expr : contract?
Like rope-type-out, but wraps every re-exported procedure in contract-out. Without #:raw or #:element, raw values are checked against the generic type-raw? and individual elements against any/c, which is sound but as loose as the generic protocol allows. #:raw and #:element tighten those two positions to whatever concrete contract the instantiating module actually promises, .e.g., string? and char? for rope/string, bytes? and byte? for rope/bytes.

in-_type-rope is re-exported bare, since a sequence macro has no useful contract. Its runtime fallback in-_type-rope-runtime, which is used when the sequence is handed to a higher-order function like sequence-map instead of appearing directly in a for clause, is contracted and re-exported alongside it.

Note that type-cursor-peek’s contract always admits #f in addition to element-contract-expr, since #f signals that the cursor has run out of elements rather than being itself an element value.

As a complete example, here is a rope type over immutable vectors, exposing only the operations needed to build, edit, and read one back:

#lang racket/base
 
(require racket/contract
         rope/define-rope-type
         rope/rope)
 
(provide
 (contract-out
  [vector-rope?         (any/c . -> . boolean?)]
  [vector->rope         (vector? . -> . vector-rope?)]
  [rope->vector         (vector-rope? . -> . vector?)]
  [vector-rope-append   (vector-rope? ... . -> . vector-rope?)]
  [vector-rope-splice   (vector-rope? exact-nonnegative-integer?
                                      exact-nonnegative-integer? vector? . -> . vector-rope?)]
  [vector-rope-slice    (vector-rope? exact-nonnegative-integer?
                                      exact-nonnegative-integer? . -> . vector-rope?)]))
 
(define-rope-type vector
  vector?
  (λ () 256)
  (λ () (vector))
  vector-length
  vector-length
  (λ (v start end) (vector-copy v start end))
  (λ (vs) (apply vector-append vs))
  vector-ref)

Every operation on the resulting vector-rope type (appending, splicing, slicing, folding, iterating with in-vector-rope) behaves exactly like its string and byte-string counterparts, at the same complexity, without any further code.

The provide clause above spells out each binding’s contract by hand, which is worth doing for a small, curated surface like this one. A type that wants to expose everything define-rope-type generates can skip the boilerplate:

(provide (rope-type-out/contract vector #:raw vector?))

8 Performance Summary🔗ℹ

The following table summarizes the running time of the principal operations, for a rope of n elements. Amortized entries can occasionally cost more on a single call, when a rebalancing rebuild is triggered, but never more than O(log n) on average over a sequence of edits.

Operation

Time

rope-length

O(1)

rope-depth

O(1)

rope-balanced?

O(1)

rope-append

O(log n) amortized

rope-split

O(log n) amortized

rope-splice

O(log n + m) amortized

rope-slice

O(log n + k) amortized

rope-compare/rope<?/etc.

O(log n + d) amortized

rope-offset-index

O(log n)

cursor-peek/cursor-advance

O(1) amortized

cursor-drop

O(log n)

cursor-take

O(log n)

rope-foldl/rope-foldr

O(n)

raw->rope

O(n)

rope->raw

O(# leaves)

equal?

O(n) worst case, O(1) if eq? or already hashed

equal-hash-code

O(n) cold, O(1) amortized (cached)

Here m is the count of the chunk being spliced in, k is the number of leaves spanned by an extracted slice, and d is the position of the first difference.

9 Measured Performance🔗ℹ

The numbers below come from this package’s own benchmark suite (documented in benchmark/README.md), run against a freshly built package so results reflect compiled (not interpreted) code.

Test machine: benchmarks were performed on a desktop PC with a 12th Gen Intel(R) Core(TM) i7-12700K with 12 cores (8 P-cores, 4 E-cores) and 20 threads, 32GiB DDR5 DRAM at 4800 MT/s, on Arch linux (July 2026) with Racket v9.2 [cs].

Methodology: default suite settings unless noted—sizes 0, 8, 64, 512, 4096, 32768, 15 measured trials per benchmark after 5 untimed warmup calls, forced GC before each trial. Warm benchmarks are batch-calibrated to at least 5ms per batch before timing (see benchmark/README.md’s Timer resolution and batching section); cold benchmarks are measured unbatched, one fresh fixture per trial. Every number below is a mean over its trials, not a single sample.

Reproducing these numbers:

racco setup rope

racket benchmark/main.rkt --save /tmp/run.json

racket benchmark/visualize.rkt --in /tmp/run.json --order scenario

Every benchmarked
operation, one row each, sorted fastest to slowest, one dot per input
size on a shared log-scale time axis. Color indicates operation category.

Here are some of the more impactful figures from that chart, at the largest test size (32,768 elements):

Operation

Time at n = 32,768

append1 (leaf-level)

~110-120ns

split at midpoint

~3us

slice, one quarter of the rope

~5-6us

splice 4 elements at the midpoint

~9us

offset-index at the midpoint

~10-11us

build from a flat raw chunk

~30-170us

equal-hash-code, already hashed (warm)

<60ns, flat across all sizes

equal-hash-code, never hashed before (cold)

~5.8-6.0ms

equal?, same content (post-optimization, see below)

~200-550us

The *-build rows in the full chart (typed-build, fragmented-build, edited-build) are the slowest operations measured, at tens to hundreds of milliseconds. This is expected, since they simulate building a rope by thousands of individual edits, which dominates the elapsed real time cost of running the full suite. Everything else in the table is a single operation on an already-built rope.

9.1 Case study: optimizing equal?🔗ℹ

As a concrete example of how the benchmarking suite is used, here are the results of a real optimization made during development. The original content-equality check compared two ropes element by element, and it was changed to compare whole overlapping chunks at once. This was the same strategy rope-compare already used, and we hypothesized that one equal? call per chunk would beat many individual per-character calls.

Percent change in mean time, new implementation vs. old, one row per
operation/scenario, one dot per input size. Green = a significant
improvement, red = a significant regression, gray = within measurement
noise (a shaded band around each dot spans roughly two standard
deviations either side, scaled to percent of the baseline mean).

The result was 74-98% reductions (roughly 8-13x) across every scenario that requires walking real content, with no measurable effect on unrelated operations. We also observed a regression—immediate-mismatch cases became a few microseconds slower. This was expected, since the new strategy pays for slicing a whole chunk up front. See benchmark/README.md for how to produce the same kind of before/after comparison for your own patches.