Download
235 lines 7.8 KiBGoModified 2026-10-31 raw
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
}