Shell scripting: An introduction to the shift method and custom functions

Shell scripting: An introduction to the shift method and custom functions

Shell scripting: An introduction to the shift method and custom functions
Image by : 

Nasjonalbiblioteket. Modified by Opensource.com. CC BY-SA 4.0

In Getting started with shell scripting, I covered the basics of shell scripting by creating a pretty handy "despacer" script to remove bothersome spaces from file names. Now I'll expand that idea to an application that can swap out other characters, too. This introduces the shift method of parsing options as well as creating custom functions.

This article is valid for the bash, ksh, and zsh shells. Most principles in it hold true for tcsh, but there are significant differences in tcsh syntax and flow of logic that it deserves its own article. If you're using tcsh and want to adapt this lesson for yourself, go study function hacks (tcsh hacks) and the syntax of conditional statements, and then give it a go. Bonus points shall be rewarded.

Argument and option parsing

If you've been using shell scripting to automate daily tasks, then you're familiar with the idea that any command you can execute from a POSIX shell can also be scripted. You can run the script later, at any time, to execute a series of commands that you would otherwise have to do manually. This practice is a great way to start off programming, but shell scripts can be a lot more than just a script for your computer to read and execute blindly; they can be modified on the fly so that they adapt to what you need at each runtime.

To illustrate the difference, take the straightforward script from the previous article:

$ ~/bin/depspacer "foo bar.txt"
$ ls
foobar.txt

A more advanced version might provide options:

$ ~/bin/hello-2.0.sh --from ' ' --to '_' "foo bar.txt"
$ ls
foo_bar.txt

Better? Sure it is!

To get on-the-fly user input passed into a script, you need to learn how to parse arguments and options. There are different methods for parsing input, and they differ from language to language, but for the POSIX shell you can use a series of if/then statements to check each argument sequentially.

Recall that a POSIX command considers itself $0, with the first argument being $1, then $2, and so on. If you type despace --from ' ' into a terminal, then despace is $0, --from is $1, ' ' is $2, and so on.

The current script looks like this:

#!/bin/sh

if [ -z "$1" ]; then
   echo "Provide a \"file name\", using quotes to nullify the space."
   exit 1
fi

mv -i "$1" `ls "$1" | tr -d ' '`

It parses exactly one argument by checking to see whether there is an argument or not. To expand the concept, write an if/then statement using test to analyze each argument. This is incomplete, but here's an example, assuming you want to be able to issue the command:

despacer --from ' ' --to '_' foo\ bar.txt

then:

if [ "$1" = "--from" ]; then
    FROM="$2"
elif [ "$3" = "--to" ]; then
    TO="$4"
fi

You might see a potential problem. It assumes that a user is always going to provide the --from argument first, or at all, and the --to argument next.

The way around that is to keep cycling over the arguments, processing each in turn until there are no more arguments left. To keep parsing until there are no more arguments to parse, you use a while loop with a break fallback. To force the script to proceed from one argument to the next, you'll use shift.

while [ True ]; do
    if [ "$1" = "--from" ]; then
        FROM="$2"
    shift 2
    elif [ "$1" = "--to" ]; then
        TO="$2"
    shift 2
    elif [ "$1" = "--help" ]; then
	    echo "despacer [ options ] FILE"
    echo "--from   character to remove"
    echo "--to     character to insert"
    echo "--help   print this help message"
    shift 1
    else
        break
    fi
done

ARG="$1"

In this code block, the values of $1 and $2 are relative to whatever happens to match. When the parser finds --from, then --from is $1 and whatever falls after it is $2. A variable is set (FROM=$2), and those arguments are shifted out of the way (shift 2).

When the parser finds --to, then --to is $2 and whatever falls afterwards is $2. A variable is set (TO=$2), and those arguments are shifted out of the way (shift 2).

Whenever --help is found, it becomes $1 with no expectation of anything following (shift 1).

When there's nothing left to parse, else is triggered and the script uses break to get out of the loop.

You can reasonably assume that whatever is left now is the file that you want to despace, so assign the value to $ARG.

Integrate it into the despacer application:

#!/bin/sh

