Code/SboxSuperuser/Core/Effects/EffectChecker.cs

A static checker for a small effect scripting language. Walks the parsed statements and expressions, verifies function names, argument counts and value kinds, assigns a slot index to every variable, and records diagnostics with source positions for anything it can reject ahead of runtime.

namespace SboxSuperuser.Core.Effects;

/// <summary>
/// Checks names, argument counts and value kinds before a script runs, and gives every variable its slot. Kinds it
/// can't know yet (a variable set to different kinds in different branches) are checked again while running.
/// </summary>
internal sealed class EffectChecker
{
	private const int MaximumListItems = 64;

	private readonly List<EffectDiagnostic> _diagnostics;
	private readonly Dictionary<string, int> _slots = new( StringComparer.Ordinal );
	private readonly List<EffectType> _types = new();

	private EffectChecker( List<EffectDiagnostic> diagnostics )
	{
		_diagnostics = diagnostics;
		for ( var slot = 0; slot < EffectSlots.Fixed; slot++ )
		{
			_slots.Add( EffectSlots.Names[ slot ], slot );
			_types.Add( EffectSlots.TypeOf( slot ) );
		}
	}

	internal bool UsesTime { get; private set; }

	internal int SlotCount => _types.Count;

	internal static EffectChecker Check( EffectNode[] statements, List<EffectDiagnostic> diagnostics )
	{
		var checker = new EffectChecker( diagnostics );
		checker.Statements( statements );
		return checker;
	}

	private void Statements( EffectNode[] statements )
	{
		foreach ( var statement in statements ) Statement( statement );
	}

	private void Statement( EffectNode node )
	{
		switch ( node.Kind )
		{
			case EffectNodeKind.Assign:
				Assign( node );
				break;
			case EffectNodeKind.ColorStatement:
				var type = Expression( node.Children[ 0 ] );
				if ( type.Kind is { } kind && kind != EffectValueKind.Color )
					Error( node, $"a line on its own sets the color, but this is {EffectValue.Describe( kind )}; write  name = ...  to keep it in a variable" );
				node.Slot = EffectSlots.Color;
				break;
			case EffectNodeKind.If:
				Expression( node.Children[ 0 ] );
				Statements( node.Body );
				Statements( node.Else );
				break;
		}
	}

	private void Assign( EffectNode node )
	{
		var type = Expression( node.Children[ 0 ] );
		var name = node.Name;
		if ( EffectFunctions.All.ContainsKey( name ) ) { Error( node, $"{name} is a built-in function; pick another name" ); return; }
		if ( EffectSymbols.IsColor( name ) ) { Error( node, $"{name} is a built-in color; pick another name" ); return; }

		if ( !_slots.TryGetValue( name, out var slot ) )
		{
			slot = _types.Count;
			_slots.Add( name, slot );
			_types.Add( type );
		}
		else if ( EffectSlots.IsReadOnly( slot ) )
		{
			Error( node, $"{name} is an input and can't be changed; store the value under a new name instead" );
			return;
		}
		else if ( EffectSlots.IsOutput( slot ) )
		{
			if ( OutputProblem( slot, type ) is { } problem ) Error( node.Children[ 0 ], problem );
		}
		else
		{
			_types[ slot ] = _types[ slot ].Merge( type );
		}
		node.Slot = slot;
	}

	/// <summary>Why a value can't go into an output, or null when it can (or can't be known yet).</summary>
	internal static string OutputProblem( int slot, EffectType type )
	{
		if ( type.Kind is not { } kind ) return null;
		var accepted = slot switch
		{
			EffectSlots.Color => kind == EffectValueKind.Color,
			EffectSlots.Alpha or EffectSlots.Offset => type.IsNumeric,
			EffectSlots.Char => kind is EffectValueKind.Text or EffectValueKind.Number,
			_ => true
		};
		if ( accepted ) return null;
		var wanted = slot switch
		{
			EffectSlots.Color => "a color, like red, #ff8800 or rgb(255, 136, 0)",
			EffectSlots.Char => "text, like \"o\"",
			_ => "a number"
		};
		return $"{EffectSlots.Names[ slot ]} needs {wanted}, but this is {EffectValue.Describe( kind )}";
	}

	private EffectType Expression( EffectNode node )
	{
		switch ( node.Kind )
		{
			case EffectNodeKind.Literal:
				return EffectType.Of( node.Value.Kind );
			case EffectNodeKind.Variable:
				return Variable( node );
			case EffectNodeKind.List:
				return List( node );
			case EffectNodeKind.Index:
				return Index( node );
			case EffectNodeKind.Call:
				return Call( node );
			case EffectNodeKind.Unary:
				var operand = Expression( node.Children[ 0 ] );
				if ( node.Name == "not" ) return EffectType.Of( EffectValueKind.Bool );
				RequireNumber( node, operand, "-" );
				return EffectType.Of( EffectValueKind.Number );
			case EffectNodeKind.Binary:
				return Binary( node, Expression( node.Children[ 0 ] ), Expression( node.Children[ 1 ] ) );
			case EffectNodeKind.And:
			case EffectNodeKind.Or:
				Expression( node.Children[ 0 ] );
				Expression( node.Children[ 1 ] );
				return EffectType.Of( EffectValueKind.Bool );
			case EffectNodeKind.Conditional:
				Expression( node.Children[ 0 ] );
				return Expression( node.Children[ 1 ] ).Merge( Expression( node.Children[ 2 ] ) );
			default:
				return EffectType.Unknown;
		}
	}

