Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Shell read *sometimes* strips trailing delimiter

Tags:

bash

shell

sh

To parse colon-delimited fields I can use read with a custom IFS:

$ echo 'foo.c:41:switch (color) {' | { IFS=: read file line text && echo "$file | $line | $text"; }
foo.c | 41 | switch (color) {

If the last field contains colons, no problem, the colons are retained.

$ echo 'foo.c:42:case RED: //alert' | { IFS=: read file line text && echo "$file | $line | $text"; }
foo.c | 42 | case RED: //alert

A trailing delimiter is also retained...

$ echo 'foo.c:42:case RED: //alert:' | { IFS=: read file line text && echo "$file | $line | $text"; }
foo.c | 42 | case RED: //alert:

...Unless it's the only extra delimiter. Then it's stripped. Wait, what?

$ echo 'foo.c:42:case RED:' | { IFS=: read file line text && echo "$file | $line | $text"; }
foo.c | 42 | case RED

Bash, ksh93, and dash all do this, so I'm guessing it is POSIX standard behavior.

  1. Why does it happen?
  2. What's the best alternative?

I want to parse the strings above into three variables and I don't want to mangle any text in the third field. I had thought read was the way to go but now I'm reconsidering.

like image 770
John Kugelman Avatar asked Oct 11 '18 14:10

John Kugelman


Video Answer


1 Answers

Yes, that's standard behaviour (see the read specification and Field Splitting). A few shells (ash-based including dash, pdksh-based, zsh, yash at least) used not to do it, but except for zsh (when not in POSIX mode), busybox sh, most of them have been updated for POSIX compliance.

That's the same for:

$ var='a:b:c:' IFS=:
$ set -- $var; echo "$#"
3

(see how the POSIX specification for read actually defers to the Field Splitting mechanism where a:b:c: is split into 3 fields, and so with IFS=: read -r a b c, there are as many fields as variables).

The rationale is that in ksh (on which the POSIX spec is based) $IFS (initially in the Bourne shell the internal field separator) became a field delimiter, I think so any list of elements (not containing the delimiter) could be represented.

When $IFS is a separator, one can't represent a list of one empty element ("" is split into a list of 0 element, ":" into a list of two empty elements¹). When it's a delimiter, you can express a list of zero element with "", or one empty element with ":", or two empty elements with "::".

It's a bit unfortunate as one of the most common usages of $IFS is to split $PATH. And a $PATH like /bin:/usr/bin: is meant to be split into "/bin", "/usr/bin", "", not just "/bin" and "/usr/bin".

Now, with POSIX shells (but not all shells are compliant in that regard), for word splitting upon parameter expansion, that can be worked around with:

IFS=:; set -o noglob
for dir in $PATH""; do
  something with "${dir:-.}"
done

That trailing "" makes sure that if $PATH ends in a trailing :, an extra empty element is added. And also that an empty $PATH is treated as one empty element as it should be.

That approach can't be used for read though.

Short of switching to zsh, there's no easy work around other than inserting an extra : and remove it afterwards like:

echo a:b:c: | sed 's/:/::/2' | { IFS=: read -r x y z; z=${z#:}; echo "$z"; }

Or (less portable):

echo a:b:c: | paste -d: - /dev/null | { IFS=: read -r x y z; z=${z%:}; echo "$z"; }

I've also added the -r which you generally want when using read.

Most likely here you'd want to use a proper text processing utility like sed/awk/perl instead of writing convoluted and probably inefficient code around read which has not been designed for that.


¹ Though in the Bourne shell, that was still split into zero elements as there was no distinction between IFS-whitespace and IFS-non-whitespace characters there, something that was also added by ksh

like image 52
Stephane Chazelas Avatar answered Sep 19 '22 23:09

Stephane Chazelas