File: Condition.cs
Web Access
Project: src\src\vstest\src\Microsoft.TestPlatform.Filter.Source\Microsoft.TestPlatform.Filter.Source.csproj (Microsoft.TestPlatform.Filter.Source)
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

#nullable enable

using System;
using System.Collections.Generic;
using System.Globalization;
#if IS_VSTEST_REPO
using System.Diagnostics.CodeAnalysis;
#endif
using System.Linq;
using System.Text;

#if !IS_VSTEST_REPO
using Microsoft.CodeAnalysis;
#endif

#if IS_VSTEST_REPO
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Utilities;

using static Microsoft.VisualStudio.TestPlatform.Common.Resources.Resources;
#endif

namespace Microsoft.VisualStudio.TestPlatform.Common.Filtering;

#if !IS_VSTEST_REPO
[Embedded]
#endif
internal enum Operation
{
    Equal,
    NotEqual,
    Contains,
    NotContains
}

/// <summary>
/// Operator in order of precedence.
/// Precedence(And) > Precedence(Or)
/// Precedence of OpenBrace and CloseBrace operators is not used, instead parsing code takes care of same.
/// </summary>
#if !IS_VSTEST_REPO
[Embedded]
#endif
internal enum Operator
{
    None,
    Or,
    And,
    OpenBrace,
    CloseBrace,
}

/// <summary>
/// Represents a condition in filter expression.
/// </summary>
#if !IS_VSTEST_REPO
[Embedded]
#endif
internal sealed class Condition
{
    /// <summary>
    ///  Default property name which will be used when filter has only property value.
    /// </summary>
    public const string DefaultPropertyName = "FullyQualifiedName";

    /// <summary>
    ///  Default operation which will be used when filter has only property value.
    /// </summary>
    public const Operation DefaultOperation = Operation.Contains;

    /// <summary>
    /// Reserved filter value that matches tests with no value for a given property (uncategorized).
    /// </summary>
    internal const string NoneFilterValue = "None";

#if !IS_VSTEST_REPO
    private const string TestCaseFilterFormatException = "Incorrect format for TestCaseFilter {0}. Specify the correct format and try again. Note that the incorrect format can lead to no test getting executed.";

    private const string InvalidCondition = "Error: Invalid Condition '{0}'";

    private const string InvalidOperator = "Error: Invalid operator '{0}'";
#endif

    internal Condition(string name, Operation operation, string value)
    {
        Name = name;
        Operation = operation;
        Value = value;
    }

    /// <summary>
    /// Name of the property used in condition.
    /// </summary>
    internal string Name { get; }

    /// <summary>
    /// Value for the property.
    /// </summary>
    internal string Value { get; }

    /// <summary>
    /// Operation to be performed.
    /// </summary>
    internal Operation Operation { get; }

