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

Regex on the Command Line and in the IDE: grep -E/-P, ripgrep, sed, git grep, PowerShell and Repo-Wide Find/Replace

Mert Özen Nov 15, 2026 14 min 5 views
Regex on the Command Line and in the IDE: grep -E/-P, ripgrep, sed, git grep, PowerShell and Repo-Wide Find/Replace

The safe order for a repo-wide regex change is: a clean working tree, the file list from git grep, a preview with rg -r, applying with sed -E and checking with git diff. We show the regex differences of grep, ripgrep, PowerShell and the IDEs, a real refactor in Bookstore that turns interpolated log calls into message templates, and why $ does not match in a CRLF file, together with .NET 11's AnyNewLine option.

Version note: The examples in this post were run with .NET 10 (SDK 10.0.404), .NET 11 (SDK 11.0.100) for one example, Node.js 26.13 LTS, GNU grep 3.12, GNU sed 4.10, ripgrep 15.2.0, git 2.43, PowerShell 7.7 and PCRE2 10.49. The grep, sed, rg and git commands run on Linux, in WSL or in Git Bash. Visual Studio, Rider and Visual Studio Code were not run for this post; the information in the IDE section comes from their official documentation.

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, a customer asks why the payment for order BKS-2026-000126 was declined. Ayşe downloads the log file of the application running on a Windows server and searches it on her Linux machine. When she opens the file she can see with her own eyes that the line ends with this number:

grep -c 'BKS-2026-000126$' app.log

What does the command return? If she read the same file in .NET with File.ReadAllText and searched for BKS-\d{4}-\d{6}$ with RegexOptions.Multiline, how many order numbers would she find? And in JavaScript with the m flag?

Searching a Repo with grep and ripgrep

The sample repo of this post is a small slice of three Bookstore services: OrderService.cs, PaymentService.cs and BookService.cs under sample/. All of them contain log calls with string interpolation such as logger.LogInformation($"Order {orderNumber} shipped"); towards the end of the post we turn them into message templates. First we find them:

$ cat shell/search-logs.sh
#!/usr/bin/env bash
# -o only the match, -i case-insensitive, -w whole words only
grep -oiwE 'payment [0-9]+' data/app.log
echo '---'
# -r recursive, -n line number, --include file filter
grep -rnE --include='*.cs' 'Log[A-Za-z]+\(\$"' sample
echo '---'
# -l only file names, -c count per file
grep -rcE 'Log[A-Za-z]+\(\$"' sample
echo '---'
# rg: recursive by default, -t cs file type, --sort path stable order
rg -n --sort path -t cs 'Log\w+\(\$"' sample
echo '---'
# -g glob filter: everything except Bookstore.Ordering
rg -l --sort path -g '!**/Bookstore.Ordering/**' 'Log\w+\(\$"' sample
echo '---'
# -U: the pattern may span lines (\s also matches \n)
rg -n -U 'Log\w+\(\s*\$"' sample/Bookstore.Payments
$ bash shell/search-logs.sh
Payment 4711
Payment 4712
Payment 4713
---
sample/Bookstore.Payments/PaymentService.cs:10:            logger.LogInformation($"Payment {paymentId} authorized");
sample/Bookstore.Catalog/BookService.cs:11:        logger.LogDebug($"Cache miss for {isbn}");
sample/Bookstore.Catalog/BookService.cs:14:            logger.LogWarning($"Book {isbn} is out of stock");
sample/Bookstore.Ordering/OrderService.cs:9:        logger.LogInformation($"Order {order.Number} created");
sample/Bookstore.Ordering/OrderService.cs:16:        logger.LogInformation($"Order {orderNumber} shipped");
sample/Bookstore.Ordering/OrderService.cs:22:        logger.LogWarning($"Order {orderNumber} cancelled: {reason}");
---
sample/Bookstore.Payments/PaymentService.cs:1
sample/Bookstore.Catalog/BookService.cs:2
sample/Bookstore.Ordering/OrderService.cs:3
---
sample/Bookstore.Catalog/BookService.cs:11:        logger.LogDebug($"Cache miss for {isbn}");
sample/Bookstore.Catalog/BookService.cs:14:            logger.LogWarning($"Book {isbn} is out of stock");
sample/Bookstore.Ordering/OrderService.cs:9:        logger.LogInformation($"Order {order.Number} created");
sample/Bookstore.Ordering/OrderService.cs:16:        logger.LogInformation($"Order {orderNumber} shipped");
sample/Bookstore.Ordering/OrderService.cs:22:        logger.LogWarning($"Order {orderNumber} cancelled: {reason}");
sample/Bookstore.Payments/PaymentService.cs:10:            logger.LogInformation($"Payment {paymentId} authorized");
---
sample/Bookstore.Catalog/BookService.cs
sample/Bookstore.Payments/PaymentService.cs
---
sample/Bookstore.Payments/PaymentService.cs:10:            logger.LogInformation($"Payment {paymentId} authorized");
sample/Bookstore.Payments/PaymentService.cs:14:        logger.LogError(
sample/Bookstore.Payments/PaymentService.cs:15:            $"Payment {paymentId} declined");

