@theia/search-in-workspace 1.45.1 → 1.46.0-next.72

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