Read me first
Tags: cli, terminal, tutorial
Published: 2019-12-02 09:42:25

---
title: Read me first
published: true
description: Creating a command-line tool to browse README files.
tags: cli, terminal, tutorial
cover_image: https://thepracticaldev.s3.amazonaws.com/i/dttdmduwhp58cuzg2wfm.jpg
series: Helper scripts
---

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!

```sh
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.

```sh
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](https://github.com/junegunn/fzf) to allow the user to interactively select a file.

```sh
  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](https://github.com/sharkdp/bat) is a syntax-highlighted drop-in replacement for `cat`, at least in the way most people use `cat`.

```sh
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.

```sh
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.

```sh
# 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:

```sh
#!/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](https://github.com/koalaman/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

```sh
#!/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](https://unsplash.com/@benwhitephotography)                                                                               on Unsplash.