Skip to main content

Bash script to search file contents (by file extension) for the specified search term. Uses grep under the hood.

#!/usr/bin/env bash
# shellcheck disable=SC2034,SC2086,SC2155,SC2001,SC2048

#
# Search file contents (by file extension) for the specified search term.
#
# grep options:
#
#   −r  Recursively search subdirectories listed.
#   −I  Ignore binary files.
#   −s  Nonexistent and unreadable files are ignored (i.e. their error messages are suppressed).
#   -l  Only the names of files containing selected lines are written to standard output.
#   -o  Print only the matched (non-empty) parts of a matching line, with each such part on a separate output line.
#   -w  The expression is searched for as a word.
#
# The following directories are ALWAYS ignored:
# node_modules, .git, packages, bin, obj, .idea, .vs, and .vscode
#
# Author.....: Jon LaBelle
# Date.......: December 24, 2019
# Homepage...: <https://jonlabelle.com/snippets/view/shell/search-file-content>
#

set -e
set -o pipefail

readonly SCRIPT_NAME=$(basename "${0}")

SEARCH_TERM=
FILE_EXTENSION=
FILES_WITH_MATCHES=false
IGNORE_CASE=""
VERBOSE=false

log_verbose() {
    if [ "$VERBOSE" = "true" ]; then
        echo "${1}"
    fi
}

show_usage() {
    echo "Usage: ${SCRIPT_NAME} -t <term> -e <file_extension> [options]"
    echo
    echo "Search options:"
    echo
    echo "  -t, --search-term         <term>        the term to search in files for (case insensitive)."
    echo "  -e, --file-extension      <extension>   only files with this extension will be searched"
    echo "  -l, −−files-with-matches                only print the matched (relative) file path to stdout."
    echo "                                          paths are listed only once per file searched."
    echo "  -i, −−ignore-case                       perform case insensitive matching."
    echo
    echo "NOTE: The following directories are ALWAYS ignored:"
    echo "'node_modules', '.git', 'packages', 'bin', 'obj', '.idea', '.vs' and '.vscode'."
    echo
    echo "Other options:"
    echo
    echo "  -v, --verbose                           useful for debugging and seeing what's going on under the hood."
    echo "  -h, --help                              show this message and exit."
    echo
    echo "Examples:"
    echo
    echo "  To search C# files containing the term 'thread':"
    echo "  ${SCRIPT_NAME} -t thread -e .cs"
    echo
    echo "  To search C# files containing the term 'thread' and print debug information (-v):"
    echo "  ${SCRIPT_NAME} -v -t thread -e .cs"
    echo
    echo "  To search C# files containing the term 'Thread()' and only print file name matches (-l):"
    echo "  ${SCRIPT_NAME} -t 'Thread()' -e .cs -l"
    echo
}

search() {
    log_verbose "> Searching '${FILE_EXTENSION}' files for the term '${SEARCH_TERM}' located in '$(pwd)'..."

    if [ "$FILES_WITH_MATCHES" = "true" ]; then
        find . \
            -path "*.git/*" -prune -o \
            -path "*.idea/*" -prune -o \
            -path "*.vs/*" -prune -o \
            -path "*.vscode/*" -prune -o \
            -path "*bin/*" -prune -o \
            -path "*node_modules/*" -prune -o \
            -path "*obj/*" -prune -o \
            -path "*packages/*" -prune -o \
            -iname '*.'${FILE_EXTENSION} \
            -type f \
            -exec grep ${IGNORE_CASE} -I -r -o -s -w -l ''${SEARCH_TERM}'' '{}' \;
    else
        find . \
            -path "*.git/*" -prune -o \
            -path "*.idea/*" -prune -o \
            -path "*.vs/*" -prune -o \
            -path "*.vscode/*" -prune -o \
            -path "*bin/*" -prune -o \
            -path "*node_modules/*" -prune -o \
            -path "*obj/*" -prune -o \
            -path "*packages/*" -prune -o \
            -iname '*.'${FILE_EXTENSION} \
            -type f \
            -exec grep ${IGNORE_CASE} -I -r -o -s -w ''${SEARCH_TERM}'' '{}' \;
    fi
}

main() {
    if [[ "$1" = "-h" || "$1" = "--help" || "$1" = "help" || "$1" = "--version"  ]]; then
        show_usage
        exit 0
    fi

    while (($# > 0)); do
        if [[ "$1" = "-t" || "$1" = "--search-term" ]]; then
            log_verbose "> Setting search term filter to: '$2'"
            SEARCH_TERM=$2
            shift 2
        elif [[ "$1" = "-e" || "$1" = "--file-extension" ]]; then
            # Normalize the file extension by remove the last period/dot.
            FILE_EXTENSION="${2##*.}"
            log_verbose "> Normalized file extension '$FILE_EXTENSION'"
            shift 2
        elif [[ "$1" = "-i" || "$1" = "--ignore-case" ]]; then
            IGNORE_CASE="--ignore-case"
            log_verbose "> Case insensitive search enabled."
            shift 1
        elif [[ "$1" = "-v" || "$1" = "--verbose" ]]; then
            VERBOSE=true
            log_verbose "> Verbose mode enabled."
            shift 1
        elif [[ "$1" = "-l" || "$1" = "−−files-with-matches" ]]; then
            FILES_WITH_MATCHES=true
            log_verbose "> Only file matches will be printed to stdout."
            shift 1
        else
            shift 1
        fi
    done

    if [[ -z "$FILE_EXTENSION" || -z "$SEARCH_TERM" ]]; then
        echo "Values for both '--search-term' and '--file-extension' are required."
        echo "Type '${SCRIPT_NAME} --help' for help information, including usage and examples."
        exit 1
    fi

    log_verbose "> The following directories will be ignored:"
    log_verbose "> .git"
    log_verbose "> .idea"
    log_verbose "> .vs"
    log_verbose "> .vscode"
    log_verbose "> bin"
    log_verbose "> node_modules"
    log_verbose "> obj"
    log_verbose "> packages"

    search $FILE_EXTENSION $SEARCH_TERM
    exit 0
}

if [ "$#" -eq 0 ]; then
    show_usage
    exit 1
else
    main $*
fi