Gitnuro/src/main/kotlin/app/git/diff/HunkDiffGenerator.kt

206 lines
7.0 KiB
Kotlin

package app.git.diff
import app.extensions.lineAt
import app.git.EntryContent
import app.git.RawFileManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.eclipse.jgit.diff.*
import org.eclipse.jgit.lib.Repository
import org.eclipse.jgit.patch.FileHeader
import org.eclipse.jgit.patch.FileHeader.PatchType
import org.eclipse.jgit.treewalk.AbstractTreeIterator
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InvalidObjectException
import kotlin.contracts.ExperimentalContracts
import kotlin.math.max
import kotlin.math.min
private const val CONTEXT_LINES = 3
/**
* Generator of [Hunk] lists from [DiffEntry]
*/
class HunkDiffGenerator @AssistedInject constructor(
@Assisted private val repository: Repository,
@Assisted private val rawFileManager: RawFileManager,
) : AutoCloseable {
private val outputStream = ByteArrayOutputStream() // Dummy output stream used for the diff formatter
private val diffFormatter = DiffFormatter(outputStream).apply {
setRepository(repository)
}
override fun close() {
outputStream.close()
}
fun scan(oldTreeIterator: AbstractTreeIterator, newTreeIterator: AbstractTreeIterator) {
rawFileManager.scan(oldTreeIterator, newTreeIterator)
diffFormatter.scan(oldTreeIterator, newTreeIterator)
}
fun format(ent: DiffEntry): DiffResult {
val fileHeader = diffFormatter.toFileHeader(ent)
val rawOld = rawFileManager.getRawContent(DiffEntry.Side.OLD, ent)
val rawNew = rawFileManager.getRawContent(DiffEntry.Side.NEW, ent)
if (rawOld == EntryContent.InvalidObjectBlob || rawNew == EntryContent.InvalidObjectBlob)
throw InvalidObjectException("Invalid object in diff format")
var diffResult: DiffResult = DiffResult.Text(ent, emptyList())
// If we can, generate text diff (if one of the files has never been a binary file)
val hasGeneratedTextDiff = canGenerateTextDiff(rawOld, rawNew) { oldRawText, newRawText ->
diffResult = DiffResult.Text(ent, format(fileHeader, oldRawText, newRawText))
}
if (!hasGeneratedTextDiff) {
diffResult = DiffResult.NonText(ent, rawOld, rawNew)
}
return diffResult
}
@OptIn(ExperimentalContracts::class)
private fun canGenerateTextDiff(
rawOld: EntryContent,
rawNew: EntryContent,
onText: (oldRawText: RawText, newRawText: RawText) -> Unit
): Boolean {
val rawOldText = when (rawOld) {
is EntryContent.Text -> rawOld.rawText
EntryContent.Missing -> RawText.EMPTY_TEXT
else -> null
}
val newOldText = when (rawNew) {
is EntryContent.Text -> rawNew.rawText
EntryContent.Missing -> RawText.EMPTY_TEXT
else -> null
}
return if (rawOldText != null && newOldText != null) {
onText(rawOldText, newOldText)
true
} else
false
}
/**
* Given a [FileHeader] and the both [RawText], generate a [List] of [Hunk]
*/
private fun format(head: FileHeader, oldRawText: RawText, newRawText: RawText): List<Hunk> {
return if (head.patchType == PatchType.UNIFIED)
format(head.toEditList(), oldRawText, newRawText)
else
emptyList()
}
private fun format(edits: EditList, oldRawText: RawText, newRawText: RawText): List<Hunk> {
var curIdx = 0
val hunksList = mutableListOf<Hunk>()
while (curIdx < edits.size) {
var curEdit = edits[curIdx]
val endIdx = findCombinedEnd(edits, curIdx)
val endEdit = edits[endIdx]
var oldCurrentLine = max(0, curEdit.beginA - CONTEXT_LINES)
var newCurrentLine = max(0, curEdit.beginB - CONTEXT_LINES)
val oldEndLine = min(oldRawText.size(), endEdit.endA + CONTEXT_LINES)
val newEndLine = min(newRawText.size(), endEdit.endB + CONTEXT_LINES)
val headerText = createHunkHeader(oldCurrentLine, oldEndLine, newCurrentLine, newEndLine)
val lines = mutableListOf<Line>()
while (oldCurrentLine < oldEndLine || newCurrentLine < newEndLine) {
if (oldCurrentLine < curEdit.beginA || endIdx + 1 < curIdx) {
val lineText = oldRawText.lineAt(oldCurrentLine)
lines.add(Line(lineText, oldCurrentLine, newCurrentLine, LineType.CONTEXT))
oldCurrentLine++
newCurrentLine++
} else if (oldCurrentLine < curEdit.endA) {
val lineText = oldRawText.lineAt(oldCurrentLine)
lines.add(Line(lineText, oldCurrentLine, newCurrentLine, LineType.REMOVED))
oldCurrentLine++
} else if (newCurrentLine < curEdit.endB) {
val lineText = newRawText.lineAt(newCurrentLine)
lines.add(Line(lineText, oldCurrentLine, newCurrentLine, LineType.ADDED))
newCurrentLine++
}
if (end(curEdit, oldCurrentLine, newCurrentLine) && ++curIdx < edits.size) curEdit = edits[curIdx]
}
hunksList.add(Hunk(headerText, lines))
}
return hunksList
}
/**
* Generates the hunk's header string like in git diff
*/
@Throws(IOException::class)
private fun createHunkHeader(
oldStartLine: Int,
oldEndLine: Int,
newStartLine: Int,
newEndLine: Int
): String {
val prefix = "@@"
val contentRemoved = createRange('-', oldStartLine + 1, oldEndLine - oldStartLine)
val contentAdded = createRange('+', newStartLine + 1, newEndLine - newStartLine)
val suffix = " @@"
return prefix + contentRemoved + contentAdded + suffix
}
private fun createRange(symbol: Char, begin: Int, linesCount: Int): String {
return when (linesCount) {
0 -> " $symbol${begin - 1},0"
1 -> " $symbol$begin" // If the range is exactly one line, produce only the number.
else -> " $symbol$begin,$linesCount"
}
}
private fun findCombinedEnd(edits: List<Edit>, i: Int): Int {
var end = i + 1
while (end < edits.size
&& (combineA(edits, end) || combineB(edits, end))
) end++
return end - 1
}
private fun combineA(e: List<Edit>, i: Int): Boolean {
return e[i].beginA - e[i - 1].endA <= 2 * CONTEXT_LINES
}
private fun combineB(e: List<Edit>, i: Int): Boolean {
return e[i].beginB - e[i - 1].endB <= 2 * CONTEXT_LINES
}
private fun end(edit: Edit, a: Int, b: Int): Boolean {
return edit.endA <= a && edit.endB <= b
}
}
sealed class DiffResult(
val diffEntry: DiffEntry,
) {
class Text(
diffEntry: DiffEntry,
val hunks: List<Hunk>
) : DiffResult(diffEntry)
class NonText(
diffEntry: DiffEntry,
val oldBinaryContent: EntryContent,
val newBinaryContent: EntryContent,
) : DiffResult(diffEntry)
}