pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
128 lines (127 loc) • 4.25 kB
JavaScript
/**
* YAML rule synthesis for ast_grep_search / ast_grep_replace (Issue #125 Phase 3).
*
* Takes a pattern + structural-intent parameters and produces a valid
* ast-grep YAML rule that routes through `sg scan --config`.
*
* This lets agents express cross-context queries without writing raw YAML:
* insideKind: "function_declaration" → rule.inside: { kind, stopBy: "end" }
* hasKind: "await_expression" → rule.has: { kind }
* follows: "return $X" → rule.follows: { pattern }
* precedes: "return $X" → rule.precedes: { pattern }
*
* Multiple constraints are combined directly on the rule object — ast-grep
* evaluates all of them as an implicit AND.
*/
import { dump } from "./deps/js-yaml.js";
const MAX_SYNTHESIZED_PATTERN_CHARS = 4_000;
const MAX_NODE_KIND_CHARS = 80;
const NODE_KIND_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
/**
* Returns true when at least one structural-intent field is present.
*/
export function hasStructuralIntent(intent) {
return !!(intent.insideKind || intent.hasKind || intent.follows || intent.precedes);
}
/**
* Synthesize an ast-grep YAML rule for replace operations.
* Adds a `fix:` field so `sg scan --update-all` applies the rewrite.
*/
export function synthesizeReplaceRule(intent) {
const base = synthesizeRule(intent);
// js-yaml dump ends with \n; append fix field
return `${base}fix: ${JSON.stringify(intent.rewrite)}\n`;
}
/**
* Synthesize an ast-grep YAML rule from a pattern and structural constraints.
*
* The generated rule uses `stopBy: end` on `inside` so the search climbs
* all ancestors, not just the immediate parent.
*
* @throws if pattern is empty
*/
export function synthesizeRule(intent) {
assertSafePattern(intent.pattern, "pattern");
// Canonical language name for the YAML header (ast-grep is case-sensitive here).
const language = canonicalLanguage(intent.lang);
const rule = {
pattern: intent.pattern,
};
if (intent.insideKind) {
rule.inside = { kind: assertSafeNodeKind(intent.insideKind, "insideKind"), stopBy: "end" };
}
if (intent.hasKind) {
rule.has = { kind: assertSafeNodeKind(intent.hasKind, "hasKind") };
}
if (intent.follows) {
assertSafePattern(intent.follows, "follows");
rule.follows = { pattern: intent.follows };
}
if (intent.precedes) {
assertSafePattern(intent.precedes, "precedes");
rule.precedes = { pattern: intent.precedes };
}
const doc = {
id: "agent-rule",
language,
rule,
};
return dump(doc, { lineWidth: -1 });
}
function assertSafePattern(value, field) {
if (!value.trim()) {
throw new Error(`${field} is required for YAML synthesis`);
}
if (value.length > MAX_SYNTHESIZED_PATTERN_CHARS) {
throw new Error(`${field} is too long for YAML synthesis`);
}
if (value.includes("\0")) {
throw new Error(`${field} contains a NUL byte`);
}
}
function assertSafeNodeKind(value, field) {
const kind = value.trim();
if (kind.length === 0) {
throw new Error(`${field} is required for YAML synthesis`);
}
if (kind.length > MAX_NODE_KIND_CHARS || !NODE_KIND_RE.test(kind)) {
throw new Error(`${field} must be a single AST node kind like function_declaration`);
}
return kind;
}
/**
* Map a user-supplied lang value (e.g. "typescript", "TypeScript") to the
* capitalisation ast-grep expects in the YAML `language:` field.
*/
function canonicalLanguage(lang) {
const map = {
typescript: "TypeScript",
tsx: "Tsx",
javascript: "JavaScript",
jsx: "JavaScript",
python: "Python",
rust: "Rust",
go: "Go",
java: "Java",
kotlin: "Kotlin",
swift: "Swift",
csharp: "CSharp",
cpp: "Cpp",
c: "C",
ruby: "Ruby",
php: "Php",
dart: "Dart",
elixir: "Elixir",
lua: "Lua",
ocaml: "OCaml",
zig: "Zig",
bash: "Bash",
css: "Css",
html: "Html",
json: "Json",
yaml: "Yaml",
toml: "Toml",
vue: "Vue",
};
return map[lang.toLowerCase()] ?? lang;
}