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

Wildcard pattern matcher improvement #6919

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -144,8 +144,8 @@ public bool HasMatchingScope(DiagnosticDescriptor descriptor)
&& !IsExcluded(globalExclusions, filePath);

private static bool IsIncluded(string[] inclusions, string filePath) =>
inclusions is { Length: 0 } || inclusions.Any(x => WildcardPatternMatcher.IsMatch(x, filePath));
inclusions is { Length: 0 } || inclusions.Any(x => WildcardPatternMatcher.IsMatch(x, filePath, true));

private static bool IsExcluded(string[] exclusions, string filePath) =>
exclusions.Any(x => WildcardPatternMatcher.IsMatch(x, filePath));
exclusions.Any(x => WildcardPatternMatcher.IsMatch(x, filePath, false));
}
134 changes: 51 additions & 83 deletions analyzers/src/SonarAnalyzer.Common/Helpers/WildcardPatternMatcher.cs
Expand Up @@ -27,108 +27,76 @@ namespace SonarAnalyzer.Helpers;

internal static class WildcardPatternMatcher
{
public static bool IsMatch(string pattern, string input) =>
private static readonly ConcurrentDictionary<string, Regex> Cache = new();

public static bool IsMatch(string pattern, string input, bool timeoutFallbackResult) =>
!(string.IsNullOrWhiteSpace(pattern) || string.IsNullOrWhiteSpace(input))
&& WildcardPattern.Create(pattern).Match(input);
&& Cache.GetOrAdd(pattern, _ => new Regex(ToRegex(pattern), RegexOptions.None, RegexConstants.DefaultTimeout)) is var regex
&& IsMatch(regex, input, timeoutFallbackResult);

/// <summary>
/// Copied from https://github.com/SonarSource/sonar-plugin-api/blob/a9bd7ff48f0f77811ed909070030678c443c975a/sonar-plugin-api/src/main/java/org/sonar/api/utils/WildcardPattern.java.
/// </summary>
private sealed class WildcardPattern
private static bool IsMatch(Regex regex, string value, bool timeoutFallbackResult)
{
private const string SpecialChars = "()[]^$.{}+|";
private static readonly ConcurrentDictionary<string, WildcardPattern> Cache = new();
private readonly Regex pattern;

private WildcardPattern(string pattern, string directorySeparator) =>
this.pattern = new Regex(ToRegexp(pattern, directorySeparator), RegexOptions.Compiled, RegexConstants.DefaultTimeout);

public bool Match(string value)
try
{
value = value.TrimStart('/');
value = value.TrimEnd('/');
try
{
return pattern.IsMatch(value);
}
catch (RegexMatchTimeoutException)
{
return false;
}
return regex.IsMatch(value.Trim('/'));
}

public static WildcardPattern Create(string pattern) =>
Create(pattern, Path.DirectorySeparatorChar.ToString());

private static WildcardPattern Create(string pattern, string directorySeparator) =>
Cache.GetOrAdd(pattern + directorySeparator, _ => new WildcardPattern(pattern, directorySeparator));

private static string ToRegexp(string wildcardPattern, string directorySeparator)
catch (RegexMatchTimeoutException)
{
var escapedDirectorySeparator = '\\' + directorySeparator;
var sb = new StringBuilder(wildcardPattern.Length);

sb.Append('^');
return timeoutFallbackResult;
}
}

var i = wildcardPattern.StartsWith("/") || wildcardPattern.StartsWith("\\") ? 1 : 0;
while (i < wildcardPattern.Length)
/// <summary>
/// Copied from https://github.com/SonarSource/sonar-plugin-api/blob/a9bd7ff48f0f77811ed909070030678c443c975a/sonar-plugin-api/src/main/java/org/sonar/api/utils/WildcardPattern.java.
/// </summary>
private static string ToRegex(string wildcardPattern)
{
var escapedDirectorySeparator = Regex.Escape(Path.DirectorySeparatorChar.ToString());
var sb = new StringBuilder("^", wildcardPattern.Length);
var i = IsSlash(wildcardPattern[0]) ? 1 : 0;
while (i < wildcardPattern.Length)
{
var ch = wildcardPattern[i];
if (ch == '*')
{
var ch = wildcardPattern[i];

if (SpecialChars.IndexOf(ch) != -1)
if (i + 1 < wildcardPattern.Length && wildcardPattern[i + 1] == '*')
{
// Escape regexp-specific characters
sb.Append('\\').Append(ch);
}
else if (ch == '*')
{
if (i + 1 < wildcardPattern.Length && wildcardPattern[i + 1] == '*')
// Double asterisk - Zero or more directories
if (i + 2 < wildcardPattern.Length && IsSlash(wildcardPattern[i + 2]))
{
// Double asterisk
// Zero or more directories
if (i + 2 < wildcardPattern.Length && IsSlash(wildcardPattern[i + 2]))
{
sb.Append("(?:.*").Append(escapedDirectorySeparator).Append("|)");
i += 2;
}
else
{
sb.Append(".*");
i += 1;
}
sb.Append($"(.*{escapedDirectorySeparator}|)");
i += 2;
}
else
{
// Single asterisk
// Zero or more characters excluding directory separator
sb.Append("[^").Append(escapedDirectorySeparator).Append("]*?");
sb.Append(".*");
i += 1;
}
}
else if (ch == '?')
{
// Any single character excluding directory separator
sb.Append("[^").Append(escapedDirectorySeparator).Append("]");
}
else if (IsSlash(ch))
{
// Directory separator
sb.Append(escapedDirectorySeparator);
}
else
{
// Single character
sb.Append(ch);
// Single asterisk - Zero or more characters excluding directory separator
sb.Append($"[^{escapedDirectorySeparator}]*?");
}

i++;
}

sb.Append('$');

return sb.ToString();
else if (ch == '?')
{
// Any single character excluding directory separator
sb.Append($"[^{escapedDirectorySeparator}]");
}
else if (IsSlash(ch))
{
sb.Append(escapedDirectorySeparator);
}
else
{
sb.Append(Regex.Escape(ch.ToString()));
}
i++;
}

private static bool IsSlash(char ch) =>
ch == '/' || ch == '\\';
return sb.Append('$').ToString();
}

private static bool IsSlash(char ch) =>
ch == '/' || ch == '\\';
}
Expand Up @@ -29,87 +29,66 @@ public class WildcardPatternMatcherTest
/// Based on https://github.com/SonarSource/sonar-plugin-api/blob/master/plugin-api/src/test/java/org/sonar/api/utils/WildcardPatternTest.java.
/// </summary>
[DataTestMethod]

