Editor/Prism/Compiler/Ir/IrBuilder.cs

IR builder for a single shader stage. It interns expression nodes (hash-consing), constructs various IR expressions and statements, manages nested blocks/scopes/loops/ifs, and provides common-subexpression binding to reuse temps across the current scope.

Native Interop
using Editor.Prism.Core;
using System.Text;

namespace Editor.Prism.Compiler.Ir;

/// <summary>
/// Builds the IR for one shader stage.
/// <para>
/// Two things make this more than a factory. First, <b>hash-consing</b>: every expression handed out
/// is interned in a structural-equality pool, so two structurally identical trees — even when they
/// come from two unrelated nodes — are the same object. Second, <b>common-subexpression binding</b>:
/// <see cref="Bind"/> looks the interned expression up in a scoped temp table and reuses the temp that
/// already holds it instead of emitting a second declaration. Together those give real CSE across the
/// whole graph rather than the per-connection memo a naive emitter manages.
/// </para>
/// <para>
/// The block stack keeps temps honest: a temp bound inside an <c>if</c> is dropped from the CSE table
/// when that block closes, so it can never be referenced from a sibling branch.
/// </para>
/// </summary>
public sealed class IrBuilder
{
	readonly Dictionary<IrExpr, IrExpr> _pool = new( StructuralComparer.Instance );
	readonly List<Frame> _frames = new();
	readonly HashSet<string> _names = new( StringComparer.Ordinal );

	int _tempSerial;

	/// <summary>Start a builder for one stage.</summary>
	public IrBuilder( ShaderStage stage, bool debugSymbols = false )
	{
		Stage = stage;
		DebugSymbols = debugSymbols;
		Root = new IrBlock();

		_frames.Add( new Frame( Root ) );
	}

	/// <summary>The stage whose code this builder is assembling.</summary>
	public ShaderStage Stage { get; }

	/// <summary>When set, temps get descriptive names and per-node comments are worth emitting.</summary>
	public bool DebugSymbols { get; set; }

	/// <summary>The outermost block. Statements land here unless a nested block is open.</summary>
	public IrBlock Root { get; }

	/// <summary>The block statements are currently being appended to.</summary>
	public IrBlock Current => _frames[^1].Block;

	/// <summary>How many blocks are open above the root.</summary>
	public int Depth => _frames.Count - 1;

	/// <summary>Temps declared so far.</summary>
	public int TempCount => _tempSerial;

	/// <summary>Distinct expressions interned so far.</summary>
	public int InternedCount => _pool.Count;

	/// <summary>Number of times a binding request was answered by an existing temp.</summary>
	public int CseHits { get; private set; }

	/// <summary>Number of times an interning request found an identical expression already in the pool.</summary>
	public int InternHits { get; private set; }

	// ---- interning --------------------------------------------------------

	/// <summary>
	/// Return the canonical instance of an expression, adding it to the pool when it is new. The
	/// returned object is reference-comparable against any structurally identical expression built
	/// through this builder.
	/// </summary>
	public IrExpr Intern( IrExpr expr )
	{
		if ( expr is null ) return null;

		if ( _pool.TryGetValue( expr, out var existing ) )
		{
			InternHits++;
			return existing;
		}

		var hashed = expr.Hash != 0 ? expr : expr with { Hash = StructuralHash( expr ) };

		_pool[hashed] = hashed;
		return hashed;
	}

	IrValue Wrap( IrExpr expr, ShaderType type ) => IrValue.Of( Intern( expr ), type );

	// ---- expression construction ------------------------------------------

	/// <summary>A literal.</summary>
	public IrValue Const( ShaderType type, ConstValue value ) => Wrap( new IrConst( type, value ), type );

	/// <summary>A reference to a named local.</summary>
	public IrValue Var( ShaderType type, string name ) => Wrap( new IrVar( type, name ), type );

	/// <summary>A reference to a module-level declaration.</summary>
	public IrValue GlobalRef( GlobalDecl decl )
	{
		if ( decl is null ) return IrValue.Invalid;

		return Wrap( new IrGlobalRef( decl.Type, decl ), decl.Type );
	}

	/// <summary>A reference to an environment-provided value.</summary>
	public IrValue BuiltinRef( Builtin id )
	{
		var type = Builtins.TypeOf( id );
		if ( type.IsVoid ) return IrValue.Invalid;

		return Wrap( new IrBuiltinRef( type, id ), type );
	}

