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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/opencode/src/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,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(),
}),
),
}
Expand Down
112 changes: 112 additions & 0 deletions packages/opencode/src/format/diff-range.ts
Original file line number Diff line number Diff line change
@@ -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[])
}
20 changes: 20 additions & 0 deletions packages/opencode/src/format/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ 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
command: string[]
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<boolean>
buildRangeCommand?(file: string, ranges: DiffRange[]): string[]
}

export const gofmt: Info = {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
24 changes: 11 additions & 13 deletions packages/opencode/src/format/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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 })
}
}
})
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,9 +62,11 @@ export const EditTool = Tool.define("edit", {
diff,
},
})
const ranges = calculateRanges(contentOld, params.newString)
await Bun.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,
Expand Down Expand Up @@ -94,9 +97,11 @@ export const EditTool = Tool.define("edit", {
},
})

const ranges = calculateRanges(contentOld, contentNew)
await file.write(contentNew)
await Bus.publish(File.Event.Edited, {
file: filePath,
ranges: ranges.map(DiffRange.toJSON),
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
Expand Down
123 changes: 123 additions & 0 deletions packages/opencode/test/diff-range.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading