vim-patch:9.2.0689: the "%" command is slow on a long line with many slashes (#40361)

Problem:  The "%" command can be very slow on a long line that contains
          many slashes, for example a line of base64 data.
Solution: When looking for a line comment, scan the line only once while
          skipping over strings, instead of rescanning from the start for
          every slash.  Move check_linecomment() to cindent.c so it can
          reuse the file-local skip_string().

related: vim/vim#20491
fixes:   vim/vim#20557
closes:  vim/vim#20575

9f9af034ad

Co-authored-by: Hirohito Higashi <h.east.727@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zeertzjq
2026-06-22 10:28:15 +08:00
committed by GitHub
parent 7d0adc08f7
commit 257701b17b
4 changed files with 104 additions and 52 deletions

View File

@@ -37,6 +37,68 @@ typedef struct {
} cpp_baseclass_cache_T;
#include "indent_c.c.generated.h"
/// Check if line[] contains a "//" comment, ignoring matches inside strings.
/// Return MAXCOL if not, otherwise return the column.
/// The line is scanned once (skipping strings), so this stays linear even on
/// lines with many slashes (e.g. base64 data).
int check_linecomment(const char *line)
{
const char *p = line; // scan from start
// skip Lispish one-line comments
if (curbuf->b_p_lisp) {
if (vim_strchr(p, ';') != NULL) { // there may be comments
bool in_str = false; // inside of string
while ((p = strpbrk(p, "\";")) != NULL) {
if (*p == '"') {
if (in_str) {
if (*(p - 1) != '\\') { // skip escaped quote
in_str = false;
}
} else if (p == line || ((p - line) >= 2
// skip #\" form
&& *(p - 1) != '\\' && *(p - 2) != '#')) {
in_str = true;
}
} else if (!in_str && ((p - line) < 2
|| (*(p - 1) != '\\' && *(p - 2) != '#'))
&& !is_pos_in_string(line, (colnr_T)(p - line))) {
break; // found!
}
p++;
}
} else {
p = NULL;
}
} else {
// Scan the line once, skipping over strings, char constants and raw
// strings, instead of testing each '/' with is_pos_in_string() (which
// rescans from the start, making this quadratic on lines with many
// slashes).
for (; *p != NUL; p++) {
p = skip_string(p);
if (*p == NUL) {
break;
}
// Accept a double /, unless it's preceded with * and followed by
// *, because * / / * is an end and start of a C comment.
if (p[0] == '/' && p[1] == '/'
&& (p == line || p[-1] != '*' || p[2] != '*')) {
break;
}
}
if (*p == NUL) {
p = NULL;
}
}
if (p == NULL) {
return MAXCOL;
}
return (int)(p - line);
}
// Find the start of a comment, not knowing if we are in a comment right now.
// Search starts at w_cursor.lnum and goes backwards.
// Return NULL when not inside a comment.

View File

@@ -47,6 +47,7 @@
#include "nvim/help.h"
#include "nvim/highlight.h"
#include "nvim/highlight_defs.h"
#include "nvim/indent_c.h"
#include "nvim/keycodes.h"
#include "nvim/macros_defs.h"
#include "nvim/mapping.h"

View File

@@ -2068,10 +2068,11 @@ pos_T *findmatchlimit(oparg_T *oap, int initc, int flags, int64_t maxtravel)
}
}
// Track block comment state when FM_SKIPCOMM is set.
// Track block comment state when FM_SKIPCOMM is set. Markers inside a
// string are not comments, so skip them while "inquote" is set.
// Backward: '/' of end-marker enters comment; '*' of start-marker exits.
// Forward: '/' of start-marker enters comment; '/' of end-marker exits.
if (skip_comments && !comment_dir) {
if (skip_comments && !comment_dir && !inquote) {
if (backwards) {
// Guard pos.col < comment_col: don't misread '* /' at the '//'
// position as a block-comment end-marker.
@@ -2339,56 +2340,6 @@ pos_T *findmatchlimit(oparg_T *oap, int initc, int flags, int64_t maxtravel)
return (pos_T *)NULL; // never found it
}
/// Check if line[] contains a / / comment.
/// @returns MAXCOL if not, otherwise return the column.
int check_linecomment(const char *line)
{
const char *p = line; // scan from start
// skip Lispish one-line comments
if (curbuf->b_p_lisp) {
if (vim_strchr(p, ';') != NULL) { // there may be comments
bool in_str = false; // inside of string
while ((p = strpbrk(p, "\";")) != NULL) {
if (*p == '"') {
if (in_str) {
if (*(p - 1) != '\\') { // skip escaped quote
in_str = false;
}
} else if (p == line || ((p - line) >= 2
// skip #\" form
&& *(p - 1) != '\\' && *(p - 2) != '#')) {
in_str = true;
}
} else if (!in_str && ((p - line) < 2
|| (*(p - 1) != '\\' && *(p - 2) != '#'))
&& !is_pos_in_string(line, (colnr_T)(p - line))) {
break; // found!
}
p++;
}
} else {
p = NULL;
}
} else {
while ((p = vim_strchr(p, '/')) != NULL) {
// Accept a double /, unless it's preceded with * and followed by *,
// because * / / * is an end and start of a C comment. Only
// accept the position if it is not inside a string.
if (p[1] == '/' && (p == line || p[-1] != '*' || p[2] != '*')
&& !is_pos_in_string(line, (colnr_T)(p - line))) {
break;
}
p++;
}
}
if (p == NULL) {
return MAXCOL;
}
return (int)(p - line);
}
/// Move cursor briefly to character matching the one under the cursor.
/// Used for Insert mode and "r" command.
/// Show the match only if it is visible on the screen.

View File

@@ -3972,6 +3972,44 @@ func Test_normal_percent_skip_comment()
bwipe!
endfunc
" A "//" inside a string must not be treated as a line comment by "%". The
" line is scanned in a single pass, so this stays fast even on lines with many
" slashes (e.g. base64 data).
func Test_normal_percent_skip_comment_string()
new
setlocal comments=s1:/*,mb:*,ex:*/,://
" The "//" inside the string is not a comment, so "(" matches the real ")".
call setline(1, ['("a // b")'])
call cursor(1, 1)
normal %
call assert_equal([1, 10], [line('.'), col('.')])
" JSON-like: "{" matches the closing "}" although the string has slashes.
silent! %delete _
call setline(1, ['{', ' "k": "x//y",', '}'])
call cursor(1, 1)
normal %
call assert_equal([3, 1], [line('.'), col('.')])
" A "/*" inside a string must not start a block comment, so "(" still
" matches the real ")" after the string.
silent! %delete _
call setline(1, ['( "a /* b" )'])
call cursor(1, 1)
normal %
call assert_equal([1, 12], [line('.'), col('.')])
" A real /* */ block comment is still skipped: "(" matches the last ")".
silent! %delete _
call setline(1, ['( /* ) */ x )'])
call cursor(1, 1)
normal %
call assert_equal([1, 13], [line('.'), col('.')])
bwipe!
endfunc
" Test for << and >> commands to shift text by 'shiftwidth'
func Test_normal_shift_rightleft()
new