#4604 · @micuintus · opened Nov 21, 2025 at 6:47 PM UTC · last updated Mar 21, 2026 at 9:09 AM UTC

feat(formater): restrict formatting to only the changed range of a file.

appfeat
39
+5722412 files

Score breakdown

Impact

7.0

Clarity

2.0

Urgency

4.0

Ease Of Review

1.0

Guidelines

2.0

Readiness

1.0

Size

1.0

Trust

5.0

Traction

9.0

Summary

This PR aims to restrict clang-format to only the edited line range, reducing diff noise from unrelated formatting changes. However, it contains an unexplained, unrelated breaking API change and is very large for a feature of this nature.

Open in GitHub

Description

Restrict formatting only to the edited line range for clang-format When using the Edit tool, clang-format now only formats the specific lines that were changed, rather than reformatting the entire file. This prevents unrelated formatting changes from cluttering the diff.

Closes #4603

Linked Issues

#4603 [FEATURE]: Restrict formatting only to the edited range for clang-format (and Prettier)

View issue

Comments

PR comments

micuintus

@rekram1-node What is the state on this? Are you considering these suggestions?

rekram1-node

Yeah I see what you are trying to solve there. I would need some time to look into it more to review but I will say this looks very vibe coded so I won't just auto merge without a throughout review

micuintus

@rekram1-node

I would need some time to look into it more to review but I will say this looks very vibe coded so I won't just auto merge without a throughout review

Yes, I did use opencode for this PR ;). I am obviously new to this codebase and not a particular TypeScript expert. I had opencode run through several iterations with several models (Sonnet 4.5, GLM 4.6, Gemini 3, CODEX) though, letting the models review and discuss their results; asked them to asses the solution and the code quality, find bugs, make suggestions for simplification and improvement etc.

But yeah, this code needs to be reviewed, of course :)

I started with the line-number-based range API of clang-format and actually left it as a separate commit (the rest is squashed into the subsequent commit). Then I checked which interface Qt Creator uses: char/byte range API for subfile formatting --- and as it enables us to apply the same logic with Prettier I thought this makes sense as a "source of truth" format.

Aside from my use case (legacy code bases with humongous unformatted areas): Auto-formatting only the affected part of the file that opencode had touched makes a lot of sense to me; formatting the rest of the file should be a different task / step anyways IMO.

Having such a system in place would not only be very beneficial for my workflow, I believe it would make opencode a better tool for everyone.

Feel feel to request changes or I'd also be happy to hand this branch over.

micuintus

@rekram1-node Is there any way I can increase your confidence with that topic?

micuintus

@rekram1-node Is there any way I can increase your confidence with that topic?

@rekram1-node bump :)

thdxr

this is good we're gonna merge it, aiden will follow up

rekram1-node

@micuintus can you update your code to follow some of our style guidelines a bit better? https://github.com/sst/opencode/blob/dev/STYLE_GUIDE.md

Ex: change changedRanges to just ranges, stuff like that

micuintus

@micuintus can you update your code to follow some of our style guidelines a bit better? https://github.com/sst/opencode/blob/dev/STYLE_GUIDE.md

Ex: change changedRanges to just ranges, stuff like that

@rekram1-node Sorry for the delay, I only just saw this. -> Done: 2d4778310978089f6001b303832cd29b377ebd12

Anything else?

rekram1-node

/review

rekram1-node

u can ignore that fail its whatever, ill merge tonight

micuintus

/review

rekram1-node

lol only i can trigger it

micuintus

@rekram1-node

lol only i can trigger it

LOL #FAIL :)

u can ignore that fail its whatever, ill merge tonight

Anything else I can do to help?

micuintus

Hm?

jasonfharris

Could this PR get fixed and merged?

micuintus

@rekram1-node ?

micuintus

@thdxr @rekram1-node: What's the status on this one now?

I'd prefer that we either merge this pull request or close it. I'd rather not leave it in limbo forever. 🥺 If more changes are required, I'm happy to work on them, of course.

micuintus

