Add formula engine: data-driven expression evaluator (Core)
CI / build-test (push) Successful in 1m19s
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:
co-authored by
Claude Opus 4.8
parent
2ac074004a
commit
10898b08a0
@@ -0,0 +1,60 @@
|
||||
namespace MrGameEng.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// A compiled arithmetic expression — the keystone of the data-driven gene system. A formula is
|
||||
/// parsed once from a string in a def (e.g. a gene effect on a trait) into a delegate tree, then
|
||||
/// evaluated many times against an <see cref="IFormulaContext"/> that supplies variable values
|
||||
/// (gene values, environment readings, other traits). Evaluation is deterministic and
|
||||
/// allocation-free; all the work happens in <see cref="Compile"/>.
|
||||
///
|
||||
/// <para>Grammar: numbers, variables, the constants <c>pi</c>/<c>tau</c>/<c>e</c>, operators
|
||||
/// <c>+ - * / %</c>, comparisons <c>< > <= >= == !=</c>, logical <c>&& || !</c>,
|
||||
/// the ternary <c>cond ? a : b</c>, and functions <c>abs sign floor ceil round sqrt exp log sin cos
|
||||
/// tan min max pow clamp lerp step</c>. Comparisons and logical operators yield <c>1</c>/<c>0</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class Formula
|
||||
{
|
||||
private static readonly IFormulaContext Empty = new EmptyContext();
|
||||
|
||||
private readonly Func<IFormulaContext, float> _root;
|
||||
|
||||
private Formula(string source, Func<IFormulaContext, float> root)
|
||||
{
|
||||
Source = source;
|
||||
_root = root;
|
||||
}
|
||||
|
||||
/// <summary>The original expression text this formula was compiled from.</summary>
|
||||
public string Source { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parses and compiles <paramref name="expression"/>. Throws <see cref="FormulaException"/> on any
|
||||
/// lexical or syntactic error, with the offending position.
|
||||
/// </summary>
|
||||
public static Formula Compile(string expression)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expression);
|
||||
var tokens = FormulaLexer.Tokenize(expression);
|
||||
var root = new FormulaParser(tokens).ParseProgram();
|
||||
return new Formula(expression, root);
|
||||
}
|
||||
|
||||
/// <summary>Evaluates the formula, resolving variables through <paramref name="context"/>.</summary>
|
||||
public float Evaluate(IFormulaContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
return _root(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a formula that references no variables. Throws <see cref="FormulaException"/> if it
|
||||
/// turns out to reference one.
|
||||
/// </summary>
|
||||
public float Evaluate() => _root(Empty);
|
||||
|
||||
private sealed class EmptyContext : IFormulaContext
|
||||
{
|
||||
public float Resolve(string name) =>
|
||||
throw new FormulaException($"No context to resolve variable '{name}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace MrGameEng.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// Supplies variable values to a compiled <see cref="Formula"/>. A context maps a variable name
|
||||
/// (a gene value, an environment reading, another trait…) to a number; the formula engine itself
|
||||
/// is data-agnostic, so any consumer can back this with whatever lookup it owns.
|
||||
/// </summary>
|
||||
public interface IFormulaContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the value bound to <paramref name="name"/>. Throw (e.g. <see cref="FormulaException"/>)
|
||||
/// if the name is unknown — the engine does not invent a default.
|
||||
/// </summary>
|
||||
float Resolve(string name);
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IFormulaContext"/> backed by a lookup delegate — handy for tests and ad-hoc use.</summary>
|
||||
public sealed class DelegateFormulaContext : IFormulaContext
|
||||
{
|
||||
private readonly Func<string, float> _resolve;
|
||||
|
||||
/// <summary>Wraps <paramref name="resolve"/>; it is called once per variable reference per evaluation.</summary>
|
||||
public DelegateFormulaContext(Func<string, float> resolve) =>
|
||||
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
||||
|
||||
/// <inheritdoc />
|
||||
public float Resolve(string name) => _resolve(name);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MrGameEng.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a <see cref="Formula"/> cannot be lexed or parsed, or when a compiled formula
|
||||
/// references a variable the context cannot resolve at evaluation time.
|
||||
/// </summary>
|
||||
public sealed class FormulaException : Exception
|
||||
{
|
||||
/// <summary>Creates the exception with a human-readable <paramref name="message"/>.</summary>
|
||||
public FormulaException(string message)
|
||||
: base(message) { }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Node = System.Func<MrGameEng.Formulas.IFormulaContext, float>;
|
||||
|
||||
namespace MrGameEng.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// The built-in function set available inside formulas. Each entry validates its argument count at
|
||||
/// compile time and returns a <see cref="Node"/> that evaluates its operands then the math. Kept
|
||||
/// deterministic and side-effect-free so formulas stay pure.
|
||||
/// </summary>
|
||||
internal static class FormulaFunctions
|
||||
{
|
||||
public static Node Build(string name, List<Node> args)
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case "abs":
|
||||
return Unary(name, args, MathF.Abs);
|
||||
case "sign":
|
||||
return Unary(name, args, x => MathF.Sign(x));
|
||||
case "floor":
|
||||
return Unary(name, args, MathF.Floor);
|
||||
case "ceil":
|
||||
return Unary(name, args, MathF.Ceiling);
|
||||
case "round":
|
||||
return Unary(name, args, MathF.Round);
|
||||
case "sqrt":
|
||||
return Unary(name, args, MathF.Sqrt);
|
||||
case "exp":
|
||||
return Unary(name, args, MathF.Exp);
|
||||
case "log":
|
||||
return args.Count == 2
|
||||
? Binary(name, args, MathF.Log)
|
||||
: Unary(name, args, MathF.Log);
|
||||
case "sin":
|
||||
return Unary(name, args, MathF.Sin);
|
||||
case "cos":
|
||||
return Unary(name, args, MathF.Cos);
|
||||
case "tan":
|
||||
return Unary(name, args, MathF.Tan);
|
||||
case "min":
|
||||
return Binary(name, args, MathF.Min);
|
||||
case "max":
|
||||
return Binary(name, args, MathF.Max);
|
||||
case "pow":
|
||||
return Binary(name, args, MathF.Pow);
|
||||
case "clamp":
|
||||
return Ternary(name, args, (x, lo, hi) => Math.Clamp(x, lo, hi));
|
||||
case "lerp":
|
||||
return Ternary(name, args, (a, b, t) => a + (b - a) * t);
|
||||
case "step":
|
||||
return Binary(name, args, (edge, x) => x < edge ? 0f : 1f);
|
||||
default:
|
||||
throw new FormulaException($"Unknown function '{name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Node Unary(string name, List<Node> args, Func<float, float> op)
|
||||
{
|
||||
Require(name, args, 1);
|
||||
var a = args[0];
|
||||
return ctx => op(a(ctx));
|
||||
}
|
||||
|
||||
private static Node Binary(string name, List<Node> args, Func<float, float, float> op)
|
||||
{
|
||||
Require(name, args, 2);
|
||||
var a = args[0];
|
||||
var b = args[1];
|
||||
return ctx => op(a(ctx), b(ctx));
|
||||
}
|
||||
|
||||
private static Node Ternary(string name, List<Node> args, Func<float, float, float, float> op)
|
||||
{
|
||||
Require(name, args, 3);
|
||||
var a = args[0];
|
||||
var b = args[1];
|
||||
var c = args[2];
|
||||
return ctx => op(a(ctx), b(ctx), c(ctx));
|
||||
}
|
||||
|
||||
private static void Require(string name, List<Node> args, int count)
|
||||
{
|
||||
if (args.Count != count)
|
||||
{
|
||||
throw new FormulaException(
|
||||
$"Function '{name}' expects {count} argument(s) but got {args.Count}."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using Node = System.Func<MrGameEng.Formulas.IFormulaContext, float>;
|
||||
|
||||
namespace MrGameEng.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// Recursive-descent parser that compiles a token list straight into a tree of <see cref="Node"/>
|
||||
/// delegates. Parsing (and therefore all closure allocation) happens once; evaluating the returned
|
||||
/// node is allocation-free. Precedence, low to high: ternary, <c>||</c>, <c>&&</c>, equality,
|
||||
/// comparison, additive, multiplicative, unary, primary.
|
||||
/// </summary>
|
||||
internal sealed class FormulaParser(List<Token> tokens)
|
||||
{
|
||||
private const float True = 1f;
|
||||
private const float False = 0f;
|
||||
|
||||
private int _pos;
|
||||
|
||||
public Node ParseProgram()
|
||||
{
|
||||
var node = ParseTernary();
|
||||
Expect(TokenType.End);
|
||||
return node;
|
||||
}
|
||||
|
||||
private Node ParseTernary()
|
||||
{
|
||||
var condition = ParseOr();
|
||||
if (!Match(TokenType.Question))
|
||||
{
|
||||
return condition;
|
||||
}
|
||||
|
||||
var whenTrue = ParseTernary();
|
||||
Expect(TokenType.Colon);
|
||||
var whenFalse = ParseTernary();
|
||||
return ctx => condition(ctx) != False ? whenTrue(ctx) : whenFalse(ctx);
|
||||
}
|
||||
|
||||
private Node ParseOr()
|
||||
{
|
||||
var left = ParseAnd();
|
||||
while (Match(TokenType.Or))
|
||||
{
|
||||
var right = ParseAnd();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) != False || right(ctx) != False ? True : False;
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
private Node ParseAnd()
|
||||
{
|
||||
var left = ParseEquality();
|
||||
while (Match(TokenType.And))
|
||||
{
|
||||
var right = ParseEquality();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) != False && right(ctx) != False ? True : False;
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
private Node ParseEquality()
|
||||
{
|
||||
var left = ParseComparison();
|
||||
while (true)
|
||||
{
|
||||
if (Match(TokenType.EqualEqual))
|
||||
{
|
||||
var right = ParseComparison();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) == right(ctx) ? True : False;
|
||||
}
|
||||
else if (Match(TokenType.NotEqual))
|
||||
{
|
||||
var right = ParseComparison();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) != right(ctx) ? True : False;
|
||||
}
|
||||
else
|
||||
{
|
||||
return left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Node ParseComparison()
|
||||
{
|
||||
var left = ParseAdditive();
|
||||
while (true)
|
||||
{
|
||||
if (Match(TokenType.Less))
|
||||
{
|
||||
left = Compare(left, ParseAdditive(), (a, b) => a < b);
|
||||
}
|
||||
else if (Match(TokenType.LessEqual))
|
||||
{
|
||||
left = Compare(left, ParseAdditive(), (a, b) => a <= b);
|
||||
}
|
||||
else if (Match(TokenType.Greater))
|
||||
{
|
||||
left = Compare(left, ParseAdditive(), (a, b) => a > b);
|
||||
}
|
||||
else if (Match(TokenType.GreaterEqual))
|
||||
{
|
||||
left = Compare(left, ParseAdditive(), (a, b) => a >= b);
|
||||
}
|
||||
else
|
||||
{
|
||||
return left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Node ParseAdditive()
|
||||
{
|
||||
var left = ParseMultiplicative();
|
||||
while (true)
|
||||
{
|
||||
if (Match(TokenType.Plus))
|
||||
{
|
||||
var right = ParseMultiplicative();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) + right(ctx);
|
||||
}
|
||||
else if (Match(TokenType.Minus))
|
||||
{
|
||||
var right = ParseMultiplicative();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) - right(ctx);
|
||||
}
|
||||
else
|
||||
{
|
||||
return left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Node ParseMultiplicative()
|
||||
{
|
||||
var left = ParseUnary();
|
||||
while (true)
|
||||
{
|
||||
if (Match(TokenType.Star))
|
||||
{
|
||||
var right = ParseUnary();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) * right(ctx);
|
||||
}
|
||||
else if (Match(TokenType.Slash))
|
||||
{
|
||||
var right = ParseUnary();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) / right(ctx);
|
||||
}
|
||||
else if (Match(TokenType.Percent))
|
||||
{
|
||||
var right = ParseUnary();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) % right(ctx);
|
||||
}
|
||||
else
|
||||
{
|
||||
return left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Node ParseUnary()
|
||||
{
|
||||
if (Match(TokenType.Minus))
|
||||
{
|
||||
var operand = ParseUnary();
|
||||
return ctx => -operand(ctx);
|
||||
}
|
||||
|
||||
if (Match(TokenType.Plus))
|
||||
{
|
||||
return ParseUnary();
|
||||
}
|
||||
|
||||
if (Match(TokenType.Not))
|
||||
{
|
||||
var operand = ParseUnary();
|
||||
return ctx => operand(ctx) != False ? False : True;
|
||||
}
|
||||
|
||||
return ParsePrimary();
|
||||
}
|
||||
|
||||
private Node ParsePrimary()
|
||||
{
|
||||
var token = Current;
|
||||
if (Match(TokenType.Number))
|
||||
{
|
||||
var value = token.Number;
|
||||
return _ => value;
|
||||
}
|
||||
|
||||
if (Match(TokenType.LParen))
|
||||
{
|
||||
var inner = ParseTernary();
|
||||
Expect(TokenType.RParen);
|
||||
return inner;
|
||||
}
|
||||
|
||||
if (Match(TokenType.Identifier))
|
||||
{
|
||||
return Peek(TokenType.LParen) ? ParseCall(token.Text) : ParseName(token.Text);
|
||||
}
|
||||
|
||||
throw new FormulaException($"Unexpected token at position {token.Position}.");
|
||||
}
|
||||
|
||||
private Node ParseName(string name)
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case "pi":
|
||||
return _ => MathF.PI;
|
||||
case "tau":
|
||||
return _ => MathF.Tau;
|
||||
case "e":
|
||||
return _ => MathF.E;
|
||||
default:
|
||||
return ctx => ctx.Resolve(name);
|
||||
}
|
||||
}
|
||||
|
||||
private Node ParseCall(string name)
|
||||
{
|
||||
Expect(TokenType.LParen);
|
||||
var args = new List<Node>();
|
||||
if (!Peek(TokenType.RParen))
|
||||
{
|
||||
do
|
||||
{
|
||||
args.Add(ParseTernary());
|
||||
} while (Match(TokenType.Comma));
|
||||
}
|
||||
|
||||
Expect(TokenType.RParen);
|
||||
return FormulaFunctions.Build(name, args);
|
||||
}
|
||||
|
||||
private static Node Compare(Node left, Node right, Func<float, float, bool> op) =>
|
||||
ctx => op(left(ctx), right(ctx)) ? True : False;
|
||||
|
||||
private Token Current => tokens[_pos];
|
||||
|
||||
private bool Peek(TokenType type) => Current.Type == type;
|
||||
|
||||
private bool Match(TokenType type)
|
||||
{
|
||||
if (Current.Type != type)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_pos++;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Expect(TokenType type)
|
||||
{
|
||||
if (!Match(type))
|
||||
{
|
||||
throw new FormulaException($"Expected {type} at position {Current.Position}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user