Skip to content
Author Nejat Hakan
eMail nejat.hakan@outlook.de
PayPal Me https://paypal.me/nejathakan


Shell Scripting Basics

Introduction What is Shell Scripting

Welcome to the world of shell scripting! Before we dive into writing scripts, let's understand the fundamental concepts.

What is a Shell?

In Linux and other Unix-like operating systems, the shell is a command-line interpreter. It's the program that takes the commands you type in your terminal, interprets them, and asks the operating system to perform the corresponding action. Think of it as the primary interface between you (the user) and the operating system's kernel. When you open a terminal window, you are interacting with a shell.

There are various shells available, each with slightly different features and syntax, but the core concepts are similar. Some popular shells include:

  • Bash (Bourne Again SHell): The most common default shell on Linux distributions. It's powerful, feature-rich, and what we will primarily focus on in this guide due to its ubiquity.
  • sh (Bourne Shell): An earlier, simpler shell. Bash is largely compatible with sh but adds many extensions. Scripts written strictly for sh (#!/bin/sh) aim for maximum portability across different Unix-like systems.
  • Zsh (Z Shell): A powerful shell with many advanced features, including improved tab completion, spelling correction, and themeability.
  • Ksh (Korn Shell): Developed by Bell Labs, it blends features from the Bourne shell and the C shell.
  • Fish (Friendly Interactive SHell): Known for its user-friendliness and features like syntax highlighting and autosuggestions out-of-the-box.

What is a Script?

In computing, a script is simply a file containing a sequence of commands that are meant to be executed by an interpreter. Instead of typing commands one by one into the terminal, you write them down in a file, and then tell the interpreter (in our case, the shell) to run all the commands in that file in order.

What is Shell Scripting?

Putting it together, shell scripting is the practice of writing scripts (files containing sequences of commands) that are executed by a shell. It's a powerful way to automate repetitive tasks, manage system configurations, perform complex operations, and essentially harness the full power of the command-line environment in an automated fashion.

Why Learn Shell Scripting?

  • Automation: The primary driver. Automate backups, system updates, file processing, report generation, software deployment, and countless other tasks.
  • Efficiency: Execute complex sequences of commands with a single instruction, saving significant time and effort compared to manual execution.
  • Consistency: Ensure tasks are performed exactly the same way every time, reducing the chance of human error.
  • Customization: Create custom tools and utilities tailored to your specific needs or workflow.
  • System Administration: An essential skill for Linux system administrators for managing users, permissions, services, logs, and more.
  • Understanding Linux: Writing shell scripts deepens your understanding of how Linux commands work together and how the system operates.

In this section, we will cover the foundational elements you need to start writing your own useful shell scripts using the Bash shell.

1. Your First Shell Script

Let's jump right in and create our very first shell script. It's a tradition in programming to start with a "Hello, World!" example, and shell scripting is no exception.

The Shebang (#!)

Almost every shell script should begin with a special line called the "shebang". It looks like this:

#!/bin/bash
  • #: The hash symbol typically indicates a comment in shell scripts (text ignored by the interpreter). However, when #! appears as the very first two characters of a file, it has a special meaning.
  • !: This is colloquially called the "bang".
  • /bin/bash: This is the absolute path to the program (the interpreter) that should be used to execute the rest of the commands in the file. In this case, we're explicitly telling the system to use the Bash shell located at /bin/bash.

Why is the shebang important?

When you try to execute a script file directly (e.g., by typing ./my_script.sh), the operating system looks at the first line. If it finds a shebang, it uses the specified interpreter (/bin/bash in our example) to run the script. If the shebang is missing, the system might try to run the script using the user's current default shell, which could be Bash, Zsh, or something else. This can lead to unexpected behavior or errors if the script uses features specific to a particular shell (like Bash) but is run by a different one (like sh). Therefore, explicitly setting the interpreter with a shebang is crucial for predictability and portability. You might sometimes see #!/usr/bin/env bash, which uses the env command to find the bash executable in the user's PATH, offering slightly more flexibility in some environments, but /bin/bash is very common and reliable on most Linux systems.

Comments

As mentioned, the # symbol is used for comments. Any text on a line following a # (unless it's part of the shebang) is ignored by the shell. Comments are essential for explaining what your script does, why certain commands are used, or how different parts of the script work. Good commenting makes your scripts understandable to others and to your future self!

#!/bin/bash

# This is a full-line comment explaining the script's purpose.
echo "Hello, World!" # This is an inline comment explaining this specific command.

Basic Commands (echo)

The echo command is one of the simplest and most frequently used commands. It simply displays the text (or arguments) you give it to the standard output (usually your terminal screen).

echo "Some text to display"

Creating the Script File

You can use any text editor to create your script file. Common command-line editors include nano (beginner-friendly) and vim (powerful but steeper learning curve). Graphical editors like gedit, kate, or VS Code also work perfectly well. Let's use nano.

  1. Open your terminal.
  2. Type nano hello_world.sh and press Enter. This opens the nano editor, creating a new file named hello_world.sh.
  3. Type the following lines into the editor:

    #!/bin/bash
    
    # My first shell script
    # Purpose: To display a greeting message
    
    echo "Hello, World! Welcome to the exciting realm of Linux shell scripting."
    
  4. Save the file: Press Ctrl+O (Write Out), confirm the filename (hello_world.sh) by pressing Enter.

  5. Exit nano: Press Ctrl+X.

You now have a file named hello_world.sh in your current directory.

Making the Script Executable

By default, new files you create usually don't have permission to be executed as programs. You need to explicitly grant this permission.

The chmod (change mode) command is used to change file permissions. The +x option adds execute permission.

  1. In your terminal, type: chmod +x hello_world.sh
  2. Press Enter.

To verify the permission change, you can use the ls -l command (list files in long format):

ls -l hello_world.sh

You should see output similar to this:

-rwxrwxr-x 1 your_user your_group 135 May 17 10:30 hello_world.sh

Notice the x characters in the permissions block (rwxrwxr-x). This indicates that the owner (rwx), group (rwx), and others (r-x) now have execute permission (along with read r and write w permissions, which might vary slightly based on your system's default settings).

Running the Script

There are a couple of ways to run your script:

  1. Using the Path (Recommended for executable scripts): If you are in the same directory as your script, you need to specify its path relative to the current location. The dot (.) represents the current directory.

    ./hello_world.sh
    

    Why ./? For security reasons, the current directory (.) is typically not included in the system's PATH environment variable (a list of directories where the shell looks for executable programs). Explicitly providing ./ tells the shell exactly where to find the script.

  2. Using the Interpreter Explicitly: You can tell the Bash interpreter to execute the script file directly. In this case, the script doesn't strictly need execute permission (+x) or even a shebang (though the shebang is still best practice).

    bash hello_world.sh
    

Both methods should produce the same output in the terminal:

Hello, World! Welcome to the exciting realm of Linux shell scripting.

Congratulations! You've created and executed your first shell script.

Workshop Creating and Running a Simple System Information Script

Goal: Create a script that displays the current date/time, the logged-in user, and the system's hostname.

Theory Recap: You'll need the shebang, comments, and the echo command. You'll also need a few new commands:

  • date: Displays the current date and time.
  • whoami: Displays the username of the current user.
  • hostname: Displays the system's network name.

Steps:

  1. Create the script file: Open your text editor (e.g., nano) to create a new file named system_info.sh.
    nano system_info.sh
    
  2. Add the Shebang and Comments: Start the script with the Bash shebang and add comments explaining its purpose.
    #!/bin/bash
    
    # Script to display basic system information
    # Date: $(date +%Y-%m-%d) # Example of dynamic comment (optional)
    
  3. Use echo and Commands: Add echo commands to print descriptive labels, and directly call the system commands to get the information.
    #!/bin/bash
    
    # Script to display basic system information
    # Date: $(date +%Y-%m-%d)
    
    echo "---------------------------------"
    echo "System Information Report"
    echo "---------------------------------"
    
    echo "Current Date and Time : $(date)" # Use command substitution (explained later, works here!)
    echo "Logged-in User        : $(whoami)"
    echo "System Hostname       : $(hostname)"
    # Alternatively, run commands directly if output format is okay
    # echo "Current Date and Time:"
    # date
    # echo "Logged-in User:"
    # whoami
    # echo "System Hostname:"
    # hostname
    
    echo "---------------------------------"
    echo "Report Complete."
    echo "---------------------------------"
    
    Self-correction/Refinement: Initially, I might just call date, whoami, hostname. But using echo with labels makes the output much clearer. Using $(command) inside the echo string (called command substitution, covered next) is a neat way to embed the output directly. Let's use that improved version.
  4. Save and Exit: Save the file (Ctrl+O, Enter) and exit the editor (Ctrl+X).
  5. Make Executable: Grant execute permission to the script.
    chmod +x system_info.sh
    
  6. Verify Permissions (Optional): Check the permissions using ls -l.
    ls -l system_info.sh
    
  7. Run the Script: Execute the script from your terminal.
    ./system_info.sh
    
  8. Observe the Output: You should see a nicely formatted report showing the current date/time, your username, and your computer's hostname, similar to this (details will vary):
    ---------------------------------
    System Information Report
    ---------------------------------
    Current Date and Time : Fri May 17 10:45:12 BST 2024
    Logged-in User        : your_user
    System Hostname       : your-linux-pc
    ---------------------------------
    Report Complete.
    ---------------------------------
    

This workshop reinforces creating, making executable, and running a script, while also introducing a few useful system commands.

2. Variables and User Input

Static scripts that do the same thing every time are useful, but scripts become truly powerful when they can handle changing data. This is where variables come in. We also need ways to get information into the script, such as from the user running it.

Variables

A variable is essentially a named placeholder for a piece of data (a string of text, a number, etc.). You can assign a value to a variable and then refer to that value later using the variable's name.

Naming Conventions:

  • Variable names can contain letters (a-z, A-Z), numbers (0-9), and underscores (_).
  • The first character must be a letter or an underscore.
  • Variable names are case-sensitive (myVar is different from myvar).
  • Best Practice: Use descriptive names (e.g., user_name, file_count, TARGET_DIRECTORY). By convention, environment variables and constants are often written in UPPERCASE, while local script variables are often lowercase or snake_case.

Assigning Values:

You assign a value to a variable using the equals sign (=). Crucially, there must be no spaces around the = sign.

# Correct assignment
first_name="Alice"
user_count=10
CONFIG_FILE="/etc/myapp.conf"

# Incorrect assignments (will cause errors)
# first_name = "Alice"  # Error: space before =
# user_count= 10       # Error: space after =

Accessing Variable Values:

To use the value stored in a variable, you prefix its name with a dollar sign ($).

greeting="Hello"
name="Bob"

echo $greeting $name # Output: Hello Bob

# It's often safer to enclose the variable name in curly braces: ${variable_name}
# This avoids ambiguity, especially when the variable name is followed by characters
# that could be part of a variable name.
fruit="apple"
echo "I like ${fruit}s" # Output: I like apples
echo "I like $fruits"   # Output: I like  (Tries to find a variable named 'fruits', which is likely unset)

Using ${variable_name} is generally recommended for clarity and safety.

Quoting:

How you enclose the value during assignment or when using echo matters:

  • Double Quotes ("): Preserve literal values of most characters, but allow variable expansion ($variable), command substitution ($(command)), and arithmetic expansion ($((expression))). This is the most commonly used type of quoting.
    user="Charlie"
    message="User logged in: $user" # $user is replaced by Charlie
    echo "$message"                # Output: User logged in: Charlie
    
  • Single Quotes ('): Preserve the literal value of every character within the quotes. No expansion occurs. Useful when you want to treat characters like $ literally.
    literal_dollar='The price is $5 today'
    echo "$literal_dollar" # Output: The price is $5 today
    echo '$user'          # Output: $user (variable not expanded)
    
  • No Quotes: The shell performs word splitting (splitting the value based on spaces, tabs, newlines) and filename expansion (globbing, e.g., * expands to filenames). This can often lead to unexpected behavior, especially if the value contains spaces or special characters. It's generally safest to double-quote variable expansions (echo "$variable") unless you specifically need word splitting or globbing.
    filename="My Important Document.txt"
    # Avoid this:
    # ls $filename # Might be interpreted as: ls My Important Document.txt (two arguments)
    # Use this:
    ls "$filename" # Interpreted as: ls "My Important Document.txt" (one argument)
    

Command Substitution:

You can capture the output of a command and store it in a variable. There are two syntaxes:

  • $(command): The modern, preferred syntax. It's easier to nest.
  • `command` (Backticks): The older syntax. Harder to read and nest. Avoid if possible.
current_date=$(date +%Y-%m-%d) # Preferred syntax
echo "Today's date is: $current_date"

# Old syntax (works but less clear)
# kernel_version=`uname -r`
# echo "Kernel: $kernel_version"

# Using preferred syntax
kernel_version=$(uname -r)
echo "Kernel: $kernel_version"

Reading User Input (read)

The read command is used to get input from the user typing in the terminal and store it in one or more variables.

echo "Please enter your name:"
read user_name # Waits for user input, stores it in user_name variable

echo "Please enter your city and country (separated by space):"
read city country # Reads first word into city, the rest of the line into country

echo "Hello, $user_name from $city, $country!"

It's often more user-friendly to display a prompt on the same line where the user types. Use the -p (prompt) option for this:

read -p "Enter your favorite color: " favorite_color
echo "You chose $favorite_color."

# Reading sensitive data (like passwords) without showing it on screen
read -s -p "Enter your password: " user_password
echo # Print a newline after the hidden input for cleaner output
echo "Password received (but not shown)."

Workshop Personalized Greeting Script

Goal: Create a script that asks the user for their name and department, then displays a personalized welcome message including the current time.

Theory Recap: You'll use variable assignment, $variable access (with ${}), double quotes for expansion, read -p for user input, and command substitution ($(date)).

Steps:

  1. Create the script file:
    nano personalized_greeting.sh
    
  2. Add Shebang and Initial Comments:
    #!/bin/bash
    
    # Script to greet a user personally and provide the time.
    
  3. Get User Input: Use read -p to prompt the user for their name and department and store the input in variables.
    #!/bin/bash
    
    # Script to greet a user personally and provide the time.
    
    echo "Welcome! Please provide your details."
    read -p "Enter your first name: " user_first_name
    read -p "Enter your department: " user_department
    
  4. Get Current Time: Use command substitution to store the current time (in a specific format) in a variable. The date command has many formatting options (see man date). Let's use Hour:Minute AM/PM format (%r).
    current_time=$(date +"%r") # Use + followed by format specifiers
    
  5. Display Personalized Greeting: Use echo with double quotes to combine the variables and static text into a meaningful message. Use ${} for clarity.
    echo # Add an empty echo for spacing
    echo "-----------------------------------------"
    echo "Hello, ${user_first_name} from the ${user_department} department!"
    echo "The current time is: ${current_time}"
    echo "Have a productive day!"
    echo "-----------------------------------------"
    
  6. Save and Exit: Save (Ctrl+O, Enter) and exit (Ctrl+X).
  7. Make Executable:
    chmod +x personalized_greeting.sh
    
  8. Run the Script:
    ./personalized_greeting.sh
    
  9. Interact and Observe: The script will pause and prompt you for input. Enter the requested details and press Enter after each prompt. Observe the final output.
    Welcome! Please provide your details.
    Enter your first name: Sarah
    Enter your department: Computer Science
    
    -----------------------------------------
    Hello, Sarah from the Computer Science department!
    The current time is: 11:15:30 AM
    Have a productive day!
    -----------------------------------------
    

This workshop provides hands-on practice with variable assignment, accessing variable values within strings, capturing command output, and interacting with the user via read.

3. Control Flow Conditional Statements

Scripts often need to make decisions based on certain conditions. Should we create a directory if it doesn't exist? Should we process a file only if it's larger than a certain size? Conditional statements allow our scripts to execute different blocks of code depending on whether specific conditions are true or false.

The if Statement

The most fundamental conditional statement is if. Its basic structure is:

if [ condition ]; then
    # Commands to execute if the condition is true
    command1
    command2
fi # 'fi' marks the end of the if block
  • if: Keyword starting the conditional.
  • [ condition ]: This is the core of the decision. The square brackets [ and ] are actually a command, synonymous with the test command. It evaluates the condition and exits with a status code: 0 (true/success) if the condition is met, or a non-zero value (false/failure) if it's not. Crucially, there must be spaces around the brackets and often around operators inside the brackets.
  • ; then: The semicolon ; acts as a command separator, allowing then to be on the same line. Alternatively, then can be on the next line without a preceding semicolon. then marks the beginning of the code block to execute if the condition is true.
  • commands: One or more commands to execute if the condition evaluates to true (exit status 0). Indentation here is purely for readability and is highly recommended.
  • fi: Keyword marking the end of the if statement (if spelled backward).

Modern Bash [[ ... ]]:

Bash provides an enhanced version of the test command using double square brackets: [[ condition ]]. This is generally preferred over single brackets [ ... ] when writing Bash-specific scripts because:

  • It prevents word splitting and filename expansion issues for variables within the brackets, meaning you often don't need to quote variables inside [[ ]] (though it's still good practice).
  • It supports more advanced features like pattern matching (=~) and logical operators (&&, ||) directly inside the brackets.
  • It's a keyword, not a command, which can make it slightly more efficient and less prone to certain errors.

We will primarily use [[ ... ]] in our examples.

# Using [[ ... ]]
if [[ condition ]]; then
    # Commands if true
    commands
fi

The test Command / Square Brackets ([ or [[) Conditions:

The condition inside the brackets is typically an expression that test (or [[ ]]) evaluates. Common types of conditions include:

  • String Comparisons:

    • [[ "$string1" == "$string2" ]]: True if strings are identical. (Note: = works too, but == is clearer for comparison). Inside [[ ]], == also performs pattern matching on the right side if unquoted.
    • [[ "$string1" = "$string2" ]]: Also checks for string equality. = is the POSIX standard equivalent for [ ].
    • [[ "$string1" != "$string2" ]]: True if strings are not identical.
    • [[ -z "$string" ]]: True if the string is empty (zero length).
    • [[ -n "$string" ]]: True if the string is not empty (non-zero length).
    • [[ "$string1" < "$string2" ]]: True if string1 sorts lexicographically before string2. (Requires [[ ]]).
    • [[ "$string1" > "$string2" ]]: True if string1 sorts lexicographically after string2. (Requires [[ ]]). Important: Always double-quote variables in string comparisons, especially when using single brackets [ ], to handle empty strings or strings with spaces correctly. It's good practice even within [[ ]].
  • Numeric Comparisons: (Use these for integers only)

    • [[ "$num1" -eq "$num2" ]]: True if numbers are equal (equal).
    • [[ "$num1" -ne "$num2" ]]: True if numbers are not equal (not equal).
    • [[ "$num1" -lt "$num2" ]]: True if num1 is less than num2 (less than).
    • [[ "$num1" -le "$num2" ]]: True if num1 is less than or equal to num2 (less or equal).
    • [[ "$num1" -gt "$num2" ]]: True if num1 is greater than num2 (greater than).
    • [[ "$num1" -ge "$num2" ]]: True if num1 is greater than or equal to num2 (greater or equal).
  • File Tests: (Check properties of files/directories)

    • [[ -e "$path" ]]: True if the path exists (file or directory or link, etc.) (exists).
    • [[ -f "$path" ]]: True if the path exists and is a regular file (file).
    • [[ -d "$path" ]]: True if the path exists and is a directory (directory).
    • [[ -L "$path" ]]: True if the path exists and is a symbolic link (link).
    • [[ -r "$path" ]]: True if the path exists and is readable (readable).
    • [[ -w "$path" ]]: True if the path exists and is writable (writable).
    • [[ -x "$path" ]]: True if the path exists and is executable (executable).
    • [[ -s "$path" ]]: True if the path exists and has a size greater than zero (size > 0).
    • [[ "$file1" -nt "$file2" ]]: True if file1 is newer than file2 (newer than).
    • [[ "$file1" -ot "$file2" ]]: True if file1 is older than file2 (older than).
  • Logical Operators: (Combine conditions)

    • [[ condition1 && condition2 ]]: Logical AND (both must be true). (Requires [[ ]]). For [ ], use [ condition1 -a condition2 ].
    • [[ condition1 || condition2 ]]: Logical OR (at least one must be true). (Requires [[ ]]). For [ ], use [ condition1 -o condition2 ].
    • [[ ! condition ]]: Logical NOT (inverts the truthiness of the condition). Works in both [[ ]] and [ ].

Example if statement:

#!/bin/bash

read -p "Enter a number: " number

if [[ "$number" -gt 10 ]]; then
    echo "The number $number is greater than 10."
fi

read -p "Enter a filename: " filename

if [[ -f "$filename" && -r "$filename" ]]; then
    echo "The file '$filename' exists and is readable."
fi

The if-else Statement

What if you want to do something else if the condition is false? Use if-else.

if [[ condition ]]; then
    # Commands if condition is true
    commands_if_true
else
    # Commands if condition is false
    commands_if_false
fi

Example if-else:

#!/bin/bash

read -p "Enter your age: " age

if [[ "$age" -ge 18 ]]; then
    echo "You are eligible to vote."
else
    echo "You are not yet eligible to vote."
fi

The if-elif-else Statement

For checking multiple conditions in sequence, use if-elif-else. elif is short for "else if".

if [[ condition1 ]]; then
    # Commands if condition1 is true
    commands1
elif [[ condition2 ]]; then
    # Commands if condition1 is false AND condition2 is true
    commands2
elif [[ condition3 ]]; then
    # Commands if condition1 and condition2 are false AND condition3 is true
    commands3
else
    # Commands if ALL preceding conditions are false
    commands_else
fi

The shell evaluates the conditions in order. As soon as it finds a true condition, it executes the corresponding commands and skips the rest of the elif/else blocks. The final else is optional and acts as a default case if none of the if or elif conditions are met.

Example if-elif-else:

#!/bin/bash

read -p "Enter a score (0-100): " score

if [[ "$score" -ge 90 ]]; then
    grade="A"
elif [[ "$score" -ge 80 ]]; then
    grade="B"
elif [[ "$score" -ge 70 ]]; then
    grade="C"
elif [[ "$score" -ge 60 ]]; then
    grade="D"
else
    grade="F"
fi

echo "Your grade is: $grade"

The case Statement

When you need to check a single variable against multiple possible patterns, the case statement is often cleaner and more readable than a long chain of if-elif statements comparing the same variable.

case $variable in
    pattern1)
        # Commands if variable matches pattern1
        commands1
        ;; # Double semicolon terminates the block
    pattern2|pattern3) # Use | for OR logic within a pattern
        # Commands if variable matches pattern2 OR pattern3
        commands2
        ;;
    pattern*) # Use wildcards (* for any chars, ? for single char, [...] for char range/set)
        # Commands if variable matches pattern*
        commands3
        ;;
    *) # Default case: matches anything not matched above
        # Commands if no other pattern matched
        default_commands
        ;;
esac # 'esac' marks the end of the case block (case spelled backward)
  • case $variable in: Starts the statement, specifying the variable to check.
  • pattern): Defines a pattern to match against the variable's value. Patterns can include literal text and shell wildcards (*, ?, [...]).
  • commands: Code to execute if the pattern matches.
  • ;;: Crucial: Terminates the commands for a specific pattern block. Without ;;, execution would fall through to the next pattern's commands (unlike switch statements in C/Java which require break).
  • *): A pattern that matches anything, typically used as the default case at the end.
  • esac: Ends the case statement.

Example case Statement:

#!/bin/bash

read -p "Enter 'start', 'stop', or 'status': " action

case $action in
    start|START)
        echo "Starting the service..."
        # Add commands to start service here
        ;;
    stop|STOP)
        echo "Stopping the service..."
        # Add commands to stop service here
        ;;
    status|STATUS)
        echo "Checking service status..."
        # Add commands to check status here
        ;;
    *)
        echo "Usage: $0 {start|stop|status}" # $0 is the script name
        echo "Error: Invalid action '$action'"
        ;;
