Mert Özen Further With Every Line
Developer Tools Regular Expressions — #04

Lookaround, Inline Flags and Unicode: Lookahead, Lookbehind, (?i), \p{L} and Turkish Characters

Mert Özen Nov 15, 2026 15 min 5 views
Lookaround, Inline Flags and Unicode: Lookahead, Lookbehind, (?i), \p{L} and Turkish Characters

Lookahead and lookbehind check what comes after or before a position without consuming characters; variable-length lookbehind works in .NET and JavaScript, is bounded in PCRE2 and absent in ripgrep. We show inline flags, JavaScript's (?i:…) modifiers, \p{L} and the v flag, and, with real output, that whether ıssız matches ISSIZ in .NET depends on the environment because IgnoreCase follows the culture.

Version note: The examples in this post were run with .NET 10 (SDK 10.0.404), Node.js 26.13 LTS, GNU grep 3.12, PCRE2 10.49, ripgrep 15.2.0 and Python 3.14. The grep and rg commands run on Linux, in WSL or in Git Bash.

Guess First

In the catalog of Bookstore, the online bookstore used as the shared example across this site's series, some publishers send book titles in capital letters: ÇALIKUŞU, İNCE MEMED and, for the Turkish edition of Robinson Crusoe, ROBINSON CRUSOE: ISSIZ BİR ADADA 28 YIL. A customer types çalıkuşu, ince memed or ıssız in lower case into the search box. The API escapes the search term with Regex.Escape and searches with RegexOptions.IgnoreCase.

On the developer's Turkish Windows the code finds all three books. The same code runs in production in a Linux container. How many results come back there? And if the Angular side ran the same search with a pattern like /ıssız/iu, what would the browser find?

Lookahead and Lookbehind: Checks That Consume No Characters

A lookaround looks forward or backward from the current position and checks a condition, but adds no character to the match. It has four forms: (?=...) this comes next, (?!...) this does not come next, (?<=...) this comes before, (?<!...) this does not come before. Like the anchors from the first post they match a position; the difference is that you write the condition.

using System.Text.RegularExpressions;

// lookahead: a position followed by groups of three digits up to the end of the number
Console.WriteLine(Regex.Replace("Total: 1234567 TRY, 950 items", @"(?<=\d)(?=(?:\d{3})+\b)", "."));

// lookbehind: the amount after "price:", without "price:" in the match
Console.WriteLine(Regex.Match("price: 450 TRY", @"(?<=price:\s*)\d+").Value);

// negative lookahead: Payments lines that are not debug lines
string log = File.ReadAllText("data/app.log");
foreach (Match m in Regex.Matches(log, @"^(?!.* DBG ).*Bookstore\.Payments.*$", RegexOptions.Multiline))
    Console.WriteLine(m.Value.TrimEnd('\r'));

// a composition rule built from lookaheads
Regex rule = new(@"^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[^\w\s]).{8,}$");
foreach (string candidate in new[] { "Password1!", "Bookstore2026!", "correct horse battery staple" })
    Console.WriteLine($"{candidate,-30} {rule.IsMatch(candidate)}");
$ dotnet run dotnet/lookaround.cs
Total: 1.234.567 TRY, 950 items
450
2026-11-15T10:42:07Z INF Bookstore.Payments Payment 4711 authorized for BKS-2026-000123
2026-11-15T10:42:09Z WRN Bookstore.Payments Payment 4712 retried for BKS-2026-000124
2026-11-15T10:42:12Z ERR Bookstore.Payments Payment 4713 declined for BKS-2026-000126
Password1!                     True
Bookstore2026!                 True
correct horse battery staple   False
$ node js/lookaround.mjs
Total: 1.234.567 TRY, 950 items
450
2026-11-15T10:42:07Z INF Bookstore.Payments Payment 4711 authorized for BKS-2026-000123
2026-11-15T10:42:09Z WRN Bookstore.Payments Payment 4712 retried for BKS-2026-000124
2026-11-15T10:42:12Z ERR Bookstore.Payments Payment 4713 declined for BKS-2026-000126

Read the pattern in the first line. (?<=\d) means "there is a digit to my left", (?=(?:\d{3})+\b) means "to my right there are digits in multiples of three and then the number ends". The positions where both are true at once are where the thousands separator goes. Because the pattern consumes no characters, the replacement only inserts a dot at those positions; 950 was left alone because it has no position with a digit to the left and a multiple of three digits to the right.