	/// <summary>A call to a canonical intrinsic.</summary>
	public IrValue Call( ShaderType type, Intrinsic id, params IrValue[] args )
	{
		var operands = Unwrap( args );
		if ( operands is null ) return IrValue.Invalid;

		var pure = IntrinsicCatalog.Get( id ).Pure && AllPure( operands );

		return Wrap( new IrCall( type, id, operands ) { Pure = pure }, type );
	}

	/// <summary>A call to a shared helper function.</summary>
	public IrValue HelperCall( HelperFunction fn, params IrValue[] args )
	{
		if ( fn is null ) return IrValue.Invalid;

		var operands = Unwrap( args );
		if ( operands is null ) return IrValue.Invalid;

		var pure = fn.Pure && AllPure( operands );

		return Wrap( new IrHelperCall( fn.ReturnType, fn, operands ) { Pure = pure }, fn.ReturnType );
	}

	/// <summary>A binary operation. Operand promotion is the caller's job.</summary>
	public IrValue Binary( ShaderType type, BinaryOp op, IrValue l, IrValue r )
	{
		if ( !l.IsValid || !r.IsValid ) return IrValue.Invalid;

		var pure = l.Expr.Pure && r.Expr.Pure;

		return Wrap( new IrBinary( type, op, l.Expr, r.Expr ) { Pure = pure }, type );
	}

	/// <summary>A unary operation.</summary>
	public IrValue Unary( ShaderType type, UnaryOp op, IrValue v )
	{
		if ( !v.IsValid ) return IrValue.Invalid;

		return Wrap( new IrUnary( type, op, v.Expr ) { Pure = v.Expr.Pure }, type );
	}

	/// <summary>A swizzle. The mask must already be normalised to the <c>xyzw</c> alphabet.</summary>
	public IrValue Swizzle( ShaderType type, IrValue v, string mask )
	{
		if ( !v.IsValid || string.IsNullOrEmpty( mask ) ) return IrValue.Invalid;

		return Wrap( new IrSwizzle( type, v.Expr, mask ) { Pure = v.Expr.Pure }, type );
	}

	/// <summary>Construction of a vector, matrix or struct from parts.</summary>
	public IrValue Construct( ShaderType type, params IrValue[] parts )
	{
		var operands = Unwrap( parts );
		if ( operands is null ) return IrValue.Invalid;

		return Wrap( new IrConstruct( type, operands ) { Pure = AllPure( operands ) }, type );
	}

	/// <summary>A conversion.</summary>
	public IrValue Cast( ShaderType type, IrValue v, CastKind kind, float fill = 0f )
	{
		if ( !v.IsValid ) return IrValue.Invalid;
		if ( v.Type == type ) return v;

		return Wrap( new IrCast( type, v.Expr, kind ) { Fill = fill, Pure = v.Expr.Pure }, type );
	}

	/// <summary>A component-wise select.</summary>
	public IrValue Select( ShaderType type, IrValue c, IrValue a, IrValue b )
	{
		if ( !c.IsValid || !a.IsValid || !b.IsValid ) return IrValue.Invalid;

		var pure = c.Expr.Pure && a.Expr.Pure && b.Expr.Pure;

		return Wrap( new IrSelect( type, c.Expr, a.Expr, b.Expr ) { Pure = pure }, type );
	}

	/// <summary>An indexing expression.</summary>
	public IrValue Index( ShaderType type, IrValue v, IrValue i )
	{
		if ( !v.IsValid || !i.IsValid ) return IrValue.Invalid;

		return Wrap( new IrIndex( type, v.Expr, i.Expr ) { Pure = v.Expr.Pure && i.Expr.Pure }, type );
	}

	/// <summary>A struct member access.</summary>
	public IrValue Member( ShaderType type, IrValue v, string field )
	{
		if ( !v.IsValid || string.IsNullOrEmpty( field ) ) return IrValue.Invalid;

		return Wrap( new IrMember( type, v.Expr, field ) { Pure = v.Expr.Pure }, type );
	}

	// ---- binding ----------------------------------------------------------

	/// <summary>
	/// True when a value is worth pulling into its own temp. Leaves and one-hop views of leaves read
	/// better inline; everything else becomes a declaration so it is computed once and so the source
	/// map has a line to point at.
	/// </summary>
	public bool ShouldBind( IrValue value )
	{
		if ( !value.IsValid ) return false;
		if ( value.Type.IsVoid || value.Type.IsObject ) return false;

		return value.Expr switch
		{
			IrConst => false,
			IrVar => false,
			IrGlobalRef => false,
			IrBuiltinRef => false,
			IrSwizzle s => s.V is not ( IrVar or IrGlobalRef or IrBuiltinRef or IrConst ),
			IrMember m => m.V is not ( IrVar or IrGlobalRef or IrBuiltinRef ),
			IrCast c => c.V is not ( IrVar or IrGlobalRef or IrBuiltinRef or IrConst ),
			_ => true
		};
	}