[DataRow("Foo", "Foo", true)]
[DataRow("foo", "FOO", false)]
[DataRow("Foo", "Foot", false)]
[DataRow("Foo", "Bar", false)]

[DataRow("org/T?st.java", "org/Test.java", true)]
[DataRow("org/T?st.java", "org/Tost.java", true)]
[DataRow("org/T?st.java", "org/Teeest.java", false)]

[DataRow("org/*.java", "org/Foo.java", true)]
[DataRow("org/*.java", "org/Bar.java", true)]

[DataRow("org/**", "org/Foo.java", true)]
[DataRow("org/T?st.cs", "org/Test.cs", true)]
[DataRow("org/T?st.cs", "org/Tost.cs", true)]
[DataRow("org/T?st.cs", "org/Teeest.cs", false)]
[DataRow("org/*.cs", "org/Foo.cs", true)]
[DataRow("org/*.cs", "org/Bar.cs", true)]
[DataRow("org/**", "org/Foo.cs", true)]
[DataRow("org/**", "org/foo/bar.jsp", true)]

[DataRow("org/**/Test.java", "org/Test.java", true)]
[DataRow("org/**/Test.java", "org/foo/Test.java", true)]
[DataRow("org/**/Test.java", "org/foo/bar/Test.java", true)]

[DataRow("org/**/*.java", "org/Foo.java", true)]
[DataRow("org/**/*.java", "org/foo/Bar.java", true)]
[DataRow("org/**/*.java", "org/foo/bar/Baz.java", true)]

[DataRow("o?/**/*.java", "org/test.java", false)]
[DataRow("o?/**/*.java", "o/test.java", false)]
[DataRow("o?/**/*.java", "og/test.java", true)]
[DataRow("o?/**/*.java", "og/foo/bar/test.java", true)]
[DataRow("o?/**/*.java", "og/foo/bar/test.jav", false)]

