10

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.

ash
  • 680

3 Answers3

16

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 trailing newline characters.
    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 &quot;wc -m&quot; to count
# the number of characters).
[ -n &quot;$c&quot; ] &amp;&amp;
  eval &quot;$1=\${$1}&quot;'$c
    [ &quot;$(($(printf %s &quot;${'&quot;$1&quot;'}&quot; | wc -m)))&quot; -eq 0 ]'; do
continue

done if [ -t 0 ]; then # restore settings saved earlier if stdin is a tty device. stty "$saved_tty_settings" fi }

  • I'm not 100% sure a sanity check is required, but as there's an eval, I'd do something like __varname_check() { case "$1" in [!_a-zA-Z]*) echo BAD;; *[_a-zA-Z0-9]*) echo BAD ;; esac; }, test as follows: __varname_check "__0kay"; __varname_check "0bad; __varname_check ")bad" – Nick Bull Feb 29 '20 at 13:27
  • EOF only happens with icanon. With -icanon dd reads char ^D instead of getting EOF. – giraffes Sep 14 '23 at 22:17
  • @giraffes, dd would also reach eof (even if that's with read() returning EIO rather than 0 bytes) if the terminal is disconnected (and SIGHUP is ignored) for instance. – Stéphane Chazelas Dec 24 '23 at 15:16
2

Quoting from this answer ... this works for me with bash:

echo -n "Is this a good question (y/n)? "
old_stty_cfg=$(stty -g)
stty raw -echo ; answer=$(head -c 1) ; stty $old_stty_cfg # Careful playing with stty
if echo "$answer" | grep -iq "^y" ;then
    echo Yes
else
    echo No
fi
  • 3
    That works if you have head -c, but it's not strictly POSIX (unless that's being changed). See: What's the POSIX way to read an exact number of bytes from a file?. Bash also isn't very strictly POSIX, even if you call it as sh. dash and busybox's sh are probably closer (they are easy to get to if you're on Linux). Though that one works in sh, too. (but you should probably quote "$old_stty_cfg" just out of principle.) – ilkkachu Aug 26 '18 at 16:45
  • @ilkkachu: dash and busybox both do not support multi byte characters and thus are not POSIX compliant. If you like a minimal POSIX shell, you should look at pbosh, a compile variant of bosh that is close to the features of dash but supports multi-byte characters. – schily Aug 26 '18 at 18:13
  • 1
    @schily, I said "closer [than Bash]", not "exactly compliant". Bash has extensions that are active even in POSIX mode, which makes it rather hard to use as a yardstick, esp. given that ksh extensions are rather commonly known. Also, I don't think multibyte support of the shell matters much for that particular script. (The fact that head -c counts bytes and not characters in at least GNU and FreeBSD does matter, but it's not the shell's fault, is it.) – ilkkachu Aug 26 '18 at 18:47
  • 1
    @ilkkachu: pbosh tries to avoid POSIX extensions, this is why it is useful as a shell to test for POSIX compliance of scripts. If you like to be shure that a script is really portable, you would still need to test a script with bash as well as it implements pipes different where POSIX allows both variants. BTW: You see the problems with dash if you set IFS=ö in UTF-8 mode and head -c is indeed non-portable. – schily Aug 26 '18 at 19:06
  • 1
    Also, printf is specified by POSIX; echo -n isn’t. – Scott - Слава Україні Aug 26 '18 at 20:23
  • You need to reread POSIX: POSIX specifies very well how -n works, since a shell on a real POSIX system (in contrast to a shell on a small embedded system) needs to be XSI compliant. – schily Aug 26 '18 at 20:40
-3

Other solution with dd:

key=$(stty -icanon; dd ibs=1 count=1 2>/dev/null)
Ipor Sircer
  • 14,546
  • 1
  • 27
  • 39
  • 4
    In most shells, that would leave terminal with -icanon. The subshell there doesn't help to reset it. – ilkkachu Aug 26 '18 at 17:11