Browse Source

refactor(search): route callers through search service

Shoubhit Dash 4 days ago
parent
commit
2fef5e6ce6

+ 2 - 2
packages/opencode/src/cli/cmd/debug/file.ts

@@ -1,10 +1,10 @@
 import { EOL } from "os"
 import { Effect } from "effect"
 import { AppRuntime } from "@/effect/app-runtime"
+import { Search } from "@/file/search"
 import { File } from "../../../file"
 import { bootstrap } from "../../bootstrap"
 import { cmd } from "../cmd"
-import { Ripgrep } from "@/file/ripgrep"
 
 const FileSearchCommand = cmd({
   command: "search <query>",
@@ -95,7 +95,7 @@ const FileTreeCommand = cmd({
       default: process.cwd(),
     }),
   async handler(args) {
-    const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 })
+    const files = await Search.tree({ cwd: args.dir, limit: 200 })
     console.log(JSON.stringify(files, null, 2))
   },
 })

+ 4 - 4
packages/opencode/src/cli/cmd/debug/ripgrep.ts

@@ -1,6 +1,6 @@
 import { EOL } from "os"
 import { AppRuntime } from "../../../effect/app-runtime"
-import { Ripgrep } from "../../../file/ripgrep"
+import { Search } from "../../../file/search"
 import { Instance } from "../../../project/instance"
 import { bootstrap } from "../../bootstrap"
 import { cmd } from "../cmd"
@@ -21,7 +21,7 @@ const TreeCommand = cmd({
     }),
   async handler(args) {
     await bootstrap(process.cwd(), async () => {
-      process.stdout.write((await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit })) + EOL)
+      process.stdout.write((await Search.tree({ cwd: Instance.directory, limit: args.limit })) + EOL)
     })
   },
 })
