diff --git a/src/tools/ast-grep/cli-binary-path-resolution.ts b/src/tools/ast-grep/cli-binary-path-resolution.ts new file mode 100644 index 00000000..7fbd760e --- /dev/null +++ b/src/tools/ast-grep/cli-binary-path-resolution.ts @@ -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 | null = null + +export async function getAstGrepPath(): Promise { + 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 { + 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 +} diff --git a/src/tools/ast-grep/cli.ts b/src/tools/ast-grep/cli.ts index 19d12443..868a1c54 100644 --- a/src/tools/ast-grep/cli.ts +++ b/src/tools/ast-grep/cli.ts @@ -1,64 +1,31 @@ import { spawn } from "bun" import { existsSync } from "fs" import { - getSgCliPath, - setSgCliPath, - findSgCliPathSync, - DEFAULT_TIMEOUT_MS, - DEFAULT_MAX_OUTPUT_BYTES, - DEFAULT_MAX_MATCHES, + getSgCliPath, + DEFAULT_TIMEOUT_MS, } from "./constants" 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 { - pattern: string - lang: CliLanguage - paths?: string[] - globs?: string[] - rewrite?: string - context?: number - updateAll?: boolean -} - -let resolvedCliPath: string | null = null -let initPromise: Promise | null = null - -export async function getAstGrepPath(): Promise { - 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(() => {}) - } + pattern: string + lang: CliLanguage + paths?: string[] + globs?: string[] + rewrite?: string + context?: number + updateAll?: boolean } export async function runSg(options: RunOptions): Promise { @@ -107,51 +74,44 @@ export async function runSg(options: RunOptions): Promise { const timeout = DEFAULT_TIMEOUT_MS - const proc = spawn([cliPath, ...args], { - stdout: "pipe", - stderr: "pipe", - }) + const proc = spawn([cliPath, ...args], { + stdout: "pipe", + stderr: "pipe", + }) - const timeoutPromise = new Promise((_, reject) => { - const id = setTimeout(() => { - proc.kill() - reject(new Error(`Search timeout after ${timeout}ms`)) - }, timeout) - proc.exited.then(() => clearTimeout(id)) - }) + let stdout: string + let stderr: string + let exitCode: number - 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, + } + } - try { - stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise]) - stderr = await new Response(proc.stderr).text() - exitCode = await proc.exited - } catch (e) { - const error = e as Error - if (error.message?.includes("timeout")) { - return { - matches: [], - totalMatches: 0, - truncated: true, - truncatedReason: "timeout", - error: error.message, - } - } + const errorMessage = error instanceof Error ? error.message : String(error) + const errorCode = + typeof error === "object" && error !== null && "code" in error + ? (error as { code?: unknown }).code + : undefined + const isNoEntry = + errorCode === "ENOENT" || errorMessage.includes("ENOENT") || errorMessage.includes("not found") - const nodeError = e as NodeJS.ErrnoException - if ( - nodeError.code === "ENOENT" || - nodeError.message?.includes("ENOENT") || - nodeError.message?.includes("not found") - ) { - const downloadedPath = await ensureAstGrepBinary() - if (downloadedPath) { - resolvedCliPath = downloadedPath - setSgCliPath(downloadedPath) - return runSg(options) - } else { + if (isNoEntry) { + const downloadedPath = await ensureAstGrepBinary() + if (downloadedPath) { + return runSg(options) + } else { return { matches: [], totalMatches: 0, @@ -166,13 +126,13 @@ export async function runSg(options: RunOptions): Promise { } } - return { - matches: [], - totalMatches: 0, - truncated: false, - error: `Failed to spawn ast-grep: ${error.message}`, - } - } + return { + matches: [], + totalMatches: 0, + truncated: false, + error: `Failed to spawn ast-grep: ${errorMessage}`, + } + } if (exitCode !== 0 && stdout.trim() === "") { if (stderr.includes("No files found")) { @@ -184,59 +144,5 @@ export async function runSg(options: RunOptions): Promise { return { matches: [], totalMatches: 0, truncated: false } } - 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 function isCliAvailable(): boolean { - const path = findSgCliPathSync() - return path !== null && existsSync(path) -} - -export async function ensureCliAvailable(): Promise { - const path = await getAstGrepPath() - return path !== null && existsSync(path) + return createSgResultFromStdout(stdout) } diff --git a/src/tools/ast-grep/constants.ts b/src/tools/ast-grep/constants.ts index d1d1609b..8ceba173 100644 --- a/src/tools/ast-grep/constants.ts +++ b/src/tools/ast-grep/constants.ts @@ -1,249 +1,5 @@ -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 = { - "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 = { - 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") -} +export type { EnvironmentCheckResult } from "./environment-check" +export { checkEnvironment, formatEnvironmentCheck } from "./environment-check" +export { CLI_LANGUAGES, NAPI_LANGUAGES, LANG_EXTENSIONS } from "./language-support" +export { DEFAULT_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_MAX_MATCHES } from "./language-support" +export { findSgCliPathSync, getSgCliPath, setSgCliPath } from "./sg-cli-path" diff --git a/src/tools/ast-grep/environment-check.ts b/src/tools/ast-grep/environment-check.ts new file mode 100644 index 00000000..d78b7b09 --- /dev/null +++ b/src/tools/ast-grep/environment-check.ts @@ -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") +} diff --git a/src/tools/ast-grep/language-support.ts b/src/tools/ast-grep/language-support.ts new file mode 100644 index 00000000..b8abc9e9 --- /dev/null +++ b/src/tools/ast-grep/language-support.ts @@ -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 = { + 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"], +} diff --git a/src/tools/ast-grep/process-output-timeout.ts b/src/tools/ast-grep/process-output-timeout.ts new file mode 100644 index 00000000..2292b37c --- /dev/null +++ b/src/tools/ast-grep/process-output-timeout.ts @@ -0,0 +1,28 @@ +type SpawnedProcess = { + stdout: ReadableStream | null + stderr: ReadableStream | null + exited: Promise + kill: () => void +} + +export async function collectProcessOutputWithTimeout( + process: SpawnedProcess, + timeoutMs: number +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const timeoutPromise = new Promise((_, 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 } +} diff --git a/src/tools/ast-grep/sg-cli-path.ts b/src/tools/ast-grep/sg-cli-path.ts new file mode 100644 index 00000000..5d7103f5 --- /dev/null +++ b/src/tools/ast-grep/sg-cli-path.ts @@ -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 = { + "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 +} diff --git a/src/tools/ast-grep/sg-compact-json-output.ts b/src/tools/ast-grep/sg-compact-json-output.ts new file mode 100644 index 00000000..21825316 --- /dev/null +++ b/src/tools/ast-grep/sg-compact-json-output.ts @@ -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, + } +}