The second line is the most common use of lookbehind: taking the value after a label without making the label part of the match. You can do the same with a group, price:\s*(\d+), and read Groups[1]; the advantage of lookbehind is that the match itself is the value you want. That is especially useful in replacements and with grep -o.

"Lines That Contain X but Not Y"

The most practical use of negative lookahead is searching logs. ^(?!.* DBG ).*Bookstore\.Payments.*$ says: stop at the start of the line, check that DBG appears nowhere in this line, then verify that the line contains Bookstore.Payments. Because the lookahead runs at the start of the line without consuming anything, it can look at the whole line. On the command line there are two ways to do this:

$ cat shell/lookaround.sh
#!/usr/bin/env bash
# PCRE2: lookaround works in grep -P
grep -P '^(?!.* DBG ).*Bookstore\.Payments' data/app.log
echo
# POSIX ERE has no lookaround: two greps in a pipe do the same job
grep -E 'Bookstore\.Payments' data/app.log | grep -v ' DBG '
echo
# \K drops what was matched so far from the output
grep -oP 'Payment \K[0-9]+(?= declined)' data/app.log
$ bash shell/lookaround.sh
2026-11-15T10:42:07Z INF Bookstore.Payments Payment 4711 authorized for BKS-2026-000123
2026-11-15T10:42:09Z WRN Bookstore.Payments Payment 4712 retried for BKS-2026-000124
2026-11-15T10:42:12Z ERR Bookstore.Payments Payment 4713 declined for BKS-2026-000126

2026-11-15T10:42:07Z INF Bookstore.Payments Payment 4711 authorized for BKS-2026-000123
2026-11-15T10:42:09Z WRN Bookstore.Payments Payment 4712 retried for BKS-2026-000124
2026-11-15T10:42:12Z ERR Bookstore.Payments Payment 4713 declined for BKS-2026-000126

4713

On the command line the second way is often more readable: each grep says one thing, and it works in every environment that follows POSIX. The single pattern with a lookahead is valuable when you have to do the job with a single Regex object in .NET or JavaScript: the filter box of a log viewer, an alert rule and the like. \K in the last line is specific to PCRE2: it drops the part matched so far from the result, and it also works where a variable-length lookbehind cannot.

Why Is a Password Rule Regex a Bad Policy?

The composition rule built from lookaheads is a favorite example of regex tutorials: at least one uppercase letter, one lowercase letter, one digit, one symbol and 8 characters. The output above shows what this rule rewards: Password1! and Bookstore2026! pass, a long passphrase of four words does not. The first two are exactly the kind of passwords at the top of guessing lists; the rule pushes users into that very shape.

NIST's digital identity guideline 800-63B-4, published in 2025, explicitly forbids composition rules that require a mix of character types and asks for at least 15 characters when a password is used on its own. The right checks are length and a check against lists of breached passwords; the details are in the "OWASP A07: Authentication Failures (1)" post of the Web Security (OWASP Top 10) series. What the lookahead teaches here is regex syntax, not policy.

Lookbehind Is Not the Same in Every Flavor

Lookahead is the same in all backtracking engines. Lookbehind gives different answers to the question "how far back may I look?". .NET and JavaScript accept lookbehind of any length; (?<=price:\s*) above worked on both sides. The others:

$ cat shell/lookbehind.sh
#!/usr/bin/env bash
# unbounded lookbehind: PCRE2 refuses it
grep -oP '(?<=price:\s*)[0-9]+' data/prices.txt
# bounded variable-length lookbehind: PCRE2 10.43+
grep -oP '(?<=price:\s{0,3})[0-9]+' data/prices.txt
# \K: forget what was matched so far
grep -oP 'price:\s*\K[0-9]+' data/prices.txt
# ripgrep's default engine has no lookaround, -P switches to PCRE2
rg -o '(?<=price: )[0-9]+' data/prices.txt
rg -oP '(?<=price:\s{0,3})[0-9]+' data/prices.txt
$ bash shell/lookbehind.sh
grep: length of lookbehind assertion is not limited
450
450
450
450
rg: regex parse error:
    (?:(?<=price: )[0-9]+)
       ^^^^
error: look-around, including look-ahead and look-behind, is not supported

