Using pipes to augment IO of command with bash script

I like the interactive mode of the bc command line calculator because it is ubiquitous, but wanted to augment its functionality a bit to add a couple features I liked from another calculator program. I wanted to make modifications to the text I typed before sent to bc and modify the text it outputted. This can be done on standard POSIX shells with mkfifo, but it took me a good while to figure out how to do this with both input and output and get something working nicely without it freezing or leaving artifacts. So I’m sharing how to do this in a bash script.

I had never used or heard of mkfifo before, but found it in my searches for how to do my task. It basically takes an argument specifying a file path, which it then creates as a pipe type file. It has a -m option to set the permissions, so we can prevent others from using this pipe. Something like:

pipe="/tmp/mypipe"
mkfifo -m 0600 "$pipe"

At first, I naively thought I could create one pipe and use it for both input and output, but that would just pipe the output of the program back to its input. Two pipes are needed for going both ways.

I was having trouble with freeze-ups and ensuring the pipe files were deleted at the end of the script. This post helped me resolve this. Basically, we can run something like exec 3<> "$pipe", where 3 is a number greater than 2. Doing this allows redirection to or from that number similar to redirecting stdin and stdout. Then we can rm "$pipe" immediately, which removes the pipe file. The exec thing, however, has created a connection to that pipe that remains until the script is terminated, so it still works.

To run bc attached to these pipes, we can do normal shell redirection using the numbers of our pipes, and background the process to keep it running. This can look like bc -l <&3 >&4 &. I had problems ensuring the bc subprocess was terminated at the end. I found the trap command for this, which I didn’t have a lot of experience with. It allows running some functionality when certain script-terminating events happen. I found that the EXIT SIGINT SIGTERM events handled the events I encountered. I stored the process ID of my bc command like PID=$! and then passed that to kill in the trap code.

To handle the input, I used read -r in to capture it into an $in variable, using a while loop to repeat.I passed did my modifications, then passed it to bc with echo "$in" >&3. Part of the loop condition was to verify the bc process was going in case it died due to a parse error, using kill -0 like kill -0 "$PID" > /dev/null 2>&1. I used read again to get the output from our bc pipe with read -t 1 out <&4 || out=''. The -t 1 part is because bc sometimes doesn’t give output, eg with variable assignment. This waits one second for output from bc and otherwise goes to the || part. Without the timeout, it would just freeze waiting for input. Since are output is already supposed to be in the pipe, we don’t really need to wait a full second, but the time value must be an integer in seconds and 0 doesn’t work.

I added a few other niceties like handling empty input and allowing to exit more easily. The full script can be found in my dotfiles, but the important part looks like:

#!/bin/bash
#--create pipes to communicate with command
inp='/tmp/'$(date +'%Y%m%d%H%M%S')'.in'
mkfifo -m 0700 $inp
exec 3<> "$inp"
outp='/tmp/'$(date +'%Y%m%d%H%M%S')'.out'
mkfifo -m 0700 $outp
exec 4<> "$outp"
#---ensure pipe files removed
rm "$inp"
rm "$outp"

#--make command terminated on exit
hasPID() {
    kill -0 "$PID" > /dev/null 2>&1 && return 0
    return 1
}
fin() {
    if hasPID; then
        kill $PID
    fi
}
trap 'fin; exit' EXIT SIGINT SIGTERM

#--start our process
bc -l <&3 >&4 &
PID=$!
#--get input
while hasPID && read -rp '> ' in; do
    #--no need to pass empty string to command
    if [ -z "$in" ]; then
        continue
    #--we can handle quitting
    elif [[ "$in" == "quit" || "$in" == "exit" || "$in" == "x" ]]; then
        fin
        exit
    fi
    #--build input (`buildIn` to be defined elsewhere)
    in=$(buildIn "$in")
    #--pass to command
    echo "$in" >&3 || fin
    #--read output (`buildOut` to be defined elsewhere)
    read -t 1 out <&4 || out=''
    if [ ! -z "$out" ]; then
        echo $(buildOut "$out")
    fi
done
fin

Of course, at 41 lines, this is a lot more verbose than what would otherwise just be 2 lines:

#!/bin/sh
bc -l

without modified input, but I think it was worth it.

This same script structure should work for any CLI command that works interactively for repeated input and output. Hopefully it helps someone save the time I spent on this.


#Page top