Skip to content

Commit 34ec670

Browse files
authored
Merge pull request #1547 from microsoft/dev/andarno/fixregextime
Remove slow regex from threading analyzers
2 parents 4332894 + c4644e8 commit 34ec670

7 files changed

Lines changed: 490 additions & 40 deletions

File tree

.github/copilot-instructions.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
* Design APIs to be highly testable, and all functionality should be tested.
1111
* Avoid introducing binary breaking changes in public APIs of projects under `src` unless their project files have `IsPackable` set to `false`.
12+
* `InternalsVisibleTo` attributes are *not allowed*.
1213

1314
## Testing
1415

@@ -17,6 +18,8 @@
1718
* There should generally be one test project (under the `test` directory) per shipping project (under the `src` directory). Test projects are named after the project being tested with a `.Tests` suffix.
1819
* Tests use xunit v3 with Microsoft.Testing.Platform (MTP v2). Traditional VSTest `--filter` syntax does NOT work.
1920
* Some tests are known to be unstable. When running tests, you should skip the unstable ones by using `-- --filter-not-trait "FailsInCloudTest=true"`.
21+
* Since `InternalsVisibleTo` is not allowed, testing must be done at the public API level.
22+
In rare cases where there are static utility methods that need to be thoroughly tested, which may be impossible or inefficient to do via public APIs, the static methods may be moved to a .cs file that is then linked both into the product and into the test project so that it may be tested directly.
2023

2124
### Running Tests
2225

.vscode/mcp.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"servers": {
3+
"github": {
4+
"url": "https://api.githubcopilot.com/mcp/"
5+
}
6+
},
7+
"inputs": []
8+
}

src/Microsoft.VisualStudio.Threading.Analyzers/CommonInterest.cs

Lines changed: 37 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,6 @@ public static class CommonInterest
6969

7070
private const string GetAwaiterMethodName = "GetAwaiter";
7171

