diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index b7daddc5fb8e..d6e4b087e2bf 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -327,6 +327,16 @@ export namespace File { "file.edited", z.object({ file: z.string(), + ranges: z + .array( + z.object({ + start: z.number(), + end: z.number(), + byteOffset: z.number().optional(), + byteLength: z.number().optional(), + }), + ) + .optional(), }), ), } diff --git a/packages/opencode/src/format/diff-range.ts b/packages/opencode/src/format/diff-range.ts new file mode 100644 index 000000000000..c252a5fd8b5d --- /dev/null +++ b/packages/opencode/src/format/diff-range.ts @@ -0,0 +1,112 @@ +import { diffLines } from "diff" + +const ADJACENT_THRESHOLD = 6 + +export type DiffRange = { + start: number + end: number + byteStart?: number + byteEnd?: number +} + +export const DiffRange = { + create(char: number, charLen: number, byte: number, byteLen: number): DiffRange { + return { start: char, end: char + charLen, byteStart: byte, byteEnd: byte + byteLen } + }, + + from(data: { start: number; end: number; byteOffset?: number; byteLength?: number }): DiffRange { + const range: DiffRange = { start: data.start, end: data.end } + if (data.byteOffset != null && data.byteLength != null) { + range.byteStart = data.byteOffset + range.byteEnd = data.byteOffset + data.byteLength + } + return range + }, + + toJSON(r: DiffRange) { + return { + start: r.start, + end: r.end, + byteOffset: r.byteStart, + byteLength: r.byteEnd != null && r.byteStart != null ? r.byteEnd - r.byteStart : undefined, + } + }, + + merge(a: DiffRange, b: DiffRange): DiffRange { + const merged: DiffRange = { + start: Math.min(a.start, b.start), + end: Math.max(a.end, b.end), + } + if (a.byteStart != null && a.byteEnd != null && b.byteStart != null && b.byteEnd != null) { + merged.byteStart = Math.min(a.byteStart, b.byteStart) + merged.byteEnd = Math.max(a.byteEnd, b.byteEnd) + } + return merged + }, + + adjacent(a: DiffRange, b: DiffRange): boolean { + return b.start - a.end <= ADJACENT_THRESHOLD + }, +} + +function buildMapping(content: string): { map: number[]; bytes: number[] } { + const map: number[] = [] + const bytes: number[] = [] + let charOffset = 0 + let byteOffset = 0 + const chars = Array.from(content) + const encoder = new TextEncoder() + + for (let i = 0; i < chars.length; i++) { + const char = chars[i]! + map.push(charOffset) + bytes.push(byteOffset) + if (char === "\r" && chars[i + 1] === "\n") { + byteOffset += 2 + charOffset += 2 + i++ + } else { + byteOffset += encoder.encode(char).length + charOffset++ + } + } + map.push(charOffset) + bytes.push(byteOffset) + + return { map, bytes } +} + +export function calculateRanges(oldContent: string, newContent: string): DiffRange[] { + const { map, bytes } = buildMapping(newContent) + const normalizedOld = oldContent.replace(/\r\n/g, "\n") + const normalizedNew = newContent.replace(/\r\n/g, "\n") + const changes = diffLines(normalizedOld, normalizedNew) + const result: DiffRange[] = [] + let offset = 0 + + for (const change of changes) { + if (change.added) { + const start = map[offset] ?? newContent.length + const endIdx = offset + change.value.length + const end = endIdx < map.length ? map[endIdx]! : newContent.length + result.push(DiffRange.create(start, end - start, bytes[offset]!, bytes[endIdx]! - bytes[offset]!)) + offset += change.value.length + } else if (change.removed) { + const start = map[offset] ?? newContent.length + result.push(DiffRange.create(start, 0, bytes[offset] ?? bytes[bytes.length - 1] ?? 0, 0)) + } else { + offset += change.value.length + } + } + + return merge(result) +} + +function merge(ranges: DiffRange[]): DiffRange[] { + if (!ranges.length) return ranges + return ranges.reduce((acc, r) => { + const last = acc[acc.length - 1] + last && DiffRange.adjacent(last, r) ? (acc[acc.length - 1] = DiffRange.merge(last, r)) : acc.push(r) + return acc + }, [] as DiffRange[]) +} diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 47b2d6a12d21..0316acd15421 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -3,6 +3,7 @@ import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Filesystem } from "../util/filesystem" import { Flag } from "@/flag/flag" +import { DiffRange } from "./diff-range" export interface Info { name: string @@ -10,6 +11,7 @@ export interface Info { environment?: Record extensions: string[] enabled(): Promise + buildRangeCommand?(file: string, ranges: DiffRange[]): string[] } export const gofmt: Info = { @@ -76,6 +78,11 @@ export const prettier: Info = { } return false }, + buildRangeCommand(file: string, ranges: DiffRange[]) { + if (!ranges.length) return [BunProc.which(), "x", "prettier", "--write", file] + const m = ranges.reduce((a, b) => DiffRange.merge(a, b)) + return [BunProc.which(), "x", "prettier", "--write", `--range-start=${m.start}`, `--range-end=${m.end}`, file] + }, } export const oxfmt: Info = { @@ -163,6 +170,19 @@ export const clang: Info = { const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree) return items.length > 0 }, + buildRangeCommand(file: string, ranges: DiffRange[]) { + const bytes = ranges + .map((r) => (r.byteStart != null && r.byteEnd != null ? { start: r.byteStart, end: r.byteEnd } : undefined)) + .filter((b): b is { start: number; end: number } => b !== undefined) + if (bytes.length !== ranges.length) return ["clang-format", "-i", file] + const cmd = ["clang-format", "-i"] + for (const b of bytes) { + cmd.push(`--offset=${b.start}`) + cmd.push(`--length=${b.end - b.start}`) + } + cmd.push(file) + return cmd + }, } export const ktlint: Info = { diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index bab758030b9f..d8667a68503d 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -8,6 +8,7 @@ import * as Formatter from "./formatter" import { Config } from "../config/config" import { mergeDeep } from "remeda" import { Instance } from "../project/instance" +import { DiffRange } from "./diff-range" export namespace Format { const log = Log.create({ service: "format" }) @@ -104,32 +105,29 @@ export namespace Format { log.info("init") Bus.subscribe(File.Event.Edited, async (payload) => { const file = payload.properties.file - log.info("formatting", { file }) + const ranges = payload.properties.ranges + log.info("formatting", { file, ranges }) const ext = path.extname(file) for (const item of await getFormatter(ext)) { log.info("running", { command: item.command }) + const data = ranges?.map(DiffRange.from) + const cmd = + item.buildRangeCommand && data?.length + ? item.buildRangeCommand(file, data) + : item.command.map((c) => c.replace("$FILE", file)) try { const proc = Bun.spawn({ - cmd: item.command.map((x) => x.replace("$FILE", file)), + cmd, 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, - }) + if (exit !== 0) log.error("failed", { command: cmd, ...item.environment }) } catch (error) { - log.error("failed to format file", { - error, - command: item.command, - ...item.environment, - file, - }) + log.error("failed to format file", { error, command: cmd, ...item.environment, file }) } } }) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 7a097d3fe19d..2adef9abc8cc 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -16,6 +16,7 @@ import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" +import { calculateRanges, DiffRange } from "../format/diff-range" import { assertExternalDirectory } from "./external-directory" const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -61,9 +62,11 @@ export const EditTool = Tool.define("edit", { diff, }, }) + const ranges = calculateRanges(contentOld, params.newString) await Filesystem.write(filePath, params.newString) await Bus.publish(File.Event.Edited, { file: filePath, + ranges: ranges.map(DiffRange.toJSON), }) await Bus.publish(FileWatcher.Event.Updated, { file: filePath, @@ -93,9 +96,11 @@ export const EditTool = Tool.define("edit", { }, }) + const ranges = calculateRanges(contentOld, contentNew) await Filesystem.write(filePath, contentNew) await Bus.publish(File.Event.Edited, { file: filePath, + ranges: ranges.map(DiffRange.toJSON), }) await Bus.publish(FileWatcher.Event.Updated, { file: filePath, diff --git a/packages/opencode/test/diff-range.test.ts b/packages/opencode/test/diff-range.test.ts new file mode 100644 index 000000000000..2d81256ffd79 --- /dev/null +++ b/packages/opencode/test/diff-range.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test" +import { calculateRanges, DiffRange } from "../src/format/diff-range" + +const expectRange = (old: string, next: string, start: number, end: number, byteStart?: number, byteEnd?: number) => { + const ranges = calculateRanges(old, next) + expect(ranges.length).toBe(1) + expect(ranges[0]!.start).toBe(start) + expect(ranges[0]!.end).toBe(end) + if (byteStart != null) expect(ranges[0]!.byteStart).toBe(byteStart) + if (byteEnd != null) expect(ranges[0]!.byteEnd).toBe(byteEnd) +} + +describe("calculateRanges", () => { + test("added lines", () => expectRange("line1\nline2\nline3", "line1\nline2\nnewline\nline3", 12, 20, 12, 20)) + + test("multiple added lines", () => + expectRange("line1\nline2\nline3", "line1\nline2\nnewline1\nnewline2\nnewline3\nline3", 12, 39, 12, 39)) + + test("removed lines", () => expectRange("line1\nline2\nline3\nline4", "line1\nline2\nline4", 12, 12, 12, 12)) + + test("removed lines at end", () => expectRange("line1\nline2\nline3", "line1\nline2", 6, 11, 6, 11)) + + test("merges adjacent ranges", () => + expectRange("line1\nline2\nline3\nline4\nline5", "line1\nnew2\nline3\nnew4\nline5", 6, 22, 6, 22)) + + test("keeps separate ranges", () => { + const ranges = calculateRanges("line1\nline2\nline3\nline4\nline5\nline6", "line1\nnew2\nline3\nline4\nline5\nnew6") + expect(ranges.length).toBe(2) + expect(ranges[0]!.start).toBe(6) + expect(ranges[0]!.end).toBe(11) + expect(ranges[1]!.start).toBe(29) + expect(ranges[1]!.end).toBe(33) + }) + + test("empty old content", () => expectRange("", "line1\nline2\nline3", 0, 17, 0, 17)) + + test("complex edit", () => + expectRange("line1\nline2\nline3\nline4\nline5", "line1\nnewA\nnewB\nline4\nline5", 6, 16, 6, 16)) + + test("adding at beginning", () => expectRange("line2\nline3", "line1\nline2\nline3", 0, 6, 0, 6)) + + test("adding at end", () => expectRange("line1\nline2\n", "line1\nline2\nline3\n", 12, 18, 12, 18)) + + test("identical content returns empty", () => { + const content = "line1\nline2\nline3" + expect(calculateRanges(content, content)).toEqual([]) + }) + + test("ignores line ending differences", () => { + expect(calculateRanges("line1\r\nline2\r\nline3", "line1\nline2\nline3")).toEqual([]) + }) + + test("unicode accuracy", () => { + const ranges = calculateRanges("hello\nworld", "hello\n世界") + expect(ranges.length).toBe(1) + expect(ranges[0]!.start).toBe(6) + expect(ranges[0]!.end).toBe(8) + expect(ranges[0]!.byteStart).toBe(6) + expect(ranges[0]!.byteEnd).toBe(12) + }) + + test("delete last line", () => expectRange("line1\nline2", "line1\n", 6, 6, 6, 6)) + + test("CRLF byte offsets", () => expectRange("a\r\nb\r\nc\r\n", "a\r\nb\r\nX\r\nc\r\n", 6, 9, 6, 9)) + + test("mixed CRLF and LF", () => + expectRange("line1\r\nline2\nline3", "line1\r\nline2\nnewLine\nline3", 13, 21, 13, 21)) + + test("completely deleted content", () => expectRange("line1\nline2\nline3", "", 0, 0, 0, 0)) + + test("delete single char", () => expectRange("a", "", 0, 0, 0, 0)) + + test("empty content edge case", () => { + const ranges = calculateRanges("", "") + expect(ranges).toEqual([]) + }) +}) + +describe("DiffRange", () => { + test("adjacent detection", () => { + const a = DiffRange.create(0, 5, 0, 5) + const b = DiffRange.create(6, 4, 6, 4) + expect(DiffRange.adjacent(a, b)).toBe(true) + const c = DiffRange.create(15, 5, 15, 5) + expect(DiffRange.adjacent(a, c)).toBe(false) + }) + + test("merge", () => { + const a = DiffRange.create(0, 5, 0, 5) + const b = DiffRange.create(6, 4, 6, 4) + const m = DiffRange.merge(a, b) + expect(m.start).toBe(0) + expect(m.end).toBe(10) + }) + + test("from roundtrip", () => { + const r = DiffRange.create(10, 5, 20, 10) + const data = { start: r.start, end: r.end, byteOffset: r.byteStart, byteLength: r.byteEnd! - r.byteStart! } + const r2 = DiffRange.from(data) + expect(r2.start).toBe(10) + expect(r2.end).toBe(15) + expect(r2.byteStart).toBe(20) + expect(r2.byteEnd).toBe(30) + }) + + test("from omits bytes when not set", () => { + const r = DiffRange.from({ start: 10, end: 15 }) + expect(r.byteStart).toBeUndefined() + expect(r.byteEnd).toBeUndefined() + }) + + test("toJSON", () => { + const r = DiffRange.create(10, 5, 20, 10) + const json = DiffRange.toJSON(r) + expect(json).toEqual({ start: 10, end: 15, byteOffset: 20, byteLength: 10 }) + }) + + test("toJSON omits undefined byteLength", () => { + const r: DiffRange = { start: 10, end: 15 } + const json = DiffRange.toJSON(r) + expect(json.byteLength).toBeUndefined() + }) +})