Editor/Prism/Model/Keyword.cs

Model class representing a shader keyword/combo for the editor. It stores id, name, kind (feature/static/dynamic), value labels, default, and group, normalizes/sanitizes names to the required prefix and character set, validates the keyword, and emits an IR ComboDecl.

Reflection
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using System.Text;

namespace Editor.Prism.Model;

/// <summary>
/// A shader combo declared by the graph: a feature the material editor can toggle, a static combo
/// that produces a separate binary, or a dynamic combo branched at runtime.
/// <para>
/// The VFX block grammar is strict about combo names — features must be <c>F_</c>, statics <c>S_</c>
/// and dynamics <c>D_</c> — and a violation produces a block-header parse failure with no usable
/// diagnostic at all. <see cref="Validate"/> catches that here, long before anything is generated.
/// </para>
/// </summary>
public sealed class Keyword : IGraphKeyword
{
	/// <summary>Build an empty feature keyword with a fresh id and the default Off/On values.</summary>
	public Keyword()
	{
		Id = ParamId.New();
		Values = new List<string> { "Off", "On" };
	}

	/// <summary>Build a named keyword of a given kind with a fresh id.</summary>
	public Keyword( string name, ComboKind kind ) : this()
	{
		Name = name;
		Kind = kind;
	}

	/// <summary>Stable id, minted once and never rewritten. Nodes reference keywords by this.</summary>
	public ParamId Id { get; set; }

	/// <summary>Combo name as it appears in the generated shader, e.g. <c>F_PUDDLES</c>.</summary>
	public string Name { get; set; } = "F_KEYWORD";

	/// <summary>Feature, static or dynamic.</summary>
	public ComboKind Kind { get; set; } = ComboKind.Feature;

	/// <summary>Value labels, in declaration order. Index 0 is value 0.</summary>
	public List<string> Values { get; set; }

	/// <summary>Index of the default value.</summary>
	public int Default { get; set; }

	/// <summary>Group heading in the material editor.</summary>
	public string Group { get; set; }

	/// <summary>The number of distinct values this combo takes. Always at least two.</summary>
	public int ValueCount => Math.Max( 2, Values?.Count ?? 0 );

	/// <summary>The prefix the VFX block grammar requires for this combo kind.</summary>
	public string RequiredPrefix => PrefixFor( Kind );

	/// <summary>True when this is a boolean on/off combo rather than an enumeration.</summary>
	public bool IsBoolean => ValueCount == 2;

	/// <summary>The default value clamped into range, so a corrupt document cannot produce an invalid combo.</summary>
	public int SafeDefault => Math.Clamp( Default, 0, ValueCount - 1 );

	/// <summary>
	/// The name with the correct prefix applied and illegal characters removed. This is what the
	/// backend emits, so a graph authored with a sloppy name still produces a legal shader.
	/// </summary>
	public string NormalizedName
	{
		get
		{
			var prefix = RequiredPrefix;
			var body = Sanitize( Name );

			foreach ( var known in s_prefixes )
			{
				if ( !body.StartsWith( known, StringComparison.Ordinal ) ) continue;

				body = body[known.Length..];
				break;
			}

			if ( string.IsNullOrEmpty( body ) ) body = "KEYWORD";

			return prefix + body;
		}
	}

	/// <summary>Lower into the combo declaration the IR module carries.</summary>
	public ComboDecl ToDecl() => new(
		NormalizedName, Kind,
		Values is { Count: > 1 } ? Values.ToArray() : new[] { "Off", "On" },
		SafeDefault, Group );

	// --------------------------------------------------------- IGraphKeyword ----
	// What the compiler sees is deliberately the *repaired* keyword, not the authored one: the VFX block
	// grammar rejects a combo whose name lacks its F_/S_/D_ prefix, or whose value list is shorter than
	// two, with a block-header parse failure that carries no usable diagnostic. Normalising here means a
	// sloppily authored keyword still produces a shader that compiles.

