package helpers import ( "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 " // 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 before the first // "diff --git", is left ok=false. func parseAllDiffLinesFromBuffer(bufferLines []string) []bufferLineParse { result := make([]bufferLineParse, len(bufferLines)) for i := 0; i < len(bufferLines); { if !strings.HasPrefix(bufferLines[i], diffFilePrefix) { i++ // not in a file section yet; leave it unresolved continue } _, end := fileSectionBounds(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 } // fileSectionBounds returns the half-open range [start, end) of the file section // containing targetIdx: the nearest "diff --git" at or above it, up to the next // one (or the end of the buffer). start is -1 when targetIdx is before the first // file section. func fileSectionBounds(bufferLines []string, targetIdx int) (start, end int) { start = -1 for i := targetIdx; i >= 0; i-- { if strings.HasPrefix(bufferLines[i], diffFilePrefix) { start = i break } } if start == -1 { return -1, -1 } end = len(bufferLines) for i := start + 1; i < len(bufferLines); i++ { if strings.HasPrefix(bufferLines[i], diffFilePrefix) { end = i break } } return start, end } // parseFileSection parses one file's diff section (fileLines, starting at its // "diff --git" line) 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. 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 file's diff section. // It prefers the "+++ b/" line, falling back to "--- a/" 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 { 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 "" }