Skip to main content

Source Generator and Analyzers

The Masterly.FluentRegex package ships three compiler add-ons: a source generator, a set of analyzers and a code fix. They run inside the C# compiler while you type and while you build. They evaluate your fluent chains by running the library's own builder code, so what they report is exactly what the chain would do at run time.

Use them when you want to:

  • build a regex once, at compile time, instead of running the fluent chain at startup,
  • fail the build when a constant pattern is invalid, throws while it is built, or can hang on hostile input,
  • catch a typo between a named group and the record you bind it to with Extract<T>,
  • move a constant pattern to .NET's own regex source generator ([GeneratedRegex]).

There is nothing extra to install. The add-ons are packed into Masterly.FluentRegex under analyzers/dotnet/cs, and every project that references the package gets them.

Add-onWhat it doesYou see it as
Source generatorEvaluates a [GeneratePattern] member and emits a constant with the regex text plus a method that returns a cached, compiled Regex. Reports FRX001, FRX002 and FRX009 when it cannot.New members on your partial type.
AnalyzersEvaluate every chain built only from constants, anywhere in your code, and report problems as FRX003 to FRX008.Build errors, warnings and IDE messages.
Code fixAdds a [GeneratedRegex] partial method next to a constant pattern (rule FRX007).A light-bulb action in the IDE, or dotnet format.
note

The add-ons are C# only. They are built against Roslyn 4.8, the compiler in the .NET 8 SDK and Visual Studio 2022 17.8, so use that SDK or a newer one. They work for every target framework of the library: netstandard2.1, net8.0 and net10.0. Only the [GeneratedRegex] suggestion needs a target that has GeneratedRegexAttribute (.NET 7 or later). See Target frameworks.

Generating a regex at build time​

Put [GeneratePattern] on a static property or field of type Pattern in a partial type. At build time the generator evaluates the chain and adds two members to the type: a constant that holds the regex text and a method that returns a ready-made Regex.

using System;
using System.Text.RegularExpressions;
using Masterly.FluentRegex;

namespace MyApp.Validation;

public static partial class Validators
{
[GeneratePattern]
public static Pattern ProductCode => Pattern.With.StartOfLine
.UppercaseLetter.Repeat.Times(3)
.Literal("-")
.Digit.Repeat.Times(4)
.EndOfLine;
}

// Elsewhere in the project
Console.WriteLine(Validators.ProductCodeSource); // ^[A-Z]{3}-\d{4}$
Console.WriteLine(Validators.ProductCodeRegex().IsMatch("ABC-1234")); // True
Console.WriteLine(Validators.ProductCodeRegex().IsMatch("abc-1234")); // False

Regex first = Validators.ProductCodeRegex();
Console.WriteLine(ReferenceEquals(first, Validators.ProductCodeRegex())); // True
Console.WriteLine(first.Options); // Compiled
Console.WriteLine(first.MatchTimeout == Regex.InfiniteMatchTimeout); // True
  • ProductCodeSource is a public const string. You can use it anywhere a constant is required, such as an attribute argument.
  • ProductCodeRegex() creates the Regex on the first call and returns the same instance after that.
  • The generated Regex always has RegexOptions.Compiled, added to whatever options the pattern carries.
  • Use an expression-bodied property (=>), as above. The property is then never called by the generated code, so the fluent chain does not run at run time at all.

What the generator emits​

With <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> in the project file, the generated code is written under obj/<Configuration>/<TargetFramework>/generated/Masterly.FluentRegex.Analyzers/Masterly.FluentRegex.Analyzers.PatternGenerator/, one file per member, named <Namespace>.<Type>.<Member>.g.cs. This is MyApp.Validation.Validators.ProductCode.g.cs for ProductCode above, as the generator wrote it:

// <auto-generated/>
#nullable enable

