// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using EpicGames.Core; #nullable enable namespace AutomationTool { /// /// Exception class thrown due to type and syntax errors in condition expressions /// public class BgConditionException : Exception { /// /// Constructor; formats the exception message with the given String.Format() style parameters. /// /// Formatting string, in String.Format syntax /// Optional arguments for the string public BgConditionException(string format, params object[] args) : base(String.Format(format, args)) { } } /// /// Context for evaluating BuildGraph conditions /// /// Root directory for resolving paths public record class BgConditionContext(DirectoryReference RootDir); /// /// Class to evaluate condition expressions in build scripts, following this grammar: /// /// or-expression ::= and-expression /// | or-expression "Or" and-expression; /// /// and-expression ::= comparison /// | and-expression "And" comparison; /// /// comparison ::= scalar /// | scalar "==" scalar /// | scalar "!=" scalar /// | scalar "<" scalar /// | scalar "<=" scalar; /// | scalar ">" scalar /// | scalar ">=" scalar; /// /// scalar ::= "(" or-expression ")" /// | "!" scalar /// | "Exists" "(" scalar ")" /// | "HasTrailingSlash" "(" scalar ")" /// | string /// | identifier; /// /// string ::= any sequence of characters terminated by single quotes (') or double quotes ("). Not escaped. /// identifier ::= any sequence of letters, digits, or underscore characters. /// /// The type of each subexpression is always a scalar, which are converted to expression-specific types (eg. booleans, integers) as required. /// Scalar values are case-insensitive strings. The identifier 'true' and the strings "true" and "True" are all identical scalars. /// public class BgCondition { /// /// Sentinel added to the end of a sequence of tokens. /// const string EndToken = ""; /// /// Tokens for the condition /// readonly List _tokens = new List(); /// /// The current token index /// int _idx; /// /// Constructor /// /// The condition text private BgCondition(string text) { Tokenize(text, _tokens); } /// /// Evaluates the given string as a condition. Throws a ConditionException on a type or syntax error. /// /// /// Context for evaluating the condition /// The result of evaluating the condition public static ValueTask EvaluateAsync(string text, BgConditionContext context) { return new BgCondition(text).EvaluateAsync(context); } /// /// Evaluates the given string as a condition. Throws a ConditionException on a type or syntax error. /// /// Context for evaluating the condition /// The result of evaluating the condition async ValueTask EvaluateAsync(BgConditionContext context) { bool result = true; if (_tokens.Count > 1) { _idx = 0; string value = await EvaluateOrAsync(context); if (_tokens[_idx] != EndToken) { throw new BgConditionException("Garbage after expression: {0}", String.Join("", _tokens.Skip(_idx))); } result = CoerceToBool(value); } return result; } /// /// Evaluates an "or-expression" production. /// /// Context for evaluating the expression /// A scalar representing the result of evaluating the expression. async ValueTask EvaluateOrAsync(BgConditionContext context) { // Or Or... string result = await EvaluateAndAsync(context); while (String.Equals(_tokens[_idx], "Or", StringComparison.OrdinalIgnoreCase)) { // Evaluate this condition. We use a binary OR here, because we want to parse everything rather than short-circuit it. _idx++; string lhs = result; string rhs = await EvaluateAndAsync(context); result = (CoerceToBool(lhs) | CoerceToBool(rhs)) ? "true" : "false"; } return result; } /// /// Evaluates an "and-expression" production. /// /// Context for evaluating the expression /// A scalar representing the result of evaluating the expression. async ValueTask EvaluateAndAsync(BgConditionContext context) { // And And... string result = await EvaluateComparisonAsync(context); while (String.Equals(_tokens[_idx], "And", StringComparison.OrdinalIgnoreCase)) { // Evaluate this condition. We use a binary AND here, because we want to parse everything rather than short-circuit it. _idx++; string lhs = result; string rhs = await EvaluateComparisonAsync(context); result = (CoerceToBool(lhs) & CoerceToBool(rhs)) ? "true" : "false"; } return result; } /// /// Evaluates a "comparison" production. /// /// Context for evaluating the expression /// The result of evaluating the expression async ValueTask EvaluateComparisonAsync(BgConditionContext context) { // scalar // scalar == scalar // scalar != scalar // scalar < scalar // scalar <= scalar // scalar > scalar // scalar >= scalar string result = await EvaluateScalarAsync(context); if (_tokens[_idx] == "==") { // Compare two scalars for equality _idx++; string lhs = result; string rhs = await EvaluateScalarAsync(context); result = String.Equals(lhs, rhs, StringComparison.OrdinalIgnoreCase) ? "true" : "false"; } else if (_tokens[_idx] == "!=") { // Compare two scalars for inequality _idx++; string lhs = result; string rhs = await EvaluateScalarAsync(context); result = String.Equals(lhs, rhs, StringComparison.OrdinalIgnoreCase) ? "false" : "true"; } else if (_tokens[_idx] == "<") { // Compares whether the first integer is less than the second _idx++; int lhs = CoerceToInteger(result); int rhs = CoerceToInteger(await EvaluateScalarAsync(context)); result = (lhs < rhs) ? "true" : "false"; } else if (_tokens[_idx] == "<=") { // Compares whether the first integer is less than the second _idx++; int lhs = CoerceToInteger(result); int rhs = CoerceToInteger(await EvaluateScalarAsync(context)); result = (lhs <= rhs) ? "true" : "false"; } else if (_tokens[_idx] == ">") { // Compares whether the first integer is less than the second _idx++; int lhs = CoerceToInteger(result); int rhs = CoerceToInteger(await EvaluateScalarAsync(context)); result = (lhs > rhs) ? "true" : "false"; } else if (_tokens[_idx] == ">=") { // Compares whether the first integer is less than the second _idx++; int lhs = CoerceToInteger(result); int rhs = CoerceToInteger(await EvaluateScalarAsync(context)); result = (lhs >= rhs) ? "true" : "false"; } return result; } /// /// Evaluates arguments from a token string. Arguments are all comma-separated tokens until a closing ) is encountered /// /// The result of evaluating the expression IEnumerable EvaluateArguments() { List arguments = new List(); // skip opening bracket if (_tokens[_idx++] != "(") { throw new BgConditionException("Expected '('"); } bool didCloseBracket = false; while (_idx < _tokens.Count) { string nextToken = _tokens.ElementAt(_idx++); if (nextToken == EndToken) { // ran out of items break; } else if (nextToken == ")") { didCloseBracket = true; break; } else if (nextToken != ",") { if (nextToken.First() == '\'' && nextToken.Last() == '\'') { nextToken = nextToken.Substring(1, nextToken.Length - 2); } arguments.Add(nextToken); } } if (!didCloseBracket) { throw new BgConditionException("Expected ')'"); } return arguments; } /// /// Evaluates a "scalar" production. /// /// Context for evaluating the expression /// The result of evaluating the expression async ValueTask EvaluateScalarAsync(BgConditionContext context) { string result; if (_tokens[_idx] == "(") { // Subexpression _idx++; result = await EvaluateOrAsync(context); if (_tokens[_idx] != ")") { throw new BgConditionException("Expected ')'"); } _idx++; } else if (_tokens[_idx] == "!") { // Logical not _idx++; string rhs = await EvaluateScalarAsync(context); result = CoerceToBool(rhs) ? "false" : "true"; } else if (String.Equals(_tokens[_idx], "Exists", StringComparison.OrdinalIgnoreCase) && _tokens[_idx + 1] == "(") { // Check whether file or directory exists. Evaluate the argument as a subexpression. _idx++; string argument = await EvaluateScalarAsync(context); result = Exists(argument, context) ? "true" : "false"; } else if (String.Equals(_tokens[_idx], "HasTrailingSlash", StringComparison.OrdinalIgnoreCase) && _tokens[_idx + 1] == "(") { // Check whether the given string ends with a slash _idx++; string argument = await EvaluateScalarAsync(context); result = (argument.Length > 0 && (argument[^1] == Path.DirectorySeparatorChar || argument[^1] == Path.AltDirectorySeparatorChar)) ? "true" : "false"; } else if (String.Equals(_tokens[_idx], "Contains", StringComparison.OrdinalIgnoreCase) && _tokens[_idx + 1] == "(") { // Check a string contains a substring. If a separator is supplied the string is first split _idx++; IEnumerable arguments = EvaluateArguments(); if (arguments.Count() != 2) { throw new BgConditionException("Invalid argument count for 'Contains'. Expected (Haystack,Needle)"); } result = Contains(arguments.ElementAt(0), arguments.ElementAt(1)) ? "true" : "false"; } else if (String.Equals(_tokens[_idx], "ContainsItem", StringComparison.OrdinalIgnoreCase) && _tokens[_idx + 1] == "(") { // Check a string contains a substring. If a separator is supplied the string is first split _idx++; IEnumerable arguments = EvaluateArguments(); if (arguments.Count() != 3) { throw new BgConditionException("Invalid argument count for 'ContainsItem'. Expected (Haystack,Needle,HaystackSeparator)"); } result = ContainsItem(arguments.ElementAt(0), arguments.ElementAt(1), arguments.ElementAt(2)) ? "true" : "false"; } else { // Raw scalar. Remove quotes from strings, and allow literals and simple identifiers to pass through directly. string token = _tokens[_idx]; if (token.Length >= 2 && (token[0] == '\'' || token[0] == '\"') && token[^1] == token[0]) { result = token.Substring(1, token.Length - 2); _idx++; } else if (Char.IsLetterOrDigit(token[0]) || token[0] == '_') { result = token; _idx++; } else { throw new BgConditionException("Token '{0}' is not a valid scalar", token); } } return result; } /// /// Determine if a path exists /// static bool Exists(string path, BgConditionContext context) { try { return FileReference.Exists(FileReference.Combine(context.RootDir, path)) || DirectoryReference.Exists(DirectoryReference.Combine(context.RootDir, path)); } catch { return false; } } /// /// Checks whether Haystack contains "Needle". /// /// The string to search /// The string to search for /// True if the path exists, false otherwise. static bool Contains(string haystack, string needle) { try { return haystack.Contains(needle, StringComparison.CurrentCultureIgnoreCase); } catch { return false; } } /// /// Checks whether HaystackItems contains "Needle". /// /// The separated list of items to check /// The item to check for /// The separator used in Haystack /// True if the path exists, false otherwise. static bool ContainsItem(string haystack, string needle, string haystackSeparator) { try { IEnumerable haystackItems = haystack.Split(new string[] { haystackSeparator }, StringSplitOptions.RemoveEmptyEntries); return haystackItems.Any(i => i.ToLower() == needle.ToLower()); } catch { return false; } } /// /// Converts a scalar to a boolean value. /// /// The scalar to convert /// The scalar converted to a boolean value. static bool CoerceToBool(string scalar) { bool result; if (String.Equals(scalar, "true", StringComparison.OrdinalIgnoreCase)) { result = true; } else if (String.Equals(scalar, "false", StringComparison.OrdinalIgnoreCase)) { result = false; } else { throw new BgConditionException("Token '{0}' cannot be coerced to a bool", scalar); } return result; } /// /// Converts a scalar to a boolean value. /// /// The scalar to convert /// The scalar converted to an integer value. static int CoerceToInteger(string scalar) { int value; if (!Int32.TryParse(scalar, out value)) { throw new BgConditionException("Token '{0}' cannot be coerced to an integer", scalar); } return value; } /// /// Splits an input string up into expression tokens. /// /// Text to be converted into tokens /// List to receive a list of tokens static void Tokenize(string text, List tokens) { int idx = 0; while (idx < text.Length) { int endIdx = idx + 1; if (!Char.IsWhiteSpace(text[idx])) { // Scan to the end of the current token if (Char.IsNumber(text[idx])) { // Number while (endIdx < text.Length && Char.IsNumber(text[endIdx])) { endIdx++; } } else if (Char.IsLetter(text[idx]) || text[idx] == '_') { // Identifier while (endIdx < text.Length && (Char.IsLetterOrDigit(text[endIdx]) || text[endIdx] == '_')) { endIdx++; } } else if (text[idx] == '!' || text[idx] == '<' || text[idx] == '>' || text[idx] == '=') { // Operator that can be followed by an equals character if (endIdx < text.Length && text[endIdx] == '=') { endIdx++; } } else if (text[idx] == '\'' || text[idx] == '\"') { // String if (endIdx < text.Length) { endIdx++; while (endIdx < text.Length && text[endIdx - 1] != text[idx]) { endIdx++; } } } tokens.Add(text.Substring(idx, endIdx - idx)); } idx = endIdx; } tokens.Add(EndToken); } /// /// Test cases for conditions. /// public static async Task TestConditionsAsync() { await TestConditionAsync("1 == 2", false); await TestConditionAsync("1 == 1", true); await TestConditionAsync("1 != 2", true); await TestConditionAsync("1 != 1", false); await TestConditionAsync("'hello' == 'hello'", true); await TestConditionAsync("'hello' == ('hello')", true); await TestConditionAsync("'hello' == 'world'", false); await TestConditionAsync("'hello' != ('world')", true); await TestConditionAsync("true == ('true')", true); await TestConditionAsync("true == ('True')", true); await TestConditionAsync("true == ('false')", false); await TestConditionAsync("true == !('False')", true); await TestConditionAsync("true == 'true' and 'false' == 'False'", true); await TestConditionAsync("true == 'true' and 'false' == 'true'", false); await TestConditionAsync("true == 'false' or 'false' == 'false'", true); await TestConditionAsync("true == 'false' or 'false' == 'true'", true); } /// /// Helper method to evaluate a condition and check it's the expected result /// /// Condition to evaluate /// The expected result static async Task TestConditionAsync(string condition, bool expectedResult) { bool result = await new BgCondition(condition).EvaluateAsync(new BgConditionContext(DirectoryReference.GetCurrentDirectory())); Console.WriteLine("{0}: {1} = {2}", (result == expectedResult) ? "PASS" : "FAIL", condition, result); } } }