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-on | What it does | You see it as |
|---|---|---|
| Source generator | Evaluates 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. |
| Analyzers | Evaluate every chain built only from constants, anywhere in your code, and report problems as FRX003 to FRX008. | Build errors, warnings and IDE messages. |
| Code fix | Adds a [GeneratedRegex] partial method next to a constant pattern (rule FRX007). | A light-bulb action in the IDE, or dotnet format. |
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
ProductCodeSourceis apublic const string. You can use it anywhere a constant is required, such as an attribute argument.ProductCodeRegex()creates theRegexon the first call and returns the same instance after that.- The generated
Regexalways hasRegexOptions.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 member | Name | Accessibility | Notes |
|---|---|---|---|
| Regex text | <Member>Source | Always public const string | Written 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 static | Holds the Regex after the first call. |
| Regex method | <Member>Regex(), or the name you pass to the attribute | Copied from the member: public, internal, protected, protected internal, private protected or private | Uses 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 asInner<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 aRegex; 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 throwArgumentExceptionon its first call. The analyzer reports that chain as the errorFRX003, 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
| Constructor | Generated method |
|---|---|
[GeneratePattern] | <Member>Regex(), for example DateRegex() |
[GeneratePattern("IsoDate")] | IsoDate() |
- The attribute's
MethodNameproperty returns the name you passed, ornullfor the default. - An empty name,
[GeneratePattern("")], also gives the default<Member>Regex(). Dateisinternal, soIsoDate()isinternaltoo.DateSourceis stillpublic.- The name is not checked against your own members. If the type already has a
CodeRegex()method next to a[GeneratePattern] Codemember, the build fails withCS0111: Type '...' already defines a member called 'CodeRegex' with the same parameter types. Pick another name with the attribute.
Rules for the member
| Rule | When 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.
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:
| Input | Example |
|---|---|
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 operators | Pattern.With.Literal("cat") | Pattern.With.Literal("dog") |
Literals and const values, including arithmetic on them | const int PinLength = 4; then Times(PinLength + 2) |
| Enum values and casts | RegexOptions.IgnoreCase | RegexOptions.Multiline, (char)Code |
| Arrays of constants or patterns | OneOf(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 expression | Then(Year), Then(TwoDigits()), Then(Fragments.Pin) |
static readonly fields with an initializer | static readonly int Count = 3; then Times(Count) |
TimeSpan.FromDays, FromHours, FromMinutes, FromSeconds, FromMilliseconds, FromMicroseconds and FromTicks | WithTimeout(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) andTimeSpan.FromTicks(2500000L)to theRegexconstructor. CommonPatternsmethods are already anchored with^and$. Do not addStartOfLineandEndOfLinearound them, or the regex gets a second pair of anchors.- Helpers that refer to each other in a cycle report
FRX001and name the helper where the cycle closed, for example "'SelfA' refers to itself" whenSelfAusesSelfBandSelfBusesSelfA.
Anything else stops the evaluation and reports FRX001. The message names the part that is not constant:
| Chain uses | Reason 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 API | method 'Text' is not part of the fluent API |
An instance member of another object, such as new Holder().Inst | instance 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... factory | TimeSpan.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().Optimized | instance member 'Optimized' is not a compile-time constant |
An expression that is not a Pattern, such as null | the 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:
| Member | Reason |
|---|---|
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 type | The result is not a Pattern. |
Workarounds:
- Instead of
UseNonBacktracking(), write.WithOptions(RegexOptions.NonBacktracking). That call is evaluated, and the generatedRegexuses the non-backtracking engine. - Instead of
AsciiDigit, use.WithAsciiDigits()or theDigitModeoverloads ofCommonPatterns. Both are evaluated. - For Unicode scripts, build the pattern at run time and keep it in a
static readonlyfield. See Unicode.
The analyzer rules
All rules use the category Masterly.FluentRegex, and each message links to this page.
| ID | Default severity | Title | Reported by |
|---|---|---|---|
FRX001 | Warning | Pattern cannot be evaluated at compile time | Generator, on [GeneratePattern] members |
FRX002 | Error | Containing type must be partial | Generator |
FRX003 | Error | Pattern produces an invalid regular expression | Analyzer, on every constant chain |
FRX004 | Warning | Pattern is vulnerable to catastrophic backtracking | Analyzer, on every constant chain |
FRX005 | Info | Pattern may backtrack excessively | Analyzer, on every constant chain |
FRX006 | Error | Pattern construction throws | Analyzer, on every constant chain |
FRX007 | Info | Pattern can use [GeneratedRegex] | Analyzer, on static Pattern fields and properties in partial types |
FRX008 | Warning | Extract target member has no matching group | Analyzer, on Extract<T>, TryExtract<T> and ExtractAll<T> |
FRX009 | Error | [GeneratePattern] member must be static | Generator |
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(...)orThen(...)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 Nestedis reported on its own declaration and once more on every constant chain that callsThen(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
FRX006is reported. When the regex is invalid, onlyFRX003is 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.
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
FRX001for 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
partialto the type. - Every enclosing type must be
partialtoo. For apartial class Innerinside a non-partialclass 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.
FRX005is Info by default. The IDE lists it, butdotnet builddoes not print it. The output above was captured after raising it to a warning (see Configuring severity).Pattern.FromRegexwith 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:
.Lazywithout a quantifier before it,Then(null),OneOf(new string[0]),WithTimeout(default(TimeSpan)), andPattern.FromRegex("(abc"). - On a
[GeneratePattern]member, the generator emits nothing and leaves the error to the analyzer. You then also seeCS0103: The name 'BrokenRegex' does not exist in the current contextwherever 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
staticand its declared type isPattern, - 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). Onnetstandard2.1the 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
widthbinds toWidth. [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
FRX003is reported. - The pattern must be a constant chain, like
Digitsabove, which is astatic readonlyfield. If a fragment comes from a local variable or a parameter, for exampleNamedGroup("width", digits)with aPattern digitsparameter, the call is not checked and noFRX008is reported. - The message says what happens at run time. For a non-nullable value type, such as
int, extracting throwsInvalidOperationException. For a reference type or aNullable<T>, the message says the value is alwaysnull. - 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 setting | Attribute argument |
|---|---|
| Regex text | First argument. A verbatim string, or an escaped string when the text has control characters. |
| Options | Second 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. |
| Timeout | Third 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 otherpartialparts 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.
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 frameworks | All: netstandard2.1, net8.0, net10.0 | .NET 7 or later |
| Follows edits to the fluent chain | Yes, regenerated on every build | No, the regex text is a copy |
How the Regex is made | Constructed with RegexOptions.Compiled on the first call | Matching code generated at build time |
| Startup cost | Parses and compiles the regex once, on first use | None |
| Generated method accessibility | Same as the member | Always private |
| Also gives you | A public const string with the regex text | Nothing 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
| Feature | netstandard2.1 | net8.0 | net10.0 |
|---|---|---|---|
[GeneratePattern] | Yes | Yes | Yes |
FRX001 to FRX006, FRX008, FRX009 | Yes | Yes | Yes |
FRX007 and its code fix | No (GeneratedRegexAttribute is missing) | Yes | Yes |
- What matters is the framework your project targets, not the library's. The add-ons themselves target
netstandard2.0and run inside the compiler. - The generated code uses
#nullable enableand??=, so it compiles with C# 8 and later, the default fornetstandard2.1projects. A generic, nestedpartialtype in anetstandard2.1library worked without changes. UseNonBacktracking()exists only onnet8.0and 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, needspartial(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 reportsFRX001("it has no single-expression initializer or body"). Move fragments to static members. - Arithmetic on a
static readonlyfield.Times(Count + 1)is not evaluated whenCountisstatic readonly. Make it aconst. - Unicode script members in a generated chain.
Script(...),LetterIn(...),DigitIn(...),AsciiDigit,CodePoint,GraphemeandIgnoreDiacritics()cannot run at compile time (FRX001). - Extra anchors.
CommonPatternsmethods already start with^and end with$. Wrapping them inStartOfLineandEndOfLinegives^^...$$. - A name clash. A hand-written
CodeRegex()method next to a[GeneratePattern] Codemember fails withCS0111. Pass a different name to the attribute. TimeSpan.Parsein a constant chain. It is not evaluated at compile time (FRX001). UseTimeSpan.FromSeconds(1)and the otherFrom...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.