Editor/Prism/Model/NodeRegistry.cs

Registry for Prism node types used by the editor. It discovers PrismNode subclasses via the editor type library or loaded assemblies, builds descriptors and lookup maps keyed by stable Ids (including former ids), exposes queries, creation helpers, search and hotload-safe Flush/Rebuild logic.

ReflectionFile Access
using Editor.Prism.Core;
using System.Reflection;

namespace Editor.Prism.Model;

/// <summary>
/// The catalogue of every node type this editor can create.
/// <para>
/// Discovery is by reflection over every concrete <see cref="PrismNode"/> subclass the editor type
/// library knows about, so any third-party editor assembly that references Prism contributes nodes
/// automatically. Types are keyed by the stable <c>[NodeInfo( Id = … )]</c> string rather than the C#
/// type name, so renaming a class never breaks a saved graph, and <c>[FormerlyKnownAs]</c> keeps old
/// ids resolving.
/// </para>
/// <para>
/// Every cache in here is static, so <see cref="Flush"/> <b>must</b> run on hotload: descriptors hold
/// <see cref="Type"/> handles into the outgoing assembly and would otherwise resurrect it.
/// </para>
/// </summary>
public static class NodeRegistry
{
	static readonly object s_lock = new();

	static Dictionary<string, PrismNodeType> s_byId;
	static Dictionary<string, PrismNodeType> s_byFormerId;
	static Dictionary<Type, NodeDescriptor> s_byType;
	static List<PrismNodeType> s_ordered;
	static List<Diagnostic> s_problems;

	/// <summary>Raised after the catalogue is rebuilt, so palettes and library trees can refresh.</summary>
	public static event Action Refreshed;

	/// <summary>Every registered node type, ordered by menu path then title. Never null.</summary>
	public static IReadOnlyList<PrismNodeType> Types
	{
		get
		{
			EnsureBuilt();
			return s_ordered;
		}
	}

	/// <summary>Every registered node type's descriptor, in the same order as <see cref="Types"/>.</summary>
	public static IEnumerable<NodeDescriptor> All => Types.Select( x => x.Descriptor );

	/// <summary>How many node types were discovered.</summary>
	public static int Count => Types.Count;

	/// <summary>
	/// Problems found while building the catalogue: duplicate ids, types without an id, types with no
	/// usable constructor. A duplicate id is an error and names both offending types.
	/// </summary>
	public static IReadOnlyList<Diagnostic> Problems
	{
		get
		{
			EnsureBuilt();
			return s_problems;
		}
	}

	/// <summary>Every distinct category string, sorted, for building the library tree.</summary>
	public static IEnumerable<string> Categories =>
		Types.Select( x => x.Category ).Where( x => !string.IsNullOrEmpty( x ) )
			.Distinct( StringComparer.OrdinalIgnoreCase ).OrderBy( x => x, StringComparer.OrdinalIgnoreCase );

	/// <summary>Build the catalogue if it has not been built yet. Cheap after the first call.</summary>
	public static void EnsureBuilt()
	{
		lock ( s_lock )
		{
			if ( s_ordered is not null ) return;
		}

		Rebuild();
	}

	/// <summary>Force a full rediscovery pass. Safe to call at any time.</summary>
	public static void Rebuild()
	{
		var byId = new Dictionary<string, PrismNodeType>( StringComparer.OrdinalIgnoreCase );
		var byFormer = new Dictionary<string, PrismNodeType>( StringComparer.OrdinalIgnoreCase );
		var byType = new Dictionary<Type, NodeDescriptor>();
		var problems = new List<Diagnostic>();

		foreach ( var type in DiscoverTypes( problems ) )
		{
			var descriptor = PrismLog.Guard( $"Describe node type '{type.FullName}'",
				() => NodeDescriptor.FromType( type ) );

			if ( descriptor is null ) continue;

			byType[type] = descriptor;

			if ( !descriptor.IsRegistered )
			{
				// No [NodeInfo( Id = … )]. Legitimate for internal placeholders such as UnknownNode,
				// so this is informational rather than an error, and the type is simply not offered.
				continue;
			}

			if ( byId.TryGetValue( descriptor.Id, out var existing ) )
			{
				problems.Add( Diagnostic.Error( DiagnosticCode.DuplicateNodeId,
					$"Two node types declare the id '{descriptor.Id}'",
					null, $"{existing.Descriptor.Type?.FullName} and {type.FullName}. " +
						"Ids must be unique; the second type will not be offered." ) );
				continue;
			}

			var nodeType = new PrismNodeType( descriptor, MakeFactory( type ) );
			byId[descriptor.Id] = nodeType;

			foreach ( var former in descriptor.FormerIds ?? Array.Empty<string>() )
			{
				if ( string.IsNullOrWhiteSpace( former ) ) continue;
				if ( byId.ContainsKey( former ) ) continue;

				byFormer[former] = nodeType;
			}
		}

		var ordered = byId.Values
			.OrderBy( x => string.Join( "/", x.MenuPath ), StringComparer.OrdinalIgnoreCase )
			.ThenBy( x => x.Title, StringComparer.OrdinalIgnoreCase )
			.ToList();

		lock ( s_lock )
		{
			s_byId = byId;
			s_byFormerId = byFormer;
			s_byType = byType;
			s_ordered = ordered;
			s_problems = problems;
		}

		// Route PrismNode.Descriptor through us so the model layer keeps no dependency on the registry.
		NodeDescriptors.Provider = DescribeCached;

		foreach ( var problem in problems )
		{
			if ( problem.Severity == DiagnosticSeverity.Error ) PrismLog.Error( problem.ToString() );
			else PrismLog.Warn( problem.ToString() );
		}

		PrismLog.Trace( $"NodeRegistry: {ordered.Count} node types, {problems.Count} problems" );

		PrismLog.Guard( "NodeRegistry.Refreshed", () => Refreshed?.Invoke() );
	}

