A small configuration reader can lose data in several ways: trimming spaces, interpreting backslashes, skipping a final line without a newline, or keeping its count only inside a pipeline subshell. This example handles those cases together using a redirected Bash while loop. Its fixture makes each behavior observable with explicit checks.
Prerequisites
Use Bash rather than a generic sh interpreter, plus mktemp and ordinary filesystem access. The executed shell was Bash 5.2.21 on Linux. Run the script from a writable scratch directory. It writes a uniquely named temporary file under the current directory and removes that file on exit. It does not inspect private files or modify shell profiles.
The fixture contains a line with surrounding spaces, a literal backslash, a blank record and a final word without a terminating newline. The loop uses IFS= for this read so surrounding whitespace is preserved. The -r option prevents backslash processing. Its alternative condition accepts nonempty text captured when read reaches end of file without a newline.
Complete example
Save as lines.sh.
#!/usr/bin/env bash
set -euo pipefail
file=$(mktemp ./line-fixture.XXXXXX)
trap 'rm -f -- "$file"' EXIT
printf ' alpha \npath\\name\n\nlast' > "$file"
count=0
lines=()
while IFS= read -r line || [[ -n "$line" ]]; do
lines+=("$line")
count=$((count + 1))
done < "$file"
[[ "$count" == 4 ]]
[[ "${lines[0]}" == ' alpha ' ]]
[[ "${lines[1]}" == 'path\name' ]]
[[ "${lines[2]}" == '' ]]
[[ "${lines[3]}" == 'last' ]]
printf 'records=%s last=%s\n' "$count" "${lines[3]}"
# Repeat against an empty file without discarding the first results.
: > "$file"
empty=0
while IFS= read -r line || [[ -n "$line" ]]; do
empty=$((empty + 1))
done < "$file"
[[ "$empty" == 0 ]]
printf 'empty=%s whitespace-and-backslash=preserved\n' "$empty"
Run and expected output
bash lines.shrecords=4 last=last
empty=0 whitespace-and-backslash=preserved
Why the count survives
Input redirection belongs to the loop itself, so the loop runs in the current shell and the final count and array remain accessible. Piping a producer into a loop can run that loop in a subshell under Bash's normal pipeline behavior. Bash has configuration-dependent exceptions, so prefer the direct file redirection when reading an existing file and needing the accumulated state afterwards.
The four fixture records are asserted individually, including the blank string and literal backslash. A second pass truncates the temporary fixture to an empty file and verifies zero records. The code increments with an assignment rather than a standalone arithmetic expression whose zero-valued result can interact unexpectedly with errexit. Every stored expansion is quoted when it is used as text.
Edge cases and troubleshooting
This is a text-record reader, not a binary parser. Bash variables cannot represent arbitrary NUL-containing data. A newline delimiter is removed by read; the example preserves record content rather than byte-for-byte file serialization. An unterminated final empty record has no bytes to distinguish it from the end of the previous record. A CRLF file retains its carriage returns unless you deliberately normalize them.
The sample keeps an array to verify preservation, which grows with the input. A production job that needs only a count can remove the array assignment and avoid retaining every line. Do not replace a literal record loop with shell word splitting, which changes its data model. If execution reports an array syntax error, confirm you invoked bash lines.sh. The local test checks text parsing, parent-shell state and empty input; it does not simulate disk I/O failures or process-generated input with independent failure statuses.
Primary references
Continue with Linux tutorials.