Consider enabling PCRE2 with the --pcre2 flag, which can handle backreferences
and look-around.
450
450
$ python3 python/lookbehind.py
(?<=price: )\d+ ['450']
(?<=price:\s*)\d+ re.error: look-behind requires fixed-width pattern
Lookbehind.NETJavaScriptPCRE2 10.49 (grep -P)Python 3.14ripgrep (default)grep -E
Fixed length (?<=price: )yesyesyesyesnono
Bounded (?<=price:\s{0,3})yesyesyesnonono
Unbounded (?<=price:\s*)yesyesnononono

PCRE2 added variable-length lookbehind in version 10.43; the default upper limit is 255 characters, but the limit must be clear from the pattern. \s* is unbounded, so it was rejected. ripgrep's default engine does not support lookaround at all; the reason is the same as for backreferences, and we explain it in the fifth post with the RE2 family. When you want to try a pattern written for .NET with grep -P on a log, this is usually the first place you get stuck; \K is a good substitute in most cases.

Inline Flags: (?i), (?m), (?s), (?x), (?n)

Instead of passing options from outside the pattern, you can write them inside it. That helps when the pattern lives in a config file or a database and the code cannot pass options. .NET has five letters: i case-insensitive, m multiline, s the dot also matches line breaks, x ignore whitespace in the pattern and allow # comments, n capture only named groups. (?i) applies from where it stands to the end of the group, (?i:...) only inside the parentheses, and (?-i) turns it off:

using System.Text.RegularExpressions;

string level = "2026-11-15T10:42:12Z err Bookstore.Payments Payment 4713 declined";

// (?i) for the whole pattern, (?i:...) for one part, (?-i) to turn it off again
Console.WriteLine(Regex.IsMatch(level, @"(?i)ERR bookstore\.payments"));
Console.WriteLine(Regex.IsMatch(level, @"(?i:ERR) bookstore\.payments"));
Console.WriteLine(Regex.IsMatch(level, @"(?i)ERR (?-i)Bookstore\.Payments"));

