Bash: declare in depth, Part 2: quoting and eval

Quoting bash scripts correctly is considered difficult by many, and rightfully so if you don't know about Bash's builtin facilities for safely quoting code.

Why would you need to quote bash code? For meta-programming!

In total, bash has three ways for you to quote code:

  • printf using %q will quote any string suitable as input to Bash,
  • the Q operator in parameter expansion will quote a variable: ${var@Q}
  • declare can dump both functions and variables of all types as bash code!

Quoting with declare -p

Let's see what we can do with declare -p:

$ now() { date; }
$ declare -pf now
now ()
{
    date
}
$ words=(hello world)
$ declare -p words
declare -a words=([0]="hello" [1]="world")

So, declare -p dumps a variable and declare -pf dumps a function. The output can be fed into bash again:

$ bash -c 'now' # not defined, will error
bash: line 1: now: command not found
$ declare -pf now | bash -c 'eval "$(< /dev/stdin)"; now' # define function from stdin
Sat Oct 16 10:56:22 AM EEST 2021

Application: integrating with a user's shell

This is all fine and dandy, but what use is it?

Maybe you have noticed that many tools now modify your shell's configuration file, adding a line like:
eval "$(rbenv init -)". The first time I encountered this was actually when using ssh-agent.

The problem is this: your program needs to modify the environment in the shell, but it doesn't have access to the shell directly.

The solution: dump instructions for the top-level shell to interpret. Often these are dynamically generated.

Your new problem: how to do this safely, i.e. without causing unintended side-effects due to quoting errors, invalid code, etc.

This is where declare -p comes in handy: in your subprogram you can set up the environment as you need it and then dump what you need to be done in the parent shell on stdout.

Quickly finding files

When working with many code repositories, I often am looking for a specific file (I know its name), but I don't know where exactly it is located in the filesystem (sometimes not even in which repository).

Modern developer tooling comes with a fuzzy finder for files in your project, but searching across project boundaries is either cumbersome (open all projects in your IDE/editor) or slow (using find).

GNU coreutils come with a little tool called locate. Locate and its ill-named companion updatedb build a compressed database of filenames and provide lightning-fast search across filenames.

The catch: by default updatedb is set up to index your whole file system, which means you need to run it as root and it is going to be very slow, since the number of files is huge.

Luckily locate can be configured to use a different database, likewise updatedb can index only specific directories.

Let's create a script that allows us to quickly cd into the directory of a file found with locate. Since we want to cd somewhere, we need to define a shell function that does it for us, as a subshell cannot change the current directory of a parent shell.

Put the following into a file called find-file somewhere on your path and make it executable:

#!/usr/bin/env bash

# Build our search index
reindex() {
  # index $HOME, ignoring .git and node_modules
  updatedb --output=$HOME/.cache/locate/db \
           --localpaths="$HOME" \
           --findoptions="-name .git -prune -o -name node_modules -prune"
}

# Search for a file; if there's no index, build it
find-file() {
  [[ -e "$HOME/.cache/locate/db" ]] || reindex
  locate --database=$HOME/.cache/locate/db "$@"
}

# Dump initialization code to stdout
init() {
  # this is the function we want to export to the user's shell
  cf() {
    local file=$(find-file "$@")
    local dir=${file%/*}
    [[ -n "$dir" ]] && cd "$dir"
  }
  declare -pf cf
}

main() {
  case "$1" in
    -r|--reindex) reindex; shift;;
    -i|--init) init; exit 0;;
  esac

  find-file "$@"
}

main "$@"

Now we can test the initialization code:

$ find-file --init
cf ()
{
    local file=$(find-file "$@" | fzf);
    local dir=${file%/*};
    [[ -n "$dir" ]] && cd "$dir"
}

And after evaluating that, we can use cf to change directories:

$ eval "$(find-file --init)"
$ cf # launches file finder and puts you in the right directory

You'll only receive email when they publish something new.

More from Dario Hamidi
All posts