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

37
Swap.cc
View File

@@ -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)
{