72-
private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // Prevent expensive CPU hang in Regex.Match if backtracking occurs due to pathological input (see #485).
73-
74-
private static readonly Regex NegatableTypeOrMemberReferenceRegex = new Regex(@"^(?<negated>!)?\[(?<typeName>[^\[\]\:]+)+\](?:\:\:(?<memberName>\S+))?\s*$", RegexOptions.Singleline | RegexOptions.CultureInvariant, RegexMatchTimeout);
75-
76-
private static readonly Regex MemberReferenceRegex = new Regex(@"^\[(?<typeName>[^\[\]\:]+)+\]::(?<memberName>\S+)\s*$", RegexOptions.Singleline | RegexOptions.CultureInvariant, RegexMatchTimeout);
77-
78-
/// <summary>
79-
/// An array with '.' as its only element.
80-
/// </summary>
81-
private static readonly char[] QualifiedIdentifierSeparators = ['.'];
82-
8372
public static IEnumerable<QualifiedMember> ReadMethods(AnalyzerOptions analyzerOptions, Regex fileNamePattern, CancellationToken cancellationToken)
8473
{
8574
foreach (string line in ReadAdditionalFiles(analyzerOptions, fileNamePattern, cancellationToken))
@@ -92,28 +81,15 @@ public static IEnumerable<TypeMatchSpec> ReadTypesAndMembers(AnalyzerOptions ana
9281
{
9382
foreach (string line in ReadAdditionalFiles(analyzerOptions, fileNamePattern, cancellationToken))
9483
{
95-
Match? match = null;
96-
try
97-
{
98-
match = NegatableTypeOrMemberReferenceRegex.Match(line);
99-
}
100-
catch (RegexMatchTimeoutException)
101-
{
102-
throw new InvalidOperationException($"Regex.Match timeout when parsing line: {line}");
103-
}
104-
105-
if (!match.Success)
84+
if (!CommonInterestParsing.TryParseNegatableTypeOrMemberReference(line, out bool negated, out ReadOnlyMemory<char> typeNameMemory, out string? memberNameValue))
10685
{
10786
throw new InvalidOperationException($"Parsing error on line: {line}");
10887
}
10988

110-
bool inverted = match.Groups["negated"].Success;
111-
string[] typeNameElements = match.Groups["typeName"].Value.Split(QualifiedIdentifierSeparators);
112-
string typeName = typeNameElements[typeNameElements.Length - 1];
113-
var containingNamespace = typeNameElements.Take(typeNameElements.Length - 1).ToImmutableArray();
89+
(ImmutableArray<string> containingNamespace, string? typeName) = SplitQualifiedIdentifier(typeNameMemory);
11490
var type = new QualifiedType(containingNamespace, typeName);
115-
QualifiedMember member = match.Groups["memberName"].Success ? new QualifiedMember(type, match.Groups["memberName"].Value) : default(QualifiedMember);
116-
yield return new TypeMatchSpec(type, member, inverted);
91+
QualifiedMember member = memberNameValue is not null ? new QualifiedMember(type, memberNameValue) : default(QualifiedMember);
92+
yield return new TypeMatchSpec(type, member, negated);
11793
}
11894
}
11995

@@ -345,26 +321,47 @@ public static IEnumerable<string> ReadLinesFromAdditionalFile(SourceText text)
345321

346322
public static QualifiedMember ParseAdditionalFileMethodLine(string line)
347323
{
348-
Match? match = null;
349-
try
324+
if (!CommonInterestParsing.TryParseMemberReference(line, out ReadOnlyMemory<char> typeNameMemory, out string? memberName))
350325
{
351-
match = MemberReferenceRegex.Match(line);
326+
throw new InvalidOperationException($"Parsing error on line: {line}");
352327
}
353-
catch (RegexMatchTimeoutException)
328+
329+
(ImmutableArray<string> containingNamespace, string? typeName) = SplitQualifiedIdentifier(typeNameMemory);
330+
var containingType = new QualifiedType(containingNamespace, typeName);
331+
return new QualifiedMember(containingType, memberName!);
332+
}
333+
334+
/// <summary>
335+
/// Splits a qualified type name (e.g. <c>My.Namespace.MyType</c>) into its containing namespace
336+
/// segments and the simple type name, without allocating an intermediate joined string.
337+
/// </summary>
338+
/// <param name="qualifiedName">The qualified type name as a memory slice.</param>
339+
/// <returns>The namespace segments and the simple type name.</returns>
340+
private static (ImmutableArray<string> ContainingNamespace, string TypeName) SplitQualifiedIdentifier(ReadOnlyMemory<char> qualifiedName)
341+
{
342+
int lastDot = qualifiedName.Span.LastIndexOf('.');
343+
if (lastDot < 0)
354344
{
355-
throw new InvalidOperationException($"Regex.Match timeout when parsing line: {line}");
345+
return (ImmutableArray<string>.Empty, qualifiedName.ToString());
356346
}
357347

358-
if (!match.Success)
348+
string typeName = qualifiedName.Slice(lastDot + 1).ToString();
349+
ReadOnlyMemory<char> nsPart = qualifiedName.Slice(0, lastDot);
350+
ImmutableArray<string>.Builder nsBuilder = ImmutableArray.CreateBuilder<string>();
351+
while (!nsPart.IsEmpty)
359352
{
360-
throw new InvalidOperationException($"Parsing error on line: {line}");
353+
int dot = nsPart.Span.IndexOf('.');
354+
if (dot < 0)
355+
{
356+
nsBuilder.Add(nsPart.ToString());
357+
break;
358+
}
359+
360+
nsBuilder.Add(nsPart.Slice(0, dot).ToString());
361+
nsPart = nsPart.Slice(dot + 1);
361362
}
362363

363-
string methodName = match.Groups["memberName"].Value;
364-
string[] typeNameElements = match.Groups["typeName"].Value.Split(QualifiedIdentifierSeparators);
365-
string typeName = typeNameElements[typeNameElements.Length - 1];
366-
var containingType = new QualifiedType(typeNameElements.Take(typeNameElements.Length - 1).ToImmutableArray(), typeName);
367-
return new QualifiedMember(containingType, methodName);
364+
return (nsBuilder.ToImmutable(), typeName);
368365
}
369366

370367
private static bool TestGetAwaiterMethod(IMethodSymbol getAwaiterMethod)
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using System;
5+
6+
namespace Microsoft.VisualStudio.Threading.Analyzers;
7+
8+
/// <summary>
9+
/// Parsing helpers for additional-file lines used by <see cref="CommonInterest" />.
10+
/// This class is intentionally kept free of Roslyn dependencies so it can be
11+
/// linked directly into test projects for unit testing.
12+
/// </summary>
13+
internal static class CommonInterestParsing
14+
{
15+
/// <summary>
16+
/// Parses a line that may begin with an optional <c>!</c>, followed by <c>[TypeName]</c>,
17+
/// and optionally <c>::MemberName</c>, without using regular expressions.
18+
/// </summary>
19+
/// <param name="line">The line to parse.</param>
20+
/// <param name="negated"><see langword="true" /> if the line begins with '!'.</param>
21+
/// <param name="typeName">The type name parsed from the brackets.</param>
22+
/// <param name="memberName">The member name after '::', or <see langword="null" /> if not present.</param>
23+
/// <returns><see langword="true" /> if parsing succeeded.</returns>
24+
internal static bool TryParseNegatableTypeOrMemberReference(string line, out bool negated, out ReadOnlyMemory<char> typeName, out string? memberName)
25+
{
26+
negated = false;
27+
typeName = default;
28+
memberName = null;
29+
30+
ReadOnlySpan<char> span = line.AsSpan();
31+
int pos = 0;
32+
33+
// Optional negation prefix.
34+
if (pos < span.Length && span[pos] == '!')
35+
{
36+
negated = true;
37+
pos++;
38+
}
39+
40+
// Required '[TypeName]'.
41+
int bracketStart = pos;
42+
if (!TryParseBracketedTypeName(span, ref pos, out _))
43+
{
44+
return false;
45+
}
46+
47+
// Compute memory slice for type name (between the brackets), using recorded bracket position.
48+
ReadOnlyMemory<char> typeNameMemory = line.AsMemory(bracketStart + 1, pos - bracketStart - 2);
49+
50+
// Optional '::memberName'.
51+
ReadOnlySpan<char> memberNameSpan = default;
52+
if (pos + 1 < span.Length && span[pos] == ':' && span[pos + 1] == ':')
53+
{
54+
pos += 2;
55+
int memberNameStart = pos;
56+
while (pos < span.Length && !char.IsWhiteSpace(span[pos]))
57+
{
58+
pos++;
59+
}
60+
61+
if (pos == memberNameStart)
62+
{
63+
// '::' present but no member name follows.
64+
return false;
65+
}
66+
67+
memberNameSpan = span.Slice(memberNameStart, pos - memberNameStart);
68+
}
69+
70+
// Allow only trailing whitespace.
71+
while (pos < span.Length && char.IsWhiteSpace(span[pos]))
72+
{
73+
pos++;
74+
}
75+
76+
if (pos != span.Length)
77+
{
78+
return false;
79+
}
80+
81+
// Only allocate strings after full validation.
82+
typeName = typeNameMemory;
83+
memberName = memberNameSpan.IsEmpty ? null : memberNameSpan.ToString();
84+
return true;
85+
}
86+
87+
/// <summary>
88+
/// Parses a line of the form <c>[TypeName]::MemberName</c>, without using regular expressions.
89+
/// </summary>
90+
/// <param name="line">The line to parse.</param>
91+
/// <param name="typeName">The type name parsed from the brackets.</param>
92+
/// <param name="memberName">The member name after '::'.</param>
93+
/// <returns><see langword="true" /> if parsing succeeded.</returns>
94+
internal static bool TryParseMemberReference(string line, out ReadOnlyMemory<char> typeName, out string? memberName)
95+
{
96+
typeName = default;
97+
memberName = null;
98+
99+
ReadOnlySpan<char> span = line.AsSpan();
100+
int pos = 0;
101+
102+
// Required '[TypeName]'.
103+
int bracketStart = pos;
104+
if (!TryParseBracketedTypeName(span, ref pos, out _))
105+
{
106+
return false;
107+
}
108+
109+
// Compute memory slice for type name (between the brackets), using recorded bracket position.
110+
ReadOnlyMemory<char> typeNameMemory = line.AsMemory(bracketStart + 1, pos - bracketStart - 2);
111+
112+
// Required '::'.
113+
if (pos + 1 >= span.Length || span[pos] != ':' || span[pos + 1] != ':')
114+
{
115+
return false;
116+
}
117+
118+
pos += 2;
119+
120+
// Member name: one or more non-whitespace chars.
121+
int memberNameStart = pos;
122+
while (pos < span.Length && !char.IsWhiteSpace(span[pos]))
123+
{
124+
pos++;
125+
}
126+
127+
if (pos == memberNameStart)
128+
{
129+
return false;
130+
}
131+
132+
ReadOnlySpan<char> memberNameSpan = span.Slice(memberNameStart, pos - memberNameStart);
133+
134+
// Allow only trailing whitespace.
135+
while (pos < span.Length && char.IsWhiteSpace(span[pos]))
136+
{
137+
pos++;
138+
}
139+
140+
if (pos != span.Length)
141+
{
142+
return false;
143+
}
144+
145+
// Only allocate strings after full validation.
146+
typeName = typeNameMemory;
147+
memberName = memberNameSpan.ToString();
148+
return true;
149+
}
150+
151+
/// <summary>
152+
/// Advances <paramref name="pos" /> past a <c>[TypeName]</c> token and outputs the type-name span.
153+
/// </summary>
154+
/// <param name="span">The full input span.</param>
155+
/// <param name="pos">The current parse position; advanced past the closing <c>]</c> on success.</param>
156+
/// <param name="typeName">A slice of <paramref name="span" /> containing the type name, without the brackets.</param>
157+
/// <returns><see langword="true" /> if a non-empty bracketed type name was consumed.</returns>
158+
internal static bool TryParseBracketedTypeName(ReadOnlySpan<char> span, ref int pos, out ReadOnlySpan<char> typeName)
159+
{
160+
typeName = default;
161+
162+
// Required opening bracket.
163+
if (pos >= span.Length || span[pos] != '[')
164+
{
165+
return false;
166+
}
167+
168+
pos++;
169+
170+
// Type name: one or more chars that are not '[', ']', or ':'.
171+
int typeNameStart = pos;
172+
while (pos < span.Length && span[pos] != '[' && span[pos] != ']' && span[pos] != ':')
173+
{
174+
pos++;
175+
}
176+
177+
if (pos == typeNameStart)
178+
{
179+
return false;
180+
}
181+
182+
typeName = span.Slice(typeNameStart, pos - typeNameStart);
183+
184+
// Required closing bracket.
185+
if (pos >= span.Length || span[pos] != ']')
186+
{
187+
return false;
188+
}
189+
190+
pos++;
191+
return true;
192+
}
193+
}

0 commit comments

Comments
 (0)