Skip to content

Fix quadratic search on long wrapped lines and whole-word misses - #6252

Open
ahmadsh2007 wants to merge 1 commit into
xtermjs:masterfrom
ahmadsh2007:fix-search-wrapped-lines
Open

ahmadsh2007 wants to merge 1 commit into
xtermjs:masterfrom
ahmadsh2007:fix-search-wrapped-lines

Conversation

@ahmadsh2007

@ahmadsh2007 ahmadsh2007 commented Oct 22, 2026 •

Copy link
Copy Markdown

Title: Fix quadratic search on long wrapped lines and whole-word misses in search addon

Fixes #4902

Problem

1. Search is quadratic in the length of a wrapped line. The forward loops in find() and findNextWithSelection() call _findInLine on every buffer row. For a continuation row (isWrapped), _findInLine recurses back up to the row where the line starts. Each time, it recomputes the string offset cell by cell (_bufferColsToStringOffset), lowercases the whole joined line again, and searches the rest of it again. A line wrapping over k rows is therefore processed about k times, at a cost of O(k) each. Searching the row where the line starts already covers every continuation row, so this work is redundant.

2. Whole-word search only checks the first occurrence on a line. If the first occurrence of the term is part of a larger word, _findInLine returns undefined and never checks later occurrences. For example, whole-word search for foo in foobar foo finds nothing. On wrapped lines this was hidden, because the redundant rescans from problem 1 happened to find the later match.

Fix

  • Forward search loops skip wrapped continuation rows, because the row where the line starts was already searched over the full joined string.
  • _findInLine keeps scanning past occurrences that aren't whole words, for both plain and regex search and in both directions. This is needed so that the first change doesn't regress whole-word matching on wrapped lines, and it also fixes the bug on normal lines.

Results

Searching for a term that isn't present. Headless Terminal, 80 cols, median of 5 runs:

Content Before After
One 8,000-char wrapped line 17.6 ms 1.4 ms
One 16,000-char wrapped line 43.4 ms 0.7 ms
One 32,000-char wrapped line 170.4 ms 1.0 ms
One 64,000-char wrapped line 658.6 ms 2.2 ms
200 lines × 4,000 chars (~10k rows), highlight-all loop 535.3 ms 18.6 ms

Before the fix, each doubling of line length makes search about 4× slower, which is the quadratic behavior described above. After the fix the time stays roughly flat.

"Highlight-all loop" is the loop from SearchAddon._highlightAllMatches, run directly against SearchEngine, since the addon itself needs a DOM.

Tests

  • Added 4 whole-word tests (plain/regex × forward/backward). All 4 fail on master and pass with this change.
  • Added 3 wrapped-line tests: a whole word after a partial match inside a wrapped line, starting a search from a continuation row, and finding a match after a long wrapped line with no match. These guard the row-skipping change.
  • Full unit suite: 2414 passing.

Notes / possible follow-ups

  • In the forward direction, a regex anchored with ^ could previously match at the start of a wrapped continuation row, because each such row was searched as its own slice. It now matches only at the start of the logical line, which is what reverse search already did.
  • Highlighting many matches inside long wrapped lines still has a smaller per-match cost from _bufferColsToStringOffset walking cells. That is unchanged and could be addressed separately.

@PerBothner

Copy link
Copy Markdown
Contributor

I believe the quadratic search problem is avoided by PR #6160.

For whole-word searches using a regex, we could simplify the logic by modifying the regex:

'\\b' + searchTerm + '\\b'

For non-regex searches, it probably makes sense to just escape the search string, regardless of whether doing a while-word search, as this would further simplify the logic:

if (!searchOptions.regex)
  searchTerm = RegExp.escape(searchTerm);

However, there is a problem in that RegExp.escape is fairly new to JavaScript - it is only widely available as of 2025. Older JavaScript engines might need a polyfill. A simple polyfill (may not handle all corner cases, but probably good enough for our needs):

searchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Search is very slow when there is a lot of content and many wrapped lines

2 participants