Skip to content

Commit 94ad29c

Browse files
author
Michael Voigt
committed
feat(format): restrict formatting to changed ranges
1 parent eda7137 commit 94ad29c

6 files changed

Lines changed: 281 additions & 13 deletions

File tree

packages/opencode/src/file/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,16 @@ export namespace File {
327327
"file.edited",
328328
z.object({
329329
file: z.string(),
330+
ranges: z
331+
.array(
332+
z.object({
333+
start: z.number(),
334+
end: z.number(),
335+
byteOffset: z.number().optional(),
336+
byteLength: z.number().optional(),
337+
}),
338+
)
339+
.optional(),
330340
}),
331341
),
332342
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { diffLines } from "diff"
2+
3+
const ADJACENT_THRESHOLD = 6
4+
5+
export type DiffRange = {
6+
start: number
7+
end: number
8+
byteStart?: number
9+
byteEnd?: number
10+
}
11+
12+
export const DiffRange = {
13+
create(char: number, charLen: number, byte: number, byteLen: number): DiffRange {
14+
return { start: char, end: char + charLen, byteStart: byte, byteEnd: byte + byteLen }
15+
},
16+
17+
from(data: { start: number; end: number; byteOffset?: number; byteLength?: number }): DiffRange {
18+
const range: DiffRange = { start: data.start, end: data.end }
19+
if (data.byteOffset != null && data.byteLength != null) {
20+
range.byteStart = data.byteOffset
21+
range.byteEnd = data.byteOffset + data.byteLength
22+
}
23+
return range
24+
},
25+
26+
toJSON(r: DiffRange) {
27+
return {
28+
start: r.start,
29+
end: r.end,
30+
byteOffset: r.byteStart,
31+
byteLength: r.byteEnd != null && r.byteStart != null ? r.byteEnd - r.byteStart : undefined,
32+
}
33+
},
34+
35+
merge(a: DiffRange, b: DiffRange): DiffRange {
36+
const merged: DiffRange = {
37+
start: Math.min(a.start, b.start),
38+
end: Math.max(a.end, b.end),
39+
}
40+
if (a.byteStart != null && a.byteEnd != null && b.byteStart != null && b.byteEnd != null) {
41+
merged.byteStart = Math.min(a.byteStart, b.byteStart)
42+
merged.byteEnd = Math.max(a.byteEnd, b.byteEnd)
43+
}
44+
return merged
45+
},
46+
47+
adjacent(a: DiffRange, b: DiffRange): boolean {
48+
return b.start - a.end <= ADJACENT_THRESHOLD
49+
},
50+
}
51+
52+
function buildMapping(content: string): { map: number[]; bytes: number[] } {
53+
const map: number[] = []
54+
const bytes: number[] = []
55+
let charOffset = 0
56+
let byteOffset = 0
57+
const chars = Array.from(content)
58+
const encoder = new TextEncoder()
59+
60+
for (let i = 0; i < chars.length; i++) {
61+
const char = chars[i]!
62+
map.push(charOffset)
63+
bytes.push(byteOffset)
64+
if (char === "\r" && chars[i + 1] === "\n") {
65+
byteOffset += 2
66+
charOffset += 2
67+
i++
68+
} else {
69+
byteOffset += encoder.encode(char).length
70+
charOffset++
71+
}
72+
}
73+
map.push(charOffset)
74+
bytes.push(byteOffset)
75+
76+
return { map, bytes }
77+
}
78+
79+
export function calculateRanges(oldContent: string, newContent: string): DiffRange[] {
80+
const { map, bytes } = buildMapping(newContent)
81+
const normalizedOld = oldContent.replace(/\r\n/g, "\n")
82+
const normalizedNew = newContent.replace(/\r\n/g, "\n")
83+
const changes = diffLines(normalizedOld, normalizedNew)
84+
const result: DiffRange[] = []
85+
let offset = 0
86+
87+
for (const change of changes) {
88+
if (change.added) {
89+
const start = map[offset] ?? newContent.length
90+
const endIdx = offset + change.value.length
91+
const end = endIdx < map.length ? map[endIdx]! : newContent.length
92+
result.push(DiffRange.create(start, end - start, bytes[offset]!, bytes[endIdx]! - bytes[offset]!))
93+
offset += change.value.length
94+
} else if (change.removed) {
95+
const start = map[offset] ?? newContent.length
96+
result.push(DiffRange.create(start, 0, bytes[offset] ?? bytes[bytes.length - 1] ?? 0, 0))
97+
} else {
98+
offset += change.value.length
99+
}
100+
}
101+
102+
return merge(result)
103+
}
104+
105+
function merge(ranges: DiffRange[]): DiffRange[] {
106+
if (!ranges.length) return ranges
107+
return ranges.reduce((acc, r) => {
108+
const last = acc[acc.length - 1]
109+
last && DiffRange.adjacent(last, r) ? (acc[acc.length - 1] = DiffRange.merge(last, r)) : acc.push(r)
110+
return acc
111+
}, [] as DiffRange[])
112+
}

