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:
2026-07-01 14:19:15 -07:00
parent b60a8dc491
commit 3126a5e523
30 changed files with 1191 additions and 126 deletions

View File

@@ -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;