@thdxr @rekram1-node ? 🥺

rekram1-node

Oof this fell under radar again, u can always reach out via dm

rekram1-node

Ran a review with an llm to check over it, this could be bs:

Yes — here is a concrete, numeric proof using the code as written. Where the mismatch happens:

  • calculateRanges diffs normalized content (\r\n → \n) and then accumulates charOffset and byteOffset from change.value lengths.
  • Those offsets are later used as byte offsets for clang-format against the raw file content.
  • That’s fine for LF, but wrong for CRLF because every \r\n is 2 bytes in the raw file and only 1 byte in the normalized diff. Proof by example (CRLF file, insert a line): Raw contents (CRLF): oldContent = "a\r\nb\r\nc\r\n" newContent = "a\r\nb\r\nX\r\nc\r\n" Normalized for diff (\r\n → \n): oldNorm = "a\nb\nc\n" newNorm = "a\nb\nX\nc\n" When the diff sees the added line "X\n", the offsets just before it are computed from newNorm:
  • charOffset before "X\n" = length of "a\nb\n" = 4
  • byteOffset before "X\n" = Buffer.byteLength("a\nb\n") = 4 So the range is produced with _byteOffset = 4. But in the raw newContent, the true byte offset before "X\r\n" is:
  • "a\r\n" = 3 bytes
  • "b\r\n" = 3 bytes
  • total = 6 bytes So the correct byte offset is 6, not 4. The code undercounts by the number of preceding CRLFs (2 here). That means clang-format --offset=4 will start 2 bytes earlier than intended (inside the CRLF sequence or the previous line), and the formatted range is wrong or skipped. This is exactly the mismatch caused by:
  • normalizing in calculateRanges (diffLines(oldContent.replace(/\r\n/g, "\n"), ...))
  • but later using the computed _byteOffset against the raw file in buildRangeCommand for clang-format. That’s the bug. The fix is to compute offsets against the raw newContent (no CRLF normalization) or build a mapping from normalized indices back to raw indices when constructing DiffRange.

micuintus

@rekram1-node Thank you. It was not BS, valid points although not critical. Addressed it and straightened up the PR

micuintus

@rekram1-node Private DM... how would I send that? :)

micuintus

@rekram1-node Hey Aiden, Could you review again, please? Would be really nice to get this included.

micuintus

Review ran with GLM-5: <img width="942" height="489" alt="Screenshot 2026-02-19 at 12 05 57" src="https://github.com/user-attachments/assets/8d7b73c5-2aa5-4073-b683-607daca95736" />

micuintus

<img width="636" height="769" alt="Screenshot 2026-02-19 at 12 12 52" src="https://github.com/user-attachments/assets/febcbde8-2480-428c-b044-d15dbdc806bf" />

micuintus

@rekram1-node Merge? :)

micuintus

@thdxr

Since Aiden is out, can sb else from the team take that over? Who would that be?

It would help the development with legacy code bases I have to deal with a lot (also for my colleagues).

micuintus

@Hona @adamdotdevin anyone?

micuintus

@nexxeln Thanks! Should be fixed!

micuintus

Gemini 3.1 says:

   1. CORRECT (100% sure?): Yes. The implementation correctly restricts formatting to edited lines. The logic for calculating ranges and mapping them to character/byte offsets is sound.
   2. Minimal: Yes. The changes are surgically applied to the formatting service and tools, adding only necessary utilities and schema updates.
   3. Project Standards: Yes. It follows the codebase's architectural patterns (Effect-TS, Zod, Bun) and maintains consistent styling.
   4. Regressions:
       * 4.a) Unicode surrogate pairs: Handled correctly. getByteOffset uses codePointAt and String.fromCodePoint to ensure non-BMP characters (like
         emojis) are treated as single units, resulting in correct 4-byte UTF-8 offsets. I verified that NaN is not produced in the specific case of
         calculateRanges("", "😀a\n").
       * 4.b) CRLF problems: Handled correctly. Both getCharOffset and getByteOffset explicitly detect and account for \r\n sequences, ensuring
         accurate line-to-offset mapping regardless of the platform's line endings.

