I recently had to change 189 files in our code base, all in almost the same way. Rather than doing it manually, I decided to brush up on my command-line text manipulation ... and ended up taking it further than I expected.
The Mission
The changes were pretty simple. In our API code, we have TypeScript definitions for every endpoint. They look something like this:
interface API {
"/api/widget/:widgetId": {
GET: {
params: {
widgetId: MongoId;
};
response: WidgetResponse;
}
}
}
You'll notice the params
are defined twice: once in the URL key string (as :widgetId
) and again in the GET
attribute (under params
); we are moving to a TypeScript template literal string parser to get the type information out of the URL key string itself, and so I wanted to remove the params
key from these definitions. But with 189 files to change, the usual manual approach wasn't so inviting.
So, I set myself the challenge of doing it via the command line.
Step 1: Remove the lines
I'll be honest, when I started, this was the only step I had in mind. I needed to do a multi-line find-and-replace, to remove params: { ... }
; a quick grep
showed me that this pattern was unique to the places I wanted to change; however, I could have narrowed the set of files I was searching to just our endpoints in src/resources
if necessary. For doing the replacement, I thought sed
might be the right tool, but new lines can be challenging to work with ... so I ended up learning my first bit of perl
to make this work.
Here's what I ended up doing (I've added line breaks for readability):
grep -r --files-with-matches "params: {" ./src | while read file;
do
perl -0777 -pi -e 's/ *params: {[^}]*};\n//igs' "$file";
done
This one-liner uses grep
to recursively search my src
directory to find all the files that have the pattern I want to remove. Actually, I usually reach for ag
(the silver searcher) or ripgrep
, but grep
is already available pretty much everywhere. Then, we'll loop over the files and use perl to replace that content.
Like I said, this was my first line of perl, but I'm fairly sure it won't be my last. This technique of using perl for find-and-replace logic is called a perl pie. Here's what it does:
0777
means perl will read in the entire filep
wraps that one-liner in the conventional perl script wrapper.i
means that perl will change the file in place; if you aren't making this change in a git repo like I am, you can do something likei.backup
and perl will create a copy of the original file, so you aren't making an irreversible change.e
expects an argument that is your one-line program
Oh, and the program itself:
s/ *params: {[^}]*};\n//igs
This is typical 's/find/replace/flags' syntax, and you know how regexes work. The flags are g
lobal, case-i
nsensitive, and s
ingle-line (where .
will also match newlines).
So, this changed the 189 files, in exactly the way I wanted. At this point, I was feeling great about my change. Reviewed the changes, committed it and started the git push
.
Step 2: Remove unused imports
Not so fast. Our pre-push hooks caught a TypeScript linting issue:
error TS6133: 'MongoId' is declared but its value is never read.
5 import { MongoId } from "our-types";
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Ah, yeah, that makes sense. URL parameters are strings, but we have a MongoId
type that's a branded string. I forgot about this step, but that's why we have pre-push checks! We'll need to remove those imports.
How can we do this? Well, let's get a list of the files we changed in our most recent commit:
git show --name-only | grep ^src
We add the grep
to only find the files within our top-level src
directory (and to remove the commit information).
Then, we need to find all the files that include MongoId
only once. If a file references MongoId
multiple times, then we don't want to remove the import, because clearly we're still using it. If the file only references MongoId
once, we can remove the import ... but we have to consider that it might not be the only thing we're importing on that line. For starters, grep
's -c
flag to count the number of occurrences per file.
for file in $(git show --name-only | grep ^src)
do
grep -c MongoId "$file"
done
A simple for
loop works here, because I know the only whitespace is the linebreaks between the file names. Once we have the count, we can check to see that there's only 1 match:
for file in $(git show --name-only | grep ^src)
do
if [ $(grep -c MongoId "$file") = 1 ]; then; echo "..."; fi
done
We're using an if
statement here, to check that the occurrence count is 1. If it is, we want to do something. But what? Remember, we might be importing multiple things on that line, so that leaves us with three possible actions:
- Remove the whole line when
MongoId
is the only item imported. - Remove
MongoId,
when it's the first item imported on that line. Don't miss that following comma! - Remove
, MongoId
when it's not the first item on the that line. Don't miss the preceding comma!
There are many ways we could do this, so let's have some fun with reading input from the command line! To be clear, this isn't the best way to do it. We could easily match our three cases above with perl
or sed
. But we've already used that pattern in this project, and reading input in a shell script is an incredibly useful tool to have in your toolbox.
At this point, we probably want to move this into an actual shell script, instead of running it like a one-off on the command line:
#!/bin/bash
for file in $(git show --name-only | grep ^src)
do
if [ $(grep -c MongoId "$file") = 1 ]
then
echo ""
echo "====================="
echo "1 - remove whole line"
echo "2 - remove first import"
echo "3 - remove other import"
echo ""
echo "file: $file"
echo "line: $(grep MongoId "$file" | grep -v "^//")"
echo -n "> "
read choice
echo "your choice: $choice"
case "$choice" in
1)
sed -i '' "/MongoId/d" "$file";
;;
2)
perl -i -pe "s/MongoId, ?//" "$file";
;;
3)
perl -i -pe "s/, ?MongoId//" "$file";
;;
*)
echo "nothing, skipping line"
;;
esac
fi
done
Don't be intimidated by this, it's mostly echo
statements. But we're doing some pretty cool stuff here.
Inside our if
statement, we start by echoing some instructions, as well as the file name and the line that we're about to operate on. Then, we read
an input from the command line. At this point, the script will pause and wait for us to type some input. Once we hit <enter>
the script will resume and assign the value we entered to our choice
variable.
Once we have determined our choice
, we can do the correct replacement using the bash equivalent of a switch/case
statement. For case 1, we're using sed
's delete line command d
. For cases 2 and 3, we'll use perl
instead of sed
, because it will operate only on the matched text, and not on the whole line. Finally, the default case will do nothing.
Running this script, we can now walk through the files, one by one, and review each change. It reduces our work to one keystroke per file, which is way less than opening each file, finding the line, removing the right stuff.
And that's it! While we don't use command-line editing commands every day, keeping these skills sharp will speed up your workflow when the right task comes along.