Fix undo correctness, crash-recovery, and syntax highlighting bugs from full codebase review
Undo/redo: - cmd_newline recorded the undo node's position after the cursor moved past the split point, so undoing Enter joined the wrong pair of lines. - Backspace/Delete line-joins recorded UndoType::Newline, whose apply() semantics are inverted for a join (forward=split); add UndoType::JoinLines with correct forward/backward behavior. - Regex replace-all, indent/unindent region, kill-to-eol, kill-line, kill-region, and delete-word-prev/next mutated the buffer via the no-undo-recording raw APIs; they now record undo, grouped atomically via a shared UndoGroupGuard. - Plain-text replace-all with an empty replacement advanced the scan position one character too far, skipping adjacent/overlapping matches. Crash recovery / memory safety: - Buffer's move ctor/assignment never carried over swap_rec_, on_disk_identity_, or the visual-line-mode fields, so the crash-recovery journal silently stopped tracking a buffer whenever Editor's std::vector<Buffer> reallocated or shifted (e.g. opening/closing files). Added SwapManager::Rehome() plus Flush()-before-mutate in Editor::AddBuffer/CloseBuffer so the journal's Buffer* key and the background writer thread never reference a stale address. - UndoTree leaked its entire node graph (including all edit text) on every buffer close/reload; added ~UndoTree() to free it. - main.cc didn't restore the terminal if an exception escaped the run loop; added an RAII guard around Frontend::Shutdown(). Input handling: - Terminal frontend used getch() instead of get_wch(), silently dropping non-ASCII keyboard input despite linking wide-char ncurses. - KKeymap's C-k lookup switches on an already-lowercased key, making `case 'E'` unreachable dead code; C-k Shift-E silently aliased to C-k e. - ImGui file picker wasn't modal: keystrokes typed while it was open still reached the buffer underneath as edit commands, and Escape didn't close it. - Gated ImGuiInputHandler's unconditional fprintf/fflush diagnostics behind an IMGUI_IH_DEBUG macro, matching QtInputHandler's existing convention. Syntax highlighting: - Go/Rust/SQL highlighters weren't stateful, so multi-line /* */ comments mis-highlighted as code starting on the second line. - Python's triple-quoted-string handler didn't re-scan the remainder of a line after a string closed mid-line, missing a same-line reopen. - HighlighterEngine's state_last_contig_ field was declared but unused, forcing an O(n) scan of state_cache_ on every stateful lookup; wired it up as a real fast path and fixed InvalidateFrom() to keep it in sync. - kge's :syntax off was silently undone the next frame because apply_syntax_to_buffer() re-applied the config default unconditionally every frame; added a user-override flag mirroring the existing edit-mode-detection guard. Added regression tests for all of the above (test_undo.cc, test_kkeymap.cc, test_command_semantics.cc, test_search_replace_flow.cc, and a new test_syntax_highlighting.cc). Full suite (163 tests) passes, including under AddressSanitizer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
22
Buffer.cc
22
Buffer.cc
@@ -236,6 +236,7 @@ Buffer::Buffer(const Buffer &other)
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
syntax_user_override_ = other.syntax_user_override_;
|
||||
filetype_ = other.filetype_;
|
||||
// Fresh undo system for the copy
|
||||
undo_tree_ = std::make_unique<UndoTree>();
|
||||
@@ -287,6 +288,7 @@ Buffer::operator=(const Buffer &other)
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
syntax_user_override_ = other.syntax_user_override_;
|
||||
filetype_ = other.filetype_;
|
||||
// Recreate undo system for this instance
|
||||
undo_tree_ = std::make_unique<UndoTree>();
|
||||
@@ -327,6 +329,9 @@ Buffer::Buffer(Buffer &&other) noexcept
|
||||
mark_set_(other.mark_set_),
|
||||
mark_curx_(other.mark_curx_),
|
||||
mark_cury_(other.mark_cury_),
|
||||
visual_line_active_(other.visual_line_active_),
|
||||
visual_line_anchor_y_(other.visual_line_anchor_y_),
|
||||
visual_line_active_y_(other.visual_line_active_y_),
|
||||
undo_tree_(std::move(other.undo_tree_)),
|
||||
undo_sys_(std::move(other.undo_sys_))
|
||||
{
|
||||
@@ -335,10 +340,17 @@ Buffer::Buffer(Buffer &&other) noexcept
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
syntax_user_override_ = other.syntax_user_override_;
|
||||
filetype_ = std::move(other.filetype_);
|
||||
highlighter_ = std::move(other.highlighter_);
|
||||
content_ = std::move(other.content_);
|
||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
||||
on_disk_identity_ = other.on_disk_identity_;
|
||||
// Non-owning: the recorder object itself is owned by SwapManager and outlives
|
||||
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
|
||||
// so the journal's Buffer* key follows this object to its new address.
|
||||
swap_rec_ = other.swap_rec_;
|
||||
other.swap_rec_ = nullptr;
|
||||
// Update UndoSystem's buffer reference to point to this object
|
||||
if (undo_sys_) {
|
||||
undo_sys_->UpdateBufferReference(*this);
|
||||
@@ -367,6 +379,9 @@ Buffer::operator=(Buffer &&other) noexcept
|
||||
mark_set_ = other.mark_set_;
|
||||
mark_curx_ = other.mark_curx_;
|
||||
mark_cury_ = other.mark_cury_;
|
||||
visual_line_active_ = other.visual_line_active_;
|
||||
visual_line_anchor_y_ = other.visual_line_anchor_y_;
|
||||
visual_line_active_y_ = other.visual_line_active_y_;
|
||||
undo_tree_ = std::move(other.undo_tree_);
|
||||
undo_sys_ = std::move(other.undo_sys_);
|
||||
|
||||
@@ -375,10 +390,17 @@ Buffer::operator=(Buffer &&other) noexcept
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
syntax_user_override_ = other.syntax_user_override_;
|
||||
filetype_ = std::move(other.filetype_);
|
||||
highlighter_ = std::move(other.highlighter_);
|
||||
content_ = std::move(other.content_);
|
||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
||||
on_disk_identity_ = other.on_disk_identity_;
|
||||
// Non-owning: the recorder object itself is owned by SwapManager and outlives
|
||||
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
|
||||
// so the journal's Buffer* key follows this object to its new address.
|
||||
swap_rec_ = other.swap_rec_;
|
||||
other.swap_rec_ = nullptr;
|
||||
// Update UndoSystem's buffer reference to point to this object
|
||||
if (undo_sys_) {
|
||||
undo_sys_->UpdateBufferReference(*this);
|
||||
|
||||
20
Buffer.h
20
Buffer.h
@@ -548,6 +548,25 @@ public:
|
||||
}
|
||||
|
||||
|
||||
// Marks that the user explicitly set syntax state via a command (:syntax
|
||||
// on/off, :set filetype=...), as opposed to it being auto-applied from
|
||||
// GUIConfig. Frontends that re-apply the config-driven default every
|
||||
// frame (e.g. ImGuiFrontend::apply_syntax_to_buffer) must check this
|
||||
// first, mirroring EditModeDetected()'s "don't stomp a manual toggle"
|
||||
// pattern - otherwise a manual :syntax off is silently undone on the very
|
||||
// next frame.
|
||||
void SetSyntaxUserOverride(bool on)
|
||||
{
|
||||
syntax_user_override_ = on;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool SyntaxUserOverride() const
|
||||
{
|
||||
return syntax_user_override_;
|
||||
}
|
||||
|
||||
|
||||
void SetFiletype(const std::string &ft)
|
||||
{
|
||||
filetype_ = ft;
|
||||
@@ -673,6 +692,7 @@ private:
|
||||
// Syntax/highlighting state
|
||||
std::uint64_t version_ = 0; // increment on edits
|
||||
bool syntax_enabled_ = true;
|
||||
bool syntax_user_override_ = false; // true once user explicitly set syntax state via a command
|
||||
std::string filetype_;
|
||||
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
||||
// Non-owning pointer to swap recorder managed by Editor/SwapManager
|
||||
|
||||
@@ -344,6 +344,7 @@ if (BUILD_TESTS)
|
||||
tests/test_migration_coverage.cc
|
||||
tests/test_smart_newline.cc
|
||||
tests/test_reflow_undo.cc
|
||||
tests/test_syntax_highlighting.cc
|
||||
|
||||
# minimal engine sources required by Buffer
|
||||
PieceTable.cc
|
||||
|
||||
204
Command.cc
204
Command.cc
@@ -159,6 +159,27 @@ ensure_at_least_one_line(Buffer &buf)
|
||||
}
|
||||
|
||||
|
||||
// RAII helper: brackets a multi-edit command so its individual undo nodes
|
||||
// undo/redo as a single atomic step, per the project's undo-grouping convention.
|
||||
struct UndoGroupGuard {
|
||||
UndoSystem *u;
|
||||
|
||||
|
||||
explicit UndoGroupGuard(UndoSystem *u_) : u(u_)
|
||||
{
|
||||
if (u)
|
||||
u->BeginGroup();
|
||||
}
|
||||
|
||||
|
||||
~UndoGroupGuard()
|
||||
{
|
||||
if (u)
|
||||
u->EndGroup();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Determine if a command mutates the buffer contents (text edits)
|
||||
static bool
|
||||
is_mutating_command(CommandId id)
|
||||
@@ -264,8 +285,12 @@ extract_region_text(const Buffer &buf, std::size_t sx, std::size_t sy, std::size
|
||||
|
||||
|
||||
// Helper: delete region and leave cursor at start (sx,sy). Adjust lines appropriately.
|
||||
// If `u` is non-null, each underlying mutation is recorded as its own undo node;
|
||||
// callers that want the whole region-delete to undo/redo as one step should
|
||||
// wrap the call in an UndoGroupGuard.
|
||||
static void
|
||||
delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::size_t ey)
|
||||
delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::size_t ey,
|
||||
UndoSystem *u = nullptr)
|
||||
{
|
||||
std::size_t nrows = buf.Nrows();
|
||||
if (nrows == 0)
|
||||
@@ -282,7 +307,14 @@ delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::
|
||||
std::size_t xe = std::min(ex, line.size());
|
||||
if (xe < xs)
|
||||
std::swap(xs, xe);
|
||||
std::string deleted = std::string(line.substr(xs, xe - xs));
|
||||
buf.delete_text(static_cast<int>(sy), static_cast<int>(xs), xe - xs);
|
||||
if (u && !deleted.empty()) {
|
||||
buf.SetCursor(xs, sy);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(std::string_view(deleted));
|
||||
u->commit();
|
||||
}
|
||||
} else {
|
||||
// Multi-line: delete from (sx,sy) to (ex,ey)
|
||||
// Strategy:
|
||||
@@ -303,11 +335,25 @@ delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::
|
||||
|
||||
// Delete tail of first line (from xs to end)
|
||||
if (xs < first_line_len) {
|
||||
std::string tail = std::string(rows[sy].substr(xs));
|
||||
buf.delete_text(static_cast<int>(sy), static_cast<int>(xs), first_line_len - xs);
|
||||
if (u && !tail.empty()) {
|
||||
buf.SetCursor(xs, sy);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(std::string_view(tail));
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
|
||||
// Delete lines from ey down to sy+1 (reverse order to preserve indices)
|
||||
for (std::size_t i = ey; i > sy; --i) {
|
||||
if (u) {
|
||||
std::string row_text = static_cast<std::string>(buf.Rows()[i]);
|
||||
buf.SetCursor(0, i);
|
||||
u->Begin(UndoType::DeleteRow);
|
||||
u->Append(std::string_view(row_text));
|
||||
u->commit();
|
||||
}
|
||||
buf.delete_row(static_cast<int>(i));
|
||||
}
|
||||
|
||||
@@ -317,6 +363,12 @@ delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::
|
||||
const auto &rows_after = buf.Rows();
|
||||
std::size_t line_len = rows_after[sy].size();
|
||||
buf.insert_text(static_cast<int>(sy), static_cast<int>(line_len), suffix);
|
||||
if (u) {
|
||||
buf.SetCursor(line_len, sy);
|
||||
u->Begin(UndoType::Insert);
|
||||
u->Append(std::string_view(suffix));
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
buf.SetCursor(sx, sy);
|
||||
@@ -908,6 +960,10 @@ cmd_unknown_esc_command(CommandContext &ctx)
|
||||
static void
|
||||
apply_filetype(Buffer &buf, const std::string &ft)
|
||||
{
|
||||
// Only reachable from explicit user commands (:syntax on, :set
|
||||
// filetype=...) - mark so frontends that re-apply config-driven syntax
|
||||
// defaults every frame (e.g. ImGuiFrontend) don't stomp this choice.
|
||||
buf.SetSyntaxUserOverride(true);
|
||||
buf.EnsureHighlighter();
|
||||
auto *eng = buf.Highlighter();
|
||||
if (!eng)
|
||||
@@ -977,6 +1033,7 @@ cmd_syntax(CommandContext &ctx)
|
||||
};
|
||||
trim(arg);
|
||||
if (arg == "on") {
|
||||
b->SetSyntaxUserOverride(true);
|
||||
b->SetSyntaxEnabled(true);
|
||||
// If no highlighter but filetype is cpp by extension, set it
|
||||
if (!b->Highlighter() || !b->Highlighter()->HasHighlighter()) {
|
||||
@@ -984,6 +1041,7 @@ cmd_syntax(CommandContext &ctx)
|
||||
}
|
||||
ctx.editor.SetStatus("syntax: on");
|
||||
} else if (arg == "off") {
|
||||
b->SetSyntaxUserOverride(true);
|
||||
b->SetSyntaxEnabled(false);
|
||||
ctx.editor.SetStatus("syntax: off");
|
||||
} else if (arg == "reload") {
|
||||
@@ -2509,12 +2567,10 @@ cmd_newline(CommandContext &ctx)
|
||||
}
|
||||
pos = p + with.size();
|
||||
} else {
|
||||
// When replacing with empty, continue after the deletion point to avoid re-matching
|
||||
// Replacing with empty leaves nothing inserted at the deletion
|
||||
// point, so resume scanning from `p` itself (not p+1) to catch
|
||||
// adjacent/overlapping matches, e.g. "aaaa" -> "" replacing "aa".
|
||||
pos = p;
|
||||
if (pos < static_cast<std::size_t>(buf->Rows()[y].size()))
|
||||
++pos;
|
||||
else
|
||||
break;
|
||||
}
|
||||
++total;
|
||||
}
|
||||
@@ -2923,14 +2979,28 @@ cmd_newline(CommandContext &ctx)
|
||||
return true;
|
||||
}
|
||||
std::size_t changed = 0;
|
||||
UndoSystem *ru = buf->Undo();
|
||||
UndoGroupGuard rguard(ru);
|
||||
// Iterate by index to allow modifications via PieceTable helpers
|
||||
for (std::size_t y = 0; y < buf->Rows().size(); ++y) {
|
||||
std::string before = static_cast<std::string>(buf->Rows()[y]);
|
||||
std::string after = std::regex_replace(before, rx, repl);
|
||||
if (after != before) {
|
||||
// Replace entire line y with 'after' using PieceTable ops
|
||||
if (ru) {
|
||||
buf->SetCursor(0, y);
|
||||
ru->Begin(UndoType::DeleteRow);
|
||||
ru->Append(std::string_view(before));
|
||||
ru->commit();
|
||||
}
|
||||
buf->delete_row(static_cast<int>(y));
|
||||
buf->insert_row(static_cast<int>(y), std::string_view(after));
|
||||
if (ru) {
|
||||
buf->SetCursor(0, y);
|
||||
ru->Begin(UndoType::InsertRow);
|
||||
ru->Append(std::string_view(after));
|
||||
ru->commit();
|
||||
}
|
||||
++changed;
|
||||
}
|
||||
}
|
||||
@@ -3003,18 +3073,28 @@ cmd_newline(CommandContext &ctx)
|
||||
ensure_cursor_visible(ctx.editor, *buf);
|
||||
return true;
|
||||
}
|
||||
UndoSystem *u = buf->Undo();
|
||||
if (u && repeat > 1)
|
||||
u->BeginGroup();
|
||||
for (int i = 0; i < repeat; ++i) {
|
||||
// Sync the buffer's cursor to the split point before Begin(), which
|
||||
// records its node's row/col from the buffer's *current* cursor. Undo
|
||||
// must reverse this exact split, not wherever the cursor ends up after
|
||||
// the whole loop finishes.
|
||||
buf->SetCursor(x, y);
|
||||
if (u) {
|
||||
u->Begin(UndoType::Newline);
|
||||
u->commit();
|
||||
}
|
||||
buf->split_line(static_cast<int>(y), static_cast<int>(x));
|
||||
// Move to start of next line
|
||||
y += 1;
|
||||
x = 0;
|
||||
}
|
||||
if (u && repeat > 1)
|
||||
u->EndGroup();
|
||||
buf->SetCursor(x, y);
|
||||
buf->SetDirty(true);
|
||||
if (auto *u = buf->Undo()) {
|
||||
u->Begin(UndoType::Newline);
|
||||
u->commit();
|
||||
}
|
||||
ensure_cursor_visible(ctx.editor, *buf);
|
||||
return true;
|
||||
}
|
||||
@@ -3198,7 +3278,9 @@ cmd_backspace(CommandContext &ctx)
|
||||
x = prev_len;
|
||||
buf->SetCursor(x, y);
|
||||
if (u) {
|
||||
u->Begin(UndoType::Newline);
|
||||
// Forward action here is a join, not a split: JoinLines has the
|
||||
// correct (inverted) apply() semantics, unlike Newline.
|
||||
u->Begin(UndoType::JoinLines);
|
||||
u->commit();
|
||||
}
|
||||
} else {
|
||||
@@ -3277,7 +3359,9 @@ cmd_delete_char(CommandContext &ctx)
|
||||
} else if (y + 1 < rows_view.size()) {
|
||||
buf->join_lines(static_cast<int>(y));
|
||||
if (u) {
|
||||
u->Begin(UndoType::Newline);
|
||||
// Forward action here is a join, not a split: JoinLines has the
|
||||
// correct (inverted) apply() semantics, unlike Newline.
|
||||
u->Begin(UndoType::JoinLines);
|
||||
u->commit();
|
||||
}
|
||||
} else {
|
||||
@@ -3350,18 +3434,32 @@ cmd_kill_to_eol(CommandContext &ctx)
|
||||
std::size_t x = buf->Curx();
|
||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
||||
std::string killed_total;
|
||||
UndoSystem *u = buf->Undo();
|
||||
UndoGroupGuard guard(u);
|
||||
for (int i = 0; i < repeat; ++i) {
|
||||
const auto &rows_view = buf->Rows();
|
||||
if (y >= rows_view.size())
|
||||
break;
|
||||
if (x < rows_view[y].size()) {
|
||||
// delete from cursor to end of line
|
||||
killed_total += rows_view[y].substr(x);
|
||||
std::string seg = static_cast<std::string>(rows_view[y].substr(x));
|
||||
killed_total += seg;
|
||||
std::size_t len = rows_view[y].size() - x;
|
||||
buf->delete_text(static_cast<int>(y), static_cast<int>(x), len);
|
||||
if (u) {
|
||||
buf->SetCursor(x, y);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(std::string_view(seg));
|
||||
u->commit();
|
||||
}
|
||||
} else if (y + 1 < rows_view.size()) {
|
||||
// at EOL: delete the newline (join with next line)
|
||||
killed_total += "\n";
|
||||
if (u) {
|
||||
buf->SetCursor(x, y);
|
||||
u->Begin(UndoType::JoinLines);
|
||||
u->commit();
|
||||
}
|
||||
buf->join_lines(static_cast<int>(y));
|
||||
} else {
|
||||
// nothing to delete
|
||||
@@ -3395,20 +3493,37 @@ cmd_kill_line(CommandContext &ctx)
|
||||
(void) x; // cursor x will be reset to 0
|
||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
||||
std::string killed_total;
|
||||
UndoSystem *u = buf->Undo();
|
||||
UndoGroupGuard guard(u);
|
||||
for (int i = 0; i < repeat; ++i) {
|
||||
const auto &rows_view = buf->Rows();
|
||||
if (rows_view.empty())
|
||||
break;
|
||||
if (rows_view.size() == 1) {
|
||||
// last remaining line: clear its contents
|
||||
killed_total += static_cast<std::string>(rows_view[0]);
|
||||
if (!rows_view[0].empty())
|
||||
buf->delete_text(0, 0, rows_view[0].size());
|
||||
std::string content = static_cast<std::string>(rows_view[0]);
|
||||
killed_total += content;
|
||||
if (!content.empty()) {
|
||||
buf->delete_text(0, 0, content.size());
|
||||
if (u) {
|
||||
buf->SetCursor(0, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(std::string_view(content));
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
y = 0;
|
||||
} else if (y < rows_view.size()) {
|
||||
// erase current line; keep y pointing at the next line
|
||||
killed_total += static_cast<std::string>(rows_view[y]);
|
||||
std::string content = static_cast<std::string>(rows_view[y]);
|
||||
killed_total += content;
|
||||
killed_total += "\n";
|
||||
if (u) {
|
||||
buf->SetCursor(0, y);
|
||||
u->Begin(UndoType::DeleteRow);
|
||||
u->Append(std::string_view(content));
|
||||
u->commit();
|
||||
}
|
||||
buf->delete_row(static_cast<int>(y));
|
||||
const auto &rows_after = buf->Rows();
|
||||
if (y >= rows_after.size()) {
|
||||
@@ -3622,7 +3737,10 @@ cmd_kill_region(CommandContext &ctx)
|
||||
return false;
|
||||
}
|
||||
std::string text = extract_region_text(*buf, sx, sy, ex, ey);
|
||||
delete_region(*buf, sx, sy, ex, ey);
|
||||
{
|
||||
UndoGroupGuard guard(buf->Undo());
|
||||
delete_region(*buf, sx, sy, ex, ey, buf->Undo());
|
||||
}
|
||||
ensure_cursor_visible(ctx.editor, *buf);
|
||||
if (!text.empty()) {
|
||||
if (ctx.editor.KillChain())
|
||||
@@ -4234,6 +4352,7 @@ cmd_delete_word_prev(CommandContext &ctx)
|
||||
std::size_t x = buf->Curx();
|
||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
||||
std::string killed_total;
|
||||
UndoGroupGuard guard(buf->Undo());
|
||||
for (int i = 0; i < repeat; ++i) {
|
||||
if (y >= rows.size()) {
|
||||
y = rows.empty() ? 0 : rows.size() - 1;
|
||||
@@ -4275,7 +4394,7 @@ cmd_delete_word_prev(CommandContext &ctx)
|
||||
}
|
||||
// Now delete from (x, y) to (start_x, start_y) using PieceTable
|
||||
std::string deleted = extract_region_text(*buf, x, y, start_x, start_y);
|
||||
delete_region(*buf, x, y, start_x, start_y);
|
||||
delete_region(*buf, x, y, start_x, start_y, buf->Undo());
|
||||
// Prepend to killed_total (since we're deleting backwards)
|
||||
killed_total = deleted + killed_total;
|
||||
}
|
||||
@@ -4307,6 +4426,7 @@ cmd_delete_word_next(CommandContext &ctx)
|
||||
std::size_t x = buf->Curx();
|
||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
||||
std::string killed_total;
|
||||
UndoGroupGuard guard(buf->Undo());
|
||||
for (int i = 0; i < repeat; ++i) {
|
||||
if (y >= rows.size())
|
||||
break;
|
||||
@@ -4346,7 +4466,7 @@ cmd_delete_word_next(CommandContext &ctx)
|
||||
}
|
||||
// Now delete from (start_x, start_y) to (x, y) using PieceTable
|
||||
std::string deleted = extract_region_text(*buf, start_x, start_y, x, y);
|
||||
delete_region(*buf, start_x, start_y, x, y);
|
||||
delete_region(*buf, start_x, start_y, x, y, buf->Undo());
|
||||
y = start_y;
|
||||
x = start_x;
|
||||
killed_total += deleted;
|
||||
@@ -4380,8 +4500,16 @@ cmd_indent_region(CommandContext &ctx)
|
||||
ctx.editor.SetStatus("No region to indent");
|
||||
return false;
|
||||
}
|
||||
UndoSystem *u = buf->Undo();
|
||||
UndoGroupGuard guard(u);
|
||||
for (std::size_t y = sy; y <= ey && y < buf->Rows().size(); ++y) {
|
||||
buf->insert_text(static_cast<int>(y), 0, std::string_view("\t"));
|
||||
if (u) {
|
||||
buf->SetCursor(0, y);
|
||||
u->Begin(UndoType::Insert);
|
||||
u->Append('\t');
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
buf->SetDirty(true);
|
||||
buf->ClearMark();
|
||||
@@ -4405,6 +4533,8 @@ cmd_unindent_region(CommandContext &ctx)
|
||||
ctx.editor.SetStatus("No region to unindent");
|
||||
return false;
|
||||
}
|
||||
UndoSystem *u = buf->Undo();
|
||||
UndoGroupGuard guard(u);
|
||||
for (std::size_t y = sy; y <= ey && y < buf->Rows().size(); ++y) {
|
||||
const auto &rows_view = buf->Rows();
|
||||
if (y >= rows_view.size())
|
||||
@@ -4413,13 +4543,26 @@ cmd_unindent_region(CommandContext &ctx)
|
||||
if (!line.empty()) {
|
||||
if (line[0] == '\t') {
|
||||
buf->delete_text(static_cast<int>(y), 0, 1);
|
||||
if (u) {
|
||||
buf->SetCursor(0, y);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append('\t');
|
||||
u->commit();
|
||||
}
|
||||
} else if (line[0] == ' ') {
|
||||
std::size_t spaces = 0;
|
||||
while (spaces < line.size() && spaces < 8 && line[spaces] == ' ') {
|
||||
++spaces;
|
||||
}
|
||||
if (spaces > 0)
|
||||
if (spaces > 0) {
|
||||
buf->delete_text(static_cast<int>(y), 0, spaces);
|
||||
if (u) {
|
||||
buf->SetCursor(0, y);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(line.substr(0, spaces));
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4436,25 +4579,8 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf)
|
||||
return false;
|
||||
struct GroupGuard {
|
||||
UndoSystem *u;
|
||||
|
||||
|
||||
explicit GroupGuard(UndoSystem *u_) : u(u_)
|
||||
{
|
||||
if (u)
|
||||
u->BeginGroup();
|
||||
}
|
||||
|
||||
|
||||
~GroupGuard()
|
||||
{
|
||||
if (u)
|
||||
u->EndGroup();
|
||||
}
|
||||
};
|
||||
// Reflow performs a multi-edit transformation; make it a single standalone undo/redo step.
|
||||
GroupGuard guard(buf->Undo());
|
||||
UndoGroupGuard guard(buf->Undo());
|
||||
if (auto *u = buf->Undo())
|
||||
u->commit();
|
||||
ensure_at_least_one_line(*buf);
|
||||
|
||||
41
Editor.cc
41
Editor.cc
@@ -165,9 +165,23 @@ std::size_t
|
||||
Editor::AddBuffer(const Buffer &buf)
|
||||
{
|
||||
auto &bufs = Buffers();
|
||||
// push_back may reallocate the vector's storage, moving every existing
|
||||
// Buffer to a new address. Drain any in-flight swap records first so the
|
||||
// writer thread never dereferences an address that's about to move, then
|
||||
// rehome each attached buffer that actually moved.
|
||||
if (swap_ && !bufs.empty())
|
||||
swap_->Flush();
|
||||
std::vector<Buffer *> old_addrs;
|
||||
old_addrs.reserve(bufs.size());
|
||||
for (auto &b: bufs)
|
||||
old_addrs.push_back(&b);
|
||||
bufs.push_back(buf);
|
||||
// Attach swap recorder
|
||||
if (swap_) {
|
||||
for (std::size_t i = 0; i < old_addrs.size(); ++i) {
|
||||
Buffer *new_addr = &bufs[i];
|
||||
if (new_addr != old_addrs[i])
|
||||
bufs[i].SetSwapRecorder(swap_->Rehome(old_addrs[i], new_addr));
|
||||
}
|
||||
swap_->Attach(&bufs.back());
|
||||
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
|
||||
}
|
||||
@@ -182,8 +196,19 @@ std::size_t
|
||||
Editor::AddBuffer(Buffer &&buf)
|
||||
{
|
||||
auto &bufs = Buffers();
|
||||
if (swap_ && !bufs.empty())
|
||||
swap_->Flush();
|
||||
std::vector<Buffer *> old_addrs;
|
||||
old_addrs.reserve(bufs.size());
|
||||
for (auto &b: bufs)
|
||||
old_addrs.push_back(&b);
|
||||
bufs.push_back(std::move(buf));
|
||||
if (swap_) {
|
||||
for (std::size_t i = 0; i < old_addrs.size(); ++i) {
|
||||
Buffer *new_addr = &bufs[i];
|
||||
if (new_addr != old_addrs[i])
|
||||
bufs[i].SetSwapRecorder(swap_->Rehome(old_addrs[i], new_addr));
|
||||
}
|
||||
swap_->Attach(&bufs.back());
|
||||
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
|
||||
}
|
||||
@@ -495,8 +520,22 @@ Editor::CloseBuffer(std::size_t index)
|
||||
// This prevents stale swap files from accumulating (e.g., when used as git editor).
|
||||
swap_->Detach(&bufs[index], true);
|
||||
bufs[index].SetSwapRecorder(nullptr);
|
||||
// Drain in-flight records before the erase-shift below moves other
|
||||
// buffers to new addresses (vector::erase never reallocates, but it does
|
||||
// move-assign each trailing buffer into the previous slot).
|
||||
swap_->Flush();
|
||||
}
|
||||
bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
if (swap_) {
|
||||
// erase() shifts every buffer after `index` down by one slot in-place
|
||||
// (no reallocation), so the buffer now at slot i used to live at slot
|
||||
// i+1 (same underlying storage, since data() doesn't move on erase).
|
||||
for (std::size_t i = index; i < bufs.size(); ++i) {
|
||||
Buffer *new_addr = &bufs[i];
|
||||
Buffer *old_addr = new_addr + 1;
|
||||
bufs[i].SetSwapRecorder(swap_->Rehome(old_addr, new_addr));
|
||||
}
|
||||
}
|
||||
if (bufs.empty()) {
|
||||
curbuf_ = 0;
|
||||
} else if (curbuf_ >= bufs.size()) {
|
||||
|
||||
@@ -44,6 +44,12 @@ apply_syntax_to_buffer(Buffer *b, const GUIConfig &cfg)
|
||||
if (!b->EditModeDetected() && !b->Filename().empty())
|
||||
b->SetEditMode(DetectEditMode(b->Filename()));
|
||||
|
||||
// If the user explicitly set syntax state via a command (:syntax on/off,
|
||||
// :set filetype=...), leave it alone - otherwise this runs every frame
|
||||
// and silently undoes a manual ":syntax off" on the very next frame.
|
||||
if (b->SyntaxUserOverride())
|
||||
return;
|
||||
|
||||
// Writing mode disables syntax; otherwise follow the global config.
|
||||
if (cfg.syntax && b->GetEditMode() != EditMode::Writing) {
|
||||
b->SetSyntaxEnabled(true);
|
||||
@@ -469,9 +475,21 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
||||
|
||||
// Route input events to the correct window's input handler
|
||||
if (target) {
|
||||
Editor &tgt_ed = (target_idx == 0) ? ed : target->editor;
|
||||
if (tgt_ed.FilePickerVisible()) {
|
||||
// Modal: don't let keystrokes fall through as edit commands
|
||||
// to the buffer underneath. Escape closes the picker;
|
||||
// everything else (navigation, filtering, double-click) is
|
||||
// handled by the picker's own ImGui widgets via
|
||||
// ImGui_ImplSDL2_ProcessEvent above.
|
||||
if (e.type == SDL_KEYDOWN && e.key.keysym.sym == SDLK_ESCAPE) {
|
||||
tgt_ed.SetFilePickerVisible(false);
|
||||
}
|
||||
} else {
|
||||
target->input.ProcessSDLEvent(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!running)
|
||||
return;
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
#include "KKeymap.h"
|
||||
#include "Editor.h"
|
||||
|
||||
// Verbose k-prefix suffix logging for debugging macOS/SDL key translation
|
||||
// issues. Default to off; enable by defining IMGUI_IH_DEBUG=1 at compile
|
||||
// time. Mirrors QtInputHandler.cc's QT_IH_DEBUG gate.
|
||||
#ifndef IMGUI_IH_DEBUG
|
||||
#define IMGUI_IH_DEBUG 0
|
||||
#endif
|
||||
|
||||
#if IMGUI_IH_DEBUG
|
||||
#define IH_LOGF(...) do { std::fprintf(stderr, __VA_ARGS__); std::fflush(stderr); } while (0)
|
||||
#else
|
||||
#define IH_LOGF(...) ((void) 0)
|
||||
#endif
|
||||
|
||||
|
||||
static bool
|
||||
map_key(const SDL_Keycode key,
|
||||
@@ -182,18 +195,19 @@ map_key(const SDL_Keycode key,
|
||||
k_ctrl_pending = false;
|
||||
CommandId id;
|
||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||
#if IMGUI_IH_DEBUG
|
||||
// Diagnostics for u/U
|
||||
if (lower == 'u') {
|
||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||
? static_cast<char>(ascii_key)
|
||||
: '?';
|
||||
std::fprintf(stderr,
|
||||
IH_LOGF(
|
||||
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
|
||||
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
||||
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
||||
mapped ? static_cast<int>(id) : -1);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
#endif
|
||||
if (mapped) {
|
||||
out = {true, id, "", 0};
|
||||
if (ed)
|
||||
@@ -524,15 +538,17 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
bool pass_ctrl = k_ctrl_pending_;
|
||||
k_ctrl_pending_ = false;
|
||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||
#if IMGUI_IH_DEBUG
|
||||
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping
|
||||
{
|
||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||
? static_cast<char>(ascii_key)
|
||||
: '?';
|
||||
std::fprintf(stderr,
|
||||
"[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
||||
IH_LOGF("[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
||||
ascii_key, disp, mapped ? 1 : 0,
|
||||
mapped ? static_cast<int>(id) : -1);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
#endif
|
||||
if (mapped) {
|
||||
mi = {true, id, "", 0};
|
||||
if (ed_)
|
||||
|
||||
14
KKeymap.cc
14
KKeymap.cc
@@ -1,6 +1,4 @@
|
||||
#include <iostream>
|
||||
#include <ncurses.h>
|
||||
#include <ostream>
|
||||
|
||||
#include "KKeymap.h"
|
||||
|
||||
@@ -40,6 +38,15 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
|
||||
return true;
|
||||
}
|
||||
if (ascii_key == 'E') {
|
||||
// Explicitly rejected and kept distinct from 'e' (OpenFileStart): the
|
||||
// switch below operates on the lowercased key, so it can't otherwise
|
||||
// tell 'E' and 'e' apart. Return false and let the caller show its
|
||||
// normal "unknown k-command" status-line message - curses already
|
||||
// owns the terminal here, so writing to stderr would corrupt the
|
||||
// screen.
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (k_lower) {
|
||||
case 'a':
|
||||
@@ -66,9 +73,6 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
case 'e':
|
||||
out = CommandId::OpenFileStart;
|
||||
return true;
|
||||
case 'E':
|
||||
std::cerr << "E is not a valid command" << std::endl;
|
||||
return false;
|
||||
case 'f':
|
||||
out = CommandId::FlushKillRing;
|
||||
return true;
|
||||
|
||||
37
Swap.cc
37
Swap.cc
@@ -268,6 +268,43 @@ SwapManager::RecorderFor(Buffer *buf)
|
||||
}
|
||||
|
||||
|
||||
SwapRecorder *
|
||||
SwapManager::Rehome(Buffer *old_addr, Buffer *new_addr)
|
||||
{
|
||||
if (!old_addr || !new_addr || old_addr == new_addr)
|
||||
return nullptr;
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
SwapRecorder *result = nullptr;
|
||||
|
||||
auto jit = journals_.find(old_addr);
|
||||
if (jit != journals_.end()) {
|
||||
JournalCtx ctx = std::move(jit->second);
|
||||
journals_.erase(jit);
|
||||
journals_[new_addr] = std::move(ctx);
|
||||
}
|
||||
|
||||
auto rit = recorders_.find(old_addr);
|
||||
if (rit != recorders_.end()) {
|
||||
recorders_.erase(rit);
|
||||
// BufferRecorder binds a Buffer& at construction, so it can't be
|
||||
// repointed in place; rebuild it against the buffer's new address.
|
||||
auto rec = std::make_unique<BufferRecorder>(*this, *new_addr);
|
||||
result = rec.get();
|
||||
recorders_[new_addr] = std::move(rec);
|
||||
}
|
||||
|
||||
// Defensive: any record still queued (not yet drained by the writer thread)
|
||||
// for the old address must follow the buffer to its new location. Callers
|
||||
// are expected to Flush() before rehoming so this should normally be a no-op.
|
||||
for (auto &p: queue_) {
|
||||
if (p.buf == old_addr)
|
||||
p.buf = new_addr;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::Attach(Buffer *buf)
|
||||
{
|
||||
|
||||
9
Swap.h
9
Swap.h
@@ -94,6 +94,15 @@ public:
|
||||
// Detach(buf) or SwapManager destruction.
|
||||
SwapRecorder *RecorderFor(Buffer *buf);
|
||||
|
||||
// Re-key an attached buffer's journal/recorder entries after its Buffer object
|
||||
// has moved to a new address (e.g. std::vector<Buffer> reallocation/erase-shift).
|
||||
// Callers must ensure no swap records for old_addr are in flight (see Flush())
|
||||
// before calling this, and must not call it with an address that isn't
|
||||
// currently attached. Returns the recorder for new_addr (nullptr if old_addr
|
||||
// wasn't attached); the caller is responsible for calling
|
||||
// new_buf->SetSwapRecorder() with the result.
|
||||
SwapRecorder *Rehome(Buffer *old_addr, Buffer *new_addr);
|
||||
|
||||
// Notify that the buffer's filename changed (e.g., SaveAs)
|
||||
void NotifyFilenameChanged(Buffer &buf);
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#include <cstdio>
|
||||
#include <climits>
|
||||
#include <cwchar>
|
||||
#include <ncurses.h>
|
||||
|
||||
#include "TerminalInputHandler.h"
|
||||
@@ -12,6 +14,22 @@ CTRL(char c)
|
||||
{
|
||||
return c & 0x1F;
|
||||
}
|
||||
|
||||
|
||||
// Encode a single wide character in the process locale's multibyte encoding
|
||||
// (UTF-8, given main.cc's setlocale(LC_ALL, "")). Returns false if the
|
||||
// codepoint can't be represented, leaving `out` untouched.
|
||||
bool
|
||||
wchar_to_mb(wchar_t wc, std::string &out)
|
||||
{
|
||||
std::mbstate_t state{};
|
||||
char buf[MB_LEN_MAX];
|
||||
std::size_t n = std::wcrtomb(buf, wc, &state);
|
||||
if (n == static_cast<std::size_t>(-1))
|
||||
return false;
|
||||
out.assign(buf, n);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
TerminalInputHandler::TerminalInputHandler() = default;
|
||||
@@ -21,6 +39,7 @@ TerminalInputHandler::~TerminalInputHandler() = default;
|
||||
|
||||
static bool
|
||||
map_key_to_command(const int ch,
|
||||
const bool is_keycode,
|
||||
bool &k_prefix,
|
||||
bool &esc_meta,
|
||||
bool &k_ctrl_pending,
|
||||
@@ -28,9 +47,13 @@ map_key_to_command(const int ch,
|
||||
Editor *ed,
|
||||
MappedInput &out)
|
||||
{
|
||||
// Handle special keys from ncurses
|
||||
// Handle special keys from ncurses. These are only meaningful when
|
||||
// get_wch() reported KEY_CODE_YES: a regular (possibly non-ASCII) wide
|
||||
// character can numerically collide with a KEY_* constant otherwise
|
||||
// (e.g. U+0107 'ć' equals KEY_BACKSPACE's value), which would wrongly
|
||||
// swallow it as a special key instead of inserting it.
|
||||
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
|
||||
switch (ch) {
|
||||
switch (is_keycode ? ch : -1) {
|
||||
case KEY_ENTER:
|
||||
// Some terminals send KEY_ENTER distinct from '\n'/'\r'
|
||||
k_prefix = false;
|
||||
@@ -259,7 +282,7 @@ map_key_to_command(const int ch,
|
||||
esc_meta = false;
|
||||
int ascii_key = ch;
|
||||
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
|
||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
||||
if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
|
||||
ascii_key = KEY_BACKSPACE; // normalized value for lookup
|
||||
} else if (ch == ',') {
|
||||
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
|
||||
@@ -281,7 +304,7 @@ map_key_to_command(const int ch,
|
||||
}
|
||||
|
||||
// Backspace in ncurses can be KEY_BACKSPACE or 127
|
||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
||||
if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Backspace, "", 0};
|
||||
@@ -297,11 +320,20 @@ map_key_to_command(const int ch,
|
||||
return true;
|
||||
}
|
||||
|
||||
// Printable ASCII
|
||||
if (ch >= 0x20 && ch <= 0x7E) {
|
||||
// Printable character: ASCII, or (for a regular decoded wide character,
|
||||
// not a keycode) any other printable Unicode codepoint - e.g. accented
|
||||
// Latin, Cyrillic, CJK, etc.
|
||||
if (!is_keycode && ch >= 0x20 && ch != 0x7F) {
|
||||
std::string mb;
|
||||
if (ch <= 0x7E) {
|
||||
mb.assign(1, static_cast<char>(ch));
|
||||
} else if (!wchar_to_mb(static_cast<wchar_t>(ch), mb)) {
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
out.hasCommand = true;
|
||||
out.id = CommandId::InsertText;
|
||||
out.arg.assign(1, static_cast<char>(ch));
|
||||
out.arg = mb;
|
||||
out.count = 0;
|
||||
return true;
|
||||
}
|
||||
@@ -314,12 +346,15 @@ map_key_to_command(const int ch,
|
||||
bool
|
||||
TerminalInputHandler::decode_(MappedInput &out)
|
||||
{
|
||||
int ch = getch();
|
||||
if (ch == ERR) {
|
||||
wint_t wch;
|
||||
int ret = get_wch(&wch);
|
||||
if (ret == ERR) {
|
||||
return false; // no input
|
||||
}
|
||||
const bool is_keycode = (ret == KEY_CODE_YES);
|
||||
const int ch = static_cast<int>(wch);
|
||||
bool consumed = map_key_to_command(
|
||||
ch,
|
||||
ch, is_keycode,
|
||||
k_prefix_, esc_meta_,
|
||||
k_ctrl_pending_,
|
||||
mouse_selecting_,
|
||||
|
||||
@@ -10,6 +10,12 @@ enum class UndoType : std::uint8_t {
|
||||
Newline,
|
||||
DeleteRow,
|
||||
InsertRow,
|
||||
// Inverse of Newline: forward = join_lines(row) [removes the newline at the
|
||||
// end of `row`], backward = split_line(row, col) [recreates the original
|
||||
// two lines]. Used by backspace-at-col-0 and delete-at-eol, whose forward
|
||||
// action is a join, not a split — Newline's apply() semantics are the
|
||||
// wrong direction for those.
|
||||
JoinLines,
|
||||
};
|
||||
|
||||
struct UndoNode {
|
||||
|
||||
@@ -37,7 +37,7 @@ UndoSystem::Begin(UndoType type)
|
||||
|
||||
// Some operations should always be standalone undo steps.
|
||||
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
|
||||
UndoType::InsertRow);
|
||||
UndoType::InsertRow || type == UndoType::JoinLines);
|
||||
if (always_standalone) {
|
||||
commit();
|
||||
}
|
||||
@@ -77,6 +77,7 @@ UndoSystem::Begin(UndoType type)
|
||||
case UndoType::Newline:
|
||||
case UndoType::DeleteRow:
|
||||
case UndoType::InsertRow:
|
||||
case UndoType::JoinLines:
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -325,6 +326,16 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
case UndoType::JoinLines:
|
||||
// Mirror image of Newline: forward removes a newline, backward restores it.
|
||||
if (direction > 0) {
|
||||
buf_->join_lines(node->row);
|
||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||
} else {
|
||||
buf_->split_line(node->row, node->col);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,6 +435,8 @@ UndoSystem::type_str(UndoType t)
|
||||
return "DeleteRow";
|
||||
case UndoType::InsertRow:
|
||||
return "InsertRow";
|
||||
case UndoType::JoinLines:
|
||||
return "JoinLines";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
25
UndoTree.cc
25
UndoTree.cc
@@ -1,3 +1,24 @@
|
||||
// Placeholder translation unit for UndoTree struct definition.
|
||||
// Undo logic is implemented in UndoSystem.
|
||||
// Undo logic is implemented in UndoSystem; this file only owns node lifetime.
|
||||
#include "UndoTree.h"
|
||||
|
||||
namespace {
|
||||
void
|
||||
free_node_graph(UndoNode *node)
|
||||
{
|
||||
// Walk the sibling (redo-branch) list; for each node, recursively free its
|
||||
// child subtree first, then the node itself.
|
||||
while (node) {
|
||||
UndoNode *next = node->next;
|
||||
free_node_graph(node->child);
|
||||
delete node;
|
||||
node = next;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
|
||||
UndoTree::~UndoTree()
|
||||
{
|
||||
free_node_graph(root);
|
||||
delete pending;
|
||||
}
|
||||
|
||||
@@ -7,4 +7,11 @@ struct UndoTree {
|
||||
UndoNode *current = nullptr; // current state of buffer
|
||||
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
|
||||
UndoNode *pending = nullptr; // in-progress batch (detached)
|
||||
|
||||
// Frees the entire node graph (root's subtree/branches plus any detached
|
||||
// pending node). `current`/`saved` are aliases into that same graph, not
|
||||
// separately owned. Without this, closing/reloading/resetting a buffer
|
||||
// (which replaces or destroys its UndoTree) leaks every UndoNode ever
|
||||
// created for it.
|
||||
~UndoTree();
|
||||
};
|
||||
|
||||
16
main.cc
16
main.cc
@@ -292,6 +292,20 @@ main(int argc, char *argv[])
|
||||
fe = std::make_unique<TerminalFrontend>();
|
||||
}
|
||||
|
||||
// Guarantee Shutdown() runs on every exit path from here on (normal
|
||||
// return, early return, or exception unwinding e.g. from Init/Execute/
|
||||
// Step) so a crash never leaves the terminal in raw/cbreak mode.
|
||||
struct FrontendShutdownGuard {
|
||||
Frontend *fe;
|
||||
|
||||
|
||||
~FrontendShutdownGuard()
|
||||
{
|
||||
if (fe)
|
||||
fe->Shutdown();
|
||||
}
|
||||
} shutdown_guard{fe.get()};
|
||||
|
||||
#if defined(KTE_BUILD_GUI) && defined(__APPLE__)
|
||||
if (use_gui) {
|
||||
/* likely using the .app, so need to cd */
|
||||
@@ -320,8 +334,6 @@ main(int argc, char *argv[])
|
||||
fe->Step(editor, running);
|
||||
}
|
||||
|
||||
fe->Shutdown();
|
||||
|
||||
return 0;
|
||||
} catch (const std::exception &e) {
|
||||
std::string msg = std::string("Unhandled exception: ") + e.what();
|
||||
|
||||
@@ -46,14 +46,45 @@ GoHighlighter::GoHighlighter()
|
||||
void
|
||||
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState prev;
|
||||
(void) HighlightLineStateful(buf, row, prev, out);
|
||||
}
|
||||
|
||||
|
||||
StatefulHighlighter::LineState
|
||||
GoHighlighter::HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState state = prev;
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
return state;
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
int bol = 0;
|
||||
while (bol < n && (s[bol] == ' ' || s[bol] == '\t'))
|
||||
++bol;
|
||||
|
||||
// Continue a multi-line block comment from the previous line.
|
||||
if (state.in_block_comment) {
|
||||
int j = i;
|
||||
while (i + 1 < n) {
|
||||
if (s[i] == '*' && s[i + 1] == '/') {
|
||||
i += 2;
|
||||
push(out, j, i, TokenKind::Comment);
|
||||
state.in_block_comment = false;
|
||||
break;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
if (state.in_block_comment) {
|
||||
push(out, j, n, TokenKind::Comment);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// line comment
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
@@ -82,7 +113,8 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
|
||||
}
|
||||
if (!closed) {
|
||||
push(out, i, n, TokenKind::Comment);
|
||||
break;
|
||||
state.in_block_comment = true;
|
||||
return state;
|
||||
} else {
|
||||
push(out, i, j, TokenKind::Comment);
|
||||
i = j;
|
||||
@@ -152,5 +184,6 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
|
||||
push(out, i, i + 1, TokenKind::Default);
|
||||
++i;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
} // namespace kte
|
||||
@@ -5,12 +5,17 @@
|
||||
#include <unordered_set>
|
||||
|
||||
namespace kte {
|
||||
class GoHighlighter final : public LanguageHighlighter {
|
||||
class GoHighlighter final : public StatefulHighlighter {
|
||||
public:
|
||||
GoHighlighter();
|
||||
|
||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||
|
||||
LineState HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const override;
|
||||
|
||||
private:
|
||||
std::unordered_set<std::string> kws_;
|
||||
std::unordered_set<std::string> types_;
|
||||
|
||||
@@ -74,7 +74,22 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
|
||||
|
||||
StatefulHighlighter::LineState prev_state;
|
||||
int start_row = -1;
|
||||
if (!state_cache_.empty()) {
|
||||
|
||||
// Fast path: state_last_contig_ tracks the highest row we've cached state
|
||||
// for, per version. If that row is already below our target, it's
|
||||
// necessarily the best anchor the O(n) scan below would have found, so we
|
||||
// can skip the scan entirely. Re-validated against state_cache_ before
|
||||
// use since InvalidateFrom()/SetHighlighter() may have raced/cleared it.
|
||||
auto contig_it = state_last_contig_.find(buf_version);
|
||||
if (contig_it != state_last_contig_.end() && contig_it->second < row) {
|
||||
auto sc_it = state_cache_.find(contig_it->second);
|
||||
if (sc_it != state_cache_.end() && sc_it->second.version == buf_version) {
|
||||
start_row = contig_it->second;
|
||||
prev_state = sc_it->second.state;
|
||||
}
|
||||
}
|
||||
|
||||
if (start_row < 0 && !state_cache_.empty()) {
|
||||
// linear search over map (unordered), track best candidate
|
||||
int best = -1;
|
||||
for (const auto &kv: state_cache_) {
|
||||
@@ -109,6 +124,9 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
|
||||
se.state = next_state;
|
||||
state_cache_[r] = se;
|
||||
cur_state = next_state;
|
||||
int &contig = state_last_contig_[buf_version];
|
||||
if (r > contig)
|
||||
contig = r;
|
||||
}
|
||||
|
||||
// Store in cache and return by value
|
||||
@@ -139,6 +157,14 @@ HighlighterEngine::InvalidateFrom(int row)
|
||||
++it;
|
||||
}
|
||||
}
|
||||
// A version's tracked contiguous-state row is no longer valid once any
|
||||
// row at or above it has been evicted from state_cache_ above.
|
||||
for (auto it = state_last_contig_.begin(); it != state_last_contig_.end();) {
|
||||
if (it->second >= row)
|
||||
it = state_last_contig_.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -55,31 +55,26 @@ PythonHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineS
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
|
||||
int i = 0;
|
||||
|
||||
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
|
||||
if (state.in_raw_string && (state.raw_delim == "'''" || state.raw_delim == "\"\"\"")) {
|
||||
auto pos = s.find(state.raw_delim);
|
||||
if (pos == std::string::npos) {
|
||||
push(out, 0, n, TokenKind::String);
|
||||
return state; // still inside
|
||||
} else {
|
||||
}
|
||||
int end = static_cast<int>(pos + static_cast<int>(state.raw_delim.size()));
|
||||
push(out, 0, end, TokenKind::String);
|
||||
// remainder processed normally
|
||||
s = s.substr(end);
|
||||
n = static_cast<int>(s.size());
|
||||
state.in_raw_string = false;
|
||||
state.raw_delim.clear();
|
||||
// Continue parsing remainder as a separate small loop
|
||||
int base = end;
|
||||
// original offset, but we already emitted to 'out' with base=0; following spans should be from 'end'
|
||||
// For simplicity, mark rest as Default
|
||||
if (n > 0)
|
||||
push(out, base, base + n, TokenKind::Default);
|
||||
return state;
|
||||
}
|
||||
// Resume the normal tokenizer at the closing delimiter's end, on the
|
||||
// same (unmodified) `s`/`n`, so anything after it - including a new
|
||||
// triple-quoted string opening on this same line - is re-scanned
|
||||
// instead of being dumped into a single opaque Default span.
|
||||
i = end;
|
||||
}
|
||||
|
||||
int i = 0;
|
||||
// Detect comment start '#', ignoring inside strings
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
|
||||
@@ -47,11 +47,42 @@ RustHighlighter::RustHighlighter()
|
||||
void
|
||||
RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState prev;
|
||||
(void) HighlightLineStateful(buf, row, prev, out);
|
||||
}
|
||||
|
||||
|
||||
StatefulHighlighter::LineState
|
||||
RustHighlighter::HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState state = prev;
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
return state;
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
|
||||
// Continue a multi-line block comment from the previous line.
|
||||
if (state.in_block_comment) {
|
||||
int j = i;
|
||||
while (i + 1 < n) {
|
||||
if (s[i] == '*' && s[i + 1] == '/') {
|
||||
i += 2;
|
||||
push(out, j, i, TokenKind::Comment);
|
||||
state.in_block_comment = false;
|
||||
break;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
if (state.in_block_comment) {
|
||||
push(out, j, n, TokenKind::Comment);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
if (c == ' ' || c == '\t') {
|
||||
@@ -79,7 +110,8 @@ RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<Highlight
|
||||
}
|
||||
if (!closed) {
|
||||
push(out, i, n, TokenKind::Comment);
|
||||
break;
|
||||
state.in_block_comment = true;
|
||||
return state;
|
||||
} else {
|
||||
push(out, i, j, TokenKind::Comment);
|
||||
i = j;
|
||||
@@ -140,5 +172,6 @@ RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<Highlight
|
||||
push(out, i, i + 1, TokenKind::Default);
|
||||
++i;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
} // namespace kte
|
||||
@@ -5,12 +5,17 @@
|
||||
#include <unordered_set>
|
||||
|
||||
namespace kte {
|
||||
class RustHighlighter final : public LanguageHighlighter {
|
||||
class RustHighlighter final : public StatefulHighlighter {
|
||||
public:
|
||||
RustHighlighter();
|
||||
|
||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||
|
||||
LineState HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const override;
|
||||
|
||||
private:
|
||||
std::unordered_set<std::string> kws_;
|
||||
std::unordered_set<std::string> types_;
|
||||
|
||||
@@ -47,12 +47,42 @@ SqlHighlighter::SqlHighlighter()
|
||||
void
|
||||
SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState prev;
|
||||
(void) HighlightLineStateful(buf, row, prev, out);
|
||||
}
|
||||
|
||||
|
||||
StatefulHighlighter::LineState
|
||||
SqlHighlighter::HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState state = prev;
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||
return;
|
||||
return state;
|
||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
|
||||
// Continue a multi-line block comment from the previous line.
|
||||
if (state.in_block_comment) {
|
||||
int j = i;
|
||||
while (i + 1 < n) {
|
||||
if (s[i] == '*' && s[i + 1] == '/') {
|
||||
i += 2;
|
||||
push(out, j, i, TokenKind::Comment);
|
||||
state.in_block_comment = false;
|
||||
break;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
if (state.in_block_comment) {
|
||||
push(out, j, n, TokenKind::Comment);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
if (c == ' ' || c == '\t') {
|
||||
@@ -68,7 +98,7 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
|
||||
push(out, i, n, TokenKind::Comment);
|
||||
break;
|
||||
}
|
||||
// simple block comment on same line: /* ... */
|
||||
// block comment: /* ... */ (may span multiple lines)
|
||||
if (c == '/' && i + 1 < n && s[i + 1] == '*') {
|
||||
int j = i + 2;
|
||||
bool closed = false;
|
||||
@@ -82,7 +112,8 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
|
||||
}
|
||||
if (!closed) {
|
||||
push(out, i, n, TokenKind::Comment);
|
||||
break;
|
||||
state.in_block_comment = true;
|
||||
return state;
|
||||
} else {
|
||||
push(out, i, j, TokenKind::Comment);
|
||||
i = j;
|
||||
@@ -151,5 +182,6 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
|
||||
push(out, i, i + 1, TokenKind::Default);
|
||||
++i;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
} // namespace kte
|
||||
@@ -5,12 +5,17 @@
|
||||
#include <unordered_set>
|
||||
|
||||
namespace kte {
|
||||
class SqlHighlighter final : public LanguageHighlighter {
|
||||
class SqlHighlighter final : public StatefulHighlighter {
|
||||
public:
|
||||
SqlHighlighter();
|
||||
|
||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||
|
||||
LineState HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const override;
|
||||
|
||||
private:
|
||||
std::unordered_set<std::string> kws_;
|
||||
std::unordered_set<std::string> types_;
|
||||
|
||||
@@ -108,3 +108,26 @@ TEST(CommandSemantics_CopyRegion_And_KillRegion)
|
||||
ASSERT_EQ(b.MarkSet(), false);
|
||||
ASSERT_EQ(h.Text(), std::string("hello "));
|
||||
}
|
||||
|
||||
|
||||
TEST(CommandSemantics_Syntax_OnOff_SetsUserOverride)
|
||||
{
|
||||
TestHarness h;
|
||||
Editor &ed = h.EditorRef();
|
||||
Buffer &b = h.Buf();
|
||||
|
||||
// Before any explicit :syntax command, nothing has overridden the
|
||||
// frontend's config-driven default.
|
||||
ASSERT_EQ(b.SyntaxUserOverride(), false);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Syntax, "off"));
|
||||
ASSERT_EQ(b.SyntaxEnabled(), false);
|
||||
// This flag is what a frontend's per-frame "apply config default" pass
|
||||
// must check before re-enabling syntax, or a manual :syntax off gets
|
||||
// silently undone on the next frame.
|
||||
ASSERT_EQ(b.SyntaxUserOverride(), true);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Syntax, "on"));
|
||||
ASSERT_EQ(b.SyntaxEnabled(), true);
|
||||
ASSERT_EQ(b.SyntaxUserOverride(), true);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,21 @@ TEST(KKeymap_KPrefix_CanonicalChords)
|
||||
}
|
||||
|
||||
|
||||
TEST(KKeymap_KPrefix_UppercaseE_RejectedAndDistinctFromLowercaseE)
|
||||
{
|
||||
CommandId id{};
|
||||
|
||||
// 'e' is a real binding (OpenFileStart).
|
||||
ASSERT_TRUE(KLookupKCommand('e', false, id));
|
||||
ASSERT_EQ(id, CommandId::OpenFileStart);
|
||||
|
||||
// 'E' must be rejected, not silently aliased to 'e' via case-insensitive
|
||||
// lookup (the switch normalizes to lowercase, so this has to be special-
|
||||
// cased before it).
|
||||
ASSERT_EQ(KLookupKCommand('E', false, id), false);
|
||||
}
|
||||
|
||||
|
||||
TEST(KKeymap_CtrlChords_CanonicalChords)
|
||||
{
|
||||
CommandId id{};
|
||||
|
||||
@@ -414,17 +414,21 @@ TEST (Migration_EmptyBufferCheck_Pattern)
|
||||
|
||||
TEST (Migration_SyntaxHighlighter_Pattern)
|
||||
{
|
||||
// Test the pattern used in syntax highlighters
|
||||
// Test the pattern used in syntax highlighters: loop Nrows(), fetch each
|
||||
// line via GetLineString(row). Assert the pattern actually returns the
|
||||
// right content, not just that it doesn't crash - see syntax highlighter
|
||||
// correctness tests (test_syntax_highlighting.cc) for tokenization coverage.
|
||||
Buffer buf;
|
||||
buf.insert_text(0, 0, std::string("int main() {\n return 0;\n}"));
|
||||
|
||||
ASSERT_EQ(buf.Nrows(), static_cast<std::size_t>(3));
|
||||
const std::vector<std::string> expected = {"int main() {", " return 0;", "}"};
|
||||
for (std::size_t row = 0; row < buf.Nrows(); ++row) {
|
||||
// This is the pattern used in all migrated highlighters
|
||||
if (row >= buf.Nrows()) {
|
||||
break; // Should never happen
|
||||
}
|
||||
std::string line = buf.GetLineString(row);
|
||||
// Successfully accessed line - size() is always valid for std::string
|
||||
ASSERT_EQ(line, expected[row]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,36 @@ TEST(SearchFlow_SearchReplace_EmptyFind_DoesNotMutateBuffer_And_ClearsState)
|
||||
}
|
||||
|
||||
|
||||
TEST(SearchFlow_SearchReplace_EmptyWith_ReplacesAdjacentOverlappingMatches)
|
||||
{
|
||||
TestHarness h;
|
||||
Editor &ed = h.EditorRef();
|
||||
Buffer &b = h.Buf();
|
||||
|
||||
// "aaaa" with "aa" -> "" must remove both non-overlapping occurrences, not
|
||||
// just the first: after deleting the match at column 0, the next "aa" now
|
||||
// sits at column 0 too (not column 1), so the scan must resume at the
|
||||
// deletion point rather than one character past it.
|
||||
b.insert_text(0, 0, "aaaa\n");
|
||||
b.SetCursor(0, 0);
|
||||
|
||||
ASSERT_TRUE(h.Exec(CommandId::SearchReplace));
|
||||
ASSERT_TRUE(ed.PromptActive());
|
||||
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceFind);
|
||||
|
||||
ASSERT_TRUE(h.Exec(CommandId::InsertText, "aa"));
|
||||
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||
ASSERT_TRUE(ed.PromptActive());
|
||||
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceWith);
|
||||
|
||||
// Leave the replacement empty and accept.
|
||||
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||
|
||||
ASSERT_TRUE(!ed.PromptActive());
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST(SearchFlow_RegexFind_InvalidPattern_FailsSafely_And_ClearsStateOnEnter)
|
||||
{
|
||||
TestHarness h;
|
||||
|
||||
167
tests/test_syntax_highlighting.cc
Normal file
167
tests/test_syntax_highlighting.cc
Normal file
@@ -0,0 +1,167 @@
|
||||
// test_syntax_highlighting.cc - tokenization correctness for LanguageHighlighter
|
||||
// implementations, focused on multi-line state propagation (block comments,
|
||||
// triple-quoted strings) that stateless-looking per-line scans easily get wrong.
|
||||
#include "Test.h"
|
||||
#include "Buffer.h"
|
||||
#include "Highlight.h"
|
||||
#include "syntax/LanguageHighlighter.h"
|
||||
#include "syntax/GoHighlighter.h"
|
||||
#include "syntax/RustHighlighter.h"
|
||||
#include "syntax/SqlHighlighter.h"
|
||||
#include "syntax/PythonHighlighter.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using kte::HighlightSpan;
|
||||
using kte::StatefulHighlighter;
|
||||
using kte::TokenKind;
|
||||
|
||||
namespace {
|
||||
bool
|
||||
has_kind(const std::vector<HighlightSpan> &spans, TokenKind k)
|
||||
{
|
||||
for (const auto &sp: spans) {
|
||||
if (sp.kind == k)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
|
||||
TEST (Syntax_Go_MultiLineBlockComment_PropagatesAcrossLines)
|
||||
{
|
||||
Buffer b;
|
||||
b.replace_all_bytes("/* start\nmiddle line\nend */\nfunc foo() {}\n");
|
||||
|
||||
kte::GoHighlighter hl;
|
||||
StatefulHighlighter::LineState state;
|
||||
std::vector<HighlightSpan> spans;
|
||||
|
||||
// Line 0 opens an unclosed block comment; state must carry forward.
|
||||
state = hl.HighlightLineStateful(b, 0, state, spans);
|
||||
ASSERT_TRUE(state.in_block_comment);
|
||||
|
||||
// Line 1 is entirely inside the comment; the whole line must be Comment,
|
||||
// not re-tokenized as code (this is the bug: without state propagation
|
||||
// "middle" and "line" would come back as identifiers).
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 1, state, spans);
|
||||
ASSERT_TRUE(state.in_block_comment);
|
||||
ASSERT_TRUE(!spans.empty());
|
||||
for (const auto &sp: spans) {
|
||||
ASSERT_TRUE(sp.kind == TokenKind::Comment);
|
||||
}
|
||||
|
||||
// Line 2 closes the comment.
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 2, state, spans);
|
||||
ASSERT_TRUE(!state.in_block_comment);
|
||||
ASSERT_TRUE(has_kind(spans, TokenKind::Comment));
|
||||
|
||||
// Line 3 is ordinary code again: "func" must be a Keyword, not a Comment.
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 3, state, spans);
|
||||
ASSERT_TRUE(!state.in_block_comment);
|
||||
ASSERT_TRUE(has_kind(spans, TokenKind::Keyword));
|
||||
ASSERT_TRUE(!has_kind(spans, TokenKind::Comment));
|
||||
}
|
||||
|
||||
|
||||
TEST (Syntax_Rust_MultiLineBlockComment_PropagatesAcrossLines)
|
||||
{
|
||||
Buffer b;
|
||||
b.replace_all_bytes("/* start\nmiddle line\nend */\nfn foo() {}\n");
|
||||
|
||||
kte::RustHighlighter hl;
|
||||
StatefulHighlighter::LineState state;
|
||||
std::vector<HighlightSpan> spans;
|
||||
|
||||
state = hl.HighlightLineStateful(b, 0, state, spans);
|
||||
ASSERT_TRUE(state.in_block_comment);
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 1, state, spans);
|
||||
ASSERT_TRUE(state.in_block_comment);
|
||||
for (const auto &sp: spans) {
|
||||
ASSERT_TRUE(sp.kind == TokenKind::Comment);
|
||||
}
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 2, state, spans);
|
||||
ASSERT_TRUE(!state.in_block_comment);
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 3, state, spans);
|
||||
ASSERT_TRUE(has_kind(spans, TokenKind::Keyword));
|
||||
ASSERT_TRUE(!has_kind(spans, TokenKind::Comment));
|
||||
}
|
||||
|
||||
|
||||
TEST (Syntax_Sql_MultiLineBlockComment_PropagatesAcrossLines)
|
||||
{
|
||||
Buffer b;
|
||||
b.replace_all_bytes("/* start\nmiddle line\nend */\nSELECT 1;\n");
|
||||
|
||||
kte::SqlHighlighter hl;
|
||||
StatefulHighlighter::LineState state;
|
||||
std::vector<HighlightSpan> spans;
|
||||
|
||||
state = hl.HighlightLineStateful(b, 0, state, spans);
|
||||
ASSERT_TRUE(state.in_block_comment);
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 1, state, spans);
|
||||
ASSERT_TRUE(state.in_block_comment);
|
||||
for (const auto &sp: spans) {
|
||||
ASSERT_TRUE(sp.kind == TokenKind::Comment);
|
||||
}
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 2, state, spans);
|
||||
ASSERT_TRUE(!state.in_block_comment);
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 3, state, spans);
|
||||
ASSERT_TRUE(has_kind(spans, TokenKind::Keyword));
|
||||
ASSERT_TRUE(!has_kind(spans, TokenKind::Comment));
|
||||
}
|
||||
|
||||
|
||||
TEST (Syntax_Python_TripleQuote_ClosesAndReopensOnSameLine)
|
||||
{
|
||||
Buffer b;
|
||||
// Line 0 opens a triple-quoted string that stays open.
|
||||
// Line 1 closes the first string ("end'''") and, on the SAME line, opens a
|
||||
// second triple-quoted string ("'''start of") that stays open into line 2.
|
||||
b.replace_all_bytes("x = '''abc\nend''' + '''start of\nnext string'''\n");
|
||||
|
||||
kte::PythonHighlighter hl;
|
||||
StatefulHighlighter::LineState state;
|
||||
std::vector<HighlightSpan> spans;
|
||||
|
||||
state = hl.HighlightLineStateful(b, 0, state, spans);
|
||||
ASSERT_TRUE(state.in_raw_string);
|
||||
ASSERT_EQ(state.raw_delim, std::string("'''"));
|
||||
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 1, state, spans);
|
||||
// The bug: without re-scanning the remainder after the closing ''' the
|
||||
// second opening ''' on this line is never noticed, so state.in_raw_string
|
||||
// would incorrectly come back false here.
|
||||
ASSERT_TRUE(state.in_raw_string);
|
||||
ASSERT_EQ(state.raw_delim, std::string("'''"));
|
||||
|
||||
// Line 2 is inside the second string; it must be highlighted as String,
|
||||
// not as ordinary code.
|
||||
spans.clear();
|
||||
state = hl.HighlightLineStateful(b, 2, state, spans);
|
||||
ASSERT_TRUE(!spans.empty());
|
||||
bool all_string = true;
|
||||
for (const auto &sp: spans) {
|
||||
if (sp.kind != TokenKind::String)
|
||||
all_string = false;
|
||||
}
|
||||
ASSERT_TRUE(all_string);
|
||||
}
|
||||
@@ -1198,6 +1198,312 @@ TEST (Undo_Command_RedoCountSelectsBranch)
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_Newline_UndoRejoinsCorrectLines)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abcdef\nghijkl");
|
||||
buf->SetCursor(3, 0);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(3));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("def"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("ghijkl"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("ghijkl"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_Backspace_JoinUndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abc\ndef");
|
||||
buf->SetCursor(0, 1);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Backspace));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(1));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("def"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(1));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_DeleteChar_JoinUndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abc\ndef");
|
||||
buf->SetCursor(3, 0);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::DeleteChar));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(1));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("def"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(1));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_RegexReplaceAll_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("foo one\nfoo two\nbar three");
|
||||
buf->SetCursor(0, 0);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::RegexpReplace));
|
||||
ASSERT_TRUE(ed.PromptActive());
|
||||
ed.SetPromptText("foo");
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||
ASSERT_TRUE(ed.PromptActive());
|
||||
ed.SetPromptText("baz");
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("baz one"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("baz two"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("bar three"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("foo one"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("foo two"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("bar three"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("baz one"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("baz two"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("bar three"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_IndentUnindentRegion_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("one\ntwo\nthree");
|
||||
buf->SetMark(0, 0);
|
||||
buf->SetCursor(0, 2);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::IndentRegion));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("\tone"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("\ttwo"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("\tthree"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("one"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("two"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("three"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("\tone"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("\ttwo"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("\tthree"));
|
||||
|
||||
buf->SetMark(0, 0);
|
||||
buf->SetCursor(0, 2);
|
||||
ASSERT_TRUE(Execute(ed, CommandId::UnindentRegion));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("one"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("two"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("three"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("\tone"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("\ttwo"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("\tthree"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_KillToEol_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abcdef\nghijkl");
|
||||
buf->SetCursor(3, 0);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::KillToEOL));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("ghijkl"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_KillLine_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abc\ndef\nghi");
|
||||
buf->SetCursor(0, 1);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::KillLine));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("ghi"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(3));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("def"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("ghi"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
|
||||
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("ghi"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_KillRegion_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abc def ghi");
|
||||
buf->SetMark(4, 0);
|
||||
buf->SetCursor(8, 0);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::KillRegion));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc ghi"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc def ghi"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc ghi"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_DeleteWordPrevNext_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
buf->replace_all_bytes("abc def ghi");
|
||||
buf->SetCursor(8, 0);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::DeleteWordPrev));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc ghi"));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc def ghi"));
|
||||
|
||||
buf->SetCursor(4, 0);
|
||||
ASSERT_TRUE(Execute(ed, CommandId::DeleteWordNext));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc ghi"));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc def ghi"));
|
||||
|
||||
validate_undo_tree(*buf->Undo());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_InsertRow_UndoDeletesRow)
|
||||
{
|
||||
Buffer b;
|
||||
|
||||
Reference in New Issue
Block a user