refactor(packages): extract ast-grep-core from ast-grep-mcp
This commit is contained in:
@@ -21,3 +21,16 @@
|
||||
- `src/features/boulder-state/format-duration.ts`: pure, zero imports.
|
||||
- `src/shared/jsonc-parser.ts`: coupled to plugin basenames; decoupled via parameterized `detectPluginConfigFile(dir, options)`.
|
||||
- `src/shared/write-file-atomically.ts`: depends on omo-specific `./tolerant-fsync`; extraction deferred by scope decision (kept in-place).
|
||||
|
||||
## [2026-05-21T00:00:00Z] Task 7 (worktree)
|
||||
- Pre-flight import audit (`packages/ast-grep-mcp/src/*.ts`) shows only `mcp.ts`, `runner.ts`, and `cli-binary-path-resolution.ts` touch adapter/runtime-specific concerns.
|
||||
- Candidate extracted files are pure from MCP perspective:
|
||||
- `types.ts`: type-only, currently coupled only by `CliLanguage` source (`CLI_LANGUAGES`).
|
||||
- `language-support.ts`: CLI language enum + numeric defaults; no MCP/Bun coupling.
|
||||
- `pattern-hints.ts`: pure heuristics; no runtime coupling (intended identical behavior for pi/codex parity).
|
||||
- `result-formatter.ts`: pure string formatter over `SgResult`.
|
||||
- `sg-compact-json-output.ts`: pure JSON parsing/truncation logic; depends only on constants/types.
|
||||
- `runner.ts` split requirement confirmed:
|
||||
- Core should own `buildSgArgs()` + `runSg()` orchestration and error mapping.
|
||||
- OMO-specific binary resolution stays adapter-side (`getAstGrepPath` in `cli-binary-path-resolution.ts`).
|
||||
- OMO-specific process spawn stays adapter-side (`bun-spawn-shim.ts`), injected via core deps (`spawnProcess`).
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@oh-my-opencode/ast-grep-core": "workspace:*",
|
||||
"@oh-my-opencode/ast-grep-mcp": "workspace:*",
|
||||
"@oh-my-opencode/rules-core": "workspace:*",
|
||||
"@oh-my-opencode/utils": "workspace:*",
|
||||
@@ -51,6 +52,10 @@
|
||||
"zod": "^4.0.0",
|
||||
},
|
||||
},
|
||||
"packages/ast-grep-core": {
|
||||
"name": "@oh-my-opencode/ast-grep-core",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"packages/ast-grep-mcp": {
|
||||
"name": "@oh-my-opencode/ast-grep-mcp",
|
||||
"version": "0.0.0",
|
||||
@@ -59,6 +64,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.41.1",
|
||||
"@oh-my-opencode/ast-grep-core": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "1.3.12",
|
||||
@@ -151,6 +157,8 @@
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
|
||||
|
||||
"@oh-my-opencode/ast-grep-core": ["@oh-my-opencode/ast-grep-core@workspace:packages/ast-grep-core"],
|
||||
|
||||
"@oh-my-opencode/ast-grep-mcp": ["@oh-my-opencode/ast-grep-mcp@workspace:packages/ast-grep-mcp"],
|
||||
|
||||
"@oh-my-opencode/rules-core": ["@oh-my-opencode/rules-core@workspace:packages/rules-core"],
|
||||
@@ -451,6 +459,24 @@
|
||||
|
||||
"@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@oh-my-opencode/ast-grep-mcp/@ast-grep/cli": ["@ast-grep/cli@0.41.1", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.41.1", "@ast-grep/cli-darwin-x64": "0.41.1", "@ast-grep/cli-linux-arm64-gnu": "0.41.1", "@ast-grep/cli-linux-x64-gnu": "0.41.1", "@ast-grep/cli-win32-arm64-msvc": "0.41.1", "@ast-grep/cli-win32-ia32-msvc": "0.41.1", "@ast-grep/cli-win32-x64-msvc": "0.41.1" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-6oSuzF1Ra0d9jdcmflRIR1DHcicI7TYVxaaV/hajV51J49r6C+1BA2H9G+e47lH4sDEXUS9KWLNGNvXa/Gqs5A=="],
|
||||
|
||||
"@oh-my-opencode/ast-grep-mcp/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
||||
|
||||
"@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
"@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.41.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-30lrXtyDB+16WS89Bk8sufA5TVUczyQye4PoIYLxZr+PRbPW7thpxHwBwGWL6QvPvUtlElrCe4seA1CEwFxeFA=="],
|
||||
|
||||
"@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.41.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-jRft57aWRgqYgLXooWxS9Nx5mb5JJ/KQIwEqacWkcmDZEdEui7oG50//6y4/vU5WRcS1n6oB2Vs7WBvTh3/Ypg=="],
|
||||
|
||||
"@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.41.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1XUL+8u+Xs1FoM2W6F4v8pRa2aQQcp5CZXBG8uy9n8FhwsQtrhBclJ2Vr9g/zzswHQT1293mnP5TOk1wlYZq6w=="],
|
||||
|
||||
"@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.41.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oSsbXzbcl4hnRAw7b1bTFZapx9s+O8ToJJKI44oJAb7xKIG3Rubn2IMBOFvMvjjWEEax8PpS2IocgdB8nUAcbA=="],
|
||||
|
||||
"@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.41.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-jTMNqjXnQUhInMB1X06sxWZJv/6pd4/iYSyk8RR5kdulnuNzoGEB9KYbm6ojxktPtMfZpb+7eShQLqqy/dG6Ag=="],
|
||||
|
||||
"@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.41.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-mCTyr6/KQneKk0iYaWup4ywW5buNcFqL6TrJVfU0tkd38fu/RtJ5zywr978vVvFxsY+urRU0qkrmtQqXQNwDFA=="],
|
||||
|
||||
"@oh-my-opencode/ast-grep-mcp/@ast-grep/cli/@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.41.1", "", { "os": "win32", "cpu": "x64" }, "sha512-AUbR67UKWsfgyy3SWQq258ZB0xSlaAe15Gl5hPu5tbUu4HTt6rKrUCTEEubYgbNdPPZWtxjobjFjMsDTWfnrug=="],
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -7,6 +7,7 @@
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/rules-core",
|
||||
"packages/ast-grep-core",
|
||||
"packages/ast-grep-mcp",
|
||||
"packages/utils"
|
||||
],
|
||||
@@ -43,7 +44,7 @@
|
||||
"prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build",
|
||||
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
|
||||
"typecheck": "tsgo --noEmit && bun run typecheck:packages",
|
||||
"typecheck:packages": "tsgo --noEmit -p packages/rules-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json",
|
||||
"typecheck:packages": "tsgo --noEmit -p packages/rules-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json",
|
||||
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
|
||||
"test": "bun test",
|
||||
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build"
|
||||
@@ -87,6 +88,7 @@
|
||||
"vscode-jsonrpc": "^8.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@oh-my-opencode/ast-grep-core": "workspace:*",
|
||||
"@oh-my-opencode/ast-grep-mcp": "workspace:*",
|
||||
"@oh-my-opencode/rules-core": "workspace:*",
|
||||
"@oh-my-opencode/utils": "workspace:*",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 +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"
|
||||
|
||||
@@ -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)
|
||||
return runSgCore(options, {
|
||||
resolveBinary: resolveBinaryPath,
|
||||
spawnProcess,
|
||||
})
|
||||
}
|
||||
|
||||
const args = createSgArgs(options, { includeJson: true, includeUpdateAll: false })
|
||||
async function resolveBinaryPath(): Promise<string> {
|
||||
const cliPath = getSgCliPath()
|
||||
if (cliPath && existsSync(cliPath)) {
|
||||
return cliPath
|
||||
}
|
||||
|
||||
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`,
|
||||
}
|
||||
if (!resolvedPath) {
|
||||
const noEntryError = new Error("ENOENT: ast-grep binary not found")
|
||||
Reflect.set(noEntryError, "code", "ENOENT")
|
||||
throw noEntryError
|
||||
}
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
const timeout = DEFAULT_TIMEOUT_MS
|
||||
|
||||
const proc = spawn([cliPath, ...args], {
|
||||
cwd: options.cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
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",
|
||||
})
|
||||
|
||||
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}`,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function createSgArgs(options: RunOptions, 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 (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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user