Skip to content

Commit 3fc949b

Browse files
authored
Overhaul how regex generated code is structured (#66432)
* Overhaul how regex generated code is structured * Address PR feedback * Change parser to return RegexMethod Clean up how the data is structured. * Add a test for same method name in multiple types
1 parent 458524a commit 3fc949b

6 files changed

Lines changed: 596 additions & 390 deletions

File tree

src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Emitter.cs

Lines changed: 315 additions & 338 deletions
Large diffs are not rendered by default.

src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.Parser.cs

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,10 @@ private static bool IsSemanticTargetForGeneration(SemanticModel semanticModel, M
178178
}
179179

180180
// Parse the input pattern
181-
RegexTree tree;
181+
RegexTree regexTree;
182182
try
183183
{
184-
tree = RegexParser.Parse(pattern, regexOptions | RegexOptions.Compiled, culture); // make sure Compiled is included to get all optimizations applied to it
184+
regexTree = RegexParser.Parse(pattern, regexOptions | RegexOptions.Compiled, culture); // make sure Compiled is included to get all optimizations applied to it
185185
}
186186
catch (Exception e)
187187
{
@@ -192,37 +192,36 @@ private static bool IsSemanticTargetForGeneration(SemanticModel semanticModel, M
192192
string? ns = regexMethodSymbol.ContainingType?.ContainingNamespace?.ToDisplayString(
193193
SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted));
194194

195+
var regexType = new RegexType(
196+
typeDec is RecordDeclarationSyntax rds ? $"{typeDec.Keyword.ValueText} {rds.ClassOrStructKeyword}" : typeDec.Keyword.ValueText,
197+
ns ?? string.Empty,
198+
$"{typeDec.Identifier}{typeDec.TypeParameterList}");
199+
195200
var regexMethod = new RegexMethod(
201+
regexType,
196202
methodSyntax,
197203
regexMethodSymbol.Name,
198204
methodSyntax.Modifiers.ToString(),
199205
pattern,
200206
regexOptions,
201207
matchTimeout ?? Timeout.Infinite,
202-
tree);
203-
204-
var regexType = new RegexType(
205-
regexMethod,
206-
typeDec is RecordDeclarationSyntax rds ? $"{typeDec.Keyword.ValueText} {rds.ClassOrStructKeyword}" : typeDec.Keyword.ValueText,
207-
ns ?? string.Empty,
208-
$"{typeDec.Identifier}{typeDec.TypeParameterList}");
208+
regexTree);
209209

210210
RegexType current = regexType;
211211
var parent = typeDec.Parent as TypeDeclarationSyntax;
212212

213213
while (parent is not null && IsAllowedKind(parent.Kind()))
214214
{
215-
current.ParentClass = new RegexType(
216-
null,
215+
current.Parent = new RegexType(
217216
parent is RecordDeclarationSyntax rds2 ? $"{parent.Keyword.ValueText} {rds2.ClassOrStructKeyword}" : parent.Keyword.ValueText,
218217
ns ?? string.Empty,
219218
$"{parent.Identifier}{parent.TypeParameterList}");
220219

221-
current = current.ParentClass;
220+
current = current.Parent;
222221
parent = parent.Parent as TypeDeclarationSyntax;
223222
}
224223

225-
return regexType;
224+
return regexMethod;
226225

227226
static bool IsAllowedKind(SyntaxKind kind) =>
228227
kind == SyntaxKind.ClassDeclaration ||
@@ -233,12 +232,16 @@ static bool IsAllowedKind(SyntaxKind kind) =>
233232
}
234233

235234
/// <summary>A regex method.</summary>
236-
internal sealed record RegexMethod(MethodDeclarationSyntax MethodSyntax, string MethodName, string Modifiers, string Pattern, RegexOptions Options, int MatchTimeout, RegexTree Tree);
235+
internal sealed record RegexMethod(RegexType DeclaringType, MethodDeclarationSyntax MethodSyntax, string MethodName, string Modifiers, string Pattern, RegexOptions Options, int MatchTimeout, RegexTree Tree)
236+
{
237+
public int GeneratedId { get; set; }
238+
public string GeneratedName => $"{MethodName}_{GeneratedId}";
239+
}
237240

238241
/// <summary>A type holding a regex method.</summary>
239-
internal sealed record RegexType(RegexMethod? Method, string Keyword, string Namespace, string Name)
242+
internal sealed record RegexType(string Keyword, string Namespace, string Name)
240243
{
241-
public RegexType? ParentClass { get; set; }
244+
public RegexType? Parent { get; set; }
242245
}
243246
}
244247
}

src/libraries/System.Text.RegularExpressions/gen/RegexGenerator.cs