namespace MyApp.Validation
{
static partial class Validators
{
/// <summary>
/// The regular expression built by <see cref="ProductCode"/>, evaluated at compile time.
/// </summary>
public const string ProductCodeSource = @"^[A-Z]{3}-\d{4}$";

private static global::System.Text.RegularExpressions.Regex? __fluentRegex_ProductCode;

/// <summary>
/// A cached, compiled regex for <see cref="ProductCode"/>:
/// <c>^[A-Z]{3}-\d{4}$</c>
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCode("Masterly.FluentRegex.Analyzers", "3.2.0")]
public static global::System.Text.RegularExpressions.Regex ProductCodeRegex() =>
__fluentRegex_ProductCode ??= new global::System.Text.RegularExpressions.Regex(ProductCodeSource, (global::System.Text.RegularExpressions.RegexOptions)8, global::System.Text.RegularExpressions.Regex.InfiniteMatchTimeout);
}
}
Generated memberNameAccessibilityNotes
Regex text<Member>SourceAlways public const stringWritten as a verbatim string. Text with control characters, such as a tab, is written as a regular escaped string instead.
Cache field__fluentRegex_<Member>private staticHolds the Regex after the first call.
Regex method<Member>Regex(), or the name you pass to the attributeCopied from the member: public, internal, protected, protected internal, private protected or privateUses the pattern's options plus Compiled, and the pattern's timeout (Regex.InfiniteMatchTimeout when it has none).
  • The generator re-creates the containing types with the same kind and type parameters: class, static class, struct, record, record struct, interface, and generic types such as Inner<T>. Nested types work too.
  • The cache uses ??= without a lock. Two threads that call the method for the very first time at the same moment may each build a Regex; one of them is kept. Both are identical, so this is harmless.
  • The generator is incremental. Editing a comment or an unrelated member does not regenerate the file. Changing the chain does.
  • The generator does not check the regex text. A chain that builds but produces an invalid regex, such as Pattern.With.RegEx("[a-z"), is still generated, and the method would throw ArgumentException on its first call. The analyzer reports that chain as the error FRX003, so the build fails unless you lowered that rule.
  • The [GeneratedCode] attribute carries the version of the package that generated the file.

Naming the generated method​

Pass a name to the attribute to choose the method name. The constant keeps the member's name.

using Masterly.FluentRegex;

public static partial class Patterns
{
private const string Separator = "-";

private static Pattern Year => Pattern.With.Digit.Repeat.Times(4);

private static Pattern TwoDigits() => Pattern.With.Digit.Repeat.Times(2);

[GeneratePattern("IsoDate")]
internal static Pattern Date => Pattern.With.StartOfLine
.Then(Year).Literal(Separator).Then(TwoDigits()).Literal(Separator).Then(TwoDigits())
.EndOfLine;
}

Console.WriteLine(Patterns.DateSource); // ^\d{4}-\d{2}-\d{2}$
Console.WriteLine(Patterns.IsoDate().IsMatch("2026-11-03")); // True
ConstructorGenerated method
[GeneratePattern]<Member>Regex(), for example DateRegex()
[GeneratePattern("IsoDate")]IsoDate()
  • The attribute's MethodName property returns the name you passed, or null for the default.
  • An empty name, [GeneratePattern("")], also gives the default <Member>Regex().
  • Date is internal, so IsoDate() is internal too. DateSource is still public.
  • The name is not checked against your own members. If the type already has a CodeRegex() method next to a [GeneratePattern] Code member, the build fails with CS0111: Type '...' already defines a member called 'CodeRegex' with the same parameter types. Pick another name with the attribute.

Rules for the member​

RuleWhen it is broken
The member is a field or a property.[GeneratePattern] on a method does not compile: CS0592: Attribute 'GeneratePattern' is not valid on this declaration type. It is only valid on 'property, indexer, field' declarations.
The member is static.Error FRX009.
The containing type, and every type it is nested in, is partial.Error FRX002.
The member has a single expression to evaluate.Warning FRX001 with the reason "it has no single-expression initializer or body".

The rules are checked in this order, and the first one that fails is the only one reported. An instance member in a non-partial type reports FRX009 only.

These member forms have a single expression and work:

[GeneratePattern] public static Pattern A => Pattern.With.Digit; // expression body (recommended)
[GeneratePattern] public static Pattern B { get; } = Pattern.With.Digit; // property initializer
[GeneratePattern] public static Pattern C { get => Pattern.With.Digit; } // expression-bodied getter
[GeneratePattern] public static Pattern D { get { return Pattern.With.Digit; } } // getter with one return statement
[GeneratePattern] public static readonly Pattern E = Pattern.With.Digit; // field initializer

A getter with more than one statement, an auto-property without an initializer, and a field without an initializer report FRX001.

tip

Prefer the expression-bodied property. A field or a property initializer still runs the chain once, when the type is first used, even though the generated method never needs it.

What a chain may contain​

The generator follows the chain the way the compiler sees it and runs each step with the real builder code. It accepts:

