feat(core): Download ripgrep at runtime, if enabled. (#7818)

This commit is contained in:
joshualitt
2025-09-08 14:44:56 -07:00
committed by GitHub
parent 097b5c734f
commit f0bbfe5f0a
17 changed files with 408 additions and 123 deletions

132
package-lock.json generated
View File

@@ -11,7 +11,6 @@
"packages/*"
],
"dependencies": {
"@lvce-editor/ripgrep": "^1.6.0",
"simple-git": "^3.28.0"
},
"bin": {
@@ -1684,6 +1683,28 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@joshua.litt/get-ripgrep": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@joshua.litt/get-ripgrep/-/get-ripgrep-0.0.2.tgz",
"integrity": "sha512-cSHA+H+HEkOXeiCxrNvGj/pgv2Y0bfp4GbH3R87zr7Vob2pDUZV3BkUL9ucHMoDFID4GteSy5z5niN/lF9QeuQ==",
"dependencies": {
"@lvce-editor/verror": "^1.6.0",
"execa": "^9.5.2",
"extract-zip": "^2.0.1",
"fs-extra": "^11.3.0",
"got": "^14.4.5",
"path-exists": "^5.0.0",
"xdg-basedir": "^5.1.0"
}
},
"node_modules/@joshua.litt/get-ripgrep/node_modules/path-exists": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
@@ -1819,32 +1840,6 @@
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
"license": "MIT"
},
"node_modules/@lvce-editor/ripgrep": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@lvce-editor/ripgrep/-/ripgrep-1.6.0.tgz",
"integrity": "sha512-880taWBVULNXmcPHXdxnFUI0FvLErBOjY9OigMXEsLZ2Q1rjcm6LixOkaccKWC8qFMpzm/ldkO7WOMK+ZRfk5Q==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@lvce-editor/verror": "^1.6.0",
"execa": "^9.5.2",
"extract-zip": "^2.0.1",
"fs-extra": "^11.3.0",
"got": "^14.4.5",
"path-exists": "^5.0.0",
"tempy": "^3.1.0",
"xdg-basedir": "^5.1.0"
}
},
"node_modules/@lvce-editor/ripgrep/node_modules/path-exists": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/@lvce-editor/verror": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@lvce-editor/verror/-/verror-1.7.0.tgz",
@@ -6475,33 +6470,6 @@
"node": ">= 8"
}
},
"node_modules/crypto-random-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
"integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==",
"license": "MIT",
"dependencies": {
"type-fest": "^1.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/crypto-random-string/node_modules/type-fest": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -14353,45 +14321,6 @@
"node": ">= 6"
}
},
"node_modules/temp-dir": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz",
"integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==",
"license": "MIT",
"engines": {
"node": ">=14.16"
}
},
"node_modules/tempy": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz",
"integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==",
"license": "MIT",
"dependencies": {
"is-stream": "^3.0.0",
"temp-dir": "^3.0.0",
"type-fest": "^2.12.2",
"unique-string": "^3.0.0"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tempy/node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/terminal-link": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz",
@@ -15027,21 +14956,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unique-string": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz",
"integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==",
"license": "MIT",
"dependencies": {
"crypto-random-string": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
@@ -16572,7 +16486,7 @@
"version": "0.3.4",
"dependencies": {
"@google/genai": "1.16.0",
"@lvce-editor/ripgrep": "^1.6.0",
"@joshua.litt/get-ripgrep": "^0.0.2",
"@modelcontextprotocol/sdk": "^1.11.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",

View File

@@ -91,7 +91,6 @@
"yargs": "^17.7.2"
},
"dependencies": {
"@lvce-editor/ripgrep": "^1.6.0",
"simple-git": "^3.28.0"
},
"optionalDependencies": {

View File

@@ -21,7 +21,7 @@
],
"dependencies": {
"@google/genai": "1.16.0",
"@lvce-editor/ripgrep": "^1.6.0",
"@joshua.litt/get-ripgrep": "^0.0.2",
"@modelcontextprotocol/sdk": "^1.11.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",

View File

@@ -25,6 +25,11 @@ import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js'
import { ShellTool } from '../tools/shell.js';
import { ReadFileTool } from '../tools/read-file.js';
import { GrepTool } from '../tools/grep.js';
import { RipGrepTool, canUseRipgrep } from '../tools/ripGrep.js';
import { logRipgrepFallback } from '../telemetry/loggers.js';
import { RipgrepFallbackEvent } from '../telemetry/types.js';
import { ToolRegistry } from '../tools/tool-registry.js';
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
@@ -56,7 +61,11 @@ vi.mock('../utils/memoryDiscovery.js', () => ({
// Mock individual tools if their constructors are complex or have side effects
vi.mock('../tools/ls');
vi.mock('../tools/read-file');
vi.mock('../tools/grep');
vi.mock('../tools/grep.js');
vi.mock('../tools/ripGrep.js', () => ({
canUseRipgrep: vi.fn(),
RipGrepTool: class MockRipGrepTool {},
}));
vi.mock('../tools/glob');
vi.mock('../tools/edit');
vi.mock('../tools/shell');
@@ -88,6 +97,15 @@ vi.mock('../telemetry/index.js', async (importOriginal) => {
};
});
vi.mock('../telemetry/loggers.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../telemetry/loggers.js')>();
return {
...actual,
logRipgrepFallback: vi.fn(),
};
});
vi.mock('../services/gitService.js', () => {
const GitServiceMock = vi.fn();
GitServiceMock.prototype.initialize = vi.fn();
@@ -666,4 +684,93 @@ describe('setApprovalMode with folder trust', () => {
expect(() => config.setApprovalMode(ApprovalMode.AUTO_EDIT)).not.toThrow();
expect(() => config.setApprovalMode(ApprovalMode.DEFAULT)).not.toThrow();
});
describe('registerCoreTools', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should register RipGrepTool when useRipgrep is true and it is available', async () => {
(canUseRipgrep as Mock).mockResolvedValue(true);
const config = new Config({ ...baseParams, useRipgrep: true });
await config.initialize();
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
const wasRipGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(RipGrepTool),
);
const wasGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(GrepTool),
);
expect(wasRipGrepRegistered).toBe(true);
expect(wasGrepRegistered).toBe(false);
expect(logRipgrepFallback).not.toHaveBeenCalled();
});
it('should register GrepTool as a fallback when useRipgrep is true but it is not available', async () => {
(canUseRipgrep as Mock).mockResolvedValue(false);
const config = new Config({ ...baseParams, useRipgrep: true });
await config.initialize();
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
const wasRipGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(RipGrepTool),
);
const wasGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(GrepTool),
);
expect(wasRipGrepRegistered).toBe(false);
expect(wasGrepRegistered).toBe(true);
expect(logRipgrepFallback).toHaveBeenCalledWith(
config,
expect.any(RipgrepFallbackEvent),
);
const event = (logRipgrepFallback as Mock).mock.calls[0][1];
expect(event.error).toBeUndefined();
});
it('should register GrepTool as a fallback when canUseRipgrep throws an error', async () => {
const error = new Error('ripGrep check failed');
(canUseRipgrep as Mock).mockRejectedValue(error);
const config = new Config({ ...baseParams, useRipgrep: true });
await config.initialize();
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
const wasRipGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(RipGrepTool),
);
const wasGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(GrepTool),
);
expect(wasRipGrepRegistered).toBe(false);
expect(wasGrepRegistered).toBe(true);
expect(logRipgrepFallback).toHaveBeenCalledWith(
config,
expect.any(RipgrepFallbackEvent),
);
const event = (logRipgrepFallback as Mock).mock.calls[0][1];
expect(event.error).toBe(String(error));
});
it('should register GrepTool when useRipgrep is false', async () => {
const config = new Config({ ...baseParams, useRipgrep: false });
await config.initialize();
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
const wasRipGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(RipGrepTool),
);
const wasGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(GrepTool),
);
expect(wasRipGrepRegistered).toBe(false);
expect(wasGrepRegistered).toBe(true);
expect(canUseRipgrep).not.toHaveBeenCalled();
expect(logRipgrepFallback).not.toHaveBeenCalled();
});
});
});

