This commit is contained in:
koalazf.99
2025-10-21 03:47:00 +08:00
parent 0922437bd5
commit aba5d33630
3 changed files with 178 additions and 34 deletions

View File

@@ -14,6 +14,7 @@ import type { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import type { ChildProcess } from 'node:child_process'; import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
// Mock @lvce-editor/ripgrep for testing // Mock @lvce-editor/ripgrep for testing
vi.mock('@lvce-editor/ripgrep', () => ({ vi.mock('@lvce-editor/ripgrep', () => ({
@@ -75,21 +76,35 @@ function createMockSpawn(
}; };
} }
function createTestConfig(
rootDir: string,
extraDirectories: string[] = [],
): Config {
const fileService = new FileDiscoveryService(rootDir);
return {
getTargetDir: () => rootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(rootDir, extraDirectories),
getDebugMode: () => false,
getFileService: () => fileService,
getFileFilteringOptions: () => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
}),
} as unknown as Config;
}
describe('RipGrepTool', () => { describe('RipGrepTool', () => {
let tempRootDir: string; let tempRootDir: string;
let grepTool: RipGrepTool; let grepTool: RipGrepTool;
let mockConfig: Config;
const abortSignal = new AbortController().signal; const abortSignal = new AbortController().signal;
const mockConfig = {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
getDebugMode: () => false,
} as unknown as Config;
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
mockSpawn.mockClear(); mockSpawn.mockClear();
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-')); tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
mockConfig = createTestConfig(tempRootDir);
grepTool = new RipGrepTool(mockConfig); grepTool = new RipGrepTool(mockConfig);
// Create some test files and directories // Create some test files and directories
@@ -293,6 +308,42 @@ describe('RipGrepTool', () => {
expect(result.returnDisplay).toBe('Found 1 match'); expect(result.returnDisplay).toBe('Found 1 match');
}); });
it('should filter out matches ignored by .qwenignore', async () => {
await fs.writeFile(
path.join(tempRootDir, '.qwenignore'),
'logs/\n',
'utf8',
);
await fs.mkdir(path.join(tempRootDir, 'logs'), { recursive: true });
await fs.writeFile(
path.join(tempRootDir, 'logs', 'ignored.txt'),
'Got it. Thanks for the context!',
'utf8',
);
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `logs/ignored.txt:1:Got it. Thanks for the context!${EOL}`,
exitCode: 0,
}),
);
const params: RipGrepToolParams = {
pattern: 'Got it\\. Thanks for the context!',
};
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.returnDisplay).toBe('No matches found');
expect(result.llmContent).toContain(
'No matches found for pattern "Got it\\. Thanks for the context!" in the workspace directory',
);
const spawnArgs = mockSpawn.mock.calls[0]?.[1] ?? [];
expect(spawnArgs).toContain('--ignore-file');
expect(spawnArgs).toContain(path.join(tempRootDir, '.qwenignore'));
});
it('should return "No matches found" when pattern does not exist', async () => { it('should return "No matches found" when pattern does not exist', async () => {
// Setup specific mock for no matches // Setup specific mock for no matches
mockSpawn.mockImplementationOnce( mockSpawn.mockImplementationOnce(
@@ -452,12 +503,7 @@ describe('RipGrepTool', () => {
); );
// Create a mock config with multiple directories // Create a mock config with multiple directories
const multiDirConfig = { const multiDirConfig = createTestConfig(tempRootDir, [secondDir]);
getTargetDir: () => tempRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
getDebugMode: () => false,
} as unknown as Config;
// Setup specific mock for this test - multi-directory search for 'world' // Setup specific mock for this test - multi-directory search for 'world'
// Mock will be called twice - once for each directory // Mock will be called twice - once for each directory
@@ -557,12 +603,7 @@ describe('RipGrepTool', () => {
); );
// Create a mock config with multiple directories // Create a mock config with multiple directories
const multiDirConfig = { const multiDirConfig = createTestConfig(tempRootDir, [secondDir]);
getTargetDir: () => tempRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
getDebugMode: () => false,
} as unknown as Config;
// Setup specific mock for this test - searching in 'sub' should only return matches from that directory // Setup specific mock for this test - searching in 'sub' should only return matches from that directory
mockSpawn.mockImplementationOnce(() => { mockSpawn.mockImplementationOnce(() => {
@@ -1187,12 +1228,7 @@ describe('RipGrepTool', () => {
it('should indicate searching across all workspace directories when no path specified', () => { it('should indicate searching across all workspace directories when no path specified', () => {
// Create a mock config with multiple directories // Create a mock config with multiple directories
const multiDirConfig = { const multiDirConfig = createTestConfig(tempRootDir, ['/another/dir']);
getTargetDir: () => tempRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, ['/another/dir']),
getDebugMode: () => false,
} as unknown as Config;
const multiDirGrepTool = new RipGrepTool(multiDirConfig); const multiDirGrepTool = new RipGrepTool(multiDirConfig);
const params: RipGrepToolParams = { pattern: 'testPattern' }; const params: RipGrepToolParams = { pattern: 'testPattern' };

View File

@@ -6,7 +6,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { EOL } from 'node:os'; import { EOL, tmpdir } from 'node:os';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import type { ToolInvocation, ToolResult } from './tools.js'; import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
@@ -58,6 +58,8 @@ class GrepToolInvocation extends BaseToolInvocation<
RipGrepToolParams, RipGrepToolParams,
ToolResult ToolResult
> { > {
private readonly tempIgnoreFiles: string[] = [];
constructor( constructor(
private readonly config: Config, private readonly config: Config,
params: RipGrepToolParams, params: RipGrepToolParams,
@@ -109,9 +111,28 @@ class GrepToolInvocation extends BaseToolInvocation<
async execute(signal: AbortSignal): Promise<ToolResult> { async execute(signal: AbortSignal): Promise<ToolResult> {
try { try {
const workspaceContext = this.config.getWorkspaceContext(); const workspaceContext = this.config.getWorkspaceContext();
const fileService = this.config.getFileService();
const fileFilteringOptions = this.config.getFileFilteringOptions();
const qwenIgnoreFiles =
fileFilteringOptions.respectGeminiIgnore === false
? []
: this.getQwenIgnoreFilePaths();
const searchDirAbs = this.resolveAndValidatePath(this.params.path); const searchDirAbs = this.resolveAndValidatePath(this.params.path);
const searchDirDisplay = this.params.path || '.'; const searchDirDisplay = this.params.path || '.';
// if (this.config.getDebugMode()) {
console.log(
`[GrepTool] Using qwenignore files: ${
qwenIgnoreFiles.length > 0
? qwenIgnoreFiles.join(', ')
: 'none (qwenignore disabled or file missing)'
}`,
);
console.log(
`[GrepTool] File filtering: respectGitIgnore=${fileFilteringOptions.respectGitIgnore ?? true}, respectQwenIgnore=${fileFilteringOptions.respectGeminiIgnore ?? true}`,
);
// }
// Determine which directories to search // Determine which directories to search
let searchDirectories: readonly string[]; let searchDirectories: readonly string[];
if (searchDirAbs === null) { if (searchDirAbs === null) {
@@ -125,26 +146,37 @@ class GrepToolInvocation extends BaseToolInvocation<
let allMatches: GrepMatch[] = []; let allMatches: GrepMatch[] = [];
const totalMaxMatches = DEFAULT_TOTAL_MAX_MATCHES; const totalMaxMatches = DEFAULT_TOTAL_MAX_MATCHES;
if (this.config.getDebugMode()) {
console.log(`[GrepTool] Total result limit: ${totalMaxMatches}`);
}
for (const searchDir of searchDirectories) { for (const searchDir of searchDirectories) {
const searchResult = await this.performRipgrepSearch({ const searchResult = await this.performRipgrepSearch({
pattern: this.params.pattern, pattern: this.params.pattern,
path: searchDir, path: searchDir,
include: this.params.include, include: this.params.include,
signal, signal,
ignoreFiles: qwenIgnoreFiles,
}); });
let filteredMatches = searchResult;
if (
fileFilteringOptions.respectGitIgnore ||
fileFilteringOptions.respectGeminiIgnore
) {
filteredMatches = searchResult.filter((match) => {
const absoluteMatchPath = path.resolve(searchDir, match.filePath);
return !fileService.shouldIgnoreFile(
absoluteMatchPath,
fileFilteringOptions,
);
});
}
if (searchDirectories.length > 1) { if (searchDirectories.length > 1) {
const dirName = path.basename(searchDir); const dirName = path.basename(searchDir);
searchResult.forEach((match) => { filteredMatches.forEach((match) => {
match.filePath = path.join(dirName, match.filePath); match.filePath = path.join(dirName, match.filePath);
}); });
} }
allMatches = allMatches.concat(searchResult); allMatches = allMatches.concat(filteredMatches);
if (allMatches.length >= totalMaxMatches) { if (allMatches.length >= totalMaxMatches) {
allMatches = allMatches.slice(0, totalMaxMatches); allMatches = allMatches.slice(0, totalMaxMatches);
@@ -219,6 +251,8 @@ class GrepToolInvocation extends BaseToolInvocation<
llmContent: `Error during grep search operation: ${errorMessage}`, llmContent: `Error during grep search operation: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`, returnDisplay: `Error: ${errorMessage}`,
}; };
} finally {
this.cleanupTemporaryIgnoreFiles();
} }
} }
@@ -265,8 +299,9 @@ class GrepToolInvocation extends BaseToolInvocation<
path: string; path: string;
include?: string; include?: string;
signal: AbortSignal; signal: AbortSignal;
ignoreFiles?: string[];
}): Promise<GrepMatch[]> { }): Promise<GrepMatch[]> {
const { pattern, path: absolutePath, include } = options; const { pattern, path: absolutePath, include, ignoreFiles } = options;
const rgArgs = [ const rgArgs = [
'--line-number', '--line-number',
@@ -281,6 +316,12 @@ class GrepToolInvocation extends BaseToolInvocation<
rgArgs.push('--glob', include); rgArgs.push('--glob', include);
} }
if (ignoreFiles && ignoreFiles.length > 0) {
for (const ignoreFile of ignoreFiles) {
rgArgs.push('--ignore-file', ignoreFile);
}
}
const excludes = [ const excludes = [
'.git', '.git',
'node_modules', 'node_modules',
@@ -389,6 +430,43 @@ class GrepToolInvocation extends BaseToolInvocation<
} }
return description; return description;
} }
private getQwenIgnoreFilePaths(): string[] {
const patterns = this.config.getFileService().getGeminiIgnorePatterns();
if (patterns.length === 0) {
return [];
}
const tempFilePath = path.join(
tmpdir(),
`qwen-ignore-${process.pid}-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}.rgignore`,
);
try {
const fileContents = `${patterns.join(EOL)}${EOL}`;
fs.writeFileSync(tempFilePath, fileContents, 'utf8');
this.tempIgnoreFiles.push(tempFilePath);
return [tempFilePath];
} catch (error: unknown) {
console.warn(
`Failed to create temporary .qwenignore for ripgrep: ${getErrorMessage(error)}`,
);
return [];
}
}
private cleanupTemporaryIgnoreFiles(): void {
for (const filePath of this.tempIgnoreFiles) {
try {
fs.unlinkSync(filePath);
} catch {
// ignore cleanup errors
}
}
this.tempIgnoreFiles.length = 0;
}
} }
/** /**

View File

@@ -52,8 +52,38 @@ export class GitIgnoreParser implements GitIgnoreFilter {
} }
private addPatterns(patterns: string[]) { private addPatterns(patterns: string[]) {
this.ig.add(patterns); const normalizedPatterns = patterns.map((pattern) => {
this.patterns.push(...patterns); if (!pattern) {
return pattern;
}
if (path.isAbsolute(pattern)) {
const relativePattern = path.relative(this.projectRoot, pattern);
if (relativePattern === '' || relativePattern === '.') {
return '/';
}
if (!relativePattern.startsWith('..')) {
let normalized = relativePattern.replace(/\\/g, '/');
if (pattern.endsWith('/') && !normalized.endsWith('/')) {
normalized += '/';
}
if (!normalized.startsWith('/')) {
normalized = `/${normalized}`;
}
return normalized;
}
}
return pattern;
});
this.ig.add(normalizedPatterns);
this.patterns.push(...normalizedPatterns);
} }
isIgnored(filePath: string): boolean { isIgnored(filePath: string): boolean {