Piece table in Python
Text-editing structure that stores edits as a list of pieces referencing an original and an add buffer.
- File
piece_table.py, 121 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/piece-table/. The authors are listed in prov/A8HMV8.jsonl.
"""Piece table: a text kept as a list of pieces into two buffers.
The original buffer is the text at load time and is never changed. The add
buffer only grows: every inserted character is appended to it once. The text
is the concatenation of the pieces in order; a piece (buf, start, length) names
buffer[buf][start : start + length]. This is the list form, in which locating
a position scans the pieces, in O(P) time for P pieces.
"""
ORIGINAL = 0
ADD = 1
class PieceTable:
"""Editable text stored as pieces over an immutable original and an append-only add buffer."""
def __init__(self, original: str = "") -> None:
self.original = original
self.add: list[str] = []
self.pieces: list[tuple[int, int, int]] = [(ORIGINAL, 0, len(original))] if original else []
self.n = len(original)
def __len__(self) -> int:
return self.n
def _buffer_slice(self, buf: int, start: int, length: int) -> str:
if buf == ORIGINAL:
return self.original[start : start + length]
return "".join(self.add[start : start + length])
def _find(self, i: int) -> tuple[int, int]:
"""Return (k, off): position i is offset off in piece k; i == n gives (P, 0)."""
off = i # @line L1
for k, (_, _, length) in enumerate(self.pieces): # @line L2
if off < length: # @line L3
return k, off
off -= length # @line L4
return len(self.pieces), 0 # @line L5
def insert(self, i: int, s: str) -> None:
"""Insert s so that it starts at position i (0 <= i <= len)."""
if not 0 <= i <= self.n:
raise IndexError("insert position out of range")
if not s: # @line L6
return
a = len(self.add) # @line L7
self.add.extend(s)
k, off = self._find(i) # @line L8
if off == 0 and k > 0: # @line L9
b, st, ln = self.pieces[k - 1]
if b == ADD and st + ln == a:
self.pieces[k - 1] = (b, st, ln + len(s))
self.n += len(s)
return
if off == 0: # @line L10
self.pieces.insert(k, (ADD, a, len(s)))
else: # @line L11
b, st, ln = self.pieces[k]
self.pieces[k : k + 1] = [(b, st, off), (ADD, a, len(s)), (b, st + off, ln - off)]
self.n += len(s)
def delete(self, i: int, d: int) -> None:
"""Delete the d characters at positions i .. i + d - 1."""
if d < 0 or not 0 <= i <= self.n - d:
raise IndexError("delete range out of range")
self.n -= d # @line L12
k, off = self._find(i) # @line L13
while d > 0: # @line L14
b, st, ln = self.pieces[k] # @line L15
take = min(d, ln - off)
if off == 0 and take == ln: # @line L16
del self.pieces[k]
elif off == 0: # @line L17
self.pieces[k] = (b, st + take, ln - take)
k += 1
elif off + take == ln: # @line L18
self.pieces[k] = (b, st, off)
k += 1
else: # @line L19
self.pieces[k : k + 1] = [(b, st, off), (b, st + off + take, ln - off - take)]
k += 1
d -= take # @line L20
off = 0
def char_at(self, i: int) -> str:
"""Return the character at position i."""
if not 0 <= i < self.n:
raise IndexError("position out of range")
k, off = self._find(i) # @line L21
b, st, _ = self.pieces[k] # @line L22
return self.original[st + off] if b == ORIGINAL else self.add[st + off]
def substring(self, i: int, j: int) -> str:
"""Return the text at positions i .. j - 1."""
if not 0 <= i <= j <= self.n:
raise IndexError("range out of range")
out: list[str] = []
k, off = self._find(i)
need = j - i
while need > 0:
b, st, ln = self.pieces[k]
take = min(need, ln - off)
out.append(self._buffer_slice(b, st + off, take))
need -= take
k, off = k + 1, 0
return "".join(out)
def text(self) -> str:
return "".join(self._buffer_slice(b, st, ln) for b, st, ln in self.pieces)
def check_invariant(self) -> None:
total = 0
for b, st, ln in self.pieces:
assert b in (ORIGINAL, ADD)
size = len(self.original) if b == ORIGINAL else len(self.add)
assert ln > 0 and 0 <= st and st + ln <= size, "piece out of its buffer"
total += ln
assert total == self.n
test_piece_table.py 70 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/piece-table/. The authors are listed in prov/A8HMV8.jsonl.
import random
import pytest
from piece_table import ADD, ORIGINAL, PieceTable
def test_example() -> None:
t = PieceTable("the fox jumps")
t.insert(4, "quick ")
assert t.pieces == [(ORIGINAL, 0, 4), (ADD, 0, 6), (ORIGINAL, 4, 9)]
t.insert(10, "brown ")
assert t.pieces == [(ORIGINAL, 0, 4), (ADD, 0, 12), (ORIGINAL, 4, 9)]
assert t.text() == "the quick brown fox jumps"
t.delete(4, 6)
assert t.pieces == [(ORIGINAL, 0, 4), (ADD, 6, 6), (ORIGINAL, 4, 9)]
assert t.text() == "the brown fox jumps"
t.delete(10, 4)
assert t.pieces == [(ORIGINAL, 0, 4), (ADD, 6, 6), (ORIGINAL, 8, 5)]
assert t.text() == "the brown jumps"
assert "".join(t.add) == "quick brown "
assert t.original == "the fox jumps"
t.check_invariant()
def test_edges() -> None:
t = PieceTable()
assert t.text() == "" and len(t) == 0
t.insert(0, "")
t.insert(0, "abc")
t.insert(3, "def")
assert t.pieces == [(ADD, 0, 6)]
t.insert(0, "x")
t.delete(0, 7)
assert t.text() == "" and t.pieces == []
t.check_invariant()
with pytest.raises(IndexError):
t.insert(1, "a")
with pytest.raises(IndexError):
t.delete(0, 1)
with pytest.raises(IndexError):
t.char_at(0)
def test_random_against_string() -> None:
rng = random.Random(4242)
for _ in range(500):
model = "".join(rng.choice("abc") for _ in range(rng.randint(0, 10)))
t = PieceTable(model)
for _ in range(rng.randint(0, 30)):
if rng.random() < 0.55 or not model:
i = rng.randint(0, len(model))
s = "".join(rng.choice("xyz") for _ in range(rng.randint(0, 4)))
t.insert(i, s)
model = model[:i] + s + model[i:]
else:
i = rng.randint(0, len(model) - 1)
d = rng.randint(0, len(model) - i)
t.delete(i, d)
model = model[:i] + model[i + d :]
t.check_invariant()
assert t.text() == model and len(t) == len(model)
if model:
i = rng.randrange(len(model))
assert t.char_at(i) == model[i]
j = rng.randint(i, len(model))
assert t.substring(i, j) == model[i:j]
What the next level needs
- the tests did not run in this build