spck 0.3.1
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/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search service - efficient server-side text search
|
|
3
|
+
*
|
|
4
|
+
* Design principles:
|
|
5
|
+
* - Use ripgrep when available for maximum performance
|
|
6
|
+
* - Stream-based processing to handle large files efficiently
|
|
7
|
+
* - Buffer-based searching for performance
|
|
8
|
+
* - Cross-platform compatible
|
|
9
|
+
* - Memory-efficient with configurable limits
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { ErrorCode, createRPCError } from '../types.js';
|
|
14
|
+
import { logSearchRead } from '../utils/logger.js';
|
|
15
|
+
import { isRipgrepAvailable, executeRipgrepStream } from '../utils/ripgrep.js';
|
|
16
|
+
export class SearchService {
|
|
17
|
+
constructor(rootPath = process.cwd(), maxFileSize = 10 * 1024 * 1024, // 10MB default
|
|
18
|
+
chunkSize = 64 * 1024, // 64KB chunks
|
|
19
|
+
ripgrepEnabled = true // Allow force disabling ripgrep
|
|
20
|
+
) {
|
|
21
|
+
this.rootPath = rootPath;
|
|
22
|
+
this.ripgrepAvailable = null;
|
|
23
|
+
this.ripgrepForceDisabled = false;
|
|
24
|
+
this.maxFileSize = maxFileSize;
|
|
25
|
+
this.chunkSize = chunkSize;
|
|
26
|
+
this.ripgrepForceDisabled = !ripgrepEnabled;
|
|
27
|
+
// Check ripgrep availability on initialization (unless force disabled)
|
|
28
|
+
if (ripgrepEnabled) {
|
|
29
|
+
this.checkRipgrepAvailability();
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
this.ripgrepAvailable = false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Check if ripgrep is available (async, caches result)
|
|
37
|
+
*/
|
|
38
|
+
async checkRipgrepAvailability() {
|
|
39
|
+
if (this.ripgrepAvailable === null) {
|
|
40
|
+
this.ripgrepAvailable = await isRipgrepAvailable();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Check if character is a word boundary (space or punctuation)
|
|
45
|
+
*/
|
|
46
|
+
isWordBoundary(char) {
|
|
47
|
+
return /[\W]/.test(char);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Find word boundary going backwards from position
|
|
51
|
+
*/
|
|
52
|
+
findWordBoundaryBackward(text, pos) {
|
|
53
|
+
if (pos <= 0)
|
|
54
|
+
return 0;
|
|
55
|
+
// Move back to find a word boundary
|
|
56
|
+
for (let i = pos; i >= 0; i--) {
|
|
57
|
+
if (this.isWordBoundary(text[i])) {
|
|
58
|
+
// Return position after the boundary character
|
|
59
|
+
return i + 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Find word boundary going forward from position
|
|
66
|
+
*/
|
|
67
|
+
findWordBoundaryForward(text, pos) {
|
|
68
|
+
if (pos >= text.length)
|
|
69
|
+
return text.length;
|
|
70
|
+
// Move forward to find a word boundary
|
|
71
|
+
for (let i = pos; i < text.length; i++) {
|
|
72
|
+
if (this.isWordBoundary(text[i])) {
|
|
73
|
+
// Return position of the boundary character
|
|
74
|
+
return i;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return text.length;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Trim line text to show context around match
|
|
81
|
+
* - Trims to word boundaries
|
|
82
|
+
* - Adds "…" prefix/suffix if trimmed
|
|
83
|
+
* - Returns trimmed line and offset
|
|
84
|
+
*/
|
|
85
|
+
trimLineToMatch(lineText, matchStart, matchEnd, maxLength) {
|
|
86
|
+
// Default maxLength if not provided or invalid
|
|
87
|
+
if (!maxLength || maxLength <= 0) {
|
|
88
|
+
maxLength = 500;
|
|
89
|
+
}
|
|
90
|
+
// Ensure valid match positions
|
|
91
|
+
if (matchStart < 0 || matchEnd > lineText.length || matchStart >= matchEnd) {
|
|
92
|
+
return { line: lineText, offset: 0 };
|
|
93
|
+
}
|
|
94
|
+
// If line is short enough, return as-is
|
|
95
|
+
if (lineText.length <= maxLength) {
|
|
96
|
+
return { line: lineText, offset: 0 };
|
|
97
|
+
}
|
|
98
|
+
const matchLength = matchEnd - matchStart;
|
|
99
|
+
// If match itself is longer than maxLength, just show the start of the match
|
|
100
|
+
if (matchLength >= maxLength) {
|
|
101
|
+
const trimmed = lineText.substring(matchStart, matchStart + maxLength);
|
|
102
|
+
return {
|
|
103
|
+
line: trimmed,
|
|
104
|
+
offset: matchStart
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// Calculate initial context on each side
|
|
108
|
+
const contextPerSide = Math.floor((maxLength - matchLength) / 2);
|
|
109
|
+
// Initial positions
|
|
110
|
+
let start = Math.max(0, matchStart - contextPerSide);
|
|
111
|
+
let end = Math.min(lineText.length, matchEnd + contextPerSide);
|
|
112
|
+
// Adjust to word boundaries
|
|
113
|
+
if (start > 0) {
|
|
114
|
+
const wordStart = this.findWordBoundaryBackward(lineText, start);
|
|
115
|
+
// Only use word boundary if it doesn't shrink the context too much
|
|
116
|
+
if (matchStart - wordStart >= contextPerSide * 0.5) {
|
|
117
|
+
start = wordStart;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (end < lineText.length) {
|
|
121
|
+
const wordEnd = this.findWordBoundaryForward(lineText, end);
|
|
122
|
+
// Only use word boundary if it doesn't shrink the context too much
|
|
123
|
+
if (wordEnd - matchEnd >= contextPerSide * 0.5) {
|
|
124
|
+
end = wordEnd;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Try to extend to include more complete words if we have room
|
|
128
|
+
let currentLength = end - start;
|
|
129
|
+
// Extend backward
|
|
130
|
+
while (start > 0 && currentLength < maxLength) {
|
|
131
|
+
const prevBoundary = this.findWordBoundaryBackward(lineText, start - 1);
|
|
132
|
+
// Break if we didn't move (no progress)
|
|
133
|
+
if (prevBoundary >= start)
|
|
134
|
+
break;
|
|
135
|
+
const newLength = end - prevBoundary;
|
|
136
|
+
// Don't extend if it would exceed maxLength
|
|
137
|
+
if (newLength > maxLength)
|
|
138
|
+
break;
|
|
139
|
+
start = prevBoundary;
|
|
140
|
+
currentLength = newLength;
|
|
141
|
+
if (currentLength >= maxLength)
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
// Extend forward
|
|
145
|
+
while (end < lineText.length && currentLength < maxLength) {
|
|
146
|
+
const nextBoundary = this.findWordBoundaryForward(lineText, end + 1);
|
|
147
|
+
// Break if we didn't move (no progress)
|
|
148
|
+
if (nextBoundary <= end)
|
|
149
|
+
break;
|
|
150
|
+
const newLength = nextBoundary - start;
|
|
151
|
+
// Don't extend if it would exceed maxLength
|
|
152
|
+
if (newLength > maxLength)
|
|
153
|
+
break;
|
|
154
|
+
end = nextBoundary;
|
|
155
|
+
currentLength = newLength;
|
|
156
|
+
if (currentLength >= maxLength)
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
// Build result with ellipsis
|
|
160
|
+
let result = lineText.substring(start, end);
|
|
161
|
+
let adjustedOffset = start;
|
|
162
|
+
// Add leading ellipsis if not at start
|
|
163
|
+
if (start > 0) {
|
|
164
|
+
result = '…' + result;
|
|
165
|
+
// Adjust offset to account for the leading ellipsis
|
|
166
|
+
adjustedOffset = start - 1;
|
|
167
|
+
}
|
|
168
|
+
// Add trailing ellipsis if not at end
|
|
169
|
+
if (end < lineText.length) {
|
|
170
|
+
result = result + '…';
|
|
171
|
+
}
|
|
172
|
+
// Final check: ensure result doesn't exceed maxLength
|
|
173
|
+
// This can happen when ellipsis are added
|
|
174
|
+
if (result.length > maxLength) {
|
|
175
|
+
// Trim from the end, preserving the match
|
|
176
|
+
const hasTrailingEllipsis = end < lineText.length;
|
|
177
|
+
// Calculate how much to trim
|
|
178
|
+
const excess = result.length - maxLength;
|
|
179
|
+
// Remove trailing ellipsis temporarily if present
|
|
180
|
+
if (hasTrailingEllipsis) {
|
|
181
|
+
result = result.slice(0, -1);
|
|
182
|
+
}
|
|
183
|
+
// Trim the excess from the end
|
|
184
|
+
result = result.slice(0, result.length - excess);
|
|
185
|
+
// Re-add trailing ellipsis if it was there
|
|
186
|
+
if (hasTrailingEllipsis) {
|
|
187
|
+
result = result + '…';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
line: result,
|
|
192
|
+
offset: adjustedOffset
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Stream search results using ripgrep with glob patterns
|
|
197
|
+
* Falls back to Node.js implementation if ripgrep is not available
|
|
198
|
+
* Sends results in batches of 50 via RPC notifications
|
|
199
|
+
*/
|
|
200
|
+
async findWithStream(params, socket) {
|
|
201
|
+
const { glob, maxLength, maxResults, searchTerm, matchCase, useRegEx, onlyWholeWords } = params;
|
|
202
|
+
const deviceId = socket.data.deviceId;
|
|
203
|
+
// Check ripgrep availability
|
|
204
|
+
await this.checkRipgrepAvailability();
|
|
205
|
+
if (!this.ripgrepAvailable) {
|
|
206
|
+
// Fall back to Node.js implementation
|
|
207
|
+
return await this.findWithStreamNode(params, socket);
|
|
208
|
+
}
|
|
209
|
+
// Build ripgrep arguments
|
|
210
|
+
const args = [
|
|
211
|
+
'--json', // Output as JSON
|
|
212
|
+
'--max-count', maxResults.toString(), // Max matches per file (high limit for streaming)
|
|
213
|
+
'--with-filename', // Include filename in output
|
|
214
|
+
'--line-number', // Include line numbers
|
|
215
|
+
];
|
|
216
|
+
// Case sensitivity
|
|
217
|
+
if (matchCase) {
|
|
218
|
+
args.push('--case-sensitive');
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
args.push('--ignore-case');
|
|
222
|
+
}
|
|
223
|
+
// Regex mode
|
|
224
|
+
if (!useRegEx) {
|
|
225
|
+
args.push('--fixed-strings');
|
|
226
|
+
}
|
|
227
|
+
// Whole words
|
|
228
|
+
if (onlyWholeWords) {
|
|
229
|
+
args.push('--word-regexp');
|
|
230
|
+
}
|
|
231
|
+
// Add glob pattern
|
|
232
|
+
if (glob) {
|
|
233
|
+
args.push('--glob', glob);
|
|
234
|
+
}
|
|
235
|
+
// Add pattern and search directory
|
|
236
|
+
args.push('--', searchTerm, '.');
|
|
237
|
+
try {
|
|
238
|
+
let batch = [];
|
|
239
|
+
let totalResults = 0;
|
|
240
|
+
const searchRoot = this.rootPath;
|
|
241
|
+
// Execute ripgrep with streaming - process results as they arrive
|
|
242
|
+
await executeRipgrepStream(searchRoot, args, {
|
|
243
|
+
timeout: 300000, // 5 minute timeout for large searches
|
|
244
|
+
onLine: (line) => {
|
|
245
|
+
if (totalResults >= maxResults)
|
|
246
|
+
return;
|
|
247
|
+
try {
|
|
248
|
+
const json = JSON.parse(line);
|
|
249
|
+
// Only process match messages
|
|
250
|
+
if (json.type === 'match') {
|
|
251
|
+
const data = json.data;
|
|
252
|
+
const lineText = data.lines?.text || '';
|
|
253
|
+
const submatches = data.submatches || [];
|
|
254
|
+
// Skip if no line text
|
|
255
|
+
if (!lineText) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// Process each submatch
|
|
259
|
+
for (const submatch of submatches) {
|
|
260
|
+
if (totalResults >= maxResults)
|
|
261
|
+
break;
|
|
262
|
+
const matchStart = submatch.start;
|
|
263
|
+
const matchEnd = submatch.end;
|
|
264
|
+
const matchValue = lineText.substring(matchStart, matchEnd);
|
|
265
|
+
// Trim line to show context around match
|
|
266
|
+
const { line: trimmedLine, offset: trimOffset } = this.trimLineToMatch(lineText, matchStart, matchEnd, maxLength);
|
|
267
|
+
// Calculate match positions relative to trimmed line
|
|
268
|
+
const relativeMatchStart = matchStart - trimOffset;
|
|
269
|
+
const relativeMatchEnd = matchEnd - trimOffset;
|
|
270
|
+
// Get file path relative to root
|
|
271
|
+
const relativePath = path.relative(searchRoot, data.path.text);
|
|
272
|
+
batch.push({
|
|
273
|
+
start: {
|
|
274
|
+
row: data.line_number - 1,
|
|
275
|
+
column: matchStart
|
|
276
|
+
},
|
|
277
|
+
end: {
|
|
278
|
+
row: data.line_number - 1,
|
|
279
|
+
column: matchEnd
|
|
280
|
+
},
|
|
281
|
+
range: {
|
|
282
|
+
start: data.absolute_offset + matchStart,
|
|
283
|
+
end: data.absolute_offset + matchEnd
|
|
284
|
+
},
|
|
285
|
+
line: trimmedLine,
|
|
286
|
+
value: matchValue,
|
|
287
|
+
match: {
|
|
288
|
+
start: relativeMatchStart,
|
|
289
|
+
end: relativeMatchEnd
|
|
290
|
+
},
|
|
291
|
+
path: relativePath
|
|
292
|
+
});
|
|
293
|
+
totalResults++;
|
|
294
|
+
if (batch.length >= 25) {
|
|
295
|
+
socket.emit('rpc', {
|
|
296
|
+
jsonrpc: '2.0',
|
|
297
|
+
method: 'search.results',
|
|
298
|
+
params: {
|
|
299
|
+
results: batch,
|
|
300
|
+
done: false
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
batch = [];
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
// Skip malformed JSON lines
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
// Send remaining results if any
|
|
314
|
+
if (batch.length > 0) {
|
|
315
|
+
socket.emit('rpc', {
|
|
316
|
+
jsonrpc: '2.0',
|
|
317
|
+
method: 'search.results',
|
|
318
|
+
params: {
|
|
319
|
+
results: batch,
|
|
320
|
+
done: false
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
// Send completion notification
|
|
325
|
+
socket.emit('rpc', {
|
|
326
|
+
jsonrpc: '2.0',
|
|
327
|
+
method: 'search.results',
|
|
328
|
+
params: {
|
|
329
|
+
results: [],
|
|
330
|
+
done: true,
|
|
331
|
+
total: totalResults
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
logSearchRead('findWithStream', params, deviceId, true, undefined, {
|
|
335
|
+
matches: totalResults,
|
|
336
|
+
method: 'ripgrep-stream',
|
|
337
|
+
glob
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
logSearchRead('findWithStream', params, deviceId, false, error, {
|
|
342
|
+
method: 'ripgrep-stream',
|
|
343
|
+
glob
|
|
344
|
+
});
|
|
345
|
+
// Send error notification
|
|
346
|
+
socket.emit('rpc', {
|
|
347
|
+
jsonrpc: '2.0',
|
|
348
|
+
method: 'search.error',
|
|
349
|
+
params: {
|
|
350
|
+
error: error.message
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Stream search results using Node.js implementation
|
|
358
|
+
* Used as fallback when ripgrep is not available
|
|
359
|
+
*/
|
|
360
|
+
async findWithStreamNode(params, socket) {
|
|
361
|
+
const { glob, maxResults, searchTerm, matchCase, useRegEx, onlyWholeWords } = params;
|
|
362
|
+
const deviceId = socket.data.deviceId;
|
|
363
|
+
try {
|
|
364
|
+
// Get all files in the directory
|
|
365
|
+
const files = await this.getAllFiles(this.rootPath, glob);
|
|
366
|
+
let batch = [];
|
|
367
|
+
let totalResults = 0;
|
|
368
|
+
// Search through each file
|
|
369
|
+
for (const file of files) {
|
|
370
|
+
if (totalResults >= maxResults)
|
|
371
|
+
break;
|
|
372
|
+
try {
|
|
373
|
+
const relativePath = path.relative(this.rootPath, file);
|
|
374
|
+
// Check file size
|
|
375
|
+
const stats = await fs.promises.stat(file);
|
|
376
|
+
if (stats.size === 0 || stats.size > this.maxFileSize || stats.isDirectory()) {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
// Build regex pattern
|
|
380
|
+
const regex = this.buildRegExp(searchTerm, useRegEx, matchCase, onlyWholeWords);
|
|
381
|
+
// Search the file
|
|
382
|
+
let results = null;
|
|
383
|
+
if (stats.size < this.chunkSize * 2) {
|
|
384
|
+
results = await this.searchSmallFile(file, relativePath, regex, 100, 10000);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
results = await this.searchLargeFile(file, relativePath, regex, 100, 10000);
|
|
388
|
+
}
|
|
389
|
+
if (results && results.length > 0) {
|
|
390
|
+
for (const result of results) {
|
|
391
|
+
if (totalResults >= maxResults)
|
|
392
|
+
break;
|
|
393
|
+
batch.push(result);
|
|
394
|
+
totalResults++;
|
|
395
|
+
// Send batch when we reach 20 results
|
|
396
|
+
if (batch.length >= 20) {
|
|
397
|
+
socket.emit('rpc', {
|
|
398
|
+
jsonrpc: '2.0',
|
|
399
|
+
method: 'search.results',
|
|
400
|
+
params: { results: batch, done: false }
|
|
401
|
+
});
|
|
402
|
+
batch = [];
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
// Skip files that can't be read
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Send final batch if any
|
|
413
|
+
if (batch.length > 0) {
|
|
414
|
+
socket.emit('rpc', {
|
|
415
|
+
jsonrpc: '2.0',
|
|
416
|
+
method: 'search.results',
|
|
417
|
+
params: { results: batch, done: false }
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
// Send completion notification
|
|
421
|
+
socket.emit('rpc', {
|
|
422
|
+
jsonrpc: '2.0',
|
|
423
|
+
method: 'search.results',
|
|
424
|
+
params: { results: [], done: true, total: totalResults }
|
|
425
|
+
});
|
|
426
|
+
logSearchRead('findWithStream', params, deviceId, true, null, {
|
|
427
|
+
matches: totalResults,
|
|
428
|
+
method: 'node-stream',
|
|
429
|
+
glob
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
logSearchRead('findWithStream', params, deviceId, false, error, {
|
|
434
|
+
method: 'node-stream',
|
|
435
|
+
glob
|
|
436
|
+
});
|
|
437
|
+
socket.emit('rpc', {
|
|
438
|
+
jsonrpc: '2.0',
|
|
439
|
+
method: 'search.error',
|
|
440
|
+
params: {
|
|
441
|
+
error: error.message
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Get all files in a directory matching optional glob pattern
|
|
449
|
+
*/
|
|
450
|
+
async getAllFiles(dir, globPattern) {
|
|
451
|
+
const files = [];
|
|
452
|
+
const ignoreSet = new Set(['.git', '.spck-editor', '.DS_Store', 'node_modules', 'dist', 'build']);
|
|
453
|
+
const walk = async (currentDir) => {
|
|
454
|
+
const entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
|
|
455
|
+
for (const entry of entries) {
|
|
456
|
+
if (ignoreSet.has(entry.name))
|
|
457
|
+
continue;
|
|
458
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
459
|
+
if (entry.isDirectory()) {
|
|
460
|
+
await walk(fullPath);
|
|
461
|
+
}
|
|
462
|
+
else if (entry.isFile()) {
|
|
463
|
+
// Basic glob matching (simple pattern support)
|
|
464
|
+
if (!globPattern || this.matchGlob(fullPath, dir, globPattern)) {
|
|
465
|
+
files.push(fullPath);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
await walk(dir);
|
|
471
|
+
return files;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Simple glob pattern matching
|
|
475
|
+
*/
|
|
476
|
+
matchGlob(filePath, baseDir, pattern) {
|
|
477
|
+
const relativePath = path.relative(baseDir, filePath);
|
|
478
|
+
// Convert glob pattern to regex
|
|
479
|
+
let regexPattern = pattern
|
|
480
|
+
.replace(/\./g, '\\.')
|
|
481
|
+
.replace(/\*\*/g, '.*')
|
|
482
|
+
.replace(/\*/g, '[^/]*')
|
|
483
|
+
.replace(/\?/g, '.');
|
|
484
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
485
|
+
return regex.test(relativePath);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Handle search RPC methods
|
|
489
|
+
*/
|
|
490
|
+
async handle(method, params, socket) {
|
|
491
|
+
switch (method) {
|
|
492
|
+
case 'findWithStream':
|
|
493
|
+
return await this.findWithStream(params, socket);
|
|
494
|
+
default:
|
|
495
|
+
throw createRPCError(ErrorCode.METHOD_NOT_FOUND, `Method not found: search.${method}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Build regular expression from search parameters
|
|
500
|
+
*/
|
|
501
|
+
buildRegExp(pattern, useRegEx, matchCase, onlyWholeWords) {
|
|
502
|
+
// Escape special regex characters if not using regex mode
|
|
503
|
+
if (!useRegEx) {
|
|
504
|
+
pattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
505
|
+
}
|
|
506
|
+
// Add word boundary markers if matching whole words
|
|
507
|
+
if (onlyWholeWords) {
|
|
508
|
+
pattern = '\\b' + pattern + '\\b';
|
|
509
|
+
}
|
|
510
|
+
const flags = matchCase ? 'gm' : 'igm';
|
|
511
|
+
return new RegExp(pattern, flags);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Search small files by reading entirely into memory
|
|
515
|
+
*/
|
|
516
|
+
async searchSmallFile(absolutePath, relativePath, regex, maxMatchPerFile, maxLength) {
|
|
517
|
+
let text;
|
|
518
|
+
try {
|
|
519
|
+
text = await fs.promises.readFile(absolutePath, 'utf8');
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
// If not valid UTF-8, skip this file
|
|
523
|
+
if (error.message?.includes('invalid') || error.message?.includes('encoding')) {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
// Log full error server-side
|
|
527
|
+
console.error('Failed to read file for search:', {
|
|
528
|
+
path: absolutePath,
|
|
529
|
+
error: error.message,
|
|
530
|
+
});
|
|
531
|
+
// Send sanitized error to client
|
|
532
|
+
throw createRPCError(ErrorCode.INTERNAL_ERROR, 'Failed to read file');
|
|
533
|
+
}
|
|
534
|
+
const results = [];
|
|
535
|
+
let match;
|
|
536
|
+
for (let i = 0; i < maxMatchPerFile; i++) {
|
|
537
|
+
match = regex.exec(text);
|
|
538
|
+
if (!match) {
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
const range = this.getSurroundingLineRange(text, match.index, match[0].length, maxLength);
|
|
542
|
+
results.push({
|
|
543
|
+
start: this.indexToPosition(text, match.index),
|
|
544
|
+
end: this.indexToPosition(text, regex.lastIndex),
|
|
545
|
+
range,
|
|
546
|
+
line: text.slice(range.start, range.end),
|
|
547
|
+
value: match[0],
|
|
548
|
+
match: {
|
|
549
|
+
start: match.index - range.start,
|
|
550
|
+
end: regex.lastIndex - range.start,
|
|
551
|
+
},
|
|
552
|
+
path: relativePath,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
return results.length > 0 ? results : null;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Search large files using streaming approach
|
|
559
|
+
* This handles files that might not fit in memory
|
|
560
|
+
*/
|
|
561
|
+
async searchLargeFile(absolutePath, relativePath, regex, maxMatchPerFile, maxLength) {
|
|
562
|
+
const stream = fs.createReadStream(absolutePath, {
|
|
563
|
+
encoding: 'utf8',
|
|
564
|
+
highWaterMark: this.chunkSize,
|
|
565
|
+
});
|
|
566
|
+
let buffer = '';
|
|
567
|
+
let offset = 0;
|
|
568
|
+
const results = [];
|
|
569
|
+
const overlapSize = Math.max(maxLength * 2, 1024); // Overlap to catch matches at chunk boundaries
|
|
570
|
+
try {
|
|
571
|
+
for await (const chunk of stream) {
|
|
572
|
+
buffer += chunk;
|
|
573
|
+
// Search in current buffer
|
|
574
|
+
regex.lastIndex = 0;
|
|
575
|
+
let match;
|
|
576
|
+
while ((match = regex.exec(buffer)) !== null && results.length < maxMatchPerFile) {
|
|
577
|
+
const absoluteIndex = offset + match.index;
|
|
578
|
+
// Build result
|
|
579
|
+
const range = this.getSurroundingLineRange(buffer, match.index, match[0].length, maxLength);
|
|
580
|
+
const lineText = buffer.slice(range.start, range.end);
|
|
581
|
+
results.push({
|
|
582
|
+
start: this.indexToPosition(buffer.slice(0, offset + buffer.length), absoluteIndex),
|
|
583
|
+
end: this.indexToPosition(buffer.slice(0, offset + buffer.length), absoluteIndex + match[0].length),
|
|
584
|
+
range: {
|
|
585
|
+
start: offset + range.start,
|
|
586
|
+
end: offset + range.end,
|
|
587
|
+
},
|
|
588
|
+
line: lineText,
|
|
589
|
+
value: match[0],
|
|
590
|
+
match: {
|
|
591
|
+
start: match.index - range.start,
|
|
592
|
+
end: regex.lastIndex - range.start,
|
|
593
|
+
},
|
|
594
|
+
path: relativePath,
|
|
595
|
+
});
|
|
596
|
+
if (results.length >= maxMatchPerFile) {
|
|
597
|
+
stream.destroy();
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (results.length >= maxMatchPerFile) {
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
// Keep overlap for next chunk to catch matches at boundaries
|
|
605
|
+
if (buffer.length > overlapSize) {
|
|
606
|
+
offset += buffer.length - overlapSize;
|
|
607
|
+
buffer = buffer.slice(-overlapSize);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
// Handle encoding errors gracefully
|
|
613
|
+
if (error.message?.includes('invalid') || error.message?.includes('encoding')) {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
// Log full error server-side
|
|
617
|
+
console.error('Failed to search file:', {
|
|
618
|
+
path: absolutePath,
|
|
619
|
+
error: error.message,
|
|
620
|
+
});
|
|
621
|
+
// Send sanitized error to client
|
|
622
|
+
throw createRPCError(ErrorCode.INTERNAL_ERROR, 'Failed to search file');
|
|
623
|
+
}
|
|
624
|
+
return results.length > 0 ? results : null;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Get surrounding line range for context
|
|
628
|
+
*/
|
|
629
|
+
getSurroundingLineRange(source, index, matchLength, maxLength) {
|
|
630
|
+
maxLength = maxLength || 10000;
|
|
631
|
+
maxLength = maxLength > matchLength ? maxLength - matchLength : 0;
|
|
632
|
+
const indexEnd = index + matchLength;
|
|
633
|
+
let before = source.slice(Math.max(index - maxLength, 0), index);
|
|
634
|
+
let after = source.slice(indexEnd, indexEnd + maxLength);
|
|
635
|
+
// Trim to line boundaries
|
|
636
|
+
const beforeLines = before.split(/[\n\r]/);
|
|
637
|
+
before = beforeLines[beforeLines.length - 1] || '';
|
|
638
|
+
before = before.replace(/^\s+/, '');
|
|
639
|
+
const afterLines = after.split(/[\n\r]/);
|
|
640
|
+
after = afterLines[0] || '';
|
|
641
|
+
after = after.replace(/\s+$/, '');
|
|
642
|
+
// Truncate if too long
|
|
643
|
+
if (before.length + after.length > maxLength) {
|
|
644
|
+
before = before.slice(Math.max(0, before.length - Math.floor(maxLength / 2)));
|
|
645
|
+
after = after.slice(0, maxLength - before.length);
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
start: index - before.length,
|
|
649
|
+
end: indexEnd + after.length,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Convert string index to row/column position
|
|
654
|
+
*/
|
|
655
|
+
indexToPosition(str, index) {
|
|
656
|
+
const beforeMatch = str.slice(0, index);
|
|
657
|
+
const lines = beforeMatch.split(/\r\n|\r|\n/g);
|
|
658
|
+
return {
|
|
659
|
+
row: lines.length - 1,
|
|
660
|
+
column: lines[lines.length - 1]?.length || 0,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Cleanup resources
|
|
665
|
+
*/
|
|
666
|
+
cleanup() {
|
|
667
|
+
// No persistent resources to clean up
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
//# sourceMappingURL=SearchService.js.map
|