Monday 26 March 2018

Writing bash scripts

bash and shell scripting seems to be a problem for many people, including capable programmers in other languages.

This guide is not a list if tips and tricks. Its a description of the syntax and a list of things I wish someone had explained to me before I started.

I've had a fair bit of experience with bash over the years, hacked at the C code added features and extensions.  None of those extensions I will recommend. This post is about writing vanilla bash scripts.

When to use bash


From Google's bash style guide: "Shell should only be used for small utilities or simple wrapper scripts."

There is no loss in starting a util with bash and, if it grows up, porting it to a "proper" language.

That said, it is difficult to have to deal with little utils and applications written in esoteric languages or compiled C that could have been a shell script.

One on the main reasons to use shell is that it is decomposable.

You run it, it works: and vim will tell you how. Copy paste will let you run parts of it.  This is a pretty neat feature and not to be scoffed at.

The main rule for bash code is to keep it simple, that does not mean you cannot achieve great things.  I have written a replacement for Jenkins on a raspberrypi as a few lines of bash and, famously, git was written as shell scripts.

ToC


  • Syntax / whitespace rules
  • How quotes work
  • Why builtins exist
  • pwd

Syntax

Syntax seems deceptively simple, many folk tear their hair out with quotes, $var, and whitespace, because they have not yet assimilated the rules.

A shell script is generally a text file made up of command lines to execute, tokenized by space (seems obvious right) but it is not always.

    myprog arg1 arg2
   
e.g.
   
    mkdir -p target/

N.B. this is split by bash into an array [ 'mkdir' ,'-p', 'target/' ]

There are times when bash's behavior seems erratic but it is quite consistent.


Assignments


The statement

    myvar=foo

is tokenized to a _single_ token [ 'myvar=foo' ] which is a command that bash knows how to interpret, the string foo is assigned to a variable called myvar.

If we change the whitespace

    myvar = foo

is tokenized to [ 'myvar', '=', 'foo'] bash tries to execute a command called myvar passing the parameters = and foo.

If you are lucky myvar does _not_ exist and you get an error, if you are unlucky myvar exists and is executed.

This is tends to be confusing because other languages parse both
a=b
and a = b as 3 tokens.

bash rules are quite simple, but different, the command line is tokenized with whitespace.

