Zsh – How to Collect an Array of Lines Properly

shell-scriptzsh

I thought the following would group the output of my_command in an array of lines:

IFS='\n' array_of_lines=$(my_command);

so that $array_of_lines[1] would refer to the first line in the output of my_command, $array_of_lines[2] to the second, and so forth.

However, the command above doesn't seem to work well. It seems to also split the output of my_command around the character n, as I have checked with print -l $array_of_lines, which I believe prints elements of an array line by line. I have also checked this with:

echo $array_of_lines[1]
echo $array_of_lines[2]
...

In a second attempt, I thought adding eval could help:

IFS='\n' array_of_lines=$(eval my_command);

but I got the exact same result as without it.

Finally, following the answer on List elements with spaces in zsh, I have also tried using parameter expansion flags instead of IFS to tell zsh how to split the input and collect the elements into an array, i.e.:

array_of_lines=("${(@f)$(my_command)}");

But I still got the same result (splitting happening on n)

With this, I have the following questions:

Q1. What are "the proper" ways of collecting the output of a command in an array of lines?

Q2. How can I specify IFS to split on newlines only?

Q3. If I use parameter expansion flags as in my third attempt above (i.e. using @f) to specify the splitting, does zsh ignore the value of IFS? Why didn't it work above?

Best Answer

TL, DR:

array_of_lines=("${(@f)$(my_command)}")

First mistake (→ Q2): IFS='\n' sets IFS to the two characters \ and n. To set IFS to a newline, use IFS=$'\n'.

Second mistake: to set a variable to an array value, you need parentheses around the elements: array_of_lines=(foo bar).

This would work, except that it strips empty lines, because consecutive whitespace counts as a single separator:

IFS=$'\n' array_of_lines=($(my_command))

You can retain the empty lines except at the very end by doubling the whitespace character in IFS:

IFS=$'\n\n' array_of_lines=($(my_command))

To keep trailing empty lines as well, you'd have to add something to the command's output, because this happens in the command substitution itself, not from parsing it.

IFS=$'\n\n' array_of_lines=($(my_command; echo .)); unset 'array_of_lines[-1]'

(assuming the output of my_command doesn't end in a non-delimited line; also note that you lose the exit status of my_command)

Note that all the snippets above leave IFS with its non-default value, so they may mess up subsequent code. To keep the setting of IFS local, put the whole thing into a function where you declare IFS local (here also taking care of preserving the command's exit status):

collect_lines() {
  local IFS=$'\n\n' ret
  array_of_lines=($("$@"; ret=$?; echo .; exit $ret))
  ret=$?
  unset 'array_of_lines[-1]'
  return $ret
}
collect_lines my_command

But I recommend not to mess with IFS; instead, use the f expansion flag to split on newlines (→ Q1):

array_of_lines=("${(@f)$(my_command)}")

Or to preserve trailing empty lines:

array_of_lines=("${(@f)$(my_command; echo .)}")
unset 'array_of_lines[-1]'

The value of IFS doesn't matter there. I suspect that you used a command that splits on IFS to print $array_of_lines in your tests (→ Q3).