	/// <summary>
	/// Bind a value to a temp when it is worth one, reusing the existing temp for a structurally
	/// identical pure expression that is still in scope. Values not worth a temp are returned as-is.
	/// </summary>
	public IrValue Bind( NodeId origin, string hint, IrValue value )
	{
		if ( !value.IsValid ) return value;
		if ( !ShouldBind( value ) ) return value;

		return BindForced( origin, hint, value );
	}

	/// <summary>
	/// Bind a value to a temp whether or not it is worth one. This is what <c>EmitContext.Let</c> does:
	/// the node is asserting the value deserves a readable name.
	/// </summary>
	public IrValue BindForced( NodeId origin, string hint, IrValue value )
	{
		if ( !value.IsValid ) return value;
		if ( value.Type.IsVoid ) return value;

		var expr = Intern( value.Expr );

		if ( expr.Pure && TryFindBinding( expr, out var existing ) )
		{
			CseHits++;
			return existing;
		}

		var name = NewTempName( hint );
		var declared = value.Type.IsVoid ? expr.Type : value.Type;

		Current.Add( new IrDecl( origin, name, declared, expr ) );

		var temp = Var( declared, name );

		if ( expr.Pure ) _frames[^1].Bindings[expr] = temp;

		return temp;
	}

	/// <summary>Forget every cached binding without dropping the interning pool. Used between functions.</summary>
	public void ClearBindings()
	{
		foreach ( var frame in _frames ) frame.Bindings.Clear();
	}

	bool TryFindBinding( IrExpr expr, out IrValue value )
	{
		for ( int i = _frames.Count - 1; i >= 0; i-- )
		{
			if ( _frames[i].Bindings.TryGetValue( expr, out value ) ) return true;
		}

		value = IrValue.Invalid;
		return false;
	}

	/// <summary>Mint a unique local name. Descriptive in debug-symbol mode, terse otherwise.</summary>
	public string NewTempName( string hint )
	{
		string name;

		if ( DebugSymbols && !string.IsNullOrWhiteSpace( hint ) )
		{
			name = $"{Sanitize( hint )}_{_tempSerial++}";
		}
		else
		{
			name = $"t{_tempSerial++}";
		}

		while ( !_names.Add( name ) )
		{
			name = $"{name}_{_tempSerial++}";
		}

		return name;
	}

	// ---- statements -------------------------------------------------------

	/// <summary>Append a statement to the current block.</summary>
	public IrBuilder Add( IrStmt statement )
	{
		if ( statement is not null ) Current.Add( statement );
		return this;
	}

	/// <summary>
	/// Declare a local with an explicit name, bypassing the CSE table.
	/// <para>
	/// The name is <em>requested</em>, not guaranteed: if it is already taken in this stage the local is
	/// given a fresh one and the returned value names that instead. Emitting two declarations of one
	/// local is a redefinition error from the shader compiler on a generated line, which is exactly the
	/// failure the source map exists to prevent — and this is public IR-builder API, so a custom node
	/// can reach it with any name it likes. Always read the name back off the returned value.
	/// </para>
	/// </summary>
	public IrValue Declare( NodeId origin, string name, ShaderType type, IrValue init )
	{
		if ( string.IsNullOrEmpty( name ) ) return IrValue.Invalid;

		if ( !_names.Add( name ) ) name = NewTempName( name );

		Current.Add( new IrDecl( origin, name, type, init.IsValid ? Intern( init.Expr ) : null ) );

		return Var( type, name );
	}

	/// <summary>Assign to an existing location.</summary>
	public IrBuilder Assign( NodeId origin, IrValue target, IrValue value )
	{
		if ( !target.IsValid || !value.IsValid ) return this;

		Current.Add( new IrAssign( origin, Intern( target.Expr ), Intern( value.Expr ) ) );
		return this;
	}

	/// <summary>Evaluate an expression for its side effects.</summary>
	public IrBuilder ExprStatement( NodeId origin, IrValue value )
	{
		if ( !value.IsValid ) return this;

		Current.Add( new IrExprStmt( origin, Intern( value.Expr ) ) );
		return this;
	}

