fix grep exact file path searches (#22356)
This commit is contained in:
@@ -330,6 +330,7 @@ export namespace Ripgrep {
|
|||||||
glob?: string[]
|
glob?: string[]
|
||||||
limit?: number
|
limit?: number
|
||||||
follow?: boolean
|
follow?: boolean
|
||||||
|
file?: string[]
|
||||||
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
|
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +352,7 @@ export namespace Ripgrep {
|
|||||||
maxDepth?: number
|
maxDepth?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
pattern?: string
|
pattern?: string
|
||||||
|
file?: string[]
|
||||||
}) {
|
}) {
|
||||||
const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
|
const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
|
||||||
if (input.follow) out.push("--follow")
|
if (input.follow) out.push("--follow")
|
||||||
@@ -363,7 +365,7 @@ export namespace Ripgrep {
|
|||||||
}
|
}
|
||||||
if (input.limit) out.push(`--max-count=${input.limit}`)
|
if (input.limit) out.push(`--max-count=${input.limit}`)
|
||||||
if (input.mode === "search") out.push("--no-messages")
|
if (input.mode === "search") out.push("--no-messages")
|
||||||
if (input.pattern) out.push("--", input.pattern)
|
if (input.pattern) out.push("--", input.pattern, ...(input.file ?? []))
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -405,6 +407,7 @@ export namespace Ripgrep {
|
|||||||
glob?: string[]
|
glob?: string[]
|
||||||
limit?: number
|
limit?: number
|
||||||
follow?: boolean
|
follow?: boolean
|
||||||
|
file?: string[]
|
||||||
}) {
|
}) {
|
||||||
return yield* Effect.scoped(
|
return yield* Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -414,6 +417,7 @@ export namespace Ripgrep {
|
|||||||
follow: input.follow,
|
follow: input.follow,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
pattern: input.pattern,
|
pattern: input.pattern,
|
||||||
|
file: input.file,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handle = yield* spawner.spawn(
|
const handle = yield* spawner.spawn(
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export const GlobTool = Tool.define(
|
|||||||
|
|
||||||
let search = params.path ?? Instance.directory
|
let search = params.path ?? Instance.directory
|
||||||
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
||||||
|
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
if (info?.type === "File") {
|
||||||
|
throw new Error(`glob path must be a directory: ${search}`)
|
||||||
|
}
|
||||||
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
|
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
|
||||||
|
|
||||||
const limit = 100
|
const limit = 100
|
||||||
|
|||||||
@@ -51,19 +51,25 @@ export const GrepTool = Tool.define(
|
|||||||
? (params.path ?? Instance.directory)
|
? (params.path ?? Instance.directory)
|
||||||
: path.join(Instance.directory, params.path ?? "."),
|
: path.join(Instance.directory, params.path ?? "."),
|
||||||
)
|
)
|
||||||
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
|
const info = yield* fs.stat(searchPath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
const cwd = info?.type === "Directory" ? searchPath : path.dirname(searchPath)
|
||||||
|
const file = info?.type === "Directory" ? undefined : [searchPath]
|
||||||
|
yield* assertExternalDirectoryEffect(ctx, searchPath, {
|
||||||
|
kind: info?.type === "Directory" ? "directory" : "file",
|
||||||
|
})
|
||||||
|
|
||||||
const result = yield* rg.search({
|
const result = yield* rg.search({
|
||||||
cwd: searchPath,
|
cwd,
|
||||||
pattern: params.pattern,
|
pattern: params.pattern,
|
||||||
glob: params.include ? [params.include] : undefined,
|
glob: params.include ? [params.include] : undefined,
|
||||||
|
file,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.items.length === 0) return empty
|
if (result.items.length === 0) return empty
|
||||||
|
|
||||||
const rows = result.items.map((item) => ({
|
const rows = result.items.map((item) => ({
|
||||||
path: AppFileSystem.resolve(
|
path: AppFileSystem.resolve(
|
||||||
path.isAbsolute(item.path.text) ? item.path.text : path.join(searchPath, item.path.text),
|
path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text),
|
||||||
),
|
),
|
||||||
line: item.line_number,
|
line: item.line_number,
|
||||||
text: item.lines.text,
|
text: item.lines.text,
|
||||||
|
|||||||
@@ -76,6 +76,25 @@ describe("Ripgrep.Service", () => {
|
|||||||
expect(result.items[0]?.lines.text).toContain("needle")
|
expect(result.items[0]?.lines.text).toContain("needle")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("search supports explicit file targets", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
|
||||||
|
await Bun.write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = path.join(tmp.path, "match.ts")
|
||||||
|
const result = await Effect.gen(function* () {
|
||||||
|
const rg = yield* Ripgrep.Service
|
||||||
|
return yield* rg.search({ cwd: tmp.path, pattern: "needle", file: [file] })
|
||||||
|
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(result.partial).toBe(false)
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
expect(result.items[0]?.path.text).toBe(file)
|
||||||
|
})
|
||||||
|
|
||||||
test("files returns stream of filenames", async () => {
|
test("files returns stream of filenames", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
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 { AppFileSystem } from "../../src/filesystem"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const it = testEffect(
|
||||||
|
Layer.mergeAll(
|
||||||
|
CrossSpawnSpawner.defaultLayer,
|
||||||
|
AppFileSystem.defaultLayer,
|
||||||
|
Ripgrep.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
Agent.defaultLayer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
sessionID: SessionID.make("ses_test"),
|
||||||
|
messageID: MessageID.make(""),
|
||||||
|
callID: "",
|
||||||
|
agent: "build",
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
messages: [],
|
||||||
|
metadata: () => Effect.void,
|
||||||
|
ask: () => Effect.void,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tool.glob", () => {
|
||||||
|
it.live("matches files from a directory path", () =>
|
||||||
|
provideTmpdirInstance((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n"))
|
||||||
|
yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n"))
|
||||||
|
const info = yield* GlobTool
|
||||||
|
const glob = yield* info.init()
|
||||||
|
const result = yield* glob.execute(
|
||||||
|
{
|
||||||
|
pattern: "*.ts",
|
||||||
|
path: dir,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
expect(result.metadata.count).toBe(1)
|
||||||
|
expect(result.output).toContain(path.join(dir, "a.ts"))
|
||||||
|
expect(result.output).not.toContain(path.join(dir, "b.txt"))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("rejects exact file paths", () =>
|
||||||
|
provideTmpdirInstance((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(dir, "a.ts")
|
||||||
|
yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
|
||||||
|
const info = yield* GlobTool
|
||||||
|
const glob = yield* info.init()
|
||||||
|
const exit = yield* glob
|
||||||
|
.execute(
|
||||||
|
{
|
||||||
|
pattern: "*.ts",
|
||||||
|
path: file,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.pipe(Effect.exit)
|
||||||
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
if (Exit.isFailure(exit)) {
|
||||||
|
const err = Cause.squash(exit.cause)
|
||||||
|
expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -90,4 +90,25 @@ describe("tool.grep", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("supports exact file paths", () =>
|
||||||
|
provideTmpdirInstance((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(dir, "test.txt")
|
||||||
|
yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
|
||||||
|
const info = yield* GrepTool
|
||||||
|
const grep = yield* info.init()
|
||||||
|
const result = yield* grep.execute(
|
||||||
|
{
|
||||||
|
pattern: "line2",
|
||||||
|
path: file,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
expect(result.metadata.matches).toBe(1)
|
||||||
|
expect(result.output).toContain(file)
|
||||||
|
expect(result.output).toContain("Line 2: line2")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user