Skip to content

Commit ac36d70

Browse files
authored
Few more fixes for search in files (#232)
* Few more fixes for search
1 parent 3505d21 commit ac36d70

File tree

7 files changed

+95
-26
lines changed

7 files changed

+95
-26
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,7 @@ coverage/
3939
server.log
4040

4141
# Local planning/documentation directories
42-
plans/
42+
plans/
43+
44+
# Test output files
45+
test/test_output/

src/handlers/search-handlers.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export async function handleStartSearch(args: unknown): Promise<ServerResult> {
3030
includeHidden: parsed.data.includeHidden,
3131
contextLines: parsed.data.contextLines,
3232
timeout: parsed.data.timeout_ms,
33+
earlyTermination: parsed.data.earlyTermination,
3334
});
3435

3536
const searchTypeText = parsed.data.searchType === 'content' ? 'content search' : 'file search';
@@ -96,11 +97,13 @@ export async function handleGetMoreSearchResults(args: unknown): Promise<ServerR
9697
parsed.data.length
9798
);
9899

99-
if (results.isError) {
100+
// Only return error if we have no results AND there's an actual error
101+
// Permission errors should not block returning found results
102+
if (results.isError && results.totalResults === 0 && results.error?.trim()) {
100103
return {
101104
content: [{
102105
type: "text",
103-
text: `Search session ${parsed.data.sessionId} encountered an error: ${results.error || 'Unknown error'}`
106+
text: `Search session ${parsed.data.sessionId} encountered an error: ${results.error}`
104107
}],
105108
isError: true,
106109
};

src/search-manager.ts

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface SearchSessionOptions {
3636
includeHidden?: boolean;
3737
contextLines?: number;
3838
timeout?: number;
39+
earlyTermination?: boolean; // Stop search early when exact filename match is found
3940
}
4041

4142
/**
@@ -127,14 +128,16 @@ export interface SearchSessionOptions {
127128
sessionId,
128129
searchType: options.searchType,
129130
hasTimeout: !!timeoutMs,
130-
timeoutMs
131+
timeoutMs,
132+
requestedPath: options.rootPath,
133+
validatedPath: validPath
131134
});
132135

133136
// Wait for first chunk of data or early completion instead of fixed delay
134137
const firstChunk = new Promise<void>(resolve => {
135-
const onData = () => {
136-
session.process.stdout?.off('data', onData);
137-
resolve();
138+
const onData = () => {
139+
session.process.stdout?.off('data', onData);
140+
resolve();
138141
};
139142
session.process.stdout?.once('data', onData);
140143
setTimeout(resolve, 40); // cap at 40ms instead of 50-100ms
@@ -189,27 +192,27 @@ export interface SearchSessionOptions {
189192
totalResults: session.totalMatches + session.totalContextLines,
190193
totalMatches: session.totalMatches, // Actual matches only
191194
isComplete: session.isComplete,
192-
isError: session.isError,
193-
error: session.error,
195+
isError: session.isError && !!session.error?.trim(), // Only error if we have actual errors
196+
error: session.error?.trim() || undefined,
194197
hasMoreResults: false, // Tail always returns what's available
195198
runtime: Date.now() - session.startTime
196199
};
197200
}
198-
201+
199202
// Handle positive offsets (range behavior) - like file reading
200203
const slicedResults = allResults.slice(offset, offset + length);
201204
const hasMoreResults = offset + length < allResults.length || !session.isComplete;
202-
205+
203206
session.lastReadTime = Date.now();
204-
207+
205208
return {
206209
results: slicedResults,
207210
returnedCount: slicedResults.length,
208211
totalResults: session.totalMatches + session.totalContextLines,
209212
totalMatches: session.totalMatches, // Actual matches only
210213
isComplete: session.isComplete,
211-
isError: session.isError,
212-
error: session.error,
214+
isError: session.isError && !!session.error?.trim(), // Only error if we have actual errors
215+
error: session.error?.trim() || undefined,
213216
hasMoreResults,
214217
runtime: Date.now() - session.startTime
215218
};
@@ -387,25 +390,58 @@ export interface SearchSessionOptions {
387390

388391
process.stderr?.on('data', (data: Buffer) => {
389392
const errorText = data.toString();
390-
session.error = (session.error || '') + errorText;
391-
capture('search_session_error', {
392-
sessionId: session.id,
393-
error: errorText.substring(0, 200) // Limit error length for telemetry
394-
});
393+
394+
// Filter meaningful errors
395+
const filteredErrors = errorText
396+
.split('\n')
397+
.filter(line => {
398+
const trimmed = line.trim();
399+
400+
// Skip empty lines and lines with just symbols/numbers/colons
401+
if (!trimmed || trimmed.match(/^[\)\(\s\d:]*$/)) return false;
402+
403+
// Skip all ripgrep system errors that start with "rg:"
404+
if (trimmed.startsWith('rg:')) return false;
405+
406+
return true;
407+
});
408+
409+
// Only add to session.error if there are actual meaningful errors after filtering
410+
if (filteredErrors.length > 0) {
411+
const meaningfulErrors = filteredErrors.join('\n').trim();
412+
if (meaningfulErrors) {
413+
session.error = (session.error || '') + meaningfulErrors + '\n';
414+
capture('search_session_error', {
415+
sessionId: session.id,
416+
error: meaningfulErrors.substring(0, 200)
417+
});
418+
}
419+
}
395420
});
396421

397422
process.on('close', (code: number) => {
398423
// Process any remaining buffer content
399424
if (session.buffer.trim()) {
400425
this.processBufferedOutput(session, true);
401426
}
402-
427+
403428
session.isComplete = true;
404-
405-
if (code !== 0 && code !== 1) {
406-
// ripgrep returns 1 when no matches found, which is not an error
407-
session.isError = true;
408-
session.error = session.error || `ripgrep exited with code ${code}`;
429+
430+
// Only treat as error if:
431+
// 1. Unexpected exit code (not 0, 1, or 2) AND
432+
// 2. We have meaningful errors after filtering AND
433+
// 3. We found no results at all
434+
if (code !== 0 && code !== 1 && code !== 2) {
435+
// Codes 0=success, 1=no matches, 2=some files couldn't be searched
436+
if (session.error?.trim() && session.totalMatches === 0) {
437+
session.isError = true;
438+
session.error = session.error || `ripgrep exited with code ${code}`;
439+
}
440+
}
441+
442+
// If we have results, don't mark as error even if there were permission issues
443+
if (session.totalMatches > 0) {
444+
session.isError = false;
409445
}
410446

411447
capture('search_session_completed', {
@@ -455,6 +491,27 @@ export interface SearchSessionOptions {
455491
} else {
456492
session.totalMatches++;
457493
}
494+
495+
// Early termination for exact filename matches (if enabled)
496+
if (session.options.earlyTermination !== false && // Default to true
497+
session.options.searchType === 'files' &&
498+
this.isExactFilename(session.options.pattern)) {
499+
const pat = path.normalize(session.options.pattern);
500+
const filePath = path.normalize(result.file);
501+
const ignoreCase = session.options.ignoreCase !== false;
502+
const ends = ignoreCase
503+
? filePath.toLowerCase().endsWith(pat.toLowerCase())
504+
: filePath.endsWith(pat);
505+
if (ends) {
506+
// Found exact match, terminate search early
507+
setTimeout(() => {
508+
if (!session.process.killed) {
509+
session.process.kill('SIGTERM');
510+
}
511+
}, 100); // Small delay to allow any remaining results
512+
break;
513+
}
514+
}
458515
}
459516
}
460517
}

src/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,13 +313,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
313313
- pattern: What to search for (file names OR content text)
314314
- filePattern: Optional filter to limit search to specific file types (e.g., "*.js", "package.json")
315315
- ignoreCase: Case-insensitive search (default: true). Works for both file names and content.
316+
- earlyTermination: Stop search early when exact filename match is found (optional: defaults to true for file searches, false for content searches)
316317
317318
EXAMPLES:
318319
- Find package.json files: searchType="files", pattern="package.json", filePattern="package.json"
319320
- Find all JS files: searchType="files", pattern="*.js" (or use filePattern="*.js")
320321
- Search for "TODO" in code: searchType="content", pattern="TODO", filePattern="*.js|*.ts"
321322
- Case-sensitive file search: searchType="files", pattern="README", ignoreCase=false
322323
- Case-insensitive file search: searchType="files", pattern="readme", ignoreCase=true
324+
- Find exact file, stop after first match: searchType="files", pattern="config.json", earlyTermination=true
325+
- Find all matching files: searchType="files", pattern="test.js", earlyTermination=false
323326
324327
Unlike regular search tools, this starts a background search process and returns
325328
immediately with a session ID. Use get_more_search_results to get results as they

src/tools/filesystem.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,7 @@ export async function searchFiles(rootPath: string, pattern: string): Promise<st
914914
searchType: 'files',
915915
ignoreCase: true,
916916
maxResults: 5000, // Higher limit for compatibility
917+
earlyTermination: true, // Use early termination for better performance
917918
});
918919

919920
const sessionId = result.sessionId;

src/tools/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export const StartSearchArgsSchema = z.object({
108108
includeHidden: z.boolean().optional().default(false),
109109
contextLines: z.number().optional().default(5),
110110
timeout_ms: z.number().optional(), // Match process naming convention
111+
earlyTermination: z.boolean().optional(), // Stop search early when exact filename match is found (default: true for files, false for content)
111112
});
112113

113114
export const GetMoreSearchResultsArgsSchema = z.object({

src/utils/system-info.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,8 @@ MACOS-SPECIFIC NOTES:
606606
- Python 3 might be 'python3' command, not 'python'
607607
- Some GNU tools have different names (e.g., gsed instead of sed)
608608
- System Integrity Protection (SIP) may block certain operations
609-
- Use 'open' command to open files/applications from terminal`;
609+
- Use 'open' command to open files/applications from terminal
610+
- For file search: Use mdfind (Spotlight) for fastest exact filename searches`;
610611
} else {
611612
guidance += `
612613

0 commit comments

Comments
 (0)