	/// <summary>Emit a comment. Callers decide whether comments are wanted at all.</summary>
	public IrBuilder Comment( NodeId origin, string text )
	{
		if ( string.IsNullOrWhiteSpace( text ) ) return this;

		Current.Add( new IrComment( origin, text ) );
		return this;
	}

	/// <summary>Return a value from the enclosing function.</summary>
	public IrBuilder Return( NodeId origin, IrValue value )
	{
		Current.Add( new IrReturn( origin, value.IsValid ? Intern( value.Expr ) : null ) );
		return this;
	}

	/// <summary>Kill the fragment unconditionally.</summary>
	public IrBuilder Discard( NodeId origin )
	{
		Current.Add( new IrDiscard( origin ) );
		return this;
	}

	/// <summary>Leave the innermost loop.</summary>
	public IrBuilder Break( NodeId origin )
	{
		Current.Add( new IrBreak( origin ) );
		return this;
	}

	/// <summary>Skip to the next iteration of the innermost loop.</summary>
	public IrBuilder Continue( NodeId origin )
	{
		Current.Add( new IrContinue( origin ) );
		return this;
	}

	// ---- blocks -----------------------------------------------------------

	/// <summary>Append statements to an arbitrary block, e.g. a function body. Dispose to pop.</summary>
	public IDisposable Block( IrBlock block )
	{
		if ( block is null ) return NullScope.Instance;

		_frames.Add( new Frame( block ) );
		return new PopScope( this, null );
	}

	/// <summary>Open a nested scope. Dispose to close it.</summary>
	public IDisposable Scope( NodeId origin )
	{
		var body = new IrBlock();
		_frames.Add( new Frame( body ) );

		return new PopScope( this, _ => Current.Add( new IrScope( origin, body ) ) );
	}

	/// <summary>Open an <c>if</c>. Dispose to close it; <see cref="Else"/> may follow immediately after.</summary>
	public IDisposable If( NodeId origin, IrValue condition )
	{
		if ( !condition.IsValid ) return NullScope.Instance;

		var cond = Intern( condition.Expr );
		var body = new IrBlock();

		_frames.Add( new Frame( body ) );

		return new PopScope( this, parent =>
		{
			parent.Block.Add( new IrIf( origin, cond, body, null ) );
			parent.LastIfIndex = parent.Block.Statements.Count - 1;
		} );
	}

	/// <summary>
	/// Open the <c>else</c> arm of the <c>if</c> that closed most recently in this block. Dispose to
	/// close it. Returns a no-op scope when there is no such <c>if</c>, so a mis-sequenced node cannot
	/// corrupt the block.
	/// </summary>
	public IDisposable Else()
	{
		var frame = _frames[^1];

		if ( frame.LastIfIndex < 0 || frame.LastIfIndex >= frame.Block.Statements.Count ) return NullScope.Instance;
		if ( frame.Block.Statements[frame.LastIfIndex] is not IrIf branch ) return NullScope.Instance;

		var index = frame.LastIfIndex;
		var body = new IrBlock();

		_frames.Add( new Frame( body ) );

		return new PopScope( this, parent =>
		{
			parent.Block.Statements[index] = branch with { Else = body };
			parent.LastIfIndex = -1;
		} );
	}

	/// <summary>Open a counted loop over <c>[0, count)</c>. Dispose to close it.</summary>
	public IDisposable For( NodeId origin, string varName, IrValue count, out IrValue index )
	{
		var name = string.IsNullOrWhiteSpace( varName ) ? NewTempName( "i" ) : Sanitize( varName );
		_names.Add( name );

		index = Var( ShaderType.Int, name );

		if ( !count.IsValid ) return NullScope.Instance;

		var limit = Intern( count.Expr );
		var body = new IrBlock();

		_frames.Add( new Frame( body ) );

		return new PopScope( this, parent => parent.Block.Add( new IrFor( origin, name, limit, body ) ) );
	}

	/// <summary>Open a conditional loop. Dispose to close it.</summary>
	public IDisposable While( NodeId origin, IrValue condition )
	{
		if ( !condition.IsValid ) return NullScope.Instance;

		var cond = Intern( condition.Expr );
		var body = new IrBlock();

		_frames.Add( new Frame( body ) );

		return new PopScope( this, parent => parent.Block.Add( new IrWhile( origin, cond, body ) ) );
	}

	void Pop( Action<Frame> onClose )
	{
		if ( _frames.Count <= 1 ) return;

		_frames.RemoveAt( _frames.Count - 1 );
		onClose?.Invoke( _frames[^1] );
	}

