/** * Tool definitions for pi-ast-grep. * * All tools shell out to the ast-grep CLI and return bounded, structured * results. Rewrites participate in Pi's per-file mutation queue so they stay * consistent with concurrent built-in edit/write calls. */ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { StringEnum } from "@earendil-works/pi-ai"; import { type AgentToolResult, type AgentToolUpdateCallback, type ToolDefinition, type TruncationResult, withFileMutationQueue, } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { type Static, Type } from "typebox"; import { type AstGrepResult, type CliRunner, DEFAULT_TIMEOUT_MS, type RunOptions, runAstGrep, surfacedStderr, } from "./cli.ts"; import { type FormattedMatch, formatMatches, formatOutline, mergeOutlineSignatures, type OutlineItem, parseJsonArrayWithMeta, toFormattedMatch, toOutlineFile, } from "./formats.ts"; import { formatLanguages, LANGUAGES } from "./languages.ts"; import { resolveTargetPath } from "./paths.ts"; /** Exit codes that ast-grep uses for successful-but-empty or diagnostic runs. */ const SEARCH_ALLOWED_CODES = [0, 1] as const; /** * Rewrite arguments that have been previewed with apply=false in this * session. Headless apply=true requires the matching fingerprint first * (preview-first policy). */ const previewedRewrites = new Set(); /** Clears the preview-before-apply bookkeeping (session shutdown, tests). */ export function clearPreviewState(): void { previewedRewrites.clear(); } const runSchema = Type.Object( { pattern: Type.Optional( Type.String({ description: "AST pattern to search for, for example console.log($ARG). Mutually exclusive with kind.", }), ), kind: Type.Optional( Type.String({ description: "AST node kind to match (tree-sitter kind, for example function_declaration, or an ESQuery-style selector). Mutually exclusive with pattern.", }), ), code: Type.Optional( Type.String({ description: "Code snippet to search instead of a path. Requires lang. The snippet is written to a temp file and searched as a single file.", }), ), lang: Type.Optional( Type.String({ description: "Language alias for the pattern, for example ts, py, rs, go, java. Omit to infer from file extensions. See ast_grep_languages for the catalog.", }), ), path: Type.Optional( Type.String({ description: "File or directory to search. Defaults to the current working directory.", }), ), globs: Type.Optional( Type.Array( Type.String({ description: "Include or exclude globs in .gitignore syntax, for example **/*.test.ts or !vendor/**. Later globs take precedence.", }), ), ), context: Type.Optional( Type.Number({ description: "Include N lines of context before and after each match; the match's lines field becomes multi-line.", minimum: 0, maximum: 10, }), ), max_results: Type.Optional( Type.Number({ description: "Maximum number of matches to return. Defaults to 200.", minimum: 1, maximum: 10_000, }), ), threads: Type.Optional( Type.Number({ description: "Worker threads to use. Omit for ast-grep's default.", minimum: 1, maximum: 32, }), ), follow: Type.Optional( Type.Boolean({ description: "Follow symbolic links.", }), ), no_ignore: Type.Optional( StringEnum(["dot", "vcs", "hidden", "exclude", "global", "parent"] as const, { description: "Suppress ignore files of the given type.", }), ), }, { additionalProperties: false }, ); type RunInput = Static; const scanSchema = Type.Object( { rule_yaml: Type.Optional( Type.String({ description: "Full YAML rule configuration, for example: id: no-console\nlanguage: TypeScript\nseverity: warning\nrule:\n pattern: console.log($ARG). Separate multiple rules with ---. Mutually exclusive with rule_file; omit both to use the project's sgconfig.yml rules.", }), ), rule_file: Type.Optional( Type.String({ description: "Path to a YAML rule file (cwd-relative or absolute) with one rule or several separated by ---.", }), ), config: Type.Optional( Type.String({ description: "Path to an sgconfig.yml; ruleDirs resolve relative to it.", }), ), path: Type.Optional( Type.String({ description: "File or directory to scan. Defaults to the current working directory.", }), ), globs: Type.Optional( Type.Array( Type.String({ description: "Include or exclude globs in .gitignore syntax, for example **/*.test.ts or !vendor/**. Later globs take precedence.", }), ), ), filter: Type.Optional( Type.String({ description: "Only run rules whose ids match this regular expression.", }), ), max_results: Type.Optional( Type.Number({ description: "Maximum number of matches to return. ast-grep caps the total before serialization; with multiple rules the surviving rule is not deterministic.", minimum: 1, maximum: 10_000, }), ), threads: Type.Optional( Type.Number({ description: "Worker threads to use. Omit for ast-grep's default.", minimum: 1, maximum: 32, }), ), follow: Type.Optional( Type.Boolean({ description: "Follow symbolic links.", }), ), no_ignore: Type.Optional( StringEnum(["dot", "vcs", "hidden", "exclude", "global", "parent"] as const, { description: "Suppress ignore files of the given type.", }), ), }, { additionalProperties: false }, ); type ScanInput = Static; const rewriteSchema = Type.Object( { mode: StringEnum(["pattern", "rule"] as const, { description: "pattern rewrites with a --rewrite template; rule rewrites with the fix key of a YAML rule.", }), pattern: Type.Optional(Type.String({ description: "AST pattern to match (required when mode is pattern)." })), rewrite: Type.Optional( Type.String({ description: "Replacement template using the pattern's meta variables, for example logger.info($ARG) (required when mode is pattern).", }), ), rule_yaml: Type.Optional( Type.String({ description: "YAML rule with a fix key (required when mode is rule), for example: id: rename-fn\nlanguage: TypeScript\nrule:\n pattern: foo($X)\nfix: bar($X).", }), ), lang: Type.Optional( Type.String({ description: "Language alias for the pattern, for example ts, py, rs. Omit to infer.", }), ), path: Type.Optional( Type.String({ description: "File or directory to rewrite. Defaults to the current working directory.", }), ), globs: Type.Optional( Type.Array( Type.String({ description: "Include or exclude globs in .gitignore syntax, for example **/*.test.ts or !vendor/**. Later globs take precedence.", }), ), ), context: Type.Optional( Type.Number({ description: "Include N lines of context before and after each match; the match's lines field becomes multi-line.", minimum: 0, maximum: 10, }), ), apply: Type.Optional( Type.Boolean({ description: "When false (default) only previews matches and replacements. When true, writes the rewrites to disk per file through Pi's mutation queue.", }), ), max_results: Type.Optional( Type.Number({ description: "Maximum number of matches to show. Defaults to 200.", minimum: 1, maximum: 10_000, }), ), threads: Type.Optional( Type.Number({ description: "Worker threads to use. Omit for ast-grep's default.", minimum: 1, maximum: 32, }), ), follow: Type.Optional( Type.Boolean({ description: "Follow symbolic links.", }), ), no_ignore: Type.Optional( StringEnum(["dot", "vcs", "hidden", "exclude", "global", "parent"] as const, { description: "Suppress ignore files of the given type.", }), ), }, { additionalProperties: false }, ); type RewriteInput = Static; const outlineSchema = Type.Object( { path: Type.String({ description: "File or directory to outline. Required.", }), items: Type.Optional( StringEnum(["structure", "exports", "imports", "all"] as const, { description: "Top-level items to show. Defaults to structure for files and exports for directories.", }), ), view: Type.Optional( StringEnum(["names", "signatures", "digest", "expanded"] as const, { description: "Detail level: names groups names by symbol type, signatures shows one line per item, digest adds member names, expanded shows one line per member.", }), ), match: Type.Optional( Type.String({ description: "Keep only top-level items whose name or signature matches this regular expression.", }), ), type: Type.Optional( Type.String({ description: "Comma-separated top-level symbol types to keep, for example class,function or struct,enum,interface.", }), ), pub_members: Type.Optional( Type.Boolean({ description: "Hide private members when the view prints members.", }), ), outline_rules: Type.Optional( Type.String({ description: "Path to a YAML file with custom outline rules (role: item or member).", }), ), no_default_outline_rules: Type.Optional( Type.Boolean({ description: "Hide the built-in outline rules; only custom outline_rules apply.", }), ), threads: Type.Optional( Type.Number({ description: "Worker threads to use. Omit for ast-grep's default.", minimum: 1, maximum: 32, }), ), follow: Type.Optional( Type.Boolean({ description: "Follow symbolic links.", }), ), no_ignore: Type.Optional( StringEnum(["dot", "vcs", "hidden", "exclude", "global", "parent"] as const, { description: "Suppress ignore files of the given type.", }), ), }, { additionalProperties: false }, ); type OutlineInput = Static; const debugQuerySchema = Type.Object( { pattern: Type.String({ description: "AST pattern or code snippet to inspect." }), lang: Type.String({ description: "Language alias for the pattern, for example ts, py, rs. Required.", }), format: Type.Optional( StringEnum(["cst", "ast", "pattern", "sexp"] as const, { description: "cst shows all nodes including punctuation, ast shows only named nodes, pattern shows how the pattern is interpreted, sexp shows the tree-sitter S-expression.", }), ), }, { additionalProperties: false }, ); type DebugQueryInput = Static; interface ToolContext { cwd: string; } /** Details shapes consumed by the TUI renderers (cast from unknown). */ interface RunDetails { matches?: Array<{ file: string; line: number; column: number; text: string }>; total_matches: number; truncated?: boolean; } interface RewriteDetails { applied?: boolean | Array<{ file: string; applied_count: number }>; changes_applied?: boolean; diffs?: FileDiff[]; preview_matches?: Array<{ file: string; text: string; replacement?: string }>; total_matches?: number; truncated?: boolean; } interface OutlineDetails { files?: Array<{ path: string; items: OutlineItem[] }>; item_count: number; } /** One localized change in a file: context plus the deleted/added lines. */ interface DiffHunk { added: string[]; after: string[]; before: string[]; deleted: string[]; line: number; } /** Per-file diff preview for rewrite matches, keyed by the match file name. */ interface FileDiff { file: string; hunks: DiffHunk[]; } /** * Builds before/after hunks around every rewrite match by reading the current * file content. Unreadable files are skipped so diffs never fail the preview. */ async function previewDiffs( files: readonly string[], matches: readonly FormattedMatch[], cwd: string, ): Promise { const diffs = await Promise.all( files.map(async (file): Promise => { const absolute = resolveTargetPath({ cwd }, file); let content: string; try { content = await readFile(absolute, "utf8"); } catch { return; } const lines = content.split("\n"); const fileMatches = matches.filter((match) => match.file === file).sort((a, b) => a.line - b.line); if (fileMatches.length === 0) { return; } const hunks: DiffHunk[] = fileMatches.map((match) => ({ line: match.line, before: lines.slice(Math.max(0, match.line - 3), match.line - 1), after: lines.slice(match.line, match.line + 2), deleted: match.text.split("\n"), added: (match.replacement ?? match.text).split("\n"), })); return { file, hunks }; }), ); return diffs.filter((diff): diff is FileDiff => diff !== undefined); } function globArgs(globs: readonly string[] | undefined): string[] { return (globs ?? []).flatMap((glob) => ["--globs", glob]); } function matchLimit(input: { max_results?: number }): number { return input.max_results ?? 200; } function contextArg(context: number | undefined): string[] { return context === undefined ? [] : ["--context", String(context)]; } function perfArgs(input: { threads?: number; follow?: boolean; no_ignore?: "dot" | "vcs" | "hidden" | "exclude" | "global" | "parent"; }): string[] { return [ ...(input.threads === undefined ? [] : ["--threads", String(input.threads)]), ...(input.follow === true ? ["--follow"] : []), ...(input.no_ignore === undefined ? [] : ["--no-ignore", input.no_ignore]), ]; } function runOptions(signal: AbortSignal | undefined, cwd: string, checkTarget?: string): RunOptions { return { ...(signal === undefined ? {} : { signal }), cwd, timeoutMs: DEFAULT_TIMEOUT_MS, allowCodes: SEARCH_ALLOWED_CODES, ...(checkTarget === undefined ? {} : { checkTarget }), }; } function resolvedPath(ctx: ToolContext, input: { path?: string }): string { return resolveTargetPath(ctx, input.path ?? "."); } function summary(matches: readonly FormattedMatch[], limit: number): string { return formatMatches(matches, { maxMatches: limit }); } function resultText(content: string, details: unknown) { return { content: [{ text: content, type: "text" as const }], details }; } /** Throws with the resolved path when the target does not exist. */ async function assertExists(path: string): Promise { try { await stat(path); } catch (error) { throw new Error(`${path}: no such file or directory`, { cause: error }); } } /** First extension of the language alias from the catalog, or .txt. */ function firstExtension(lang: string): string { for (const language of LANGUAGES) { if (language.aliases.includes(lang)) { return language.extensions.find((extension) => extension === lang) ?? language.extensions[0] ?? "txt"; } } return "txt"; } interface CodeSnippet { cleanup: () => Promise; path: string; } /** Spills a code snippet to a temp file so ast-grep can search it. */ async function writeCodeSnippet(code: string, lang: string): Promise { const directory = await mkdtemp(join(tmpdir(), "pi-ast-grep-")); const safeLang = lang.replace(/[/\\]/g, "_"); const filePath = join(directory, `${safeLang}.${firstExtension(lang)}`); await writeFile(filePath, code, "utf8"); return { path: filePath, cleanup: () => rm(directory, { recursive: true, force: true }) }; } /** Copies ast-grep's truncation metadata into tool details when truncated. */ function truncationDetails(result: AstGrepResult): { truncation?: TruncationResult; fullOutputPath?: string } { if (result.truncation === undefined) { return {}; } const details: { truncation: TruncationResult; fullOutputPath?: string } = { truncation: result.truncation }; if (result.fullOutputPath !== undefined) { details.fullOutputPath = result.fullOutputPath; } return details; } /** Canonical fingerprint of a rewrite; excludes max_results (no write impact). */ function rewriteFingerprint( input: { mode: "pattern" | "rule"; pattern?: string; rewrite?: string; rule_yaml?: string; lang?: string; globs?: readonly string[]; }, target: string, ): string { return JSON.stringify({ mode: input.mode, pattern: input.pattern, rewrite: input.rewrite, rule_yaml: input.rule_yaml, lang: input.lang, path: target, globs: input.globs, }); } function sendRunningUpdate(onUpdate: AgentToolUpdateCallback | undefined): void { onUpdate?.({ content: [{ type: "text", text: "…running ast-grep…" }], details: { phase: "running" } }); } /** Text notice pointing at the spilled full output when truncation occurred. */ function outputNotice(result: AstGrepResult): string { return result.fullOutputPath === undefined ? "" : `\n[Output truncated: full output saved to ${result.fullOutputPath}]`; } /** * Prepares legacy argument names before schema validation: renames are * applied only when the new key is absent, and the result is a new object. */ function normalizeArgs(args: unknown, renames: Record): unknown { if (!args || typeof args !== "object" || Array.isArray(args)) { return args; } const raw = args as Record; let next: Record | undefined; for (const from of Object.keys(renames)) { const to = renames[from] as string; if (from in raw && !(to in raw)) { next = next ?? { ...raw }; next[to] = raw[from]; } } return next ?? raw; } function contentText(result: AgentToolResult): string { return result.content.map((part) => (part.type === "text" ? part.text : "")).join(""); } function outlineLines(items: readonly OutlineItem[], depth = 0): string[] { const lines: string[] = []; for (const item of items) { lines.push(`${" ".repeat(depth)}${item.line}: ${item.signature}`); if (item.members !== undefined) { lines.push(...outlineLines(item.members, depth + 1)); } } return lines; } export const TOOL_NAMES = [ "ast_grep_run", "ast_grep_scan", "ast_grep_rewrite", "ast_grep_outline", "ast_grep_debug_query", "ast_grep_languages", ] as const; export function createAstGrepTools(runner: CliRunner): ToolDefinition[] { return [ { description: "Search code by AST structure with a pattern (and optional meta variables, for example console.log($ARG)), a node kind, or a code snippet. Use when the query is structural (shape matters) instead of text grep; kind matches whole nodes of a tree-sitter kind.", label: "ast-grep run", name: "ast_grep_run", promptSnippet: "Structural code search with ast-grep patterns", promptGuidelines: [ "Use ast_grep_run for structural code search (AST patterns, meta variables) instead of text grep when shape matters.", "Pass either pattern or kind (never both); use kind to match whole nodes of a tree-sitter kind such as function_declaration.", "Use code to search an inline snippet instead of a path; code mode requires lang.", "Use ast_grep_debug_query before ast_grep_run when you do not know the AST node kinds for a language.", ], parameters: runSchema, prepareArguments: (args) => normalizeArgs(args, { language: "lang", file: "path", maxResults: "max_results" }) as Static, async execute(_toolCallId, input: RunInput, signal, onUpdate, ctx) { const hasPattern = input.pattern !== undefined; const hasKind = input.kind !== undefined; if (hasPattern === hasKind) { throw new Error("ast_grep_run requires exactly one of pattern or kind"); } let snippet: CodeSnippet | undefined; let target: string; if (input.code === undefined) { target = resolvedPath(ctx, input); } else { if (input.path !== undefined) { throw new Error("provide only one of code, path"); } if (input.code.trim() === "") { throw new Error("code mode requires non-empty code"); } if (input.lang === undefined) { throw new Error("code mode requires lang so the snippet can be parsed"); } if (!LANGUAGES.some((language) => language.aliases.includes(input.lang as string))) { throw new Error(`unknown language alias: ${input.lang} (see ast_grep_languages for the catalog)`); } snippet = await writeCodeSnippet(input.code, input.lang); target = snippet.path; } try { const patternArgs = hasKind ? ["--kind", input.kind as string] : ["--pattern", input.pattern as string]; const args = [ "run", ...patternArgs, ...(input.lang === undefined ? [] : ["--lang", input.lang]), ...contextArg(input.context), ...perfArgs(input), "--json=compact", ...globArgs(input.globs), target, ]; sendRunningUpdate(onUpdate); const result = await runAstGrep(runner, args, runOptions(signal, ctx.cwd, target)); const parsed = parseJsonArrayWithMeta(result.text); const matches = parsed.matches .map((raw) => toFormattedMatch(raw)) .filter((match): match is FormattedMatch => match !== undefined); if (matches.length === 0) { const surfaced = surfacedStderr(result.stderr, false); if (surfaced !== undefined) { throw new Error(surfaced); } } const limit = matchLimit(input); const shown = matches.slice(0, limit); return resultText(`${summary(shown, limit)}${outputNotice(result)}`, { command: args, matches: shown, total_matches: matches.length, truncated: result.truncated || parsed.truncated || matches.length > limit, ...truncationDetails(result), }); } finally { if (snippet !== undefined) { await snippet.cleanup(); } } }, renderCall(args, theme, _context) { const input = args as Partial>; let text = theme.fg("toolTitle", theme.bold("ast-grep run ")); text += theme.fg("accent", input.pattern ?? input.kind ?? ""); if (input.path !== undefined) { text += theme.fg("muted", ` in ${input.path}`); } return new Text(text, 0, 0); }, renderResult(result, { expanded, isPartial }, theme, _context) { if (isPartial) { return new Text(theme.fg("warning", "running…"), 0, 0); } const details = result.details as Partial | undefined; if (details === undefined) { return new Text(contentText(result), 0, 0); } const total = details.total_matches ?? 0; let text = total === 0 ? theme.fg("dim", "No matches") : theme.fg("success", `${total} match${total === 1 ? "" : "es"}`); if (details.truncated === true) { text += theme.fg("warning", " (truncated)"); } if (expanded) { for (const match of (details.matches ?? []).slice(0, 20)) { text += `\n${theme.fg("dim", `${match.file}:${match.line}:${match.column} ${match.text}`)}`; } } return new Text(text, 0, 0); }, }, { description: "Scan code with YAML ast-grep rules (inline YAML, a rule file, or the project's sgconfig.yml rules) and report structured matches. Use when a search needs relational or composite rules (inside, has, any, not) or the project's reusable sgconfig.yml rules.", label: "ast-grep scan", name: "ast_grep_scan", promptSnippet: "Scan code with YAML ast-grep rules", promptGuidelines: [ "Use ast_grep_scan when a search needs relational or composite rules (inside, has, any, not) or a reusable rule configuration.", "Always use stopBy: end in has and inside rules unless you need neighbor-only traversal.", "Provide exactly one of rule_yaml or rule_file; when neither is given the project's sgconfig.yml rules are discovered automatically.", "Invalid rules fail with ast-grep's rule-parse error; use --max-results to cap matches before serialization.", ], parameters: scanSchema, prepareArguments: (args) => normalizeArgs(args, { yaml: "rule_yaml", file: "path", maxResults: "max_results" }) as Static< typeof scanSchema >, async execute(_toolCallId, input: ScanInput, signal, onUpdate, ctx) { if (input.rule_yaml !== undefined && input.rule_file !== undefined) { throw new Error("provide only one of rule_yaml, rule_file"); } const target = resolvedPath(ctx, input); const ruleArgs: string[] = []; if (input.rule_file !== undefined) { const rulePath = resolveTargetPath(ctx, input.rule_file); await assertExists(rulePath); ruleArgs.push("--rule", rulePath); } else if (input.rule_yaml !== undefined) { ruleArgs.push("--inline-rules", input.rule_yaml); } if (input.config !== undefined && input.rule_yaml === undefined && input.rule_file === undefined) { ruleArgs.push("--config", resolveTargetPath(ctx, input.config)); } const args = [ "scan", ...ruleArgs, ...(input.filter === undefined ? [] : ["--filter", input.filter]), ...(input.max_results === undefined ? [] : ["--max-results", String(input.max_results)]), ...perfArgs(input), "--json=compact", ...globArgs(input.globs), target, ]; sendRunningUpdate(onUpdate); const result = await runAstGrep(runner, args, runOptions(signal, ctx.cwd, target)); const parsed = parseJsonArrayWithMeta(result.text); const matches = parsed.matches .map((raw) => toFormattedMatch(raw)) .filter((match): match is FormattedMatch => match !== undefined); if (matches.length === 0) { const surfaced = surfacedStderr(result.stderr, false); if (surfaced !== undefined) { throw new Error(surfaced); } } const limit = matchLimit(input); const shown = matches.slice(0, limit); const ruleIds = [...new Set(shown.map((match) => match.ruleId).filter((id): id is string => id !== undefined))]; const severities = Object.fromEntries( [...new Set(shown.map((match) => match.severity).filter((s): s is string => s !== undefined))].map( (severity) => [severity, shown.filter((match) => match.severity === severity).length], ), ); return resultText(`${summary(shown, limit)}${outputNotice(result)}`, { command: args, matches: shown, total_matches: matches.length, rule_ids: ruleIds, severity_counts: severities, truncated: result.truncated || parsed.truncated || matches.length > limit, ...truncationDetails(result), }); }, }, { description: "Preview or apply structural code rewrites: pattern-based (--rewrite template) or rule-based (YAML fix key). Apply writes through Pi's mutation queue. Use for structural transformation; always preview with apply=false first, then apply=true for the same arguments.", label: "ast-grep rewrite", name: "ast_grep_rewrite", promptSnippet: "Preview and apply ast-grep structural rewrites", promptGuidelines: [ "Use ast_grep_rewrite for structural code transformation; preview with apply=false first, then apply=true for the same arguments.", "Remember ast_grep_rewrite is destructive when apply is true; show the preview before applying.", "apply=true requires a prior apply=false preview with the same arguments in this session (preview-first policy); interactive sessions also confirm with the user.", ], parameters: rewriteSchema, prepareArguments: (args) => normalizeArgs(args, { rewrite_mode: "mode", pattern2: "pattern", replacement: "rewrite", yaml: "rule_yaml", file: "path", maxResults: "max_results", }) as Static, async execute(_toolCallId, input: RewriteInput, signal, onUpdate, ctx) { if (input.mode === "pattern") { if (input.pattern === undefined) { throw new Error("mode pattern requires the pattern field"); } if (input.rewrite === undefined) { throw new Error("mode pattern requires the rewrite field"); } } else if (input.rule_yaml === undefined) { throw new Error("mode rule requires the rule_yaml field"); } const target = resolvedPath(ctx, input); const fingerprint = rewriteFingerprint(input, target); sendRunningUpdate(onUpdate); if (input.apply === true) { const trusted = typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : true; if (!trusted) { throw new Error("rewrites require a trusted project; run apply=false for a preview"); } if (ctx.hasUI !== true && !previewedRewrites.has(fingerprint)) { throw new Error( "apply=true requires a prior apply=false preview with the same arguments in this session (preview-first policy)", ); } } const base = input.mode === "pattern" ? [ "run", "--pattern", input.pattern as string, ...(input.lang === undefined ? [] : ["--lang", input.lang]), "--rewrite", input.rewrite as string, ...contextArg(input.context), ...perfArgs(input), "--json=compact", ] : ["scan", "--inline-rules", input.rule_yaml as string, ...perfArgs(input), "--json=compact"]; // --json suppresses the actual write even with --update-all, so the // apply pass must drop it and verify with a separate JSON search. const applyBase = base.filter((arg) => arg !== "--json=compact"); const dryRunArgs = [...base, ...globArgs(input.globs), target]; const dryRun = await runAstGrep(runner, dryRunArgs, runOptions(signal, ctx.cwd, target)); const parsed = parseJsonArrayWithMeta(dryRun.text); const preview = parsed.matches .map((raw) => toFormattedMatch(raw)) .filter((match): match is FormattedMatch => match !== undefined); if (preview.length === 0) { const surfaced = surfacedStderr(dryRun.stderr, false); if (surfaced !== undefined) { throw new Error(surfaced); } } const files = [...new Set(preview.map((match) => match.file).filter((file) => file !== ""))]; const diffs = await previewDiffs(files, preview, ctx.cwd); const limit = matchLimit(input); if (input.apply !== true) { previewedRewrites.add(fingerprint); return resultText( `Preview only (apply=false). ${summary(preview, limit)}${outputNotice(dryRun)}\nPer-file diffs are in details.diffs.`, { command: dryRunArgs, preview_matches: preview.slice(0, limit), total_matches: preview.length, applied: false, diffs, truncated: dryRun.truncated || parsed.truncated || preview.length > limit, ...truncationDetails(dryRun), }, ); } if (ctx.hasUI === true) { const ok = await ctx.ui.confirm( "Apply ast-grep rewrites", `Apply ${preview.length} rewrite(s) across ${files.length} file(s)? This writes to disk.`, { timeout: 60_000 }, ); if (!ok) { throw new Error("apply cancelled by user"); } } const applied: Array<{ file: string; applied_count: number }> = []; const applyToFile = async (file: string): Promise<{ file: string; applied_count: number }> => { if (files.length > 1) { onUpdate?.({ content: [{ type: "text", text: `applying to ${file}…` }], details: { phase: "applying" }, }); } const absoluteFile = resolveTargetPath(ctx, file); const before = preview.filter((match) => match.file === file).length; await withFileMutationQueue(absoluteFile, async () => { const applyArgs = [...applyBase, "--update-all", absoluteFile]; // A zero-match --update-all prints nothing and exits 1; that is an // idempotent re-apply (nothing left to rewrite), not a broken spawn. await runAstGrep(runner, applyArgs, { ...runOptions(signal, ctx.cwd, absoluteFile), allowEmptyOutput: true, }); }); const verify = await runAstGrep(runner, [...base, absoluteFile], runOptions(signal, ctx.cwd, absoluteFile)); const remaining = parseJsonArrayWithMeta(verify.text).matches.length; return { file, applied_count: Math.max(0, before - remaining) }; }; // Each file's rewrite must be verified after its own apply lands, and // a failure must stop later files, so the pairs run strictly // sequentially: parallelizing would race verifies against the // per-file mutation queue and rewrite files after an error. await files.reduce>(async (chain, file) => { await chain; applied.push(await applyToFile(file)); }, Promise.resolve()); return resultText( `Applied ${applied.reduce((sum, entry) => sum + entry.applied_count, 0)} rewrite(s) across ${applied.length} file(s).\n${summary(preview, limit)}${outputNotice(dryRun)}\nPer-file diffs are in details.diffs.`, { command: dryRunArgs, preview_matches: preview.slice(0, limit), applied, total_matches: preview.length, changes_applied: true, diffs, truncated: dryRun.truncated || parsed.truncated || preview.length > limit, ...truncationDetails(dryRun), }, ); }, renderCall(args, theme, _context) { const input = args as Partial>; let text = theme.fg("toolTitle", theme.bold("ast-grep rewrite ")); text += theme.fg("accent", input.mode ?? ""); if (input.path !== undefined) { text += theme.fg("muted", ` in ${input.path}`); } return new Text(text, 0, 0); }, renderResult(result, { expanded, isPartial }, theme, _context) { if (isPartial) { return new Text(theme.fg("warning", "running…"), 0, 0); } const details = result.details as Partial | undefined; if (details === undefined) { return new Text(contentText(result), 0, 0); } const isApplied = details.changes_applied === true || details.applied === true; const appliedFiles = Array.isArray(details.applied) ? details.applied : []; let text: string; if (isApplied) { const count = appliedFiles.reduce((sum, entry) => sum + entry.applied_count, 0); text = theme.fg( "success", `${count} rewrite${count === 1 ? "" : "s"} across ${appliedFiles.length} file${appliedFiles.length === 1 ? "" : "s"}`, ); } else { text = theme.fg("success", `${details.total_matches ?? 0} rewrites (preview)`); } if (details.truncated === true) { text += theme.fg("warning", " (truncated)"); } if (expanded) { const diffs = Array.isArray(details.diffs) && details.diffs.length > 0 ? details.diffs : undefined; if (diffs === undefined) { for (const match of details.preview_matches ?? []) { text += `\n${theme.fg("dim", `- ${match.text}`)}`; if (match.replacement !== undefined) { text += `\n${theme.fg("dim", `+ ${match.replacement}`)}`; } } } else { for (const file of diffs) { text += `\n${theme.fg("accent", file.file)}`; for (const hunk of file.hunks) { for (const line of hunk.before) { text += `\n${theme.fg("dim", line)}`; } for (const line of hunk.deleted) { text += `\n${theme.fg("dim", `- ${line}`)}`; } for (const line of hunk.added) { text += `\n${theme.fg("dim", `+ ${line}`)}`; } for (const line of hunk.after) { text += `\n${theme.fg("dim", line)}`; } } } } } return new Text(text, 0, 0); }, }, { description: "Print a compact structural outline of a file or directory: top-level items (functions, classes, imports, exports) and direct members with signatures and line numbers. Use before reading an unfamiliar file or directory to get a cheap structural map; use items=exports for a directory's public surface and items=imports for its imports.", label: "ast-grep outline", name: "ast_grep_outline", promptSnippet: "Outline code structure (files, symbols, members)", promptGuidelines: [ "Use ast_grep_outline before reading a full file when exploring unfamiliar code; it gives a cheap structural map with line numbers.", "Use ast_grep_outline on a directory with items=exports to map a project's public surface.", "Pass outline_rules to customize which nodes become items/members, and no_default_outline_rules to hide the built-in rules.", ], parameters: outlineSchema, prepareArguments: (args) => normalizeArgs(args, { file: "path" }) as Static, async execute(_toolCallId, input: OutlineInput, signal, onUpdate, ctx) { const target = resolvedPath(ctx, input); const outlineRuleArgs: string[] = []; if (input.outline_rules !== undefined) { const outlineRulesPath = resolveTargetPath(ctx, input.outline_rules); await assertExists(outlineRulesPath); outlineRuleArgs.push("--outline-rules", outlineRulesPath); } if (input.no_default_outline_rules === true) { outlineRuleArgs.push("--no-default-outline-rules"); } const buildOutlineArgs = (view: string | undefined): string[] => [ "outline", ...outlineRuleArgs, target, ...(input.items === undefined ? [] : ["--items", input.items]), ...(view === undefined ? [] : ["--view", view]), ...(input.match === undefined ? [] : ["--match", input.match]), ...(input.type === undefined ? [] : ["--type", input.type]), ...(input.pub_members === true ? ["--pub-members"] : []), ...perfArgs(input), "--json=compact", ]; sendRunningUpdate(onUpdate); const args = buildOutlineArgs(input.view); const result = await runAstGrep(runner, args, runOptions(signal, ctx.cwd, target)); const parsed = parseJsonArrayWithMeta(result.text); let files = parsed.matches .map((raw) => toOutlineFile(raw)) .filter((file): file is NonNullable> => file !== undefined); if (files.length === 0) { const surfaced = surfacedStderr(result.stderr, false); if (surfaced !== undefined) { throw new Error(surfaced); } } // The expanded view is the only view whose JSON carries member // signatures. Run it once more and merge, so the default and // signatures views show member signatures with line numbers, and // the signatures view keeps its members (its own JSON omits them). if ((input.view === undefined || input.view === "signatures") && files.length > 0) { const expandedResult = await runAstGrep( runner, buildOutlineArgs("expanded"), runOptions(signal, ctx.cwd, target), ); const expanded = parseJsonArrayWithMeta(expandedResult.text) .matches.map((raw) => toOutlineFile(raw)) .filter((file): file is NonNullable> => file !== undefined); files = mergeOutlineSignatures(files, expanded); } const itemCount = files.reduce((sum, file) => sum + file.items.length, 0); return resultText( `${files.length} file(s), ${itemCount} top-level item(s):\n${formatOutline(files)}${outputNotice(result)}`, { command: args, files, item_count: itemCount, truncated: result.truncated || parsed.truncated, ...truncationDetails(result), }, ); }, renderCall(args, theme, _context) { const input = args as Partial>; let text = theme.fg("toolTitle", theme.bold("ast-grep outline ")); text += theme.fg("accent", input.path ?? ""); return new Text(text, 0, 0); }, renderResult(result, { expanded, isPartial }, theme, _context) { if (isPartial) { return new Text(theme.fg("warning", "running…"), 0, 0); } const details = result.details as Partial | undefined; if (details === undefined) { return new Text(contentText(result), 0, 0); } const fileCount = (details.files ?? []).length; const itemCount = details.item_count ?? 0; let text = theme.fg( "success", `${fileCount} file${fileCount === 1 ? "" : "s"}, ${itemCount} item${itemCount === 1 ? "" : "s"}`, ); if (expanded) { for (const file of details.files ?? []) { text += `\n${theme.fg("accent", file.path)}`; for (const line of outlineLines(file.items)) { text += `\n${theme.fg("dim", line)}`; } } } return new Text(text, 0, 0); }, }, { description: "Inspect how ast-grep parses a pattern: dump the tree-sitter CST, AST, pattern interpretation, or S-expression. Use when an ast_grep_run pattern fails to match or you need the tree-sitter node kind names for a language.", label: "ast-grep debug query", name: "ast_grep_debug_query", promptSnippet: "Debug ast-grep pattern parsing (CST/AST dump)", promptGuidelines: [ "Use ast_grep_debug_query when an ast_grep_run pattern does not match or you need the tree-sitter node kind names for a language.", ], parameters: debugQuerySchema, async execute(_toolCallId, input: DebugQueryInput, signal, _onUpdate, ctx) { const emptyFile = process.platform === "win32" ? "NUL" : "/dev/null"; const format = input.format ?? "cst"; const args = ["run", "--pattern", input.pattern, "--lang", input.lang, `--debug-query=${format}`, emptyFile]; const result = await runAstGrep(runner, args, runOptions(signal, ctx.cwd)); // ast-grep prints the debug query to stderr, not stdout. const combined = `${result.text}\n${result.stderr}`.trim(); const text = combined === "" ? "(no debug output)" : combined; return resultText(`${text}${outputNotice(result)}`, { command: args, format, truncated: result.truncated }); }, }, { description: "List the languages ast-grep supports: aliases accepted by --lang and file extensions used for detection. Use before run/rewrite/scan to check the language alias and file extension for a target.", label: "ast-grep languages", name: "ast_grep_languages", promptSnippet: "List ast-grep supported languages", parameters: Type.Object({}, { additionalProperties: false }), async execute() { return resultText( `${LANGUAGES.length} supported languages:\n${formatLanguages()}\n\nFull reference: https://astgrep.com/reference/languages`, { languages: LANGUAGES }, ); }, }, ]; }