Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 109 additions & 98 deletions packages/core/src/filesystem/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,109 +123,120 @@ export const fffLayer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const result = yield* Effect.try({
try: () =>
Fff.create({
basePath: location.directory,
aiMode: true,
}),
catch: (cause) => cause,
}).pipe(
Effect.catch((error) => Effect.logWarning("failed to initialize fff", { error }).pipe(Effect.as(undefined))),
)
if (!result?.ok) {
if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error })
return Service.of({
find: () => Effect.succeed([]),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
})
}
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
return Service.of({
glob: (input) =>
Effect.sync(() => {
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
pageIndex: 0,
pageSize: input.limit,
})
if (!found.ok) throw found.error
return found.value.items.map((item) =>
FileSystem.Entry.make({
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
type: "file",
const scope = yield* Scope.Scope
const load = yield* Effect.cached(
Effect.gen(function* () {
const result = yield* Effect.try({
try: () =>
Fff.create({
basePath: location.directory,
aiMode: true,
}),
)
}),
grep: (input) =>
Effect.sync(() => {
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
const found = result.value.grep(
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
.filter((value) => value !== undefined)
.join(" "),
{ mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 },
)
if (!found.ok) throw found.error
return found.value.items.map((match) => {
const bytes = Buffer.from(match.lineContent)
return FileSystem.Match.make({
entry: FileSystem.Entry.make({
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
type: "file",
}),
line: match.lineNumber,
offset: match.byteOffset,
text: match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent,
submatches: match.matchRanges.map(([start, end]) => ({
text: bytes.subarray(start, end).toString("utf8"),
start,
end,
})),
})
catch: (cause) => cause,
}).pipe(
Effect.catch((error) => Effect.logWarning("failed to initialize fff", { error }).pipe(Effect.as(undefined))),
)
if (!result?.ok) {
if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error })
return Service.of({
find: () => Effect.succeed([]),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
})
}),
find: (input) =>
Effect.sync(() => {
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
const items = (() => {
if (input.type === "file") {
const found = result.value.fileSearch(input.query.trim(), options)
}
yield* Scope.addFinalizer(scope, Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
return Service.of({
glob: (input) =>
Effect.sync(() => {
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
pageIndex: 0,
pageSize: input.limit,
})
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.relativePath,
type: "file" as const,
score: found.value.scores[index]?.total ?? 0,
}))
}
if (input.type === "directory") {
const found = result.value.directorySearch(input.query.trim(), options)
return found.value.items.map((item) =>
FileSystem.Entry.make({
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
type: "file",
}),
)
}),
grep: (input) =>
Effect.sync(() => {
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
const found = result.value.grep(
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
.filter((value) => value !== undefined)
.join(" "),
{ mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 },
)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.relativePath,
type: "directory" as const,
score: found.value.scores[index]?.total ?? 0,
}))
}
const found = result.value.mixedSearch(input.query.trim(), options)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.item.relativePath,
type: item.type,
score: found.value.scores[index]?.total ?? 0,
}))
})()
return items
.sort((a, b) => b.score - a.score || a.path.length - b.path.length)
.map((item) => {
const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "")
return FileSystem.Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
type: item.type,
return found.value.items.map((match) => {
const bytes = Buffer.from(match.lineContent)
return FileSystem.Match.make({
entry: FileSystem.Entry.make({
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
type: "file",
}),
line: match.lineNumber,
offset: match.byteOffset,
text:
match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent,
submatches: match.matchRanges.map(([start, end]) => ({
text: bytes.subarray(start, end).toString("utf8"),
start,
end,
})),
})
})
})
}),
}),
find: (input) =>
Effect.sync(() => {
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
const items = (() => {
if (input.type === "file") {
const found = result.value.fileSearch(input.query.trim(), options)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.relativePath,
type: "file" as const,
score: found.value.scores[index]?.total ?? 0,
}))
}
if (input.type === "directory") {
const found = result.value.directorySearch(input.query.trim(), options)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.relativePath,
type: "directory" as const,
score: found.value.scores[index]?.total ?? 0,
}))
}
const found = result.value.mixedSearch(input.query.trim(), options)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.item.relativePath,
type: item.type,
score: found.value.scores[index]?.total ?? 0,
}))
})()
return items
.sort((a, b) => b.score - a.score || a.path.length - b.path.length)
.map((item) => {
const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "")
return FileSystem.Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
type: item.type,
})
})
}),
})
}),
)
return Service.of({
find: (input) => Effect.flatMap(load, (service) => service.find(input)),
glob: (input) => Effect.flatMap(load, (service) => service.glob(input)),
grep: (input) => Effect.flatMap(load, (service) => service.grep(input)),
})
}),
)
Expand Down
74 changes: 73 additions & 1 deletion packages/core/test/filesystem/search.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Context, Effect, Layer, Scope } from "effect"
import { FileFinder } from "@ff-labs/fff-bun"
import "@opencode-ai/core/filesystem"
import { Fff } from "@opencode-ai/core/filesystem/fff.bun"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"

Expand Down Expand Up @@ -42,3 +47,70 @@ describe("Ripgrep", () => {
),
)
})

describe("FileSystemSearch", () => {
it.live("initializes fff on the first search", () =>
withTmp((directory) =>
Effect.gen(function* () {
if (!Fff.available()) return
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "hello\n"))
const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search"))
const create = FileFinder.create
const calls = { create: 0, destroy: 0 }
yield* Effect.acquireUseRelease(
Effect.sync(() => {
FileFinder.create = (options) => {
calls.create++
const result = create(options)
if (!result.ok) return result
const destroy = result.value.destroy.bind(result.value)
result.value.destroy = () => {
calls.destroy++
destroy()
}
return result
}
}),
() =>
Effect.gen(function* () {
yield* Effect.acquireUseRelease(
Scope.make(),
(scope) =>
Effect.gen(function* () {
const context = yield* Layer.buildWithScope(
FileSystemSearch.fffLayer.pipe(
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(location(Location.Ref.make({ directory }))),
),
),
),
scope,
)
const service = Context.get(context, FileSystemSearch.Service)
expect(calls.create).toBe(0)

yield* Effect.all(
[
service.find({ query: "", type: "file", limit: 1 }),
service.find({ query: "", type: "file", limit: 1 }),
],
{ concurrency: "unbounded" },
)
expect(calls.create).toBe(1)

yield* service.find({ query: "", type: "file", limit: 1 })
expect(calls.create).toBe(1)
expect(calls.destroy).toBe(0)
}),
(scope, exit) => Scope.close(scope, exit),
)
expect(calls.destroy).toBe(1)
}),
() => Effect.sync(() => (FileFinder.create = create)),
)
}),
),
)
})
Loading