using System.Collections.Generic; using System.Globalization; namespace SourceGit.Models { public class TextInlineChange(int dp, int dc, int ap, int ac) { public int DeletedStart { get; set; } = dp; public int DeletedCount { get; set; } = dc; public int AddedStart { get; set; } = ap; public int AddedCount { get; set; } = ac; private class Chunk(int hash, int start, int size) { public readonly int Hash = hash; public readonly int Start = start; public readonly int Size = size; public bool Modified; } private enum Edit { None, DeletedRight, DeletedLeft, AddedRight, AddedLeft, } private enum CharCategory : byte { Other, // default: whitespace, control, punctuation, symbols, etc. Letter, // Ll/Lu/Lt/Lm + digit: ASCII and euro letters (latin, greek, cyrillic, etc.) OtherLetter, // Lo: CJK, hiragana, katakana, hangul, Thai, Arabic, etc. } private class EditResult { public Edit State; public int DeleteStart; public int DeleteEnd; public int AddStart; public int AddEnd; } public static List Compare(string oldValue, string newValue) { var hashes = new Dictionary(); var chunksOld = MakeChunks(hashes, oldValue); var chunksNew = MakeChunks(hashes, newValue); var sizeOld = chunksOld.Count; var sizeNew = chunksNew.Count; var max = sizeOld + sizeNew + 2; var forward = new int[max]; var reverse = new int[max]; CheckModified(chunksOld, 0, sizeOld, chunksNew, 0, sizeNew, forward, reverse); var ret = new List(); var posOld = 0; var posNew = 0; TextInlineChange last = null; do { while (posOld < sizeOld && posNew < sizeNew && !chunksOld[posOld].Modified && !chunksNew[posNew].Modified) { posOld++; posNew++; } var beginOld = posOld; var beginNew = posNew; var countOld = 0; var countNew = 0; for (; posOld < sizeOld && chunksOld[posOld].Modified; posOld++) countOld += chunksOld[posOld].Size; for (; posNew < sizeNew && chunksNew[posNew].Modified; posNew++) countNew += chunksNew[posNew].Size; if (countOld + countNew == 0) continue; var diff = new TextInlineChange( countOld > 0 ? chunksOld[beginOld].Start : 0, countOld, countNew > 0 ? chunksNew[beginNew].Start : 0, countNew); if (last != null) { var midSizeOld = diff.DeletedStart - last.DeletedStart - last.DeletedCount; var midSizeNew = diff.AddedStart - last.AddedStart - last.AddedCount; if (midSizeOld == 1 && midSizeNew == 1) { last.DeletedCount += (1 + countOld); last.AddedCount += (1 + countNew); continue; } } last = diff; ret.Add(diff); } while (posOld < sizeOld && posNew < sizeNew); return ret; } private static List MakeChunks(Dictionary hashes, string text) { var start = 0; var size = text.Length; var chunks = new List(); if (size == 0) return chunks; var prev = GetCategory(text[0]); for (var i = 1; i < size; i++) { var ch = text[i]; var category = GetCategory(ch); if (prev != category || category == CharCategory.Other) { AddChunk(chunks, hashes, text[start..i], start); start = i; } prev = category; } if (start < size) AddChunk(chunks, hashes, text[start..], start); return chunks; } private static void CheckModified(List chunksOld, int startOld, int endOld, List chunksNew, int startNew, int endNew, int[] forward, int[] reverse) { while (startOld < endOld && startNew < endNew && chunksOld[startOld].Hash == chunksNew[startNew].Hash) { startOld++; startNew++; } while (startOld < endOld && startNew < endNew && chunksOld[endOld - 1].Hash == chunksNew[endNew - 1].Hash) { endOld--; endNew--; } var lenOld = endOld - startOld; var lenNew = endNew - startNew; if (lenOld > 0 && lenNew > 0) { var rs = CheckModifiedEdit(chunksOld, startOld, endOld, chunksNew, startNew, endNew, forward, reverse); if (rs.State == Edit.None) return; if (rs.State == Edit.DeletedRight && rs.DeleteStart - 1 > startOld) { chunksOld[--rs.DeleteStart].Modified = true; } else if (rs.State == Edit.DeletedLeft && rs.DeleteEnd < endOld) { chunksOld[rs.DeleteEnd++].Modified = true; } else if (rs.State == Edit.AddedRight && rs.AddStart - 1 > startNew) { chunksNew[--rs.AddStart].Modified = true; } else if (rs.State == Edit.AddedLeft && rs.AddEnd < endNew) { chunksNew[rs.AddEnd++].Modified = true; } CheckModified(chunksOld, startOld, rs.DeleteStart, chunksNew, startNew, rs.AddStart, forward, reverse); CheckModified(chunksOld, rs.DeleteEnd, endOld, chunksNew, rs.AddEnd, endNew, forward, reverse); } else if (lenOld > 0) { for (int i = startOld; i < endOld; i++) chunksOld[i].Modified = true; } else if (lenNew > 0) { for (int i = startNew; i < endNew; i++) chunksNew[i].Modified = true; } } private static EditResult CheckModifiedEdit(List chunksOld, int startOld, int endOld, List chunksNew, int startNew, int endNew, int[] forward, int[] reverse) { var lenOld = endOld - startOld; var lenNew = endNew - startNew; var max = lenOld + lenNew + 1; var half = max / 2; var delta = lenOld - lenNew; var deltaEven = delta % 2 == 0; var rs = new EditResult() { State = Edit.None }; forward[1 + half] = 0; reverse[1 + half] = lenOld + 1; for (int i = 0; i <= half; i++) { for (int j = -i; j <= i; j += 2) { var idx = j + half; int o; if (j == -i || (j != i && forward[idx - 1] < forward[idx + 1])) { o = forward[idx + 1]; rs.State = Edit.AddedRight; } else { o = forward[idx - 1] + 1; rs.State = Edit.DeletedRight; } var n = o - j; var startX = o; var startY = n; while (o < lenOld && n < lenNew && chunksOld[o + startOld].Hash == chunksNew[n + startNew].Hash) { o++; n++; } forward[idx] = o; if (!deltaEven && j - delta >= -i + 1 && j - delta <= i - 1) { var revIdx = (j - delta) + half; var revOld = reverse[revIdx]; int revNew = revOld - j; if (revOld <= o && revNew <= n) { if (i == 0) { rs.State = Edit.None; } else { rs.DeleteStart = startX + startOld; rs.DeleteEnd = o + startOld; rs.AddStart = startY + startNew; rs.AddEnd = n + startNew; } return rs; } } } for (int j = -i; j <= i; j += 2) { var idx = j + half; int o; if (j == -i || (j != i && reverse[idx + 1] <= reverse[idx - 1])) { o = reverse[idx + 1] - 1; rs.State = Edit.DeletedLeft; } else { o = reverse[idx - 1]; rs.State = Edit.AddedLeft; } var n = o - (j + delta); var endX = o; var endY = n; while (o > 0 && n > 0 && chunksOld[startOld + o - 1].Hash == chunksNew[startNew + n - 1].Hash) { o--; n--; } reverse[idx] = o; if (deltaEven && j + delta >= -i && j + delta <= i) { var forIdx = (j + delta) + half; var forOld = forward[forIdx]; int forNew = forOld - (j + delta); if (forOld >= o && forNew >= n) { if (i == 0) { rs.State = Edit.None; } else { rs.DeleteStart = o + startOld; rs.DeleteEnd = endX + startOld; rs.AddStart = n + startNew; rs.AddEnd = endY + startNew; } return rs; } } } } rs.State = Edit.None; return rs; } private static void AddChunk(List chunks, Dictionary hashes, string data, int start) { if (!hashes.TryGetValue(data, out var hash)) { hash = hashes.Count; hashes.Add(data, hash); } chunks.Add(new Chunk(hash, start, data.Length)); } private static CharCategory[] BuildCategoryCache() { // Pre-compute category for all char values. // All entries default to Other (0). var cache = new CharCategory[65536]; for (int i = 0; i < 65536; i++) { var ch = (char)i; // Unicode Lo: CJK, hiragana, katakana, hangul, Thai, Arabic, Hebrew, etc. // → group consecutive chars into one chunk (no space delimiter in these languages) if (char.GetUnicodeCategory(ch) == UnicodeCategory.OtherLetter) cache[i] = CharCategory.OtherLetter; // Unicode Ll/Lu/Lt/Lm + digit: latin, greek, cyrillic and their diacritic variants // → group consecutive chars into one chunk (words in space-delimited languages) else if (char.IsLetterOrDigit(ch)) cache[i] = CharCategory.Letter; // everything else (whitespace, control, punctuation, symbols) → Other (default) } return cache; } private static CharCategory GetCategory(char ch) => s_charCategoryCache[ch]; private static readonly CharCategory[] s_charCategoryCache = BuildCategoryCache(); } }