Editor/Prism/Model/NodeDescriptor.cs

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.

Reflection
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();
	}
}