// 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);
}
}
}