diff --git a/OpenNest.Core/Math/ExpressionEvaluator.cs b/OpenNest.Core/Math/ExpressionEvaluator.cs new file mode 100644 index 0000000..591aa28 --- /dev/null +++ b/OpenNest.Core/Math/ExpressionEvaluator.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace OpenNest.Math +{ + /// + /// Recursive descent parser for simple arithmetic expressions supporting + /// +, -, *, /, parentheses, unary minus/plus, and $variable references. + /// + public static class ExpressionEvaluator + { + public static double Evaluate(string expression, IReadOnlyDictionary 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 _input; + private readonly IReadOnlyDictionary _variables; + private int _pos; + + public Parser(string input, IReadOnlyDictionary 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; + } + } + } +} diff --git a/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.csproj b/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.csproj index 740fe7b..1b60598 100644 --- a/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.csproj +++ b/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.csproj @@ -11,6 +11,6 @@ ..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\ - + diff --git a/OpenNest.Tests/Math/ExpressionEvaluatorTests.cs b/OpenNest.Tests/Math/ExpressionEvaluatorTests.cs new file mode 100644 index 0000000..067354d --- /dev/null +++ b/OpenNest.Tests/Math/ExpressionEvaluatorTests.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using OpenNest.Math; + +namespace OpenNest.Tests.Math; + +public class ExpressionEvaluatorTests +{ + private static readonly Dictionary Empty = new(); + + [Fact] + public void Evaluate_NumericLiteral() + { + Assert.Equal(42.0, ExpressionEvaluator.Evaluate("42", Empty)); + } + + [Fact] + public void Evaluate_DecimalLiteral() + { + Assert.Equal(0.3, ExpressionEvaluator.Evaluate("0.3", Empty)); + } + + [Fact] + public void Evaluate_NegativeLiteral() + { + Assert.Equal(-5.0, ExpressionEvaluator.Evaluate("-5", Empty)); + } + + [Fact] + public void Evaluate_Addition() + { + Assert.Equal(5.0, ExpressionEvaluator.Evaluate("2 + 3", Empty)); + } + + [Fact] + public void Evaluate_Subtraction() + { + Assert.Equal(1.0, ExpressionEvaluator.Evaluate("3 - 2", Empty)); + } + + [Fact] + public void Evaluate_Multiplication() + { + Assert.Equal(6.0, ExpressionEvaluator.Evaluate("2 * 3", Empty)); + } + + [Fact] + public void Evaluate_Division() + { + Assert.Equal(2.5, ExpressionEvaluator.Evaluate("5 / 2", Empty)); + } + + [Fact] + public void Evaluate_OperatorPrecedence() + { + Assert.Equal(7.0, ExpressionEvaluator.Evaluate("1 + 2 * 3", Empty)); + } + + [Fact] + public void Evaluate_Parentheses() + { + Assert.Equal(9.0, ExpressionEvaluator.Evaluate("(1 + 2) * 3", Empty)); + } + + [Fact] + public void Evaluate_VariableReference() + { + var vars = new Dictionary { { "diameter", 0.3 } }; + Assert.Equal(0.3, ExpressionEvaluator.Evaluate("$diameter", vars)); + } + + [Fact] + public void Evaluate_VariableInExpression() + { + var vars = new Dictionary { { "diameter", 0.6 } }; + Assert.Equal(0.3, ExpressionEvaluator.Evaluate("$diameter / 2", vars)); + } + + [Fact] + public void Evaluate_MultipleVariables() + { + var vars = new Dictionary { { "x", 2.0 }, { "y", 3.0 } }; + Assert.Equal(5.0, ExpressionEvaluator.Evaluate("$x + $y", vars)); + } + + [Fact] + public void Evaluate_CaseInsensitiveVariables() + { + var vars = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "Diameter", 0.3 } + }; + Assert.Equal(0.3, ExpressionEvaluator.Evaluate("$diameter", vars)); + } + + [Fact] + public void Evaluate_UndefinedVariable_Throws() + { + Assert.Throws(() => + ExpressionEvaluator.Evaluate("$missing", Empty)); + } + + [Fact] + public void Evaluate_NestedParentheses() + { + Assert.Equal(20.0, ExpressionEvaluator.Evaluate("((2 + 3) * (1 + 1)) * 2", Empty)); + } + + [Fact] + public void Evaluate_UnaryMinusBeforeParens() + { + Assert.Equal(-6.0, ExpressionEvaluator.Evaluate("-(2 + 4)", Empty)); + } + + [Fact] + public void Evaluate_WhitespaceHandling() + { + Assert.Equal(5.0, ExpressionEvaluator.Evaluate(" 2 + 3 ", Empty)); + } +}