package prosediff
import (
"fmt"
"strings"
"unicode/utf8"
)
// RenderOptions controls the plain-text renderer. It exists for tests and for
// reading a diff in a terminal; the web layer walks Diff.Changes itself.
type RenderOptions struct {
// Width wraps rendered prose. 0 means do not wrap.
Width int
// ShowEqual prints unchanged blocks too, instead of collapsing them.
ShowEqual bool
// Context is how many unchanged blocks to keep around a change when
// ShowEqual is false. 0 keeps none.
Context int
// Markers are the inline delete/insert brackets. Empty uses the
// defaults "[-", "-]", "{+", "+}".
DelOpen, DelClose, InsOpen, InsClose string
}
// DefaultRenderOptions wraps at 80 columns and hides unchanged blocks.
func DefaultRenderOptions() RenderOptions {
return RenderOptions{Width: 80, Context: 0}
}
func (o RenderOptions) markers() (string, string, string, string) {
d0, d1, i0, i1 := o.DelOpen, o.DelClose, o.InsOpen, o.InsClose
if d0 == "" && d1 == "" && i0 == "" && i1 == "" {
return "[-", "-]", "{+", "+}"
}
return d0, d1, i0, i1
}
// RenderText renders a diff as plain text.
func RenderText(d *Diff, opts RenderOptions) string {
var sb strings.Builder
keep := visible(d.Changes, opts)
lastPath := "\x00"
skipped := 0
flushSkipped := func() {
if skipped > 0 {
fmt.Fprintf(&sb, " … %d unchanged block(s)\n", skipped)
skipped = 0
}
}
for i, c := range d.Changes {
if !keep[i] {
if c.Kind == ChangeEqual {
skipped++
}
continue
}
flushSkipped()
b := c.New
if b == nil {
b = c.Old
}
if p := strings.Join(b.HeadingPath, " › "); p != lastPath {
if p == "" {
p = "(document preamble)"
}
fmt.Fprintf(&sb, "\n@@ %s @@\n", p)
lastPath = p
}
sb.WriteString(renderChange(c, opts))
}
flushSkipped()
return sb.String()
}
func visible(changes []BlockChange, opts RenderOptions) []bool {
keep := make([]bool, len(changes))
for i, c := range changes {
if c.Kind != ChangeEqual || opts.ShowEqual {
keep[i] = true
}
}
if opts.Context > 0 && !opts.ShowEqual {
orig := append([]bool(nil), keep...)
for i := range changes {
if !orig[i] {
continue
}
for j := i - opts.Context; j <= i+opts.Context; j++ {
if j >= 0 && j < len(keep) {
keep[j] = true
}
}
}
}
return keep
}
func renderChange(c BlockChange, opts RenderOptions) string {
var sb strings.Builder
d0, d1, i0, i1 := opts.markers()
switch c.Kind {
case ChangeEqual:
fmt.Fprintf(&sb, " %s %s\n", loc(c.Old, c.New), c.New.Label())
writeBody(&sb, " ", c.New.Text, opts.Width, c.New.Kind.Prose())
case ChangeInsert:
fmt.Fprintf(&sb, "+ %s %s\n", loc(nil, c.New), c.New.Label())
writeBody(&sb, "+ ", c.New.Text, opts.Width, c.New.Kind.Prose())
case ChangeDelete:
fmt.Fprintf(&sb, "- %s %s\n", loc(c.Old, nil), c.Old.Label())
writeBody(&sb, "- ", c.Old.Text, opts.Width, c.Old.Kind.Prose())
case ChangeMoveOut:
fmt.Fprintf(&sb, "< %s %s moved away (now line %d)\n",
loc(c.Old, nil), c.Old.Label(), c.New.StartLine)
case ChangeMoveIn:
fmt.Fprintf(&sb, "> %s %s moved here (was line %d)\n",
loc(nil, c.New), c.New.Label(), c.Old.StartLine)
case ChangeModify:
note := ""
if c.StructureOnly {
note = fmt.Sprintf(" (structure only: %s → %s)", c.Old.Label(), c.New.Label())
}
if c.Moved {
note += fmt.Sprintf(" (moved from line %d)", c.Old.StartLine)
}
fmt.Fprintf(&sb, "~ %s %s modified%s\n", loc(c.Old, c.New), c.New.Label(), note)
if len(c.Lines) > 0 {
for _, s := range c.Lines {
switch s.Op {
case OpEqual:
fmt.Fprintf(&sb, " %s\n", s.Text)
case OpDelete:
fmt.Fprintf(&sb, " - %s\n", s.Text)
case OpInsert:
fmt.Fprintf(&sb, " + %s\n", s.Text)
}
}
break
}
var body strings.Builder
for _, s := range c.Words {
if s.Space && body.Len() > 0 {
body.WriteByte(' ')
}
switch s.Op {
case OpEqual:
body.WriteString(s.Text)
case OpDelete:
body.WriteString(d0 + s.Text + d1)
case OpInsert:
body.WriteString(i0 + s.Text + i1)
}
}
writeBody(&sb, " ", body.String(), opts.Width, true)
}
return sb.String()
}
func loc(old, nw *Block) string {
switch {
case old != nil && nw != nil:
if old.StartLine == nw.StartLine {
return fmt.Sprintf("L%d", old.StartLine)
}
return fmt.Sprintf("L%d→%d", old.StartLine, nw.StartLine)
case old != nil:
return fmt.Sprintf("L%d", old.StartLine)
case nw != nil:
return fmt.Sprintf("L%d", nw.StartLine)
}
return "L?"
}
func writeBody(sb *strings.Builder, prefix, body string, width int, reflow bool) {
var lines []string
if reflow {
lines = wrap(body, width-len(prefix))
} else {
lines = strings.Split(body, "\n")
}
for _, line := range lines {
sb.WriteString(prefix)
sb.WriteString(line)
sb.WriteByte('\n')
}
}
// wrap reflows prose to width columns. Prose is always rewrapped: a diff that
// preserved the source wrapping would put the reviewer back where a line
// differ left them.
func wrap(s string, width int) []string {
fields := strings.Fields(s)
if len(fields) == 0 {
return []string{""}
}
if width <= 0 {
return []string{strings.Join(fields, " ")}
}
var out []string
line := fields[0]
for _, f := range fields[1:] {
if utf8.RuneCountInString(line)+1+utf8.RuneCountInString(f) > width {
out = append(out, line)
line = f
continue
}
line += " " + f
}
return append(out, line)
}