[


Take, for example, the following test

    [ -z "$myvar" ] && echo missing variable:     myvar

-z means zero length string, this line checks to see if myvar is empty and if so, it is prints a message  missing variable: myvar.

There are two things that confuse people here...

The following does not work

    [-z $myvar] && echo missing variable:     myvar

[ is a command called [  it _requires_ whitespace to tokenize. 
assuming myvar is missing [-z $myvar]  is tokenized to [ '[-z' ,']' ] i.e. bash looks for a program called  [-z and passes it the argument ] and you get a cryptic message

    [-z: command not found

Remember that bash tokenizes lines with whitespace and the error messages will not seem so confusing.

echo

echo concatenates all its arguments with 1 space.

    echo missing variable:    myvar

Is tokenized to [ 'echo', 'missing', 'variable:', 'myvar' ] and echo prints it as

    missing variable: myvar
   
N.B. without the extra whitespace before myvar, seems a bit confusing at first, but your script is not "stripping whitespace",  bash is tokenizing and echo is printing what it receives.

If you quote it

    echo 'missing variable:    myvar'
   
Cli is tokenized to [ 'echo', 'missing variable:    myvar' ] and now echo prints with the correct whitespace.

    missing variable:    myvar

Concatenating arguments is only a feature of echo, its not a general practice.

    git commit -m documentation changes

Will submit just "documentation" as the comment (the parameter to -m).

    git commit -m "documentation changes"

Passes the single argument with the text.

Quotes

The rules for quoting strings are different from other languages. The basic rules for quoting are simple but can lead to confusion, and there are confusing exceptions.

The basic rules are

1. everything is a string
2. spaces require quotes
3. single quotes do no escaping or replacements
4. double quotes support escaping and replacements

1 Everything is a string

There are no number data types as with other languages.

"1" and '1' and 1 are all the string "1".  Programs may interpret the strings as numbers, but bash does not.

bc is a command line calculator

    echo 1 + 1 | bc

prints

    2

bash tokenized the command to

    [ 'echo', '1', '+', '1', '|', 'bc' ]

bc converted the string "1 + 1" to number.

2 Spaces require quotes

If you want a space in a string you must quote it.

    "hello world"
   
or single quotes

    'hello world'

Unlike JavaScript and python, " and ' are very different.

3 Single quotes do no replacements or escaping

Single quotes do no changes at all to the string.

singe quotes...

    myvar=foo
    echo 'myvar: $myvar'


prints

    myvar: $myvar

double quotes...

    myvar=foo
    echo "myvar: $myvar"


prints

    myvar: foo


In single quotes there is no escaping supported  not even ', there is no way to escape ' inside single quotes. 

4 Double quotes support escaping and replacements

Double quotes do replacement magic and escaping magic.

"Replacement" replaces $var` with the value from an environment variable.

"Escaping" is prefixing some characters with \

  • \"  escapes to just "
  • \$ escapes to just $

so

    myvar=cruel
    echo "hello    \"$myvar\"    world"

   
does escaping and replacing to print

    hello    "cruel"    world

N.B. ${} syntax has a lot more magic than you might expect. Details follow.

Using the simple rules

When no quotes are supplied strings are tokenized to multiple strings, escaping and replacements work without quotes.

    myvar=foo
    echo $myvar


Its good practice to quote strings with spaces even it does not really matter as with echo.

    echo "out:failed:missing_file"


Any string can be quoted or un-quoted, its possible but bad practice to quote programs and arguments that don't have spaces.

The following is valid but looks a bit weird

    "mkdir" "logs"
    "rm" '-rf'

   
N.B. there is nothing special about args this is just a convention

    grep "-red"

Is the same as

    grep -red

grep will interpret the first parameter as argument"-", it will not search for the string "-red".

echo newlines

Newlines terminate the command line and, as with JavaScript semicolons ; are optional.

Unlike most other languages a new line is valid inside a string.

    echo '
        one
       
        two
       
        three
    '


prints the new lines.

    one

    two

    three


You may see here docs in existing scripts

    echo <<EOF
        one
       
        two
       
        three
    EOF


Single and double quotes are often more convenient.

Empty strings

Empty string can be a bit complicated

    myprog $myvar


is subtly different to

    myprog "$myvar"


"" is an empty string, it is a token. If $myvar is missing it is not an empty string.

The former will pass a single argument, the empty string, to myprog the later will not send any arguments.

This can be important if arguments are expected

    myprog -v -m $foo -b $baa


Is better written, as follows if the variables may be empty.

    myprog -v -m "$foo" -b "$baa"

Breaking the rules

Now you know the rules, and recognize how simple they are, you will be disheartened to know that there are couple of constructs that break these simple rules.  Fortunately they behave a bit more "normally".

((

In later versions of bash there is the (( construct, this is not a program called ((, its proper syntax and its parsing rules are different.

Inside double parens, the simple whitespace tokenizing rules are broken and things that look like numbers are numbers.  The rules are much more complicated, they are undocumented C-style constructs, but they are familiar if you write and C based languages like JavaScript.

Basically, you can do math, if myvar looks like a number

    ((myvar++))

works as "expected".

    ((myvar+=2))

also works as "expected" provided you were not expecting the simpler whitespace token rules.

[[

[[ is a more modern version of [ its rules are subtly different, [[ is designed to be more natural and its use is recommended. Again [[ is not a command it is proper syntax.

  • missing vars $nothere generate empty strings.
  • && and || mean "and" and "or" (they do not terminate the command)
  • < and > mean greater and less than (not redirects)
  • ~ is not $HOME (its used for regexp)
  • globs *.* and regexp don't need (so much) escaping

The result is generally code that look more like other languages, despite breaking the simple rules.

"\ "

Rule 2 "spaces require quotes" is not 100% true, you can escape space if it is outside quotes.

It is usually best to ignore this fact in scripts.

Escaping spaces looks pretty odd in scripts.

    echo hello\ world

Escaping is more useful on the cli with tab completion.

Understanding Builtins

aka command vs programs.

Typically a the first token in a command line is a single "command" and all the others are "arguments".

    echo hello world


The "command" is "echo" and the arguments are "hello" and "world".  Bash has a few "builtins" i.e. commands that do not need an external program to run. Builtins are checked first, if one does not exist, bash looks for a program.

There is a builtin called echo, were it not there, bash would look for a program called echo and would probably find /bin/echo.

The builtin echo and the program /bin/echo are similar but its important to know they are not the same thing.

Builtin rules

Builtins run inside the bash process so they can change bash's behavior and state.

Programs run in a separate process so then cannot change bash's state.


This is why commands like set and export must be builtins, they change bash's state. echo does not change bash's state, it could be a builtin or a program.

echo exists as a builtin as an optimization, it saves forking a process to print a line of output.

So next time you ask yourself how can I run a "program" that changes a variable in the current shell, you know the answer, you cant! You have to use a builtin.

There are various builtins and ways to fork processes, the following list servers to explain why builtins exist, its not comprehensive.

cd

cd changes the current directory of the current bash shell, therefor it must be a builtin.

source

source runs a whole script (excluding the shebang) inside the current process without forking, therefor it must be a builtin. The script it runs must be a bash script since it runs inside the current bash process, you cant source a python script. Source is like a dynamic include.

Because source is a builtin it can change bash's state.  So if you want to write a script that changes lots of variables in the current bash instance you should use source.

e.g. you could create a file all-my-vars.sh that has lots of handy variables like LOG=/var/log/mylog
BIN=/usr/lib/myprog/bin
etc. To use the variables in the current script or the current shell use source.

    source ./all-my-vars.sh

There is a shortcut for this the period ".".

    . ./all-my-vars.sh

Careful with the difference between
./all-my-vars.sh
and
. all-my-vars.sh
The former "runs the script", it will not affect the current instance, the latter "sources the script" and will affect the current instance.

export

    myvar=foo

Changes the environment of the current bash shell, therefor it must be a builtin.

    export myvar=foo

Changes the environment of the current bash shell, it also tells bash to set the myvar variable in any subshell it creates from now on.

    myvar=foo myprog

sets the myvar variable just for the single execution of the myprog command.

exec

exec transmogrifies the bash process into a different program.  Its pretty magic, since it very much changes the state of bash it must be a builtin.

If at the end of a script you write

    exec myprog arg1 arg2

When you run the script, the bash instance will "disappear" and will be replaced by myprog, when you look in the ps list you will not see bash.

This is handy for launch scripts.  For example if you want to write a script ./run-myprog.sh to run a program called myprog that creates a needed directory first, you could write

    #!/bin/bash

    mkdir -p /tmp/myprog
   
    myprog $1


When you run this and look in the ps list you will see.

    me  123   44  bash ./run-myprog.sh arg1
    me  124  123  myprog arg1


Using exec builtin the bash process will magically disappear
   
    #!/bin/bash

    mkdir -p /tmp/myprog
   
    exec myprog arg1


When you run this and look in the ps list you will just see.

    me  124  123  myprog arg1

If you are using lots of little scripts to setup launching a daemon, judicious use of source and exec can keep the number of bash instances in the ps list during and after the boot to a minimum.


Subshells

Subshells are bash instances started by bash, there are a few syntax options.

    bash ./myscript
    (./myscript)


Subshells are new processes, not builtins, they cannot change the current process.
You can capture the output of a subshells, you have two syntax options, backticks or the more modern dollar parens.

    output=`./myscript`
    output=$(myscript)


String manipulation

Linux provides lots of tools for processing strings in pipes. e.g. sed and awk it is tempting to use subshells and pipes to change strings

    new_string=`echo $oldstring | cut 5-10 | tr -d '\n'`

For example, if you have a variable my_file containing myprog.dat and you want to create a filename called myprog.bak.

You could solve this with subshells and Linux tools e.g.

    new_name=$(echo $my_file | sed -e 's/.dat$//')
    new_name=${new_name}.bak


There is nothing wrong with this approach except perhaps performance.  Bash ninjas would prefer not to fork processes and will tend to use the builtin string manipulation features of bash.  This is a big subject, the important thing to know is that...

  1. bash has very flexible string manipulation
  2. the syntax is screwy

The basic syntax is ${VAR [insert magic] }  where magic has operators such as  %, #, /, and :

Regexp is the easiest to remember since it uses / / similar to sed.

    new_name=${my_file/.dat/.bak}


Escaping regexp is complicated. In this case trim is more reliable.

    new_name=${my_file%.dat}.bak

I would recommend commenting such magic. % is not a common trim operator.

The performance benefits are often negligible, kudos on StackOverflow is priceless.

If you find yourself using string manipulation a lot in one particular script, it is an indicator you should port to a proper language.


PWD concenrs


Every unix process has a current working directory PWD, bash included, it changes it more often than most programs.

Usually the same command works on the command line, as in a script.

    mkdir -p target/

typed on the cli and in a script has the same effect.

Where the target dir is created is dependent on your current directory, so its best to always use absolute paths in scripts.
Or add

    cd $(dirname $0)

at the start so that all commands run from the directory where the script is located.

e.g. if you have a script called /home/me/workspace/init.sh

    #!/bin/bash
    cd $(dirname $0)
    mkdir -p target/


and you run it as ./init.sh or from home as workspace/init.sh it will have the same result.

Subshells can have a different PWD and do not affect the parent shell.  This can be useful to temporarily change directory.

You could save the PWD and cd back to it after your change...
   
    OLDPWD=$PWD
    cd /var/log/$myprogname
    cat $myprogname.log
    cd $OLDPWD
    unset OLDPWD

   
This is neater with subshells

    ( cd /var/log/$myprogname; cat $myprogname.log )

Handy for executing prgrams the are dependent on the PWD.

    ( cd src/c; make )


Or running a series of commands in a different directory
   
    (
        cd /tmp
        touch foo
        touch baa
        touch quxx
    )


Now what?

That was not really too much to digest was it. I get called over to help with tricky bash issues quite a lot and 9 times out of 10 the problems is caused by one of these issues.  Hence writing this doc.

Once you know the basics, I highly recommend you read man bash through once.  It is dull, but there you will find a ton of tricks.

For sane coding conventions I would recommend (Google's shell style guide)
Google come down hard on the the tabs vs spaces issue. It makes sense because you really want to be able to copy paste and tab is introduces a serious risk if you do that.  You really should avoid tabs in new code.

For sane design guide, KISS cannot be overstated.