Search code examples
linuxshellposix

How to set a variable to the variable inside a variable in a shell script


I need to write a POSIX shell script that will change system configurations. Before doing so I want to ensure there are backups of any file I edit. A requirement for this script is that is uses dmenu to prompt the user if installed and read if not. I want one function (named communicate below) that will automatically handle this for me based on a variable that gets set on run, $dmenu.

I'm having issues writing to a variable inside a variable, as shown below:

#!/usr/bin/env sh

[ $(command -v dmenu 2>/dev/null) ] && dmenu='true'

communicate(){
    description="$1"; options="$2"; outcome="$3"
    if [ $dmenu ]; then
        echo "$(printf "$options" | dmenu -i -p "$description")" >&0 | read $outcome
    else
        printf "$description $options "; read $outcome
    fi
}

backup(){
    [ $1 ] && file="$1" || communicate 'Enter file: ' '' 'file'
    [ ! -f $file ] && backup "$1"
    cp "$file" "$file.bak"
}

select_interface(){
    [ $1 ] && interface="$1" || communicate 'Select interface:' "$interfaces" 'interface'
}

backup wants to save user input to a variable called $file, whereas later select_interface wants to save to a variable called $interface. if dmenu is not installed, writing to $outcome works fine with the else statement, whereas if it is installed, I cannot seem to get the read command to trigger when passing the outcome of dmenu through with the STDIN redirect into read, which works outside of the script.

Can someone see what I'm doing wrong or how I could do this better? I need it all to be in the one function communicate, acting as the communicating agent with the user.


Solution

  • The statement

    echo "$(printf "$options" | dmenu -i -p "$description")" >&0 | read $outcome
    

    being a pipe, causes the shell to implement echo and read as 2 separate processes. read is still a forked shell, and it still sets the variable $outcome, but it only sets it in the forked shell, not in the forking (parent) shell.

    The technically correct way to do it is:

    eval $outcome=\$\(printf "$options" \| dmenu -i -p "$description"\)'
    

    BUT I would advise against eval for anything but throwaway code.

    I also advise against functions which accept variable names to set, it's pretty hard to get right.

    The cleaner way to do it:

    #!/usr/bin/env sh
    
    if [ $(command -v dmenu 2>/dev/null) ]; then
        communicate() {
            description="$1"
            options="$2"
            # also fixed this bug with the menu selection, each option needs to be in a new line
            printf "%s\n" $options | dmenu -i -p "${description}:"
        }
    else
        communicate() {
            description="$1"
            options="$2"
            if [ -n "$options" ]; then
                optstring="options: ${options}; "
            else
                optstring=""
            fi
            read -p "${optstring}${description}: " outcome
            echo $outcome
        }
    fi
    
    backup() {
        if [ -n "$1" ]; then
            file="$1"
        else
            file=$(communicate 'Enter file')
        fi
        if [ -f "$file" ]; then
            cp "$file" "${file}.bak"
        else
            backup
        fi
    }
    
    select_interface() {
        if [ -n "$1" ]; then
            interface="$1"
        else
            interface=$(communicate "Enter interface" "$interfaces")
        fi
    }