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); + } + } +}