Diff-based file editing and file discovery tools
Project Files
src / ripgrep.ts
import { existsSync } from "fs"
import * as path from "path"
import * as childProcess from "child_process"
import * as readline from "readline"
const isWindows = /^win/.test(process.platform)
const binName = isWindows ? "rg.exe" : "rg"
interface SearchResult {
filePath: string
line: number
column: number
match: string
beforeContext: string[]
afterContext: string[]
}
const MAX_RESULTS = 300
export async function getBinPath(cwd: string): Promise<string | undefined> {
const checkPath = async (pkgFolder: string) => {
const fullPath = path.join(cwd, pkgFolder, binName)
return (existsSync(fullPath)) ? fullPath : undefined
}
return (
(await checkPath("node_modules/@vscode/ripgrep/bin/")) ||
(await checkPath("node_modules/vscode-ripgrep/bin"))
)
}
async function execRipgrep(bin: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const rgProcess = childProcess.spawn(bin, args)
// cross-platform alternative to head, which is ripgrep author's recommendation for limiting output.
const rl = readline.createInterface({
input: rgProcess.stdout,
crlfDelay: Infinity, // treat \r\n as a single line break even if it's split across chunks. This ensures consistent behavior across different operating systems.
})
let output = ""
let lineCount = 0
const maxLines = MAX_RESULTS * 5 // limiting ripgrep output with max lines since there's no other way to limit results. it's okay that we're outputting as json, since we're parsing it line by line and ignore anything that's not part of a match. This assumes each result is at most 5 lines.
rl.on("line", (line) => {
if (lineCount < maxLines) {
output += line + "\n"
lineCount++
} else {
rl.close()
rgProcess.kill()
}
})
let errorOutput = ""
rgProcess.stderr.on("data", (data) => {
errorOutput += data.toString()
})
rl.on("close", () => {
if (errorOutput) {
reject(new Error(`ripgrep process error: ${errorOutput}`))
} else {
resolve(output)
}
})
rgProcess.on("error", (error) => {
reject(new Error(`ripgrep process error: ${error.message}`))
})
})
}
export async function regexSearchFiles(
cwd: string,
directoryPath: string,
regex: string,
filePattern?: string,
): Promise<string> {
const rgPath = await getBinPath(cwd)
if (!rgPath) {
throw new Error("Could not find ripgrep binary")
}
const args = ["--json", "-e", regex, "--glob", filePattern || "*", "--context", "1", directoryPath]
let output: string
try {
output = await execRipgrep(rgPath, args)
} catch {
return "No results found"
}
const results: SearchResult[] = []
let currentResult: Partial<SearchResult> | null = null
output.split("\n").forEach((line) => {
if (line) {
try {
const parsed = JSON.parse(line)
if (parsed.type === "match") {
if (currentResult) {
results.push(currentResult as SearchResult)
}
currentResult = {
filePath: parsed.data.path.text,
line: parsed.data.line_number,
column: parsed.data.submatches[0].start,
match: parsed.data.lines.text,
beforeContext: [],
afterContext: [],
}
} else if (parsed.type === "context" && currentResult) {
if (parsed.data.line_number < currentResult.line!) {
currentResult.beforeContext!.push(parsed.data.lines.text)
} else {
currentResult.afterContext!.push(parsed.data.lines.text)
}
}
} catch (error) {
console.error("Error parsing ripgrep output:", error)
}
}
})
if (currentResult) {
results.push(currentResult as SearchResult)
}
return formatResults(results, cwd)
}
const MAX_RIPGREP_MB = 0.25
const MAX_BYTE_SIZE = MAX_RIPGREP_MB * 1024 * 1024 // 0./25MB in bytes
function formatResults(results: SearchResult[], cwd: string): string {
const groupedResults: { [key: string]: SearchResult[] } = {}
let output = ""
if (results.length >= MAX_RESULTS) {
output += `Showing first ${MAX_RESULTS} of ${MAX_RESULTS}+ results. Use a more specific search if necessary.\n\n`
} else {
output += `Found ${results.length === 1 ? "1 result" : `${results.length.toLocaleString()} results`}.\n\n`
}
// Group results by file name
results.slice(0, MAX_RESULTS).forEach((result) => {
const relativeFilePath = path.relative(cwd, result.filePath)
if (!groupedResults[relativeFilePath]) {
groupedResults[relativeFilePath] = []
}
groupedResults[relativeFilePath].push(result)
})
// Track byte size
let byteSize = Buffer.byteLength(output, "utf8")
let wasLimitReached = false
for (const [filePath, fileResults] of Object.entries(groupedResults)) {
// Check if adding this file's path would exceed the byte limit
const filePathString = `${filePath.replace(/\\/g, '/')}\n│----\n`
const filePathBytes = Buffer.byteLength(filePathString, "utf8")
if (byteSize + filePathBytes >= MAX_BYTE_SIZE) {
wasLimitReached = true
break
}
output += filePathString
byteSize += filePathBytes
for (let resultIndex = 0; resultIndex < fileResults.length; resultIndex++) {
const result = fileResults[resultIndex]
const allLines = [...result.beforeContext, result.match, ...result.afterContext]
// Calculate bytes in all lines for this result
let resultBytes = 0
const resultLines: string[] = []
for (const line of allLines) {
const trimmedLine = line?.trimEnd() ?? ""
const lineString = `│${trimmedLine}\n`
const lineBytes = Buffer.byteLength(lineString, "utf8")
// Check if adding this line would exceed the byte limit
if (byteSize + resultBytes + lineBytes >= MAX_BYTE_SIZE) {
wasLimitReached = true
break
}
resultLines.push(lineString)
resultBytes += lineBytes
}
// If we hit the limit in the middle of processing lines, break out of the result loop
if (wasLimitReached) {
break
}
// Add all lines for this result to the output
resultLines.forEach((line) => {
output += line
})
byteSize += resultBytes
// Add separator between results if needed
if (resultIndex < fileResults.length - 1) {
const separatorString = "│----\n"
const separatorBytes = Buffer.byteLength(separatorString, "utf8")
if (byteSize + separatorBytes >= MAX_BYTE_SIZE) {
wasLimitReached = true
break
}
output += separatorString
byteSize += separatorBytes
}
// Check if we've hit the byte limit
if (byteSize >= MAX_BYTE_SIZE) {
wasLimitReached = true
break
}
}
// If we hit the limit, break out of the file loop
if (wasLimitReached) {
break
}
const closingString = "│----\n\n"
const closingBytes = Buffer.byteLength(closingString, "utf8")
if (byteSize + closingBytes >= MAX_BYTE_SIZE) {
wasLimitReached = true
break
}
output += closingString
byteSize += closingBytes
}
// Add a message if we hit the byte limit
if (wasLimitReached) {
const truncationMessage = `\n[Results truncated due to exceeding the ${MAX_RIPGREP_MB}MB size limit. Please use a more specific search pattern.]`
// Only add the message if it fits within the limit
if (byteSize + Buffer.byteLength(truncationMessage, "utf8") < MAX_BYTE_SIZE) {
output += truncationMessage
}
}
return output.trim()
}
src / ripgrep.ts
import { existsSync } from "fs"
import * as path from "path"
import * as childProcess from "child_process"
import * as readline from "readline"
const isWindows = /^win/.test(process.platform)
const binName = isWindows ? "rg.exe" : "rg"
interface SearchResult {
filePath: string
line: number
column: number
match: string
beforeContext: string[]
afterContext: string[]
}
const MAX_RESULTS = 300
export async function getBinPath(cwd: string): Promise<string | undefined> {
const checkPath = async (pkgFolder: string) => {
const fullPath = path.join(cwd, pkgFolder, binName)
return (existsSync(fullPath)) ? fullPath : undefined
}
return (
(await checkPath("node_modules/@vscode/ripgrep/bin/")) ||
(await checkPath("node_modules/vscode-ripgrep/bin"))
)
}
async function execRipgrep(bin: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const rgProcess = childProcess.spawn(bin, args)
// cross-platform alternative to head, which is ripgrep author's recommendation for limiting output.
const rl = readline.createInterface({
input: rgProcess.stdout,
crlfDelay: Infinity, // treat \r\n as a single line break even if it's split across chunks. This ensures consistent behavior across different operating systems.
})
let output = ""
let lineCount = 0
const maxLines = MAX_RESULTS * 5 // limiting ripgrep output with max lines since there's no other way to limit results. it's okay that we're outputting as json, since we're parsing it line by line and ignore anything that's not part of a match. This assumes each result is at most 5 lines.
rl.on("line", (line) => {
if (lineCount < maxLines) {
output += line + "\n"
lineCount++
} else {
rl.close()
rgProcess.kill()
}
})
let errorOutput = ""
rgProcess.stderr.on("data", (data) => {
errorOutput += data.toString()
})
rl.on("close", () => {
if (errorOutput) {
reject(new Error(`ripgrep process error: ${errorOutput}`))
} else {
resolve(output)
}
})
rgProcess.on("error", (error) => {
reject(new Error(`ripgrep process error: ${error.message}`))
})
})
}
export async function regexSearchFiles(
cwd: string,
directoryPath: string,
regex: string,
filePattern?: string,
): Promise<string> {
const rgPath = await getBinPath(cwd)
if (!rgPath) {
throw new Error("Could not find ripgrep binary")
}
const args = ["--json", "-e", regex, "--glob", filePattern || "*", "--context", "1", directoryPath]
let output: string
try {
output = await execRipgrep(rgPath, args)
} catch {
return "No results found"
}
const results: SearchResult[] = []
let currentResult: Partial<SearchResult> | null = null
output.split("\n").forEach((line) => {
if (line) {
try {
const parsed = JSON.parse(line)
if (parsed.type === "match") {
if (currentResult) {
results.push(currentResult as SearchResult)
}
currentResult = {
filePath: parsed.data.path.text,
line: parsed.data.line_number,
column: parsed.data.submatches[0].start,
match: parsed.data.lines.text,
beforeContext: [],
afterContext: [],
}
} else if (parsed.type === "context" && currentResult) {
if (parsed.data.line_number < currentResult.line!) {
currentResult.beforeContext!.push(parsed.data.lines.text)
} else {
currentResult.afterContext!.push(parsed.data.lines.text)
}
}
} catch (error) {
console.error("Error parsing ripgrep output:", error)
}
}
})
if (currentResult) {
results.push(currentResult as SearchResult)
}
return formatResults(results, cwd)
}
const MAX_RIPGREP_MB = 0.25
const MAX_BYTE_SIZE = MAX_RIPGREP_MB * 1024 * 1024 // 0./25MB in bytes
function formatResults(results: SearchResult[], cwd: string): string {
const groupedResults: { [key: string]: SearchResult[] } = {}
let output = ""
if (results.length >= MAX_RESULTS) {
output += `Showing first ${MAX_RESULTS} of ${MAX_RESULTS}+ results. Use a more specific search if necessary.\n\n`
} else {
output += `Found ${results.length === 1 ? "1 result" : `${results.length.toLocaleString()} results`}.\n\n`
}
// Group results by file name
results.slice(0, MAX_RESULTS).forEach((result) => {
const relativeFilePath = path.relative(cwd, result.filePath)
if (!groupedResults[relativeFilePath]) {
groupedResults[relativeFilePath] = []
}
groupedResults[relativeFilePath].push(result)
})
// Track byte size
let byteSize = Buffer.byteLength(output, "utf8")
let wasLimitReached = false
for (const [filePath, fileResults] of Object.entries(groupedResults)) {
// Check if adding this file's path would exceed the byte limit
const filePathString = `${filePath.replace(/\\/g, '/')}\n│----\n`
const filePathBytes = Buffer.byteLength(filePathString, "utf8")
if (byteSize + filePathBytes >= MAX_BYTE_SIZE) {
wasLimitReached = true
break
}
output += filePathString
byteSize += filePathBytes
for (let resultIndex = 0; resultIndex < fileResults.length; resultIndex++) {
const result = fileResults[resultIndex]
const allLines = [...result.beforeContext, result.match, ...result.afterContext]
// Calculate bytes in all lines for this result
let resultBytes = 0
const resultLines: string[] = []
for (const line of allLines) {
const trimmedLine = line?.trimEnd() ?? ""
const lineString = `│${trimmedLine}\n`
const lineBytes = Buffer.byteLength(lineString, "utf8")
// Check if adding this line would exceed the byte limit
if (byteSize + resultBytes + lineBytes >= MAX_BYTE_SIZE) {
wasLimitReached = true
break
}
resultLines.push(lineString)
resultBytes += lineBytes
}
// If we hit the limit in the middle of processing lines, break out of the result loop
if (wasLimitReached) {
break
}
// Add all lines for this result to the output
resultLines.forEach((line) => {
output += line
})
byteSize += resultBytes
// Add separator between results if needed
if (resultIndex < fileResults.length - 1) {
const separatorString = "│----\n"
const separatorBytes = Buffer.byteLength(separatorString, "utf8")
if (byteSize + separatorBytes >= MAX_BYTE_SIZE) {
wasLimitReached = true
break
}
output += separatorString
byteSize += separatorBytes
}
// Check if we've hit the byte limit
if (byteSize >= MAX_BYTE_SIZE) {
wasLimitReached = true
break
}
}
// If we hit the limit, break out of the file loop
if (wasLimitReached) {
break
}
const closingString = "│----\n\n"
const closingBytes = Buffer.byteLength(closingString, "utf8")
if (byteSize + closingBytes >= MAX_BYTE_SIZE) {
wasLimitReached = true
break
}
output += closingString
byteSize += closingBytes
}
// Add a message if we hit the byte limit
if (wasLimitReached) {
const truncationMessage = `\n[Results truncated due to exceeding the ${MAX_RIPGREP_MB}MB size limit. Please use a more specific search pattern.]`
// Only add the message if it fits within the limit
if (byteSize + Buffer.byteLength(truncationMessage, "utf8") < MAX_BYTE_SIZE) {
output += truncationMessage
}
}
return output.trim()
}