	// ---- helpers ----------------------------------------------------------

	static bool AllPure( IrExpr[] items )
	{
		foreach ( var item in items )
		{
			if ( item is null || !item.Pure ) return false;
		}

		return true;
	}

	IrExpr[] Unwrap( IrValue[] values )
	{
		if ( values is null ) return Array.Empty<IrExpr>();

		var result = new IrExpr[values.Length];

		for ( int i = 0; i < values.Length; i++ )
		{
			if ( !values[i].IsValid ) return null;

			result[i] = Intern( values[i].Expr );
		}

		return result;
	}

	/// <summary>Turn a free-form hint into a legal HLSL identifier fragment.</summary>
	public static string Sanitize( string hint )
	{
		if ( string.IsNullOrWhiteSpace( hint ) ) return "t";

		var sb = new StringBuilder( hint.Length );

		foreach ( var c in hint )
		{
			if ( char.IsLetterOrDigit( c ) ) sb.Append( c );
			else if ( c is '_' ) sb.Append( '_' );
		}

		if ( sb.Length == 0 ) return "t";
		if ( char.IsDigit( sb[0] ) ) sb.Insert( 0, '_' );

		return sb.ToString();
	}

	/// <summary>
	/// Structural hash of one expression node, folding in the already-computed hashes of its interned
	/// children so hashing stays linear in the number of children rather than in subtree size.
	/// </summary>
	public static int StructuralHash( IrExpr expr )
	{
		if ( expr is null ) return 0;

		var hash = expr switch
		{
			IrConst c => HashCode.Combine( 1, c.Value ),
			IrVar v => HashCode.Combine( 2, v.Name ),
			IrGlobalRef g => HashCode.Combine( 3, g.Decl?.Name ),
			IrBuiltinRef b => HashCode.Combine( 4, (int)b.Id ),
			IrCall call => HashCode.Combine( 5, (int)call.Id ),
			IrHelperCall helper => HashCode.Combine( 6, helper.Fn?.Name ),
			IrBinary bin => HashCode.Combine( 7, (int)bin.Op ),
			IrUnary un => HashCode.Combine( 8, (int)un.Op ),
			IrSwizzle sw => HashCode.Combine( 9, sw.Mask ),
			IrConstruct => 10,
			IrCast cast => HashCode.Combine( 11, (int)cast.Kind, cast.Fill ),
			IrSelect => 12,
			IrIndex => 13,
			IrMember member => HashCode.Combine( 14, member.Field ),
			_ => 15
		};

		hash = HashCode.Combine( hash, expr.Type );

		foreach ( var child in expr.Children )
		{
			hash = HashCode.Combine( hash, child is null ? 0 : child.Hash != 0 ? child.Hash : child.GetHashCode() );
		}

		return hash == 0 ? 1 : hash;
	}

	sealed class Frame
	{
		public Frame( IrBlock block ) => Block = block;

		public IrBlock Block { get; }
		public Dictionary<IrExpr, IrValue> Bindings { get; } = new( StructuralComparer.Instance );
		public int LastIfIndex { get; set; } = -1;
	}

	sealed class PopScope : IDisposable
	{
		readonly IrBuilder _builder;
		readonly Action<Frame> _onClose;
		bool _closed;

		public PopScope( IrBuilder builder, Action<Frame> onClose )
		{
			_builder = builder;
			_onClose = onClose;
		}

		public void Dispose()
		{
			if ( _closed ) return;

			_closed = true;
			_builder.Pop( _onClose );
		}
	}

	sealed class NullScope : IDisposable
	{
		public static readonly NullScope Instance = new();

		public void Dispose() { }
	}

	/// <summary>
	/// Structural comparer that consults the cached <see cref="IrExpr.Hash"/> before falling back to the
	/// record's own deep equality, so unequal expressions almost always reject on the hash alone.
	/// </summary>
	sealed class StructuralComparer : IEqualityComparer<IrExpr>
	{
		public static readonly StructuralComparer Instance = new();

		public bool Equals( IrExpr a, IrExpr b )
		{
			if ( ReferenceEquals( a, b ) ) return true;
			if ( a is null || b is null ) return false;
			if ( a.Hash != 0 && b.Hash != 0 && a.Hash != b.Hash ) return false;

			return a.Equals( b );
		}

		public int GetHashCode( IrExpr expr ) =>
			expr is null ? 0 : expr.Hash != 0 ? expr.Hash : StructuralHash( expr );
	}
}