LearnAI ToolsCareerPractice BuildsPlayContact
PerlBeginner~1.5 hours

Find & Replace Tool

Build a command-line script that finds and replaces text across many files.

Regex SubstitutionFile I/OCommand-Line Args

Overview

Find-and-replace across a batch of files is the job Perl was originally famous for — its name is even jokingly backronymed as "Practical Extraction and Report Language" for exactly this kind of text-wrangling. The core operation is `s/pattern/replacement/g`, Perl's substitution operator, which searches a string for every match of `pattern` and swaps in `replacement`, and the `g` flag is what makes it replace every occurrence in the string rather than stopping after the first.

By the end of this tutorial you will have a script that takes a search pattern, a replacement, and a filename glob from the command line via `@ARGV`, finds every matching file, and runs a substitution across each one — with a `--dry-run` flag that reports what would change without touching any file, and a `--backup` option that saves the original before overwriting it. That combination of "show me what would happen" and "keep the original just in case" is what turns a script that edits files in place from something risky into something safe to run against a real project.

What You'll Build
  • Command-line argument parsing from `@ARGV`, including `--dry-run` and `--backup` flags.
  • File discovery with Perl's built-in `glob()` function against a wildcard pattern.
  • A `read_file()` helper that slurps an entire file into one string with proper error checking.
  • A substitution step using `s///g` that also counts how many replacements were made.
  • A `write_file()` helper that overwrites a file only when not in dry-run mode, optionally backing up the original first.
  • A summary report listing every file touched and how many replacements were made in each.

Prerequisites

  • Scalars and arrays — including the special array `@ARGV` that holds command-line arguments.
  • Regular expressions — `s///` substitution, the `g` (global) and `i` (case-insensitive) flags.
  • File handling — `open`/`close`, reading and writing with file handles.
  • Subroutines — defining a `sub` and passing arguments through `@_`.
  • Perl's `glob()` function for expanding a wildcard pattern like `*.txt` into a list of matching filenames.

Project Structure

The whole tool is one script, `find_replace.pl`. It is organized into three small subroutines — `read_file()`, `write_file()`, and `process_file()` — called from a short `main`-style block at the bottom that parses `@ARGV`, expands the glob pattern into a file list, and loops over that list calling `process_file()` on each one. Keeping the file-reading and file-writing logic in their own subroutines means `process_file()`'s job is just "read, substitute, decide whether to write" without also juggling `open`/`close` boilerplate inline.

This script edits files in place, which is inherently a little dangerous — a typo in the search pattern could silently corrupt every matching file in a directory. That is exactly why `--dry-run` and `--backup` exist as first-class options here rather than being left as an afterthought: a script that modifies files on disk should always give the person running it a safe way to preview the change first.

Step 1: Read Command-Line Arguments From @ARGV

`@ARGV` is a special Perl array that automatically holds every command-line argument the script was invoked with, in order — it never needs to be populated manually. This script expects the search pattern and replacement as the first two positional arguments, an optional file glob as the third, and any `--dry-run`/`--backup` flags mixed in among them, so the flags are filtered out of `@ARGV` first with `grep`, leaving only the positional arguments behind.

use strict;
use warnings;
# --dry-run and --backup can appear anywhere in @ARGV; check for them and
# then strip them out, leaving only the positional arguments behind.
my $dry_run = grep { $_ eq '--dry-run' } @ARGV; # True (non-zero) if the flag is present anywhere
my $backup = grep { $_ eq '--backup' } @ARGV;
@ARGV = grep { $_ ne '--dry-run' && $_ ne '--backup' } @ARGV; # Remaining args are positional
# After flag removal, exactly three positional arguments must remain:
# search pattern, replacement text, and a file glob.
if (@ARGV != 3) {
die "Usage: perl find_replace.pl [--dry-run] [--backup] <search> <replace> <glob>\n";
}
my ($search, $replace, $file_glob) = @ARGV; # e.g. ('foo', 'bar', '*.txt')

Step 2: Expand a File Glob Pattern

Perl's built-in `glob()` function expands a shell-style wildcard pattern like `*.txt` into the list of filenames on disk that actually match it, the same way a shell expands `*.txt` before a command ever sees it. This matters on Windows in particular, where the shell does not always expand wildcards itself before handing them to a program — calling `glob()` inside the script guarantees the expansion happens consistently no matter what shell invoked it.

my @files = glob($file_glob); # e.g. glob('*.txt') => ('notes.txt', 'todo.txt', ...)
unless (@files) {
die "No files matched pattern '$file_glob'\n"; # Fail loudly rather than silently doing nothing
}
print "Found " . scalar(@files) . " file(s) matching '$file_glob'\n";

Step 3: Read a File's Full Contents

`read_file()` "slurps" the whole file into one scalar string rather than processing it line by line, which is the right approach here because `s///g` needs to see the entire file content at once to replace every match throughout it, including matches that might span what would otherwise be separate lines. Setting `local $/` (the input record separator) to `undef` inside the block is the standard Perl idiom for slurp mode — it tells the `<$fh>` read to treat the whole file as a single "line."

sub read_file {
my ($path) = @_;
open(my $fh, '<', $path)
or die "Could not open '$path' for reading: $!\n";
local $/; # Undefining the input record separator makes <$fh> read the whole file at once
my $content = <$fh>;
close($fh);
return $content; # A single scalar string holding the entire file's text
}
Example Usage

Click Run to see what this code prints.

Step 4: Substitute With s/// and Count Matches

