How to read the user input line by line until Ctrl+D and include the line where Ctrl+D was typed
To do that, you'd have to read character by character, not line by line.
Why? The shell very likely uses the standard C library function
to read the data that the user is typing in, and that function returns
the number of bytes actually read. If it returns zero, that means it has
encountered EOF (see the
man 2 read). Note that EOF
isn't a character but a condition, i.e. the condition "there is nothing
more to be read", end-of-file.
Ctrl+D sends an end-of-transmission character
(EOT, ASCII character code 4,
bash) to the terminal
driver. This has the effect of sending whatever there is to send to the
read() call of the shell.
When you press Ctrl+D halfway through
entering the text on a line, whatever you have typed so far is
sent to the shell1. This means that if you enter
Ctrl+D twice after having typed something on
a line, the first one will send some data, and the second one will
send nothing, and the
read() call will return zero and the shell
interpret that as EOF. Likewise, if you press Enter followed
by Ctrl+D, the shell gets EOF at once as there
wasn't any data to send.
So how to avoid having to type Ctrl+D twice?
As I said, read single characters. When you use the
built-in command, it probably has an input buffer and asks
read a maximum of that many characters from the input stream (maybe 16
kb or so). This means that the shell will get a bunch of 16 kb chunks
of input, followed by a chunk that may be less than 16 kb, followed by
zero bytes (EOF). Once encountering the end of input (or a newline, or a
specified delimiter), control is returned to the script.
If you use
read -n 1 to read a single character, the shell will use
a buffer of a single byte in its call to
read(), i.e. it will sit in
a tight loop reading character by character, returning control to the
shell script after each one.
The only issue with
read -n is that it sets the terminal to "raw
mode", which means that characters are sent as they are without any
interpretation. For example, if you press Ctrl+D,
you'll get a literal EOT character in your string. So we have to check
for that. This also has the side-effect that the user will be unable to edit the line before submitting it to the script, for example by pressing Backspace, or by using Ctrl+W (to delete the previous word) or Ctrl+U (to delete to the beginning of the line).
To make a long story short: The following is the final loop that your
bash script needs to do to read a line of input, while at the same time
allowing the user to interrupt the input at any time by pressing
while true; do line='' while IFS= read -r -N 1 ch; do case "$ch" in $'\04') got_eot=1 ;& $'\n') break ;; *) line="$line$ch" ;; esac done printf 'line: "%s"\n' "$line" if (( got_eot )); then break fi done
Without going into too much detail about this:
IFSvariable. Without this, we would not be able to read spaces. I use
read -Ninstead of
read -n, otherwise we wouldn't be able to detect newlines. The
readenables us to read backslashes properly.
casestatement acts on each read character (
$ch). If an EOT (
$'\04') is detected, it sets
got_eotto 1 and then falls through to the
breakstatement which gets it out of the inner loop. If a newline (
$'\n') is detected, it just breaks out of the inner loop. Otherwise it adds the character to the end of the
After the loop, the line is printed to standard output. This would be where you call your script or function that uses
"$line". If we got here by detecting an EOT, we exit the outermost loop.
1 You may test this by running
cat >file in one terminal
tail -f file in another, and then enter a partial line into the
cat and press Ctrl+D to see what happens in the
ksh93 users: The loop above will read a carriage return character rather than a newline character in
ksh93, which means that the test for
$'\n' will need to change to a test for
$'\r'. The shell will also display these as
To work around this:
stty_saved="$( stty -g )" stty -echoctl # the loop goes here, with $'\n' replaced by $'\r' stty "$stty_saved"
You might also want to output a newline explicitly just before the
break to get exactly the same behaviour as in