Files
kte/tests/test_syntax_highlighting.cc
Kyle Isom 3126a5e523 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>
2026-07-01 14:19:15 -07:00

168 lines
4.9 KiB
C++

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