Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Reusable Bash Functions and Script Debugging

Hello! Last lesson, your log scanner used conditions, loops, and exit statuses to check multiple files. It works, but the file-checking logic and the overall script flow are intertwined. Today you’ll separate them into reusable Bash functions, then use tracing and ShellCheck to investigate a realistic defect. Everything runs locally in Ubuntu; allow about 45 minutes.


Give each function one job

A function groups commands under a name. Defining it does not run its body; calling its name does. A useful split for the log scanner is a scan_one function that checks one file and a main function that handles the argument list and decides the script’s final result.

Watch the functions portion of freeCodeCamp.org’s Bash Scripting Tutorial for Beginners. Its examples establish the mechanics before we apply them to your scanner.

Bash Scripting Tutorial for Beginners

In Bash Scripting Tutorial for Beginners, freeCodeCamp.org demonstrates why functions help, how to call them, and how arguments and local variables work.

Watch define and call for the basic structure. Then watch local scope to see what can go wrong when a function changes a script-wide variable. Finish with function arguments; focus on how a function receives its own positional parameters.

Here is the smallest useful pattern:

describe_file() {
    local path=$1
    printf 'Checking: %s\n' "$path"
}

describe_file 'api events.log'

The call supplies a path as $1 inside the function. Quoting "$path" preserves the space in the filename. local keeps path from accidentally changing a variable of the same name elsewhere in the script. Prefer a function argument over having describe_file silently depend on a particular global variable: the caller can then use it for any path.

The Bash Reference Manual provides the precise rules behind that example. Read the indicated parts of “3.3 Shell Functions”; you do not need its more advanced discussion of traps or dynamic scoping for this lesson.

Shell Functions (Bash Reference Manual)

Read “3.3 Shell Functions” in the Bash Reference Manual to distinguish a function’s arguments and status from the script’s, and to understand why local matters.

At the start of “3.3 Shell Functions,” read definition and status. Farther down, read the paragraph beginning “When a function is executed” through function parameters. In the later paragraph beginning “If the builtin command,” read return behavior and the following explanation of restored positional parameters. Finally, in the paragraph beginning “Variables local to the function,” read local variables.

return ends a function; exit ends the script. A function’s return status is a small integer signal, just like a command’s exit status. It is not a way to return a filename or other string; print data when a caller needs data, and use status to indicate the outcome. If a function reaches its closing brace without an explicit return, its status is that of its last command—a rule worth remembering when editing it later.


Refactor the scanner without changing its contract

Work in the practice directory containing the two logs from last lesson. If you used a different directory name, use that one instead:

cd ~/cloud-labs/bash-flow-practice

Create scan-logs-functions.sh with the following content. It retains the earlier script’s contract: 0 means no error entries, 1 means at least one was found, and 2 means the requested scan could not be completed.

#!/usr/bin/env bash

# Check one log. Return 0 for no match, 1 for a match, or 2 on error.
scan_one() {
    local log_path=$1
    local grep_status

    if [ ! -f "$log_path" ] || [ ! -r "$log_path" ]; then
        printf 'Not a readable regular file: %s\n' "$log_path" >&2
        return 2
    fi

    printf 'Checking: %s\n' "$log_path"

    if grep -F -- ' ERROR ' "$log_path"; then
        return 1
    else
        grep_status=$?
        if [ "$grep_status" -eq 1 ]; then
            printf 'No ERROR entries found\n'
            return 0
        fi

        printf 'Could not search: %s\n' "$log_path" >&2
        return 2
    fi
}

# Scan every requested file and report the overall result.
main() {
    local log_path
    local result
    local found_errors=0

    if [ "$#" -eq 0 ]; then
        printf 'Usage: %s LOG_FILE [LOG_FILE ...]\n' "$0" >&2
        return 2
    fi

    for log_path in "$@"; do
        if scan_one "$log_path"; then
            continue
        else
            result=$?
            case "$result" in
                1) found_errors=1 ;;
                2) return 2 ;;
                *)
                    printf 'Unexpected scan result: %s\n' "$result" >&2
                    return 2
                    ;;
            esac
        fi
    done

    if [ "$found_errors" -eq 1 ]; then
        printf 'Scan complete: ERROR entries found\n'
        return 1
    fi

    printf 'Scan complete: no ERROR entries found\n'
    return 0
}

main "$@"

Follow the division of responsibility. scan_one "$log_path" receives one path and checks it. A matching grep prints the entry and leads to return 1; no match leads to return 0; an unreadable path or failed search leads to return 2. As before, grep status 1 (no match) is different from a search error.

main "$@" passes the script’s arguments into main without breaking filenames containing spaces. Inside main, the loop passes each filename to scan_one. When scan_one returns a nonzero status, the else branch saves $? immediately. It remembers a finding but continues scanning; it stops on an error because it can no longer claim to have checked every requested file. The final call to main "$@" is the script’s last command, so its status becomes the script’s status.

