Как стать автором
Обновить

Roslyn-анализатор для преобразования кода из Regex в новый Regex Source Generator

Время на прочтение10 мин
Количество просмотров3.3K
Автор оригинала: Gérald Barré

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];
            }
            
        }
    }
}

Теги:
Хабы:
Всего голосов 16: ↑16 и ↓0+16
Комментарии0

Публикации

Истории

Работа

Ближайшие события