-w in the first command matters: Payment inside Bookstore.Payments is part of a longer word, so it was skipped and only standalone words such as Payment 4711 came through. With -i the lowercase pattern found uppercase text. grep -r walks the files in the order the directory lists them and does not sort them; that is why Payments came first in the output. If you want the same order on every run, use --sort path in ripgrep; it turns off ripgrep's parallel search and makes it a little slower on large repos.

There are three places where ripgrep differs from grep in daily work. It descends into directories on its own. Inside a Git repo it reads .gitignore and skips directories such as bin/, obj/ and node_modules/. It selects files with -t cs for known file types and with -g using the globs we saw in the fifth post; a leading ! excludes that glob.

The last command shows the line boundary of regex. By default grep and ripgrep search each line on its own; that is why the LogError( call split over two lines in PaymentService.cs never showed up in the earlier searches. rg -U searches the file as a whole, and \s* can match the line break too. We must not forget this line when we make the bulk change.

Bulk Changes: Preview, Apply, Check with git diff

In .NET a log message of the form $"Order {orderNumber} shipped" builds a new string on every call and goes to the log as a single piece of plain text. A message template, "Order {OrderNumber} shipped", orderNumber, carries the value as a separate property; in a log tool such as Seq you can filter by OrderNumber. The reasoning is in the "What to Log and What Not To" post of the Logging in .NET series. We look at the regex side of the conversion.

The rule is this: before a repo-wide change the working tree must be clean, meaning everything is committed. That way the change shows up on its own in git diff, and if you do not like it, git restore . takes it back. The script does this in a throwaway Git copy of sample/:

$ cat shell/refactor-logs.sh
#!/usr/bin/env bash
set -euo pipefail
# work on a throwaway git copy of sample/ so every change can be reviewed and undone
repo=$(mktemp -d)
cp -r sample/. "$repo"
cd "$repo"
printf 'obj/\n' > .gitignore
git init -q && git add . && git -c user.name=Ada -c user.email=ada@example.com commit -qm "before refactor"
# build output that is not tracked by git
mkdir -p Bookstore.Catalog/obj
echo 'logger.LogDebug($"Generated {id}");' > Bookstore.Catalog/obj/Generated.cs

# one placeholder, a plain identifier: Log...($"text {name} text")
find='(Log[A-Za-z]+)\(\$"([^"{]*)\{([A-Za-z_][A-Za-z0-9_]*)\}([^"{]*)"\)'

echo "== which files?"
echo "grep -r: $(grep -rlE "$find" . | wc -l) files, git grep: $(git grep -lE "$find" | wc -l) files"

echo "== preview (rg -r changes nothing on disk)"
rg --sort path -n -o "$find" -r '$1("$2{$3}$4", $3)' .

echo "== apply (GNU sed, \u upper-cases the next character)"
git grep -lE "$find" | xargs sed -E -i "s/$find/\1(\"\2{\u\3}\4\", \3)/"
git diff --stat
git diff -U0 | grep '^[-+] '

echo "== still interpolated"
git grep -nE 'Log[A-Za-z]+\(\s*(\$"|$)'
rm -rf "$repo"
$ bash shell/refactor-logs.sh
== which files?
grep -r: 4 files, git grep: 3 files
== preview (rg -r changes nothing on disk)
./Bookstore.Catalog/BookService.cs:11:LogDebug("Cache miss for {isbn}", isbn)
./Bookstore.Catalog/BookService.cs:14:LogWarning("Book {isbn} is out of stock", isbn)
./Bookstore.Ordering/OrderService.cs:16:LogInformation("Order {orderNumber} shipped", orderNumber)
./Bookstore.Payments/PaymentService.cs:10:LogInformation("Payment {paymentId} authorized", paymentId)
== apply (GNU sed, \u upper-cases the next character)
 Bookstore.Catalog/BookService.cs     | 4 ++--
 Bookstore.Ordering/OrderService.cs   | 2 +-
 Bookstore.Payments/PaymentService.cs | 2 +-
 3 files changed, 4 insertions(+), 4 deletions(-)
-        logger.LogDebug($"Cache miss for {isbn}");
+        logger.LogDebug("Cache miss for {Isbn}", isbn);
-            logger.LogWarning($"Book {isbn} is out of stock");
+            logger.LogWarning("Book {Isbn} is out of stock", isbn);
-        logger.LogInformation($"Order {orderNumber} shipped");
+        logger.LogInformation("Order {OrderNumber} shipped", orderNumber);
-            logger.LogInformation($"Payment {paymentId} authorized");
+            logger.LogInformation("Payment {PaymentId} authorized", paymentId);
== still interpolated
Bookstore.Ordering/OrderService.cs:9:        logger.LogInformation($"Order {order.Number} created");
Bookstore.Ordering/OrderService.cs:22:        logger.LogWarning($"Order {orderNumber} cancelled: {reason}");
Bookstore.Payments/PaymentService.cs:14:        logger.LogError(

Let us go step by step. The pattern in the find variable is deliberately narrow: a single placeholder with a plain variable name inside it. The [^"{]* groups make sure the rest of the text contains no other { or quote; the same negated class rule as in the second post. You could widen the pattern to cover expressions such as {order.Number}, but then you also have to decide how to generate the placeholder name. Leaving to regex the part it can do safely and doing the rest by hand is less risky.

The "which files" line shows the difference of git grep. grep -r also found the generated file under obj/; git grep searches only the files Git tracks. Taking the file list for a bulk change from git grep -l is a good habit, so that you do not change build output, node_modules or someone else's uncommitted file.

The preview uses rg -r: ripgrep's replace option changes only the output and writes nothing to disk. The replacement syntax is $1, $2; with -o only the part that will change is shown. The placeholder names start with a lowercase letter here, because ripgrep's replacement has no case conversion.

The change is applied with GNU sed. In sed's replacement \u upper-cases the next character: orderNumber became OrderNumber in the template, while the variable name passed as the argument stayed the same. git diff --stat shows how many lines changed in which files, and git diff -U0 shows the changed lines without context. The last command lists what is left: {order.Number}, the line with two placeholders and the LogError( call split over two lines. These three lines get fixed by hand; .NET's CA2254 analyzer can also flag message templates that are not constant, as the Logging series explains.

sed's -i option is not portable across platforms. In GNU sed -i on its own edits the file in place, and a backup suffix is written attached to it: -i.bak. BSD sed on macOS always expects a suffix argument after -i; if you write sed -i 's/a/b/' file it takes s/a/b/ as the suffix and the file name as the sed command and fails. The correct form on BSD is sed -i '' ..., but in GNU sed the same '' becomes an empty sed script and the real command is taken as a file name. The only form that works on both is sed -i.bak ... followed by deleting the backups. If your team runs scripts on different systems, you need to know this difference.

PowerShell on Windows: Select-String, -match and -replace

If there is no grep on Windows (you are not using Git Bash or WSL), PowerShell has its own tools, and all of them use .NET regex:

# Select-String is the PowerShell counterpart of grep; it is case-insensitive by default
Get-ChildItem sample -Recurse -Filter *.cs | Sort-Object Name |
    Select-String -Pattern 'Log\w+\(\$"' |
    ForEach-Object { '{0}:{1}: {2}' -f $_.Filename, $_.LineNumber, $_.Line.Trim() }
'---'
# -match fills $Matches; -cmatch is the case-sensitive form
'BKS-2026-000123' -match '^bks-(?<year>\d{4})'
$Matches.year
'BKS-2026-000123' -cmatch '^bks-'
'---'
# -replace uses .NET syntax: in double quotes PowerShell expands $2 and $1 as variables first
'Herbert, Frank' -replace '(\w+), (\w+)', '$2 $1'
'Herbert, Frank' -replace '(\w+), (\w+)', "$2 $1"
'Herbert, Frank' -replace '(\w+), (\w+)', "`$2 `$1"
$ pwsh -NoProfile -File powershell/search-replace.ps1
BookService.cs:11: logger.LogDebug($"Cache miss for {isbn}");
BookService.cs:14: logger.LogWarning($"Book {isbn} is out of stock");
OrderService.cs:9: logger.LogInformation($"Order {order.Number} created");
OrderService.cs:16: logger.LogInformation($"Order {orderNumber} shipped");
OrderService.cs:22: logger.LogWarning($"Order {orderNumber} cancelled: {reason}");
PaymentService.cs:10: logger.LogInformation($"Payment {paymentId} authorized");
---
True
2026
False
---
Frank Herbert
 
Frank Herbert

There are three details. First, PowerShell's regex operators are case-insensitive by default: -match '^bks-' found BKS; for case-sensitive comparison there are -cmatch, -creplace and Select-String -CaseSensitive. That is the opposite default from grep and from .NET's Regex class. Second, a successful -match automatically fills the $Matches variable, and you can reach named groups as $Matches.year.

The third is the most common mistake. -replace uses .NET's $1 syntax, but in a double-quoted string PowerShell first expands $2 and $1 as its own variables. These variables are not defined, so the replacement became a single space, which produced the blank-looking line in the output; there is no error either. Write the replacement in single quotes, and if you need double quotes, escape $ with a backtick. More PowerShell details on this topic come in the upcoming PowerShell Scripting series.

Regex Find/Replace in the IDE: VS Code, Visual Studio and Rider

You can do the same refactor in the IDE too; the advantage is that you see the result in every file and can approve it one by one. I did not run the three IDEs on this machine; the following comes from each one's official documentation:

VS CodeVisual StudioRider
Search in filesCtrl+Shift+FCtrl+Shift+F, Ctrl+Shift+H to replaceCtrl+Shift+F, Ctrl+Shift+R to replace
Turn on regexthe regex button in the search boxAlt+EAlt+X
Regex syntaxsearch across files uses ripgrep.NETthe docs point to Java's Pattern syntax
Group replacement$1$1, ${name}$1
Case conversion\u, \l, \U, \Lnot in the docs\u, \l, \U...\E, \L...\E
Multi-line searchnew line in the box with Ctrl+Enterwith \r?\nCtrl+Shift+Enter
File filterinclude/exclude globsFile types and folder setsFile mask

Here is what the rows mean in practice. In VS Code you can do the refactor exactly as above: put the pattern from the find variable in the search box, $1("$2{\u$3}$4", $3) in the replace box, and **/*.cs in files to include. While you type in the replace box VS Code shows the pending changes as a diff, and you can approve each file separately. According to its documentation, search across files is done with ripgrep; the ripgrep behavior from the fifth post applies here too.

Visual Studio uses .NET regex; so the inline flags and lookarounds from the fourth post work as they are, and the replacement has $1 and ${name}. Its documentation does not mention case conversion for replacements; if you want placeholder names to start with an uppercase letter you need a second step. The documentation's own note matters too: to rename a variable or a method use rename refactoring instead of find/replace, because it understands the meaning of the code and regex does not. Rider's documentation refers to Java's regex documentation as its syntax reference and supports $1 and case conversions such as \u in the replace box.

In all three IDEs the file filter is a glob and the search is a regex. The distinction from the fifth post is useful here too.

CRLF and $: Files That Come from Windows

Back to the guess. On Windows lines end with \r\n (CRLF), on Linux with \n only. In most tools regex's $ recognizes only \n; \r stays an ordinary character of the line:

$ cat shell/crlf.sh
#!/usr/bin/env bash
# a copy of the log with Windows line endings
crlf=$(mktemp)
sed 's/$/\r/' data/app.log > "$crlf"
tail -1 "$crlf" | od -c | tail -2
# $ is before \n, the \r is still part of the line
grep -c 'BKS-2026-000126$' data/app.log
grep -c 'BKS-2026-000126$' "$crlf"
grep -c $'BKS-2026-000126\r$' "$crlf"
# rg --crlf: $ also matches before \r\n
rg -c 'BKS-2026-000126$' "$crlf" || echo "rg: 0"
rg -c --crlf 'BKS-2026-000126$' "$crlf"
rm -f "$crlf"
$ bash shell/crlf.sh
0000120   0   0   1   2   6  \r  \n
0000127
1
0
1
rg: 0
1

The grep answer to the guess is 0. od -c shows the end of the line character by character: after the 6 come \r and \n, and $ is at the position before \n; the \r sits in between. Putting \r into the pattern with Bash's $'...' string, or ripgrep's --crlf option, solves it. The same text in .NET and JavaScript:

using System.Text.RegularExpressions;

// the log file as it arrives from a Windows machine: every line ends with \r\n
string text = File.ReadAllText("data/app.log").ReplaceLineEndings("\r\n");

// $ in Multiline matches only before \n, so the \r in front of it blocks the match
Console.WriteLine(Regex.Count(text, @"BKS-\d{4}-\d{6}$", RegexOptions.Multiline));
Console.WriteLine(Regex.Count(text, @"BKS-\d{4}-\d{6}\r?$", RegexOptions.Multiline));

// . matches \r, so .+$ carries the \r into the value
string last = Regex.Match(text, @"declined.+$", RegexOptions.Multiline).Value;
Console.WriteLine(last.Replace("\r", "\\r"));
import { readFileSync } from "node:fs";

// same text in JavaScript: \r is a line terminator for ^, $ and .
const text = readFileSync("data/app.log", "utf8").replace(/\n/g, "\r\n");

console.log(text.match(/BKS-\d{4}-\d{6}$/gm).length);
console.log(text.match(/declined.+$/m)[0].replace("\r", "\\r"));
$ dotnet run dotnet/crlf.cs
0
3
declined for BKS-2026-000126\r
$ node js/crlf.mjs
3
declined for BKS-2026-000126

.NET found no order number at all, and all three with \r?$. The sneakier one is the third line: the value taken with .+$ carries a \r at the end. If you use that value as a key in a dictionary or compare it with another string, it will not be equal, and you cannot see why on the screen. JavaScript counts \r as a line terminator too; the same pattern gave three results and the value is clean.

.NET 11 brings a new option for this. According to the RegexOptions page on Microsoft Learn, RegexOptions.AnyNewLine makes ^, $ and . recognize \r\n, \r, \n and Unicode's other line terminators, and treats \r\n as a single line break. This file is in a separate folder because it needs the .NET 11 SDK:

using System.Text.RegularExpressions;

// .NET 11: AnyNewLine makes ^, $ and . treat \r\n, \r and \n as line breaks
string text = File.ReadAllText("data/app.log").ReplaceLineEndings("\r\n");
RegexOptions options = RegexOptions.Multiline | RegexOptions.AnyNewLine;

Console.WriteLine(Regex.Count(text, @"BKS-\d{4}-\d{6}$", options));
Console.WriteLine(Regex.Match(text, @"declined.+$", options).Value.Replace("\r", "\\r"));

// it cannot be combined with NonBacktracking or ECMAScript
try
{
    _ = new Regex("x$", options | RegexOptions.NonBacktracking);
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine(ex.Message);
}
$ dotnet run dotnet11/any-newline.cs
3
declined for BKS-2026-000126
Specified argument was out of the range of valid values. (Parameter 'options')

If you stay on .NET 10 there are two ways: read the file line by line with File.ReadAllLines (as we did in the third post; the line endings are dropped anyway), or add \r? to the pattern. For files inside the repo the lasting fix is to standardize line endings: as in this series' snapshots, writing * text=auto eol=lf into .gitattributes makes Git keep LF line endings even for files checked out on Windows.

In Production

Always make bulk changes in a clean working tree, and preview first. The order is: is git status empty, see what will change with rg -r or the IDE's preview, apply, check with git diff --stat that the file and line counts are what you expect, run the build and the tests, then commit. If something goes wrong, git restore . is one command.

Take the file list from git grep -l or rg -l; grep -r and find also go into bin/, obj/, node_modules/ and .git/. Changing a file inside .git/ with sed can break the repo.

Keep generated code (Entity Framework Core migrations, *.g.cs files, clients generated from OpenAPI) out of the bulk change, or regenerate it from its source. A change made by hand is lost at the next generation.

When you search a log file, know where it came from. Text that comes from a Windows server, a Windows container or a CSV file sent by a customer normally has CRLF. Looking once with the file command or od -c takes less time than half an hour of "why doesn't it find it".

Common Pitfalls

Running the change without a commit

sed -i or the IDE's Replace All offer no undo step. Commit first, then change; git diff lets you review and git restore lets you undo.

$ in a file with CRLF

The default $ of grep, .NET and ripgrep does not match before \r. Write \r?$, use rg --crlf, turn on RegexOptions.AnyNewLine in .NET 11, or normalize the line endings.

Writing the replacement in double quotes in PowerShell

"$2 $1" expands to PowerShell variables and the result is empty. Use single quotes.

Assuming BSD sed and GNU sed are the same

sed -i expects different arguments in the two. The portable form is sed -i.bak.

Using regex instead of rename refactoring

To rename a method or a property, the IDE's rename understands the meaning of the code; regex also changes strings, comments and members of other classes that carry the same name.

A Small Experiment

★☆☆ Count the ERR and WRN lines in data/app.log with a single grep -cE command, then do the same in PowerShell with Select-String.

★★☆ Extend the pattern in refactor-logs.sh so that it also converts calls with two placeholders ($"Order {orderNumber} cancelled: {reason}"). What new problems show up in the preview?

★★★ Write the same refactor in .NET with Regex.Replace and a MatchEvaluator: find every placeholder, turn its name into PascalCase and build the argument list in order. Which option do you need to also catch the LogError( call split over two lines?

We are done with the command line and the IDE. In the next post we look only at .NET: the difference between the interpreter, RegexOptions.Compiled and [GeneratedRegex] and how to measure it with BenchmarkDotNet, the allocation-free Span APIs, timeouts and NonBacktracking, and ISBN validation and slug generation with Turkish characters for Bookstore.