	/// <summary>
	/// Drop every cache in the model and serialization layers. <b>Must run on hotload</b>, before
	/// anything touches a node type again: every cache here holds <see cref="Type"/> handles and
	/// <see cref="System.Reflection.PropertyInfo"/> objects that belong to the outgoing assembly.
	/// <para>
	/// This is the single call a hotload handler needs for this package. It also flushes the descriptor
	/// cache, the port reflection cache and the node-property reflection cache. Migration registries
	/// and the legacy import table hold delegates into the outgoing assembly too, so whichever package
	/// registers those is responsible for re-registering them after a hotload.
	/// </para>
	/// </summary>
	public static void Flush()
	{
		lock ( s_lock )
		{
			s_byId = null;
			s_byFormerId = null;
			s_byType = null;
			s_ordered = null;
			s_problems = null;
		}

		NodeDescriptors.Provider = null;
		NodeDescriptors.Flush();

		PrismLog.Guard( "Flush node property cache", Serialization.NodeProperties.Flush );
	}

	/// <summary>Metadata for a node type. Falls back to plain reflection for unregistered types.</summary>
	public static NodeDescriptor Describe( Type type )
	{
		if ( type is null ) return NodeDescriptor.FromType( null );

		EnsureBuilt();

		lock ( s_lock )
		{
			if ( s_byType is not null && s_byType.TryGetValue( type, out var found ) ) return found;
		}

		return NodeDescriptor.FromType( type );
	}

	/// <summary>The node type registered under this stable id, including former ids. Null when unknown.</summary>
	public static PrismNodeType Find( string typeId )
	{
		if ( string.IsNullOrWhiteSpace( typeId ) ) return null;

		EnsureBuilt();

		lock ( s_lock )
		{
			if ( s_byId is not null && s_byId.TryGetValue( typeId, out var found ) ) return found;
			if ( s_byFormerId is not null && s_byFormerId.TryGetValue( typeId, out var former ) ) return former;
		}

		return null;
	}

	/// <summary>The node type backed by a given CLR type. Null when it is not registered.</summary>
	public static PrismNodeType Find( Type type )
	{
		if ( type is null ) return null;

		EnsureBuilt();

		lock ( s_lock )
		{
			if ( s_ordered is null ) return null;

			foreach ( var candidate in s_ordered )
			{
				if ( candidate.Descriptor.Type == type ) return candidate;
			}
		}

		return null;
	}

	/// <summary>Resolve a stable id to a descriptor, reporting whether it was known.</summary>
	public static bool TryResolve( string typeId, out NodeDescriptor descriptor )
	{
		var found = Find( typeId );
		descriptor = found?.Descriptor;
		return descriptor is not null;
	}

	/// <summary>True when the id resolves to something, directly or through a former id.</summary>
	public static bool IsRegistered( string typeId ) => Find( typeId ) is not null;

	/// <summary>
	/// Instantiate a node by stable id. Returns null when the id is unknown or the constructor threw —
	/// callers turn that into an <see cref="UnknownNode"/> rather than losing the node.
	/// </summary>
	public static PrismNode Create( string typeId ) => Find( typeId )?.Create();

	/// <summary>Instantiate a node from a descriptor.</summary>
	public static PrismNode Create( NodeDescriptor descriptor )
	{
		if ( descriptor?.Type is null ) return null;

		var registered = Find( descriptor.Id );

		return registered is not null
			? registered.Create()
			: PrismLog.Guard<PrismNode>( $"Create node '{descriptor.Type.FullName}'",
				() => Activator.CreateInstance( descriptor.Type ) as PrismNode );
	}