// (?x) ignores pattern whitespace and allows # comments; (?n) captures only named groups
Regex order = new(@"(?xn)
    ^BKS -
    (?<year> [0-9]{4} ) -    # year
    ([0-9]{6}) $             # sequence, not captured because of n
");
Match m = order.Match("BKS-2026-000123");
Console.WriteLine($"{m.Success} groups={m.Groups.Count} year={m.Groups["year"].Value}");

// \w and \p{L} include Turkish letters in .NET
Console.WriteLine($"{Regex.IsMatch("Çalıkuşu", @"^\w+$")} {Regex.IsMatch("Çalıkuşu", @"^\p{L}+$")} {Regex.IsMatch("Çalıkuşu", @"^\p{Lu}\p{Ll}+$")}");
$ dotnet run dotnet/inline-flags.cs
True
False
True
True groups=2 year=2026
True True True

The second line is False because (?i:...) covers only ERR; bookstore stayed case-sensitive and did not match Bookstore in the log. Lines such as # year in the (?x) pattern are the pattern's own comments; the engine ignores them. Thanks to (?n) the sequence group was not captured, so Groups.Count is 2 (group 0 and year). We look at ways to keep large patterns readable separately in the eighth post.

JavaScript is different. Flags go after the pattern and apply to the whole pattern. Applying a flag to part of a pattern became possible with the modifiers feature, which reached all current browsers in September 2025; but only for i, m and s, and only in the bracketed (?i:...) form:

$ node js/modifiers.mjs
/(?i:ERR) Bookstore\.Payments/       true
/ERR (?-i:Bookstore)\.payments/i     true
/ERR (?-i:bookstore)\.payments/i     false
/(?i)ERR Bookstore\.Payments/        SyntaxError: Invalid regular expression: /(?i)ERR Bookstore\.Payments/: Invalid group
/(?x:ERR)/                           SyntaxError: Invalid regular expression: /(?x:ERR)/: Invalid group
/(?i-i:ERR)/                         SyntaxError: Invalid regular expression: /(?i-i:ERR)/: Repeated flag in flag group

A pattern written for .NET that starts with (?i)... throws a syntax error when moved to JavaScript. JavaScript has no x flag at all and no equivalent of n. If you want a portable pattern, pass the flags outside the pattern, in each platform's own way.

Task.NET RegexOptions.NET inlineJavaScript flagJavaScript modifier
Case-insensitiveIgnoreCase(?i)i(?i:...)
^/$ on every lineMultiline(?m)m(?m:...)
Dot also matches line breaksSingleline(?s)s(?s:...)
Whitespace and # commentsIgnorePatternWhitespace(?x)nonenone
Named groups onlyExplicitCapture(?n)nonenone

Unicode: \p{L}, Scripts and JavaScript's v Flag

In the first post we saw that \w and \d use different alphabets in different flavors. The way to say explicitly which alphabet you mean is Unicode properties: \p{L} any letter, \p{Lu} uppercase letter, \p{Ll} lowercase letter, \p{Nd} decimal digit. The category names are the same everywhere. When it comes to scripts, the syntax changes. Bookstore's catalog also has Russian editions:

$ cat data/titles.txt
Dune
Война и мир
Çalıkuşu
Anna Karenina
Анна Каренина
$ dotnet run dotnet/unicode-properties.cs
\p{IsCyrillic}       Война и мир | Анна Каренина
^\p{Lu}\p{Ll}+$      Dune | Çalıkuşu
\p{Script=Cyrillic}  Invalid pattern '\p{Script=Cyrillic}' at offset 10. Incomplete \p{X} character escape.
\p{Cyrillic}         Invalid pattern '\p{Cyrillic}' at offset 12. Unknown property 'Cyrillic'.
$ node js/unicode-properties.mjs
\p{Script=Cyrillic}  Война и мир | Анна Каренина
\p{sc=Cyrl}          Война и мир | Анна Каренина
^\p{Lu}\p{Ll}+$      Dune | Çalıkuşu
\p{IsCyrillic}       SyntaxError: Invalid regular expression: /\p{IsCyrillic}/u: Invalid property name
$ cat shell/unicode.sh
#!/usr/bin/env bash
# Unicode script property: PCRE2 and ripgrep
grep -P '\p{Cyrillic}' data/titles.txt
rg '\p{Script=Cyrillic}' data/titles.txt
# POSIX ERE has no \p: the backslash is ignored with a warning
grep -E '\p{L}' data/titles.txt
# letter categories: one uppercase letter followed by lowercase letters
grep -P '^\p{Lu}\p{Ll}+$' data/titles.txt
rg '^\p{Lu}\p{Ll}+$' data/titles.txt
# \w with Turkish letters
printf 'Çalıkuşu\n' | grep -P '^\w+$'
printf 'Çalıkuşu\n' | rg '^\w+$'
$ bash shell/unicode.sh
Война и мир
Анна Каренина
Война и мир
Анна Каренина
grep: warning: stray \ before p
Dune
Çalıkuşu
Dune
Çalıkuşu
Çalıkuşu
Çalıkuşu

.NET uses a Unicode block name, not a script: \p{IsCyrillic} covers the block from U+0400 to U+04FF; for the extra Cyrillic characters outside that block there are other blocks such as \p{IsCyrillicSupplement}. JavaScript wants \p{Script=Cyrillic} or its short form \p{sc=Cyrl}, and requires the u or v flag for it. PCRE2 accepts \p{Cyrillic}, ripgrep accepts \p{Cyrillic} and \p{Script=Cyrillic}, and grep -E has no \p. The lines below the script output show ^\p{Lu}\p{Ll}+$ and \w with Turkish letters in grep -P and ripgrep.

JavaScript has two more details. Without the u flag \p{L} is not a property but the plain text p{L}, and it does not even throw an error. The v flag that came with ES2024 builds on u and adds set operations inside a character class and "properties of strings":

// \w stays ASCII in JavaScript; \p{L} needs the u or v flag
console.log(/^\w+$/u.test("Çalıkuşu"), /^\p{L}+$/u.test("Çalıkuşu"), /^\p{Lu}\p{Ll}+$/u.test("Çalıkuşu"));

// without u, \p{L} is just the letters "p{L}"
console.log(/\p{L}/.test("p{L}"), /\p{L}/.test("Dune"));

// v flag: set subtraction and intersection inside a class
const nonAsciiLetter = /[\p{L}--[a-zA-Z]]/v;
console.log(nonAsciiLetter.test("Dune"), nonAsciiLetter.test("Çalıkuşu"));
console.log(/^[\p{Script=Cyrillic}&&\p{Lu}]/v.test("Война и мир"));

// properties of strings exist only with v
console.log(/^\p{RGI_Emoji}$/v.test("👍🏽"), /^\p{Emoji}$/u.test("👍🏽"));
$ node js/unicode.mjs
false true true
true false
false true
true
true false

[\p{L}--[a-zA-Z]] means "a letter that is not ASCII"; a short way to pick out a title with Turkish characters. && is intersection: "Cyrillic and uppercase". The thumbs-up emoji with a skin-tone modifier in the last line consists of two code points; \p{Emoji} looks at a single code point and fails, while \p{RGI_Emoji} also knows sequences of several code points. Code point details are in the Unicode post of the Number Systems and Data Representation series.

Turkish has four separate i letters: I and ı are one pair, İ and i another. Unicode's default rules pair I with i. Let's run the search from the guess with an explicit culture:

using System.Globalization;
using System.Text.RegularExpressions;

string[] catalog = ["ÇALIKUŞU", "İNCE MEMED", "ROBINSON CRUSOE: ISSIZ BİR ADADA 28 YIL"];
string[] terms = ["çalıkuşu", "ince memed", "ıssız"];

// culture name from the command line: tr-TR, en-US or "" (invariant)
if (args.Length > 0)
    CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(args[0]);
Console.WriteLine($"CurrentCulture: \"{CultureInfo.CurrentCulture.Name}\"");

foreach (string term in terms)
{
    Regex re = new(Regex.Escape(term), RegexOptions.IgnoreCase);
    Console.WriteLine($"  {term,-11} regex: {catalog.Count(re.IsMatch)}  tr-TR CompareInfo: {catalog.Count(t => ContainsTr(t, term))}");
}

// culture-aware search without regex: the culture is explicit, not inherited
static bool ContainsTr(string text, string term) =>
    CultureInfo.GetCultureInfo("tr-TR").CompareInfo.IndexOf(text, term, CompareOptions.IgnoreCase) >= 0;
$ dotnet run dotnet/turkish-search.cs -- tr-TR
CurrentCulture: "tr-TR"
  çalıkuşu    regex: 1  tr-TR CompareInfo: 1
  ince memed  regex: 1  tr-TR CompareInfo: 1
  ıssız       regex: 1  tr-TR CompareInfo: 1
$ dotnet run dotnet/turkish-search.cs -- en-US
CurrentCulture: "en-US"
  çalıkuşu    regex: 0  tr-TR CompareInfo: 1
  ince memed  regex: 1  tr-TR CompareInfo: 1
  ıssız       regex: 0  tr-TR CompareInfo: 1
$ dotnet run dotnet/turkish-search.cs -- ""
CurrentCulture: ""
  çalıkuşu    regex: 0  tr-TR CompareInfo: 1
  ince memed  regex: 0  tr-TR CompareInfo: 1
  ıssız       regex: 0  tr-TR CompareInfo: 1
$ DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 dotnet run dotnet/turkish-search.cs
CurrentCulture: ""
Unhandled exception. System.Globalization.CultureNotFoundException: Only the invariant culture is supported in globalization-invariant mode. See https://aka.ms/GlobalizationInvariantMode for more information. (Parameter 'name')
tr-tr is an invalid culture identifier.

The .NET answer to the guess: the result depends on the CurrentCulture at the moment the Regex object is created. In the Turkish culture all three were found. In en-US only ince memed was found; ı and I do not match in that culture. In the invariant culture none was found. .NET container images based on Alpine and Ubuntu chiseled do not include ICU, and apps in these images run in globalization-invariant mode; there is no culture data at all, and asking for tr-TR throws an exception as in the last run. On Windows you can try the same with $env:DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 in PowerShell. RegexOptions.CultureInvariant, on the other hand, always uses the invariant rules instead of taking the culture from the environment; that is the right choice for system identifiers such as file paths and URL schemes, not for searching Turkish text.

In JavaScript the i flag looks at no culture and does not know the Turkish pairs at all:

$ node js/turkish-search.mjs
çalıkuşu    iu: 0  tr-TR lower: 1
ince memed  iu: 0  tr-TR lower: 1
ıssız       iu: 0  tr-TR lower: 1

The fix is to lower-case both sides with toLocaleLowerCase("tr-TR") and search without the flag. On the command line, (*TURKISH_CASING), which came with PCRE2 10.45, and GNU grep's locale rules do the same job:

$ cat data/catalog-tr.txt
ÇALIKUŞU
İNCE MEMED
ROBINSON CRUSOE: ISSIZ BİR ADADA 28 YIL
INCE
$ cat shell/turkish-casing.sh
#!/usr/bin/env bash
# PCRE2 caseless matching follows Unicode, not Turkish rules
grep -iP 'ince' data/catalog-tr.txt
# (*TURKISH_CASING) at the start of the pattern: PCRE2 10.45+
grep -iP '(*TURKISH_CASING)ince' data/catalog-tr.txt
grep -iP '(*TURKISH_CASING)ıssız' data/catalog-tr.txt
# GNU grep -E uses the locale's case rules
LC_ALL=tr_TR.UTF-8 grep -iE 'ince' data/catalog-tr.txt
LC_ALL=en_US.UTF-8 grep -iE 'ince' data/catalog-tr.txt
$ bash shell/turkish-casing.sh
INCE
İNCE MEMED
ROBINSON CRUSOE: ISSIZ BİR ADADA 28 YIL
İNCE MEMED
INCE

I added INCE, written with English rules, as the last line for comparison. grep -iP 'ince' found only that one with Unicode rules. With (*TURKISH_CASING), ince found İNCE MEMED and ıssız found ISSIZ. grep -iE takes its result from the locale: Turkish rules in tr_TR.UTF-8, English rules in en_US.UTF-8.

In Production

Do not leave a culture-sensitive search to the culture of the environment. The developer machine, the CI agent and the production container run with different cultures; the same test passes on one and fails on another. If you search Turkish text, pass the culture explicitly in code: with CompareInfo, or by lower-casing both sides with ToLower(CultureInfo.GetCultureInfo("tr-TR")) and searching without IgnoreCase. Also know whether your container image runs in invariant mode; an app that needs culture data needs an image with ICU or ICU added to the image.

The thousands separator example is a regex exercise; the right way to format numbers is ToString("N0", culture), or Intl.NumberFormat in JavaScript. If you apply the same pattern to a whole text, it also adds dots to long digit sequences such as ISBNs.

If you write a validator that relies on Unicode in an Angular form, do not forget the u or v flag, and compare the same rule with \p{...} on the .NET side: relying on \w means rejecting Turkish names in the browser and accepting them on the server.

Lookarounds are not free. ^(?!.* DBG ) looks to the end of every line. On long lines and with many patterns full of lookaheads this matters; measure the cost as in the second post. Which constructs .NET's NonBacktracking option does not support is covered in the seventh post.

Common Pitfalls

Assuming variable-length lookbehind exists everywhere

(?<=price:\s*), which works in .NET and JavaScript, is an error in PCRE2 and Python, and ripgrep has no lookaround at all. Use \K or a capturing group.

Moving (?i) to JavaScript

JavaScript only knows the (?i:...) form, and only for i, m and s. A pattern that starts with (?i) throws an Invalid group error.

Assuming \w covers Turkish characters

It does in .NET, PCRE2 and ripgrep, but not in JavaScript, not even with the u flag. If you want letters, write \p{L}.

\p{L} without the u flag

In JavaScript it throws no error and searches for plain text. Make sure every pattern that uses a Unicode property has u or v.

Assuming IgnoreCase is culture-independent

In .NET the result depends on the culture at the moment the Regex is created. Pass the culture explicitly for Turkish text and use CultureInvariant for system identifiers.

A Small Experiment

★☆☆ Find the line in data/app.log that contains Bookstore.Payments and belongs to order BKS-2026-000126 with a single pattern that has two lookaheads. Does the order of the lookaheads change the result?

★★☆ Change the thousands separator pattern so that, in the text Order 9780441172719 total 1234567, it applies only to the number after the word total. Try it with a lookbehind in .NET and with \K in grep -P.

★★★ Write a search function for Bookstore's search endpoint that uses the tr-TR culture explicitly and gives the same result in .NET normally and in invariant mode. Since CultureInfo.GetCultureInfo("tr-TR") does not work in invariant mode, which way would you choose? Hint: think about why a container runs in invariant mode and what adding ICU costs.

That completes reading the syntax: how the engine tries positions, quantifiers and backtracking, groups and replacements, lookaround and Unicode. In the next post we move to the tools: we put the differences between .NET, JavaScript, PCRE2, POSIX's two regex syntaxes and RE2 into one table, and see why ripgrep supports neither lookaround nor backreferences and where globs and regex get mixed up.