Просмотр исходного кода

refactor(tool): convert grep tool to Tool.defineEffect (#21937)

Kit Langton 1 неделя назад
Родитель
Сommit
d72ddd71fa

+ 163 - 142
packages/opencode/src/tool/grep.ts

@@ -1,156 +1,177 @@
 import z from "zod"
-import { text } from "node:stream/consumers"
+import { Effect } from "effect"
+import * as Stream from "effect/Stream"
 import { Tool } from "./tool"
 import { Filesystem } from "../util/filesystem"
 import { Ripgrep } from "../file/ripgrep"
-import { Process } from "../util/process"
+import { ChildProcess } from "effect/unstable/process"
+import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
 
 import DESCRIPTION from "./grep.txt"
 import { Instance } from "../project/instance"
 import path from "path"
-import { assertExternalDirectory } from "./external-directory"
+import { assertExternalDirectoryEffect } from "./external-directory"
 
 const MAX_LINE_LENGTH = 2000
 
-export const GrepTool = Tool.define("grep", {
-  description: DESCRIPTION,
-  parameters: z.object({
-    pattern: z.string().describe("The regex pattern to search for in file contents"),
-    path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
-    include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
-  }),
-  async execute(params, ctx) {
-    if (!params.pattern) {
-      throw new Error("pattern is required")
-    }
-
-    await ctx.ask({
-      permission: "grep",
-      patterns: [params.pattern],
-      always: ["*"],
-      metadata: {
-        pattern: params.pattern,
-        path: params.path,
-        include: params.include,
-      },
-    })
-
-    let searchPath = params.path ?? Instance.directory
-    searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
-    await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
-
-    const rgPath = await Ripgrep.filepath()
-    const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
-    if (params.include) {
-      args.push("--glob", params.include)
-    }
-    args.push(searchPath)
-
-    const proc = Process.spawn([rgPath, ...args], {
-      stdout: "pipe",
-      stderr: "pipe",
-      abort: ctx.abort,
-    })
-
-    if (!proc.stdout || !proc.stderr) {
-      throw new Error("Process output not available")
-    }
-
-    const output = await text(proc.stdout)
-    const errorOutput = await text(proc.stderr)
-    const exitCode = await proc.exited
-
-    // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
-    // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
-    // Only fail if exit code is 2 AND no output was produced
-    if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
-      return {
-        title: params.pattern,
-        metadata: { matches: 0, truncated: false },
-        output: "No files found",
-      }
-    }
-
-    if (exitCode !== 0 && exitCode !== 2) {
-      throw new Error(`ripgrep failed: ${errorOutput}`)
-    }
-
-    const hasErrors = exitCode === 2
-
-    // Handle both Unix (\n) and Windows (\r\n) line endings
-    const lines = output.trim().split(/\r?\n/)
-    const matches = []
-
-    for (const line of lines) {
-      if (!line) continue
-
-      const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
-      if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
-
-      const lineNum = parseInt(lineNumStr, 10)
-      const lineText = lineTextParts.join("|")
-
-      const stats = Filesystem.stat(filePath)
-      if (!stats) continue
-
-      matches.push({
-        path: filePath,
-        modTime: stats.mtime.getTime(),
-        lineNum,
-        lineText,
-      })
-    }
-
-    matches.sort((a, b) => b.modTime - a.modTime)
-
-    const limit = 100
-    const truncated = matches.length > limit
-    const finalMatches = truncated ? matches.slice(0, limit) : matches
-
-    if (finalMatches.length === 0) {
-      return {
-        title: params.pattern,
-        metadata: { matches: 0, truncated: false },
-        output: "No files found",
-      }
-    }
-
-    const totalMatches = matches.length
-    const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
-
-    let currentFile = ""
-    for (const match of finalMatches) {
-      if (currentFile !== match.path) {
-        if (currentFile !== "") {
-          outputLines.push("")
-        }
-        currentFile = match.path
-        outputLines.push(`${match.path}:`)
-      }
-      const truncatedLineText =
-        match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
-      outputLines.push(`  Line ${match.lineNum}: ${truncatedLineText}`)
-    }
-
-    if (truncated) {
-      outputLines.push("")
-      outputLines.push(
-        `(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
-      )
-    }
-
-    if (hasErrors) {
-      outputLines.push("")
-      outputLines.push("(Some paths were inaccessible and skipped)")
-    }
+export const GrepTool = Tool.defineEffect(
+  "grep",
+  Effect.gen(function* () {
+    const spawner = yield* ChildProcessSpawner
 
     return {
-      title: params.pattern,
-      metadata: {
-        matches: totalMatches,
-        truncated,
-      },
-      output: outputLines.join("\n"),
+      description: DESCRIPTION,
+      parameters: z.object({
+        pattern: z.string().describe("The regex pattern to search for in file contents"),
+        path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
+        include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
+      }),
+      execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
+        Effect.gen(function* () {
+          if (!params.pattern) {
+            throw new Error("pattern is required")
+          }
+
+          yield* Effect.promise(() =>
+            ctx.ask({
+              permission: "grep",
+              patterns: [params.pattern],
+              always: ["*"],
+              metadata: {
+                pattern: params.pattern,
+                path: params.path,
+                include: params.include,
+              },
+            }),
+          )
+
+          let searchPath = params.path ?? Instance.directory
+          searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
+          yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
+
+          const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
+          const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
+          if (params.include) {
+            args.push("--glob", params.include)
+          }
+          args.push(searchPath)
+
+          const result = yield* Effect.scoped(
+            Effect.gen(function* () {
+              const handle = yield* spawner.spawn(
+                ChildProcess.make(rgPath, args, {
+                  stdin: "ignore",
+                }),
+              )
+
+              const [output, errorOutput] = yield* Effect.all(
+                [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
+                { concurrency: 2 },
+              )
+
+              const exitCode = yield* handle.exitCode
+
+              return { output, errorOutput, exitCode }
+            }),
+          )
+
+          const { output, errorOutput, exitCode } = result
+
+          // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
+          // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
+          // Only fail if exit code is 2 AND no output was produced
+          if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
+            return {
+              title: params.pattern,
+              metadata: { matches: 0, truncated: false },
+              output: "No files found",
+            }
+          }
+
+          if (exitCode !== 0 && exitCode !== 2) {
+            throw new Error(`ripgrep failed: ${errorOutput}`)
+          }
+
+          const hasErrors = exitCode === 2
+
+          // Handle both Unix (\n) and Windows (\r\n) line endings
+          const lines = output.trim().split(/\r?\n/)
+          const matches = []
+
+          for (const line of lines) {
+            if (!line) continue
+
+            const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
+            if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
+
+            const lineNum = parseInt(lineNumStr, 10)
+            const lineText = lineTextParts.join("|")
+
+            const stats = Filesystem.stat(filePath)
+            if (!stats) continue
+
+            matches.push({
+              path: filePath,
+              modTime: stats.mtime.getTime(),
+              lineNum,
+              lineText,
+            })
+          }
+
+          matches.sort((a, b) => b.modTime - a.modTime)
+
+          const limit = 100
+          const truncated = matches.length > limit
+          const finalMatches = truncated ? matches.slice(0, limit) : matches
+
+          if (finalMatches.length === 0) {
+            return {
+              title: params.pattern,
+              metadata: { matches: 0, truncated: false },
+              output: "No files found",
+            }
+          }
+
+          const totalMatches = matches.length
+          const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
+
+          let currentFile = ""
+          for (const match of finalMatches) {
+            if (currentFile !== match.path) {
+              if (currentFile !== "") {
+                outputLines.push("")
+              }
+              currentFile = match.path
+              outputLines.push(`${match.path}:`)
+            }
+            const truncatedLineText =
+              match.lineText.length > MAX_LINE_LENGTH
+                ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..."
+                : match.lineText
+            outputLines.push(`  Line ${match.lineNum}: ${truncatedLineText}`)
+          }
+
+          if (truncated) {
+            outputLines.push("")
+            outputLines.push(
+              `(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
+            )
+          }
+
+          if (hasErrors) {
+            outputLines.push("")
+            outputLines.push("(Some paths were inaccessible and skipped)")
+          }
+
+          return {
+            title: params.pattern,
+            metadata: {
+              matches: totalMatches,
+              truncated,
+            },
+            output: outputLines.join("\n"),
+          }
+        }).pipe(Effect.orDie, Effect.runPromise),
     }
-  },
-})
+  }),
+)

