Search: stop at the first match from the cursor; capped count

run_search() replaces collecting every match in the buffer (and building
the full list again for every next/previous):

- Plain search finds forward from the search origin (then the current
  match) in the materialized text, or backwards through 1 MiB blocks
  searched forward (string_view::rfind scans a byte at a time: 511 ms
  per previous-match on 105 MB, now 6 ms). Regex search walks lines
  from the position with early exit.
- The i/N count stops at 1,000 matches, and for regex after 4 MiB; the
  status says 'N+' when capped.
- The first match is the first at or after the cursor (it was the first
  in the buffer). Extending the query keeps the current match while it
  still matches. Left/Right also step through matches in the regex
  replace prompt; Up/Down in a regex prompt use the regex.

On a 26 MB file: plain keystroke 30 ms -> 0.1 ms, next 24 -> 3 ms, regex
keystroke 434 -> 1.6 ms, regex next 420 -> 66 ms.

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 404b983009
commit fc15a58a2b
4 changed files with 510 additions and 319 deletions
+13
View File
@@ -700,6 +700,19 @@ public:
return content_.Size(); return content_.Size();
} }
// Byte offset of (row, col) in the content (clamped), and back.
[[nodiscard]] std::size_t RowColToOffset(std::size_t row, std::size_t col) const
{
return content_.LineColToByteOffset(row, col);
}
[[nodiscard]] std::pair<std::size_t, std::size_t> OffsetToRowCol(std::size_t off) const
{
return content_.ByteOffsetToLineCol(off);
}
// Undo system accessors (created per-buffer) // Undo system accessors (created per-buffer)
[[nodiscard]] UndoSystem *Undo(); [[nodiscard]] UndoSystem *Undo();
+337 -315
View File
@@ -907,150 +907,344 @@ line_text_view(const Buffer &buf, const std::size_t y)
return v; return v;
} }
static std::vector<std::pair<std::size_t, std::size_t> >
search_compute_matches(const Buffer &buf, const std::string &q)
{ // ===== Incremental search engine =====
std::vector<std::pair<std::size_t, std::size_t> > out; // Finding a match stops at the first one after the current position (it
if (q.empty()) // used to collect every match in the buffer on every keystroke and every
return out; // next/previous); the "i/N" count is capped (see search_count).
// Views into the materialized buffer (one copy, kept until the next
// edit): Rows() built a string per line, and kept them all, on every struct SearchHit {
// search keystroke after an edit. std::size_t y = 0;
// Matches never span lines (the query is matched within each line). std::size_t x = 0;
if (q.find('\n') != std::string::npos) std::size_t len = 0;
return out; };
const std::size_t nrows = buf.Nrows();
if (nrows == 0) // Count caps: stop at this many matches, and after scanning this many bytes
return out; // (the regex budget is lower: std::regex is far slower than find).
// One scan of the whole text (find is much faster over one large range static constexpr std::size_t kSearchCountCap = 1000;
// than restarted per line), mapping each hit to its row as we go. static constexpr std::size_t kSearchCountBytesPlain = std::size_t{64} << 20;
const char *base = buf.GetLineView(0).data(); static constexpr std::size_t kSearchCountBytesRegex = std::size_t{4} << 20;
const auto last = buf.GetLineView(nrows - 1);
const std::string_view text(base, static_cast<std::size_t>(last.data() + last.size() - base));
std::size_t y = 0; class RegexMatcher {
std::size_t line_start = 0; public:
std::size_t next_nl = text.find('\n'); bool Compile(const std::string &pattern, std::string &err)
std::size_t pos = 0; {
while ((pos = text.find(q, pos)) != std::string_view::npos) { err.clear();
while (next_nl < pos) { try {
++y; rx_ = std::regex(pattern);
line_start = next_nl + 1; return true;
next_nl = text.find('\n', line_start); } catch (const std::regex_error &e) {
err = e.what();
return false;
} }
out.emplace_back(y, pos - line_start);
pos += q.size();
} }
return out;
}
// Regex-based matches (per-line), capturing match length for highlighting // First match in `line` starting at byte >= from.
struct RegexMatch { bool Find(std::string_view line, std::size_t from, std::size_t &pos, std::size_t &len) const
std::size_t y; {
std::size_t x; if (from > line.size())
std::size_t len; return false;
std::cmatch m;
const auto flags = from > 0 ? std::regex_constants::match_prev_avail
: std::regex_constants::match_default;
if (!std::regex_search(line.data() + from, line.data() + line.size(), m, rx_, flags))
return false;
pos = from + static_cast<std::size_t>(m.position(0));
len = static_cast<std::size_t>(m.length(0));
return true;
}
private:
std::regex rx_;
}; };
static std::vector<RegexMatch> static std::string_view line_text_view(const Buffer &buf, std::size_t y);
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)
// The whole text as one view into the materialized buffer.
static std::string_view
buffer_text_view(const Buffer &buf)
{ {
if (skipped) const std::size_t nrows = buf.Nrows();
*skipped = 0; if (nrows == 0)
std::vector<RegexMatch> out; return {};
err_out.clear(); const char *base = buf.GetLineView(0).data();
if (pattern.empty()) const auto last = buf.GetLineView(nrows - 1);
return out; return {base, static_cast<std::size_t>(last.data() + last.size() - base)};
try { }
const std::regex rx(pattern);
const std::size_t nrows = buf.Nrows();
// std::regex recurses per matched character; see RegexGuard.h. // The last occurrence of q starting before `end`. string_view::rfind scans
kte::RunWithLargeStack([&] { // backwards a byte at a time (half a second over 100 MB); forward find is
for (std::size_t y = 0; y < nrows; ++y) { // vectorized, so scan blocks forward, moving back one block at a time.
const std::string_view line = line_text_view(buf, y); static std::size_t
// std::regex can be quadratic in line length for ordinary rfind_before(std::string_view text, std::string_view q, std::size_t end)
// patterns (".*q" on a 40 KB minified line took over a {
// minute, uninterruptibly), so the per-keystroke caller constexpr std::size_t kBlock = std::size_t{1} << 20;
// passes a limit; moving to the next/previous match end = std::min(end, text.size());
// (Left/Right in the prompt) searches every line. while (end > 0) {
if (line.size() > line_limit) { const std::size_t start = end > kBlock ? end - kBlock : 0;
if (skipped) // Matches starting in [start, end); they may run past `end`.
++*skipped; const std::string_view block = text.substr(start, std::min(text.size() - start, end - start + q.size() - 1));
continue; std::size_t last = std::string_view::npos;
for (std::size_t p = block.find(q); p != std::string_view::npos && start + p < end; p = block.find(q, p + 1))
last = p;
if (last != std::string_view::npos)
return start + last;
end = start;
}
return std::string_view::npos;
}
// Plain text: the first match at or after (y, x) (forward), or the last one
// starting before it (backward), wrapping around the buffer.
static bool
plain_find(const Buffer &buf, const std::string &q, std::size_t y, std::size_t x, bool forward, SearchHit &hit)
{
if (q.empty() || q.find('\n') != std::string::npos)
return false; // matches never span lines
const std::string_view text = buffer_text_view(buf);
const std::size_t off = std::min(buf.RowColToOffset(y, x), text.size());
std::size_t p = std::string_view::npos;
if (forward) {
p = text.find(q, off);
if (p == std::string_view::npos) {
p = text.find(q);
if (p >= off)
p = std::string_view::npos;
}
} else {
p = rfind_before(text, q, off);
if (p == std::string_view::npos) {
p = rfind_before(text, q, text.size());
if (p != std::string_view::npos && p < off)
p = std::string_view::npos;
}
}
if (p == std::string_view::npos)
return false;
const auto [ry, rx] = buf.OffsetToRowCol(p);
hit = SearchHit{ry, rx, q.size()};
return true;
}
// Regex: like plain_find, line by line. Lines longer than line_limit are
// skipped (counted in *skipped).
static bool
regex_find(const Buffer &buf, const RegexMatcher &m, std::size_t y0, std::size_t x0, bool forward,
std::size_t line_limit, std::size_t *skipped, SearchHit &hit)
{
const std::size_t nrows = buf.Nrows();
if (nrows == 0)
return false;
y0 = std::min(y0, nrows - 1);
bool found = false;
kte::RunWithLargeStack([&] {
// nrows + 1 visits: the start row is visited again after wrapping,
// for the part on the other side of x0.
for (std::size_t i = 0; i <= nrows && !found; ++i) {
const std::size_t y = forward ? (y0 + i) % nrows : (y0 + nrows - i % nrows) % nrows;
const std::string_view line = line_text_view(buf, y);
if (line.size() > line_limit) {
if (skipped && i < nrows)
++*skipped;
continue;
}
const bool first = i == 0, again = i == nrows;
std::size_t pos = 0, len = 0;
if (forward) {
const std::size_t from = first ? x0 : 0;
if (m.Find(line, from, pos, len) && (!again || pos < x0)) {
hit = SearchHit{y, pos, len};
found = true;
} }
for (auto it = std::cregex_iterator(line.data(), line.data() + line.size(), rx); } else {
it != std::cregex_iterator(); ++it) { // The last match in the line before x0 (first visit), at or
const auto &m = *it; // after it (after wrapping), or anywhere (other rows).
out.push_back(RegexMatch{ bool have = false;
y, static_cast<std::size_t>(m.position()), static_cast<std::size_t>(m.length()) SearchHit best{};
}); std::size_t from = 0;
while (from <= line.size() && m.Find(line, from, pos, len)) {
if (first && pos >= x0)
break;
if (!again || pos >= x0) {
best = SearchHit{y, pos, len};
have = true;
}
from = pos + std::max<std::size_t>(len, 1);
}
if (have) {
hit = best;
found = true;
} }
} }
}); }
} catch (const std::regex_error &e) { });
err_out = e.what(); return found;
// Return empty results on error
}
return out;
} }
static void struct SearchCount {
search_apply_match_regex(Editor &ed, Buffer &buf, const std::vector<RegexMatch> &matches) std::size_t before = 0; // matches before the current one
std::size_t total = 0;
bool reached = false; // the scan got as far as the current match
bool complete = false; // every match was counted
};
static SearchCount
search_count(const Buffer &buf, bool regex, const std::string &q, const RegexMatcher *m, const SearchHit &cur,
std::size_t line_limit)
{ {
const std::string &q = ed.SearchQuery(); SearchCount c;
if (matches.empty()) { const std::size_t nrows = buf.Nrows();
if (!regex) {
const std::string_view all = buffer_text_view(buf);
const std::string_view text = all.substr(0, kSearchCountBytesPlain);
const std::size_t cur_off = buf.RowColToOffset(cur.y, cur.x);
std::size_t p = 0;
while (c.total < kSearchCountCap && (p = text.find(q, p)) != std::string_view::npos) {
if (p < cur_off)
++c.before;
else
c.reached = true;
++c.total;
p += q.size();
}
if (p != std::string_view::npos && p >= cur_off)
c.reached = true;
c.complete = p == std::string_view::npos && text.size() == all.size();
if (c.complete)
c.reached = true;
return c;
}
std::size_t scanned = 0;
bool stopped = false;
kte::RunWithLargeStack([&] {
for (std::size_t y = 0; y < nrows && !stopped; ++y) {
const std::string_view line = line_text_view(buf, y);
if (line.size() > line_limit)
continue;
scanned += line.size() + 1;
std::size_t from = 0, pos = 0, len = 0;
while (from <= line.size() && m->Find(line, from, pos, len)) {
if (y < cur.y || (y == cur.y && pos < cur.x))
++c.before;
else
c.reached = true;
if (++c.total >= kSearchCountCap) {
stopped = true;
break;
}
from = pos + std::max<std::size_t>(len, 1);
}
if (y >= cur.y)
c.reached = true;
if (scanned > kSearchCountBytesRegex && y + 1 < nrows) {
stopped = true;
break;
}
}
});
c.complete = !stopped;
if (c.complete)
c.reached = true;
return c;
}
enum class SearchStep { Here, Next, Prev };
// Run the search for the editor's query and show the result: move the
// cursor to the match (or back to the origin if there is none) and set the
// status. Here: from the current match (so it stays while the extended query
// still matches there) or the search origin; Next/Prev: after/before the
// current match. incremental limits regex to lines up to
// kRegexIncrementalLineLimit (search-as-you-type).
static void
run_search(Editor &ed, Buffer &buf, bool regex, SearchStep step, bool incremental)
{
const std::string q = ed.SearchQuery();
const std::string prefix = regex ? "Regex: " : "Find: ";
const bool have_cur = ed.SearchIndex() >= 0;
std::size_t y = buf.Cury(), x = buf.Curx();
if (have_cur) {
y = ed.SearchMatchY();
x = ed.SearchMatchX();
} else if (step == SearchStep::Here && ed.SearchOriginSet()) {
y = ed.SearchOrigY();
x = ed.SearchOrigX();
}
if (step == SearchStep::Next && have_cur) {
const std::size_t len = std::max<std::size_t>(ed.SearchMatchLen(), 1);
x += len;
if (x > line_text_view(buf, y).size() && y + 1 < buf.Nrows()) {
++y;
x = 0;
}
}
RegexMatcher m;
std::string err;
SearchHit hit;
bool found = false;
std::size_t skipped = 0;
const std::size_t line_limit = (regex && incremental) ? kte::kRegexIncrementalLineLimit
: static_cast<std::size_t>(-1);
if (!q.empty()) {
if (!regex)
found = plain_find(buf, q, y, x, step != SearchStep::Prev, hit);
else if (m.Compile(q, err))
found = regex_find(buf, m, y, x, step != SearchStep::Prev, line_limit, &skipped, hit);
}
if (!found) {
ed.SetSearchMatch(0, 0, 0); ed.SetSearchMatch(0, 0, 0);
// Restore cursor to origin if present
if (ed.SearchOriginSet()) { if (ed.SearchOriginSet()) {
buf.SetCursor(ed.SearchOrigX(), ed.SearchOrigY()); buf.SetCursor(ed.SearchOrigX(), ed.SearchOrigY());
buf.SetOffsets(ed.SearchOrigRowoffs(), ed.SearchOrigColoffs()); buf.SetOffsets(ed.SearchOrigRowoffs(), ed.SearchOrigColoffs());
} }
ed.SetSearchIndex(-1); ed.SetSearchIndex(-1);
ed.SetStatus("Regex: " + q); std::string status = prefix + q;
if (!err.empty())
status += " [error: " + err + "]";
else if (skipped > 0)
status += " [" + std::to_string(skipped) + " long line(s) skipped; Left/Right search all]";
ed.SetStatus(status);
return; return;
} }
int idx = ed.SearchIndex();
if (idx < 0 || idx >= static_cast<int>(matches.size())) ed.SetSearchMatch(hit.y, hit.x, hit.len);
idx = 0; buf.SetCursor(hit.x, hit.y);
const auto &m = matches[static_cast<std::size_t>(idx)];
ed.SetSearchMatch(m.y, m.x, m.len);
buf.SetCursor(m.x, m.y);
ensure_cursor_visible(ed, buf); ensure_cursor_visible(ed, buf);
char tmp[64]; const SearchCount c = search_count(buf, regex, q, regex ? &m : nullptr, hit, line_limit);
snprintf(tmp, sizeof(tmp), "%d/%zu", idx + 1, matches.size()); ed.SetSearchIndex(static_cast<int>(c.reached ? c.before : 0));
ed.SetStatus(std::string("Regex: ") + q + " " + tmp); std::string where;
ed.SetSearchIndex(idx); if (c.complete)
where = std::to_string(c.before + 1) + "/" + std::to_string(c.total);
else if (c.reached)
where = std::to_string(c.before + 1) + "/" + std::to_string(c.total) + "+";
else
where = std::to_string(c.total) + "+ matches";
std::string status = prefix + q + " " + where;
if (skipped > 0)
status += " [" + std::to_string(skipped) + " long line(s) skipped; Left/Right search all]";
ed.SetStatus(status);
} }
static void static bool
search_apply_match(Editor &ed, Buffer &buf, const std::vector<std::pair<std::size_t, std::size_t> > &matches) regex_prompt(const Editor &ed)
{ {
const std::string &q = ed.SearchQuery(); return ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
if (matches.empty()) { ed.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind;
ed.SetSearchMatch(0, 0, 0);
// Restore cursor to origin if present
if (ed.SearchOriginSet()) {
buf.SetCursor(ed.SearchOrigX(), ed.SearchOrigY());
buf.SetOffsets(ed.SearchOrigRowoffs(), ed.SearchOrigColoffs());
}
ed.SetSearchIndex(-1);
ed.SetStatus("Find: " + q);
return;
}
int idx = ed.SearchIndex();
if (idx < 0 || idx >= static_cast<int>(matches.size()))
idx = 0;
auto [y, x] = matches[static_cast<std::size_t>(idx)];
ed.SetSearchMatch(y, x, q.size());
buf.SetCursor(x, y);
ensure_cursor_visible(ed, buf);
char tmp[64];
snprintf(tmp, sizeof(tmp), "%d/%zu", idx + 1, matches.size());
ed.SetStatus(std::string("Find: ") + q + " " + tmp);
ed.SetSearchIndex(idx);
} }
@@ -2519,33 +2713,7 @@ cmd_insert_text(CommandContext &ctx)
ctx.editor.CurrentPromptKind() == Editor::PromptKind::ReplaceFind || ctx.editor.CurrentPromptKind() == Editor::PromptKind::ReplaceFind ||
ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) { ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) {
ctx.editor.SetSearchQuery(ctx.editor.PromptText()); ctx.editor.SetSearchQuery(ctx.editor.PromptText());
if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch || run_search(ctx.editor, *buf, regex_prompt(ctx.editor), SearchStep::Here, true);
ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) {
std::string 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 +
"]");
}
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; " +
(ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch
? "Left/Right search all]"
: "Enter searches all]"));
} else {
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery());
// Keep index stable unless out of range
if (ctx.editor.SearchIndex() >= static_cast<int>(matches.size()))
ctx.editor.SetSearchIndex(matches.empty() ? -1 : 0);
search_apply_match(ctx.editor, *buf, matches);
}
} else { } else {
// For other prompts, just echo label:text in status // For other prompts, just echo label:text in status
ctx.editor.SetStatus(ctx.editor.PromptLabel() + ": " + ctx.editor.PromptText()); ctx.editor.SetStatus(ctx.editor.PromptLabel() + ": " + ctx.editor.PromptText());
@@ -2558,30 +2726,7 @@ cmd_insert_text(CommandContext &ctx)
q += ctx.arg; // arg already printable text q += ctx.arg; // arg already printable text
ctx.editor.SetSearchQuery(q); ctx.editor.SetSearchQuery(q);
// Recompute matches and move cursor to current index run_search(ctx.editor, *buf, false, SearchStep::Here, true);
const auto matches = search_compute_matches(*buf, q);
if (matches.empty()) {
ctx.editor.SetSearchMatch(0, 0, 0);
// Restore to origin if available
if (ctx.editor.SearchOriginSet()) {
buf->SetCursor(ctx.editor.SearchOrigX(), ctx.editor.SearchOrigY());
buf->SetOffsets(ctx.editor.SearchOrigRowoffs(), ctx.editor.SearchOrigColoffs());
}
ctx.editor.SetSearchIndex(-1);
ctx.editor.SetStatus("Find: " + q);
} else {
int idx = ctx.editor.SearchIndex();
if (idx < 0 || idx >= static_cast<int>(matches.size()))
idx = 0;
auto [y, x] = matches[static_cast<std::size_t>(idx)];
ctx.editor.SetSearchMatch(y, x, q.size());
buf->SetCursor(x, y);
ensure_cursor_visible(ctx.editor, *buf);
char tmp[64];
snprintf(tmp, sizeof(tmp), "%d/%zu", idx + 1, matches.size());
ctx.editor.SetStatus(std::string("Find: ") + q + " " + tmp);
ctx.editor.SetSearchIndex(idx);
}
return true; return true;
} }
// Disallow newlines in InsertText; they should come via Newline // Disallow newlines in InsertText; they should come via Newline
@@ -2957,10 +3102,8 @@ cmd_newline(CommandContext &ctx)
// Keep search highlights active using the collected find string // Keep search highlights active using the collected find string
ctx.editor.SetSearchActive(true); ctx.editor.SetSearchActive(true);
ctx.editor.SetSearchQuery(value); ctx.editor.SetSearchQuery(value);
if (Buffer *b = ctx.editor.CurrentBuffer()) { if (Buffer *b = ctx.editor.CurrentBuffer())
auto matches = search_compute_matches(*b, ctx.editor.SearchQuery()); run_search(ctx.editor, *b, false, SearchStep::Here, false);
search_apply_match(ctx.editor, *b, matches);
}
ctx.editor.StartPrompt(Editor::PromptKind::ReplaceWith, "Replace: with", ""); ctx.editor.StartPrompt(Editor::PromptKind::ReplaceWith, "Replace: with", "");
ctx.editor.SetStatus("Replace: with: "); ctx.editor.SetStatus("Replace: with: ");
return true; return true;
@@ -3395,14 +3538,8 @@ cmd_newline(CommandContext &ctx)
// Keep search highlights active using the collected regex pattern // Keep search highlights active using the collected regex pattern
ctx.editor.SetSearchActive(true); ctx.editor.SetSearchActive(true);
ctx.editor.SetSearchQuery(value); ctx.editor.SetSearchQuery(value);
if (Buffer *b = ctx.editor.CurrentBuffer()) { if (Buffer *b = ctx.editor.CurrentBuffer())
std::string err; run_search(ctx.editor, *b, true, SearchStep::Here, false);
auto rm = search_compute_matches_regex(*b, ctx.editor.SearchQuery(), err);
if (!err.empty()) {
ctx.editor.SetStatus(std::string("Regex: ") + value + " [error: " + err + "]");
}
search_apply_match_regex(ctx.editor, *b, rm);
}
ctx.editor.StartPrompt(Editor::PromptKind::RegexReplaceWith, "Regex replace: with", ""); ctx.editor.StartPrompt(Editor::PromptKind::RegexReplaceWith, "Regex replace: with", "");
ctx.editor.SetStatus("Regex replace: with: "); ctx.editor.SetStatus("Regex replace: with: ");
return true; return true;
@@ -3649,29 +3786,7 @@ cmd_backspace(CommandContext &ctx)
Buffer *buf2 = ctx.editor.CurrentBuffer(); Buffer *buf2 = ctx.editor.CurrentBuffer();
if (buf2) { if (buf2) {
ctx.editor.SetSearchQuery(ctx.editor.PromptText()); ctx.editor.SetSearchQuery(ctx.editor.PromptText());
if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch || run_search(ctx.editor, *buf2, regex_prompt(ctx.editor), SearchStep::Here, true);
ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) {
std::string 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; " +
(ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch
? "Left/Right search all]"
: "Enter searches all]"));
} else {
auto matches = search_compute_matches(*buf2, ctx.editor.SearchQuery());
search_apply_match(ctx.editor, *buf2, matches);
}
} }
} else { } else {
ctx.editor.SetStatus(ctx.editor.PromptLabel() + ": " + ctx.editor.PromptText()); ctx.editor.SetStatus(ctx.editor.PromptLabel() + ": " + ctx.editor.PromptText());
@@ -3685,11 +3800,8 @@ cmd_backspace(CommandContext &ctx)
q.pop_back(); q.pop_back();
ctx.editor.SetSearchQuery(q); ctx.editor.SetSearchQuery(q);
} }
Buffer *buf2 = ctx.editor.CurrentBuffer(); if (Buffer *buf2 = ctx.editor.CurrentBuffer())
if (buf2) { run_search(ctx.editor, *buf2, false, SearchStep::Here, true);
auto matches = search_compute_matches(*buf2, ctx.editor.SearchQuery());
search_apply_match(ctx.editor, *buf2, matches);
}
return true; return true;
} }
Buffer *buf = ctx.editor.CurrentBuffer(); Buffer *buf = ctx.editor.CurrentBuffer();
@@ -4314,54 +4426,18 @@ cmd_move_left(CommandContext &ctx)
if (auto *u = buf->Undo()) if (auto *u = buf->Undo())
u->commit(); u->commit();
// If a prompt is active and it's search, go to previous match // If a prompt is active and it's search, go to previous match
// In a search prompt (or search mode), Left/Right step to the previous/
// next match.
if (ctx.editor.PromptActive() && if (ctx.editor.PromptActive() &&
(ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search || (ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search ||
ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
ctx.editor.CurrentPromptKind() == Editor::PromptKind::ReplaceFind)) { ctx.editor.CurrentPromptKind() == Editor::PromptKind::ReplaceFind ||
if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch) { ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
std::string err; run_search(ctx.editor, *buf, regex_prompt(ctx.editor), SearchStep::Prev, false);
auto rmatches = search_compute_matches_regex(*buf, ctx.editor.SearchQuery(), err);
if (!err.empty()) {
ctx.editor.SetStatus(
std::string("Regex: ") + ctx.editor.PromptText() + " [error: " + err + "]");
}
if (!rmatches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx - 1 + static_cast<int>(rmatches.size())) % static_cast<int>(rmatches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match_regex(ctx.editor, *buf, rmatches);
} else {
search_apply_match_regex(ctx.editor, *buf, rmatches);
}
} else {
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery());
if (!matches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx - 1 + static_cast<int>(matches.size())) % static_cast<int>(matches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match(ctx.editor, *buf, matches);
} else {
search_apply_match(ctx.editor, *buf, matches);
}
}
return true; return true;
} }
if (ctx.editor.SearchActive()) { if (ctx.editor.SearchActive()) {
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery()); run_search(ctx.editor, *buf, false, SearchStep::Prev, false);
if (!matches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx - 1 + static_cast<int>(matches.size())) % static_cast<int>(matches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match(ctx.editor, *buf, matches);
} else {
search_apply_match(ctx.editor, *buf, matches);
}
return true; return true;
} }
ensure_at_least_one_line(*buf); ensure_at_least_one_line(*buf);
@@ -4399,54 +4475,18 @@ cmd_move_right(CommandContext &ctx)
return false; return false;
if (auto *u = buf->Undo()) if (auto *u = buf->Undo())
u->commit(); u->commit();
// In a search prompt (or search mode), Left/Right step to the previous/
// next match.
if (ctx.editor.PromptActive() && if (ctx.editor.PromptActive() &&
(ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search || (ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search ||
ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
ctx.editor.CurrentPromptKind() == Editor::PromptKind::ReplaceFind)) { ctx.editor.CurrentPromptKind() == Editor::PromptKind::ReplaceFind ||
if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch) { ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
std::string err; run_search(ctx.editor, *buf, regex_prompt(ctx.editor), SearchStep::Next, false);
auto rmatches = search_compute_matches_regex(*buf, ctx.editor.SearchQuery(), err);
if (!err.empty()) {
ctx.editor.SetStatus(
std::string("Regex: ") + ctx.editor.PromptText() + " [error: " + err + "]");
}
if (!rmatches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx + 1) % static_cast<int>(rmatches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match_regex(ctx.editor, *buf, rmatches);
} else {
search_apply_match_regex(ctx.editor, *buf, rmatches);
}
} else {
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery());
if (!matches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx + 1) % static_cast<int>(matches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match(ctx.editor, *buf, matches);
} else {
search_apply_match(ctx.editor, *buf, matches);
}
}
return true; return true;
} }
if (ctx.editor.SearchActive()) { if (ctx.editor.SearchActive()) {
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery()); run_search(ctx.editor, *buf, false, SearchStep::Next, false);
if (!matches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx + 1) % static_cast<int>(matches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match(ctx.editor, *buf, matches);
} else {
search_apply_match(ctx.editor, *buf, matches);
}
return true; return true;
} }
ensure_at_least_one_line(*buf); ensure_at_least_one_line(*buf);
@@ -4480,18 +4520,9 @@ cmd_move_up(CommandContext &ctx)
u->commit(); u->commit();
if ((ctx.editor.PromptActive() && ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search) || ctx.editor. if ((ctx.editor.PromptActive() && ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search) || ctx.editor.
SearchActive()) { SearchActive()) {
// Up == previous match // Up == previous match (a regex query in a regex prompt)
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery()); run_search(ctx.editor, *buf, ctx.editor.PromptActive() && regex_prompt(ctx.editor), SearchStep::Prev,
if (!matches.empty()) { false);
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx - 1 + static_cast<int>(matches.size())) % static_cast<int>(matches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match(ctx.editor, *buf, matches);
} else {
search_apply_match(ctx.editor, *buf, matches);
}
return true; return true;
} }
ensure_at_least_one_line(*buf); ensure_at_least_one_line(*buf);
@@ -4529,18 +4560,9 @@ cmd_move_down(CommandContext &ctx)
u->commit(); u->commit();
if ((ctx.editor.PromptActive() && ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search) || ctx.editor. if ((ctx.editor.PromptActive() && ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search) || ctx.editor.
SearchActive()) { SearchActive()) {
// Down == next match // Down == next match (a regex query in a regex prompt)
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery()); run_search(ctx.editor, *buf, ctx.editor.PromptActive() && regex_prompt(ctx.editor), SearchStep::Next,
if (!matches.empty()) { false);
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx + 1) % static_cast<int>(matches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match(ctx.editor, *buf, matches);
} else {
search_apply_match(ctx.editor, *buf, matches);
}
return true; return true;
} }
ensure_at_least_one_line(*buf); ensure_at_least_one_line(*buf);
+15 -2
View File
@@ -513,6 +513,19 @@ before the fix (unless noted).
- replace_all_bytes clears undo history (it was recorded against, and - replace_all_bytes clears undo history (it was recorded against, and
may reference, the previous content). may reference, the previous content).
**Follow-up: search**
- Search finds the next match from the search origin (then from the
current match) and stops there; it no longer collects every match in
the buffer on every keystroke and next/previous. The i/N count stops
at 1,000 matches (and, for regex, after 4 MiB). On 26 MB: plain
keystroke 30 ms -> 0.1 ms, next 24 -> 3 ms, regex keystroke 434 ->
1.6 ms. Previous-match scans backwards in 1 MiB forward-searched
blocks (string_view::rfind: 511 ms -> 6 ms on 105 MB).
- Semantics change: the first match is the first one at or after the
cursor (it was the first in the buffer), and Left/Right also step
through matches in the regex replace prompt; Up/Down in a regex prompt
use the regex.
**Known limitations (not fixed)** **Known limitations (not fixed)**
- Catastrophic regex backtracking (e.g. `(a*)*b`) can still take very - Catastrophic regex backtracking (e.g. `(a*)*b`) can still take very
long; std::regex has no time limit. long; std::regex has no time limit.
@@ -524,8 +537,8 @@ before the fix (unless noted).
- Journals are created at the first edit, so a second kte that opens - Journals are created at the first edit, so a second kte that opens
the file before the first one edits it is not warned at open; the the file before the first one edits it is not warned at open; the
session that is locked out is told at its first edit instead. session that is locked out is told at its first edit instead.
- Regex search runs std::regex over the whole buffer per keystroke - A regex that matches rarely or not at all still scans the whole
(about 0.4 s on 26 MB). buffer with std::regex (1.6 s on 105 MB).
- A journal whose buffer exceeds 16 MiB cannot be checkpointed; after a - A journal whose buffer exceeds 16 MiB cannot be checkpointed; after a
lost record such a journal stays incomplete until the file is saved lost record such a journal stays incomplete until the file is saved
(reported to the user). (reported to the user).
+145 -2
View File
@@ -1294,10 +1294,14 @@ TEST(Audit_RegexSearch_LongLineFoundByNext)
ASSERT_EQ(h.Buf().Curx(), (std::size_t) 25000); ASSERT_EQ(h.Buf().Curx(), (std::size_t) 25000);
ASSERT_TRUE(h.Exec(CommandId::Refresh)); ASSERT_TRUE(h.Exec(CommandId::Refresh));
ASSERT_TRUE(!ed.PromptActive()); ASSERT_TRUE(!ed.PromptActive());
// The replace prompt has no next/previous; its Enter searches all lines. // The regex replace prompt steps through matches the same way.
h.Buf().SetCursor(0, 0);
ASSERT_TRUE(h.Exec(CommandId::RegexpReplace)); ASSERT_TRUE(h.Exec(CommandId::RegexpReplace));
ASSERT_TRUE(h.Exec(CommandId::InsertText, "NEE+DLE")); ASSERT_TRUE(h.Exec(CommandId::InsertText, "NEE+DLE"));
ASSERT_TRUE(ed.Status().find("Enter searches all") != std::string::npos); 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);
} }
@@ -1869,3 +1873,142 @@ TEST(Undo_ByteBudgetKeepsGroupsWhole)
} }
} }
} }
// Search stops at the first match after the search origin (it collected
// every match in the buffer on every keystroke); the count is capped.
TEST(Search_FindsFromOriginAndCapsCount)
{
TestHarness h;
Editor &ed = h.EditorRef();
h.Buf().insert_text(0, 0, "foo\nbar\nfoo\nbar\nfoo\n");
h.Buf().SetCursor(0, 1); // after the first foo
ASSERT_TRUE(h.Exec(CommandId::FindStart));
ASSERT_TRUE(h.Exec(CommandId::InsertText, "fo"));
ASSERT_EQ(h.Buf().Cury(), (std::size_t) 2);
ASSERT_TRUE(ed.Status().find("2/3") != std::string::npos);
// Extending the query keeps the current match while it still matches.
ASSERT_TRUE(h.Exec(CommandId::InsertText, "o"));
ASSERT_EQ(h.Buf().Cury(), (std::size_t) 2);
// Next, then wrap to the first.
ASSERT_TRUE(h.Exec(CommandId::MoveRight));
ASSERT_EQ(h.Buf().Cury(), (std::size_t) 4);
ASSERT_TRUE(h.Exec(CommandId::MoveRight));
ASSERT_EQ(h.Buf().Cury(), (std::size_t) 0);
ASSERT_TRUE(ed.Status().find("1/3") != std::string::npos);
// Previous wraps back to the last.
ASSERT_TRUE(h.Exec(CommandId::MoveLeft));
ASSERT_EQ(h.Buf().Cury(), (std::size_t) 4);
ASSERT_TRUE(h.Exec(CommandId::Refresh));
TestHarness g;
std::string many;
for (int i = 0; i < 3000; ++i)
many += "x\n";
g.Buf().insert_text(0, 0, many);
g.Buf().SetCursor(0, 0);
ASSERT_TRUE(g.Exec(CommandId::FindStart));
ASSERT_TRUE(g.Exec(CommandId::InsertText, "x"));
ASSERT_TRUE(g.EditorRef().Status().find("1/1000+") != std::string::npos);
ASSERT_TRUE(g.Exec(CommandId::Refresh));
}
// Next/Previous visit exactly the matches a per-line scan finds, in order
// and wrapping, for plain and regex queries (including zero-width ones).
TEST(Search_NextPrevVisitReferenceMatches)
{
std::mt19937 rng(11);
const char alphabet[] = {'a', 'b', '\n', 'a', ' ', 'b'};
const char *regexes[] = {"a+", "^", "b$", "ab|ba", "a*", "\\bb"};
for (int iter = 0; iter < 60; ++iter) {
std::string text;
const int n = 20 + static_cast<int>(rng() % 120);
for (int i = 0; i < n; ++i)
text.push_back(alphabet[rng() % sizeof(alphabet)]);
const bool regex = iter % 2 == 1;
const std::string q = regex ? regexes[(iter / 2) % 6] : ((iter / 2) % 2 ? "ab" : "a");
// Reference: per line, non-overlapping, empty matches advance by one.
std::vector<std::pair<std::size_t, std::size_t> > want;
{
std::size_t y = 0, start = 0;
const std::regex rx(regex ? q : std::string("x"));
while (true) {
const std::size_t nl = text.find('\n', start);
const std::string line = text.substr(start, nl == std::string::npos ? std::string::npos : nl - start);
if (!regex) {
for (std::size_t p = line.find(q); p != std::string::npos; p = line.find(q, p + q.size()))
want.emplace_back(y, p);
} else {
std::size_t from = 0;
std::cmatch m;
while (from <= line.size() &&
std::regex_search(line.data() + from, line.data() + line.size(), m, rx,
from ? std::regex_constants::match_prev_avail
: std::regex_constants::match_default)) {
const std::size_t pos = from + static_cast<std::size_t>(m.position(0));
want.emplace_back(y, pos);
from = pos + std::max<std::size_t>(static_cast<std::size_t>(m.length(0)), 1);
}
}
if (nl == std::string::npos)
break;
start = nl + 1;
++y;
}
}
TestHarness h;
h.Buf().insert_text(0, 0, text);
h.Buf().SetCursor(0, 0);
ASSERT_TRUE(h.Exec(regex ? CommandId::RegexFindStart : CommandId::FindStart));
ASSERT_TRUE(h.Exec(CommandId::InsertText, q));
if (want.empty()) {
ASSERT_EQ(h.EditorRef().SearchIndex(), -1);
ASSERT_TRUE(h.Exec(CommandId::Refresh));
continue;
}
const std::size_t k = want.size();
for (std::size_t i = 0; i <= k; ++i) { // one full cycle, wrapping
ASSERT_EQ(h.Buf().Cury(), want[i % k].first);
ASSERT_EQ(h.Buf().Curx(), want[i % k].second);
ASSERT_TRUE(h.Exec(CommandId::MoveRight));
}
// Now at want[1 % k]; step back around.
for (std::size_t i = 0; i <= k; ++i) {
ASSERT_TRUE(h.Exec(CommandId::MoveLeft));
const std::size_t j = (1 + 2 * k - 1 - i) % k;
ASSERT_EQ(h.Buf().Cury(), want[j].first);
ASSERT_EQ(h.Buf().Curx(), want[j].second);
}
ASSERT_TRUE(h.Exec(CommandId::Refresh));
}
}
// Previous-match search scans backwards in 1 MiB blocks; matches straddling
// and next to block boundaries are all found, in order.
TEST(Search_PrevAcrossBlocks)
{
const std::size_t mib = std::size_t{1} << 20;
std::string text(3 * mib + 100, 'x');
for (std::size_t i = 64; i < text.size(); i += 65)
text[i] = '\n';
std::vector<std::size_t> at = {5, mib - 2, mib, 2 * mib - 1, 2 * mib + 3, 3 * mib + 50};
for (std::size_t p: at)
text.replace(p, 2, "QZ"); // may overwrite a newline: fine, offsets still match
std::vector<std::size_t> want;
for (std::size_t p = text.find("QZ"); p != std::string::npos; p = text.find("QZ", p + 2))
want.push_back(p);
TestHarness h;
h.Buf().insert_text(0, 0, text);
h.Buf().SetCursor(0, 0);
ASSERT_TRUE(h.Exec(CommandId::FindStart));
ASSERT_TRUE(h.Exec(CommandId::InsertText, "QZ"));
for (std::size_t i = 0; i < want.size(); ++i) {
ASSERT_TRUE(h.Exec(CommandId::MoveLeft));
const std::size_t j = want.size() - 1 - i;
const std::size_t off = h.Buf().RowColToOffset(h.Buf().Cury(), h.Buf().Curx());
ASSERT_EQ(off, want[j]);
}
ASSERT_TRUE(h.Exec(CommandId::Refresh));
}