refactor(ast-grep): split cli.ts and constants.ts into focused modules
Extract AST-grep tooling into single-responsibility files: - cli-binary-path-resolution.ts, sg-cli-path.ts - environment-check.ts, language-support.ts - process-output-timeout.ts, sg-compact-json-output.ts
This commit is contained in:
parent
e4583668c0
commit
76fad73550
60
src/tools/ast-grep/cli-binary-path-resolution.ts
Normal file
60
src/tools/ast-grep/cli-binary-path-resolution.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
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,64 +1,31 @@
|
|||||||
import { spawn } from "bun"
|
import { spawn } from "bun"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import {
|
import {
|
||||||
getSgCliPath,
|
getSgCliPath,
|
||||||
setSgCliPath,
|
DEFAULT_TIMEOUT_MS,
|
||||||
findSgCliPathSync,
|
|
||||||
DEFAULT_TIMEOUT_MS,
|
|
||||||
DEFAULT_MAX_OUTPUT_BYTES,
|
|
||||||
DEFAULT_MAX_MATCHES,
|
|
||||||
} from "./constants"
|
} from "./constants"
|
||||||
import { ensureAstGrepBinary } from "./downloader"
|
import { ensureAstGrepBinary } from "./downloader"
|
||||||
import type { CliMatch, CliLanguage, SgResult } from "./types"
|
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 {
|
export interface RunOptions {
|
||||||
pattern: string
|
pattern: string
|
||||||
lang: CliLanguage
|
lang: CliLanguage
|
||||||
paths?: string[]
|
paths?: string[]
|
||||||
globs?: string[]
|
globs?: string[]
|
||||||
rewrite?: string
|
rewrite?: string
|
||||||
context?: number
|
context?: number
|
||||||
updateAll?: boolean
|
updateAll?: boolean
|
||||||
}
|
|
||||||
|
|
||||||
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 async function runSg(options: RunOptions): Promise<SgResult> {
|
export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||||
@ -107,51 +74,44 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
|||||||
|
|
||||||
const timeout = DEFAULT_TIMEOUT_MS
|
const timeout = DEFAULT_TIMEOUT_MS
|
||||||
|
|
||||||
const proc = spawn([cliPath, ...args], {
|
const proc = spawn([cliPath, ...args], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
})
|
})
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
let stdout: string
|
||||||
const id = setTimeout(() => {
|
let stderr: string
|
||||||
proc.kill()
|
let exitCode: number
|
||||||
reject(new Error(`Search timeout after ${timeout}ms`))
|
|
||||||
}, timeout)
|
|
||||||
proc.exited.then(() => clearTimeout(id))
|
|
||||||
})
|
|
||||||
|
|
||||||
let stdout: string
|
try {
|
||||||
let stderr: string
|
const output = await collectProcessOutputWithTimeout(proc, timeout)
|
||||||
let exitCode: number
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
const errorCode =
|
||||||
stderr = await new Response(proc.stderr).text()
|
typeof error === "object" && error !== null && "code" in error
|
||||||
exitCode = await proc.exited
|
? (error as { code?: unknown }).code
|
||||||
} catch (e) {
|
: undefined
|
||||||
const error = e as Error
|
const isNoEntry =
|
||||||
if (error.message?.includes("timeout")) {
|
errorCode === "ENOENT" || errorMessage.includes("ENOENT") || errorMessage.includes("not found")
|
||||||
return {
|
|
||||||
matches: [],
|
|
||||||
totalMatches: 0,
|
|
||||||
truncated: true,
|
|
||||||
truncatedReason: "timeout",
|
|
||||||
error: error.message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeError = e as NodeJS.ErrnoException
|
if (isNoEntry) {
|
||||||
if (
|
const downloadedPath = await ensureAstGrepBinary()
|
||||||
nodeError.code === "ENOENT" ||
|
if (downloadedPath) {
|
||||||
nodeError.message?.includes("ENOENT") ||
|
return runSg(options)
|
||||||
nodeError.message?.includes("not found")
|
} else {
|
||||||
) {
|
|
||||||
const downloadedPath = await ensureAstGrepBinary()
|
|
||||||
if (downloadedPath) {
|
|
||||||
resolvedCliPath = downloadedPath
|
|
||||||
setSgCliPath(downloadedPath)
|
|
||||||
return runSg(options)
|
|
||||||
} else {
|
|
||||||
return {
|
return {
|
||||||
matches: [],
|
matches: [],
|
||||||
totalMatches: 0,
|
totalMatches: 0,
|
||||||
@ -166,13 +126,13 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
matches: [],
|
matches: [],
|
||||||
totalMatches: 0,
|
totalMatches: 0,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
error: `Failed to spawn ast-grep: ${error.message}`,
|
error: `Failed to spawn ast-grep: ${errorMessage}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exitCode !== 0 && stdout.trim() === "") {
|
if (exitCode !== 0 && stdout.trim() === "") {
|
||||||
if (stderr.includes("No files found")) {
|
if (stderr.includes("No files found")) {
|
||||||
@ -184,59 +144,5 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
|
|||||||
return { matches: [], totalMatches: 0, truncated: false }
|
return { matches: [], totalMatches: 0, truncated: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stdout.trim()) {
|
return createSgResultFromStdout(stdout)
|
||||||
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 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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,249 +1,5 @@
|
|||||||
import { createRequire } from "module"
|
export type { EnvironmentCheckResult } from "./environment-check"
|
||||||
import { dirname, join } from "path"
|
export { checkEnvironment, formatEnvironmentCheck } from "./environment-check"
|
||||||
import { existsSync, statSync } from "fs"
|
export { CLI_LANGUAGES, NAPI_LANGUAGES, LANG_EXTENSIONS } from "./language-support"
|
||||||
import { getCachedBinaryPath } from "./downloader"
|
export { DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_MATCHES } from "./language-support"
|
||||||
|
export { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./sg-cli-path"
|
||||||
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 cliPkgPath = require.resolve("@ast-grep/cli/package.json")
|
|
||||||
const cliDir = dirname(cliPkgPath)
|
|
||||||
const sgPath = join(cliDir, binaryName)
|
|
||||||
|
|
||||||
if (existsSync(sgPath) && isValidBinary(sgPath)) {
|
|
||||||
return sgPath
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// @ast-grep/cli not installed
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformPkg = getPlatformPackageName()
|
|
||||||
if (platformPkg) {
|
|
||||||
try {
|
|
||||||
const require = createRequire(import.meta.url)
|
|
||||||
const pkgPath = require.resolve(`${platformPkg}/package.json`)
|
|
||||||
const pkgDir = dirname(pkgPath)
|
|
||||||
const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
|
|
||||||
const binaryPath = join(pkgDir, astGrepName)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
// Language to file extensions mapping
|
|
||||||
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"],
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (e) {
|
|
||||||
result.napi.available = false
|
|
||||||
result.napi.error = `@ast-grep/napi not installed: ${e instanceof Error ? e.message : String(e)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|||||||
89
src/tools/ast-grep/environment-check.ts
Normal file
89
src/tools/ast-grep/environment-check.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
63
src/tools/ast-grep/language-support.ts
Normal file
63
src/tools/ast-grep/language-support.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// 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"],
|
||||||
|
}
|
||||||
28
src/tools/ast-grep/process-output-timeout.ts
Normal file
28
src/tools/ast-grep/process-output-timeout.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
102
src/tools/ast-grep/sg-cli-path.ts
Normal file
102
src/tools/ast-grep/sg-cli-path.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
54
src/tools/ast-grep/sg-compact-json-output.ts
Normal file
54
src/tools/ast-grep/sg-compact-json-output.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user