+ 2 - 1
packages/opencode/src/tool/registry.ts

@@ -112,6 +112,7 @@ export namespace ToolRegistry {
       const globtool = yield* GlobTool
       const writetool = yield* WriteTool
       const edit = yield* EditTool
+      const greptool = yield* GrepTool
 
       const state = yield* InstanceState.make<State>(
         Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -173,7 +174,7 @@ export namespace ToolRegistry {
             bash: Tool.init(bash),
             read: Tool.init(read),
             glob: Tool.init(globtool),
-            grep: Tool.init(GrepTool),
+            grep: Tool.init(greptool),
             edit: Tool.init(edit),
             write: Tool.init(writetool),
             task: Tool.init(task),

+ 11 - 3
packages/opencode/test/tool/grep.test.ts

@@ -1,9 +1,17 @@
 import { describe, expect, test } from "bun:test"
 import path from "path"
+import { Effect, Layer, ManagedRuntime } from "effect"
 import { GrepTool } from "../../src/tool/grep"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
 import { SessionID, MessageID } from "../../src/session/schema"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+
+const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
+
+function initGrep() {
+  return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
+}
 
 const ctx = {
   sessionID: SessionID.make("ses_test"),
@@ -23,7 +31,7 @@ describe("tool.grep", () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
-        const grep = await GrepTool.init()
+        const grep = await initGrep()
         const result = await grep.execute(
           {
             pattern: "export",
@@ -47,7 +55,7 @@ describe("tool.grep", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const grep = await GrepTool.init()
+        const grep = await initGrep()
         const result = await grep.execute(
           {
             pattern: "xyznonexistentpatternxyz123",
@@ -72,7 +80,7 @@ describe("tool.grep", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const grep = await GrepTool.init()
+        const grep = await initGrep()
         const result = await grep.execute(
           {
             pattern: "line",