In list context, `s///g` returns the number of replacements it made rather than just a true/false success flag — assigning its result to a scalar like `my $count = ($content =~ s/$search/$replace/g)` captures that number directly. `quotemeta()` escapes any regex metacharacters in `$search` before it is used, so a literal period or dollar sign typed on the command line is matched literally rather than being interpreted as regex syntax.

sub substitute_text {
my ($content, $search, $replace) = @_;
my $pattern = quotemeta($search); # Escapes regex metacharacters so $search is matched literally
my $count = ($content =~ s/$pattern/$replace/g); # /g replaces every occurrence; returns the match count
return ($content, $count // 0); # s///g returns undef (via //) rather than 0 when there were no matches
}

Step 5: Write Back, With a Dry-Run Option

`write_file()` is only ever called when `$dry_run` is false — in dry-run mode `process_file()` (built in the next step) reports what it would have replaced without calling this subroutine at all, so no file on disk is ever touched during a dry run. When `$backup` is true, the original content is written to `$path.bak` before the real file is overwritten, giving a way back to the pre-substitution version if the replacement turns out to be wrong.

sub write_file {
my ($path, $content, $make_backup) = @_;
if ($make_backup) {
open(my $backup_fh, '>', "$path.bak")
or die "Could not create backup '$path.bak': $!\n";
print $backup_fh read_file($path); # Write the file's current (pre-replacement) content as the backup
close($backup_fh);
}
open(my $fh, '>', $path)
or die "Could not open '$path' for writing: $!\n";
print $fh $content; # Overwrite the file with the substituted content
close($fh);
}

Step 6: Tie It Together Across Every Matched File

`process_file()` composes every subroutine written so far: read, substitute, then either write back or just report, depending on `$dry_run`. Looping this over `@files` from Step 2 is what turns a single-file operation into the batch find-and-replace tool the project is named after — the same four-line body runs once per matched file, with its own independent replacement count.

sub process_file {
my ($path, $search, $replace, $dry_run, $backup) = @_;
my $original = read_file($path);
my ($updated, $count) = substitute_text($original, $search, $replace);
if ($count == 0) {
print " $path: no matches\n";
return;
}
if ($dry_run) {
print " $path: would replace $count occurrence(s) [dry run, no changes made]\n";
} else {
write_file($path, $updated, $backup);
print " $path: replaced $count occurrence(s)" . ($backup ? " (backup saved as $path.bak)" : "") . "\n";
}
}
print "\nProcessing files:\n";
foreach my $file (@files) {
process_file($file, $search, $replace, $dry_run, $backup);
}
print "\nDone.\n";

Complete Code

Here is the full script assembled in the order it runs, ready to save as `find_replace.pl` and run with `perl find_replace.pl [--dry-run] [--backup] <search> <replace> <glob>`.

use strict;
use warnings;
my $dry_run = grep { $_ eq '--dry-run' } @ARGV;
my $backup = grep { $_ eq '--backup' } @ARGV;
@ARGV = grep { $_ ne '--dry-run' && $_ ne '--backup' } @ARGV;
if (@ARGV != 3) {
die "Usage: perl find_replace.pl [--dry-run] [--backup] <search> <replace> <glob>\n";
}
my ($search, $replace, $file_glob) = @ARGV;
sub read_file {
my ($path) = @_;
open(my $fh, '<', $path)
or die "Could not open '$path' for reading: $!\n";
local $/;
my $content = <$fh>;
close($fh);
return $content;
}
sub write_file {
my ($path, $content, $make_backup) = @_;
if ($make_backup) {
open(my $backup_fh, '>', "$path.bak")
or die "Could not create backup '$path.bak': $!\n";
print $backup_fh read_file($path);
close($backup_fh);
}
open(my $fh, '>', $path)
or die "Could not open '$path' for writing: $!\n";
print $fh $content;
close($fh);
}
sub substitute_text {
my ($content, $search, $replace) = @_;
my $pattern = quotemeta($search);
my $count = ($content =~ s/$pattern/$replace/g);
return ($content, $count // 0);
}
sub process_file {
my ($path, $search, $replace, $dry_run, $backup) = @_;
my $original = read_file($path);
my ($updated, $count) = substitute_text($original, $search, $replace);
if ($count == 0) {
print " $path: no matches\n";
return;
}
if ($dry_run) {
print " $path: would replace $count occurrence(s) [dry run, no changes made]\n";
} else {
write_file($path, $updated, $backup);
print " $path: replaced $count occurrence(s)" . ($backup ? " (backup saved as $path.bak)" : "") . "\n";
}
}
my @files = glob($file_glob);
unless (@files) {
die "No files matched pattern '$file_glob'\n";
}
print "Found " . scalar(@files) . " file(s) matching '$file_glob'\n";
print "\nProcessing files:\n";
foreach my $file (@files) {
process_file($file, $search, $replace, $dry_run, $backup);
}
print "\nDone.\n";

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Add a `--case-insensitive` flag that appends the `i` modifier to the substitution.
  • Add a `--regex` flag that skips `quotemeta()` so `$search` can be used as a real, unescaped regular expression.
  • Recurse into subdirectories using `File::Find` instead of a single flat `glob()` pattern.
  • Print a unified diff of each file's before/after content instead of just a replacement count, using the `Text::Diff` module.
  • Add an `--interactive` mode that asks "Replace in this file? (y/n)" before writing each one.

Summary

You built a batch find-and-replace tool that reads its instructions from `@ARGV`, expands a wildcard into a file list with `glob()`, and edits each file's contents in memory with `s///g` before deciding whether to actually write the result back. The dry-run and backup options you added are not incidental extras — they are the difference between a script that is safe to run against real files and one that risks silently destroying data on a typo, a distinction worth carrying into every file-modifying script you write from here on.