@theia/search-in-workspace 1.34.2 → 1.34.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +641 -641
  2. package/README.md +40 -40
  3. package/lib/browser/components/search-in-workspace-input.d.ts +39 -39
  4. package/lib/browser/components/search-in-workspace-input.js +120 -120
  5. package/lib/browser/search-in-workspace-context-key-service.d.ts +23 -23
  6. package/lib/browser/search-in-workspace-context-key-service.js +90 -90
  7. package/lib/browser/search-in-workspace-factory.d.ts +10 -10
  8. package/lib/browser/search-in-workspace-factory.js +68 -68
  9. package/lib/browser/search-in-workspace-frontend-contribution.d.ts +53 -53
  10. package/lib/browser/search-in-workspace-frontend-contribution.js +410 -410
  11. package/lib/browser/search-in-workspace-frontend-module.d.ts +6 -6
  12. package/lib/browser/search-in-workspace-frontend-module.js +70 -70
  13. package/lib/browser/search-in-workspace-label-provider.d.ts +9 -9
  14. package/lib/browser/search-in-workspace-label-provider.js +57 -57
  15. package/lib/browser/search-in-workspace-preferences.d.ts +17 -17
  16. package/lib/browser/search-in-workspace-preferences.js +82 -82
  17. package/lib/browser/search-in-workspace-result-tree-widget.d.ts +253 -253
  18. package/lib/browser/search-in-workspace-result-tree-widget.js +1072 -1072
  19. package/lib/browser/search-in-workspace-service.d.ts +35 -35
  20. package/lib/browser/search-in-workspace-service.js +158 -158
  21. package/lib/browser/search-in-workspace-widget.d.ts +121 -121
  22. package/lib/browser/search-in-workspace-widget.js +602 -602
  23. package/lib/browser/search-layout-migrations.d.ts +5 -5
  24. package/lib/browser/search-layout-migrations.js +64 -64
  25. package/lib/common/search-in-workspace-interface.d.ts +112 -112
  26. package/lib/common/search-in-workspace-interface.js +35 -35
  27. package/lib/node/ripgrep-search-in-workspace-server.d.ts +94 -94
  28. package/lib/node/ripgrep-search-in-workspace-server.js +423 -423
  29. package/lib/node/ripgrep-search-in-workspace-server.slow-spec.d.ts +1 -1
  30. package/lib/node/ripgrep-search-in-workspace-server.slow-spec.js +899 -899
  31. package/lib/node/search-in-workspace-backend-module.d.ts +3 -3
  32. package/lib/node/search-in-workspace-backend-module.js +32 -32
  33. package/package.json +9 -9
  34. package/src/browser/components/search-in-workspace-input.tsx +139 -139
  35. package/src/browser/search-in-workspace-context-key-service.ts +93 -93
  36. package/src/browser/search-in-workspace-factory.ts +59 -59
  37. package/src/browser/search-in-workspace-frontend-contribution.ts +402 -402
  38. package/src/browser/search-in-workspace-frontend-module.ts +82 -82
  39. package/src/browser/search-in-workspace-label-provider.ts +48 -48
  40. package/src/browser/search-in-workspace-preferences.ts +91 -91
  41. package/src/browser/search-in-workspace-result-tree-widget.tsx +1225 -1225
  42. package/src/browser/search-in-workspace-service.ts +152 -152
  43. package/src/browser/search-in-workspace-widget.tsx +711 -711
  44. package/src/browser/search-layout-migrations.ts +53 -53
  45. package/src/browser/styles/index.css +367 -367
  46. package/src/browser/styles/search.svg +6 -6
  47. package/src/common/search-in-workspace-interface.ts +149 -149
  48. package/src/node/ripgrep-search-in-workspace-server.slow-spec.ts +1073 -1073
  49. package/src/node/ripgrep-search-in-workspace-server.ts +482 -482
  50. package/src/node/search-in-workspace-backend-module.ts +33 -33
