Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 79 additions & 3 deletions src/TestFramework/TestFramework/Assertions/Assert.Matches.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// 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.TestTools.UnitTesting;
Expand All @@ -7,6 +7,12 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting;

public sealed partial class Assert
{
// Match the runtime's default static Regex cache size while bounding retained user-provided pattern text.
private const int RegexCacheSize = 15;
private const int MaximumCachedRegexPatternLength = 512;

private static readonly BoundedRegexCache RegexCache = new();

#region MatchesRegex

/// <summary>
Expand Down Expand Up @@ -195,9 +201,79 @@ public static void DoesNotMatchRegex([NotNull] string? pattern, [NotNull] string

#endregion // DoesNotMatchRegex

private static Regex? ToRegex([NotNull] string? pattern)
private static Regex ToRegex([NotNull] string? pattern)
{
CheckParameterNotNull(pattern, "Assert.MatchesRegex", "pattern");
return new Regex(pattern);
if (pattern.Length > MaximumCachedRegexPatternLength)
{
return new Regex(pattern);
}

string cultureName = CultureInfo.CurrentCulture.Name;
if (RegexCache.TryGet(pattern, cultureName, out Regex cachedRegex))
{
return cachedRegex;
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
}

Regex regex = new(pattern);
return RegexCache.AddOrGetExisting(pattern, cultureName, regex);
}

private sealed class BoundedRegexCache
{
#if NET9_0_OR_GREATER
private readonly Lock _lock = new();
#else
private readonly object _lock = new();
#endif

private readonly RegexCacheEntry?[] _entries = new RegexCacheEntry[RegexCacheSize];

private int _nextInsertionIndex;

public bool TryGet(string pattern, string cultureName, out Regex regex)
{
int nextInsertionIndex = Volatile.Read(ref _nextInsertionIndex);
for (int offset = 1; offset <= RegexCacheSize; offset++)
{
int index = (nextInsertionIndex - offset + RegexCacheSize) % RegexCacheSize;
RegexCacheEntry? entry = Volatile.Read(ref _entries[index]);
if (entry is not null
&& string.Equals(entry.Pattern, pattern, StringComparison.Ordinal)
&& string.Equals(entry.CultureName, cultureName, StringComparison.Ordinal))
{
regex = entry.Regex;
return true;
}
}

regex = null!;
return false;
}

public Regex AddOrGetExisting(string pattern, string cultureName, Regex regex)
{
lock (_lock)
{
if (TryGet(pattern, cultureName, out Regex cachedRegex))
{
return cachedRegex;
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
}

int insertionIndex = _nextInsertionIndex;
Volatile.Write(ref _entries[insertionIndex], new RegexCacheEntry(pattern, cultureName, regex));
Volatile.Write(ref _nextInsertionIndex, (insertionIndex + 1) % RegexCacheSize);
return regex;
}
}
}

private sealed class RegexCacheEntry(string pattern, string cultureName, Regex regex)
{
public string Pattern { get; } = pattern;

public string CultureName { get; } = cultureName;

public Regex Regex { get; } = regex;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Text.RegularExpressions;
Expand All @@ -9,6 +9,21 @@ namespace Microsoft.VisualStudio.TestPlatform.TestFramework.UnitTests;

public partial class AssertTests
{
private static readonly int RegexCacheSize =
(int)typeof(Assert)
.GetField("RegexCacheSize", BindingFlags.Static | BindingFlags.NonPublic)!
.GetRawConstantValue()!;

private static readonly int MaximumCachedRegexPatternLength =
(int)typeof(Assert)
.GetField("MaximumCachedRegexPatternLength", BindingFlags.Static | BindingFlags.NonPublic)!
.GetRawConstantValue()!;

private static readonly Func<string?, Regex> ToRegex =
(Func<string?, Regex>)typeof(Assert)
.GetMethod("ToRegex", BindingFlags.Static | BindingFlags.NonPublic)!
.CreateDelegate(typeof(Func<string?, Regex>));

public void MatchesRegex_WithRegexPattern_OnSuccess_DoesNotThrow()
=> FluentActions.Invoking(() => Assert.MatchesRegex(new Regex("^he"), "hello"))
.Should().NotThrow();
Expand All @@ -17,6 +32,162 @@ public void MatchesRegex_WithStringPattern_OnSuccess_DoesNotThrow()
=> FluentActions.Invoking(() => Assert.MatchesRegex("^he", "hello"))
.Should().NotThrow();

public void MatchesRegex_WithRepeatedStringPattern_ReusesRegex()
{
string pattern = $"^{Guid.NewGuid():N}$";

ToRegex(pattern).Should().BeSameAs(ToRegex(pattern));
}

public void MatchesRegex_WithCaseDistinctStringPatterns_DoesNotReuseRegex()
{
string patternPrefix = Guid.NewGuid().ToString("N");
string lowercasePattern = $"^{patternPrefix}-a$";
string uppercasePattern = $"^{patternPrefix}-A$";

ToRegex(lowercasePattern).Should().NotBeSameAs(ToRegex(uppercasePattern));
}

public void MatchesRegex_WhenOldestRegexIsReusedBeforeCapacityIsExceeded_StillEvictsOldestRegex()
{
string patternPrefix = Guid.NewGuid().ToString("N");
string oldestPattern = $"^{patternPrefix}-oldest$";
Regex oldestRegex = ToRegex(oldestPattern);

for (int i = 0; i < RegexCacheSize - 1; i++)
{
_ = ToRegex($"^{patternPrefix}-{i}$");
}

ToRegex(oldestPattern).Should().BeSameAs(oldestRegex);
_ = ToRegex($"^{patternPrefix}-newest$");

ToRegex(oldestPattern).Should().NotBeSameAs(oldestRegex);
}

public void MatchesRegex_WithMaximumLengthStringPattern_ReusesRegex()
{
string pattern = new('a', MaximumCachedRegexPatternLength);

ToRegex(pattern).Should().BeSameAs(ToRegex(pattern));
}

public void MatchesRegex_WithOverMaximumLengthStringPattern_DoesNotCacheRegex()
{
string pattern = new('a', MaximumCachedRegexPatternLength + 1);

ToRegex(pattern).Should().NotBeSameAs(ToRegex(pattern));
Comment thread
Evangelink marked this conversation as resolved.
}

public void MatchesRegex_WithCultureSensitivePattern_DoesNotReuseRegexAcrossCultures()
{
const string Pattern = "(?i)^i$";
CultureInfo originalCulture = CultureInfo.CurrentCulture;

try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
Regex enUsRegex = ToRegex(Pattern);
enUsRegex.IsMatch("I").Should().BeTrue();

CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR");
Regex trTrRegex = ToRegex(Pattern);
trTrRegex.Should().NotBeSameAs(enUsRegex);
trTrRegex.IsMatch("I").Should().BeFalse();
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
}
}

public void MatchesRegex_WithConcurrentCandidateRegexes_ConvergesOnSingleCachedRegex()
{
string pattern = $"^{Guid.NewGuid():N}$";
string cultureName = CultureInfo.CurrentCulture.Name;
const int ThreadCount = 8;
var regexes = new Regex[ThreadCount];
var exceptions = new Exception?[ThreadCount];
using var ready = new CountdownEvent(ThreadCount);
using var start = new ManualResetEventSlim();
Type cacheType = typeof(Assert).GetNestedType("BoundedRegexCache", BindingFlags.NonPublic)!;
object cache = Activator.CreateInstance(cacheType, nonPublic: true)!;
var addOrGetExisting = (Func<string, string, Regex, Regex>)cacheType
.GetMethod("AddOrGetExisting", BindingFlags.Instance | BindingFlags.Public)!
.CreateDelegate(typeof(Func<string, string, Regex, Regex>), cache);
var threads = new Thread[ThreadCount];

for (int i = 0; i < threads.Length; i++)
{
int index = i;
threads[i] = new Thread(() =>
{
try
{
var candidate = new Regex(pattern);
ready.Signal();
start.Wait();
regexes[index] = addOrGetExisting(pattern, cultureName, candidate);
}
catch (Exception ex)
{
exceptions[index] = ex;
}
});
threads[i].Start();
}

ready.Wait();
start.Set();

foreach (Thread thread in threads)
{
thread.Join();
}

exceptions.Should().BeEquivalentTo(new Exception?[ThreadCount]);
regexes.Should().OnlyContain(regex => ReferenceEquals(regexes[0], regex));
}

public void MatchesRegex_WithInvalidStringPatternAndNullValue_ThrowsPatternExceptionFirst()
{
Action action = () => Assert.MatchesRegex("[", null);

action.Should().Throw<ArgumentException>();
}

public void DoesNotMatchRegex_WithInvalidStringPatternAndNullValue_ThrowsPatternExceptionFirst()
{
Action action = () => Assert.DoesNotMatchRegex("[", null);

action.Should().Throw<ArgumentException>();
}

public void MatchesRegex_WithValidStringPatternAndNullValue_ThrowsAssertFailedException()
{
Action action = () => Assert.MatchesRegex("valid", null);

action.Should().Throw<AssertFailedException>();
}

public void DoesNotMatchRegex_WithValidStringPatternAndNullValue_ThrowsAssertFailedException()
{
Action action = () => Assert.DoesNotMatchRegex("valid", null);

action.Should().Throw<AssertFailedException>();
}

public void MatchesRegex_WithRegexPattern_BypassesStringPatternCache()
{
const string Pattern = "^abc$";
Regex cachedRegex = ToRegex(Pattern);
var suppliedRegex = new Regex(Pattern, RegexOptions.IgnoreCase);

Assert.MatchesRegex(suppliedRegex, "ABC");
ToRegex(Pattern).Should().BeSameAs(cachedRegex);
suppliedRegex.Should().NotBeSameAs(cachedRegex);
}

public void MatchesRegex_WithRegexPattern_OnFailure_UsesStructuredMessageAndPayload()
{
Action action = () => Assert.MatchesRegex(new Regex("^foo"), "hello", "User-provided message");
Expand Down
Loading