Editor/Prism/Model/PrismNodeType.cs

Model for a node type and search query used by the Prism editor. PrismNodeType wraps a NodeDescriptor and a factory, exposes derived metadata (Id, Title, Category, ports, etc.), provides creation with guarded factory invocation, matching/selection helpers for inputs/outputs, and a scoring/matching algorithm for search; NodeSearchQuery is a small readonly record struct carrying text plus optional plug type and direction.

Reflection
using Editor.Prism.Core;

namespace Editor.Prism.Model;

/// <summary>
/// One entry in the node search palette and the library tree: the metadata needed to present a node
/// type, plus the factory that instantiates it.
/// <para>
/// Deliberately free of <c>Editor.NodeEditor</c>. The UI package wraps this in an <c>INodeType</c>
/// adapter; everything the adapter needs — a menu path, a plug-type match test and a factory — is
/// exposed here, so the model layer never learns that the node-graph framework exists.
/// </para>
/// </summary>
public sealed class PrismNodeType
{
	/// <summary>Build a node type around a descriptor and a factory.</summary>
	public PrismNodeType( NodeDescriptor descriptor, Func<PrismNode> factory )
	{
		Descriptor = descriptor ?? NodeDescriptor.FromType( null );
		Factory = factory;
	}

	/// <summary>The reflected metadata this entry presents.</summary>
	public NodeDescriptor Descriptor { get; }

	/// <summary>Creates an instance. Never null for a registered type.</summary>
	public Func<PrismNode> Factory { get; }

	/// <summary>Stable node type id written into documents.</summary>
	public string Id => Descriptor.Id;

	/// <summary>Display title.</summary>
	public string Title => Descriptor.Title;

	/// <summary>Slash-separated category, e.g. <c>Math/Basic</c>.</summary>
	public string Category => Descriptor.Category;

	/// <summary>Material Icons glyph name.</summary>
	public string Icon => Descriptor.Icon;

	/// <summary>One-line description shown under the title in the palette.</summary>
	public string Description => Descriptor.Description;

	/// <summary>Extra search terms.</summary>
	public IReadOnlyList<string> Keywords => Descriptor.Keywords ?? Array.Empty<string>();

	/// <summary>Category elements plus the title — the path a menu builds from.</summary>
	public IReadOnlyList<string> MenuPath => Descriptor.MenuPath ?? Array.Empty<string>();

	/// <summary>How prominently the node is offered.</summary>
	public NodeTier Tier => Descriptor.Tier;

	/// <summary>True when the node appears without a search filter.</summary>
	public bool IsCommon => Descriptor.Tier == NodeTier.Common;

	/// <summary>True when the node is hidden from search entirely.</summary>
	public bool IsHidden => Descriptor.Tier == NodeTier.Deprecated;

	/// <summary>Input port declarations, for plug-compatibility matching.</summary>
	public IReadOnlyList<PortDef> Inputs => Descriptor.Inputs ?? Array.Empty<PortDef>();

	/// <summary>Output port declarations, for plug-compatibility matching.</summary>
	public IReadOnlyList<PortDef> Outputs => Descriptor.Outputs ?? Array.Empty<PortDef>();

	/// <summary>Instantiate the node. Returns null and logs rather than throwing when the factory fails.</summary>
	public PrismNode Create()
	{
		if ( Factory is null ) return null;

		return PrismLog.Guard<PrismNode>( $"Create node '{Id}'", Factory );
	}

	/// <summary>
	/// The first input that would accept a value of this type, preferring an exact match over a
	/// convertible one and a generic port over both.
	/// </summary>
	public bool TryGetInput( ShaderType type, out PortId port ) => TryMatch( Inputs, type, true, out port );

	/// <summary>
	/// The first output that could feed a port of this type, preferring an exact match over a
	/// convertible one and a generic port over both.
	/// </summary>
	public bool TryGetOutput( ShaderType type, out PortId port ) => TryMatch( Outputs, type, false, out port );

