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.
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_" };
}