Claude Opus 4.6 says:

  Summary
Criterion	Verdict
Correctness	Pass — all logic is sound, edge cases handled
Minimality	Pass — minor extras (DiffRange helpers, rlang fix) but justified
Coding standards	Pass — follows project conventions
4a. Surrogate pairs	No bug — offsets are correct, no NaN. Test exists.
4b. CRLF	No bug — normalization + raw-offset functions handle it correctly. Tests exist.
The commit is clean. The only suggestion I'd add is a CRLF end-to-end test that goes through calculateRanges → getByteOffset for a CRLF file with emoji (combining both edge cases), but the individual tests already cover each dimension independently.

micuintus

Had to partially redo this due to some changes in the formatting architecture (effects), here are the new reviews:

<img width="1136" height="913" alt="Screenshot 2026-03-19 at 17 47 10" src="https://github.com/user-attachments/assets/115ea6c9-8293-478c-a1d2-2996cfabb14a" /> <img width="727" height="572" alt="Screenshot 2026-03-19 at 17 21 28" src="https://github.com/user-attachments/assets/20af7dba-56b4-4046-9502-bfd3ca27c040" />

micuintus

Gemini Pro 3.1

   1. Correctness: Yes, the changes are correct. The new implementation in packages/opencode/src/format/diff-range.ts correctly handles unicode surrogate pairs and CRLF line endings.
   2. Minimality: Yes, the changes are minimal and focused on extracting range calculation logic into a reusable module.
   3. Standards: Yes, the code follows project coding standards (TypeScript, proper types, tests).
   4. Regressions: I have verified the reported regression:
       - Unicode Surrogate Pairs: The implementation uses a tokenizer that correctly identifies surrogate pairs (e.g., tokenize("😀") returns a single token). calculateRanges("", "😀a\n") produces valid byte
         offsets, and byteEnd is not NaN.
       - CRLF: The implementation handles CRLF by tokenizing \r\n as a single unit, ensuring alignment with normalized diffs while correctly counting bytes (2 bytes for \r\n).
       - Tests: A specific regression test test("unicode surrogate pairs", ...) is included in packages/opencode/test/diff-range.test.ts.

micuintus

Claude Opus 4.6 review:

┌─────┬───────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────┐   
│  #  │         Check         │                                         Result                                          │
├─────┼───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤   
│ 1   │ Correctness           │ Pass                                                                                    │
├─────┼───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ 2   │ Minimality            │ Pass                                                                                    │
├─────┼───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ 3   │ Coding standards      │ Pass                                                                                    │
├─────┼───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤   
│ 4a  │ Surrogate pair        │ Pass — tokenize regex handles them correctly, byteEnd=NaN cannot occur. Tests at lines  │
│     │ regression            │ 79-113.                                                                                 │   
├─────┼───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ 4b  │ CRLF regression       │ Pass — normalization preserves token count alignment. Tests at lines 65-135.            │   
├─────┼───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤   
│ 5   │ All tools covered     │ Pass — write, edit, apply_patch all pass ranges. No emitters missed.                    │
├─────┼───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤   
│ 6   │ Range merging         │ Pass — collapse() merges adjacent ranges; clang batches natively, prettier/ruff         │
│     │                       │ reverse-sort to avoid offset shifts.                                                    │   
├─────┼───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ 7   │ Architecture          │ Pass — backward-compatible via optional schema/interface, clean event-driven            │   
│     │                       │ integration.                                                                            │   
├─────┼───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ 8   │ Formatter coverage    │ Pass — prettier, clang-format, ruff have range support; 22 others fall back safely to   │   
│     │                       │ full-file formatting.                                                                   │   
└─────┴───────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────┘
                                                                                                                            
46 tests pass. No changes required.                                                                                         

micuintus

