Editor/JsonDiffUtilities.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
internal static class JsonDiffUtilities
{
internal sealed class FieldDifference
{
public string Path { get; set; } = "";
public string LocalValue { get; set; }
public string RemoteValue { get; set; }
public bool IsAdded { get; set; }
}
internal struct LineDiffCounts
{
public int Added;
public int Removed;
public int Changed;
public bool HasChanges => Added > 0 || Removed > 0 || Changed > 0;
}
internal sealed class ComparisonResult
{
public List<FieldDifference> Added { get; } = new();
public List<FieldDifference> Changed { get; } = new();
public LineDiffCounts LineCounts { get; set; }
public bool IsRemoteAdditiveOnly => Added.Count > 0 && Changed.Count == 0;
}
private enum LineOpKind
{
Same,
Added,
Removed
}
private struct LineOperation
{
public LineOpKind Kind;
public int? LocalIndex;
public int? RemoteIndex;
}
internal static ComparisonResult Analyze( string localJson, string remoteJson )
{
var result = new ComparisonResult
{
LineCounts = CountLineDifferences( localJson, remoteJson )
};
try
{
var local = JsonSerializer.Deserialize<JsonElement>( localJson );
var remote = JsonSerializer.Deserialize<JsonElement>( remoteJson );
CompareElements( local, remote, "", result );
}
catch
{
if ( result.LineCounts.HasChanges )
{
result.Changed.Add( new FieldDifference
{
Path = "content",
LocalValue = Truncate( localJson ),
RemoteValue = Truncate( remoteJson ),
IsAdded = false
} );
}
}
return result;
}
internal static LineDiffCounts CountLineDifferences( string localJson, string remoteJson )
{
var localLines = NormalizePretty( localJson ).Split( '\n' );
var remoteLines = NormalizePretty( remoteJson ).Split( '\n' );
var ops = BuildLineOperations( localLines, remoteLines );
var counts = new LineDiffCounts();
var index = 0;
while ( index < ops.Count )
{
if ( ops[index].Kind == LineOpKind.Same )
{
index++;
continue;
}
var removed = 0;
var added = 0;
while ( index < ops.Count && ops[index].Kind != LineOpKind.Same )
{
if ( ops[index].Kind == LineOpKind.Removed ) removed++;
else added++;
index++;
}
var changed = Math.Min( removed, added );
counts.Changed += changed;
counts.Removed += removed - changed;
counts.Added += added - changed;
}
return counts;
}
internal static string SummarizeLineDifferences( LineDiffCounts counts )
{
var parts = new List<string>();
if ( counts.Added > 0 ) parts.Add( $"{counts.Added} line{Plural( counts.Added )} added" );
if ( counts.Changed > 0 ) parts.Add( $"{counts.Changed} line{Plural( counts.Changed )} changed" );
if ( counts.Removed > 0 ) parts.Add( $"{counts.Removed} line{Plural( counts.Removed )} removed" );
return parts.Count == 0 ? "No line differences" : $"Remote {string.Join( ", ", parts )}";
}
internal static string PreviewPaths( IEnumerable<FieldDifference> differences, int maxCount = 2 )
{
var names = differences
.Select( x => x.Path )
.Where( x => !string.IsNullOrWhiteSpace( x ) )
.Distinct()
.ToList();
if ( names.Count == 0 )
return "";
var shown = names.Take( maxCount ).ToList();
var preview = string.Join( ", ", shown );
var remaining = names.Count - shown.Count;
if ( remaining > 0 )
preview += $", +{remaining} more";
return preview;
}
private static void CompareElements( JsonElement local, JsonElement remote, string path, ComparisonResult result )
{
if ( local.ValueKind == JsonValueKind.Object && remote.ValueKind == JsonValueKind.Object )
{
var localProps = local.EnumerateObject().ToDictionary( x => x.Name, x => x.Value );
var remoteProps = remote.EnumerateObject().ToDictionary( x => x.Name, x => x.Value );
foreach ( var key in localProps.Keys.OrderBy( x => x ) )
{
var childPath = CombinePath( path, key );
if ( !remoteProps.TryGetValue( key, out var remoteValue ) )
{
result.Changed.Add( new FieldDifference
{
Path = childPath,
LocalValue = FormatShort( localProps[key] ),
RemoteValue = "(missing)",
IsAdded = false
} );
continue;
}
CompareElements( localProps[key], remoteValue, childPath, result );
}
foreach ( var key in remoteProps.Keys.Except( localProps.Keys ).OrderBy( x => x ) )
{
result.Added.Add( new FieldDifference
{
Path = CombinePath( path, key ),
LocalValue = null,
RemoteValue = FormatShort( remoteProps[key] ),
IsAdded = true
} );
}
return;
}
if ( NormalizeJson( local.GetRawText() ) == NormalizeJson( remote.GetRawText() ) )
return;
result.Changed.Add( new FieldDifference
{
Path = string.IsNullOrEmpty( path ) ? "content" : path,
LocalValue = FormatShort( local ),
RemoteValue = FormatShort( remote ),
IsAdded = false
} );
}
private static List<LineOperation> BuildLineOperations( string[] localLines, string[] remoteLines )
{
var localCount = localLines.Length;
var remoteCount = remoteLines.Length;
var lcs = new int[localCount + 1, remoteCount + 1];
for ( int local = localCount - 1; local >= 0; local-- )
{
var localText = GetLineText( localLines, local );
for ( int remote = remoteCount - 1; remote >= 0; remote-- )
{
if ( localText == GetLineText( remoteLines, remote ) )
{
lcs[local, remote] = lcs[local + 1, remote + 1] + 1;
}
else
{
lcs[local, remote] = Math.Max( lcs[local + 1, remote], lcs[local, remote + 1] );
}
}
}
var ops = new List<LineOperation>();
var localIndex = 0;
var remoteIndex = 0;
while ( localIndex < localCount && remoteIndex < remoteCount )
{
var localText = GetLineText( localLines, localIndex );
var remoteText = GetLineText( remoteLines, remoteIndex );
if ( localText == remoteText )
{
ops.Add( new LineOperation
{
Kind = LineOpKind.Same,
LocalIndex = localIndex,
RemoteIndex = remoteIndex
} );
localIndex++;
remoteIndex++;
}
else if ( lcs[localIndex + 1, remoteIndex] >= lcs[localIndex, remoteIndex + 1] )
{
ops.Add( new LineOperation
{
Kind = LineOpKind.Removed,
LocalIndex = localIndex
} );
localIndex++;
}
else
{
ops.Add( new LineOperation
{
Kind = LineOpKind.Added,
RemoteIndex = remoteIndex
} );
remoteIndex++;
}
}
while ( localIndex < localCount )
{
ops.Add( new LineOperation
{
Kind = LineOpKind.Removed,
LocalIndex = localIndex
} );
localIndex++;
}
while ( remoteIndex < remoteCount )
{
ops.Add( new LineOperation
{
Kind = LineOpKind.Added,
RemoteIndex = remoteIndex
} );
remoteIndex++;
}
return ops;
}
private static string NormalizePretty( string json )
{
if ( string.IsNullOrEmpty( json ) ) return "";
try
{
var el = JsonSerializer.Deserialize<JsonElement>( json );
return JsonSerializer.Serialize( SortElement( el ), new JsonSerializerOptions { WriteIndented = true } );
}
catch
{
return json.Replace( "\r\n", "\n" ).Replace( '\r', '\n' );
}
}
private static string NormalizeJson( string json )
{
try
{
var el = JsonSerializer.Deserialize<JsonElement>( json );
return JsonSerializer.Serialize( SortElement( el ), new JsonSerializerOptions { WriteIndented = false } );
}
catch
{
return json.Trim();
}
}
private static object SortElement( JsonElement el )
{
switch ( el.ValueKind )
{
case JsonValueKind.Object:
var dict = new SortedDictionary<string, object>();
foreach ( var prop in el.EnumerateObject() )
dict[prop.Name] = SortElement( prop.Value );
return dict;
case JsonValueKind.Array:
var arr = new List<object>();
foreach ( var item in el.EnumerateArray() )
arr.Add( SortElement( item ) );
return arr;
case JsonValueKind.String:
return el.GetString();
case JsonValueKind.Number:
return el.TryGetInt64( out var l ) ? (object)l : el.GetDouble();
case JsonValueKind.True:
return true;
case JsonValueKind.False:
return false;
default:
return null;
}
}
private static string CombinePath( string prefix, string name )
{
return string.IsNullOrEmpty( prefix ) ? name : $"{prefix}.{name}";
}
private static string GetLineText( string[] lines, int index )
{
return lines[index].TrimEnd( '\r' );
}
private static string FormatShort( JsonElement el )
{
if ( el.ValueKind == JsonValueKind.String )
return $"\"{el.GetString()}\"";
return Truncate( el.GetRawText() );
}
private static string Truncate( string text, int maxLength = 80 )
{
if ( string.IsNullOrEmpty( text ) || text.Length <= maxLength )
return text ?? "";
return text[..(maxLength - 3)] + "...";
}
private static string Plural( int count )
{
return count == 1 ? "" : "s";
}
}