InputExample
Any member of Pattern, Repeat and CommonPatterns that the compile-time build includes (see below)Pattern.With.Digit.Repeat.Times(4), CommonPatterns.Integer(DigitMode.Ascii)
new Pattern()new Pattern().Digit
The + and | pattern operatorsPattern.With.Literal("cat") | Pattern.With.Literal("dog")
Literals and const values, including arithmetic on themconst int PinLength = 4; then Times(PinLength + 2)
Enum values and castsRegexOptions.IgnoreCase | RegexOptions.Multiline, (char)Code
Arrays of constants or patternsOneOf(new[] { Pattern.With.Digit, Pattern.With.Word })
Other static fields, properties and parameterless static methods declared in your source code, in any type, as long as they have a single expressionThen(Year), Then(TwoDigits()), Then(Fragments.Pin)
static readonly fields with an initializerstatic readonly int Count = 3; then Times(Count)
TimeSpan.FromDays, FromHours, FromMinutes, FromSeconds, FromMilliseconds, FromMicroseconds and FromTicksWithTimeout(TimeSpan.FromMilliseconds(250))
WithOptions(...) and WithTimeout(...)see below

Composition, operators, options and a timeout together:

using System;
using System.Text.RegularExpressions;
using Masterly.FluentRegex;

public static partial class Patterns
{
private const string Separator = "-";

private static Pattern Year => Pattern.With.Digit.Repeat.Times(4);

private static Pattern TwoDigits() => Pattern.With.Digit.Repeat.Times(2);

[GeneratePattern]
public static Pattern YearMonth =>
Pattern.With.StartOfLine + Year + Pattern.With.Literal(Separator) + TwoDigits() + Pattern.With.EndOfLine;

[GeneratePattern]
public static Pattern Pet => (Pattern.With.Literal("cat") | Pattern.With.Literal("dog"))
.WithOptions(RegexOptions.IgnoreCase)
.WithTimeout(TimeSpan.FromMilliseconds(250));

[GeneratePattern]
public static readonly Pattern Integer = CommonPatterns.Integer();
}

Console.WriteLine(Patterns.YearMonthSource); // ^\d{4}-\d{2}$
Console.WriteLine(Patterns.YearMonthRegex().IsMatch("2026-11")); // True

Console.WriteLine(Patterns.PetSource); // (?:cat|dog)
Regex pet = Patterns.PetRegex();
Console.WriteLine(pet.Options); // IgnoreCase, Compiled
Console.WriteLine(pet.MatchTimeout); // 00:00:00.2500000
Console.WriteLine(pet.IsMatch("HOTDOG")); // True

Console.WriteLine(Patterns.IntegerSource); // ^-?\d+$
Console.WriteLine(Patterns.IntegerRegex().IsMatch("-42")); // True
  • The generated PetRegex() passes (RegexOptions)9 (IgnoreCase and Compiled) and TimeSpan.FromTicks(2500000L) to the Regex constructor.
  • CommonPatterns methods are already anchored with ^ and $. Do not add StartOfLine and EndOfLine around them, or the regex gets a second pair of anchors.
  • Helpers that refer to each other in a cycle report FRX001 and name the helper where the cycle closed, for example "'SelfA' refers to itself" when SelfA uses SelfB and SelfB uses SelfA.

Anything else stops the evaluation and reports FRX001. The message names the part that is not constant:

Chain usesReason in the FRX001 message
A runtime value, such as Environment.MachineName'Environment.MachineName' is not a compile-time constant
Arithmetic on a static readonly field, such as Times(Count + 1)'Count + 1' is not a compile-time constant
A static property such as TimeSpan.Zero'TimeSpan.Zero' is not a compile-time constant
A method with parameters that is not part of the fluent APImethod 'Text' is not part of the fluent API
An instance member of another object, such as new Holder().Instinstance member 'Inst' is not a compile-time constant
A helper whose body has more than one statement'Make' has no single-expression initializer or body
TimeSpan.Parse("00:00:01") or any other TimeSpan method that is not a From... factoryTimeSpan.Parse is not evaluated at compile time; use a factory such as TimeSpan.FromSeconds
A member of a result object, such as Pattern.With.Digit.Optimize().Optimizedinstance member 'Optimized' is not a compile-time constant
An expression that is not a Pattern, such as nullthe expression does not produce a Pattern

Only the TimeSpan.From... factories are evaluated, because TimeSpan.Parse reads the text with the culture of the machine that runs the code. Write TimeSpan.FromSeconds(1) instead of TimeSpan.Parse("00:00:01").

If evaluating a member fails in a way the evaluator did not expect, the generator reports FRX001 with evaluation failed with ... for that member and still generates every other member. The analyzer treats such a chain like a dynamic one and skips it.

