|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
namespace Microsoft.DotNet.Cli.Commands.New.MSBuildEvaluation;
/// <remarks>
/// As implemented in: https://docs.microsoft.com/en-us/dotnet/api/microsoft.visualstudio.shell.interop.vsprojectcapabilityexpressionmatcher?
/// </remarks>
internal class CapabilityExpressionEvaluator
{
/// <summary>
/// The set of terms that are present.
/// </summary>
private readonly IReadOnlyList<string> _presentTerms;
/// <summary>
/// The tokenizer that reads the expression.
/// </summary>
private readonly Tokenizer _tokenizer;
/// <summary>
/// The set of disallowed characters in terms.
/// </summary>
/// <remarks>
/// We restrict many symbols, especially mathematical symbols, because we may eventually want to
/// support arithmetic expressions.
/// </remarks>
internal static readonly char[] DisallowedCharacters = "\"'`:;,+-*/\\!~|&%$@^()={}[]<>? \t\b\n\r".ToCharArray();
/// <summary>
/// Initializes a new instance of the <see cref="CapabilityExpressionEvaluator"/> class.
/// </summary>
/// <param name="expression">The expression.</param>
/// <param name="presentTerms">The present terms.</param>
private CapabilityExpressionEvaluator(string expression, IReadOnlyList<string> presentTerms)
{
_tokenizer = new Tokenizer(expression);
_presentTerms = presentTerms ?? throw new ArgumentNullException(nameof(presentTerms));
}
/// <summary>
/// Evaluates the given expression against the given set of true terms. Missing terms are assumed to be false.
/// </summary>
/// <param name="expression">
/// The expression, such as "(VisualC | CSharp) + (MSTest | NUnit)".
/// The '|' is the OR operator.
/// The '&' and '+' characters are both AND operators.
/// The '!' character is the NOT operator.
/// Parentheses force evaluation precedence order.
/// A null or empty expression is evaluated as true.
/// </param>
/// <param name="presentTerms">The terms that are currently defined.</param>
/// <returns>The result of evaluating the Boolean expression.</returns>
public static bool Evaluate(string expression, IReadOnlyList<string> presentTerms)
{
if (string.IsNullOrWhiteSpace(expression))
{
// An empty expression evaluates to true.
return true;
}
var eval = new CapabilityExpressionEvaluator(expression, presentTerms);
return eval.Top();
}
/// <summary>
/// Checks whether a given character is an allowed member of a term.
/// </summary>
/// <param name="ch">The character to test.</param>
/// <returns>true if the character would be an allowed member of a term; false otherwise.</returns>
private static bool IsSymbolCharacter(char ch)
{
return !DisallowedCharacters.Contains(ch);
}
/// <summary>
/// Processes | operators.
/// </summary>
/// <returns>The result of evaluating the current sub-expression.</returns>
private bool OrTerm()
{
bool lhs = AndTerm();
while (_tokenizer.Peek() == "|")
{
_tokenizer.Next();
bool rhs = AndTerm();
lhs = lhs || rhs;
}
return lhs;
}
/// <summary>
/// Processes & operators.
/// </summary>
/// <returns>The result of evaluating the current sub-expression.</returns>
private bool AndTerm()
{
bool lhs = Term();
while (_tokenizer.Peek() == "&")
{
_tokenizer.Next();
bool rhs = Term();
lhs = lhs && rhs;
}
return lhs;
}
/// <summary>
/// Processes terms.
/// </summary>
/// <returns>The result of evaluating the current sub-expression.</returns>
private bool Term()
{
int notCount = 0;
while (_tokenizer.Peek() == "!")
{
_tokenizer.Next();
notCount++;
}
if (_tokenizer.Peek() == "(")
{
_tokenizer.Next();
bool r = OrTerm();
if (_tokenizer.Peek() != ")")
{
throw _tokenizer.CreateInvalidExpressionException();
}
_tokenizer.Next();
return notCount % 2 == 0 ? r : !r;
}
else if (_tokenizer.Peek() != null && IsSymbolCharacter(_tokenizer.Peek()![0]))
{
string? ident = _tokenizer.Next();
bool isPresent = _presentTerms.Contains(ident, StringComparer.OrdinalIgnoreCase);
return notCount % 2 == 0 ? isPresent : !isPresent;
}
else
{
throw _tokenizer.CreateInvalidExpressionException();
}
}
/// <summary>
/// Processes the entire expression.
/// </summary>
/// <returns>The result of evaluating the expression.</returns>
private bool Top()
{
bool r = OrTerm();
if (_tokenizer.Peek() != null)
{
throw Tokenizer.CreateInvalidExpressionException(_tokenizer.Input.Length);
}
return r;
}
/// <summary>
/// The expression tokenizer.
/// </summary>
/// <devremarks>
/// This is a struct rather than a class to avoid allocating memory unnecessarily.
/// </devremarks>
private class Tokenizer
{
/// <summary>
/// The most recently previewed token.
/// </summary>
private string? _peeked;
/// <summary>
/// Initializes a new instance of the <see cref="Tokenizer"/> class.
/// </summary>
/// <param name="input">The expression to parse.</param>
internal Tokenizer(string input)
{
if (string.IsNullOrEmpty(input))
{
throw new ArgumentException($"'{nameof(input)}' cannot be null or empty.", nameof(input));
}
Input = input;
}
/// <summary>
/// Gets the entire expression being tokenized.
/// </summary>
internal string Input { get; }
/// <summary>
/// Gets the position of the next token.
/// </summary>
internal int Position { get; private set; }
/// <summary>
/// Gets the next token in the expression.
/// </summary>
internal string? Next()
{
// If the last call to Next() was within a Peek() method call,
// we need to return the same value again this time so that
// the Peek() doesn't impact the token stream.
if (_peeked != null)
{
string token = _peeked;
_peeked = null;
return token;
}
// Skip whitespace.
while (Position < Input.Length && char.IsWhiteSpace(Input[Position]))
{
Position++;
}
if (Position == Input.Length)
{
return null;
}
switch (Input[Position])
{
case char sym when IsSymbolCharacter(sym):
int begin = Position;
while (Position < Input.Length && IsSymbolCharacter(Input[Position]))
{
Position++;
}
int end = Position;
return Input.Substring(begin, end - begin);
// we prefer & but also accept + so that XML manifest files don't have to write the & escape sequence.
case '&':
case '+':
Position++;
return "&"; // always return '&' to simplify the parser logic by consolidating on only one of the two possible operators.
case '|':
Position++;
return "|";
case '(':
Position++;
return "(";
case ')':
Position++;
return ")";
case '!':
Position++;
return "!";
default: throw CreateInvalidExpressionException(Position);
}
}
/// <summary>
/// Peeks at the next token in the stream without skipping it on
/// the next invocation of <see cref="Next"/>.
/// </summary>
internal string? Peek()
{
return _peeked = Next();
}
/// <summary>
/// Create an exception indicating that the expression is invalid and reporting the current position.
/// </summary>
/// <param name="position">The position in the expression where the error was detected.</param>
/// <returns>An exception for diagnosing the invalid expression.</returns>
internal Exception CreateInvalidExpressionException()
{
return CreateInvalidExpressionException(Position);
}
/// <summary>
/// Create an exception indicating that the expression is invalid and reporting the given position.
/// </summary>
/// <param name="position">The position in the expression where the error was detected.</param>
/// <returns>An exception for diagnosing the invalid expression.</returns>
internal static Exception CreateInvalidExpressionException(int position)
{
return new ArgumentException(
string.Format(CliCommandStrings.CapabilityExpressionEvaluator_Exception_InvalidExpression, position),
"expression");
}
}
}
|