starmux 0.12.0 → 0.13.0
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.
- package/dist/src/cli/mac-mcp-gateway.js +4 -3
- package/dist/src/cli/mac-mcp-gateway.js.map +1 -1
- package/dist/src/cli/starmux.js +1 -1
- package/dist/src/cli/starmux.js.map +1 -1
- package/dist/src/runtime-paths.js +3 -3
- package/dist/src/runtime-paths.js.map +1 -1
- package/dist/vendor/desktop-commander/LICENSE +21 -0
- package/dist/vendor/desktop-commander/dist/bootstrap.js +22 -0
- package/dist/vendor/desktop-commander/dist/command-manager.js +246 -0
- package/dist/vendor/desktop-commander/dist/config-field-definitions.js +37 -0
- package/dist/vendor/desktop-commander/dist/config-manager.js +634 -0
- package/dist/vendor/desktop-commander/dist/config-recovery.js +81 -0
- package/dist/vendor/desktop-commander/dist/config.js +19 -0
- package/dist/vendor/desktop-commander/dist/custom-stdio.js +358 -0
- package/dist/vendor/desktop-commander/dist/error-handlers.js +15 -0
- package/dist/vendor/desktop-commander/dist/handlers/edit-search-handlers.js +6 -0
- package/dist/vendor/desktop-commander/dist/handlers/filesystem-handlers.js +450 -0
- package/dist/vendor/desktop-commander/dist/handlers/history-handlers.js +67 -0
- package/dist/vendor/desktop-commander/dist/handlers/index.js +7 -0
- package/dist/vendor/desktop-commander/dist/handlers/process-handlers.js +15 -0
- package/dist/vendor/desktop-commander/dist/handlers/search-answers.js +172 -0
- package/dist/vendor/desktop-commander/dist/handlers/search-handlers.js +136 -0
- package/dist/vendor/desktop-commander/dist/handlers/terminal-handlers.js +35 -0
- package/dist/vendor/desktop-commander/dist/index.js +88 -0
- package/dist/vendor/desktop-commander/dist/search-manager.js +1262 -0
- package/dist/vendor/desktop-commander/dist/server.js +1414 -0
- package/dist/vendor/desktop-commander/dist/terminal-manager.js +757 -0
- package/dist/vendor/desktop-commander/dist/tools/config.js +211 -0
- package/dist/vendor/desktop-commander/dist/tools/edit.js +455 -0
- package/dist/vendor/desktop-commander/dist/tools/feedback.js +10 -0
- package/dist/vendor/desktop-commander/dist/tools/filesystem.js +1047 -0
- package/dist/vendor/desktop-commander/dist/tools/fuzzySearch.js +74 -0
- package/dist/vendor/desktop-commander/dist/tools/fuzzySearchCore.js +125 -0
- package/dist/vendor/desktop-commander/dist/tools/improved-process-tools.js +726 -0
- package/dist/vendor/desktop-commander/dist/tools/mime-types.js +28 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/extract-images.js +131 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/index.js +4 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/lib/pdf2md.js +78 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/manipulations.js +112 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/markdown.js +30 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/utils.js +45 -0
- package/dist/vendor/desktop-commander/dist/tools/process.js +90 -0
- package/dist/vendor/desktop-commander/dist/tools/prompts.js +259 -0
- package/dist/vendor/desktop-commander/dist/tools/schemas.js +242 -0
- package/dist/vendor/desktop-commander/dist/tools/usage.js +24 -0
- package/dist/vendor/desktop-commander/dist/types.js +1 -0
- package/dist/vendor/desktop-commander/dist/ui/config-editor/config-editor-runtime.js +150 -0
- package/dist/vendor/desktop-commander/dist/ui/config-editor/index.html +13 -0
- package/dist/vendor/desktop-commander/dist/ui/config-editor/styles.css +587 -0
- package/dist/vendor/desktop-commander/dist/ui/contracts.js +57 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/index.html +16 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/preview-runtime.js +471 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/shared/preview-file-types.js +59 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/styles.css +1631 -0
- package/dist/vendor/desktop-commander/dist/ui/resources.js +93 -0
- package/dist/vendor/desktop-commander/dist/utils/ab-test.js +122 -0
- package/dist/vendor/desktop-commander/dist/utils/atomic-write.js +80 -0
- package/dist/vendor/desktop-commander/dist/utils/capture.js +40 -0
- package/dist/vendor/desktop-commander/dist/utils/dockerPrompt.js +9 -0
- package/dist/vendor/desktop-commander/dist/utils/exit-process.js +31 -0
- package/dist/vendor/desktop-commander/dist/utils/feature-flags.js +18 -0
- package/dist/vendor/desktop-commander/dist/utils/files/base.js +5 -0
- package/dist/vendor/desktop-commander/dist/utils/files/binary.js +65 -0
- package/dist/vendor/desktop-commander/dist/utils/files/docx.js +694 -0
- package/dist/vendor/desktop-commander/dist/utils/files/excel.js +497 -0
- package/dist/vendor/desktop-commander/dist/utils/files/factory.js +168 -0
- package/dist/vendor/desktop-commander/dist/utils/files/image.js +89 -0
- package/dist/vendor/desktop-commander/dist/utils/files/index.js +14 -0
- package/dist/vendor/desktop-commander/dist/utils/files/pdf.js +124 -0
- package/dist/vendor/desktop-commander/dist/utils/files/text.js +423 -0
- package/dist/vendor/desktop-commander/dist/utils/fuzzySearchLogger.js +126 -0
- package/dist/vendor/desktop-commander/dist/utils/internal-facts.js +23 -0
- package/dist/vendor/desktop-commander/dist/utils/lazy-package.js +44 -0
- package/dist/vendor/desktop-commander/dist/utils/lineEndingHandler.js +77 -0
- package/dist/vendor/desktop-commander/dist/utils/logger.js +72 -0
- package/dist/vendor/desktop-commander/dist/utils/mcp-ui-ab-test.js +62 -0
- package/dist/vendor/desktop-commander/dist/utils/open-browser.js +2 -0
- package/dist/vendor/desktop-commander/dist/utils/process-detection.js +260 -0
- package/dist/vendor/desktop-commander/dist/utils/process-tree.js +223 -0
- package/dist/vendor/desktop-commander/dist/utils/rename.js +74 -0
- package/dist/vendor/desktop-commander/dist/utils/ripgrep-resolver.js +69 -0
- package/dist/vendor/desktop-commander/dist/utils/shell.js +187 -0
- package/dist/vendor/desktop-commander/dist/utils/system-info.js +727 -0
- package/dist/vendor/desktop-commander/dist/utils/terminal-timestamps.js +18 -0
- package/dist/vendor/desktop-commander/dist/utils/toolHistory.js +309 -0
- package/dist/vendor/desktop-commander/dist/utils/trackTools.js +55 -0
- package/dist/vendor/desktop-commander/dist/utils/unsupportedParams.js +66 -0
- package/dist/vendor/desktop-commander/dist/utils/usageTracker.js +225 -0
- package/dist/vendor/desktop-commander/dist/utils/welcome-onboarding.js +2 -0
- package/dist/vendor/desktop-commander/dist/utils/withTimeout.js +85 -0
- package/dist/vendor/desktop-commander/dist/version.js +1 -0
- package/dist/vendor/desktop-commander/package.json +15 -0
- package/package.json +14 -2
|
@@ -0,0 +1,1262 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { constants } from 'buffer';
|
|
3
|
+
import { once } from 'events';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import { validatePath } from './tools/filesystem.js';
|
|
7
|
+
import { capture } from './utils/capture.js';
|
|
8
|
+
import { logger } from './utils/logger.js';
|
|
9
|
+
import { getRipgrepPath } from './utils/ripgrep-resolver.js';
|
|
10
|
+
import { isExcelFile } from './utils/files/index.js';
|
|
11
|
+
import { exceljsPackage } from './utils/files/excel.js';
|
|
12
|
+
import { pizzipPackage } from './utils/files/docx.js';
|
|
13
|
+
/**
|
|
14
|
+
* Default time limit of a file search for an exact filename ("package.json"):
|
|
15
|
+
* a quick lookup of one known file, which gives up rather than walk a huge tree.
|
|
16
|
+
* A content search for the same text looks for every reference to that file,
|
|
17
|
+
* so it has no default limit.
|
|
18
|
+
*/
|
|
19
|
+
const EXACT_FILENAME_SEARCH_TIMEOUT_MS = 1500;
|
|
20
|
+
/** The longest a timer waits (2^31 - 1 ms, ~24.8 days): setTimeout fires a longer delay after 1 ms */
|
|
21
|
+
const LONGEST_TIMER_MS = 2 ** 31 - 1;
|
|
22
|
+
/** The extensions of the files the Excel and DOCX searches are for (see targetsOfficeFiles) */
|
|
23
|
+
const EXCEL_EXTENSIONS = ['.xlsx', '.xls', '.xlsm', '.xlsb'];
|
|
24
|
+
const DOCX_EXTENSIONS = ['.docx'];
|
|
25
|
+
/**
|
|
26
|
+
* The OS errors ripgrep reports for a path it may not read ("rg: <path>: ...
|
|
27
|
+
* (os error N)"): on Windows ERROR_ACCESS_DENIED (5); elsewhere EACCES (13) and
|
|
28
|
+
* EPERM (1: on macOS, a folder its privacy settings protect). On Windows, 1 is
|
|
29
|
+
* an error of another kind.
|
|
30
|
+
*/
|
|
31
|
+
const PERMISSION_OS_ERRORS = process.platform === 'win32' ? [5] : [1, 13];
|
|
32
|
+
/** Whether an error of the Excel/DOCX search is about permissions */
|
|
33
|
+
const isPermissionError = (error) => ['EACCES', 'EPERM'].includes(error?.code ?? '');
|
|
34
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
35
|
+
/** The alternatives of a filePattern ("*.js|*.ts") */
|
|
36
|
+
const filePatternAlternatives = (filePattern) => (filePattern ?? '').split('|').map(p => p.trim()).filter(Boolean);
|
|
37
|
+
/** Answers show this many characters of a result's text, then '...' if there is more */
|
|
38
|
+
export const SHOWN_TEXT_CHARS = 100;
|
|
39
|
+
/**
|
|
40
|
+
* What a session keeps of a line's text: what answers show, and one character
|
|
41
|
+
* more so they still know to add '...'. ripgrep sends each line whole (--json
|
|
42
|
+
* ignores --max-columns), and a line can be megabytes long. Copied, because a
|
|
43
|
+
* V8 substring keeps the whole string it was cut from alive. No text (ripgrep
|
|
44
|
+
* sends text that is not valid UTF-8 as "bytes") stays no text, as it always was.
|
|
45
|
+
*/
|
|
46
|
+
const keptText = (text) => text === undefined ? undefined : Buffer.from(text.slice(0, SHOWN_TEXT_CHARS + 1), 'utf16le').toString('utf16le');
|
|
47
|
+
/**
|
|
48
|
+
* A glob as ripgrep matches its -g globs (gitignore style), for the files
|
|
49
|
+
* ripgrep doesn't select itself: a file search's files, and the Excel and DOCX
|
|
50
|
+
* files. Without a '/' it is matched against the file's name, with one against
|
|
51
|
+
* its path below the search path ('/'-separated). '*' and '?' stop at '/', "**"
|
|
52
|
+
* as a whole path segment spans folders, "[...]" is a character class ("[!...]"
|
|
53
|
+
* negated) and "{a,b}" a choice.
|
|
54
|
+
*/
|
|
55
|
+
function ripgrepGlobMatcher(glob, ignoreCase) {
|
|
56
|
+
const byPath = glob.includes('/'); // "/x.ts" too: x.ts in the search path itself
|
|
57
|
+
let regex;
|
|
58
|
+
try {
|
|
59
|
+
regex = new RegExp(`^${globRegexSource(glob.replace(/^\//, ''))}$`, ignoreCase ? 'i' : '');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// A glob ripgrep rejects too ("[z-a]"): every glob a search uses reaches
|
|
63
|
+
// ripgrep (see buildRipgrepArgs), and the search answers with its error
|
|
64
|
+
return () => false;
|
|
65
|
+
}
|
|
66
|
+
return relativePath => regex.test(byPath ? relativePath : relativePath.slice(relativePath.lastIndexOf('/') + 1));
|
|
67
|
+
}
|
|
68
|
+
/** The regular expression source of a glob (see ripgrepGlobMatcher) */
|
|
69
|
+
function globRegexSource(glob) {
|
|
70
|
+
let source = '';
|
|
71
|
+
for (let i = 0; i < glob.length; i++) {
|
|
72
|
+
const c = glob[i];
|
|
73
|
+
const classEnd = c === '[' ? characterClassEnd(glob, i) : -1;
|
|
74
|
+
const choiceEnd = c === '{' ? glob.indexOf('}', i) : -1;
|
|
75
|
+
if (c === '*' && glob[i + 1] === '*' && (i === 0 || glob[i - 1] === '/') && (i + 2 === glob.length || glob[i + 2] === '/')) {
|
|
76
|
+
// "**" as a whole path segment: any number of folders ("**/": none too)
|
|
77
|
+
source += i + 2 === glob.length ? '.*' : '(?:.*/)?';
|
|
78
|
+
i += i + 2 === glob.length ? 1 : 2;
|
|
79
|
+
}
|
|
80
|
+
else if (c === '*') {
|
|
81
|
+
source += '[^/]*';
|
|
82
|
+
}
|
|
83
|
+
else if (c === '?') {
|
|
84
|
+
source += '[^/]';
|
|
85
|
+
}
|
|
86
|
+
else if (classEnd > 0) {
|
|
87
|
+
let body = glob.slice(i + 1, classEnd);
|
|
88
|
+
const negated = body.startsWith('!') || body.startsWith('^');
|
|
89
|
+
if (negated)
|
|
90
|
+
body = body.slice(1);
|
|
91
|
+
source += `[${negated ? '^/' : ''}${body.replace(/[\\\]^]/g, '\\$&')}]`;
|
|
92
|
+
i = classEnd;
|
|
93
|
+
}
|
|
94
|
+
else if (choiceEnd > 0) {
|
|
95
|
+
source += `(?:${glob.slice(i + 1, choiceEnd).split(',').map(globRegexSource).join('|')})`;
|
|
96
|
+
i = choiceEnd;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
source += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return source;
|
|
103
|
+
}
|
|
104
|
+
/** A file's path below the search path, '/'-separated (just its name when it is the search path) */
|
|
105
|
+
const pathBelow = (root, file) => path.relative(root, file).split(path.sep).join('/') || path.basename(file);
|
|
106
|
+
/** A glob that matches `text` itself: each glob character in a class of its own ("[*]", "[[]") */
|
|
107
|
+
const literalGlob = (text) => text.replace(/[*?[\]{}]/g, '[$&]');
|
|
108
|
+
/** The ']' that closes the character class opening at `start` (one right after "[" or "[!" is part of it), or -1 */
|
|
109
|
+
function characterClassEnd(glob, start) {
|
|
110
|
+
let i = start + 1;
|
|
111
|
+
if (glob[i] === '!' || glob[i] === '^')
|
|
112
|
+
i++;
|
|
113
|
+
if (glob[i] === ']')
|
|
114
|
+
i++;
|
|
115
|
+
return glob.indexOf(']', i);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The longest line of ripgrep output the server assembles, in characters: a
|
|
119
|
+
* match or context line in its JSON. Half of V8's longest string (2^29 - 24
|
|
120
|
+
* characters in Node 24), past which the append throws and the server exits:
|
|
121
|
+
* only a longer line is skipped, and answers name its file. Any shorter line is
|
|
122
|
+
* processed whole.
|
|
123
|
+
*/
|
|
124
|
+
export const MAX_OUTPUT_LINE_CHARS = Math.floor(constants.MAX_STRING_LENGTH / 2);
|
|
125
|
+
/** The most of ripgrep's error output (stderr) a session keeps, in characters */
|
|
126
|
+
const MAX_KEPT_ERROR_CHARS = 64 * 1024;
|
|
127
|
+
/**
|
|
128
|
+
* Search Session Manager - handles ripgrep processes like terminal sessions
|
|
129
|
+
* Supports both file search and content search with progressive results
|
|
130
|
+
*/ export class SearchManager {
|
|
131
|
+
constructor() {
|
|
132
|
+
this.sessions = new Map();
|
|
133
|
+
this.sessionCounter = 0;
|
|
134
|
+
this.cleanupTimer = null;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Start a new search session (like start_process)
|
|
138
|
+
* Returns immediately with initial state and results
|
|
139
|
+
*/
|
|
140
|
+
async startSearch(options) {
|
|
141
|
+
const sessionId = `search_${++this.sessionCounter}_${Date.now()}`;
|
|
142
|
+
// Validate path first
|
|
143
|
+
const validPath = await validatePath(options.rootPath);
|
|
144
|
+
// Build ripgrep arguments
|
|
145
|
+
const args = this.buildRipgrepArgs({ ...options, rootPath: validPath });
|
|
146
|
+
// Get ripgrep path with fallback resolution
|
|
147
|
+
let rgPath;
|
|
148
|
+
try {
|
|
149
|
+
rgPath = await getRipgrepPath();
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
throw new Error(`Failed to locate ripgrep binary: ${err instanceof Error ? err.message : String(err)}`);
|
|
153
|
+
}
|
|
154
|
+
// Start ripgrep process. It matches a glob with a '/' ("src/*.ts") against
|
|
155
|
+
// the path below its working directory: that must be the root path. A root
|
|
156
|
+
// that can't be stat'ed (missing...) can't be searched: start_search answers
|
|
157
|
+
// with why, as for any search that can't start.
|
|
158
|
+
const rootIsDirectory = (await fs.stat(validPath)).isDirectory();
|
|
159
|
+
const rgProcess = spawn(rgPath, args, {
|
|
160
|
+
windowsHide: true, // Prevent visible console windows on Windows
|
|
161
|
+
cwd: rootIsDirectory ? validPath : undefined
|
|
162
|
+
});
|
|
163
|
+
await this.whenStarted(rgProcess);
|
|
164
|
+
// Create session
|
|
165
|
+
let markCompleted;
|
|
166
|
+
const completed = new Promise(resolve => { markCompleted = resolve; });
|
|
167
|
+
const session = {
|
|
168
|
+
id: sessionId,
|
|
169
|
+
process: rgProcess,
|
|
170
|
+
results: [],
|
|
171
|
+
isComplete: false,
|
|
172
|
+
startTime: Date.now(),
|
|
173
|
+
lastReadTime: Date.now(),
|
|
174
|
+
options,
|
|
175
|
+
buffer: '',
|
|
176
|
+
skippedLines: new Map(),
|
|
177
|
+
totalMatches: 0,
|
|
178
|
+
totalContextLines: 0,
|
|
179
|
+
filePatternIncludes: this.filePatternIncludes(options, validPath),
|
|
180
|
+
pendingSources: new Set(['ripgrep']),
|
|
181
|
+
unsearched: new Map(),
|
|
182
|
+
completed,
|
|
183
|
+
markCompleted
|
|
184
|
+
};
|
|
185
|
+
this.sessions.set(sessionId, session);
|
|
186
|
+
// Set up process event handlers
|
|
187
|
+
this.setupProcessHandlers(session);
|
|
188
|
+
// Start cleanup interval now that we have a session
|
|
189
|
+
this.startCleanupIfNeeded();
|
|
190
|
+
// Set up the time limit, if any: it stops every source still running.
|
|
191
|
+
// For exact filename file searches, use a shorter default timeout
|
|
192
|
+
const timeoutMs = options.timeout ??
|
|
193
|
+
(this.isExactFilenameSearch(options) ? EXACT_FILENAME_SEARCH_TIMEOUT_MS : undefined);
|
|
194
|
+
if (timeoutMs) {
|
|
195
|
+
session.timeLimitMs = timeoutMs;
|
|
196
|
+
session.timeoutTimer = setTimeout(() => {
|
|
197
|
+
session.stopReason ?? (session.stopReason = 'timed_out');
|
|
198
|
+
this.stopSources(session);
|
|
199
|
+
}, Math.min(timeoutMs, LONGEST_TIMER_MS));
|
|
200
|
+
}
|
|
201
|
+
capture('search_session_started', {
|
|
202
|
+
sessionId,
|
|
203
|
+
searchType: options.searchType,
|
|
204
|
+
hasTimeout: !!timeoutMs,
|
|
205
|
+
timeoutMs,
|
|
206
|
+
requestedPath: options.rootPath,
|
|
207
|
+
validatedPath: validPath
|
|
208
|
+
});
|
|
209
|
+
// For content searches, only search Excel files when contextually relevant:
|
|
210
|
+
// - filePattern explicitly targets Excel files (*.xlsx, *.xls, etc.)
|
|
211
|
+
// - or rootPath is an Excel file itself
|
|
212
|
+
const shouldSearchExcel = options.searchType === 'content' &&
|
|
213
|
+
this.targetsOfficeFiles(EXCEL_EXTENSIONS, options.filePattern, validPath);
|
|
214
|
+
if (shouldSearchExcel) {
|
|
215
|
+
this.startOfficeSource(session, 'excel', sink => this.searchExcelFiles(validPath, options.pattern, options.ignoreCase !== false, sink, options.filePattern, // Pass filePattern to filter Excel files too
|
|
216
|
+
options.includeHidden === true));
|
|
217
|
+
}
|
|
218
|
+
// For content searches, also search DOCX files
|
|
219
|
+
const shouldSearchDocx = options.searchType === 'content' &&
|
|
220
|
+
this.targetsOfficeFiles(DOCX_EXTENSIONS, options.filePattern, validPath);
|
|
221
|
+
if (shouldSearchDocx) {
|
|
222
|
+
this.startOfficeSource(session, 'docx', sink => this.searchDocxFiles(validPath, options.pattern, options.ignoreCase !== false, sink, options.filePattern, options.includeHidden === true));
|
|
223
|
+
}
|
|
224
|
+
// Wait for first chunk of data or early completion instead of fixed delay
|
|
225
|
+
// Office searches run in background and add their matches to the session as they find them
|
|
226
|
+
const firstChunk = new Promise(resolve => {
|
|
227
|
+
const onData = () => {
|
|
228
|
+
session.process.stdout?.off('data', onData);
|
|
229
|
+
resolve();
|
|
230
|
+
};
|
|
231
|
+
session.process.stdout?.once('data', onData);
|
|
232
|
+
setTimeout(resolve, 40); // cap at 40ms instead of 50-100ms
|
|
233
|
+
});
|
|
234
|
+
// Only wait for ripgrep first chunk - Excel results merge asynchronously
|
|
235
|
+
await firstChunk;
|
|
236
|
+
return { ...this.stateOf(session), results: [...session.results] };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Read search results with offset-based pagination (like read_file)
|
|
240
|
+
* Supports both range reading and tail behavior
|
|
241
|
+
*/
|
|
242
|
+
readSearchResults(sessionId, offset = 0, length = 100) {
|
|
243
|
+
const session = this.sessions.get(sessionId);
|
|
244
|
+
if (!session) {
|
|
245
|
+
throw new Error(`Search session ${sessionId} not found`);
|
|
246
|
+
}
|
|
247
|
+
// Get all results (excluding internal markers)
|
|
248
|
+
const allResults = session.results.filter(r => r.file !== '__LAST_READ_MARKER__');
|
|
249
|
+
// Every read keeps the session from cleanup, a read of the last results too
|
|
250
|
+
session.lastReadTime = Date.now();
|
|
251
|
+
// Handle negative offsets (tail behavior) - like file reading
|
|
252
|
+
if (offset < 0) {
|
|
253
|
+
const tailCount = Math.abs(offset);
|
|
254
|
+
const tailResults = allResults.slice(-tailCount);
|
|
255
|
+
return {
|
|
256
|
+
...this.stateOf(session),
|
|
257
|
+
results: tailResults,
|
|
258
|
+
returnedCount: tailResults.length,
|
|
259
|
+
hasMoreResults: false // Tail always returns what's available
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// Handle positive offsets (range behavior) - like file reading
|
|
263
|
+
const slicedResults = allResults.slice(offset, offset + length);
|
|
264
|
+
const hasMoreResults = offset + length < allResults.length || !session.isComplete;
|
|
265
|
+
return {
|
|
266
|
+
...this.stateOf(session),
|
|
267
|
+
results: slicedResults,
|
|
268
|
+
returnedCount: slicedResults.length,
|
|
269
|
+
hasMoreResults
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/** What answers say about a session (see handlers/search-answers.ts) */
|
|
273
|
+
stateOf(session) {
|
|
274
|
+
return {
|
|
275
|
+
sessionId: session.id,
|
|
276
|
+
isComplete: session.isComplete,
|
|
277
|
+
outcome: session.outcome,
|
|
278
|
+
failure: session.failure,
|
|
279
|
+
unsearched: [...session.unsearched.values()],
|
|
280
|
+
timeLimitMs: session.timeLimitMs,
|
|
281
|
+
maxResults: session.options.maxResults,
|
|
282
|
+
totalMatches: session.totalMatches,
|
|
283
|
+
totalResults: session.totalMatches + session.totalContextLines,
|
|
284
|
+
runtime: (session.endTime ?? Date.now()) - session.startTime,
|
|
285
|
+
skippedLines: this.skippedLinesOf(session)
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
skippedLinesOf(session) {
|
|
289
|
+
return [...session.skippedLines].map(([file, count]) => ({ file, count }));
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Keeps ripgrep's error output for the answer (shown when a search fails
|
|
293
|
+
* without results), each piece once and up to MAX_KEPT_ERROR_CHARS: a search
|
|
294
|
+
* over folders it can't read can print an error per entry, and the session
|
|
295
|
+
* keeps this until it is cleaned up. What comes first is kept.
|
|
296
|
+
*/
|
|
297
|
+
keepErrorOutput(session, text) {
|
|
298
|
+
const kept = session.error ?? '';
|
|
299
|
+
if (kept.length >= MAX_KEPT_ERROR_CHARS)
|
|
300
|
+
return;
|
|
301
|
+
session.error = kept + text.slice(0, MAX_KEPT_ERROR_CHARS - kept.length);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Resolves with all of a session's results once it is complete: every source
|
|
305
|
+
* has finished, or the search was stopped.
|
|
306
|
+
*/
|
|
307
|
+
async waitForCompletion(sessionId) {
|
|
308
|
+
const session = this.sessions.get(sessionId);
|
|
309
|
+
if (!session) {
|
|
310
|
+
throw new Error(`Search session ${sessionId} not found`);
|
|
311
|
+
}
|
|
312
|
+
await session.completed;
|
|
313
|
+
return [...session.results];
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Terminate a search session (like force_terminate)
|
|
317
|
+
*/
|
|
318
|
+
terminateSearch(sessionId) {
|
|
319
|
+
const session = this.sessions.get(sessionId);
|
|
320
|
+
if (!session) {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
if (!session.isComplete)
|
|
324
|
+
session.stopReason ?? (session.stopReason = 'stopped');
|
|
325
|
+
this.stopSources(session);
|
|
326
|
+
// Don't delete session immediately - let user read final results
|
|
327
|
+
// It will be cleaned up by cleanup process
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Stop every running search and drop all sessions and the cleanup timer.
|
|
332
|
+
* For owners that tear the search manager down (shutdown, tests); a later
|
|
333
|
+
* startSearch() starts afresh.
|
|
334
|
+
*/
|
|
335
|
+
dispose() {
|
|
336
|
+
for (const session of this.sessions.values()) {
|
|
337
|
+
this.stopSources(session);
|
|
338
|
+
}
|
|
339
|
+
this.sessions.clear();
|
|
340
|
+
if (this.cleanupTimer) {
|
|
341
|
+
clearInterval(this.cleanupTimer);
|
|
342
|
+
this.cleanupTimer = null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Start the periodic cleanup of old sessions. It is housekeeping only, so it
|
|
347
|
+
* is unref'd: it must never keep the process alive on its own.
|
|
348
|
+
*/
|
|
349
|
+
startCleanupIfNeeded() {
|
|
350
|
+
if (this.cleanupTimer)
|
|
351
|
+
return;
|
|
352
|
+
this.cleanupTimer = setInterval(() => this.cleanupSessions(), 5 * 60 * 1000);
|
|
353
|
+
this.cleanupTimer.unref();
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Get list of active search sessions (like list_sessions)
|
|
357
|
+
*/
|
|
358
|
+
listSearchSessions() {
|
|
359
|
+
return Array.from(this.sessions.values()).map(session => ({
|
|
360
|
+
id: session.id,
|
|
361
|
+
searchType: session.options.searchType,
|
|
362
|
+
pattern: session.options.pattern,
|
|
363
|
+
isComplete: session.isComplete,
|
|
364
|
+
isError: session.outcome === 'failed',
|
|
365
|
+
runtime: (session.endTime ?? Date.now()) - session.startTime,
|
|
366
|
+
totalResults: session.totalMatches + session.totalContextLines
|
|
367
|
+
}));
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Run an Excel or DOCX search as one of the session's sources: its matches are
|
|
371
|
+
* collected as it finds them, until it has searched every file or the session
|
|
372
|
+
* stops it (stop_search, timeout, maxResults).
|
|
373
|
+
*/
|
|
374
|
+
startOfficeSource(session, source, search) {
|
|
375
|
+
session.pendingSources.add(source);
|
|
376
|
+
const sink = {
|
|
377
|
+
onMatch: result => {
|
|
378
|
+
if (session.pendingSources.has(source))
|
|
379
|
+
this.collectMatch(session, result);
|
|
380
|
+
},
|
|
381
|
+
isStopped: () => !session.pendingSources.has(source),
|
|
382
|
+
couldNotRead: (what, filePath, error) => {
|
|
383
|
+
if (!session.pendingSources.has(source))
|
|
384
|
+
return;
|
|
385
|
+
const code = error?.code;
|
|
386
|
+
if (isPermissionError(error))
|
|
387
|
+
this.noteUnsearched(session, 'permissions', filePath);
|
|
388
|
+
else
|
|
389
|
+
this.noteUnsearched(session, `${source}_${what}`, what === 'folder' ? `${filePath}: ${code ?? errorText(error)}` : filePath);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
search(sink)
|
|
393
|
+
.catch((err) => {
|
|
394
|
+
// Log Office search errors but don't fail the whole search: its answer
|
|
395
|
+
// says that part couldn't be searched
|
|
396
|
+
const message = errorText(err);
|
|
397
|
+
logger.error(`The ${source} part of search ${session.id} failed; its matches are missing: ${message}`);
|
|
398
|
+
capture(`${source}_search_error`, { error: message });
|
|
399
|
+
if (session.pendingSources.has(source))
|
|
400
|
+
this.noteUnsearched(session, `${source}_search`, message);
|
|
401
|
+
})
|
|
402
|
+
.finally(() => this.finishSource(session, source));
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Search Excel files for content matches
|
|
406
|
+
* Called during content search to include Excel files alongside text files
|
|
407
|
+
* Searches ALL sheets in each Excel file (row-wise for cross-column matching)
|
|
408
|
+
*
|
|
409
|
+
* TODO: Refactor - Extract Excel search logic to separate module (src/utils/search/excel-search.ts)
|
|
410
|
+
* and inject into SearchManager, similar to how file handlers are structured in src/utils/files/
|
|
411
|
+
* This would allow adding other file type searches (PDF, etc.) without bloating search-manager.ts
|
|
412
|
+
*/
|
|
413
|
+
async searchExcelFiles(rootPath, pattern, ignoreCase, sink, filePattern, includeHidden = false) {
|
|
414
|
+
// Office file search always uses literal matching to prevent ReDoS.
|
|
415
|
+
// Regex patterns are treated as literal strings — this is intentional.
|
|
416
|
+
const searchTerm = ignoreCase ? pattern.toLowerCase() : pattern;
|
|
417
|
+
// Find Excel files recursively
|
|
418
|
+
let excelFiles = await this.findExcelFiles(rootPath, sink, includeHidden);
|
|
419
|
+
// Filter by filePattern if provided
|
|
420
|
+
if (filePattern) {
|
|
421
|
+
excelFiles = this.filterOfficeFiles(excelFiles, filePattern, rootPath);
|
|
422
|
+
}
|
|
423
|
+
if (excelFiles.length === 0)
|
|
424
|
+
return;
|
|
425
|
+
// ExcelJS, to search all sheets: loaded here if the server hasn't yet
|
|
426
|
+
// (a search runs in the background, so it isn't refused while it loads)
|
|
427
|
+
const ExcelJS = exceljsPackage.load();
|
|
428
|
+
for (const filePath of excelFiles) {
|
|
429
|
+
if (sink.isStopped())
|
|
430
|
+
break;
|
|
431
|
+
try {
|
|
432
|
+
const workbook = new ExcelJS.Workbook();
|
|
433
|
+
await workbook.xlsx.readFile(filePath);
|
|
434
|
+
// Search ALL sheets in the workbook (row-wise for speed and cross-column matching)
|
|
435
|
+
for (const worksheet of workbook.worksheets) {
|
|
436
|
+
if (sink.isStopped())
|
|
437
|
+
break;
|
|
438
|
+
const sheetName = worksheet.name;
|
|
439
|
+
// Iterate through rows (faster than cell-by-cell)
|
|
440
|
+
worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
|
441
|
+
if (sink.isStopped())
|
|
442
|
+
return;
|
|
443
|
+
// Build a concatenated string of all cell values in the row
|
|
444
|
+
const rowValues = [];
|
|
445
|
+
row.eachCell({ includeEmpty: false }, (cell) => {
|
|
446
|
+
if (cell.value === null || cell.value === undefined)
|
|
447
|
+
return;
|
|
448
|
+
let cellStr;
|
|
449
|
+
if (typeof cell.value === 'object') {
|
|
450
|
+
if ('result' in cell.value) {
|
|
451
|
+
cellStr = String(cell.value.result ?? '');
|
|
452
|
+
}
|
|
453
|
+
else if ('richText' in cell.value) {
|
|
454
|
+
cellStr = cell.value.richText.map((rt) => rt.text).join('');
|
|
455
|
+
}
|
|
456
|
+
else if ('text' in cell.value) {
|
|
457
|
+
cellStr = String(cell.value.text);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
cellStr = String(cell.value);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
cellStr = String(cell.value);
|
|
465
|
+
}
|
|
466
|
+
if (cellStr.trim()) {
|
|
467
|
+
rowValues.push(cellStr);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
// Join all cell values with space for cross-column matching
|
|
471
|
+
const rowText = rowValues.join(' ');
|
|
472
|
+
const textToSearch = ignoreCase ? rowText.toLowerCase() : rowText;
|
|
473
|
+
const matchIndex = textToSearch.indexOf(searchTerm);
|
|
474
|
+
if (matchIndex !== -1) {
|
|
475
|
+
const matchContext = this.getMatchContext(rowText, matchIndex, searchTerm.length);
|
|
476
|
+
sink.onMatch({
|
|
477
|
+
file: `${filePath}:${sheetName}!Row${rowNumber}`,
|
|
478
|
+
line: rowNumber,
|
|
479
|
+
match: matchContext,
|
|
480
|
+
type: 'content'
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
// Skip files that can't be read (permission issues, corrupted, etc.); the answer says so
|
|
488
|
+
sink.couldNotRead('file', filePath, error);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Whether the Excel/DOCX search walks into a folder: never node_modules, and a
|
|
495
|
+
* hidden one (a name starting with '.') only with includeHidden, as ripgrep
|
|
496
|
+
* enters hidden folders only with --hidden.
|
|
497
|
+
*/
|
|
498
|
+
officeWalkEnters(name, includeHidden) {
|
|
499
|
+
return name !== 'node_modules' && (includeHidden || !name.startsWith('.'));
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Find all Excel files in a directory recursively. Stops walking once the
|
|
503
|
+
* search is stopped; a folder it can't list is reported to the sink.
|
|
504
|
+
*/
|
|
505
|
+
async findExcelFiles(rootPath, sink, includeHidden = false) {
|
|
506
|
+
const excelFiles = [];
|
|
507
|
+
const enters = (name) => this.officeWalkEnters(name, includeHidden);
|
|
508
|
+
async function walk(dir) {
|
|
509
|
+
if (sink.isStopped())
|
|
510
|
+
return;
|
|
511
|
+
try {
|
|
512
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
513
|
+
for (const entry of entries) {
|
|
514
|
+
const fullPath = path.join(dir, entry.name);
|
|
515
|
+
if (entry.isDirectory()) {
|
|
516
|
+
if (enters(entry.name)) {
|
|
517
|
+
await walk(fullPath);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
else if (entry.isFile() && isExcelFile(entry.name)) {
|
|
521
|
+
excelFiles.push(fullPath);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
// Skip directories we can't read; the answer says so
|
|
527
|
+
sink.couldNotRead('folder', dir, error);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// Check if rootPath is a file or directory
|
|
531
|
+
try {
|
|
532
|
+
const stats = await fs.stat(rootPath);
|
|
533
|
+
if (stats.isFile() && isExcelFile(rootPath)) {
|
|
534
|
+
return [rootPath];
|
|
535
|
+
}
|
|
536
|
+
else if (stats.isDirectory()) {
|
|
537
|
+
await walk(rootPath);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
// Path doesn't exist or can't be accessed
|
|
542
|
+
}
|
|
543
|
+
return excelFiles;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* The Excel/DOCX files a filePattern selects, as ripgrep's globs select text
|
|
547
|
+
* files (ripgrepGlobMatcher), ignoring case: one of its alternatives matches
|
|
548
|
+
* the file (any file, when all of them are "!" alternatives), and none of its
|
|
549
|
+
* "!" alternatives leaves it out (see officeFileExcluded). A search path that
|
|
550
|
+
* is itself a file is searched whatever the pattern says, as ripgrep searches
|
|
551
|
+
* a file it is given.
|
|
552
|
+
*/
|
|
553
|
+
filterOfficeFiles(files, filePattern, rootPath) {
|
|
554
|
+
const patterns = filePatternAlternatives(filePattern);
|
|
555
|
+
const includes = patterns.filter(pat => !pat.startsWith('!')).map(pat => ripgrepGlobMatcher(pat, true));
|
|
556
|
+
const excludes = patterns.filter(pat => pat.startsWith('!')).map(pat => pat.slice(1));
|
|
557
|
+
return files.filter(filePath => {
|
|
558
|
+
if (filePath === rootPath)
|
|
559
|
+
return true;
|
|
560
|
+
const relativePath = pathBelow(rootPath, filePath);
|
|
561
|
+
return (includes.length === 0 || includes.some(matches => matches(relativePath))) &&
|
|
562
|
+
!excludes.some(pat => this.officeFileExcluded(relativePath, pat));
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Whether a "!" alternative of a filePattern (given without its "!") leaves an
|
|
567
|
+
* Excel/DOCX file out - as it leaves text files out of ripgrep's search: when
|
|
568
|
+
* it matches the file, or a directory between the search path and the file;
|
|
569
|
+
* ending in '/', only directories.
|
|
570
|
+
*/
|
|
571
|
+
officeFileExcluded(relativePath, exclusion) {
|
|
572
|
+
const dirOnly = exclusion.endsWith('/');
|
|
573
|
+
const matches = ripgrepGlobMatcher(exclusion.replace(/\/+$/, ''), true);
|
|
574
|
+
const parts = relativePath.split('/');
|
|
575
|
+
// The directories between the search path and the file, then the file itself
|
|
576
|
+
const candidates = parts.map((_, i) => parts.slice(0, i + 1).join('/')).slice(0, dirOnly ? -1 : undefined);
|
|
577
|
+
return candidates.some(candidate => matches(candidate));
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Whether a content search runs the Excel or DOCX search, the one for files
|
|
581
|
+
* with these extensions. Only when contextually relevant: the rootPath is
|
|
582
|
+
* such a file, or a filePattern alternative targets them ("*.xlsx",
|
|
583
|
+
* "budget.xlsx") - which one that leaves files out ("!*.xlsx") does not.
|
|
584
|
+
* Each alternative is checked on its own, so "report.xlsx|memo.docx" runs both.
|
|
585
|
+
*/
|
|
586
|
+
targetsOfficeFiles(extensions, filePattern, rootPath) {
|
|
587
|
+
const lowerRootPath = rootPath.toLowerCase();
|
|
588
|
+
const targetingPatterns = filePatternAlternatives(filePattern)
|
|
589
|
+
.filter(pattern => !pattern.startsWith('!'))
|
|
590
|
+
.map(pattern => pattern.toLowerCase());
|
|
591
|
+
return extensions.some(ext => lowerRootPath.endsWith(ext) ||
|
|
592
|
+
targetingPatterns.some(pattern => pattern.includes(`*${ext}`) || pattern.endsWith(ext)));
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Search DOCX files for content matches
|
|
596
|
+
* Extracts <w:t> text from document.xml and searches it
|
|
597
|
+
*/
|
|
598
|
+
async searchDocxFiles(rootPath, pattern, ignoreCase, sink, filePattern, includeHidden = false) {
|
|
599
|
+
// Office file search always uses literal matching to prevent ReDoS.
|
|
600
|
+
// Regex patterns are treated as literal strings — this is intentional.
|
|
601
|
+
const searchTerm = ignoreCase ? pattern.toLowerCase() : pattern;
|
|
602
|
+
let docxFiles = await this.findDocxFiles(rootPath, sink, includeHidden);
|
|
603
|
+
if (filePattern) {
|
|
604
|
+
docxFiles = this.filterOfficeFiles(docxFiles, filePattern, rootPath);
|
|
605
|
+
}
|
|
606
|
+
if (docxFiles.length === 0)
|
|
607
|
+
return;
|
|
608
|
+
// PizZip, to open the DOCX files: loaded here if the server hasn't yet
|
|
609
|
+
const PizZip = pizzipPackage.load();
|
|
610
|
+
for (const filePath of docxFiles) {
|
|
611
|
+
if (sink.isStopped())
|
|
612
|
+
break;
|
|
613
|
+
try {
|
|
614
|
+
const buf = await fs.readFile(filePath);
|
|
615
|
+
const zip = new PizZip(buf);
|
|
616
|
+
// Search all XML parts that can contain text
|
|
617
|
+
const xmlParts = ['word/document.xml', 'word/header1.xml', 'word/header2.xml',
|
|
618
|
+
'word/header3.xml', 'word/footer1.xml', 'word/footer2.xml', 'word/footer3.xml'];
|
|
619
|
+
for (const xmlPath of xmlParts) {
|
|
620
|
+
if (sink.isStopped())
|
|
621
|
+
break;
|
|
622
|
+
const file = zip.file(xmlPath);
|
|
623
|
+
if (!file)
|
|
624
|
+
continue;
|
|
625
|
+
const xml = file.asText();
|
|
626
|
+
// Extract all <w:t> text with position tracking
|
|
627
|
+
const wtRe = /<w:t(?:\s[^>]*)?>([^<]*)<\/w:t>/g;
|
|
628
|
+
let m;
|
|
629
|
+
let lineNum = 0;
|
|
630
|
+
while ((m = wtRe.exec(xml)) !== null) {
|
|
631
|
+
if (sink.isStopped())
|
|
632
|
+
break;
|
|
633
|
+
const text = m[1];
|
|
634
|
+
if (!text || !text.trim())
|
|
635
|
+
continue;
|
|
636
|
+
lineNum++;
|
|
637
|
+
const textToSearch = ignoreCase ? text.toLowerCase() : text;
|
|
638
|
+
const matchIndex = textToSearch.indexOf(searchTerm);
|
|
639
|
+
if (matchIndex !== -1) {
|
|
640
|
+
const matchContext = this.getMatchContext(text, matchIndex, searchTerm.length);
|
|
641
|
+
const partName = xmlPath === 'word/document.xml' ? '' : `:${xmlPath.replace('word/', '')}`;
|
|
642
|
+
sink.onMatch({
|
|
643
|
+
file: `${filePath}${partName}`,
|
|
644
|
+
line: lineNum,
|
|
645
|
+
match: matchContext,
|
|
646
|
+
type: 'content'
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
// A file that can't be read: the answer says so
|
|
654
|
+
sink.couldNotRead('file', filePath, error);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Find all DOCX files in a directory recursively. Stops walking once the
|
|
661
|
+
* search is stopped; a folder it can't list is reported to the sink.
|
|
662
|
+
*/
|
|
663
|
+
async findDocxFiles(rootPath, sink, includeHidden = false) {
|
|
664
|
+
const docxFiles = [];
|
|
665
|
+
const enters = (name) => this.officeWalkEnters(name, includeHidden);
|
|
666
|
+
const isDocx = (name) => name.toLowerCase().endsWith('.docx');
|
|
667
|
+
async function walk(dir) {
|
|
668
|
+
if (sink.isStopped())
|
|
669
|
+
return;
|
|
670
|
+
try {
|
|
671
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
672
|
+
for (const entry of entries) {
|
|
673
|
+
const fullPath = path.join(dir, entry.name);
|
|
674
|
+
if (entry.isDirectory()) {
|
|
675
|
+
if (enters(entry.name)) {
|
|
676
|
+
await walk(fullPath);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
else if (entry.isFile() && isDocx(entry.name)) {
|
|
680
|
+
docxFiles.push(fullPath);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
sink.couldNotRead('folder', dir, error);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
try {
|
|
689
|
+
const stats = await fs.stat(rootPath);
|
|
690
|
+
if (stats.isFile() && isDocx(rootPath)) {
|
|
691
|
+
return [rootPath];
|
|
692
|
+
}
|
|
693
|
+
else if (stats.isDirectory()) {
|
|
694
|
+
await walk(rootPath);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
catch { /* skip */ }
|
|
698
|
+
return docxFiles;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Extract context around a match for display (show surrounding text)
|
|
702
|
+
*/
|
|
703
|
+
getMatchContext(text, matchStart, matchLength) {
|
|
704
|
+
const contextChars = 50; // chars before and after match
|
|
705
|
+
const start = Math.max(0, matchStart - contextChars);
|
|
706
|
+
const end = Math.min(text.length, matchStart + matchLength + contextChars);
|
|
707
|
+
let context = text.substring(start, end);
|
|
708
|
+
// Add ellipsis if truncated
|
|
709
|
+
if (start > 0)
|
|
710
|
+
context = '...' + context;
|
|
711
|
+
if (end < text.length)
|
|
712
|
+
context = context + '...';
|
|
713
|
+
return context;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Clean up completed sessions older than specified time
|
|
717
|
+
* Called automatically by cleanup interval
|
|
718
|
+
*/
|
|
719
|
+
cleanupSessions(maxAge = 5 * 60 * 1000) {
|
|
720
|
+
const cutoffTime = Date.now() - maxAge;
|
|
721
|
+
for (const [sessionId, session] of this.sessions) {
|
|
722
|
+
if (session.isComplete && session.lastReadTime < cutoffTime) {
|
|
723
|
+
this.sessions.delete(sessionId);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Get total number of active sessions (excluding completed ones)
|
|
729
|
+
*/
|
|
730
|
+
getActiveSessionCount() {
|
|
731
|
+
return Array.from(this.sessions.values()).filter(session => !session.isComplete).length;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* A file search for one exact filename ("package.json"): a lookup of a known
|
|
735
|
+
* file, unlike a content search for that same text
|
|
736
|
+
*/
|
|
737
|
+
isExactFilenameSearch(options) {
|
|
738
|
+
return options.searchType === 'files' && this.isExactFilename(options.pattern, options.literalSearch);
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Detect if pattern looks like an exact filename
|
|
742
|
+
* (has file extension and no glob wildcards; a literal pattern has none)
|
|
743
|
+
*/
|
|
744
|
+
isExactFilename(pattern, literal = false) {
|
|
745
|
+
return /\.[a-zA-Z0-9]+$/.test(pattern) &&
|
|
746
|
+
(literal || !this.isGlobPattern(pattern));
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Detect if pattern contains glob wildcards
|
|
750
|
+
*/
|
|
751
|
+
isGlobPattern(pattern) {
|
|
752
|
+
return pattern.includes('*') ||
|
|
753
|
+
pattern.includes('?') ||
|
|
754
|
+
pattern.includes('[') ||
|
|
755
|
+
pattern.includes('{') ||
|
|
756
|
+
pattern.includes(']') ||
|
|
757
|
+
pattern.includes('}');
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* A file search's filePattern alternatives other than "!", which a file it
|
|
761
|
+
* finds must match one of (see filePatternSelects); undefined when there are none
|
|
762
|
+
*/
|
|
763
|
+
filePatternIncludes(options, root) {
|
|
764
|
+
if (options.searchType !== 'files')
|
|
765
|
+
return undefined;
|
|
766
|
+
const includes = filePatternAlternatives(options.filePattern).filter(pattern => !pattern.startsWith('!'));
|
|
767
|
+
if (includes.length === 0)
|
|
768
|
+
return undefined;
|
|
769
|
+
return { root, matchers: includes.map(glob => ripgrepGlobMatcher(glob, options.ignoreCase !== false)) };
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Whether a file a file search found is one its filePattern selects: ripgrep
|
|
773
|
+
* checked the "!" alternatives (see buildRipgrepArgs), one of the others must
|
|
774
|
+
* match it. ripgrep can't check those itself: with the pattern's glob in the
|
|
775
|
+
* same list, any glob that matches lets a file in.
|
|
776
|
+
*/
|
|
777
|
+
filePatternSelects(session, file) {
|
|
778
|
+
const includes = session.filePatternIncludes;
|
|
779
|
+
if (!includes)
|
|
780
|
+
return true;
|
|
781
|
+
const relativePath = pathBelow(includes.root, file);
|
|
782
|
+
return includes.matchers.some(matches => matches(relativePath));
|
|
783
|
+
}
|
|
784
|
+
buildRipgrepArgs(options) {
|
|
785
|
+
// These arguments are the whole search: the user's ripgrep config file
|
|
786
|
+
// (RIPGREP_CONFIG_PATH) must not add flags such as --hidden, --glob,
|
|
787
|
+
// --max-count or --null that change what is found or the output parsed here
|
|
788
|
+
const args = ['--no-config'];
|
|
789
|
+
if (options.searchType === 'content') {
|
|
790
|
+
// Content search mode
|
|
791
|
+
args.push('--json', '--line-number');
|
|
792
|
+
// Add literal search support for content searches
|
|
793
|
+
if (options.literalSearch) {
|
|
794
|
+
args.push('-F'); // Fixed string matching (literal)
|
|
795
|
+
}
|
|
796
|
+
if (options.contextLines && options.contextLines > 0) {
|
|
797
|
+
args.push('-C', options.contextLines.toString());
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
// File search mode
|
|
802
|
+
args.push('--files');
|
|
803
|
+
}
|
|
804
|
+
// Case-insensitive: content searches use -i flag, file searches use --iglob
|
|
805
|
+
if (options.searchType === 'content' && options.ignoreCase !== false) {
|
|
806
|
+
args.push('-i');
|
|
807
|
+
}
|
|
808
|
+
if (options.includeHidden) {
|
|
809
|
+
args.push('--hidden');
|
|
810
|
+
}
|
|
811
|
+
// maxResults caps the TOTAL number of matches (see collectMatch()), so no
|
|
812
|
+
// file can contribute more than that: -m stops ripgrep there in each file.
|
|
813
|
+
// Without it a folder search keeps a file's whole output in ripgrep's memory
|
|
814
|
+
// until the file is done, however little of it the search can use. (ripgrep
|
|
815
|
+
// still prints a match that falls in the trailing context of its last one;
|
|
816
|
+
// collectRipgrepLine() takes it like any other. --files ignores -m.)
|
|
817
|
+
if (options.maxResults && options.maxResults > 0) {
|
|
818
|
+
args.push('-m', options.maxResults.toString());
|
|
819
|
+
}
|
|
820
|
+
// File pattern filtering (for file type restrictions like *.js, *.d.ts)
|
|
821
|
+
if (options.filePattern && options.searchType === 'content') {
|
|
822
|
+
for (const p of filePatternAlternatives(options.filePattern)) {
|
|
823
|
+
args.push('-g', p);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// Handle the main search pattern
|
|
827
|
+
if (options.searchType === 'files') {
|
|
828
|
+
// For file search: determine how to treat the pattern
|
|
829
|
+
// (--iglob for case-insensitive or --glob for case-sensitive)
|
|
830
|
+
const globFlag = options.ignoreCase !== false ? '--iglob' : '--glob';
|
|
831
|
+
// literalSearch: the pattern is an exact string, glob characters and all
|
|
832
|
+
const name = options.literalSearch ? literalGlob(options.pattern) : options.pattern;
|
|
833
|
+
if (this.isExactFilename(options.pattern, options.literalSearch)) {
|
|
834
|
+
// Exact filename: use appropriate glob flag with the exact pattern
|
|
835
|
+
args.push(globFlag, name);
|
|
836
|
+
}
|
|
837
|
+
else if (!options.literalSearch && this.isGlobPattern(options.pattern)) {
|
|
838
|
+
// Already a glob pattern: use appropriate glob flag as-is
|
|
839
|
+
args.push(globFlag, options.pattern);
|
|
840
|
+
}
|
|
841
|
+
else {
|
|
842
|
+
// Substring/fuzzy search: wrap with wildcards
|
|
843
|
+
args.push(globFlag, `*${name}*`);
|
|
844
|
+
}
|
|
845
|
+
// filePattern narrows the files the pattern finds. Its "!" alternatives
|
|
846
|
+
// come after the pattern's glob, since ripgrep's last matching glob wins;
|
|
847
|
+
// the others are checked on what ripgrep finds (filePatternSelects), and
|
|
848
|
+
// still reach ripgrep as --pre-globs: it parses them (an invalid one is its
|
|
849
|
+
// error, as for any glob) but without --pre never applies them
|
|
850
|
+
for (const p of filePatternAlternatives(options.filePattern)) {
|
|
851
|
+
args.push(...(p.startsWith('!') ? [globFlag, p] : ['--pre-glob', p]));
|
|
852
|
+
}
|
|
853
|
+
// Add the root path for file mode
|
|
854
|
+
args.push(options.rootPath);
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
// Content search: terminate options before the pattern to prevent
|
|
858
|
+
// patterns starting with '-' being interpreted as flags
|
|
859
|
+
args.push('--', options.pattern, options.rootPath);
|
|
860
|
+
}
|
|
861
|
+
return args;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Resolves once ripgrep has started; else logs why it could not start (not
|
|
865
|
+
* found, not executable...) and rejects with the error start_search always
|
|
866
|
+
* reported. 'spawn' or 'error' comes on the next tick, before any I/O, so
|
|
867
|
+
* the caller still sets up its handlers in time.
|
|
868
|
+
*/
|
|
869
|
+
async whenStarted(child) {
|
|
870
|
+
try {
|
|
871
|
+
await once(child, 'spawn');
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
875
|
+
logger.error(`Failed to start ripgrep: ${reason}`);
|
|
876
|
+
throw Object.assign(new Error('Failed to start ripgrep process'), { cause: error });
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
setupProcessHandlers(session) {
|
|
880
|
+
const { process } = session;
|
|
881
|
+
// Decoded as streams: a character cut between two chunks stays whole
|
|
882
|
+
// (decoding each chunk on its own made it U+FFFD, in matches and paths)
|
|
883
|
+
process.stdout?.setEncoding('utf8');
|
|
884
|
+
process.stderr?.setEncoding('utf8');
|
|
885
|
+
process.stdout?.on('data', (data) => {
|
|
886
|
+
session.printedOutput = true;
|
|
887
|
+
this.processOutput(session, data);
|
|
888
|
+
});
|
|
889
|
+
process.stderr?.on('data', (data) => {
|
|
890
|
+
const errorText = data.toString();
|
|
891
|
+
// Store error text for potential user display (bounded: see keepErrorOutput)
|
|
892
|
+
this.keepErrorOutput(session, errorText);
|
|
893
|
+
// Filter meaningful errors, for telemetry; they are part of the text kept above
|
|
894
|
+
const filteredErrors = errorText
|
|
895
|
+
.split('\n')
|
|
896
|
+
.filter(line => {
|
|
897
|
+
const trimmed = line.trim();
|
|
898
|
+
// Skip empty lines and lines with just symbols/numbers/colons
|
|
899
|
+
if (!trimmed || trimmed.match(/^[\)\(\s\d:]*$/))
|
|
900
|
+
return false;
|
|
901
|
+
// Skip all ripgrep system errors that start with "rg:"
|
|
902
|
+
if (trimmed.startsWith('rg:'))
|
|
903
|
+
return false;
|
|
904
|
+
return true;
|
|
905
|
+
});
|
|
906
|
+
if (filteredErrors.length > 0) {
|
|
907
|
+
const meaningfulErrors = filteredErrors.join('\n').trim();
|
|
908
|
+
if (meaningfulErrors) {
|
|
909
|
+
capture('search_session_error', {
|
|
910
|
+
sessionId: session.id,
|
|
911
|
+
error: meaningfulErrors.substring(0, 200)
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
process.on('close', (code) => {
|
|
917
|
+
// Process the last line, if the output did not end with a newline
|
|
918
|
+
this.processOutput(session, '', true);
|
|
919
|
+
// Exit codes: 0 found, 1 found nothing, 2 an error. No code: a signal
|
|
920
|
+
// ended it, ours (time limit, stop_search, maxResults) or not.
|
|
921
|
+
// A content search's ripgrep prints a JSON line for each file it searches
|
|
922
|
+
// and a summary at the end: exiting 2 with nothing printed, it could not
|
|
923
|
+
// search at all (an invalid pattern or glob), rather than meeting files it
|
|
924
|
+
// couldn't read. A file search prints only the names it finds, so for it
|
|
925
|
+
// the error must say so: a glob it could not parse. Its error is the answer,
|
|
926
|
+
// whatever the Excel/DOCX searches find.
|
|
927
|
+
const couldNotSearch = session.options.searchType === 'content' || /^rg: error parsing glob /m.test(session.error ?? '');
|
|
928
|
+
if (code === 2 && !session.printedOutput && session.error?.trim() && couldNotSearch) {
|
|
929
|
+
session.failure = { error: session.error.trim() };
|
|
930
|
+
}
|
|
931
|
+
else if (code === 2) {
|
|
932
|
+
this.noteRipgrepErrors(session);
|
|
933
|
+
}
|
|
934
|
+
else if (code !== 0 && code !== 1 && !session.stoppedRipgrep) {
|
|
935
|
+
const how = code === null ? `signal ${session.process.signalCode}` : `exit code ${code}`;
|
|
936
|
+
session.ripgrepTrouble ?? (session.ripgrepTrouble = how);
|
|
937
|
+
this.noteUnsearched(session, 'ripgrep_ended', how);
|
|
938
|
+
}
|
|
939
|
+
session.exitCode = code;
|
|
940
|
+
this.finishSource(session, 'ripgrep');
|
|
941
|
+
});
|
|
942
|
+
process.on('error', (error) => {
|
|
943
|
+
session.error = `Process error: ${error.message}`;
|
|
944
|
+
session.ripgrepTrouble ?? (session.ripgrepTrouble = session.error);
|
|
945
|
+
this.noteUnsearched(session, 'ripgrep_ended', session.error);
|
|
946
|
+
this.finishSource(session, 'ripgrep');
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* ripgrep searched, and ended with exit code 2: some paths couldn't be
|
|
951
|
+
* searched, each named on stderr ("rg: <path>: <why> (os error N)"). Those it
|
|
952
|
+
* may not read are about permissions; any other error is trouble, which fails
|
|
953
|
+
* a search that found nothing (see outcomeOf).
|
|
954
|
+
*/
|
|
955
|
+
noteRipgrepErrors(session) {
|
|
956
|
+
const errors = (session.error ?? '').split('\n').filter(line => line.startsWith('rg: ')).map(line => line.slice(4).trim());
|
|
957
|
+
for (const error of errors) {
|
|
958
|
+
const osError = Number(/\(os error (\d+)\)$/.exec(error)?.[1]);
|
|
959
|
+
if (PERMISSION_OS_ERRORS.includes(osError)) {
|
|
960
|
+
this.noteUnsearched(session, 'permissions', error);
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
session.ripgrepTrouble ?? (session.ripgrepTrouble = error);
|
|
964
|
+
this.noteUnsearched(session, 'ripgrep_error', error);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (errors.length === 0) {
|
|
968
|
+
session.ripgrepTrouble ?? (session.ripgrepTrouble = 'exit code 2');
|
|
969
|
+
this.noteUnsearched(session, 'ripgrep_ended', 'exit code 2');
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
/** Something the search couldn't search: counted by kind, the first one kept as the example */
|
|
973
|
+
noteUnsearched(session, kind, example) {
|
|
974
|
+
const noted = session.unsearched.get(kind);
|
|
975
|
+
if (noted)
|
|
976
|
+
noted.count++;
|
|
977
|
+
else
|
|
978
|
+
session.unsearched.set(kind, { kind, count: 1, example });
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* The session's one outcome, once every source is done. A failure first:
|
|
982
|
+
* ripgrep couldn't search at all, or had trouble and nothing was found. Then
|
|
983
|
+
* what stopped the search, if anything did; then what couldn't be searched.
|
|
984
|
+
*/
|
|
985
|
+
outcomeOf(session) {
|
|
986
|
+
if (!session.failure && session.ripgrepTrouble && session.totalMatches === 0) {
|
|
987
|
+
const error = session.error?.trim();
|
|
988
|
+
session.failure = error ? { error } : { ripgrepEnded: session.ripgrepTrouble };
|
|
989
|
+
}
|
|
990
|
+
if (session.failure)
|
|
991
|
+
return 'failed';
|
|
992
|
+
if (session.stopReason)
|
|
993
|
+
return session.stopReason;
|
|
994
|
+
return session.unsearched.size > 0 ? 'partial' : 'completed';
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* A source has ended, or was stopped. This is the one place a session
|
|
998
|
+
* completes: when its last source is done, whatever the order they end in.
|
|
999
|
+
*/
|
|
1000
|
+
finishSource(session, source) {
|
|
1001
|
+
session.pendingSources.delete(source);
|
|
1002
|
+
if (session.pendingSources.size > 0 || session.isComplete)
|
|
1003
|
+
return;
|
|
1004
|
+
session.outcome = this.outcomeOf(session);
|
|
1005
|
+
session.isComplete = true;
|
|
1006
|
+
session.endTime = Date.now();
|
|
1007
|
+
clearTimeout(session.timeoutTimer);
|
|
1008
|
+
capture('search_session_completed', {
|
|
1009
|
+
sessionId: session.id,
|
|
1010
|
+
exitCode: session.exitCode,
|
|
1011
|
+
totalResults: session.totalMatches + session.totalContextLines,
|
|
1012
|
+
totalMatches: session.totalMatches,
|
|
1013
|
+
runtime: session.endTime - session.startTime,
|
|
1014
|
+
outcome: session.outcome,
|
|
1015
|
+
wasIncomplete: session.outcome === 'partial' // Some files couldn't be searched
|
|
1016
|
+
});
|
|
1017
|
+
session.markCompleted();
|
|
1018
|
+
// Rely on cleanupSessions(maxAge) only; no per-session timer
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Stop every source still running (stop_search, timeout, dispose); the
|
|
1022
|
+
* session completes once ripgrep has exited.
|
|
1023
|
+
*/
|
|
1024
|
+
stopSources(session) {
|
|
1025
|
+
this.stopOfficeSources(session);
|
|
1026
|
+
this.stopRipgrep(session);
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* The Excel/DOCX searches are done as of now. They can't be interrupted
|
|
1030
|
+
* mid-file: each stops at its next check, and anything it still finds is dropped.
|
|
1031
|
+
*/
|
|
1032
|
+
stopOfficeSources(session) {
|
|
1033
|
+
for (const source of session.pendingSources) {
|
|
1034
|
+
if (source === 'excel' || source === 'docx')
|
|
1035
|
+
this.finishSource(session, source);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
stopRipgrep(session) {
|
|
1039
|
+
if (session.pendingSources.has('ripgrep') && !session.process.killed) {
|
|
1040
|
+
session.stoppedRipgrep = true;
|
|
1041
|
+
session.process.kill('SIGTERM');
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Split ripgrep's output into lines as it arrives. Only the new text is split,
|
|
1046
|
+
* its first piece joined to the line pending from earlier chunks, so a line
|
|
1047
|
+
* costs time linear in its length (splitting the whole pending line again for
|
|
1048
|
+
* every chunk made a long line quadratic). With isFinal, the pending line is
|
|
1049
|
+
* complete as it is.
|
|
1050
|
+
*/
|
|
1051
|
+
processOutput(session, text, isFinal = false) {
|
|
1052
|
+
const pieces = text.split('\n');
|
|
1053
|
+
const lines = [];
|
|
1054
|
+
pieces.forEach((piece, i) => {
|
|
1055
|
+
this.appendToLine(session, piece);
|
|
1056
|
+
if (i < pieces.length - 1 || isFinal) {
|
|
1057
|
+
const line = this.takeLine(session);
|
|
1058
|
+
if (line !== undefined)
|
|
1059
|
+
lines.push(line);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
this.processLines(session, lines);
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Add text to the line being received, unless that makes it longer than
|
|
1066
|
+
* MAX_OUTPUT_LINE_CHARS: then the line is skipped, noted by its file, and
|
|
1067
|
+
* the rest of it dropped as it arrives.
|
|
1068
|
+
*/
|
|
1069
|
+
appendToLine(session, text) {
|
|
1070
|
+
if (session.skippingLine || !text)
|
|
1071
|
+
return;
|
|
1072
|
+
if (session.buffer.length + text.length <= MAX_OUTPUT_LINE_CHARS) {
|
|
1073
|
+
session.buffer += text;
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
const file = this.fileOfOutputLine((session.buffer + text).slice(0, 4096)) ?? '';
|
|
1077
|
+
session.skippedLines.set(file, (session.skippedLines.get(file) ?? 0) + 1);
|
|
1078
|
+
session.skippingLine = true;
|
|
1079
|
+
session.buffer = '';
|
|
1080
|
+
}
|
|
1081
|
+
/** The line being received, now complete; undefined if it was skipped */
|
|
1082
|
+
takeLine(session) {
|
|
1083
|
+
const line = session.buffer;
|
|
1084
|
+
session.buffer = '';
|
|
1085
|
+
if (session.skippingLine) {
|
|
1086
|
+
session.skippingLine = false;
|
|
1087
|
+
return undefined;
|
|
1088
|
+
}
|
|
1089
|
+
return line;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* The file a line of ripgrep's --json output is about, read from the line's
|
|
1093
|
+
* start: {"type":"match","data":{"path":{"text":"..."},... (a path that is
|
|
1094
|
+
* not valid UTF-8 comes as "bytes" and is not read)
|
|
1095
|
+
*/
|
|
1096
|
+
fileOfOutputLine(start) {
|
|
1097
|
+
const quoted = /^\{"type":"\w+","data":\{"path":\{"text":("(?:[^"\\]|\\.)*")/.exec(start)?.[1];
|
|
1098
|
+
try {
|
|
1099
|
+
return quoted ? JSON.parse(quoted) : undefined;
|
|
1100
|
+
}
|
|
1101
|
+
catch {
|
|
1102
|
+
// A malformed escape in the path: the skipped line is then counted without a file name
|
|
1103
|
+
return undefined;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
processLines(session, lines) {
|
|
1107
|
+
for (const line of lines) {
|
|
1108
|
+
if (!line.trim())
|
|
1109
|
+
continue;
|
|
1110
|
+
const parsed = this.parseLine(line, session.options.searchType);
|
|
1111
|
+
if (parsed?.kind === 'match' && parsed.result.type === 'file' && !this.filePatternSelects(session, parsed.result.file)) {
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
if (parsed) {
|
|
1115
|
+
this.collectRipgrepLine(session, parsed);
|
|
1116
|
+
// Early termination for exact filename matches (if enabled)
|
|
1117
|
+
if (parsed.kind === 'match' &&
|
|
1118
|
+
session.options.earlyTermination !== false && // Default to true
|
|
1119
|
+
this.isExactFilenameSearch(session.options)) {
|
|
1120
|
+
const pat = path.normalize(session.options.pattern);
|
|
1121
|
+
const filePath = path.normalize(parsed.result.file);
|
|
1122
|
+
const ignoreCase = session.options.ignoreCase !== false;
|
|
1123
|
+
const ends = ignoreCase
|
|
1124
|
+
? filePath.toLowerCase().endsWith(pat.toLowerCase())
|
|
1125
|
+
: filePath.endsWith(pat);
|
|
1126
|
+
if (ends) {
|
|
1127
|
+
// Found exact match, terminate search early
|
|
1128
|
+
setTimeout(() => this.stopRipgrep(session), 100); // Small delay to allow any remaining results
|
|
1129
|
+
break;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Collect one line of ripgrep output. Once maxResults matches are in, only the
|
|
1137
|
+
* last match's trailing context is still collected - a matching line inside it
|
|
1138
|
+
* is kept as context, like grep -m - and ripgrep is stopped when it ends.
|
|
1139
|
+
*/
|
|
1140
|
+
collectRipgrepLine(session, line) {
|
|
1141
|
+
if (line.kind !== 'match' && line.kind !== 'context') {
|
|
1142
|
+
// A file ended (or the next began): no trailing context is pending any more
|
|
1143
|
+
session.trailingContext = undefined;
|
|
1144
|
+
}
|
|
1145
|
+
else if (!this.atMaxResults(session)) {
|
|
1146
|
+
if (line.kind === 'context') {
|
|
1147
|
+
session.results.push(line.result);
|
|
1148
|
+
session.totalContextLines++;
|
|
1149
|
+
}
|
|
1150
|
+
else {
|
|
1151
|
+
// Note where this match's trailing context ends, in case it is the last match
|
|
1152
|
+
const contextLines = session.options.contextLines ?? 0;
|
|
1153
|
+
session.trailingContext = line.result.type === 'content' && line.result.line !== undefined && contextLines > 0
|
|
1154
|
+
? { file: line.result.file, lastLine: line.result.line + contextLines }
|
|
1155
|
+
: undefined;
|
|
1156
|
+
this.collectMatch(session, line.result);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
else {
|
|
1160
|
+
const trailing = session.trailingContext;
|
|
1161
|
+
const lineNumber = line.result.line;
|
|
1162
|
+
if (trailing && line.result.file === trailing.file && lineNumber !== undefined && lineNumber <= trailing.lastLine) {
|
|
1163
|
+
session.results.push({ ...line.result, context: true });
|
|
1164
|
+
session.totalContextLines++;
|
|
1165
|
+
if (lineNumber === trailing.lastLine) {
|
|
1166
|
+
session.trailingContext = undefined;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
else {
|
|
1170
|
+
// Past the last match's trailing context: everything from here on is dropped
|
|
1171
|
+
session.trailingContext = undefined;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
this.stopAtMaxResults(session);
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* The one place a session counts a match - from ripgrep, Excel or DOCX alike -
|
|
1178
|
+
* so maxResults caps the TOTAL number of matches a search returns.
|
|
1179
|
+
*/
|
|
1180
|
+
collectMatch(session, result) {
|
|
1181
|
+
if (this.atMaxResults(session))
|
|
1182
|
+
return;
|
|
1183
|
+
session.results.push(result);
|
|
1184
|
+
session.totalMatches++;
|
|
1185
|
+
if (this.atMaxResults(session)) {
|
|
1186
|
+
session.stopReason ?? (session.stopReason = 'max_results');
|
|
1187
|
+
this.stopAtMaxResults(session);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
/** Whether options.maxResults matches are in: later ones are dropped */
|
|
1191
|
+
atMaxResults(session) {
|
|
1192
|
+
const { maxResults } = session.options;
|
|
1193
|
+
return !!maxResults && maxResults > 0 && session.totalMatches >= maxResults;
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Once maxResults matches are in, stop the sources that cannot contribute
|
|
1197
|
+
* anything more: the Excel/DOCX searches at once, ripgrep when the last
|
|
1198
|
+
* match's trailing context is complete.
|
|
1199
|
+
*/
|
|
1200
|
+
stopAtMaxResults(session) {
|
|
1201
|
+
if (!this.atMaxResults(session))
|
|
1202
|
+
return;
|
|
1203
|
+
this.stopOfficeSources(session);
|
|
1204
|
+
if (!session.trailingContext) {
|
|
1205
|
+
this.stopRipgrep(session);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
parseLine(line, searchType) {
|
|
1209
|
+
if (searchType === 'content') {
|
|
1210
|
+
// Parse JSON output from content search
|
|
1211
|
+
try {
|
|
1212
|
+
const parsed = JSON.parse(line);
|
|
1213
|
+
if (parsed.type === 'match') {
|
|
1214
|
+
// Handle multiple submatches per line - return first submatch
|
|
1215
|
+
const submatch = parsed.data?.submatches?.[0];
|
|
1216
|
+
return {
|
|
1217
|
+
kind: 'match',
|
|
1218
|
+
result: {
|
|
1219
|
+
file: parsed.data.path.text,
|
|
1220
|
+
line: parsed.data.line_number,
|
|
1221
|
+
match: keptText(submatch?.match?.text || parsed.data.lines.text),
|
|
1222
|
+
type: 'content'
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
if (parsed.type === 'context') {
|
|
1227
|
+
return {
|
|
1228
|
+
kind: 'context',
|
|
1229
|
+
result: {
|
|
1230
|
+
context: true,
|
|
1231
|
+
file: parsed.data.path.text,
|
|
1232
|
+
line: parsed.data.line_number,
|
|
1233
|
+
match: keptText(parsed.data.lines.text.trim()),
|
|
1234
|
+
type: 'content'
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
// begin/end frame each file's lines; summary closes the output
|
|
1239
|
+
if (parsed.type === 'begin' || parsed.type === 'end') {
|
|
1240
|
+
return { kind: parsed.type, file: parsed.data.path.text };
|
|
1241
|
+
}
|
|
1242
|
+
return { kind: 'summary' };
|
|
1243
|
+
}
|
|
1244
|
+
catch (error) {
|
|
1245
|
+
// Skip invalid JSON lines
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
else {
|
|
1250
|
+
// File search - each line is a file path
|
|
1251
|
+
return {
|
|
1252
|
+
kind: 'match',
|
|
1253
|
+
result: {
|
|
1254
|
+
file: line.trim(),
|
|
1255
|
+
type: 'file'
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
// Global search manager instance
|
|
1262
|
+
export const searchManager = new SearchManager();
|