From 95bcf8ef09da84dbb55d33f2e6ff0149ddd20406 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:57:42 +0000 Subject: [PATCH] PieceTable: binary-search piece lookup; lazy line-start shift Each edit shifted every later line start in the line index, O(lines) per keystroke (0.5 ms near the top of a 1M-line file). The shift is now kept pending ("from entry k, add d") and applied on read; further edits at the same line only adjust it, and it is folded in when an edit lands elsewhere. locate() walked the piece list for every lookup (two per rendered line per frame on an edited buffer); it now binary-searches prefix sums rebuilt once per modification (keyed on version_, which every mutation bumps; AdoptOriginal now bumps it after installing its piece). Typing on line 10 of a 1M-line file with a frame's worth of line reads per keystroke: 0.52 ms -> 0.017 ms. A new randomized test checks every read path against a string model with consolidation forced; it fails under each of three deliberate breakages of the new bookkeeping. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB --- PieceTable.cc | 98 +++++++++++++++++++++++++++++---------- PieceTable.h | 25 ++++++++++ tests/test_piece_table.cc | 59 ++++++++++++++++++++++- 3 files changed, 156 insertions(+), 26 deletions(-) diff --git a/PieceTable.cc b/PieceTable.cc index 6cf7840..cdd599c 100644 --- a/PieceTable.cc +++ b/PieceTable.cc @@ -53,6 +53,7 @@ PieceTable::operator=(const PieceTable &other) range_cache_ = {}; find_cache_ = {}; last_deleted_ = {}; + piece_ends_version_ = std::numeric_limits::max(); InvalidateLineIndex(); return *this; } @@ -69,6 +70,7 @@ PieceTable::PieceTable(PieceTable &&other) noexcept other.dirty_ = true; other.total_size_ = 0; other.InvalidateLineIndex(); // its index described the moved-away text + other.piece_ends_version_ = std::numeric_limits::max(); version_ = other.version_; range_cache_ = {}; find_cache_ = {}; @@ -94,6 +96,8 @@ PieceTable::operator=(PieceTable &&other) noexcept find_cache_ = {}; last_deleted_ = {}; other.last_deleted_ = {}; + piece_ends_version_ = std::numeric_limits::max(); + other.piece_ends_version_ = std::numeric_limits::max(); InvalidateLineIndex(); return *this; } @@ -209,6 +213,7 @@ PieceTable::AdoptOriginal(std::string &&bytes) total_size_ = original_.size(); if (total_size_ > 0) pieces_.push_back(Piece{Source::Original, 0, total_size_}); + version_++; } @@ -343,16 +348,20 @@ PieceTable::locate(const std::size_t byte_offset) const if (byte_offset >= total_size_) { return {pieces_.size(), 0}; } - std::size_t off = byte_offset; - for (std::size_t i = 0; i < pieces_.size(); ++i) { - const auto &p = pieces_[i]; - if (off < p.len) { - return {i, off}; - } - off -= p.len; + // Rebuilt once per modification: an edited buffer's lookups (two per + // rendered line per frame) used to walk the piece list each time. + if (piece_ends_version_ != version_ || piece_ends_.size() != pieces_.size()) { + piece_ends_.resize(pieces_.size()); + std::size_t end = 0; + for (std::size_t i = 0; i < pieces_.size(); ++i) + piece_ends_[i] = end += pieces_[i].len; + piece_ends_version_ = version_; } - // Should not reach here unless inconsistency; return end - return {pieces_.size(), 0}; + const auto it = std::upper_bound(piece_ends_.begin(), piece_ends_.end(), byte_offset); + if (it == piece_ends_.end()) + return {pieces_.size(), 0}; // inconsistent sizes; treat as the end + const std::size_t i = static_cast(it - piece_ends_.begin()); + return {i, byte_offset - (i > 0 ? piece_ends_[i - 1] : 0)}; } @@ -397,6 +406,32 @@ PieceTable::InvalidateLineIndex() const } +std::size_t +PieceTable::lineUpperBound(const std::size_t byte_offset) const +{ + // Line starts ascend; binary search over them with the shift applied. + std::size_t lo = 0, hi = line_index_.size(); + while (lo < hi) { + const std::size_t mid = lo + (hi - lo) / 2; + if (lineStart(mid) <= byte_offset) + lo = mid + 1; + else + hi = mid; + } + return lo; +} + + +void +PieceTable::applyLineShift() const +{ + for (std::size_t i = line_shift_from_; i < line_index_.size(); ++i) + line_index_[i] += line_shift_by_; + line_shift_from_ = std::numeric_limits::max(); + line_shift_by_ = 0; +} + + void PieceTable::lineIndexOnInsert(std::size_t offset, const char *text, std::size_t len) const { @@ -405,15 +440,21 @@ PieceTable::lineIndexOnInsert(std::size_t offset, const char *text, std::size_t return; // Line starts after the insertion point move right; a start exactly at // the insertion point stays (the text joins that line). - auto first_after = std::upper_bound(line_index_.begin(), line_index_.end(), offset); - for (auto it = first_after; it != line_index_.end(); ++it) - *it += len; + const std::size_t first_after = lineUpperBound(offset); std::vector added; const char *end = text + len; for (const char *p = text; (p = static_cast(std::memchr(p, '\n', end - p))) != nullptr; ++p) added.push_back(offset + static_cast(p - text) + 1); + // The pending shift can absorb this one only if it starts at the same + // line (the usual case: typing along one line). + if (line_shift_by_ != 0 && line_shift_from_ != first_after) + applyLineShift(); if (!added.empty()) - line_index_.insert(first_after, added.begin(), added.end()); + line_index_.insert(line_index_.begin() + static_cast(first_after), added.begin(), + added.end()); + // The new starts are exact; the ones after them move by len. + line_shift_from_ = first_after + added.size(); + line_shift_by_ += len; } @@ -425,11 +466,16 @@ PieceTable::lineIndexOnDelete(std::size_t offset, std::size_t len) const return; // Starts in (offset, offset+len] followed a deleted newline: drop them. // Later starts move left. - auto lo = std::upper_bound(line_index_.begin(), line_index_.end(), offset); - auto hi = std::upper_bound(lo, line_index_.end(), offset + len); - for (auto it = hi; it != line_index_.end(); ++it) - *it -= len; - line_index_.erase(lo, hi); + const std::size_t lo = lineUpperBound(offset); + const std::size_t hi = lineUpperBound(offset + len); + // After the erase the starts from hi on are at lo; a pending shift + // beginning anywhere in [lo, hi] covers exactly them. + if (line_shift_by_ != 0 && (line_shift_from_ < lo || line_shift_from_ > hi)) + applyLineShift(); + line_index_.erase(line_index_.begin() + static_cast(lo), + line_index_.begin() + static_cast(hi)); + line_shift_from_ = lo; + line_shift_by_ -= len; } @@ -443,6 +489,8 @@ PieceTable::RebuildLineIndex() const } line_index_.clear(); line_index_.push_back(0); + line_shift_from_ = std::numeric_limits::max(); + line_shift_by_ = 0; std::size_t pos = 0; for (const auto &pc: pieces_) { @@ -818,8 +866,8 @@ PieceTable::GetLineRange(std::size_t line_num) const return {0, 0}; if (line_num >= line_index_.size()) return {0, 0}; - std::size_t start = line_index_[line_num]; - std::size_t end = (line_num + 1 < line_index_.size()) ? line_index_[line_num + 1] : total_size_; + std::size_t start = lineStart(line_num); + std::size_t end = (line_num + 1 < line_index_.size()) ? lineStart(line_num + 1) : total_size_; return {start, end}; } @@ -846,9 +894,9 @@ PieceTable::ByteOffsetToLineCol(std::size_t byte_offset) const RebuildLineIndex(); if (line_index_.empty()) return {0, 0}; - auto it = std::upper_bound(line_index_.begin(), line_index_.end(), byte_offset); - std::size_t row = (it == line_index_.begin()) ? 0 : static_cast((it - line_index_.begin()) - 1); - std::size_t col = byte_offset - line_index_[row]; + const std::size_t ub = lineUpperBound(byte_offset); + std::size_t row = ub == 0 ? 0 : ub - 1; + std::size_t col = byte_offset - lineStart(row); return {row, col}; } @@ -861,8 +909,8 @@ PieceTable::LineColToByteOffset(std::size_t row, std::size_t col) const return 0; if (row >= line_index_.size()) return total_size_; - std::size_t start = line_index_[row]; - std::size_t end = (row + 1 < line_index_.size()) ? line_index_[row + 1] : total_size_; + std::size_t start = lineStart(row); + std::size_t end = (row + 1 < line_index_.size()) ? lineStart(row + 1) : total_size_; // Clamp col to the line's length without its newline (every line but // the last has one). if (end > start && row + 1 < line_index_.size()) diff --git a/PieceTable.h b/PieceTable.h index bfae059..5356ca0 100644 --- a/PieceTable.h +++ b/PieceTable.h @@ -237,9 +237,34 @@ private: mutable std::uint64_t version_ = 0; std::size_t total_size_ = 0; + // End offset of each piece (prefix sums) for binary-search lookup, + // valid while piece_ends_version_ == version_ (every mutation bumps it). + mutable std::vector piece_ends_; + mutable std::uint64_t piece_ends_version_ = std::numeric_limits::max(); + // Cached line index: starting byte offset of each line (always contains at least 1 entry: 0) mutable std::vector line_index_; mutable bool line_index_dirty_ = true; + // A shift not yet applied to line_index_: entries from index + // line_shift_from_ on are really line_index_[i] + line_shift_by_ (mod + // 2^64, so it may be negative). An edit moves every later line start; + // applying that at once cost O(lines) per keystroke, so consecutive edits + // on one line only adjust the pending shift. + mutable std::size_t line_shift_from_ = std::numeric_limits::max(); + mutable std::size_t line_shift_by_ = 0; + + // Start of line i (i < line_index_.size()), with the pending shift. + [[nodiscard]] std::size_t lineStart(std::size_t i) const + { + return i >= line_shift_from_ ? line_index_[i] + line_shift_by_ : line_index_[i]; + } + + + // Index of the first line starting after byte_offset. + [[nodiscard]] std::size_t lineUpperBound(std::size_t byte_offset) const; + + // Fold the pending shift into line_index_. + void applyLineShift() const; // Heuristic knobs std::size_t piece_limit_ = 4096; // trigger consolidation when exceeded diff --git a/tests/test_piece_table.cc b/tests/test_piece_table.cc index a2951d7..a1c1776 100644 --- a/tests/test_piece_table.cc +++ b/tests/test_piece_table.cc @@ -196,4 +196,61 @@ TEST (PieceTable_ReferenceModel_RandomEdits_Deterministic) ASSERT_EQ(r.second, exp_end); ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line)); } -} \ No newline at end of file +} + +// Random edits against a std::string model, starting from adopted original +// storage, with consolidation forced often: every read path (ranges, views, +// lines, offset conversions) must agree with the model after each edit. +TEST (PieceTable_ReferenceModel_AllReadPaths) +{ + std::mt19937 rng(0x5EEDu); + for (int round = 0; round < 20; ++round) { + PieceTable pt(0, 16, 8, 64); // tiny limits: consolidation runs constantly + std::string model = "first line\nsecond\n\nthird line here\nlast"; + pt.AdoptOriginal(std::string(model)); + for (int step = 0; step < 300; ++step) { + const std::size_t n = model.size(); + if (n == 0 || rng() % 3 != 0) { + static const char *frags[] = {"x", "\n", "ab\ncd", "", "tail\n", "\n\n", "é"}; + const std::string ins = frags[rng() % 7]; + const std::size_t at = n ? rng() % (n + 1) : 0; + pt.Insert(at, ins.data(), ins.size()); + model.insert(at, ins); + } else { + const std::size_t at = rng() % n; + const std::size_t len = 1 + rng() % std::min(n - at, 7); + pt.Delete(at, len); + model.erase(at, len); + } + ASSERT_EQ(pt.Size(), model.size()); + ASSERT_EQ(std::string(pt.ContentView()), model); + // Line structure. + std::vector starts{0}; + for (std::size_t i = 0; i < model.size(); ++i) + if (model[i] == '\n') + starts.push_back(i + 1); + ASSERT_EQ(pt.LineCount(), starts.size()); + for (std::size_t l = 0; l < starts.size(); ++l) { + const std::size_t end = l + 1 < starts.size() ? starts[l + 1] - 1 : model.size(); + ASSERT_EQ(pt.GetLine(l), model.substr(starts[l], end - starts[l])); + ASSERT_EQ(pt.LineColToByteOffset(l, 1000), end); + } + // Random ranges and views (edit first, so views take the + // no-materialization path where they can). + for (int k = 0; k < 4 && !model.empty(); ++k) { + const std::size_t off = rng() % model.size(); + const std::size_t len = 1 + rng() % 9; + ASSERT_EQ(pt.GetRange(off, len), model.substr(off, len)); + pt.Insert(0, "", 0); // no-op: must not disturb anything + ASSERT_EQ(std::string(pt.View(off, len)), model.substr(off, len)); + const auto [row, col] = pt.ByteOffsetToLineCol(off); + ASSERT_EQ(starts[row] + col, off); + } + std::string chunks; + pt.ForEachChunk([&](const char *d, std::size_t len) { + chunks.append(d, len); + }); + ASSERT_EQ(chunks, model); + } + } +}