	/// <summary>Instantiate a node by CLR type.</summary>
	public static T Create<T>() where T : PrismNode => Create( typeof( T ) ) as T;

	/// <summary>Instantiate a node by CLR type.</summary>
	public static PrismNode Create( Type type )
	{
		if ( type is null ) return null;

		return PrismLog.Guard<PrismNode>( $"Create node '{type.FullName}'",
			() => Activator.CreateInstance( type ) as PrismNode );
	}

	/// <summary>Every node type whose category matches, in menu order.</summary>
	public static IEnumerable<PrismNodeType> InCategory( string category )
	{
		if ( string.IsNullOrEmpty( category ) ) return Types;

		return Types.Where( x => string.Equals( x.Category, category, StringComparison.OrdinalIgnoreCase ) );
	}

	/// <summary>
	/// Rank the catalogue against a search query, best first. Ties break on title so the palette is
	/// stable between identical searches.
	/// </summary>
	public static IEnumerable<PrismNodeType> Search( NodeSearchQuery query, int limit = 0 )
	{
		var scored = new List<(PrismNodeType Type, int Score)>();

		foreach ( var type in Types )
		{
			if ( type.Score( query ) is not { } score ) continue;

			scored.Add( (type, score) );
		}

		var ordered = scored
			.OrderByDescending( x => x.Score )
			.ThenBy( x => x.Type.Title, StringComparer.OrdinalIgnoreCase )
			.Select( x => x.Type );

		return limit > 0 ? ordered.Take( limit ) : ordered;
	}

	/// <summary>Rank the catalogue against plain search text.</summary>
	public static IEnumerable<PrismNodeType> Search( string text, int limit = 0 ) =>
		Search( NodeSearchQuery.ForText( text ), limit );

	static NodeDescriptor DescribeCached( Type type )
	{
		lock ( s_lock )
		{
			if ( s_byType is not null && s_byType.TryGetValue( type, out var found ) ) return found;
		}

		return NodeDescriptor.FromType( type );
	}

	static Func<PrismNode> MakeFactory( Type type ) =>
		() => Activator.CreateInstance( type ) as PrismNode;

	/// <summary>
	/// Every concrete <see cref="PrismNode"/> subclass, from the editor type library when it is
	/// available and from the loaded assemblies otherwise. The fallback matters: it keeps the
	/// registry working in a plain build or a unit-test host where the type library is not running.
	/// </summary>
	static IEnumerable<Type> DiscoverTypes( List<Diagnostic> problems )
	{
		var found = new List<Type>();
		var seen = new HashSet<Type>();

		var fromLibrary = PrismLog.Guard( "NodeRegistry type-library scan", () =>
			EditorTypeLibrary.GetTypes<PrismNode>()
				.Where( x => x is not null && !x.IsAbstract )
				.Select( x => x.TargetType )
				.Where( x => x is not null )
				.ToArray(),
			Array.Empty<Type>() );

		foreach ( var type in fromLibrary )
		{
			if ( !seen.Add( type ) ) continue;

			found.Add( type );
		}

		if ( found.Count == 0 )
		{
			foreach ( var type in ScanLoadedAssemblies( problems ) )
			{
				if ( !seen.Add( type ) ) continue;

				found.Add( type );
			}
		}

		// Deterministic order in, deterministic duplicate reporting out.
		return found.OrderBy( x => x.FullName, StringComparer.Ordinal );
	}

	static IEnumerable<Type> ScanLoadedAssemblies( List<Diagnostic> problems )
	{
		var result = new List<Type>();

		foreach ( var assembly in AppDomain.CurrentDomain.GetAssemblies() )
		{
			Type[] types;

			try
			{
				types = assembly.GetTypes();
			}
			catch ( ReflectionTypeLoadException e )
			{
				types = e.Types?.Where( x => x is not null ).ToArray() ?? Array.Empty<Type>();
			}
			catch ( Exception )
			{
				continue;
			}

			foreach ( var type in types )
			{
				if ( type is null || type.IsAbstract || !type.IsClass ) continue;
				if ( !typeof( PrismNode ).IsAssignableFrom( type ) ) continue;

				if ( type.GetConstructor( Type.EmptyTypes ) is null )
				{
					problems.Add( Diagnostic.Warning( DiagnosticCode.UnknownNodeType,
						$"Node type '{type.FullName}' has no public parameterless constructor and was skipped" ) );
					continue;
				}

				result.Add( type );
			}
		}

		return result;
	}
}