View File

@@ -20,7 +20,7 @@ import { ToolRegistry } from '../tools/tool-registry.js';
import { LSTool } from '../tools/ls.js';
import { ReadFileTool } from '../tools/read-file.js';
import { GrepTool } from '../tools/grep.js';
import { RipGrepTool } from '../tools/ripGrep.js';
import { canUseRipgrep, RipGrepTool } from '../tools/ripGrep.js';
import { GlobTool } from '../tools/glob.js';
import { EditTool } from '../tools/edit.js';
import { SmartEditTool } from '../tools/smart-edit.js';
@@ -50,8 +50,16 @@ import { IdeClient } from '../ide/ide-client.js';
import { ideContext } from '../ide/ideContext.js';
import type { FileSystemService } from '../services/fileSystemService.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { logCliConfiguration, logIdeConnection } from '../telemetry/loggers.js';
import { IdeConnectionEvent, IdeConnectionType } from '../telemetry/types.js';
import {
logCliConfiguration,
logIdeConnection,
logRipgrepFallback,
} from '../telemetry/loggers.js';
import {
IdeConnectionEvent,
IdeConnectionType,
RipgrepFallbackEvent,
} from '../telemetry/types.js';
import type { FallbackModelHandler } from '../fallback/types.js';
// Re-export OAuth config type
@@ -892,7 +900,19 @@ export class Config {
registerCoreTool(ReadFileTool, this);
if (this.getUseRipgrep()) {
registerCoreTool(RipGrepTool, this);
let useRipgrep = false;
let errorString: undefined | string = undefined;
try {
useRipgrep = await canUseRipgrep();
} catch (error: unknown) {
errorString = String(error);
}
if (useRipgrep) {
registerCoreTool(RipGrepTool, this);
} else {
logRipgrepFallback(this, new RipgrepFallbackEvent(errorString));
registerCoreTool(GrepTool, this);
}
} else {
registerCoreTool(GrepTool, this);
}

View File

@@ -52,4 +52,9 @@ describe('Storage additional helpers', () => {
);
expect(Storage.getMcpOAuthTokensPath()).toBe(expected);
});
it('getGlobalBinDir returns ~/.gemini/tmp/bin', () => {
const expected = path.join(os.homedir(), '.gemini', 'tmp', 'bin');
expect(Storage.getGlobalBinDir()).toBe(expected);
});
});