Members that cannot run at compile time​

The compile-time build of the library contains the builder, the parser, the safety analyzer and CommonPatterns. A few members live outside that set and cannot be evaluated:

MemberReason
Script(...), LetterIn(...), DigitIn(...)type 'Masterly.FluentRegex.UnicodeScript' cannot be evaluated at compile time
AsciiDigit, CodePoint, Grapheme, IgnoreDiacritics()'AsciiDigit' cannot be evaluated at compile time (and so on)
UseNonBacktracking()'UseNonBacktracking' cannot be evaluated at compile time
Intersect(...), Except(...), Union(...) (pattern algebra)'Intersect' cannot be evaluated at compile time (and so on)
Pattern.Learn(...)'Learn' cannot be evaluated at compile time
Must(...) and other members that return a different typeThe result is not a Pattern.

Workarounds:

  • Instead of UseNonBacktracking(), write .WithOptions(RegexOptions.NonBacktracking). That call is evaluated, and the generated Regex uses the non-backtracking engine.
  • Instead of AsciiDigit, use .WithAsciiDigits() or the DigitMode overloads of CommonPatterns. Both are evaluated.
  • For Unicode scripts, build the pattern at run time and keep it in a static readonly field. See Unicode.

The analyzer rules​

All rules use the category Masterly.FluentRegex, and each message links to this page.

IDDefault severityTitleReported by
FRX001WarningPattern cannot be evaluated at compile timeGenerator, on [GeneratePattern] members
FRX002ErrorContaining type must be partialGenerator
FRX003ErrorPattern produces an invalid regular expressionAnalyzer, on every constant chain
FRX004WarningPattern is vulnerable to catastrophic backtrackingAnalyzer, on every constant chain
FRX005InfoPattern may backtrack excessivelyAnalyzer, on every constant chain
FRX006ErrorPattern construction throwsAnalyzer, on every constant chain
FRX007InfoPattern can use [GeneratedRegex]Analyzer, on static Pattern fields and properties in partial types
FRX008WarningExtract target member has no matching groupAnalyzer, on Extract<T>, TryExtract<T> and ExtractAll<T>
FRX009Error[GeneratePattern] member must be staticGenerator

What the analyzers check​

The analyzers do not need [GeneratePattern]. They look at every expression of type Pattern in your code and evaluate it when it is built only from constants, using the same rules as the generator (see What a chain may contain).

  • Dynamic chains are skipped, silently. A chain that uses a local variable, a parameter or a runtime value cannot be evaluated, so it is not checked. Validate such patterns at run time instead. See Validation and Debugging.
  • One report per chain. A problem is reported on the outermost chain, not on every nested call. A pattern written inline inside OneOf(...) or Then(...) is checked as part of the outer chain only.
  • Fragments in their own members are reported again where they are used. A risky fragment kept in static readonly Pattern Nested is reported on its own declaration and once more on every constant chain that calls Then(Nested), because the outer regex contains it too. Fix the fragment and all of those reports go away.
  • Errors first. When building the chain throws, only FRX006 is reported. When the regex is invalid, only FRX003 is reported. The ReDoS rules run only on a valid regex.
  • Generated code is not analyzed, and the analyzers do nothing in a project that does not reference Masterly.FluentRegex.
tip

Keep reusable fragments in static readonly fields or expression-bodied properties, not in local variables. A fragment in a local turns the whole chain dynamic, and the analyzers can no longer check it.

The sections below show code that triggers each rule and the message the build printed for it.

FRX001: Pattern cannot be evaluated at compile time​

A [GeneratePattern] chain uses a value the compiler cannot know. Nothing is generated, so calls to the missing ...Regex() method also fail with CS0103 or CS0117.

using System;
using Masterly.FluentRegex;

public static partial class Frx001
{
[GeneratePattern]
public static Pattern Host => Pattern.With.Literal(Environment.MachineName);
}
warning FRX001: The pattern for 'Host' cannot be evaluated at compile time: 'Environment.MachineName' is not a compile-time constant
  • The warning points at the part of the chain that is not constant, here Environment.MachineName.
  • Fix: replace the value with a constant, or drop [GeneratePattern] and build the pattern at run time.
  • The analyzers do not report FRX001 for ordinary chains without the attribute. Those are simply skipped.

FRX002: Containing type must be partial​

using Masterly.FluentRegex;

