diff --git a/OpenRA.Game/OpenRA.Game.csproj b/OpenRA.Game/OpenRA.Game.csproj index d7b00aae34..bd055fffd3 100644 --- a/OpenRA.Game/OpenRA.Game.csproj +++ b/OpenRA.Game/OpenRA.Game.csproj @@ -246,6 +246,7 @@ + diff --git a/OpenRA.Game/Support/BooleanExpression.cs b/OpenRA.Game/Support/BooleanExpression.cs new file mode 100644 index 0000000000..00b56181a1 --- /dev/null +++ b/OpenRA.Game/Support/BooleanExpression.cs @@ -0,0 +1,294 @@ +#region Copyright & License Information +/* + * Copyright 2007-2016 The OpenRA Developers (see AUTHORS) + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace OpenRA.Support +{ + public class BooleanExpression + { + readonly HashSet variables = new HashSet(); + public IEnumerable Variables { get { return variables; } } + + readonly Token[] postfix; + + enum Associativity { Left, Right } + class Token + { + public readonly string Symbol; + public readonly int Index; + public readonly int Precedence; + public readonly Associativity Associativity; + + public Token(string symbol, int index, Associativity associativity, int precedence) + { + Symbol = symbol; + Index = index; + Associativity = associativity; + Precedence = precedence; + } + } + + class BinaryOperationToken : Token + { + public BinaryOperationToken(string symbol, int index, Associativity associativity = Associativity.Left, int precedence = 0) + : base(symbol, index, associativity, precedence) { } + } + + class UnaryOperationToken : Token + { + public UnaryOperationToken(string symbol, int index, Associativity associativity = Associativity.Right, int precedence = 1) + : base(symbol, index, associativity, precedence) { } + } + + class OpenParenToken : Token { public OpenParenToken(int index) : base("(", index, Associativity.Left, -1) { } } + class CloseParenToken : Token { public CloseParenToken(int index) : base(")", index, Associativity.Left, -1) { } } + class VariableToken : Token + { + public VariableToken(int index, string symbol) + : base(symbol, index, Associativity.Left, 0) { } + } + + class AndToken : BinaryOperationToken { public AndToken(int index) : base("&&", index) { } } + class OrToken : BinaryOperationToken { public OrToken(int index) : base("||", index) { } } + class EqualsToken : BinaryOperationToken { public EqualsToken(int index) : base("==", index) { } } + class NotEqualsToken : BinaryOperationToken { public NotEqualsToken(int index) : base("!=", index) { } } + class NotToken : UnaryOperationToken { public NotToken(int index) : base("!", index) { } } + + public BooleanExpression(string expression) + { + var openParens = 0; + var closeParens = 0; + var tokens = new List(); + for (var i = 0; i < expression.Length; i++) + { + switch (expression[i]) + { + case '(': + { + tokens.Add(new OpenParenToken(i)); + openParens++; + break; + } + + case ')': + { + tokens.Add(new CloseParenToken(i)); + if (++closeParens > openParens) + throw new InvalidDataException("Unmatched closing parenthesis at index {0}".F(i)); + + break; + } + + default: + { + // Ignore whitespace + if (char.IsWhiteSpace(expression[i])) + break; + + var token = ParseSymbol(expression, ref i); + tokens.Add(token); + + var variable = token as VariableToken; + if (variable != null) + variables.Add(variable.Symbol); + + break; + } + } + } + + // Sanity check parsed tree + if (!tokens.Any()) + throw new InvalidDataException("Empty expression"); + + if (closeParens != openParens) + throw new InvalidDataException("Mismatched opening and closing parentheses"); + + for (var i = 0; i < tokens.Count - 1; i++) + { + // Unary tokens must be followed by a variable, another unary token, or an opening parenthesis + if (tokens[i] is UnaryOperationToken && !(tokens[i + 1] is VariableToken || tokens[i + 1] is UnaryOperationToken + || tokens[i + 1] is OpenParenToken)) + throw new InvalidDataException("Unexpected token `{0}` at index {1}".F(tokens[i].Symbol, tokens[i].Index)); + + // Disallow empty parentheses + if (tokens[i] is OpenParenToken && tokens[i + 1] is CloseParenToken) + throw new InvalidDataException("Empty parenthesis at index {0}".F(tokens[i].Index)); + + // A variable must be followed by a binary operation or by a closing parenthesis + if (tokens[i] is VariableToken && !(tokens[i + 1] is BinaryOperationToken || tokens[i + 1] is CloseParenToken)) + throw new InvalidDataException("Missing binary operation at index {0}".F(tokens[i + 1].Index)); + } + + // Expressions can't start with an operation + if (tokens[0] is BinaryOperationToken) + throw new InvalidDataException("Unexpected token `{0}` at index `{1}`".F(tokens[0].Symbol, tokens[0].Index)); + + // Expressions can't end with a binary or unary operation + if (tokens[tokens.Count - 1] is BinaryOperationToken || tokens[tokens.Count - 1] is UnaryOperationToken) + throw new InvalidDataException("Unexpected token `{0}` at index `{1}`".F(tokens[tokens.Count - 1].Symbol, tokens[tokens.Count - 1].Index)); + + // Binary operations must be preceeded by a closing paren or a variable + // Binary operations must be followed by an opening paren, a variable, or a unary operation + for (var i = 1; i < tokens.Count - 1; i++) + { + if (tokens[i] is BinaryOperationToken && ( + !(tokens[i - 1] is CloseParenToken || tokens[i - 1] is VariableToken) || + !(tokens[i + 1] is OpenParenToken || tokens[i + 1] is VariableToken || tokens[i + 1] is UnaryOperationToken))) + throw new InvalidDataException("Unexpected token `{0}` at index `{1}`".F(tokens[i].Symbol, tokens[i].Index)); + } + + // Convert to postfix (discarding parentheses) ready for evaluation + postfix = ToPostfix(tokens).ToArray(); + } + + static Token ParseSymbol(string expression, ref int i) + { + var start = i; + var c = expression[start]; + + // Parse operators + if (c == '!') + { + if (i < expression.Length - 1 && expression[start + 1] == '=') + { + i++; + return new NotEqualsToken(start); + } + + return new NotToken(start); + } + + if (c == '=') + { + if (i < expression.Length - 1 && expression[start + 1] == '=') + { + i++; + return new EqualsToken(start); + } + + throw new InvalidDataException("Unexpected character '=' at index {0}".F(start)); + } + + if (c == '&') + { + if (i < expression.Length - 1 && expression[start + 1] == '&') + { + i++; + return new AndToken(start); + } + + throw new InvalidDataException("Unexpected character '&' at index {0}".F(start)); + } + + if (c == '|') + { + if (i < expression.Length - 1 && expression[start + 1] == '|') + { + i++; + return new OrToken(start); + } + + throw new InvalidDataException("Unexpected character '|' at index {0}".F(start)); + } + + // Scan forwards until we find an invalid name character + for (; i < expression.Length; i++) + { + c = expression[i]; + if (char.IsWhiteSpace(c) || c == '(' || c == ')' || c == '!' || c == '&' || c == '|' || c == '=') + { + // Put the bad character back for the next parse attempt + i--; + return new VariableToken(start, expression.Substring(start, i - start + 1)); + } + } + + // Take the rest of the string + return new VariableToken(start, expression.Substring(start)); + } + + static bool ParseSymbol(VariableToken t, Dictionary symbols) + { + bool value; + symbols.TryGetValue(t.Symbol, out value); + return value; + } + + static void ApplyBinaryOperation(Stack s, Func f) + { + var x = s.Pop(); + var y = s.Pop(); + s.Push(f(x, y)); + } + + static void ApplyUnaryOperation(Stack s, Func f) + { + var x = s.Pop(); + s.Push(f(x)); + } + + static IEnumerable ToPostfix(IEnumerable tokens) + { + var s = new Stack(); + foreach (var t in tokens) + { + if (t is OpenParenToken) + s.Push(t); + else if (t is CloseParenToken) + { + Token temp; + while (!((temp = s.Pop()) is OpenParenToken)) + yield return temp; + } + else if (t is VariableToken) + yield return t; + else + { + while (s.Count > 0 && ((t.Associativity == Associativity.Right && t.Precedence < s.Peek().Precedence) + || (t.Associativity == Associativity.Left && t.Precedence <= s.Peek().Precedence))) + yield return s.Pop(); + + s.Push(t); + } + } + + while (s.Count > 0) + yield return s.Pop(); + } + + public bool Evaluate(Dictionary symbols) + { + var s = new Stack(); + foreach (var t in postfix) + { + if (t is AndToken) + ApplyBinaryOperation(s, (x, y) => y & x); + else if (t is NotEqualsToken) + ApplyBinaryOperation(s, (x, y) => y ^ x); + else if (t is OrToken) + ApplyBinaryOperation(s, (x, y) => y | x); + else if (t is EqualsToken) + ApplyBinaryOperation(s, (x, y) => y == x); + else if (t is NotToken) + ApplyUnaryOperation(s, x => !x); + else if (t is VariableToken) + s.Push(ParseSymbol((VariableToken)t, symbols)); + } + + return s.Pop(); + } + } +} diff --git a/OpenRA.Test/OpenRA.Game/BooleanExpressionTest.cs b/OpenRA.Test/OpenRA.Game/BooleanExpressionTest.cs new file mode 100644 index 0000000000..1b40536ff9 --- /dev/null +++ b/OpenRA.Test/OpenRA.Game/BooleanExpressionTest.cs @@ -0,0 +1,150 @@ +#region Copyright & License Information +/* + * Copyright 2007-2016 The OpenRA Developers (see AUTHORS) + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NUnit.Framework; +using OpenRA.Support; + +namespace OpenRA.Test +{ + [TestFixture] + public class BooleanExpressionTest + { + Dictionary testValues = new Dictionary() + { + { "true", true }, + { "false", false } + }; + + void AssertFalse(string expression) + { + Assert.False(new BooleanExpression(expression).Evaluate(testValues), expression); + } + + void AssertTrue(string expression) + { + Assert.True(new BooleanExpression(expression).Evaluate(testValues), expression); + } + + void AssertParseFailure(string expression) + { + Assert.Throws(typeof(InvalidDataException), () => new BooleanExpression(expression).Evaluate(testValues), expression); + } + + [TestCase(TestName = "AND operation")] + public void TestAnd() + { + AssertTrue("true && true"); + AssertFalse("false && false"); + AssertFalse("true && false"); + AssertFalse("false && true"); + } + + [TestCase(TestName = "OR operation")] + public void TestOR() + { + AssertTrue("true || true"); + AssertFalse("false || false"); + AssertTrue("true || false"); + AssertTrue("false || true"); + } + + [TestCase(TestName = "Equals operation")] + public void TestEquals() + { + AssertTrue("true == true"); + AssertTrue("false == false"); + AssertFalse("true == false"); + AssertFalse("false == true"); + } + + [TestCase(TestName = "Not-equals (XOR) operation")] + public void TestNotEquals() + { + AssertFalse("true != true"); + AssertFalse("false != false"); + AssertTrue("true != false"); + AssertTrue("false != true"); + } + + [TestCase(TestName = "NOT operation")] + public void TestNOT() + { + AssertFalse("!true"); + AssertTrue("!false"); + AssertTrue("!!true"); + AssertFalse("!!false"); + } + + [TestCase(TestName = "Precedence")] + public void TestPrecedence() + { + AssertTrue("true && false || true"); + AssertFalse("false || false && true"); + AssertTrue("true && !true || !false"); + AssertFalse("false || !true && !false"); + } + + [TestCase(TestName = "Parenthesis")] + public void TestParens() + { + AssertTrue("(true)"); + AssertTrue("((true))"); + AssertFalse("(false)"); + AssertFalse("((false))"); + } + + [TestCase(TestName = "Parenthesis and mixed operations")] + public void TestMixedParens() + { + AssertTrue("(!false)"); + AssertTrue("!(false)"); + AssertFalse("!(!false)"); + AssertTrue("(true) || (false)"); + AssertTrue("true && (false || true)"); + AssertTrue("(true && false) || true"); + AssertTrue("!(true && false) || false"); + AssertTrue("((true != true) == false) && true"); + AssertFalse("(true != false) == false && true"); + AssertTrue("true || ((true != false) != !(false && true))"); + AssertFalse("((true != false) != !(false && true))"); + } + + [TestCase(TestName = "Test parser errors")] + public void TestParseErrors() + { + AssertParseFailure("()"); + AssertParseFailure("! && true"); + AssertParseFailure("(true"); + AssertParseFailure(")true"); + AssertParseFailure("false)"); + AssertParseFailure("false("); + AssertParseFailure("false!"); + AssertParseFailure("true false"); + AssertParseFailure("true & false"); + AssertParseFailure("true | false"); + AssertParseFailure("true / false"); + AssertParseFailure("true & false && !"); + AssertParseFailure("(true && !)"); + AssertParseFailure("&& false"); + AssertParseFailure("false ||"); + } + + [TestCase(TestName = "Undefined symbols are treated as `false` values")] + public void TestUndefinedSymbols() + { + AssertFalse("undef1 || undef2"); + } + } +} diff --git a/OpenRA.Test/OpenRA.Test.csproj b/OpenRA.Test/OpenRA.Test.csproj index 3710a4db67..4caa55258f 100644 --- a/OpenRA.Test/OpenRA.Test.csproj +++ b/OpenRA.Test/OpenRA.Test.csproj @@ -55,6 +55,7 @@ +