Editor tool that parses Slang compiler reflection JSON into in-memory types. It defines data records for counts, attributes, bindings, types, parameters and entry points, and contains a reader that parses JsonNode output from slangc -reflection-json, tolerant of sentinel strings and single-vs-plural binding forms.
using Editor.Prism.Core;
using System.ComponentModel;
namespace Editor.Prism.Toolchain;
/// <summary>Whether a reflected count is a real number, one of Slang's sentinels, or simply absent.</summary>
public enum SlangCountKind
{
/// <summary>The key was not present.</summary>
Absent,
/// <summary>A real number.</summary>
Known,
/// <summary>Slang wrote the string <c>"unbounded"</c> where a number was expected.</summary>
Unbounded,
/// <summary>Slang wrote the string <c>"unknown"</c> where a number was expected.</summary>
Unknown
}
/// <summary>
/// A number in the reflection JSON, which Slang is entitled to write as the string
/// <c>"unbounded"</c> or <c>"unknown"</c> instead. Every numeric field of the schema goes through this
/// so a bindless array cannot make the whole document fail to parse.
/// </summary>
public readonly record struct SlangCount( int Value, SlangCountKind Kind )
{
/// <summary>The absent value, and the default.</summary>
public static readonly SlangCount Absent = new( 0, SlangCountKind.Absent );
/// <summary>True when this is a real number.</summary>
public bool IsKnown => Kind == SlangCountKind.Known;
/// <summary>True when Slang reported an unbounded size.</summary>
public bool IsUnbounded => Kind == SlangCountKind.Unbounded;
/// <summary>True when the key was present with a value we could not interpret.</summary>
public bool IsUnknown => Kind == SlangCountKind.Unknown;
/// <summary>True when the key was not present at all.</summary>
public bool IsAbsent => Kind == SlangCountKind.Absent;
/// <summary>The number, or a caller-chosen fallback when it is not one.</summary>
public int Or( int fallback ) => IsKnown ? Value : fallback;
/// <inheritdoc/>
public override string ToString() => Kind switch
{
SlangCountKind.Known => Value.ToString(),
SlangCountKind.Unbounded => "unbounded",
SlangCountKind.Unknown => "unknown",
_ => "-"
};
}
/// <summary>
/// A user-defined attribute reflected off a parameter — the mechanism that carries material-UI metadata
/// out of a Slang module. <c>[UIRange(0,1)] uniform float g_Rough</c> arrives here as
/// <c>UIRange</c> with two numeric arguments.
/// </summary>
public sealed record SlangUserAttribute( string Name, IReadOnlyList<object> Arguments )
{
/// <summary>An argument as a float.</summary>
public float Float( int index, float fallback = 0f ) => Arguments is not null && index >= 0 &&
index < Arguments.Count && Arguments[index] is IConvertible c
? PrismLog.Guard( "Reading a Slang attribute argument",
() => Convert.ToSingle( c, System.Globalization.CultureInfo.InvariantCulture ), fallback )
: fallback;
/// <summary>An argument as an int.</summary>
public int Int( int index, int fallback = 0 ) => (int)Float( index, fallback );
/// <summary>An argument as a string.</summary>
public string String( int index, string fallback = null ) =>
Arguments is not null && index >= 0 && index < Arguments.Count ? Arguments[index]?.ToString() : fallback;
/// <inheritdoc/>
public override string ToString() =>
$"[{Name}({string.Join( ", ", Arguments ?? Array.Empty<object>() )})]";
}
/// <summary>One layout binding of a reflected parameter.</summary>
public sealed record SlangBinding( string Kind )
{
/// <summary>Byte offset, for <c>uniform</c> bindings.</summary>
public SlangCount Offset { get; init; }
/// <summary>Byte size, for <c>uniform</c> bindings.</summary>
public SlangCount Size { get; init; }
/// <summary>Array element stride, for <c>uniform</c> bindings.</summary>
public SlangCount ElementStride { get; init; }
/// <summary>Register space.</summary>
public SlangCount Space { get; init; }
/// <summary>Register index.</summary>
public SlangCount Index { get; init; }
/// <summary>How many consecutive registers are taken.</summary>
public SlangCount Count { get; init; }
/// <summary>Whether the compiled kernel actually reads this. Only present inside an entry point.</summary>
public bool? Used { get; init; }
/// <inheritdoc/>
public override string ToString() => Kind == "uniform"
? $"uniform +{Offset} ({Size} bytes)"
: $"{Kind} space {Space} index {Index}";
}
/// <summary>A reflected type or type layout, discriminated by <see cref="Kind"/>.</summary>
public sealed record SlangTypeInfo( string Kind )
{
/// <summary>Struct, interface or generic-parameter name.</summary>
public string Name { get; init; }
/// <summary>Scalar type spelling, e.g. <c>float32</c>.</summary>
public string ScalarType { get; init; }
/// <summary>Vector width or array length.</summary>
public SlangCount ElementCount { get; init; }
/// <summary>Matrix rows.</summary>
public SlangCount RowCount { get; init; }
/// <summary>Matrix columns.</summary>
public SlangCount ColumnCount { get; init; }
/// <summary>Element type of a vector, matrix, array, buffer or parameter block.</summary>
[Hide, Browsable( false )]
public SlangTypeInfo ElementType { get; init; }
/// <summary>Result type of a resource.</summary>
[Hide, Browsable( false )]
public SlangTypeInfo ResultType { get; init; }
/// <summary>Resource shape, e.g. <c>texture2D</c>.</summary>
public string BaseShape { get; init; }
/// <summary>Resource access, absent when read-only.</summary>
public string Access { get; init; }
/// <summary>True when the resource is an array.</summary>
public bool IsArray { get; init; }
/// <summary>True when the resource is multisampled.</summary>
public bool IsMultisample { get; init; }
/// <summary>Struct fields.</summary>
public IReadOnlyList<SlangReflectionParameter> Fields { get; init; } = Array.Empty<SlangReflectionParameter>();
/// <summary>Attributes declared on the type.</summary>
public IReadOnlyList<SlangUserAttribute> UserAttributes { get; init; } = Array.Empty<SlangUserAttribute>();
/// <summary>
/// Translate into Prism's type lattice. Returns false for anything with no Prism equivalent — a
/// pointer, a dynamic resource, an unresolved generic — rather than guessing.
/// </summary>
public bool TryToShaderType( out ShaderType type )
{
type = ShaderType.Void;
switch ( Kind )
{
case "scalar":
type = ShaderType.Vec( Scalar( ScalarType ), 1 );
return !type.IsVoid;
case "vector":
type = ShaderType.Vec( Scalar( ElementType?.ScalarType ), ElementCount.Or( 1 ) );
return !type.IsVoid;
case "matrix":
type = ShaderType.Mat( Scalar( ElementType?.ScalarType ), RowCount.Or( 1 ), ColumnCount.Or( 1 ) );
return !type.IsVoid;
case "struct":
if ( string.IsNullOrWhiteSpace( Name ) ) return false;
type = ShaderType.Struct( Name );
return true;
case "samplerState":
type = ShaderType.Sampler;
return true;
case "resource":
var kind = ResourceKind();
if ( kind == ObjectKind.None ) return false;
type = ShaderType.Obj( kind );
return true;
case "constantBuffer":
case "parameterBlock":
case "textureBuffer":
case "array":
return ElementType is not null && ElementType.TryToShaderType( out type );
default:
return false;
}
}
/// <summary>The Prism type, or <c>void</c> when there is no equivalent.</summary>
public ShaderType ToShaderType() => TryToShaderType( out var type ) ? type : ShaderType.Void;
ObjectKind ResourceKind()
{
var writable = Access is "write" or "readWrite" or "rasterOrdered" or "append" or "consume";
return BaseShape switch
{
"texture1D" => IsArray ? ObjectKind.Texture1DArray : ObjectKind.Texture1D,
"texture2D" when IsMultisample => ObjectKind.Texture2DMS,
"texture2D" when writable => ObjectKind.RWTexture2D,
"texture2D" => IsArray ? ObjectKind.Texture2DArray : ObjectKind.Texture2D,
"texture3D" => writable ? ObjectKind.RWTexture3D : ObjectKind.Texture3D,
"textureCube" => IsArray ? ObjectKind.TextureCubeArray : ObjectKind.TextureCube,
"structuredBuffer" => writable ? ObjectKind.RWStructuredBuffer : ObjectKind.StructuredBuffer,
"byteAddressBuffer" => writable ? ObjectKind.RWByteAddressBuffer : ObjectKind.ByteAddressBuffer,
"textureBuffer" => writable ? ObjectKind.RWBuffer : ObjectKind.Buffer,
_ => ObjectKind.None
};
}
static ScalarKind Scalar( string name ) => name switch
{
"bool" => ScalarKind.Bool,
"int8" or "int16" or "int32" or "int64" or "intptr" => ScalarKind.Int,
"uint8" or "uint16" or "uint32" or "uint64" or "uintptr" => ScalarKind.UInt,
"float16" or "bfloat16" or "float_e4m3" or "float_e5m2" => ScalarKind.Half,
"float32" => ScalarKind.Float,
"float64" => ScalarKind.Double,
_ => ScalarKind.Void
};
/// <inheritdoc/>
public override string ToString()
{
var type = ToShaderType();
return type.IsVoid ? Kind + ( Name is null ? string.Empty : $" {Name}" ) : type.ToString();
}
}
/// <summary>One reflected parameter — global, struct field or entry-point argument.</summary>
public sealed record SlangReflectionParameter( string Name )
{
/// <summary>The stage this parameter belongs to, when it is stage-specific.</summary>
public string Stage { get; init; }
/// <summary>The single binding, when the parameter has exactly one layout category.</summary>
public SlangBinding Binding => Bindings is { Count: > 0 } ? Bindings[0] : null;
/// <summary>Every binding. Slang writes <c>binding</c> for one and <c>bindings</c> for several.</summary>
public IReadOnlyList<SlangBinding> Bindings { get; init; } = Array.Empty<SlangBinding>();
/// <summary>The parameter's type layout.</summary>
public SlangTypeInfo Type { get; init; }
/// <summary>User-defined attributes, which is where material-UI metadata lives.</summary>
public IReadOnlyList<SlangUserAttribute> UserAttributes { get; init; } = Array.Empty<SlangUserAttribute>();
/// <summary>Semantic name, for varying parameters. Slang upper-cases it.</summary>
public string SemanticName { get; init; }
/// <summary>Semantic index, present only when non-zero.</summary>
public int SemanticIndex { get; init; }
/// <summary>True when the <c>Shared</c> modifier was present.</summary>
public bool Shared { get; init; }
/// <summary>Image format name, for writable textures that declared one.</summary>
public string Format { get; init; }
/// <summary>The attribute with a given name, or null.</summary>
public SlangUserAttribute Attribute( string name ) => UserAttributes?
.FirstOrDefault( x => string.Equals( x.Name, name, StringComparison.OrdinalIgnoreCase ) );
/// <inheritdoc/>
public override string ToString() => $"{Type?.ToString() ?? "?"} {Name}";
}
/// <summary>One reflected entry point.</summary>
public sealed record SlangReflectionEntryPoint( string Name, string Stage )
{
/// <summary>Declared parameters.</summary>
public IReadOnlyList<SlangReflectionParameter> Parameters { get; init; } = Array.Empty<SlangReflectionParameter>();
/// <summary>Bindings the compiled kernel resolved, each carrying a <c>used</c> flag.</summary>
public IReadOnlyList<SlangReflectionParameter> Bindings { get; init; } = Array.Empty<SlangReflectionParameter>();
/// <summary>The result parameter, when the entry point has one.</summary>
public SlangReflectionParameter Result { get; init; }
/// <summary>Thread group size, for compute entry points.</summary>
public IReadOnlyList<int> ThreadGroupSize { get; init; } = Array.Empty<int>();
/// <summary>Attributes declared on the entry point.</summary>
public IReadOnlyList<SlangUserAttribute> UserAttributes { get; init; } = Array.Empty<SlangUserAttribute>();
/// <summary>True when any input is sample-rate.</summary>
public bool UsesAnySampleRateInput { get; init; }
/// <summary>Translate the stage name into Prism's enum. Slang says <c>fragment</c>, we say Pixel.</summary>
public ShaderStage ToStage() => Stage switch
{
"vertex" => ShaderStage.Vertex,
"fragment" or "pixel" => ShaderStage.Pixel,
"geometry" => ShaderStage.Geometry,
"compute" => ShaderStage.Compute,
_ => ShaderStage.None
};
/// <inheritdoc/>
public override string ToString() => $"{Name} ({Stage})";
}
/// <summary>A parameter the graph says the module should expose, for <see cref="SlangReflectionReader.CrossCheck"/>.</summary>
public readonly record struct SlangExpectedParameter( string Name, ShaderType Type )
{
/// <inheritdoc/>
public override string ToString() => $"{Type.Slang} {Name}";
}
/// <summary>A parsed <c>-reflection-json</c> document.</summary>
public sealed record SlangReflection
{
/// <summary>An empty document. Returned instead of null whenever parsing gives up.</summary>
public static readonly SlangReflection Empty = new();
/// <summary>Global parameters.</summary>
public IReadOnlyList<SlangReflectionParameter> Parameters { get; init; } = Array.Empty<SlangReflectionParameter>();
/// <summary>Entry points.</summary>
public IReadOnlyList<SlangReflectionEntryPoint> EntryPoints { get; init; } = Array.Empty<SlangReflectionEntryPoint>();
/// <summary>String hashes, when the module used them.</summary>
public IReadOnlyDictionary<string, long> HashedStrings { get; init; } = new Dictionary<string, long>();
/// <summary>Bindless space index, when present.</summary>
public SlangCount BindlessSpaceIndex { get; init; }
/// <summary>True when the document described nothing at all.</summary>
public bool IsEmpty => Parameters.Count == 0 && EntryPoints.Count == 0;
/// <summary>Find a global parameter by name.</summary>
public SlangReflectionParameter Find( string name ) => Parameters
.FirstOrDefault( x => string.Equals( x.Name, name, StringComparison.Ordinal ) );
/// <summary>Find an entry point by name.</summary>
public SlangReflectionEntryPoint FindEntryPoint( string name ) => EntryPoints
.FirstOrDefault( x => string.Equals( x.Name, name, StringComparison.Ordinal ) );
/// <inheritdoc/>
public override string ToString() => $"{Parameters.Count} parameters, {EntryPoints.Count} entry points";
}
/// <summary>
/// Reads the JSON that <c>slangc -reflection-json</c> writes.
/// <para>
/// Two things make a naive deserializer fail on real output, and both are handled here. Numeric fields
/// can be the strings <c>"unbounded"</c> or <c>"unknown"</c> — Slang's sentinels for bindless and
/// not-yet-laid-out — so every number goes through <see cref="SlangCount"/>. And a parameter with one
/// layout category writes a <c>binding</c> object while one with several writes a <c>bindings</c>
/// array, so both spellings are accepted into the same list.
/// </para>
/// <para>
/// Prism uses this to cross-check that the Slang module it emitted exposes exactly the parameter set
/// the graph declares. That catches emitter bugs that produce text which reads fine and is wrong.
/// </para>
/// </summary>
public static class SlangReflectionReader
{
/// <summary>Parse reflection JSON. Returns <see cref="SlangReflection.Empty"/> on any failure.</summary>
public static SlangReflection Read( string json ) => TryRead( json, out var reflection, out _ )
? reflection
: SlangReflection.Empty;
/// <summary>Parse reflection JSON from a file written by <c>-reflection-json</c>.</summary>
public static SlangReflection ReadFile( string path )
{
var text = PrismLog.Guard( "Reading Slang reflection JSON",
() => System.IO.File.Exists( path ) ? System.IO.File.ReadAllText( path ) : null, null );
return Read( text );
}
/// <summary>Parse reflection JSON, reporting why it failed rather than throwing.</summary>
public static bool TryRead( string json, out SlangReflection reflection, out string error )
{
reflection = SlangReflection.Empty;
error = null;
if ( string.IsNullOrWhiteSpace( json ) )
{
error = "The reflection document was empty.";
return false;
}
JsonNode root = null;
string failure = null;
PrismLog.Guard( "Parsing Slang reflection JSON", () =>
{
try
{
root = JsonNode.Parse( json );
}
catch ( JsonException e )
{
failure = e.Message;
}
} );
if ( root is not JsonObject obj )
{
error = failure ?? "The reflection document was not a JSON object.";
return false;
}
reflection = PrismLog.Guard( "Reading Slang reflection JSON", () => new SlangReflection
{
Parameters = Parameters( obj["parameters"] ),
EntryPoints = EntryPoints( obj["entryPoints"] ),
HashedStrings = HashedStrings( obj["hashedStrings"] ),
BindlessSpaceIndex = Count( obj["bindlessSpaceIndex"] )
}, SlangReflection.Empty ) ?? SlangReflection.Empty;
return true;
}
/// <summary>
/// Compare what the module exposes against what the graph says it should. Every finding is a warning
/// or information — Slang validation is Tier 2 and never gates rendering, so a mismatch tells the
/// developer their emitter drifted, it does not stop the user working.
/// </summary>
public static IReadOnlyList<Diagnostic> CrossCheck( SlangReflection reflection,
IEnumerable<SlangExpectedParameter> expected )
{
var results = new List<Diagnostic>();
if ( reflection is null || expected is null ) return results;
var seen = new HashSet<string>( StringComparer.Ordinal );
foreach ( var want in expected )
{
if ( string.IsNullOrWhiteSpace( want.Name ) ) continue;
seen.Add( want.Name );
var found = reflection.Find( want.Name );
if ( found is null )
{
results.Add( Diagnostic.Warning( DiagnosticCode.SlangDiagnostic,
$"The generated Slang module does not expose '{want.Name}'", null,
$"The graph declares {want.Type.Slang} {want.Name}, but slangc's reflection does not " +
"list it. Either the emitter dropped it or the compiler optimised it away because " +
"nothing reads it." ) );
continue;
}
if ( want.Type.IsVoid || found.Type is null ) continue;
var actual = found.Type.ToShaderType();
if ( actual.IsVoid || actual == want.Type ) continue;
results.Add( Diagnostic.Warning( DiagnosticCode.SlangDiagnostic,
$"'{want.Name}' is {actual.Slang} in the generated Slang module, not {want.Type.Slang}",
null, "The graph and the emitted module disagree about this parameter's type." ) );
}
foreach ( var parameter in reflection.Parameters )
{
if ( string.IsNullOrWhiteSpace( parameter.Name ) || seen.Contains( parameter.Name ) ) continue;
results.Add( Diagnostic.Info( DiagnosticCode.SlangDiagnostic,
$"The generated Slang module exposes '{parameter.Name}', which the graph does not declare",
null, "Usually a helper the backend introduced; worth a look if it was not expected." ) );
}
return results;
}
// ---- readers ---------------------------------------------------------
static IReadOnlyList<SlangReflectionParameter> Parameters( JsonNode node )
{
var results = new List<SlangReflectionParameter>();
if ( node is not JsonArray array ) return results;
foreach ( var entry in array )
{
var parameter = Parameter( entry );
if ( parameter is not null ) results.Add( parameter );
}
return results;
}
static SlangReflectionParameter Parameter( JsonNode node )
{
if ( node is not JsonObject obj ) return null;
return new SlangReflectionParameter( Text( obj["name"] ) )
{
Stage = Text( obj["stage"] ),
Bindings = Bindings( obj ),
Type = Type( obj["type"] ),
UserAttributes = Attributes( obj["userAttribs"] ),
SemanticName = Text( obj["semanticName"] ),
SemanticIndex = Count( obj["semanticIndex"] ).Or( 0 ),
Shared = Bool( obj["shared"] ),
Format = Text( obj["format"] )
};
}
/// <summary>Accept both spellings: <c>binding</c> for one category, <c>bindings</c> for several.</summary>
static IReadOnlyList<SlangBinding> Bindings( JsonObject obj )
{
var results = new List<SlangBinding>();
if ( obj["binding"] is JsonObject single )
{
var binding = Binding( single );
if ( binding is not null ) results.Add( binding );
}
if ( obj["bindings"] is JsonArray array )
{
foreach ( var entry in array )
{
var binding = Binding( entry );
if ( binding is not null ) results.Add( binding );
}
}
return results;
}
static SlangBinding Binding( JsonNode node )
{
if ( node is not JsonObject obj ) return null;
return new SlangBinding( Text( obj["kind"] ) ?? "unknown" )
{
Offset = Count( obj["offset"] ),
Size = Count( obj["size"] ),
ElementStride = Count( obj["elementStride"] ),
Space = Count( obj["space"] ),
Index = Count( obj["index"] ),
Count = Count( obj["count"] ),
Used = obj["used"] is null ? (bool?)null : Bool( obj["used"] )
};
}
static SlangTypeInfo Type( JsonNode node )
{
if ( node is not JsonObject obj ) return null;
return new SlangTypeInfo( Text( obj["kind"] ) ?? "unknown" )
{
Name = Text( obj["name"] ),
ScalarType = Text( obj["scalarType"] ),
ElementCount = Count( obj["elementCount"] ),
RowCount = Count( obj["rowCount"] ),
ColumnCount = Count( obj["columnCount"] ),
ElementType = Type( obj["elementType"] ),
ResultType = Type( obj["resultType"] ),
BaseShape = Text( obj["baseShape"] ),
Access = Text( obj["access"] ),
IsArray = Bool( obj["array"] ),
IsMultisample = Bool( obj["multisample"] ),
Fields = Parameters( obj["fields"] ),
UserAttributes = Attributes( obj["userAttribs"] )
};
}
static IReadOnlyList<SlangReflectionEntryPoint> EntryPoints( JsonNode node )
{
var results = new List<SlangReflectionEntryPoint>();
if ( node is not JsonArray array ) return results;
foreach ( var entry in array )
{
if ( entry is not JsonObject obj ) continue;
results.Add( new SlangReflectionEntryPoint( Text( obj["name"] ), Text( obj["stage"] ) )
{
Parameters = Parameters( obj["parameters"] ),
Bindings = Parameters( obj["bindings"] ),
Result = Parameter( obj["result"] ),
ThreadGroupSize = Ints( obj["threadGroupSize"] ),
UserAttributes = Attributes( obj["userAttribs"] ),
UsesAnySampleRateInput = Bool( obj["usesAnySampleRateInput"] )
} );
}
return results;
}
static IReadOnlyList<SlangUserAttribute> Attributes( JsonNode node )
{
var results = new List<SlangUserAttribute>();
if ( node is not JsonArray array ) return results;
foreach ( var entry in array )
{
if ( entry is not JsonObject obj ) continue;
var arguments = new List<object>();
if ( obj["arguments"] is JsonArray values )
{
foreach ( var value in values )
{
arguments.Add( Scalar( value ) );
}
}
results.Add( new SlangUserAttribute( Text( obj["name"] ) ?? "?", arguments ) );
}
return results;
}
static IReadOnlyDictionary<string, long> HashedStrings( JsonNode node )
{
var results = new Dictionary<string, long>( StringComparer.Ordinal );
if ( node is not JsonObject obj ) return results;
foreach ( var pair in obj )
{
var count = Count( pair.Value );
results[pair.Key] = count.IsKnown ? count.Value : 0;
}
return results;
}
static IReadOnlyList<int> Ints( JsonNode node )
{
var results = new List<int>();
if ( node is not JsonArray array ) return results;
foreach ( var entry in array )
{
results.Add( Count( entry ).Or( 0 ) );
}
return results;
}
// ---- primitives ------------------------------------------------------
/// <summary>Read a number that Slang may have written as a sentinel string.</summary>
public static SlangCount Count( JsonNode node )
{
if ( node is not JsonValue value ) return SlangCount.Absent;
if ( value.TryGetValue<int>( out var number ) ) return new SlangCount( number, SlangCountKind.Known );
if ( value.TryGetValue<long>( out var wide ) ) return new SlangCount( (int)wide, SlangCountKind.Known );
if ( value.TryGetValue<double>( out var real ) )
{
return new SlangCount( (int)real, SlangCountKind.Known );
}
if ( value.TryGetValue<bool>( out var flag ) )
{
return new SlangCount( flag ? 1 : 0, SlangCountKind.Known );
}
if ( !value.TryGetValue<string>( out var text ) ) return new SlangCount( 0, SlangCountKind.Unknown );
if ( string.Equals( text, "unbounded", StringComparison.OrdinalIgnoreCase ) )
{
return new SlangCount( 0, SlangCountKind.Unbounded );
}
if ( int.TryParse( text, out var parsed ) ) return new SlangCount( parsed, SlangCountKind.Known );
return new SlangCount( 0, SlangCountKind.Unknown );
}
static string Text( JsonNode node ) =>
node is JsonValue value && value.TryGetValue<string>( out var text ) ? text : null;
static bool Bool( JsonNode node )
{
if ( node is not JsonValue value ) return false;
if ( value.TryGetValue<bool>( out var flag ) ) return flag;
if ( value.TryGetValue<int>( out var number ) ) return number != 0;
if ( value.TryGetValue<string>( out var text ) ) return bool.TryParse( text, out var parsed ) && parsed;
return false;
}
static object Scalar( JsonNode node )
{
if ( node is not JsonValue value ) return null;
if ( value.TryGetValue<string>( out var text ) ) return text;
if ( value.TryGetValue<bool>( out var flag ) ) return flag;
if ( value.TryGetValue<int>( out var number ) ) return number;
if ( value.TryGetValue<double>( out var real ) ) return real;
return value.ToString();
}
}