Skip to content

Commit 7cd6e8a

Browse files
committed
Finding multiple instances on the same line
add matchMultiple property to ISearchOptions add ISearchResult buffer private member to SearchHelper class reimplement _findInLine to match multiple instances of search term in a given line seperate logic to different methods in SearchHelper class
1 parent 5bc7fc4 commit 7cd6e8a

File tree

3 files changed

+187
-73
lines changed

3 files changed

+187
-73
lines changed

src/addons/search/Interfaces.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface ISearchOptions {
2525
regex?: boolean;
2626
wholeWord?: boolean;
2727
caseSensitive?: boolean;
28+
matchMultiple?: boolean;
2829
}
2930

3031
export interface ISearchResult {

src/addons/search/SearchHelper.ts

Lines changed: 118 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ const nonWordCharacters = ' ~!@#$%^&*()+`-=[]{}|\;:"\',./<>?';
1010
* A class that knows how to search the terminal and how to display the results.
1111
*/
1212
export class SearchHelper implements ISearchHelper {
13+
private _currentLineMatches: ISearchResult[] = [];
1314
constructor(private _terminal: ISearchAddonTerminal) {
14-
// TODO: Search for multiple instances on 1 line
1515
// TODO: Don't use the actual selection, instead use a "find selection" so multiple instances can be highlighted
1616
// TODO: Highlight other instances in the viewport
1717
}
@@ -29,29 +29,32 @@ export class SearchHelper implements ISearchHelper {
2929
}
3030

3131
let result: ISearchResult;
32-
33-
let startRow = this._terminal._core.buffer.ydisp;
34-
if (this._terminal._core.selectionManager.selectionEnd) {
35-
// Start from the selection end if there is a selection
36-
startRow = this._terminal._core.selectionManager.selectionEnd[1];
37-
}
38-
39-
// Search from ydisp + 1 to end
40-
for (let y = startRow + 1; y < this._terminal._core.buffer.ybase + this._terminal.rows; y++) {
41-
result = this._findInLine(term, y, searchOptions);
42-
if (result) {
43-
break;
32+
if (this._currentLineMatches.length > 0) {
33+
result = this._currentLineMatches.shift();
34+
} else {
35+
let startRow = this._terminal._core.buffer.ydisp;
36+
if (this._terminal._core.selectionManager.selectionEnd) {
37+
// Start from the selection end if there is a selection
38+
startRow = this._terminal._core.selectionManager.selectionEnd[1];
4439
}
45-
}
4640

47-
// Search from the top to the current ydisp
48-
if (!result) {
49-
for (let y = 0; y < startRow; y++) {
41+
// Search from ydisp + 1 to end
42+
for (let y = startRow + 1; y < this._terminal._core.buffer.ybase + this._terminal.rows; y++) {
5043
result = this._findInLine(term, y, searchOptions);
5144
if (result) {
5245
break;
5346
}
5447
}
48+
49+
// Search from the top to the current ydisp
50+
if (!result) {
51+
for (let y = 0; y < startRow; y++) {
52+
result = this._findInLine(term, y, searchOptions);
53+
if (result) {
54+
break;
55+
}
56+
}
57+
}
5558
}
5659

5760
// Set selection and scroll if a result was found
@@ -71,29 +74,36 @@ export class SearchHelper implements ISearchHelper {
7174
}
7275

7376
let result: ISearchResult;
74-
75-
let startRow = this._terminal._core.buffer.ydisp;
76-
if (this._terminal._core.selectionManager.selectionStart) {
77-
// Start from the selection end if there is a selection
78-
startRow = this._terminal._core.selectionManager.selectionStart[1];
79-
}
80-
81-
// Search from ydisp + 1 to end
82-
for (let y = startRow - 1; y >= 0; y--) {
83-
result = this._findInLine(term, y, searchOptions);
84-
if (result) {
85-
break;
77+
if (this._currentLineMatches.length > 0) {
78+
result = this._currentLineMatches.pop();
79+
} else {
80+
let startRow = this._terminal._core.buffer.ydisp;
81+
if (this._terminal._core.selectionManager.selectionStart) {
82+
// Start from the selection end if there is a selection
83+
startRow = this._terminal._core.selectionManager.selectionStart[1];
8684
}
87-
}
8885

89-
// Search from the top to the current ydisp
90-
if (!result) {
91-
for (let y = this._terminal._core.buffer.ybase + this._terminal.rows - 1; y > startRow; y--) {
86+
// Search from ydisp + 1 to end
87+
for (let y = startRow - 1; y >= 0; y--) {
9288
result = this._findInLine(term, y, searchOptions);
89+
this._currentLineMatches.unshift(result); // Handle backward search
90+
result = this._currentLineMatches.pop();
9391
if (result) {
9492
break;
9593
}
9694
}
95+
96+
// Search from the top to the current ydisp
97+
if (!result) {
98+
for (let y = this._terminal._core.buffer.ybase + this._terminal.rows - 1; y > startRow; y--) {
99+
result = this._findInLine(term, y, searchOptions);
100+
this._currentLineMatches.unshift(result); // Handle backward search
101+
result = this._currentLineMatches.pop();
102+
if (result) {
103+
break;
104+
}
105+
}
106+
}
97107
}
98108

99109
// Set selection and scroll if a result was found
@@ -128,51 +138,19 @@ export class SearchHelper implements ISearchHelper {
128138

129139
const stringLine = this.translateBufferLineToStringWithWrap(y, true);
130140
const searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();
131-
const searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
132-
let searchIndex = -1;
133-
134-
if (searchOptions.regex) {
135-
const searchRegex = RegExp(searchTerm, 'g');
136-
const foundTerm = searchRegex.exec(searchStringLine);
137-
if (foundTerm && foundTerm[0].length > 0) {
138-
searchIndex = searchRegex.lastIndex - foundTerm[0].length;
139-
term = foundTerm[0];
140-
}
141-
} else {
142-
searchIndex = searchStringLine.indexOf(searchTerm);
143-
}
141+
const lineMatch = this._getNextMatch(term, searchStringLine, y, searchOptions);
144142

145-
if (searchIndex >= 0) {
143+
if (lineMatch) {
146144
// Adjust the row number and search index if needed since a "line" of text can span multiple rows
147-
if (searchIndex >= this._terminal.cols) {
148-
y += Math.floor(searchIndex / this._terminal.cols);
149-
searchIndex = searchIndex % this._terminal.cols;
145+
if (lineMatch.col >= this._terminal.cols) {
146+
lineMatch.row += Math.floor(lineMatch.col / this._terminal.cols);
147+
lineMatch.col = lineMatch.col % this._terminal.cols;
150148
}
151-
if (searchOptions.wholeWord && !this._isWholeWord(searchIndex, searchStringLine, term)) {
149+
if (searchOptions.wholeWord && !this._isWholeWord(lineMatch.col, searchStringLine, lineMatch.term)) {
152150
return;
153151
}
154-
155-
const line = this._terminal._core.buffer.lines.get(y);
156-
157-
for (let i = 0; i < searchIndex; i++) {
158-
const charData = line.get(i);
159-
// Adjust the searchIndex to normalize emoji into single chars
160-
const char = charData[1/*CHAR_DATA_CHAR_INDEX*/];
161-
if (char.length > 1) {
162-
searchIndex -= char.length - 1;
163-
}
164-
// Adjust the searchIndex for empty characters following wide unicode
165-
// chars (eg. CJK)
166-
const charWidth = charData[2/*CHAR_DATA_WIDTH_INDEX*/];
167-
if (charWidth === 0) {
168-
searchIndex++;
169-
}
170-
}
171-
return {
172-
term,
173-
col: searchIndex,
174-
row: y
175-
};
152+
this._normalizeMatch(lineMatch);
153+
return lineMatch;
176154
}
177155
}
178156

@@ -212,4 +190,71 @@ export class SearchHelper implements ISearchHelper {
212190
this._terminal.scrollLines(result.row - this._terminal._core.buffer.ydisp);
213191
return true;
214192
}
193+
194+
/**
195+
* Returnes an array of matches in the given line.
196+
* @param term The term to search for.
197+
* @param searchString The text to search in.
198+
* @param row The row number.
199+
* @param searchOptions search options,
200+
* @return An array of matches or first match.
201+
*/
202+
private _getNextMatch(term: string, searchString: string, row: number, searchOptions: ISearchOptions): ISearchResult {
203+
204+
if (this._currentLineMatches.length > 0 && searchOptions.matchMultiple) {
205+
return this._currentLineMatches.shift();
206+
}
207+
208+
const searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
209+
210+
let currentIndex = 0;
211+
if (searchOptions.regex) {
212+
const searchRegex = RegExp(searchTerm, 'g');
213+
let foundTerm: RegExpExecArray;
214+
do {
215+
foundTerm = searchRegex.exec(searchString);
216+
if (foundTerm && foundTerm[0].length > 0) {
217+
term = foundTerm[0];
218+
currentIndex = searchRegex.lastIndex - foundTerm[0].length;
219+
this._currentLineMatches.push({term, col: currentIndex, row});
220+
searchRegex.lastIndex -= (term.length - 1); // Handle regex match overlap
221+
}
222+
} while (foundTerm && searchOptions.matchMultiple);
223+
} else {
224+
let nextIndex = 0;
225+
while (currentIndex >= 0) {
226+
currentIndex = searchString.indexOf(searchTerm, nextIndex);
227+
if (currentIndex >= 0) {
228+
this._currentLineMatches.push({term , col: currentIndex, row});
229+
nextIndex = currentIndex + 1;
230+
}
231+
if (!searchOptions.matchMultiple) {
232+
break;
233+
}
234+
}
235+
}
236+
237+
if (this._currentLineMatches.length > 0) {
238+
return this._currentLineMatches.shift();
239+
}
240+
return undefined;
241+
}
242+
243+
private _normalizeMatch(match: ISearchResult): void {
244+
const line = this._terminal._core.buffer.lines.get(match.row);
245+
for (let i = 0; i < match.col; i++) {
246+
const charData = line.get(i);
247+
// Adjust the searchIndex to normalize emoji into single chars
248+
const char = charData[1/*CHAR_DATA_CHAR_INDEX*/];
249+
if (char.length > 1) {
250+
match.col -= char.length - 1;
251+
}
252+
// Adjust the searchIndex for empty characters following wide unicode
253+
// chars (eg. CJK)
254+
const charWidth = charData[2/*CHAR_DATA_WIDTH_INDEX*/];
255+
if (charWidth === 0) {
256+
match.col++;
257+
}
258+
}
259+
}
215260
}

src/addons/search/search.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,5 +243,73 @@ describe('search addon', () => {
243243
expect(hello4).eql(undefined);
244244
expect(hello5).eql(undefined);
245245
});
246+
it('should find multiple matches in line', function(): void {
247+
search.apply(<any>MockTerminal);
248+
const term = new MockTerminal({cols: 20, rows: 5});
249+
term.core.write('AaazcxzczAaa\r\naaAda');
250+
term.pushWriteData();
251+
const searchOptions = {
252+
regex: false,
253+
wholeWord: false,
254+
caseSensitive: false,
255+
matchMultiple: true
256+
};
257+
const find0 = term.searchHelper.findInLine('aa', 0, searchOptions);
258+
const find1 = term.searchHelper.findInLine('aa', 0, searchOptions);
259+
const find2 = term.searchHelper.findInLine('aa', 0, searchOptions);
260+
const find3 = term.searchHelper.findInLine('aa', 0, searchOptions);
261+
const find4 = term.searchHelper.findInLine('aa', 1, searchOptions);
262+
const find5 = term.searchHelper.findInLine('aa', 1, searchOptions);
263+
expect(find0).eql({col: 0, row: 0, term: 'aa'});
264+
expect(find1).eql({col: 1, row: 0, term: 'aa'});
265+
expect(find2).eql({col: 9, row: 0, term: 'aa'});
266+
expect(find3).eql({col: 10, row: 0, term: 'aa'});
267+
expect(find4).eql({col: 0, row: 1, term: 'aa'});
268+
expect(find5).eql({col: 1, row: 1, term: 'aa'});
269+
});
270+
it('should find multiple matches in line - case sensitive', function(): void {
271+
search.apply(<any>MockTerminal);
272+
const term = new MockTerminal({cols: 20, rows: 5});
273+
term.core.write('aaaAa\r\nAAa');
274+
term.pushWriteData();
275+
const searchOptions = {
276+
regex: false,
277+
wholeWord: false,
278+
caseSensitive: true,
279+
matchMultiple: true
280+
};
281+
const find0 = term.searchHelper.findInLine('Aa', 0, searchOptions);
282+
const find1 = term.searchHelper.findInLine('AA', 0, searchOptions);
283+
const find2 = term.searchHelper.findInLine('AA', 1, searchOptions);
284+
const find3 = term.searchHelper.findInLine('aa', 1, searchOptions);
285+
expect(find0).eql({col: 3, row: 0, term: 'Aa'});
286+
expect(find1).eql(undefined);
287+
expect(find2).eql({col: 0, row: 1, term: 'AA'});
288+
expect(find3).eql(undefined);
289+
});
290+
it('should find multiple matches in line - use regex', function(): void {
291+
search.apply(<any>MockTerminal);
292+
const term = new MockTerminal({cols: 20, rows: 5});
293+
term.core.write('1234\r\naaaa');
294+
term.pushWriteData();
295+
const searchOptions = {
296+
regex: true,
297+
wholeWord: false,
298+
caseSensitive: false,
299+
matchMultiple: true
300+
};
301+
const find0 = term.searchHelper.findInLine('\\d\\d', 0, searchOptions);
302+
const find1 = term.searchHelper.findInLine('\\d\\d', 0, searchOptions);
303+
const find2 = term.searchHelper.findInLine('\\d\\d', 0, searchOptions);
304+
const find3 = term.searchHelper.findInLine('aa', 1, searchOptions);
305+
const find4 = term.searchHelper.findInLine('aa', 1, searchOptions);
306+
const find5 = term.searchHelper.findInLine('aa', 1, searchOptions);
307+
expect(find0).eql({col: 0, row: 0, term: '12'});
308+
expect(find1).eql({col: 1, row: 0, term: '23'});
309+
expect(find2).eql({col: 2, row: 0, term: '34'});
310+
expect(find3).eql({col: 0, row: 1, term: 'aa'});
311+
expect(find4).eql({col: 1, row: 1, term: 'aa'});
312+
expect(find5).eql({col: 2, row: 1, term: 'aa'});
313+
});
246314
});
247315
});

0 commit comments

Comments
 (0)