Skip to content

Latest commit

 

History

History
857 lines (674 loc) · 14.2 KB

File metadata and controls

857 lines (674 loc) · 14.2 KB

Shell Scripting Guide

Comprehensive guide to shell scripting with Bash and Zsh, covering fundamentals, best practices, and common patterns.

Table of Contents

Getting Started

Your First Script

#!/bin/bash
# or
#!/bin/zsh

echo "Hello, World!"

Make it executable:

chmod +x script.sh
./script.sh

Shebang Lines

Shebang Shell Use Case
#!/bin/bash Bash System bash (may be old)
#!/usr/bin/env bash Bash Use bash from PATH (recommended)
#!/bin/zsh Zsh System zsh
#!/usr/bin/env zsh Zsh Use zsh from PATH (recommended)
#!/bin/sh POSIX sh Maximum compatibility

Tip

Use #!/usr/bin/env bash for better portability across different systems.

Script Basics

Comments

# Single line comment

: << 'COMMENT'
Multi-line comment
Everything here is ignored
COMMENT

Input and Output

# Print to stdout
echo "Hello"
printf "Name: %s\n" "$name"

# Read from stdin
read -p "Enter your name: " name
read -sp "Enter password: " password  # Silent input

# Here document
cat << EOF
Multiple lines
of text
EOF

# Here string
grep "pattern" <<< "search this string"

Exit Codes

# Success
exit 0

# Error
exit 1

# Check exit code
if [ $? -eq 0 ]; then
    echo "Success"
fi

# Different error codes
exit 1   # General error
exit 2   # Misuse of command
exit 127 # Command not found
exit 130 # Ctrl+C

Variables

Declaration and Assignment

# Basic assignment (no spaces around =)
name="Alice"
count=42

# Command substitution
current_dir=$(pwd)
files=$(ls)

# Read from file
content=$(cat file.txt)

# Arithmetic
((count++))
((result = 5 + 3))

Variable Expansion

name="Alice"

# Basic expansion
echo "$name"              # Alice
echo "${name}"            # Alice (preferred)

# Default values
echo "${name:-Guest}"     # Use "Guest" if name is unset/empty
echo "${name:=Guest}"     # Set and use "Guest" if unset/empty
echo "${name:?Error}"     # Error if name is unset/empty

# String manipulation
echo "${name,,}"          # alice (lowercase)
echo "${name^^}"          # ALICE (uppercase)
echo "${#name}"           # 5 (length)

# Substrings
text="Hello World"
echo "${text:0:5}"        # Hello
echo "${text:6}"          # World
echo "${text: -5}"        # World (last 5 chars)

# Search and replace
echo "${text/World/Universe}"   # Hello Universe (first)
echo "${text//o/0}"             # Hell0 W0rld (all)

Arrays

Bash Arrays

# Declaration
fruits=("apple" "banana" "cherry")
declare -a numbers=(1 2 3 4 5)

# Access (0-indexed)
echo "${fruits[0]}"        # apple
echo "${fruits[@]}"        # All elements
echo "${!fruits[@]}"       # All indices
echo "${#fruits[@]}"       # Length

# Iteration
for fruit in "${fruits[@]}"; do
    echo "$fruit"
done

# Add elements
fruits+=("date")

# Associative arrays (Bash 4+)
declare -A person
person[name]="Alice"
person[age]=30

echo "${person[name]}"     # Alice
for key in "${!person[@]}"; do
    echo "$key: ${person[$key]}"
done

Zsh Arrays

# Declaration (1-indexed!)
fruits=(apple banana cherry)

# Access (1-indexed!)
echo $fruits[1]            # apple (first element)
echo $fruits[-1]           # cherry (last element)
echo $fruits[2,3]          # banana cherry (slice)
echo ${#fruits}            # 3 (length)

# All elements
echo ${fruits[@]}
echo ${fruits[*]}

# Associative arrays
typeset -A person
person[name]="Alice"
person[age]=30

# Iteration
for fruit in $fruits; do
    echo $fruit
done

for key value in ${(kv)person}; do
    echo "$key: $value"
done

Control Structures

Conditionals

# if-elif-else
if [ "$count" -gt 10 ]; then
    echo "Greater than 10"
elif [ "$count" -eq 10 ]; then
    echo "Equals 10"
else
    echo "Less than 10"
fi

# Modern test [[ ]] (Bash/Zsh)
if [[ "$name" == "Alice" && "$age" -gt 18 ]]; then
    echo "Adult Alice"
fi

# One-liner
[ -f "file.txt" ] && echo "File exists"

# Case statement
case "$1" in
    start)
        echo "Starting..."
        ;;
    stop)
        echo "Stopping..."
        ;;
    restart)
        echo "Restarting..."
        ;;
    *)
        echo "Usage: $0 {start|stop|restart}"
        exit 1
        ;;
esac

Test Operators

File Tests

[ -e file ]    # Exists
[ -f file ]    # Regular file
[ -d dir ]     # Directory
[ -L link ]    # Symbolic link
[ -r file ]    # Readable
[ -w file ]    # Writable
[ -x file ]    # Executable
[ -s file ]    # Not empty
[ file1 -nt file2 ]  # file1 newer than file2
[ file1 -ot file2 ]  # file1 older than file2