[DataRow("org/**/Test.cs", "org/Test.cs", true)]
[DataRow("org/**/Test.cs", "org/foo/Test.cs", true)]
[DataRow("org/**/Test.cs", "org/foo/bar/Test.cs", true)]
[DataRow("org/**/*.cs", "org/Foo.cs", true)]
[DataRow("org/**/*.cs", "org/foo/Bar.cs", true)]
[DataRow("org/**/*.cs", "org/foo/bar/Baz.cs", true)]
[DataRow("o?/**/*.cs", "org/test.cs", false)]
[DataRow("o?/**/*.cs", "o/test.cs", false)]
[DataRow("o?/**/*.cs", "og/test.cs", true)]
[DataRow("o?/**/*.cs", "og/foo/bar/test.cs", true)]
[DataRow("o?/**/*.cs", "og/foo/bar/test.c", false)]
[DataRow("org/sonar/**", "org/sonar/commons/Foo", true)]
[DataRow("org/sonar/**", "org/sonar/Foo.java", true)]

[DataRow("org/sonar/**", "org/sonar/Foo.cs", true)]
[DataRow("xxx/org/sonar/**", "org/sonar/Foo", false)]

[DataRow("org/sonar/**/**", "org/sonar/commons/Foo", true)]
[DataRow("org/sonar/**/**", "org/sonar/commons/sub/Foo.java", true)]

[DataRow("org/sonar/**/**", "org/sonar/commons/sub/Foo.cs", true)]
[DataRow("org/sonar/**/Foo", "org/sonar/commons/sub/Foo", true)]
[DataRow("org/sonar/**/Foo", "org/sonar/Foo", true)]

[DataRow("*/foo/*", "org/foo/Bar", true)]
[DataRow("*/foo/*", "foo/Bar", false)]
[DataRow("*/foo/*", "foo", false)]
[DataRow("*/foo/*", "org/foo/bar/Hello", false)]

[DataRow("hell?", "hell", false)]
[DataRow("hell?", "hello", true)]
[DataRow("hell?", "helloworld", false)]

[DataRow("**/Reader", "java/io/Reader", true)]
[DataRow("**/Reader", "org/sonar/channel/CodeReader", false)]

[DataRow("**", "java/io/Reader", true)]

[DataRow("**/app/**", "com/app/Utils", true)]
[DataRow("**/app/**", "com/application/MyService", false)]

[DataRow("**/*$*", "foo/bar", false)]
[DataRow("**/*$*", "foo/bar$baz", true)]
[DataRow("a+", "aa", false)]
[DataRow("a+", "a+", true)]
[DataRow("[ab]", "a", false)]
[DataRow("[ab]", "[ab]", true)]

[DataRow("\\n", "\n", false)]
[DataRow("foo\\bar", "foo/bar", true)]

[DataRow("/foo", "foo", true)]
[DataRow("\\foo", "foo", true)]

[DataRow("foo\\bar", "foo\\bar", true)]
[DataRow("foo/bar", "foo\\bar", true)]
[DataRow("foo\\bar/baz", "foo\\bar\\baz", true)]

public void IsMatch_MatchesPatternsAsExpected(string pattern, string input, bool expectedResult)
{
// The test cases are copied from the plugin-api and the directory separators need replacing as Roslyn will not give us the paths with '/'.
input = input.Replace("/", Path.DirectorySeparatorChar.ToString());

WildcardPatternMatcher.IsMatch(pattern, input).Should().Be(expectedResult);
WildcardPatternMatcher.IsMatch(pattern, input, false).Should().Be(expectedResult);
}

[DataTestMethod]
Expand All @@ -118,12 +97,12 @@ public void IsMatch_MatchesPatternsAsExpected(string pattern, string input, bool
[DataRow("/")]
[DataRow("\\")]
public void IsMatch_InvalidPattern_ReturnsFalse(string pattern) =>
WildcardPatternMatcher.IsMatch(pattern, "foo").Should().BeFalse();
WildcardPatternMatcher.IsMatch(pattern, "foo", false).Should().BeFalse();

[DataTestMethod]
[DataRow(null, "foo")]
[DataRow("foo", null)]
public void IsMatch_InputParametersArenull_DoesNotThrow(string pattern, string input) =>
WildcardPatternMatcher.IsMatch(pattern, input).Should().BeFalse();
WildcardPatternMatcher.IsMatch(pattern, input, false).Should().BeFalse();
}
}