Algorithm Search⌘K

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
piece_table.pyRawGitHub
# 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

Read the full page about Piece table