From 2f32ba82e654f5efcd3ef246cbca1872ea14b9c0 Mon Sep 17 00:00:00 2001 From: ahmadsh2007 Date: Thu, 22 Oct 2026 13:12:27 +0300 Subject: [PATCH] Fix quadratic search on long wrapped lines and whole-word misses Fixes #4902 --- addons/addon-search/src/SearchEngine.test.ts | 77 ++++++++++++++++++++ addons/addon-search/src/SearchEngine.ts | 56 +++++++++++--- 2 files changed, 122 insertions(+), 11 deletions(-) diff --git a/addons/addon-search/src/SearchEngine.test.ts b/addons/addon-search/src/SearchEngine.test.ts index b3cea8f54f..295674bf0d 100644 --- a/addons/addon-search/src/SearchEngine.test.ts +++ b/addons/addon-search/src/SearchEngine.test.ts @@ -184,6 +184,50 @@ describe('SearchEngine', () => { assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined); }); + + it('should find a whole word after a partial match on the same line', async () => { + await writeP(terminal, 'foobar foo'); + + assert.deepStrictEqual(searchEngine.find('foo', 0, 0, { wholeWord: true }), { + term: 'foo', + col: 7, + row: 0, + size: 3 + }); + }); + + it('should find a whole word after a partial match on the same line with regex', async () => { + await writeP(terminal, 'foobar foo'); + + assert.deepStrictEqual(searchEngine.find('fo+', 0, 0, { wholeWord: true, regex: true }), { + term: 'foo', + col: 7, + row: 0, + size: 3 + }); + }); + + it('should find a whole word before a partial match when searching backwards', async () => { + await writeP(terminal, 'foo barfoo'); + + assert.deepStrictEqual(searchEngine.findPreviousWithSelection('foo', { wholeWord: true }), { + term: 'foo', + col: 0, + row: 0, + size: 3 + }); + }); + + it('should find a whole word before a partial match when searching backwards with regex', async () => { + await writeP(terminal, 'foo barfoo'); + + assert.deepStrictEqual(searchEngine.findPreviousWithSelection('fo+', { wholeWord: true, regex: true }), { + term: 'foo', + col: 0, + row: 0, + size: 3 + }); + }); }); describe('regex', () => { @@ -488,6 +532,39 @@ describe('SearchEngine', () => { }); }); + it('should find a whole word after a partial match within a wrapped line', async () => { + await writeP(terminal, 'foobar' + 'A'.repeat(100) + ' foo'); + + assert.deepStrictEqual(searchEngine.find('foo', 0, 0, { wholeWord: true }), { + term: 'foo', + col: 27, + row: 1, + size: 3 + }); + }); + + it('should find a match when starting from a wrapped continuation row', async () => { + await writeP(terminal, 'target' + 'A'.repeat(100) + 'target'); + + assert.deepStrictEqual(searchEngine.find('target', 1, 0), { + term: 'target', + col: 26, + row: 1, + size: 6 + }); + }); + + it('should find a match on the next line after a long wrapped line without a match', async () => { + await writeP(terminal, 'A'.repeat(80 * 50) + '\r\ntarget'); + + assert.deepStrictEqual(searchEngine.find('target', 0, 0), { + term: 'target', + col: 0, + row: 50, + size: 6 + }); + }); + it('should skip wrapped lines correctly in findInLine', async () => { const longText = 'A'.repeat(200); await writeP(terminal, longText + '\r\nNext line with target'); diff --git a/addons/addon-search/src/SearchEngine.ts b/addons/addon-search/src/SearchEngine.ts index 1760bc2bd1..0e1826b975 100644 --- a/addons/addon-search/src/SearchEngine.ts +++ b/addons/addon-search/src/SearchEngine.ts @@ -76,6 +76,9 @@ export class SearchEngine { // Search from startRow + 1 to end if (!result) { for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) { + if (this._isWrappedRow(y)) { + continue; + } searchPosition.startRow = y; searchPosition.startCol = 0; result = this._findInLine(term, searchPosition, searchOptions); @@ -127,6 +130,9 @@ export class SearchEngine { // Search from startRow + 1 to end if (!result) { for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) { + if (this._isWrappedRow(y)) { + continue; + } searchPosition.startRow = y; searchPosition.startCol = 0; result = this._findInLine(term, searchPosition, searchOptions); @@ -138,6 +144,9 @@ export class SearchEngine { // If we hit the bottom and didn't search from the very top wrap back up if (!result && startRow !== 0) { for (let y = 0; y < startRow; y++) { + if (this._isWrappedRow(y)) { + continue; + } searchPosition.startRow = y; searchPosition.startCol = 0; result = this._findInLine(term, searchPosition, searchOptions); @@ -225,6 +234,15 @@ export class SearchEngine { return result; } + /** + * Whether a buffer row is a continuation of a wrapped line. Forward searches skip these rows + * because searching the row the wrapped line starts on already covers the entire wrapped line; + * searching each continuation row again makes the search quadratic in the wrapped line's length. + */ + private _isWrappedRow(row: number): boolean { + return this._terminal.buffer.active.getLine(row)?.isWrapped ?? false; + } + /** * A found substring is a whole word if it doesn't have an alphanumeric character directly * adjacent to it. @@ -288,32 +306,48 @@ export class SearchEngine { let foundTerm: RegExpExecArray | null; if (isReverseSearch) { // This loop will get the resultIndex of the _last_ regex match in the range 0..offset - while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) { - resultIndex = searchRegex.lastIndex - foundTerm[0].length; - term = foundTerm[0]; - searchRegex.lastIndex -= (term.length - 1); + const searchSlice = searchStringLine.slice(0, offset); + while (foundTerm = searchRegex.exec(searchSlice)) { + const matchIndex = searchRegex.lastIndex - foundTerm[0].length; + if (!searchOptions.wholeWord || this._isWholeWord(matchIndex, searchStringLine, foundTerm[0])) { + resultIndex = matchIndex; + term = foundTerm[0]; + } + searchRegex.lastIndex = matchIndex + 1; } } else { - foundTerm = searchRegex.exec(searchStringLine.slice(offset)); - if (foundTerm && foundTerm[0].length > 0) { - resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length); - term = foundTerm[0]; + const searchSlice = searchStringLine.slice(offset); + while (foundTerm = searchRegex.exec(searchSlice)) { + if (foundTerm[0].length === 0) { + break; + } + const matchIndex = offset + (searchRegex.lastIndex - foundTerm[0].length); + if (!searchOptions.wholeWord || this._isWholeWord(matchIndex, searchStringLine, foundTerm[0])) { + resultIndex = matchIndex; + term = foundTerm[0]; + break; + } + // Not a whole word, continue searching from the next character + searchRegex.lastIndex = matchIndex - offset + 1; } } } else { if (isReverseSearch) { if (offset - searchTerm.length >= 0) { resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length); + while (searchOptions.wholeWord && resultIndex >= 0 && !this._isWholeWord(resultIndex, searchStringLine, searchTerm)) { + resultIndex = resultIndex > 0 ? searchStringLine.lastIndexOf(searchTerm, resultIndex - 1) : -1; + } } } else { resultIndex = searchStringLine.indexOf(searchTerm, offset); + while (searchOptions.wholeWord && resultIndex >= 0 && !this._isWholeWord(resultIndex, searchStringLine, searchTerm)) { + resultIndex = searchStringLine.indexOf(searchTerm, resultIndex + 1); + } } } if (resultIndex >= 0) { - if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) { - return; - } // Adjust the row number and search index if needed since a "line" of text can span multiple // rows