public static class Frx002
{
[GeneratePattern]
public static Pattern Code => Pattern.With.Digit.Repeat.Times(4);
}
error FRX002: Type 'Frx002' must be declared partial to use [GeneratePattern]
  • Fix: add partial to the type.
  • Every enclosing type must be partial too. For a partial class Inner inside a non-partial class Outer, the message names the outer type: Type 'Outer' must be declared partial to use [GeneratePattern].

FRX003: Pattern produces an invalid regular expression​

The chain builds, but .NET rejects the regex text. The message shows the regex and the .NET parser's error.

using Masterly.FluentRegex;

public static class Frx003
{
public static readonly Pattern Repeated = Pattern.With.Digit.Backreference("missing");

public static readonly Pattern Unclosed = Pattern.With.RegEx("[a-z");
}
error FRX003: The pattern produces an invalid regular expression '\d\k<missing>': Invalid pattern '\d\k<missing>' at offset 13. Reference to undefined group name 'missing'.
error FRX003: The pattern produces an invalid regular expression '[a-z': Invalid pattern '[a-z' at offset 4. Unterminated [] set.
  • Typical causes: a backreference to a group that does not exist, and raw regex text added with RegEx(...) that is not valid.
  • Fix: correct the group name or the raw text. See Groups and Backreferences.

FRX004: Pattern is vulnerable to catastrophic backtracking​

The safety analyzer found a High risk: a short hostile input can make the match take exponential time (ReDoS).

using Masterly.FluentRegex;

public static class Frx004
{
public static readonly Pattern Risky = Pattern.With.StartOfLine
.Group(Pattern.With.Digit.Repeat.OneOrMore).Repeat.OneOrMore
.EndOfLine;
}
warning FRX004: ReDoS risk in '(\d+)+': The repetition '\d+' is nested inside the repetition '+'. The same text can be split between iterations in exponentially many ways, so a failing match backtracks through all of them. Fix: Make the inner repetition possessive (.Possessive), wrap it in AtomicGroup(...), or remove one of the quantifiers. For untrusted input also use WithTimeout(...) or UseNonBacktracking().

The message comes straight from AnalyzeSafety(): the risky fragment, why it is risky, and how to fix it. Making the inner repetition possessive removes the warning:

public static readonly Pattern Safe = Pattern.With.StartOfLine
.Group(Pattern.With.Digit.Repeat.OneOrMore.Possessive).Repeat.OneOrMore
.EndOfLine; // no diagnostic
  • A timeout does not change the structure of the pattern, so WithTimeout(...) alone does not remove the warning.
  • ReDoS Safety explains every finding and every fix.

FRX005: Pattern may backtrack excessively​

The safety analyzer found a Medium risk: matching can take polynomial time on long hostile input. Low findings are not reported.

using Masterly.FluentRegex;

public static class Frx005
{
public static readonly Pattern Slow = Pattern.FromRegex(@"^\d+\d+$");
}
warning FRX005: Polynomial backtracking in '\d+\d+': The repetitions '\d+' and '\d+' can match the same characters, so a failing match tries every way of dividing them (polynomial time). Fix: Merge the repetitions into one, for example \d+\d+ into \d{2,}, or make their character sets disjoint.
  • FRX005 is Info by default. The IDE lists it, but dotnet build does not print it. The output above was captured after raising it to a warning (see Configuring severity).
  • Pattern.FromRegex with a constant string is checked like a fluent chain.

FRX006: Pattern construction throws​

Building the chain throws an exception, so the same line would throw at run time. The message names the exception type and its message.

using Masterly.FluentRegex;

public static class Frx006
{
public static readonly Pattern Backwards = Pattern.With.Digit.Repeat.Times(5, 2);

public static readonly Pattern NothingToRepeat = Pattern.With.Repeat.OneOrMore;

public static readonly Pattern BadRange = Pattern.With.Range('z', 'a');
}
error FRX006: This pattern throws ArgumentOutOfRangeException when it is built: The maximum cannot be less than the minimum. (Parameter 'max')
error FRX006: This pattern throws InvalidOperationException when it is built: Repeat must follow an element to repeat, for example Pattern.With.Digit.Repeat.OneOrMore.
error FRX006: This pattern throws ArgumentException when it is built: The range start 'z' is greater than the range end 'a'. (Parameter 'from')
  • The error is placed on the call that throws, together with the part of the chain before it. Calls after the one that throws are not underlined.
  • Other common causes: .Lazy without a quantifier before it, Then(null), OneOf(new string[0]), WithTimeout(default(TimeSpan)), and Pattern.FromRegex("(abc").
  • On a [GeneratePattern] member, the generator emits nothing and leaves the error to the analyzer. You then also see CS0103: The name 'BrokenRegex' does not exist in the current context wherever the missing method is called.

