Documentation
¶
Overview ¶
Package hunkpatch applies sloppy, LLM-generated unified diffs to text.
Language models do not produce diffs that `patch` or `git apply` will accept. They get line numbers wrong, copy the surrounding context imperfectly, shift the indentation, wrap the patch in an `*** Begin Patch` envelope, or emit a hunk that is nothing but an anchor line. A strict patcher rejects all of that. This package accepts it, using the fuzzy hunk-application algorithm from aider: match the change block exactly, and when that fails, retry with less and less surrounding context until something lines up.
Where this code comes from ¶
The algorithm is aider's (https://github.com/Aider-AI/aider, Apache-2.0), which was ported to TypeScript as the npm package llm-diff-patcher@0.2.1 (MIT). This package is a line-by-line Go port of that npm package's `aider_port` directory. The JS files it corresponds to:
dist/aider_port/normalize_utils.js dist/aider_port/search_replace.js dist/aider_port/aider_udiff.js dist/aider_port/diff_lines.js dist/aider_port/make_new_lines_explicit.js dist/aider_port/apply_hunk.js
plus the parts of its two third-party dependencies that are actually reached: unidiff's diffLines/formatLines (which embeds jsdiff's Myers implementation) and diff-match-patch (here: github.com/sergi/go-diff).
Porting principle: faithful first, better second ¶
The port reproduces the original byte for byte, including its dead code and its half-finished pieces. For example search_replace.js destructures a `relativeIndent` flag and then never uses it, which means aider's RelativeIndenter was never actually ported to JS; that gap is preserved here and marked with a comment. Improvements are opt-in and live behind Options, so that "we changed the behaviour" is never confused with "we ported it wrong".
Equivalence is not a claim, it is a test: every function below was compared against the JS original running in a JS engine, over several hundred inputs, and the recorded results are checked in under testdata/. See the README for how that suite is built and why comparing only the final output is not enough.
Typical use ¶
out, err := hunkpatch.Apply(source, modelProducedDiff)
Apply targets a single file. For a patch that spans several files, use FindDiffs to split it and ApplyHunk to apply each hunk to the right file.
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrNoHunks = errors.New("hunkpatch: no hunk found in diff")
ErrNoHunks is returned when the diff contains nothing that can be read as a hunk — an empty string, prose with no `@@`/`-`/`+` lines, and so on.
Functions ¶
func Apply ¶
Apply applies diff to source and returns the patched text.
diff is whatever the model produced: a unified diff, a hunk with no header, a patch wrapped in an `*** Begin Patch` envelope, a fenced ```diff block. Line numbers in `@@` headers are ignored — hunks are located by content.
Apply targets a SINGLE file: any file headers in the diff are replaced with a placeholder before matching, so every hunk is applied to source. For a patch spanning several files, use FindDiffs to split it first and ApplyHunk to apply each hunk to its own file.
The returned text is always usable. err is ErrNoHunks when the diff held no hunk at all (source is returned unchanged), or a *PartialError when some hunks could not be located (the rest have been applied).
Example ¶
package main
import (
"fmt"
"github.com/zbysir/hunkpatch"
)
func main() {
source := `func greet() string {
return "hello"
}
`
// What a model typically emits: no file header, and a line range that does
// not correspond to anything.
diff := `@@ -1,3 +1,3 @@
func greet() string {
- return "hello"
+ return "hello, world"
}
`
out, err := hunkpatch.Apply(source, diff)
if err != nil {
panic(err)
}
fmt.Print(out)
}
Output: func greet() string { return "hello, world" }
Example (Envelope) ¶
Apply accepts the `*** Begin Patch` envelope, prose around the diff, and ```diff fences without any pre-processing by the caller.
package main
import (
"fmt"
"github.com/zbysir/hunkpatch"
)
func main() {
source := "const port = 8080\n"
diff := `Sure — here's the change:
*** Begin Patch
*** Update File: config.ts
@@
-const port = 8080
+const port = 3000
*** End Patch`
out, err := hunkpatch.Apply(source, diff)
if err != nil {
panic(err)
}
fmt.Print(out)
}
Output: const port = 3000
Example (Partial) ¶
A hunk that cannot be located does not sink the whole patch: the hunks that matched are applied and the failure is reported.
package main
import (
"errors"
"fmt"
"github.com/zbysir/hunkpatch"
)
func main() {
source := "alpha\nbravo\ncharlie\n"
diff := "@@\n alpha\n-bravo\n+BRAVO\n@@\n-delta\n+DELTA\n"
out, err := hunkpatch.Apply(source, diff)
var partial *hunkpatch.PartialError
if errors.As(err, &partial) {
fmt.Printf("applied %d of %d hunks\n", partial.Applied, partial.Total)
}
fmt.Print(out)
}
Output: applied 1 of 2 hunks alpha BRAVO charlie
func ApplyHunk ¶
ApplyHunk applies a single hunk to content and reports whether it matched.
A hunk is a slice of lines, each keeping its trailing newline and its leading operator: ' ' for context, '-' for removal, '+' for addition. A line shorter than two UTF-16 code units is treated as context.
ok == false means the hunk could not be located, and the first return value is then "" rather than content.
Beware that ok == true does NOT guarantee the text changed. A hunk with no context lines at all — only '-' and '+' — has no context/change/context triple to work with, so the matching loop never runs and the content comes back untouched with ok == true. That is upstream's behaviour and it is preserved here; ApplyWith compensates by treating an unchanged result as a skipped hunk.
Corresponds to applyHunks(content, hunk) upstream — despite the plural, that function takes a single hunk.
Types ¶
type FileDiff ¶
type FileDiff struct {
// OldFileName is the path from the `--- ` header with any a/ or b/ prefix
// removed, or "unknown" when the diff carried no matching header.
OldFileName string
// NewFileName is the path from the `+++ ` header, prefix removed.
NewFileName string
// Hunks holds each hunk as a slice of lines, newline included, with the
// leading ' ', '-' or '+' operator still attached.
Hunks [][]string
}
FileDiff is one file's worth of hunks, as returned by FindDiffs.
func FindDiffs ¶
FindDiffs splits a patch into per-file hunks. It corresponds to findDiffs(content) and only reads what is inside ```diff fences; a bare diff has to be fenced first (Apply does that for you).
Hunks with no `--- `/`+++ ` header pair are dropped, matching the original.
Example ¶
For a patch touching several files, split it with FindDiffs and apply each file's hunks yourself.
package main
import (
"fmt"
"github.com/zbysir/hunkpatch"
)
func main() {
patch := "```diff\n" +
"--- a/main.go\n+++ b/main.go\n@@ @@\n-var x = 1\n+var x = 2\n" +
"--- a/util.go\n+++ b/util.go\n@@ @@\n-var y = 1\n+var y = 2\n" +
"```\n"
files := map[string]string{
"main.go": "var x = 1\n",
"util.go": "var y = 1\n",
}
for _, fd := range hunkpatch.FindDiffs(patch) {
content := files[fd.NewFileName]
for _, hunk := range fd.Hunks {
if next, ok := hunkpatch.ApplyHunk(content, hunk); ok {
content = next
}
}
fmt.Printf("%s: %s", fd.NewFileName, content)
}
}
Output: main.go: var x = 2 util.go: var y = 2
type Options ¶
type Options struct {
// IndentTolerant enables the indentation-tolerant strategy: after every
// exact strategy has failed, retry the match ignoring leading whitespace.
// The block must match in exactly one place AND every line must be off by
// the same indentation prefix, otherwise it still fails. See indent.go.
IndentTolerant bool
}
Options turns on behaviour that goes beyond the port. The zero value is exactly the upstream JavaScript implementation.
They are per-call rather than a package-level switch so that two policies can coexist in one process: strict equivalence with the JavaScript implementation where that is what you need, and the extra strategies where rescuing a hunk is worth more than matching upstream exactly.
type PartialError ¶
type PartialError struct {
// Total is how many hunks the diff contained.
Total int
// Applied is how many of them changed the text.
Applied int
// Skipped holds the hunks that left the text unchanged, in the order they
// appeared.
Skipped [][]string
}
PartialError reports that the patch applied, but not completely. Apply and ApplyWith still return the best-effort text alongside it: the hunks that did match have been applied, and the ones in Skipped have not.
This is worth handling rather than ignoring. A model that miscopied context produces a hunk that cannot be located, and without this you would write back a file that looks fine and is missing an edit.
A hunk that leaves the text exactly as it was counts as skipped, since nothing would be written either way. That covers both the hunk whose context could not be found and the hunk that asks for no change at all (an anchor-only hunk, or one whose '-' and '+' lines are identical).
func (*PartialError) Error ¶
func (e *PartialError) Error() string
type Result ¶
type Result struct {
// Text is the patched text. When some hunks did not apply it still holds
// the ones that did.
Text string
// Total is how many hunks the diff contained.
Total int
// Applied is how many of them changed the text.
Applied int
// Skipped holds the hunks that left the text unchanged; see PartialError.
Skipped [][]string
}
Result is the full outcome of ApplyWith.
func ApplyWith ¶
ApplyWith is Apply with the opt-in strategies in Options enabled, and reports per-hunk detail in Result. The error follows the same contract as Apply, so a caller that already inspects Result can ignore it.
Example (IndentTolerant) ¶
Options.IndentTolerant rescues the common case of a model that copied the code but not its indentation. The replacement is written with the file's indentation, not the model's.
package main
import (
"fmt"
"github.com/zbysir/hunkpatch"
)
func main() {
source := "if ok {\n alpha := 1\n bravo := 2\n}\n"
// The model used 4 spaces where the file uses 8.
diff := "@@\n- alpha := 1\n- bravo := 2\n+ alpha := 10\n+ bravo := 2\n"
res, err := hunkpatch.ApplyWith(source, diff, hunkpatch.Options{IndentTolerant: true})
if err != nil {
panic(err)
}
fmt.Print(res.Text)
}
Output: if ok { alpha := 10 bravo := 2 }