    private bool EvaluateEqualOperation(string[]? multiValue)
    {
        // Reserved keyword: "None" matches tests with no value for this property (uncategorized).
        if (multiValue is null or { Length: 0 }
            && string.Equals(Value, NoneFilterValue, StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        // if any value in multi-valued property matches 'this.Value', for Equal to evaluate true.
        if (multiValue != null)
        {
            foreach (string propertyValue in multiValue)
            {
                if (string.Equals(propertyValue, Value, StringComparison.OrdinalIgnoreCase))
                {
                    return true;
                }
            }
        }

        return false;
    }

    private bool EvaluateContainsOperation(string[]? multiValue)
    {
        if (multiValue != null)
        {
            foreach (string propertyValue in multiValue)
            {
#if IS_VSTEST_REPO
                TPDebug.Assert(null != propertyValue, "PropertyValue can not be null.");
#endif
                if (propertyValue!.IndexOf(Value, StringComparison.OrdinalIgnoreCase) != -1)
                {
                    return true;
                }
            }
        }

        return false;
    }

    /// <summary>
    /// Evaluate this condition for testObject.
    /// </summary>
    internal bool Evaluate(Func<string, object?> propertyValueProvider)
    {
#if IS_VSTEST_REPO
        ValidateArg.NotNull(propertyValueProvider, nameof(propertyValueProvider));
#endif
        var multiValue = GetPropertyValue(propertyValueProvider);
        var result = Operation switch
        {
            // if any value in multi-valued property matches 'this.Value', for Equal to evaluate true.
            Operation.Equal => EvaluateEqualOperation(multiValue),
            // all values in multi-valued property should not match 'this.Value' for NotEqual to evaluate true.
            Operation.NotEqual => !EvaluateEqualOperation(multiValue),
            // if any value in multi-valued property contains 'this.Value' for 'Contains' to be true.
            Operation.Contains => EvaluateContainsOperation(multiValue),
            // all values in multi-valued property should not contain 'this.Value' for NotContains to evaluate true.
            Operation.NotContains => !EvaluateContainsOperation(multiValue),
            _ => false,
        };

        return result;
    }

    /// <summary>
    /// Returns a condition object after parsing input string of format '<propertyName>Operation</propertyName>'
    /// </summary>
    internal static Condition Parse(string? conditionString)
    {
#if IS_VSTEST_REPO
        if (conditionString.IsNullOrWhiteSpace())
#else
        if (string.IsNullOrWhiteSpace(conditionString))
#endif
        {
            ThrownFormatExceptionForInvalidCondition(conditionString);
        }

        var parts = TokenizeFilterConditionString(conditionString!).ToArray();
        if (parts.Length == 1)
        {
            // If only parameter values is passed, create condition with default property name,
            // default operation and given condition string as parameter value.
            return new Condition(DefaultPropertyName, DefaultOperation, FilterHelper.Unescape(conditionString!.Trim()));
        }

        if (parts.Length != 3)
        {
            ThrownFormatExceptionForInvalidCondition(conditionString);
        }

        for (int index = 0; index < 3; index++)
        {
#if IS_VSTEST_REPO
            if (parts[index].IsNullOrWhiteSpace())
#else
            if (string.IsNullOrWhiteSpace(parts[index]))
#endif
            {
                ThrownFormatExceptionForInvalidCondition(conditionString);
            }
            parts[index] = parts[index].Trim();
        }

        Operation operation = GetOperator(parts[1]);
        Condition condition = new(parts[0], operation, FilterHelper.Unescape(parts[2]));
        return condition;
    }

#if IS_VSTEST_REPO
    [DoesNotReturn]
#endif
    private static void ThrownFormatExceptionForInvalidCondition(string? conditionString)
    {
        throw new FormatException(string.Format(CultureInfo.CurrentCulture, TestCaseFilterFormatException,
            string.Format(CultureInfo.CurrentCulture, InvalidCondition, conditionString)));
    }

    /// <summary>
    /// Check if condition validates any property in properties.
    /// </summary>
#if IS_VSTEST_REPO
    internal bool ValidForProperties(IEnumerable<string> properties, Func<string, TestProperty?>? propertyProvider)
#else
    internal bool ValidForProperties(IEnumerable<string> properties)
#endif
    {
        bool valid = false;

        if (properties.Contains(Name, StringComparer.OrdinalIgnoreCase))
        {
            valid = true;

#if IS_VSTEST_REPO
            // Check if operation ~ (Contains) is on property of type string.
            if (Operation == Operation.Contains)
            {
                valid = ValidForContainsOperation(propertyProvider);
            }
#endif
        }
        return valid;
    }

#if IS_VSTEST_REPO
    private bool ValidForContainsOperation(Func<string, TestProperty?>? propertyProvider)
    {
        bool valid = true;

        // It is OK for propertyProvider to be null, no syntax check will happen.

        // Check validity of operator only if related TestProperty is non-null.
        // if null, it might be custom validation ignore it.
        if (null != propertyProvider)
        {
            TestProperty? testProperty = propertyProvider(Name);
            if (null != testProperty)
            {
                Type propertyType = testProperty.GetValueType();
                valid = typeof(string) == propertyType ||
                        typeof(string[]) == propertyType;
            }
        }
        return valid;
    }
#endif

    /// <summary>
    /// Return Operation corresponding to the operationString
    /// </summary>
    private static Operation GetOperator(string operationString)
    {
        return operationString switch
        {
            "=" => Operation.Equal,
            "!=" => Operation.NotEqual,
            "~" => Operation.Contains,
            "!~" => Operation.NotContains,
            _ => throw new FormatException(string.Format(CultureInfo.CurrentCulture, TestCaseFilterFormatException, string.Format(CultureInfo.CurrentCulture, InvalidOperator, operationString))),
        };
    }

    /// <summary>
    /// Returns property value for Property using propertValueProvider.
    /// </summary>
    private string[]? GetPropertyValue(Func<string, object?> propertyValueProvider)
    {
        var propertyValue = propertyValueProvider(Name);
        if (null != propertyValue)
        {
            if (propertyValue is not string[] multiValue)
            {
                multiValue = new string[1];
                multiValue[0] = propertyValue.ToString()!;
            }
            return multiValue;
        }

        return null;
    }

    internal static IEnumerable<string> TokenizeFilterConditionString(string str)
    {
        return str == null ? throw new ArgumentNullException(nameof(str)) : TokenizeFilterConditionStringWorker(str);

        static IEnumerable<string> TokenizeFilterConditionStringWorker(string s)
        {
            StringBuilder tokenBuilder = new();

            var last = '\0';
            for (int i = 0; i < s.Length; ++i)
            {
                var current = s[i];

                if (last == FilterHelper.EscapeCharacter)
                {
                    // Don't check if `current` is one of the special characters here.
                    // Instead, we blindly let any character follows '\' pass though and
                    // relies on `FilterHelpers.Unescape` to report such errors.
                    tokenBuilder.Append(current);

                    if (current == FilterHelper.EscapeCharacter)
                    {
                        // We just encountered double backslash (i.e. escaped '\'), therefore set `last` to '\0'
                        // so the second '\' (i.e. current) will not be treated as the prefix of escape sequence
                        // in next iteration.
                        current = '\0';
                    }
                }
                else
                {
                    switch (current)
                    {
                        case '=':
                            if (tokenBuilder.Length > 0)
                            {
                                yield return tokenBuilder.ToString();
                                tokenBuilder.Clear();
                            }
                            yield return "=";
                            break;

                        case '!':
                            if (tokenBuilder.Length > 0)
                            {
                                yield return tokenBuilder.ToString();
                                tokenBuilder.Clear();
                            }
                            // Determine if this is a "!=" or "!~" or just a single "!".
                            var next = i + 1;
                            if (next < s.Length && s[next] == '=')
                            {
                                i = next;
                                current = '=';
                                yield return "!=";
                            }
                            else if (next < s.Length && s[next] == '~')
                            {
                                i = next;
                                current = '~';
                                yield return "!~";
                            }
                            else
                            {
                                yield return "!";
                            }
                            break;

                        case '~':
                            if (tokenBuilder.Length > 0)
                            {
                                yield return tokenBuilder.ToString();
                                tokenBuilder.Clear();
                            }
                            yield return "~";
                            break;

                        default:
                            tokenBuilder.Append(current);
                            break;
                    }
                }
                last = current;
            }

            if (tokenBuilder.Length > 0)
            {
                yield return tokenBuilder.ToString();
            }
        }
    }
}