	/// <summary>
	/// Score this type against a search query. Returns null when it does not match at all; higher
	/// scores sort first. Deterministic, so the palette never reshuffles between identical queries.
	/// </summary>
	public int? Score( NodeSearchQuery query )
	{
		if ( IsHidden ) return null;
		if ( !IsCommon && string.IsNullOrWhiteSpace( query.Text ) ) return null;

		if ( query.PlugDirection is { } direction )
		{
			// Dragging from an output needs a node with a compatible input, and the other way round.
			var matched = direction == PortDirection.Output
				? TryGetInput( query.PlugType, out _ )
				: TryGetOutput( query.PlugType, out _ );

			if ( !matched ) return null;
		}

		if ( string.IsNullOrWhiteSpace( query.Text ) ) return 1;

		var text = query.Text.Trim();
		var best = (int?)null;

		best = Better( best, MatchScore( Title, text, 1000 ) );
		best = Better( best, MatchScore( Id, text, 700 ) );
		best = Better( best, MatchScore( Category, text, 400 ) );

		foreach ( var keyword in Keywords )
		{
			best = Better( best, MatchScore( keyword, text, 600 ) );
		}

		if ( best is null ) return null;

		var score = best.Value;

		if ( Tier == NodeTier.Common ) score += 40;
		if ( Tier == NodeTier.Experimental ) score -= 40;

		return score;
	}

	/// <summary>True when the type matches the query at all.</summary>
	public bool Matches( NodeSearchQuery query ) => Score( query ) is not null;

	/// <inheritdoc/>
	public override string ToString() => $"{Id} — {string.Join( " / ", MenuPath )}";

	static int? Better( int? a, int? b ) => a is null ? b : b is null ? a : Math.Max( a.Value, b.Value );

	static int? MatchScore( string candidate, string query, int weight )
	{
		if ( string.IsNullOrEmpty( candidate ) ) return null;

		if ( string.Equals( candidate, query, StringComparison.OrdinalIgnoreCase ) ) return weight + 200;

		var index = candidate.IndexOf( query, StringComparison.OrdinalIgnoreCase );

		if ( index == 0 ) return weight + 100;
		if ( index > 0 ) return weight + 50 - Math.Min( index, 40 );

		return Subsequence( candidate, query ) ? weight - 100 : null;
	}

	/// <summary>Fuzzy fallback: does every query character appear in order inside the candidate?</summary>
	static bool Subsequence( string candidate, string query )
	{
		var q = 0;

		for ( int i = 0; i < candidate.Length && q < query.Length; i++ )
		{
			if ( char.ToLowerInvariant( candidate[i] ) == char.ToLowerInvariant( query[q] ) ) q++;
		}

		return q == query.Length;
	}

	static bool TryMatch( IReadOnlyList<PortDef> ports, ShaderType type, bool asTarget, out PortId port )
	{
		port = PortId.None;

		if ( ports is null || ports.Count == 0 ) return false;

		PortDef exact = null;
		PortDef convertible = null;
		PortDef generic = null;

		foreach ( var def in ports )
		{
			if ( def is null || def.Hidden ) continue;

			if ( def.IsGeneric )
			{
				generic ??= def;
				continue;
			}

			var fixedType = def.FixedType;

			if ( fixedType == type )
			{
				exact ??= def;
				continue;
			}

			if ( type.IsVoid ) continue;

			var from = asTarget ? type : fixedType;
			var to = asTarget ? fixedType : type;

			if ( TypeRules.CanConvert( from, to, out var kind ) && kind != ConversionKind.Illegal )
			{
				convertible ??= def;
			}
		}

		var chosen = exact ?? generic ?? convertible;

		if ( chosen is null ) return false;

		port = chosen.Id;
		return true;
	}
}

/// <summary>
/// A query against the node palette: free text plus, optionally, the type and direction of the plug
/// the user dragged out of. The two together are what makes "drag a wire into space and search"
/// offer only nodes that can actually connect.
/// </summary>
public readonly record struct NodeSearchQuery( string Text )
{
	/// <summary>The resolved type of the plug the search was started from. Void when there is none.</summary>
	public ShaderType PlugType { get; init; }

	/// <summary>
	/// The direction of the plug the search was started from. <c>Output</c> means the user dragged
	/// from an output and needs a node with a compatible <em>input</em>, and vice versa.
	/// </summary>
	public PortDirection? PlugDirection { get; init; }

	/// <summary>A plain text query with no plug context.</summary>
	public static NodeSearchQuery ForText( string text ) => new( text );

	/// <summary>A query started by dragging a wire out of a plug.</summary>
	public static NodeSearchQuery ForPlug( string text, ShaderType type, PortDirection direction ) =>
		new( text ) { PlugType = type, PlugDirection = direction };

	/// <inheritdoc/>
	public override string ToString() =>
		PlugDirection is null ? $"'{Text}'" : $"'{Text}' from {PlugDirection} {PlugType}";
}