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 @@ -263,6 +263,16 @@ export namespace File {
"file.edited",
z.object({
file: z.string(),
changedRanges: z
.array(
z.object({
start: z.number(),
end: z.number(),
_byteOffset: z.number().optional(),
_byteLength: z.number().optional(),
}),
)
.optional(),
}),
),
}
Expand Down
161 changes: 161 additions & 0 deletions packages/opencode/src/format/diff-range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { diffLines } from "diff"

// Configuration constants
const MERGE_THRESHOLD = 6 // characters - about 1 line gap
const EOF_CLAMP_OFFSET = 1 // Safety margin for EOF clamping

/**
* Clamp offset to be within valid file bounds
*/
function clampOffset(offset: number, total: number): number {
return total > 0 ? Math.min(offset, total - EOF_CLAMP_OFFSET) : 0
}

/**
* Simplified range representation with conversion methods
*/
export class DiffRange {
constructor(
public readonly start: number,
public readonly end: number,
) {}

/**
* Convert to plain object for serialization
*/
toJSON(): { start: number; end: number; _byteOffset?: number; _byteLength?: number } {
const byteOffsets = this.getCachedByteOffsets()
return {
start: this.start,
end: this.end,
_byteOffset: byteOffsets?.start,
_byteLength: byteOffsets ? byteOffsets.end - byteOffsets.start : undefined,
}
}

/**
* Create from plain object (for deserialization)
*/
static fromJSON(data: { start: number; end: number; _byteOffset?: number; _byteLength?: number }): DiffRange {
if (data.start < 0 || data.end < 0) {
throw new Error(`Invalid range: negative offsets (start=${data.start}, end=${data.end})`)
}
if (data.start > data.end) {
throw new Error(`Invalid range: start ${data.start} > end ${data.end}`)
}
const range = new DiffRange(data.start, data.end)
if (data._byteOffset !== undefined && data._byteLength !== undefined) {
range._byteOffset = data._byteOffset
range._byteLength = data._byteLength
}
return range
}

get length(): number {
return this.end - this.start
}

// Private storage for cached byte offsets (mutable for factory methods)
private _byteOffset?: number
private _byteLength?: number

/**
* Create DiffRange from character and byte offsets
*/
static fromOffsets(charOffset: number, charLength: number, byteOffset: number, byteLength: number): DiffRange {
const range = new DiffRange(charOffset, charOffset + charLength)
range._byteOffset = byteOffset
range._byteLength = byteLength
return range
}

getCachedByteOffsets(): { start: number; end: number } | undefined {
if (this._byteOffset !== undefined && this._byteLength !== undefined) {
return { start: this._byteOffset, end: this._byteOffset + this._byteLength }
}
return undefined
}

/**
* Check if this range should be merged with another
*/
shouldMerge(other: DiffRange): boolean {
return other.start - this.end <= MERGE_THRESHOLD
}

/**
* Merge this range with another
*/
merge(other: DiffRange): DiffRange {
const newStart = Math.min(this.start, other.start)
const newEnd = Math.max(this.end, other.end)
const merged = new DiffRange(newStart, newEnd)

// Merge cached byte offsets if available
const thisBytes = this.getCachedByteOffsets()
const otherBytes = other.getCachedByteOffsets()
if (thisBytes && otherBytes) {
merged._byteOffset = Math.min(thisBytes.start, otherBytes.start)
merged._byteLength = Math.max(thisBytes.end, otherBytes.end) - merged._byteOffset
}

return merged
}
}

/**
* Calculate changed ranges from old and new content
* Returns ranges that can be converted to both character and byte offsets
*/
export function calculateRanges(oldContent: string, newContent: string): DiffRange[] {
const changes = diffLines(oldContent.replace(/\r\n/g, "\n"), newContent.replace(/\r\n/g, "\n"))
const result: DiffRange[] = []

let charOffset = 0
let byteOffset = 0

const totalChars = newContent.length
const totalBytes = Buffer.byteLength(newContent)

for (const change of changes) {
if (change.added) {
const text = change.value
const bytes = Buffer.byteLength(text)
result.push(DiffRange.fromOffsets(charOffset, text.length, byteOffset, bytes))
charOffset += text.length
byteOffset += bytes
continue
}
if (change.removed) {
result.push(DiffRange.fromOffsets(clampOffset(charOffset, totalChars), 0, clampOffset(byteOffset, totalBytes), 0))
continue
}
const text = change.value
const bytes = Buffer.byteLength(text)
charOffset += text.length
byteOffset += bytes
}

return mergeRanges(result)
}

