From 3126a5e5234286b27f95548b3f20cedcff7a0103 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Wed, 1 Jul 2026 14:19:15 -0700 Subject: [PATCH] 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 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 --- Buffer.cc | 62 ++++-- Buffer.h | 20 ++ CMakeLists.txt | 1 + Command.cc | 204 ++++++++++++++++---- Editor.cc | 41 +++- ImGuiFrontend.cc | 20 +- ImGuiInputHandler.cc | 44 +++-- KKeymap.cc | 14 +- Swap.cc | 37 ++++ Swap.h | 9 + TerminalInputHandler.cc | 57 ++++-- UndoNode.h | 6 + UndoSystem.cc | 15 +- UndoTree.cc | 25 ++- UndoTree.h | 7 + main.cc | 16 +- syntax/GoHighlighter.cc | 37 +++- syntax/GoHighlighter.h | 7 +- syntax/HighlighterEngine.cc | 28 ++- syntax/PythonHighlighter.cc | 27 ++- syntax/RustHighlighter.cc | 37 +++- syntax/RustHighlighter.h | 7 +- syntax/SqlHighlighter.cc | 38 +++- syntax/SqlHighlighter.h | 7 +- tests/test_command_semantics.cc | 23 +++ tests/test_kkeymap.cc | 15 ++ tests/test_migration_coverage.cc | 10 +- tests/test_search_replace_flow.cc | 30 +++ tests/test_syntax_highlighting.cc | 167 ++++++++++++++++ tests/test_undo.cc | 306 ++++++++++++++++++++++++++++++ 30 files changed, 1191 insertions(+), 126 deletions(-) create mode 100644 tests/test_syntax_highlighting.cc diff --git a/Buffer.cc b/Buffer.cc index f2c12c9..99c8d9e 100644 --- a/Buffer.cc +++ b/Buffer.cc @@ -235,7 +235,8 @@ Buffer::Buffer(const Buffer &other) edit_mode_ = other.edit_mode_; edit_mode_detected_ = other.edit_mode_detected_; version_ = other.version_; - syntax_enabled_ = other.syntax_enabled_; + 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(); @@ -286,7 +287,8 @@ Buffer::operator=(const Buffer &other) edit_mode_ = other.edit_mode_; edit_mode_detected_ = other.edit_mode_detected_; version_ = other.version_; - syntax_enabled_ = other.syntax_enabled_; + 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(); @@ -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_)) { @@ -334,11 +339,18 @@ Buffer::Buffer(Buffer &&other) noexcept edit_mode_ = other.edit_mode_; edit_mode_detected_ = other.edit_mode_detected_; version_ = other.version_; - syntax_enabled_ = other.syntax_enabled_; + 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); @@ -353,32 +365,42 @@ Buffer::operator=(Buffer &&other) noexcept if (this == &other) return *this; - curx_ = other.curx_; - cury_ = other.cury_; - rx_ = other.rx_; - nrows_ = other.nrows_; - rowoffs_ = other.rowoffs_; - coloffs_ = other.coloffs_; - rows_ = std::move(other.rows_); - filename_ = std::move(other.filename_); - is_file_backed_ = other.is_file_backed_; - dirty_ = other.dirty_; - read_only_ = other.read_only_; - mark_set_ = other.mark_set_; - mark_curx_ = other.mark_curx_; - mark_cury_ = other.mark_cury_; - undo_tree_ = std::move(other.undo_tree_); - undo_sys_ = std::move(other.undo_sys_); + curx_ = other.curx_; + cury_ = other.cury_; + rx_ = other.rx_; + nrows_ = other.nrows_; + rowoffs_ = other.rowoffs_; + coloffs_ = other.coloffs_; + rows_ = std::move(other.rows_); + filename_ = std::move(other.filename_); + is_file_backed_ = other.is_file_backed_; + dirty_ = other.dirty_; + read_only_ = other.read_only_; + 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_); // Move edit mode + syntax/highlighting state edit_mode_ = other.edit_mode_; edit_mode_detected_ = other.edit_mode_detected_; version_ = other.version_; - syntax_enabled_ = other.syntax_enabled_; + 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); diff --git a/Buffer.h b/Buffer.h index 13d12a4..51fd17b 100644 --- a/Buffer.h +++ b/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 highlighter_; // Non-owning pointer to swap recorder managed by Editor/SwapManager diff --git a/CMakeLists.txt b/CMakeLists.txt index a413af4..1396571 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/Command.cc b/Command.cc index 67f479e..db09669 100644 --- a/Command.cc +++ b/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(sy), static_cast(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(sy), static_cast(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(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(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(sy), static_cast(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(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(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(y)); buf->insert_row(static_cast(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(y), static_cast(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(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(rows_view[y].substr(x)); + killed_total += seg; std::size_t len = rows_view[y].size() - x; buf->delete_text(static_cast(y), static_cast(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(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(rows_view[0]); - if (!rows_view[0].empty()) - buf->delete_text(0, 0, rows_view[0].size()); + std::string content = static_cast(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(rows_view[y]); + std::string content = static_cast(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(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(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(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(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); diff --git a/Editor.cc b/Editor.cc index 16c9572..6623a87 100644 --- a/Editor.cc +++ b/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 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 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(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()) { diff --git a/ImGuiFrontend.cc b/ImGuiFrontend.cc index d0b34fc..cf6757d 100644 --- a/ImGuiFrontend.cc +++ b/ImGuiFrontend.cc @@ -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,7 +475,19 @@ GUIFrontend::Step(Editor &ed, bool &running) // Route input events to the correct window's input handler if (target) { - target->input.ProcessSDLEvent(e); + 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); + } } } diff --git a/ImGuiInputHandler.cc b/ImGuiInputHandler.cc index 436fa29..c36bff4 100644 --- a/ImGuiInputHandler.cc +++ b/ImGuiInputHandler.cc @@ -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(ascii_key) : '?'; - std::fprintf(stderr, - "[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n", - static_cast(key), static_cast(mod), ascii_key, disp, - ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0, - mapped ? static_cast(id) : -1); - std::fflush(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(key), static_cast(mod), ascii_key, disp, + ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0, + mapped ? static_cast(id) : -1); } +#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(ascii_key) - : '?'; - std::fprintf(stderr, - "[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n", - ascii_key, disp, mapped ? 1 : 0, - mapped ? static_cast(id) : -1); - std::fflush(stderr); + { + char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e) + ? static_cast(ascii_key) + : '?'; + IH_LOGF("[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n", + ascii_key, disp, mapped ? 1 : 0, + mapped ? static_cast(id) : -1); + } +#endif if (mapped) { mi = {true, id, "", 0}; if (ed_) diff --git a/KKeymap.cc b/KKeymap.cc index e0b6146..5af2291 100644 --- a/KKeymap.cc +++ b/KKeymap.cc @@ -1,6 +1,4 @@ -#include #include -#include #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; diff --git a/Swap.cc b/Swap.cc index e036d0e..10abcb9 100644 --- a/Swap.cc +++ b/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 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(*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) { diff --git a/Swap.h b/Swap.h index 5f4f872..645d651 100644 --- a/Swap.h +++ b/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 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); diff --git a/TerminalInputHandler.cc b/TerminalInputHandler.cc index 198d517..d4e0d27 100644 --- a/TerminalInputHandler.cc +++ b/TerminalInputHandler.cc @@ -1,4 +1,6 @@ #include +#include +#include #include #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(-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,12 +320,21 @@ 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(ch)); + } else if (!wchar_to_mb(static_cast(ch), mb)) { + out.hasCommand = false; + return true; + } out.hasCommand = true; out.id = CommandId::InsertText; - out.arg.assign(1, static_cast(ch)); - out.count = 0; + 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(wch); bool consumed = map_key_to_command( - ch, + ch, is_keycode, k_prefix_, esc_meta_, k_ctrl_pending_, mouse_selecting_, diff --git a/UndoNode.h b/UndoNode.h index a0b6504..c0adc2d 100644 --- a/UndoNode.h +++ b/UndoNode.h @@ -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 { diff --git a/UndoSystem.cc b/UndoSystem.cc index 45e3ea4..09f40a6 100644 --- a/UndoSystem.cc +++ b/UndoSystem.cc @@ -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(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(node->col), static_cast(node->row)); + } else { + buf_->split_line(node->row, node->col); + buf_->SetCursor(0, static_cast(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 "?"; } diff --git a/UndoTree.cc b/UndoTree.cc index 3ae9229..bf4d449 100644 --- a/UndoTree.cc +++ b/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; +} diff --git a/UndoTree.h b/UndoTree.h index e75afab..034b315 100644 --- a/UndoTree.h +++ b/UndoTree.h @@ -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(); }; diff --git a/main.cc b/main.cc index a68495a..d5690d7 100644 --- a/main.cc +++ b/main.cc @@ -292,6 +292,20 @@ main(int argc, char *argv[]) fe = std::make_unique(); } + // 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(); diff --git a/syntax/GoHighlighter.cc b/syntax/GoHighlighter.cc index 4ac90ae..02a52bf 100644 --- a/syntax/GoHighlighter.cc +++ b/syntax/GoHighlighter.cc @@ -46,14 +46,45 @@ GoHighlighter::GoHighlighter() void GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector &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 &out) const +{ + StatefulHighlighter::LineState state = prev; if (row < 0 || static_cast(row) >= buf.Nrows()) - return; + return state; std::string s = buf.GetLineString(static_cast(row)); int n = static_cast(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 namespace kte { -class GoHighlighter final : public LanguageHighlighter { +class GoHighlighter final : public StatefulHighlighter { public: GoHighlighter(); void HighlightLine(const Buffer &buf, int row, std::vector &out) const override; + LineState HighlightLineStateful(const Buffer &buf, + int row, + const LineState &prev, + std::vector &out) const override; + private: std::unordered_set kws_; std::unordered_set types_; diff --git a/syntax/HighlighterEngine.cc b/syntax/HighlighterEngine.cc index 8c5fbb8..b890b79 100644 --- a/syntax/HighlighterEngine.cc +++ b/syntax/HighlighterEngine.cc @@ -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; + } } diff --git a/syntax/PythonHighlighter.cc b/syntax/PythonHighlighter.cc index 4d244bc..950b0b6 100644 --- a/syntax/PythonHighlighter.cc +++ b/syntax/PythonHighlighter.cc @@ -55,31 +55,26 @@ PythonHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineS std::string s = buf.GetLineString(static_cast(row)); int n = static_cast(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(pos + static_cast(state.raw_delim.size())); - push(out, 0, end, TokenKind::String); - // remainder processed normally - s = s.substr(end); - n = static_cast(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; } + int end = static_cast(pos + static_cast(state.raw_delim.size())); + push(out, 0, end, TokenKind::String); + state.in_raw_string = false; + state.raw_delim.clear(); + // 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]; diff --git a/syntax/RustHighlighter.cc b/syntax/RustHighlighter.cc index cd4725a..a83a667 100644 --- a/syntax/RustHighlighter.cc +++ b/syntax/RustHighlighter.cc @@ -47,11 +47,42 @@ RustHighlighter::RustHighlighter() void RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector &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 &out) const +{ + StatefulHighlighter::LineState state = prev; if (row < 0 || static_cast(row) >= buf.Nrows()) - return; + return state; std::string s = buf.GetLineString(static_cast(row)); int n = static_cast(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 namespace kte { -class RustHighlighter final : public LanguageHighlighter { +class RustHighlighter final : public StatefulHighlighter { public: RustHighlighter(); void HighlightLine(const Buffer &buf, int row, std::vector &out) const override; + LineState HighlightLineStateful(const Buffer &buf, + int row, + const LineState &prev, + std::vector &out) const override; + private: std::unordered_set kws_; std::unordered_set types_; diff --git a/syntax/SqlHighlighter.cc b/syntax/SqlHighlighter.cc index 4413f27..7878dda 100644 --- a/syntax/SqlHighlighter.cc +++ b/syntax/SqlHighlighter.cc @@ -47,12 +47,42 @@ SqlHighlighter::SqlHighlighter() void SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector &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 &out) const +{ + StatefulHighlighter::LineState state = prev; if (row < 0 || static_cast(row) >= buf.Nrows()) - return; + return state; std::string s = buf.GetLineString(static_cast(row)); int n = static_cast(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 namespace kte { -class SqlHighlighter final : public LanguageHighlighter { +class SqlHighlighter final : public StatefulHighlighter { public: SqlHighlighter(); void HighlightLine(const Buffer &buf, int row, std::vector &out) const override; + LineState HighlightLineStateful(const Buffer &buf, + int row, + const LineState &prev, + std::vector &out) const override; + private: std::unordered_set kws_; std::unordered_set types_; diff --git a/tests/test_command_semantics.cc b/tests/test_command_semantics.cc index b4950fd..751f912 100644 --- a/tests/test_command_semantics.cc +++ b/tests/test_command_semantics.cc @@ -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); +} diff --git a/tests/test_kkeymap.cc b/tests/test_kkeymap.cc index 633854b..7d4a25f 100644 --- a/tests/test_kkeymap.cc +++ b/tests/test_kkeymap.cc @@ -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{}; diff --git a/tests/test_migration_coverage.cc b/tests/test_migration_coverage.cc index 1fc9090..5330d9d 100644 --- a/tests/test_migration_coverage.cc +++ b/tests/test_migration_coverage.cc @@ -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(3)); + const std::vector 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]); } } diff --git a/tests/test_search_replace_flow.cc b/tests/test_search_replace_flow.cc index 56f79ed..9adb9c2 100644 --- a/tests/test_search_replace_flow.cc +++ b/tests/test_search_replace_flow.cc @@ -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; diff --git a/tests/test_syntax_highlighting.cc b/tests/test_syntax_highlighting.cc new file mode 100644 index 0000000..283a6e5 --- /dev/null +++ b/tests/test_syntax_highlighting.cc @@ -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 +#include + +using kte::HighlightSpan; +using kte::StatefulHighlighter; +using kte::TokenKind; + +namespace { +bool +has_kind(const std::vector &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 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 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 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 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); +} diff --git a/tests/test_undo.cc b/tests/test_undo.cc index 21606e4..e7a1494 100644 --- a/tests/test_undo.cc +++ b/tests/test_undo.cc @@ -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(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(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(1)); + ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef")); + + ASSERT_TRUE(Execute(ed, CommandId::Undo)); + ASSERT_EQ(buf->Nrows(), static_cast(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(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(1)); + ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef")); + + ASSERT_TRUE(Execute(ed, CommandId::Undo)); + ASSERT_EQ(buf->Nrows(), static_cast(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(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(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(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(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(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;