FRX007: Pattern can use [GeneratedRegex]​

A static Pattern field or property in a partial type is a compile-time constant, so .NET's regex source generator can implement it. This rule is a suggestion and carries the code fix.

using Masterly.FluentRegex;

public static partial class Frx007
{
private static readonly Pattern Number = Pattern.With.StartOfLine.Digit.Repeat.OneOrMore.EndOfLine;
}
info FRX007: 'Number' is a compile-time constant pattern and can be backed by a [GeneratedRegex] method

It is reported only when all of these hold:

  • the member is static and its declared type is Pattern,
  • the type that declares it, and every type around that one, is partial, so the method the code fix adds compiles,
  • the member has a single expression that evaluates to a valid regex,
  • the member does not have [GeneratePattern], which already generates a cached regex,
  • the type has no [GeneratedRegex] method with the same regex text and options yet,
  • the project targets a framework with System.Text.RegularExpressions.GeneratedRegexAttribute (.NET 7 or later). On netstandard2.1 the rule is never reported.

It is also reported on helper fragments such as Year above, which are only building blocks. Silence it for those members, or turn the rule off (see Configuring severity).

FRX008: Extract target member has no matching group​

Extract<T>, TryExtract<T> and ExtractAll<T> bind named groups to the constructor parameters of T. The analyzer compares the two at compile time.

using Masterly.FluentRegex;

public record Size(int Width, int Height);

public static class Frx008
{
private static readonly Pattern Digits = Pattern.With.Digit.Repeat.OneOrMore;

// input comes from your application
public static Size Parse(string input) => Pattern.With
.NamedGroup("width", Digits)
.Literal("x")
.NamedGroup("heigth", Digits)
.Extract<Size>(input);
}
warning FRX008: 'Size.Height' has no matching group 'Height' in the pattern, so extracting throws InvalidOperationException. Available groups: 0, width, heigth.
  • The warning sits on the method name (Extract<Size>).
  • Group names match parameter names without regard to case, so width binds to Width.
  • [RegexGroup("name")] on the parameter, or on the property of the same name, sets the group name to look for.
  • Parameters with a default value are skipped. They may stay unset.
  • The analyzer checks the public constructor with the most parameters. A class with only a parameterless constructor and settable properties is not checked.
  • If the regex is invalid, only FRX003 is reported.
  • The pattern must be a constant chain, like Digits above, which is a static readonly field. If a fragment comes from a local variable or a parameter, for example NamedGroup("width", digits) with a Pattern digits parameter, the call is not checked and no FRX008 is reported.
  • The message says what happens at run time. For a non-nullable value type, such as int, extracting throws InvalidOperationException. For a reference type or a Nullable<T>, the message says the value is always null.
  • At run time the exception reads: InvalidOperationException: Cannot extract 'Size': the pattern has no group for Height (group 'Height'). Available groups: 0, width, heigth. Add NamedGroup(...) to the pattern, give the parameter a default value, or use [RegexGroup("name")]. See Typed Extraction.

FRX009: Member must be static​

using Masterly.FluentRegex;

public partial class Frx009
{
[GeneratePattern]
public Pattern Code => Pattern.With.Digit.Repeat.Times(4);
}
error FRX009: 'Code' must be static to use [GeneratePattern]

Fix: make the member static. The generated method is static, so it cannot read instance state.

Code fix: switch to [GeneratedRegex]​

.NET 7 added its own regex source generator. A [GeneratedRegex] method has its matching code written at build time, so there is no regex parsing or compiling when the program runs. Roslyn source generators cannot read each other's output, so [GeneratePattern] cannot emit [GeneratedRegex] directly. The FRX007 code fix bridges the gap: it writes the attribute into your source file, and .NET's generator implements it on the next build.

In Visual Studio or Rider, put the cursor on the member name, open the light bulb and pick Add [GeneratedRegex] method 'NumberRegex()'. "Fix all" in a document, project or solution is supported. From the command line, dotnet format runs the same fix:

dotnet format analyzers --diagnostics FRX007 --severity info

Before:

using System;
using System.Text.RegularExpressions;
using Masterly.FluentRegex;

namespace MyApp;

