It's possible but it's not pretty.
Probably the best solution is to use a temporary file as suggested in a comment by @shellter. That requires a little care to ensure that the temporary file doesn't overwrite anything else and is properly cleaned up, but it should at least be reliable.
However, it is possible to do it in bash, although I can't honestly say that I'm 100% certain that I've covered all possible corner cases [Note 1]. The following function runs a command and records its stdout and stderr into two environment variables whose names are supplied as arguments. It has two modes of operation:
If less than three arguments are provided, the first arguments name the variables which will receive stdout and stderr. The arguments default to out
and err
, respectively. The command must be provided to the function's stdin, and will be run in a separate bash shell.
If there are at least three arguments, then the first two name the variables as above, and the remaining arguments are executed as an external command. (This version cannot be used if you want to run a pipeline or a bash function.)
Reading the command from stdin
will be the solution if you want to be able to do this with arbitrary bash commands, but a child bash
process is not the same as a subshell; you'll need to make sure you export
any variables and functions which you need to use in the commands (and unfortunately you cannot export bash arrays). You can supply the input either as a here-string or as a here-doc; for commands which require quoting characters, you'll probably find it simpler to use a quoted here-doc, as shown below.
For most purposes, you will probably want to use the version which runs the specified command, but remember that expansions in the command are executed before the function is called, and so resulting errors will not be captured in the error variable.
Here's the function:
bsplit () {
local -a cmd=("${@:3}")
if ! ((${#cmd})); then cmd=("$SHELL"); fi
local outerr=$(
"${cmd[@]}" 2>&1 > >(
IFS= read -rd '' s
printf '%s|%d' "$s" ${#s} 1>&2
)
)
local n=${outerr##*|}
outerr=${outerr%|*}
declare -n out_=${1:-out} err_=${2:-err}
if ((n)); then
out_=${outerr: -$n} err_=${outerr:0:-$n}
else
out_="" err_=$outerr
fi
}
The heart of the function is the invocation of the command:
local outerr=$(
"${cmd[@]}" 2>&1 > >(
IFS= read -rd '' s
printf '%s|%d' "$s" ${#s} 1>&2
)
)
The first redirect (2>&1
) causes the stderr output to be sent directly to the command substitution where it will be captured in the environment variable. The second redirect (> >(...)
) passes the stdout output to a subshell which reads the entire output and appends its length in bytes before sending that on to stderr. Note that the stderr it sends it to is the one which was redirected earlier, which will go to the command substituion. This dance ensures that the regular output is sent after the error output; the printf
does not execute until the original stdout is closed by the termination of the command being run, at which point all of the command's error output will already have been sent.
The rest of the function just splits the concatenated output apart into two pieces, using the length indicator at the end to find the split point.
Some examples:
# Execute bash arithmetic substitutions using a here-string
$ bsplit <<<'echo $((1/1)); echo $((1/0))'
$ echo "$out"
1
$ echo "$err"
/bin/bash: line 1: 1/0: division by 0 (error token is "0")
# Here's a script being executed in a quoted here-doc to avoid quoting issues:
$ bsplit <<"EOS"
> cat <(printf %s "Hello,") <(printf '%s!' " world")
> not a command
> EOS
$ echo "$out"
Hello, world!
$ echo "$err"
/bin/bash: line 2: not: command not found
# And a more typical case where it just runs a command:
$ bsplit out err ls -l a_file a_nonexistent_file
$ echo "$out"
-rw-rw-r-- 1 rici rici 0 Sep 6 13:04 a_file
$ echo "$err"
ls: cannot access 'a_nonexistent_file': No such file or directory
Notes:
- If you find a corner case not covered, let me know in a comment. No promises, though :-)