esac

Workshop Simple File Analyzer

Goal: Create a script that accepts a single argument (a path to a file or directory). The script should check if the argument was provided, if the path exists, and then report whether it's a regular file, a directory, or something else.

Theory Recap: You'll use if, elif, else. You'll need file test operators (-e, -f, -d). You'll also encounter special shell variables:

  • $#: Holds the number of command-line arguments passed to the script.
  • $1: Holds the value of the first command-line argument ($2 for the second, etc.).
  • $0: Holds the name of the script itself.
  • exit <number>: Terminates the script immediately with a specific exit status (0 for success, non-zero for error).

Steps:

  1. Create the script file:
    nano file_analyzer.sh
    
  2. Add Shebang and Comments:
    #!/bin/bash
    
    # Script to analyze a path provided as a command-line argument.
    # It checks if the path exists and identifies its type (file/directory).
    
  3. Check Number of Arguments: The script requires exactly one argument. Check $#. If it's not equal to 1 (-ne), print a usage message and exit with an error status (e.g., 1). Error messages should ideally go to standard error (>&2).
    #!/bin/bash
    
    # Script to analyze a path provided as a command-line argument.
    # It checks if the path exists and identifies its type (file/directory).
    
    # Check if exactly one argument is provided
    if [[ $# -ne 1 ]]; then
        echo "Usage: $0 <path>" >&2
        echo "Error: Please provide exactly one argument." >&2
        exit 1 # Exit with a non-zero status indicating an error
    fi
    
  4. Store Argument in Variable: Store the first argument ($1) in a more descriptive variable. It's good practice to quote it in case the path contains spaces.
    target_path="$1"
    
  5. Check if Path Exists: Use if [[ -e "$target_path" ]] to check for existence.

    echo "Analyzing path: '$target_path'"
    
    if [[ -e "$target_path" ]]; then
        # Path exists, now determine the type
        echo -n "'$target_path' exists. It is " # -n keeps echo on the same line
    
        if [[ -f "$target_path" ]]; then
            echo "a regular file."
            # Optional: Add more checks like readability/writability
            if [[ -r "$target_path" ]]; then
                 echo "    - It is readable."
            fi
            if [[ -w "$target_path" ]]; then
                 echo "    - It is writable."
            fi
            if [[ -x "$target_path" ]]; then
                 echo "    - It is executable."
            fi
        elif [[ -d "$target_path" ]]; then
            echo "a directory."
            # Optional: List contents?
            # echo "    - Contents:"
            # ls -l "$target_path" # Be careful with large directories
        else
            echo "neither a regular file nor a directory (e.g., link, device file)."
        fi
    else
        # Path does not exist
        echo "Error: The path '$target_path' does not exist." >&2
        exit 2 # Use a different exit code for different errors
    fi
    
    # If we reached here, analysis was successful (path existed)
    exit 0 # Explicitly exit with success status
    
    Refinement: Added more detail within the file/directory checks and specific exit codes. Used echo -n to build the output message more smoothly. Redirected error messages to stderr.

  6. Save and Exit: Save (Ctrl+O, Enter) and exit (Ctrl+X).

  7. Make Executable:
    chmod +x file_analyzer.sh
    
  8. Test: Run the script with different inputs:
    • No arguments:
      ./file_analyzer.sh
      # Expected: Usage message, Error message, exits
      echo $? # Check exit status, should be 1
      
    • A non-existent path:
      ./file_analyzer.sh /path/to/nonexistent/thing
      # Expected: Analyzing message, Error message (does not exist), exits
      echo $? # Should be 2
      
    • An existing file (e.g., the script itself):
      ./file_analyzer.sh file_analyzer.sh
      # Expected: Analyzing message, Exists message (regular file), Read/Write/Exec info, exits
      echo $? # Should be 0
      
    • An existing directory (e.g., your home directory ~ or /tmp):
      ./file_analyzer.sh /tmp
      # Expected: Analyzing message, Exists message (directory), exits
      echo $? # Should be 0
      
    • A path with spaces (create one first):
      touch "my test file.txt"
      ./file_analyzer.sh "my test file.txt" # Important: Quote the argument!
      # Expected: Analyzing message, Exists message (regular file), exits
      echo $? # Should be 0
      rm "my test file.txt" # Clean up
      

This workshop demonstrates decision-making using if/elif/else, handling command-line arguments ($#, $1), checking file attributes (-e, -f, -d, etc.), and controlling script termination (exit).

4. Control Flow Loops

Loops are fundamental constructs that allow you to execute a block of code repeatedly. This is essential for processing lists of items, reading files line by line, or performing actions a specific number of times. Bash provides several looping mechanisms, primarily for and while.

The for Loop

The for loop iterates over a list of items (words, filenames, numbers, etc.) and executes a block of code once for each item in the list.

1. Iterating Over an Explicit List:

for variable_name in item1 item2 item3 ... itemN; do
    # Commands to execute for each item
    # The current item is available in $variable_name
    echo "Processing item: $variable_name"
done # 'done' marks the end of the loop block
  • for variable_name: Specifies the variable that will hold the current item during each iteration.
  • in item1 item2 ...: Provides the list of items to iterate over. Items are separated by whitespace.
  • do: Marks the beginning of the code block to be executed for each item.
  • commands: The code to be executed in each iteration.
  • done: Marks the end of the for loop.

Example:

#!/bin/bash

echo "Iterating over colors:"
for color in red green blue yellow; do
    echo "    - Current color: $color"
    sleep 1 # Pause for 1 second
done
echo "Finished iterating."

2. Iterating Over Variable Content:

If a variable contains a space-separated list, you can iterate over it. Be mindful of quoting.

#!/bin/bash

file_list="report.txt data.csv image.jpg"

echo "Processing files (unquoted, risky):"
# Unquoted: Shell performs word splitting on $file_list
for file in $file_list; do
    echo "    -> $file"
done

echo # Blank line for separation

# If a filename contains spaces, the above will fail.
filename_with_space="My Document.pdf"
all_files="$file_list $filename_with_space" # Add the tricky filename

echo "Processing files including spaces (unquoted, breaks):"
for file in $all_files; do
    echo "    -> $file" # Output: My, Document.pdf (split incorrectly)
done

echo # Blank line

# Better approach: Use arrays (covered later) or handle quoting carefully.
# For simple lists stored in a variable, quoting the expansion often doesn't help
# as the 'in' part expects separate words. Arrays are the robust solution.
# However, let's show iteration over command output which is very common.

3. Iterating Over Command Output:

Use command substitution $(command) to generate the list.

#!/bin/bash

echo "Listing files in /etc whose names start with 'a':"
# Use command substitution to get the list
# Important: Use 'ls -b' or similar if filenames might contain newlines/special chars,
# or better yet, use find (see below). This is a simple example.
for item in $(ls /etc | grep '^a'); do
    echo "    - Found: $item"
done
Caution: Parsing the output of ls is generally discouraged for complex scripting due to potential issues with filenames containing spaces, newlines, or special characters. Using find with -print0 and xargs -0 or a while read loop is often more robust for file processing (see while loop section).

4. Iterating Over Files (Globbing):

You can use wildcards (globbing patterns like *, ?, [...]) directly in the for loop. The shell expands the pattern into a list of matching filenames. This is a very common and useful technique.

#!/bin/bash

echo "Processing all .txt files in the current directory:"
# The shell expands *.txt into a list of matching filenames
for textfile in *.txt; do
    # Important check: If no .txt files exist, *.txt might be passed literally.
    # Check if the item is actually a file before processing.
    if [[ -f "$textfile" ]]; then
        echo "    - Processing '$textfile'..."
        word_count=$(wc -w < "$textfile") # Count words in the file
        echo "      Word count: $word_count"
    else
        echo "    - No .txt files found or '$textfile' is not a file."
        # Optionally break the loop if no files were found on the first iteration
        # if [[ "$textfile" == "*.txt" ]]; then break; fi # Simple check
    fi
done

# More robust check for no matches using nullglob (Bash specific)
shopt -s nullglob # Make globs expand to nothing if no match
log_files=(*.log) # Store matches in an array
shopt -u nullglob # Turn off nullglob

if [[ ${#log_files[@]} -eq 0 ]]; then
    echo "No .log files found."
else
    echo "Processing log files found:"
    for logfile in "${log_files[@]}"; do # Iterate over array elements
         echo "    - Found log file: '$logfile'"
         # Process the log file
    done
fi
Refinement: Added explanation and example of nullglob and arrays for safer handling of cases where no files match the glob pattern. Also added the check [[ -f "$textfile" ]] inside the loop.

5. C-Style for Loop (Bash Specific):

Bash also supports a C-style for loop, useful for numeric ranges.

for (( initialization; condition; increment/decrement )); do
    # Commands
done

Example:

#!/bin/bash

echo "Counting from 1 to 5:"
for (( i=1; i<=5; i++ )); do
    echo "    Number: $i"
done

echo "Countdown:"
for (( count=3; count>=0; count-- )); do
    echo "    T-minus $count..."
    sleep 1
done
echo "    Liftoff!"

The while Loop

The while loop executes a block of code as long as a specified condition remains true (evaluates to an exit status of 0).

while [[ condition ]]; do
    # Commands to execute as long as condition is true
    commands
    # It's crucial that something inside the loop eventually makes the condition false,
    # otherwise you create an infinite loop!
done

Example (Counter):

#!/bin/bash

counter=0
max_count=5

echo "While loop counting up:"
while [[ $counter -lt $max_count ]]; do
    echo "    Counter is $counter"
    # Increment the counter (essential to eventually stop the loop)
    ((counter++)) # Arithmetic expansion for incrementing
done
echo "Loop finished."

Reading Files Line by Line (Common Use Case):

A very common and powerful use of while is to read a file line by line.

#!/bin/bash

input_file="my_data.txt"

# Create a dummy input file for testing
echo "Line 1: Some data" > "$input_file"
echo "Line 2: More data" >> "$input_file"
echo "Line 3: Final line" >> "$input_file"

echo "Reading '$input_file' line by line:"

# Check if file exists and is readable
if [[ ! -f "$input_file" || ! -r "$input_file" ]]; then
    echo "Error: Cannot read file '$input_file'" >&2
    exit 1
fi

line_num=0
# 'read line' reads one line, exits with non-zero (false) at EOF
# '< "$input_file"' redirects the file content to the standard input of the while loop
while IFS= read -r line; do
    ((line_num++))
    echo "    Line $line_num: -->$line<--"
done < "$input_file"

echo "Finished reading file."

# Clean up dummy file
# rm "$input_file"
  • while IFS= read -r line; do: This is the standard, robust way to read lines.
    • IFS=: Temporarily clears the Internal Field Separator variable. This prevents leading/trailing whitespace from being stripped from the lines read.
    • read -r: The -r option prevents backslash interpretation (raw read), ensuring backslashes in the file are treated literally.
    • line: The variable where each line's content is stored.
    • done < "$input_file": This redirects the entire content of $input_file to the standard input of the while loop. The read command then consumes this input line by line. read returns a non-zero exit status when it reaches the end of the file (EOF), causing the while condition to become false and the loop to terminate.

The until Loop

The until loop is similar to while, but it executes the code block as long as the condition is false. It stops when the condition becomes true.

until [[ condition ]]; do
    # Commands to execute as long as condition is false
    commands
    # Something inside the loop should eventually make the condition true
done

Example:

#!/bin/bash

# Wait until a specific file exists
target_file="wait_for_me.flag"

echo "Waiting for file '$target_file' to appear..."

until [[ -e "$target_file" ]]; do
    echo "    File not found yet. Waiting 5 seconds..."
    sleep 5
    # In a real scenario, another process would create this file.
    # For testing: touch wait_for_me.flag
done

echo "File '$target_file' found! Proceeding..."
# rm "$target_file" # Clean up

until is less common than while, but can be useful for conditions that express a goal to be reached (e.g., "keep trying until successful").

Loop Control (break and continue)

You can alter the flow of execution within loops:

  • break: Immediately exits the current (innermost) loop entirely. Execution continues with the first command after the done keyword.
  • continue: Skips the rest of the commands in the current iteration of the loop and jumps to the beginning of the next iteration.

Example (break and continue):

#!/bin/bash

echo "Looping from 1 to 10, skipping 5, breaking at 8:"
for i in {1..10}; do # {1..10} is brace expansion generating numbers 1 to 10
    if [[ "$i" -eq 5 ]]; then
        echo "    Skipping number 5 (continue)..."
        continue # Go to the next iteration (i=6)
    fi

    if [[ "$i" -eq 8 ]]; then
        echo "    Reached 8, breaking out of loop..."
        break # Exit the for loop completely
    fi

    echo "    Processing number $i"
done

echo "Loop finished."

Workshop Backup Script for Specific File Types

Goal: Create a script that finds all files with a .log extension in the current directory and copies them into a uniquely named backup subdirectory. The backup directory should be created if it doesn't exist, and its name should include the current date and time.

Theory Recap: You'll use a for loop with globbing (*.log), command substitution ($(date)), file tests (-f, -d), the mkdir command (make directory), and the cp command (copy).

Steps:

  1. Create the script file:
    nano backup_logs.sh
    
  2. Add Shebang and Comments:
    #!/bin/bash
    
    # Script to back up all .log files from the current directory
    # into a timestamped subdirectory.
    
  3. Define Backup Directory Name: Create a unique directory name using the current date and time. Use date with a suitable format (e.g., YearMonthDay_HourMinuteSecond).
    # Generate a timestamp format like 20240517_143055
    timestamp=$(date +%Y%m%d_%H%M%S)
    backup_dir="log_backups_${timestamp}"
    
  4. Create Backup Directory: Check if the directory already exists (unlikely, but good practice). If not, create it using mkdir. Add an informational message. Exit if mkdir fails (e.g., due to permissions).
    echo "Preparing backup directory: '$backup_dir'"
    if [[ ! -d "$backup_dir" ]]; then
        if mkdir "$backup_dir"; then
            echo "Successfully created directory: '$backup_dir'"
        else
            echo "Error: Failed to create directory '$backup_dir'. Check permissions." >&2
            exit 1
        fi
    fi
    
    Refinement: Added check on mkdir success.
  5. Find and Copy Log Files: Use a for loop with *.log globbing. Inside the loop, verify that the item found is a regular file (-f) before attempting to copy it using cp. Provide feedback for each file copied.
    echo "Starting backup of .log files..."
    log_file_count=0
    
    # Use nullglob for safety in case no .log files exist
    shopt -s nullglob
    for logfile in *.log; do
        # Although nullglob helps, checking -f is still good practice in case
        # the glob matches something unexpected (like a directory named *.log)
        if [[ -f "$logfile" ]]; then
            echo "    - Copying '$logfile' to '$backup_dir/'..."
            if cp "$logfile" "$backup_dir/"; then
                ((log_file_count++))
            else
                echo "      Warning: Failed to copy '$logfile'. Skipping." >&2
            fi
        fi
    done
    shopt -u nullglob # Reset nullglob
    
    echo "Backup process complete."
    echo "Total log files backed up: $log_file_count"
    
    Refinement: Used nullglob, added check on cp success, and added a counter for backed-up files.
  6. Add Final Message: Inform the user about the outcome.
    if [[ $log_file_count -gt 0 ]]; then
        echo "Backup successful. Files are in '$backup_dir'."
    else
        echo "No .log files were found in the current directory to back up."
        # Optional: Remove the empty backup directory if nothing was copied
        # echo "Removing empty backup directory '$backup_dir'."
        # rmdir "$backup_dir"
    fi
    
    exit 0
    
  7. Save and Exit: Save (Ctrl+O, Enter) and exit (Ctrl+X).
  8. Make Executable:
    chmod +x backup_logs.sh
    
  9. Test:
    • Create dummy log files:
      echo "Error log entry 1" > app_error.log
      echo "User login event" > access.log
      echo "Debug message" > debug_info.log
      touch not_a_log_file.txt
      mkdir tricky_dir.log # Create a directory ending in .log
      
    • Run the script:
      ./backup_logs.sh
      
    • Observe: You should see output indicating the creation of the backup directory and the copying of app_error.log, access.log, and debug_info.log. The tricky_dir.log and not_a_log_file.txt should be ignored.
    • Verify: Use ls to see the new log_backups_YYYYMMDD_HHMMSS directory. Use ls log_backups_*/ to see the copied log files inside it.
    • Run again: Execute ./backup_logs.sh again. A new timestamped directory should be created, and the logs copied again into that new directory.
    • Test with no log files: Remove the dummy log files (rm *.log) and run ./backup_logs.sh again. It should report that no log files were found.

This workshop provides practice with loops (for), globbing (*.log), command substitution (date), directory creation (mkdir), file copying (cp), and basic error checking within a loop.

5. Functions

As your scripts grow larger and more complex, you'll find yourself repeating blocks of code. Functions allow you to group a sequence of commands under a single name, making your scripts more modular, readable, reusable, and easier to maintain.

What are Functions?

A function is a named block of code that performs a specific task. Once defined, you can "call" the function by its name whenever you need to execute that task, instead of rewriting the code block each time.

Why Use Functions?

  • Modularity: Break down a complex script into smaller, manageable, logical units. Each function handles one specific part of the overall task.
  • Reusability: Write a piece of code once (inside a function) and call it multiple times from different parts of your script, or even from other scripts (by sourcing the file containing the function).
  • Readability: Well-named functions make the main part of your script easier to follow, as it reads more like a sequence of steps rather than a huge block of commands.
  • Maintainability: If you need to update the logic for a specific task, you only need to change it in one place (inside the function definition).
  • Debugging: It's often easier to test and debug individual functions than one monolithic script.

Defining Functions

There are two common syntaxes for defining functions in Bash:

Syntax 1 (Preferred, POSIX-compatible):

function_name() {
    # Commands that make up the function body
    command1
    command2
    # ...
}

Syntax 2 (Bash keyword):

function function_name {
    # Commands that make up the function body
    command1
    command2
    # ...
}
  • function_name: The name you choose for your function. Follow similar naming conventions as variables (letters, numbers, underscores; start with letter or underscore). Choose descriptive names.
  • (): Parentheses are required after the function name in the first syntax.
  • { ... }: Curly braces enclose the body of the function (the commands). There must be a space after the opening brace { or a newline, and the closing brace } must be preceded by a semicolon ; or a newline if it's on the same line as the last command (usually, it's placed on its own line).
  • function keyword: Optional in Bash (Syntax 2). The first syntax (name() { ... }) is more common and compatible with other shells like sh. We will use Syntax 1.

Example Definition:

# Function to print a separator line
print_separator() {
    echo "-----------------------------------------"
}

# Function to greet a user
greet_user() {
    echo "Hello there!"
}

Calling Functions

To execute the code inside a function, simply use its name as if it were a regular command.

#!/bin/bash

# Define the functions first
print_separator() {
    echo "========================================="
}

greet_user() {
    echo "Welcome to the function example script!"
}

# Now call the functions
print_separator # Calls the print_separator function
greet_user      # Calls the greet_user function
print_separator # Call it again!

Important: You must define a function before you call it in the script's execution flow. It's common practice to put all function definitions at the top of the script.

Passing Arguments to Functions

Functions can accept input values, called arguments (or parameters), similar to how scripts accept command-line arguments. Inside the function, these arguments are accessed using the same positional parameters:

  • $1: The first argument passed to the function.
  • $2: The second argument passed to the function, and so on.
  • $#: The number of arguments passed to the function.
  • $@: Represents all arguments passed to the function as individual quoted strings ("$1" "$2" ...). This is usually the preferred way to pass all arguments along to another command within the function.
  • $*: Represents all arguments passed to the function as a single string, joined by the first character of the IFS variable (usually a space) ("$1 $2 ..."). Less commonly used than $@.

Example with Arguments:

#!/bin/bash

# Function to greet a specific user
personalized_greet() {
    local user_name="$1" # Store the first argument in a local variable
    local greeting="Hello"

    # Check if a name was provided
    if [[ -z "$user_name" ]]; then
        echo "Usage: personalized_greet <name>" >&2
        return 1 # Indicate an error (explained next)
    fi

    echo "$greeting, $user_name! Welcome."
}

# Function to sum two numbers
add_numbers() {
    local num1="$1"
    local num2="$2"
    local sum=$((num1 + num2)) # Arithmetic expansion
    echo "The sum of $num1 and $num2 is: $sum"
}

# Call the functions with arguments
personalized_greet "Alice"
personalized_greet "Bob"
personalized_greet # Call without arguments to see the error handling

echo # Blank line

add_numbers 5 10
add_numbers 100 23

Variable Scope (local)

By default, variables defined within a script are global, meaning they are accessible anywhere in the script, both inside and outside functions. This can lead to conflicts if a function accidentally modifies a global variable that's used elsewhere.

To create variables that exist only within a function (local scope), use the local keyword before the variable assignment. This is highly recommended for variables that are only needed inside the function to avoid polluting the global namespace and prevent unintended side effects.

#!/bin/bash

global_var="I am global"

my_function() {
    local local_var="I am local to my_function"
    echo "Inside function: local_var = '$local_var'"
    echo "Inside function: global_var = '$global_var'"
    # Modify global_var (possible but often discouraged without clear intent)
    global_var="Modified by function"
}

echo "Before function: global_var = '$global_var'"
# echo "Before function: local_var = '$local_var'" # This would cause an error - local_var doesn't exist yet

my_function

echo "After function: global_var = '$global_var'" # Shows the modification
# echo "After function: local_var = '$local_var'" # This still causes an error - local_var is gone

Best Practice: Use local for all variables inside your functions unless you explicitly intend for the function to modify a global variable.

Returning Values from Functions

Shell functions don't return arbitrary data (like strings or arrays) in the same way as functions in languages like Python or C. They primarily return an exit status (an integer between 0 and 255) using the return command.

  • return 0: Indicates success (this is the default if no return is specified or if the last command in the function succeeded).
  • return <non-zero>: Indicates failure or a specific error condition (e.g., return 1, return 5).

The exit status of the last executed command in the function is used as the return status if return is not explicitly used.

You can capture the exit status of a function immediately after calling it using the $? special variable.

#!/bin/bash

check_file_exists() {
    local file_path="$1"
    if [[ -e "$file_path" ]]; then
        echo "File '$file_path' found."
        return 0 # Success
    else
        echo "File '$file_path' not found."
        return 1 # Failure
    fi
}

# Call the function and check its exit status
check_file_exists "/etc/passwd"
status=$? # Capture exit status immediately
echo "Exit status for /etc/passwd check: $status"

echo # Blank line

check_file_exists "/non/existent/file"
status=$?
echo "Exit status for non-existent file check: $status"

echo # Blank line

# Using the function directly in an if statement
if check_file_exists "/etc/hosts"; then
    echo "/etc/hosts check succeeded (returned 0)."
else
    echo "/etc/hosts check failed (returned non-zero)."
fi

How to Get Data Out of a Function:

If you need a function to produce data (like a calculated value or a processed string) rather than just a success/failure status, the standard shell way is to have the function print the data to standard output (echo) and then capture that output using command substitution $(...) when you call the function.

#!/bin/bash

# Function that "returns" a string by printing it
get_greeting() {
    local name="$1"
    if [[ -z "$name" ]]; then
        name="Guest"
    fi
    echo "Welcome, $name!" # Print the result to standard output
}

# Function that "returns" a number by printing it
calculate_square() {
    local num="$1"
    local square=$((num * num))
    echo "$square" # Print the result to standard output
}

# Call the functions and capture their output
greeting_message=$(get_greeting "Alice")
guest_greeting=$(get_greeting)
number_squared=$(calculate_square 8)

echo "Captured greeting 1: $greeting_message"
echo "Captured greeting 2: $guest_greeting"
echo "Captured square: $number_squared"

# Note: Any other 'echo' statements inside the function will also be captured!
# Keep functions intended for data capture focused on printing only the result.

Workshop Refactoring the File Analyzer with Functions

Goal: Take the file_analyzer.sh script created in Workshop 3 and refactor it to use functions for better organization and clarity.

Theory Recap: Define functions using name() { ... }, call functions, use local variables, handle arguments ($1), and use return for status / echo for data capture.

Steps:

  1. Copy the previous script:
    cp file_analyzer.sh file_analyzer_func.sh
    
  2. Open the new script:
    nano file_analyzer_func.sh
    
  3. Identify Code Blocks to Functionalize: Look at the script's logic:

    • Checking arguments and printing usage.
    • Checking if a path exists.
    • Determining the type (file/directory/other) of an existing path.
    • Printing file details (read/write/execute).
  4. Define Functions: Create functions for these blocks at the top of the script.

    • print_usage() function: Handles printing the usage message and exiting.

      # Function to print usage information and exit
      print_usage() {
          echo "Usage: $0 <path>" >&2
          echo "Error: Please provide exactly one argument." >&2
          exit 1
      }
      

    • check_path_existence() function: Takes a path as an argument, checks existence, prints a message, and returns 0 (success) if it exists, 1 (failure) if not.

      # Function to check if a path exists
      # Arguments: $1 = path to check
      # Returns: 0 if exists, 1 otherwise
      check_path_existence() {
          local target_path="$1"
          if [[ -e "$target_path" ]]; then
              echo "Status: Path '$target_path' exists."
              return 0
          else
              echo "Status: Path '$target_path' does NOT exist." >&2
              return 1
          fi
      }
      

    • analyze_path_type() function: Takes an existing path, determines its type, and prints the details.

      # Function to analyze the type and properties of an existing path
      # Arguments: $1 = path to analyze (assumed to exist)
      analyze_path_type() {
          local target_path="$1"
          echo -n "Type: It is " # Use -n to continue on the same line
      
          if [[ -f "$target_path" ]]; then
              echo "a regular file."
              # Print permissions (could be another function!)
              if [[ -r "$target_path" ]]; then echo "      - Readable"; fi
              if [[ -w "$target_path" ]]; then echo "      - Writable"; fi
              if [[ -x "$target_path" ]]; then echo "      - Executable"; fi
          elif [[ -d "$target_path" ]]; then
              echo "a directory."
              # Could add directory-specific checks here if needed
          elif [[ -L "$target_path" ]]; then
              # Added check for symbolic link
              local link_target=$(readlink "$target_path")
              echo "a symbolic link pointing to -> '$link_target'."
          else
              echo "neither a file, directory, nor a symbolic link."
          fi
          # This function doesn't need to return a status if it just prints info
      }
      
      Refinement: Added a check for symbolic links (-L and readlink). Made permissions printing more concise.

  5. Rewrite the Main Script Logic: Replace the original code blocks with calls to the new functions. The main part should now be much shorter and easier to read.

    #!/bin/bash
    
    # Script to analyze a path provided as a command-line argument (Refactored with Functions).
    
    # --- Function Definitions ---
    
    # Function to print usage information and exit
    print_usage() {
        echo "Usage: $0 <path>" >&2
        echo "Error: Please provide exactly one argument." >&2
        exit 1
    }
    
    # Function to check if a path exists
    # Arguments: $1 = path to check
    # Returns: 0 if exists, 1 otherwise
    check_path_existence() {
        local target_path="$1"
        if [[ -e "$target_path" ]]; then
            echo "Status: Path '$target_path' exists."
            return 0
        else
            echo "Status: Path '$target_path' does NOT exist." >&2
            return 1
        fi
    }
    
    # Function to analyze the type and properties of an existing path
    # Arguments: $1 = path to analyze (assumed to exist)
    analyze_path_type() {
        local target_path="$1"
        echo -n "Type: It is " # Use -n to continue on the same line
    
        if [[ -f "$target_path" ]]; then
            echo "a regular file."
            # Print permissions
            if [[ -r "$target_path" ]]; then echo "      - Readable"; fi
            if [[ -w "$target_path" ]]; then echo "      - Writable"; fi
            if [[ -x "$target_path" ]]; then echo "      - Executable"; fi
        elif [[ -d "$target_path" ]]; then
            echo "a directory."
        elif [[ -L "$target_path" ]]; then
            local link_target=$(readlink "$target_path")
            echo "a symbolic link pointing to -> '$link_target'."
        else
            echo "neither a file, directory, nor a symbolic link."
        fi
    }
    
    # --- Main Script Logic ---
    
    # Check number of arguments
    if [[ $# -ne 1 ]]; then
        print_usage # Call the usage function
    fi
    
    # Store argument
    main_target_path="$1" # Use a different name than inside functions if desired
    
    echo "--- Starting Analysis for '$main_target_path' ---"
    
    # Check existence by calling the function and checking its return status ($?)
    if check_path_existence "$main_target_path"; then
        # Path exists (function returned 0), proceed with type analysis
        analyze_path_type "$main_target_path"
        # Script succeeds if path exists and was analyzed
        exit 0
    else
        # Path does not exist (function returned non-zero)
        # The function already printed an error message
        exit 2 # Use the specific exit code for "path does not exist"
    fi
    
    6. Save and Exit: Save (Ctrl+O, Enter) and exit (Ctrl+X). 7. Make Executable:
    chmod +x file_analyzer_func.sh
    
    8. Test: Run the refactored script with the same test cases used for file_analyzer.sh (no args, non-existent path, file, directory, path with spaces, symbolic link). The output behavior should be identical, but the code structure is now much cleaner.

    • Create a symbolic link for testing: ln -s /etc/passwd my_passwd_link
    • Test the link: ./file_analyzer_func.sh my_passwd_link
    • Clean up: rm my_passwd_link

This workshop illustrates how functions improve script organization. The main logic becomes a high-level overview, calling functions that encapsulate the detailed implementation of each step.

6. Exit Statuses and Basic Error Handling

Robust scripts don't just execute commands; they also anticipate and handle potential problems. A key part of this is understanding and using exit statuses.

Exit Status ($?)

Every command or script executed in the shell finishes with an exit status (also called a return code or exit code). This is an integer value indicating whether the command completed successfully or encountered an error.

  • Exit Status 0: Conventionally means success. The command completed its task without any problems.
  • Exit Status Non-zero (1-255): Conventionally means failure or an abnormal condition. Different non-zero values can signify different types of errors. For example:
    • 1: General error (very common).
    • 2: Misuse of shell builtins (according to Bash documentation).
    • 126: Command invoked cannot execute (permission problem).
    • 127: Command not found.
    • 128+N: Fatal error signal "N" (e.g., 130 for Ctrl+C/SIGINT).

The shell stores the exit status of the most recently executed foreground command in a special variable: $?.

You can check this immediately after running a command:

ls /etc/passwd  # This file usually exists
echo "Exit status of previous ls: $?" # Should print 0

ls /non/existent/directory # This command will likely fail
echo "Exit status of previous ls: $?" # Should print a non-zero value (e.g., 1, 2)

# Note: The value of $? is updated after *every* command, even 'echo'!
echo "This echo command succeeded."
echo "Exit status of the previous echo: $?" # Should print 0

Using Exit Statuses in Conditionals

The primary use of $? in scripting is to check if a critical command succeeded before proceeding.

#!/bin/bash

source_file="data.txt"
dest_file="/tmp/data_backup.txt"

echo "Attempting to copy '$source_file' to '$dest_file'..."
cp "$source_file" "$dest_file"

# Check the exit status of the 'cp' command
if [[ $? -eq 0 ]]; then
    echo "Copy successful!"
    # Proceed with next steps using the backup file...
    echo "Processing backup file..."
else
    echo "Error: Failed to copy '$source_file'!" >&2
    echo "Please check if the source file exists and you have permissions." >&2
    exit 1 # Terminate the script because a critical step failed
fi

echo "Script continues..." # This line only runs if cp succeeded

Shortcut: Using Commands Directly in if

The if statement (and while/until) actually works by checking the exit status of the command provided as the condition. If the command exits with 0 (success), the condition is true; otherwise, it's false. This means you often don't need to check $? explicitly.

#!/bin/bash

source_file="data.txt"
dest_file="/tmp/data_backup.txt"

echo "Attempting to copy '$source_file' to '$dest_file'..."

# Run 'cp' directly as the condition for 'if'
if cp "$source_file" "$dest_file"; then
    echo "Copy successful!"
    echo "Processing backup file..."
else
    # cp failed (returned non-zero), so the else block runs
    echo "Error: Failed to copy '$source_file'!" >&2
    echo "Please check if the source file exists and you have permissions." >&2
    exit 1
fi

echo "Script continues..."

This is much more concise and readable. Similarly for grep:

if grep "ERROR" /var/log/syslog; then
    echo "Found 'ERROR' in syslog."
fi
# Note: grep returns 0 if it finds matches, 1 if not, >1 on error.
# So this 'if' block runs only if 'ERROR' is found.

The exit Command

We've used exit before. It terminates the execution of the entire script immediately. You can optionally provide an exit status number as an argument.

  • exit: Exits with the status of the last command executed before exit.
  • exit 0: Exits with a success status (0). Good practice to put at the end of a successfully completed script.
  • exit <non-zero>: Exits with the specified failure status (e.g., exit 1, exit 5). Use different codes to signify different error conditions if needed.
#!/bin/bash

read -p "Enter filename to process: " filename

if [[ ! -f "$filename" ]]; then
    echo "Error: File '$filename' not found or is not a regular file." >&2
    exit 3 # Specific code for "file not found" error
fi

if [[ ! -r "$filename" ]]; then
    echo "Error: File '$filename' is not readable." >&2
    exit 4 # Specific code for "permission denied" error
fi

echo "Processing file '$filename'..."
# ... processing commands ...

echo "Processing complete."
exit 0 # Explicitly indicate success

Basic Error Handling Strategies

  1. Check Critical Commands: Use if command; then ... else ... fi or check $? after commands whose success is essential for the script's continuation (e.g., cp, mv, mkdir, rm, critical data processing steps). Exit the script if they fail.
  2. Validate Input: Check command-line arguments ($#), user input (read), and configuration file values to ensure they are present and reasonable before using them. Exit gracefully with informative messages if validation fails.
  3. Check File/Directory Existence and Permissions: Before attempting to read, write, or execute files, or create files in directories, use file test operators (-e, -f, -d, -r, -w, -x) to ensure the target exists and has the necessary permissions.
  4. Provide Informative Error Messages: When errors occur, use echo to print clear messages explaining what went wrong and, if possible, how to fix it. Redirect error messages to standard error (>&2) so they can be separated from normal output if needed (./my_script.sh > output.log 2> errors.log).
  5. Use exit with Appropriate Statuses: Terminate the script using exit when an unrecoverable error occurs. Use non-zero exit codes to signal failure.

Shell Options for Stricter Error Handling (set)

Bash provides options (set using the set command) that can make scripts automatically more sensitive to errors:

  • set -e (or set -o errexit): Exit immediately if any command (that isn't part of a test in if/while, or part of an && or || list) exits with a non-zero status.

    • Pros: Can catch errors you might otherwise miss, reduces the need for explicit if command; else exit; fi checks.
    • Cons: Can sometimes be too aggressive, making debugging harder if the script exits unexpectedly. Commands that might "fail" normally (like grep not finding a match, which returns 1) will terminate the script unless handled properly (e.g., grep "pattern" file || true). Use with caution and understand its implications.
  • set -u (or set -o nounset): Treat unset variables (except for special parameters like $@ and $*) as an error when performing parameter expansion. This helps catch typos in variable names.

    • Pros: Prevents bugs caused by misspelled variable names resulting in empty strings.
    • Cons: Requires careful handling of potentially unset variables (e.g., using ${variable:-default} or checking with [[ -v variable_name ]]).
  • set -o pipefail: Causes a pipeline (e.g., command1 | command2) to return the exit status of the last command in the pipeline that exited with a non-zero status, or zero if all commands succeed. By default, a pipeline's status is only that of the very last command.

    • Pros: Catches errors happening earlier in a pipeline.
    • Cons: None significant, generally considered good practice to use when pipelines are critical.

You can combine them: set -euo pipefail. It's often placed near the top of a script.

#!/bin/bash
set -euo pipefail # Enable strict error handling

echo "This will run."
# ls /non/existent/directory # This would cause immediate exit due to 'set -e'
# echo "This line will NOT be reached if the above command fails."

# Example with pipefail
# Normally, 'false | true' exits with 0 (status of 'true')
# With pipefail, 'false | true' exits with 1 (status of 'false')
if false | true; then
    echo "Pipeline succeeded (without pipefail or if both commands succeed)."
else
    echo "Pipeline failed (with pipefail if any command fails)."
fi

# Example with nounset
# unset my_variable
# echo "My variable is: $my_variable" # This would cause immediate exit due to 'set -u'
# echo "This line would not be reached."

# Safe way with nounset
unset my_variable
echo "My variable is: ${my_variable:-default value}" # Provides a default if unset

echo "Script finished successfully."
exit 0

Using set -e, set -u, and set -o pipefail encourages writing more robust code but requires a disciplined approach to handle potential failures and unset variables explicitly.

Workshop Enhancing the Backup Script with Error Handling

Goal: Improve the backup_logs.sh script (from Workshop 4) to be more robust by adding explicit checks for command failures and potentially using set options.

Theory Recap: Check $? or use commands directly in if, use exit on critical failures, print errors to stderr (>&2), consider using set -e, set -u, set -o pipefail.

Steps:

  1. Copy the previous script:
    cp backup_logs.sh backup_logs_robust.sh
    
  2. Open the new script:
    nano backup_logs_robust.sh
    
  3. Add set Options (Optional but Recommended): Place this near the top, after the shebang and initial comments. Decide if you want the strict behavior. Let's add it for this workshop to demonstrate.
    #!/bin/bash
    set -euo pipefail # Exit on error, unset var, pipe failure
    
    # Script to back up all .log files from the current directory
    # into a timestamped subdirectory (Robust Version).
    
  4. Review Existing Checks: We already added a check for mkdir failure in the previous version. Since we have set -e, the script would exit automatically if mkdir fails. We can simplify the check slightly, perhaps just printing the message before mkdir is attempted. However, keeping the explicit if mkdir allows for a custom error message before set -e terminates the script. Let's keep the explicit check for clarity.

    Modify the mkdir block slightly for better messaging:

    # Define Backup Directory Name (unchanged)
    timestamp=$(date +%Y%m%d_%H%M%S)
    backup_dir="log_backups_${timestamp}"
    
    # Create Backup Directory
    echo "Preparing backup directory: '$backup_dir'"
    # Check if it exists (unlikely, but safe)
    if [[ -e "$backup_dir" ]]; then
        echo "Error: Backup destination '$backup_dir' already exists (collision?)" >&2
        exit 1
    fi
    
    # Attempt to create the directory
    if ! mkdir "$backup_dir"; then
        # This block might not even be reached if 'set -e' is active and mkdir fails,
        # but it provides a clear error message if 'set -e' were disabled.
        # If 'set -e' is active, the script would likely exit on the mkdir line itself.
        echo "Error: Failed to create directory '$backup_dir'. Check permissions." >&2
        # No 'exit 1' needed here if 'set -e' is active. If not active, add 'exit 1'.
        exit 1 # Keep exit 1 for clarity, works with or without set -e
    fi
    echo "Successfully created directory: '$backup_dir'"
    
    Self-correction: Realized set -e makes the explicit else exit redundant after mkdir, but the custom message in the if ! mkdir block is still useful. Added a check if the directory already exists beforehand, which is a different kind of error.

  5. Improve the Loop/Copy: The cp command inside the loop is critical. With set -e, if cp fails, the script will halt. This might be desired, or you might want to log the error and continue with other files. Let's choose the latter: log the error but continue the loop. To do this with set -e active, we need to explicitly handle the cp failure so it doesn't trigger set -e. We can do this by adding || <command_that_returns_true> or by putting the cp in an if statement.

    Modify the loop:

    echo "Starting backup of .log files..."
    log_file_count=0
    error_count=0
    
    # Use nullglob for safety (already present)
    shopt -s nullglob
    log_files=(*.log) # Use an array
    shopt -u nullglob
    
    if [[ ${#log_files[@]} -eq 0 ]]; then
        echo "Info: No .log files found in the current directory. Nothing to back up."
        # Optional: Remove the empty backup dir created earlier
        # echo "Info: Removing empty backup directory '$backup_dir'."
        # rmdir "$backup_dir"
        exit 0 # Successful exit, just nothing to do
    fi
    
    for logfile in "${log_files[@]}"; do # Iterate over the array
        # We assume *.log found actual files because of the array,
        # but double-checking -f is still safest.
        if [[ -f "$logfile" ]]; then
            echo "    - Copying '$logfile' to '$backup_dir/'..."
            # Explicitly handle cp failure to prevent set -e exit
            if ! cp "$logfile" "$backup_dir/"; then
                echo "      Warning: Failed to copy '$logfile'. Skipping." >&2
                ((error_count++))
                # The 'if ! cp' structure handles the non-zero exit of cp,
                # preventing set -e from triggering. We continue the loop.
            else
                 ((log_file_count++))
            fi
        # else # This case should ideally not happen if using the array properly
            # echo "    - Warning: Item '$logfile' is not a regular file. Skipping." >&2
        fi
    done
    
    echo "Backup process complete."
    echo "Successfully backed up: $log_file_count file(s)."
    if [[ $error_count -gt 0 ]]; then
        echo "Encountered errors copying: $error_count file(s)." >&2
        # Exit with an error status if any copy failed
        exit 1 # Indicate partial success / failure
    fi
    
    Refinement: Used the array approach properly. Added an error_count. Made the script exit with status 1 if any copy failed, even if others succeeded. Handled the cp failure explicitly within an if to prevent set -e from halting the entire script, allowing the loop to continue. Added a check if no log files were found before the loop.

  6. Final Exit Status: The script now exits 0 if no files are found, exits 0 if all found files are copied successfully, and exits 1 if any errors occurred during directory creation or file copying.

  7. Save and Exit: Save (Ctrl+O, Enter) and exit (Ctrl+X).

  8. Make Executable:
    chmod +x backup_logs_robust.sh
    
  9. Test:
    • Run with dummy log files (as before). Check output and exit status (echo $? should be 0).
    • Run with no log files (rm *.log). Check output and exit status (echo $? should be 0).
    • Introduce an error: Make one log file unreadable.
      echo "Readable log" > readable.log
      echo "Unreadable log" > unreadable.log
      chmod 000 unreadable.log # Remove read permission
      ls -l *.log
      ./backup_logs_robust.sh
      # Expected: Copies readable.log, prints Warning for unreadable.log,
      # reports 1 success and 1 error.
      echo $? # Should be 1 (due to the copy error)
      chmod 644 unreadable.log # Restore permissions
      rm *.log log_backups_*/ -rf # Clean up
      
    • Introduce a directory creation error (more complex to set up reliably, but imagine /tmp being full or having restrictive permissions preventing subdirectory creation). The script should exit early with an error.

This workshop demonstrates how to make a script significantly more robust by checking the success of critical commands, providing clear feedback, handling errors gracefully (continuing vs. exiting), and optionally using set options for stricter behavior.

Conclusion

You have now covered the fundamental building blocks of shell scripting in Linux using Bash. We started with the basics of creating and running scripts, understanding the shebang and comments. We then explored how to make scripts dynamic using variables, command substitution, and user input (read). Control flow was introduced through conditional statements (if, case) and loops (for, while), allowing scripts to make decisions and repeat actions. Functions (name() { ... }) were presented as a crucial tool for creating modular, reusable, and readable code. Finally, we emphasized the importance of checking exit statuses ($?, if command) and implementing basic error handling (exit, >&2, set -e) to build robust and reliable scripts.

The workshops provided practical, step-by-step projects to reinforce these concepts, moving from simple greetings to file analysis and backup utilities.

Shell scripting is a vast and powerful domain. While this covered the basics, there's much more to explore, including:

  • Text Processing Utilities: grep, sed, awk for powerful text manipulation.
  • Regular Expressions: Advanced pattern matching used with grep, sed, and within [[ ... ]].
  • Arrays: Handling lists of data more effectively.
  • Advanced I/O Redirection: Manipulating standard input, output, and error streams.
  • Script Debugging Tools: Using set -x or bash -x to trace execution.
  • Signal Handling: Using trap to catch signals like Ctrl+C.

The key to mastering shell scripting is practice. Try automating tasks you perform regularly, analyze existing scripts to understand how they work, and don't be afraid to consult documentation (man command) or online resources when you encounter challenges. With the foundation you've built here, you are well-equipped to start writing your own effective shell scripts to leverage the full power of the Linux command line.