public static partial class Numbers
{
private static readonly Pattern Number = Pattern.With.StartOfLine.Digit.Repeat.OneOrMore.EndOfLine;

private static Pattern Word => Pattern.With.Word.Repeat.OneOrMore
.WithOptions(RegexOptions.IgnoreCase)
.WithTimeout(TimeSpan.FromMilliseconds(500));
}

After the fix (exactly as dotnet format wrote it):

public static partial class Numbers
{
private static readonly Pattern Number = Pattern.With.StartOfLine.Digit.Repeat.OneOrMore.EndOfLine;
/// <summary>Generated by the .NET regex source generator from <see cref="Number"/>.</summary>
[global::System.Text.RegularExpressions.GeneratedRegex(@"^\d+$")]
private static partial global::System.Text.RegularExpressions.Regex NumberRegex();

private static Pattern Word => Pattern.With.Word.Repeat.OneOrMore
.WithOptions(RegexOptions.IgnoreCase)
.WithTimeout(TimeSpan.FromMilliseconds(500));
/// <summary>Generated by the .NET regex source generator from <see cref="Word"/>.</summary>
[global::System.Text.RegularExpressions.GeneratedRegex(@"\w+", global::System.Text.RegularExpressions.RegexOptions.IgnoreCase, 500)]
private static partial global::System.Text.RegularExpressions.Regex WordRegex();
}

Use the new methods like any [GeneratedRegex] method:

// Inside Numbers
public static bool IsNumber(string text) => NumberRegex().IsMatch(text);

public static Regex WordMatcher() => WordRegex();

// Elsewhere
Console.WriteLine(Numbers.IsNumber("12345")); // True
Console.WriteLine(Numbers.IsNumber("12a45")); // False

Regex word = Numbers.WordMatcher();
Console.WriteLine(word.Options); // IgnoreCase
Console.WriteLine(word.MatchTimeout); // 00:00:00.5000000

How the fix builds the attribute:

Pattern settingAttribute argument
Regex textFirst argument. A verbatim string, or an escaped string when the text has control characters.
OptionsSecond argument, as named RegexOptions flags joined with |. Compiled is dropped, because the source generator makes it pointless. With no options and no timeout, the argument is left out.
TimeoutThird argument, in whole milliseconds (any fraction of a millisecond is dropped). With a timeout but no options, the options argument is RegexOptions.None.

For example, Pattern.With.Digit.WithTimeout(TimeSpan.FromSeconds(2)) and Pattern.With.RegEx("\t").WithOptions(RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.CultureInvariant) got these attributes:

[global::System.Text.RegularExpressions.GeneratedRegex(@"\d", global::System.Text.RegularExpressions.RegexOptions.None, 2000)]
[global::System.Text.RegularExpressions.GeneratedRegex("\t", global::System.Text.RegularExpressions.RegexOptions.Multiline | global::System.Text.RegularExpressions.RegexOptions.CultureInvariant)]
  • The method is always private static partial, named <Member>Regex. When a method with that name already exists in the same type declaration, the fix adds the first free number: NumberRegex2, NumberRegex3, and so on. Methods in other partial parts of the type are not looked at.
  • The method is inserted directly after the member. The member stays, because the method's <see cref> refers to it.
note

The fix copies the regex text once. FRX007 stays quiet while the type has a [GeneratedRegex] method with the same regex text and options, so the fix is offered only once. If you later edit the fluent chain, the old method no longer matches and FRX007 appears again: update or delete the old [GeneratedRegex] method. If you want the regex to follow the chain on every build, use [GeneratePattern] instead.

[GeneratePattern] or [GeneratedRegex]?​

[GeneratePattern][GeneratedRegex] via the FRX007 fix
Target frameworksAll: netstandard2.1, net8.0, net10.0.NET 7 or later
Follows edits to the fluent chainYes, regenerated on every buildNo, the regex text is a copy
How the Regex is madeConstructed with RegexOptions.Compiled on the first callMatching code generated at build time
Startup costParses and compiles the regex once, on first useNone
Generated method accessibilitySame as the memberAlways private
Also gives youA public const string with the regex textNothing else

Choose [GeneratePattern] while the pattern is still changing, or when you target netstandard2.1. Choose [GeneratedRegex] for a hot path whose pattern is settled, or when startup time and trimming matter.

Configuring severity​

You can raise, lower or turn off every rule, the generator rules FRX001, FRX002 and FRX009 included.

Rules in .editorconfig​

Set the rules in .editorconfig, like any other analyzer:

root = true

