Editor/Prism/Compiler/Ir/IrPreprocessor.cs

Editor-side compiler IR helpers for preprocessor-controlled shader combo conditions and guarded statements. Defines ComboCondition (representation of #if expressions over combo symbols), rendering to C preprocessor syntax, validation, symbol enumeration, and factories for IrPreprocessorIf and Select helper that emit preprocessor-guarded IR blocks.

Reflection
using System.Globalization;
using System.Text;
using Editor.Prism.Core;

namespace Editor.Prism.Compiler.Ir;

/// <summary>How a combo symbol is compared against a literal inside a preprocessor condition.</summary>
public enum ComboComparison
{
	/// <summary><c>SYMBOL == value</c>.</summary>
	Equal,
	/// <summary><c>SYMBOL != value</c>.</summary>
	NotEqual,
	/// <summary><c>SYMBOL &lt; value</c>.</summary>
	Less,
	/// <summary><c>SYMBOL &lt;= value</c>.</summary>
	LessOrEqual,
	/// <summary><c>SYMBOL &gt; value</c>.</summary>
	Greater,
	/// <summary><c>SYMBOL &gt;= value</c>.</summary>
	GreaterOrEqual
}

/// <summary>What shape a <see cref="ComboCondition"/> has.</summary>
public enum ComboLogic
{
	/// <summary>A leaf: one combo symbol compared against a literal.</summary>
	Compare,
	/// <summary>A leaf: <c>defined( SYMBOL )</c>.</summary>
	Defined,
	/// <summary>A leaf: <c>!defined( SYMBOL )</c>.</summary>
	NotDefined,
	/// <summary>Every operand must hold.</summary>
	And,
	/// <summary>At least one operand must hold.</summary>
	Or,
	/// <summary>The single operand must not hold.</summary>
	Not
}

/// <summary>
/// A condition over shader combos, evaluated by the C preprocessor rather than at run time.
/// <para>
/// This is deliberately <em>not</em> an <see cref="IrExpr"/>. A preprocessor condition may only
/// mention combo symbols and integer literals — never a temp, never a uniform, never an intrinsic —
/// because it is resolved before the compiler has a notion of either. Keeping it a separate,
/// closed model is what stops a node from accidentally asking for <c>#if someTemp &gt; 0.5</c>.
/// </para>
/// <para>
/// Both backends render this the same way: the VFX <c>.shader</c> is preprocessed by DXC and the
/// Slang module by Slang's own C preprocessor, and both accept the identical grammar.
/// </para>
/// </summary>
public sealed record ComboCondition
{
	/// <summary>What shape this condition has.</summary>
	public ComboLogic Logic { get; init; } = ComboLogic.Compare;

	/// <summary>The combo symbol, for a leaf condition, e.g. <c>S_PUDDLES</c>.</summary>
	public string Symbol { get; init; }

	/// <summary>How the symbol is compared, for <see cref="ComboLogic.Compare"/>.</summary>
	public ComboComparison Comparison { get; init; } = ComboComparison.NotEqual;

	/// <summary>The literal the symbol is compared against, for <see cref="ComboLogic.Compare"/>.</summary>
	public int Value { get; init; }

	/// <summary>The operands, for a compound condition.</summary>
	public IReadOnlyList<ComboCondition> Operands { get; init; } = Array.Empty<ComboCondition>();

	/// <summary>The combo is set to anything other than zero: <c>SYMBOL != 0</c>.</summary>
	public static ComboCondition On( string symbol ) =>
		new() { Logic = ComboLogic.Compare, Symbol = symbol, Comparison = ComboComparison.NotEqual, Value = 0 };

	/// <summary>The combo is zero: <c>SYMBOL == 0</c>.</summary>
	public static ComboCondition Off( string symbol ) =>
		new() { Logic = ComboLogic.Compare, Symbol = symbol, Comparison = ComboComparison.Equal, Value = 0 };

	/// <summary>The combo holds a particular value: <c>SYMBOL == value</c>.</summary>
	public static ComboCondition Is( string symbol, int value ) =>
		new() { Logic = ComboLogic.Compare, Symbol = symbol, Comparison = ComboComparison.Equal, Value = value };

	/// <summary>An arbitrary comparison of a combo against a literal.</summary>
	public static ComboCondition Compare( string symbol, ComboComparison comparison, int value ) =>
		new() { Logic = ComboLogic.Compare, Symbol = symbol, Comparison = comparison, Value = value };

	/// <summary><c>defined( SYMBOL )</c>. Useful for a macro the engine may or may not have declared.</summary>
	public static ComboCondition Defined( string symbol ) =>
		new() { Logic = ComboLogic.Defined, Symbol = symbol };

	/// <summary><c>!defined( SYMBOL )</c>.</summary>
	public static ComboCondition NotDefined( string symbol ) =>
		new() { Logic = ComboLogic.NotDefined, Symbol = symbol };

	/// <summary>Every operand must hold. A single operand collapses to itself.</summary>
	public static ComboCondition All( params ComboCondition[] operands ) => Combine( ComboLogic.And, operands );

	/// <summary>At least one operand must hold. A single operand collapses to itself.</summary>
	public static ComboCondition Any( params ComboCondition[] operands ) => Combine( ComboLogic.Or, operands );

	/// <summary>Negate a condition. Negating a negation collapses back to the original.</summary>
	public static ComboCondition Not( ComboCondition operand )
	{
		if ( operand is null ) return null;
		if ( operand.Logic == ComboLogic.Not ) return operand.Operands.Count == 1 ? operand.Operands[0] : null;

		return new ComboCondition { Logic = ComboLogic.Not, Operands = [operand] };
	}

	static ComboCondition Combine( ComboLogic logic, IReadOnlyList<ComboCondition> operands )
	{
		var kept = new List<ComboCondition>();

		foreach ( var operand in operands ?? Array.Empty<ComboCondition>() )
		{
			if ( operand is null || !operand.IsValid ) continue;

			// Flatten, so All( a, All( b, c ) ) renders as one conjunction rather than nested parentheses.
			if ( operand.Logic == logic )
			{
				kept.AddRange( operand.Operands );
				continue;
			}

			kept.Add( operand );
		}

		if ( kept.Count == 0 ) return null;
		if ( kept.Count == 1 ) return kept[0];

		return new ComboCondition { Logic = logic, Operands = kept };
	}

	/// <summary>
	/// True when this condition can be written as a preprocessor expression. An invalid condition is
	/// never emitted as a broken <c>#if</c>: the backend reports it and emits the guarded code
	/// unconditionally instead, which is the failure that loses the least work.
	/// </summary>
	public bool IsValid
	{
		get
		{
			switch ( Logic )
			{
				case ComboLogic.Compare:
				case ComboLogic.Defined:
				case ComboLogic.NotDefined:
					return IsIdentifier( Symbol );

				case ComboLogic.Not:
					return Operands is { Count: 1 } && Operands[0] is { IsValid: true };

				default:
					if ( Operands is not { Count: > 1 } ) return false;

					foreach ( var operand in Operands )
					{
						if ( operand is null || !operand.IsValid ) return false;
					}

					return true;
			}
		}
	}

	/// <summary>
	/// The preprocessor expression, without the leading <c>#if</c>. Empty when
	/// <see cref="IsValid"/> is false.
	/// </summary>
	public string Expression
	{
		get
		{
			if ( !IsValid ) return string.Empty;

			var builder = new StringBuilder();
			Render( builder, false );

			return builder.ToString();
		}
	}

	/// <summary>Every combo symbol this condition mentions, in order, without duplicates.</summary>
	public IEnumerable<string> Symbols
	{
		get
		{
			var seen = new HashSet<string>( StringComparer.Ordinal );

			foreach ( var symbol in Walk( this ) )
			{
				if ( seen.Add( symbol ) ) yield return symbol;
			}

			static IEnumerable<string> Walk( ComboCondition condition )
			{
				if ( condition is null ) yield break;

				if ( !string.IsNullOrEmpty( condition.Symbol ) ) yield return condition.Symbol;

				foreach ( var operand in condition.Operands ?? Array.Empty<ComboCondition>() )
				{
					foreach ( var symbol in Walk( operand ) ) yield return symbol;
				}
			}
		}
	}

	void Render( StringBuilder builder, bool parenthesise )
	{
		switch ( Logic )
		{
			case ComboLogic.Compare:
				builder.Append( Symbol ).Append( ' ' ).Append( Symbolise( Comparison ) ).Append( ' ' )
					.Append( Value.ToString( CultureInfo.InvariantCulture ) );
				break;

			case ComboLogic.Defined:
				builder.Append( "defined( " ).Append( Symbol ).Append( " )" );
				break;

			case ComboLogic.NotDefined:
				builder.Append( "!defined( " ).Append( Symbol ).Append( " )" );
				break;

			case ComboLogic.Not:
				builder.Append( "!( " );
				Operands[0].Render( builder, false );
				builder.Append( " )" );
				break;

			default:
				var separator = Logic == ComboLogic.And ? " && " : " || ";

				if ( parenthesise ) builder.Append( "( " );

				for ( int i = 0; i < Operands.Count; i++ )
				{
					if ( i > 0 ) builder.Append( separator );

					Operands[i].Render( builder, true );
				}

				if ( parenthesise ) builder.Append( " )" );
				break;
		}
	}

	static string Symbolise( ComboComparison comparison ) => comparison switch
	{
		ComboComparison.Equal => "==",
		ComboComparison.Less => "<",
		ComboComparison.LessOrEqual => "<=",
		ComboComparison.Greater => ">",
		ComboComparison.GreaterOrEqual => ">=",
		_ => "!="
	};

	/// <summary>True when the text is a legal preprocessor identifier.</summary>
	public static bool IsIdentifier( string text )
	{
		if ( string.IsNullOrEmpty( text ) ) return false;
		if ( !char.IsLetter( text[0] ) && text[0] != '_' ) return false;

		foreach ( var c in text )
		{
			if ( !char.IsLetterOrDigit( c ) && c != '_' ) return false;
		}

		return true;
	}

	/// <inheritdoc/>
	public override string ToString() => IsValid ? Expression : "<invalid combo condition>";
}

/// <summary>
/// A branch resolved by the preprocessor rather than by the GPU.
/// <para>
/// The backends write this as a real <c>#if</c> / <c>#else</c> / <c>#endif</c>, so only the taken
/// side ever reaches the compiler. That is the whole point: a run-time <c>select</c> evaluates both
/// sides and pays for both, while a static combo should cost exactly one. Texture samples, loops and
/// stage-restricted intrinsics inside the untaken side disappear entirely instead of being compiled
/// and discarded.
/// </para>
/// <para>
/// <see cref="Then"/> and <see cref="Else"/> are ordinary <see cref="IrBlock"/>s, so anything that
/// can be a statement can be guarded — including another <see cref="IrPreprocessorIf"/>.
/// </para>
/// </summary>
public sealed record IrPreprocessorIf( NodeId Origin, ComboCondition Condition, IrBlock Then, IrBlock Else )
	: IrStmt( Origin )
{
	/// <summary>True when the else side would emit anything.</summary>
	public bool HasElse => Else is { IsEmpty: false };

	/// <summary>True when the condition can be written as a preprocessor expression.</summary>
	public bool IsValid => Condition is { IsValid: true };

	/// <inheritdoc/>
	public override string ToString() =>
		$"#if {( Condition?.Expression ?? "?" )} ({Then?.Statements.Count ?? 0} / {Else?.Statements.Count ?? 0})";
}

/// <summary>
/// Factories for preprocessor-guarded statements, plus the directive spelling both backends share.
/// <para>
/// The spelling lives here rather than in either backend because a <c>.shader</c> and a
/// <c>.slang</c> module are preprocessed by two different C preprocessors that happen to agree
/// exactly, and the day one of them stops agreeing this is the single place that has to change.
/// </para>
/// </summary>
public static class IrPreprocessor
{
	/// <summary>Guard a block on a combo condition.</summary>
	public static IrStmt PreprocessorIf( NodeId origin, ComboCondition condition, IrBlock then,
		IrBlock otherwise = null ) =>
		new IrPreprocessorIf( origin, condition, then ?? new IrBlock(), otherwise );

	/// <summary>Guard a block on a combo symbol being non-zero.</summary>
	public static IrStmt PreprocessorIf( NodeId origin, string comboSymbol, IrBlock then,
		IrBlock otherwise = null ) =>
		PreprocessorIf( origin, ComboCondition.On( comboSymbol ), then, otherwise );

	/// <summary>
	/// The statements that pick one of two values at compile time: an uninitialised local followed by a
	/// preprocessor branch that assigns it.
	/// <para>
	/// This is the shape a keyword branch wants. A run-time <c>select</c> would evaluate both inputs;
	/// this evaluates exactly the one the combo selected, and the other side's texture samples and
	/// loops never reach the compiler at all.
	/// </para>
	/// </summary>
	public static IReadOnlyList<IrStmt> Select( NodeId origin, ComboCondition condition, string name,
		ShaderType type, IrExpr on, IrExpr off )
	{
		if ( string.IsNullOrWhiteSpace( name ) || on is null || off is null ) return Array.Empty<IrStmt>();

		var target = new IrVar( type, name );
		var then = new IrBlock().Add( new IrAssign( origin, target, on ) );
		var otherwise = new IrBlock().Add( new IrAssign( origin, target, off ) );

		return
		[
			new IrDecl( origin, name, type, null ),
			PreprocessorIf( origin, condition, then, otherwise )
		];
	}

	/// <summary>
	/// The opening directive for a condition, e.g. <c>#if S_PUDDLES != 0</c>. An invalid condition
	/// produces an empty string; the caller then emits the guarded body unconditionally rather than
	/// writing a directive the preprocessor would reject.
	/// </summary>
	public static string OpenDirective( ComboCondition condition ) =>
		condition is { IsValid: true } ? $"#if {condition.Expression}" : string.Empty;

	/// <summary>The <c>#else</c> directive.</summary>
	public const string ElseDirective = "#else";

	/// <summary>The <c>#endif</c> directive.</summary>
	public const string EndDirective = "#endif";
}