5

I have a script where I want to list USB devices using the command lsblk.

The command:

$ lsblk -o NAME,TRAN,VENDOR,MODEL | grep usb

which results in

sdb   usb    Kingston DataTraveler 2.0
sdc   usb    Kingston DT 101 G2 

I want to save the result in a variable to work later, so I write

$ usbs=$(lsblk -o NAME,TRAN,VENDOR,MODEL | grep usb)

What I was expecting is that the variable usbs stores the result in two whole lines like above. But if I run:

for i in ${usbs[@]}; do
  echo $i
done

I get the result split into words:

sdb
usb
Kingston
DataTraveler
2.0
sdc
usb
Kingston
DT
101
G2

Question: Is there a way in which, using the grep command, I can store the result of the command as two whole lines?

I prefer to know if there's a simple solution instead of dumping the result in a file and then read it.

Jeff Schaller
  • 67,283
  • 35
  • 116
  • 255
  • 1
    Try echo "${#usbs[@]}" to see the number of items in the usbs "array", or "${!usbs[@]}" to list its indices. Or print it with echo "$usbs". It is likely storing what you are expecting it to. – fra-san May 15 '19 at 20:18
  • 1
    Double-quote your variables (and $(...) constructs) when you reference them and the shell will keep your whitespace intact. But be aware the shell won't automatically assign array elements based on newlines. It will still be one string, just with a newline in the middle. – Chris Davies May 15 '19 at 20:28
  • @Christopher I like your solution but gave me a headache :), because subsecuents comands use the IFS set before... it took me some time to figure what was happening, it does it silently. – schrodingerscatcuriosity May 15 '19 at 22:00
  • var=$(...) is equivalent to var="$(...)" – jesse_b May 15 '19 at 22:53

3 Answers3

10

This is a good situation to use readarray/mapfile:

readarray -t usbs < <(lsblk -o NAME,TRAN,VENDOR,MODEL | grep usb)

This will create an array with your output where each line is separated into it's own element.

In your case it would make an array like:

usbs=(
'sdb   usb    Kingston DataTraveler 2.0'
'sdc   usb    Kingston DT 101 G2'
)

As is you are assigning your entire output to a single variable (not an array) which essentially does this:

usbs='sdb   usb    Kingston DataTraveler 2.0
sdc   usb    Kingston DT 101 G2 '

In order to make it an array you would do:

usbs=($(lsblk -o NAME,TRAN,VENDOR,MODEL | grep usb))

but this would make each word separated by whitespace into its own element, equivalent to:

usbs=(
sdb
usb
Kingston
DataTraveler
2.0
sdc
usb
Kingston
DT
101
G2
)

As has been pointed out by several commenters and is generally best practice at all times, variables should be quoted. In this case you must and generally always should quote your variables.

First, I'd say it's not the right way to address the problem. It's a bit like saying "You should not murder people because otherwise you'll go to jail."

Similarly, you don't quote your variable because otherwise you're introducing security vulnerabilities. You quote your variables because it is wrong not to (but if the fear of the jail can help, why not).

  • Stéphane Chazelas

In the case of for i in ${usbs[@]}; do, i will be set to every word (separated by whitespace) in usbs. If you quote it like for i in "${usbs[@]}"; do, then i will be set to every element of usbs, as is desired.

jesse_b
  • 37,005
4

This is mostly a dupe of new lines and bash variable although that doesn't cover arrays. From there, to use a variable containing multiple lines, you need to make parameter expansion split at newline and skip globbing, and depending on your data possibly avoid other mangling:

 usbs=$( lsusb ... )
 IFS=$'\n'  # ksh bash zsh; in other shells you may need to quote an actual newline
 set -o noglob  # or more tersely set -f
 for i in $usbs; do
   printf '%s\n' "$i" # not echo which sometimes modifies some data
 done
 # if you do further things in the same script (or function) you may 
 # need to re-set IFS and/or glob, which may require saving them first

For an array, readarray/mapfile as suggested by Jesse_b is the simplest, because it already splits at lines. But you can do it 'manually' much as above:

set -o noglob  # ditto 
IFS=$'\n' usbs=( $( lsusb ... ) )  # only ksh up has arrays so $'' safe
# set +o noglob or set +f if needed
for i in "${usbs[@]}"; do # quoted array[@] forces splits equal to array elements only
  printf '%s\n' "$i"
done
3

Question: Is there a way in which, using the grep command, I can store the result of the command as two whole lines?

Yes, and your assignment code was correct:

usbs=$(lsblk -o NAME,TRAN,VENDOR,MODEL | grep usb)

This does exactly as required; in a single variable, (not an array), it stores two lines from lsblk separated by a newline. But a for loop is not the right tool to read that variable. A while loop is much better, here's an example with made-up data since some readers may not have any USB devices plugged in:

t=$(echo foo bar; echo baz bing;)
while read i ; do echo "$i" ; done <<< "$t"

Output:

foo bar
baz bing

Here's an even shorter method:

xargs -L 1 <<< "$t"

Note: while a plain POSIX style variable x is not an array, bash allows x to be identified using array notation and won't complain about x not being an array. Demo:

x=f
echo $x ${x[0]} ${x[@]}

Output:

f f f

But x is not an array. If it were, this code, (using the bash parameter transformation Assignment operator), would definitely output an array:

echo "${x[@]@A}"

...it doesn't:

x='f'

For contrast, let's compare the above to what it would look like if it were an array. First make a very similar array y, then use the Assignment operator to show the difference:

y=(f)
echo "${y[@]@A}"

Output:

declare -a y=([0]="f")
agc
  • 7,223