Files
lazygit/pkg/gui/controllers/helpers/diff_line_parser.go
T
Stefan HallerandClaude Opus 5 2770b2e5a2 Take a diff renderer at its word about a diff line
Where a renderer states which line of which file it is rendering, take
that as the line's identity. The renderer knows, and for a rendering
that no longer looks like a diff nothing else does. Reading the rendered
text stays the way for the renderings that keep a diff's structure, and
for the renderers that say nothing at all.

Which of the two is used is settled for the rendering as a whole, not
row by row. A rendering with records is not parsed at all, not even for
the rows the renderer says nothing about. Its text is no unified diff,
and a row of it can read like one. Under delta, a commit that adds a
test whose input is a diff shows the test's "diff --git a/img.png
b/img.png" line as a row of its own; parsed, that row opens a file
section that runs to the end of the rendering, and every row delta puts
between hunks and files comes out as a header of a file called img.png.
So the untagged rows of such a rendering stay unresolved, as the
protocol has it (spec section 6.4).

Header records are accepted too. This lets a renderer point at a file
with no content lines, such as a pure rename, a mode change or a binary
file: for those files the header is the only row in the diff.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 09:54:56 +02:00

413 lines
15 KiB
Go

package helpers
import (
"regexp"
"slices"
"strconv"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
// diffFilePrefix marks the start of a file's section in a (possibly multi-file)
// unified diff.
const diffFilePrefix = "diff --git "
// submodulePrefix opens the line a submodule's section of a diff starts with, which
// stands in for the "diff --git" header a file of the repo gets.
const submodulePrefix = "Submodule "
// submoduleSectionPattern matches that line and captures the submodule's path. git
// writes one of two kinds: the commit the submodule is checked out at has moved
// ("Submodule sub a32f27c..2d9f921:", with "..." in place of ".." where the move is
// no fast-forward, " (rewind)" where it goes backwards, and a message in brackets in
// place of the colon where the two commits can't both be read), or its working tree
// is dirty ("Submodule sub contains untracked content").
//
// The path is captured greedily: git writes it unquoted, so a path that itself ends
// in something reading like a range of commits is told apart by taking the last such
// range on the line.
var submoduleSectionPattern = regexp.MustCompile(
`^Submodule (.+) (?:contains (?:untracked|modified) content|[0-9a-f]+\.{2,3}[0-9a-f]+(?: \(.*\))?:?)$`)
// parsedDiffLine is what the parser recovers about a row of a rendered diff.
// Path is the path as the diff header spells it, i.e. relative to the repo root;
// the caller turns it into the absolute path of types.DiffLineInfo.
type parsedDiffLine struct {
Path string
Type types.DiffLineType
NewLine int
OldLine int
}
// bufferLineParse is the parser's result for one buffer line: the recovered
// identity, and whether the line could be resolved at all (false for a line in
// an unparseable section, or outside any file section).
type bufferLineParse struct {
parsed parsedDiffLine
ok bool
}
// parseDiffLineFromBuffer recovers the identity of a row of a rendered diff by
// parsing the view's decolorized contents.
//
// bufferLines is the full unwrapped view buffer; targetIdx is the buffer line to
// resolve. A commit's diff spans several files, so we isolate the file section
// containing targetIdx and parse just that one (see parseFileSection). Use this
// for a single line, e.g. the one under the cursor; to resolve every line of a
// buffer, use parseAllDiffLinesFromBuffer, which parses each section only once.
//
// ok is false when the buffer isn't a parseable unified diff at targetIdx,
// because the diff renderer restructured it, so that the caller can fall back.
func parseDiffLineFromBuffer(bufferLines []string, targetIdx int) (parsedDiffLine, bool) {
if targetIdx < 0 || targetIdx >= len(bufferLines) {
return parsedDiffLine{}, false
}
start, end := fileSectionBounds(bufferLines, targetIdx)
if start == -1 {
return parsedDiffLine{}, false
}
r := parseFileSection(bufferLines[start:end], end == len(bufferLines))[targetIdx-start]
return r.parsed, r.ok
}
// parseAllDiffLinesFromBuffer resolves every line of a (possibly multi-file)
// diff buffer in one pass, parsing each file section exactly once. It is the
// batch form of parseDiffLineFromBuffer, for callers that scan a whole buffer:
// resolving line by line would re-parse a section once per line of it — O(n²) on
// a large single-file diff — whereas this is O(n). The result is indexed 1:1
// with bufferLines; a line in an unparseable section, or above the first one, is
// left ok=false.
func parseAllDiffLinesFromBuffer(bufferLines []string) []bufferLineParse {
result := make([]bufferLineParse, len(bufferLines))
for i := 0; i < len(bufferLines); {
if !startsFileSection(bufferLines[i]) {
i++ // in no file section; leave it unresolved
continue
}
end := fileSectionEnd(bufferLines, i)
copy(result[i:end], parseFileSection(bufferLines[i:end], end == len(bufferLines)))
i = end
}
return result
}
// diffLineTexts extracts the text of each rendered row — the material the buffer
// parser works on.
func diffLineTexts(contents []gocui.DiffLineContent) []string {
texts := make([]string, len(contents))
for i, content := range contents {
texts[i] = content.Text
}
return texts
}
// renderingStatesDiffLines reports whether the renderer stated, for at least one row
// of the rendering, which diff line it shows. The version-only record a renderer
// announces the protocol with names no line, and doesn't count.
//
// The answer settles how the whole rendering is read. A renderer that states its
// lines lays the diff out as it likes, so its text is no unified diff and must not be
// parsed as one, not even for the rows it says nothing about. Such a row can read
// like a diff header when the file being diffed is itself a diff; the parser would
// take that for the start of a file section and place every untagged row below it
// in a file the diff doesn't have. So the rows a renderer leaves untagged (dividers,
// padding) have no identity, as the protocol has it. A rendering without any record
// is a diff that describes itself, and is parsed as one.
func renderingStatesDiffLines(contents []gocui.DiffLineContent) bool {
return slices.ContainsFunc(contents, func(content gocui.DiffLineContent) bool {
return slices.ContainsFunc(content.Metadata, func(record string) bool {
_, ok := parseDiffLineMetadata(record)
return ok
})
})
}
// fileSectionBounds returns the half-open range [start, end) of the file section
// containing targetIdx: the nearest line starting a section at or above it, up to
// where that section ends. start is -1 when targetIdx is above the first file
// section, or below the end of the last one that begins above it.
func fileSectionBounds(bufferLines []string, targetIdx int) (start, end int) {
for start = targetIdx; start >= 0; start-- {
if !startsFileSection(bufferLines[start]) {
continue
}
if end = fileSectionEnd(bufferLines, start); targetIdx < end {
return start, end
}
return -1, -1
}
return -1, -1
}
// fileSectionEnd returns the line the file section beginning at start ends before.
//
// A file's section runs to the next one, since every line between them is part of
// its diff. A submodule's runs only as far as what git writes for it — the line
// naming it, and the log of the commits it moved over — because the lines after
// that need not belong to any section at all. A diff renderer's output has no
// "diff --git" line to stop at, and the rows it puts between one file and the next
// belong to neither.
func fileSectionEnd(bufferLines []string, start int) int {
if submodulePath(bufferLines[start]) != "" {
end := start + 1
for end < len(bufferLines) && isSubmoduleLogLine(bufferLines[end]) {
end++
}
return end
}
for i := start + 1; i < len(bufferLines); i++ {
if startsFileSection(bufferLines[i]) {
return i
}
}
return len(bufferLines)
}
// startsFileSection reports whether the line opens a section of a diff: git's header
// for a file of the repo, or the line a submodule's section begins with.
func startsFileSection(line string) bool {
return strings.HasPrefix(line, diffFilePrefix) || submodulePath(line) != ""
}
// isSubmoduleLogLine reports whether the line is one of the commits git lists under
// a submodule's header, which it writes as two spaces, the direction the commit was
// moved in, and the commit's subject.
func isSubmoduleLogLine(line string) bool {
return strings.HasPrefix(line, " > ") || strings.HasPrefix(line, " < ")
}
// submodulePath returns the submodule whose section the given line opens, and "" for
// every other line.
//
// A submodule gets no "diff --git" header and no hunks: git states which commits it
// moved between and lists them, so that one line is all there is to take the path
// from. The prefix is tested first so that the pattern is run over next to no lines
// of a diff.
func submodulePath(line string) string {
if !strings.HasPrefix(line, submodulePrefix) {
return ""
}
if match := submoduleSectionPattern.FindStringSubmatch(line); match != nil {
return match[1]
}
return ""
}
// parseFileSection parses one file's diff section (fileLines, starting at the line
// that opens it) a single time and returns the identity of each of its
// lines, indexed 1:1 with fileLines. patch.Parse's line indices line up with the
// section's buffer lines, so the type and the old/new line numbers fall out of
// the patch arithmetic. A submodule's section has no hunks at all, so every row of
// it comes out as a header of the submodule, which is what they are: what git states
// there is which commits it moved between, not lines of a file.
//
// Every line is left ok=false when the section has no
// recoverable path or isn't a well-formed unified diff — the rendering
// restructured it, and acting on a mis-parse would land us on the wrong line, so
// the caller should fall back.
//
// endsTheBuffer says the section runs to the end of what we were given. That is
// where a diff we have only part of breaks off. A long one is read a screenful
// at a time and the rest as the user scrolls, so its last hunk holds fewer lines
// than its header declares until the reading is done. Insisting on the whole
// hunk there would leave every line of the file unresolved while the diff is the
// one on screen, so a section in that position is held to what has arrived.
func parseFileSection(fileLines []string, endsTheBuffer bool) []bufferLineParse {
result := make([]bufferLineParse, len(fileLines))
relPath := pathFromDiffHeader(fileLines)
if relPath == "" {
return result
}
p := patch.Parse(strings.Join(fileLines, "\n"))
isWellFormed := p.IsWellFormed
if endsTheBuffer {
isWellFormed = p.IsWellFormedSoFar
}
if !isWellFormed() {
return result
}
patchLines := p.Lines()
for i := range fileLines {
if i >= len(patchLines) {
break
}
parsed := parsedDiffLine{
Path: relPath,
Type: diffLineTypeForKind(patchLines[i].Kind),
NewLine: p.LineNumberOfLine(i),
}
if parsed.Type == types.DiffLineDeleted {
parsed.OldLine = p.OldLineNumberOfLine(i)
}
result[i] = bufferLineParse{parsed, true}
}
return result
}
func diffLineTypeForKind(kind patch.PatchLineKind) types.DiffLineType {
switch kind {
case patch.PATCH_HEADER:
return types.DiffLineFileHeader
case patch.HUNK_HEADER:
return types.DiffLineHunkHeader
case patch.ADDITION:
return types.DiffLineAdded
case patch.DELETION:
return types.DiffLineDeleted
case patch.CONTEXT:
return types.DiffLineContext
default:
return types.DiffLineOther
}
}
// pathFromDiffHeader extracts the new-file path of a single diff section. A
// submodule's section states its path in the line it opens with. For a file of the
// repo the path comes from the "+++ b/<path>" line, falling back to "--- a/<path>"
// when the new path is /dev/null (a deleted file), and to the "diff --git" line when
// there are no such lines at all (a pure rename, which has no hunks).
func pathFromDiffHeader(fileLines []string) string {
if path := submodulePath(fileLines[0]); path != "" {
return path
}
var oldPath, newPath string
for _, line := range fileLines {
if strings.HasPrefix(line, "@@") {
break // past the header
}
switch {
case strings.HasPrefix(line, "+++ "):
newPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "+++ "))
case strings.HasPrefix(line, "--- "):
oldPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "--- "))
}
}
if newPath != "" && newPath != "/dev/null" {
return newPath
}
if oldPath != "" && oldPath != "/dev/null" {
return oldPath
}
return pathFromDiffGitLine(fileLines[0])
}
// pathFromDiffHeaderField decodes one path field of a diff header — the part
// after "--- " or "+++ ", or one of the two paths on the "diff --git" line —
// into the repo-relative path it names.
//
// git spells such a field in three ways: plain; terminated by a tab, when the
// path contains a space; or C-quoted as a whole, when the path contains
// characters git won't print raw — which, with core.quotePath enabled (the
// default), includes every non-ASCII byte, so `café` arrives as
// `"b/caf\303\251"`. The quoting is Go's string syntax, octal escapes included,
// so strconv decodes it for us.
//
// Returns "" for a quoted field we can't decode: better to resolve nothing than
// to point a consumer at a path that doesn't exist.
func pathFromDiffHeaderField(field string) string {
field = strings.TrimSuffix(field, "\t")
if strings.HasPrefix(field, `"`) {
unquoted, err := strconv.Unquote(field)
if err != nil {
return ""
}
field = unquoted
}
return stripDiffPathPrefix(field)
}
// stripDiffPathPrefix removes the a/ or b/ prefix git puts on the paths in a
// diff header. We ask git for these prefixes explicitly (diff.noprefix=false),
// so they are always there.
func stripDiffPathPrefix(path string) string {
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
return path[2:]
}
return path
}
// parseDiffLineMetadata parses the payload of an OSC 1717 record, in which a
// diff renderer states which line of which file it is rendering. The v1 payload
// is positional and ';'-delimited:
//
// version;type;new-line;old-line;file
//
// The file comes last so that it may itself contain a ';'. The old-file line is
// empty unless the line is a deletion, the only kind that needs it, and the
// new-file line is empty on a file header, the one kind that has no line.
//
// ok is false for a payload of an unknown version or shape, so that the caller
// can fall back to reading the rendered text.
func parseDiffLineMetadata(payload string) (parsedDiffLine, bool) {
fields := strings.SplitN(payload, ";", 5)
if len(fields) < 5 || fields[0] != "1" {
return parsedDiffLine{}, false
}
lineType, ok := diffLineTypeFromMetadata(fields[1])
if !ok {
return parsedDiffLine{}, false
}
newLine := 0
if fields[2] != "" {
var err error
if newLine, err = strconv.Atoi(fields[2]); err != nil {
return parsedDiffLine{}, false
}
} else if lineType != types.DiffLineFileHeader {
return parsedDiffLine{}, false
}
oldLine := 0
if fields[3] != "" {
var err error
if oldLine, err = strconv.Atoi(fields[3]); err != nil {
return parsedDiffLine{}, false
}
}
return parsedDiffLine{Path: fields[4], Type: lineType, NewLine: newLine, OldLine: oldLine}, true
}
func diffLineTypeFromMetadata(typeField string) (types.DiffLineType, bool) {
switch typeField {
case "c":
return types.DiffLineContext, true
case "a":
return types.DiffLineAdded, true
case "d":
return types.DiffLineDeleted, true
case "f":
return types.DiffLineFileHeader, true
case "h":
return types.DiffLineHunkHeader, true
default:
return types.DiffLineOther, false
}
}
// pathFromDiffGitLine extracts the new-file path from a "diff --git a/X b/X"
// line, where the two paths are separated by a space and either may be quoted.
// A path containing " b/" (or ` "b/`) would defeat this, but the +++/--- lines
// are unambiguous and we only get here when they are absent.
func pathFromDiffGitLine(line string) string {
rest := strings.TrimPrefix(line, diffFilePrefix)
if idx := strings.LastIndex(rest, ` "b/`); idx != -1 {
return pathFromDiffHeaderField(rest[idx+1:])
}
if idx := strings.LastIndex(rest, " b/"); idx != -1 {
return pathFromDiffHeaderField(rest[idx+1:])
}
return ""
}