/**
* Search Tools — glob and grep implementations
*
* Extracted from local-tools.ts for single-responsibility.
*/
import { existsSync } from "fs";
import { join } from "path";
import { execSync } from "child_process";
import { homedir } from "os";
import { isRgAvailable, rgGrep, rgGlob } from "../ripgrep.js";
import { ToolResult } from "../../../shared/types.js";
function resolvePath(p: string): string {
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
return p;
}
// --- GLOB -------------------------------------------------------------------
export function globSearch(input: Record<string, unknown>): ToolResult {
const pattern = input.pattern as string;
const basePath = resolvePath((input.path as string) || process.cwd());
if (!existsSync(basePath)) return { success: false, output: `Directory not found: ${basePath}` };
// Try ripgrep first for speed
if (isRgAvailable()) {
try {
const result = rgGlob({ pattern, path: basePath, headLimit: 200 });
if (result === null) return { success: true, output: "No files found" };
const files = result.split("\n").filter(Boolean);
return { success: true, output: `${files.length} files:\n${result}` };
} catch {
// Fall through to find
}
}
// Fallback: system find
let searchDir = basePath;
let namePattern = pattern;
const lastSlash = pattern.lastIndexOf("/");
if (lastSlash >= 0) {
const dirPart = pattern.slice(0, lastSlash).replace(/\*\*\/?/g, "").replace(/\/+$/, "");
namePattern = pattern.slice(lastSlash + 1);
if (dirPart && !dirPart.includes("*")) {
searchDir = join(basePath, dirPart);
}
}
let findCmd: string;
const braceMatch = namePattern.match(/\{([^}]+)\}/);
if (braceMatch) {
const extensions = braceMatch[1].split(",").map((e) => e.trim());
const conditions = extensions
.map((ext) => {
const expanded = namePattern.replace(`{${braceMatch[1]}}`, ext);
// Escape single quotes to prevent shell injection
const escaped = expanded.replace(/'/g, "'\\''");
return `-name '${escaped}'`;
})
.join(" -o ");
const escapedDir = searchDir.replace(/'/g, "'\\''");
findCmd = `find '${escapedDir}' \\( ${conditions} \\) -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' 2>/dev/null | sort | head -200`;
} else {
const escapedDir = searchDir.replace(/'/g, "'\\''");
const escapedPattern = namePattern.replace(/'/g, "'\\''");
findCmd = `find '${escapedDir}' -name '${escapedPattern}' -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' 2>/dev/null | sort | head -200`;
}
try {
const output = execSync(findCmd, { encoding: "utf-8", timeout: 10000 });
const files = output.trim().split("\n").filter(Boolean);
if (files.length === 0) return { success: true, output: "No files found" };
return { success: true, output: `${files.length} files:\n${files.join("\n")}` };
} catch {
return { success: true, output: "No files found" };
}
}
// --- GREP -------------------------------------------------------------------
export function grepSearch(input: Record<string, unknown>): ToolResult {
const pattern = input.pattern as string;
const path = resolvePath((input.path as string) || process.cwd());
const globFilter = input.glob as string | undefined;
const outputMode = (input.output_mode as string) || "files_with_matches";
const contextLines = input.context as number | undefined;
const beforeLines = input.before as number | undefined;
const afterLines = input.after as number | undefined;
const caseInsensitive = input.case_insensitive as boolean | undefined;
const fileType = input.type as string | undefined;
const headLimit = (input.head_limit as number) || 200;
const offset = (input.offset as number) || 0;
const multiline = input.multiline as boolean | undefined;
// Try ripgrep first
if (isRgAvailable()) {
try {
const result = rgGrep({
pattern,
path,
outputMode: outputMode as "content" | "files_with_matches" | "count",
glob: globFilter,
type: fileType,
caseInsensitive: caseInsensitive || false,
multiline: multiline || false,
context: contextLines,
before: beforeLines,
after: afterLines,
headLimit,
});
if (result === null) return { success: true, output: "No matches found" };
// For count mode, filter zero-count files
if (outputMode === "count") {
const lines = result.split("\n").filter((l) => !l.endsWith(":0"));
const sliced = offset > 0 ? lines.slice(offset) : lines;
return { success: true, output: sliced.join("\n") || "No matches found" };
}
// Apply offset (skip first N entries)
if (offset > 0) {
const lines = result.split("\n");
const sliced = lines.slice(offset);
return { success: true, output: sliced.join("\n") || "No matches found" };
}
return { success: true, output: result };
} catch {
// Fall through to system grep
}
}
// Multiline requires rg — can't do with system grep
if (multiline) {
return { success: false, output: "Multiline search requires ripgrep (rg). Install: brew install ripgrep" };
}
// Fallback: system grep
const parts: string[] = ["grep", "-r"];
if (caseInsensitive) parts.push("-i");
switch (outputMode) {
case "files_with_matches": parts.push("-l"); break;
case "count": parts.push("-c"); break;
case "content": parts.push("-n"); break;
}
if (outputMode === "content") {
if (contextLines) parts.push(`-C ${contextLines}`);
if (beforeLines) parts.push(`-B ${beforeLines}`);
if (afterLines) parts.push(`-A ${afterLines}`);
}
const typeMap: Record<string, string> = {
js: "*.js", ts: "*.ts", tsx: "*.tsx", jsx: "*.jsx",
py: "*.py", rust: "*.rs", go: "*.go", java: "*.java",
rb: "*.rb", php: "*.php", css: "*.css", html: "*.html",
json: "*.json", yaml: "*.yaml", yml: "*.yml", md: "*.md",
swift: "*.swift", kt: "*.kt", cpp: "*.cpp", c: "*.c", h: "*.h",
sh: "*.sh", sql: "*.sql", xml: "*.xml", toml: "*.toml",
};
if (globFilter) {
parts.push(`--include='${globFilter.replace(/'/g, "'\\''")}'`);
} else if (fileType && typeMap[fileType]) {
parts.push(`--include='${typeMap[fileType]}'`);
}
parts.push("--exclude-dir='node_modules'", "--exclude-dir='.git'", "--exclude-dir='dist'");
const escaped = pattern.replace(/'/g, "'\\''");
parts.push(`'${escaped}'`, `'${path.replace(/'/g, "'\\''")}'`);
const cmd = `${parts.join(" ")} 2>/dev/null | head -${headLimit}`;
try {
const output = execSync(cmd, { encoding: "utf-8", timeout: 15000 });
const result = output.trim();
if (!result) return { success: true, output: "No matches found" };
if (outputMode === "count") {
const lines = result.split("\n").filter((l) => !l.endsWith(":0"));
return { success: true, output: lines.join("\n") || "No matches found" };
}
return { success: true, output: result };
} catch {
return { success: true, output: "No matches found" };
}
}