mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-16 03:34:22 +00:00
Compare commits
4 Commits
effectify-
...
effectify-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56d5cb3e8f | ||
|
|
40a14d24b8 | ||
|
|
a2cb2463c7 | ||
|
|
5241170e8f |
@@ -7,6 +7,7 @@ import { PermissionService } from "@/permission/service"
|
||||
import { FileWatcherService } from "@/file/watcher"
|
||||
import { VcsService } from "@/project/vcs"
|
||||
import { FileTimeService } from "@/file/time"
|
||||
import { FormatService } from "@/format"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
export { InstanceContext } from "./instance-context"
|
||||
@@ -18,6 +19,7 @@ export type InstanceServices =
|
||||
| FileWatcherService
|
||||
| VcsService
|
||||
| FileTimeService
|
||||
| FormatService
|
||||
|
||||
function lookup(directory: string) {
|
||||
const project = Instance.project
|
||||
@@ -29,6 +31,7 @@ function lookup(directory: string) {
|
||||
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(VcsService.layer),
|
||||
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(FormatService.layer),
|
||||
).pipe(Layer.provide(ctx))
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,13 @@ import { Config } from "../config/config"
|
||||
import { mergeDeep } from "remeda"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Process } from "../util/process"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
export namespace Format {
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
export const Status = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
@@ -24,117 +27,135 @@ export namespace Format {
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const cfg = await Config.get()
|
||||
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
if (cfg.formatter === false) {
|
||||
log.info("all formatters are disabled")
|
||||
return {
|
||||
enabled,
|
||||
formatters,
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of Object.values(Formatter)) {
|
||||
formatters[item.name] = item
|
||||
}
|
||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||
if (item.disabled) {
|
||||
delete formatters[name]
|
||||
continue
|
||||
}
|
||||
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
|
||||
command: [],
|
||||
extensions: [],
|
||||
...item,
|
||||
})
|
||||
|
||||
if (result.command.length === 0) continue
|
||||
|
||||
result.enabled = async () => true
|
||||
result.name = name
|
||||
formatters[name] = result
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
formatters,
|
||||
}
|
||||
})
|
||||
|
||||
async function isEnabled(item: Formatter.Info) {
|
||||
const s = await state()
|
||||
let status = s.enabled[item.name]
|
||||
if (status === undefined) {
|
||||
status = await item.enabled()
|
||||
s.enabled[item.name] = status
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const formatters = await state().then((x) => x.formatters)
|
||||
const result = []
|
||||
for (const item of Object.values(formatters)) {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
if (!(await isEnabled(item))) continue
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
export async function init() {
|
||||
return runPromiseInstance(FormatService.use((s) => s.init()))
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
const s = await state()
|
||||
const result: Status[] = []
|
||||
for (const formatter of Object.values(s.formatters)) {
|
||||
const enabled = await isEnabled(formatter)
|
||||
result.push({
|
||||
name: formatter.name,
|
||||
extensions: formatter.extensions,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function init() {
|
||||
log.info("init")
|
||||
Bus.subscribe(File.Event.Edited, async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0)
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return runPromiseInstance(FormatService.use((s) => s.status()))
|
||||
}
|
||||
}
|
||||
|
||||
export namespace FormatService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Format.Status[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
|
||||
static readonly layer = Layer.effect(
|
||||
FormatService,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
|
||||
if (cfg.formatter !== false) {
|
||||
for (const item of Object.values(Formatter)) {
|
||||
formatters[item.name] = item
|
||||
}
|
||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||
if (item.disabled) {
|
||||
delete formatters[name]
|
||||
continue
|
||||
}
|
||||
const result = mergeDeep(formatters[name] ?? {}, {
|
||||
command: [],
|
||||
extensions: [],
|
||||
...item,
|
||||
}) as Formatter.Info
|
||||
|
||||
if (result.command.length === 0) continue
|
||||
|
||||
result.enabled = async () => true
|
||||
result.name = name
|
||||
formatters[name] = result
|
||||
}
|
||||
} else {
|
||||
log.info("all formatters are disabled")
|
||||
}
|
||||
|
||||
async function isEnabled(item: Formatter.Info) {
|
||||
let status = enabled[item.name]
|
||||
if (status === undefined) {
|
||||
status = await item.enabled()
|
||||
enabled[item.name] = status
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const result = []
|
||||
for (const item of Object.values(formatters)) {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
if (!(await isEnabled(item))) continue
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const unsubscribe = Bus.subscribe(
|
||||
File.Event.Edited,
|
||||
Instance.bind(async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0)
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
||||
log.info("init")
|
||||
|
||||
const init = Effect.fn("FormatService.init")(function* () {})
|
||||
|
||||
const status = Effect.fn("FormatService.status")(function* () {
|
||||
const result: Format.Status[] = []
|
||||
for (const formatter of Object.values(formatters)) {
|
||||
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
||||
result.push({
|
||||
name: formatter.name,
|
||||
extensions: formatter.extensions,
|
||||
enabled: isOn,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return FormatService.of({ init, status })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function InstanceBootstrap() {
|
||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||
await Plugin.init()
|
||||
ShareNext.init()
|
||||
Format.init()
|
||||
await Format.init()
|
||||
await LSP.init()
|
||||
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
|
||||
File.init()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { File } from "../../src/file"
|
||||
@@ -391,4 +392,469 @@ describe("file/index Filesystem patterns", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.status()", () => {
|
||||
test("detects modified file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(filepath, "modified\nextra line\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
const entry = result.find((f) => f.path === "file.txt")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("modified")
|
||||
expect(entry!.added).toBeGreaterThan(0)
|
||||
expect(entry!.removed).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("detects untracked file as added", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
const entry = result.find((f) => f.path === "new.txt")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("added")
|
||||
expect(entry!.added).toBe(4) // 3 lines + trailing newline splits to 4
|
||||
expect(entry!.removed).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("detects deleted file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "gone.txt")
|
||||
await fs.writeFile(filepath, "content\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.rm(filepath)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
// Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted")
|
||||
const entries = result.filter((f) => f.path === "gone.txt")
|
||||
expect(entries.some((e) => e.status === "deleted")).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("detects mixed changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "keep.txt"), "keep\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "remove.txt"), "remove\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "initial"`.cwd(tmp.path).quiet()
|
||||
|
||||
// Modify one, delete one, add one
|
||||
await fs.writeFile(path.join(tmp.path, "keep.txt"), "changed\n", "utf-8")
|
||||
await fs.rm(path.join(tmp.path, "remove.txt"))
|
||||
await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true)
|
||||
expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true)
|
||||
expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns empty for non-git project", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
expect(result).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns empty for clean repo", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
expect(result).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("parses binary numstat as 0", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "data.bin")
|
||||
// Write content with null bytes so git treats it as binary
|
||||
const binaryData = Buffer.alloc(256)
|
||||
for (let i = 0; i < 256; i++) binaryData[i] = i
|
||||
await fs.writeFile(filepath, binaryData)
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()
|
||||
// Modify the binary
|
||||
const modified = Buffer.alloc(512)
|
||||
for (let i = 0; i < 512; i++) modified[i] = i % 256
|
||||
await fs.writeFile(filepath, modified)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
const entry = result.find((f) => f.path === "data.bin")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("modified")
|
||||
expect(entry!.added).toBe(0)
|
||||
expect(entry!.removed).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.list()", () => {
|
||||
test("returns files and directories with correct shape", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.mkdir(path.join(tmp.path, "subdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(2)
|
||||
for (const node of nodes) {
|
||||
expect(node).toHaveProperty("name")
|
||||
expect(node).toHaveProperty("path")
|
||||
expect(node).toHaveProperty("absolute")
|
||||
expect(node).toHaveProperty("type")
|
||||
expect(node).toHaveProperty("ignored")
|
||||
expect(["file", "directory"]).toContain(node.type)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("sorts directories before files, alphabetical within each", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.mkdir(path.join(tmp.path, "beta"))
|
||||
await fs.mkdir(path.join(tmp.path, "alpha"))
|
||||
await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
const dirs = nodes.filter((n) => n.type === "directory")
|
||||
const files = nodes.filter((n) => n.type === "file")
|
||||
// Dirs come first
|
||||
const firstFile = nodes.findIndex((n) => n.type === "file")
|
||||
const lastDir = nodes.findLastIndex((n) => n.type === "directory")
|
||||
if (lastDir >= 0 && firstFile >= 0) {
|
||||
expect(lastDir).toBeLessThan(firstFile)
|
||||
}
|
||||
// Alphabetical within dirs
|
||||
expect(dirs.map((d) => d.name)).toEqual(dirs.map((d) => d.name).toSorted())
|
||||
// Alphabetical within files
|
||||
expect(files.map((f) => f.name)).toEqual(files.map((f) => f.name).toSorted())
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("excludes .git and .DS_Store", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
const names = nodes.map((n) => n.name)
|
||||
expect(names).not.toContain(".git")
|
||||
expect(names).not.toContain(".DS_Store")
|
||||
expect(names).toContain("visible.txt")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("marks gitignored files as ignored", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, ".gitignore"), "*.log\nbuild/\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "app.log"), "log data", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8")
|
||||
await fs.mkdir(path.join(tmp.path, "build"))
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
const logNode = nodes.find((n) => n.name === "app.log")
|
||||
const tsNode = nodes.find((n) => n.name === "main.ts")
|
||||
const buildNode = nodes.find((n) => n.name === "build")
|
||||
expect(logNode?.ignored).toBe(true)
|
||||
expect(tsNode?.ignored).toBe(false)
|
||||
expect(buildNode?.ignored).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("lists subdirectory contents", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.mkdir(path.join(tmp.path, "sub"))
|
||||
await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list("sub")
|
||||
expect(nodes.length).toBe(2)
|
||||
expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"])
|
||||
// Paths should be relative to project root (normalize for Windows)
|
||||
expect(nodes[0].path.replaceAll("\\", "/").startsWith("sub/")).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws for paths outside project directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(File.list("../outside")).rejects.toThrow("Access denied")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("works without git", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(1)
|
||||
// Without git, ignored should be false for all
|
||||
for (const node of nodes) {
|
||||
expect(node.ignored).toBe(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.search()", () => {
|
||||
async function setupSearchableRepo() {
|
||||
const tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "utils.ts"), "utils", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "readme.md"), "readme", "utf-8")
|
||||
await fs.mkdir(path.join(tmp.path, "src"))
|
||||
await fs.mkdir(path.join(tmp.path, ".hidden"))
|
||||
await fs.writeFile(path.join(tmp.path, "src", "main.ts"), "main", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, ".hidden", "secret.ts"), "secret", "utf-8")
|
||||
return tmp
|
||||
}
|
||||
|
||||
test("empty query returns files", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
File.init()
|
||||
// Give the background scan time to populate
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: "", type: "file" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("empty query returns dirs sorted with hidden last", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
File.init()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: "", type: "directory" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
// Find first hidden dir index
|
||||
const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1))
|
||||
const lastVisible = result.findLastIndex((d) => !d.split("/").some((p) => p.startsWith(".") && p.length > 1))
|
||||
if (firstHidden >= 0 && lastVisible >= 0) {
|
||||
expect(firstHidden).toBeGreaterThan(lastVisible)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("fuzzy matches file names", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
File.init()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: "main", type: "file" })
|
||||
expect(result.some((f) => f.includes("main"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("type filter returns only files", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
File.init()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: "", type: "file" })
|
||||
// Files don't end with /
|
||||
for (const f of result) {
|
||||
expect(f.endsWith("/")).toBe(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("type filter returns only directories", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
File.init()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: "", type: "directory" })
|
||||
// Directories end with /
|
||||
for (const d of result) {
|
||||
expect(d.endsWith("/")).toBe(true)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("respects limit", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
File.init()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: "", type: "file", limit: 2 })
|
||||
expect(result.length).toBeLessThanOrEqual(2)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("query starting with dot prefers hidden files", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
File.init()
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
const result = await File.search({ query: ".hidden", type: "directory" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result[0]).toContain(".hidden")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.read() - diff/patch", () => {
|
||||
test("returns diff and patch for modified tracked file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original content\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(filepath, "modified content\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("file.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("modified content")
|
||||
expect(result.diff).toBeDefined()
|
||||
expect(result.diff).toContain("original content")
|
||||
expect(result.diff).toContain("modified content")
|
||||
expect(result.patch).toBeDefined()
|
||||
expect(result.patch!.hunks.length).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns diff for staged changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "staged.txt")
|
||||
await fs.writeFile(filepath, "before\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(filepath, "after\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("staged.txt")
|
||||
expect(result.diff).toBeDefined()
|
||||
expect(result.patch).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns no diff for unmodified file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "clean.txt")
|
||||
await fs.writeFile(filepath, "unchanged\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("clean.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("unchanged")
|
||||
expect(result.diff).toBeUndefined()
|
||||
expect(result.patch).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
64
packages/opencode/test/format/format.test.ts
Normal file
64
packages/opencode/test/format/format.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { withServices } from "../fixture/instance"
|
||||
import { FormatService } from "../../src/format"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
describe("FormatService", () => {
|
||||
afterEach(() => Instance.disposeAll())
|
||||
|
||||
test("status() returns built-in formatters when no config overrides", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
||||
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
|
||||
expect(Array.isArray(statuses)).toBe(true)
|
||||
expect(statuses.length).toBeGreaterThan(0)
|
||||
|
||||
for (const s of statuses) {
|
||||
expect(typeof s.name).toBe("string")
|
||||
expect(Array.isArray(s.extensions)).toBe(true)
|
||||
expect(typeof s.enabled).toBe("boolean")
|
||||
}
|
||||
|
||||
const gofmt = statuses.find((s) => s.name === "gofmt")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt!.extensions).toContain(".go")
|
||||
})
|
||||
})
|
||||
|
||||
test("status() returns empty list when formatter is disabled", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: { formatter: false },
|
||||
})
|
||||
|
||||
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
||||
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
|
||||
expect(statuses).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
test("status() excludes formatters marked as disabled in config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
formatter: {
|
||||
gofmt: { disabled: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
||||
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
|
||||
const gofmt = statuses.find((s) => s.name === "gofmt")
|
||||
expect(gofmt).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test("init() completes without error", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
||||
await rt.runPromise(FormatService.use((s) => s.init()))
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user