diff options
author | Jesse Duffield <jessedduffield@gmail.com> | 2023-03-19 16:53:02 +1100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-03-19 16:53:02 +1100 |
commit | cef804f27aaaec6a728bf1c0df89df2ffb355669 (patch) | |
tree | cca09391436d59a10ba342d528b4a8a14a5ccf09 | |
parent | b542579db31f160a8d13d255b447d654d253db17 (diff) | |
parent | 60f902f026559b1501b34ea3cfbacaf1fff168bf (diff) |
Merge pull request #2513 from jesseduffield/refactor-patch-handling
31 files changed, 931 insertions, 710 deletions
@@ -18,7 +18,7 @@ require ( github.com/integrii/flaggy v1.4.0 github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68 github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d - github.com/jesseduffield/gocui v0.3.1-0.20230314081453-8d2162479b92 + github.com/jesseduffield/gocui v0.3.1-0.20230319043340-e793609bfbf5 github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10 github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e @@ -72,8 +72,8 @@ github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68 h1:EQP2Tv8T github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68/go.mod h1:+LLj9/WUPAP8LqCchs7P+7X0R98HiFujVFANdNaxhGk= github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d h1:bO+OmbreIv91rCe8NmscRwhFSqkDJtzWCPV4Y+SQuXE= github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d/go.mod h1:nGNEErzf+NRznT+N2SWqmHnDnF9aLgANB1CUNEan09o= -github.com/jesseduffield/gocui v0.3.1-0.20230314081453-8d2162479b92 h1:GO1Cpn3kdnBuUEzEVuvooD9vxmTd9hbyjwJJ6tvafwI= -github.com/jesseduffield/gocui v0.3.1-0.20230314081453-8d2162479b92/go.mod h1:znJuCDnF2Ph40YZSlBwdX/4GEofnIoWLGdT4mK5zRAU= +github.com/jesseduffield/gocui v0.3.1-0.20230319043340-e793609bfbf5 h1:8k7VTfj/RSKwYQ7Cn+iy876CjixBrTyyn+npxT/Wn/Q= +github.com/jesseduffield/gocui v0.3.1-0.20230319043340-e793609bfbf5/go.mod h1:znJuCDnF2Ph40YZSlBwdX/4GEofnIoWLGdT4mK5zRAU= github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10 h1:jmpr7KpX2+2GRiE91zTgfq49QvgiqB0nbmlwZ8UnOx0= github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10/go.mod h1:aA97kHeNA+sj2Hbki0pvLslmE4CbDyhBeSSTUUnOuVo= github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY= diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 35cead260..caf03db75 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -117,14 +117,14 @@ func NewGitCommandAux( workingTreeCommands := git_commands.NewWorkingTreeCommands(gitCommon, submoduleCommands, fileLoader) rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands) stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands) - // TODO: have patch manager take workingTreeCommands in its entirety - patchManager := patch.NewPatchManager(cmn.Log, workingTreeCommands.ApplyPatch, + // TODO: have patch builder take workingTreeCommands in its entirety + patchBuilder := patch.NewPatchBuilder(cmn.Log, workingTreeCommands.ApplyPatch, func(from string, to string, reverse bool, filename string, plain bool) (string, error) { - // TODO: make patch manager take Gui.IgnoreWhitespaceInDiffView into + // TODO: make patch builder take Gui.IgnoreWhitespaceInDiffView into // account. For now we just pass false. return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, plain, false) }) - patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchManager) + patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder) bisectCommands := git_commands.NewBisectCommands(gitCommon) branchLoader := git_commands.NewBranchLoader(cmn, branchCommands.GetRawBranches, branchCommands.CurrentBranchInfo, configCommands) diff --git a/pkg/commands/git_commands/patch.go b/pkg/commands/git_commands/patch.go index 643091f63..7511d1a5b 100644 --- a/pkg/commands/git_commands/patch.go +++ b/pkg/commands/git_commands/patch.go @@ -16,7 +16,7 @@ type PatchCommands struct { status *StatusCommands stash *StashCommands - PatchManager *patch.PatchManager + PatchBuilder *patch.PatchBuilder } func NewPatchCommands( @@ -25,7 +25,7 @@ func NewPatchCommands( commit *CommitCommands, status *StatusCommands, stash *StashCommands, - patchManager *patch.PatchManager, + patchBuilder *patch.PatchBuilder, ) *PatchCommands { return &PatchCommands{ GitCommon: gitCommon, @@ -33,7 +33,7 @@ func NewPatchCommands( commit: commit, status: status, stash: stash, - PatchManager: patchManager, + PatchBuilder: patchBuilder, } } @@ -44,7 +44,7 @@ func (self *PatchCommands) DeletePatchesFromCommit(commits []*models.Commit, com } // apply each patch in reverse - if err := self.PatchManager.ApplyPatches(true); err != nil { + if err := self.PatchBuilder.ApplyPatches(true); err != nil { _ = self.rebase.AbortRebase() return err } @@ -55,7 +55,7 @@ func (self *PatchCommands) DeletePatchesFromCommit(commits []*models.Commit, com } self.rebase.onSuccessfulContinue = func() error { - self.PatchManager.Reset() + self.PatchBuilder.Reset() return nil } @@ -70,7 +70,7 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s } // apply each patch forward - if err := self.PatchManager.ApplyPatches(false); err != nil { + if err := self.PatchBuilder.ApplyPatches(false); err != nil { // Don't abort the rebase here; this might cause conflicts, so give // the user a chance to resolve them return err @@ -82,7 +82,7 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s } self.rebase.onSuccessfulContinue = func() error { - self.PatchManager.Reset() + self.PatchBuilder.Reset() return nil } @@ -117,7 +117,7 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s } // apply each patch in reverse - if err := self.PatchManager.ApplyPatches(true); err != nil { + if err := self.PatchBuilder.ApplyPatches(true); err != nil { _ = self.rebase.AbortRebase() return err } @@ -152,7 +152,7 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s } self.rebase.onSuccessfulContinue = func() error { - self.PatchManager.Reset() + self.PatchBuilder.Reset() return nil } @@ -173,7 +173,7 @@ func (self *PatchCommands) MovePatchIntoIndex(commits []*models.Commit, commitId return err } - if err := self.PatchManager.ApplyPatches(true); err != nil { + if err := self.PatchBuilder.ApplyPatches(true); err != nil { if self.status.WorkingTreeState() == enums.REBASE_MODE_REBASING { _ = self.rebase.AbortRebase() } @@ -210,7 +210,7 @@ func (self *PatchCommands) MovePatchIntoIndex(commits []*models.Commit, commitId } } - self.PatchManager.Reset() + self.PatchBuilder.Reset() return nil } @@ -222,7 +222,7 @@ func (self *PatchCommands) PullPatchIntoNewCommit(commits []*models.Commit, comm return err } - if err := self.PatchManager.ApplyPatches(true); err != nil { + if err := self.PatchBuilder.ApplyPatches(true); err != nil { _ = self.rebase.AbortRebase() return err } @@ -253,7 +253,7 @@ func (self *PatchCommands) PullPatchIntoNewCommit(commits []*models.Commit, comm return errors.New("You are midway through another rebase operation. Please abort to start again") } - self.PatchManager.Reset() + self.PatchBuilder.Reset() return self.rebase.ContinueRebase() } diff --git a/pkg/commands/patch/format.go b/pkg/commands/patch/format.go new file mode 100644 index 000000000..d04b6bec1 --- /dev/null +++ b/pkg/commands/patch/format.go @@ -0,0 +1,169 @@ +package patch + +import ( + "strings" + + "github.com/jesseduffield/generics/set" + "github.com/jesseduffield/lazygit/pkg/gui/style" + "github.com/jesseduffield/lazygit/pkg/theme" + "github.com/samber/lo" +) + +type patchPresenter struct { + patch *Patch + // if true, all following fields are ignored + plain bool + + isFocused bool + // first line index for selected cursor range + firstLineIndex int + // last line index for selected cursor range + lastLineIndex int + // line indices for tagged lines (e.g. lines added to a custom patch) + incLineIndices *set.Set[int] +} + +// formats the patch as a plain string +func formatPlain(patch *Patch) string { + presenter := &patchPresenter{ + patch: patch, + plain: true, + incLineIndices: set.New[int](), + } + return presenter.format() +} + +func formatRangePlain(patch *Patch, startIdx int, endIdx int) string { + lines := patch.Lines()[startIdx : endIdx+1] + return strings.Join( + lo.Map(lines, func(line *PatchLine, _ int) string { + return line.Content + "\n" + }), + "", + ) +} + +type FormatViewOpts struct { + IsFocused bool + // first line index for selected cursor range + FirstLineIndex int + // last line index for selected cursor range + LastLineIndex int + // line indices for tagged lines (e.g. lines added to a custom patch) + IncLineIndices *set.Set[int] +} + +// formats the patch for rendering within a view, meaning it's coloured and +// highlights selected items +func formatView(patch *Patch, opts FormatViewOpts) string { + includedLineIndices := opts.IncLineIndices + if includedLineIndices == nil { + includedLineIndices = set.New[int]() + } + presenter := &patchPresenter{ + patch: patch, + plain: false, + isFocused: opts.IsFocused, + firstLineIndex: opts.FirstLineIndex, + lastLineIndex: opts.LastLineIndex, + incLineIndices: includedLineIndices, + } + return presenter.format() +} + +func (self *patchPresenter) format() string { + // if we have no changes in our patch (i.e. no additions or deletions) then + // the patch is effectively empty and we can return an empty string + if !self.patch.ContainsChanges() { + return "" + } + + stringBuilder := &strings.Builder{} + lineIdx := 0 + appendLine := func(line string) { + _, _ = stringBuilder.WriteString(line + "\n") + + lineIdx++ + } + appendFormattedLine := func(line string, style style.TextStyle) { + formattedLine := self.formatLine( + line, + style, + lineIdx, + ) + + appendLine(formattedLine) + } + + for _, line := range self.patch.header { + appendFormattedLine(line, theme.DefaultTextColor.SetBold()) + } + + for _, hunk := range self.patch.hunks { + appendLine( + self.formatLine( + hunk.formatHeaderStart(), + style.FgCyan, + lineIdx, + ) + + // we're splitting the line into two parts: the diff header and the context + // We explicitly pass 'included' as false here so that we're only tagging the + // first half of the line as included if the line is indeed included. + self.formatLineAux( + hunk.headerContext, + theme.DefaultTextColor, + lineIdx, + false, + ), + ) + + for _, line := range hunk.bodyLines { + appendFormattedLine(line.Content, self.patchLineStyle(line)) + } + } + + return stringBuilder.String() +} + +func (self *patchPresenter) patchLineStyle(patchLine *PatchLine) style.TextStyle { + switch patchLine.Kind { + case ADDITION: + return style.FgGreen + case DELETION: + return style.FgRed + default: + return theme.DefaultTextColor + } +} + +func (self *patchPresenter) formatLine(str string, textStyle style.TextStyle, index int) string { + included := self.incLineIndices.Includes(index) + + return self.formatLineAux(str, textStyle, index, included) +} + +// 'selected' means you've got it highlighted with your cursor +// 'included' means the line has been included in the patch (only applicable when +// building a patch) +func (self *patchPresenter) formatLineAux(str string, textStyle style.TextStyle, index int, included bool) string { + if self.plain { + return str + } + + selected := self.isFocused && index >= self.firstLineIndex && index <= self.lastLineIndex + + if selected { + textStyle = textStyle.MergeStyle(theme.SelectedRangeBgColor) + } + + firstCharStyle := textStyle + if included { + firstCharStyle = firstCharStyle.MergeStyle(style.BgGreen) + } + + if len(str) < 2 { + return firstCharStyle.Sprint(str) + } + + return firstCharStyle.Sprint(str[:1]) + textStyle.Sprint(str[1:]) +} diff --git a/pkg/commands/patch/hunk.go b/pkg/commands/patch/hunk.go index 605c473c1..6d0177d05 100644 --- a/pkg/commands/patch/hunk.go +++ b/pkg/commands/patch/hunk.go @@ -1,130 +1,67 @@ package patch -import ( - "fmt" - "strings" - - "github.com/jesseduffield/lazygit/pkg/utils" - "github.com/samber/lo" -) - -type PatchHunk struct { - FirstLineIdx int - oldStart int - newStart int - heading string - bodyLines []string -} - -func (hunk *PatchHunk) LastLineIdx() int { - return hunk.FirstLineIdx + len(hunk.bodyLines) +import "fmt" + +// Example hunk: +// @@ -16,2 +14,3 @@ func (f *CommitFile) Description() string { +// return f.Name +// -} +// + +// +// test + +type Hunk struct { + // the line number of the first line in the old file ('16' in the above example) + oldStart int + // the line number of the first line in the new file ('14' in the above example) + newStart int + // the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example) + headerContext string + // the body of the hunk, excluding the header line + bodyLines []*PatchLine } -func newHunk(lines []string, firstLineIdx int) *PatchHunk { - header := lines[0] - bodyLines := lines[1:] - - oldStart, newStart, heading := headerInfo(header) - - return &PatchHunk{ - oldStart: oldStart, - newStart: newStart, - heading: heading, - FirstLineIdx: firstLineIdx, - bodyLines: bodyLines, - } +// Returns the number of lines in the hunk in the original file ('2' in the above example) +func (self *Hunk) oldLength() int { + return nLinesWithKind(self.bodyLines, []PatchLineKind{CONTEXT, DELETION}) } -func headerInfo(header string) (int, int, string) { - match := hunkHeaderRegexp.FindStringSubmatch(header) - - oldStart := utils.MustConvertToInt(match[1]) - newStart := utils.MustConvertToInt(match[2]) - heading := match[3] - - return oldStart, newStart, heading +// Returns the number of lines in the hunk in the new file ('3' in the above example) +func (self *Hunk) newLength() int { + return nLinesWithKind(self.bodyLines, []PatchLineKind{CONTEXT, ADDITION}) } -func (hunk *PatchHunk) updatedLines(lineIndices []int, reverse bool) []string { - skippedNewlineMessageIndex := -1 - newLines := []string{} - - lineIdx := hunk.FirstLineIdx - for _, line := range hunk.bodyLines { - lineIdx++ // incrementing at the start to skip the header line - if line == "" { - break - } - isLineSelected := lo.Contains(lineIndices, lineIdx) - - firstChar, content := line[:1], line[1:] - transformedFirstChar := transformedFirstChar(firstChar, reverse, isLineSelected) - - if isLineSelected || (transformedFirstChar == "\\" && skippedNewlineMessageIndex != lineIdx) || transformedFirstChar == " " { - newLines = append(newLines, transformedFirstChar+content) - continue - } - - if transformedFirstChar == "+" { - // we don't want to include the 'newline at end of file' line if it involves an addition we're not including - skippedNewlineMessageIndex = lineIdx + 1 - } - } - - return newLines +// Returns true if the hunk contains any changes (i.e. if it's not just a context hunk). +// We'll end up with a context hunk if we're transforming a patch and one of the hunks +// has no selected lines. +func (self *Hunk) containsChanges() bool { + return nLinesWithKind(self.bodyLines, []PatchLineKind{ADDITION, DELETION}) > 0 } -func transformedFirstChar(firstChar string, reverse bool, isLineSelected bool) string { - linesToKeepInPatchContext := "-" - if reverse { - linesToKeepInPatchContext = "+" - } - if !isLineSelected && firstChar == linesToKeepInPatchContext { - return " " - } - - return firstChar +// Returns the number of lines in the hunk, including the header line +func (self *Hunk) lineCount() int { + return len(self.bodyLines) + 1 } -func (hunk *PatchHunk) formatHeader(oldStart int, oldLength int, newStart int, newLength int, heading string) string { - return fmt.Sprintf("@@ -%d,%d +%d,%d @@%s\n", oldStart, oldLength, newStart, newLength, heading) +// Returns all lines in the hunk, including the header line +func (self *Hunk) allLines() []*PatchLine { + lines := []*PatchLine{{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}} + lines = append(lines, self.bodyLines...) + return lines } -func (hunk *PatchHunk) formatWithChanges(lineIndices []int, reverse bool, startOffset int) (int, string) { - bodyLines := hunk.updatedLines(lineIndices, reverse) - startOffset, header, ok := hunk.updatedHeader(bodyLines, startOffset) - if !ok { - return startOffset, "" - } - return startOffset, header + strings.Join(bodyLines, "") +// Returns the header line, including the unified diff header and the context +func (self *Hunk) formatHeaderLine() string { + return fmt.Sprintf("%s%s", self.formatHeaderStart(), self.headerContext) } -func (hunk *PatchHunk) updatedHeader(newBodyLines []string, startOffset int) (int, string, bool) { - changeCount := nLinesWithPrefix(newBodyLines, []string{"+", "-"}) - oldLength := nLinesWithPrefix(newBodyLines, []string{" ", "-"}) - newLength := nLinesWithPrefix(newBodyLines, []string{"+", " "}) - - if changeCount == 0 { - // if nothing has changed we just return nothing - return startOffset, "", false - } - - oldStart := hunk.oldStart - - var newStartOffset int - // if the hunk went from zero to positive length, we need to increment the starting point by one - // if the hunk went from positive to zero length, we need to decrement the starting point by one - if oldLength == 0 { - newStartOffset = 1 - } else if newLength == 0 { - newStartOffset = -1 - } else { - newStartOffset = 0 +// Returns the first part of the header line i.e. the unified diff part (excluding any context) +func (self *Hunk) formatHeaderStart() string { + newLengthDisplay := "" + newLength := self.newLength() + // if the new length is 1, it's omitted + if newLength != 1 { + newLengthDisplay = fmt.Sprintf(",%d", newLength) } - newStart := oldStart + startOffset + newStartOffset - - newStartOffset = startOffset + newLength - oldLength - formattedHeader := hunk.formatHeader(oldStart, oldLength, newStart, newLength, hunk.heading) - return newStartOffset, formattedHeader, true + return fmt.Sprintf("@@ -%d,%d +%d%s @@", self.oldStart, self.oldLength(), self.newStart, newLengthDisplay) } diff --git a/pkg/commands/patch/parse.go b/pkg/commands/patch/parse.go new file mode 100644 index 000000000..fee7d2918 --- /dev/null +++ b/pkg/commands/patch/parse.go @@ -0,0 +1,85 @@ +package patch + +import ( + "regexp" + "strings" + + "github.com/jesseduffield/lazygit/pkg/utils" +) + +var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`) + +func Parse(patchStr string) *Patch { + // ignore trailing newline. + lines := strings.Split(strings.TrimSuffix(patchStr, "\n"), "\n") + + hunks := []*Hunk{} + patchHeader := []string{} + + var currentHunk *Hunk + for _, line := range lines { + if strings.HasPrefix(line, "@@") { + oldStart, newStart, headerContext := headerInfo(line) + + currentHunk = &Hunk{ + oldStart: oldStart, + newStart: newStart, + headerContext: headerContext, + bodyLines: []*PatchLine{}, + } + hunks = append(hunks, currentHunk) + } else if currentHunk != nil { + currentHunk.bodyLines = append(currentHunk.bodyLines, newHunkLine(line)) + } else { + patchHeader = append(patchHeader, line) + } + } + + return &Patch{ + hunks: hunks, + header: patchHeader, + } +} + +func headerInfo(header string) (int, int, string) { + match := hunkHeaderRegexp.FindStringSubmatch(header) + + oldStart := utils.MustConvertToInt(match[1]) + newStart := utils.MustConvertToInt(match[2]) + headerContext := match[3] + + return oldStart, newStart, headerContext +} + +func newHunkLine(line string) *PatchLine { + if line == "" { + return &PatchLine{ + Kind: CONTEXT, + Content: "", + } + } + + firstChar := line[:1] + + kind := parseFirstChar(firstChar) + + return &PatchLine{ + Kind: kind, + Content: line, + } +} + +func parseFirstChar(firstChar string) PatchLineKind { + switch firstChar { + case " ": + return CONTEXT + case "+": + return ADDITION + case "-": + return DELETION + case "\\": + return NEWLINE_MESSAGE + } + + return CONTEXT +} diff --git a/pkg/commands/patch/patch.go b/pkg/commands/patch/patch.go new file mode 100644 index 000000000..5275fb613 --- /dev/null +++ b/pkg/commands/patch/patch.go @@ -0,0 +1,151 @@ +package patch + +import ( + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" +) + +type Patch struct { + // header of the patch (split on newlines) e.g. + // diff --git a/filename b/filename + // index dcd3485..1ba5540 100644 + // --- a/filename + // +++ b/filename + header []string + // hunks of the patch + hunks []*Hunk +} + +// Returns a new patch with the specified transformation applied (e.g. +// only selecting a subset of changes). +// Leaves the original patch unchanged. +func (self *Patch) Transform(opts TransformOpts) *Patch { + return transform(self, opts) +} + +// Returns the patch as a plain string +func (self *Patch) FormatPlain() string { + return formatPlain(self) +} + +// Returns a range of lines from the patch as a plain string (range is inclusive) +func (self *Patch) FormatRangePlain(startIdx int, endIdx int) string { + return formatRangePlain(self, startIdx, endIdx) +} + +// Returns the patch as a string with ANSI color codes for displaying in a view +func (self *Patch) FormatView(opts FormatViewOpts) string { + return formatView(self, opts) +} + +// Returns the lines of the patch +func (self *Patch) Lines() []*PatchLine { + lines := []*PatchLine{} + for _, line := range self.header { + lines = append(lines, &PatchLine{Content: line, Kind: PATCH_HEADER}) + } + + for _, hunk := range self.hunks { + lines = append(lines, hunk.allLines()...) + } + + return lines +} + +// Returns the patch line index of the first line in the given hunk +func (self *Patch) HunkStartIdx(hunkIndex int) int { + hunkIndex = utils.Clamp(hunkIndex, 0, len(self.hunks)-1) + + result := len(self.header) + for i := 0; i < hunkIndex; i++ { + result += self.hunks[i].lineCount() + } + return result +} + +// Returns the patch line index of the last line in the given hunk +func (self *Patch) HunkEndIdx(hunkIndex int) int { + hunkIndex = utils.Clamp(hunkIndex, 0, len(self.hunks)-1) + + return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1 +} + +func (self *Patch) ContainsChanges() bool { + return lo.SomeBy(self.hunks, func(hunk *Hunk) bool { + return hunk.containsChanges() + }) +} + +// Takes a line index in the patch and returns the line number in the new file. +// If the line is a header line, returns 1. +// If the line is a hunk header line, returns the first file line number in that hunk. +// If the line is out of range below, returns the last file line number in the last hunk. +func (self *Patch) LineNumberOfLine(idx int) int { + if idx < len(self.header) || len(self.hunks) == 0 { + return 1 + } + + hunkIdx := self.HunkContainingLine(idx) + // cursor out of range, return last file line number + if hunkIdx == -1 { + lastHunk := self.hunks[len(self.hunks)-1] + return lastHunk.newStart + lastHunk.newLength() - 1 + } + + hunk := self.hunks[hunkIdx] + hunkStartIdx := self.HunkStartIdx(hunkIdx) + idxInHunk := idx - hunkStartIdx + + if idxInHunk == 0 { + return hunk.oldStart + } + + lines := hunk.bodyLines[:idxInHunk-1] + offset := nLinesWithKind(lines, []PatchLineKind{ADDITION, CONTEXT}) + return hunk.oldStart + offset +} + +// Returns hunk index containing the line at the given patch line index +func (self *Patch) HunkContainingLine(idx int) int { + for hunkIdx, hunk := range self.hunks { + hunkStartIdx := self.HunkStartIdx(hunkIdx) + if idx >= hunkStartIdx && idx < hunkStartIdx+hunk.lineCount() { |