	/// <summary>The prefix-corrected combo name the backend emits.</summary>
	string IGraphKeyword.Name => NormalizedName;

	/// <summary>The value labels, never fewer than the two a combo must have.</summary>
	IReadOnlyList<string> IGraphKeyword.Values =>
		Values is { Count: > 1 } ? Values : new List<string> { "Off", "On" };

	/// <summary>The default index, clamped into range so a corrupt document cannot emit an invalid combo.</summary>
	int IGraphKeyword.Default => SafeDefault;

	/// <summary>
	/// Report anything that would make the generated block header fail to parse. Returns an empty list
	/// when the keyword is well formed.
	/// </summary>
	public IReadOnlyList<Diagnostic> Validate()
	{
		var problems = new List<Diagnostic>();

		if ( string.IsNullOrWhiteSpace( Name ) )
		{
			problems.Add( Diagnostic.Error( DiagnosticCode.InvalidBlock, "Keyword has no name" ) );
			return problems;
		}

		if ( !Name.StartsWith( RequiredPrefix, StringComparison.Ordinal ) )
		{
			problems.Add( Diagnostic.Warning( DiagnosticCode.InvalidBlock,
				$"'{Name}' will be emitted as '{NormalizedName}'",
				null, $"A {Kind} combo must be named with the '{RequiredPrefix}' prefix." ) );
		}

		if ( Sanitize( Name ) != Name )
		{
			problems.Add( Diagnostic.Warning( DiagnosticCode.InvalidBlock,
				$"'{Name}' contains characters that are illegal in a combo name",
				null, "Combo names may only contain A-Z, 0-9 and underscores." ) );
		}

		if ( Values is null || Values.Count < 2 )
		{
			problems.Add( Diagnostic.Warning( DiagnosticCode.InvalidBlock,
				$"'{Name}' declares fewer than two values; Off/On will be used" ) );
		}

		if ( Default < 0 || Default >= ValueCount )
		{
			problems.Add( Diagnostic.Warning( DiagnosticCode.InvalidBlock,
				$"'{Name}' has an out-of-range default; {SafeDefault} will be used" ) );
		}

		return problems;
	}

	/// <summary>Deep copy, keeping the same id.</summary>
	public Keyword Clone() => new()
	{
		Id = Id,
		Name = Name,
		Kind = Kind,
		Values = Values is null ? new List<string> { "Off", "On" } : new List<string>( Values ),
		Default = Default,
		Group = Group
	};

	/// <summary>Deep copy with a freshly minted id.</summary>
	public Keyword CloneWithNewId()
	{
		var copy = Clone();
		copy.Id = ParamId.New();
		return copy;
	}

	/// <inheritdoc/>
	public override string ToString() => $"{NormalizedName} ({Kind}, {ValueCount} values)";

	/// <summary>The prefix the VFX block grammar requires for a combo kind.</summary>
	public static string PrefixFor( ComboKind kind ) => kind switch
	{
		ComboKind.Static => "S_",
		ComboKind.Dynamic => "D_",
		_ => "F_"
	};

	/// <summary>Uppercase, underscore-separated, alphanumeric only — the only shape a combo name may take.</summary>
	public static string Sanitize( string name )
	{
		if ( string.IsNullOrWhiteSpace( name ) ) return string.Empty;

		var sb = new StringBuilder( name.Length );

		foreach ( var c in name )
		{
			if ( char.IsLetterOrDigit( c ) )
			{
				sb.Append( char.ToUpperInvariant( c ) );
				continue;
			}

			if ( sb.Length > 0 && sb[^1] != '_' ) sb.Append( '_' );
		}

		var result = sb.ToString().Trim( '_' );

		if ( result.Length > 0 && char.IsDigit( result[0] ) ) result = "_" + result;

		return result;
	}

	static readonly string[] s_prefixes = { "F_", "S_", "D_" };
}