diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts index 82312b22da6..b5aee9a5263 100644 --- a/packages/core/src/file-mutation.ts +++ b/packages/core/src/file-mutation.ts @@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect" import { dirname } from "path" import { KeyedMutex } from "./effect/keyed-mutex" import { FSUtil } from "@opencode-ai/util/fs-util" +import { Bom } from "@opencode-ai/util/bom" export interface Target { readonly canonical: string @@ -108,13 +109,13 @@ const layer = Layer.effect( const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) => withTargetLock(input.target)( Effect.gen(function* () { - const next = splitBom(input.content) + const next = Bom.split(input.content) const current = yield* fs .readFile(input.target.canonical) .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) yield* fs.writeWithDirs( input.target.canonical, - joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom), + Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom), ) return writeResult(input.target, current !== undefined) }), @@ -172,20 +173,6 @@ const layer = Layer.effect( }), ) -function splitBom(text: string) { - const stripped = text.replace(/^\uFEFF+/, "") - return { bom: stripped.length !== text.length, text: stripped } -} - -function joinBom(text: string, bom: boolean) { - const stripped = splitBom(text).text - return bom ? `\uFEFF${stripped}` : stripped -} - -function hasUtf8Bom(content: Uint8Array) { - return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf -} - function sameBytes(left: Uint8Array, right: Uint8Array) { if (left.length !== right.length) return false return left.every((byte, index) => byte === right[index]) diff --git a/packages/core/src/formatter.ts b/packages/core/src/formatter.ts new file mode 100644 index 00000000000..4eb6a91c5bf --- /dev/null +++ b/packages/core/src/formatter.ts @@ -0,0 +1,157 @@ +export * as Formatter from "./formatter" + +import { Context, Effect, Layer, Schema } from "effect" +import { ChildProcess } from "effect/unstable/process" +import path from "path" +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Npm } from "@opencode-ai/util/npm" +import { AppProcess } from "@opencode-ai/util/process" +import { Config } from "./config" +import { Location } from "./location" +import { make, type Info } from "./formatter/builtins" + +export const Status = Schema.Struct({ + name: Schema.String, + extensions: Schema.Array(Schema.String), + enabled: Schema.Boolean, +}).annotate({ identifier: "FormatterStatus" }) +export type Status = typeof Status.Type + +export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly file: (filepath: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Formatter") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const processes = yield* AppProcess.Service + const commands = new Map() + let formatters: Info[] = [] + + const load = yield* Effect.cached( + Effect.gen(function* () { + const configured = Config.latest(yield* config.entries(), "formatter") + if (!configured) { + yield* Effect.logInfo("all formatters are disabled") + return + } + + const builtIns = make({ + directory: location.directory, + worktree: location.project.directory, + fs, + npm, + processes, + }) + formatters = builtIns + if (configured === true) return + if (configured.ruff?.disabled || configured.uv?.disabled) { + formatters = formatters.filter((formatter) => formatter.name !== "ruff" && formatter.name !== "uv") + } + + for (const [name, entry] of Object.entries(configured)) { + const index = formatters.findIndex((formatter) => formatter.name === name) + if (entry.disabled) { + if (index !== -1) formatters.splice(index, 1) + continue + } + + const builtIn = builtIns.find((formatter) => formatter.name === name) + const formatter: Info = { + name, + extensions: entry.extensions ?? builtIn?.extensions ?? [], + environment: { ...builtIn?.environment, ...entry.environment }, + enabled: + builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false), + } + if (index === -1) formatters.push(formatter) + else formatters[index] = formatter + } + }).pipe(Effect.withSpan("Formatter.load")), + ) + + const command = Effect.fnUntraced(function* (formatter: Info) { + const cached = commands.get(formatter.name) + if (cached !== undefined) return cached + const result = yield* formatter.enabled + if (result !== false) commands.set(formatter.name, result) + return result + }) + + const init = Effect.fn("Formatter.init")(function* () { + yield* load + }) + + const status = Effect.fn("Formatter.status")(function* () { + yield* load + return yield* Effect.forEach(formatters, (formatter) => + command(formatter).pipe( + Effect.map((enabled) => ({ + name: formatter.name, + extensions: [...formatter.extensions], + enabled: enabled !== false, + })), + ), + ) + }) + + const file = Effect.fn("Formatter.file")(function* (filepath: string) { + yield* load + const matching = formatters.filter((formatter) => + formatter.extensions.includes(path.extname(filepath)), + ) + + for (const formatter of matching) { + const enabled = yield* command(formatter) + if (enabled === false) continue + const cmd = enabled.map((argument) => argument.replace("$FILE", filepath)) + yield* Effect.logInfo("formatting file", { file: filepath, command: cmd }) + const result = yield* processes + .run( + ChildProcess.make(cmd[0], cmd.slice(1), { + cwd: location.directory, + env: formatter.environment, + extendEnv: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ) + .pipe( + Effect.catch((error) => + Effect.logError("failed to format file", { + file: filepath, + command: cmd, + error: error.message, + }).pipe(Effect.as(undefined)), + ), + ) + if (!result) continue + if (result.exitCode === 0) return true + yield* Effect.logError("formatter exited unsuccessfully", { + file: filepath, + command: cmd, + exitCode: result.exitCode, + }) + } + return false + }) + + return Service.of({ init, status, file }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node], +}) diff --git a/packages/core/src/formatter/builtins.ts b/packages/core/src/formatter/builtins.ts new file mode 100644 index 00000000000..9c9c51ab142 --- /dev/null +++ b/packages/core/src/formatter/builtins.ts @@ -0,0 +1,315 @@ +import { Effect } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Npm } from "@opencode-ai/util/npm" +import { AppProcess } from "@opencode-ai/util/process" +import { which } from "../util/which" + +export interface Info { + readonly name: string + readonly environment?: Record + readonly extensions: readonly string[] + readonly enabled: Effect.Effect +} + +export function make(input: { + readonly directory: string + readonly worktree: string + readonly fs: FSUtil.Interface + readonly npm: Npm.Interface + readonly processes: AppProcess.Interface + readonly experimentalOxfmt?: boolean +}) { + const disabled = false as const + const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree) + const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => "")) + const commandOutput = (command: string[]) => + input.processes + .run( + ChildProcess.make(command[0], command.slice(1), { + cwd: input.directory, + extendEnv: true, + stdin: "ignore", + }), + ) + .pipe(Effect.option) + + const gofmt: Info = { + name: "gofmt", + extensions: [".go"], + enabled: Effect.sync(() => { + const match = which("gofmt") + return match ? [match, "-w", "$FILE"] : disabled + }), + } + + const mix: Info = { + name: "mix", + extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], + enabled: Effect.sync(() => { + const match = which("mix") + return match ? [match, "format", "$FILE"] : disabled + }), + } + + const prettier: Info = { + name: "prettier", + environment: { BUN_BE_BUN: "1" }, + extensions: [ + ".js", + ".jsx", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".mts", + ".cts", + ".html", + ".htm", + ".css", + ".scss", + ".sass", + ".less", + ".vue", + ".svelte", + ".json", + ".jsonc", + ".yaml", + ".yml", + ".toml", + ".xml", + ".md", + ".mdx", + ".graphql", + ".gql", + ], + enabled: Effect.gen(function* () { + for (const file of yield* findUp("package.json")) { + if (!hasDependency(yield* input.fs.readJson(file), "prettier")) continue + const bin = yield* input.npm.which("prettier") + if (bin) return [bin, "--write", "$FILE"] + } + return disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const oxfmt: Info = { + name: "oxfmt", + environment: { BUN_BE_BUN: "1" }, + extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"], + enabled: Effect.gen(function* () { + for (const file of yield* findUp("package.json")) { + if (!hasDependency(yield* input.fs.readJson(file), "oxfmt")) continue + const bin = yield* input.npm.which("oxfmt") + if (bin) return [bin, "$FILE"] + } + return disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const biome: Info = { + name: "biome", + environment: { BUN_BE_BUN: "1" }, + extensions: [ + ".js", + ".jsx", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".mts", + ".cts", + ".html", + ".htm", + ".css", + ".scss", + ".sass", + ".less", + ".vue", + ".svelte", + ".json", + ".jsonc", + ".yaml", + ".yml", + ".toml", + ".xml", + ".md", + ".mdx", + ".graphql", + ".gql", + ], + enabled: Effect.gen(function* () { + const found = yield* Effect.forEach(["biome.json", "biome.jsonc"], findUp, { concurrency: "unbounded" }) + if (!found.some((items) => items.length > 0)) return disabled + const bin = yield* input.npm.which("@biomejs/biome") + return bin ? [bin, "format", "--write", "$FILE"] : disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const zig: Info = { + name: "zig", + extensions: [".zig", ".zon"], + enabled: Effect.sync(() => { + const match = which("zig") + return match ? [match, "fmt", "$FILE"] : disabled + }), + } + + const clang: Info = { + name: "clang-format", + extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], + enabled: Effect.gen(function* () { + if (!(yield* findUp(".clang-format")).length) return disabled + const match = which("clang-format") + return match ? [match, "-i", "$FILE"] : disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const ktlint: Info = { + name: "ktlint", + extensions: [".kt", ".kts"], + enabled: Effect.sync(() => { + const match = which("ktlint") + return match ? [match, "-F", "$FILE"] : disabled + }), + } + + const ruff: Info = { + name: "ruff", + extensions: [".py", ".pyi"], + enabled: Effect.gen(function* () { + if (!which("ruff")) return disabled + for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) { + const found = yield* findUp(config) + if (!found.length) continue + if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) { + return ["ruff", "format", "$FILE"] + } + } + for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) { + const found = yield* findUp(dependency) + if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"] + } + return disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const air: Info = { + name: "air", + extensions: [".R"], + enabled: Effect.gen(function* () { + const bin = which("air") + if (!bin) return disabled + const output = yield* commandOutput([bin, "--help"]) + if (output._tag === "None" || output.value.exitCode !== 0) return disabled + const first = output.value.stdout.toString("utf8").split("\n")[0] + return first.includes("R language") && first.includes("formatter") ? [bin, "format", "$FILE"] : disabled + }), + } + + const uv: Info = { + name: "uv", + extensions: [".py", ".pyi"], + enabled: Effect.gen(function* () { + const bin = which("uv") + if (!bin) return disabled + const output = yield* commandOutput([bin, "format", "--help"]) + return output._tag === "Some" && output.value.exitCode === 0 + ? [bin, "format", "--", "$FILE"] + : disabled + }), + } + + const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"]) + const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"]) + const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"]) + const dart = executable("dart", [".dart"], ["format", "$FILE"]) + + const ocamlformat: Info = { + name: "ocamlformat", + extensions: [".ml", ".mli"], + enabled: Effect.gen(function* () { + if (!(yield* findUp(".ocamlformat")).length) return disabled + const match = which("ocamlformat") + return match ? [match, "-i", "$FILE"] : disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"]) + const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"]) + const gleam = executable("gleam", [".gleam"], ["format", "$FILE"]) + const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"]) + const nixfmt = executable("nixfmt", [".nix"], ["$FILE"]) + const rustfmt = executable("rustfmt", [".rs"], ["$FILE"]) + + const pint: Info = { + name: "pint", + extensions: [".php"], + enabled: Effect.gen(function* () { + for (const file of yield* findUp("composer.json")) { + const json = yield* input.fs.readJson(file) + if (hasRecordKey(json, "require", "laravel/pint") || hasRecordKey(json, "require-dev", "laravel/pint")) { + return ["./vendor/bin/pint", "$FILE"] + } + } + return disabled + }).pipe(Effect.orElseSucceed(() => disabled)), + } + + const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"]) + const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"]) + const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"]) + + return [ + gofmt, + mix, + oxfmt, + prettier, + biome, + zig, + clang, + ktlint, + ruff, + air, + uv, + rubocop, + standardrb, + htmlbeautifier, + dart, + ocamlformat, + terraform, + latexindent, + gleam, + shfmt, + nixfmt, + rustfmt, + pint, + ormolu, + cljfmt, + dfmt, + ] satisfies Info[] +} + +function executable(name: string, extensions: readonly string[], args: string[]): Info { + return { + name, + extensions, + enabled: Effect.sync(() => { + const match = which(name) + return match ? [match, ...args] : false + }), + } +} + +function hasDependency(input: unknown, dependency: string) { + return hasRecordKey(input, "dependencies", dependency) || hasRecordKey(input, "devDependencies", dependency) +} + +function hasRecordKey(input: unknown, field: string, key: string) { + if (!isRecord(input)) return false + return isRecord(input[field]) && key in input[field] +} + +function isRecord(input: unknown): input is Record { + return Boolean(input && typeof input === "object" && !Array.isArray(input)) +} diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 7a39aa278a1..781dce902cb 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -8,6 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Node } from "@opencode-ai/util/effect/app-node" import { Bus } from "./bus" import { FileMutation } from "./file-mutation" +import { Formatter } from "./formatter" import { FileSystem } from "./filesystem" import { FileSystemSearch } from "./filesystem/search" import { Generate } from "./generate" @@ -73,6 +74,7 @@ const locationServiceNodes = [ InstructionDiscovery.node, LocationMutation.node, FileMutation.node, + Formatter.node, MCP.node, Permission.node, Tool.node, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index b46a6ae7460..3327bef396f 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -16,6 +16,7 @@ import { ConfigSkillPlugin } from "../config/plugin/skill" import { ConfigWebSearchPlugin } from "../config/plugin/websearch" import { Bus } from "../bus" import { FileMutation } from "../file-mutation" +import { Formatter } from "../formatter" import { Form } from "../form" import { FileSystem } from "../filesystem" import { FSUtil } from "@opencode-ai/util/fs-util" @@ -68,6 +69,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const config = yield* Config.Service const bus = yield* Bus.Service const mutation = yield* FileMutation.Service + const formatter = yield* Formatter.Service const filesystem = yield* FileSystem.Service const fs = yield* FSUtil.Service const global = yield* Global.Service @@ -98,6 +100,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(Config.Service, config), Context.make(Bus.Service, bus), Context.make(FileMutation.Service, mutation), + Context.make(Formatter.Service, formatter), Context.make(FileSystem.Service, filesystem), Context.make(FSUtil.Service, fs), Context.make(Global.Service, global), diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index bcca1f53fac..9472637343c 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -14,6 +14,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { httpClient } from "@opencode-ai/util/effect/app-node-platform" import { Bus } from "../bus" import { FileMutation } from "../file-mutation" +import { Formatter } from "../formatter" import { FileSystem } from "../filesystem" import { Watcher } from "../filesystem/watcher" import { Form } from "../form" @@ -318,6 +319,7 @@ export const node = makeLocationNode({ Config.node, Bus.node, FileMutation.node, + Formatter.node, FileSystem.node, FSUtil.node, Global.node, diff --git a/packages/core/src/tool/plugin/edit.ts b/packages/core/src/tool/plugin/edit.ts index 57aacecb606..2babc4be480 100644 --- a/packages/core/src/tool/plugin/edit.ts +++ b/packages/core/src/tool/plugin/edit.ts @@ -9,9 +9,11 @@ export * as EditTool from "./edit" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import { ToolFailure } from "@opencode-ai/ai" import { FileDiff } from "@opencode-ai/schema/file-diff" +import { Bom } from "@opencode-ai/util/bom" import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Schema } from "effect" import { FileMutation } from "../../file-mutation" +import { Formatter } from "../../formatter" import { FSUtil } from "@opencode-ai/util/fs-util" import { LocationMutation } from "../../location-mutation" import { Permission } from "../../permission" @@ -99,7 +101,6 @@ const findLineOccurrences = (content: string, search: string) => { } /** Deferred edit behavior and UX integrations remain visible at the model-facing seam. */ -// TODO: Add formatter integration after formatter runtime exists. // TODO: Publish watcher/file-edit events after watcher integration exists. // TODO: Add snapshots / undo after design exists. // TODO: Add LSP notification and diagnostics after LSP runtime exists. @@ -109,6 +110,7 @@ export const Plugin = { effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service + const formatter = yield* Formatter.Service const fs = yield* FSUtil.Service const permission = yield* Permission.Service @@ -167,9 +169,8 @@ export const Plugin = { if (info.type === "Directory") { return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }) } - const bytes = yield* fs.readFile(target.canonical) - const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf - const source = new TextDecoder().decode(bom ? bytes.slice(3) : bytes) + const original = yield* Bom.readFile(fs, target.canonical) + const source = original.text const ending = source.includes(crlf) ? crlf : "\n" const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending) const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending) @@ -201,23 +202,27 @@ export const Plugin = { `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`, source, ) - const counts = diffLines(source, replaced).reduce( + const replacementBom = replaced.startsWith("\uFEFF") + const result = yield* files.write({ + target, + content: Bom.join(replaced, original.bom || replacementBom), + }) + const bom = original.bom || replacementBom + const formatted = (yield* formatter.file(target.canonical)) + ? yield* Bom.syncFile(fs, target.canonical, bom) + : (yield* Bom.readFile(fs, target.canonical)).text + const counts = diffLines(source, formatted).reduce( (result, item) => ({ additions: result.additions + (item.added ? (item.count ?? 0) : 0), deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), }), { additions: 0, deletions: 0 }, ) - const replacementBom = replaced.startsWith("\uFEFF") - const result = yield* files.write({ - target, - content: `${bom || replacementBom ? "\uFEFF" : ""}${replacementBom ? replaced.slice(1) : replaced}`, - }) return { files: [ { file: result.resource, - patch: createTwoFilesPatch(result.resource, result.resource, source, replaced), + patch: createTwoFilesPatch(result.resource, result.resource, source, formatted), status: "modified" as const, ...counts, }, diff --git a/packages/core/src/tool/plugin/patch.ts b/packages/core/src/tool/plugin/patch.ts index ea29a4829c9..0433b47e9b8 100644 --- a/packages/core/src/tool/plugin/patch.ts +++ b/packages/core/src/tool/plugin/patch.ts @@ -7,7 +7,9 @@ import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Schema } from "effect" import { PlatformError } from "effect/PlatformError" import path from "path" +import { Bom } from "@opencode-ai/util/bom" import { FSUtil } from "@opencode-ai/util/fs-util" +import { Formatter } from "../../formatter" import { Location } from "../../location" import { Patch } from "@opencode-ai/util/patch" import { Permission } from "../../permission" @@ -68,6 +70,7 @@ export const Plugin = { id: "opencode.tool.patch", effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) { const fs = yield* FSUtil.Service + const formatter = yield* Formatter.Service const location = yield* Location.Service const permission = yield* Permission.Service @@ -129,15 +132,16 @@ export const Plugin = { ...hunk, target, before: "", - after: (hunk.contents.endsWith("\n") || hunk.contents === "" - ? hunk.contents - : `${hunk.contents}\n` - ).replace(/^\uFEFF/, ""), + after: Bom.split( + hunk.contents.endsWith("\n") || hunk.contents === "" + ? hunk.contents + : `${hunk.contents}\n`, + ).text, }) return } if (hunk.type === "delete") { - const content = yield* fs.readFile(target.canonical).pipe( + const content = yield* Bom.readFile(fs, target.canonical).pipe( Effect.mapError( (error) => new ToolFailure({ @@ -145,8 +149,7 @@ export const Plugin = { }), ), ) - const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content) - prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" }) + prepared.push({ ...hunk, target, before: content.text, after: "" }) return } const previous = updates.get(target.canonical) @@ -166,18 +169,17 @@ export const Plugin = { message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`, }) } - return new TextDecoder("utf-8", { ignoreBOM: true }).decode( - yield* fs.readFile(target.canonical).pipe( - Effect.mapError( - (error) => - new ToolFailure({ - message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`, - }), - ), + const content = yield* Bom.readFile(fs, target.canonical).pipe( + Effect.mapError( + (error) => + new ToolFailure({ + message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`, + }), ), ) + return Bom.join(content.text, content.bom) })) - const before = original.replace(/^\uFEFF/, "") + const before = Bom.split(original).text const update = yield* Effect.try({ try: () => Patch.derive(hunk.path, hunk.chunks, original), catch: (error) => @@ -217,7 +219,7 @@ export const Plugin = { ) } - const patchFiles = prepared.map(patchFile) + const patchFiles = prepared.map((change) => patchFile(change)) yield* permission.assert({ action: "edit", resources: [...new Set(targets.map((target) => target.resource))], @@ -295,7 +297,31 @@ export const Plugin = { }), { discard: true }, ) - return { applied, files: patchFiles } + const formatted = new Map() + yield* Effect.forEach( + [...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))], + (target) => + Effect.gen(function* () { + const current = yield* Bom.readFile(fs, target).pipe( + Effect.mapError((error) => fail(`Failed to read ${target}`, error)), + ) + formatted.set( + target, + (yield* formatter.file(target)) + ? yield* Bom.syncFile(fs, target, current.bom).pipe( + Effect.mapError((error) => fail(`Failed to sync ${target}`, error)), + ) + : current.text, + ) + }), + { discard: true }, + ) + const files = yield* Effect.forEach(prepared, (change) => { + if (change.type === "delete") return Effect.succeed(patchFile(change)) + const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target + return Effect.succeed(patchFile(change, formatted.get(target.canonical))) + }) + return { applied, files } }).pipe( Effect.map((output) => ({ output, @@ -337,15 +363,15 @@ function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error) } -function patchFile(change: Prepared): typeof FileDiff.Info.Type { +function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type { const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource const patch = trimDiff( - createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, change.after), + createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after), ) const counts = change.type === "delete" ? { additions: 0, deletions: change.before.split("\n").length } - : diffLines(change.before, change.after).reduce( + : diffLines(change.before, after).reduce( (result, item) => ({ additions: result.additions + (item.added ? (item.count ?? 0) : 0), deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), diff --git a/packages/core/src/tool/plugin/write.ts b/packages/core/src/tool/plugin/write.ts index d5e9411a527..ea8594813a7 100644 --- a/packages/core/src/tool/plugin/write.ts +++ b/packages/core/src/tool/plugin/write.ts @@ -9,7 +9,10 @@ export * as WriteTool from "./write" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema } from "effect" +import { Bom } from "@opencode-ai/util/bom" +import { FSUtil } from "@opencode-ai/util/fs-util" import { FileMutation } from "../../file-mutation" +import { Formatter } from "../../formatter" import { LocationMutation } from "../../location-mutation" import { Permission } from "../../permission" @@ -36,7 +39,6 @@ export const toModelOutput = (output: Output) => `${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}` /** Deferred write UX integrations remain visible at the model-facing seam. */ -// TODO: Add formatter integration after formatter runtime exists. // TODO: Publish watcher/file-edit events after watcher integration exists. // TODO: Add snapshots / undo after design exists. // TODO: Add LSP notification and diagnostics after LSP runtime exists. @@ -46,6 +48,8 @@ export const Plugin = { effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service + const formatter = yield* Formatter.Service + const fs = yield* FSUtil.Service const permission = yield* Permission.Service yield* ctx.tool @@ -82,7 +86,10 @@ export const Plugin = { agent: context.agent, source, }) - return yield* files.writeTextPreservingBom({ target, content: input.content }) + const result = yield* files.writeTextPreservingBom({ target, content: input.content }) + const bom = (yield* Bom.readFile(fs, target.canonical)).bom + if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom) + return result }).pipe( Effect.map((output) => ({ output, content: toModelOutput(output) })), Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })), diff --git a/packages/core/test/formatter.test.ts b/packages/core/test/formatter.test.ts new file mode 100644 index 00000000000..fbacfb971bd --- /dev/null +++ b/packages/core/test/formatter.test.ts @@ -0,0 +1,199 @@ +import fs from "fs/promises" +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Layer, Schema, Stream } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Npm } from "@opencode-ai/util/npm" +import { Config } from "../src/config" +import { Formatter } from "../src/formatter" +import { Location } from "../src/location" +import { location } from "./fixture/location" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect(Layer.empty) +type ConfigInput = typeof Config.Info.Encoded + +function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) { + const entries = + configured === undefined + ? [] + : [ + new Config.Document({ + type: "document", + info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }), + }), + ] + return AppNodeBuilder.build(Formatter.node, [ + [ + Config.node, + Layer.succeed( + Config.Service, + Config.Service.of({ + entries: () => Effect.succeed(entries), + changes: () => Stream.empty, + }), + ), + ], + [ + Location.node, + Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(directory) })), + ), + ], + [Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })], + ]) +} + +function withTemp(body: (directory: string) => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => body(tmp.path), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) +} + +describe("Formatter", () => { + it.live("status() returns empty list when no formatters are configured", () => + withTemp((directory) => + Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))), + ), + ) + + it.live("status() returns built-in formatters when formatter is true", () => + withTemp((directory) => + Formatter.Service.use((formatter) => + Effect.gen(function* () { + const statuses = yield* formatter.status() + const gofmt = statuses.find((item) => item.name === "gofmt") + expect(gofmt).toBeDefined() + expect(gofmt?.extensions).toContain(".go") + }), + ).pipe(Effect.provide(formatterLayer(directory, true))), + ), + ) + + it.live("status() keeps built-in formatters when config object is provided", () => + withTemp((directory) => + Formatter.Service.use((formatter) => + Effect.gen(function* () { + const statuses = yield* formatter.status() + expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go") + expect(statuses.find((item) => item.name === "mix")).toBeDefined() + }), + ).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))), + ), + ) + + it.live("status() excludes formatters marked as disabled in config", () => + withTemp((directory) => + Formatter.Service.use((formatter) => + Effect.gen(function* () { + const statuses = yield* formatter.status() + expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined() + expect(statuses.find((item) => item.name === "mix")).toBeDefined() + }), + ).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))), + ), + ) + + it.live("service initializes without error", () => + withTemp((directory) => + Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))), + ), + ) + + it.live("file() returns false when no formatter runs", () => + withTemp((directory) => + Effect.gen(function* () { + const file = path.join(directory, "test.txt") + yield* Effect.promise(() => fs.writeFile(file, "x")) + expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false) + }).pipe(Effect.provide(formatterLayer(directory, false))), + ), + ) + + it.live("status() initializes formatter state per directory", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([off, on]) => + Effect.gen(function* () { + const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe( + Effect.provide(formatterLayer(off.path, false)), + ) + const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe( + Effect.provide(formatterLayer(on.path, true)), + ) + expect(disabled).toEqual([]) + expect(enabled.find((item) => item.name === "gofmt")).toBeDefined() + }), + (directories) => + Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)), + ), + ) + + it.live("stops after the first matching formatter succeeds", () => + withTemp((directory) => + Effect.gen(function* () { + const file = path.join(directory, "test.seq") + yield* Effect.promise(() => fs.writeFile(file, "x")) + expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA") + }).pipe( + Effect.provide( + formatterLayer(directory, { + first: { + command: [ + process.execPath, + "-e", + "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')", + "$FILE", + ], + extensions: [".seq"], + }, + second: { + command: [ + process.execPath, + "-e", + "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')", + "$FILE", + ], + extensions: [".seq"], + }, + }), + ), + ), + ), + ) + + it.live("tries the next matching formatter when the first fails", () => + withTemp((directory) => + Effect.gen(function* () { + const file = path.join(directory, "test.fallback") + yield* Effect.promise(() => fs.writeFile(file, "x")) + expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB") + }).pipe( + Effect.provide( + formatterLayer(directory, { + first: { + command: [process.execPath, "-e", "process.exit(1)", "$FILE"], + extensions: [".fallback"], + }, + second: { + command: [ + process.execPath, + "-e", + "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')", + "$FILE", + ], + extensions: [".fallback"], + }, + }), + ), + ), + ), + ) +}) diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 6e4f11a1d99..3890497ebdc 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -5,6 +5,7 @@ import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { FileMutation } from "@opencode-ai/core/file-mutation" +import { Formatter } from "@opencode-ai/core/formatter" import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" @@ -22,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from " const editToolNode = makeLocationNode({ name: "test/edit-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)), - deps: [Tool.node, LocationMutation.node, FileMutation.node, FSUtil.node, Permission.node], + deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node], }) const sessionID = Session.ID.make("ses_edit_tool_test") @@ -31,6 +32,7 @@ const writes: string[] = [] let reads = 0 let denyAction: string | undefined let afterRead = (_target: string, _content: Uint8Array): Effect.Effect => Effect.void +let formatFile = (_target: string): Effect.Effect => Effect.succeed(false) const permission = Layer.succeed( Permission.Service, @@ -57,12 +59,17 @@ const permission = Layer.succeed( }), ) +const formatter = Layer.mock(Formatter.Service, { + file: (target) => formatFile(target), +}) + const reset = () => { assertions.length = 0 writes.length = 0 reads = 0 denyAction = undefined afterRead = () => Effect.void + formatFile = () => Effect.succeed(false) } const filesystem = Layer.effect( @@ -109,6 +116,7 @@ const withTool = (directory: string, body: (registry: Tool.Interface) = [ [FSUtil.node, filesystem], [Location.node, activeLocation], + [Formatter.node, formatter], [Permission.node, permission], ], ), @@ -181,6 +189,39 @@ describe("EditTool", () => { ), ) + it.live("returns the diff for final formatted content", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const target = path.join(tmp.path, "formatted.txt") + formatFile = (file) => + Effect.promise(async () => { + await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("after", "AFTER")) + return true + }) + return Effect.promise(() => fs.writeFile(target, "before\n")).pipe( + Effect.andThen( + withTool(tmp.path, (registry) => + Effect.gen(function* () { + const settled = yield* executeTool( + registry, + call({ path: "formatted.txt", oldString: "before", newString: "after" }), + ) + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.output.files[0]?.patch).toContain("-before\n+AFTER") + expect(settled.metadata?.files?.[0]?.patch).toContain("-before\n+AFTER") + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("AFTER\n") + }), + ), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.live("accepts an absolute file path inside the active Location", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -574,6 +615,11 @@ describe("EditTool", () => { (tmp) => { reset() const target = path.join(tmp.path, "windows.txt") + formatFile = (file) => + Effect.promise(async () => { + await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, "")) + return true + }) return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe( Effect.andThen( withTool(tmp.path, (registry) => diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 471bf9e795c..4a1a2e095f8 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -6,6 +6,7 @@ import { systemError } from "effect/PlatformError" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { FSUtil } from "@opencode-ai/util/fs-util" +import { Formatter } from "@opencode-ai/core/formatter" import { Location } from "@opencode-ai/core/location" import { Permission } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -21,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from " const patchToolNode = makeLocationNode({ name: "test/patch-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), - deps: [Tool.node, FSUtil.node, Location.node, Permission.node], + deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node], }) const sessionID = Session.ID.make("ses_patch_tool_test") @@ -33,6 +34,7 @@ let failWriteTarget: string | undefined let readsBeforeEditApproval = 0 let editApproved = false let afterEditApproval = (): Effect.Effect => Effect.void +let formatFile = (_target: string): Effect.Effect => Effect.succeed(false) const permission = Layer.succeed( Permission.Service, @@ -63,6 +65,10 @@ const permission = Layer.succeed( }), ) +const formatter = Layer.mock(Formatter.Service, { + file: (target) => formatFile(target), +}) + const reset = () => { assertions.length = 0 denyAction = undefined @@ -72,6 +78,7 @@ const reset = () => { readsBeforeEditApproval = 0 editApproved = false afterEditApproval = () => Effect.void + formatFile = () => Effect.succeed(false) } const filesystem = Layer.effect( @@ -135,6 +142,7 @@ const withTool = ( AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [ [FSUtil.node, filesystem], [Location.node, activeLocation], + [Formatter.node, formatter], [Permission.node, permission], ]), ), @@ -254,6 +262,28 @@ describe("PatchTool", () => { ), ) + it.live("returns file diffs for final formatted content", () => + withTempTool((directory, registry) => { + const target = path.join(directory, "formatted.txt") + formatFile = (file) => + Effect.promise(async () => { + await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("created", "FORMATTED")) + return true + }) + return Effect.gen(function* () { + const settled = yield* executeTool( + registry, + call("*** Begin Patch\n*** Add File: formatted.txt\n+created\n*** End Patch"), + ) + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.output.files[0]?.patch).toContain("+FORMATTED") + expect(settled.metadata?.files?.[0]?.patch).toContain("+FORMATTED") + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMATTED\n") + }) + }), + ) + it.live("moves and updates a file", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -552,6 +582,11 @@ describe("PatchTool", () => { const bom = "\uFEFF" const target = path.join(directory, "example.cs") yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`)) + formatFile = (file) => + Effect.promise(async () => { + await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, "")) + return true + }) const settled = yield* executeTool( registry, call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"), diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index cd2781189f3..0c7dd951e84 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -3,6 +3,7 @@ import path from "path" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { FileMutation } from "@opencode-ai/core/file-mutation" +import { Formatter } from "@opencode-ai/core/formatter" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { FSUtil } from "@opencode-ai/util/fs-util" @@ -22,12 +23,13 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from " const writeToolNode = makeLocationNode({ name: "test/write-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)), - deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node], + deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node], }) const sessionID = Session.ID.make("ses_write_tool_test") const assertions: Permission.AssertInput[] = [] const writes: string[] = [] +let formatFile = (_target: string): Effect.Effect => Effect.succeed(false) let denyAction: string | undefined const permission = Layer.succeed( @@ -55,9 +57,14 @@ const permission = Layer.succeed( }), ) +const formatter = Layer.mock(Formatter.Service, { + file: (target) => formatFile(target), +}) + const reset = () => { assertions.length = 0 writes.length = 0 + formatFile = () => Effect.succeed(false) denyAction = undefined } @@ -93,6 +100,7 @@ const withTool = (directory: string, body: (registry: Tool.Interface) = [ [FSUtil.node, filesystem], [Location.node, activeLocation], + [Formatter.node, formatter], [Permission.node, permission], ], ), @@ -140,6 +148,30 @@ describe("WriteTool", () => { ), ) + it.live("formats the committed file", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const target = path.join(tmp.path, "formatted.txt") + formatFile = (file) => + Effect.promise(async () => { + await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase()) + return true + }) + return withTool(tmp.path, (registry) => + Effect.gen(function* () { + expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({ + status: "completed", + }) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME") + }), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.live("overwrites a relative existing file and reports that it wrote the file", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -174,6 +206,11 @@ describe("WriteTool", () => { reset() const preserved = path.join(tmp.path, "preserved.txt") const deduplicated = path.join(tmp.path, "deduplicated.txt") + formatFile = (target) => + Effect.promise(async () => { + await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`) + return true + }) return Effect.promise(() => Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]), ).pipe( diff --git a/packages/util/src/bom.ts b/packages/util/src/bom.ts new file mode 100644 index 00000000000..776ca118a90 --- /dev/null +++ b/packages/util/src/bom.ts @@ -0,0 +1,38 @@ +export * as Bom from "./bom.js" + +import { Effect } from "effect" +import { FSUtil } from "./fs-util.js" + +const code = 0xfeff +const value = String.fromCharCode(code) + +export function split(text: string) { + const stripped = text.replace(/^\uFEFF+/, "") + return { bom: stripped.length !== text.length, text: stripped } +} + +export function join(text: string, bom: boolean) { + const stripped = split(text).text + return bom ? value + stripped : stripped +} + +export function has(content: Uint8Array) { + return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf +} + +export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filepath: string) { + return split(decode(yield* fs.readFile(filepath))) +}) + +export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filepath: string, bom: boolean) { + const decoded = decode(yield* fs.readFile(filepath)) + const current = split(decoded) + const canonical = join(current.text, bom) + if (decoded === canonical) return current.text + yield* fs.writeWithDirs(filepath, canonical) + return current.text +}) + +function decode(content: Uint8Array) { + return new TextDecoder("utf-8", { ignoreBOM: true }).decode(content) +} diff --git a/packages/util/src/patch.ts b/packages/util/src/patch.ts index 54766c5a61e..11aadd52bb1 100644 --- a/packages/util/src/patch.ts +++ b/packages/util/src/patch.ts @@ -1,6 +1,7 @@ export * as Patch from "./patch.js" import { Result, Schema } from "effect" +import { Bom } from "./bom.js" export class BoundaryError extends Schema.TaggedErrorClass()("Patch.BoundaryError", { boundary: Schema.Literals(["first", "last"]), @@ -125,20 +126,19 @@ export function parse(patchText: string): Result.Result, Par } export function derive(path: string, chunks: ReadonlyArray, original: string): FileUpdate { - const source = splitBom(original) + const source = Bom.split(original) const lines = source.text.split("\n") if (lines.at(-1) === "") lines.pop() const replacements = computeReplacements(lines, path, chunks) const updated = [...lines] for (const [start, remove, insert] of replacements.toReversed()) updated.splice(start, remove, ...insert) if (updated.at(-1) !== "") updated.push("") - const next = splitBom(updated.join("\n")) + const next = Bom.split(updated.join("\n")) return { content: next.text, bom: source.bom || next.bom } } export function joinBom(text: string, bom: boolean) { - const stripped = splitBom(text).text - return bom ? `\uFEFF${stripped}` : stripped + return Bom.join(text, bom) } function parseAdd( @@ -379,6 +379,4 @@ const normalize = (value: string) => .replace(/[“”„‟]/g, '"') .replace(/[‐‑‒–—―−]/g, "-") .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ") -const splitBom = (text: string) => - text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text } const stripHeredoc = (input: string) => input.match(/^(?:cat\s+)?<<(['"]?)(\w+)\1\s*\n([\s\S]*?)\n\2\s*$/)?.[3] ?? input