Shell – read a single character from stdin in POSIX shell

portabilityposixshell-script

Only read -r is specified by POSIX; read -n NUM, used to read NUM characters, is not. Is there a portable way to automatically return after reading a given number of characters from stdin?

My usecase is printing prompts like this:

Do the thing? [y/n]

If possible, I'd like to have the program automatically proceed after typing y or n, without needing the user to press enter afterwards.

Best Answer

Reading one character means reading one byte at a time until you get a full character.

To read one byte with the POSIX toolchest, there's dd bs=1 count=1.

Note however reading from a terminal device, when that device is in icanon mode (as it generally is by default), only ever returns when you press Return (a.k.a. Enter), because until then the terminal device driver implements a form of line editor that allows you to use Backspace or other editing characters to amend what you enter, and what you enter is made available to the reading application only when you submit that line you've been editing (with Return or Ctrl+D).

For that reason, ksh's read -n/N or zsh's read -k, when they detect stdin is a terminal device, put that device out of the icanon mode, so that bytes are available to read as soon as they are sent by the terminal.

Now note that ksh's read -n n only reads up to n characters from a single line, it still stops when a newline character is read (use -N n to read n characters). bash, contrary ksh93, still does IFS and backslash processing for both -n and -N.

To mimic zsh's read -k or ksh93's read -N1 or bash's IFS= read -rN 1, that is, read one and only one character from stdin, POSIXly:

readc() { # arg: <variable-name>
  if [ -t 0 ]; then
    # if stdin is a tty device, put it out of icanon, set min and
    # time to sane value, but don't otherwise touch other input or
    # or local settings (echo, isig, icrnl...). Take a backup of the
    # previous settings beforehand.
    saved_tty_settings=$(stty -g)
    stty -icanon min 1 time 0
  fi
  eval "$1="
  while
    # read one byte, using a work around for the fact that command
    # substitution strips the last character.
    c=$(dd bs=1 count=1 2> /dev/null; echo .)
    c=${c%.}

    # break out of the loop on empty input (eof) or if a full character
    # has been accumulated in the output variable (using "wc -m" to count
    # the number of characters).
    [ -n "$c" ] &&
      eval "$1=\${$1}"'$c
        [ "$(($(printf %s "${'"$1"'}" | wc -m)))" -eq 0 ]'; do
    continue
  done
  if [ -t 0 ]; then
    # restore settings saved earlier if stdin is a tty device.
    stty "$saved_tty_settings"
  fi
}
Related Question