diff --git a/src/tools/ast-grep/tools.test.ts b/src/tools/ast-grep/tools.test.ts
new file mode 100644
index 000000000..b02fa8ea4
--- /dev/null
+++ b/src/tools/ast-grep/tools.test.ts
@@ -0,0 +1,55 @@
+///
+
+import { beforeEach, describe, expect, it, mock } from "bun:test"
+import { AST_GREP_REPLACE_DESCRIPTION, AST_GREP_SEARCH_DESCRIPTION } from "./tool-descriptions"
+
+const runSgMock = mock(async () => ({
+ matches: [],
+ totalMatches: 0,
+ truncated: false,
+}))
+
+mock.module("./cli", () => ({
+ runSg: runSgMock,
+}))
+
+import { createAstGrepTools } from "./tools"
+
+describe("createAstGrepTools", () => {
+ beforeEach(() => {
+ runSgMock.mockClear()
+ })
+
+ it("#given the production tool factory #when creating tools #then exposes shared ast-grep descriptions", () => {
+ // given / when
+ const tools = createAstGrepTools({ directory: "/repo" } as never)
+
+ // then
+ expect(tools.ast_grep_search.description).toBe(AST_GREP_SEARCH_DESCRIPTION)
+ expect(tools.ast_grep_replace.description).toBe(AST_GREP_REPLACE_DESCRIPTION)
+ expect(tools.ast_grep_search.description).toContain("NOT regex")
+ })
+
+ it("#given empty search results from a regex-shaped pattern #when executing #then appends the pattern hint", async () => {
+ // given
+ const tools = createAstGrepTools({ directory: "/repo" } as never)
+
+ // when
+ const output = await tools.ast_grep_search.execute(
+ { pattern: "foo|bar", lang: "typescript" },
+ {},
+ )
+
+ // then
+ expect(output).toContain("No matches found")
+ expect(output).toContain("alternation")
+ expect(output).toContain("grep")
+ expect(runSgMock).toHaveBeenCalledWith({
+ pattern: "foo|bar",
+ lang: "typescript",
+ paths: ["/repo"],
+ globs: undefined,
+ context: undefined,
+ })
+ })
+})
diff --git a/src/tools/ast-grep/tools.ts b/src/tools/ast-grep/tools.ts
index 98b2d0c7e..2a2454fd2 100644
--- a/src/tools/ast-grep/tools.ts
+++ b/src/tools/ast-grep/tools.ts
@@ -3,6 +3,12 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { CLI_LANGUAGES } from "./constants"
import { runSg } from "./cli"
import { formatSearchResult, formatReplaceResult } from "./result-formatter"
+import { getPatternHint } from "./pattern-hints"
+import {
+ AST_GREP_REPLACE_DESCRIPTION,
+ AST_GREP_SEARCH_DESCRIPTION,
+ AST_GREP_SEARCH_PATTERN_PARAM,
+} from "./tool-descriptions"
import type { CliLanguage } from "./types"
async function showOutputToUser(context: unknown, output: string): Promise {
@@ -12,39 +18,11 @@ async function showOutputToUser(context: unknown, output: string): Promise
await ctx.metadata?.({ metadata: { output } })
}
-function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
- const src = pattern.trim()
-
- if (lang === "python") {
- if (src.startsWith("class ") && src.endsWith(":")) {
- const withoutColon = src.slice(0, -1)
- return `Hint: Remove trailing colon. Try: "${withoutColon}"`
- }
- if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
- const withoutColon = src.slice(0, -1)
- return `Hint: Remove trailing colon. Try: "${withoutColon}"`
- }
- }
-
- if (["javascript", "typescript", "tsx"].includes(lang)) {
- if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) {
- return `Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"`
- }
- }
-
- return null
-}
-
export function createAstGrepTools(ctx: PluginInput): Record {
const ast_grep_search: ToolDefinition = tool({
- description:
- "Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " +
- "Use meta-variables: $VAR (single node), $$$ (multiple nodes). " +
- "IMPORTANT: Patterns must be complete AST nodes (valid code). " +
- "For functions, include params and body: 'export async function $NAME($$$) { $$$ }' not 'export async function $NAME'. " +
- "Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'",
+ description: AST_GREP_SEARCH_DESCRIPTION,
args: {
- pattern: tool.schema.string().describe("AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."),
+ pattern: tool.schema.string().describe(AST_GREP_SEARCH_PATTERN_PARAM),
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)"),
@@ -63,7 +41,7 @@ export function createAstGrepTools(ctx: PluginInput): Record