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) }