Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to escape/unescape testcase filter strings #1627

Merged
merged 11 commits into from
Jun 5, 2018
12 changes: 7 additions & 5 deletions src/Microsoft.TestPlatform.Common/Filtering/Condition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ namespace Microsoft.VisualStudio.TestPlatform.Common.Filtering
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

using Microsoft.VisualStudio.TestPlatform.ObjectModel;
Expand Down Expand Up @@ -44,7 +45,7 @@ internal class Condition
/// String seperator used for parsing input string of format '<propertyName>Operation<propertyValue>'
/// ! is not a valid operation, but is required to filter the invalid patterns.
/// </summary>
private static string propertyNameValueSeperatorString = @"(\!\=)|(\=)|(\~)|(\!)";
//private static string propertyNameValueSeperatorString = @"(\!\=)|(\=)|(\~)|(\!)";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete this please


/// <summary>
/// Default property name which will be used when filter has only property value.
Expand Down Expand Up @@ -99,7 +100,7 @@ internal Condition(string name, Operation operation, string value)
/// </summary>
internal bool Evaluate(Func<string, Object> propertyValueProvider)
{
ValidateArg.NotNull(propertyValueProvider, "propertyValueProvider");
ValidateArg.NotNull(propertyValueProvider, nameof(propertyValueProvider));
var result = false;
var multiValue = this.GetPropertyValue(propertyValueProvider);
switch (this.Operation)
Expand Down Expand Up @@ -169,12 +170,13 @@ internal static Condition Parse(string conditionString)
{
ThrownFormatExceptionForInvalidCondition(conditionString);
}
string[] parts = Regex.Split(conditionString, propertyNameValueSeperatorString);

var parts = FilterHelpers.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(Condition.DefaultPropertyName, Condition.DefaultOperation, conditionString.Trim());
return new Condition(Condition.DefaultPropertyName, Condition.DefaultOperation, FilterHelpers.Unescape(conditionString.Trim()));
}

if (parts.Length != 3)
Expand All @@ -192,7 +194,7 @@ internal static Condition Parse(string conditionString)
}

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

Expand Down
14 changes: 7 additions & 7 deletions src/Microsoft.TestPlatform.Common/Filtering/FilterExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ internal class FilterExpression

