refactor(tools): remove native ast-grep tool
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -90,7 +90,6 @@ describe("team-mode tool registry wiring", () => {
|
||||
createSkillTool: mock(() => fakeTool),
|
||||
createGrepTools: mock(() => ({})),
|
||||
createGlobTools: mock(() => ({})),
|
||||
createAstGrepTools: mock(() => ({})),
|
||||
createSessionManagerTools: mock(() => ({})),
|
||||
createDelegateTask: mock(() => fakeTool),
|
||||
discoverCommandsSync: mock(() => []),
|
||||
@@ -164,7 +163,6 @@ describe("team-mode tool registry wiring", () => {
|
||||
createSkillTool: mock(() => fakeTool),
|
||||
createGrepTools: mock(() => ({})),
|
||||
createGlobTools: mock(() => ({})),
|
||||
createAstGrepTools: mock(() => ({})),
|
||||
createSessionManagerTools: mock(() => ({})),
|
||||
createDelegateTask: mock(() => fakeTool),
|
||||
discoverCommandsSync: mock(() => []),
|
||||
|
||||
@@ -53,7 +53,6 @@ const toolFactories: NonNullable<Parameters<typeof createToolRegistry>[0]["toolF
|
||||
createSkillTool: mock(() => fakeTool),
|
||||
createGrepTools: mock(() => ({})),
|
||||
createGlobTools: mock(() => ({})),
|
||||
createAstGrepTools: mock(() => ({})),
|
||||
createSessionManagerTools: mock(() => ({})),
|
||||
createDelegateTask: mock((options: { onSyncSessionCreated?: typeof syncSessionCreatedCallbacks[number] }) => {
|
||||
syncSessionCreatedCallbacks.push(options.onSyncSessionCreated)
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
createSkillTool,
|
||||
createGrepTools,
|
||||
createGlobTools,
|
||||
createAstGrepTools,
|
||||
createSessionManagerTools,
|
||||
createDelegateTask,
|
||||
discoverCommandsSync,
|
||||
@@ -59,7 +58,6 @@ type ToolRegistryFactories = {
|
||||
createSkillTool: typeof createSkillTool
|
||||
createGrepTools: typeof createGrepTools
|
||||
createGlobTools: typeof createGlobTools
|
||||
createAstGrepTools: typeof createAstGrepTools
|
||||
createSessionManagerTools: typeof createSessionManagerTools
|
||||
createDelegateTask: typeof createDelegateTask
|
||||
discoverCommandsSync: typeof discoverCommandsSync
|
||||
@@ -91,7 +89,6 @@ const defaultToolRegistryFactories: ToolRegistryFactories = {
|
||||
createSkillTool,
|
||||
createGrepTools,
|
||||
createGlobTools,
|
||||
createAstGrepTools,
|
||||
createSessionManagerTools,
|
||||
createDelegateTask,
|
||||
discoverCommandsSync,
|
||||
@@ -338,7 +335,6 @@ export function createToolRegistry(args: {
|
||||
const allTools: Record<string, ToolDefinition> = {
|
||||
...factories.createGrepTools(ctx),
|
||||
...factories.createGlobTools(ctx),
|
||||
...factories.createAstGrepTools(ctx),
|
||||
...factories.createSessionManagerTools(ctx),
|
||||
...backgroundTools,
|
||||
call_omo_agent: callOmoAgent,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# src/tools/ast-grep/ -- AST-Aware Search and Rewrite
|
||||
|
||||
**Generated:** 2026-05-18
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Two always-on tools: `ast_grep_search` (find AST patterns) and `ast_grep_replace` (rewrite AST patterns). 25 languages supported via `@ast-grep/napi` as primary backend with fallback to `sg` CLI.
|
||||
|
||||
Pattern syntax uses AST meta-variables, not regex. `$VAR` matches one AST node. `$$$` matches zero or more nodes. `$$$VAR` captures a named list. Patterns must be complete, parseable source code.
|
||||
|
||||
`ast_grep_replace` defaults to dry-run. Pass `dryRun=false` to apply changes.
|
||||
|
||||
## FILE CATALOG
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `tools.ts` | `createAstGrepTools` factory -- returns Record with 2 tool entries |
|
||||
| `cli.ts` | `runSg` -- spawns sg process, handles two-pass rewrite |
|
||||
| `cli-binary-path-resolution.ts` | Async init wrapper with singleton promise dedup |
|
||||
| `sg-cli-path.ts` | Resolve sg via node_modules, platform subpackages, Homebrew, or cache |
|
||||
| `downloader.ts` | Auto-download from GitHub releases if missing |
|
||||
| `environment-check.ts` | Verify CLI + NAPI availability at startup |
|
||||
| `language-support.ts` | 25 CLI languages + 5 NAPI languages + extension map |
|
||||
| `pattern-hints.ts` | Detect regex misuse and language-specific mistakes |
|
||||
| `result-formatter.ts` | Format matches with file:line:column for LLM |
|
||||
| `sg-compact-json-output.ts` | Parse `sg --json=compact` into `SgResult` |
|
||||
| `tool-descriptions.ts` | Tool description constants |
|
||||
| `process-output-timeout.ts` | 300s timeout wrapper for spawn |
|
||||
| `types.ts` | `CliMatch`, `SgResult`, `AnalyzeResult`, etc. |
|
||||
| `constants.ts` | Re-exports from language-support, environment-check, sg-cli-path |
|
||||
| `index.ts` | Barrel |
|
||||
|
||||
## KEY BEHAVIORS
|
||||
|
||||
- Dual binary detection: NAPI primary, CLI fallback
|
||||
- Fallback chain: node_modules → platform subpackage → Homebrew → cached download
|
||||
- Dry-run protection: `ast_grep_replace` defaults to preview; pass `dryRun=false` to apply
|
||||
- Two-pass rewrite: when rewrite + apply both requested, cli.ts runs `--json=compact` first, then `--update-all`
|
||||
- Output limits: 1MB max output or 500 matches, whichever comes first
|
||||
- Timeout: 300s cap via `process-output-timeout.ts`; kills process and returns truncated result
|
||||
|
||||
## LANGUAGES
|
||||
|
||||
25 CLI languages: bash, c, cpp, csharp, css, elixir, go, haskell, html, java, javascript, json, kotlin, lua, nix, php, python, ruby, rust, scala, solidity, swift, typescript, tsx, yaml.
|
||||
|
||||
5 NAPI languages (native bindings): html, javascript, tsx, css, typescript.
|
||||
|
||||
## PATTERN HINTS
|
||||
|
||||
When a search returns zero matches, `pattern-hints.ts` scans for regex-style misuse (`|`, `.*`, `\w`, `[a-z]`) and returns a corrective hint redirecting to ast-grep meta-variable syntax. Also catches language-specific mistakes like trailing colons in Python def/class patterns or incomplete function signatures in JS/Go/Rust.
|
||||
|
||||
## RELATED
|
||||
|
||||
Doctor check at `src/cli/doctor/checks/tools.ts` verifies both NAPI and CLI availability.
|
||||
@@ -1,60 +0,0 @@
|
||||
import { existsSync } from "fs"
|
||||
|
||||
import { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./constants"
|
||||
import { ensureAstGrepBinary } from "./downloader"
|
||||
|
||||
let resolvedCliPath: string | null = null
|
||||
let initPromise: Promise<string | null> | null = null
|
||||
|
||||
export async function getAstGrepPath(): Promise<string | null> {
|
||||
if (resolvedCliPath !== null && existsSync(resolvedCliPath)) {
|
||||
return resolvedCliPath
|
||||
}
|
||||
|
||||
if (initPromise) {
|
||||
return initPromise
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
const syncPath = findSgCliPathSync()
|
||||
if (syncPath && existsSync(syncPath)) {
|
||||
resolvedCliPath = syncPath
|
||||
setSgCliPath(syncPath)
|
||||
return syncPath
|
||||
}
|
||||
|
||||
const downloadedPath = await ensureAstGrepBinary()
|
||||
if (downloadedPath) {
|
||||
resolvedCliPath = downloadedPath
|
||||
setSgCliPath(downloadedPath)
|
||||
return downloadedPath
|
||||
}
|
||||
|
||||
return null
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
export function startBackgroundInit(): void {
|
||||
if (!initPromise) {
|
||||
initPromise = getAstGrepPath()
|
||||
initPromise.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export function isCliAvailable(): boolean {
|
||||
const path = findSgCliPathSync()
|
||||
return path !== null && existsSync(path)
|
||||
}
|
||||
|
||||
export async function ensureCliAvailable(): Promise<boolean> {
|
||||
const path = await getAstGrepPath()
|
||||
return path !== null && existsSync(path)
|
||||
}
|
||||
|
||||
export function getResolvedSgCliPath(): string | null {
|
||||
const path = getSgCliPath()
|
||||
if (path && existsSync(path)) return path
|
||||
return null
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { spawn } from "../../shared/bun-spawn-shim"
|
||||
import { existsSync } from "fs"
|
||||
import {
|
||||
getSgCliPath,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
} from "./constants"
|
||||
import { ensureAstGrepBinary } from "./downloader"
|
||||
import type { CliLanguage, SgResult } from "./types"
|
||||
|
||||
import { getAstGrepPath } from "./cli-binary-path-resolution"
|
||||
import { collectProcessOutputWithTimeout } from "./process-output-timeout"
|
||||
import { createSgResultFromStdout } from "./sg-compact-json-output"
|
||||
|
||||
export {
|
||||
ensureCliAvailable,
|
||||
getAstGrepPath,
|
||||
isCliAvailable,
|
||||
startBackgroundInit,
|
||||
} from "./cli-binary-path-resolution"
|
||||
|
||||
export interface RunOptions {
|
||||
pattern: string
|
||||
lang: CliLanguage
|
||||
paths?: string[]
|
||||
globs?: string[]
|
||||
rewrite?: string
|
||||
context?: number
|
||||
updateAll?: boolean
|
||||
}
|
||||
|
||||
export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||
// ast-grep CLI silently ignores --update-all when --json is present.
|
||||
// When both rewrite and updateAll are requested, we must run two separate
|
||||
// invocations: one with --json=compact to collect match results, and
|
||||
// another with --update-all to perform the actual file writes.
|
||||
const shouldSeparateWritePass = !!(options.rewrite && options.updateAll)
|
||||
|
||||
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
|
||||
|
||||
if (options.rewrite) {
|
||||
args.push("-r", options.rewrite)
|
||||
if (options.updateAll && !shouldSeparateWritePass) {
|
||||
args.push("--update-all")
|
||||
}
|
||||
}
|
||||
|
||||
if (options.context && options.context > 0) {
|
||||
args.push("-C", String(options.context))
|
||||
}
|
||||
|
||||
if (options.globs) {
|
||||
for (const glob of options.globs) {
|
||||
args.push("--globs", glob)
|
||||
}
|
||||
}
|
||||
|
||||
const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]
|
||||
args.push(...paths)
|
||||
|
||||
let cliPath = getSgCliPath()
|
||||
|
||||
if (!cliPath || !existsSync(cliPath)) {
|
||||
const downloadedPath = await getAstGrepPath()
|
||||
if (downloadedPath) {
|
||||
cliPath = downloadedPath
|
||||
} else {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error:
|
||||
`ast-grep (sg) binary not found.\n\n` +
|
||||
`Install options:\n` +
|
||||
` bun add -D @ast-grep/cli\n` +
|
||||
` cargo install ast-grep --locked\n` +
|
||||
` brew install ast-grep`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = DEFAULT_TIMEOUT_MS
|
||||
|
||||
const proc = spawn([cliPath, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
let stdout: string
|
||||
let stderr: string
|
||||
let exitCode: number
|
||||
|
||||
try {
|
||||
const output = await collectProcessOutputWithTimeout(proc, timeout)
|
||||
stdout = output.stdout
|
||||
stderr = output.stderr
|
||||
exitCode = output.exitCode
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("timeout")) {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: true,
|
||||
truncatedReason: "timeout",
|
||||
error: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const errorCode =
|
||||
typeof error === "object" && error !== null && "code" in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined
|
||||
const isNoEntry =
|
||||
errorCode === "ENOENT" || errorMessage.includes("ENOENT") || errorMessage.includes("not found")
|
||||
|
||||
if (isNoEntry) {
|
||||
const downloadedPath = await ensureAstGrepBinary()
|
||||
if (downloadedPath) {
|
||||
return runSg(options)
|
||||
} else {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error:
|
||||
`ast-grep CLI binary not found.\n\n` +
|
||||
`Auto-download failed. Manual install options:\n` +
|
||||
` bun add -D @ast-grep/cli\n` +
|
||||
` cargo install ast-grep --locked\n` +
|
||||
` brew install ast-grep`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error: `Failed to spawn ast-grep: ${errorMessage}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0 && stdout.trim() === "") {
|
||||
if (stderr.includes("No files found")) {
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
if (stderr.trim()) {
|
||||
return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() }
|
||||
}
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
|
||||
const jsonResult = createSgResultFromStdout(stdout)
|
||||
|
||||
if (shouldSeparateWritePass && jsonResult.matches.length > 0) {
|
||||
const writeArgs = args.filter(a => a !== "--json=compact")
|
||||
writeArgs.push("--update-all")
|
||||
|
||||
const writeProc = spawn([cliPath, ...writeArgs], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
try {
|
||||
const writeOutput = await collectProcessOutputWithTimeout(writeProc, timeout)
|
||||
if (writeOutput.exitCode !== 0) {
|
||||
const errorDetail = writeOutput.stderr.trim() || `ast-grep exited with code ${writeOutput.exitCode}`
|
||||
return { ...jsonResult, error: `Replace failed: ${errorDetail}` }
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { ...jsonResult, error: `Replace failed: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResult
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export type { EnvironmentCheckResult } from "./environment-check"
|
||||
export { checkEnvironment, formatEnvironmentCheck } from "./environment-check"
|
||||
export { CLI_LANGUAGES, NAPI_LANGUAGES, LANG_EXTENSIONS } from "./language-support"
|
||||
export { DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_MATCHES } from "./language-support"
|
||||
export { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./sg-cli-path"
|
||||
@@ -1,119 +0,0 @@
|
||||
import { existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { homedir } from "os"
|
||||
import { createRequire } from "module"
|
||||
import {
|
||||
cleanupArchive,
|
||||
downloadArchive,
|
||||
ensureCacheDir,
|
||||
ensureExecutable,
|
||||
extractZipArchive,
|
||||
getCachedBinaryPath as getCachedBinaryPathShared,
|
||||
} from "../../shared/binary-downloader"
|
||||
import { log } from "../../shared/logger"
|
||||
import { CACHE_DIR_NAME, PUBLISHED_PACKAGE_NAME } from "../../shared/plugin-identity"
|
||||
|
||||
const REPO = "ast-grep/ast-grep"
|
||||
|
||||
// IMPORTANT: Update this when bumping @ast-grep/cli in package.json
|
||||
// This is only used as fallback when @ast-grep/cli package.json cannot be read
|
||||
const DEFAULT_VERSION = "0.41.1"
|
||||
|
||||
function getAstGrepVersion(): string {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkg = require("@ast-grep/cli/package.json")
|
||||
return pkg.version
|
||||
} catch {
|
||||
return DEFAULT_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
interface PlatformInfo {
|
||||
arch: string
|
||||
os: string
|
||||
}
|
||||
|
||||
const PLATFORM_MAP: Record<string, PlatformInfo> = {
|
||||
"darwin-arm64": { arch: "aarch64", os: "apple-darwin" },
|
||||
"darwin-x64": { arch: "x86_64", os: "apple-darwin" },
|
||||
"linux-arm64": { arch: "aarch64", os: "unknown-linux-gnu" },
|
||||
"linux-x64": { arch: "x86_64", os: "unknown-linux-gnu" },
|
||||
"win32-x64": { arch: "x86_64", os: "pc-windows-msvc" },
|
||||
"win32-arm64": { arch: "aarch64", os: "pc-windows-msvc" },
|
||||
"win32-ia32": { arch: "i686", os: "pc-windows-msvc" },
|
||||
}
|
||||
|
||||
export function getCacheDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
|
||||
const base = localAppData || join(homedir(), "AppData", "Local")
|
||||
return join(base, CACHE_DIR_NAME, "bin")
|
||||
}
|
||||
|
||||
const xdgCache = process.env.XDG_CACHE_HOME
|
||||
const base = xdgCache || join(homedir(), ".cache")
|
||||
return join(base, CACHE_DIR_NAME, "bin")
|
||||
}
|
||||
|
||||
export function getBinaryName(): string {
|
||||
return process.platform === "win32" ? "sg.exe" : "sg"
|
||||
}
|
||||
|
||||
export function getCachedBinaryPath(): string | null {
|
||||
return getCachedBinaryPathShared(getCacheDir(), getBinaryName())
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promise<string | null> {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformInfo = PLATFORM_MAP[platformKey]
|
||||
|
||||
if (!platformInfo) {
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] Unsupported platform for ast-grep: ${platformKey}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheDir = getCacheDir()
|
||||
const binaryName = getBinaryName()
|
||||
const binaryPath = join(cacheDir, binaryName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
const { arch, os } = platformInfo
|
||||
const assetName = `app-${arch}-${os}.zip`
|
||||
const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`
|
||||
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] Downloading ast-grep binary...`)
|
||||
|
||||
try {
|
||||
const archivePath = join(cacheDir, assetName)
|
||||
ensureCacheDir(cacheDir)
|
||||
await downloadArchive(downloadUrl, archivePath)
|
||||
await extractZipArchive(archivePath, cacheDir)
|
||||
cleanupArchive(archivePath)
|
||||
ensureExecutable(binaryPath)
|
||||
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] ast-grep binary ready.`)
|
||||
|
||||
return binaryPath
|
||||
} catch (err) {
|
||||
log(
|
||||
`[${PUBLISHED_PACKAGE_NAME}] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureAstGrepBinary(): Promise<string | null> {
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
const version = getAstGrepVersion()
|
||||
return downloadAstGrep(version)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { existsSync } from "fs"
|
||||
|
||||
import { CLI_LANGUAGES, NAPI_LANGUAGES } from "./language-support"
|
||||
import { getSgCliPath } from "./sg-cli-path"
|
||||
|
||||
export interface EnvironmentCheckResult {
|
||||
cli: {
|
||||
available: boolean
|
||||
path: string
|
||||
error?: string
|
||||
}
|
||||
napi: {
|
||||
available: boolean
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ast-grep CLI and NAPI are available.
|
||||
* Call this at startup to provide early feedback about missing dependencies.
|
||||
*/
|
||||
export function checkEnvironment(): EnvironmentCheckResult {
|
||||
const cliPath = getSgCliPath()
|
||||
const result: EnvironmentCheckResult = {
|
||||
cli: {
|
||||
available: false,
|
||||
path: cliPath ?? "not found",
|
||||
},
|
||||
napi: {
|
||||
available: false,
|
||||
},
|
||||
}
|
||||
|
||||
if (cliPath && existsSync(cliPath)) {
|
||||
result.cli.available = true
|
||||
} else if (!cliPath) {
|
||||
result.cli.error = "ast-grep binary not found. Install with: bun add -D @ast-grep/cli"
|
||||
} else {
|
||||
result.cli.error = `Binary not found: ${cliPath}`
|
||||
}
|
||||
|
||||
// Check NAPI availability
|
||||
try {
|
||||
require("@ast-grep/napi")
|
||||
result.napi.available = true
|
||||
} catch (error) {
|
||||
result.napi.available = false
|
||||
result.napi.error = `@ast-grep/napi not installed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Format environment check result as user-friendly message.
|
||||
*/
|
||||
export function formatEnvironmentCheck(result: EnvironmentCheckResult): string {
|
||||
const lines: string[] = ["ast-grep Environment Status:", ""]
|
||||
|
||||
// CLI status
|
||||
if (result.cli.available) {
|
||||
lines.push(`[OK] CLI: Available (${result.cli.path})`)
|
||||
} else {
|
||||
lines.push("[X] CLI: Not available")
|
||||
if (result.cli.error) {
|
||||
lines.push(` Error: ${result.cli.error}`)
|
||||
}
|
||||
lines.push(" Install: bun add -D @ast-grep/cli")
|
||||
}
|
||||
|
||||
// NAPI status
|
||||
if (result.napi.available) {
|
||||
lines.push("[OK] NAPI: Available")
|
||||
} else {
|
||||
lines.push("[X] NAPI: Not available")
|
||||
if (result.napi.error) {
|
||||
lines.push(` Error: ${result.napi.error}`)
|
||||
}
|
||||
lines.push(" Install: bun add -D @ast-grep/napi")
|
||||
}
|
||||
|
||||
lines.push("")
|
||||
lines.push(`CLI supports ${CLI_LANGUAGES.length} languages`)
|
||||
lines.push(`NAPI supports ${NAPI_LANGUAGES.length} languages: ${NAPI_LANGUAGES.join(", ")}`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { createAstGrepTools } from "./tools"
|
||||
export { ensureAstGrepBinary, getCachedBinaryPath, getCacheDir } from "./downloader"
|
||||
export { getAstGrepPath, isCliAvailable, ensureCliAvailable, startBackgroundInit } from "./cli"
|
||||
export { checkEnvironment, formatEnvironmentCheck } from "./constants"
|
||||
export type { EnvironmentCheckResult } from "./constants"
|
||||
@@ -1,63 +0,0 @@
|
||||
// CLI supported languages (25 total)
|
||||
export const CLI_LANGUAGES = [
|
||||
"bash",
|
||||
"c",
|
||||
"cpp",
|
||||
"csharp",
|
||||
"css",
|
||||
"elixir",
|
||||
"go",
|
||||
"haskell",
|
||||
"html",
|
||||
"java",
|
||||
"javascript",
|
||||
"json",
|
||||
"kotlin",
|
||||
"lua",
|
||||
"nix",
|
||||
"php",
|
||||
"python",
|
||||
"ruby",
|
||||
"rust",
|
||||
"scala",
|
||||
"solidity",
|
||||
"swift",
|
||||
"typescript",
|
||||
"tsx",
|
||||
"yaml",
|
||||
] as const
|
||||
|
||||
// NAPI supported languages (5 total - native bindings)
|
||||
export const NAPI_LANGUAGES = ["html", "javascript", "tsx", "css", "typescript"] as const
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 300_000
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
|
||||
export const DEFAULT_MAX_MATCHES = 500
|
||||
|
||||
export const LANG_EXTENSIONS: Record<string, string[]> = {
|
||||
bash: [".bash", ".sh", ".zsh", ".bats"],
|
||||
c: [".c", ".h"],
|
||||
cpp: [".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".h"],
|
||||
csharp: [".cs"],
|
||||
css: [".css"],
|
||||
elixir: [".ex", ".exs"],
|
||||
go: [".go"],
|
||||
haskell: [".hs", ".lhs"],
|
||||
html: [".html", ".htm"],
|
||||
java: [".java"],
|
||||
javascript: [".js", ".jsx", ".mjs", ".cjs"],
|
||||
json: [".json"],
|
||||
kotlin: [".kt", ".kts"],
|
||||
lua: [".lua"],
|
||||
nix: [".nix"],
|
||||
php: [".php"],
|
||||
python: [".py", ".pyi"],
|
||||
ruby: [".rb", ".rake"],
|
||||
rust: [".rs"],
|
||||
scala: [".scala", ".sc"],
|
||||
solidity: [".sol"],
|
||||
swift: [".swift"],
|
||||
typescript: [".ts", ".cts", ".mts"],
|
||||
tsx: [".tsx"],
|
||||
yaml: [".yml", ".yaml"],
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
detectLanguageSpecificMistake,
|
||||
detectRegexMisuse,
|
||||
getPatternHint,
|
||||
} from "./pattern-hints"
|
||||
|
||||
describe("detectRegexMisuse", () => {
|
||||
describe("#given pure regex alternation", () => {
|
||||
it("#when pattern is lowercase alternation #then returns alternation hint", () => {
|
||||
// given
|
||||
const pattern = "watch|WatchMode|--watch"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).not.toBeNull()
|
||||
expect(hint).toContain("|")
|
||||
expect(hint).toContain("alternation")
|
||||
expect(hint).toContain("grep")
|
||||
})
|
||||
|
||||
it("#when pattern is camelCase alternation #then returns alternation hint", () => {
|
||||
// given
|
||||
const pattern = "noEmit|NoEmit"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toContain("alternation")
|
||||
})
|
||||
|
||||
it("#when pattern mixes wildcard and alternation #then returns a hint", () => {
|
||||
// given
|
||||
const pattern = "func.*build|BuildMode|projectReferences"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given valid AST patterns using |", () => {
|
||||
it("#when pattern uses meta-vars around pipe (bitwise OR) #then returns null", () => {
|
||||
// given
|
||||
const pattern = "$A | $B"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
|
||||
it("#when pattern is a Rust closure #then returns null", () => {
|
||||
// given
|
||||
const pattern = "|x| x + 1"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given regex escape sequences", () => {
|
||||
it("#when pattern contains \\w #then returns regex-escape hint", () => {
|
||||
// given
|
||||
const pattern = "\\w+Mode"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toContain("regex escape")
|
||||
expect(hint).toContain("grep")
|
||||
})
|
||||
|
||||
it("#when pattern contains \\d #then returns regex-escape hint", () => {
|
||||
// given
|
||||
const pattern = "id\\d+"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toContain("regex escape")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given character class ranges", () => {
|
||||
it("#when pattern contains [a-z] #then returns character-class hint", () => {
|
||||
// given
|
||||
const pattern = "[a-z]+Mode"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toContain("character classes")
|
||||
expect(hint).toContain("grep")
|
||||
})
|
||||
|
||||
it("#when pattern contains [0-9] #then returns character-class hint", () => {
|
||||
// given
|
||||
const pattern = "v[0-9]+"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toContain("character classes")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given regex wildcards embedded in identifiers", () => {
|
||||
it("#when pattern uses foo.*bar without meta-vars #then returns wildcard hint", () => {
|
||||
// given
|
||||
const pattern = "func.*build"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toContain("regex wildcards")
|
||||
expect(hint).toContain("$$$")
|
||||
})
|
||||
|
||||
it("#when pattern uses $$$ (proper AST) #then returns null", () => {
|
||||
// given
|
||||
const pattern = "func $NAME($$$) { $$$ }"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given legitimate AST patterns", () => {
|
||||
it("#when pattern is a JS function #then returns null", () => {
|
||||
// given
|
||||
const pattern = "function $NAME($$$) { $$$ }"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
|
||||
it("#when pattern is console.log call #then returns null", () => {
|
||||
// given
|
||||
const pattern = "console.log($$$)"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
|
||||
it("#when pattern is a Python def #then returns null", () => {
|
||||
// given
|
||||
const pattern = "def $FUNC($$$)"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
|
||||
it("#when pattern is array access a[0] #then returns null (not character class)", () => {
|
||||
// given
|
||||
const pattern = "$A[0]"
|
||||
|
||||
// when
|
||||
const hint = detectRegexMisuse(pattern)
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectLanguageSpecificMistake", () => {
|
||||
describe("#given a Python def with trailing colon", () => {
|
||||
it("#when lang is python #then suggests removing the colon", () => {
|
||||
// given
|
||||
const pattern = "def $FUNC($$$):"
|
||||
|
||||
// when
|
||||
const hint = detectLanguageSpecificMistake(pattern, "python")
|
||||
|
||||
// then
|
||||
expect(hint).toContain("Remove trailing colon")
|
||||
expect(hint).toContain("def $FUNC($$$)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a Python class with trailing colon", () => {
|
||||
it("#when lang is python #then suggests removing the colon", () => {
|
||||
// given
|
||||
const pattern = "class $C:"
|
||||
|
||||
// when
|
||||
const hint = detectLanguageSpecificMistake(pattern, "python")
|
||||
|
||||
// then
|
||||
expect(hint).toContain("Remove trailing colon")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a TypeScript function with no body", () => {
|
||||
it("#when lang is typescript #then suggests adding params and body", () => {
|
||||
// given
|
||||
const pattern = "function $NAME"
|
||||
|
||||
// when
|
||||
const hint = detectLanguageSpecificMistake(pattern, "typescript")
|
||||
|
||||
// then
|
||||
expect(hint).toContain("params and body")
|
||||
expect(hint).toContain("function $NAME($$$) { $$$ }")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a Go function with no body", () => {
|
||||
it("#when lang is go #then suggests Go function template", () => {
|
||||
// given
|
||||
const pattern = "func $NAME"
|
||||
|
||||
// when
|
||||
const hint = detectLanguageSpecificMistake(pattern, "go")
|
||||
|
||||
// then
|
||||
expect(hint).not.toBeNull()
|
||||
expect(hint).toContain("func $NAME($$$) { $$$ }")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a Rust fn with no body", () => {
|
||||
it("#when lang is rust #then suggests Rust fn template", () => {
|
||||
// given
|
||||
const pattern = "fn $NAME"
|
||||
|
||||
// when
|
||||
const hint = detectLanguageSpecificMistake(pattern, "rust")
|
||||
|
||||
// then
|
||||
expect(hint).not.toBeNull()
|
||||
expect(hint).toContain("fn $NAME($$$) { $$$ }")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPatternHint", () => {
|
||||
it("#given regex alternation #when composing #then regex hint wins over language check", () => {
|
||||
// given
|
||||
const pattern = "foo|bar"
|
||||
|
||||
// when
|
||||
const hint = getPatternHint(pattern, "typescript")
|
||||
|
||||
// then
|
||||
expect(hint).toContain("alternation")
|
||||
})
|
||||
|
||||
it("#given a clean AST pattern #when composing #then returns null", () => {
|
||||
// given
|
||||
const pattern = "function $NAME($$$) { $$$ }"
|
||||
|
||||
// when
|
||||
const hint = getPatternHint(pattern, "typescript")
|
||||
|
||||
// then
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
|
||||
it("#given a Python def with trailing colon #when composing #then returns the colon hint", () => {
|
||||
// given
|
||||
const pattern = "def $FUNC($$$):"
|
||||
|
||||
// when
|
||||
const hint = getPatternHint(pattern, "python")
|
||||
|
||||
// then
|
||||
expect(hint).toContain("Remove trailing colon")
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { CliLanguage } from "./types"
|
||||
|
||||
export function detectRegexMisuse(pattern: string): string | null {
|
||||
const src = pattern.trim()
|
||||
|
||||
if (/\\[wWdDsSbB]/.test(src)) {
|
||||
return 'Hint: "\\w", "\\d", "\\s", "\\b" are regex escapes. ast-grep matches AST nodes, not text - use $VAR for identifiers, $$$ for node lists, or switch to grep for text search.'
|
||||
}
|
||||
|
||||
if (/\[[a-zA-Z0-9]-[a-zA-Z0-9]\]/.test(src)) {
|
||||
return 'Hint: "[a-z]" and similar character classes are regex, not AST. Use $VAR to match any identifier, or switch to grep for text search.'
|
||||
}
|
||||
|
||||
if (!src.includes("$") && /\w\.[*+]/.test(src)) {
|
||||
return 'Hint: ".*" and ".+" are regex wildcards. In ast-grep use $$$ for multiple AST nodes and $VAR for a single node. For text patterns, switch to grep.'
|
||||
}
|
||||
|
||||
if (/^[-\w.*]+\|[-\w.*|]+$/.test(src)) {
|
||||
return 'Hint: "|" is regex alternation and does NOT work in ast-grep patterns. Options: (a) fire one ast_grep_search per alternative, or (b) switch to grep with a regex pattern like "foo|bar".'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function detectLanguageSpecificMistake(
|
||||
pattern: string,
|
||||
lang: CliLanguage,
|
||||
): string | null {
|
||||
const src = pattern.trim()
|
||||
|
||||
if (lang === "python") {
|
||||
if (src.startsWith("class ") && src.endsWith(":")) {
|
||||
return `Hint: Remove trailing colon. Try: "${src.slice(0, -1)}"`
|
||||
}
|
||||
if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
|
||||
return `Hint: Remove trailing colon. Try: "${src.slice(0, -1)}"`
|
||||
}
|
||||
}
|
||||
|
||||
if (["javascript", "typescript", "tsx"].includes(lang)) {
|
||||
if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) {
|
||||
return 'Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"'
|
||||
}
|
||||
}
|
||||
|
||||
if (lang === "go") {
|
||||
if (/^func\s+\$[A-Z_]+\s*$/i.test(src)) {
|
||||
return 'Hint: Go function patterns need params and body. Try "func $NAME($$$) { $$$ }"'
|
||||
}
|
||||
}
|
||||
|
||||
if (lang === "rust") {
|
||||
if (/^fn\s+\$[A-Z_]+\s*$/i.test(src)) {
|
||||
return 'Hint: Rust fn patterns need params and body. Try "fn $NAME($$$) { $$$ }"'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getPatternHint(pattern: string, lang: CliLanguage): string | null {
|
||||
return detectRegexMisuse(pattern) ?? detectLanguageSpecificMistake(pattern, lang)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
type SpawnedProcess = {
|
||||
stdout: ReadableStream | null
|
||||
stderr: ReadableStream | null
|
||||
exited: Promise<number>
|
||||
kill: () => void
|
||||
}
|
||||
|
||||
export async function collectProcessOutputWithTimeout(
|
||||
process: SpawnedProcess,
|
||||
timeoutMs: number
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
process.kill()
|
||||
reject(new Error(`Search timeout after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
process.exited.then(() => clearTimeout(timeoutId))
|
||||
})
|
||||
|
||||
const stdoutPromise = process.stdout ? new Response(process.stdout).text() : Promise.resolve("")
|
||||
const stderrPromise = process.stderr ? new Response(process.stderr).text() : Promise.resolve("")
|
||||
|
||||
const stdout = await Promise.race([stdoutPromise, timeoutPromise])
|
||||
const stderr = await stderrPromise
|
||||
const exitCode = await process.exited
|
||||
|
||||
return { stdout, stderr, exitCode }
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { AnalyzeResult, SgResult } from "./types"
|
||||
|
||||
export function formatSearchResult(result: SgResult): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
return "No matches found"
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
if (result.truncated) {
|
||||
const reason = result.truncatedReason === "max_matches"
|
||||
? `showing first ${result.matches.length} of ${result.totalMatches}`
|
||||
: result.truncatedReason === "max_output_bytes"
|
||||
? "output exceeded 1MB limit"
|
||||
: "search timed out"
|
||||
lines.push(`[TRUNCATED] Results truncated (${reason})\n`)
|
||||
}
|
||||
|
||||
lines.push(`Found ${result.matches.length} match(es)${result.truncated ? ` (truncated from ${result.totalMatches})` : ""}:\n`)
|
||||
|
||||
for (const match of result.matches) {
|
||||
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
|
||||
lines.push(`${loc}`)
|
||||
lines.push(` ${match.lines.trim()}`)
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatReplaceResult(result: SgResult, isDryRun: boolean): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
return "No matches found to replace"
|
||||
}
|
||||
|
||||
const prefix = isDryRun ? "[DRY RUN] " : ""
|
||||
const lines: string[] = []
|
||||
|
||||
if (result.truncated) {
|
||||
const reason = result.truncatedReason === "max_matches"
|
||||
? `showing first ${result.matches.length} of ${result.totalMatches}`
|
||||
: result.truncatedReason === "max_output_bytes"
|
||||
? "output exceeded 1MB limit"
|
||||
: "search timed out"
|
||||
lines.push(`[TRUNCATED] Results truncated (${reason})\n`)
|
||||
}
|
||||
|
||||
lines.push(`${prefix}${result.matches.length} replacement(s):\n`)
|
||||
|
||||
for (const match of result.matches) {
|
||||
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
|
||||
lines.push(`${loc}`)
|
||||
lines.push(` ${match.text}`)
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
lines.push("Use dryRun=false to apply changes")
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatAnalyzeResult(results: AnalyzeResult[], extractedMetaVars: boolean): string {
|
||||
if (results.length === 0) {
|
||||
return "No matches found"
|
||||
}
|
||||
|
||||
const lines: string[] = [`Found ${results.length} match(es):\n`]
|
||||
|
||||
for (const result of results) {
|
||||
const loc = `L${result.range.start.line + 1}:${result.range.start.column + 1}`
|
||||
lines.push(`[${loc}] (${result.kind})`)
|
||||
lines.push(` ${result.text}`)
|
||||
|
||||
if (extractedMetaVars && result.metaVariables.length > 0) {
|
||||
lines.push(" Meta-variables:")
|
||||
for (const mv of result.metaVariables) {
|
||||
lines.push(` $${mv.name} = "${mv.text}" (${mv.kind})`)
|
||||
}
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatTransformResult(_original: string, transformed: string, editCount: number): string {
|
||||
if (editCount === 0) {
|
||||
return "No matches found to transform"
|
||||
}
|
||||
|
||||
return `Transformed (${editCount} edit(s)):\n\`\`\`\n${transformed}\n\`\`\``
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { createRequire } from "module"
|
||||
import { dirname, join } from "path"
|
||||
import { existsSync, statSync } from "fs"
|
||||
|
||||
import { getCachedBinaryPath } from "./downloader"
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
function isValidBinary(filePath: string): boolean {
|
||||
try {
|
||||
return statSync(filePath).size > 10000
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getPlatformPackageName(): string | null {
|
||||
const platform = process.platform as Platform
|
||||
const arch = process.arch
|
||||
|
||||
const platformMap: Record<string, string> = {
|
||||
"darwin-arm64": "@ast-grep/cli-darwin-arm64",
|
||||
"darwin-x64": "@ast-grep/cli-darwin-x64",
|
||||
"linux-arm64": "@ast-grep/cli-linux-arm64-gnu",
|
||||
"linux-x64": "@ast-grep/cli-linux-x64-gnu",
|
||||
"win32-x64": "@ast-grep/cli-win32-x64-msvc",
|
||||
"win32-arm64": "@ast-grep/cli-win32-arm64-msvc",
|
||||
"win32-ia32": "@ast-grep/cli-win32-ia32-msvc",
|
||||
}
|
||||
|
||||
return platformMap[`${platform}-${arch}`] ?? null
|
||||
}
|
||||
|
||||
export function findSgCliPathSync(): string | null {
|
||||
const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
|
||||
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath && isValidBinary(cachedPath)) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const cliPackageJsonPath = require.resolve("@ast-grep/cli/package.json")
|
||||
const cliDirectory = dirname(cliPackageJsonPath)
|
||||
const sgPath = join(cliDirectory, binaryName)
|
||||
|
||||
if (existsSync(sgPath) && isValidBinary(sgPath)) {
|
||||
return sgPath
|
||||
}
|
||||
} catch {
|
||||
// @ast-grep/cli not installed
|
||||
}
|
||||
|
||||
const platformPackage = getPlatformPackageName()
|
||||
if (platformPackage) {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJsonPath = require.resolve(`${platformPackage}/package.json`)
|
||||
const packageDirectory = dirname(packageJsonPath)
|
||||
const astGrepBinaryName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
|
||||
const binaryPath = join(packageDirectory, astGrepBinaryName)
|
||||
|
||||
if (existsSync(binaryPath) && isValidBinary(binaryPath)) {
|
||||
return binaryPath
|
||||
}
|
||||
} catch {
|
||||
// Platform-specific package not installed
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"]
|
||||
for (const path of homebrewPaths) {
|
||||
if (existsSync(path) && isValidBinary(path)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
let resolvedCliPath: string | null = null
|
||||
|
||||
export function getSgCliPath(): string | null {
|
||||
if (resolvedCliPath !== null) {
|
||||
return resolvedCliPath
|
||||
}
|
||||
|
||||
const syncPath = findSgCliPathSync()
|
||||
if (syncPath) {
|
||||
resolvedCliPath = syncPath
|
||||
return syncPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function setSgCliPath(path: string): void {
|
||||
resolvedCliPath = path
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { DEFAULT_MAX_MATCHES, DEFAULT_MAX_OUTPUT_BYTES } from "./constants"
|
||||
import type { CliMatch, SgResult } from "./types"
|
||||
|
||||
export function createSgResultFromStdout(stdout: string): SgResult {
|
||||
if (!stdout.trim()) {
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
|
||||
const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
|
||||
const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
|
||||
|
||||
let matches: CliMatch[] = []
|
||||
try {
|
||||
matches = JSON.parse(outputToProcess) as CliMatch[]
|
||||
} catch {
|
||||
if (outputTruncated) {
|
||||
try {
|
||||
const lastValidIndex = outputToProcess.lastIndexOf("}")
|
||||
if (lastValidIndex > 0) {
|
||||
const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex)
|
||||
if (bracketIndex > 0) {
|
||||
const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]"
|
||||
matches = JSON.parse(truncatedJson) as CliMatch[]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: true,
|
||||
truncatedReason: "max_output_bytes",
|
||||
error: "Output too large and could not be parsed",
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatches = matches.length
|
||||
const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES
|
||||
const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches
|
||||
|
||||
return {
|
||||
matches: finalMatches,
|
||||
totalMatches,
|
||||
truncated: outputTruncated || matchesTruncated,
|
||||
truncatedReason: outputTruncated
|
||||
? "max_output_bytes"
|
||||
: matchesTruncated
|
||||
? "max_matches"
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
AST_GREP_REPLACE_DESCRIPTION,
|
||||
AST_GREP_SEARCH_DESCRIPTION,
|
||||
AST_GREP_SEARCH_PATTERN_PARAM,
|
||||
} from "./tool-descriptions"
|
||||
|
||||
describe("AST_GREP_SEARCH_DESCRIPTION", () => {
|
||||
it("#given the description #when inspecting #then asserts it is NOT regex", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("NOT regex")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then explains meta-variables $VAR and $$$", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("$VAR")
|
||||
expect(description).toContain("$$$")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then warns against regex alternation", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("alternation")
|
||||
expect(description).toContain("|")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then warns against regex wildcards", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain(".*")
|
||||
expect(description).toContain("wildcards")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then warns against regex escapes", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("\\w")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then warns against character classes", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("[a-z]")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then tells LLM to use grep as fallback", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description.toLowerCase()).toContain("grep")
|
||||
})
|
||||
|
||||
it("#given the description #when showing Python example #then omits the trailing colon bug", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).not.toContain("def $FUNC($$$):")
|
||||
expect(description).toContain("def $FUNC($$$)")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then shows TypeScript example", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("typescript")
|
||||
expect(description).toContain("function $NAME($$$) { $$$ }")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then shows Go example", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("go")
|
||||
expect(description).toContain("func $NAME($$$) { $$$ }")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then shows Rust example", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("rust")
|
||||
expect(description).toContain("fn $NAME(")
|
||||
})
|
||||
|
||||
it("#given the description #when measuring #then stays within a token-reasonable length", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description.length).toBeLessThan(2000)
|
||||
expect(description.length).toBeGreaterThan(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe("AST_GREP_SEARCH_PATTERN_PARAM", () => {
|
||||
it("#given the param description #when inspecting #then states meta-var rules", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_PATTERN_PARAM
|
||||
|
||||
// then
|
||||
expect(description).toContain("$VAR")
|
||||
expect(description).toContain("$$$")
|
||||
})
|
||||
|
||||
it("#given the param description #when inspecting #then forbids regex syntax", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_PATTERN_PARAM
|
||||
|
||||
// then
|
||||
expect(description).toContain("NOT regex")
|
||||
expect(description).toContain("|")
|
||||
expect(description).toContain(".*")
|
||||
})
|
||||
|
||||
it("#given the param description #when inspecting #then directs to grep for fallback", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_SEARCH_PATTERN_PARAM
|
||||
|
||||
// then
|
||||
expect(description.toLowerCase()).toContain("grep")
|
||||
})
|
||||
})
|
||||
|
||||
describe("AST_GREP_REPLACE_DESCRIPTION", () => {
|
||||
it("#given the description #when inspecting #then mentions AST meta-variables", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_REPLACE_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("$VAR")
|
||||
expect(description).toContain("$$$")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then warns against regex", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_REPLACE_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description.toLowerCase()).toContain("regex does not work")
|
||||
})
|
||||
|
||||
it("#given the description #when inspecting #then provides an example", () => {
|
||||
// given / when
|
||||
const description = AST_GREP_REPLACE_DESCRIPTION
|
||||
|
||||
// then
|
||||
expect(description).toContain("console.log($MSG)")
|
||||
expect(description).toContain("logger.info($MSG)")
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
export const AST_GREP_SEARCH_DESCRIPTION = [
|
||||
"Search code by AST structure (25 languages). This is NOT regex.",
|
||||
"",
|
||||
"Meta-variables (the only wildcards ast-grep understands):",
|
||||
" $VAR - one AST node (an identifier, expression, statement, ...)",
|
||||
" $$$ - zero or more nodes (argument lists, function bodies, ...)",
|
||||
" $$$VAR - same, captured by name",
|
||||
"Patterns must be complete, parseable source code. Each meta-variable replaces a whole node, not a substring.",
|
||||
"",
|
||||
"Regex syntax does NOT work - never pass these to pattern:",
|
||||
' "foo|bar" alternation → run separate calls, or switch to grep',
|
||||
' ".*", ".+" wildcards → use $$$ between AST fragments',
|
||||
' "\\w", "\\d" escapes → use $VAR to capture any identifier',
|
||||
' "[a-z]" class ranges → no AST equivalent',
|
||||
"For text search, cross-language search, or regex features, use the grep tool instead.",
|
||||
"",
|
||||
"Examples by language:",
|
||||
' typescript/tsx "function $NAME($$$) { $$$ }", "console.log($$$)", "import { $$$ } from \'$MOD\'"',
|
||||
' python "def $FUNC($$$)", "class $C($$$)" - no trailing colon',
|
||||
' go "func $NAME($$$) { $$$ }", "if err != nil { $$$ }"',
|
||||
' rust "fn $NAME($$$) -> $RET { $$$ }", "impl $TRAIT for $T { $$$ }"',
|
||||
"",
|
||||
"On empty results the tool returns a hint naming the exact mistake. If the pattern is fundamentally text-shaped, stop retrying and switch to grep.",
|
||||
].join("\n")
|
||||
|
||||
export const AST_GREP_SEARCH_PATTERN_PARAM =
|
||||
"AST pattern - valid, parseable code using $VAR (one node) and $$$ (many nodes). NOT regex: no `|`, no `.*`, no `\\w`, no `[a-z]`. For text or alternation, use grep instead."
|
||||
|
||||
export const AST_GREP_REPLACE_DESCRIPTION = [
|
||||
"Rewrite code by AST pattern (25 languages). Dry-run by default.",
|
||||
"Both pattern and rewrite use AST syntax ($VAR for one node, $$$ for many) - regex does NOT work.",
|
||||
"Meta-variables captured in pattern can be reused in rewrite to preserve matched content.",
|
||||
'Example: pattern="console.log($MSG)" rewrite="logger.info($MSG)"',
|
||||
"For text-only replacement or regex features, use a text editor instead.",
|
||||
].join("\n")
|
||||
@@ -1,55 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { AST_GREP_REPLACE_DESCRIPTION, AST_GREP_SEARCH_DESCRIPTION } from "./tool-descriptions"
|
||||
|
||||
const runSgMock = mock(async () => ({
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
}))
|
||||
|
||||
mock.module("./cli", () => ({
|
||||
runSg: runSgMock,
|
||||
}))
|
||||
|
||||
import { createAstGrepTools } from "./tools"
|
||||
|
||||
describe("createAstGrepTools", () => {
|
||||
beforeEach(() => {
|
||||
runSgMock.mockClear()
|
||||
})
|
||||
|
||||
it("#given the production tool factory #when creating tools #then exposes shared ast-grep descriptions", () => {
|
||||
// given / when
|
||||
const tools = createAstGrepTools({ directory: "/repo" } as never)
|
||||
|
||||
// then
|
||||
expect(tools.ast_grep_search.description).toBe(AST_GREP_SEARCH_DESCRIPTION)
|
||||
expect(tools.ast_grep_replace.description).toBe(AST_GREP_REPLACE_DESCRIPTION)
|
||||
expect(tools.ast_grep_search.description).toContain("NOT regex")
|
||||
})
|
||||
|
||||
it("#given empty search results from a regex-shaped pattern #when executing #then appends the pattern hint", async () => {
|
||||
// given
|
||||
const tools = createAstGrepTools({ directory: "/repo" } as never)
|
||||
|
||||
// when
|
||||
const output = await tools.ast_grep_search.execute(
|
||||
{ pattern: "foo|bar", lang: "typescript" },
|
||||
{},
|
||||
)
|
||||
|
||||
// then
|
||||
expect(output).toContain("No matches found")
|
||||
expect(output).toContain("alternation")
|
||||
expect(output).toContain("grep")
|
||||
expect(runSgMock).toHaveBeenCalledWith({
|
||||
pattern: "foo|bar",
|
||||
lang: "typescript",
|
||||
paths: ["/repo"],
|
||||
globs: undefined,
|
||||
context: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { CLI_LANGUAGES } from "./constants"
|
||||
import { runSg } from "./cli"
|
||||
import { formatSearchResult, formatReplaceResult } from "./result-formatter"
|
||||
import { getPatternHint } from "./pattern-hints"
|
||||
import {
|
||||
AST_GREP_REPLACE_DESCRIPTION,
|
||||
AST_GREP_SEARCH_DESCRIPTION,
|
||||
AST_GREP_SEARCH_PATTERN_PARAM,
|
||||
} from "./tool-descriptions"
|
||||
import type { CliLanguage } from "./types"
|
||||
|
||||
async function showOutputToUser(context: unknown, output: string): Promise<void> {
|
||||
const ctx = context as {
|
||||
metadata?: (input: { metadata: { output: string } }) => void | Promise<void>
|
||||
}
|
||||
await ctx.metadata?.({ metadata: { output } })
|
||||
}
|
||||
|
||||
export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
|
||||
const ast_grep_search: ToolDefinition = tool({
|
||||
description: AST_GREP_SEARCH_DESCRIPTION,
|
||||
args: {
|
||||
pattern: tool.schema.string().describe(AST_GREP_SEARCH_PATTERN_PARAM),
|
||||
lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
|
||||
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"),
|
||||
globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"),
|
||||
context: tool.schema.number().optional().describe("Context lines around match"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const result = await runSg({
|
||||
pattern: args.pattern,
|
||||
lang: args.lang as CliLanguage,
|
||||
paths: args.paths ?? [ctx.directory],
|
||||
globs: args.globs,
|
||||
context: args.context,
|
||||
})
|
||||
|
||||
let output = formatSearchResult(result)
|
||||
|
||||
if (result.matches.length === 0 && !result.error) {
|
||||
const hint = getPatternHint(args.pattern, args.lang as CliLanguage)
|
||||
if (hint) {
|
||||
output += `\n\n${hint}`
|
||||
}
|
||||
}
|
||||
|
||||
await showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
await showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const ast_grep_replace: ToolDefinition = tool({
|
||||
description: AST_GREP_REPLACE_DESCRIPTION,
|
||||
args: {
|
||||
pattern: tool.schema.string().describe("AST pattern to match"),
|
||||
rewrite: tool.schema.string().describe("Replacement pattern (can use $VAR from pattern)"),
|
||||
lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
|
||||
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search"),
|
||||
globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs"),
|
||||
dryRun: tool.schema.boolean().optional().describe("Preview changes without applying (default: true)"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const result = await runSg({
|
||||
pattern: args.pattern,
|
||||
rewrite: args.rewrite,
|
||||
lang: args.lang as CliLanguage,
|
||||
paths: args.paths ?? [ctx.directory],
|
||||
globs: args.globs,
|
||||
updateAll: args.dryRun === false,
|
||||
})
|
||||
const output = formatReplaceResult(result, args.dryRun !== false)
|
||||
await showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
await showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return { ast_grep_search, ast_grep_replace }
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { CLI_LANGUAGES, NAPI_LANGUAGES } from "./constants"
|
||||
|
||||
export type CliLanguage = (typeof CLI_LANGUAGES)[number]
|
||||
export type NapiLanguage = (typeof NAPI_LANGUAGES)[number]
|
||||
|
||||
export interface Position {
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
export interface Range {
|
||||
start: Position
|
||||
end: Position
|
||||
}
|
||||
|
||||
export interface CliMatch {
|
||||
text: string
|
||||
range: {
|
||||
byteOffset: { start: number; end: number }
|
||||
start: Position
|
||||
end: Position
|
||||
}
|
||||
file: string
|
||||
lines: string
|
||||
charCount: { leading: number; trailing: number }
|
||||
language: string
|
||||
}
|
||||
|
||||
export interface SearchMatch {
|
||||
file: string
|
||||
text: string
|
||||
range: Range
|
||||
lines: string
|
||||
}
|
||||
|
||||
export interface MetaVariable {
|
||||
name: string
|
||||
text: string
|
||||
kind: string
|
||||
}
|
||||
|
||||
export interface AnalyzeResult {
|
||||
text: string
|
||||
range: Range
|
||||
kind: string
|
||||
metaVariables: MetaVariable[]
|
||||
}
|
||||
|
||||
export interface TransformResult {
|
||||
original: string
|
||||
transformed: string
|
||||
editCount: number
|
||||
}
|
||||
|
||||
export interface SgResult {
|
||||
matches: CliMatch[]
|
||||
totalMatches: number
|
||||
truncated: boolean
|
||||
truncatedReason?: "max_matches" | "max_output_bytes" | "timeout"
|
||||
error?: string
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export { createAstGrepTools } from "./ast-grep"
|
||||
export { createGrepTools } from "./grep"
|
||||
export { createGlobTools } from "./glob"
|
||||
export { createSkillTool } from "./skill"
|
||||
|
||||
Reference in New Issue
Block a user