@@ -1,482 +1,482 @@
1
- // *****************************************************************************
2
- // Copyright (C) 2017-2021 Ericsson and others.
3
- //
4
- // This program and the accompanying materials are made available under the
5
- // terms of the Eclipse Public License v. 2.0 which is available at
6
- // http://www.eclipse.org/legal/epl-2.0.
7
- //
8
- // This Source Code may also be made available under the following Secondary
9
- // Licenses when the conditions for such availability set forth in the Eclipse
10
- // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
- // with the GNU Classpath Exception which is available at
12
- // https://www.gnu.org/software/classpath/license.html.
13
- //
14
- // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15
- // *****************************************************************************
16
-
17
- import * as fs from '@theia/core/shared/fs-extra';
18
- import * as path from 'path';
19
- import { ILogger } from '@theia/core';
20
- import { RawProcess, RawProcessFactory, RawProcessOptions } from '@theia/process/lib/node';
21
- import { FileUri } from '@theia/core/lib/node/file-uri';
22
- import URI from '@theia/core/lib/common/uri';
23
- import { inject, injectable } from '@theia/core/shared/inversify';
24
- import { SearchInWorkspaceServer, SearchInWorkspaceOptions, SearchInWorkspaceResult, SearchInWorkspaceClient, LinePreview } from '../common/search-in-workspace-interface';
25
-
26
- export const RgPath = Symbol('RgPath');
27
-
28
- /**
29
- * Typing for ripgrep's arbitrary data object:
30
- *
31
- * https://docs.rs/grep-printer/0.1.0/grep_printer/struct.JSON.html#object-arbitrary-data
32
- */
33
- export type IRgBytesOrText = { bytes: string } | { text: string };
34
-
35
- function bytesOrTextToString(obj: IRgBytesOrText): string {
36
- return 'bytes' in obj ?
37
- Buffer.from(obj.bytes, 'base64').toString() :
38
- obj.text;
39
- }
40
-
41
- type IRgMessage = IRgMatch | IRgBegin | IRgEnd;
42
-
43
- interface IRgMatch {
44
- type: 'match';
45
- data: {
46
- path: IRgBytesOrText;
47
- lines: IRgBytesOrText;
48
- line_number: number;
49
- absolute_offset: number;
50
- submatches: IRgSubmatch[];
51
- };
52
- }
53
-
54
- export interface IRgSubmatch {
55
- match: IRgBytesOrText;
56
- start: number;
57
- end: number;
58
- }
59
-
60
- interface IRgBegin {
61
- type: 'begin';
62
- data: {
63
- path: IRgBytesOrText;
64
- lines: string;
65
- };
66
- }
67
-
68
- interface IRgEnd {
69
- type: 'end';
70
- data: {
71
- path: IRgBytesOrText;
72
- };
73
- }
74
-
75
- @injectable()
76
- export class RipgrepSearchInWorkspaceServer implements SearchInWorkspaceServer {
77
-
78
- // List of ongoing searches, maps search id to a the started rg process.
79
- private ongoingSearches: Map<number, RawProcess> = new Map();
80
-
81
- // Each incoming search is given a unique id, returned to the client. This is the next id we will assigned.
82
- private nextSearchId: number = 1;
83
-
84
- private client: SearchInWorkspaceClient | undefined;
85
-
86
- @inject(RgPath)
87
- protected readonly rgPath: string;
88
-
89
- constructor(
90
- @inject(ILogger) protected readonly logger: ILogger,
91
- @inject(RawProcessFactory) protected readonly rawProcessFactory: RawProcessFactory,
92
- ) { }
93
-
94
- setClient(client: SearchInWorkspaceClient | undefined): void {
95
- this.client = client;
96
- }
97
-
98
- protected getArgs(options?: SearchInWorkspaceOptions): string[] {
99
- const args = new Set<string>();
100
-
101
- args.add('--hidden');
102
- args.add('--json');
103
-
104
- if (options?.matchCase) {
105
- args.add('--case-sensitive');
106
- } else {
107
- args.add('--ignore-case');
108
- }
109
-
110
- if (options?.includeIgnored) {
111
- args.add('--no-ignore');
112
- }
113
- if (options?.maxFileSize) {
114
- args.add('--max-filesize=' + options.maxFileSize.trim());
115
- } else {
116
- args.add('--max-filesize=20M');
117
- }
118
-
119
- if (options?.include) {
120
- this.addGlobArgs(args, options.include, false);
121
- }
122
-
123
- if (options?.exclude) {
124
- this.addGlobArgs(args, options.exclude, true);
125
- }
126
-
127
- if (options?.followSymlinks) {
128
- args.add('--follow');
129
- }
130
-
131
- if (options?.useRegExp || options?.matchWholeWord) {
132
- args.add('--regexp');
133
- } else {
134
- args.add('--fixed-strings');
135
- args.add('--');
136
- }
137
-
138
- return Array.from(args);
139
- }
140
-
141
- /**
142
- * Add glob patterns to ripgrep's arguments
143
- * @param args ripgrep set of arguments
144
- * @param patterns patterns to include as globs
145
- * @param exclude whether to negate the glob pattern or not
146
- */
147
- protected addGlobArgs(args: Set<string>, patterns: string[], exclude: boolean = false): void {
148
- const sanitizedPatterns = patterns.map(pattern => pattern.trim()).filter(pattern => pattern.length > 0);
149
- for (let pattern of sanitizedPatterns) {
150
- // make sure the pattern always starts with `**/`
151
- if (pattern.startsWith('/')) {
152
- pattern = '**' + pattern;
153
- } else if (!pattern.startsWith('**/')) {
154
- pattern = '**/' + pattern;
155
- }
156
- // add the exclusion prefix
157
- if (exclude) {
158
- pattern = '!' + pattern;
159
- }
160
- args.add(`--glob=${pattern}`);
161
- // add a generic glob cli argument entry to include files inside a given directory
162
- if (!pattern.endsWith('*')) {
163
- // ensure the new pattern ends with `/*`
164
- pattern += pattern.endsWith('/') ? '*' : '/*';
165
- args.add(`--glob=${pattern}`);
166
- }
167
- }
168
- }
169
-
170
- /**
171
- * Transforms relative patterns to absolute paths, one for each given search path.
172
- * The resulting paths are not validated in the file system as the pattern keeps glob information.
173
- *
174
- * @returns The resulting list may be larger than the received patterns as a relative pattern may
175
- * resolve to multiple absolute patterns up to the number of search paths.
176
- */
177
- protected replaceRelativeToAbsolute(roots: string[], patterns: string[] = []): string[] {
178
- const expandedPatterns = new Set<string>();
179
- for (const pattern of patterns) {
180
- if (this.isPatternRelative(pattern)) {
181
- // create new patterns using the absolute form for each root
182
- for (const root of roots) {
183
- expandedPatterns.add(path.resolve(root, pattern));
184
- }
185
- } else {
186
- expandedPatterns.add(pattern);
187
- }
188
- }
189
- return Array.from(expandedPatterns);
190
- }
191
-
192
- /**
193
- * Tests if the pattern is relative and should/can be made absolute.
194
- */
195
- protected isPatternRelative(pattern: string): boolean {
196
- return pattern.replace(/\\/g, '/').startsWith('./');
197
- }
198
-
199
- /**
200
- * By default, sets the search directories for the string WHAT to the provided ROOTURIS directories
201
- * and returns the assigned search id.
202
- *
203
- * The include / exclude (options in SearchInWorkspaceOptions) are lists of patterns for files to
204
- * include / exclude during search (glob characters are allowed).
205
- *
206
- * include patterns successfully recognized as absolute paths will override the default search and set
207
- * the search directories to the ones provided as includes.
208
- * Relative paths are allowed, the application will attempt to translate them to valid absolute paths
209
- * based on the applicable search directories.
210
- */
211
- async search(what: string, rootUris: string[], options: SearchInWorkspaceOptions = {}): Promise<number> {
212
- // Start the rg process. Use --vimgrep to get one result per
213
- // line, --color=always to get color control characters that
214
- // we'll use to parse the lines.
215
- const searchId = this.nextSearchId++;
216
- const rootPaths = rootUris.map(root => FileUri.fsPath(root));
217
- // If there are absolute paths in `include` we will remove them and use
218
- // those as paths to search from.
219
- const searchPaths = this.extractSearchPathsFromIncludes(rootPaths, options);
220
- options.include = this.replaceRelativeToAbsolute(searchPaths, options.include);
221
- options.exclude = this.replaceRelativeToAbsolute(searchPaths, options.exclude);
222
- const rgArgs = this.getArgs(options);
223
- // If we use matchWholeWord we use regExp internally, so we need
224
- // to escape regexp characters if we actually not set regexp true in UI.
225
- if (options?.matchWholeWord && !options.useRegExp) {
226
- what = what.replace(/[\-\\\{\}\*\+\?\|\^\$\.\[\]\(\)\#]/g, '\\$&');
227
- if (!/\B/.test(what.charAt(0))) {
228
- what = '\\b' + what;
229
- }
230
- if (!/\B/.test(what.charAt(what.length - 1))) {
231
- what = what + '\\b';
232
- }
233
- }
234
-
235
- const args = [...rgArgs, what, ...searchPaths];
236
- const processOptions: RawProcessOptions = {
237
- command: this.rgPath,
238
- args
239
- };
240
-
241
- // TODO: Use child_process directly instead of rawProcessFactory?
242
- const rgProcess: RawProcess = this.rawProcessFactory(processOptions);
243
- this.ongoingSearches.set(searchId, rgProcess);
244
-
245
- rgProcess.onError(error => {
246
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
247
- let errorCode = (error as any).code;
248
-
249
- // Try to provide somewhat clearer error messages, if possible.
250
- if (errorCode === 'ENOENT') {
251
- errorCode = 'could not find the ripgrep (rg) binary';
252
- } else if (errorCode === 'EACCES') {
253
- errorCode = 'could not execute the ripgrep (rg) binary';
254
- }
255
-
256
- const errorStr = `An error happened while searching (${errorCode}).`;
257
- this.wrapUpSearch(searchId, errorStr);
258
- });
259
-
260
- // Running counter of results.
261
- let numResults = 0;
262
-
263
- // Buffer to accumulate incoming output.
264
- let databuf: string = '';
265
-
266
- let currentSearchResult: SearchInWorkspaceResult | undefined;
267
-
268
- rgProcess.outputStream.on('data', (chunk: Buffer) => {
269
- // We might have already reached the max number of
270
- // results, sent a TERM signal to rg, but we still get
271
- // the data that was already output in the mean time.
272
- // It's not necessary to return early here (the check
273
- // for maxResults below would avoid sending extra
274
- // results), but it avoids doing unnecessary work.
275
- if (options?.maxResults && numResults >= options.maxResults) {
276
- return;
277
- }
278
-
279
- databuf += chunk;
280
-
281
- while (1) {
282
- // Check if we have a complete line.
283
- const eolIdx = databuf.indexOf('\n');
284
- if (eolIdx < 0) {
285
- break;
286
- }
287
-
288
- // Get and remove the line from the data buffer.
289
- const lineBuf = databuf.slice(0, eolIdx);
290
- databuf = databuf.slice(eolIdx + 1);
291
-
292
- const obj = JSON.parse(lineBuf) as IRgMessage;
293
- if (obj.type === 'begin') {
294
- const file = bytesOrTextToString(obj.data.path);
295
- if (file) {
296
- currentSearchResult = {
297
- fileUri: FileUri.create(file).toString(),
298
- root: this.getRoot(file, rootUris).toString(),
299
- matches: []
300
- };
301
- } else {
302
- this.logger.error('Begin message without path. ' + JSON.stringify(obj));
303
- }
304
- } else if (obj.type === 'end') {
305
- if (currentSearchResult && this.client) {
306
- this.client.onResult(searchId, currentSearchResult);
307
- }
308
- currentSearchResult = undefined;
309
- } else if (obj.type === 'match') {
310
- if (!currentSearchResult) {
311
- continue;
312
- }
313
- const data = obj.data;
314
- const file = bytesOrTextToString(data.path);
315
- const line = data.line_number;
316
- const lineText = bytesOrTextToString(data.lines);
317
-
318
- if (file === undefined || lineText === undefined) {
319
- continue;
320
- }
321
-
322
- const lineInBytes = Buffer.from(lineText);
323
-
324
- for (const submatch of data.submatches) {
325
- const startOffset = lineInBytes.slice(0, submatch.start).toString().length;
326
- const match = bytesOrTextToString(submatch.match);
327
- let lineInfo: string | LinePreview = lineText.trimRight();
328
- if (lineInfo.length > 300) {
329
- const prefixLength = 25;
330
- const start = Math.max(startOffset - prefixLength, 0);
331
- const length = prefixLength + match.length + 70;
332
- let prefix = '';
333
- if (start >= prefixLength) {
334
- prefix = '...';
335
- }
336
- const character = (start < prefixLength ? start : prefixLength) + prefix.length + 1;
337
- lineInfo = <LinePreview>{
338
- text: prefix + lineInfo.substr(start, length),
339
- character
340
- };
341
- }
342
- currentSearchResult.matches.push({
343
- line,
344
- character: startOffset + 1,
345
- length: match.length,
346
- lineText: lineInfo
347
- });
348
- numResults++;
349
-
350
- // Did we reach the maximum number of results?
351
- if (options?.maxResults && numResults >= options.maxResults) {
352
- rgProcess.kill();
353
- if (currentSearchResult && this.client) {
354
- this.client.onResult(searchId, currentSearchResult);
355
- }
356
- currentSearchResult = undefined;
357
- this.wrapUpSearch(searchId);
358
- break;
359
- }
360
- }
361
- }
362
- }
363
- });
364
-
365
- rgProcess.outputStream.on('end', () => {
366
- // If we reached maxResults, we should have already
367
- // wrapped up the search. Returning early avoids
368
- // logging a warning message in wrapUpSearch.
369
- if (options?.maxResults && numResults >= options.maxResults) {
370
- return;
371
- }
372
-
373
- this.wrapUpSearch(searchId);
374
- });
375
-
376
- return searchId;
377
- }
378
-
379
- /**
380
- * The default search paths are set to be the root paths associated to a workspace
381
- * however the search scope can be further refined with the include paths available in the search options.
382
- * This method will replace the searching paths to the ones specified in the 'include' options but as long
383
- * as the 'include' paths can be successfully validated as existing.
384
- *
385
- * Therefore the returned array of paths can be either the workspace root paths or a set of validated paths
386
- * derived from the include options which can be used to perform the search.
387
- *
388
- * Any pattern that resulted in a valid search path will be removed from the 'include' list as it is
389
- * provided as an equivalent search path instead.
390
- */
391
- protected extractSearchPathsFromIncludes(rootPaths: string[], options: SearchInWorkspaceOptions): string[] {
392
- if (!options.include) {
393
- return rootPaths;
394
- }
395
- const resolvedPaths = new Set<string>();
396
- options.include = options.include.filter(pattern => {
397
- let keep = true;
398
- for (const root of rootPaths) {
399
- const absolutePath = this.getAbsolutePathFromPattern(root, pattern);
400
- // undefined means the pattern cannot be converted into an absolute path
401
- if (absolutePath) {
402
- resolvedPaths.add(absolutePath);
403
- keep = false;
404
- }
405
- }
406
- return keep;
407
- });
408
- return resolvedPaths.size > 0
409
- ? Array.from(resolvedPaths)
410
- : rootPaths;
411
- }
412
-
413
- /**
414
- * Transform include/exclude option patterns from relative patterns to absolute patterns.
415
- * E.g. './abc/foo.*' to '${root}/abc/foo.*', the transformation does not validate the
416
- * pattern against the file system as glob suffixes remain.
417
- *
418
- * @returns undefined if the pattern cannot be converted into an absolute path.
419
- */
420
- protected getAbsolutePathFromPattern(root: string, pattern: string): string | undefined {
421
- pattern = pattern.replace(/\\/g, '/');
422
- // The pattern is not referring to a single file or folder, i.e. not to be converted
423
- if (!path.isAbsolute(pattern) && !pattern.startsWith('./')) {
424
- return undefined;
425
- }
426
- // remove the `/**` suffix if present
427
- if (pattern.endsWith('/**')) {
428
- pattern = pattern.substr(0, pattern.length - 3);
429
- }
430
- // if `pattern` is absolute then `root` will be ignored by `path.resolve()`
431
- const targetPath = path.resolve(root, pattern);
432
- if (fs.existsSync(targetPath)) {
433
- return targetPath;
434
- }
435
- return undefined;
436
- }
437
-
438
- /**
439
- * Returns the root folder uri that a file belongs to.
440
- * In case that a file belongs to more than one root folders, returns the root folder that is closest to the file.
441
- * If the file is not from the current workspace, returns empty string.
442
- * @param filePath string path of the file
443
- * @param rootUris string URIs of the root folders in the current workspace
444
- */
445
- private getRoot(filePath: string, rootUris: string[]): URI {
446
- const roots = rootUris.filter(root => new URI(root).withScheme('file').isEqualOrParent(FileUri.create(filePath).withScheme('file')));
447
- if (roots.length > 0) {
448
- return FileUri.create(FileUri.fsPath(roots.sort((r1, r2) => r2.length - r1.length)[0]));
449
- }
450
- return new URI();
451
- }
452
-
453
- // Cancel an ongoing search. Trying to cancel a search that doesn't exist isn't an
454
- // error, otherwise we'd have to deal with race conditions, where a client cancels a
455
- // search that finishes normally at the same time.
456
- cancel(searchId: number): Promise<void> {
457
- const process = this.ongoingSearches.get(searchId);
458
- if (process) {
459
- process.kill();
460
- this.wrapUpSearch(searchId);
461
- }
462
-
463
- return Promise.resolve();
464
- }
465
-
466
- // Send onDone to the client and clean up what we know about search searchId.
467
- private wrapUpSearch(searchId: number, error?: string): void {
468
- if (this.ongoingSearches.delete(searchId)) {
469
- if (this.client) {
470
- this.logger.debug('Sending onDone for ' + searchId, error);
471
- this.client.onDone(searchId, error);
472
- } else {
473
- this.logger.debug('Wrapping up search ' + searchId + ' but no client');
474
- }
475
- } else {
476
- this.logger.debug("Trying to wrap up a search we don't know about " + searchId);
477
- }
478
- }
479
-
480
- dispose(): void {
481
- }
482
- }
1
+ // *****************************************************************************
2
+ // Copyright (C) 2017-2021 Ericsson and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import * as fs from '@theia/core/shared/fs-extra';
18
+ import * as path from 'path';
19
+ import { ILogger } from '@theia/core';
20
+ import { RawProcess, RawProcessFactory, RawProcessOptions } from '@theia/process/lib/node';
21
+ import { FileUri } from '@theia/core/lib/node/file-uri';
22
+ import URI from '@theia/core/lib/common/uri';
23
+ import { inject, injectable } from '@theia/core/shared/inversify';
24
+ import { SearchInWorkspaceServer, SearchInWorkspaceOptions, SearchInWorkspaceResult, SearchInWorkspaceClient, LinePreview } from '../common/search-in-workspace-interface';
25
+
26
+ export const RgPath = Symbol('RgPath');
27
+
28
+ /**
29
+ * Typing for ripgrep's arbitrary data object:
30
+ *
31
+ * https://docs.rs/grep-printer/0.1.0/grep_printer/struct.JSON.html#object-arbitrary-data
32
+ */
33
+ export type IRgBytesOrText = { bytes: string } | { text: string };
34
+
35
+ function bytesOrTextToString(obj: IRgBytesOrText): string {
36
+ return 'bytes' in obj ?
37
+ Buffer.from(obj.bytes, 'base64').toString() :
38
+ obj.text;
39
+ }
40
+
41
+ type IRgMessage = IRgMatch | IRgBegin | IRgEnd;
42
+
43
+ interface IRgMatch {
44
+ type: 'match';
45
+ data: {
46
+ path: IRgBytesOrText;
47
+ lines: IRgBytesOrText;
48
+ line_number: number;
49
+ absolute_offset: number;
50
+ submatches: IRgSubmatch[];
51
+ };
52
+ }
53
+
54
+ export interface IRgSubmatch {
55
+ match: IRgBytesOrText;
56
+ start: number;
57
+ end: number;
58
+ }
59
+
60
+ interface IRgBegin {
61
+ type: 'begin';
62
+ data: {
63
+ path: IRgBytesOrText;
64
+ lines: string;
65
+ };
66
+ }
67
+
68
+ interface IRgEnd {
69
+ type: 'end';
70
+ data: {
71
+ path: IRgBytesOrText;
72
+ };
73
+ }
74
+
75
+ @injectable()
76
+ export class RipgrepSearchInWorkspaceServer implements SearchInWorkspaceServer {
77
+
78
+ // List of ongoing searches, maps search id to a the started rg process.
79
+ private ongoingSearches: Map<number, RawProcess> = new Map();
80
+
81
+ // Each incoming search is given a unique id, returned to the client. This is the next id we will assigned.
82
+ private nextSearchId: number = 1;
83
+
84
+ private client: SearchInWorkspaceClient | undefined;
85
+
86
+ @inject(RgPath)
87
+ protected readonly rgPath: string;
88
+
89
+ constructor(
90
+ @inject(ILogger) protected readonly logger: ILogger,
91
+ @inject(RawProcessFactory) protected readonly rawProcessFactory: RawProcessFactory,
92
+ ) { }
93
+
94
+ setClient(client: SearchInWorkspaceClient | undefined): void {
95
+ this.client = client;
96
+ }
97
+
98
+ protected getArgs(options?: SearchInWorkspaceOptions): string[] {
99
+ const args = new Set<string>();
100
+
101
+ args.add('--hidden');
102
+ args.add('--json');
103
+
104
+ if (options?.matchCase) {
105
+ args.add('--case-sensitive');
106
+ } else {
107
+ args.add('--ignore-case');
108
+ }
109
+
110
+ if (options?.includeIgnored) {
111
+ args.add('--no-ignore');
112
+ }
113
+ if (options?.maxFileSize) {
114
+ args.add('--max-filesize=' + options.maxFileSize.trim());
115
+ } else {
116
+ args.add('--max-filesize=20M');
117
+ }
118
+
119
+ if (options?.include) {
120
+ this.addGlobArgs(args, options.include, false);
121
+ }
122
+
123
+ if (options?.exclude) {
124
+ this.addGlobArgs(args, options.exclude, true);
125
+ }
126
+
127
+ if (options?.followSymlinks) {
128
+ args.add('--follow');
129
+ }
130
+
131
+ if (options?.useRegExp || options?.matchWholeWord) {
132
+ args.add('--regexp');
133
+ } else {
134
+ args.add('--fixed-strings');
135
+ args.add('--');
136
+ }
137
+
138
+ return Array.from(args);
139
+ }
140
+
141
+ /**
142
+ * Add glob patterns to ripgrep's arguments
143
+ * @param args ripgrep set of arguments
144
+ * @param patterns patterns to include as globs
145
+ * @param exclude whether to negate the glob pattern or not
146
+ */
147
+ protected addGlobArgs(args: Set<string>, patterns: string[], exclude: boolean = false): void {
148
+ const sanitizedPatterns = patterns.map(pattern => pattern.trim()).filter(pattern => pattern.length > 0);
149
+ for (let pattern of sanitizedPatterns) {
150
+ // make sure the pattern always starts with `**/`
151
+ if (pattern.startsWith('/')) {
152
+ pattern = '**' + pattern;
153
+ } else if (!pattern.startsWith('**/')) {
154
+ pattern = '**/' + pattern;
155
+ }
156
+ // add the exclusion prefix
157
+ if (exclude) {
158
+ pattern = '!' + pattern;
159
+ }
160
+ args.add(`--glob=${pattern}`);
161
+ // add a generic glob cli argument entry to include files inside a given directory
162
+ if (!pattern.endsWith('*')) {
163
+ // ensure the new pattern ends with `/*`
164
+ pattern += pattern.endsWith('/') ? '*' : '/*';
165
+ args.add(`--glob=${pattern}`);
166
+ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Transforms relative patterns to absolute paths, one for each given search path.
172
+ * The resulting paths are not validated in the file system as the pattern keeps glob information.
173
+ *
174
+ * @returns The resulting list may be larger than the received patterns as a relative pattern may
175
+ * resolve to multiple absolute patterns up to the number of search paths.
176
+ */
177
+ protected replaceRelativeToAbsolute(roots: string[], patterns: string[] = []): string[] {
178
+ const expandedPatterns = new Set<string>();
179
+ for (const pattern of patterns) {
180
+ if (this.isPatternRelative(pattern)) {
181
+ // create new patterns using the absolute form for each root
182
+ for (const root of roots) {
183
+ expandedPatterns.add(path.resolve(root, pattern));
184
+ }
185
+ } else {
186
+ expandedPatterns.add(pattern);
187
+ }
188
+ }
189
+ return Array.from(expandedPatterns);
190
+ }
191
+
192
+ /**
193
+ * Tests if the pattern is relative and should/can be made absolute.
194
+ */
195
+ protected isPatternRelative(pattern: string): boolean {
196
+ return pattern.replace(/\\/g, '/').startsWith('./');
197
+ }
198
+
199
+ /**
200
+ * By default, sets the search directories for the string WHAT to the provided ROOTURIS directories
201
+ * and returns the assigned search id.
202
+ *
203
+ * The include / exclude (options in SearchInWorkspaceOptions) are lists of patterns for files to
204
+ * include / exclude during search (glob characters are allowed).
205
+ *
206
+ * include patterns successfully recognized as absolute paths will override the default search and set
207
+ * the search directories to the ones provided as includes.
208
+ * Relative paths are allowed, the application will attempt to translate them to valid absolute paths
209
+ * based on the applicable search directories.
210
+ */
211
+ async search(what: string, rootUris: string[], options: SearchInWorkspaceOptions = {}): Promise<number> {
212
+ // Start the rg process. Use --vimgrep to get one result per
213
+ // line, --color=always to get color control characters that
214
+ // we'll use to parse the lines.
215
+ const searchId = this.nextSearchId++;
216
+ const rootPaths = rootUris.map(root => FileUri.fsPath(root));
217
+ // If there are absolute paths in `include` we will remove them and use
218
+ // those as paths to search from.
219
+ const searchPaths = this.extractSearchPathsFromIncludes(rootPaths, options);
220
+ options.include = this.replaceRelativeToAbsolute(searchPaths, options.include);
221
+ options.exclude = this.replaceRelativeToAbsolute(searchPaths, options.exclude);
222
+ const rgArgs = this.getArgs(options);
223
+ // If we use matchWholeWord we use regExp internally, so we need
224
+ // to escape regexp characters if we actually not set regexp true in UI.
225
+ if (options?.matchWholeWord && !options.useRegExp) {
226
+ what = what.replace(/[\-\\\{\}\*\+\?\|\^\$\.\[\]\(\)\#]/g, '\\$&');
227
+ if (!/\B/.test(what.charAt(0))) {
228
+ what = '\\b' + what;
229
+ }
230
+ if (!/\B/.test(what.charAt(what.length - 1))) {
231
+ what = what + '\\b';
232
+ }
233
+ }
234
+
235
+ const args = [...rgArgs, what, ...searchPaths];
236
+ const processOptions: RawProcessOptions = {
237
+ command: this.rgPath,
238
+ args
239
+ };
240
+
241
+ // TODO: Use child_process directly instead of rawProcessFactory?
242
+ const rgProcess: RawProcess = this.rawProcessFactory(processOptions);
243
+ this.ongoingSearches.set(searchId, rgProcess);
244
+
245
+ rgProcess.onError(error => {
246
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
247
+ let errorCode = (error as any).code;
248
+
249
+ // Try to provide somewhat clearer error messages, if possible.
250
+ if (errorCode === 'ENOENT') {
251
+ errorCode = 'could not find the ripgrep (rg) binary';
252
+ } else if (errorCode === 'EACCES') {
253
+ errorCode = 'could not execute the ripgrep (rg) binary';
254
+ }
255
+
256
+ const errorStr = `An error happened while searching (${errorCode}).`;
257
+ this.wrapUpSearch(searchId, errorStr);
258
+ });
259
+
260
+ // Running counter of results.
261
+ let numResults = 0;
262
+
263
+ // Buffer to accumulate incoming output.
264
+ let databuf: string = '';
265
+
266
+ let currentSearchResult: SearchInWorkspaceResult | undefined;
267
+
268
+ rgProcess.outputStream.on('data', (chunk: Buffer) => {
269
+ // We might have already reached the max number of
270
+ // results, sent a TERM signal to rg, but we still get
271
+ // the data that was already output in the mean time.
272
+ // It's not necessary to return early here (the check
273
+ // for maxResults below would avoid sending extra
274
+ // results), but it avoids doing unnecessary work.
275
+ if (options?.maxResults && numResults >= options.maxResults) {
276
+ return;
277
+ }
278
+
279
+ databuf += chunk;
280
+
281
+ while (1) {
282
+ // Check if we have a complete line.
283
+ const eolIdx = databuf.indexOf('\n');
284
+ if (eolIdx < 0) {
285
+ break;
286
+ }
287
+
288
+ // Get and remove the line from the data buffer.
289
+ const lineBuf = databuf.slice(0, eolIdx);
290
+ databuf = databuf.slice(eolIdx + 1);
291
+
292
+ const obj = JSON.parse(lineBuf) as IRgMessage;
293
+ if (obj.type === 'begin') {
294
+ const file = bytesOrTextToString(obj.data.path);
295
+ if (file) {
296
+ currentSearchResult = {
297
+ fileUri: FileUri.create(file).toString(),
298
+ root: this.getRoot(file, rootUris).toString(),
299
+ matches: []
300
+ };
301
+ } else {
302
+ this.logger.error('Begin message without path. ' + JSON.stringify(obj));
303
+ }
304
+ } else if (obj.type === 'end') {
305
+ if (currentSearchResult && this.client) {
306
+ this.client.onResult(searchId, currentSearchResult);
307
+ }
308
+ currentSearchResult = undefined;
309
+ } else if (obj.type === 'match') {
310
+ if (!currentSearchResult) {
311
+ continue;
312
+ }
313
+ const data = obj.data;
314
+ const file = bytesOrTextToString(data.path);
315
+ const line = data.line_number;
316
+ const lineText = bytesOrTextToString(data.lines);
317
+
318
+ if (file === undefined || lineText === undefined) {
319
+ continue;
320
+ }
321
+
322
+ const lineInBytes = Buffer.from(lineText);
323
+
324
+ for (const submatch of data.submatches) {
325
+ const startOffset = lineInBytes.slice(0, submatch.start).toString().length;
326
+ const match = bytesOrTextToString(submatch.match);
327
+ let lineInfo: string | LinePreview = lineText.trimRight();
328
+ if (lineInfo.length > 300) {
329
+ const prefixLength = 25;
330
+ const start = Math.max(startOffset - prefixLength, 0);
331
+ const length = prefixLength + match.length + 70;
332
+ let prefix = '';
333
+ if (start >= prefixLength) {
334
+ prefix = '...';
335
+ }
336
+ const character = (start < prefixLength ? start : prefixLength) + prefix.length + 1;
337
+ lineInfo = <LinePreview>{
338
+ text: prefix + lineInfo.substr(start, length),
339
+ character
340
+ };
341
+ }
342
+ currentSearchResult.matches.push({
343
+ line,
344
+ character: startOffset + 1,
345
+ length: match.length,
346
+ lineText: lineInfo
347
+ });
348
+ numResults++;
349
+
350
+ // Did we reach the maximum number of results?
351
+ if (options?.maxResults && numResults >= options.maxResults) {
352
+ rgProcess.kill();
353
+ if (currentSearchResult && this.client) {
354
+ this.client.onResult(searchId, currentSearchResult);
355
+ }
356
+ currentSearchResult = undefined;
357
+ this.wrapUpSearch(searchId);
358
+ break;
359
+ }
360
+ }
361
+ }
362
+ }
363
+ });
364
+
365
+ rgProcess.outputStream.on('end', () => {
366
+ // If we reached maxResults, we should have already
367
+ // wrapped up the search. Returning early avoids
368
+ // logging a warning message in wrapUpSearch.
369
+ if (options?.maxResults && numResults >= options.maxResults) {
370
+ return;
371
+ }
372
+
373
+ this.wrapUpSearch(searchId);
374
+ });
375
+
376
+ return searchId;
377
+ }
378
+
379
+ /**
380
+ * The default search paths are set to be the root paths associated to a workspace
381
+ * however the search scope can be further refined with the include paths available in the search options.
382
+ * This method will replace the searching paths to the ones specified in the 'include' options but as long
383
+ * as the 'include' paths can be successfully validated as existing.
384
+ *
385
+ * Therefore the returned array of paths can be either the workspace root paths or a set of validated paths
386
+ * derived from the include options which can be used to perform the search.
387
+ *
388
+ * Any pattern that resulted in a valid search path will be removed from the 'include' list as it is
389
+ * provided as an equivalent search path instead.
390
+ */
391
+ protected extractSearchPathsFromIncludes(rootPaths: string[], options: SearchInWorkspaceOptions): string[] {
392
+ if (!options.include) {
393
+ return rootPaths;
394
+ }
395
+ const resolvedPaths = new Set<string>();
396
+ options.include = options.include.filter(pattern => {
397
+ let keep = true;
398
+ for (const root of rootPaths) {
399
+ const absolutePath = this.getAbsolutePathFromPattern(root, pattern);
400
+ // undefined means the pattern cannot be converted into an absolute path
401
+ if (absolutePath) {
402
+ resolvedPaths.add(absolutePath);
403
+ keep = false;
404
+ }
405
+ }
406
+ return keep;
407
+ });
408
+ return resolvedPaths.size > 0
409
+ ? Array.from(resolvedPaths)
410
+ : rootPaths;
411
+ }
412
+
413
+ /**
414
+ * Transform include/exclude option patterns from relative patterns to absolute patterns.
415
+ * E.g. './abc/foo.*' to '${root}/abc/foo.*', the transformation does not validate the
416
+ * pattern against the file system as glob suffixes remain.
417
+ *
418
+ * @returns undefined if the pattern cannot be converted into an absolute path.
419
+ */
420
+ protected getAbsolutePathFromPattern(root: string, pattern: string): string | undefined {
421
+ pattern = pattern.replace(/\\/g, '/');
422
+ // The pattern is not referring to a single file or folder, i.e. not to be converted
423
+ if (!path.isAbsolute(pattern) && !pattern.startsWith('./')) {
424
+ return undefined;
425
+ }
426
+ // remove the `/**` suffix if present
427
+ if (pattern.endsWith('/**')) {
428
+ pattern = pattern.substr(0, pattern.length - 3);
429
+ }
430
+ // if `pattern` is absolute then `root` will be ignored by `path.resolve()`
431
+ const targetPath = path.resolve(root, pattern);
432
+ if (fs.existsSync(targetPath)) {
433
+ return targetPath;
434
+ }
435
+ return undefined;
436
+ }
437
+
438
+ /**
439
+ * Returns the root folder uri that a file belongs to.
440
+ * In case that a file belongs to more than one root folders, returns the root folder that is closest to the file.
441
+ * If the file is not from the current workspace, returns empty string.
442
+ * @param filePath string path of the file
443
+ * @param rootUris string URIs of the root folders in the current workspace
444
+ */
445
+ private getRoot(filePath: string, rootUris: string[]): URI {
446
+ const roots = rootUris.filter(root => new URI(root).withScheme('file').isEqualOrParent(FileUri.create(filePath).withScheme('file')));
447
+ if (roots.length > 0) {
448
+ return FileUri.create(FileUri.fsPath(roots.sort((r1, r2) => r2.length - r1.length)[0]));
449
+ }
450
+ return new URI();
451
+ }
452
+
453
+ // Cancel an ongoing search. Trying to cancel a search that doesn't exist isn't an
454
+ // error, otherwise we'd have to deal with race conditions, where a client cancels a
455
+ // search that finishes normally at the same time.
456
+ cancel(searchId: number): Promise<void> {
457
+ const process = this.ongoingSearches.get(searchId);
458
+ if (process) {
459
+ process.kill();
460
+ this.wrapUpSearch(searchId);
461
+ }
462
+
463
+ return Promise.resolve();
464
+ }
465
+
466
+ // Send onDone to the client and clean up what we know about search searchId.
467
+ private wrapUpSearch(searchId: number, error?: string): void {
468
+ if (this.ongoingSearches.delete(searchId)) {
469
+ if (this.client) {
470
+ this.logger.debug('Sending onDone for ' + searchId, error);
471
+ this.client.onDone(searchId, error);
472
+ } else {
473
+ this.logger.debug('Wrapping up search ' + searchId + ' but no client');
474
+ }
475
+ } else {
476
+ this.logger.debug("Trying to wrap up a search we don't know about " + searchId);
477
+ }
478
+ }
479
+
480
+ dispose(): void {
481
+ }
482
+ }