View File

@@ -13,6 +13,7 @@ export const GEMINI_DIR = '.gemini';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
export const OAUTH_FILE = 'oauth_creds.json';
const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin';
export class Storage {
private readonly targetDir: string;
@@ -57,6 +58,10 @@ export class Storage {
return path.join(Storage.getGlobalGeminiDir(), TMP_DIR_NAME);
}
static getGlobalBinDir(): string {
return path.join(Storage.getGlobalTempDir(), BIN_DIR_NAME);
}
getGeminiDir(): string {
return path.join(this.targetDir, GEMINI_DIR);
}

View File

@@ -392,6 +392,24 @@ describe('ClearcutLogger', () => {
});
});
describe('logRipgrepFallbackEvent', () => {
it('logs an event with the proper name', () => {
const { logger } = setup();
// Spy on flushToClearcut to prevent it from clearing the queue
const flushSpy = vi
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.spyOn(logger!, 'flushToClearcut' as any)
.mockResolvedValue({ nextRequestWaitMs: 0 });
logger?.logRipgrepFallbackEvent();
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.RIPGREP_FALLBACK);
expect(flushSpy).toHaveBeenCalledOnce();
});
});
describe('enqueueLogEvent', () => {
it('should add events to the queue', () => {
const { logger } = setup();

View File

@@ -44,6 +44,7 @@ export enum EventNames {
API_ERROR = 'api_error',
END_SESSION = 'end_session',
FLASH_FALLBACK = 'flash_fallback',
RIPGREP_FALLBACK = 'ripgrep_fallback',
LOOP_DETECTED = 'loop_detected',
NEXT_SPEAKER_CHECK = 'next_speaker_check',
SLASH_COMMAND = 'slash_command',
@@ -631,6 +632,13 @@ export class ClearcutLogger {
});
}
logRipgrepFallbackEvent(): void {
this.enqueueLogEvent(this.createLogEvent(EventNames.RIPGREP_FALLBACK, []));
this.flushToClearcut().catch((error) => {
console.debug('Error flushing to Clearcut:', error);
});
}
logLoopDetectedEvent(event: LoopDetectedEvent): void {
const data: EventValue[] = [
{

View File

@@ -13,6 +13,7 @@ export const EVENT_API_ERROR = 'gemini_cli.api_error';
export const EVENT_API_RESPONSE = 'gemini_cli.api_response';
export const EVENT_CLI_CONFIG = 'gemini_cli.config';
export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback';
export const EVENT_RIPGREP_FALLBACK = 'gemini_cli.ripgrep_fallback';
export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check';
export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command';
export const EVENT_IDE_CONNECTION = 'gemini_cli.ide_connection';

View File

@@ -30,6 +30,7 @@ import {
EVENT_FLASH_FALLBACK,
EVENT_MALFORMED_JSON_RESPONSE,
EVENT_FILE_OPERATION,
EVENT_RIPGREP_FALLBACK,
} from './constants.js';
import {
logApiRequest,
@@ -41,6 +42,7 @@ import {
logChatCompression,
logMalformedJsonResponse,
logFileOperation,
logRipgrepFallback,
} from './loggers.js';
import { ToolCallDecision } from './tool-call-decision.js';
import {
@@ -50,6 +52,7 @@ import {
ToolCallEvent,
UserPromptEvent,
FlashFallbackEvent,
RipgrepFallbackEvent,
MalformedJsonResponseEvent,
makeChatCompressionEvent,
FileOperationEvent,
@@ -453,6 +456,59 @@ describe('loggers', () => {
});
});
describe('logRipgrepFallback', () => {
const mockConfig = {
getSessionId: () => 'test-session-id',
getUsageStatisticsEnabled: () => true,
} as unknown as Config;
beforeEach(() => {
vi.spyOn(ClearcutLogger.prototype, 'logRipgrepFallbackEvent');
});
it('should log ripgrep fallback event', () => {
const event = new RipgrepFallbackEvent();
logRipgrepFallback(mockConfig, event);
expect(
ClearcutLogger.prototype.logRipgrepFallbackEvent,
).toHaveBeenCalled();
const emittedEvent = mockLogger.emit.mock.calls[0][0];
expect(emittedEvent.body).toBe('Switching to grep as fallback.');
expect(emittedEvent.attributes).toEqual(
expect.objectContaining({
'session.id': 'test-session-id',
'user.email': 'test-user@example.com',
'event.name': EVENT_RIPGREP_FALLBACK,
error: undefined,
}),
);
});
it('should log ripgrep fallback event with an error', () => {
const event = new RipgrepFallbackEvent('rg not found');
logRipgrepFallback(mockConfig, event);
expect(
ClearcutLogger.prototype.logRipgrepFallbackEvent,
).toHaveBeenCalled();
const emittedEvent = mockLogger.emit.mock.calls[0][0];
expect(emittedEvent.body).toBe('Switching to grep as fallback.');
expect(emittedEvent.attributes).toEqual(
expect.objectContaining({
'session.id': 'test-session-id',
'user.email': 'test-user@example.com',
'event.name': EVENT_RIPGREP_FALLBACK,
error: 'rg not found',
}),
);
});
});
describe('logToolCall', () => {
const cfg1 = {
getSessionId: () => 'test-session-id',

View File

@@ -27,6 +27,7 @@ import {
EVENT_CONTENT_RETRY,
EVENT_CONTENT_RETRY_FAILURE,
EVENT_FILE_OPERATION,
EVENT_RIPGREP_FALLBACK,
} from './constants.js';
import type {
ApiErrorEvent,
@@ -48,6 +49,7 @@ import type {
InvalidChunkEvent,
ContentRetryEvent,
ContentRetryFailureEvent,
RipgrepFallbackEvent,
} from './types.js';
import {
recordApiErrorMetrics,
@@ -268,6 +270,28 @@ export function logFlashFallback(
logger.emit(logRecord);
}
export function logRipgrepFallback(
config: Config,
event: RipgrepFallbackEvent,
): void {
ClearcutLogger.getInstance(config)?.logRipgrepFallbackEvent();
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_RIPGREP_FALLBACK,
'event.timestamp': new Date().toISOString(),
};
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Switching to grep as fallback.`,
attributes,
};
logger.emit(logRecord);
}
export function logApiError(config: Config, event: ApiErrorEvent): void {
const uiEvent = {
...event,

View File

@@ -281,6 +281,16 @@ export class FlashFallbackEvent implements BaseTelemetryEvent {
}
}
export class RipgrepFallbackEvent implements BaseTelemetryEvent {
'event.name': 'ripgrep_fallback';
'event.timestamp': string;
constructor(public error?: string) {
this['event.name'] = 'ripgrep_fallback';
this['event.timestamp'] = new Date().toISOString();
}
}
export enum LoopType {
CONSECUTIVE_IDENTICAL_TOOL_CALLS = 'consecutive_identical_tool_calls',
CHANTING_IDENTICAL_SENTENCES = 'chanting_identical_sentences',

View File

@@ -4,9 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
describe,
it,
expect,
beforeEach,
afterEach,
vi,
type Mock,
} from 'vitest';
import type { RipGrepToolParams } from './ripGrep.js';
import { RipGrepTool } from './ripGrep.js';
import { canUseRipgrep, RipGrepTool } from './ripGrep.js';
import path from 'node:path';
import fs from 'node:fs/promises';
import os, { EOL } from 'node:os';
@@ -14,10 +22,24 @@ import type { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
import { fileExists } from '../utils/fileUtils.js';
// Mock @lvce-editor/ripgrep for testing
vi.mock('@lvce-editor/ripgrep', () => ({
rgPath: '/mock/rg/path',
// Mock dependencies for canUseRipgrep
vi.mock('@joshua.litt/get-ripgrep', () => ({
downloadRipGrep: vi.fn(),
}));
vi.mock('../utils/fileUtils.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/fileUtils.js')>();
return {
...actual,
fileExists: vi.fn(),
};
});
vi.mock('../config/storage.js', () => ({
Storage: {
getGlobalBinDir: vi.fn().mockReturnValue('/mock/bin/dir'),
},
}));
// Mock child_process for ripgrep calls
@@ -27,6 +49,54 @@ vi.mock('child_process', () => ({
const mockSpawn = vi.mocked(spawn);
describe('canUseRipgrep', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return true if ripgrep already exists', async () => {
(fileExists as Mock).mockResolvedValue(true);
const result = await canUseRipgrep();
expect(result).toBe(true);
expect(fileExists).toHaveBeenCalledWith(path.join('/mock/bin/dir', 'rg'));
expect(downloadRipGrep).not.toHaveBeenCalled();
});
it('should download ripgrep and return true if it does not exist initially', async () => {
(fileExists as Mock)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
(downloadRipGrep as Mock).mockResolvedValue(undefined);
const result = await canUseRipgrep();
expect(result).toBe(true);
expect(fileExists).toHaveBeenCalledTimes(2);
expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir');
});
it('should return false if download fails and file does not exist', async () => {
(fileExists as Mock).mockResolvedValue(false);
(downloadRipGrep as Mock).mockResolvedValue(undefined);
const result = await canUseRipgrep();
expect(result).toBe(false);
expect(fileExists).toHaveBeenCalledTimes(2);
expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir');
});
it('should propagate errors from downloadRipGrep', async () => {
const error = new Error('Download failed');
(fileExists as Mock).mockResolvedValue(false);
(downloadRipGrep as Mock).mockRejectedValue(error);
await expect(canUseRipgrep()).rejects.toThrow(error);
expect(fileExists).toHaveBeenCalledTimes(1);
expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir');
});
});
// Helper function to create mock spawn implementations
function createMockSpawn(
options: {

View File

@@ -8,16 +8,34 @@ import fs from 'node:fs';
import path from 'node:path';
import { EOL } from 'node:os';
import { spawn } from 'node:child_process';
import { rgPath } from '@lvce-editor/ripgrep';
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import type { Config } from '../config/config.js';
import { fileExists } from '../utils/fileUtils.js';
import { Storage } from '../config/storage.js';
const DEFAULT_TOTAL_MAX_MATCHES = 20000;
function getRgPath() {
return path.join(Storage.getGlobalBinDir(), 'rg');
}
/**
* Checks if `rg` exists, if not then attempt to download it.
*/
export async function canUseRipgrep(): Promise<boolean> {
if (await fileExists(getRgPath())) {
return true;
}
await downloadRipGrep(Storage.getGlobalBinDir());
return await fileExists(getRgPath());
}
/**
* Parameters for the GrepTool
*/
@@ -293,7 +311,7 @@ class GrepToolInvocation extends BaseToolInvocation<
try {
const output = await new Promise<string>((resolve, reject) => {
const child = spawn(rgPath, rgArgs, {
const child = spawn(getRgPath(), rgArgs, {
windowsHide: true,
});

View File

@@ -28,6 +28,7 @@ import {
processSingleFileContent,
detectBOM,
readFileWithEncoding,
fileExists,
} from './fileUtils.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
@@ -133,6 +134,25 @@ describe('fileUtils', () => {
});
});
describe('fileExists', () => {
it('should return true if the file exists', async () => {
const testFile = path.join(tempRootDir, 'exists.txt');
actualNodeFs.writeFileSync(testFile, 'content');
await expect(fileExists(testFile)).resolves.toBe(true);
});
it('should return false if the file does not exist', async () => {
const testFile = path.join(tempRootDir, 'does-not-exist.txt');
await expect(fileExists(testFile)).resolves.toBe(false);
});
it('should return true for a directory that exists', async () => {
const testDir = path.join(tempRootDir, 'exists-dir');
actualNodeFs.mkdirSync(testDir);
await expect(fileExists(testDir)).resolves.toBe(true);
});
});
describe('isBinaryFile', () => {
let filePathForBinaryTest: string;

View File

@@ -5,6 +5,7 @@
*/
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import type { PartUnion } from '@google/genai';
// eslint-disable-next-line import/no-internal-modules
@@ -467,3 +468,12 @@ export async function processSingleFileContent(
};
}
}
export async function fileExists(filePath: string): Promise<boolean> {
try {
await fsPromises.access(filePath, fs.constants.F_OK);
return true;
} catch (_: unknown) {
return false;
}
}