loading...
Cover image for Read me first

Read me first

moopet profile image Ben Sinclair Updated on ・5 min read

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 Dec 2 '19 by:

moopet profile

Ben Sinclair

@moopet

I've been a professional C, Perl, PHP and Python developer. I'm an ex-sysadmin. Back in the day, I had a geekcode which I'm not going to share with you. 418 I'm a teapot.

Discussion

markdown guide
 

I don't like side effects

configure_find_command() {
  if command -v rg >/dev/null; then
    echo "rg --files --iglob readme --iglob readme.md --iglob readme.txt"
    return
  fi
  echo "find . -type f -iname readme -o -iname readme.md -o -iname readme.txt"
}

find_command="$(configure_find_command)"
 

Do you mean you don't want it to do the fuzzy selection or launch an editor? If so, it's not particularly worth it as a script, is it?

EDIT: oh wait, I misread. You mean you don't like me futzing with global variables. That's valid.

 

Though exit will bomb out of the script, and should be return. I might re-work this script later.