Bash

stristr() in Bash: Case-Insensitive Substring Search

By Nawawi JamiliPublished Updated 5 min read

PHP's stristr() is strstr() with the case ignored: "QUOTA", "Quota" and "quota" all count as the same needle. In shell scripts I need this whenever I grep through output written by someone else's program, where capitalisation is not consistent. Bash 4 can lowercase both strings with parameter expansion and compare them in one [[ ]] test, and Bash 3.2 can do it with the nocasematch option.

Like the strstr() rewrite, this post gives you a yes/no test (stristr) and a PHP-style version that prints the rest of the string (php_stristr).

The function

bash
# stristr() -- succeed if HAYSTACK contains NEEDLE, ignoring case
# Usage: stristr haystack needle   (Bash 4+)
stristr() {
    (( $# == 2 )) || return 2
    [[ ${1,,} == *"${2,,}"* ]]
}
# php_stristr() -- print HAYSTACK from the first NEEDLE onwards
# (case-insensitive), or the part before it with a third argument
# Usage: php_stristr haystack needle [before]   (Bash 4+)
php_stristr() {
    (( $# == 2 || $# == 3 )) || return 2
    local hay=${1,,} needle=${2,,} before
    [[ $hay == *"$needle"* ]] || return 1
    before=${hay%%"$needle"*}
    if (( $# == 3 )); then
        printf '%s\n' "${1:0:${#before}}"
    else
        printf '%s\n' "${1:${#before}}"
    fi
}

Exit status 0 means found, 1 means not found, 2 means the function was called wrongly.

How it works

${1,,} is the Bash 4 expansion that lowercases every character of $1 (${1^^} would uppercase it). Once both sides are lowercase, the test is the same as in strstr(): *"$needle"* is a glob where the stars are wildcards and the quoted needle is matched literally.

php_stristr does the search on the lowercased copy, because that is where the match is case-blind. It measures how many characters come before the match, then slices the original $1 at that offset. That is why the output keeps the haystack's own capitalisation, as PHP does. Lowercasing changes the case of characters, not how many there are, so the offset is valid in both strings.

There is no strtolower call any more. The 2009 version depended on my strtolower() function and ran it twice through $(...), which is two subshells per test. Doing the lowercasing inline is both faster and one less dependency.

Usage

terminal
$ msg="Error: Disk QUOTA exceeded on /home"
$ stristr "$msg" "quota" && echo match
match
$ stristr "$msg" "Quota Exceeded" && echo match
match
$ stristr "$msg" "warning"; echo $?
1
$ stristr "$msg"; echo $?
2

$ php_stristr "Content-Type: text/HTML" "text/html"
text/HTML
$ php_stristr "Content-Type: text/HTML" "TYPE" before
Content-

A Bash 3.2 version with nocasematch

${var,,} does not exist in Bash 3.2, which is still /bin/bash on macOS. The function fails there with a "bad substitution" error. The nocasematch shell option is older and makes [[ == ]] and case compare without regard to case. The only care needed is to put the option back the way you found it, otherwise every later comparison in the script turns case-insensitive too:

bash
# stristr() -- Bash 3.2 version using nocasematch
# Usage: stristr haystack needle
stristr() {
    (( $# == 2 )) || return 2
    local restore rc
    restore=$(shopt -p nocasematch)
    shopt -s nocasematch
    [[ $1 == *"$2"* ]]
    rc=$?
    eval "$restore"
    return "$rc"
}
terminal
$ msg="Error: Disk QUOTA exceeded on /home"
$ stristr "$msg" "quota" && echo match
match
$ shopt nocasematch
nocasematch    	off
$ [[ ABC == abc ]] || echo "case-sensitive again"
case-sensitive again

shopt -p nocasematch prints the command that recreates the current setting (shopt -u nocasematch or shopt -s nocasematch), and eval runs it after the test. The $(...) costs one subshell per call, so on Bash 4 and later prefer the first version.

Pitfalls

The typo in the 2009 example

The original post's second example called stirstr instead of stristr. It is worth showing what that does, because it fails in the worst possible way:

bash
. ./stristr.sh
buff="$(ps ax)"
if ! stirstr "${buff}" "httpd"; then
    echo "service httpd down"
fi
output
typo.sh: line 3: stirstr: command not found
service httpd down

An unknown command returns status 127, ! turns that into success, and the script announces an outage that may not exist. The error message goes to stderr, which in a cron job usually means nobody sees it.

The defence is to keep "not found" and "something went wrong" apart. The new functions return 2 on bad usage, and a careful caller treats anything above 1 as an error rather than as a negative answer:

bash
. ./stristr.sh
buff="$(ps ax)"
stirstr "$buff" "httpd"
rc=$?
if (( rc > 1 )); then
    echo "check failed (status $rc)" >&2
    exit 1
fi
(( rc == 0 )) || echo "service httpd down"
output
rc.sh: line 3: stirstr: command not found
check failed (status 127)

The typo is still there, but now it stops the script instead of raising a false alarm.

The original also returned 1 when strtolower was not defined. A missing dependency looked exactly like "not found".

Locale and non-ASCII letters

${var,,} follows the current locale. In a UTF-8 locale accented capitals are lowercased. In the C locale, which is what many cron jobs and minimal containers run with, only A-Z are:

terminal
$ stristr "ÉCOLE Normale" "école" && echo match
match
$ LC_ALL=C; stristr "ÉCOLE Normale" "école" || echo "no match"
no match
$ stristr "ÉCOLE Normale" "normale" && echo match
match

If your matches involve non-English text, set a UTF-8 LC_ALL or LC_CTYPE at the top of the script instead of trusting the environment.

Wildcards in the needle

The 2009 code left the needle unquoted, so *, ? and [...] acted as wildcards. The quoted version matches them literally:

terminal
$ stristr "README.TXT" "*.txt" || echo "no match"
no match

The substring test also ignores word boundaries: "inactive" contains "active". When you need a whole-word or whole-value match, compare the full value instead.

Alternatives

For a one-off check, case with nocasematch lets you test several needles at once. For files and long streams, grep -qiF (quiet, ignore case, fixed string) is the right tool:

terminal
$ hdr="Server: Apache/2.4.37"
$ shopt -s nocasematch
$ case $hdr in *apache*) echo "Apache" ;; *nginx*) echo "nginx" ;; esac
Apache
$ shopt -u nocasematch
$ printf '%s\n' "$hdr" | grep -qiF 'apache' && echo found
found

Piping a variable into grep starts a process just to answer a question Bash can answer itself. So I only use it when the data is not already in a variable. The PHP original is documented on the stristr() manual page.

History of this snippet

The April 2009 version called strtolower twice through command substitution, left the needle unquoted, returned 1 for every kind of failure, and had the stirstr typo in its example. The rewrite uses ${var,,} directly, quotes the needle, adds a Bash 3.2 fallback and a PHP-style php_stristr.