packages/opencode/src/format/formatter.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { BunProc } from "../bun"
33
import { Instance } from "../project/instance"
44
import { Filesystem } from "../util/filesystem"
55
import { Flag } from "@/flag/flag"
6+
import { DiffRange } from "./diff-range"
67

78
export interface Info {
89
name: string
910
command: string[]
1011
environment?: Record<string, string>
1112
extensions: string[]
1213
enabled(): Promise<boolean>
14+
buildRangeCommand?(file: string, ranges: DiffRange[]): string[]
1315
}
1416

1517
export const gofmt: Info = {
@@ -76,6 +78,11 @@ export const prettier: Info = {
7678
}
7779
return false
7880
},
81+
buildRangeCommand(file: string, ranges: DiffRange[]) {
82+
if (!ranges.length) return [BunProc.which(), "x", "prettier", "--write", file]
83+
const m = ranges.reduce((a, b) => DiffRange.merge(a, b))
84+
return [BunProc.which(), "x", "prettier", "--write", `--range-start=${m.start}`, `--range-end=${m.end}`, file]
85+
},
7986
}
8087

8188
export const oxfmt: Info = {
@@ -163,6 +170,19 @@ export const clang: Info = {
163170
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
164171
return items.length > 0
165172
},
173+
buildRangeCommand(file: string, ranges: DiffRange[]) {
174+
const bytes = ranges
175+
.map((r) => (r.byteStart != null && r.byteEnd != null ? { start: r.byteStart, end: r.byteEnd } : undefined))
176+
.filter((b): b is { start: number; end: number } => b !== undefined)
177+
if (bytes.length !== ranges.length) return ["clang-format", "-i", file]
178+
const cmd = ["clang-format", "-i"]
179+
for (const b of bytes) {
180+
cmd.push(`--offset=${b.start}`)
181+
cmd.push(`--length=${b.end - b.start}`)
182+
}
183+
cmd.push(file)
184+
return cmd
185+
},
166186
}
167187

