Loop over a string in zsh and Bash
Is there a way to loop over $x in zsh when it is a string and in a way compatible with Bash?
Yes!. A var expansion is not split (by default) in zsh, but command expansions are. Therefore in both Bash and zsh you can use:
x="one two three"
for i in $( echo "$x" )
do
echo "$i"
done
In fact, the code above works the same in all Bourne shell descendants (but not in the original Bourne, change $(…)
to `…`
to get it working there).
The code above still have some issues with globbing and the use of echo, keep reading.
In zsh, a var expansion like $var
is not split (also not glob) by default.
This code has no problems (doesn't expand to all files in the pwd) in zsh:
var="one * two"
printf "<%s> " ${var}; echo
But also doesn't split var by the value of IFS.
For zsh, splitting on IFS could be done by either using:
1. Call splitting explicitly: `echo ${=var}`.
2. Set SH_WORD_SPLIT option: `set -y; echo ${var}`.
3. Using read to split to a list of vars (or an array with `-A`).
But none of those options are portable to bash (or any other shell except ksh for -A
).
Going down to an older syntax that is shared by both shells: read
might help.
But that can only work for one character delimiters (not IFS), and only if the delimiter exists in the input string:
# ksh, zsh and bash(3.0+)
t1(){ set -f;
while read -rd "$delimiter" i; do
echo "|$i|"
done <<<"$x"
}
Where $1
is a one character delimiter.
That still suffer of the expansion of globbing characters (*
, ?
and [
), so a set -f
is required. And, we can set an array variable outarr
instead:
# for either zsh or ksh
t2(){ set -f; IFS="$delimiter" read -d $'\3' -A outarr < <(printf '%s\3' "$x"); }
And the same idea for bash:
# bash
t3(){ local -; set -f; mapfile -td "$1" outarr < <(printf '%s' "$x"); }
The effect of set -f
is restored in the bash function by using local -
.
The concept could even be extended to a limited shell like dash:
# valid for dash and bash
t4(){ local -; set -f;
while read -r i; do
printf '%s' "$i" | tr "$delimiter"'\n' '\n'"$delimiter"; echo
done <<-EOT
$(echo "$x" | tr "$delimiter"'\n' '\n'"$delimiter")
EOT
}
No <<<
, no <(…)
and no read -A
or readarray
used, but it works (for one character delimiters) with spaces, newlines, and/or control characters in the input.
But it is a lot easier to simply do:
t9(){ set -f; outarr=( $(printf '%s' "$x") ); }
Sadly, zsh doesn't understand the local -
, so the value of set -f
has to be restored as this:
t99(){ oldset=$(set +o); set -f; outarr=( $( printf '%s' "$x" ) ); eval "$oldset"; }
Any of the functions above may be called with:
IFS=$1 delimiter=$1 $2
Where the first argument $
is the delimiter (and IFS) and the second argument is the function to call (t1, t2, … t9, t99). That call sets the value of IFS only for the duration of the function call which gets restored to its original value when the function called exits.
if type emulate >/dev/null 2>/dev/null; then emulate ksh; fi
In zsh, this activates options that make it more compatible with ksh and bash, including sh_word_split
. In other shells, emulate
doesn't exist so this does nothing.
If you are not afraid to use eval (= evil):
x="one two three"
eval "x=($x)"
for i in ${x[@]}; do
echo $i
done