BASH Notes

Compound commands

Commands in a list with () are executed in a subshell:

(ls ; cd $HOME)

Commands in a list with {} are executed in current shell environment. Keep spaces after and before brackets and end with a semicolon.

{ ls ; cd $HOME ; }

Basic variables (aka Parameters)

Quoting

Variables are expanded in double quotes but not single quotes:

echo "I am $HOME"
I am /home/USER

But:

echo 'I am $HOME'
I am $HOME

Setting

It’s okay to leave curly braces {} out for minor uses, small scripts, etc., but using {} is more robust, explicit, and it is especially helpful when marking the end of a variable value.

name="Ray"
echo "Hello, $name!"
echo "Hello, ${name}!"
echo "Hello, ${name}'s kids!"

Appending

Append to a variable:

name="Ray"
name+=" of Light"
echo "$name"
Ray of Light

Or append numbers as strings:

a=4
a+=3
echo "$a"
43

Types

Declare variable to be an integer.

declare -i a=4
a+=3
echo "$a"
7

Declare variable as an indexed array:

declare -a fruit=(apple banana cherry)

Declare variable as an associated array:

declare -A fruit=(["first"]="banana" ["second"]="apple" ["third"]="cherry")

Parameter Expansion

Substitution

Use ${parameter:-substitute} if parameter is unset. This returns ‘Harry’ instead of a value for ‘friend’ since ‘friend’ is unset:

name="Norman"
echo "${name} and ${friend:-Harry}!"
Norman and Harry

But in this example, ‘Harry’ isn’t used because ‘Gina’ is declared.

friend="Gina"
echo "${name} and ${friend:-Harry}!"
Norman and Gina

Using the + sign returns the alternate, even if the variable is set:

friend="Gina"
echo "${name} and ${friend:+Harry}!"
Norman and Harry

Substring expansion

Removes the first two characters:

letters="abcdefg"
echo "${letters:2}"
cdefg

Removes the first two characters, returns the next three and stops:

echo "${str:2:3}"

Matching Prefixes

Names the matching prefix:

filename="1a_history_linux.md"
filetype=(md txt csv dat)
echo "${!file*}"
filename filetype

Parameter length

Return the length of a variable:

filename="1a_history_linux.md"
echo "${#filename}"
19

Remove Matching Prefix Patterns

Let’s set a variable:

filename="1a_history_linux.md"

The following will remove up to the shortest (first) match of the underscore, from left to right:

echo "${filename#*_}"
history_linux.md

The following will remove up to the longest (last) match of the underscore:

echo "${filename##*_}"
linux.md

Since there’s only one period, you can remove all through the period:

echo "${filename#*.}"
md

Remove Matching Suffix Patterns

Remove the suffix, i.e., matching right to left. Using the % matches the first pattern and %% matches the furthest. Working right to left, then:

echo "${filename%.md}"
1a_history_linux

Or, to be file extension agnostic:

echo "${filename%.*}"

Go to the first match of the underscore (which can be replaced with another pattern) from the right:

echo "${filename%_*}"
1a_history

And the longest match from the right:

echo "${filename%%_*}"
1a

Match the a to remove all but the 1:

echo ${filename%%a_*}
1

Search and Replace: Pattern Substitution

Replaces first match:

my_path="/home/home/bin"
echo "${my_path/home/usr}"
/usr/home/bin

Use two // to replace all matches:

echo "${my_path//home/usr}"
/usr/usr/bin

In the past, I’d use sed for something to print my shell’s paths on separate newlines, but we can add newlines with this method. See the section on QUOTING in the Bash man page for the meaning $'string' (ANSI C quoting):

echo -e "${PATH//:/$'\n'}"

printf works and is probably better:

printf "%s\n" "${PATH//:/$'\n'}"

Another example:

name="Chris"
echo "${name/Chris/Ralph}"
Ralph

Or:

printf "%s\n" "${name/Chris/Ralph}"
Ralph

Case modification

We can change the case in various ways.

Lower to Upper

The ^ converts lower to upper:

To change the first character:

name="chris"
echo "${name^}"
Chris

Upper cases:

echo "${name^^}"
CHRIS

Use pattern matching. Here we match for the letter i:

echo "${name^^i}"
chrIs

Or ranges:

echo "${name^^[a-n]}"
CHrIs

