Fix round-four regressions: save fallback, replace memory, regex skip

- Save's in-place fallbacks (no temp file; no hard-link copy) now apply
  only when the directory refuses a new name (EACCES, EPERM,
  ENAMETOOLONG). On ENOSPC, EDQUOT or EIO the in-place write truncated
  the file and then failed, leaving it corrupt; now the save fails and
  the file is untouched (checked on a full tmpfs).
- replace_rows_text/replace_buffer_text delete and insert only the span
  between the old and new text's common prefix and suffix (kept on
  UTF-8 boundaries). Replace-all recorded the whole buffer twice in the
  undo tree per run.
- Regex search skips long lines only while typing (limit lowered to
  2,000 bytes, as for renderer highlighting; '.*q' on a 20 KB line took
  12 s), reports how many were skipped, and next/previous match search
  every line. Previously matches on long lines could not be found.

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 ca3a6e675b
commit b1b0b3de57
5 changed files with 230 additions and 58 deletions
+81 -44
View File
@@ -629,58 +629,78 @@ buffer_text(const Buffer &buf)
}
// Replace the whole buffer's text as one undo group (one delete, one insert).
// Replace the text of rows [y, y + old_text lines) in place with new_text,
// where old_text is those rows joined by '\n' (no trailing newline). Unlike
// delete_row/insert_row this never adds or removes the newline after the
// last replaced row, so a file without a trailing newline keeps that shape.
//
// Only the span between the texts' common prefix and suffix is deleted and
// inserted: recording the whole text kept two more copies of it in the undo
// tree (and the piece table and journal) for every replace, however small.
static void
replace_buffer_text(Buffer &buf, const std::string &old_text, const std::string &new_text, UndoSystem *u)
replace_rows_text(Buffer &buf, std::size_t y, const std::string &old_text, const std::string &new_text,
UndoSystem *u)
{
UndoGroupGuard group(u);
if (!old_text.empty()) {
buf.delete_text(0, 0, old_text.size());
const std::size_t common = std::min(old_text.size(), new_text.size());
std::size_t pre = 0;
while (pre < common && old_text[pre] == new_text[pre])
++pre;
std::size_t suf = 0;
while (suf < common - pre && old_text[old_text.size() - 1 - suf] == new_text[new_text.size() - 1 - suf])
++suf;
// Keep UTF-8 sequences whole on both sides of the edit.
auto is_cont = [](char c) {
return (static_cast<unsigned char>(c) & 0xC0) == 0x80;
};
while (pre > 0 && ((pre < old_text.size() && is_cont(old_text[pre])) ||
(pre < new_text.size() && is_cont(new_text[pre]))))
--pre;
while (suf > 0 && (is_cont(old_text[old_text.size() - suf]) || is_cont(new_text[new_text.size() - suf])))
--suf;
if (pre + suf > old_text.size() || pre + suf > new_text.size())
suf = std::min(old_text.size(), new_text.size()) - pre;
const std::size_t del_len = old_text.size() - pre - suf;
const std::size_t ins_len = new_text.size() - pre - suf;
if (del_len == 0 && ins_len == 0)
return;
// Row and column of the first changed byte.
std::size_t row = y, col = pre;
const std::size_t last_nl = pre == 0 ? std::string::npos : old_text.rfind('\n', pre - 1);
if (last_nl != std::string::npos) {
row += static_cast<std::size_t>(std::count(old_text.begin(),
old_text.begin() + static_cast<std::ptrdiff_t>(pre), '\n'));
col = pre - last_nl - 1;
}
if (del_len > 0) {
buf.delete_text(static_cast<int>(row), static_cast<int>(col), del_len);
if (u) {
buf.SetCursor(0, 0);
buf.SetCursor(col, row);
u->Begin(UndoType::Delete);
u->Append(std::string_view(old_text));
u->Append(std::string_view(old_text).substr(pre, del_len));
u->commit();
}
}
if (!new_text.empty()) {
buf.insert_text(0, 0, new_text);
if (ins_len > 0) {
const std::string_view ins = std::string_view(new_text).substr(pre, ins_len);
buf.insert_text(static_cast<int>(row), static_cast<int>(col), ins);
if (u) {
buf.SetCursor(0, 0);
buf.SetCursor(col, row);
u->Begin(UndoType::Insert);
u->Append(std::string_view(new_text));
u->Append(ins);
u->commit();
}
}
}
// Replace the text of rows [y, y + old_text lines) in place with new_text,
// where old_text is those rows joined by '\n' (no trailing newline). Unlike
// delete_row/insert_row this never adds or removes the newline after the
// last replaced row, so a file without a trailing newline keeps that shape.
// Replace the whole buffer's text as one undo group.
static void
replace_rows_text(Buffer &buf, std::size_t y, const std::string &old_text, const std::string &new_text,
UndoSystem *u)
replace_buffer_text(Buffer &buf, const std::string &old_text, const std::string &new_text, UndoSystem *u)
{
if (!old_text.empty()) {
buf.delete_text(static_cast<int>(y), 0, old_text.size());
if (u) {
buf.SetCursor(0, y);
u->Begin(UndoType::Delete);
u->Append(std::string_view(old_text));
u->commit();
}
}
if (!new_text.empty()) {
buf.insert_text(static_cast<int>(y), 0, new_text);
if (u) {
buf.SetCursor(0, y);
u->Begin(UndoType::Insert);
u->Append(std::string_view(new_text));
u->commit();
}
}
UndoGroupGuard group(u);
replace_rows_text(buf, 0, old_text, new_text, u);
}
@@ -848,8 +868,11 @@ struct RegexMatch {
static std::vector<RegexMatch>
search_compute_matches_regex(const Buffer &buf, const std::string &pattern, std::string &err_out)
search_compute_matches_regex(const Buffer &buf, const std::string &pattern, std::string &err_out,
std::size_t line_limit = static_cast<std::size_t>(-1), std::size_t *skipped = nullptr)
{
if (skipped)
*skipped = 0;
std::vector<RegexMatch> out;
err_out.clear();
if (pattern.empty())
@@ -860,13 +883,16 @@ search_compute_matches_regex(const Buffer &buf, const std::string &pattern, std:
// std::regex recurses per matched character; see RegexGuard.h.
kte::RunWithLargeStack([&] {
for (std::size_t y = 0; y < rows.size(); ++y) {
// This runs on every prompt keystroke, and std::regex can be
// quadratic in line length for ordinary patterns (".*q" on a
// 40 KB minified line took over a minute, uninterruptibly), so
// incremental search skips very long lines. Regex replace, an
// explicit one-shot command, still processes every line.
if (rows[y].size() > kte::kRegexIncrementalLineLimit)
// std::regex can be quadratic in line length for ordinary
// patterns (".*q" on a 40 KB minified line took over a
// minute, uninterruptibly), so the per-keystroke caller
// passes a limit; moving to the next/previous match
// (Left/Right in the prompt) searches every line.
if (rows[y].size() > line_limit) {
if (skipped)
++*skipped;
continue;
}
std::string line = static_cast<std::string>(rows[y]);
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
it != std::sregex_iterator(); ++it) {
@@ -2411,7 +2437,9 @@ cmd_insert_text(CommandContext &ctx)
if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) {
std::string err;
auto rmatches = search_compute_matches_regex(*buf, ctx.editor.SearchQuery(), err);
std::size_t skipped = 0;
auto rmatches = search_compute_matches_regex(*buf, ctx.editor.SearchQuery(), err,
kte::kRegexIncrementalLineLimit, &skipped);
if (!err.empty()) {
ctx.editor.SetStatus(
std::string("Regex: ") + ctx.editor.PromptText() + " [error: " + err +
@@ -2420,6 +2448,9 @@ cmd_insert_text(CommandContext &ctx)
if (ctx.editor.SearchIndex() >= static_cast<int>(rmatches.size()))
ctx.editor.SetSearchIndex(rmatches.empty() ? -1 : 0);
search_apply_match_regex(ctx.editor, *buf, rmatches);
if (skipped > 0 && err.empty())
ctx.editor.SetStatus(ctx.editor.Status() + " [" + std::to_string(skipped) +
" long line(s) skipped; Left/Right search all]");
} else {
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery());
// Keep index stable unless out of range
@@ -3571,13 +3602,19 @@ cmd_backspace(CommandContext &ctx)
if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) {
std::string err;
auto rm = search_compute_matches_regex(*buf2, ctx.editor.SearchQuery(), err);
std::size_t skipped = 0;
auto rm = search_compute_matches_regex(
*buf2, ctx.editor.SearchQuery(), err, kte::kRegexIncrementalLineLimit,
&skipped);
if (!err.empty()) {
ctx.editor.SetStatus(
std::string("Regex: ") + ctx.editor.PromptText() + " [error: "
+ err + "]");
}
search_apply_match_regex(ctx.editor, *buf2, rm);
if (skipped > 0 && err.empty())
ctx.editor.SetStatus(ctx.editor.Status() + " [" + std::to_string(skipped) +
" long line(s) skipped; Left/Right search all]");
} else {
auto matches = search_compute_matches(*buf2, ctx.editor.SearchQuery());
search_apply_match(ctx.editor, *buf2, matches);