namespace MS.Microservice.Core.Text; public enum TextDiffKind { Unchanged, Added, Removed } /// A line including its original CR, LF or CRLF terminator, if present. public sealed record TextDiffLine(TextDiffKind Kind, string Text); public static class TextDiff { /// Compares exact lines using a linear-memory longest common subsequence. /// Upper bound on LCS cell comparisons after common edges are removed. public static IReadOnlyList Compare( string original, string updated, long maximumComparisonWork = 4_000_000) { ArgumentNullException.ThrowIfNull(original); ArgumentNullException.ThrowIfNull(updated); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumComparisonWork); var left = SplitLines(original); var right = SplitLines(updated); var prefix = 0; while (prefix < left.Length && prefix < right.Length && left[prefix] == right[prefix]) prefix++; var suffix = 0; while (suffix < left.Length - prefix && suffix < right.Length - prefix && left[^(suffix + 1)] == right[^(suffix + 1)]) suffix++; var leftCount = left.Length - prefix - suffix; var rightCount = right.Length - prefix - suffix; if (2L * leftCount * rightCount > maximumComparisonWork) throw new ArgumentException("The line comparison exceeds the configured work limit.", nameof(maximumComparisonWork)); var result = new List(); var forward = new int[rightCount + 1]; var backward = new int[rightCount + 1]; for (var index = 0; index < prefix; index++) result.Add(new(TextDiffKind.Unchanged, left[index])); Match(prefix, leftCount, prefix, rightCount); for (var index = left.Length - suffix; index < left.Length; index++) result.Add(new(TextDiffKind.Unchanged, left[index])); return result; void Match(int aStart, int aCount, int bStart, int bCount) { if (aCount == 0) { for (var index = bStart; index < bStart + bCount; index++) result.Add(new(TextDiffKind.Added, right[index])); return; } if (bCount == 0) { for (var index = aStart; index < aStart + aCount; index++) result.Add(new(TextDiffKind.Removed, left[index])); return; } if (aCount == 1) { var match = Array.IndexOf(right, left[aStart], bStart, bCount); if (match < 0) result.Add(new(TextDiffKind.Removed, left[aStart])); for (var index = bStart; index < bStart + bCount; index++) result.Add(new(index == match ? TextDiffKind.Unchanged : TextDiffKind.Added, right[index])); return; } var half = aCount / 2; CalculateLengths(aStart, half, bStart, bCount, forward, reverse: false); CalculateLengths(aStart + half, aCount - half, bStart, bCount, backward, reverse: true); var split = 0; var best = -1; for (var index = 0; index <= bCount; index++) { var length = forward[index] + backward[bCount - index]; if (length > best) { best = length; split = index; } } // Only the split survives recursion; both vectors are reused by each child. Match(aStart, half, bStart, split); Match(aStart + half, aCount - half, bStart + split, bCount - split); } void CalculateLengths(int aStart, int aCount, int bStart, int bCount, int[] row, bool reverse) { row.AsSpan(0, bCount + 1).Clear(); for (var a = 0; a < aCount; a++) { var previous = 0; var line = left[reverse ? aStart + aCount - a - 1 : aStart + a]; for (var b = 0; b < bCount; b++) { var saved = row[b + 1]; row[b + 1] = line == right[reverse ? bStart + bCount - b - 1 : bStart + b] ? previous + 1 : Math.Max(row[b], row[b + 1]); previous = saved; } } } } private static string[] SplitLines(string text) { var lines = new List(); var start = 0; for (var index = 0; index < text.Length; index++) { if (text[index] is not ('\r' or '\n')) continue; if (text[index] == '\r' && index + 1 < text.Length && text[index + 1] == '\n') index++; lines.Add(text[start..(index + 1)]); start = index + 1; } if (start < text.Length) lines.Add(text[start..]); return lines.ToArray(); } }