Gemini 3.1 Pro:

 Summary of Review

  After a comprehensive audit of the changes against origin/dev, I can confirm the following:

   * Correctness (10000% Sure): The logic is sound. The specific concern regarding byteEnd=NaN for surrogate pairs is
     non-existent in the current implementation; the code explicitly uses TextEncoder on individual tokens to ensure accurate
     byte calculations.
   * Minimal & Clean: The changes are surgical. They introduce the calculateRanges utility and hook it into the Format.Service
     without bloating the existing tool logic.
   * Coding Standards: The implementation strictly adheres to the project's architectural patterns (using Effect, Bus events,
     and Zod for schema validation).
   * Regression Proof: 
       * Unicode: Non-BMP characters are handled correctly via the surrogate-aware tokenizer.
       * CRLF: Line-ending normalization ensures that line-ending-only changes do not trigger ranges.
   * Tool Integration: The feature is globally available to any tool that publishes the File.Event.Edited event. It is
     currently implemented for write, edit, and apply_patch.
   * Multi-Range Strategy: 
       * Merging: Tools that support it (like clang-format) have their ranges merged into a single process call.
       * Isolation: Tools that don't support it use a "reverse-sorted" execution strategy, ensuring that formatting a range at
         the end of a file doesn't break the character offsets of ranges earlier in the file.
   * Architectural Fit: This is a "pristine" feature. It leverages the existing decoupled Bus architecture, allowing
     formatters to react to file edits without the tools needing to know the specifics of each formatter's CLI arguments.

  Verdict: The PR is high-quality, handles the identified edge cases correctly, and is ready for merge.

micuintus

Note: I had to add two unrelated commits to fix issues with the pipeline on origin/dev

Changed Files

packages/app/src/components/dialog-connect-provider.tsx

