|
1 | 1 | import { spawn } from "bun" |
2 | 2 | import { existsSync } from "fs" |
3 | | -import { getSgCliPath, setSgCliPath, findSgCliPathSync } from "./constants" |
| 3 | +import { |
| 4 | + getSgCliPath, |
| 5 | + setSgCliPath, |
| 6 | + findSgCliPathSync, |
| 7 | + DEFAULT_TIMEOUT_MS, |
| 8 | + DEFAULT_MAX_OUTPUT_BYTES, |
| 9 | + DEFAULT_MAX_MATCHES, |
| 10 | +} from "./constants" |
4 | 11 | import { ensureAstGrepBinary } from "./downloader" |
5 | | -import type { CliMatch, CliLanguage } from "./types" |
| 12 | +import type { CliMatch, CliLanguage, SgResult } from "./types" |
6 | 13 |
|
7 | 14 | export interface RunOptions { |
8 | 15 | pattern: string |
@@ -54,26 +61,7 @@ export function startBackgroundInit(): void { |
54 | 61 | } |
55 | 62 | } |
56 | 63 |
|
57 | | -interface SpawnResult { |
58 | | - stdout: string |
59 | | - stderr: string |
60 | | - exitCode: number |
61 | | -} |
62 | | - |
63 | | -async function spawnSg(cliPath: string, args: string[]): Promise<SpawnResult> { |
64 | | - const proc = spawn([cliPath, ...args], { |
65 | | - stdout: "pipe", |
66 | | - stderr: "pipe", |
67 | | - }) |
68 | | - |
69 | | - const stdout = await new Response(proc.stdout).text() |
70 | | - const stderr = await new Response(proc.stderr).text() |
71 | | - const exitCode = await proc.exited |
72 | | - |
73 | | - return { stdout, stderr, exitCode } |
74 | | -} |
75 | | - |
76 | | -export async function runSg(options: RunOptions): Promise<CliMatch[]> { |
| 64 | +export async function runSg(options: RunOptions): Promise<SgResult> { |
77 | 65 | const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"] |
78 | 66 |
|
79 | 67 | if (options.rewrite) { |
@@ -105,55 +93,129 @@ export async function runSg(options: RunOptions): Promise<CliMatch[]> { |
105 | 93 | } |
106 | 94 | } |
107 | 95 |
|
108 | | - let result: SpawnResult |
| 96 | + const timeout = DEFAULT_TIMEOUT_MS |
| 97 | + |
| 98 | + const proc = spawn([cliPath, ...args], { |
| 99 | + stdout: "pipe", |
| 100 | + stderr: "pipe", |
| 101 | + }) |
| 102 | + |
| 103 | + const timeoutPromise = new Promise<never>((_, reject) => { |
| 104 | + const id = setTimeout(() => { |
| 105 | + proc.kill() |
| 106 | + reject(new Error(`Search timeout after ${timeout}ms`)) |
| 107 | + }, timeout) |
| 108 | + proc.exited.then(() => clearTimeout(id)) |
| 109 | + }) |
| 110 | + |
| 111 | + let stdout: string |
| 112 | + let stderr: string |
| 113 | + let exitCode: number |
| 114 | + |
109 | 115 | try { |
110 | | - result = await spawnSg(cliPath, args) |
| 116 | + stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise]) |
| 117 | + stderr = await new Response(proc.stderr).text() |
| 118 | + exitCode = await proc.exited |
111 | 119 | } catch (e) { |
112 | | - const error = e as NodeJS.ErrnoException |
| 120 | + const error = e as Error |
| 121 | + if (error.message?.includes("timeout")) { |
| 122 | + return { |
| 123 | + matches: [], |
| 124 | + totalMatches: 0, |
| 125 | + truncated: true, |
| 126 | + truncatedReason: "timeout", |
| 127 | + error: error.message, |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + const nodeError = e as NodeJS.ErrnoException |
113 | 132 | if ( |
114 | | - error.code === "ENOENT" || |
115 | | - error.message?.includes("ENOENT") || |
116 | | - error.message?.includes("not found") |
| 133 | + nodeError.code === "ENOENT" || |
| 134 | + nodeError.message?.includes("ENOENT") || |
| 135 | + nodeError.message?.includes("not found") |
117 | 136 | ) { |
118 | 137 | const downloadedPath = await ensureAstGrepBinary() |
119 | 138 | if (downloadedPath) { |
120 | 139 | resolvedCliPath = downloadedPath |
121 | 140 | setSgCliPath(downloadedPath) |
122 | | - result = await spawnSg(downloadedPath, args) |
| 141 | + return runSg(options) |
123 | 142 | } else { |
124 | | - throw new Error( |
125 | | - `ast-grep CLI binary not found.\n\n` + |
| 143 | + return { |
| 144 | + matches: [], |
| 145 | + totalMatches: 0, |
| 146 | + truncated: false, |
| 147 | + error: |
| 148 | + `ast-grep CLI binary not found.\n\n` + |
126 | 149 | `Auto-download failed. Manual install options:\n` + |
127 | 150 | ` bun add -D @ast-grep/cli\n` + |
128 | 151 | ` cargo install ast-grep --locked\n` + |
129 | | - ` brew install ast-grep` |
130 | | - ) |
| 152 | + ` brew install ast-grep`, |
| 153 | + } |
131 | 154 | } |
132 | | - } else { |
133 | | - throw new Error(`Failed to spawn ast-grep: ${error.message}`) |
134 | 155 | } |
135 | | - } |
136 | 156 |
|
137 | | - const { stdout, stderr, exitCode } = result |
| 157 | + return { |
| 158 | + matches: [], |
| 159 | + totalMatches: 0, |
| 160 | + truncated: false, |
| 161 | + error: `Failed to spawn ast-grep: ${error.message}`, |
| 162 | + } |
| 163 | + } |
138 | 164 |
|
139 | 165 | if (exitCode !== 0 && stdout.trim() === "") { |
140 | 166 | if (stderr.includes("No files found")) { |
141 | | - return [] |
| 167 | + return { matches: [], totalMatches: 0, truncated: false } |
142 | 168 | } |
143 | 169 | if (stderr.trim()) { |
144 | | - throw new Error(stderr.trim()) |
| 170 | + return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() } |
145 | 171 | } |
146 | | - return [] |
| 172 | + return { matches: [], totalMatches: 0, truncated: false } |
147 | 173 | } |
148 | 174 |
|
149 | 175 | if (!stdout.trim()) { |
150 | | - return [] |
| 176 | + return { matches: [], totalMatches: 0, truncated: false } |
151 | 177 | } |
152 | 178 |
|
| 179 | + const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES |
| 180 | + const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout |
| 181 | + |
| 182 | + let matches: CliMatch[] = [] |
153 | 183 | try { |
154 | | - return JSON.parse(stdout) as CliMatch[] |
| 184 | + matches = JSON.parse(outputToProcess) as CliMatch[] |
155 | 185 | } catch { |
156 | | - return [] |
| 186 | + if (outputTruncated) { |
| 187 | + try { |
| 188 | + const lastValidIndex = outputToProcess.lastIndexOf("}") |
| 189 | + if (lastValidIndex > 0) { |
| 190 | + const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex) |
| 191 | + if (bracketIndex > 0) { |
| 192 | + const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]" |
| 193 | + matches = JSON.parse(truncatedJson) as CliMatch[] |
| 194 | + } |
| 195 | + } |
| 196 | + } catch { |
| 197 | + return { |
| 198 | + matches: [], |
| 199 | + totalMatches: 0, |
| 200 | + truncated: true, |
| 201 | + truncatedReason: "max_output_bytes", |
| 202 | + error: "Output too large and could not be parsed", |
| 203 | + } |
| 204 | + } |
| 205 | + } else { |
| 206 | + return { matches: [], totalMatches: 0, truncated: false } |
| 207 | + } |
| 208 | + } |
| 209 | + |
| 210 | + const totalMatches = matches.length |
| 211 | + const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES |
| 212 | + const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches |
| 213 | + |
| 214 | + return { |
| 215 | + matches: finalMatches, |
| 216 | + totalMatches, |
| 217 | + truncated: outputTruncated || matchesTruncated, |
| 218 | + truncatedReason: outputTruncated ? "max_output_bytes" : matchesTruncated ? "max_matches" : undefined, |
157 | 219 | } |
158 | 220 | } |
159 | 221 |
|
|
0 commit comments