String Tests

[ -z "$str" ]       # Empty string
[ -n "$str" ]       # Non-empty string
[ "$a" = "$b" ]     # Equal (POSIX)
[ "$a" == "$b" ]    # Equal (Bash/Zsh)
[ "$a" != "$b" ]    # Not equal
[[ "$a" < "$b" ]]   # Lexicographic comparison
[[ "$a" =~ regex ]] # Regex match (Bash/Zsh)

Numeric Tests

[ "$a" -eq "$b" ]   # Equal
[ "$a" -ne "$b" ]   # Not equal
[ "$a" -lt "$b" ]   # Less than
[ "$a" -le "$b" ]   # Less than or equal
[ "$a" -gt "$b" ]   # Greater than
[ "$a" -ge "$b" ]   # Greater than or equal

Logical Operators

[ cond1 ] && [ cond2 ]     # AND
[ cond1 ] || [ cond2 ]     # OR
! [ cond ]                  # NOT

# Modern [[ ]]
[[ cond1 && cond2 ]]
[[ cond1 || cond2 ]]
[[ ! cond ]]

Loops

# for loop
for i in 1 2 3 4 5; do
    echo "$i"
done

# C-style for
for ((i=0; i<10; i++)); do
    echo "$i"
done

# Range expansion
for i in {1..10}; do
    echo "$i"
done

# Iterate files
for file in *.txt; do
    echo "Processing $file"
done

# while loop
count=0
while [ $count -lt 10 ]; do
    echo "$count"
    ((count++))
done

# Read file line by line
while IFS= read -r line; do
    echo "$line"
done < file.txt

# until loop
count=0
until [ $count -ge 10 ]; do
    echo "$count"
    ((count++))
done

# Loop control
for i in {1..10}; do
    [ $i -eq 5 ] && continue  # Skip 5
    [ $i -eq 8 ] && break     # Stop at 8
    echo "$i"
done

Functions

Basic Functions

# Definition
greet() {
    echo "Hello, $1!"
}

# Alternative syntax
function greet {
    echo "Hello, $1!"
}

# Call
greet "Alice"             # Hello, Alice!

Function Arguments

process() {
    local file="$1"       # First argument
    local mode="${2:-default}"  # Second arg or "default"
    
    echo "File: $file"
    echo "Mode: $mode"
    echo "All args: $@"
    echo "Num args: $#"
}

process "file.txt" "verbose"

Return Values

# Return exit code (0-255)
is_even() {
    local num=$1
    if (( num % 2 == 0 )); then
        return 0  # Success
    else
        return 1  # Failure
    fi
}

if is_even 4; then
    echo "Even"
fi

# Return string via stdout
get_name() {
    echo "Alice"
}

name=$(get_name)
echo "$name"

# Return multiple values
get_person() {
    echo "Alice"
    echo "30"
    echo "Engineer"
}

read name age job <<< "$(get_person | tr '\n' ' ')"

Local Variables

global_var="global"

my_function() {
    local local_var="local"
    global_var="modified"
    
    echo "Local: $local_var"
    echo "Global: $global_var"
}

my_function
echo "Global outside: $global_var"  # modified
# echo "$local_var"  # Error: not defined

Error Handling

Exit on Error

#!/bin/bash
set -e              # Exit on error
set -u              # Error on undefined variable
set -o pipefail     # Fail on pipe errors

# Combined
set -euo pipefail

# Or in shebang
#!/bin/bash -euo pipefail

Error Checking

# Check command success
if ! command -v git &> /dev/null; then
    echo "Git not found"
    exit 1
fi

# Check file exists
if [ ! -f "config.txt" ]; then
    echo "Config file not found"
    exit 1
fi

# Or with ||
cd /some/dir || exit 1

Trap Errors

# Cleanup on exit
cleanup() {
    echo "Cleaning up..."
    rm -f /tmp/tempfile
}
trap cleanup EXIT

# Handle errors
error_handler() {
    echo "Error on line $1"
    exit 1
}
trap 'error_handler $LINENO' ERR

# Handle signals
trap 'echo "Interrupted"; exit 130' INT
trap 'echo "Terminated"; exit 143' TERM

Robust Error Handling

#!/bin/bash
set -euo pipefail

# Error handler
err_report() {
    echo "Error on line $1" >&2
    exit 1
}
trap 'err_report $LINENO' ERR

# Cleanup handler
cleanup() {
    local exit_code=$?
    echo "Cleaning up..."
    # Cleanup code here
    exit $exit_code
}
trap cleanup EXIT

# Your script here
main() {
    echo "Starting..."
    # Your code
}

main "$@"

Common Patterns

Argument Parsing

# Simple argument parsing
while [[ $# -gt 0 ]]; do
    case $1 in
        -h|--help)
            show_help
            exit 0
            ;;
        -v|--verbose)
            VERBOSE=1
            shift
            ;;
        -o|--output)
            OUTPUT_FILE="$2"
            shift 2
            ;;
        *)
            echo "Unknown option: $1"
            exit 1
            ;;
    esac
