Skip to content

Commit 7e1d165

Browse files
author
José Valim
authored
Support unary functions in String.replace/4 (#9073)
We also deprecate the :insert_replaced option since the anonymous functions is strictly superior. Closes #9023.
1 parent bb0e8a8 commit 7e1d165

File tree

2 files changed

+123
-55
lines changed

2 files changed

+123
-55
lines changed

lib/elixir/lib/string.ex

Lines changed: 77 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,8 +1278,13 @@ defmodule String do
12781278
Returns a new string created by replacing occurrences of `pattern` in
12791279
`subject` with `replacement`.
12801280
1281+
The `subject` is always a string.
1282+
12811283
The `pattern` may be a string, a regular expression, or a compiled pattern.
12821284
1285+
The `replacement` may be a string or a function that receives the matched
1286+
pattern and must return the replacement as a string or iodata.
1287+
12831288
By default it replaces all occurrences but this behaviour can be controlled
12841289
through the `:global` option; see the "Options" section below.
12851290
@@ -1289,12 +1294,6 @@ defmodule String do
12891294
with `replacement`, otherwise only the first occurrence is
12901295
replaced. Defaults to `true`
12911296
1292-
* `:insert_replaced` - (integer or list of integers) specifies the position
1293-
where to insert the replaced part inside the `replacement`. If any
1294-
position given in the `:insert_replaced` option is larger than the
1295-
replacement string, or is negative, an `ArgumentError` is raised. See the
1296-
examples below
1297-
12981297
## Examples
12991298
13001299
iex> String.replace("a,b,c", ",", "-")
@@ -1303,6 +1302,12 @@ defmodule String do
13031302
iex> String.replace("a,b,c", ",", "-", global: false)
13041303
"a-b,c"
13051304
1305+
The pattern may also be a list of strings and the replacement may also
1306+
be a function that receives the matched patterns:
1307+
1308+
iex> String.replace("a,b,c", ["a", "c"], fn <<char>> -> <<char + 1>> end)
1309+
"b,b,d"
1310+
13061311
When the pattern is a regular expression, one can give `\N` or
13071312
`\g{N}` in the `replacement` string to access a specific capture in the
13081313
regular expression:
@@ -1315,25 +1320,11 @@ defmodule String do
13151320
giving `\0`, one can inject the whole matched pattern in the replacement
13161321
string.
13171322
1318-
When the pattern is a string, a developer can use the replaced part inside
1319-
the `replacement` by using the `:insert_replaced` option and specifying the
1320-
position(s) inside the `replacement` where the string pattern will be
1321-
inserted:
1322-
1323-
iex> String.replace("a,b,c", "b", "[]", insert_replaced: 1)
1324-
"a,[b],c"
1325-
1326-
iex> String.replace("a,b,c", ",", "[]", insert_replaced: 2)
1327-
"a[],b[],c"
1328-
1329-
iex> String.replace("a,b,c", ",", "[]", insert_replaced: [1, 1])
1330-
"a[,,]b[,,]c"
1331-
13321323
A compiled pattern can also be given:
13331324
13341325
iex> pattern = :binary.compile_pattern(",")
1335-
iex> String.replace("a,b,c", pattern, "[]", insert_replaced: 2)
1336-
"a[],b[],c"
1326+
iex> String.replace("a,b,c", pattern, "[]")
1327+
"a[]b[]c"
13371328
13381329
When an empty string is provided as a `pattern`, the function will treat it as
13391330
an implicit empty string between each grapheme and the string will be
@@ -1347,43 +1338,89 @@ defmodule String do
13471338
"ELIXIR"
13481339
13491340
"""
1350-
@spec replace(t, pattern | Regex.t(), t, keyword) :: t
1341+
@spec replace(t, pattern | Regex.t(), t | (t -> t | iodata), keyword) :: t
13511342
def replace(subject, pattern, replacement, options \\ [])
1352-
def replace(subject, "", "", _), do: subject
13531343

1354-
def replace(subject, "", replacement, options) do
1344+
def replace(subject, %{__struct__: Regex} = regex, replacement, options)
1345+
when is_binary(replacement) or is_function(replacement, 1) do
1346+
Regex.replace(regex, subject, replacement, options)
1347+
end
1348+
1349+
def replace(subject, "", "", _) when is_binary(subject) do
1350+
subject
1351+
end
1352+
1353+
def replace(subject, "", replacement, options)
1354+
when is_binary(subject) and is_binary(replacement) do
13551355
if Keyword.get(options, :global, true) do
1356-
IO.iodata_to_binary([replacement | intersperse(subject, replacement)])
1356+
IO.iodata_to_binary([replacement | intersperse_bin(subject, replacement)])
13571357
else
13581358
replacement <> subject
13591359
end
13601360
end
13611361

1362-
def replace(subject, pattern, replacement, options) when is_binary(replacement) do
1363-
if Regex.regex?(pattern) do
1364-
Regex.replace(pattern, subject, replacement, global: options[:global])
1362+
def replace(subject, "", replacement, options)
1363+
when is_binary(subject) and is_function(replacement, 1) do
1364+
if Keyword.get(options, :global, true) do
1365+
IO.iodata_to_binary([replacement.("") | intersperse_fun(subject, replacement)])
13651366
else
1366-
opts = translate_replace_options(options)
1367-
:binary.replace(subject, pattern, replacement, opts)
1367+
IO.iodata_to_binary([replacement.("") | subject])
13681368
end
13691369
end
13701370

1371-
defp intersperse(subject, replacement) do
1371+
def replace(subject, pattern, replacement, options) when is_binary(subject) do
1372+
if insert = Keyword.get(options, :insert_replaced) do
1373+
IO.warn(
1374+
"String.replace/4 with :insert_replaced option is deprecated. " <>
1375+
"Please use :binary.replace/4 instead or pass an anonymous function as replacement"
1376+
)
1377+
1378+
binary_options = if Keyword.get(options, :global) != false, do: [:global], else: []
1379+
:binary.replace(subject, pattern, replacement, [insert_replaced: insert] ++ binary_options)
1380+
else
1381+
matches =
1382+
if Keyword.get(options, :global, true) do
1383+
:binary.matches(subject, pattern)
1384+
else
1385+
case :binary.match(subject, pattern) do
1386+
:nomatch -> []
1387+
match -> [match]
1388+
end
1389+
end
1390+
1391+
IO.iodata_to_binary(do_replace(subject, matches, replacement, 0))
1392+
end
1393+
end
1394+
1395+
defp intersperse_bin(subject, replacement) do
1396+
case next_grapheme(subject) do
1397+
{current, rest} -> [current, replacement | intersperse_bin(rest, replacement)]
1398+
nil -> []
1399+
end
1400+
end
1401+
1402+
defp intersperse_fun(subject, replacement) do
13721403
case next_grapheme(subject) do
1373-
{current, rest} -> [current, replacement | intersperse(rest, replacement)]
1404+
{current, rest} -> [current, replacement.("") | intersperse_fun(rest, replacement)]
13741405
nil -> []
13751406
end
13761407
end
13771408

1378-
defp translate_replace_options(options) do
1379-
global = if Keyword.get(options, :global) != false, do: [:global], else: []
1409+
defp do_replace(subject, [], _, n) do
1410+
[binary_part(subject, n, byte_size(subject) - n)]
1411+
end
1412+
1413+
defp do_replace(subject, [{start, length} | matches], replacement, n) do
1414+
prefix = binary_part(subject, n, start - n)
13801415

1381-
insert =
1382-
if insert = Keyword.get(options, :insert_replaced),
1383-
do: [{:insert_replaced, insert}],
1384-
else: []
1416+
middle =
1417+
if is_binary(replacement) do
1418+
replacement
1419+
else
1420+
replacement.(binary_part(subject, start, length))
1421+
end
13851422

1386-
global ++ insert
1423+
[prefix, middle | do_replace(subject, matches, replacement, start + length)]
13871424
end
13881425

13891426
@doc ~S"""

lib/elixir/test/elixir/string_test.exs

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -394,25 +394,56 @@ defmodule StringTest do
394394
assert String.reverse(String.reverse("Hello \r\n World")) == "Hello \r\n World"
395395
end
396396

397-
test "replace/3" do
398-
assert String.replace("a,b,c", ",", "-") == "a-b-c"
399-
assert String.replace("a,b,c", [",", "b"], "-") == "a---c"
397+
describe "replace/3" do
398+
test "with empty string and string replacement" do
399+
assert String.replace("elixir", "", "") == "elixir"
400+
assert String.replace("ELIXIR", "", ".") == ".E.L.I.X.I.R."
401+
assert String.replace("ELIXIR", "", ".", global: true) == ".E.L.I.X.I.R."
402+
assert String.replace("ELIXIR", "", ".", global: false) == ".ELIXIR"
403+
end
404+
405+
test "with match pattern and string replacement" do
406+
assert String.replace("a,b,c", ",", "-") == "a-b-c"
407+
assert String.replace("a,b,c", [",", "b"], "-") == "a---c"
408+
409+
assert String.replace("a,b,c", ",", "-", global: false) == "a-b,c"
410+
assert String.replace("a,b,c", [",", "b"], "-", global: false) == "a-b,c"
411+
assert String.replace("ãéã", "é", "e", global: false) == "ãeã"
412+
end
413+
414+
test "with regex and string replacement" do
415+
assert String.replace("a,b,c", ~r/,(.)/, ",\\1\\1") == "a,bb,cc"
416+
assert String.replace("a,b,c", ~r/,(.)/, ",\\1\\1", global: false) == "a,bb,c"
417+
end
400418

401-
assert String.replace("a,b,c", ",", "-", global: false) == "a-b,c"
402-
assert String.replace("a,b,c", [",", "b"], "-", global: false) == "a-b,c"
403-
assert String.replace("ãéã", "é", "e", global: false) == "ãeã"
419+
test "with empty string and function replacement" do
420+
assert String.replace("elixir", "", fn "" -> "" end) == "elixir"
421+
assert String.replace("ELIXIR", "", fn "" -> "." end) == ".E.L.I.X.I.R."
422+
assert String.replace("ELIXIR", "", fn "" -> "." end, global: true) == ".E.L.I.X.I.R."
423+
assert String.replace("ELIXIR", "", fn "" -> "." end, global: false) == ".ELIXIR"
404424

405-
assert String.replace("a,b,c", ",", "[]", insert_replaced: 2) == "a[],b[],c"
406-
assert String.replace("a,b,c", ",", "[]", insert_replaced: [1, 1]) == "a[,,]b[,,]c"
407-
assert String.replace("a,b,c", "b", "[]", insert_replaced: 1, global: false) == "a,[b],c"
425+
assert String.replace("elixir", "", fn "" -> [""] end) == "elixir"
426+
assert String.replace("ELIXIR", "", fn "" -> ["."] end) == ".E.L.I.X.I.R."
427+
assert String.replace("ELIXIR", "", fn "" -> ["."] end, global: true) == ".E.L.I.X.I.R."
428+
assert String.replace("ELIXIR", "", fn "" -> ["."] end, global: false) == ".ELIXIR"
429+
end
408430

409-
assert String.replace("a,b,c", ~r/,(.)/, ",\\1\\1") == "a,bb,cc"
410-
assert String.replace("a,b,c", ~r/,(.)/, ",\\1\\1", global: false) == "a,bb,c"
431+
test "with match pattern and function replacement" do
432+
assert String.replace("a,b,c", ",", fn "," -> "-" end) == "a-b-c"
433+
assert String.replace("a,b,c", [",", "b"], fn x -> "[#{x}]" end) == "a[,][b][,]c"
434+
assert String.replace("a,b,c", [",", "b"], fn x -> [?[, x, ?]] end) == "a[,][b][,]c"
411435

412-
assert String.replace("elixir", "", "") == "elixir"
413-
assert String.replace("ELIXIR", "", ".") == ".E.L.I.X.I.R."
414-
assert String.replace("ELIXIR", "", ".", global: true) == ".E.L.I.X.I.R."
415-
assert String.replace("ELIXIR", "", ".", global: false) == ".ELIXIR"
436+
assert String.replace("a,b,c", ",", fn "," -> "-" end, global: false) == "a-b,c"
437+
assert String.replace("a,b,c", [",", "b"], fn x -> "[#{x}]" end, global: false) == "a[,]b,c"
438+
assert String.replace("ãéã", "é", fn "é" -> "e" end, global: false) == "ãeã"
439+
end
440+
441+
test "with regex and function replacement" do
442+
assert String.replace("a,b,c", ~r/,(.)/, fn x -> "#{x}#{x}" end) == "a,b,b,c,c"
443+
assert String.replace("a,b,c", ~r/,(.)/, fn x -> [x, x] end) == "a,b,b,c,c"
444+
assert String.replace("a,b,c", ~r/,(.)/, fn x -> "#{x}#{x}" end, global: false) == "a,b,b,c"
445+
assert String.replace("a,b,c", ~r/,(.)/, fn x -> [x, x] end, global: false) == "a,b,b,c"
446+
end
416447
end
417448

418449
test "duplicate/2" do

0 commit comments

Comments
 (0)