diff --git a/bun.lock b/bun.lock index e2c1c9e..ffd912e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "oh-my-opencode", "dependencies": { + "@ast-grep/napi": "^0.40.0", "@opencode-ai/plugin": "^1.0.7", }, "devDependencies": { @@ -17,6 +18,26 @@ }, }, "packages": { + "@ast-grep/napi": ["@ast-grep/napi@0.40.0", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.40.0", "@ast-grep/napi-darwin-x64": "0.40.0", "@ast-grep/napi-linux-arm64-gnu": "0.40.0", "@ast-grep/napi-linux-arm64-musl": "0.40.0", "@ast-grep/napi-linux-x64-gnu": "0.40.0", "@ast-grep/napi-linux-x64-musl": "0.40.0", "@ast-grep/napi-win32-arm64-msvc": "0.40.0", "@ast-grep/napi-win32-ia32-msvc": "0.40.0", "@ast-grep/napi-win32-x64-msvc": "0.40.0" } }, "sha512-tq6nO/8KwUF/mHuk1ECaAOSOlz2OB/PmygnvprJzyAHGRVzdcffblaOOWe90M9sGz5MAasXoF+PTcayQj9TKKA=="], + + "@ast-grep/napi-darwin-arm64": ["@ast-grep/napi-darwin-arm64@0.40.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZMjl5yLhKjxdwbqEEdMizgQdWH2NrWsM6Px+JuGErgCDe6Aedq9yurEPV7veybGdLVJQhOah6htlSflXxjHnYA=="], + + "@ast-grep/napi-darwin-x64": ["@ast-grep/napi-darwin-x64@0.40.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-f9Ol5oQKNRMBkvDtzBK1WiNn2/3eejF2Pn9xwTj7PhXuSFseedOspPYllxQo0gbwUlw/DJqGFTce/jarhR/rBw=="], + + "@ast-grep/napi-linux-arm64-gnu": ["@ast-grep/napi-linux-arm64-gnu@0.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tO+VW5GDhT9jGkKOK+3b8+ohKjC98WTzn7wSskd/myyhK3oYL1WTKqCm07WSYBZOJvb3z+WaX+wOUrc4bvtyQ=="], + + "@ast-grep/napi-linux-arm64-musl": ["@ast-grep/napi-linux-arm64-musl@0.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-MS9qalLRjUnF2PCzuTKTvCMVSORYHxxe3Qa0+SSaVULsXRBmuy5C/b1FeWwMFnwNnC0uie3VDet31Zujwi8q6A=="], + + "@ast-grep/napi-linux-x64-gnu": ["@ast-grep/napi-linux-x64-gnu@0.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BeHZVMNXhM3WV3XE2yghO0fRxhMOt8BTN972p5piYEQUvKeSHmS8oeGcs6Ahgx5znBclqqqq37ZfioYANiTqJA=="], + + "@ast-grep/napi-linux-x64-musl": ["@ast-grep/napi-linux-x64-musl@0.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rG1YujF7O+lszX8fd5u6qkFTuv4FwHXjWvt1CCvCxXwQLSY96LaCW88oVKg7WoEYQh54y++Fk57F+Wh9Gv9nVQ=="], + + "@ast-grep/napi-win32-arm64-msvc": ["@ast-grep/napi-win32-arm64-msvc@0.40.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9SqmnQqd4zTEUk6yx0TuW2ycZZs2+e569O/R0QnhSiQNpgwiJCYOe/yPS0BC9HkiaozQm6jjAcasWpFtz/dp+w=="], + + "@ast-grep/napi-win32-ia32-msvc": ["@ast-grep/napi-win32-ia32-msvc@0.40.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-0JkdBZi5l9vZhGEO38A1way0LmLRDU5Vos6MXrLIOVkymmzDTDlCdY394J1LMmmsfwWcyJg6J7Yv2dw41MCxDQ=="], + + "@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.128", "", { "dependencies": { "@opencode-ai/sdk": "1.0.128", "zod": "4.1.8" } }, "sha512-M5vjz3I6KeoBSNduWmT5iHXRtTLCqICM5ocs+WrB3uxVorslcO3HVwcLzrERh/ntpxJ/1xhnHQaeG6Mg+P744A=="], "@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.128", "", {}, "sha512-Kow3Ivg8bR8dNRp8C0LwF9e8+woIrwFgw3ZALycwCfqS/UujDkJiBeYHdr1l/07GSHP9sZPmvJ6POuvfZ923EA=="], diff --git a/package.json b/package.json index 8b02c53..5add7e6 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ }, "homepage": "https://github.com/code-yeongyu/oh-my-opencode#readme", "dependencies": { + "@ast-grep/napi": "^0.40.0", "@opencode-ai/plugin": "^1.0.7" }, "devDependencies": { diff --git a/src/tools/ast-grep/cli.ts b/src/tools/ast-grep/cli.ts new file mode 100644 index 0000000..66f8f05 --- /dev/null +++ b/src/tools/ast-grep/cli.ts @@ -0,0 +1,66 @@ +import { spawn } from "bun" +import { SG_CLI_PATH } from "./constants" +import type { CliMatch, CliLanguage } from "./types" + +export interface RunOptions { + pattern: string + lang: CliLanguage + paths?: string[] + globs?: string[] + rewrite?: string + context?: number + updateAll?: boolean +} + +export async function runSg(options: RunOptions): Promise { + const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"] + + if (options.rewrite) { + args.push("-r", options.rewrite) + if (options.updateAll) { + args.push("--update-all") + } + } + + if (options.context && options.context > 0) { + args.push("-C", String(options.context)) + } + + if (options.globs) { + for (const glob of options.globs) { + args.push("--globs", glob) + } + } + + const paths = options.paths && options.paths.length > 0 ? options.paths : ["."] + args.push(...paths) + + const proc = spawn([SG_CLI_PATH, ...args], { + stdout: "pipe", + stderr: "pipe", + }) + + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + const exitCode = await proc.exited + + if (exitCode !== 0 && stdout.trim() === "") { + if (stderr.includes("No files found")) { + return [] + } + if (stderr.trim()) { + throw new Error(stderr.trim()) + } + return [] + } + + if (!stdout.trim()) { + return [] + } + + try { + return JSON.parse(stdout) as CliMatch[] + } catch { + return [] + } +} diff --git a/src/tools/ast-grep/constants.ts b/src/tools/ast-grep/constants.ts new file mode 100644 index 0000000..6055c55 --- /dev/null +++ b/src/tools/ast-grep/constants.ts @@ -0,0 +1,63 @@ +// ast-grep CLI path (homebrew installed) +export const SG_CLI_PATH = "sg" + +// 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 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/index.ts b/src/tools/ast-grep/index.ts new file mode 100644 index 0000000..b77430c --- /dev/null +++ b/src/tools/ast-grep/index.ts @@ -0,0 +1,23 @@ +import { + ast_grep_search, + ast_grep_replace, + ast_grep_languages, + ast_grep_analyze, + ast_grep_transform, +} from "./tools" + +export const builtinTools = { + ast_grep_search, + ast_grep_replace, + ast_grep_languages, + ast_grep_analyze, + ast_grep_transform, +} + +export { + ast_grep_search, + ast_grep_replace, + ast_grep_languages, + ast_grep_analyze, + ast_grep_transform, +} diff --git a/src/tools/ast-grep/napi.ts b/src/tools/ast-grep/napi.ts new file mode 100644 index 0000000..62e178f --- /dev/null +++ b/src/tools/ast-grep/napi.ts @@ -0,0 +1,106 @@ +import { parse, Lang } from "@ast-grep/napi" +import type { NapiLanguage, AnalyzeResult, MetaVariable, Range } from "./types" + +const LANG_MAP: Record = { + html: Lang.Html, + javascript: Lang.JavaScript, + tsx: Lang.Tsx, + css: Lang.Css, + typescript: Lang.TypeScript, +} + +export function parseCode(code: string, lang: NapiLanguage) { + return parse(LANG_MAP[lang], code) +} + +export function findPattern(root: ReturnType, pattern: string) { + return root.root().findAll(pattern) +} + +function nodeToRange(node: ReturnType["root"]>): Range { + const range = node.range() + return { + start: { line: range.start.line, column: range.start.column }, + end: { line: range.end.line, column: range.end.column }, + } +} + +function extractMetaVariablesFromPattern(pattern: string): string[] { + const matches = pattern.match(/\$[A-Z_][A-Z0-9_]*/g) || [] + return [...new Set(matches.map((m) => m.slice(1)))] +} + +export function extractMetaVariables( + node: ReturnType["root"]>, + pattern: string +): MetaVariable[] { + const varNames = extractMetaVariablesFromPattern(pattern) + const result: MetaVariable[] = [] + + for (const name of varNames) { + const match = node.getMatch(name) + if (match) { + result.push({ + name, + text: match.text(), + kind: String(match.kind()), + }) + } + } + + return result +} + +export function analyzeCode( + code: string, + lang: NapiLanguage, + pattern: string, + shouldExtractMetaVars: boolean +): AnalyzeResult[] { + const root = parseCode(code, lang) + const matches = findPattern(root, pattern) + + return matches.map((node) => ({ + text: node.text(), + range: nodeToRange(node), + kind: String(node.kind()), + metaVariables: shouldExtractMetaVars ? extractMetaVariables(node, pattern) : [], + })) +} + +export function transformCode( + code: string, + lang: NapiLanguage, + pattern: string, + rewrite: string +): { transformed: string; editCount: number } { + const root = parseCode(code, lang) + const matches = findPattern(root, pattern) + + if (matches.length === 0) { + return { transformed: code, editCount: 0 } + } + + const edits = matches.map((node) => { + const metaVars = extractMetaVariables(node, pattern) + let replacement = rewrite + + for (const mv of metaVars) { + replacement = replacement.replace(new RegExp(`\\$${mv.name}`, "g"), mv.text) + } + + return node.replace(replacement) + }) + + const transformed = root.root().commitEdits(edits) + return { transformed, editCount: edits.length } +} + +export function getRootInfo(code: string, lang: NapiLanguage): { kind: string; childCount: number } { + const root = parseCode(code, lang) + const rootNode = root.root() + return { + kind: String(rootNode.kind()), + childCount: rootNode.children().length, + } +} diff --git a/src/tools/ast-grep/tools.ts b/src/tools/ast-grep/tools.ts new file mode 100644 index 0000000..fcf1d98 --- /dev/null +++ b/src/tools/ast-grep/tools.ts @@ -0,0 +1,158 @@ +import { tool } from "@opencode-ai/plugin/tool" +import { CLI_LANGUAGES, NAPI_LANGUAGES, LANG_EXTENSIONS } from "./constants" +import { runSg } from "./cli" +import { analyzeCode, transformCode, getRootInfo } from "./napi" +import { formatSearchResult, formatReplaceResult, formatAnalyzeResult, formatTransformResult } from "./utils" +import type { CliLanguage, NapiLanguage } from "./types" + +function showOutputToUser(context: unknown, output: string): void { + const ctx = context as { metadata?: (input: { metadata: { output: string } }) => void } + ctx.metadata?.({ metadata: { output } }) +} + +export const ast_grep_search = tool({ + description: + "Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " + + "Use meta-variables: $VAR (single node), $$$ (multiple nodes). " + + "Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'", + args: { + pattern: tool.schema.string().describe("AST pattern with meta-variables ($VAR, $$$)"), + lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"), + paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"), + globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"), + context: tool.schema.number().optional().describe("Context lines around match"), + }, + execute: async (args, context) => { + try { + const matches = await runSg({ + pattern: args.pattern, + lang: args.lang as CliLanguage, + paths: args.paths, + globs: args.globs, + context: args.context, + }) + const output = formatSearchResult(matches) + showOutputToUser(context, output) + return output + } catch (e) { + const output = `Error: ${e instanceof Error ? e.message : String(e)}` + showOutputToUser(context, output) + return output + } + }, +}) + +export const ast_grep_replace = tool({ + description: + "Replace code patterns across filesystem with AST-aware rewriting. " + + "Dry-run by default. Use meta-variables in rewrite to preserve matched content. " + + "Example: pattern='console.log($MSG)' rewrite='logger.info($MSG)'", + args: { + pattern: tool.schema.string().describe("AST pattern to match"), + rewrite: tool.schema.string().describe("Replacement pattern (can use $VAR from pattern)"), + lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"), + paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search"), + globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs"), + dryRun: tool.schema.boolean().optional().describe("Preview changes without applying (default: true)"), + }, + execute: async (args, context) => { + try { + const matches = await runSg({ + pattern: args.pattern, + rewrite: args.rewrite, + lang: args.lang as CliLanguage, + paths: args.paths, + globs: args.globs, + updateAll: args.dryRun === false, + }) + const output = formatReplaceResult(matches, args.dryRun !== false) + showOutputToUser(context, output) + return output + } catch (e) { + const output = `Error: ${e instanceof Error ? e.message : String(e)}` + showOutputToUser(context, output) + return output + } + }, +}) + +export const ast_grep_languages = tool({ + description: + "List all supported languages for ast-grep tools with their file extensions. " + + "Use this to determine valid language options.", + args: {}, + execute: async (_args, context) => { + const lines: string[] = [`Supported Languages (${CLI_LANGUAGES.length}):`] + for (const lang of CLI_LANGUAGES) { + const exts = LANG_EXTENSIONS[lang]?.join(", ") || "" + lines.push(` ${lang}: ${exts}`) + } + lines.push("") + lines.push(`NAPI (in-memory) languages: ${NAPI_LANGUAGES.join(", ")}`) + const output = lines.join("\n") + showOutputToUser(context, output) + return output + }, +}) + +export const ast_grep_analyze = tool({ + description: + "Parse code and extract AST structure with pattern matching (in-memory). " + + "Extracts meta-variable bindings. Only for: html, javascript, tsx, css, typescript. " + + "Use for detailed code analysis without file I/O.", + args: { + code: tool.schema.string().describe("Source code to analyze"), + lang: tool.schema.enum(NAPI_LANGUAGES).describe("Language (html, javascript, tsx, css, typescript)"), + pattern: tool.schema.string().optional().describe("Pattern to find (omit for root structure)"), + extractMetaVars: tool.schema.boolean().optional().describe("Extract meta-variable bindings (default: true)"), + }, + execute: async (args, context) => { + try { + if (!args.pattern) { + const info = getRootInfo(args.code, args.lang as NapiLanguage) + const output = `Root kind: ${info.kind}\nChildren: ${info.childCount}` + showOutputToUser(context, output) + return output + } + + const results = analyzeCode(args.code, args.lang as NapiLanguage, args.pattern, args.extractMetaVars !== false) + const output = formatAnalyzeResult(results, args.extractMetaVars !== false) + showOutputToUser(context, output) + return output + } catch (e) { + const output = `Error: ${e instanceof Error ? e.message : String(e)}` + showOutputToUser(context, output) + return output + } + }, +}) + +export const ast_grep_transform = tool({ + description: + "Transform code in-memory using AST-aware rewriting. " + + "Only for: html, javascript, tsx, css, typescript. " + + "Returns transformed code without writing to filesystem.", + args: { + code: tool.schema.string().describe("Source code to transform"), + lang: tool.schema.enum(NAPI_LANGUAGES).describe("Language"), + pattern: tool.schema.string().describe("Pattern to match"), + rewrite: tool.schema.string().describe("Replacement (can use $VAR from pattern)"), + }, + execute: async (args, context) => { + try { + const { transformed, editCount } = transformCode( + args.code, + args.lang as NapiLanguage, + args.pattern, + args.rewrite + ) + const output = formatTransformResult(args.code, transformed, editCount) + showOutputToUser(context, output) + return output + } catch (e) { + const output = `Error: ${e instanceof Error ? e.message : String(e)}` + showOutputToUser(context, output) + return output + } + }, +}) diff --git a/src/tools/ast-grep/types.ts b/src/tools/ast-grep/types.ts new file mode 100644 index 0000000..6e10d5f --- /dev/null +++ b/src/tools/ast-grep/types.ts @@ -0,0 +1,53 @@ +import type { CLI_LANGUAGES, NAPI_LANGUAGES } from "./constants" + +export type CliLanguage = (typeof CLI_LANGUAGES)[number] +export type NapiLanguage = (typeof NAPI_LANGUAGES)[number] + +export interface Position { + line: number + column: number +} + +export interface Range { + start: Position + end: Position +} + +export interface CliMatch { + text: string + range: { + byteOffset: { start: number; end: number } + start: Position + end: Position + } + file: string + lines: string + charCount: { leading: number; trailing: number } + language: string +} + +export interface SearchMatch { + file: string + text: string + range: Range + lines: string +} + +export interface MetaVariable { + name: string + text: string + kind: string +} + +export interface AnalyzeResult { + text: string + range: Range + kind: string + metaVariables: MetaVariable[] +} + +export interface TransformResult { + original: string + transformed: string + editCount: number +} diff --git a/src/tools/ast-grep/utils.ts b/src/tools/ast-grep/utils.ts new file mode 100644 index 0000000..d5a966a --- /dev/null +++ b/src/tools/ast-grep/utils.ts @@ -0,0 +1,72 @@ +import type { CliMatch, AnalyzeResult } from "./types" + +export function formatSearchResult(matches: CliMatch[]): string { + if (matches.length === 0) { + return "No matches found" + } + + const lines: string[] = [`Found ${matches.length} match(es):\n`] + + for (const match of matches) { + const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}` + lines.push(`${loc}`) + lines.push(` ${match.lines.trim()}`) + lines.push("") + } + + return lines.join("\n") +} + +export function formatReplaceResult(matches: CliMatch[], isDryRun: boolean): string { + if (matches.length === 0) { + return "No matches found to replace" + } + + const prefix = isDryRun ? "[DRY RUN] " : "" + const lines: string[] = [`${prefix}${matches.length} replacement(s):\n`] + + for (const match of matches) { + const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}` + lines.push(`${loc}`) + lines.push(` ${match.text}`) + lines.push("") + } + + if (isDryRun) { + lines.push("Use dryRun=false to apply changes") + } + + return lines.join("\n") +} + +export function formatAnalyzeResult(results: AnalyzeResult[], extractedMetaVars: boolean): string { + if (results.length === 0) { + return "No matches found" + } + + const lines: string[] = [`Found ${results.length} match(es):\n`] + + for (const result of results) { + const loc = `L${result.range.start.line + 1}:${result.range.start.column + 1}` + lines.push(`[${loc}] (${result.kind})`) + lines.push(` ${result.text}`) + + if (extractedMetaVars && result.metaVariables.length > 0) { + lines.push(" Meta-variables:") + for (const mv of result.metaVariables) { + lines.push(` $${mv.name} = "${mv.text}" (${mv.kind})`) + } + } + lines.push("") + } + + return lines.join("\n") +} + +export function formatTransformResult(original: string, transformed: string, editCount: number): string { + if (editCount === 0) { + return "No matches found to transform" + } + + return `Transformed (${editCount} edit(s)):\n\`\`\`\n${transformed}\n\`\`\`` +}