[*.cs]
# Fail the build on ReDoS risks and Extract<T> typos
dotnet_diagnostic.FRX004.severity = error
dotnet_diagnostic.FRX008.severity = error

# Show polynomial backtracking in the build output
dotnet_diagnostic.FRX005.severity = warning

# Turn off the [GeneratedRegex] suggestion
dotnet_diagnostic.FRX007.severity = none

The values are error, warning, suggestion, silent and none. With this file, the build that triggers every rule printed FRX004 and FRX008 as errors, FRX005 as a warning, and no FRX007.

To change the whole category at once:

[*.cs]
dotnet_analyzer_diagnostic.category-Masterly.FluentRegex.severity = warning

This also lowers the error rules FRX003 and FRX006 to warnings, so add per-rule lines after it for the rules you want to keep as errors. The category setting does not reach the generator rules FRX001, FRX002 and FRX009; set those per rule, as in dotnet_diagnostic.FRX009.severity = warning.

Rules in .globalconfig​

A .globalconfig file next to the project file (or in a parent folder) applies to every file of the project, including generated ones:

is_global = true

# Fail the build when a [GeneratePattern] chain cannot be evaluated
dotnet_diagnostic.FRX001.severity = error

MSBuild properties​

The usual compiler switches work for every rule:

<PropertyGroup>
<!-- Treat these warnings as errors -->
<WarningsAsErrors>$(WarningsAsErrors);FRX001;FRX008</WarningsAsErrors>
<!-- Hide a warning everywhere -->
<NoWarn>$(NoWarn);FRX004</NoWarn>
</PropertyGroup>

NoWarn hides a rule completely, whatever its severity. <NoWarn>$(NoWarn);FRX003;FRX002</NoWarn> turned both errors off in the test build, so use it with care: a hidden FRX002 means the generator silently emits nothing.

Suppressing one occurrence​

#pragma works on a single declaration for every rule. Say why in the comment:

#pragma warning disable FRX004 // input is length-limited before matching
public static readonly Pattern Risky = Pattern.With.StartOfLine
.Group(Pattern.With.Digit.Repeat.OneOrMore).Repeat.OneOrMore
.EndOfLine;
#pragma warning restore FRX004

Seeing info diagnostics​

FRX005 and FRX007 are Info by default. Visual Studio and Rider list them, but dotnet build does not print info diagnostics. To see them in a command-line build or in CI, raise them to warning in .editorconfig.

Target frameworks​

Featurenetstandard2.1net8.0net10.0
[GeneratePattern]YesYesYes
FRX001 to FRX006, FRX008, FRX009YesYesYes
FRX007 and its code fixNo (GeneratedRegexAttribute is missing)YesYes
  • What matters is the framework your project targets, not the library's. The add-ons themselves target netstandard2.0 and run inside the compiler.
  • The generated code uses #nullable enable and ??=, so it compiles with C# 8 and later, the default for netstandard2.1 projects. A generic, nested partial type in a netstandard2.1 library worked without changes.
  • UseNonBacktracking() exists only on net8.0 and later and cannot be evaluated at compile time. Use .WithOptions(RegexOptions.NonBacktracking) in a [GeneratePattern] chain instead.

Common pitfalls​

  • The type is not partial. Every type around a [GeneratePattern] member, including outer types, needs partial (FRX002).
  • A fragment lives in a local variable. The chain becomes dynamic and the analyzers skip it without a word. In a [GeneratePattern] getter, the extra statement reports FRX001 ("it has no single-expression initializer or body"). Move fragments to static members.
  • Arithmetic on a static readonly field. Times(Count + 1) is not evaluated when Count is static readonly. Make it a const.
  • Unicode script members in a generated chain. Script(...), LetterIn(...), DigitIn(...), AsciiDigit, CodePoint, Grapheme and IgnoreDiacritics() cannot run at compile time (FRX001).
  • Extra anchors. CommonPatterns methods already start with ^ and end with $. Wrapping them in StartOfLine and EndOfLine gives ^^...$$.
  • A name clash. A hand-written CodeRegex() method next to a [GeneratePattern] Code member fails with CS0111. Pass a different name to the attribute.
  • TimeSpan.Parse in a constant chain. It is not evaluated at compile time (FRX001). Use TimeSpan.FromSeconds(1) and the other From... factories.
  • Expecting the [GeneratedRegex] copy to follow the chain. It does not. Only [GeneratePattern] is regenerated on every build.

For catching problems in patterns that are built at run time, see Validation and Debugging. To scan plain regex string literals in C# files, see the scan command of the Command-Line Tool.