+11
@@ -383,7 +383,7 @@ export function DialogConnectProvider(props: { provider: string }) {
setFormStore("error", undefined)
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
body: {
type: "api",
key: apiKey,
},

packages/app/src/components/dialog-custom-provider.tsx

+11
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
const auth = result.key
? globalSDK.client.auth.set({
providerID: result.providerID,
auth: {
body: {
type: "api",
key: result.key,
},

packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx

+11
@@ -265,7 +265,7 @@ function ApiMethod(props: ApiMethodProps) {
if (!value) return
await sdk.client.auth.set({
providerID: props.providerID,
auth: {
body: {
type: "api",
key: value,
},

packages/opencode/src/file/index.ts

+140
@@ -79,6 +79,20 @@ 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(),
lineStart: z.number().optional(),
lineEnd: z.number().optional(),
columnStart: z.number().optional(),
columnEnd: z.number().optional(),
}),
)
.optional(),
}),
),
}

packages/opencode/src/format/diff-range.ts

+1790
@@ -0,0 +1,179 @@
import { diffLines } from "diff"
const ADJACENT_THRESHOLD = 6
export type DiffRange = {
start: number
end: number
byteStart?: number
byteEnd?: number
lineStart?: number
lineEnd?: number
columnStart?: number
columnEnd?: number
}
export const DiffRange = {
create(
char: number,
charLen: number,
byte: number,
byteLen: number,
lineStart: number,
lineEnd: number,
columnStart: number,
columnEnd: number,
): DiffRange {
return {
start: char,
end: char + charLen,
byteStart: byte,
byteEnd: byte + byteLen,
lineStart,
lineEnd,
columnStart,
columnEnd,
}
},
from(data: {
start: number
end: number
byteOffset?: number
byteLength?: number
lineStart?: number
lineEnd?: number
columnStart?: number
columnEnd?: number
}): DiffRange {
const range: DiffRange = {
start: data.start,
end: data.end,
lineStart: data.lineStart,
lineEnd: data.lineEnd,
columnStart: data.columnStart,
columnEnd: data.columnEnd,
}
if (data.byteOffset != null && data

packages/opencode/src/format/formatter.ts

+352
@@ -1,17 +1,18 @@
import { text } from "node:stream/consumers"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
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>
buildRangeCommands?(file: string, ranges: DiffRange[]): string[][]
}
export const gofmt: Info = {
@@ -78,6 +79,19 @@ export const prettier: Info = {
}
return false
},
buildRangeCommands(file: string, ranges: DiffRange[]) {
// Sort backwards so earlier ranges aren't shifted by formatting later ranges
const sorted = [...ranges].sort((a, b) => b.start - a.start)
return sorted.map((r) => [
BunProc.which(),
"x",
"prettier",
"--write",
`--range-start=${r.start}`,
`--range-end=${r.end}`,
file,
])
},
}
export const oxfmt: Info = {
@@ -165,6 +179,16 @@ export const clang: Info = {
con

packages/opencode/src/format/index.ts

+2619
@@ -11,6 +11,7 @@ import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { Log } from "../util/log"
import * as Formatter from "./formatter"
import { DiffRange } from "./diff-range"
export namespace Format {
const log = Log.create({ service: "format" })
@@ -103,35 +104,41 @@ export namespace Format {
File.Event.Edited,
Instance.bind(async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ranges = payload.properties.ranges
if (ranges && ranges.length === 0) return
log.info("formatting", { file, ranges })
const ext = path.extname(file)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
const data = ranges?.map(DiffRange.from)
const cmds =

packages/opencode/src/tool/apply_patch.ts

+30
@@ -13,6 +13,7 @@ import { LSP } from "../lsp"
import { Filesystem } from "../util/filesystem"
import DESCRIPTION from "./apply_patch.txt"
import { File } from "../file"
import { calculateRanges, DiffRange } from "../format/diff-range"
const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
@@ -220,8 +221,10 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
}
if (edited) {
const ranges = calculateRanges(change.oldContent, change.newContent)
await Bus.publish(File.Event.Edited, {
file: edited,
ranges: ranges.map(DiffRange.toJSON),
})
}
}

packages/opencode/src/tool/edit.ts

+50
@@ -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
@@ -70,9 +71,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,
@@ -107,9 +110,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.U

packages/opencode/src/tool/write.ts

+30
@@ -11,6 +11,7 @@ import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { trimDiff } from "./edit"
import { calculateRanges, DiffRange } from "../format/diff-range"
import { assertExternalDirectory } from "./external-directory"
const MAX_DIAGNOSTICS_PER_FILE = 20
@@ -42,8 +43,10 @@ export const WriteTool = Tool.define("write", {
})
await Filesystem.write(filepath, params.content)
const ranges = calculateRanges(contentOld, params.content)
await Bus.publish(File.Event.Edited, {
file: filepath,
ranges: ranges.map(DiffRange.toJSON),
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filepath,

packages/opencode/test/diff-range.test.ts

+2520
@@ -0,0 +1,252 @@
import { describe, expect, test } from "bun:test"
import { calculateRanges, DiffRange } from "../src/format/diff-range"
import { prettier, clang, ruff } from "../src/format/formatter"
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", () =>
expectRang

packages/opencode/test/format/format.test.ts

+520
@@ -6,6 +6,7 @@ import { Bus } from "../../src/bus"
import { File } from "../../src/file"
import { Format } from "../../src/format"
import * as Formatter from "../../src/format/formatter"
import { DiffRange } from "../../src/format/diff-range"
import { Instance } from "../../src/project/instance"
describe("Format", () => {
@@ -169,4 +170,55 @@ describe("Format", () => {
expect(await Bun.file(file).text()).toBe("xAB")
})
test("passes range arguments to formatter with buildRangeCommands", async () => {
const captured: string[][] = []
await using tmp = await tmpdir({
config: {
formatter: {
rangespy: {
command: ["sh", "-c", "true"],
extensions: [".spy"],
},
},
},
})
const file = `${tmp.path}/test.spy`
await Bun.write(file, "hello\nworld\n")
await withServices(tmp.path, Format.layer, async (rt) => {
await rt.runPromise(Format.Service.use((s) => s.init()))
// Intercept via a simpler approach: override the formatter in Formatter module
const saved = { ...Formatter.gofmt }
Formatter.gofmt.extensions = [".spy"]
Format