8

Reading a single character, how can I tell the difference between the null <EOF> and \n?

Eg:

f() { read -rn 1 -p "Enter a character: " char &&
      printf "\nYou entered '%s'\n" "$char"; }

With a printable character:

$ f
Enter a character: x
You entered 'x'

When pressing Enter:

$ f
Enter a character: 

You entered ''

When pressing Ctrl + D:

$ f
Enter a character: ^D
You entered ''
$ 

Why is the output the same in the last two cases? How can I distinguish between them?

Is there a different way to do this in POSIX shell vs bash?

0

4 Answers 4

12

With read -n "$n" (not a POSIX feature), and if stdin is a terminal device, read puts the terminal out of the icanon mode, as otherwise read would only see full lines as returned by the terminal line discipline internal line editor and then reads one byte at a time until $n characters or a newline have been read (you may see unexpected results if invalid characters are entered).

It reads up to $n character from one line. You'll also need to empty $IFS for it not to strip IFS characters from the input.

Since we leave the icanon mode, ^D is no longer special. So if you press Ctrl+D, the ^D character will be read.

You wouldn't see eof from the terminal device unless the terminal is somehow disconnected. If stdin is another type of file, you may see eof (like in : | IFS= read -rn 1; echo "$?" where stdin is an empty pipe, or with redirecting stdin from /dev/null)

read will return 0 if $n characters (bytes not forming part of valid characters being counted as 1 character) or a full line have been read.

So, in the special case of only one character being requested:

if IFS= read -rn 1 var; then
  if [ "${#var}" -eq 0 ]; then
    echo an empty line was read
  else
    printf %s "${#var} character "
    (export LC_ALL=C; printf '%s\n' "made of ${#var} byte(s) was read")
  fi
else
  echo "EOF found"
fi

Doing it POSIXly is rather complicated.

That would be something like (assuming an ASCII-based (as opposed to EBCDIC for instance) system):

readk() {
  REPLY= ret=1
  if [ -t 0 ]; then
    saved_settings=$(stty -g)
    stty -icanon min 1 time 0 icrnl
  fi
  while true; do
    code=$(dd bs=1 count=1 2> /dev/null | od -An -vto1 | tr -cd 0-7)
    [ -n "$code" ] || break
    case $code in
      000 | 012) ret=0; break;; # can't store NUL in variable anyway
      (*) REPLY=$REPLY$(printf "\\$code");;
    esac
    if expr " $REPLY" : ' .' > /dev/null; then
      ret=0
      break
    fi
  done
  if [ -t 0 ]; then
    stty "$saved_settings"
  fi
  return "$ret"
}

