pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
189 lines (188 loc) • 9.76 kB
JavaScript
/**
* ast_grep_replace tool definition
*
* Extracted from index.ts for maintainability.
*/
import { Type } from "../clients/deps/typebox.js";
import { astGrepRemediationHint, classifyAstGrepError, logAstGrepToolEvent, } from "../clients/ast-grep-tool-logger.js";
import { hasStructuralIntent, synthesizeReplaceRule } from "../clients/ast-grep-yaml-synth.js";
import { compactRenderResult } from "./render-compact.js";
/**
* Build the agent-facing error text, appending a remediation hint derived from
* the same classification we log. Curated spawn errors return null from the
* hint map (their message already carries guidance), so this only adds value
* for the raw-stderr categories.
*/
function errorTextWithHint(raw) {
const hint = astGrepRemediationHint(classifyAstGrepError(raw));
return hint ? `Error: ${raw}\n\n${hint}` : `Error: ${raw}`;
}
import { LANGUAGES } from "./shared.js";
function lineCount(value) {
if (!value)
return 0;
let lines = 1;
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) === 10)
lines++;
}
return lines;
}
export function createAstGrepReplaceTool(astGrepClient) {
return {
name: "ast_grep_replace",
label: "AST Replace",
description: "Replace code using AST-aware pattern matching. IMPORTANT: Use specific AST patterns, not text. Dry-run by default (use apply=true to apply).\n\n" +
"✅ GOOD patterns (single AST node):\n" +
" - pattern='console.log($MSG)' rewrite='logger.info($MSG)'\n" +
" - pattern='var $X' rewrite='let $X'\n" +
" - pattern='function $NAME() { }' rewrite='' (delete)\n\n" +
"❌ BAD patterns (will error):\n" +
" - Raw text without code structure\n" +
' - Missing parentheses: use it($TEST) not it"text"\n' +
" - Incomplete code fragments\n\n" +
"Always use 'paths' to scope to specific files/folders. Dry-run first to preview changes.",
promptSnippet: "AST-aware structural find-and-replace",
renderResult: compactRenderResult(({ details, isError, text }) => {
if (details?.stalePreview) {
return `ast_grep_replace — stale preview, re-run with apply:false`;
}
if (isError) {
return `ast_grep_replace — ${text.split("\n")[0] ?? "error"}`;
}
const n = details?.matchCount ?? 0;
const state = details?.applied ? "applied" : "preview";
return `ast_grep_replace — ${n} match${n === 1 ? "" : "es"}, ${state}`;
}),
parameters: Type.Object({
pattern: Type.String({
description: "AST pattern to match (be specific with context)",
}),
rewrite: Type.String({
description: "Replacement using meta-variables from pattern",
}),
lang: Type.String({
enum: [...LANGUAGES],
description: "Target language",
}),
paths: Type.Optional(Type.Array(Type.String(), { description: "Specific files/folders" })),
insideKind: Type.Optional(Type.String({ description: "Restrict matches to nodes inside an ancestor of this AST node kind. Synthesizes a YAML rule." })),
hasKind: Type.Optional(Type.String({ description: "Restrict matches to nodes that contain a descendant of this AST node kind." })),
follows: Type.Optional(Type.String({ description: "Restrict matches to nodes that immediately follow a sibling matching this pattern." })),
precedes: Type.Optional(Type.String({ description: "Restrict matches to nodes that immediately precede a sibling matching this pattern." })),
apply: Type.Optional(Type.Boolean({ description: "Apply changes (default: false)" })),
strictness: Type.Optional(Type.String({
enum: ["smart", "relaxed", "ast", "cst", "signature", "template"],
description: "Pattern matching strictness. 'smart' (default) ignores comments and whitespace. 'relaxed' also ignores unnamed nodes like punctuation. 'ast' ignores all whitespace.",
})),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const startedAt = Date.now();
const { pattern, rewrite, paths, apply, strictness, insideKind, hasKind, follows, precedes } = params;
const lang = (params.lang ?? "").replace(/^"|"$/g, "");
const pathsCount = paths?.length ?? 1;
const applyFlag = apply ?? false;
function logOutcome(outcome, details = {}) {
try {
logAstGrepToolEvent({
tool: "ast_grep_replace",
lang,
pattern,
patternLineCount: lineCount(pattern),
rewrite,
rewriteLineCount: lineCount(rewrite ?? ""),
pathsCount,
applied: applyFlag,
outcome,
errorKind: outcome === "error"
? classifyAstGrepError(details.errorRaw)
: undefined,
errorRaw: details.errorRaw,
matchCount: details.matchCount ?? 0,
truncated: details.truncated ?? false,
durationMs: Date.now() - startedAt,
});
}
catch {
// Telemetry must never break the tool path.
}
}
if (!(await astGrepClient.ensureAvailable())) {
logOutcome("error", { errorRaw: "ast-grep CLI not found" });
return {
content: [
{
type: "text",
text: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli",
},
],
isError: true,
details: {},
};
}
const searchPaths = paths?.length ? paths : [ctx.cwd || "."];
// Phase 3: structural-intent params → synthesize YAML with fix: field
if (hasStructuralIntent({ insideKind, hasKind, follows, precedes })) {
let ruleYaml;
try {
ruleYaml = synthesizeReplaceRule({ pattern, lang, rewrite, insideKind, hasKind, follows, precedes });
}
catch (err) {
logOutcome("error", { errorRaw: String(err) });
return { content: [{ type: "text", text: `Error synthesizing rule: ${err}` }], isError: true, details: {} };
}
const ruleResult = await astGrepClient.replaceWithRule(ruleYaml, searchPaths, applyFlag);
if (ruleResult.stalePreview) {
logOutcome("error", { errorRaw: "stale_preview" });
return { content: [{ type: "text", text: "Stale preview — pattern no longer matches. Re-run with apply: false." }], isError: true, details: { stalePreview: true } };
}
if (ruleResult.error) {
logOutcome("error", { errorRaw: ruleResult.error });
return { content: [{ type: "text", text: errorTextWithHint(ruleResult.error) }], isError: true, details: {} };
}
const output = astGrepClient.formatMatches(ruleResult.matches, !applyFlag, true);
logOutcome(ruleResult.matches.length === 0 ? "no_matches" : "success", { matchCount: ruleResult.matches.length });
return { content: [{ type: "text", text: output }], details: { matchCount: ruleResult.matches.length, applied: applyFlag } };
}
const result = await astGrepClient.replace(pattern, rewrite, lang, searchPaths, applyFlag, { strictness });
if (result.stalePreview) {
logOutcome("error", { errorRaw: "stale_preview" });
return {
content: [
{
type: "text",
text: "Stale preview — the pattern no longer matches any files. The file content has changed since your last apply: false preview. Re-run with apply: false to get a fresh preview before applying.",
},
],
isError: true,
details: { stalePreview: true },
};
}
if (result.error) {
logOutcome("error", { errorRaw: result.error });
return {
content: [
{ type: "text", text: errorTextWithHint(result.error) },
],
isError: true,
details: {},
};
}
const isDryRun = !applyFlag;
const output = astGrepClient.formatMatches(result.matches, isDryRun, true);
logOutcome(result.matches.length === 0 ? "no_matches" : "success", {
matchCount: result.matches.length,
truncated: result.truncated,
});
return {
content: [{ type: "text", text: output }],
details: {
matchCount: result.matches.length,
totalMatches: result.totalMatches,
truncated: result.truncated,
applied: applyFlag,
},
};
},
};
}