summaryrefslogtreecommitdiff
path: root/pkg/gitdiff/apply_text.go
diff options
context:
space:
mode:
authorAnton Medvedev <anton@medv.io>2025-11-30 12:46:34 +0100
committerAnton Medvedev <anton@medv.io>2025-11-30 12:46:34 +0100
commitf6b0f38af648d028422a7494378b5dabdc90573f (patch)
tree3c26cfc269c021300a2d1e4e02623dd440c20226 /pkg/gitdiff/apply_text.go
First commit
Diffstat (limited to 'pkg/gitdiff/apply_text.go')
-rw-r--r--pkg/gitdiff/apply_text.go158
1 files changed, 158 insertions, 0 deletions
diff --git a/pkg/gitdiff/apply_text.go b/pkg/gitdiff/apply_text.go
new file mode 100644
index 0000000..8d0accb
--- /dev/null
+++ b/pkg/gitdiff/apply_text.go
@@ -0,0 +1,158 @@
+package gitdiff
+
+import (
+ "errors"
+ "io"
+)
+
+// TextApplier applies changes described in text fragments to source data. If
+// changes are described in multiple fragments, those fragments must be applied
+// in order. The applier must be closed after use.
+//
+// By default, TextApplier operates in "strict" mode, where fragment content
+// and positions must exactly match those of the source.
+type TextApplier struct {
+ dst io.Writer
+ src io.ReaderAt
+ lineSrc LineReaderAt
+ nextLine int64
+
+ closed bool
+ dirty bool
+}
+
+// NewTextApplier creates a TextApplier that reads data from src and writes
+// modified data to dst. If src implements LineReaderAt, it is used directly.
+func NewTextApplier(dst io.Writer, src io.ReaderAt) *TextApplier {
+ a := TextApplier{
+ dst: dst,
+ src: src,
+ }
+
+ if lineSrc, ok := src.(LineReaderAt); ok {
+ a.lineSrc = lineSrc
+ } else {
+ a.lineSrc = &lineReaderAt{r: src}
+ }
+
+ return &a
+}
+
+// ApplyFragment applies the changes in the fragment f, writing unwritten data
+// before the start of the fragment and any changes from the fragment. If
+// multiple text fragments apply to the same content, ApplyFragment must be
+// called in order of increasing start position. As a result, each fragment can
+// be applied at most once.
+//
+// If an error occurs while applying, ApplyFragment returns an *ApplyError that
+// annotates the error with additional information. If the error is because of
+// a conflict between the fragment and the source, the wrapped error will be a
+// *Conflict.
+func (a *TextApplier) ApplyFragment(f *TextFragment) error {
+ if a.closed {
+ return applyError(errApplierClosed)
+ }
+
+ // mark an apply as in progress, even if it fails before making changes
+ a.dirty = true
+
+ // application code assumes fragment fields are consistent
+ if err := f.Validate(); err != nil {
+ return applyError(err)
+ }
+
+ // lines are 0-indexed, positions are 1-indexed (but new files have position = 0)
+ fragStart := f.OldPosition - 1
+ if fragStart < 0 {
+ fragStart = 0
+ }
+ fragEnd := fragStart + f.OldLines
+
+ start := a.nextLine
+ if fragStart < start {
+ return applyError(&Conflict{"fragment overlaps with an applied fragment"})
+ }
+
+ if f.OldPosition == 0 {
+ ok, err := isLen(a.src, 0)
+ if err != nil {
+ return applyError(err)
+ }
+ if !ok {
+ return applyError(&Conflict{"cannot create new file from non-empty src"})
+ }
+ }
+
+ preimage := make([][]byte, fragEnd-start)
+ n, err := a.lineSrc.ReadLinesAt(preimage, start)
+ if err != nil {
+ // an EOF indicates that source file is shorter than the patch expects,
+ // which should be reported as a conflict rather than a generic error
+ if errors.Is(err, io.EOF) {
+ err = &Conflict{"src has fewer lines than required by fragment"}
+ }
+ return applyError(err, lineNum(start+int64(n)))
+ }
+
+ // copy leading data before the fragment starts
+ for i, line := range preimage[:fragStart-start] {
+ if _, err := a.dst.Write(line); err != nil {
+ a.nextLine = start + int64(i)
+ return applyError(err, lineNum(a.nextLine))
+ }
+ }
+ preimage = preimage[fragStart-start:]
+
+ // apply the changes in the fragment
+ used := int64(0)
+ for i, line := range f.Lines {
+ if err := applyTextLine(a.dst, line, preimage, used); err != nil {
+ a.nextLine = fragStart + used
+ return applyError(err, lineNum(a.nextLine), fragLineNum(i))
+ }
+ if line.Old() {
+ used++
+ }
+ }
+ a.nextLine = fragStart + used
+
+ // new position of +0,0 mean a full delete, so check for leftovers
+ if f.NewPosition == 0 && f.NewLines == 0 {
+ var b [1][]byte
+ n, err := a.lineSrc.ReadLinesAt(b[:], a.nextLine)
+ if err != nil && err != io.EOF {
+ return applyError(err, lineNum(a.nextLine))
+ }
+ if n > 0 {
+ return applyError(&Conflict{"src still has content after full delete"}, lineNum(a.nextLine))
+ }
+ }
+
+ return nil
+}
+
+func applyTextLine(dst io.Writer, line Line, preimage [][]byte, i int64) (err error) {
+ if line.Old() && string(preimage[i]) != line.Line {
+ return &Conflict{"fragment line does not match src line"}
+ }
+ if line.New() {
+ _, err = io.WriteString(dst, line.Line)
+ }
+ return err
+}
+
+// Close writes any data following the last applied fragment and prevents
+// future calls to ApplyFragment.
+func (a *TextApplier) Close() (err error) {
+ if a.closed {
+ return nil
+ }
+
+ a.closed = true
+ if !a.dirty {
+ _, err = copyFrom(a.dst, a.src, 0)
+ } else {
+ _, err = copyLinesFrom(a.dst, a.lineSrc, a.nextLine)
+ }
+ return err
+}