From ef46d64ae5a2a8b652bdfcab1059698a8747e655 Mon Sep 17 00:00:00 2001 From: fuyou Date: Fri, 22 Aug 2025 14:10:45 +0800 Subject: [PATCH] Fix(grep): memory overflow in grep search and enhance test coverage (#5911) Co-authored-by: Jacob Richman --- eslint.config.js | 6 +- integration-tests/test-helper.ts | 4 +- package-lock.json | 693 +++++++++- package.json | 1 + packages/cli/src/config/config.test.ts | 40 + packages/cli/src/config/config.ts | 1 + .../cli/src/config/settingsSchema.test.ts | 1 + packages/cli/src/config/settingsSchema.ts | 10 + .../components/messages/DiffRenderer.test.tsx | 21 +- .../ui/components/messages/DiffRenderer.tsx | 3 +- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 32 +- packages/cli/src/ui/utils/MarkdownDisplay.tsx | 3 +- packages/cli/src/zed-integration/acp.ts | 3 +- packages/core/src/config/config.test.ts | 34 + packages/core/src/config/config.ts | 16 +- packages/core/src/index.ts | 1 + packages/core/src/tools/ripGrep.test.ts | 1224 +++++++++++++++++ packages/core/src/tools/ripGrep.ts | 502 +++++++ packages/core/src/tools/shell.test.ts | 3 +- packages/core/src/tools/shell.ts | 4 +- 20 files changed, 2566 insertions(+), 36 deletions(-) create mode 100644 packages/core/src/tools/ripGrep.test.ts create mode 100644 packages/core/src/tools/ripGrep.ts diff --git a/eslint.config.js b/eslint.config.js index b716b9b3..e5ded380 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,7 +30,11 @@ export default tseslint.config( 'node_modules/*', '.integration-tests/**', 'eslint.config.js', - 'packages/**/dist/**', + 'packages/cli/dist/**', + 'packages/core/dist/**', + 'packages/server/dist/**', + 'packages/test-utils/dist/**', + 'packages/vscode-ide-companion/dist/**', 'bundle/**', 'package/bundle/**', '.integration-tests/**', diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index a4d94ba7..6ad90ae0 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -249,7 +249,7 @@ export class TestRig { if (env.GEMINI_SANDBOX === 'podman') { // Remove telemetry JSON objects from output // They are multi-line JSON objects that start with { and contain telemetry fields - const lines = result.split('\n'); + const lines = result.split(EOL); const filteredLines = []; let inTelemetryObject = false; let braceDepth = 0; @@ -510,7 +510,7 @@ export class TestRig { // If no matches found with the simple pattern, try the JSON parsing approach // in case the format changes if (logs.length === 0) { - const lines = stdout.split('\n'); + const lines = stdout.split(EOL); let currentObject = ''; let inObject = false; let braceDepth = 0; diff --git a/package-lock.json b/package-lock.json index 797a80c6..82831917 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "packages/*" ], "dependencies": { + "@lvce-editor/ripgrep": "^2.1.0", "node-fetch": "^3.3.2", "strip-ansi": "^7.1.0" }, @@ -1468,6 +1469,41 @@ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", "license": "MIT" }, + "node_modules/@lvce-editor/ripgrep": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@lvce-editor/ripgrep/-/ripgrep-2.1.0.tgz", + "integrity": "sha512-pZObE9Y4x9Prn1c5cmTqHMv4ezHUaYi1qex9TD7YSXtkmoXUyDpYaDUWg56GBpeMUCprtPAhw9u26n6u8AKJyA==", + "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.7", + "path-exists": "^5.0.0", + "tempy": "^3.1.0", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=22" + } + }, + "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", + "integrity": "sha512-+LGuAEIC2L7pbvkyAQVWM2Go0dAy+UWEui28g07zNtZsCBhm+gusBK8PNwLJLV5Jay+TyUYuwLIbJdjLLzqEBg==", + "license": "MIT" + }, "node_modules/@lydell/node-pty": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", @@ -2578,6 +2614,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", @@ -2598,6 +2640,42 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.2.tgz", + "integrity": "sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2764,6 +2842,12 @@ "integrity": "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==", "license": "MIT" }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -2988,6 +3072,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.35.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", @@ -3922,6 +4016,15 @@ "node": ">=8" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3962,6 +4065,33 @@ "node": ">=8" } }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-12.0.1.tgz", + "integrity": "sha512-Yo9wGIQUaAfIbk+qY0X4cDQgCosecfBe3V9NSyeY4qPC2SAkbCS4Xj79VP8WOzitpJUZKc/wsRCYF5ariDIwkg==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.0.4", + "get-stream": "^9.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.4", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.1", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4512,6 +4642,33 @@ "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/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -4634,6 +4791,33 @@ "dev": true, "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -4697,6 +4881,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -4962,6 +5155,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -5648,6 +5850,44 @@ "node": ">=20.0.0" } }, + "node_modules/execa": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz", + "integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/expect-type": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", @@ -5721,6 +5961,41 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5796,6 +6071,15 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -5958,6 +6242,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data-encoder": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz", + "integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -5994,6 +6287,29 @@ "node": ">= 0.8" } }, + "node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6189,6 +6505,34 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -6418,6 +6762,43 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "14.4.7", + "resolved": "https://registry.npmjs.org/got/-/got-14.4.7.tgz", + "integrity": "sha512-DI8zV1231tqiGzOiOzQWDhsBmncFW7oQDH6Zgy6pDPrqJuVZMtoSgPLLsBZQj8Jg4JFfwoOsDA8NGtLQLnIx2g==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^7.0.1", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^12.0.1", + "decompress-response": "^6.0.0", + "form-data-encoder": "^4.0.2", + "http2-wrapper": "^2.2.1", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^4.0.1", + "responselike": "^3.0.0", + "type-fest": "^4.26.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/got/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6699,6 +7080,12 @@ "entities": "^4.4.0" } }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -6738,6 +7125,19 @@ "node": ">= 14" } }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -6751,6 +7151,15 @@ "node": ">= 14" } }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/hyperdyperid": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", @@ -7554,6 +7963,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -7949,7 +8370,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, "license": "MIT" }, "node_modules/json-parse-better-errors": { @@ -7985,6 +8405,27 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -8026,7 +8467,6 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -8191,6 +8631,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lowlight": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", @@ -8398,6 +8850,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -8659,6 +9123,18 @@ "node": ">=10" } }, + "node_modules/normalize-url": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.2.tgz", + "integrity": "sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npm-run-all": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", @@ -8871,6 +9347,46 @@ "which": "bin/which" } }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nwsapi": { "version": "2.2.20", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", @@ -9102,6 +9618,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/p-cancelable": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", + "integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -9212,6 +9737,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -9358,6 +9895,12 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9508,6 +10051,21 @@ "dev": true, "license": "MIT" }, + "node_modules/pretty-ms": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", + "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9575,6 +10133,16 @@ "url": "https://github.com/sponsors/lupomontero" } }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9642,6 +10210,18 @@ ], "license": "MIT" }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -9972,6 +10552,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -9992,6 +10578,21 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/restore-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", @@ -10860,6 +11461,18 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -10949,6 +11562,45 @@ "dev": true, "license": "MIT" }, + "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": "3.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-3.0.0.tgz", @@ -11510,6 +12162,21 @@ "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", @@ -12424,6 +13091,16 @@ "node": ">=8" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -12437,6 +13114,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors-cjs": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", diff --git a/package.json b/package.json index 6fcda594..92386a60 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "yargs": "^17.7.2" }, "dependencies": { + "@lvce-editor/ripgrep": "^2.1.0", "node-fetch": "^3.3.2", "strip-ansi": "^7.1.0" }, diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index c9426379..917c7446 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1562,6 +1562,46 @@ describe('loadCliConfig chatCompression', () => { }); }); +describe('loadCliConfig useRipgrep', () => { + const originalArgv = process.argv; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + }); + + afterEach(() => { + process.argv = originalArgv; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should be false by default when useRipgrep is not set in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = {}; + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getUseRipgrep()).toBe(false); + }); + + it('should be true when useRipgrep is set to true in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { useRipgrep: true }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getUseRipgrep()).toBe(true); + }); + + it('should be false when useRipgrep is explicitly set to false in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { useRipgrep: false }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getUseRipgrep()).toBe(false); + }); +}); + describe('loadCliConfig tool exclusions', () => { const originalArgv = process.argv; const originalIsTTY = process.stdin.isTTY; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index aaaf293d..498d0255 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -552,6 +552,7 @@ export async function loadCliConfig( folderTrust, interactive, trustedFolder, + useRipgrep: settings.useRipgrep, shouldUseNodePtyShell: settings.shouldUseNodePtyShell, skipNextSpeakerCheck: settings.skipNextSpeakerCheck, enablePromptCompletion: settings.enablePromptCompletion ?? false, diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 43bf52c9..4c247e65 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -52,6 +52,7 @@ describe('SettingsSchema', () => { 'model', 'hasSeenIdeIntegrationNudge', 'folderTrustFeature', + 'useRipgrep', ]; expectedSettings.forEach((setting) => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 5f939b56..d4391cb3 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -534,6 +534,16 @@ export const SETTINGS_SCHEMA = { description: 'Skip the next speaker check.', showInDialog: true, }, + useRipgrep: { + type: 'boolean', + label: 'Use Ripgrep', + category: 'Tools', + requiresRestart: false, + default: false, + description: + 'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.', + showInDialog: true, + }, enablePromptCompletion: { type: 'boolean', label: 'Enable Prompt Completion', diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx index 9629b94b..a3c745cc 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx @@ -9,6 +9,7 @@ import { render } from 'ink-testing-library'; import { DiffRenderer } from './DiffRenderer.js'; import * as CodeColorizer from '../../utils/CodeColorizer.js'; import { vi } from 'vitest'; +import { EOL } from 'os'; describe('', () => { const mockColorizeCode = vi.spyOn(CodeColorizer, 'colorizeCode'); @@ -29,7 +30,7 @@ index 0000000..e69de29 +++ b/test.py @@ -0,0 +1 @@ +print("hello world") -`; +`.replace(/\n/g, EOL); render( @@ -109,7 +110,7 @@ index 0000001..0000002 100644 @@ -1 +1 @@ -old line +new line -`; +`.replace(/\n/g, EOL); const { lastFrame } = render( ', () => { const baseProps = { @@ -54,7 +55,7 @@ describe('', () => { ## Header 2 ### Header 3 #### Header 4 -`; +`.replace(/\n/g, EOL); const { lastFrame } = render( @@ -64,7 +65,10 @@ describe('', () => { }); it('renders a fenced code block with a language', () => { - const text = '```javascript\nconst x = 1;\nconsole.log(x);\n```'; + const text = '```javascript\nconst x = 1;\nconsole.log(x);\n```'.replace( + /\n/g, + EOL, + ); const { lastFrame } = render( @@ -74,7 +78,7 @@ describe('', () => { }); it('renders a fenced code block without a language', () => { - const text = '```\nplain text\n```'; + const text = '```\nplain text\n```'.replace(/\n/g, EOL); const { lastFrame } = render( @@ -84,7 +88,7 @@ describe('', () => { }); it('handles unclosed (pending) code blocks', () => { - const text = '```typescript\nlet y = 2;'; + const text = '```typescript\nlet y = 2;'.replace(/\n/g, EOL); const { lastFrame } = render( @@ -98,7 +102,7 @@ describe('', () => { - item A * item B + item C -`; +`.replace(/\n/g, EOL); const { lastFrame } = render( @@ -112,7 +116,7 @@ describe('', () => { * Level 1 * Level 2 * Level 3 -`; +`.replace(/\n/g, EOL); const { lastFrame } = render( @@ -125,7 +129,7 @@ describe('', () => { const text = ` 1. First item 2. Second item -`; +`.replace(/\n/g, EOL); const { lastFrame } = render( @@ -141,7 +145,7 @@ Hello World *** Test -`; +`.replace(/\n/g, EOL); const { lastFrame } = render( @@ -156,7 +160,7 @@ Test |----------|:--------:| | Cell 1 | Cell 2 | | Cell 3 | Cell 4 | -`; +`.replace(/\n/g, EOL); const { lastFrame } = render( @@ -170,7 +174,7 @@ Test Some text before. | A | B | |---| -| 1 | 2 |`; +| 1 | 2 |`.replace(/\n/g, EOL); const { lastFrame } = render( @@ -182,7 +186,7 @@ Some text before. it('inserts a single space between paragraphs', () => { const text = `Paragraph 1. -Paragraph 2.`; +Paragraph 2.`.replace(/\n/g, EOL); const { lastFrame } = render( @@ -205,7 +209,7 @@ some code \`\`\` Another paragraph. -`; +`.replace(/\n/g, EOL); const { lastFrame } = render( @@ -215,7 +219,7 @@ Another paragraph. }); it('hides line numbers in code blocks when showLineNumbers is false', () => { - const text = '```javascript\nconst x = 1;\n```'; + const text = '```javascript\nconst x = 1;\n```'.replace(/\n/g, EOL); const settings = new LoadedSettings( { path: '', settings: {} }, { path: '', settings: { showLineNumbers: false } }, @@ -234,7 +238,7 @@ Another paragraph. }); it('shows line numbers in code blocks by default', () => { - const text = '```javascript\nconst x = 1;\n```'; + const text = '```javascript\nconst x = 1;\n```'.replace(/\n/g, EOL); const { lastFrame } = render( diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 7568e1f8..660ce2b1 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { Text, Box } from 'ink'; +import { EOL } from 'os'; import { Colors } from '../colors.js'; import { colorizeCode } from './CodeColorizer.js'; import { TableRenderer } from './TableRenderer.js'; @@ -34,7 +35,7 @@ const MarkdownDisplayInternal: React.FC = ({ }) => { if (!text) return <>; - const lines = text.split('\n'); + const lines = text.split(EOL); const headerRegex = /^ *(#{1,4}) +(.*)/; const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\w*?) *$/; const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/; diff --git a/packages/cli/src/zed-integration/acp.ts b/packages/cli/src/zed-integration/acp.ts index eef4e1ee..3ce9bd42 100644 --- a/packages/cli/src/zed-integration/acp.ts +++ b/packages/cli/src/zed-integration/acp.ts @@ -7,6 +7,7 @@ /* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */ import { z } from 'zod'; +import { EOL } from 'os'; import * as schema from './schema.js'; export * from './schema.js'; @@ -172,7 +173,7 @@ class Connection { const decoder = new TextDecoder(); for await (const chunk of output) { content += decoder.decode(chunk, { stream: true }); - const lines = content.split('\n'); + const lines = content.split(EOL); content = lines.pop() || ''; for (const line of lines) { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 91f91729..c1b81c5d 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -594,4 +594,38 @@ describe('Server Config (config.ts)', () => { expect(config.getTelemetryOtlpProtocol()).toBe('grpc'); }); }); + + describe('UseRipgrep Configuration', () => { + it('should default useRipgrep to false when not provided', () => { + const config = new Config(baseParams); + expect(config.getUseRipgrep()).toBe(false); + }); + + it('should set useRipgrep to true when provided as true', () => { + const paramsWithRipgrep: ConfigParameters = { + ...baseParams, + useRipgrep: true, + }; + const config = new Config(paramsWithRipgrep); + expect(config.getUseRipgrep()).toBe(true); + }); + + it('should set useRipgrep to false when explicitly provided as false', () => { + const paramsWithRipgrep: ConfigParameters = { + ...baseParams, + useRipgrep: false, + }; + const config = new Config(paramsWithRipgrep); + expect(config.getUseRipgrep()).toBe(false); + }); + + it('should default useRipgrep to false when undefined', () => { + const paramsWithUndefinedRipgrep: ConfigParameters = { + ...baseParams, + useRipgrep: undefined, + }; + const config = new Config(paramsWithUndefinedRipgrep); + expect(config.getUseRipgrep()).toBe(false); + }); + }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 227a17c0..20287b8d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -16,6 +16,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 { GlobTool } from '../tools/glob.js'; import { EditTool } from '../tools/edit.js'; import { ShellTool } from '../tools/shell.js'; @@ -199,6 +200,7 @@ export interface ConfigParameters { chatCompression?: ChatCompressionSettings; interactive?: boolean; trustedFolder?: boolean; + useRipgrep?: boolean; shouldUseNodePtyShell?: boolean; skipNextSpeakerCheck?: boolean; enablePromptCompletion?: boolean; @@ -267,6 +269,7 @@ export class Config { private readonly chatCompression: ChatCompressionSettings | undefined; private readonly interactive: boolean; private readonly trustedFolder: boolean | undefined; + private readonly useRipgrep: boolean; private readonly shouldUseNodePtyShell: boolean; private readonly skipNextSpeakerCheck: boolean; private readonly enablePromptCompletion: boolean = false; @@ -338,6 +341,7 @@ export class Config { this.chatCompression = params.chatCompression; this.interactive = params.interactive ?? false; this.trustedFolder = params.trustedFolder; + this.useRipgrep = params.useRipgrep ?? false; this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false; this.storage = new Storage(this.targetDir); @@ -727,6 +731,10 @@ export class Config { return this.interactive; } + getUseRipgrep(): boolean { + return this.useRipgrep; + } + getShouldUseNodePtyShell(): boolean { return this.shouldUseNodePtyShell; } @@ -789,7 +797,13 @@ export class Config { registerCoreTool(LSTool, this); registerCoreTool(ReadFileTool, this); - registerCoreTool(GrepTool, this); + + if (this.getUseRipgrep()) { + registerCoreTool(RipGrepTool, this); + } else { + registerCoreTool(GrepTool, this); + } + registerCoreTool(GlobTool, this); registerCoreTool(EditTool, this); registerCoreTool(WriteFileTool, this); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index afdba8fc..855b36fc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -73,6 +73,7 @@ export * from './prompts/mcp-prompts.js'; export * from './tools/read-file.js'; export * from './tools/ls.js'; export * from './tools/grep.js'; +export * from './tools/ripGrep.js'; export * from './tools/glob.js'; export * from './tools/edit.js'; export * from './tools/write-file.js'; diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts new file mode 100644 index 00000000..6db4e065 --- /dev/null +++ b/packages/core/src/tools/ripGrep.test.ts @@ -0,0 +1,1224 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { RipGrepTool, RipGrepToolParams } from './ripGrep.js'; +import path from 'path'; +import fs from 'fs/promises'; +import os, { EOL } from 'os'; +import { Config } from '../config/config.js'; +import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; +import { spawn, ChildProcess } from 'child_process'; + +// Mock @lvce-editor/ripgrep for testing +vi.mock('@lvce-editor/ripgrep', () => ({ + rgPath: '/mock/rg/path', +})); + +// Mock child_process for ripgrep calls +vi.mock('child_process', () => ({ + spawn: vi.fn(), +})); + +const mockSpawn = vi.mocked(spawn); + +// Helper function to create mock spawn implementations +function createMockSpawn( + options: { + outputData?: string; + exitCode?: number; + signal?: string; + } = {}, +) { + const { outputData, exitCode = 0, signal } = options; + + return () => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + // Set up event listeners immediately + setTimeout(() => { + const stdoutDataHandler = mockProcess.stdout.on.mock.calls.find( + (call) => call[0] === 'data', + )?.[1]; + + const closeHandler = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (stdoutDataHandler && outputData) { + stdoutDataHandler(Buffer.from(outputData)); + } + + if (closeHandler) { + closeHandler(exitCode, signal); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }; +} + +describe('RipGrepTool', () => { + let tempRootDir: string; + let grepTool: RipGrepTool; + const abortSignal = new AbortController().signal; + + const mockConfig = { + getTargetDir: () => tempRootDir, + getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), + getDebugMode: () => false, + } as unknown as Config; + + beforeEach(async () => { + vi.clearAllMocks(); + mockSpawn.mockClear(); + tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-')); + grepTool = new RipGrepTool(mockConfig); + + // Create some test files and directories + await fs.writeFile( + path.join(tempRootDir, 'fileA.txt'), + 'hello world\nsecond line with world', + ); + await fs.writeFile( + path.join(tempRootDir, 'fileB.js'), + 'const foo = "bar";\nfunction baz() { return "hello"; }', + ); + await fs.mkdir(path.join(tempRootDir, 'sub')); + await fs.writeFile( + path.join(tempRootDir, 'sub', 'fileC.txt'), + 'another world in sub dir', + ); + await fs.writeFile( + path.join(tempRootDir, 'sub', 'fileD.md'), + '# Markdown file\nThis is a test.', + ); + }); + + afterEach(async () => { + await fs.rm(tempRootDir, { recursive: true, force: true }); + }); + + describe('validateToolParams', () => { + it('should return null for valid params (pattern only)', () => { + const params: RipGrepToolParams = { pattern: 'hello' }; + expect(grepTool.validateToolParams(params)).toBeNull(); + }); + + it('should return null for valid params (pattern and path)', () => { + const params: RipGrepToolParams = { pattern: 'hello', path: '.' }; + expect(grepTool.validateToolParams(params)).toBeNull(); + }); + + it('should return null for valid params (pattern, path, and include)', () => { + const params: RipGrepToolParams = { + pattern: 'hello', + path: '.', + include: '*.txt', + }; + expect(grepTool.validateToolParams(params)).toBeNull(); + }); + + it('should return error if pattern is missing', () => { + const params = { path: '.' } as unknown as RipGrepToolParams; + expect(grepTool.validateToolParams(params)).toBe( + `params must have required property 'pattern'`, + ); + }); + + it('should return null for what would be an invalid regex pattern', () => { + const params: RipGrepToolParams = { pattern: '[[' }; + expect(grepTool.validateToolParams(params)).toBeNull(); + }); + + it('should return error if path does not exist', () => { + const params: RipGrepToolParams = { + pattern: 'hello', + path: 'nonexistent', + }; + // Check for the core error message, as the full path might vary + expect(grepTool.validateToolParams(params)).toContain( + 'Failed to access path stats for', + ); + expect(grepTool.validateToolParams(params)).toContain('nonexistent'); + }); + + it('should return error if path is a file, not a directory', async () => { + const filePath = path.join(tempRootDir, 'fileA.txt'); + const params: RipGrepToolParams = { pattern: 'hello', path: filePath }; + expect(grepTool.validateToolParams(params)).toContain( + `Path is not a directory: ${filePath}`, + ); + }); + }); + + describe('execute', () => { + it('should find matches for a simple pattern in all files', async () => { + mockSpawn.mockImplementationOnce( + createMockSpawn({ + outputData: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}sub/fileC.txt:1:another world in sub dir${EOL}`, + exitCode: 0, + }), + ); + + const params: RipGrepToolParams = { pattern: 'world' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + expect(result.llmContent).toContain( + 'Found 3 matches for pattern "world" in the workspace directory', + ); + expect(result.llmContent).toContain('File: fileA.txt'); + expect(result.llmContent).toContain('L1: hello world'); + expect(result.llmContent).toContain('L2: second line with world'); + expect(result.llmContent).toContain( + `File: ${path.join('sub', 'fileC.txt')}`, + ); + expect(result.llmContent).toContain('L1: another world in sub dir'); + expect(result.returnDisplay).toBe('Found 3 matches'); + }); + + it('should find matches in a specific path', async () => { + // Setup specific mock for this test - searching in 'sub' should only return matches from that directory + mockSpawn.mockImplementationOnce( + createMockSpawn({ + outputData: `fileC.txt:1:another world in sub dir${EOL}`, + exitCode: 0, + }), + ); + + const params: RipGrepToolParams = { pattern: 'world', path: 'sub' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + expect(result.llmContent).toContain( + 'Found 1 match for pattern "world" in path "sub"', + ); + expect(result.llmContent).toContain('File: fileC.txt'); // Path relative to 'sub' + expect(result.llmContent).toContain('L1: another world in sub dir'); + expect(result.returnDisplay).toBe('Found 1 match'); + }); + + it('should find matches with an include glob', async () => { + // Setup specific mock for this test + mockSpawn.mockImplementationOnce( + createMockSpawn({ + outputData: `fileB.js:2:function baz() { return "hello"; }${EOL}`, + exitCode: 0, + }), + ); + + const params: RipGrepToolParams = { pattern: 'hello', include: '*.js' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + expect(result.llmContent).toContain( + 'Found 1 match for pattern "hello" in the workspace directory (filter: "*.js"):', + ); + expect(result.llmContent).toContain('File: fileB.js'); + expect(result.llmContent).toContain( + 'L2: function baz() { return "hello"; }', + ); + expect(result.returnDisplay).toBe('Found 1 match'); + }); + + it('should find matches with an include glob and path', async () => { + await fs.writeFile( + path.join(tempRootDir, 'sub', 'another.js'), + 'const greeting = "hello";', + ); + + // Setup specific mock for this test - searching for 'hello' in 'sub' with '*.js' filter + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const onData = mockProcess.stdout.on.mock.calls.find( + (call) => call[0] === 'data', + )?.[1]; + const onClose = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (onData) { + // Only return match from the .js file in sub directory + onData(Buffer.from(`another.js:1:const greeting = "hello";${EOL}`)); + } + if (onClose) { + onClose(0); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const params: RipGrepToolParams = { + pattern: 'hello', + path: 'sub', + include: '*.js', + }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + expect(result.llmContent).toContain( + 'Found 1 match for pattern "hello" in path "sub" (filter: "*.js")', + ); + expect(result.llmContent).toContain('File: another.js'); + expect(result.llmContent).toContain('L1: const greeting = "hello";'); + expect(result.returnDisplay).toBe('Found 1 match'); + }); + + it('should return "No matches found" when pattern does not exist', async () => { + // Setup specific mock for no matches + mockSpawn.mockImplementationOnce( + createMockSpawn({ + exitCode: 1, // No matches found + }), + ); + + const params: RipGrepToolParams = { pattern: 'nonexistentpattern' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + expect(result.llmContent).toContain( + 'No matches found for pattern "nonexistentpattern" in the workspace directory.', + ); + expect(result.returnDisplay).toBe('No matches found'); + }); + + it('should return an error from ripgrep for invalid regex pattern', async () => { + mockSpawn.mockImplementationOnce( + createMockSpawn({ + exitCode: 2, + }), + ); + + const params: RipGrepToolParams = { pattern: '[[' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + expect(result.llmContent).toContain('ripgrep exited with code 2'); + expect(result.returnDisplay).toContain( + 'Error: ripgrep exited with code 2', + ); + }); + + it('should handle regex special characters correctly', async () => { + // Setup specific mock for this test - regex pattern 'foo.*bar' should match 'const foo = "bar";' + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const onData = mockProcess.stdout.on.mock.calls.find( + (call) => call[0] === 'data', + )?.[1]; + const onClose = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (onData) { + // Return match for the regex pattern + onData(Buffer.from(`fileB.js:1:const foo = "bar";${EOL}`)); + } + if (onClose) { + onClose(0); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const params: RipGrepToolParams = { pattern: 'foo.*bar' }; // Matches 'const foo = "bar";' + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + expect(result.llmContent).toContain( + 'Found 1 match for pattern "foo.*bar" in the workspace directory:', + ); + expect(result.llmContent).toContain('File: fileB.js'); + expect(result.llmContent).toContain('L1: const foo = "bar";'); + }); + + it('should be case-insensitive by default (JS fallback)', async () => { + // Setup specific mock for this test - case insensitive search for 'HELLO' + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const onData = mockProcess.stdout.on.mock.calls.find( + (call) => call[0] === 'data', + )?.[1]; + const onClose = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (onData) { + // Return case-insensitive matches for 'HELLO' + onData( + Buffer.from( + `fileA.txt:1:hello world${EOL}fileB.js:2:function baz() { return "hello"; }${EOL}`, + ), + ); + } + if (onClose) { + onClose(0); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const params: RipGrepToolParams = { pattern: 'HELLO' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + expect(result.llmContent).toContain( + 'Found 2 matches for pattern "HELLO" in the workspace directory:', + ); + expect(result.llmContent).toContain('File: fileA.txt'); + expect(result.llmContent).toContain('L1: hello world'); + expect(result.llmContent).toContain('File: fileB.js'); + expect(result.llmContent).toContain( + 'L2: function baz() { return "hello"; }', + ); + }); + + it('should throw an error if params are invalid', async () => { + const params = { path: '.' } as unknown as RipGrepToolParams; // Invalid: pattern missing + expect(() => grepTool.build(params)).toThrow( + /params must have required property 'pattern'/, + ); + }); + }); + + describe('multi-directory workspace', () => { + it('should search across all workspace directories when no path is specified', async () => { + // Create additional directory with test files + const secondDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'grep-tool-second-'), + ); + await fs.writeFile( + path.join(secondDir, 'other.txt'), + 'hello from second directory\nworld in second', + ); + await fs.writeFile( + path.join(secondDir, 'another.js'), + 'function world() { return "test"; }', + ); + + // Create a mock config with multiple directories + const multiDirConfig = { + getTargetDir: () => tempRootDir, + getWorkspaceContext: () => + createMockWorkspaceContext(tempRootDir, [secondDir]), + getDebugMode: () => false, + } as unknown as Config; + + // Setup specific mock for this test - multi-directory search for 'world' + // Mock will be called twice - once for each directory + let callCount = 0; + mockSpawn.mockImplementation(() => { + callCount++; + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const stdoutDataHandler = mockProcess.stdout.on.mock.calls.find( + (call) => call[0] === 'data', + )?.[1]; + + const closeHandler = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + let outputData = ''; + if (callCount === 1) { + // First directory (tempRootDir) + outputData = + [ + 'fileA.txt:1:hello world', + 'fileA.txt:2:second line with world', + 'sub/fileC.txt:1:another world in sub dir', + ].join(EOL) + EOL; + } else if (callCount === 2) { + // Second directory (secondDir) + outputData = + [ + 'other.txt:2:world in second', + 'another.js:1:function world() { return "test"; }', + ].join(EOL) + EOL; + } + + if (stdoutDataHandler && outputData) { + stdoutDataHandler(Buffer.from(outputData)); + } + + if (closeHandler) { + closeHandler(0); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const multiDirGrepTool = new RipGrepTool(multiDirConfig); + const params: RipGrepToolParams = { pattern: 'world' }; + const invocation = multiDirGrepTool.build(params); + const result = await invocation.execute(abortSignal); + + // Should find matches in both directories + expect(result.llmContent).toContain( + 'Found 5 matches for pattern "world"', + ); + + // Matches from first directory + expect(result.llmContent).toContain('fileA.txt'); + expect(result.llmContent).toContain('L1: hello world'); + expect(result.llmContent).toContain('L2: second line with world'); + expect(result.llmContent).toContain('fileC.txt'); + expect(result.llmContent).toContain('L1: another world in sub dir'); + + // Matches from both directories + expect(result.llmContent).toContain('other.txt'); + expect(result.llmContent).toContain('L2: world in second'); + expect(result.llmContent).toContain('another.js'); + expect(result.llmContent).toContain('L1: function world()'); + + // Clean up + await fs.rm(secondDir, { recursive: true, force: true }); + mockSpawn.mockClear(); + }); + + it('should search only specified path within workspace directories', async () => { + // Create additional directory + const secondDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'grep-tool-second-'), + ); + await fs.mkdir(path.join(secondDir, 'sub')); + await fs.writeFile( + path.join(secondDir, 'sub', 'test.txt'), + 'hello from second sub directory', + ); + + // Create a mock config with multiple directories + const multiDirConfig = { + 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 + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const onData = mockProcess.stdout.on.mock.calls.find( + (call) => call[0] === 'data', + )?.[1]; + const onClose = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (onData) { + onData(Buffer.from(`fileC.txt:1:another world in sub dir${EOL}`)); + } + if (onClose) { + onClose(0); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const multiDirGrepTool = new RipGrepTool(multiDirConfig); + + // Search only in the 'sub' directory of the first workspace + const params: RipGrepToolParams = { pattern: 'world', path: 'sub' }; + const invocation = multiDirGrepTool.build(params); + const result = await invocation.execute(abortSignal); + + // Should only find matches in the specified sub directory + expect(result.llmContent).toContain( + 'Found 1 match for pattern "world" in path "sub"', + ); + expect(result.llmContent).toContain('File: fileC.txt'); + expect(result.llmContent).toContain('L1: another world in sub dir'); + + // Should not contain matches from second directory + expect(result.llmContent).not.toContain('test.txt'); + + // Clean up + await fs.rm(secondDir, { recursive: true, force: true }); + }); + }); + + describe('abort signal handling', () => { + it('should handle AbortSignal during search', async () => { + const controller = new AbortController(); + const params: RipGrepToolParams = { pattern: 'world' }; + const invocation = grepTool.build(params); + + controller.abort(); + + const result = await invocation.execute(controller.signal); + expect(result).toBeDefined(); + }); + + it('should abort streaming search when signal is triggered', async () => { + // Setup specific mock for this test - simulate process being killed due to abort + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + // Simulate process being aborted - use setTimeout to ensure handlers are registered first + setTimeout(() => { + const closeHandler = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (closeHandler) { + // Simulate process killed by signal (code is null, signal is SIGTERM) + closeHandler(null, 'SIGTERM'); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const controller = new AbortController(); + const params: RipGrepToolParams = { pattern: 'test' }; + const invocation = grepTool.build(params); + + // Abort immediately before starting the search + controller.abort(); + + const result = await invocation.execute(controller.signal); + expect(result.llmContent).toContain( + 'Error during grep search operation: ripgrep exited with code null', + ); + expect(result.returnDisplay).toContain( + 'Error: ripgrep exited with code null', + ); + }); + }); + + describe('error handling and edge cases', () => { + it('should handle workspace boundary violations', () => { + const params: RipGrepToolParams = { pattern: 'test', path: '../outside' }; + expect(() => grepTool.build(params)).toThrow(/Path validation failed/); + }); + + it('should handle empty directories gracefully', async () => { + const emptyDir = path.join(tempRootDir, 'empty'); + await fs.mkdir(emptyDir); + + // Setup specific mock for this test - searching in empty directory should return no matches + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const onClose = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (onClose) { + onClose(1); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const params: RipGrepToolParams = { pattern: 'test', path: 'empty' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('No matches found'); + expect(result.returnDisplay).toBe('No matches found'); + }); + + it('should handle empty files correctly', async () => { + await fs.writeFile(path.join(tempRootDir, 'empty.txt'), ''); + + // Setup specific mock for this test - searching for anything in empty files should return no matches + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const onClose = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (onClose) { + onClose(1); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const params: RipGrepToolParams = { pattern: 'anything' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('No matches found'); + }); + + it('should handle special characters in file names', async () => { + const specialFileName = 'file with spaces & symbols!.txt'; + await fs.writeFile( + path.join(tempRootDir, specialFileName), + 'hello world with special chars', + ); + + // Setup specific mock for this test - searching for 'world' should find the file with special characters + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const onData = mockProcess.stdout.on.mock.calls.find( + (call) => call[0] === 'data', + )?.[1]; + const onClose = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (onData) { + onData( + Buffer.from( + `${specialFileName}:1:hello world with special chars${EOL}`, + ), + ); + } + if (onClose) { + onClose(0); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const params: RipGrepToolParams = { pattern: 'world' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain(specialFileName); + expect(result.llmContent).toContain('hello world with special chars'); + }); + + it('should handle deeply nested directories', async () => { + const deepPath = path.join(tempRootDir, 'a', 'b', 'c', 'd', 'e'); + await fs.mkdir(deepPath, { recursive: true }); + await fs.writeFile( + path.join(deepPath, 'deep.txt'), + 'content in deep directory', + ); + + // Setup specific mock for this test - searching for 'deep' should find the deeply nested file + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const onData = mockProcess.stdout.on.mock.calls.find( + (call) => call[0] === 'data', + )?.[1]; + const onClose = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (onData) { + onData( + Buffer.from( + `a/b/c/d/e/deep.txt:1:content in deep directory${EOL}`, + ), + ); + } + if (onClose) { + onClose(0); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const params: RipGrepToolParams = { pattern: 'deep' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('deep.txt'); + expect(result.llmContent).toContain('content in deep directory'); + }); + }); + + describe('regex pattern validation', () => { + it('should handle complex regex patterns', async () => { + await fs.writeFile( + path.join(tempRootDir, 'code.js'), + 'function getName() { return "test"; }\nconst getValue = () => "value";', + ); + + // Setup specific mock for this test - regex pattern should match function declarations + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const onData = mockProcess.stdout.on.mock.calls.find( + (call) => call[0] === 'data', + )?.[1]; + const onClose = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (onData) { + onData( + Buffer.from( + `code.js:1:function getName() { return "test"; }${EOL}`, + ), + ); + } + if (onClose) { + onClose(0); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const params: RipGrepToolParams = { pattern: 'function\\s+\\w+\\s*\\(' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('function getName()'); + expect(result.llmContent).not.toContain('const getValue'); + }); + + it('should handle case sensitivity correctly in JS fallback', async () => { + await fs.writeFile( + path.join(tempRootDir, 'case.txt'), + 'Hello World\nhello world\nHELLO WORLD', + ); + + // Setup specific mock for this test - case insensitive search should match all variants + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const onData = mockProcess.stdout.on.mock.calls.find( + (call) => call[0] === 'data', + )?.[1]; + const onClose = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (onData) { + onData( + Buffer.from( + `case.txt:1:Hello World${EOL}case.txt:2:hello world${EOL}case.txt:3:HELLO WORLD${EOL}`, + ), + ); + } + if (onClose) { + onClose(0); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const params: RipGrepToolParams = { pattern: 'hello' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Hello World'); + expect(result.llmContent).toContain('hello world'); + expect(result.llmContent).toContain('HELLO WORLD'); + }); + + it('should handle escaped regex special characters', async () => { + await fs.writeFile( + path.join(tempRootDir, 'special.txt'), + 'Price: $19.99\nRegex: [a-z]+ pattern\nEmail: test@example.com', + ); + + // Setup specific mock for this test - escaped regex pattern should match price format + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const onData = mockProcess.stdout.on.mock.calls.find( + (call) => call[0] === 'data', + )?.[1]; + const onClose = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (onData) { + onData(Buffer.from(`special.txt:1:Price: $19.99${EOL}`)); + } + if (onClose) { + onClose(0); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const params: RipGrepToolParams = { pattern: '\\$\\d+\\.\\d+' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Price: $19.99'); + expect(result.llmContent).not.toContain('Email: test@example.com'); + }); + }); + + describe('include pattern filtering', () => { + it('should handle multiple file extensions in include pattern', async () => { + await fs.writeFile( + path.join(tempRootDir, 'test.ts'), + 'typescript content', + ); + await fs.writeFile(path.join(tempRootDir, 'test.tsx'), 'tsx content'); + await fs.writeFile( + path.join(tempRootDir, 'test.js'), + 'javascript content', + ); + await fs.writeFile(path.join(tempRootDir, 'test.txt'), 'text content'); + + // Setup specific mock for this test - include pattern should filter to only ts/tsx files + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const onData = mockProcess.stdout.on.mock.calls.find( + (call) => call[0] === 'data', + )?.[1]; + const onClose = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (onData) { + onData( + Buffer.from( + `test.ts:1:typescript content${EOL}test.tsx:1:tsx content${EOL}`, + ), + ); + } + if (onClose) { + onClose(0); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const params: RipGrepToolParams = { + pattern: 'content', + include: '*.{ts,tsx}', + }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('test.ts'); + expect(result.llmContent).toContain('test.tsx'); + expect(result.llmContent).not.toContain('test.js'); + expect(result.llmContent).not.toContain('test.txt'); + }); + + it('should handle directory patterns in include', async () => { + await fs.mkdir(path.join(tempRootDir, 'src'), { recursive: true }); + await fs.writeFile( + path.join(tempRootDir, 'src', 'main.ts'), + 'source code', + ); + await fs.writeFile(path.join(tempRootDir, 'other.ts'), 'other code'); + + // Setup specific mock for this test - include pattern should filter to only src/** files + mockSpawn.mockImplementationOnce(() => { + const mockProcess = { + stdout: { + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + on: vi.fn(), + removeListener: vi.fn(), + }, + on: vi.fn(), + removeListener: vi.fn(), + kill: vi.fn(), + }; + + setTimeout(() => { + const onData = mockProcess.stdout.on.mock.calls.find( + (call) => call[0] === 'data', + )?.[1]; + const onClose = mockProcess.on.mock.calls.find( + (call) => call[0] === 'close', + )?.[1]; + + if (onData) { + onData(Buffer.from(`src/main.ts:1:source code${EOL}`)); + } + if (onClose) { + onClose(0); + } + }, 0); + + return mockProcess as unknown as ChildProcess; + }); + + const params: RipGrepToolParams = { + pattern: 'code', + include: 'src/**', + }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('main.ts'); + expect(result.llmContent).not.toContain('other.ts'); + }); + }); + + describe('getDescription', () => { + it('should generate correct description with pattern only', () => { + const params: RipGrepToolParams = { pattern: 'testPattern' }; + const invocation = grepTool.build(params); + expect(invocation.getDescription()).toBe("'testPattern'"); + }); + + it('should generate correct description with pattern and include', () => { + const params: RipGrepToolParams = { + pattern: 'testPattern', + include: '*.ts', + }; + const invocation = grepTool.build(params); + expect(invocation.getDescription()).toBe("'testPattern' in *.ts"); + }); + + it('should generate correct description with pattern and path', async () => { + const dirPath = path.join(tempRootDir, 'src', 'app'); + await fs.mkdir(dirPath, { recursive: true }); + const params: RipGrepToolParams = { + pattern: 'testPattern', + path: path.join('src', 'app'), + }; + const invocation = grepTool.build(params); + // The path will be relative to the tempRootDir, so we check for containment. + expect(invocation.getDescription()).toContain("'testPattern' within"); + expect(invocation.getDescription()).toContain(path.join('src', 'app')); + }); + + it('should indicate searching across all workspace directories when no path specified', () => { + // Create a mock config with multiple directories + const multiDirConfig = { + getTargetDir: () => tempRootDir, + getWorkspaceContext: () => + createMockWorkspaceContext(tempRootDir, ['/another/dir']), + getDebugMode: () => false, + } as unknown as Config; + + const multiDirGrepTool = new RipGrepTool(multiDirConfig); + const params: RipGrepToolParams = { pattern: 'testPattern' }; + const invocation = multiDirGrepTool.build(params); + expect(invocation.getDescription()).toBe( + "'testPattern' across all workspace directories", + ); + }); + + it('should generate correct description with pattern, include, and path', async () => { + const dirPath = path.join(tempRootDir, 'src', 'app'); + await fs.mkdir(dirPath, { recursive: true }); + const params: RipGrepToolParams = { + pattern: 'testPattern', + include: '*.ts', + path: path.join('src', 'app'), + }; + const invocation = grepTool.build(params); + expect(invocation.getDescription()).toContain( + "'testPattern' in *.ts within", + ); + expect(invocation.getDescription()).toContain(path.join('src', 'app')); + }); + + it('should use ./ for root path in description', () => { + const params: RipGrepToolParams = { pattern: 'testPattern', path: '.' }; + const invocation = grepTool.build(params); + expect(invocation.getDescription()).toBe("'testPattern' within ./"); + }); + }); +}); diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts new file mode 100644 index 00000000..e5d8974b --- /dev/null +++ b/packages/core/src/tools/ripGrep.ts @@ -0,0 +1,502 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; +import { EOL } from 'os'; +import { spawn } from 'child_process'; +import { rgPath } from '@lvce-editor/ripgrep'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + ToolInvocation, + ToolResult, +} from './tools.js'; +import { SchemaValidator } from '../utils/schemaValidator.js'; +import { makeRelative, shortenPath } from '../utils/paths.js'; +import { getErrorMessage, isNodeError } from '../utils/errors.js'; +import { Config } from '../config/config.js'; + +const DEFAULT_TOTAL_MAX_MATCHES = 20000; + +/** + * Parameters for the GrepTool + */ +export interface RipGrepToolParams { + /** + * The regular expression pattern to search for in file contents + */ + pattern: string; + + /** + * The directory to search in (optional, defaults to current directory relative to root) + */ + path?: string; + + /** + * File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}") + */ + include?: string; +} + +/** + * Result object for a single grep match + */ +interface GrepMatch { + filePath: string; + lineNumber: number; + line: string; +} + +class GrepToolInvocation extends BaseToolInvocation< + RipGrepToolParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: RipGrepToolParams, + ) { + super(params); + } + + /** + * Checks if a path is within the root directory and resolves it. + * @param relativePath Path relative to the root directory (or undefined for root). + * @returns The absolute path if valid and exists, or null if no path specified (to search all directories). + * @throws {Error} If path is outside root, doesn't exist, or isn't a directory. + */ + private resolveAndValidatePath(relativePath?: string): string | null { + // If no path specified, return null to indicate searching all workspace directories + if (!relativePath) { + return null; + } + + const targetPath = path.resolve(this.config.getTargetDir(), relativePath); + + // Security Check: Ensure the resolved path is within workspace boundaries + const workspaceContext = this.config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(targetPath)) { + const directories = workspaceContext.getDirectories(); + throw new Error( + `Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`, + ); + } + + // Check existence and type after resolving + try { + const stats = fs.statSync(targetPath); + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${targetPath}`); + } + } catch (error: unknown) { + if (isNodeError(error) && error.code !== 'ENOENT') { + throw new Error(`Path does not exist: ${targetPath}`); + } + throw new Error( + `Failed to access path stats for ${targetPath}: ${error}`, + ); + } + + return targetPath; + } + + async execute(signal: AbortSignal): Promise { + try { + const workspaceContext = this.config.getWorkspaceContext(); + const searchDirAbs = this.resolveAndValidatePath(this.params.path); + const searchDirDisplay = this.params.path || '.'; + + // Determine which directories to search + let searchDirectories: readonly string[]; + if (searchDirAbs === null) { + // No path specified - search all workspace directories + searchDirectories = workspaceContext.getDirectories(); + } else { + // Specific path provided - search only that directory + searchDirectories = [searchDirAbs]; + } + + let allMatches: GrepMatch[] = []; + const totalMaxMatches = DEFAULT_TOTAL_MAX_MATCHES; + + if (this.config.getDebugMode()) { + console.log(`[GrepTool] Total result limit: ${totalMaxMatches}`); + } + + for (const searchDir of searchDirectories) { + const searchResult = await this.performRipgrepSearch({ + pattern: this.params.pattern, + path: searchDir, + include: this.params.include, + signal, + }); + + if (searchDirectories.length > 1) { + const dirName = path.basename(searchDir); + searchResult.forEach((match) => { + match.filePath = path.join(dirName, match.filePath); + }); + } + + allMatches = allMatches.concat(searchResult); + + if (allMatches.length >= totalMaxMatches) { + allMatches = allMatches.slice(0, totalMaxMatches); + break; + } + } + + let searchLocationDescription: string; + if (searchDirAbs === null) { + const numDirs = workspaceContext.getDirectories().length; + searchLocationDescription = + numDirs > 1 + ? `across ${numDirs} workspace directories` + : `in the workspace directory`; + } else { + searchLocationDescription = `in path "${searchDirDisplay}"`; + } + + if (allMatches.length === 0) { + const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`; + return { llmContent: noMatchMsg, returnDisplay: `No matches found` }; + } + + const wasTruncated = allMatches.length >= totalMaxMatches; + + const matchesByFile = allMatches.reduce( + (acc, match) => { + const fileKey = match.filePath; + if (!acc[fileKey]) { + acc[fileKey] = []; + } + acc[fileKey].push(match); + acc[fileKey].sort((a, b) => a.lineNumber - b.lineNumber); + return acc; + }, + {} as Record, + ); + + const matchCount = allMatches.length; + const matchTerm = matchCount === 1 ? 'match' : 'matches'; + + let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}`; + + if (wasTruncated) { + llmContent += ` (results limited to ${totalMaxMatches} matches for performance)`; + } + + llmContent += `:\n---\n`; + + for (const filePath in matchesByFile) { + llmContent += `File: ${filePath}\n`; + matchesByFile[filePath].forEach((match) => { + const trimmedLine = match.line.trim(); + llmContent += `L${match.lineNumber}: ${trimmedLine}\n`; + }); + llmContent += '---\n'; + } + + let displayMessage = `Found ${matchCount} ${matchTerm}`; + if (wasTruncated) { + displayMessage += ` (limited)`; + } + + return { + llmContent: llmContent.trim(), + returnDisplay: displayMessage, + }; + } catch (error) { + console.error(`Error during GrepLogic execution: ${error}`); + const errorMessage = getErrorMessage(error); + return { + llmContent: `Error during grep search operation: ${errorMessage}`, + returnDisplay: `Error: ${errorMessage}`, + }; + } + } + + private parseRipgrepOutput(output: string, basePath: string): GrepMatch[] { + const results: GrepMatch[] = []; + if (!output) return results; + + const lines = output.split(EOL); + + for (const line of lines) { + if (!line.trim()) continue; + + const firstColonIndex = line.indexOf(':'); + if (firstColonIndex === -1) continue; + + const secondColonIndex = line.indexOf(':', firstColonIndex + 1); + if (secondColonIndex === -1) continue; + + const filePathRaw = line.substring(0, firstColonIndex); + const lineNumberStr = line.substring( + firstColonIndex + 1, + secondColonIndex, + ); + const lineContent = line.substring(secondColonIndex + 1); + + const lineNumber = parseInt(lineNumberStr, 10); + + if (!isNaN(lineNumber)) { + const absoluteFilePath = path.resolve(basePath, filePathRaw); + const relativeFilePath = path.relative(basePath, absoluteFilePath); + + results.push({ + filePath: relativeFilePath || path.basename(absoluteFilePath), + lineNumber, + line: lineContent, + }); + } + } + return results; + } + + private async performRipgrepSearch(options: { + pattern: string; + path: string; + include?: string; + signal: AbortSignal; + }): Promise { + const { pattern, path: absolutePath, include } = options; + + const rgArgs = [ + '--line-number', + '--no-heading', + '--with-filename', + '--ignore-case', + '--regexp', + pattern, + ]; + + if (include) { + rgArgs.push('--glob', include); + } + + const excludes = [ + '.git', + 'node_modules', + 'bower_components', + '*.log', + '*.tmp', + 'build', + 'dist', + 'coverage', + ]; + excludes.forEach((exclude) => { + rgArgs.push('--glob', `!${exclude}`); + }); + + rgArgs.push('--threads', '4'); + rgArgs.push(absolutePath); + + try { + const output = await new Promise((resolve, reject) => { + const child = spawn(rgPath, rgArgs, { + windowsHide: true, + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + const cleanup = () => { + if (options.signal.aborted) { + child.kill(); + } + }; + + options.signal.addEventListener('abort', cleanup, { once: true }); + + child.stdout.on('data', (chunk) => stdoutChunks.push(chunk)); + child.stderr.on('data', (chunk) => stderrChunks.push(chunk)); + + child.on('error', (err) => { + options.signal.removeEventListener('abort', cleanup); + reject( + new Error( + `Failed to start ripgrep: ${err.message}. Please ensure @lvce-editor/ripgrep is properly installed.`, + ), + ); + }); + + child.on('close', (code) => { + options.signal.removeEventListener('abort', cleanup); + const stdoutData = Buffer.concat(stdoutChunks).toString('utf8'); + const stderrData = Buffer.concat(stderrChunks).toString('utf8'); + + if (code === 0) { + resolve(stdoutData); + } else if (code === 1) { + resolve(''); // No matches found + } else { + reject( + new Error(`ripgrep exited with code ${code}: ${stderrData}`), + ); + } + }); + }); + + return this.parseRipgrepOutput(output, absolutePath); + } catch (error: unknown) { + console.error(`GrepLogic: ripgrep failed: ${getErrorMessage(error)}`); + throw error; + } + } + + /** + * Gets a description of the grep operation + * @param params Parameters for the grep operation + * @returns A string describing the grep + */ + getDescription(): string { + let description = `'${this.params.pattern}'`; + if (this.params.include) { + description += ` in ${this.params.include}`; + } + if (this.params.path) { + const resolvedPath = path.resolve( + this.config.getTargetDir(), + this.params.path, + ); + if ( + resolvedPath === this.config.getTargetDir() || + this.params.path === '.' + ) { + description += ` within ./`; + } else { + const relativePath = makeRelative( + resolvedPath, + this.config.getTargetDir(), + ); + description += ` within ${shortenPath(relativePath)}`; + } + } else { + // When no path is specified, indicate searching all workspace directories + const workspaceContext = this.config.getWorkspaceContext(); + const directories = workspaceContext.getDirectories(); + if (directories.length > 1) { + description += ` across all workspace directories`; + } + } + return description; + } +} + +/** + * Implementation of the Grep tool logic (moved from CLI) + */ +export class RipGrepTool extends BaseDeclarativeTool< + RipGrepToolParams, + ToolResult +> { + static readonly Name = 'search_file_content'; + + constructor(private readonly config: Config) { + super( + RipGrepTool.Name, + 'SearchText', + 'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers. Total results limited to 20,000 matches like VSCode.', + Kind.Search, + { + properties: { + pattern: { + description: + "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').", + type: 'string', + }, + path: { + description: + 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.', + type: 'string', + }, + include: { + description: + "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).", + type: 'string', + }, + }, + required: ['pattern'], + type: 'object', + }, + ); + } + + /** + * Checks if a path is within the root directory and resolves it. + * @param relativePath Path relative to the root directory (or undefined for root). + * @returns The absolute path if valid and exists, or null if no path specified (to search all directories). + * @throws {Error} If path is outside root, doesn't exist, or isn't a directory. + */ + private resolveAndValidatePath(relativePath?: string): string | null { + // If no path specified, return null to indicate searching all workspace directories + if (!relativePath) { + return null; + } + + const targetPath = path.resolve(this.config.getTargetDir(), relativePath); + + // Security Check: Ensure the resolved path is within workspace boundaries + const workspaceContext = this.config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(targetPath)) { + const directories = workspaceContext.getDirectories(); + throw new Error( + `Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`, + ); + } + + // Check existence and type after resolving + try { + const stats = fs.statSync(targetPath); + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${targetPath}`); + } + } catch (error: unknown) { + if (isNodeError(error) && error.code !== 'ENOENT') { + throw new Error(`Path does not exist: ${targetPath}`); + } + throw new Error( + `Failed to access path stats for ${targetPath}: ${error}`, + ); + } + + return targetPath; + } + + /** + * Validates the parameters for the tool + * @param params Parameters to validate + * @returns An error message string if invalid, null otherwise + */ + override validateToolParams(params: RipGrepToolParams): string | null { + const errors = SchemaValidator.validate( + this.schema.parametersJsonSchema, + params, + ); + if (errors) { + return errors; + } + + // Only validate path if one is provided + if (params.path) { + try { + this.resolveAndValidatePath(params.path); + } catch (error) { + return getErrorMessage(error); + } + } + + return null; // Parameters are valid + } + + protected createInvocation( + params: RipGrepToolParams, + ): ToolInvocation { + return new GrepToolInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 79354cf8..8685616e 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -32,6 +32,7 @@ import { } from '../services/shellExecutionService.js'; import * as fs from 'fs'; import * as os from 'os'; +import { EOL } from 'os'; import * as path from 'path'; import * as crypto from 'crypto'; import * as summarizer from '../utils/summarizer.js'; @@ -141,7 +142,7 @@ describe('ShellTool', () => { resolveShellExecution({ pid: 54321 }); vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n'); // Service PID and background PID + vi.mocked(fs.readFileSync).mockReturnValue(`54321${EOL}54322${EOL}`); // Service PID and background PID const result = await promise; diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 3fce7c2d..5e64d030 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -6,7 +6,7 @@ import fs from 'fs'; import path from 'path'; -import os from 'os'; +import os, { EOL } from 'os'; import crypto from 'crypto'; import { Config } from '../config/config.js'; import { @@ -192,7 +192,7 @@ class ShellToolInvocation extends BaseToolInvocation< if (fs.existsSync(tempFilePath)) { const pgrepLines = fs .readFileSync(tempFilePath, 'utf8') - .split('\n') + .split(EOL) .filter(Boolean); for (const line of pgrepLines) { if (!/^\d+$/.test(line)) {