private FilterExpression(FilterExpression left, FilterExpression right, bool areJoinedByAnd)
{
ValidateArg.NotNull(left, "left");
ValidateArg.NotNull(right, "right");
ValidateArg.NotNull(left, nameof(left));
ValidateArg.NotNull(right, nameof(right));

this.left = left;
this.right = right;
Expand All @@ -62,7 +62,7 @@ private FilterExpression(FilterExpression left, FilterExpression right, bool are

private FilterExpression(Condition condition)
{
ValidateArg.NotNull(condition, "condition");
ValidateArg.NotNull(condition, nameof(condition));
this.condition = condition;
}
#endregion
Expand Down Expand Up @@ -168,16 +168,16 @@ internal string[] ValidForProperties(IEnumerable<string> properties, Func<string
/// </summary>
internal static FilterExpression Parse(string filterString, out FastFilter fastFilter)
{
ValidateArg.NotNull(filterString, "filterString");
ValidateArg.NotNull(filterString, nameof(filterString));

// below parsing doesn't error out on pattern (), so explicitly search for that (empty parethesis).
//// below parsing doesn't error out on pattern (), so explicitly search for that (empty parethesis).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit nit nit: might want to undo the changes to this comment (remove the extra //).

var invalidInput = Regex.Match(filterString, @"\(\s*\)");
if (invalidInput.Success)
{
throw new FormatException(string.Format(CultureInfo.CurrentCulture, CommonResources.TestCaseFilterFormatException, CommonResources.EmptyParenthesis));
}

var tokens = Regex.Split(filterString, filterExpressionSeperatorString);
var tokens = FilterHelpers.TokenizeFilterExpressionString(filterString);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Delete not required fields filterExpressionSeperatorString and propertyNameValueSeperatorString.

var operatorStack = new Stack<Operator>();
var filterStack = new Stack<FilterExpression>();

Expand Down Expand Up @@ -283,7 +283,7 @@ internal static FilterExpression Parse(string filterString, out FastFilter fastF
/// <returns> True if evaluation is successful. </returns>
internal bool Evaluate(Func<string, Object> propertyValueProvider)
{
ValidateArg.NotNull(propertyValueProvider, "propertyValueProvider");
ValidateArg.NotNull(propertyValueProvider, nameof(propertyValueProvider));

bool filterResult = false;
if (null != this.condition)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public class FilterExpressionWrapper
/// </summary>
public FilterExpressionWrapper(string filterString, FilterOptions options)
{
ValidateArg.NotNullOrEmpty(filterString, "filterString");
ValidateArg.NotNullOrEmpty(filterString, nameof(filterString));

this.FilterString = filterString;
this.FilterOptions = options;
Expand Down Expand Up @@ -120,7 +120,7 @@ public string[] ValidForProperties(IEnumerable<String> supportedProperties, Func
/// </summary>
public bool Evaluate(Func<string, Object> propertyValueProvider)
{
ValidateArg.NotNull(propertyValueProvider, "propertyValueProvider");
ValidateArg.NotNull(propertyValueProvider, nameof(propertyValueProvider));

if (UseFastFilter)
{
Expand Down
231 changes: 231 additions & 0 deletions src/Microsoft.TestPlatform.Common/Filtering/FilterHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Microsoft.VisualStudio.TestPlatform.Common.Filtering
{
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using Microsoft.VisualStudio.TestPlatform.Common.Resources;

public static class FilterHelpers
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be internal? LUT has dependency on Microsoft.TestPlatform.Common?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, made this public for LUT. LUT has dependency on TranslationLayer, which pulls in Common as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. However not all clients(VS code, Rider,..) uses TranslationLayer. We should rename this to FilterEscapeHelper and move this to OM?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the tokenizer code should remain private to FilterExpression/Condition in this case, don't see a point of exposing them.

{
private const char EscapePrefix = '\\';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:EscapeCharacter makes more sense?

private static readonly char[] SpecialCharacters = { '\\', '(', ')', '&', '|', '=', '!', '~' };

/// <summary>
/// Escapes a set of special characters for filter (%, (, ), &, |, =, !, ~) by replacing them with their escape sequences.
/// </summary>
/// <param name="str">The input string that contains the text to convert.</param>
/// <returns>A string of characters with special characters converted to their escaped form.</returns>
public static string Escape(string str)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am guessing. we are just going to replicate this code on the IDE side. If yes, it is problematic, maintenance issues and what not.
Also, we would need to document this for other consumers of TP to use this escaping logic

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made the helper class public, so it can be used by other components. But I'm not sure if this is the right place for public helpers.

{
if (str == null)
{
throw new ArgumentNullException(nameof(str));
}

if (str.IndexOfAny(SpecialCharacters) < 0)
{
return str;
}

var builder = new StringBuilder();
for (int i = 0; i < str.Length; ++i)
{
var currentChar = str[i];
if (SpecialCharacters.Contains(currentChar))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use HashSet for SpecialCharacters. It may improve perf?

{
builder.Append(EscapePrefix);
}
builder.Append(currentChar);
}

return builder.ToString();
}

/// <summary>
/// Converts any escaped characters in the input filter string.
/// </summary>
/// <param name="str">The input string that contains the text to convert.</param>
/// <returns>A filter string of characters with any escaped characters converted to their unescaped form.</returns>
public static string Unescape(string str)
{
if (str == null)
{
throw new ArgumentNullException(nameof(str));
}

if (str.IndexOf(EscapePrefix) < 0)
{
return str;
}

var builder = new StringBuilder();
for (int i = 0; i < str.Length; ++i)
{
var currentChar = str[i];
if (currentChar == EscapePrefix)
{
if (++i == str.Length || !SpecialCharacters.Contains(currentChar = str[i]))
{
// "\" should be followed by a special character.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Show the position and character in error message and log same error message to EqtTrace.

throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resources.TestCaseFilterEscapeException, str));
}
}

// Strictly speaking, string to be unescaped shouldn't contain any of the special characters,
// other than being part of escape sequence, but we will ignore that to avoid additional overhead.

builder.Append(currentChar);
}

return builder.ToString();
}

internal static IEnumerable<string> TokenizeFilterExpressionString(string str)
{
if (str == null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use string.IsNullOrEmpty() instead everywhere.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to ensure the exact same behavior as before (with regex). we might be able to do further adjustment in Parse methods but I think it can be done in a separate PR.

{
throw new ArgumentNullException(nameof(str));
}

StringBuilder tokenBuilder = new StringBuilder();

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

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

if (current == EscapePrefix)
{
// We just encountered "\\" (escaped '\'), this will set last to '\0'
// so the next char will not be treated as a suffix of escape sequence.
current = '\0';
}
}
else
{
switch (current)
{
case '(':
case ')':
case '&':
case '|':
if (tokenBuilder.Length > 0)
{
yield return tokenBuilder.ToString();
tokenBuilder.Clear();
}
yield return current.ToString();
break;

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

last = current;
}

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

internal static IEnumerable<string> TokenizeFilterConditionString(string str)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Please add documentation for these as well.

{
if (str == null)
{
throw new ArgumentNullException(nameof(str));
}

StringBuilder tokenBuilder = new StringBuilder();

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

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: pass through*/ FilterHelpers* (Same in the other method)
I guess you'd change the name though.

tokenBuilder.Append(current);

if (current == FilterHelpers.EscapePrefix)
{
// We just encountered "\\" (escaped '\'), this will set last to '\0'
// so the next char will not be treated as a suffix of escape sequence.
current = '\0';
}
}
else
{
switch (current)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just put a comment telling the allowed special tokens, eg "=", "!=", "!", "~"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When do we expect just "!" ? Can you share an example, also add to tests.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't expect '!', but it's not handled in tokenizer. Instead, an exception is thrown in Condition.GetOperator.

{
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 just a single "!".
var next = str[i + 1];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will cause System.IndexOutOfRangeException for "FullyQualifiedName!", Make sure we display or throw proper message.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Fixed

if (next == '=')
{
++i;
current = next;
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();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class TestCaseFilterExpression : ITestCaseFilterExpression
/// </summary>
public TestCaseFilterExpression(FilterExpressionWrapper filterWrapper)
{
ValidateArg.NotNull(filterWrapper, "filterWrapper");
ValidateArg.NotNull(filterWrapper, nameof(filterWrapper));
this.filterWrapper = filterWrapper;
this.validForMatch = string.IsNullOrEmpty(filterWrapper.ParseError);
}
Expand Down Expand Up @@ -62,8 +62,8 @@ public string[] ValidForProperties(IEnumerable<String> supportedProperties, Func
/// </summary>
public bool MatchTestCase(TestCase testCase, Func<string, Object> propertyValueProvider)
{
ValidateArg.NotNull(testCase, "testCase");
ValidateArg.NotNull(propertyValueProvider, "propertyValueProvider");
ValidateArg.NotNull(testCase, nameof(testCase));
ValidateArg.NotNull(propertyValueProvider, nameof(propertyValueProvider));
if (!this.validForMatch)
{
return false;
Expand Down
Loading