168188
export const ktlint: Info = {

packages/opencode/src/format/index.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as Formatter from "./formatter"
88
import { Config } from "../config/config"
99
import { mergeDeep } from "remeda"
1010
import { Instance } from "../project/instance"
11+
import { DiffRange } from "./diff-range"
1112

1213
export namespace Format {
1314
const log = Log.create({ service: "format" })
@@ -104,32 +105,29 @@ export namespace Format {
104105
log.info("init")
105106
Bus.subscribe(File.Event.Edited, async (payload) => {
106107
const file = payload.properties.file
107-
log.info("formatting", { file })
108+
const ranges = payload.properties.ranges
109+
log.info("formatting", { file, ranges })
108110
const ext = path.extname(file)
109111

110112
for (const item of await getFormatter(ext)) {
111113
log.info("running", { command: item.command })
114+
const data = ranges?.map(DiffRange.from)
115+
const cmd =
116+
item.buildRangeCommand && data?.length
117+
? item.buildRangeCommand(file, data)
118+
: item.command.map((c) => c.replace("$FILE", file))
112119
try {
113120
const proc = Bun.spawn({
114-
cmd: item.command.map((x) => x.replace("$FILE", file)),
121+
cmd,
115122
cwd: Instance.directory,
116123
env: { ...process.env, ...item.environment },
117124
stdout: "ignore",
118125
stderr: "ignore",
119126
})
120127
const exit = await proc.exited
121-
if (exit !== 0)
122-
log.error("failed", {
123-
command: item.command,
124-
...item.environment,
125-
})
128+
if (exit !== 0) log.error("failed", { command: cmd, ...item.environment })
126129
} catch (error) {
127-
log.error("failed to format file", {
128-
error,
129-
command: item.command,
130-
...item.environment,
131-
file,
132-
})
130+
log.error("failed to format file", { error, command: cmd, ...item.environment, file })
133131
}
134132
}
135133
})

packages/opencode/src/tool/edit.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { FileTime } from "../file/time"
1616
import { Filesystem } from "../util/filesystem"
1717
import { Instance } from "../project/instance"
1818
import { Snapshot } from "@/snapshot"
19+
import { calculateRanges, DiffRange } from "../format/diff-range"
1920
import { assertExternalDirectory } from "./external-directory"
2021

2122
const MAX_DIAGNOSTICS_PER_FILE = 20
@@ -61,9 +62,11 @@ export const EditTool = Tool.define("edit", {
6162
diff,
6263
},
6364
})
65+
const ranges = calculateRanges(contentOld, params.newString)
6466
await Filesystem.write(filePath, params.newString)
6567
await Bus.publish(File.Event.Edited, {
6668
file: filePath,
69+
ranges: ranges.map(DiffRange.toJSON),
6770
})
6871
await Bus.publish(FileWatcher.Event.Updated, {
6972
file: filePath,
@@ -93,9 +96,11 @@ export const EditTool = Tool.define("edit", {
9396
},
9497
})
9598

99+
const ranges = calculateRanges(contentOld, contentNew)
96100
await Filesystem.write(filePath, contentNew)
97101
await Bus.publish(File.Event.Edited, {
98102
file: filePath,
103+
ranges: ranges.map(DiffRange.toJSON),
99104
})
100105
await Bus.publish(FileWatcher.Event.Updated, {
101106
file: filePath,
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { calculateRanges, DiffRange } from "../src/format/diff-range"
3+
4+
const expectRange = (old: string, next: string, start: number, end: number, byteStart?: number, byteEnd?: number) => {
5+
const ranges = calculateRanges(old, next)
6+
expect(ranges.length).toBe(1)
7+
expect(ranges[0]!.start).toBe(start)
8+
expect(ranges[0]!.end).toBe(end)
9+
if (byteStart != null) expect(ranges[0]!.byteStart).toBe(byteStart)
10+
if (byteEnd != null) expect(ranges[0]!.byteEnd).toBe(byteEnd)
11+
}
12+
13+
describe("calculateRanges", () => {
14+
test("added lines", () => expectRange("line1\nline2\nline3", "line1\nline2\nnewline\nline3", 12, 20, 12, 20))
15+
16+
test("multiple added lines", () =>
17+
expectRange("line1\nline2\nline3", "line1\nline2\nnewline1\nnewline2\nnewline3\nline3", 12, 39, 12, 39))
18+
19+
test("removed lines", () => expectRange("line1\nline2\nline3\nline4", "line1\nline2\nline4", 12, 12, 12, 12))
20+
21+
test("removed lines at end", () => expectRange("line1\nline2\nline3", "line1\nline2", 6, 11, 6, 11))
22+
23+
test("merges adjacent ranges", () =>
24+
expectRange("line1\nline2\nline3\nline4\nline5", "line1\nnew2\nline3\nnew4\nline5", 6, 22, 6, 22))
25+
26+
test("keeps separate ranges", () => {
27+
const ranges = calculateRanges("line1\nline2\nline3\nline4\nline5\nline6", "line1\nnew2\nline3\nline4\nline5\nnew6")
28+
expect(ranges.length).toBe(2)
29+
expect(ranges[0]!.start).toBe(6)
30+
expect(ranges[0]!.end).toBe(11)
31+
expect(ranges[1]!.start).toBe(29)
32+
expect(ranges[1]!.end).toBe(33)
33+
})
34+
35+
test("empty old content", () => expectRange("", "line1\nline2\nline3", 0, 17, 0, 17))
36+
37+
test("complex edit", () =>
38+
expectRange("line1\nline2\nline3\nline4\nline5", "line1\nnewA\nnewB\nline4\nline5", 6, 16, 6, 16))
39+
40+
test("adding at beginning", () => expectRange("line2\nline3", "line1\nline2\nline3", 0, 6, 0, 6))
41+
42+
test("adding at end", () => expectRange("line1\nline2\n", "line1\nline2\nline3\n", 12, 18, 12, 18))
43+
44+
test("identical content returns empty", () => {
45+
const content = "line1\nline2\nline3"
46+
expect(calculateRanges(content, content)).toEqual([])
47+
})
48+
49+
test("ignores line ending differences", () => {
50+
expect(calculateRanges("line1\r\nline2\r\nline3", "line1\nline2\nline3")).toEqual([])
51+
})
52+
53+
test("unicode accuracy", () => {
54+
const ranges = calculateRanges("hello\nworld", "hello\n世界")
55+
expect(ranges.length).toBe(1)
56+
expect(ranges[0]!.start).toBe(6)
57+
expect(ranges[0]!.end).toBe(8)
58+
expect(ranges[0]!.byteStart).toBe(6)
59+
expect(ranges[0]!.byteEnd).toBe(12)
60+
})
61+
62+
test("delete last line", () => expectRange("line1\nline2", "line1\n", 6, 6, 6, 6))
63+
64+
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))
65+
66+
test("mixed CRLF and LF", () =>
67+
expectRange("line1\r\nline2\nline3", "line1\r\nline2\nnewLine\nline3", 13, 21, 13, 21))
68+
69+
test("completely deleted content", () => expectRange("line1\nline2\nline3", "", 0, 0, 0, 0))
70+
71+
test("delete single char", () => expectRange("a", "", 0, 0, 0, 0))
72+
73+
test("empty content edge case", () => {
74+
const ranges = calculateRanges("", "")
75+
expect(ranges).toEqual([])
76+
})
77+
})
78+
79+
describe("DiffRange", () => {
80+
test("adjacent detection", () => {
81+
const a = DiffRange.create(0, 5, 0, 5)
82+
const b = DiffRange.create(6, 4, 6, 4)
83+
expect(DiffRange.adjacent(a, b)).toBe(true)
84+
const c = DiffRange.create(15, 5, 15, 5)
85+
expect(DiffRange.adjacent(a, c)).toBe(false)
86+
})
87+
88+
test("merge", () => {
89+
const a = DiffRange.create(0, 5, 0, 5)
90+
const b = DiffRange.create(6, 4, 6, 4)
91+
const m = DiffRange.merge(a, b)
92+
expect(m.start).toBe(0)
93+
expect(m.end).toBe(10)
94+
})
95+
96+
test("from roundtrip", () => {
97+
const r = DiffRange.create(10, 5, 20, 10)
98+
const data = { start: r.start, end: r.end, byteOffset: r.byteStart, byteLength: r.byteEnd! - r.byteStart! }
99+
const r2 = DiffRange.from(data)
100+
expect(r2.start).toBe(10)
101+
expect(r2.end).toBe(15)
102+
expect(r2.byteStart).toBe(20)
103+
expect(r2.byteEnd).toBe(30)
104+
})
105+
106+
test("from omits bytes when not set", () => {
107+
const r = DiffRange.from({ start: 10, end: 15 })
108+
expect(r.byteStart).toBeUndefined()
109+
expect(r.byteEnd).toBeUndefined()
110+
})
111+
112+
test("toJSON", () => {
113+
const r = DiffRange.create(10, 5, 20, 10)
114+
const json = DiffRange.toJSON(r)
115+
expect(json).toEqual({ start: 10, end: 15, byteOffset: 20, byteLength: 10 })
116+
})
117+
118+
test("toJSON omits undefined byteLength", () => {
119+
const r: DiffRange = { start: 10, end: 15 }
120+
const json = DiffRange.toJSON(r)
121+
expect(json.byteLength).toBeUndefined()
122+
})
123+
})

0 commit comments

Comments
 (0)