Skip to content

Commit debe110

Browse files
committed
Add String() methods to parsed types
This enables clients to move back and forth between parsed objects and text patches. The generated patches are semantically equal to the parsed object and should parse to the same object, but may not be byte-for-byte identical to the original input.
1 parent 0a4e55f commit debe110

File tree

3 files changed

+214
-1
lines changed

3 files changed

+214
-1
lines changed

Diff for: gitdiff/gitdiff.go

+134-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"os"
7+
"strings"
78
)
89

910
// File describes changes to a single file. It can be either a text file or a
@@ -38,6 +39,119 @@ type File struct {
3839
ReverseBinaryFragment *BinaryFragment
3940
}
4041

42+
// String returns a git diff representation of this file. The value can be
43+
// parsed by this library to obtain the same File, but may not be the same as
44+
// the original input or the same as what Git would produces
45+
func (f *File) String() string {
46+
var diff strings.Builder
47+
48+
diff.WriteString("diff --git ")
49+
50+
var aName, bName string
51+
switch {
52+
case f.OldName == "":
53+
aName = f.NewName
54+
bName = f.NewName
55+
56+
case f.NewName == "":
57+
aName = f.OldName
58+
bName = f.OldName
59+
60+
default:
61+
aName = f.OldName
62+
bName = f.NewName
63+
}
64+
65+
writeQuotedName(&diff, "a/"+aName)
66+
diff.WriteByte(' ')
67+
writeQuotedName(&diff, "b/"+bName)
68+
diff.WriteByte('\n')
69+
70+
diff.WriteString("--- ")
71+
if f.OldName == "" {
72+
diff.WriteString("/dev/null")
73+
} else {
74+
writeQuotedName(&diff, f.OldName)
75+
}
76+
diff.WriteByte('\n')
77+
78+
diff.WriteString("+++ ")
79+
if f.NewName == "" {
80+
diff.WriteString("/dev/null")
81+
} else {
82+
writeQuotedName(&diff, f.NewName)
83+
}
84+
diff.WriteByte('\n')
85+
86+
if f.OldMode != 0 {
87+
if f.IsDelete {
88+
fmt.Fprintf(&diff, "deleted file mode %o\n", f.OldMode)
89+
} else {
90+
fmt.Fprintf(&diff, "old mode %o\n", f.OldMode)
91+
}
92+
}
93+
94+
if f.NewMode != 0 {
95+
if f.IsNew {
96+
fmt.Fprintf(&diff, "new file mode %o\n", f.NewMode)
97+
} else {
98+
fmt.Fprintf(&diff, "new mode %o\n", f.NewMode)
99+
}
100+
}
101+
102+
if f.Score > 0 {
103+
if f.IsCopy || f.IsRename {
104+
fmt.Fprintf(&diff, "similarity index %d%%\n", f.Score)
105+
} else {
106+
fmt.Fprintf(&diff, "dissimilarity index %d%%\n", f.Score)
107+
}
108+
}
109+
110+
if f.IsCopy {
111+
if f.OldName != "" {
112+
diff.WriteString("copy from ")
113+
writeQuotedName(&diff, f.OldName)
114+
diff.WriteByte('\n')
115+
}
116+
if f.NewName != "" {
117+
diff.WriteString("copy to ")
118+
writeQuotedName(&diff, f.NewName)
119+
diff.WriteByte('\n')
120+
}
121+
}
122+
123+
if f.IsRename {
124+
if f.OldName != "" {
125+
diff.WriteString("rename from ")
126+
writeQuotedName(&diff, f.OldName)
127+
diff.WriteByte('\n')
128+
}
129+
if f.NewName != "" {
130+
diff.WriteString("rename to ")
131+
writeQuotedName(&diff, f.NewName)
132+
diff.WriteByte('\n')
133+
}
134+
}
135+
136+
if f.OldOIDPrefix != "" && f.NewOIDPrefix != "" {
137+
fmt.Fprintf(&diff, "index %s..%s", f.OldOIDPrefix, f.NewOIDPrefix)
138+
if f.OldMode != 0 {
139+
fmt.Fprintf(&diff, " %o", f.OldMode)
140+
}
141+
diff.WriteByte('\n')
142+
}
143+
144+
if f.IsBinary {
145+
// TODO(bkeyes): add string method for BinaryFragments
146+
} else {
147+
for _, frag := range f.TextFragments {
148+
diff.WriteString(frag.String())
149+
}
150+
}
151+
152+
return diff.String()
153+
}
154+
41155
// TextFragment describes changed lines starting at a specific line in a text file.
42156
type TextFragment struct {
43157
Comment string
@@ -57,7 +171,26 @@ type TextFragment struct {
57171
Lines []Line
58172
}
59173

60-
// Header returns the canonical header of this fragment.
174+
// String returns a git diff format of this fragment. See [File.String] for
175+
// more details on this format.
176+
func (f *TextFragment) String() string {
177+
var diff strings.Builder
178+
179+
diff.WriteString(f.Header())
180+
diff.WriteString("\n")
181+
182+
for _, line := range f.Lines {
183+
diff.WriteString(line.String())
184+
if line.NoEOL() {
185+
diff.WriteString("\n\\ No newline at end of file\n")
186+
}
187+
}
188+
189+
return diff.String()
190+
}
191+
192+
// Header returns a git diff header of this fragment. See [File.String] for
193+
// more details on this format.
61194
func (f *TextFragment) Header() string {
62195
return fmt.Sprintf("@@ -%d,%d +%d,%d @@ %s", f.OldPosition, f.OldLines, f.NewPosition, f.NewLines, f.Comment)
63196
}

Diff for: gitdiff/quote.go

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package gitdiff
2+
3+
import (
4+
"strings"
5+
)
6+
7+
// writeQuotedName writes s to b, quoting it using C-style octal escapes if necessary.
8+
func writeQuotedName(b *strings.Builder, s string) {
9+
qpos := 0
10+
for i := 0; i < len(s); i++ {
11+
ch := s[i]
12+
if q, quoted := quoteByte(ch); quoted {
13+
if qpos == 0 {
14+
b.WriteByte('"')
15+
}
16+
b.WriteString(s[qpos:i])
17+
b.Write(q)
18+
qpos = i + 1
19+
}
20+
}
21+
b.WriteString(s[qpos:])
22+
if qpos > 0 {
23+
b.WriteByte('"')
24+
}
25+
}
26+
27+
var quoteEscapeTable = map[byte]byte{
28+
'\a': 'a',
29+
'\b': 'b',
30+
'\t': 't',
31+
'\n': 'n',
32+
'\v': 'v',
33+
'\f': 'f',
34+
'\r': 'r',
35+
'"': '"',
36+
'\\': '\\',
37+
}
38+
39+
func quoteByte(b byte) ([]byte, bool) {
40+
if q, ok := quoteEscapeTable[b]; ok {
41+
return []byte{'\\', q}, true
42+
}
43+
if b < 0x20 || b >= 0x7F {
44+
return []byte{
45+
'\\',
46+
'0' + (b>>6)&0o3,
47+
'0' + (b>>3)&0o7,
48+
'0' + (b>>0)&0o7,
49+
}, true
50+
}
51+
return nil, false
52+
}

Diff for: gitdiff/quote_test.go

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package gitdiff
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestWriteQuotedName(t *testing.T) {
9+
tests := []struct {
10+
Input string
11+
Expected string
12+
}{
13+
{"noquotes.txt", `noquotes.txt`},
14+
{"no quotes.txt", `no quotes.txt`},
15+
{"new\nline", `"new\nline"`},
16+
{"escape\x1B null\x00", `"escape\033 null\000"`},
17+
{"snowman \u2603 snowman", `"snowman \342\230\203 snowman"`},
18+
{"\"already quoted\"", `"\"already quoted\""`},
19+
}
20+
21+
for _, test := range tests {
22+
var b strings.Builder
23+
writeQuotedName(&b, test.Input)
24+
if b.String() != test.Expected {
25+
t.Errorf("expected %q, got %q", test.Expected, b.String())
26+
}
27+
}
28+
}

0 commit comments

Comments
 (0)