The continue simply starts the next loop iteration when a file has no matches. You could structure that branch differently; what matters is that a successful check does not overwrite the saved overall finding.

Run the same kinds of checks as last lesson:

bash scan-logs-functions.sh 'worker events.log'
printf 'Status: %s\n' "$?"

bash scan-logs-functions.sh 'api events.log' 'worker events.log'
printf 'Status: %s\n' "$?"

bash scan-logs-functions.sh 'missing events.log'
printf 'Status: %s\n' "$?"

Expect 0, 1, and 2. This is a useful refactoring test: the internal structure changed, but callers still receive the same documented results.


Use three different diagnostic lenses

A script can have a syntax error, a suspicious construction, or a valid-looking command that behaves incorrectly at runtime. No single tool catches all three.

Tool Best question to ask
bash -n script.sh “Can Bash parse this script without running it?”
shellcheck script.sh “Does static analysis spot likely bugs or questionable shell usage?”
bash -x script.sh ... “Which commands and expanded arguments did this run actually use?”

Google’s Shell Style Guide recommends ShellCheck and also explains why local variables and an identifiable main make larger scripts easier to maintain. Read those portions rather than trying to adopt every convention in the guide.

Shell Style Guide

Read the relevant parts of Google’s Shell Style Guide for a maintenance perspective on the functions you just wrote and the static check you will run next.

Under “Features and Bugs,” find the “ShellCheck” subsection and read its recommendation. Under “Use Local Variables,” read from “Declare function-specific variables” through the scope rationale. Then read “Function Location” and “main,” starting at the organization guidance. Focus on the prose; the extracted examples on this page may be difficult to read.

If ShellCheck is not installed in your Ubuntu environment, install it with sudo apt update followed by sudo apt install shellcheck. Then check the working script:

bash -n scan-logs-functions.sh
shellcheck scan-logs-functions.sh

A successful bash -n produces no output. ShellCheck also commonly produces no output when it has no findings. Silence from either tool is not proof that the script behaves correctly; your status checks are still necessary.

The supplied ShellCheck screenshot illustrates a different kind of finding: it reports that several constructs are not portable to the shell being checked.

ShellCheck output for a script checked as POSIX `sh`, including warnings about the `function` keyword, string replacement, and `echo` flags.

Our script declares Bash with #!/usr/bin/env bash and is run with bash, so a warning about what POSIX sh supports would call for checking the selected shell—not automatically removing valid Bash features. Conversely, a Bash shebang does not make unquoted filenames safe.

To see runtime tracing, watch the short demonstration in Bash Debug Mode Explained by You Suck at Programming.

Bash Debug Mode Explained

Bash Debug Mode Explained, from You Suck at Programming, shows how bash -x exposes executed commands and branch decisions.

Watch enable tracing, then read the trace. Notice the difference between the commands Bash displays for diagnosis and the script’s ordinary printed output.

Run bash -x scan-logs-functions.sh 'worker events.log'. Lines prefixed with + are trace output, normally sent to standard error. They show assignments, function calls, tests, and commands after expansion. Do not trace a script handling credentials or secrets into a shared terminal, CI log, or saved file: expanded values may appear in the trace.


Diagnose a defect that passes the syntax check

Make a disposable copy so the working version remains available:

cp scan-logs-functions.sh scan-logs-broken.sh
nano scan-logs-broken.sh

In the copy, change only the grep line inside scan_one from:

if grep -F -- ' ERROR ' "$log_path"; then

to:

if grep -F -- ' ERROR ' $log_path; then

Now the script is syntactically valid, but the unquoted expansion can split a filename at its space. Check it in stages:

bash -n scan-logs-broken.sh
shellcheck scan-logs-broken.sh
bash -x scan-logs-broken.sh 'api events.log'
printf 'Status: %s\n' "$?"

bash -n should still be silent: missing quotes are not a parsing error. ShellCheck should flag the unquoted expansion as a word-splitting risk (commonly SC2086). In the trace, look for grep receiving api and events.log as separate arguments rather than the single path api events.log. The earlier quoted file test may succeed, yet the search fails and the script returns 2. That contrast narrows the defect to the command that uses the path, not the argument supplied by the caller.

Restore the quotes in the disposable copy, then rerun ShellCheck and the two-file status test. This is the core debugging cycle: observe the reported behavior, locate the command and its expanded arguments, make one targeted change, and verify the result.


What to carry forward

Reusable functions have clear inputs, local working variables, and documented statuses. In this scanner, scan_one checks one path while main controls the full run; return communicates with the caller, whereas exit would terminate the entire script. For defects, use bash -n to check syntax, ShellCheck to catch likely mistakes, and bash -x—carefully, without secrets—to see what Bash actually executed.

Next, you’ll track scripts in Git so changes like this refactor and its bug fix can be reviewed and recovered rather than living only in a working directory.

Can't find a good explanation? Sign up and we'll make it for you

Sign up