Assertions (Lookaround)
An assertion checks the text around the current position without consuming it. A lookahead looks at what comes next. A lookbehind looks at what came before. Each one can be positive (the context must be there) or negative (the context must not be there). The match itself never includes the text the assertion looked at.
Use assertions when you need to:
- match a value only when a marker follows or precedes it, such as digits after a
$or beforepx, - exclude a match because of its neighbours, such as a number that is part of a longer number,
- check several independent conditions on the same text, such as password rules,
- match a position between characters, to insert text there or split on it.
All four members live in the Masterly.FluentRegex namespace, are methods of Pattern, and work on every target the package supports: .NET Standard 2.1, .NET 8 and .NET 10.
| Member | Regex | Succeeds when |
|---|---|---|
.PositiveLookahead(pattern) | (?=...) | pattern matches the text right after the current position. |
.NegativeLookahead(pattern) | (?!...) | pattern does not match the text right after the current position. |
.PositiveLookbehind(pattern) | (?<=...) | pattern matches the text that ends at the current position. |
.NegativeLookbehind(pattern) | (?<!...) | pattern does not match the text that ends at the current position. |
Every member takes one Pattern, wraps it in the assertion and returns a new Pattern. The argument can be any pattern you can build: a literal, a class, a group, an alternation or a whole chain.
How lookaround works
An assertion is a test at a position. The engine runs the inner pattern, notes whether it matched, and then goes back to where it was. Nothing the inner pattern read becomes part of the match.
using System;
using System.Text.RegularExpressions;
using Masterly.FluentRegex;
Pattern amount = Pattern.With
.PositiveLookbehind(Pattern.With.Literal("$"))
.Digit.Repeat.OneOrMore
.NegativeLookahead(Pattern.With.Literal("%"));
Console.WriteLine(amount.ToString()); // (?<=\$)\d+(?!%)
Match match = amount.Match("Save 15% today: only $120");
Console.WriteLine(match.Value); // 120
Console.WriteLine(match.Index); // 22
15is skipped because no$precedes it. The lookbehind is what rejects it: on its own,\d+(?!%)would still match the1of15, because the1is followed by5, not by%(see Negative lookbehind for the same effect).- The match is
120, not$120. The$was only checked, never consumed, somatch.Indexpoints at the1. Literal("$")is escaped for you (\$). You never escape text inside an assertion yourself.Matchcomes fromSystem.Text.RegularExpressions.
The difference from ordinary matching shows up in the match length:
Pattern consuming = Pattern.With.Literal("q").Literal("u");
Pattern lookingAhead = Pattern.With.Literal("q").PositiveLookahead(Pattern.With.Literal("u"));
Console.WriteLine(consuming.Match("queen").Value); // qu
Console.WriteLine(lookingAhead.Match("queen").Value); // q
Console.WriteLine(lookingAhead.Match("queen").Length); // 1
Both patterns need a u after the q. Only the first one makes it part of the match.
Because an assertion consumes nothing, the rest of the pattern continues from the same position. That is what lets you stack several assertions in a row and have each one examine the same text. See Stacking lookaheads for AND rules.
Lookahead
A lookahead checks the text to the right of the current position.
Positive lookahead
PositiveLookahead succeeds when its pattern matches at the current position. Use it to require a suffix that should not be part of the result.
Pattern qWithU = Pattern.With
.Literal("q")
.PositiveLookahead(Pattern.With.Literal("u"));
Console.WriteLine(qWithU.ToString()); // q(?=u)
Console.WriteLine(qWithU.IsMatch("queen")); // True
Console.WriteLine(qWithU.IsMatch("iraq")); // False
A common use is reading a value while ignoring its unit:
Pattern pixelValue = Pattern.With
.Digit.Repeat.OneOrMore
.PositiveLookahead(Pattern.With.Literal("px"));
Console.WriteLine(pixelValue.ToString()); // \d+(?=px)
foreach (Match match in pixelValue.Matches("margin: 12px 4em 8px 0"))
{
Console.WriteLine(match.Value); // prints 12, then 8
}
4emand0are skipped becausepxdoes not follow them.- The values come back without the unit, so you can parse them directly.
Negative lookahead
NegativeLookahead succeeds when its pattern does not match at the current position. It also succeeds at the end of the input, where nothing follows.
Pattern qWithoutU = Pattern.With
.Literal("q")
.NegativeLookahead(Pattern.With.Literal("u"));
Console.WriteLine(qWithoutU.ToString()); // q(?!u)
Console.WriteLine(qWithoutU.IsMatch("iraq")); // True
Console.WriteLine(qWithoutU.IsMatch("queen")); // False
Console.WriteLine(qWithoutU.Match("Qatar and Iraq").Index); // 13
- In
iraqtheqis the last character. Nothing follows it, so "not followed byu" is true. - The
QofQatardoes not match becauseLiteral("q")is case-sensitive. The match is the finalqofIraq.
Put a negative lookahead right after the start anchor to reject whole inputs. This pattern accepts any word except a few reserved device names:
Pattern fileName = Pattern.With
.StartOfLine
.NegativeLookahead(Pattern.With.OneOf("con", "nul", "prn").EndOfLine)
.Word.Repeat.OneOrMore
.EndOfLine;
Console.WriteLine(fileName.ToString()); // ^(?!(?:con|nul|prn)$)\w+$
Console.WriteLine(fileName.IsMatch("report")); // True
Console.WriteLine(fileName.IsMatch("con")); // False
Console.WriteLine(fileName.IsMatch("console")); // True
- The
EndOfLineinside the lookahead makes it reject only the exact names. Without it,consolewould be rejected too, because it starts withcon. OneOfwraps the alternatives in a non-capturing group, so the anchor applies to all of them.
Lookbehind
A lookbehind checks the text to the left of the current position: the text that ends exactly where the rest of the pattern starts.
Positive lookbehind
PositiveLookbehind succeeds when its pattern matches the text just before the current position. Use it to require a prefix that should not be part of the result.
Pattern price = Pattern.With
.PositiveLookbehind(Pattern.With.Literal("$"))
.Digit.Repeat.OneOrMore;
Console.WriteLine(price.ToString()); // (?<=\$)\d+
Console.WriteLine(price.Match("Price: $100").Value); // 100
Negative lookbehind
NegativeLookbehind succeeds when its pattern does not match the text just before the current position. It also succeeds at the start of the input.
Pattern unsigned = Pattern.With
.NegativeLookbehind(Pattern.With.Literal("-"))
.Digit.Repeat.OneOrMore;
Console.WriteLine(unsigned.ToString()); // (?<!-)\d+
Console.WriteLine(unsigned.Match("123").Value); // 123
Console.WriteLine(unsigned.Match("-456").Value); // 56
The second result is a classic surprise. The engine cannot start at 4, because - precedes it. It moves one character on and starts at 5, which is preceded by 4, not by -. So it matches 56.
A negative lookbehind only guards the first character of the match. When the match can start in the middle of a run, also exclude the characters of that run. Here, "not after a - or a digit" stops the engine from starting inside a number:
Pattern wholeUnsigned = Pattern.With
.NegativeLookbehind(Pattern.With.Set(Pattern.With.Literal("-").Digit))
.Digit.Repeat.OneOrMore;
Console.WriteLine(wholeUnsigned.ToString()); // (?<![\-\d])\d+
Console.WriteLine(wholeUnsigned.IsMatch("-456")); // False
foreach (Match match in wholeUnsigned.Matches("7 -456 89"))
{
Console.WriteLine(match.Value); // prints 7, then 89
}
The same thing happens with any prefix:
Pattern notAfterDollar = Pattern.With
.NegativeLookbehind(Pattern.With.Literal("$"))
.Digit.Repeat.OneOrMore;
Console.WriteLine(notAfterDollar.IsMatch("Item 100")); // True
Console.WriteLine(notAfterDollar.IsMatch("$100")); // True
Console.WriteLine(notAfterDollar.Match("$100").Value); // 00
IsMatch("$100") is true, because 00 is a run of digits that is not preceded by $. Use NegativeLookbehind(Pattern.With.Set(Pattern.With.Literal("$").Digit)) when you mean "a number that does not start with $".
Variable-length lookbehind
The .NET engine runs a lookbehind from right to left, so its pattern can have any length, including alternatives of different lengths and open-ended repeats. Many other engines cannot do this.
Pattern labelledValue = Pattern.With
.PositiveLookbehind(Pattern.With.OneOf("Total", "Subtotal").Literal(":").Whitespace.Repeat.ZeroOrMore)
.Digit.Repeat.OneOrMore;
Console.WriteLine(labelledValue.ToString()); // (?<=(?:Total|Subtotal):\s*)\d+
foreach (Match match in labelledValue.Matches("Subtotal: 90, Tax:5, Total: 95"))
{
Console.WriteLine(match.Value); // prints 90, then 95
}
Tax:5is skipped becauseTaxis not one of the labels.- Any number of spaces may sit between the label and the value, and none of them end up in the match.
This pattern runs everywhere in .NET, but MySQL and MariaDB reject it when you export it to SQL. See Exporting to other engines.
Combining assertions
Guarding both sides
Put a lookbehind before an element and a lookahead after it to control both neighbours. This pattern finds exactly three digits that are not part of a longer number:
Pattern isolatedNumber = Pattern.With
.NegativeLookbehind(Pattern.With.Digit)
.Digit.Repeat.Times(3)
.NegativeLookahead(Pattern.With.Digit);
Console.WriteLine(isolatedNumber.ToString()); // (?<!\d)\d{3}(?!\d)
Console.WriteLine(isolatedNumber.IsMatch("123")); // True
Console.WriteLine(isolatedNumber.IsMatch("1234")); // False
Console.WriteLine(isolatedNumber.IsMatch("a123b")); // True
- A word boundary (
WordBoundary,\b) would rejecta123b, because letters are word characters. The assertions only look at digits, so letters next to the number are fine. - Both checks also pass at the start and end of the input.
Stacking lookaheads for AND rules
Several lookaheads in a row all start from the same position. Put them after the start anchor and each one checks the whole input for one rule. The input passes only when every rule holds.
Pattern strongPassword = Pattern.With
.StartOfLine
.PositiveLookahead(Pattern.With.Anything.Repeat.ZeroOrMore.Set(Pattern.With.LowercaseLetter))
.PositiveLookahead(Pattern.With.Anything.Repeat.ZeroOrMore.Set(Pattern.With.UppercaseLetter))
.PositiveLookahead(Pattern.With.Anything.Repeat.ZeroOrMore.Digit)
.Anything.Repeat.AtLeast(8)
.EndOfLine;
Console.WriteLine(strongPassword.ToString()); // ^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$
Console.WriteLine(strongPassword.IsMatch("Password1")); // True
Console.WriteLine(strongPassword.IsMatch("password1")); // False (no uppercase letter)
Console.WriteLine(strongPassword.IsMatch("PASSWORD1")); // False (no lowercase letter)
Console.WriteLine(strongPassword.IsMatch("Password")); // False (no digit)
Console.WriteLine(strongPassword.IsMatch("Pass1")); // False (shorter than 8)
- Each lookahead reads "somewhere ahead there is a ...".
.*skips any characters before the one the rule needs. - The order of the lookaheads does not change the result. Put the rule that fails most often first, so bad input is rejected sooner.
- The consuming part (
.{8,}) sets the length rule. The lookaheads never move the position, so they do not count towards it. LowercaseLetterandUppercaseLetterare ASCII ranges (a-z,A-Z). For letters in other scripts, see Unicode.
The library ships a ready-made version that also requires a special character:
Pattern strong = CommonPatterns.StrongPassword();
Console.WriteLine(strong.ToString()); // ^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#\$%\^&\*\(\)_\+\-=]).{8,}$
Console.WriteLine(strong.IsMatch("Password1!")); // True
Console.WriteLine(strong.IsMatch("Password1")); // False (no special character)
See Common Patterns for the rest of the catalog.
A single regex only tells you that some rule failed, not which one. When you need to show the user each failed rule, keep one small pattern per rule, or use Semantic Rules.
Requirements and exclusions together
Mix positive and negative lookaheads to state what the whole input must and must not be, then describe its shape with ordinary elements:
Pattern username = Pattern.With
.StartOfLine
.NegativeLookahead(Pattern.With.OneOf("admin", "root").EndOfLine)
.PositiveLookahead(Pattern.With.Anything.Repeat.Times(3, 16).EndOfLine)
.Letter
.Word.Repeat.ZeroOrMore
.EndOfLine;
Console.WriteLine(username.ToString()); // ^(?!(?:admin|root)$)(?=.{3,16}$)[a-zA-Z]\w*$
Console.WriteLine(username.IsMatch("alice_01")); // True
Console.WriteLine(username.IsMatch("admin")); // False (reserved)
Console.WriteLine(username.IsMatch("administrator")); // True (only the exact word is reserved)
Console.WriteLine(username.IsMatch("al")); // False (shorter than 3)
Console.WriteLine(username.IsMatch("1alice")); // False (starts with a digit)
- The length lookahead (
(?=.{3,16}$)) keeps the length rule separate from the shape rule. Without it you would have to work the limit into the\w*repeat. Letteris the ASCII class[a-zA-Z].
Matching a position
A pattern made only of assertions matches an empty string at a position. Use it with Replace to insert text there, or with Split to cut between characters without losing any.
This pattern finds every gap between digits that is followed by a multiple of three digits, and inserts a thousands separator:
Pattern thousandsGap = Pattern.With
.PositiveLookbehind(Pattern.With.Digit)
.PositiveLookahead(Pattern.With
.NonCapturingGroup(Pattern.With.Digit.Repeat.Times(3)).Repeat.OneOrMore
.NegativeLookahead(Pattern.With.Digit));
Console.WriteLine(thousandsGap.ToString());
// (?<=\d)(?=(?:\d{3})+(?!\d))
Console.WriteLine(thousandsGap.Replace("Population: 1234567, budget 950", ","));
// Population: 1,234,567, budget 950
- Assertions can nest. The negative lookahead inside the positive one makes sure the group of three digits reaches the end of the number.
950has no gap that is followed by a multiple of three digits, so it is left alone.
This pattern splits a camelCase name at every lower-to-upper case change:
Pattern wordGap = Pattern.With
.PositiveLookbehind(Pattern.With.Set(Pattern.With.LowercaseLetter))
.PositiveLookahead(Pattern.With.Set(Pattern.With.UppercaseLetter));
Console.WriteLine(wordGap.ToString()); // (?<=[a-z])(?=[A-Z])
Console.WriteLine(string.Join(" ", wordGap.Split("parseHttpRequestBody"))); // parse Http Request Body
Because the separator is empty, Split keeps every character.
What goes inside an assertion
Any pattern, reused freely
The argument is an ordinary Pattern. Patterns are immutable, so you can build a piece once and use it in several assertions:
Pattern currency = Pattern.With.Literal("$");
Pattern price = Pattern.With.PositiveLookbehind(currency).Digit.Repeat.OneOrMore;
Pattern noCurrency = Pattern.With.NegativeLookbehind(currency).Digit.Repeat.OneOrMore;
Console.WriteLine(price.ToString()); // (?<=\$)\d+
Console.WriteLine(noCurrency.ToString()); // (?<!\$)\d+
Inner patterns can use anchors, groups, alternations, quantifiers and other assertions. An anchor inside a lookahead checks the position the lookahead reached:
Pattern lastWord = Pattern.With
.Word.Repeat.OneOrMore
.PositiveLookahead(Pattern.With.EndOfLine);
Console.WriteLine(lastWord.ToString()); // \w+(?=$)
Console.WriteLine(lastWord.Match("first second third").Value); // third
Captures inside assertions
Groups inside a positive assertion capture as usual, even though their text is not part of the match. Groups inside a negative assertion never keep a value, because the assertion only succeeds when its pattern failed.
Pattern quantity = Pattern.With
.Digit.Repeat.OneOrMore
.PositiveLookahead(Pattern.With.Whitespace.NamedGroup("unit", Pattern.With.OneOf("kg", "lb")));
Match match = quantity.Match("Weight: 12 kg");
Console.WriteLine(quantity.ToString()); // \d+(?=\s(?<unit>(?:kg|lb)))
Console.WriteLine(match.Value); // 12
Console.WriteLine(match.Groups["unit"].Value); // kg
Pattern notBefore = Pattern.With
.Literal("a")
.NegativeLookahead(Pattern.With.NamedGroup("next", Pattern.With.Literal("b")));
Console.WriteLine(notBefore.Match("ac").Groups["next"].Success); // False
This is handy when you want a value and its context as separate results: match.Value holds the number and the unit group holds the unit. For more on groups, see Groups and Backreferences.
Empty patterns
An empty inner pattern is allowed. It always matches, so a positive assertion around it always succeeds and a negative one always fails:
Pattern alwaysPasses = Pattern.With.Literal("a").PositiveLookahead(Pattern.With);
Pattern neverPasses = Pattern.With.Literal("a").NegativeLookahead(Pattern.With);
Console.WriteLine(alwaysPasses.ToString()); // a(?=)
Console.WriteLine(alwaysPasses.IsMatch("a")); // True
Console.WriteLine(neverPasses.ToString()); // a(?!)
Console.WriteLine(neverPasses.IsMatch("a")); // False
You rarely write this on purpose. It usually means a conditional builder produced an empty pattern, so check for pattern.IsEmpty before you pass it in.
Options belong to the outer pattern
Only the text of the inner pattern is copied into the assertion. Its execution settings (WithOptions, WithTimeout) are dropped, because the outer pattern decides how the whole expression runs. To change matching behaviour for just the inner part, use an inline option such as IgnoreCase(...):
Pattern currencyCode = Pattern.With.Literal("USD").WithOptions(RegexOptions.IgnoreCase);
Pattern ignoredOptions = Pattern.With
.Digit.Repeat.OneOrMore
.PositiveLookahead(Pattern.With.Whitespace.Then(currencyCode));
Console.WriteLine(ignoredOptions.ToString()); // \d+(?=\sUSD)
Console.WriteLine(ignoredOptions.IsMatch("100 usd")); // False
Pattern inlineOptions = Pattern.With
.Digit.Repeat.OneOrMore
.PositiveLookahead(Pattern.With.Whitespace.IgnoreCase(Pattern.With.Literal("USD")));
Console.WriteLine(inlineOptions.ToString()); // \d+(?=\s(?i:USD))
Console.WriteLine(inlineOptions.IsMatch("100 usd")); // True
Then, the group methods and the other members that take a pattern behave the same way. See Core Concepts.
Null arguments
Passing null throws ArgumentNullException with ParamName set to "pattern". When you pass a literal null, the compile-time analyzer reports this as error FRX006 before you run anything (see Compile-Time Checks).
Pitfalls
Quantifiers on an assertion
An assertion matches no text, so repeating it achieves nothing. The .NET engine accepts a quantifier after one, and Repeat.Optional quietly turns the check off:
Pattern optionalCheck = Pattern.With
.Literal("a")
.PositiveLookahead(Pattern.With.Literal("b")).Repeat.Optional;
Console.WriteLine(optionalCheck.ToString()); // a(?=b)?
Console.WriteLine(optionalCheck.IsMatch("ac")); // True: the check was made optional
Never put .Repeat right after an assertion. Put the quantifier inside the inner pattern instead. Export to MySQL also rejects this construct (quantifier '?' on a zero-width construct).
Lookaheads, . and line breaks
Anything (.) does not match \n, and EndOfLine ($) also matches before a final newline. Both affect "anywhere in the input" rules:
Pattern hasDigit = Pattern.With
.StartOfLine
.PositiveLookahead(Pattern.With.Anything.Repeat.ZeroOrMore.Digit)
.Anything.Repeat.AtLeast(8)
.EndOfLine;
Console.WriteLine(hasDigit.IsMatch("abcdefg1\n")); // True: $ also matches before a final newline
Console.WriteLine(hasDigit.IsMatch("abcdefgh\n1")); // False: . stops at the newline
Pattern wholeInput = Pattern.With
.StartOfInput
.PositiveLookahead(Pattern.With.Anything.Repeat.ZeroOrMore.Digit)
.Anything.Repeat.AtLeast(8)
.EndOfInput;
Console.WriteLine(wholeInput.ToString()); // \A(?=.*\d).{8,}\z
Console.WriteLine(wholeInput.IsMatch("abcdefg1\n")); // False
- Use
StartOfInputandEndOfInput(\A,\z) when you validate a whole value and a trailing newline must not slip through. - Add
RegexOptions.SinglelinewithWithOptionswhen the input may span lines and.should cross them.
Cost of .* inside lookaheads
Each (?=.*X) scans forward to the end of the line and then backtracks to find X. For short inputs such as passwords this is cheap. For long inputs, a negated class finds the first match without backtracking:
Pattern hasDigit = Pattern.With
.StartOfLine
.PositiveLookahead(Pattern.With.NegatedSet(Pattern.With.Digit).Repeat.ZeroOrMore.Digit)
.Anything.Repeat.AtLeast(8)
.EndOfLine;
Console.WriteLine(hasDigit.ToString()); // ^(?=[^\d]*\d).{8,}$
Console.WriteLine(hasDigit.IsMatch("abcdefg1")); // True
Console.WriteLine(hasDigit.IsMatch("abcdefgh")); // False
ReDoS inside assertions
An assertion does not make its pattern safe. A nested repeat inside a lookahead can backtrack exponentially just like one outside it. AnalyzeSafety inspects assertion bodies too:
Pattern risky = Pattern.With
.StartOfLine
.PositiveLookahead(Pattern.With
.NonCapturingGroup(Pattern.With.Letter.Repeat.OneOrMore).Repeat.OneOrMore
.Literal("!"))
.Anything.Repeat.ZeroOrMore;
SafetyReport report = risky.AnalyzeSafety();
Console.WriteLine(report.Risk); // High
Console.WriteLine(report.Issues[0].Kind); // NestedQuantifier
Console.WriteLine(report.Issues[0].Fragment); // (?:[a-zA-Z]+)+
The compile-time analyzer reports the same problem as warning FRX004 when you build the project. See ReDoS Safety.
Assertions and the non-backtracking engine
The .NET non-backtracking engine does not support lookarounds. UseNonBacktracking() (.NET 8 and later) accepts the setting, but the pattern throws when it first runs:
Pattern price = Pattern.With
.PositiveLookbehind(Pattern.With.Literal("$"))
.Digit.Repeat.OneOrMore
.UseNonBacktracking();
try
{
price.IsMatch("$5");
}
catch (NotSupportedException ex)
{
Console.WriteLine(ex.Message);
// RegexOptions.NonBacktracking is not supported in conjunction with expressions containing:
// 'positive lookahead (?= pattern) or positive lookbehind (?<= pattern)'.
}
When you need assertions and protection from slow input, keep the backtracking engine and set a timeout with WithTimeout(...).
Lookaround across the library
Assertions work with every tool in the library, but some tools can only reason about part of what an assertion does. This table sums it up. The sections below show the cases where you are most likely to notice.
| Feature | How it treats assertions |
|---|---|
Explain() | Describes each one, for example "Positive lookbehind (must precede)". |
Pattern.FromRegex(...).ToFluentCode() | Turns (?=, (?!, (?<= and (?<! back into the four fluent methods. See Regex to Fluent Code. |
GenerateMatching / GenerateNonMatching | Generates text for the consuming part and keeps only samples the .NET engine confirms, so every sample honours the assertions. Very narrow assertions can leave fewer samples than you asked for. See Test Data Generation. |
AnalyzeSafety | Inspects assertion bodies for ReDoS risks. |
Compare, IsEquivalentTo, IsSubsetOf | Support positive and negative lookaheads at the very start of the pattern (with or without a leading ^ or \A). Any other lookahead and every lookbehind throw NotSupportedException. See Pattern Algebra. |
GetLanguageFacts | Exact when the pattern only has leading lookaheads; otherwise a safe approximation (IsExact is false). See Language Facts. |
MeasureCoverage, TrackCoverage | Track branches inside positive assertions. Negative assertions are not instrumented, because their captures never survive. See Regex Coverage. |
ToJavaScript(), ToFlavor(RegexFlavor.JavaScript), ToFlavor(RegexFlavor.JsonSchema) | Exported as-is, including variable-length lookbehinds. |
ToFlavor for PostgreSQL | Supported. Groups inside an assertion do not capture in PostgreSQL, and a backreference inside an assertion is rejected. |
ToFlavor for MySQL | Supported, but a lookbehind must have a bounded length and a quantifier on an assertion is rejected. |
ToFlavor for MariaDB | Supported, but each top-level branch of a lookbehind must have a fixed length. |
ToFlavor for SQL Server and SQLite | Not supported. Throws NotSupportedException. |
ToLike | Drops assertions. The result matches a superset of the pattern and reports itself as not exact. See SQL LIKE. |
UseNonBacktracking() | Not supported by the .NET engine. Throws NotSupportedException when the pattern runs. |
Reading and round-tripping assertions
Explain describes each assertion, and ToFluentCode rebuilds one from regex text:
Pattern price = Pattern.With
.PositiveLookbehind(Pattern.With.Literal("$"))
.Digit.Repeat.OneOrMore;
Console.WriteLine(price.Explain());
// Pattern: (?<=\$)\d+
//
// Explanation:
// - Positive lookbehind (must precede)
// - Escaped literal '$'
// - End of group
// - Digit (any Unicode decimal digit)
// - One or more
Console.WriteLine(Pattern.FromRegex(@"(?<!\d)\d{3}(?=px)").ToFluentCode());
// Pattern.With
// .NegativeLookbehind(Pattern.With.Digit)
// .Digit.Repeat.Times(3)
// .PositiveLookahead(Pattern.With.Literal("px"))
Generating test data
Generated samples always respect the assertions, so you can feed them straight into tests:
// strongPassword is the pattern from "Stacking lookaheads for AND rules"
string[] valid = strongPassword.GenerateMatching(count: 20, seed: 7);
string[] invalid = strongPassword.GenerateNonMatching(count: 20, seed: 7);
Console.WriteLine(Array.TrueForAll(valid, strongPassword.IsMatch)); // True
Console.WriteLine(Array.TrueForAll(invalid, sample => !strongPassword.IsMatch(sample))); // True
Proving facts about leading lookaheads
Language algebra treats a lookahead at the very start of a pattern as an extra condition on the whole input, so it can prove facts about it. Other assertions are not regular in the sense the algebra needs, and it says so:
Pattern fourDigits = Pattern.With.StartOfLine.Digit.Repeat.Times(4).EndOfLine;
Pattern guardedDigits = Pattern.With
.StartOfLine
.PositiveLookahead(Pattern.With.Digit.Repeat.Times(4).EndOfLine)
.Digit.Repeat.OneOrMore
.EndOfLine;
Console.WriteLine(guardedDigits.IsEquivalentTo(fourDigits)); // True
Pattern qWithoutU = Pattern.With.Literal("q").NegativeLookahead(Pattern.With.Literal("u"));
try
{
qWithoutU.IsEquivalentTo(fourDigits);
}
catch (NotSupportedException ex)
{
Console.WriteLine(ex.Message);
// Language algebra does not support lookaheads except at the very start of the pattern: '(?!u)' at position 1. ...
}
Exporting to other engines
The .NET engine is more flexible with lookbehinds than most. Check every pattern you export:
// labelledValue is the pattern from "Variable-length lookbehind"
Console.WriteLine(labelledValue.ToJavaScript());
// /(?<=(?:Total|Subtotal):[\t\n\v\f\r\x85\p{Z}]*)\p{Nd}+/u
try
{
labelledValue.ToFlavor(RegexFlavor.MySql);
}
catch (NotSupportedException ex)
{
Console.WriteLine(ex.Message);
// The pattern cannot be converted to MySQL without changing its meaning:
// - lookbehind with an unbounded length (ICU requires a bounded lookbehind) at position 0
}
Pattern boundedLabel = Pattern.With
.PositiveLookbehind(Pattern.With.OneOf("Total", "Subtotal").Literal(":").Whitespace.Repeat.Times(0, 3))
.Digit.Repeat.OneOrMore;
Console.WriteLine(boundedLabel.ToFlavor(RegexFlavor.MySql).Pattern);
// (?-i)(?<=(?:Total|Subtotal):[\t\n\x{000B}\f\r\x{0085}\p{Z}]{0,3})\p{Nd}+
- For MySQL, replace an open repeat inside a lookbehind with a bounded one such as
Repeat.Times(0, 3). - MariaDB is stricter: each top-level branch of the lookbehind must have a fixed length.
OneOfwraps its branches in a group, soPositiveLookbehind(Pattern.With.OneOf("Total: ", "Subtotal: "))((?<=(?:Total: |Subtotal: ))) is rejected. Write the branches at the top level with raw text, for examplePositiveLookbehind(Pattern.With.RegEx("Total: |Subtotal: ")), which exports as(?<=Total: |Subtotal: ). - PostgreSQL never captures inside an assertion. A named group there has no entry in the conversion's
GroupNumbers, soGroupNumbers.ContainsKey("unit")isfalsefor thequantitypattern above. - SQL Server and SQLite have no lookaround at all. Move the context check into the SQL query, or filter in .NET after the query.
See Export and SQL Dialects for every rule per engine.
Tips
- Prefer a plain match when you need the context anyway.
Literal("$").Digit.Repeat.OneOrMorewith a named group for the number is easier to read than a lookbehind, and works in every engine. - Use a lookbehind or lookahead when the context must stay out of the match: replacements, splits, and values you pass on unchanged.
- For "the whole input must not be X", put a negative lookahead with an end anchor right after the start anchor.
- For several independent rules on one input, stack lookaheads after the start anchor, but keep separate patterns when users need per-rule messages.
- Run
AnalyzeSafetyon patterns that use.*inside assertions and run on untrusted input, and setWithTimeout(...). - For more patterns built from these pieces, see Common Patterns and Best Practices.