CI / build-test (push) Successful in 1m12s
Three regex-powered content tools (phase G5):
- Formula group functions gsum/gcount/gavg/gmin/gmax('regex') aggregate
over every context variable whose name matches the pattern, e.g.
gsum('leaf_.*'). Adds string literals to the formula grammar and an
IFormulaContext.ResolveMatching hook; GenomeContext enumerates matching
genes, so a trait can sum/average a gene group.
- DefDatabase content patches: a { "type": "Patch", patches:[{ defType,
match (regex on defName), set:{fields} }] } file sets fields on every
matching raw def before resolution — mods patch Core in bulk.
- DefDatabase.RegisterValidator(typeKey, field, regex): load-time check
that a string field matches a pattern, throwing otherwise (naming/format
conventions).
Covered by FormulaTests (group aggregation, composition, bad calls) and
DefDatabaseTests (patch set/match/unknown-type, validator pass/reject).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
240 lines
7.0 KiB
C#
240 lines
7.0 KiB
C#
using System.Globalization;
|
|
|
|
namespace MrGameEng.Formulas;
|
|
|
|
internal enum TokenType
|
|
{
|
|
Number,
|
|
Identifier,
|
|
String,
|
|
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;
|
|
}
|
|
|
|
if (c == '\'')
|
|
{
|
|
var open = i;
|
|
var start = ++i;
|
|
while (i < source.Length && source[i] != '\'')
|
|
{
|
|
i++;
|
|
}
|
|
|
|
if (i >= source.Length)
|
|
{
|
|
throw new FormulaException($"Unterminated string at position {open}.");
|
|
}
|
|
|
|
tokens.Add(
|
|
new Token(TokenType.String, open, text: source.Substring(start, i - start))
|
|
);
|
|
i++; // пропускаем закрывающую кавычку
|
|
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';
|
|
}
|