skip to Main Content

My Goal

I’m writing a small Bash script, which uses entr, which is a utility to re-run arbitrary commands when it detects file-system events. My immediate goal is to pass entr a command which converts a given markdown file to HTML. entr will run this command every time the markdown file changes. A simplified but working script looks like:

# script 1
in="$1"
out="${in%.md}.html"
echo "$in" | entr pandoc "${in}" -o "${out}"

This works fine. The filename to be watched is supplied to entr on stdin. On detecting changes in that file, entr runs the command specified by its args. In this example that is pandoc, and all the args after it, to convert the markdown file to an HTML file.

For future reference, set -x shows that entr was invoked as we’d expect. (Throughout, lines starting with + show the output from set -x):

+ entr pandoc 'READ ME.md' -o 'READ ME.html'

The problem

I want to look-up the command given to entr depending on the file-type of the
given input file. So the file-conversion command ends up in a variable, and I want to use that variable as the command-line args to entr. But I can’t get the quoting right.
Again, simplified:

# script 2
in="$1"
out="${in%.md}.html"
cmd="pandoc "${in}" -o "${out}""
echo "$in" | entr "$cmd"

(shellcheck.net detects no issues on the above)

This fails. Because "$cmd" in the final line is in quotes, the entirety of $cmd
is treated as a single arg to entr:

+ entr 'pandoc "READ ME.md" -o "READ ME.html"'

entr tries to interpret the whole thing as the name of an executable, which
it cannot find:

entr: exec pandoc "READ ME.md" -o "READ ME.html": No such file or directory

So how should I modify script 2, to use the content of $cmd as the args to
entr?

What have I tried?

  1. Check that $cmd is being formed as I expect? If I echo "$cmd" right after
    it is defined in script 2, it looks exactly how I’d hope:

    pandoc "READ ME.md" -o "READ ME.html"
    

    I tried messing around with alternate ways of constructing cmd, such as:

    cmd='pandoc "'"${in}"'" -o "'"${out}"'"'
    

    but variations like this produce identical values of $cmd, and identical
    behavior as script2.

  2. Try not quoting the use of $cmd?

    Since the final line of script 2 erroneously treats the whole of "$cmd"
    as a single arg, and we want it to split up the words into seprate args
    instead, maybe removing the quotes and using a bare $cmd is a step in the
    right direction?

    echo "$in" | entr $cmd
    

    Predictably enough though, this splits $cmd up on every space, even the
    ones inside our double-quotes:

    + entr pandoc '"READ' 'ME.md"' -o '"READ' 'ME.html"'
    

    This makes Pandoc try, and fail, to open a file called "READ:

    pandoc: "READ: openBinaryFile: does not exist (No such file or directory)
    
  3. Try constructing $cmd using printf?

    I notice printf -v can store output in a variable. How about using that
    instead of assiging to cmd?

    printf -v cmd 'pandoc "%s" -o "%s"' "$in" "$out"
    

    Predictably enough, this produces the same results as script2. I tried some
    speculative variations, such as %q in the format string, or using $in
    and $out directly in the format string, but didn’t stumble on anything
    that seemed to help.

  4. Try using the ${var@Q} form of parameter expansion.

    echo "$in" | entr ${cmd@Q}
    

    Tried with and without double quotes around the use of ${cmd@q}. No joy,
    I guess I’m misunderstanding what @Q is for.

    + entr ''''pandoc' '"READ' 'ME.md"' -o '"READ' 'ME.html"''''
    entr: exec 'pandoc: No such file or directory
    

Details

I’m using Bash v5.1.16, in Pop!_OS 22.04, derived from Ubuntu 22.04 (Jammy).

The current ‘apt’ version of entr (v5.1) in Ubuntu Jammy (22.04) is too old
for my needs (e.g. the -z flag doesn’t work.) so I’m compiling my own from
the latest v5.3 source release.

I know there are a lot of questions about quoting in Bash, but I don’t see any that seem to match this. Apologies if I’m wrong.

2

Answers


  1. Chosen as BEST ANSWER

    Assemble the command as an array, instead of a string.

    I read somewhere that maybe $@ might do what I need, so I put the parts of $cmd into an array:

    in="$1"
    out="${in%.md}.html"
    cmd=(pandoc "$in" -o "$out")
    echo "$in" | entr "${cmd[@]}"
    

    This correctly quotes the items in ${cmd[@]} which require it (e.g. have spaces in.)

    + entr pandoc 'READ ME.md' -o 'READ ME.html'
    

    So ‘entr’ successfully calls ‘pandoc’, which successfully converts the documents. It works! I confess I did not expect that.

    This approach seems viable for other similar situations, not just when invoking entr.

    So I have a solution. It doesn't seem completely ideal for my future plans. I had visions of these 'file conversion commands' being configurable, and hence defined in a text file somewhere, so that users (==me, probably) could override them and define their own, and I'm not fluent enough with Bash to be sure how to go about that when commands are defined as arrays instead of strings.

    I can't help but feel I've overlooked something simpler.


  2. Use a shell to interpret the value of "$cmd", ie:

    # replace this:
    echo "$in" | entr "$cmd"
    # with this:
    echo "$in" | entr sh -c "$cmd"
    

    Similarly, entr has a -s option which invokes a shell for you (chosen using the first word in $SHELL):

    echo "$in" | entr -s "$cmd"
    

    Using ‘-s’ works equally well, and ‘entr’ helpfully prints "Bash exit value: x" to stdout each time it executes $cmd, which may or may not be what you want.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search