Note that we return only when a full character has been read. If the input is in the wrong encoding (different from the locale's encoding), for instance if your terminal sends é encoded in iso8859-1 (0xe9) when we expect UTF-8 (0xc3 0xa9), then you may enter as many é as you like, the function will not return. bash's read -n1 would return upon the second 0xe9 (and store both in the variable) which is a slightly better behaviour.

If you also wanted to read a ^C character upon Ctrl+C (instead of letting it kill your script; also for ^Z, ^\...), or ^S/^Q upon Ctrl+S/Q (instead of flow control), you could add a -isig -ixon to the stty line. Note that bash's read -n1 doesn't do it either (it even restores isig if it was off).

That will not restore the tty settings if the script is killed (like if you press Ctrl+C. You could add a trap, but that would potentially override other traps in the script.

You could also use zsh instead of bash, where read -k (which predates ksh93 or bash's read -n/-N) reads one character from the terminal and handles ^D by itself (returns non-zero if that character is entered) and doesn't treat newline specially.

if read -k k; then
  printf '1 character entered: %q\n' $k
fi
8
  • 1
    I love your answers, @Stéphane Chazelas. If we leave icanon mode and can capture ^D, then why can't we capture \n?
    – Tom Hale
    Commented Aug 1, 2017 at 15:14
  • @TomHale, see edit if you can use zsh. For the POSIX approach, you can take care of newline in the 012 case. Commented Aug 1, 2017 at 15:21
  • With -icannon a ctrl-s (as one example of un-managed input) will put the code in suspension. That will block the TTY until a ctrl-q is issued. There are several other keys that will not be read but will afect the tty, as an aditional example ctrl-C.
    – user232326
    Commented Aug 2, 2017 at 1:51
  • 1
    Not a big issue but: It will be wise to change the printf "\\$code" to printf '%s' "\\$code" as the value of $code could be anything if the -t test fails.
    – user232326
    Commented Aug 2, 2017 at 1:53
  • Why do you need REPLY=$REPLY…… if the function is reading a one character anyway ?
    – user232326
    Commented Aug 2, 2017 at 2:06
2

In f() change the %s to %q:

f() { read -rn 1 -p "Enter a character: " char && \
      printf "\nYou entered '%q'\n" "$char"; }
f;f

Output, if the user enters a newline, then 'Ctrl-D':

Enter a character: 

You entered ''''
Enter a character: ^D
You entered '$'\004''

From `man printf:

 %q       ARGUMENT is printed in a format that can be reused as shell input, 
          escaping non-printable characters with the proposed POSIX $'' syntax.
4
  • How do I get the newline case to show You entered '$'\012'' vs the null character it's currently showing?
    – Tom Hale
    Commented Aug 1, 2017 at 14:48
  • @TomHale The newline terminates the input, it isn't part of it
    – n.caillou
    Commented Aug 1, 2017 at 14:55
  • @TomHale You can capture the newline if you add -d ''
    – meuh
    Commented Aug 1, 2017 at 15:06
  • Not with: f() { read -rd '' -n1 -p "Enter a character: " char && printf "\nYou entered: %q\n" "$char"; } I raised a separate question for this.
    – Tom Hale
    Commented Aug 1, 2017 at 15:20
2

Actually, if you run read -rn1 in Bash, and hit ^D, it's treated as the literal control character, not an EOF condition. The control character just isn't visible when printed, so it doesn't appear with printf "'%s'". Piping the output to something like od -c would show it, as would printf "%q" which other answers already mentioned.

With actually nothing as input, the result is different, here empty even with printf "%q":

$ f()  { read -rn 1  x ; printf "%q\n" "$x"; }
$ printf "" | f
''

The newline isn't returned by read here for two reasons. First, it's the default line delimiter of read, and hence returned as output. Second, it's also part of the default IFS, and read removes leading and trailing whitespace if they are part of IFS.

So, we need read -d to change the delimiter from the default, and make IFS empty:

$ g() { IFS= read -rn 1 -d '' x ; printf "%q\n" "$x"; }
$ printf "\n" | g
$'\n'

read -d "" makes the delimiter effectively the NUL byte, which means this still doesn't tell the difference between an input of nothing, and an input of a NUL byte:

$ printf "" | g
''
$ printf "\000" | g
''

Though with nothing as input, read returns false, so we could check $? to detect that.

1
read -r var
status=$?
echo "\$var='$var':\$?=$status"

The \n (Enter or Return) and <EOF> (Ctrl+D) cases are distinguished by the exit status.

In case read is "given a newline", the standard input stream is read as is specified for normal operation, albeit with an empty string, and the exit status is therefore 0, whilst when read is "given <EOF>", the exit status is 1, as documented here.

N.B.: Older versions of the POSIX standard only specified an exit status >0; but since the Specifications Issue 8 (IEEE Std 1003.1-2024), the exit status is explicitly specified as 1 when "End-of-file was detected".

3
  • Not with -n 1. The status is 0.
    – meuh
    Commented Aug 1, 2017 at 15:02
  • -n 1 and status 0 indicates a `\n' which is mysteriously removed.
    – Tom Hale
    Commented Aug 1, 2017 at 15:16
  • @meuh -n is a bash feature, and while the question is tagged "bash", it is also tagged "POSIX". So, this answer is actually the only valid one in a POSIX context.
    – 7heo.tk
    Commented Aug 4 at 16:02

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .