effect(patch,tool): migrate patch/index and tool/read to AppFileSystem (#27155)
This commit is contained in:
@@ -67,11 +67,11 @@ Most exported tools are already on the intended Effect-native shape. The remaini
|
|||||||
|
|
||||||
Current spot cleanups worth tracking:
|
Current spot cleanups worth tracking:
|
||||||
|
|
||||||
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
|
- [x] `read.ts` — streams through `AppFileSystem.Service.stream` with `Stream.splitLines`; the legacy Node stream / `readline` helper is gone
|
||||||
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
|
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
|
||||||
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
|
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
|
||||||
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
|
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
|
||||||
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
|
- [x] `patch/index.ts` — apply path now returns `Effect` over `AppFileSystem.Service`; the parser and chunk replacer stay pure
|
||||||
|
|
||||||
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
|
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
|
||||||
|
|
||||||
@@ -85,6 +85,4 @@ Notable items that are already effectively on the target path and do not need se
|
|||||||
|
|
||||||
Current raw fs users that still appear relevant here:
|
Current raw fs users that still appear relevant here:
|
||||||
|
|
||||||
- `tool/read.ts` — `fs.createReadStream`, `readline`
|
|
||||||
- `file/ripgrep.ts` — `fs/promises`
|
- `file/ripgrep.ts` — `fs/promises`
|
||||||
- `patch/index.ts` — `fs`, `fs/promises`
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import * as fs from "fs/promises"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { readFileSync } from "fs"
|
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import * as Bom from "../util/bom"
|
import * as Bom from "../util/bom"
|
||||||
|
|
||||||
@@ -308,14 +307,12 @@ interface ApplyPatchFileUpdate {
|
|||||||
bom: boolean
|
bom: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
|
export function deriveNewContentsFromChunks(
|
||||||
// Read original file content
|
filePath: string,
|
||||||
let originalContent: ReturnType<typeof Bom.split>
|
chunks: UpdateFileChunk[],
|
||||||
try {
|
originalText: string,
|
||||||
originalContent = Bom.split(readFileSync(filePath, "utf-8"))
|
): ApplyPatchFileUpdate {
|
||||||
} catch (error) {
|
const originalContent = Bom.split(originalText)
|
||||||
throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
|
|
||||||
}
|
|
||||||
|
|
||||||
let originalLines = originalContent.text.split("\n")
|
let originalLines = originalContent.text.split("\n")
|
||||||
|
|
||||||
@@ -423,11 +420,11 @@ function applyReplacements(lines: string[], replacements: Array<[number, number,
|
|||||||
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
|
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
|
||||||
function normalizeUnicode(str: string): string {
|
function normalizeUnicode(str: string): string {
|
||||||
return str
|
return str
|
||||||
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
|
.replace(/[‘’‚‛]/g, "'") // single quotes
|
||||||
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
|
.replace(/[“”„‟]/g, '"') // double quotes
|
||||||
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
|
.replace(/[‐‑‒–—―]/g, "-") // dashes
|
||||||
.replace(/\u2026/g, "...") // ellipsis
|
.replace(/…/g, "...") // ellipsis
|
||||||
.replace(/\u00A0/g, " ") // non-breaking space
|
.replace(/ /g, " ") // non-breaking space
|
||||||
}
|
}
|
||||||
|
|
||||||
type Comparator = (a: string, b: string) => boolean
|
type Comparator = (a: string, b: string) => boolean
|
||||||
@@ -517,77 +514,71 @@ function generateUnifiedDiff(oldContent: string, newContent: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply hunks to filesystem
|
// Apply hunks to filesystem
|
||||||
export async function applyHunksToFiles(hunks: Hunk[]): Promise<AffectedPaths> {
|
export const applyHunksToFiles = Effect.fn("Patch.applyHunksToFiles")(function* (hunks: Hunk[]) {
|
||||||
if (hunks.length === 0) {
|
if (hunks.length === 0) {
|
||||||
throw new Error("No files were modified.")
|
return yield* Effect.fail(new Error("No files were modified."))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
|
||||||
const added: string[] = []
|
const added: string[] = []
|
||||||
const modified: string[] = []
|
const modified: string[] = []
|
||||||
const deleted: string[] = []
|
const deleted: string[] = []
|
||||||
|
|
||||||
for (const hunk of hunks) {
|
for (const hunk of hunks) {
|
||||||
switch (hunk.type) {
|
switch (hunk.type) {
|
||||||
case "add":
|
case "add": {
|
||||||
// Create parent directories
|
yield* fs.writeWithDirs(hunk.path, hunk.contents)
|
||||||
const addDir = path.dirname(hunk.path)
|
|
||||||
if (addDir !== "." && addDir !== "/") {
|
|
||||||
await fs.mkdir(addDir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.writeFile(hunk.path, hunk.contents, "utf-8")
|
|
||||||
added.push(hunk.path)
|
added.push(hunk.path)
|
||||||
log.info(`Added file: ${hunk.path}`)
|
log.info(`Added file: ${hunk.path}`)
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "delete":
|
case "delete": {
|
||||||
await fs.unlink(hunk.path)
|
yield* fs.remove(hunk.path)
|
||||||
deleted.push(hunk.path)
|
deleted.push(hunk.path)
|
||||||
log.info(`Deleted file: ${hunk.path}`)
|
log.info(`Deleted file: ${hunk.path}`)
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "update":
|
case "update": {
|
||||||
const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
|
const originalText = yield* fs.readFileString(hunk.path)
|
||||||
|
const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks, originalText)
|
||||||
|
|
||||||
if (hunk.move_path) {
|
if (hunk.move_path) {
|
||||||
// Handle file move
|
yield* fs.writeWithDirs(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom))
|
||||||
const moveDir = path.dirname(hunk.move_path)
|
yield* fs.remove(hunk.path)
|
||||||
if (moveDir !== "." && moveDir !== "/") {
|
|
||||||
await fs.mkdir(moveDir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.writeFile(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8")
|
|
||||||
await fs.unlink(hunk.path)
|
|
||||||
modified.push(hunk.move_path)
|
modified.push(hunk.move_path)
|
||||||
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
|
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
|
||||||
} else {
|
} else {
|
||||||
// Regular update
|
yield* fs.writeWithDirs(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom))
|
||||||
await fs.writeFile(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8")
|
|
||||||
modified.push(hunk.path)
|
modified.push(hunk.path)
|
||||||
log.info(`Updated file: ${hunk.path}`)
|
log.info(`Updated file: ${hunk.path}`)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { added, modified, deleted }
|
return { added, modified, deleted } satisfies AffectedPaths
|
||||||
}
|
})
|
||||||
|
|
||||||
// Main patch application function
|
// Main patch application function
|
||||||
export async function applyPatch(patchText: string): Promise<AffectedPaths> {
|
export const applyPatch = Effect.fn("Patch.applyPatch")(function* (patchText: string) {
|
||||||
const { hunks } = parsePatch(patchText)
|
const { hunks } = parsePatch(patchText)
|
||||||
return applyHunksToFiles(hunks)
|
return yield* applyHunksToFiles(hunks)
|
||||||
}
|
})
|
||||||
|
|
||||||
// Async version of maybeParseApplyPatchVerified
|
type MaybeApplyPatchVerifiedResult =
|
||||||
export async function maybeParseApplyPatchVerified(
|
|
||||||
argv: string[],
|
|
||||||
cwd: string,
|
|
||||||
): Promise<
|
|
||||||
| { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction }
|
| { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction }
|
||||||
| { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
|
| { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
|
||||||
| { type: MaybeApplyPatchVerified.NotApplyPatch }
|
| { type: MaybeApplyPatchVerified.NotApplyPatch }
|
||||||
> {
|
|
||||||
|
// Effectful verified-parse: needs AppFileSystem.Service to read existing files
|
||||||
|
export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatchVerified")(function* (
|
||||||
|
argv: string[],
|
||||||
|
cwd: string,
|
||||||
|
) {
|
||||||
// Detect implicit patch invocation (raw patch without apply_patch command)
|
// Detect implicit patch invocation (raw patch without apply_patch command)
|
||||||
if (argv.length === 1) {
|
if (argv.length === 1) {
|
||||||
try {
|
try {
|
||||||
@@ -595,7 +586,7 @@ export async function maybeParseApplyPatchVerified(
|
|||||||
return {
|
return {
|
||||||
type: MaybeApplyPatchVerified.CorrectnessError,
|
type: MaybeApplyPatchVerified.CorrectnessError,
|
||||||
error: new Error(ApplyPatchError.ImplicitInvocation),
|
error: new Error(ApplyPatchError.ImplicitInvocation),
|
||||||
}
|
} satisfies MaybeApplyPatchVerifiedResult
|
||||||
} catch {
|
} catch {
|
||||||
// Not a patch, continue
|
// Not a patch, continue
|
||||||
}
|
}
|
||||||
@@ -604,8 +595,9 @@ export async function maybeParseApplyPatchVerified(
|
|||||||
const result = maybeParseApplyPatch(argv)
|
const result = maybeParseApplyPatch(argv)
|
||||||
|
|
||||||
switch (result.type) {
|
switch (result.type) {
|
||||||
case MaybeApplyPatch.Body:
|
case MaybeApplyPatch.Body: {
|
||||||
const { args } = result
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const args = result.args
|
||||||
const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
|
const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
|
||||||
const changes = new Map<string, ApplyPatchFileChange>()
|
const changes = new Map<string, ApplyPatchFileChange>()
|
||||||
|
|
||||||
@@ -623,27 +615,37 @@ export async function maybeParseApplyPatchVerified(
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
case "delete":
|
case "delete": {
|
||||||
// For delete, we need to read the current content
|
|
||||||
const deletePath = path.resolve(effectiveCwd, hunk.path)
|
const deletePath = path.resolve(effectiveCwd, hunk.path)
|
||||||
try {
|
const content = yield* fs.readFileString(deletePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
const content = await fs.readFile(deletePath, "utf-8")
|
if (content === undefined) {
|
||||||
changes.set(resolvedPath, {
|
|
||||||
type: "delete",
|
|
||||||
content,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return {
|
return {
|
||||||
type: MaybeApplyPatchVerified.CorrectnessError,
|
type: MaybeApplyPatchVerified.CorrectnessError,
|
||||||
error: new Error(`Failed to read file for deletion: ${deletePath}`),
|
error: new Error(`Failed to read file for deletion: ${deletePath}`),
|
||||||
}
|
} satisfies MaybeApplyPatchVerifiedResult
|
||||||
}
|
}
|
||||||
|
changes.set(resolvedPath, {
|
||||||
|
type: "delete",
|
||||||
|
content,
|
||||||
|
})
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "update":
|
case "update": {
|
||||||
const updatePath = path.resolve(effectiveCwd, hunk.path)
|
const updatePath = path.resolve(effectiveCwd, hunk.path)
|
||||||
|
const originalText = yield* fs.readFileString(updatePath).pipe(
|
||||||
|
Effect.catch((cause) =>
|
||||||
|
Effect.succeed(new Error(`Failed to read file ${updatePath}: ${cause}`, { cause })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (originalText instanceof Error) {
|
||||||
|
return {
|
||||||
|
type: MaybeApplyPatchVerified.CorrectnessError,
|
||||||
|
error: originalText,
|
||||||
|
} satisfies MaybeApplyPatchVerifiedResult
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks)
|
const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks, originalText)
|
||||||
changes.set(resolvedPath, {
|
changes.set(resolvedPath, {
|
||||||
type: "update",
|
type: "update",
|
||||||
unified_diff: fileUpdate.unified_diff,
|
unified_diff: fileUpdate.unified_diff,
|
||||||
@@ -654,9 +656,10 @@ export async function maybeParseApplyPatchVerified(
|
|||||||
return {
|
return {
|
||||||
type: MaybeApplyPatchVerified.CorrectnessError,
|
type: MaybeApplyPatchVerified.CorrectnessError,
|
||||||
error: error as Error,
|
error: error as Error,
|
||||||
}
|
} satisfies MaybeApplyPatchVerifiedResult
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,17 +670,18 @@ export async function maybeParseApplyPatchVerified(
|
|||||||
patch: args.patch,
|
patch: args.patch,
|
||||||
cwd: effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
},
|
},
|
||||||
}
|
} satisfies MaybeApplyPatchVerifiedResult
|
||||||
|
}
|
||||||
|
|
||||||
case MaybeApplyPatch.PatchParseError:
|
case MaybeApplyPatch.PatchParseError:
|
||||||
return {
|
return {
|
||||||
type: MaybeApplyPatchVerified.CorrectnessError,
|
type: MaybeApplyPatchVerified.CorrectnessError,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
}
|
} satisfies MaybeApplyPatchVerifiedResult
|
||||||
|
|
||||||
case MaybeApplyPatch.NotApplyPatch:
|
case MaybeApplyPatch.NotApplyPatch:
|
||||||
return { type: MaybeApplyPatchVerified.NotApplyPatch }
|
return { type: MaybeApplyPatchVerified.NotApplyPatch } satisfies MaybeApplyPatchVerifiedResult
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
export * as Patch from "."
|
export * as Patch from "."
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export const ApplyPatchTool = Tool.define(
|
|||||||
|
|
||||||
// Apply the update chunks to get new content
|
// Apply the update chunks to get new content
|
||||||
try {
|
try {
|
||||||
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
|
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks, Bom.join(source.text, source.bom))
|
||||||
newContent = fileUpdate.content
|
newContent = fileUpdate.content
|
||||||
bom = fileUpdate.bom
|
bom = fileUpdate.bom
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Effect, Option, Schema, Scope } from "effect"
|
import { Effect, Option, Schema, Scope, Stream } from "effect"
|
||||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||||
import { createReadStream } from "fs"
|
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { createInterface } from "readline"
|
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
@@ -105,6 +103,49 @@ export const ReadTool = Tool.define(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const lines = Effect.fn("ReadTool.lines")(function* (filepath: string, opts: { limit: number; offset: number }) {
|
||||||
|
const start = opts.offset - 1
|
||||||
|
const raw: string[] = []
|
||||||
|
const flags = { bytes: 0, count: 0, cut: false, more: false, done: false }
|
||||||
|
|
||||||
|
// Note: prefer manual TextDecoder over Stream.decodeText — when the source stream
|
||||||
|
// ends without flushing, decodeText drops the final unterminated line. We also
|
||||||
|
// avoid Stream.runForEachWhile (it currently swallows the final unterminated
|
||||||
|
// line of the upstream splitLines pipeline) and instead toggle a `done` flag
|
||||||
|
// and ignore subsequent lines.
|
||||||
|
const decoder = new TextDecoder("utf-8")
|
||||||
|
yield* fs.stream(filepath).pipe(
|
||||||
|
Stream.map((bytes) => decoder.decode(bytes, { stream: true })),
|
||||||
|
Stream.splitLines,
|
||||||
|
Stream.runForEach((text) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (flags.done) return
|
||||||
|
flags.count += 1
|
||||||
|
if (flags.count <= start) return
|
||||||
|
|
||||||
|
if (raw.length >= opts.limit) {
|
||||||
|
flags.more = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
|
||||||
|
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
|
||||||
|
if (flags.bytes + size > MAX_BYTES) {
|
||||||
|
flags.cut = true
|
||||||
|
flags.more = true
|
||||||
|
flags.done = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw.push(line)
|
||||||
|
flags.bytes += size
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { raw, count: flags.count, cut: flags.cut, more: flags.more, offset: opts.offset }
|
||||||
|
})
|
||||||
|
|
||||||
const isBinaryFile = (filepath: string, bytes: Uint8Array) => {
|
const isBinaryFile = (filepath: string, bytes: Uint8Array) => {
|
||||||
const ext = path.extname(filepath).toLowerCase()
|
const ext = path.extname(filepath).toLowerCase()
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
@@ -247,9 +288,7 @@ export const ReadTool = Tool.define(
|
|||||||
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
|
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = yield* Effect.promise(() =>
|
const file = yield* lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset || 1 })
|
||||||
lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset || 1 }),
|
|
||||||
)
|
|
||||||
if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) {
|
if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) {
|
||||||
return yield* Effect.fail(
|
return yield* Effect.fail(
|
||||||
new Error(`Offset ${file.offset} is out of range for this file (${file.count} lines)`),
|
new Error(`Offset ${file.offset} is out of range for this file (${file.count} lines)`),
|
||||||
@@ -296,47 +335,3 @@ export const ReadTool = Tool.define(
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function lines(filepath: string, opts: { limit: number; offset: number }) {
|
|
||||||
const stream = createReadStream(filepath, { encoding: "utf8" })
|
|
||||||
const rl = createInterface({
|
|
||||||
input: stream,
|
|
||||||
// Note: we use the crlfDelay option to recognize all instances of CR LF
|
|
||||||
// ('\r\n') in file as a single line break.
|
|
||||||
crlfDelay: Infinity,
|
|
||||||
})
|
|
||||||
|
|
||||||
const start = opts.offset - 1
|
|
||||||
const raw: string[] = []
|
|
||||||
let bytes = 0
|
|
||||||
let count = 0
|
|
||||||
let cut = false
|
|
||||||
let more = false
|
|
||||||
try {
|
|
||||||
for await (const text of rl) {
|
|
||||||
count += 1
|
|
||||||
if (count <= start) continue
|
|
||||||
|
|
||||||
if (raw.length >= opts.limit) {
|
|
||||||
more = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
|
|
||||||
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
|
|
||||||
if (bytes + size > MAX_BYTES) {
|
|
||||||
cut = true
|
|
||||||
more = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
raw.push(line)
|
|
||||||
bytes += size
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
rl.close()
|
|
||||||
stream.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
return { raw, count, cut, more, offset: opts.offset }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||||
import { Patch } from "../../src/patch"
|
import { Effect } from "effect"
|
||||||
import * as fs from "fs/promises"
|
import * as fs from "fs/promises"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { tmpdir } from "os"
|
import { tmpdir } from "os"
|
||||||
|
import { Patch } from "../../src/patch"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const it = testEffect(AppFileSystem.defaultLayer)
|
||||||
|
|
||||||
describe("Patch namespace", () => {
|
describe("Patch namespace", () => {
|
||||||
let tempDir: string
|
let tempDir: string
|
||||||
@@ -134,46 +139,53 @@ PATCH`
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("applyPatch", () => {
|
describe("applyPatch", () => {
|
||||||
test("should add a new file", async () => {
|
it.live("should add a new file", () =>
|
||||||
const patchText = `*** Begin Patch
|
Effect.gen(function* () {
|
||||||
|
const patchText = `*** Begin Patch
|
||||||
*** Add File: ${tempDir}/new-file.txt
|
*** Add File: ${tempDir}/new-file.txt
|
||||||
+Hello World
|
+Hello World
|
||||||
+This is a new file
|
+This is a new file
|
||||||
*** End Patch`
|
*** End Patch`
|
||||||
|
|
||||||
const result = await Patch.applyPatch(patchText)
|
const result = yield* Patch.applyPatch(patchText)
|
||||||
expect(result.added).toHaveLength(1)
|
expect(result.added).toHaveLength(1)
|
||||||
expect(result.modified).toHaveLength(0)
|
expect(result.modified).toHaveLength(0)
|
||||||
expect(result.deleted).toHaveLength(0)
|
expect(result.deleted).toHaveLength(0)
|
||||||
|
|
||||||
const content = await fs.readFile(result.added[0], "utf-8")
|
const content = yield* Effect.promise(() => fs.readFile(result.added[0], "utf-8"))
|
||||||
expect(content).toBe("Hello World\nThis is a new file")
|
expect(content).toBe("Hello World\nThis is a new file")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("should delete an existing file", async () => {
|
it.live("should delete an existing file", () =>
|
||||||
const filePath = path.join(tempDir, "to-delete.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filePath, "This file will be deleted")
|
const filePath = path.join(tempDir, "to-delete.txt")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(filePath, "This file will be deleted"))
|
||||||
|
|
||||||
const patchText = `*** Begin Patch
|
const patchText = `*** Begin Patch
|
||||||
*** Delete File: ${filePath}
|
*** Delete File: ${filePath}
|
||||||
*** End Patch`
|
*** End Patch`
|
||||||
|
|
||||||
const result = await Patch.applyPatch(patchText)
|
const result = yield* Patch.applyPatch(patchText)
|
||||||
expect(result.deleted).toHaveLength(1)
|
expect(result.deleted).toHaveLength(1)
|
||||||
expect(result.deleted[0]).toBe(filePath)
|
expect(result.deleted[0]).toBe(filePath)
|
||||||
|
|
||||||
const exists = await fs
|
const exists = yield* Effect.promise(() =>
|
||||||
.access(filePath)
|
fs
|
||||||
.then(() => true)
|
.access(filePath)
|
||||||
.catch(() => false)
|
.then(() => true)
|
||||||
expect(exists).toBe(false)
|
.catch(() => false),
|
||||||
})
|
)
|
||||||
|
expect(exists).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("should update an existing file", async () => {
|
it.live("should update an existing file", () =>
|
||||||
const filePath = path.join(tempDir, "to-update.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filePath, "line 1\nline 2\nline 3\n")
|
const filePath = path.join(tempDir, "to-update.txt")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(filePath, "line 1\nline 2\nline 3\n"))
|
||||||
|
|
||||||
const patchText = `*** Begin Patch
|
const patchText = `*** Begin Patch
|
||||||
*** Update File: ${filePath}
|
*** Update File: ${filePath}
|
||||||
@@
|
@@
|
||||||
line 1
|
line 1
|
||||||
@@ -182,20 +194,22 @@ PATCH`
|
|||||||
line 3
|
line 3
|
||||||
*** End Patch`
|
*** End Patch`
|
||||||
|
|
||||||
const result = await Patch.applyPatch(patchText)
|
const result = yield* Patch.applyPatch(patchText)
|
||||||
expect(result.modified).toHaveLength(1)
|
expect(result.modified).toHaveLength(1)
|
||||||
expect(result.modified[0]).toBe(filePath)
|
expect(result.modified[0]).toBe(filePath)
|
||||||
|
|
||||||
const content = await fs.readFile(filePath, "utf-8")
|
const content = yield* Effect.promise(() => fs.readFile(filePath, "utf-8"))
|
||||||
expect(content).toBe("line 1\nline 2 updated\nline 3\n")
|
expect(content).toBe("line 1\nline 2 updated\nline 3\n")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("should move and update a file", async () => {
|
it.live("should move and update a file", () =>
|
||||||
const oldPath = path.join(tempDir, "old-name.txt")
|
Effect.gen(function* () {
|
||||||
const newPath = path.join(tempDir, "new-name.txt")
|
const oldPath = path.join(tempDir, "old-name.txt")
|
||||||
await fs.writeFile(oldPath, "old content\n")
|
const newPath = path.join(tempDir, "new-name.txt")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(oldPath, "old content\n"))
|
||||||
|
|
||||||
const patchText = `*** Begin Patch
|
const patchText = `*** Begin Patch
|
||||||
*** Update File: ${oldPath}
|
*** Update File: ${oldPath}
|
||||||
*** Move to: ${newPath}
|
*** Move to: ${newPath}
|
||||||
@@
|
@@
|
||||||
@@ -203,29 +217,33 @@ PATCH`
|
|||||||
+new content
|
+new content
|
||||||
*** End Patch`
|
*** End Patch`
|
||||||
|
|
||||||
const result = await Patch.applyPatch(patchText)
|
const result = yield* Patch.applyPatch(patchText)
|
||||||
expect(result.modified).toHaveLength(1)
|
expect(result.modified).toHaveLength(1)
|
||||||
expect(result.modified[0]).toBe(newPath)
|
expect(result.modified[0]).toBe(newPath)
|
||||||
|
|
||||||
const oldExists = await fs
|
const oldExists = yield* Effect.promise(() =>
|
||||||
.access(oldPath)
|
fs
|
||||||
.then(() => true)
|
.access(oldPath)
|
||||||
.catch(() => false)
|
.then(() => true)
|
||||||
expect(oldExists).toBe(false)
|
.catch(() => false),
|
||||||
|
)
|
||||||
|
expect(oldExists).toBe(false)
|
||||||
|
|
||||||
const newContent = await fs.readFile(newPath, "utf-8")
|
const newContent = yield* Effect.promise(() => fs.readFile(newPath, "utf-8"))
|
||||||
expect(newContent).toBe("new content\n")
|
expect(newContent).toBe("new content\n")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("should handle multiple operations in one patch", async () => {
|
it.live("should handle multiple operations in one patch", () =>
|
||||||
const file1 = path.join(tempDir, "file1.txt")
|
Effect.gen(function* () {
|
||||||
const file2 = path.join(tempDir, "file2.txt")
|
const file1 = path.join(tempDir, "file1.txt")
|
||||||
const file3 = path.join(tempDir, "file3.txt")
|
const file2 = path.join(tempDir, "file2.txt")
|
||||||
|
const file3 = path.join(tempDir, "file3.txt")
|
||||||
|
|
||||||
await fs.writeFile(file1, "content 1")
|
yield* Effect.promise(() => fs.writeFile(file1, "content 1"))
|
||||||
await fs.writeFile(file2, "content 2")
|
yield* Effect.promise(() => fs.writeFile(file2, "content 2"))
|
||||||
|
|
||||||
const patchText = `*** Begin Patch
|
const patchText = `*** Begin Patch
|
||||||
*** Add File: ${file3}
|
*** Add File: ${file3}
|
||||||
+new file content
|
+new file content
|
||||||
*** Update File: ${file1}
|
*** Update File: ${file1}
|
||||||
@@ -235,98 +253,114 @@ PATCH`
|
|||||||
*** Delete File: ${file2}
|
*** Delete File: ${file2}
|
||||||
*** End Patch`
|
*** End Patch`
|
||||||
|
|
||||||
const result = await Patch.applyPatch(patchText)
|
const result = yield* Patch.applyPatch(patchText)
|
||||||
expect(result.added).toHaveLength(1)
|
expect(result.added).toHaveLength(1)
|
||||||
expect(result.modified).toHaveLength(1)
|
expect(result.modified).toHaveLength(1)
|
||||||
expect(result.deleted).toHaveLength(1)
|
expect(result.deleted).toHaveLength(1)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("should create parent directories when adding files", async () => {
|
it.live("should create parent directories when adding files", () =>
|
||||||
const nestedPath = path.join(tempDir, "deep", "nested", "file.txt")
|
Effect.gen(function* () {
|
||||||
|
const nestedPath = path.join(tempDir, "deep", "nested", "file.txt")
|
||||||
|
|
||||||
const patchText = `*** Begin Patch
|
const patchText = `*** Begin Patch
|
||||||
*** Add File: ${nestedPath}
|
*** Add File: ${nestedPath}
|
||||||
+Deep nested content
|
+Deep nested content
|
||||||
*** End Patch`
|
*** End Patch`
|
||||||
|
|
||||||
const result = await Patch.applyPatch(patchText)
|
const result = yield* Patch.applyPatch(patchText)
|
||||||
expect(result.added).toHaveLength(1)
|
expect(result.added).toHaveLength(1)
|
||||||
expect(result.added[0]).toBe(nestedPath)
|
expect(result.added[0]).toBe(nestedPath)
|
||||||
|
|
||||||
const exists = await fs
|
const exists = yield* Effect.promise(() =>
|
||||||
.access(nestedPath)
|
fs
|
||||||
.then(() => true)
|
.access(nestedPath)
|
||||||
.catch(() => false)
|
.then(() => true)
|
||||||
expect(exists).toBe(true)
|
.catch(() => false),
|
||||||
})
|
)
|
||||||
|
expect(exists).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("error handling", () => {
|
describe("error handling", () => {
|
||||||
test("should throw error when updating non-existent file", async () => {
|
it.live("should fail when updating non-existent file", () =>
|
||||||
const nonExistent = path.join(tempDir, "does-not-exist.txt")
|
Effect.gen(function* () {
|
||||||
|
const nonExistent = path.join(tempDir, "does-not-exist.txt")
|
||||||
|
|
||||||
const patchText = `*** Begin Patch
|
const patchText = `*** Begin Patch
|
||||||
*** Update File: ${nonExistent}
|
*** Update File: ${nonExistent}
|
||||||
@@
|
@@
|
||||||
-old line
|
-old line
|
||||||
+new line
|
+new line
|
||||||
*** End Patch`
|
*** End Patch`
|
||||||
|
|
||||||
await expect(Patch.applyPatch(patchText)).rejects.toThrow()
|
const exit = yield* Effect.exit(Patch.applyPatch(patchText))
|
||||||
})
|
expect(exit._tag).toBe("Failure")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("should throw error when deleting non-existent file", async () => {
|
it.live("should fail when deleting non-existent file", () =>
|
||||||
const nonExistent = path.join(tempDir, "does-not-exist.txt")
|
Effect.gen(function* () {
|
||||||
|
const nonExistent = path.join(tempDir, "does-not-exist.txt")
|
||||||
|
|
||||||
const patchText = `*** Begin Patch
|
const patchText = `*** Begin Patch
|
||||||
*** Delete File: ${nonExistent}
|
*** Delete File: ${nonExistent}
|
||||||
*** End Patch`
|
*** End Patch`
|
||||||
|
|
||||||
await expect(Patch.applyPatch(patchText)).rejects.toThrow()
|
const exit = yield* Effect.exit(Patch.applyPatch(patchText))
|
||||||
})
|
expect(exit._tag).toBe("Failure")
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("edge cases", () => {
|
describe("edge cases", () => {
|
||||||
test("should handle empty files", async () => {
|
it.live("should handle empty files", () =>
|
||||||
const emptyFile = path.join(tempDir, "empty.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(emptyFile, "")
|
const emptyFile = path.join(tempDir, "empty.txt")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(emptyFile, ""))
|
||||||
|
|
||||||
const patchText = `*** Begin Patch
|
const patchText = `*** Begin Patch
|
||||||
*** Update File: ${emptyFile}
|
*** Update File: ${emptyFile}
|
||||||
@@
|
@@
|
||||||
+First line
|
+First line
|
||||||
*** End Patch`
|
*** End Patch`
|
||||||
|
|
||||||
const result = await Patch.applyPatch(patchText)
|
const result = yield* Patch.applyPatch(patchText)
|
||||||
expect(result.modified).toHaveLength(1)
|
expect(result.modified).toHaveLength(1)
|
||||||
|
|
||||||
const content = await fs.readFile(emptyFile, "utf-8")
|
const content = yield* Effect.promise(() => fs.readFile(emptyFile, "utf-8"))
|
||||||
expect(content).toBe("First line\n")
|
expect(content).toBe("First line\n")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("should handle files with no trailing newline", async () => {
|
it.live("should handle files with no trailing newline", () =>
|
||||||
const filePath = path.join(tempDir, "no-newline.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filePath, "no newline")
|
const filePath = path.join(tempDir, "no-newline.txt")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(filePath, "no newline"))
|
||||||
|
|
||||||
const patchText = `*** Begin Patch
|
const patchText = `*** Begin Patch
|
||||||
*** Update File: ${filePath}
|
*** Update File: ${filePath}
|
||||||
@@
|
@@
|
||||||
-no newline
|
-no newline
|
||||||
+has newline now
|
+has newline now
|
||||||
*** End Patch`
|
*** End Patch`
|
||||||
|
|
||||||
const result = await Patch.applyPatch(patchText)
|
const result = yield* Patch.applyPatch(patchText)
|
||||||
expect(result.modified).toHaveLength(1)
|
expect(result.modified).toHaveLength(1)
|
||||||
|
|
||||||
const content = await fs.readFile(filePath, "utf-8")
|
const content = yield* Effect.promise(() => fs.readFile(filePath, "utf-8"))
|
||||||
expect(content).toBe("has newline now\n")
|
expect(content).toBe("has newline now\n")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("should handle multiple update chunks in single file", async () => {
|
it.live("should handle multiple update chunks in single file", () =>
|
||||||
const filePath = path.join(tempDir, "multi-chunk.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filePath, "line 1\nline 2\nline 3\nline 4\n")
|
const filePath = path.join(tempDir, "multi-chunk.txt")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(filePath, "line 1\nline 2\nline 3\nline 4\n"))
|
||||||
|
|
||||||
const patchText = `*** Begin Patch
|
const patchText = `*** Begin Patch
|
||||||
*** Update File: ${filePath}
|
*** Update File: ${filePath}
|
||||||
@@
|
@@
|
||||||
line 1
|
line 1
|
||||||
@@ -338,11 +372,12 @@ PATCH`
|
|||||||
+LINE 4
|
+LINE 4
|
||||||
*** End Patch`
|
*** End Patch`
|
||||||
|
|
||||||
const result = await Patch.applyPatch(patchText)
|
const result = yield* Patch.applyPatch(patchText)
|
||||||
expect(result.modified).toHaveLength(1)
|
expect(result.modified).toHaveLength(1)
|
||||||
|
|
||||||
const content = await fs.readFile(filePath, "utf-8")
|
const content = yield* Effect.promise(() => fs.readFile(filePath, "utf-8"))
|
||||||
expect(content).toBe("line 1\nLINE 2\nline 3\nLINE 4\n")
|
expect(content).toBe("line 1\nLINE 2\nline 3\nLINE 4\n")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user