Editor/SyncToolYamlRenderer.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
/// <summary>
/// Renders JSON text as YAML for display in the Sync Tool's diff view.
/// Object keys are sorted recursively so that two semantically-identical
/// inputs produce identical line-by-line output regardless of original
/// key order, which is what the side-by-side diff window relies on.
///
/// This is intentionally separate from <see cref="SyncToolPullWriter"/>'s
/// file emitter: the writer must preserve the canonical insertion order of
/// authored fields, while the diff renderer must normalize to a sorted form
/// to avoid spurious diffs from key reordering.
/// </summary>
public static class SyncToolYamlRenderer
{
/// <summary>
/// Convert a JSON string into pretty-printed YAML with sorted keys.
/// Returns the original string unchanged when the input is not valid JSON
/// so the caller still has something useful to show.
/// </summary>
public static string RenderFromJson( string json )
{
if ( string.IsNullOrEmpty( json ) ) return "";
try
{
var element = JsonSerializer.Deserialize<JsonElement>( json );
var normalized = NormalizeAndSort( element );
return RenderRoot( normalized );
}
catch
{
return json;
}
}
private static object NormalizeAndSort( JsonElement element )
{
switch ( element.ValueKind )
{
case JsonValueKind.Object:
var dict = new Dictionary<string, object>( StringComparer.Ordinal );
foreach ( var prop in element.EnumerateObject()
.OrderBy( p => p.Name, StringComparer.Ordinal ) )
{
dict[prop.Name] = NormalizeAndSort( prop.Value );
}
return dict;
case JsonValueKind.Array:
var list = new List<object>();
foreach ( var item in element.EnumerateArray() )
list.Add( NormalizeAndSort( item ) );
return list;
case JsonValueKind.String:
return element.GetString();
case JsonValueKind.Number:
return element.TryGetInt64( out var l ) ? (object)l : element.GetDouble();
case JsonValueKind.True:
return true;
case JsonValueKind.False:
return false;
default:
return null;
}
}
private static string RenderRoot( object value )
{
switch ( value )
{
case Dictionary<string, object> dict when dict.Count == 0:
return "{}\n";
case Dictionary<string, object> dict:
{
var lines = new List<string>();
AppendMapping( lines, dict, 0 );
lines.Add( "" );
return string.Join( "\n", lines );
}
case List<object> list when list.Count == 0:
return "[]\n";
case List<object> list:
{
var lines = new List<string>();
AppendList( lines, list, 0 );
lines.Add( "" );
return string.Join( "\n", lines );
}
default:
return FormatScalar( value ) + "\n";
}
}
private static void AppendMapping( List<string> lines, IDictionary<string, object> values, int indentLevel )
{
if ( values.Count == 0 )
{
lines.Add( $"{Indent( indentLevel )}{{}}" );
return;
}
foreach ( var pair in values )
AppendProperty( lines, pair.Key, pair.Value, indentLevel );
}
private static void AppendProperty( List<string> lines, string key, object value, int indentLevel )
{
var indent = Indent( indentLevel );
var yamlKey = FormatKey( key );
switch ( value )
{
case Dictionary<string, object> dict when dict.Count == 0:
lines.Add( $"{indent}{yamlKey}: {{}}" );
return;
case Dictionary<string, object> dict:
lines.Add( $"{indent}{yamlKey}:" );
AppendMapping( lines, dict, indentLevel + 1 );
return;
case List<object> list when list.Count == 0:
lines.Add( $"{indent}{yamlKey}: []" );
return;
case List<object> list:
lines.Add( $"{indent}{yamlKey}:" );
AppendList( lines, list, indentLevel + 1 );
return;
default:
lines.Add( $"{indent}{yamlKey}: {FormatScalar( value )}" );
return;
}
}
private static void AppendList( List<string> lines, List<object> items, int indentLevel )
{
foreach ( var item in items )
AppendListItem( lines, item, indentLevel );
}
private static void AppendListItem( List<string> lines, object item, int indentLevel )
{
var indent = Indent( indentLevel );
switch ( item )
{
case Dictionary<string, object> dict when dict.Count == 0:
lines.Add( $"{indent}- {{}}" );
return;
case Dictionary<string, object> dict:
lines.Add( $"{indent}-" );
AppendMapping( lines, dict, indentLevel + 1 );
return;
case List<object> list when list.Count == 0:
lines.Add( $"{indent}- []" );
return;
case List<object> list:
lines.Add( $"{indent}-" );
AppendList( lines, list, indentLevel + 1 );
return;
default:
lines.Add( $"{indent}- {FormatScalar( item )}" );
return;
}
}
private static string FormatKey( string key )
{
// Bare keys are safe when they only contain identifier-friendly characters.
// Anything else gets JSON-quoted, which is also valid YAML.
return !string.IsNullOrEmpty( key )
&& key.All( ch => char.IsLetterOrDigit( ch ) || ch == '_' || ch == '-' || ch == '.' )
? key
: JsonSerializer.Serialize( key );
}
private static string FormatScalar( object value )
{
return value switch
{
null => "null",
bool b => b ? "true" : "false",
sbyte n => n.ToString( CultureInfo.InvariantCulture ),
byte n => n.ToString( CultureInfo.InvariantCulture ),
short n => n.ToString( CultureInfo.InvariantCulture ),
ushort n => n.ToString( CultureInfo.InvariantCulture ),
int n => n.ToString( CultureInfo.InvariantCulture ),
uint n => n.ToString( CultureInfo.InvariantCulture ),
long n => n.ToString( CultureInfo.InvariantCulture ),
ulong n => n.ToString( CultureInfo.InvariantCulture ),
float n => n.ToString( "R", CultureInfo.InvariantCulture ),
double n => n.ToString( "R", CultureInfo.InvariantCulture ),
decimal n => n.ToString( CultureInfo.InvariantCulture ),
_ => JsonSerializer.Serialize( value.ToString() )
};
}
private static string Indent( int indentLevel ) => new( ' ', indentLevel * 2 );
}