Upper to Lower

The , converts upper to lower case:

name="CHRIS"

Conver the first character:

echo "${name,}"
cHRIS

Convert all characters:

echo "${name,,}"
chris

Use pattern matching:

echo "${name,,I}"
CHRiS

Parameter Transformation

We more options with this syntax: ${parameter@operation}.

Converts lower to uppercase:

name="Chris"
echo "${name@U}"
CHRIS

Converts first character to upper and remaining characters to lower:

name="chris"
echo "${name@u}"
Chris

Example on an array:

animals=(bats kangaroos cheetahs zebras snakes)
echo "${animals[@]@u}" 
Bats Kangaroos Cheetahs Zebras Snakes

Transformations include upper to lowercase:

name="CHRIS"
echo "${name@L}"
chris

Returns single quoted string:

name="CHRIS"
echo "${name@Q}"
'CHRIS'

Returns parameter and value for associative array

animals=(bats kangaroos cheetahs zebras snakes)
echo "${animals[@]@A}" 
declare -a animals=([0]="bats" [1]="kangaroos" [2]="cheetahs" [3]="zebras" [4]="snakes")

For example:

while IFS=, read -r food type; do
    echo "${food@u} ${type@U}"
done < foods.txt

where foods.txt is:

apple,is fruit
broccoli,is vegetable
banana,is fruit
kale,is vegetable

The while loop returns:

Apple IS FRUIT
Broccoli IS VEGETABLE
Banana IS FRUIT
Kale IS VEGETABLE

Arrays

Indexed Arrays

declare is optional but use it to make it obvious that we’re declaring an array:

declare -a fruits=(apple banana cherry)

Returns the entire array:

echo "${fruits[@]}"
apple banana cherry

Index starts at 0, so this returns the second item in the list:

echo "${fruits[1]}"
banana

Starting from the right, which indexes at -1:

echo "${fruits[-1]}"
cherry

Add the # to get the number of items in (or length of) the array:

echo "${#fruits[@]}"
3

Slice the array:

echo "${fruits[@]:1:3}"
banana cherry

Append to the array. Note that if item exists at index 3, then this replaces the item:

fruits[3]="orange"

Agnostic way to append to the array:

fruits+=(orange)

Print all keys or indices in array

echo "${!fruits[@]}"

Loop through an array

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

Loop through an array’s indices, print each line

for i in "${!fruits[@]}" ; do
    echo "$i"
done

Associative Arrays

declare -A fruits=(["first"]="Banana" ["second"]="Apple" ["third"]="Cherry")

Print all values but not keys

echo "${fruits[@]}"

Print all keys or indices in array. Unfortunately, Bash doesn’t preserve the order:

echo "${!fruits[@]}"
second first third

Add to assoc array

fruits+=(["fourth"]="orange") 

Brace Expansion

Generate arbitrary strings. Use the following syntax:

{x..y[..incr]}

Thus, this expands to 1 2 3 4 5:

echo {1..5}
1 2 3 4 5

Or using the alphabet:

echo {a..f}
a b c d e f

To use increments (or steps):

echo {1..10..2}
1 3 5 7 9

Again, can use increments with the alphabet:

echo {a..m..2}
a c e g i k m

Or in reverse:

echo {m..a..2}
m k i g e c a

We can also use brace list expansion:

echo {ralph,gina}@example.com
ralph@example.com gina@example.com

Or creating directories:

mkdir -p bin/{src,images}

Values in {..} are expanded before $var is evaluated, so the following doesn’t work: (we’d expect the output: 1 2 3 4 5).

n=5
echo {1..$n}
1..5

Command Substitution

The output of a command replaces the command name. I use this often, but basically it’s like so:

touch "$(date +%A).md"
ls
Wednesday.md

Process Substitution

Command substitution creates text as output, but process substitution creates a file as output. So if we want to treat the output as a file, then use process substitution. Thus, it seems to me that process substitution’s best use case is to avoid the use of temporary files. E.g., instead of:

sort a.txt > a.sorted
sort b.txt > b.sorted
diff a.sorted b.sorted

We can do:

diff <(sort a.txt) <(sort b.txt)

Here’s a simple case using grep alternation. If today is ‘Wed’ or ‘Thu’, then the output returns that:

grep -E "Wed|Thu" <(date)
Wed