/**
* Merge adjacent ranges to reduce formatter invocations
*/
function mergeRanges(ranges: DiffRange[]): DiffRange[] {
if (ranges.length === 0) return ranges

return ranges
.toSorted((a, b) => a.start - b.start)
.reduce((acc, cur) => {
if (acc.length === 0) return [cur]

const last = acc[acc.length - 1]
if (last.shouldMerge(cur)) {
acc[acc.length - 1] = last.merge(cur)
return acc
}
acc.push(cur)
return acc
}, [] as DiffRange[])
}
36 changes: 36 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 type { 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 @@ -73,6 +75,25 @@ export const prettier: Info = {
}
return false
},
buildRangeCommand(file: string, ranges: DiffRange[]) {
// Prettier only supports a single range, so we merge all ranges into one
if (ranges.length === 0) {
return [BunProc.which(), "x", "prettier", "--write", file]
}

// Merge all ranges into one for prettier
const merged = ranges.reduce((acc, range) => acc.merge(range), ranges[0])

return [
BunProc.which(),
"x",
"prettier",
"--write",
`--range-start=${merged.start}`,
`--range-end=${merged.end}`,
file,
]
},
}

export const oxfmt: Info = {
Expand Down Expand Up @@ -157,6 +178,21 @@ 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 cmd = ["clang-format", "-i"]

// clang-format requires byte offsets - we must have cached values
for (const range of ranges) {
const byteOffsets = range.getCachedByteOffsets()
if (!byteOffsets) {
throw new Error("clang-format requires byte offsets but none were cached for range")
}
cmd.push(`--offset=${byteOffsets.start}`)
cmd.push(`--length=${byteOffsets.end - byteOffsets.start}`)
}
cmd.push(file)
return cmd
},
}

export const ktlint: Info = {
Expand Down
26 changes: 23 additions & 3 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,14 +105,33 @@ export namespace Format {
log.info("init")
Bus.subscribe(File.Event.Edited, async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const changedRanges = payload.properties.changedRanges
log.info("formatting", { file, changedRanges })
const ext = path.extname(file)

for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
let cmd: string[]

// Use range formatting if supported and ranges are provided
if (item.buildRangeCommand && changedRanges) {
// Convert plain objects back to DiffRange instances
const rangeObjects = changedRanges.map((data) => DiffRange.fromJSON(data))

if (rangeObjects.length > 0) {
cmd = item.buildRangeCommand(file, rangeObjects)
log.info("using range formatting", { ranges: rangeObjects })
} else {
log.info("formatting skipped: no changed ranges detected", { file })
continue
}
} else {
cmd = item.command.map((x) => x.replace("$FILE", file))
}

const proc = Bun.spawn({
cmd: item.command.map((x) => x.replace("$FILE", file)),
cmd,
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
Expand All @@ -120,7 +140,7 @@ export namespace Format {
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {
command: item.command,
command: cmd,
...item.environment,
})
} catch (error) {
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectory } from "./external-directory"
import { calculateRanges } from "../format/diff-range"

const MAX_DIAGNOSTICS_PER_FILE = 20

Expand Down Expand Up @@ -62,6 +63,8 @@ export const EditTool = Tool.define("edit", {
},
})
await Bun.write(filePath, params.newString)

// For new file content, format the entire file
await Bus.publish(File.Event.Edited, {
file: filePath,
})
Expand Down Expand Up @@ -95,8 +98,12 @@ export const EditTool = Tool.define("edit", {
})

await file.write(contentNew)

const ranges = calculateRanges(contentOld, contentNew)

await Bus.publish(File.Event.Edited, {
file: filePath,
changedRanges: ranges.map((r) => r.toJSON()),
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
Expand Down
Loading
Loading