Source generators (генераторы исходного кода) — это часть платформы Roslyn, которая появилась в .NET 5. Они позволяют анализировать существующий код и создавать новые файлы с исходным кодом, которые в свою очередь могут добавляться в процесс компиляции.
В .NET 7 появилась новая функиональность для регулярных выражений, которая позволяет генерировать исходный код для проверки регулярного выражения во время компиляции с помощью специального source generator. Генерация исходного кода во время компиляции, а не во время выполнения, имеет несколько преимуществ:
Ускоряется первый вызов regex — потому что для него не нужно анализировать регулярное выражение и генерировать код для его выполнения в рантайме.
Во время компиляции можно потратить больше времени на оптимизацию кода регулярного выражения, поэтому код максимально оптимизирован. Сейчас (в .NET 7 Preview 3) при использовании regex source generator результирующий код совпадает с тем, который генерируется для регулярных выражений с флагом
RegexOptions.Compiled, но в будущем это поведение может измениться.Для платформ, которые не позволяют генерировать код в рантайме, таких как iOS, можно добиться максимальной производительности регулярных выражений.
Исходный код становится более читаемым в сравнении с использованием
Regex.IsMatch(value, pattern)потому что метод проверки выражения будет иметь осмысленное понятное имя.Сгенерированный код содержит комментарии, которые описывают, чему соответствует регулярное выражение. Это поможет понять и лучше разобраться, что делает regex, даже если вы не знаете какую-то часть синтаксиса регулярных выражений.
В случае self-contained application, когда .net runtime и библиотеки упаковываются в результирующее приложение, упаковка получится более компактной потому что не будет содержать кода для парсинга регулярных выражений и генерации кода для них.
Можно дебажить код при необходимости!
Можно узнать о хороших приемах оптимизации, читая сгенерированный код (но об этом будет в самом конце статьи).
Для генерации кода все параметры регулярного выражения (regex pattern, опции и таймаут) должны быть константными.
public static bool IsLowercase(string value) { // ✔️ pattern задан константой // => Регулярное выражение может быть преобразовано в использование source generator var lowercaseLettersRegex = new Regex("[a-z]+"); return lowercaseLettersRegex.IsMatch("abc"); } public static bool IsLowercase(string value) { // ✔️ pattern, опции и таймаут заданы константой // => Регулярное выражение может быть преобразовано в использование source generator return Regex.IsMatch(value, "[a-z]+", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)); } public static bool Match(string value, string pattern) { // ❌ pattern неизвестен на этапе компиляции и задается параметром // => Невозможно использовать source generator return Regex.IsMatch(value, pattern); }
Чтобы конвертировать регулярное выражение в применение source generator вам нужно создать вместо него partial-метод, помеченный атрибутом [RegexGenerator]. Тип, в котором используется регулярное выражение тоже нужно будет пометить как partial:
// Source Generator сгенерирует код метода во время компиляции [RegexGenerator("^[a-z]+$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] private static partial Regex LowercaseLettersRegex(); public static bool IsLowercase(string value) { return LowercaseLettersRegex().IsMatch(value); }
Сгенерированный код можно посмотреть в partial-классе через Solution explorer или перейти к нему командой "Go to definition":


Автоматическое преобразование Regex в Source Generator
NuGet-пакет Meziantou.Analyzer содержит анализатор для поиска регулярных выражений, которые могут быть преобразованы в Source Generator, и позволяет легко конвертировать существующие Regex в partail-метод с аннотацией [RegexGenerator]. Достаточно добавить пакет в проект:
dotnet add package Meziantou.Analyzer
Правило MA0110 сообщит о всех регулярных выражениях, для которых можно сгенерировать код на этапе компиляции. Анализатор предоставляет действие для преобразования кода (code fix) из Regex в генератор.

Статус поддержки
Использовать regex source generator и Meziantou.Analyzer можно с .NET 7 (начиная с Preview 1) и C# 11.
Rider частично поддерживает C# 11 с версии 2022.1 EAP — код при компиляции генерируется, к нему можно перейти через Go to definition, но сгенерированный файл не отображается в дереве решений.
Visual Studio 17.2 Preview 1 и более поздние версии поддерживают .NET 7 и C# 11.
Ложка дёгтя — пример сгенерированного кода
По описанию regex source generator из исходной статьи — это отличная фича не только для производительности и уменьшения размера self-contained application, но и для упрощения чтения и дебага сложных регулярных выражений. На сколько же лаконичным получается сгенерированный код? Давайте посмотрим на примере поиска номера телефона в строке:
[RegexGenerator(@"(\+7|7|8)?[\s\-]?\(?[489][0-9]{2}\)?[\s\-]?[0-9]{3}[\s\-]?[0-9]{2}[\s\-]?[0-9]{2}"] private partial Regex RussianPhoneNumberRegex(); public string? FindPhoneNumber(string text) { var match = RussianPhoneNumberRegex().Match(text); return match.Success ? match.Value : null; }
В результате генерируется файл для проверки регулярного выражения, который можно попытаться изучить самостоятельно и оценить читаемость и внутреннее устройство кода:
362 строки сгенерированного кода регулярного выражения
// <auto-generated/> #nullable enable #pragma warning disable CS0162 // Unreachable code #pragma warning disable CS0164 // Unreferenced label #pragma warning disable CS0219 // Variable assigned but never used namespace RegexGeneratorExample { partial class RegexContainer { [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Text.RegularExpressions.Generator", "7.0.6.17504")] private partial global::System.Text.RegularExpressions.Regex RussianPhoneNumberRegex() => global::System.Text.RegularExpressions.Generated.__2b701bf8.RussianPhoneNumberRegex_0.Instance; } } namespace System.Text.RegularExpressions.Generated { using System; using System.CodeDom.Compiler; using System.Collections; using System.ComponentModel; using System.Globalization; using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using System.Threading; [GeneratedCodeAttribute("System.Text.RegularExpressions.Generator", "7.0.6.17504")] [EditorBrowsable(EditorBrowsableState.Never)] internal static class __2b701bf8 { /// <summary>Custom <see cref="Regex"/>-derived type for the RussianPhoneNumberRegex method.</summary> internal sealed class RussianPhoneNumberRegex_0 : Regex { /// <summary>Cached, thread-safe singleton instance.</summary> internal static readonly RussianPhoneNumberRegex_0 Instance = new(); /// <summary>Initializes the instance.</summary> private RussianPhoneNumberRegex_0() { base.pattern = "(\\+7|7|8)?[\\s\\-]?\\(?[489][0-9]{2}\\)?[\\s\\-]?[0-9]{3}[\\s\\-]?[0-9]{2}[\\s\\-]?[0-9]{2}"; base.roptions = RegexOptions.CultureInvariant; base.internalMatchTimeout = TimeSpan.FromMilliseconds(1000); base.factory = new RunnerFactory(); base.capsize = 2; } /// <summary>Provides a factory for creating <see cref="RegexRunner"/> instances to be used by methods on <see cref="Regex"/>.</summary> private sealed class RunnerFactory : RegexRunnerFactory { /// <summary>Creates an instance of a <see cref="RegexRunner"/> used by methods on <see cref="Regex"/>.</summary> protected override RegexRunner CreateInstance() => new Runner(); /// <summary>Provides the runner that contains the custom logic implementing the specified regular expression.</summary> private sealed class Runner : RegexRunner { // Description: // ○ Optional (greedy). // ○ 1st capture group. // ○ Match with 2 alternative expressions. // ○ Match the string "+7". // ○ Match a character in the set [78]. // ○ Match a character in the set [-\s] atomically, optionally. // ○ Match '(' atomically, optionally. // ○ Match a character in the set [489]. // ○ Match '0' through '9' exactly 2 times. // ○ Match ')' atomically, optionally. // ○ Match a character in the set [-\s] atomically, optionally. // ○ Match '0' through '9' exactly 3 times. // ○ Match a character in the set [-\s] atomically, optionally. // ○ Match '0' through '9' exactly 2 times. // ○ Match a character in the set [-\s] atomically, optionally. // ○ Match '0' through '9' exactly 2 times. /// <summary>Scan the <paramref name="inputSpan"/> starting from base.runtextstart for the next match.</summary> /// <param name="inputSpan">The text being scanned by the regular expression.</param> protected override void Scan(ReadOnlySpan<char> inputSpan) { // Search until we can't find a valid starting position, we find a match, or we reach the end of the input. while (TryFindNextPossibleStartingPosition(inputSpan)) { base.CheckTimeout(); if (TryMatchAtCurrentPosition(inputSpan) || base.runtextpos == inputSpan.Length) { return; } base.runtextpos++; } } /// <summary>Search <paramref name="inputSpan"/> starting from base.runtextpos for the next location a match could possibly start.</summary> /// <param name="inputSpan">The text being scanned by the regular expression.</param> /// <returns>true if a possible match was found; false if no more matches are possible.</returns> private bool TryFindNextPossibleStartingPosition(ReadOnlySpan<char> inputSpan) { int pos = base.runtextpos; char ch; // Validate that enough room remains in the input to match. // Any possible match is at least 10 characters. if (pos <= inputSpan.Length - 10) { // The pattern begins with a character in the set [(+-47-9\s]. // Find the next occurrence. If it can't be found, there's no match. ReadOnlySpan<char> span = inputSpan.Slice(pos); for (int i = 0; i < span.Length; i++) { if (((ch = span[i]) < 128 ? ("㸀\0⤁ΐ\0\0\0\0"[ch >> 4] & (1 << (ch & 0xF))) != 0 : RegexRunner.CharInClass((char)ch, "\0\n\u0001()+,-.457:d"))) { base.runtextpos = pos + i; return true; } } } // No match found. base.runtextpos = inputSpan.Length; return false; } /// <summary>Determine whether <paramref name="inputSpan"/> at base.runtextpos is a match for the regular expression.</summary> /// <param name="inputSpan">The text being scanned by the regular expression.</param> /// <returns>true if the regular expression matches at the current position; otherwise, false.</returns> private bool TryMatchAtCurrentPosition(ReadOnlySpan<char> inputSpan) { int pos = base.runtextpos; int matchStart = pos; int loopTimeoutCounter = 0; char ch; int loop_iteration = 0, loop_starting_pos = 0; int stackpos = 0; ReadOnlySpan<char> slice = inputSpan.Slice(pos); // Optional (greedy). //{ loop_iteration = 0; loop_starting_pos = pos; LoopBody: if (++loopTimeoutCounter == 2048) { loopTimeoutCounter = 0; base.CheckTimeout(); } Utilities.StackPush3(ref base.runstack!, ref stackpos, base.Crawlpos(), loop_starting_pos, pos); loop_starting_pos = pos; loop_iteration++; // 1st capture group. //{ int capture_starting_pos = pos; // Match with 2 alternative expressions. //{ if (slice.IsEmpty) { goto LoopIterationNoMatch; } switch (slice[0]) { case '+': // Match '7'. if ((uint)slice.Length < 2 || slice[1] != '7') { goto LoopIterationNoMatch; } pos += 2; slice = inputSpan.Slice(pos); break; case '7' or '8': pos++; slice = inputSpan.Slice(pos); break; default: goto LoopIterationNoMatch; } //} base.Capture(1, capture_starting_pos, pos); //} if (pos != loop_starting_pos && loop_iteration == 0) { goto LoopBody; } goto LoopEnd; LoopIterationNoMatch: loop_iteration--; if (loop_iteration < 0) { UncaptureUntil(0); return false; // The input didn't match. } Utilities.StackPop2(base.runstack, ref stackpos, out pos, out loop_starting_pos); UncaptureUntil(base.runstack![--stackpos]); slice = inputSpan.Slice(pos); LoopEnd:; //} // Match a character in the set [-\s] atomically, optionally. { if (!slice.IsEmpty && ((ch = slice[0]) < 128 ? ("㸀\0 \0\0\0\0\0"[ch >> 4] & (1 << (ch & 0xF))) != 0 : RegexRunner.CharInClass((char)ch, "\0\u0002\u0001-.d"))) { slice = slice.Slice(1); pos++; } } // Match '(' atomically, optionally. { if (!slice.IsEmpty && slice[0] == '(') { slice = slice.Slice(1); pos++; } } if ((uint)slice.Length < 3 || (((ch = slice[0]) != '4') & (ch != '8') & (ch != '9')) || // Match a character in the set [489]. (((uint)slice[1]) - '0' > (uint)('9' - '0')) || // Match '0' through '9' exactly 2 times. (((uint)slice[2]) - '0' > (uint)('9' - '0'))) { goto LoopIterationNoMatch; } // Match ')' atomically, optionally. { if ((uint)slice.Length > (uint)3 && slice[3] == ')') { slice = slice.Slice(1); pos++; } } // Match a character in the set [-\s] atomically, optionally. { if ((uint)slice.Length > (uint)3 && ((ch = slice[3]) < 128 ? ("㸀\0 \0\0\0\0\0"[ch >> 4] & (1 << (ch & 0xF))) != 0 : RegexRunner.CharInClass((char)ch, "\0\u0002\u0001-.d"))) { slice = slice.Slice(1); pos++; } } // Match '0' through '9' exactly 3 times. { if ((uint)slice.Length < 6 || (((uint)slice[3]) - '0' > (uint)('9' - '0')) || (((uint)slice[4]) - '0' > (uint)('9' - '0')) || (((uint)slice[5]) - '0' > (uint)('9' - '0'))) { goto LoopIterationNoMatch; } } // Match a character in the set [-\s] atomically, optionally. { if ((uint)slice.Length > (uint)6 && ((ch = slice[6]) < 128 ? ("㸀\0 \0\0\0\0\0"[ch >> 4] & (1 << (ch & 0xF))) != 0 : RegexRunner.CharInClass((char)ch, "\0\u0002\u0001-.d"))) { slice = slice.Slice(1); pos++; } } // Match '0' through '9' exactly 2 times. { if ((uint)slice.Length < 8 || (((uint)slice[6]) - '0' > (uint)('9' - '0')) || (((uint)slice[7]) - '0' > (uint)('9' - '0'))) { goto LoopIterationNoMatch; } } // Match a character in the set [-\s] atomically, optionally. { if ((uint)slice.Length > (uint)8 && ((ch = slice[8]) < 128 ? ("㸀\0 \0\0\0\0\0"[ch >> 4] & (1 << (ch & 0xF))) != 0 : RegexRunner.CharInClass((char)ch, "\0\u0002\u0001-.d"))) { slice = slice.Slice(1); pos++; } } // Match '0' through '9' exactly 2 times. { if ((uint)slice.Length < 10 || (((uint)slice[8]) - '0' > (uint)('9' - '0')) || (((uint)slice[9]) - '0' > (uint)('9' - '0'))) { goto LoopIterationNoMatch; } } // The input matched. pos += 10; base.runtextpos = pos; base.Capture(0, matchStart, pos); return true; // <summary>Undo captures until it reaches the specified capture position.</summary> [MethodImpl(MethodImplOptions.AggressiveInlining)] void UncaptureUntil(int capturePosition) { while (base.Crawlpos() > capturePosition) { base.Uncapture(); } } } } } } private static class Utilities { // <summary>Pushes 3 values onto the backtracking stack.</summary> [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static void StackPush3(ref int[] stack, ref int pos, int arg0, int arg1, int arg2) { // If there's space available for all 3 values, store them. int[] s = stack; int p = pos; if ((uint)(p + 2) < (uint)s.Length) { s[p] = arg0; s[p + 1] = arg1; s[p + 2] = arg2; pos += 3; return; } // Otherwise, resize the stack to make room and try again. WithResize(ref stack, ref pos, arg0, arg1, arg2); // <summary>Resize the backtracking stack array and push 3 values onto the stack.</summary> [MethodImpl(MethodImplOptions.NoInlining)] static void WithResize(ref int[] stack, ref int pos, int arg0, int arg1, int arg2) { Array.Resize(ref stack, (pos + 2) * 2); StackPush3(ref stack, ref pos, arg0, arg1, arg2); } } // <summary>Pops 2 values from the backtracking stack.</summary> [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static void StackPop2(int[] stack, ref int pos, out int arg0, out int arg1) { arg0 = stack[--pos]; arg1 = stack[--pos]; } } } }