	private EffectType Variable( EffectNode node )
	{
		if ( _slots.TryGetValue( node.Name, out var slot ) )
		{
			node.Slot = slot;
			if ( slot == EffectSlots.Time ) UsesTime = true;
			return _types[ slot ];
		}
		var suggestion = EffectSymbols.Suggest( node.Name, _slots.Keys.Concat( EffectSymbols.Colors.Keys ) );
		Error( node, suggestion is null
			? $"\"{node.Name}\" isn't defined yet; set it first, like  {node.Name} = 0"
			: $"\"{node.Name}\" isn't defined; did you mean {suggestion}?" );
		return EffectType.Unknown;
	}

	private EffectType List( EffectNode node )
	{
		if ( node.Children.Length > MaximumListItems ) Error( node, $"a list can hold at most {MaximumListItems} values" );
		var item = node.Children.Length == 0 ? EffectType.Unknown : Expression( node.Children[ 0 ] );
		foreach ( var child in node.Children.Skip( 1 ) ) item = item.Merge( Expression( child ) );
		node.Arguments = new EffectValue[ node.Children.Length ];
		return new EffectType( EffectValueKind.List, item.Kind );
	}

	private EffectType Index( EffectNode node )
	{
		var target = Expression( node.Children[ 0 ] );
		RequireNumber( node.Children[ 1 ], Expression( node.Children[ 1 ] ), "[ ]" );
		switch ( target.Kind )
		{
			case EffectValueKind.List:
				return new EffectType( target.Item );
			case EffectValueKind.Text:
				return EffectType.Of( EffectValueKind.Text );
			case null:
				return EffectType.Unknown;
			default:
				Error( node, $"[ ] picks from a list or text, but this is {EffectValue.Describe( target.Kind.Value )}" );
				return EffectType.Unknown;
		}
	}

	private EffectType Call( EffectNode node )
	{
		var arguments = node.Children.Select( Expression ).ToArray();
		if ( !EffectFunctions.All.TryGetValue( node.Name, out var function ) )
		{
			var suggestion = EffectSymbols.Suggest( node.Name, EffectSymbols.FunctionNames );
			Error( node, $"there is no function called {node.Name}" + (suggestion is null ? "" : $"; did you mean {suggestion}?") );
			return EffectType.Unknown;
		}

		var symbol = EffectSymbols.Find( node.Name );
		if ( arguments.Length < symbol.MinArguments || arguments.Length > symbol.MaxArguments )
		{
			var expected = symbol.MinArguments == symbol.MaxArguments ? $"{symbol.MinArguments}" : $"{symbol.MinArguments} or {symbol.MaxArguments}";
			Error( node, $"{node.Name} takes {expected} value{(symbol.MaxArguments == 1 ? "" : "s")}, but got {arguments.Length}; for example  {symbol.Example}" );
			return EffectType.Of( function.Returns );
		}

		for ( var index = 0; index < arguments.Length; index++ )
		{
			var parameter = function.Parameters[ index ];
			if ( arguments[ index ].Kind is { } kind && !Accepts( parameter, kind ) )
				Error( node.Children[ index ], $"{node.Name} needs {Describe( parameter )} here, but this is {EffectValue.Describe( kind )}" );
		}
		node.Arguments = new EffectValue[ arguments.Length ];
		return EffectType.Of( function.Returns );
	}

	private EffectType Binary( EffectNode node, EffectType left, EffectType right )
	{
		switch ( node.Name )
		{
			case "==":
			case "!=":
				return EffectType.Of( EffectValueKind.Bool );
			case "+" when left.Kind == EffectValueKind.Text || right.Kind == EffectValueKind.Text:
				return EffectType.Of( EffectValueKind.Text );
			case "+" when left.Kind == EffectValueKind.Color || right.Kind == EffectValueKind.Color:
				Error( node, "colors can't be added; use mix(first, second, amount) to blend them" );
				return EffectType.Unknown;
			case "+" when left.Kind is null || right.Kind is null:
				return EffectType.Unknown;
		}

		RequireNumber( node.Children[ 0 ], left, node.Name );
		RequireNumber( node.Children[ 1 ], right, node.Name );
		return EffectType.Of( node.Name is "<" or "<=" or ">" or ">=" ? EffectValueKind.Bool : EffectValueKind.Number );
	}

	private void RequireNumber( EffectNode node, EffectType type, string operation )
	{
		if ( type.Kind is { } kind && !type.IsNumeric ) Error( node, $"{operation} works on numbers, but this is {EffectValue.Describe( kind )}" );
	}

	internal static bool Accepts( char parameter, EffectValueKind kind ) => parameter switch
	{
		'n' => kind is EffectValueKind.Number or EffectValueKind.Bool,
		'c' => kind == EffectValueKind.Color,
		't' => kind == EffectValueKind.Text,
		's' => kind is EffectValueKind.Text or EffectValueKind.List,
		_ => true
	};

	internal static string Describe( char parameter ) => parameter switch
	{
		'n' => "a number",
		'c' => "a color",
		't' => "text",
		_ => "text or a list"
	};

	private void Error( EffectNode node, string message ) => _diagnostics.Add( new EffectDiagnostic( message, node.Start, Math.Max( 1, node.Length ) ) );
}