Add formula engine: data-driven expression evaluator (Core)
CI / build-test (push) Successful in 1m19s

Keystone for the gene system. A Formula compiles a string from a def
(lexer -> recursive-descent parser -> tree of Func<IFormulaContext,float>)
once, then evaluates allocation-free against a variable context.

Supports + - * / %, comparisons, && || !, ternary, the constants
pi/tau/e, and functions abs sign floor ceil round sqrt exp log sin cos
tan min max pow clamp lerp step. Deterministic and side-effect-free so
gene-effect formulas stay pure. Covered by FormulaTests (parsing,
precedence, functions, logic/ternary, variables, errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-12 23:26:06 +03:00
co-authored by Claude Opus 4.8
parent 2ac074004a
commit 10898b08a0
7 changed files with 792 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
using System.Globalization;
namespace MrGameEng.Formulas;
internal enum TokenType
{
Number,
Identifier,
Plus,
Minus,
Star,
Slash,
Percent,
LParen,
RParen,
Comma,
Less,
Greater,
LessEqual,
GreaterEqual,
EqualEqual,
NotEqual,
And,
Or,
Not,
Question,
Colon,
End,
}
internal readonly struct Token(TokenType type, int position, float number = 0f, string text = "")
{
public TokenType Type { get; } = type;
public int Position { get; } = position;
public float Number { get; } = number;
public string Text { get; } = text;
}
/// <summary>
/// Turns a formula string into a flat token list. Pure and allocation-light; recognizes numbers,
/// identifiers, the arithmetic/comparison/logical operators and the punctuation the parser needs.
/// </summary>
internal static class FormulaLexer
{
public static List<Token> Tokenize(string source)
{
var tokens = new List<Token>();
var i = 0;
while (i < source.Length)
{
var c = source[i];
if (char.IsWhiteSpace(c))
{
i++;
continue;
}
if (
char.IsDigit(c)
|| (c == '.' && i + 1 < source.Length && char.IsDigit(source[i + 1]))
)
{
var start = i;
while (i < source.Length && (char.IsDigit(source[i]) || source[i] == '.'))
{
i++;
}
var span = source.AsSpan(start, i - start);
if (
!float.TryParse(
span,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var value
)
)
{
throw new FormulaException(
$"Invalid number '{span.ToString()}' at position {start}."
);
}
tokens.Add(new Token(TokenType.Number, start, value));
continue;
}
if (char.IsLetter(c) || c == '_')
{
var start = i;
while (i < source.Length && (char.IsLetterOrDigit(source[i]) || source[i] == '_'))
{
i++;
}
tokens.Add(
new Token(TokenType.Identifier, start, text: source.Substring(start, i - start))
);
continue;
}
var pos = i;
switch (c)
{
case '+':
tokens.Add(new Token(TokenType.Plus, pos));
i++;
break;
case '-':
tokens.Add(new Token(TokenType.Minus, pos));
i++;
break;
case '*':
tokens.Add(new Token(TokenType.Star, pos));
i++;
break;
case '/':
tokens.Add(new Token(TokenType.Slash, pos));
i++;
break;
case '%':
tokens.Add(new Token(TokenType.Percent, pos));
i++;
break;
case '(':
tokens.Add(new Token(TokenType.LParen, pos));
i++;
break;
case ')':
tokens.Add(new Token(TokenType.RParen, pos));
i++;
break;
case ',':
tokens.Add(new Token(TokenType.Comma, pos));
i++;
break;
case '?':
tokens.Add(new Token(TokenType.Question, pos));
i++;
break;
case ':':
tokens.Add(new Token(TokenType.Colon, pos));
i++;
break;
case '<':
i = AddMaybeEqual(tokens, source, i, TokenType.LessEqual, TokenType.Less);
break;
case '>':
i = AddMaybeEqual(tokens, source, i, TokenType.GreaterEqual, TokenType.Greater);
break;
case '=':
if (Next(source, i) == '=')
{
tokens.Add(new Token(TokenType.EqualEqual, pos));
i += 2;
break;
}
throw new FormulaException($"Expected '==' at position {pos}.");
case '!':
if (Next(source, i) == '=')
{
tokens.Add(new Token(TokenType.NotEqual, pos));
i += 2;
break;
}
tokens.Add(new Token(TokenType.Not, pos));
i++;
break;
case '&':
if (Next(source, i) == '&')
{
tokens.Add(new Token(TokenType.And, pos));
i += 2;
break;
}
throw new FormulaException($"Expected '&&' at position {pos}.");
case '|':
if (Next(source, i) == '|')
{
tokens.Add(new Token(TokenType.Or, pos));
i += 2;
break;
}
throw new FormulaException($"Expected '||' at position {pos}.");
default:
throw new FormulaException($"Unexpected character '{c}' at position {pos}.");
}
}
tokens.Add(new Token(TokenType.End, source.Length));
return tokens;
}
private static int AddMaybeEqual(
List<Token> tokens,
string source,
int i,
TokenType withEqual,
TokenType plain
)
{
if (Next(source, i) == '=')
{
tokens.Add(new Token(withEqual, i));
return i + 2;
}
tokens.Add(new Token(plain, i));
return i + 1;
}
private static char Next(string source, int i) => i + 1 < source.Length ? source[i + 1] : '\0';
}