done

# Using getopts (single-char options)
while getopts "hvo:" opt; do
    case $opt in
        h) show_help; exit 0 ;;
        v) VERBOSE=1 ;;
        o) OUTPUT_FILE="$OPTARG" ;;
        ?) exit 1 ;;
    esac
done
shift $((OPTIND-1))

Input Validation

# Check required arguments
if [ $# -eq 0 ]; then
    echo "Usage: $0 <filename>"
    exit 1
fi

# Validate file exists
file="$1"
if [ ! -f "$file" ]; then
    echo "Error: File '$file' not found"
    exit 1
fi

# Validate number
if ! [[ "$1" =~ ^[0-9]+$ ]]; then
    echo "Error: Not a valid number"
    exit 1
fi

Configuration Files

# Load configuration
CONFIG_FILE="${1:-config.sh}"
if [ -f "$CONFIG_FILE" ]; then
    source "$CONFIG_FILE"
else
    echo "Config file not found: $CONFIG_FILE"
    exit 1
fi

# Safe config loading
load_config() {
    local config_file="$1"
    if [ -f "$config_file" ]; then
        # Only allow variable assignments
        while IFS='=' read -r key value; do
            # Skip comments and empty lines
            [[ "$key" =~ ^[[:space:]]*# ]] && continue
            [[ -z "$key" ]] && continue
            
            # Export variable
            export "$key"="$value"
        done < "$config_file"
    fi
}

Logging

# Simple logging
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}

log "Starting process..."
log "Processing file: $file"

# Log levels
log_info() {
    echo "[INFO] $(date '+%H:%M:%S') - $*"
}

log_error() {
    echo "[ERROR] $(date '+%H:%M:%S') - $*" >&2
}

log_debug() {
    [ "$DEBUG" = "1" ] && echo "[DEBUG] $(date '+%H:%M:%S') - $*"
}

Process Management

# Run in background
long_task &
pid=$!
echo "Running with PID: $pid"

# Wait for completion
wait $pid
echo "Task completed with exit code: $?"

# Timeout
timeout 30s long_task || echo "Timed out"

# Check if process is running
if ps -p $pid > /dev/null; then
    echo "Process is running"
fi

Best Practices

1. Use Strict Mode

#!/bin/bash
set -euo pipefail
IFS=$'\n\t'

2. Quote Variables

# ✓ Good
echo "$variable"
rm "$file"
cd "$directory"

# ✗ Bad (word splitting issues)
echo $variable
rm $file

3. Use Functions

# ✓ Good - modular and reusable
check_requirements() {
    command -v git &> /dev/null || {
        echo "Git not found"
        return 1
    }
}

main() {
    check_requirements || exit 1
    # Main logic
}

main "$@"

# ✗ Bad - everything in global scope
command -v git &> /dev/null || exit 1
# Script continues...

4. Use ShellCheck

# Install ShellCheck
# macOS: brew install shellcheck
# Ubuntu: apt install shellcheck

# Check script
shellcheck script.sh

5. Document Your Code

#!/bin/bash
#
# backup.sh - Backup important directories
#
# Usage: backup.sh [options] <source> <destination>
#
# Options:
#   -v, --verbose    Enable verbose output
#   -h, --help       Show this help message
#
# Examples:
#   backup.sh ~/Documents /mnt/backup
#   backup.sh -v ~/Pictures /mnt/backup

# Function documentation
## Backs up a directory to destination
## Arguments:
##   $1 - Source directory
##   $2 - Destination directory
## Returns:
##   0 on success, 1 on failure
backup_directory() {
    local src="$1"
    local dest="$2"
    # Implementation
}

6. Handle Edge Cases

# Check prerequisites
command -v required_tool &> /dev/null || {
    echo "Error: required_tool not found"
    exit 1
}

# Validate input
if [ $# -eq 0 ]; then
    echo "Usage: $0 <arg>"
    exit 1
fi

# Check file permissions
if [ ! -r "$file" ]; then
    echo "Error: Cannot read file: $file"
    exit 1
fi

Testing and Debugging

Debug Mode

# Enable debugging
bash -x script.sh

# Or in script
set -x    # Enable
set +x    # Disable

# Conditional debugging
[ "$DEBUG" = "1" ] && set -x

Testing Frameworks

Bats (Bash Automated Testing System)

# Install: brew install bats-core

# test.bats
#!/usr/bin/env bats

@test "addition" {
    result="$(( 2 + 2 ))"
    [ "$result" -eq 4 ]
}

@test "file exists" {
    [ -f "README.md" ]
}

# Run tests
bats test.bats

Dry Run Mode

DRY_RUN="${DRY_RUN:-0}"

run_command() {
    if [ "$DRY_RUN" = "1" ]; then
        echo "Would run: $*"
    else
        "$@"
    fi
}

# Usage
run_command rm -f file.txt

# Run in dry-run mode
DRY_RUN=1 ./script.sh

See Also

References