Files
OpenNest/OpenNest.Core/Math/ExpressionEvaluator.cs
T
aj aec0523062 style: apply CSharpier formatting to all C# sources
Repo-wide sweep with the pinned CSharpier 1.3.0 tool. Whitespace and
line-wrapping only; OpenNest.Engine.Tests (109) and OpenNest.IO.Tests
pass after reformat, full solution builds 0 errors.

Added .csharpierignore so csproj/config XML keeps its existing layout
(CSharpier's XML wrapping churns attributes with zero benefit).

Formatting is now enforceable: dotnet csharpier check . passes.
2026-09-20 16:41:50 -04:00

180 lines
5.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
namespace OpenNest.Math
{
/// <summary>
/// Recursive descent parser for simple arithmetic expressions supporting
/// +, -, *, /, parentheses, unary minus/plus, and $variable references.
/// </summary>
public static class ExpressionEvaluator
{
public static double Evaluate(
string expression,
IReadOnlyDictionary<string, double> variables
)
{
var parser = new Parser(expression, variables);
var result = parser.ParseExpression();
parser.SkipWhitespace();
if (!parser.IsEnd)
throw new FormatException(
$"Unexpected character at position {parser.Position}: '{parser.Current}'"
);
return result;
}
private ref struct Parser
{
private readonly ReadOnlySpan<char> _input;
private readonly IReadOnlyDictionary<string, double> _variables;
private int _pos;
public Parser(string input, IReadOnlyDictionary<string, double> variables)
{
_input = input.AsSpan();
_variables = variables;
_pos = 0;
}
public int Position => _pos;
public bool IsEnd => _pos >= _input.Length;
public char Current => _input[_pos];
public void SkipWhitespace()
{
while (_pos < _input.Length && _input[_pos] == ' ')
_pos++;
}
// Expression = Term (('+' | '-') Term)*
public double ParseExpression()
{
SkipWhitespace();
var left = ParseTerm();
while (true)
{
SkipWhitespace();
if (IsEnd)
break;
var op = Current;
if (op != '+' && op != '-')
break;
_pos++;
SkipWhitespace();
var right = ParseTerm();
left = op == '+' ? left + right : left - right;
}
return left;
}
// Term = Unary (('*' | '/') Unary)*
private double ParseTerm()
{
var left = ParseUnary();
while (true)
{
SkipWhitespace();
if (IsEnd)
break;
var op = Current;
if (op != '*' && op != '/')
break;
_pos++;
SkipWhitespace();
var right = ParseUnary();
left = op == '*' ? left * right : left / right;
}
return left;
}
// Unary = ('-' | '+')? Primary
private double ParseUnary()
{
SkipWhitespace();
if (!IsEnd && Current == '-')
{
_pos++;
return -ParsePrimary();
}
if (!IsEnd && Current == '+')
{
_pos++;
}
return ParsePrimary();
}
// Primary = '(' Expression ')' | '$' Identifier | Number
private double ParsePrimary()
{
SkipWhitespace();
if (IsEnd)
throw new FormatException("Unexpected end of expression.");
if (Current == '(')
{
_pos++; // consume '('
var value = ParseExpression();
SkipWhitespace();
if (IsEnd || Current != ')')
throw new FormatException("Expected closing parenthesis.");
_pos++; // consume ')'
return value;
}
if (Current == '$')
{
_pos++; // consume '$'
var start = _pos;
while (
_pos < _input.Length
&& (char.IsLetterOrDigit(_input[_pos]) || _input[_pos] == '_')
)
_pos++;
if (_pos == start)
throw new FormatException("Expected variable name after '$'.");
var name = _input.Slice(start, _pos - start).ToString();
if (!_variables.TryGetValue(name, out var varValue))
throw new KeyNotFoundException($"Undefined variable: ${name}");
return varValue;
}
// Number
var numStart = _pos;
while (_pos < _input.Length && (char.IsDigit(_input[_pos]) || _input[_pos] == '.'))
_pos++;
if (_pos == numStart)
throw new FormatException(
$"Unexpected character '{Current}' at position {_pos}."
);
var numSpan = _input.Slice(numStart, _pos - numStart).ToString();
if (
!double.TryParse(
numSpan,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var number
)
)
throw new FormatException($"Invalid number: '{numSpan}'");
return number;
}
}
}
}