123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 | // Package diffview shows unified diffs, and other text the user must judge,
// on a terminal.
package diffview
import (
"fmt"
"math"
"strconv"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
// Styles are the colors of a diff's parts.
type Styles struct {
Add, Del lipgloss.Style // an added or removed line
AddEmph, DelEmph lipgloss.Style // the part of a changed line that changed
Hunk, Dim lipgloss.Style // hunk headers, and file headers
}
// Dark and Light are Styles for a dark and a light background. Dim, ANSI
// 8, is barely readable on many dark themes, so it is a gray instead.
var (
Dark = Styles{
Add: lipgloss.NewStyle().Background(lipgloss.Color("#002800")),
Del: lipgloss.NewStyle().Background(lipgloss.Color("#3f0001")),
AddEmph: lipgloss.NewStyle().Background(lipgloss.Color("#006000")),
DelEmph: lipgloss.NewStyle().Background(lipgloss.Color("#901011")),
Hunk: lipgloss.NewStyle().Foreground(lipgloss.Color("6")),
Dim: lipgloss.NewStyle().Foreground(lipgloss.Color("250")),
}
Light = Styles{
Add: lipgloss.NewStyle().Background(lipgloss.Color("#d0ffd0")),
Del: lipgloss.NewStyle().Background(lipgloss.Color("#ffe0e0")),
AddEmph: lipgloss.NewStyle().Background(lipgloss.Color("#a0efa0")),
DelEmph: lipgloss.NewStyle().Background(lipgloss.Color("#ffc0c0")),
Hunk: lipgloss.NewStyle().Foreground(lipgloss.Color("6")),
Dim: lipgloss.NewStyle().Foreground(lipgloss.Color("242")),
}
)
// Render shows a unified diff the way delta does, for a terminal width
// columns wide, keeping at most limit lines (0 for all): added and removed lines on a green or red background
// that runs to the edge of the screen, with the part of a changed line
// that differs from the line it replaces on a brighter one. Long lines
// wrap, so nothing is cut off. The diff's text must be safe to show: the
// caller strips control characters from it, and makes tabs spaces.
func Render(d string, limit, width int, s Styles) string {
lines := strings.Split(strings.TrimRight(d, "\n"), "\n")
more := 0
if limit > 0 && len(lines) > limit {
lines, more = lines[:limit], len(lines)-limit
}
var out []string
// The old and new lines left in the hunk, as its header counts them:
// within it, "---" is a removed "--"; past it, the next file's header.
oldLeft, newLeft := 0, 0
for i := 0; i < len(lines); {
line := lines[i]
inHunk := oldLeft > 0 || newLeft > 0
switch {
case !inHunk && (strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---")):
out = append(out, s.Dim.Render(" "+line))
i++
case !inHunk && strings.HasPrefix(line, "@@"):
oldLeft, newLeft = hunkCounts(line)
out = append(out, s.Hunk.Render(" "+line))
i++
case inHunk && (strings.HasPrefix(line, "-") || strings.HasPrefix(line, "+")):
// A run of removed lines and the added lines after it. When
// there are as many of each, they are taken to be the same
// lines changed, and paired up to show what changed in each.
dels := run(lines[i:], "-", oldLeft)
adds := run(lines[i+len(dels):], "+", newLeft)
i += len(dels) + len(adds)
oldLeft, newLeft = oldLeft-len(dels), newLeft-len(adds)
paired := len(dels) == len(adds)
for j, del := range dels {
from, to := 0, 0
if paired {
from, to = changed(del[1:], adds[j][1:])
}
out = append(out, diffRow(del, from, to, s.Del, s.DelEmph, width))
}
for j, add := range adds {
from, to := 0, 0
if paired {
from, to = changed(add[1:], dels[j][1:])
}
out = append(out, diffRow(add, from, to, s.Add, s.AddEmph, width))
}
default:
if inHunk && !strings.HasPrefix(line, `\`) { // not "\ No newline at end of file"
oldLeft, newLeft = oldLeft-1, newLeft-1
}
out = append(out, " "+strings.ReplaceAll(ansi.Hardwrap(line, max(width-4, 10), true), "\n", "\n "))
i++
}
}
if more > 0 {
out = append(out, s.Dim.Render(fmt.Sprintf(" … %d more lines", more)))
}
return strings.Join(out, "\n")
}
// run is the lines at the start of lines that begin with prefix, at most
// most of them.
func run(lines []string, prefix string, most int) []string {
n := 0
for n < len(lines) && n < most && strings.HasPrefix(lines[n], prefix) {
n++
}
return lines[:n]
}
// hunkCounts reads the old and new line counts of a hunk's header, as in
// "@@ -1,5 +1,6 @@", where a count left out is 1. One it cannot read runs
// to the end of the diff.
func hunkCounts(header string) (old, new int) {
f := strings.Fields(header)
if len(f) < 3 {
return math.MaxInt, math.MaxInt
}
return hunkCount(f[1], "-"), hunkCount(f[2], "+")
}
func hunkCount(field, sign string) int {
r, ok := strings.CutPrefix(field, sign)
if !ok {
return math.MaxInt
}
_, count, found := strings.Cut(r, ",")
if !found {
return 1
}
n, err := strconv.Atoi(count)
if err != nil || n < 0 {
return math.MaxInt
}
return n
}
// changed finds the runes of a that differ from b: what is left between
// the start and end they have in common. It is empty when the two have
// nothing in common at either end, since then the whole line changed and
// emphasizing all of it would say nothing.
func changed(a, b string) (from, to int) {
ar, br := []rune(a), []rune(b)
for from < len(ar) && from < len(br) && ar[from] == br[from] {
from++
}
end := 0
for end < len(ar)-from && end < len(br)-from && ar[len(ar)-1-end] == br[len(br)-1-end] {
end++
}
if from == 0 && end == 0 {
return 0, 0
}
return from, len(ar) - end
}
// diffRow shows an added or removed line on its background, wrapped to the
// width and padded to its edge, with the runes from..to of its text (after
// the + or -) on the emphasis background.
func diffRow(line string, from, to int, style, emph lipgloss.Style, width int) string {
w := max(width-4, 10)
// Rune i of the line is rune i-1 of its text.
from, to = from+1, to+1
var rows []string
pos := 0
for _, part := range strings.Split(ansi.Hardwrap(line, w, true), "\n") {
var b strings.Builder
rs := []rune(part)
for start := 0; start < len(rs); {
emphasized := pos+start >= from && pos+start < to
end := start + 1
for end < len(rs) && (pos+end >= from && pos+end < to) == emphasized {
end++
}
s := style
if emphasized {
s = emph
}
b.WriteString(s.Render(string(rs[start:end])))
start = end
}
pos += len(rs)
if pad := w - ansi.StringWidth(part); pad > 0 {
b.WriteString(style.Render(strings.Repeat(" ", pad)))
}
rows = append(rows, " "+b.String())
}
return strings.Join(rows, "\n")
}
// Visible shows text the user must judge, such as a script or diff to
// approve, with nothing hidden: every character that would not print
// (escape sequences, other control and format characters, "\r" too) or
// prints as nothing (see blank) is shown as a Go escape, such as \x1b,
// instead of being removed, so it can neither act on the terminal nor
// hide what follows it. Tabs become spaces; newlines stay.
func Visible(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch {
case r == '\n':
b.WriteRune(r)
case r == '\t':
b.WriteString(" ")
case !strconv.IsPrint(r) || blank(r):
q := strconv.QuoteRuneToASCII(r) // QuoteRune leaves blank ones as they are
b.WriteString(q[1 : len(q)-1])
default:
b.WriteRune(r)
}
}
return b.String()
}
// blank reports whether a printable character shows as nothing at all, or
// as a space: fillers that pass for spaces in code (U+3164 is a letter,
// so it can name a variable), and the joiner and variation selectors,
// which add nothing visible to the character before them but can carry
// hidden text.
func blank(r rune) bool {
switch {
case r == '\u115f', r == '\u1160', r == '\u3164', r == '\uffa0', r == '\u2800',
r == '\u034f', r == '\u17b4', r == '\u17b5', r >= '\u180b' && r <= '\u180f',
r >= '\ufe00' && r <= '\ufe0f', r >= '\U000e0100' && r <= '\U000e01ef':
return true
}
return false
}
|