@@ -46,7 +46,7 @@ const FilesCommand = cmd({
   async handler(args) {
     await bootstrap(process.cwd(), async () => {
       const files: string[] = []
-      for await (const file of await Ripgrep.files({
+      for await (const file of await Search.files({
         cwd: Instance.directory,
         glob: args.glob ? [args.glob] : undefined,
       })) {
@@ -79,7 +79,7 @@ const SearchCommand = cmd({
   async handler(args) {
     await bootstrap(process.cwd(), async () => {
       const results = await AppRuntime.runPromise(
-        Ripgrep.Service.use((svc) =>
+        Search.Service.use((svc) =>
           svc.search({
             cwd: Instance.directory,
             pattern: args.pattern,

+ 21 - 7
packages/opencode/src/file/index.ts

@@ -14,7 +14,7 @@ import { Global } from "../global"
 import { Instance } from "../project/instance"
 import { Log } from "../util/log"
 import { Protected } from "./protected"
-import { Ripgrep } from "./ripgrep"
+import { Search } from "./search"
 
 export namespace File {
   export const Info = z
@@ -344,7 +344,7 @@ export namespace File {
     Service,
     Effect.gen(function* () {
       const appFs = yield* AppFileSystem.Service
-      const rg = yield* Ripgrep.Service
+      const searchSvc = yield* Search.Service
       const git = yield* Git.Service
 
       const state = yield* InstanceState.make<State>(
@@ -384,7 +384,7 @@ export namespace File {
 
           next.dirs = Array.from(dirs).toSorted()
         } else {
-          const files = yield* rg.files({ cwd: Instance.directory }).pipe(
+          const files = yield* searchSvc.files({ cwd: Instance.directory }).pipe(
             Stream.runCollect,
             Effect.map((chunk) => [...chunk]),
           )
@@ -512,6 +512,8 @@ export namespace File {
 
         if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
 
+        yield* searchSvc.open({ cwd: Instance.directory, file }).pipe(Effect.ignore)
+
         if (isImageByExtension(file)) {
           const exists = yield* appFs.existsSafe(full)
           if (exists) {
@@ -617,14 +619,26 @@ export namespace File {
         dirs?: boolean
         type?: "file" | "directory"
       }) {
-        yield* ensure()
-        const { cache } = yield* InstanceState.get(state)
-
         const query = input.query.trim()
         const limit = input.limit ?? 100
         const kind = input.type ?? (input.dirs === false ? "file" : "all")
         log.info("search", { query, kind })
 
+        if (query && kind === "file") {
+          const files = yield* searchSvc.file({
+            cwd: Instance.directory,
+            query,
+            limit,
+          })
+          if (files.length) {
+            log.info("search", { query, kind, results: files.length, mode: "fff" })
+            return files
+          }
+        }
+
+        yield* ensure()
+        const { cache } = yield* InstanceState.get(state)
+
         const preferHidden = query.startsWith(".") || query.includes("/.")
 
         if (!query) {
@@ -649,7 +663,7 @@ export namespace File {
   )
 
   export const defaultLayer = layer.pipe(
-    Layer.provide(Ripgrep.defaultLayer),
+    Layer.provide(Search.defaultLayer),
     Layer.provide(AppFileSystem.defaultLayer),
     Layer.provide(Git.defaultLayer),
   )

+ 4 - 4
packages/opencode/src/server/instance/file.ts

@@ -4,7 +4,7 @@ import { Effect } from "effect"
 import z from "zod"
 import { AppRuntime } from "../../effect/app-runtime"
 import { File } from "../../file"
-import { Ripgrep } from "../../file/ripgrep"
+import { Search } from "../../file/search"
 import { LSP } from "../../lsp"
 import { Instance } from "../../project/instance"
 import { lazy } from "../../util/lazy"
@@ -15,14 +15,14 @@ export const FileRoutes = lazy(() =>
       "/find",
       describeRoute({
         summary: "Find text",
-        description: "Search for text patterns across files in the project using ripgrep.",
+        description: "Search for text patterns across files in the project.",
         operationId: "find.text",
         responses: {
           200: {
             description: "Matches",
             content: {
               "application/json": {
-                schema: resolver(Ripgrep.Match.shape.data.array()),
+                schema: resolver(Search.Match.array()),
               },
             },
           },
@@ -37,7 +37,7 @@ export const FileRoutes = lazy(() =>
       async (c) => {
         const pattern = c.req.valid("query").pattern
         const result = await AppRuntime.runPromise(
-          Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
+          Search.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
         )
         return c.json(result.items)
       },

+ 15 - 34
packages/opencode/src/tool/glob.ts

@@ -1,10 +1,9 @@
 import path from "path"
 import z from "zod"
-import { Effect, Option } from "effect"
-import * as Stream from "effect/Stream"
+import { Effect } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { AppFileSystem } from "../filesystem"
-import { Ripgrep } from "../file/ripgrep"
+import { Search } from "../file/search"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import DESCRIPTION from "./glob.txt"
 import { Tool } from "./tool"
@@ -12,8 +11,8 @@ import { Tool } from "./tool"
 export const GlobTool = Tool.define(
   "glob",
   Effect.gen(function* () {
-    const rg = yield* Ripgrep.Service
     const fs = yield* AppFileSystem.Service
+    const searchSvc = yield* Search.Service
 
     return {
       description: DESCRIPTION,
@@ -48,36 +47,18 @@ export const GlobTool = Tool.define(
           yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
 
           const limit = 100
-          let truncated = false
-          const files = yield* rg.files({ cwd: search, glob: [params.pattern], signal: ctx.abort }).pipe(
-            Stream.mapEffect((file) =>
-              Effect.gen(function* () {
-                const full = path.resolve(search, file)
-                const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
-                const mtime =
-                  info?.mtime.pipe(
-                    Option.map((date) => date.getTime()),
-                    Option.getOrElse(() => 0),
-                  ) ?? 0
-                return { path: full, mtime }
-              }),
-            ),
-            Stream.take(limit + 1),
-            Stream.runCollect,
-            Effect.map((chunk) => [...chunk]),
-          )
-
-          if (files.length > limit) {
-            truncated = true
-            files.length = limit
-          }
-          files.sort((a, b) => b.mtime - a.mtime)
+          const files = yield* searchSvc.glob({
+            cwd: search,
+            pattern: params.pattern,
+            limit,
+            signal: ctx.abort,
+          })
 
           const output = []
-          if (files.length === 0) output.push("No files found")
-          if (files.length > 0) {
-            output.push(...files.map((file) => file.path))
-            if (truncated) {
+          if (files.files.length === 0) output.push("No files found")
+          if (files.files.length > 0) {
+            output.push(...files.files)
+            if (files.truncated) {
               output.push("")
               output.push(
                 `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
@@ -88,8 +69,8 @@ export const GlobTool = Tool.define(
           return {
             title: path.relative(ins.worktree, search),
             metadata: {
-              count: files.length,
-              truncated,
+              count: files.files.length,
+              truncated: files.truncated,
             },
             output: output.join("\n"),
           }

+ 11 - 31
packages/opencode/src/tool/grep.ts

@@ -1,9 +1,9 @@
 import path from "path"
 import z from "zod"
-import { Effect, Option } from "effect"
+import { Effect } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { AppFileSystem } from "../filesystem"
-import { Ripgrep } from "../file/ripgrep"
+import { Search } from "../file/search"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import DESCRIPTION from "./grep.txt"
 import { Tool } from "./tool"
@@ -14,7 +14,7 @@ export const GrepTool = Tool.define(
   "grep",
   Effect.gen(function* () {
     const fs = yield* AppFileSystem.Service
-    const rg = yield* Ripgrep.Service
+    const searchSvc = yield* Search.Service
 
     return {
       description: DESCRIPTION,
@@ -58,7 +58,7 @@ export const GrepTool = Tool.define(
             kind: info?.type === "Directory" ? "directory" : "file",
           })
 
-          const result = yield* rg.search({
+          const result = yield* searchSvc.search({
             cwd,
             pattern: params.pattern,
             glob: params.include ? [params.include] : undefined,
@@ -74,37 +74,13 @@ export const GrepTool = Tool.define(
             line: item.line_number,
             text: item.lines.text,
           }))
-          const times = new Map(
-            (yield* Effect.forEach(
-              [...new Set(rows.map((row) => row.path))],
-              Effect.fnUntraced(function* (file) {
-                const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
-                if (!info || info.type === "Directory") return undefined
-                return [
-                  file,
-                  info.mtime.pipe(
-                    Option.map((time) => time.getTime()),
-                    Option.getOrElse(() => 0),
-                  ) ?? 0,
-                ] as const
-              }),
-              { concurrency: 16 },
-            )).filter((entry): entry is readonly [string, number] => Boolean(entry)),
-          )
-          const matches = rows.flatMap((row) => {
-            const mtime = times.get(row.path)
-            if (mtime === undefined) return []
-            return [{ ...row, mtime }]
-          })
-
-          matches.sort((a, b) => b.mtime - a.mtime)
 
           const limit = 100
-          const truncated = matches.length > limit
-          const final = truncated ? matches.slice(0, limit) : matches
+          const truncated = rows.length > limit
+          const final = truncated ? rows.slice(0, limit) : rows
           if (final.length === 0) return empty
 
-          const total = matches.length
+          const total = rows.length
           const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`]
 
           let current = ""
@@ -130,6 +106,10 @@ export const GrepTool = Tool.define(
             output.push("")
             output.push("(Some paths were inaccessible and skipped)")
           }
+          if (result.regexFallbackError) {
+            output.push("")
+            output.push(`(Regex fallback: ${result.regexFallbackError})`)
+          }
 
           return {
             title: params.pattern,

+ 3 - 3
packages/opencode/src/tool/ls.ts

@@ -3,7 +3,7 @@ import z from "zod"
 import { Effect } from "effect"
 import * as Stream from "effect/Stream"
 import { InstanceState } from "@/effect/instance-state"
-import { Ripgrep } from "../file/ripgrep"
+import { Search } from "../file/search"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import DESCRIPTION from "./ls.txt"
 import { Tool } from "./tool"
@@ -40,7 +40,7 @@ const LIMIT = 100
 export const ListTool = Tool.define(
   "list",
   Effect.gen(function* () {
-    const rg = yield* Ripgrep.Service
+    const searchSvc = yield* Search.Service
 
     return {
       description: DESCRIPTION,
@@ -67,7 +67,7 @@ export const ListTool = Tool.define(
           })
 
           const glob = IGNORE_PATTERNS.map((item) => `!${item}*`).concat(params.ignore?.map((item) => `!${item}`) || [])
-          const files = yield* rg.files({ cwd: search, glob, signal: ctx.abort }).pipe(
+          const files = yield* searchSvc.files({ cwd: search, glob, signal: ctx.abort }).pipe(
             Stream.take(LIMIT + 1),
             Stream.runCollect,
             Effect.map((chunk) => [...chunk]),

+ 3 - 0
packages/opencode/src/tool/read.ts

@@ -8,6 +8,7 @@ import { Tool } from "./tool"
 import { AppFileSystem } from "../filesystem"
 import { LSP } from "../lsp"
 import { FileTime } from "../file/time"
+import { Search } from "../file/search"
 import DESCRIPTION from "./read.txt"
 import { Instance } from "../project/instance"
 import { assertExternalDirectoryEffect } from "./external-directory"
@@ -31,6 +32,7 @@ export const ReadTool = Tool.define(
     const fs = yield* AppFileSystem.Service
     const instruction = yield* Instruction.Service
     const lsp = yield* LSP.Service
+    const search = yield* Search.Service
     const time = yield* FileTime.Service
     const scope = yield* Scope.Scope
 
@@ -76,6 +78,7 @@ export const ReadTool = Tool.define(
     })
 
     const warm = Effect.fn("ReadTool.warm")(function* (filepath: string, sessionID: Tool.Context["sessionID"]) {
+      yield* search.open({ file: filepath }).pipe(Effect.ignore)
       yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
       yield* time.read(sessionID, filepath)
     })

+ 3 - 3
packages/opencode/src/tool/skill.ts

@@ -4,7 +4,7 @@ import z from "zod"
 import { Effect } from "effect"
 import * as Stream from "effect/Stream"
 import { EffectLogger } from "@/effect/logger"
-import { Ripgrep } from "../file/ripgrep"
+import { Search } from "../file/search"
 import { Skill } from "../skill"
 import { Tool } from "./tool"
 
@@ -16,7 +16,7 @@ export const SkillTool = Tool.define(
   "skill",
   Effect.gen(function* () {
     const skill = yield* Skill.Service
-    const rg = yield* Ripgrep.Service
+    const searchSvc = yield* Search.Service
 
     return () =>
       Effect.gen(function* () {
@@ -62,7 +62,7 @@ export const SkillTool = Tool.define(
               const dir = path.dirname(info.location)
               const base = pathToFileURL(dir).href
               const limit = 10
-              const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
+              const files = yield* searchSvc.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
                 Stream.filter((file) => !file.includes("SKILL.md")),
                 Stream.map((file) => path.resolve(dir, file)),
                 Stream.take(limit),

+ 2 - 2
packages/opencode/test/session/prompt-effect.test.ts

@@ -40,7 +40,7 @@ import { ToolRegistry } from "../../src/tool/registry"
 import { Truncate } from "../../src/tool/truncate"
 import { Log } from "../../src/util/log"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Search } from "../../src/file/search"
 import { Format } from "../../src/format"
 import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
@@ -187,7 +187,7 @@ function makeHttp() {
     Layer.provide(Skill.defaultLayer),
     Layer.provide(FetchHttpClient.layer),
     Layer.provide(CrossSpawnSpawner.defaultLayer),
-    Layer.provide(Ripgrep.defaultLayer),
+    Layer.provide(Search.defaultLayer),
     Layer.provide(Format.defaultLayer),
     Layer.provideMerge(todo),
     Layer.provideMerge(question),

+ 2 - 2
packages/opencode/test/session/snapshot-tool-race.test.ts

@@ -55,7 +55,7 @@ import { ToolRegistry } from "../../src/tool/registry"
 import { Truncate } from "../../src/tool/truncate"
 import { AppFileSystem } from "../../src/filesystem"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Search } from "../../src/file/search"
 import { Format } from "../../src/format"
 
 Log.init({ print: false })
@@ -141,7 +141,7 @@ function makeHttp() {
     Layer.provide(Skill.defaultLayer),
     Layer.provide(FetchHttpClient.layer),
     Layer.provide(CrossSpawnSpawner.defaultLayer),
-    Layer.provide(Ripgrep.defaultLayer),
+    Layer.provide(Search.defaultLayer),
     Layer.provide(Format.defaultLayer),
     Layer.provideMerge(todo),
     Layer.provideMerge(question),

+ 2 - 2
packages/opencode/test/tool/glob.test.ts

@@ -4,7 +4,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
 import { GlobTool } from "../../src/tool/glob"
 import { SessionID, MessageID } from "../../src/session/schema"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Search } from "../../src/file/search"
 import { AppFileSystem } from "../../src/filesystem"
 import { Truncate } from "../../src/tool/truncate"
 import { Agent } from "../../src/agent/agent"
@@ -15,7 +15,7 @@ const it = testEffect(
   Layer.mergeAll(
     CrossSpawnSpawner.defaultLayer,
     AppFileSystem.defaultLayer,
-    Ripgrep.defaultLayer,
+    Search.defaultLayer,
     Truncate.defaultLayer,
     Agent.defaultLayer,
   ),

+ 2 - 2
packages/opencode/test/tool/grep.test.ts

@@ -7,7 +7,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 import { Truncate } from "../../src/tool/truncate"
 import { Agent } from "../../src/agent/agent"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Search } from "../../src/file/search"
 import { AppFileSystem } from "../../src/filesystem"
 import { testEffect } from "../lib/effect"
 
@@ -15,7 +15,7 @@ const it = testEffect(
   Layer.mergeAll(
     CrossSpawnSpawner.defaultLayer,
     AppFileSystem.defaultLayer,
-    Ripgrep.defaultLayer,
+    Search.defaultLayer,
     Truncate.defaultLayer,
     Agent.defaultLayer,
   ),

+ 2 - 0
packages/opencode/test/tool/read.test.ts

@@ -4,6 +4,7 @@ import path from "path"
 import { Agent } from "../../src/agent/agent"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 import { AppFileSystem } from "../../src/filesystem"
+import { Search } from "../../src/file/search"
 import { FileTime } from "../../src/file/time"
 import { LSP } from "../../src/lsp"
 import { Permission } from "../../src/permission"
@@ -42,6 +43,7 @@ const it = testEffect(
     FileTime.defaultLayer,
     Instruction.defaultLayer,
     LSP.defaultLayer,
+    Search.defaultLayer,
     Truncate.defaultLayer,
   ),
 )