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.
Files changed (155) hide show
  1. package/.oxlintrc.json +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +631 -0
  4. package/bin/cli.js +20 -0
  5. package/bin/validate-cwd.js +41 -0
  6. package/dist/config/__tests__/config.test.d.ts +2 -0
  7. package/dist/config/__tests__/config.test.js +262 -0
  8. package/dist/config/__tests__/credentials.test.d.ts +2 -0
  9. package/dist/config/__tests__/credentials.test.js +360 -0
  10. package/dist/config/config.d.ts +33 -0
  11. package/dist/config/config.js +185 -0
  12. package/dist/config/credentials.d.ts +75 -0
  13. package/dist/config/credentials.js +259 -0
  14. package/dist/config/server-selection.d.ts +40 -0
  15. package/dist/config/server-selection.js +130 -0
  16. package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
  17. package/dist/connection/__tests__/firebase-auth.test.js +96 -0
  18. package/dist/connection/__tests__/hmac.test.d.ts +2 -0
  19. package/dist/connection/__tests__/hmac.test.js +372 -0
  20. package/dist/connection/auth.d.ts +13 -0
  21. package/dist/connection/auth.js +91 -0
  22. package/dist/connection/firebase-auth.d.ts +40 -0
  23. package/dist/connection/firebase-auth.js +429 -0
  24. package/dist/connection/hmac.d.ts +24 -0
  25. package/dist/connection/hmac.js +109 -0
  26. package/dist/i18n/index.d.ts +25 -0
  27. package/dist/i18n/index.js +101 -0
  28. package/dist/i18n/locales/en.json +313 -0
  29. package/dist/i18n/locales/es.json +302 -0
  30. package/dist/i18n/locales/fr.json +302 -0
  31. package/dist/i18n/locales/id.json +302 -0
  32. package/dist/i18n/locales/ja.json +302 -0
  33. package/dist/i18n/locales/ko.json +302 -0
  34. package/dist/i18n/locales/locales/en.json +309 -0
  35. package/dist/i18n/locales/locales/es.json +302 -0
  36. package/dist/i18n/locales/locales/fr.json +302 -0
  37. package/dist/i18n/locales/locales/id.json +302 -0
  38. package/dist/i18n/locales/locales/ja.json +302 -0
  39. package/dist/i18n/locales/locales/ko.json +302 -0
  40. package/dist/i18n/locales/locales/pt.json +302 -0
  41. package/dist/i18n/locales/locales/zh-Hans.json +302 -0
  42. package/dist/i18n/locales/pt.json +302 -0
  43. package/dist/i18n/locales/zh-Hans.json +302 -0
  44. package/dist/index.d.ts +25 -0
  45. package/dist/index.js +493 -0
  46. package/dist/proxy/ProxyClient.d.ts +125 -0
  47. package/dist/proxy/ProxyClient.js +781 -0
  48. package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
  49. package/dist/proxy/ProxySocketWrapper.js +98 -0
  50. package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
  51. package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
  52. package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
  53. package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
  54. package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
  55. package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
  56. package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
  57. package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
  58. package/dist/proxy/chunking.d.ts +53 -0
  59. package/dist/proxy/chunking.js +127 -0
  60. package/dist/proxy/handshake-validation.d.ts +21 -0
  61. package/dist/proxy/handshake-validation.js +49 -0
  62. package/dist/rpc/__tests__/router.test.d.ts +2 -0
  63. package/dist/rpc/__tests__/router.test.js +262 -0
  64. package/dist/rpc/router.d.ts +37 -0
  65. package/dist/rpc/router.js +132 -0
  66. package/dist/services/BrowserProxyService.d.ts +13 -0
  67. package/dist/services/BrowserProxyService.js +139 -0
  68. package/dist/services/FilesystemService.d.ts +99 -0
  69. package/dist/services/FilesystemService.js +742 -0
  70. package/dist/services/GitService.d.ts +243 -0
  71. package/dist/services/GitService.js +1439 -0
  72. package/dist/services/SearchService.d.ts +93 -0
  73. package/dist/services/SearchService.js +670 -0
  74. package/dist/services/TerminalService.d.ts +62 -0
  75. package/dist/services/TerminalService.js +337 -0
  76. package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
  77. package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
  78. package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
  79. package/dist/services/__tests__/FilesystemService.test.js +609 -0
  80. package/dist/services/__tests__/GitService.test.d.ts +2 -0
  81. package/dist/services/__tests__/GitService.test.js +953 -0
  82. package/dist/services/__tests__/SearchService.test.d.ts +2 -0
  83. package/dist/services/__tests__/SearchService.test.js +384 -0
  84. package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
  85. package/dist/services/__tests__/TerminalService.test.js +513 -0
  86. package/dist/setup/wizard.d.ts +10 -0
  87. package/dist/setup/wizard.js +172 -0
  88. package/dist/types.d.ts +196 -0
  89. package/dist/types.js +44 -0
  90. package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
  91. package/dist/utils/__tests__/gitignore.test.js +127 -0
  92. package/dist/utils/gitignore.d.ts +24 -0
  93. package/dist/utils/gitignore.js +77 -0
  94. package/dist/utils/logger.d.ts +96 -0
  95. package/dist/utils/logger.js +456 -0
  96. package/dist/utils/project-dir.d.ts +51 -0
  97. package/dist/utils/project-dir.js +191 -0
  98. package/dist/utils/ripgrep.d.ts +34 -0
  99. package/dist/utils/ripgrep.js +148 -0
  100. package/dist/utils/tool-detection.d.ts +17 -0
  101. package/dist/utils/tool-detection.js +126 -0
  102. package/dist/watcher/FileWatcher.d.ts +10 -0
  103. package/dist/watcher/FileWatcher.js +42 -0
  104. package/package.json +70 -0
  105. package/src/config/__tests__/config.test.ts +318 -0
  106. package/src/config/__tests__/credentials.test.ts +494 -0
  107. package/src/config/config.ts +206 -0
  108. package/src/config/credentials.ts +302 -0
  109. package/src/config/server-selection.ts +150 -0
  110. package/src/connection/__tests__/firebase-auth.test.ts +121 -0
  111. package/src/connection/__tests__/hmac.test.ts +509 -0
  112. package/src/connection/auth.ts +140 -0
  113. package/src/connection/firebase-auth.ts +504 -0
  114. package/src/connection/hmac.ts +139 -0
  115. package/src/i18n/index.ts +119 -0
  116. package/src/i18n/locales/en.json +313 -0
  117. package/src/i18n/locales/es.json +302 -0
  118. package/src/i18n/locales/fr.json +302 -0
  119. package/src/i18n/locales/id.json +302 -0
  120. package/src/i18n/locales/ja.json +302 -0
  121. package/src/i18n/locales/ko.json +302 -0
  122. package/src/i18n/locales/pt.json +302 -0
  123. package/src/i18n/locales/zh-Hans.json +302 -0
  124. package/src/index.ts +542 -0
  125. package/src/proxy/ProxyClient.ts +968 -0
  126. package/src/proxy/ProxySocketWrapper.ts +113 -0
  127. package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
  128. package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
  129. package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
  130. package/src/proxy/chunking.ts +162 -0
  131. package/src/proxy/handshake-validation.ts +64 -0
  132. package/src/rpc/__tests__/router.test.ts +400 -0
  133. package/src/rpc/router.ts +183 -0
  134. package/src/services/BrowserProxyService.ts +179 -0
  135. package/src/services/FilesystemService.ts +841 -0
  136. package/src/services/GitService.ts +1639 -0
  137. package/src/services/SearchService.ts +809 -0
  138. package/src/services/TerminalService.ts +413 -0
  139. package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
  140. package/src/services/__tests__/FilesystemService.test.ts +1002 -0
  141. package/src/services/__tests__/GitService.test.ts +1552 -0
  142. package/src/services/__tests__/SearchService.test.ts +484 -0
  143. package/src/services/__tests__/TerminalService.test.ts +702 -0
  144. package/src/setup/wizard.ts +242 -0
  145. package/src/types/fossil-delta.d.ts +4 -0
  146. package/src/types.ts +287 -0
  147. package/src/utils/__tests__/gitignore.test.ts +174 -0
  148. package/src/utils/gitignore.ts +91 -0
  149. package/src/utils/logger.ts +578 -0
  150. package/src/utils/project-dir.ts +218 -0
  151. package/src/utils/ripgrep.ts +180 -0
  152. package/src/utils/tool-detection.ts +141 -0
  153. package/src/watcher/FileWatcher.ts +53 -0
  154. package/tsconfig.json +24 -0
  155. 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