refactor(packages): extract ast-grep-core from ast-grep-mcp

This commit is contained in:
YeonGyu-Kim
2026-05-21 01:17:17 +09:00
parent a5c1d71001
commit 3b303e79f4
20 changed files with 608 additions and 421 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@oh-my-opencode/ast-grep-core",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Pure TypeScript ast-grep core logic shared across harness adapters.",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./src/index.ts"
}
},
"types": "./index.d.ts",
"scripts": {
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "bun test src/*.test.ts"
}
}
+19
View File
@@ -0,0 +1,19 @@
export { CLI_LANGUAGES, DEFAULT_MAX_MATCHES, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS } from "./language-support"
export { getPatternHint, detectLanguageSpecificMistake, detectRegexMisuse } from "./pattern-hints"
export { formatReplaceResult, formatSearchResult } from "./result-formatter"
export { createSgResultFromStdout } from "./sg-compact-json-output"
export { buildSgArgs, runSg } from "./runner"
export type {
CliLanguage,
CliMatch,
Position,
Range,
SgResult,
} from "./types"
export type {
SgRunArgs,
SgRunnerDeps,
SpawnOptions,
SpawnProcess,
SpawnResult,
} from "./runner"
@@ -0,0 +1,31 @@
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
export const DEFAULT_TIMEOUT_MS = 300_000
export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
export const DEFAULT_MAX_MATCHES = 500
@@ -0,0 +1,63 @@
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)
}
@@ -0,0 +1,70 @@
import type { 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")
}
+195
View File
@@ -0,0 +1,195 @@
import { DEFAULT_TIMEOUT_MS } from "./language-support"
import { createSgResultFromStdout } from "./sg-compact-json-output"
import type { CliLanguage, SgResult } from "./types"
const SG_BINARY_NOT_FOUND_MESSAGE =
`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`
export interface SgRunArgs {
readonly pattern: string
readonly lang: CliLanguage
readonly cwd?: string
readonly paths?: readonly string[]
readonly globs?: readonly string[]
readonly rewrite?: string
readonly context?: number
readonly updateAll?: boolean
}
export interface SpawnOptions {
readonly cwd?: string
readonly stdout?: "pipe" | "inherit" | "ignore"
readonly stderr?: "pipe" | "inherit" | "ignore"
}
export interface SpawnResult {
readonly stdout: string
readonly stderr: string
readonly exitCode: number
}
export type SpawnProcess = (
binary: string,
args: readonly string[],
options?: SpawnOptions,
) => Promise<SpawnResult>
export interface SgRunnerDeps {
readonly resolveBinary: () => Promise<string>
readonly spawnProcess: SpawnProcess
}
export function buildSgArgs(
options: SgRunArgs,
flags: { readonly includeJson: boolean; readonly includeUpdateAll: boolean },
): string[] {
const args = ["run", "-p", options.pattern, "--lang", options.lang]
if (flags.includeJson) {
args.push("--json=compact")
}
if (options.rewrite) {
args.push("-r", options.rewrite)
if (flags.includeUpdateAll) {
args.push("--update-all")
}
}
if (typeof options.context === "number" && 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)
return args
}
export async function runSg(options: SgRunArgs, deps: SgRunnerDeps): Promise<SgResult> {
const shouldSeparateWritePass = Boolean(options.rewrite && options.updateAll)
const args = buildSgArgs(options, { includeJson: true, includeUpdateAll: false })
let binary: string
try {
binary = await deps.resolveBinary()
} catch (error) {
return {
matches: [],
totalMatches: 0,
truncated: false,
error: isNoEntryError(error) ? SG_BINARY_NOT_FOUND_MESSAGE : `Failed to resolve ast-grep binary: ${errorMessage(error)}`,
}
}
const searchResult = await trySpawn(binary, args, options.cwd, deps)
if (searchResult.error) {
return searchResult.error
}
const output = searchResult.value
if (output.exitCode !== 0 && output.stdout.trim() === "") {
if (output.stderr.includes("No files found")) {
return { matches: [], totalMatches: 0, truncated: false }
}
if (output.stderr.trim()) {
return { matches: [], totalMatches: 0, truncated: false, error: output.stderr.trim() }
}
return { matches: [], totalMatches: 0, truncated: false }
}
const jsonResult = createSgResultFromStdout(output.stdout)
if (!(shouldSeparateWritePass && jsonResult.matches.length > 0)) {
return jsonResult
}
const writeArgs = buildSgArgs(options, { includeJson: false, includeUpdateAll: true })
const writeResult = await trySpawn(binary, writeArgs, options.cwd, deps)
if (writeResult.error) {
return { ...jsonResult, error: `Replace failed: ${writeResult.error.error ?? "unknown error"}` }
}
if (writeResult.value.exitCode !== 0) {
const errorDetail =
writeResult.value.stderr.trim() || `ast-grep exited with code ${writeResult.value.exitCode}`
return { ...jsonResult, error: `Replace failed: ${errorDetail}` }
}
return jsonResult
}
async function trySpawn(
binary: string,
args: readonly string[],
cwd: string | undefined,
deps: SgRunnerDeps,
): Promise<{ readonly value: SpawnResult; readonly error?: never } | { readonly value?: never; readonly error: SgResult }> {
try {
const value = await deps.spawnProcess(binary, args, {
cwd,
stdout: "pipe",
stderr: "pipe",
})
return { value }
} catch (error) {
if (error instanceof Error && error.message.includes("timeout")) {
return {
error: {
matches: [],
totalMatches: 0,
truncated: true,
truncatedReason: "timeout",
error: error.message,
},
}
}
if (isNoEntryError(error)) {
return {
error: {
matches: [],
totalMatches: 0,
truncated: false,
error: SG_BINARY_NOT_FOUND_MESSAGE,
},
}
}
return {
error: {
matches: [],
totalMatches: 0,
truncated: false,
error: `Failed to spawn ast-grep: ${errorMessage(error)}`,
},
}
}
}
function isNoEntryError(error: unknown): boolean {
if (typeof error !== "object" || error === null) {
return false
}
const code = Reflect.get(error, "code")
const message = errorMessage(error)
return code === "ENOENT" || message.includes("ENOENT") || message.includes("not found")
}
function errorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return String(error)
}
export { DEFAULT_TIMEOUT_MS }
@@ -0,0 +1,54 @@
import { DEFAULT_MAX_MATCHES, DEFAULT_MAX_OUTPUT_BYTES } from "./language-support"
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,
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { CLI_LANGUAGES } from "./language-support"
export type CliLanguage = (typeof CLI_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 SgResult {
matches: CliMatch[]
totalMatches: number
truncated: boolean
truncatedReason?: "max_matches" | "max_output_bytes" | "timeout"
error?: string
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ESNext", "DOM"],
"types": ["bun-types"]
},
"include": ["src/**/*"]
}
+1
View File
@@ -18,6 +18,7 @@
"test": "bun test src/*.test.ts"
},
"dependencies": {
"@oh-my-opencode/ast-grep-core": "workspace:*",
"@ast-grep/cli": "^0.41.1"
},
"devDependencies": {
+6 -1
View File
@@ -1,2 +1,7 @@
export { CLI_LANGUAGES, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_MATCHES } from "./language-support"
export {
CLI_LANGUAGES,
DEFAULT_TIMEOUT_MS,
DEFAULT_MAX_OUTPUT_BYTES,
DEFAULT_MAX_MATCHES,
} from "@oh-my-opencode/ast-grep-core"
export { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./sg-cli-path"
+6 -31
View File
@@ -1,31 +1,6 @@
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
export const DEFAULT_TIMEOUT_MS = 300_000
export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
export const DEFAULT_MAX_MATCHES = 500
export {
CLI_LANGUAGES,
DEFAULT_MAX_MATCHES,
DEFAULT_MAX_OUTPUT_BYTES,
DEFAULT_TIMEOUT_MS,
} from "@oh-my-opencode/ast-grep-core"
+5 -63
View File
@@ -1,63 +1,5 @@
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)
}
export {
detectLanguageSpecificMistake,
detectRegexMisuse,
getPatternHint,
} from "@oh-my-opencode/ast-grep-core"
+1 -70
View File
@@ -1,70 +1 @@
import type { 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 { formatReplaceResult, formatSearchResult } from "@oh-my-opencode/ast-grep-core"
+40 -166
View File
@@ -1,184 +1,58 @@
import { spawn } from "./bun-spawn-shim"
import { existsSync } from "fs"
import { existsSync } from "node:fs"
import {
getSgCliPath,
DEFAULT_TIMEOUT_MS,
} from "./constants"
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 {
runSg as runSgCore,
type SgResult,
type SgRunArgs,
type SpawnOptions,
type SpawnResult,
} from "@oh-my-opencode/ast-grep-core"
import { spawn } from "./bun-spawn-shim"
import {
ensureCliAvailable,
getAstGrepPath,
isCliAvailable,
startBackgroundInit,
} from "./cli-binary-path-resolution"
import { getSgCliPath } from "./constants"
import { collectProcessOutputWithTimeout } from "./process-output-timeout"
export interface RunOptions {
pattern: string
lang: CliLanguage
cwd?: string
paths?: readonly string[]
globs?: readonly string[]
rewrite?: string
context?: number
updateAll?: boolean
}
export { ensureCliAvailable, getAstGrepPath, isCliAvailable, startBackgroundInit }
export type RunOptions = SgRunArgs
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 = createSgArgs(options, { includeJson: true, includeUpdateAll: false })
let cliPath = getSgCliPath()
if (!cliPath || !existsSync(cliPath)) {
const resolvedPath = await getAstGrepPath()
if (resolvedPath) {
cliPath = resolvedPath
} 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], {
cwd: options.cwd,
stdout: "pipe",
stderr: "pipe",
return runSgCore(options, {
resolveBinary: resolveBinaryPath,
spawnProcess,
})
}
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 = errorCodeFrom(error)
const isNoEntry =
errorCode === "ENOENT" || errorMessage.includes("ENOENT") || errorMessage.includes("not found")
if (isNoEntry) {
return {
matches: [],
totalMatches: 0,
truncated: false,
error:
`ast-grep CLI 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`,
}
}
return {
matches: [],
totalMatches: 0,
truncated: false,
error: `Failed to spawn ast-grep: ${errorMessage}`,
}
async function resolveBinaryPath(): Promise<string> {
const cliPath = getSgCliPath()
if (cliPath && existsSync(cliPath)) {
return cliPath
}
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 = createSgArgs(options, { includeJson: false, includeUpdateAll: true })
const writeProc = spawn([cliPath, ...writeArgs], {
cwd: options.cwd,
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
const resolvedPath = await getAstGrepPath()
if (!resolvedPath) {
const noEntryError = new Error("ENOENT: ast-grep binary not found")
Reflect.set(noEntryError, "code", "ENOENT")
throw noEntryError
}
return resolvedPath
}
function createSgArgs(options: RunOptions, flags: { readonly includeJson: boolean; readonly includeUpdateAll: boolean }): string[] {
const args = ["run", "-p", options.pattern, "--lang", options.lang]
async function spawnProcess(
binary: string,
args: readonly string[],
options?: SpawnOptions,
): Promise<SpawnResult> {
const proc = spawn([binary, ...args], {
cwd: options?.cwd,
stdout: options?.stdout ?? "pipe",
stderr: options?.stderr ?? "pipe",
})
if (flags.includeJson) {
args.push("--json=compact")
}
if (options.rewrite) {
args.push("-r", options.rewrite)
if (flags.includeUpdateAll) {
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)
return args
}
function errorCodeFrom(error: unknown): unknown {
if (typeof error !== "object" || error === null || !("code" in error)) return undefined
return Reflect.get(error, "code")
return collectProcessOutputWithTimeout(proc, DEFAULT_TIMEOUT_MS)
}
@@ -1,54 +1 @@
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,
}
}
export { createSgResultFromStdout } from "@oh-my-opencode/ast-grep-core"
+7 -35
View File
@@ -1,35 +1,7 @@
import type { CLI_LANGUAGES } from "./constants"
export type CliLanguage = (typeof CLI_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 SgResult {
matches: CliMatch[]
totalMatches: number
truncated: boolean
truncatedReason?: "max_matches" | "max_output_bytes" | "timeout"
error?: string
}
export type {
CliLanguage,
CliMatch,
Position,
Range,
SgResult,
} from "@oh-my-opencode/ast-grep-core"