An editor-side model for Prism node metadata. NodeDescriptor holds reflected, immutable metadata about a node type (id, title, category, ports, version, keywords, menu path, etc.) and provides helpers to build a descriptor from a Type and prettify type names. NodeDescriptors is a small cache/provider wrapper around descriptor creation with thread safety and a Flush method.
using System.Reflection;
using System.Text;
namespace Editor.Prism.Model;
/// <summary>
/// Cached, immutable metadata about one node type: everything the library tree, the search palette,
/// the serializer and the migration table need, resolved once by reflection.
/// </summary>
public sealed class NodeDescriptor
{
/// <summary>The concrete node class.</summary>
public Type Type { get; init; }
/// <summary>Stable node type id written to documents, e.g. <c>prism.math.multiply</c>.</summary>
public string Id { get; init; }
/// <summary>Display title.</summary>
public string Title { get; init; }
/// <summary>Slash-separated category, e.g. <c>Math/Basic</c>.</summary>
public string Category { get; init; }
/// <summary>Material Icons glyph name.</summary>
public string Icon { get; init; }
/// <summary>One-line description.</summary>
public string Description { get; init; }
/// <summary>Extra search terms.</summary>
public IReadOnlyList<string> Keywords { get; init; }
/// <summary>How prominently the node is offered.</summary>
public NodeTier Tier { get; init; }
/// <summary>Serialization version of this node type, written as <c>v</c>.</summary>
public int Version { get; init; }
/// <summary>Version of Prism this node first appeared in.</summary>
public string Since { get; init; }
/// <summary>For deprecated nodes: the id of the node type that replaces this one.</summary>
public string DeprecatedBy { get; init; }
/// <summary>Former type ids that must still deserialize into this type.</summary>
public IReadOnlyList<string> FormerIds { get; init; }
/// <summary>The category split into menu path elements, with the title appended.</summary>
public IReadOnlyList<string> MenuPath { get; init; }
/// <summary>Input port declarations implied by the type's attributes.</summary>
public IReadOnlyList<PortDef> Inputs { get; init; }
/// <summary>Output port declarations implied by the type's attributes.</summary>
public IReadOnlyList<PortDef> Outputs { get; init; }
/// <summary>True when the node type declared a <see cref="NodeInfoAttribute"/> with an id.</summary>
public bool IsRegistered => !string.IsNullOrEmpty( Id );
/// <summary>True when the node should be hidden from the palette unless explicitly searched for.</summary>
public bool IsCommon => Tier == NodeTier.Common;
/// <inheritdoc/>
public override string ToString() => $"{Id} ({Type?.Name})";
/// <summary>Build a descriptor from a node type by reflection. Never throws on a malformed type.</summary>
public static NodeDescriptor FromType( Type type )
{
var info = type?.GetCustomAttribute<NodeInfoAttribute>();
var version = type?.GetCustomAttribute<NodeVersionAttribute>();
var formerly = type?.GetCustomAttributes<FormerlyKnownAsAttribute>()
.Select( x => x.OldName )
.Where( x => !string.IsNullOrEmpty( x ) )
.ToArray() ?? Array.Empty<string>();
var title = info?.Title;
if ( string.IsNullOrEmpty( title ) ) title = Prettify( type?.Name );
var category = info?.Category ?? string.Empty;
var (inputs, outputs) = PortBuilder.Reflect( type );
var path = new List<string>();
if ( !string.IsNullOrEmpty( category ) )
{
path.AddRange( category.Split( '/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ) );
}
path.Add( title );
return new NodeDescriptor
{
Type = type,
Id = info?.Id,
Title = title,
Category = category,
Icon = info?.Icon,
Description = info?.Description,
Keywords = info?.Keywords ?? Array.Empty<string>(),
Tier = info?.Tier ?? NodeTier.Common,
Version = version?.Version ?? 1,
Since = info?.Since,
DeprecatedBy = info?.DeprecatedBy,
FormerIds = formerly,
MenuPath = path,
Inputs = inputs,
Outputs = outputs
};
}
/// <summary>Turn <c>TextureSample2DNode</c> into <c>Texture Sample 2D</c>.</summary>
public static string Prettify( string typeName )
{
if ( string.IsNullOrEmpty( typeName ) ) return string.Empty;
var name = typeName.EndsWith( "Node", StringComparison.Ordinal ) && typeName.Length > 4
? typeName[..^4]
: typeName;
var sb = new StringBuilder( name.Length + 8 );
for ( int i = 0; i < name.Length; i++ )
{
var c = name[i];
if ( i > 0 && char.IsUpper( c ) && !char.IsUpper( name[i - 1] ) )
{
sb.Append( ' ' );
}
sb.Append( c );
}
return sb.ToString();
}
}
/// <summary>
/// The descriptor cache. <c>NodeRegistry</c> may install a <see cref="Provider"/> that returns
/// richer descriptors (subgraph-backed node types, for instance); everything else just calls
/// <see cref="Describe"/> and gets a reflected descriptor.
/// </summary>
public static class NodeDescriptors
{
static readonly Dictionary<Type, NodeDescriptor> s_cache = new();
static readonly object s_lock = new();
/// <summary>
/// Optional override installed by the node registry. Returning null falls back to reflection.
/// </summary>
public static Func<Type, NodeDescriptor> Provider { get; set; }
/// <summary>Describe a node type, caching the result.</summary>
public static NodeDescriptor Describe( Type type )
{
if ( type is null ) return NodeDescriptor.FromType( null );
lock ( s_lock )
{
if ( s_cache.TryGetValue( type, out var cached ) ) return cached;
}
var descriptor = Provider?.Invoke( type ) ?? NodeDescriptor.FromType( type );
lock ( s_lock )
{
s_cache[type] = descriptor;
}
return descriptor;
}
/// <summary>
/// Drop every cached descriptor. Must run on hotload: descriptors hold <see cref="Type"/> handles
/// into the outgoing assembly and would otherwise keep it alive and stale.
/// </summary>
public static void Flush()
{
lock ( s_lock )
{
s_cache.Clear();
}
PortBuilder.FlushCache();
}
}