Highlighting: invalidate from the edited row, not the whole buffer

Every edit bumped the buffer version (through SetDirty) and called
InvalidateFrom(0), so all cached spans and line states were discarded
and stateful highlighters re-lexed from row 0 to the viewport on each
keystroke.

Buffer mutators now report the row they changed (edited_at_), which
bumps the version and tells the engine to drop only rows from there
down; a row's highlighting depends only on rows above it. A version
change that arrives without that notice still clears everything, so a
missed path cannot show stale spans. The engine keeps states as a
contiguous vector and caches spans for every row it lexes. SetDirty no
longer touches the version: the edits themselves do, including undo
and redo back to the saved state (D9 still holds; its test now checks
the highlighting itself).

Typing near the end of a 100k-line C++ file: 46.3 ms -> 0.14 ms of
highlighting work per keystroke.

Audit finding P3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
This commit is contained in:
Claude
2026-09-24 09:16:04 -07:00
committed by Kyle Isom
parent 4c42761fc0
commit f853fa60b1
5 changed files with 177 additions and 135 deletions
+72 -7
View File
@@ -9,6 +9,9 @@
#include "ErrorRecovery.h"
#include "PieceTable.h"
#include "UndoTree.h"
#include "syntax/HighlighterEngine.h"
#include "syntax/HighlighterRegistry.h"
#include "syntax/LanguageHighlighter.h"
#include <chrono>
#include <random>
@@ -202,15 +205,77 @@ TEST(Audit_D6_Reflow_OnBlankLine_KeepsParagraphsSeparate)
}
// D9: clearing the dirty flag (undo to the saved state) changes the version
// so version-keyed highlight caches are refreshed.
TEST(Audit_D9_SetDirtyFalse_BumpsVersion)
static bool
row_has_comment(Buffer &b, int row)
{
Buffer b;
b.SetDirty(true);
const auto v = b.Version();
const auto lh = b.Highlighter()->GetLine(b, row, b.Version());
for (const auto &sp: lh.spans)
if (sp.kind == kte::TokenKind::Comment)
return true;
return false;
}
// D9: undo back to the saved state must not leave stale highlighting (the
// rest of the file coloured as a comment after "/*" is undone).
TEST(Audit_D9_UndoToSaved_RefreshesHighlighting)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, "int a;\nint b;\n");
b.EnsureHighlighter();
b.Highlighter()->SetHighlighter(kte::HighlighterRegistry::CreateFor("cpp"));
if (auto *u = b.Undo())
u->mark_saved();
b.SetDirty(false);
ASSERT_TRUE(b.Version() != v);
ASSERT_TRUE(!row_has_comment(b, 1));
b.SetCursor(0, 0);
ASSERT_TRUE(h.Exec(CommandId::InsertText, "/*"));
ASSERT_TRUE(row_has_comment(b, 1));
ASSERT_TRUE(h.Undo());
ASSERT_EQ(h.Line(0), std::string("int a;"));
ASSERT_TRUE(!b.Dirty());
ASSERT_TRUE(!row_has_comment(b, 1));
}
// P3: an edit keeps cached highlighting for the rows above it; only rows
// from the edit down are recomputed.
TEST(Audit_P3_EditKeepsHighlightCacheAbove)
{
struct Counting : kte::StatefulHighlighter {
int *calls;
explicit Counting(int *c) : calls(c) {}
void HighlightLine(const Buffer &, int, std::vector<kte::HighlightSpan> &) const override {}
LineState HighlightLineStateful(const Buffer &, int, const LineState &prev,
std::vector<kte::HighlightSpan> &) const override
{
++*calls;
return prev;
}
};
TestHarness h;
Buffer &b = h.Buf();
std::string text;
for (int i = 0; i < 1000; ++i)
text += "line\n";
b.insert_text(0, 0, text);
b.EnsureHighlighter();
int calls = 0;
b.Highlighter()->SetHighlighter(std::make_unique<Counting>(&calls));
(void) b.Highlighter()->GetLine(b, 999, b.Version());
ASSERT_EQ(calls, 1000);
calls = 0;
b.insert_text(990, 0, "x");
(void) b.Highlighter()->GetLine(b, 999, b.Version());
ASSERT_EQ(calls, 10); // rows 990..999, not the whole file
}