Redirection

Here Documents

This takes the syntax of:

command <<EOF
...
...
...
EOF

I use this in shell script with ed because it’s cool to add ed commands:

ed testfile <<EOF
a
This is a test file.
It's nothing but a file that I created with a HERE DOC.
.
wq
EOF

Then we can edit it and run commands:

ed testfile <<EOF
a
I'm adding more lines to this file.
.
g/adding/s/more/new/
wq
EOF

In scripts (specifically in indented loops, functions, etc), add the dash, which strips leading tabs, etc, and helps to keep the function clean looking:

function() {
    command
    command <<-EOF
    ...
    ...
    ...
    EOF
}

Here Strings

Here strings come in the form:

command <<< "some input"

And is a better alternative than:

echo ... | command

Avoiding echo means avoiding a subshell:

grep hello <<< "hello world"

as compared to:

echo "hello world" | grep hello

Some use cases.

A command that takes stdin:

wc -c <<< "$(date)"

Hash a string:

sha256sum <<< "some_string_of_characters"

Check that a regex matches some value:

grep -E '[0-9]{4}' <<< "$(date)"

Avoid subshells. This is good:

count=0
while read -r line; do
    ((count++))
done <<< "one line"

But this would create a subshell:

count=0
echo "one line" | while read -r line; do
    ((count++))
done

echo "$count"

{Pre,Post}-{increment,decrement}

Post-increment id++ and post-decrement id-- will return the original value and then auto increment in arithemtic expansion.

a=4
echo $((a++))
4
echo $((a++))
5

Pre-increment ++id and pre-decrement --id auto increments immediately.

a=4
echo $((++a))
5
echo $((++a))
6

However, both act the same way in a, e.g., a for loop. I.e., these are equivalent.

Post-increment:

for ((i=0;i<10;i++)) ; do echo $i ; done
0
1
2
3
4
5
6
7
8
9

Pre-increment:

for ((i=0;i<10;++i)) ; do echo $i ; done
0
1
2
3
4
5
6
7
8
9

However, in a while loop when incrementing in a condition.

Post-increment:

i=0
while (( i++ <3 )); do echo "i is now $i" ; done
i is now 1
i is now 2
i is now 3

Pre-increment:

i=0
while (( ++i <3 )); do echo "i is now $i" ; done
i is now 1
i is now 2

Loops

Count columns

Here’s an examle where I wanted to do something in Bash, like count numbers in separate columns in a file, that I’d normally do in something like awk:

#!/usr/bin/env bash

while IFS=, read -r one two three; do
    (( sum_one += one ))
    (( sum_two += two ))
    (( sum_three += three))
done < "$1"

echo "$sum_one $sum_two $sum_three"

Where "$1" might be:

1,2,3
4,5,6
7,8,9

The above would be the awk equivalent:

#!/usr/bin/awk -f

BEGIN { FS="," }

{
    sum_one += $1
    sum_two += $2
    sum_three += $3
}

END {
    print sum_one, sum_two, sum_three
}

Office Hours

Each semester I put a sign on my door that lists my office hours for that semester. I usually do this in code. Here are a couple of examples:

#!/usr/bin/env bash

declare -a office_days=(Wed Thu)

today=$(date +%a)

for d in "${office_days[@]}"; do
    if [[ "$today" = "$d" ]]; then
        echo "office hours today"
        exit 0
    fi
done

echo "office hours not today"

Here’s a wordier version:

#!/usr/bin/env bash

declare -a office_days=(Mon Tue Wed Thu Fri)

if [[ "${office_days[0]}" = "$(date +%a)" || "${office_days[1]}" = "$(date +%a)" ]] ; then
    echo "office hours today"
else
    echo "office hours not today"
fi 

Using read like I would awk

Based on the @YSAP YT channel:

#!/usr/bin/env bash

while IFS=, read -r name age job; do
  echo "$name works as a $job and is $age years old."
done < data.csv

Or:

#!/usr/bin/env bash

data=(name age job)

name="${data[0]}"
age="${data[1]}"
job="${data[2]}"

while IFS=, read -r "${data[@]}" ; do
  echo "${name}, the ${job}, is ${age} years old."
done < data.csv

Where data.csv is:

angie,23,cat burgler
barry,30,graphics designer
carolyn,44,tycoon
dennis,50,professor