while [ True ]; do
    if [ "$1" = "--from" ]; then
        FROM="$2"
    shift 2
    elif [ "$1" = "--to" ]; then
        TO="$2"
    shift 2
    elif [ "$1" = "--help" ]; then
	    echo "despacer [ options ] FILE"
    echo "--from   character to remove"
    echo "--to     character to insert"
    echo "--help   print this help message"
    shift 1
    else
        break
    fi
done

ARG="$1"
mv -i "$ARG" `ls "$ARG" | tr "$FROM" "$TO"`

Try it out:

$ ./despacer --from ' ' --to '_' "foo bar.txt" 
$ ls
foo_bar.txt

It works! Try it again, using just the default actions:

$ ./despacer.sh "foo bar.txt" 
mv: target 'bar.txt' is not a directory

You've introduced a new feature, but you've also introduced a bug that causes it to fail. Worse yet, it breaks how the application used to work.

The main problem is that if the values of $FROM and $TO are not set, then the tr command has no way to succeed. Furthermore, if one or the other is not set, or if neither are set, then tr fails.

Trickier still, if $FROM is set but $TO is left to its default, tr fails because tr wants two arguments unless the --delete (or -d for short) option is used.

But if you hard code -d to make tr work, then a $TO will never be valid.

It's quite the puzzle. Can you guess how to solve it?

There are many possible answers, but the way I'll solve it here is by manipulating the order in which variables are set, and how the final command is launched.

However, before continuing, you need to learn about custom functions.

Functions

Most shells and programming languages provide a way to write a series of statements once and then re-use them later on in the code. In this way, you only have to write the code once, meaning that if there are any bugs or logic errors in what it's doing, you only have to look in one place for the error. Plus, you usually end up writing fewer lines of code, which is often indicative of good optimization.

In this small script, the code you need to adjust has already been reduced to a one-liner, so using functions won't benefit you that much, but it does help keep your code clean and make it easy to follow.

To create a function in a bash, ksh, or zsh script:

myfunction() {
    echo "world"
}

Invoke this function later in your script by using the function name as if it were a command:

printf "hello \n"
myfunction

You can use this technique to provide your script with different command sequences, depending on how variables are set.

A functional solution

The puzzle I posed earlier is solved with a combination of functions and clever variable setting.

First, provide a default FROM value: a space (' ').

Also, set the default final command to a function containing the tr -d "$FROM" phrase.

Assuming there are no options provided, these defaults stay true.

If you give a new FROM value, then the default command stays true and only the character that tr deletes is different.

But if you provide a new TO value, then tr is no longer in delete mode, so you'll create a different function that uses tr in its native translate mode.

First, set the initial values:

#!/bin/sh
FROM=' '
ACTION=trdelete

Add in the two functions:

trtarget() {
mv -i "${ARG}" `ls "${ARG}" | tr "${FROM}" "${TO}"`
}

trdelete() {
mv -i "${ARG}" `ls "${ARG}" | tr -d "${FROM}"`
}

Add in the override for the ACTION variable if a --to option is provided:

    while [ True ]; do
    if [ "${1}" = "--from" ]; then
	FROM="${2}"
	shift 2
    elif [ "${1}" = "--to" ]; then
	TO="${2}"
	ACTION=trtarget
	shift 2
    elif [ "${1}" = "--help" ]; then
	echo "despacer [ options ] FILE"
	echo "--from   character to remove"
	echo "--to     character to insert"
	echo "--help   print this help message"
	shift 1
    else
	break
    fi
done

ARG="${1}"

Finally, execute the final command, whatever it ends up being:

$ACTION

This a fully functional bash, ksh, or zsh script with variable overrides, functions, and option parsing.

If this kind of hacking excite you, don't stop here! Find other tasks on your system that you do frequently, and find a way to make them better for yourself. Not every script or application you write has to break new ground. Sometimes, you write code just to save yourself from silly mistakes, or from having to type or click as much. In other words, no task is too small to be improved, so get in there and improve your work environment. It'll only lead to bigger and better things later on.

About the author

image from https://openclipart.org/detail/196235/penguin-profile-medalion
Seth Kenlon - Seth Kenlon is an independent multimedia artist, free culture advocate, and UNIX geek. He has worked in the film and computing industry, often at the same time. He is one of the maintainers of the Slackware-based multimedia production project, http://slackermedia.info