Regex: optional PCRE2 engine behind kte::Regex

RegexEngine.h/.cc wrap the regex engine for search, regex replace-all and
the terminal and ImGui renderers' match highlighting:

- With KTE_USE_PCRE2 (default ON; used when pkg-config finds libpcre2-8,
  linked as -lpcre2-8) patterns are compiled by PCRE2 with its JIT and
  matched under a match limit: catastrophic backtracking stops with a
  status note instead of hanging the editor, and matching does not
  recurse on the C stack (no large-stack thread; the per-keystroke and
  per-frame line limit is 1 MiB instead of 2,000 bytes). Otherwise
  std::regex is used as before.
- Replacement strings follow std::regex_replace (ECMAScript as libstdc++
  reads it) with either engine: the PCRE2 path expands $&, $n, $`, $',
  $$ itself and steps over empty matches the same way.
- Regex search walks lines over the materialized text instead of calling
  GetLineView per row. The terminal renderer compiles the pattern once
  per frame (it compiled it for every row).
- Only one checkpoint per journal is queued at a time: whole-file edits
  in a row on a large buffer each queued a full copy of the buffer.
- default.nix and the Dockerfile add pcre2 and pkg-config.

On 26 MB: regex keystroke 1.6 -> 0.14 ms, next 62 -> 6 ms, replace-all
929 -> 438 ms; a rarely matching regex over 105 MB: 1.55 s -> 0.10 s.

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 a33c1759e7
commit e782d24e32
14 changed files with 670 additions and 107 deletions
+52 -3
View File
@@ -9,6 +9,7 @@
#include "ErrorRecovery.h"
#include "PieceTable.h"
#include "RegexGuard.h"
#include "RegexEngine.h"
#include "Swap.h"
#include "UndoTree.h"
#include "syntax/HighlighterEngine.h"
@@ -1285,14 +1286,16 @@ TEST(Audit_RegexSearch_LongLineFoundByNext)
{
TestHarness h;
Editor &ed = h.EditorRef();
h.Buf().insert_text(0, 0, "short\n" + std::string(25000, 'a') + "NEEDLE\nend\n");
// Longer than search-as-you-type scans (depends on the regex engine).
const std::size_t long_len = kte::Regex::IncrementalLineLimit() + 1000;
h.Buf().insert_text(0, 0, "short\n" + std::string(long_len, 'a') + "NEEDLE\nend\n");
h.Buf().SetCursor(0, 0);
ASSERT_TRUE(h.Exec(CommandId::RegexFindStart));
ASSERT_TRUE(h.Exec(CommandId::InsertText, "NEE+DLE"));
ASSERT_TRUE(ed.Status().find("long line") != std::string::npos);
ASSERT_TRUE(h.Exec(CommandId::MoveRight));
ASSERT_EQ(h.Buf().Cury(), (std::size_t) 1);
ASSERT_EQ(h.Buf().Curx(), (std::size_t) 25000);
ASSERT_EQ(h.Buf().Curx(), long_len);
ASSERT_TRUE(h.Exec(CommandId::Refresh));
ASSERT_TRUE(!ed.PromptActive());
// The regex replace prompt steps through matches the same way.
@@ -1302,7 +1305,7 @@ TEST(Audit_RegexSearch_LongLineFoundByNext)
ASSERT_TRUE(ed.Status().find("Left/Right search all") != std::string::npos);
ASSERT_TRUE(h.Exec(CommandId::MoveRight));
ASSERT_EQ(h.Buf().Cury(), (std::size_t) 1);
ASSERT_EQ(h.Buf().Curx(), (std::size_t) 25000);
ASSERT_EQ(h.Buf().Curx(), long_len);
}
@@ -2179,3 +2182,49 @@ TEST(Swap_DeferredBaseCrc)
b.SetSwapRecorder(nullptr);
sm.Detach(&b, true);
}
// kte::Regex replacements follow std::regex_replace (ECMAScript), whichever
// engine is built in. (References to groups the pattern does not have, like
// $3 with two groups, are implementation-defined and left out.)
TEST(RegexEngine_ReplaceMatchesStdRegex)
{
const char *subjects[] = {"", "baaac", "one two three", "a-b-c", "xyz", "aaa", " lead", "tail "};
const std::pair<const char *, const char *> cases[] = {
{"a*", "X"}, {"a", "[$&]"}, {"(\\w+)", "<$1>"}, {"(o)(n)?", "$2$1"}, {"-", "$`|$'"},
{"\\s+", "$$"}, {"^", ">"}, {"$", "<"}, {"b|", "_"}, {"\\b", "|"},
{"([a-z])([a-z])", "$2$1"}, {"(a)(x)?", "[$2]"}
};
for (const char *subj: subjects) {
for (const auto &[pat, fmt]: cases) {
kte::Regex rx;
std::string err;
ASSERT_TRUE(rx.Compile(pat, err));
const std::string got = rx.ReplaceAll(subj, fmt);
const std::string want = std::regex_replace(std::string(subj), std::regex(pat), fmt);
if (got != want)
fprintf(stderr, "subj=[%s] pat=[%s] fmt=[%s] got=[%s] want=[%s]\n", subj, pat, fmt,
got.c_str(), want.c_str());
ASSERT_EQ(got, want);
}
}
}
// With PCRE2, catastrophic backtracking stops at the match limit and says
// so, instead of hanging the editor.
TEST(RegexEngine_CatastrophicPatternStops)
{
if (std::string(kte::Regex::EngineName()) != "PCRE2")
return; // std::regex has no limit (a known limitation)
TestHarness h;
h.Buf().insert_text(0, 0, std::string(40, 'a') + "!\n");
h.Buf().SetCursor(0, 0);
const auto t0 = std::chrono::steady_clock::now();
ASSERT_TRUE(h.Exec(CommandId::RegexFindStart));
ASSERT_TRUE(h.Exec(CommandId::InsertText, "^(a+)+$"));
const double secs = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
ASSERT_TRUE(secs < 10.0);
ASSERT_TRUE(h.EditorRef().Status().find("too complex") != std::string::npos);
ASSERT_TRUE(h.Exec(CommandId::Refresh));
}