fix(inccommand): enforce timeout during matching, not just after a line #40892

Problem: do_sub() only checks the timeout limit after finishing a line.
         A pathological regex will run on a single line input unbounded
         until the compute is completed.

Solution: Pass the timeout limit to `vim_regexec_multi()` so the
          computation on the regex engine is bounded per-line.

Signed-off-by: XiaowenHu96 <me@xiaowenhu.com>
(cherry picked from commit bd73d8c011)
This commit is contained in:
XiaowenHu
2026-07-22 20:27:54 +08:00
committed by github-actions[bot]
parent 290f7e69a1
commit 41163b7731
2 changed files with 28 additions and 7 deletions

View File

@@ -3525,10 +3525,11 @@ static int check_regexp_delim(int c)
///
/// The usual escapes are supported as described in the regexp docs.
///
/// @param tm Timeout limit
/// @param cmdpreview_ns The namespace to show 'inccommand' preview highlights.
/// If <= 0, preview shouldn't be shown.
/// @return 0, 1 or 2. See cmdpreview_may_show() for more information on the meaning.
static int do_sub(exarg_T *eap, const proftime_T timeout, const int cmdpreview_ns,
static int do_sub(exarg_T *eap, proftime_T tm, const int cmdpreview_ns,
const handle_T cmdpreview_bufnr)
{
#define ADJUST_SUB_FIRSTLNUM() \
@@ -3759,13 +3760,15 @@ static int do_sub(exarg_T *eap, const proftime_T timeout, const int cmdpreview_n
// If preview: limit to max('cmdwinheight', viewport).
linenr_T line2 = eap->line2;
int timed_out = false;
for (linenr_T lnum = eap->line1;
lnum <= line2 && !got_quit && !aborting()
lnum <= line2 && !got_quit && !timed_out && !aborting()
&& (cmdpreview_ns <= 0 || preview_lines.lines_needed <= (linenr_T)p_cwh
|| lnum <= curwin->w_botline);
lnum++) {
int nmatch = vim_regexec_multi(&regmatch, curwin, curbuf, lnum,
0, NULL, NULL);
0, &tm, &timed_out);
if (nmatch) {
colnr_T copycol;
colnr_T matchcol;
@@ -4342,7 +4345,7 @@ skip:
|| nmatch_tl > 0
|| (nmatch = vim_regexec_multi(&regmatch, curwin,
curbuf, sub_firstlnum,
matchcol, NULL, NULL)) == 0
matchcol, &tm, &timed_out)) == 0
|| regmatch.startpos[0].lnum > 0) {
if (new_start != NULL) {
// Copy the rest of the line, that didn't match.
@@ -4418,7 +4421,7 @@ skip:
}
if (nmatch == -1 && !lastone) {
nmatch = vim_regexec_multi(&regmatch, curwin, curbuf,
sub_firstlnum, matchcol, NULL, NULL);
sub_firstlnum, matchcol, &tm, &timed_out);
}
// 5. break if there isn't another match in this line
@@ -4475,7 +4478,7 @@ skip:
line_breakcheck();
if (profile_passed_limit(timeout)) {
if (timed_out || profile_passed_limit(tm)) {
got_quit = true;
}
}
@@ -4558,7 +4561,7 @@ skip:
// Show 'inccommand' preview if there are matched lines.
if (cmdpreview_ns > 0 && !aborting()) {
if (got_quit || profile_passed_limit(timeout)) { // Too slow, disable.
if (got_quit || profile_passed_limit(tm)) { // Too slow, disable.
set_option_direct(kOptInccommand, STATIC_CSTR_AS_OPTVAL(""), 0, SID_NONE);
} else if (*p_icm != NUL && pat != NULL) {
if (pre_hl_id == 0) {

View File

@@ -1140,6 +1140,24 @@ describe(':substitute, inccommand=split', function()
eq('split', eval('&inccommand'))
end)
it('time limit is enforced while matching a single line #40773', function()
-- prevent redraws from 'incsearch'
api.nvim_set_option_value('incsearch', false, {})
-- Assert that 'inccommand' is ENABLED initially.
eq('split', eval('&inccommand'))
-- Set 'redrawtime' to minimal value, to ensure timeout is triggered.
command('set redrawtime=1 nowrap')
-- Prepare the text
api.nvim_buf_set_lines(0, 0, -1, true, { ('aaaaaaaa/'):rep(6) .. ('b'):rep(200) })
feed([[:%s/.\+\/\(.\+\)\+ft\/\1]])
screen:expect({ any = vim.pesc([[:%s/.\+\/\(.\+\)\+ft\/\1]]) })
-- Assert that 'inccommand' is DISABLED in cmdline mode.
eq('', eval('&inccommand'))
-- Assert that 'inccommand' is again ENABLED after leaving cmdline mode.
feed([[<C-\><C-N>]])
eq('split', eval('&inccommand'))
end)
it("deactivates if 'foldexpr' is slow #9557", function()
insert([[
a