Ben Sinclair
Posted on December 2, 2019
I like to make little helper scripts from time to time. Anything that solves more than an immediate, one-off problem.
Incentive
We've had a push at work to go through legacy projects and add README files to all custom modules. Some of these modules are ours and some are inherited technical debt from when we've taken over the codebase from another agency.
Wouldn't it be nice if there was an easy way to browse through READMEs? One that would work on a remote host, in a docker container, basically anywhere?
Automatic, Axiomatic, Hyyyydromatic
What is a README file?
Well, it's a text file called README
. Or Readme
, maybe. Or readme
? It might have an extension, like .md
or .txt
. I think anything else can be disregarded - we don't want to pick up any "README.bak" files, so let's use a little case-insensitive filter list.
You the real MVP
So we should start with a Minimum Viable Product, or at least an idea of what that could be.
- We want to search for potential README files and list them.
That's pretty Minimum, isn't it? It's one point!
find . -type f -iname readme -o -iname readme.md -o -iname readme.txt
Boom. Done. We can go home now, right?
Progressive enhancement
It's not just a Javascript thing.
There are tools which can make this script much nicer. command -v <name> > /dev/null
is the best way of checking if a command exists. It's more portable than which
.
It's also a lot easier to extend scripts when you cut them up into functions. That's what you do in regular programming, right? People seem to throw that all away when writing shell scripts, but it helps readability if nothing else.
Top tip: if you seem to have hundreds of lines after splitting things up, you should probably switch to a "real" programming language :)
Use a modern search tool to ignore VCS files
Ripgrep is my grep-replacement of choice, and it lets you search filenames instead of file contents.
It understands .ignore
and things like your git configuration.
configure_find_command() {
if command -v rg >/dev/null; then
find_command="rg --files --iglob readme --iglob readme.md --iglob readme.txt"
else
find_command="find . -type f -iname readme -o -iname readme.md -o -iname readme.txt"
fi
}
And that's where we put the list I was talking about.
If you're wondering why I didn't pass the path through to either find command, it's because I chose to cd
to the path separately. It makes it easier in the long run because some commands might have positional parameter requirements or return falsy exit codes that are ambiguous. I prefer to give people an explicit, "can't do that because..." error message where I can.
Let the user narrow down their search
We can use a fuzzy tool like fzf to allow the user to interactively select a file.
if ! command -v fzf >/dev/null; then
$find_command | sort
exit
fi
match="$($find_command | sort | fzf --tac --exit-0 --preview="$preview_command {}")"
Make the preview prettier
A progressive enhancement for a progressive enhancement! Woohoo.
If you don't know, bat is a syntax-highlighted drop-in replacement for cat
, at least in the way most people use cat
.
configure_preview_command() {
if command -v bat >/dev/null; then
preview_command="bat --color=always"
else
preview_command="head -n100"
fi
}
Magically open the chosen file with the most appropriate editor
This cascade will choose VSCode/VSCodium (because a lot of my colleagues use it) above all else.
Then we fall back to whatever your GUI environment has associated with the chosen file. open
is a Mac thing, and gnome-open
is a... Gnome thing. There are probably equivalents on other systems, but I'll leave that as an exercise for the class.
configure_open_command() {
if command -v code >/dev/null; then
open_command="code"
elif command -v gnome-open >/dev/null; then
open_command="gnome-open"
elif command -v open >/dev/null; then
open_command="open"
else
open_command="${EDITOR:-ls -lh}"
fi
}
Our last-ditch attempt to do something good looks a bit odd. ${EDITOR:-ls -lh}
means to use the $EDITOR
variable or default to ls -lh
if it's not set. If you're using a terminal regularly, you'll want to set it to your preferred editor. It's usually nano
or something like that.
Helpful, aren't we?
We should add some helpful bits and pieces to this script:
- A header comment giving an overview of the script's purpose
- A
--help
or-h
flag to display options
My style of header comment is usually a single line description, followed by a blank line, followed by need-to-know infonuggets.
# Open all files that contain a search pattern.
#
# Works with ripgrep if installed.
# Uses bat for highlighted previews if installed.
# Uses FZF if installed to let you narrow down the results.
Portability is not an afterthought!
This script should do its best not to be tied to a particular system. Foreign systems - Docker containers, especially - have unpredictable shells installed.
There's no guarantee that /bin/bash
is going to be there, for example.
If there's no need to stray from POSIX, let's commit to POSIX:
#!/bin/sh
Part of portability is simplicity. Keep the script readable and put everything into its own function. If you find something doesn't work on an unfamiliar system, you can narrow it down and use a conditional, but keep it obvious that's what you're doing.
Check, please
As always, running scripts through Shellcheck (or incorporating Shellcheck into your editor) is the best idea.
It hunts down all your bad habits and tells you off.
If you haven't used it before... then you have bad habits you didn't know about.
Here's the "final" script
#!/bin/sh
# Open all files that contain a search pattern.
#
# Uses ripgrep if installed.
# Uses bat for highlighted previews if installed.
# Uses FZF if installed to let you narrow down the results.
VERSION="1.0.0"
usage() {
command_name="$(basename "$0")"
printf "%s %s\n\n" "$command_name" "$VERSION"
printf "Preview/open README files.\n\n"
printf "USAGE:\n"
printf " %s <path> [options]\n\n" "$command_name"
printf "OPTIONS:\n"
printf " -h, --help\n"
printf " Show this help message.\n\n"
printf "COMPATIBILITY:\n"
printf "%s works best with fzf installed. Without it, a simple list of matching READMEs will be displayed.\n" "$command_name"
printf "If VSCode is installed, it will be used to open the selected README.\n"
printf "If ripgrep is installed, it will be used to ignore VCS files.\n"
printf "If bat is installed, it will be used for syntax-highlighted previews.\n\n"
}
parse_options() {
case "$1" in
-h|--help)
usage
exit 0
;;
"")
;;
esac
}
configure_find_command() {
if command -v rg >/dev/null; then
find_command="rg --files --iglob readme --iglob readme.md --iglob readme.txt"
else
find_command="find . -type f -iname readme -o -iname readme.md -o -iname readme.txt"
fi
}
configure_preview_command() {
if command -v bat >/dev/null; then
preview_command="bat --color=always"
else
preview_command="head -n100"
fi
}
configure_open_command() {
if command -v code >/dev/null; then
open_command="code"
elif command -v gnome-open >/dev/null; then
open_command="gnome-open"
elif command -v open >/dev/null; then
open_command="open"
else
open_command="${EDITOR:-ls -lh}"
fi
}
do_search() {
if [ -n "$1" ]; then
if ! cd "$1" 2>/dev/null; then
printf "Path not found: %s\n" "$1"
exit 2
fi
fi
if ! command -v fzf >/dev/null; then
$find_command | sort
exit
fi
match="$($find_command | sort | fzf --tac --exit-0 --preview="$preview_command {}")"
if [ -n "$match" ]; then
$open_command "$match"
fi
}
parse_options "$@"
configure_find_command
configure_preview_command
configure_open_command
do_search "$@"
Ok, this bit is an afterthought
What could we do to make it better?
- Support Ack, or the-silver-searcher or other ripgrep equivalents
- Support other syntax highlighters
- Support other fuzzy match tools
- Allow users to pass flags depending what they want to do with the selected files
- Allow users to search within files
- Allow multiple file selection
- Anything else you can think of!
--
Cover image by Ben White on Unsplash.
Posted on December 2, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.