Bash

str_replace() in Bash: Replace Text in a String

By Nawawi JamiliPublished 5 min read

PHP's str_replace($search, $replace, $subject) replaces every occurrence of one string with another. Bash can do the same with a single parameter expansion, ${subject//search/replace}, with no sed and no subshell. The problem is that the search part is a glob pattern, not a plain string, and that Bash 5.2 changed what & means in the replacement.

This post wraps the expansion in a small function that behaves like the PHP original, then goes through the variants and the mistakes to avoid.

The function

bash
# str_replace() -- replace all occurrences of search with replace
# Usage: str_replace search replace subject
str_replace() {
    [ $# -eq 3 ] || return 1
    local search="$1" replace="$2" subject="$3"
    if [ -z "$search" ]; then
        printf '%s\n' "$subject"
        return 0
    fi
    printf '%s\n' "${subject//"$search"/"$replace"}"
}

The argument order matches PHP: search, replace, subject. It returns 1 on a wrong argument count, and an empty search string returns the subject unchanged, which is also what PHP does.

How it works

${var/pattern/string} replaces the first match of pattern in $var with string. Doubling the first slash, ${var//pattern/string}, replaces every match. The matching is longest-match, the same as in ${var##...}.

The important detail is the quotes inside the braces: "$search" and "$replace". Quoting the search makes Bash treat it as a literal string instead of a pattern. Quoting the replacement stops Bash 5.2 from treating & specially (more on that below). Both quotes are needed, and both are easy to forget.

Usage

terminal
$ str_replace "cat" "dog" "the cat sat on the cat mat"
the dog sat on the dog mat
$ str_replace " " "" "a b c"
abc
$ str_replace $'\n' ", " $'one\ntwo\nthree'
one, two, three
$ str_replace "*" "x" "a*b*c"
axbxc
$ str_replace "?" "!" "why? really?"
why! really!
$ str_replace "[a]" "A" "x[a]y a"
xAy a
$ str_replace "o" "&" "foo"
f&&

The last four are the ones a simple version gets wrong. *, ? and [a] are replaced as literal text, and & comes out as a plain ampersand. I ran these on Bash 4.4.20 and on Bash 5.2.21 and got identical output.

First match, all matches and anchors

The expansion has four forms. Without the function:

bash
s="one two one two"
printf '%s\n' "${s/one/1}" "${s//one/1}"
s="foofoo"
printf '%s\n' "${s/#foo/bar}" "${s/%foo/bar}" "${s/#oo/bar}"
output
1 two one two
1 two 1 two
barfoo
foobar
foofoo
  • ${s/x/y}: first match only.
  • ${s//x/y}: every match.
  • ${s/#x/y}: only if x matches at the start.
  • ${s/%x/y}: only if x matches at the end.

The anchored forms are handy for prefixes and suffixes. ${file/%.txt/.bak} changes the extension only when it really is at the end. In the last example "oo" does not match at the start, so nothing changes. Leaving out the replacement deletes the match: ${s//-} turns a-b-c into abc, which is exactly what rm_str() does.

On an array, the replacement is applied to each element: "${files[@]//.txt/.bak}" turns one.txt two.txt into one.bak two.bak.

Quote the search, or it is a pattern

When the search comes from a variable and you leave it unquoted, its glob characters keep their special meaning:

bash
s="a*b*c"; p="*"
printf '%s\n' "${s//$p/x}" "${s//"$p"/x}"
s="v1.2.3"; p="?"
printf '%s\n' "${s//$p/X}" "${s//"$p"/X}"
output
x
axbxc
XXXXXX
v1.2.3

An unquoted * matches the whole string, so everything becomes a single x. An unquoted ? matches any one character, so every character is replaced. A dot, on the other hand, is not special in globs (it is in regular expressions), so ${s//./_} is safe either way. The rule is simple: quote the search unless you want a pattern.

Sometimes you do want one, and that is a feature str_replace() in PHP does not have:

terminal
$ s="a1b22c333"; printf '%s\n' "${s//[0-9]/#}"
a#b##c###

With shopt -s extglob you can go further, for example ${s//+([0-9])/N} to replace each run of digits with one N. In my tests on 4.4 and 5.2, shopt -s nocasematch also made the expansion case-insensitive ("Cat CAT cat" became dog dog dog), which is the closest thing to PHP's str_ireplace(). Turn it off again afterwards, because it also affects [[ ]] and case.

The & in the replacement (Bash 5.2)

Bash 5.2 added a shell option, patsub_replacement, and turned it on by default. With it, an unquoted & in the replacement string stands for the matched text, much like & in sed. This is what I got on Bash 5.2.21:

bash
s="foo bar"
echo "${s//o/[&]}"    # f[o][o] bar  on 5.2
r="&"
echo "${s//o/"$r"}"   # f&& bar      on 5.2
echo "${s//o/\&}"    # f&& bar      on 5.2

On the Bash 4.4 I am writing this on, & has no special meaning, so the first line prints f[&][&] bar:

terminal
$ s="foo"; r="&"
$ printf '%s\n' "${s//o/"$r"}" "${s//o/$r}" "${s//o/[&]}"
f&&
f&&
f[&][&]

The middle line is the dangerous one. On 5.2, ${s//o/$r} with r="&" printed foo: the & that came out of the unquoted variable was replaced with the match, so nothing appeared to change. A script that moves from an older system to Bash 5.2 can change behaviour silently if it substitutes data containing & (URLs with query strings are the common case). Quoting the replacement, as the function does, gives a literal & on both versions. A backslash, \&, also forces a literal ampersand. If you have a lot of old code, shopt -u patsub_replacement turns the feature off, but only on 5.2 and later. On older versions shopt will complain that the option does not exist.

Pitfalls

  • Empty search. The raw expansion with an empty pattern does nothing (${s//$e/x} leaves abc alone), but the function checks for it explicitly so the intent is clear.
  • Output that looks like an option. printf '%s\n' prints -n or -e as text. echo would treat them as options and not print them.
  • One search at a time. PHP accepts arrays for search and replace. In Bash, call the expansion once per pair, in order, and remember that a later pair can match text inserted by an earlier one.
  • Large strings. The expansion is fast for normal strings, but for a multi-megabyte file run sed once on the file instead of loading it into a variable.

Alternatives

sed 's/cat/dog/g' is the obvious one, and the right tool for files. For strings already in variables it costs a fork and brings its own escaping problems. The search is a regular expression, so ., *, [ and / in your data need escaping, and & in the replacement means the match. That is double the quoting rules for no gain. Inside Bash, use the expansion.

The Bash manual documents the pattern substitution forms and patsub_replacement. str_replace() on php.net has the PHP version.