Wrapping `time` (and similar keywords) in call from another script

I have a Bash script (let's call it clock) which should work as a wrapper similar to the time keyword in Bash, e.g. clock ls should do something and then run ls. Here is an example of this script:

#!/bin/bash
echo "do something"
$@

Note that it does not use exec, to allow wrapping built-ins.

However, when the argument to wrap is the time keyword, it does not work as expected: the output shows it runs the /usr/bin/time command, not the shell keyword.

How can I make my wrapper script treat keywords (such as time) exactly as if they had been typed directly in the shell?

Note: In my related question, I learned how to make it work when clock was a Bash function in the same script, but in my real use case, clock is actually a Bash script itself, so the previous solution does not work. Also, the solutions mentioned in the related question (using $@ directly, or running exec bash -c ""$@"") do not work in this case.

One partial solution I found was to use eval $@, but it is very unreliable. It works in this simple case with time, but fails in many situations, such as in clock ls '~$Document1'.


Solution 1:

Analysis

The problem is time you want to use is designed to handle entire pipelines in Bash. It wouldn't be possible if time was recognized and "executed" like any regular command (e.g. the external time executable) or even a builtin. It has to be a keyword. The shell needs to recognize it very early, about the time it recognizes pipelines.

You cannot inject working |, &&, or ; into the shell code by making them pop out from a variable (or parameter) during variable expansion. At the time variable expansion occurs the shell already knows what the logic of the line is. Similarly it's too late for time to pop out and be interpreted as the keyword.

This means the only way to pass time via a variable (or parameter) and have it interpreted as the keyword is to evaluate it (along with the entire command-to-be) from the beginning after variable expansion. This is what eval or bash -c can do. You cannot avoid it.


Basic solution

The simplest method would be to require clock (your script) to accept only one argument. You would use it like this:

clock ls
clock 'ls -l'
clock 'time ls -l'
clock 'time ls -l | wc -l'

Inside the script the crucial command should be:

eval "$1"
# or
exec bash -c "$1" "$0"

(If you wonder about this "$0" then read this. The point is to make $0 in the new shell the same as in the current shell. Its value will most likely be clock.)

I guess you would like to be able to conveniently run clock time ls -l instead of clock 'time ls -l'. If so, the crucial command(s) in the script should be:

eval "$@"
# or
IFS=$' \t\n'; eval "$*"
# or
IFS=$' \t\n'; exec bash -c "$*" "$0"

If I were you I would prefer eval because it doesn't start bash from the beginning (performance) and it keeps unexported variables available (these may be relevant if instead/aside of echo "do something" your script sets some variables).

I would prefer eval "$@" over eval "$*" because the former doesn't depend on IFS. Upon receiving multiple arguments (which may be the case with "$@") eval concatenates them together, separating with spaces, before it evaluates the result. This is equivalent to passing "$*" (which is always a single argument), if only the IFS variable begins with space. Wherever I used "$*" I made sure IFS begins with space in case your script changed the variable earlier for whatever reason. Space+tab+newline is the default value.

My choice:

#!/bin/bash
echo "do something"
eval "$@"

Quoting

Whatever you choose, double-quote $@, $* or $1 in the script. Note there are three stages of expansion:

  1. When you pass clock whatever to a shell, the shell parses the command like it always does: token recognition, brace expansion, tilde expansion and so on. You can avoid (in context of this list: possibly delay) various expansions by quoting and/or escaping.

  2. When your script gets to "$@", "$*" or "$1", parameter expansion occurs. If the parameter wasn't double-quoted, the result would undergo word splitting and filename expansion. You most likely don't want these at this stage if you use eval; and you definitely don't want these if you use bash -c.

  3. Finally, when eval or bash -c does its job, it parses the string passed as argument(s) from the beginning. Again you can avoid various expansions by proper quoting or escaping. Note quotes and/or backslashes that should suppress some expansion, or characters like * or snippets like {a,b,c} or $foo that should get expanded at this stage – they should be originally quoted or escaped so they survive the first stage rather than being "used up" too early.

You should carefully quote and/or escape at the first stage, being aware and planning how the command will look at the last stage.

If you choose the solution with "$@" (or "$*") rather than with "$1", the following two commands will be equivalent:

clock 'ls -l'
clock ls -l

(unless the custom part of your script distinguishes them). But not these two:

clock 'ls -l | wc -l'
clock ls -l | wc -l

Note this is very similar to how commands like watch 'ls -l' or ssh user@host 'ls -l' behave. You can omit quotes and get the same results. Still watch 'ls -l | wc -l' and watch ls -l | wc -l are not equivalent; neither are ssh user@host 'ls -l > foo.txt' and ssh user@host ls -l > foo.txt.


Your attempts

using $@ directly

Sole $@ does not provide any additional evaluation after variable expansion. When time pops up it's too late to interpret it as the keyword.

If time was not the issue then $@ or exec$@might be a good idea, but think twice if you want$@` unquoted in such case.


running exec bash -c ""$@""

This is wrong and I notified the author of the answer you got it from (the answer was improved). These neighboring double-quotes cancel each other out. In effect $@ is unquoted and prone to word splitting and filename generation, as mentioned above. But even "$@" would be wrong here because bash -c takes exactly one argument as code. Following arguments (if any) define positional parameters (from 0, there's a reason for this). If your script uses this flawed code then e.g. clock ls -l will run ls, not ls -l; Even clockls -lwill runls` without the argument because of word splitting.


One partial solution I found was to use eval $@, but it is very unreliable. It works in this simple case with time, but fails in many situations, such as in clock ls '~$Document1'.

By single-quoting you protected $Document from being expanded (as a variable) in the first stage, but not in the last stage. With slightly different string ~ could also be problematic. Unquoted $@ introduced the possibility of problems in between, although not in this particular case. You need to protect $ twice:

clock ls '~\$Document1'

My basic solution requires protecting $ twice in this case as well. To make time work as you want you need this additional stage of expansion, so you just need to deal with this.

Compare watch ls '~$Document1' and watch ls '~\$Document1'. The same situation.

There is a trick. See below.


The trick

The ability to choose at which stage some substring gets expanded is useful in case of watch or ssh.

E.g. you may want to monitor sizes of already existing *.tmp files without paying attention to new files. In this case you need * to be expanded once: watch ls -l *.tmp. Or you may want to include new files matching the pattern. In this case you need * to be expanded repeatedly: watch 'ls -l *.tmp'. With ssh you may want a variable to be expanded locally or on the remote server. For both tools it's sometimes useful to delay expansion.

Your script, however, should work similarly to the time keyword. The keyword does not introduce additional stage of expansion and your example with ~$Document1 shows that you don't want to introduce it. Still, according to my analysis you need it, but only to interprets words like time (passed as arguments) as keywords.

There is a way to suppress these unwanted expansions at the last stage. You can use the Q operator earlier:

${parameter@operator}

The expansion is either a transformation of the value of parameter or information about parameter itself, depending on the value of operator. Each operator is a single letter:

Q
The expansion is a string that is the value of parameter quoted in a format that can be reused as input.

(source)

This adds one level of single-quoting/escaping to the expanded string. Now the idea is to use it at our stage 2, so at stage 3 these extra quotes will prevent various expansions (and get removed).

Simply changing eval "$@" to eval "${@@Q}" will result in:

  • ability to run clock ls '~$Document1' just like that (nice!);
  • inability to run clock 'time ls -l | wc -l' (oh well);
  • inability to recognize time in clock time ls -l as a keyword (oops!); at stage 3 time would be single-quoted and 'time' is not a keyword.

The solution is not to use Q for the first command line argument:

#!/bin/bash
echo "do something"
cmnd="$1"
shift
eval "$cmnd" "${@@Q}"

The first command line argument to clock is not protected from expansion at stage 3, but other arguments are. In result:

  • you can run clock ls '~$Document1' just like that (nice!);
  • you can run clock 'time ls -l | wc -l' (good), although you need to mind the quotes for stage 1 and stage 3 (this question may help in some cases);
  • time in clock time … or clock 'time …' is the time you want (yay!).

Should you worry about the first command line argument to clock not being protected from expansion at stage 3? Not really. It will be either a full long command (like a pipeline) quoted as a whole, then you should treat it like a long command passed to watch or ssh as one argument; or it will be a keyword/builtin/command that cannot trigger any undesired expansion at stage 3 because command names are deliberately simple and safe (no $, ~ or such). It would be different if you wanted to run clock '*' … or clock './~$Document1' …. I believe you have no reasons to do this.