Lines changed: 208 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5+
using System.CodeDom.Compiler;
56
using System.Collections.Generic;
67
using System.Collections.Immutable;
78
using System.Diagnostics;
89
using System.Diagnostics.CodeAnalysis;
910
using System.Diagnostics.Tracing;
11+
using System.IO;
1012
using System.Linq;
1113
using System.Runtime.CompilerServices;
1214
using System.Text;
@@ -23,65 +25,239 @@ namespace System.Text.RegularExpressions.Generator
2325
[Generator(LanguageNames.CSharp)]
2426
public partial class RegexGenerator : IIncrementalGenerator
2527
{
26-
public void Initialize(IncrementalGeneratorInitializationContext context)
28+
/// <summary>Name of the type emitted to contain helpers used by the generated code.</summary>
29+
private const string HelpersTypeName = "Utilities";
30+
/// <summary>Namespace containing all the generated code.</summary>
31+
private const string GeneratedNamespace = "System.Text.RegularExpressions.Generated";
32+
/// <summary>Code for a [GeneratedCode] attribute to put on the top-level generated members.</summary>
33+
private static readonly string s_generatedCodeAttribute = $"GeneratedCodeAttribute(\"{typeof(RegexGenerator).Assembly.GetName().Name}\", \"{typeof(RegexGenerator).Assembly.GetName().Version}\")";
34+
/// <summary>Header comments and usings to include at the top of every generated file.</summary>
35+
private static readonly string[] s_headers = new string[]
2736
{
28-
// To avoid invalidating the generator's output when anything from the compilation
29-
// changes, we will extract from it the only thing we care about: whether unsafe
30-
// code is allowed.
31-
IncrementalValueProvider<bool> allowUnsafeProvider =
32-
context.CompilationProvider
33-
.Select((x, _) => x.Options is CSharpCompilationOptions { AllowUnsafe: true });
37+
"// <auto-generated/>",
38+
"#nullable enable",
39+
"#pragma warning disable CS0162 // Unreachable code",
40+
"#pragma warning disable CS0164 // Unreferenced label",
41+
"#pragma warning disable CS0219 // Variable assigned but never used",
42+
};
3443

35-
// Contains one entry per regex method, either the generated code for that regex method,
36-
// a diagnostic to fail with, or null if no action should be taken for that regex.
44+
public void Initialize(IncrementalGeneratorInitializationContext context)
45+
{
46+
// Produces one entry per generated regex. This may be:
47+
// - Diagnostic in the case of a failure that should end the compilation
48+
// - (RegexMethod regexMethod, string runnerFactoryImplementation, Dictionary<string, string[]> requiredHelpers) in the case of valid regex
49+
// - (RegexMethod regexMethod, string reason, Diagnostic diagnostic) in the case of a limited-support regex
3750
IncrementalValueProvider<ImmutableArray<object?>> codeOrDiagnostics =
3851
context.SyntaxProvider
3952

40-
// Find all MethodDeclarationSyntax nodes attributed with RegexGenerator and gather the required information
53+
// Find all MethodDeclarationSyntax nodes attributed with RegexGenerator and gather the required information.
4154
.CreateSyntaxProvider(IsSyntaxTargetForGeneration, GetSemanticTargetForGeneration)
4255
.Where(static m => m is not null)
4356

44-
// Pair each with whether unsafe code is allowed
45-
.Combine(allowUnsafeProvider)
46-
47-
// Get the resulting code string or error Diagnostic for
48-
// each MethodDeclarationSyntax/allow-unsafe-blocks pair
57+
// Generate the RunnerFactory for each regex, if possible. This is where the bulk of the implementation occurs.
4958
.Select((state, _) =>
5059
{
51-
Debug.Assert(state.Left is not null);
52-
return state.Left is RegexType regexType ? EmitRegexType(regexType, state.Right) : state.Left;
60+
if (state is not RegexMethod regexMethod)
61+
{
62+
Debug.Assert(state is Diagnostic);
63+
return state;
64+
}
65+
66+
// If we're unable to generate a full implementation for this regex, report a diagnostic.
67+
// We'll still output a limited implementation that just caches a new Regex(...).
68+
if (!regexMethod.Tree.Root.SupportsCompilation(out string? reason))
69+
{
70+
return (regexMethod, reason, Diagnostic.Create(DiagnosticDescriptors.LimitedSourceGeneration, regexMethod.MethodSyntax.GetLocation()));
71+
}
72+
73+
// Generate the core logic for the regex.
74+
Dictionary<string, string[]> requiredHelpers = new();
75+
var sw = new StringWriter();
76+
var writer = new IndentedTextWriter(sw);
77+
writer.Indent += 3;
78+
writer.WriteLine();
79+
EmitRegexDerivedTypeRunnerFactory(writer, regexMethod, requiredHelpers);
80+
writer.Indent -= 3;
81+
return (regexMethod, sw.ToString(), requiredHelpers);
5382
})
5483
.Collect();
5584

85+
// To avoid invalidating every regex's output when anything from the compilation changes,
86+
// we extract from it the only things we care about: whether unsafe code is allowed,
87+
// and a name based on the assembly's name, and only that information is then fed into
88+
// RegisterSourceOutput along with all of the cached generated data from each regex.
89+
IncrementalValueProvider<(bool AllowUnsafe, string? AssemblyName)> compilationDataProvider =
90+
context.CompilationProvider
91+
.Select((x, _) => (x.Options is CSharpCompilationOptions { AllowUnsafe: true }, x.AssemblyName));
92+
5693
// When there something to output, take all the generated strings and concatenate them to output,
5794
// and raise all of the created diagnostics.
58-
context.RegisterSourceOutput(codeOrDiagnostics, static (context, results) =>
95+
context.RegisterSourceOutput(codeOrDiagnostics.Combine(compilationDataProvider), static (context, compilationDataAndResults) =>
5996
{
60-
var code = new List<string>(s_headers.Length + results.Length);
61-
62-
// Add file header and required usings
63-
code.AddRange(s_headers);
97+
ImmutableArray<object?> results = compilationDataAndResults.Left;
6498

99+
// Report any top-level diagnostics.
100+
bool allFailures = true;
65101
foreach (object? result in results)
66102
{
67-
switch (result)
103+
if (result is Diagnostic d)
68104
{
69-
case Diagnostic d:
70-
context.ReportDiagnostic(d);
71-
break;
105+
context.ReportDiagnostic(d);
106+
}
107+
else
108+
{
109+
allFailures = false;
110+
}
111+
}
112+
if (allFailures)
113+
{
114+
return;
115+
}
116+
117+
// At this point we'll be emitting code. Create a writer to hold it all.
118+
var sw = new StringWriter();
119+
IndentedTextWriter writer = new(sw);
120+
121+
// Add file headers and required usings.
122+
foreach (string header in s_headers)
123+
{
124+
writer.WriteLine(header);
125+
}
126+
writer.WriteLine();
72127

73-
case ValueTuple<string, ImmutableArray<Diagnostic>> t:
74-
code.Add(t.Item1);
75-
foreach (Diagnostic d in t.Item2)
128+
// For every generated type, we give it an incrementally increasing ID, in order to create
129+
// unique type names even in situations where method names were the same, while also keeping
130+
// the type names short. Note that this is why we only generate the RunnerFactory implementations
131+
// earlier in the pipeline... we want to avoid generating code that relies on the class names
132+
// until we're able to iterate through them linearly keeping track of a deterministic ID
133+
// used to name them. The boilerplate code generation that happens here is minimal when compared to
134+
// the work required to generate the actual matching code for the regex.
135+
int id = 0;
136+
string generatedClassName = $"__{ComputeStringHash(compilationDataAndResults.Right.AssemblyName ?? ""):x}";
137+
138+
// If we have any (RegexMethod regexMethod, string generatedName, string reason, Diagnostic diagnostic), these are regexes for which we have
139+
// limited support and need to simply output boilerplate. We need to emit their diagnostics.
140+
// If we have any (RegexMethod regexMethod, string generatedName, string runnerFactoryImplementation, Dictionary<string, string[]> requiredHelpers),
141+
// those are generated implementations to be emitted. We need to gather up their required helpers.
142+
Dictionary<string, string[]> requiredHelpers = new();
143+
foreach (object? result in results)
144+
{
145+
RegexMethod? regexMethod = null;
146+
if (result is ValueTuple<RegexMethod, string, Diagnostic> limitedSupportResult)
147+
{
148+
context.ReportDiagnostic(limitedSupportResult.Item3);
149+
regexMethod = limitedSupportResult.Item1;
150+
}
151+
else if (result is ValueTuple<RegexMethod, string, Dictionary<string, string[]>> regexImpl)
152+
{
153+
foreach (KeyValuePair<string, string[]> helper in regexImpl.Item3)
154+
{
155+
if (!requiredHelpers.ContainsKey(helper.Key))
76156
{
77-
context.ReportDiagnostic(d);
157+
requiredHelpers.Add(helper.Key, helper.Value);
78158
}
79-
break;
159+
}
160+
161+
regexMethod = regexImpl.Item1;
162+
}
163+
164+
if (regexMethod is not null)
165+
{
166+
regexMethod.GeneratedId = id++;
167+
EmitRegexPartialMethod(regexMethod, writer, generatedClassName);
168+
writer.WriteLine();
169+
}
170+
}
171+
172+
// At this point we've emitted all the partial method definitions, but we still need to emit the actual regex-derived implementations.
173+
// These are all emitted inside of our generated class.
174+
// TODO https://github.com/dotnet/csharplang/issues/5529:
175+
// When C# provides a mechanism for shielding generated code from the rest of the project, it should be employed
176+
// here for the generated class. At that point, the generated class wrapper can be removed, and all of the types
177+
// generated inside of it (one for each regex as well as the helpers type) should be shielded.
178+
179+
writer.WriteLine($"namespace {GeneratedNamespace}");
180+
writer.WriteLine($"{{");
181+
182+
// We emit usings here now that we're inside of a namespace block and are no longer emitting code into
183+
// a user's partial type. We can now rely on binding rules mapping to these usings and don't need to
184+
// use global-qualified names for the rest of the implementation.
185+
writer.WriteLine($" using System;");
186+
writer.WriteLine($" using System.CodeDom.Compiler;");
187+
writer.WriteLine($" using System.Collections;");
188+
writer.WriteLine($" using System.ComponentModel;");
189+
writer.WriteLine($" using System.Globalization;");
190+
writer.WriteLine($" using System.Runtime.CompilerServices;");
191+
writer.WriteLine($" using System.Text.RegularExpressions;");
192+
writer.WriteLine($" using System.Threading;");
193+
writer.WriteLine($"");
194+
if (compilationDataAndResults.Right.AllowUnsafe)
195+
{
196+
writer.WriteLine($" [SkipLocalsInit]");
197+
}
198+
writer.WriteLine($" [{s_generatedCodeAttribute}]");
199+
writer.WriteLine($" [EditorBrowsable(EditorBrowsableState.Never)]");
200+
writer.WriteLine($" internal static class {generatedClassName}");
201+
writer.WriteLine($" {{");
202+
203+
// Emit each Regex-derived type.
204+
writer.Indent += 2;
205+
foreach (object? result in results)
206+
{
207+
if (result is ValueTuple<RegexMethod, string, Diagnostic> limitedSupportResult)
208+
{
209+
EmitRegexLimitedBoilerplate(writer, limitedSupportResult.Item1, limitedSupportResult.Item1.GeneratedId, limitedSupportResult.Item2);
210+
writer.WriteLine();
211+
}
212+
else if (result is ValueTuple<RegexMethod, string, Dictionary<string, string[]>> regexImpl)
213+
{
214+
EmitRegexDerivedImplementation(writer, regexImpl.Item1, regexImpl.Item1.GeneratedId, regexImpl.Item2);
215+
writer.WriteLine();
216+
}
217+
}
218+
writer.Indent -= 2;
219+
220+
// If any of the Regex-derived types asked for helper methods, emit those now.
221+
if (requiredHelpers.Count != 0)
222+
{
223+
writer.Indent += 2;
224+
writer.WriteLine($"private static class {HelpersTypeName}");
225+
writer.WriteLine($"{{");
226+
writer.Indent++;
227+
foreach (KeyValuePair<string, string[]> helper in requiredHelpers)
228+
{
229+
foreach (string value in helper.Value)
230+
{
231+
writer.WriteLine(value);
232+
}
233+
writer.WriteLine();
80234
}
235+
writer.Indent--;
236+
writer.WriteLine($"}}");
237+
writer.Indent -= 2;
81238
}
82239

83-
context.AddSource("RegexGenerator.g.cs", string.Join(Environment.NewLine, code));
240+
writer.WriteLine($" }}");
241+
writer.WriteLine($"}}");
242+
243+
// Save out the source
244+
context.AddSource("RegexGenerator.g.cs", sw.ToString());
84245
});
85246
}
247+
248+
/// <summary>Computes a hash of the string.</summary>
249+
/// <remarks>
250+
/// Currently an FNV-1a hash function. The actual algorithm used doesn't matter; just something
251+
/// simple to create a deterministic, pseudo-random value that's based on input text.
252+
/// </remarks>
253+
private static uint ComputeStringHash(string s)
254+
{
255+
uint hashCode = 2166136261;
256+
foreach (char c in s)
257+
{
258+
hashCode = (c ^ hashCode) * 16777619;
259+
}
260+
return hashCode;
261+
}
86262
}
87263
}

src/libraries/System.Text.RegularExpressions/gen/System.Text.RegularExpressions.Generator.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<IsNETCoreAppAnalyzer>true</IsNETCoreAppAnalyzer>
1414
<AnalyzerLanguage>cs</AnalyzerLanguage>
1515
<IsPackable>false</IsPackable>
16+
<LangVersion>Preview</LangVersion>
1617
</PropertyGroup>
1718

1819
<ItemGroup>

0 commit comments

Comments
 (0)