Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions lib/lua/vm/stdlib/pattern.ex
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,16 @@ defmodule Lua.VM.Stdlib.Pattern do
Global match - returns list of all matches as {start, stop, captures}.
"""
def gmatch(subject, pattern) do
{_anchored, pattern_elems} = compile(pattern)
# Lua 5.3 §6.4.3: in gmatch a leading `^` does not work as an anchor
# (it would prevent the iteration). PUC-Lua's gmatch_aux never strips
# it, so `match` sees it as an ordinary character — recompile the
# pattern with the caret kept as a literal.
pattern_elems =
case compile(pattern) do
{false, elems} -> elems
{true, _elems} -> compile_elements(pattern, [])
end

gmatch_from(subject, 0, byte_size(subject), pattern_elems, [], -1)
end

Expand Down Expand Up @@ -121,7 +130,7 @@ defmodule Lua.VM.Stdlib.Pattern do
`gsub_stateful/5` instead.
"""
def gsub(subject, pattern, repl, max_n \\ nil) do
{_anchored, pattern_elems} = compile(pattern)
{anchored, pattern_elems} = compile(pattern)

stateful_repl =
if is_function(repl, 1) do
Expand All @@ -130,8 +139,7 @@ defmodule Lua.VM.Stdlib.Pattern do
repl
end

{result, count, _state} =
gsub_from(subject, 0, byte_size(subject), pattern_elems, stateful_repl, max_n, 0, [], nil, false)
{result, count, _state} = do_gsub(subject, anchored, pattern_elems, stateful_repl, max_n, nil)

{result, count}
end
Expand All @@ -144,8 +152,35 @@ defmodule Lua.VM.Stdlib.Pattern do
changes back out. String and table replacements are state-pass-through.
"""
def gsub_stateful(subject, pattern, repl, state, max_n \\ nil) do
{_anchored, pattern_elems} = compile(pattern)
gsub_from(subject, 0, byte_size(subject), pattern_elems, repl, max_n, 0, [], state, false)
{anchored, pattern_elems} = compile(pattern)
do_gsub(subject, anchored, pattern_elems, repl, max_n, state)
end

# A `^`-anchored pattern matches only at the start of the subject
# (Lua 5.3 §6.4.1), so gsub performs at most one replacement there and
# keeps the remainder untouched — PUC-Lua str_gsub's `anchor` flag makes
# its scan loop run exactly once. An unanchored pattern scans every
# position via gsub_from/10.

defp do_gsub(subject, true, _pattern, _repl, max_n, state) when max_n != nil and max_n <= 0 do
{subject, 0, state}
end

defp do_gsub(subject, true, pattern, repl, _max_n, state) do
case match_pattern(subject, 0, pattern, subject) do
{:match, end_pos, captures} ->
whole_match = binary_part(subject, 0, end_pos)
{replacement, state} = apply_replacement(repl, whole_match, captures, state)
rest = binary_part(subject, end_pos, byte_size(subject) - end_pos)
{IO.iodata_to_binary([replacement, rest]), 1, state}

:nomatch ->
{subject, 0, state}
end
end

defp do_gsub(subject, false, pattern, repl, max_n, state) do
gsub_from(subject, 0, byte_size(subject), pattern, repl, max_n, 0, [], state, false)
end

# Lua 5.3.3+ semantics: an empty match that starts where the *previous*
Expand Down
89 changes: 89 additions & 0 deletions test/lua/vm/stdlib/pattern_anchor_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
defmodule Lua.VM.Stdlib.PatternAnchorTest do
use ExUnit.Case, async: true

# Pins Lua 5.3 §6.4.1 ^-anchor semantics for string.gsub and
# string.gmatch: a pattern beginning with `^` matches only at the start
# of the subject, so gsub performs at most one replacement and reports a
# count of 0 or 1 (mirrors the `anchor` handling in PUC-Lua lstrlib.c
# str_gsub). In gmatch a leading `^` does not anchor — PUC-Lua matches
# it as a literal caret (§6.4.3). Expected values verified against
# PUC-Lua 5.3.6.

alias Lua.VM.Stdlib.Pattern

describe "anchored string.gsub" do
test "replaces only the leading occurrence" do
assert {["Yax", 1], _} = Lua.eval!(~S|return string.gsub("xax", "^x", "Y")|)
end

test "replaces nothing when the subject does not start with a match" do
assert {["aha", 0], _} = Lua.eval!(~S|return string.gsub("aha", "^h", "H")|)
end

test "replaces a leading multi-char run at most once" do
assert {["Xabc", 1], _} = Lua.eval!(~S|return string.gsub("abcabc", "^abc", "X")|)
end

test "empty anchored match replaces once at the start" do
assert {["Xbbb", 1], _} = Lua.eval!(~S|return string.gsub("bbb", "^a*", "X")|)
end

test "leading-whitespace trim preserves interior and trailing whitespace" do
assert {["_a b ", 1], _} = Lua.eval!(~S|return string.gsub(" a b ", "^%s+", "_")|)
end

test "n = 0 suppresses the anchored replacement" do
assert {["xax", 0], _} = Lua.eval!(~S|return string.gsub("xax", "^x", "Y", 0)|)
end

test "captures reach a function replacement exactly once" do
script = ~S"""
local calls = {}
local s, n = string.gsub("abcabc", "^(a)(b)", function(a, b)
calls[#calls + 1] = a .. b
return "<" .. b .. a .. ">"
end)
return s, n, #calls, calls[1]
"""

assert {["<ba>cabc", 1, 1, "ab"], _} = Lua.eval!(script)
end

test "anchored pattern matching the whole subject replaces it" do
assert {["X", 1], _} = Lua.eval!(~S|return string.gsub("abc", "^abc$", "X")|)
end
end

describe "leading caret in string.gmatch" do
test "does not anchor and does not match without a literal caret" do
script = ~S"""
local n = 0
for w in ("aaa"):gmatch("^a") do n = n + 1 end
return n
"""

assert {[0], _} = Lua.eval!(script)
end

test "matches a literal caret like any other character" do
script = ~S"""
local t = {}
for w in ("^a ^a"):gmatch("^a") do t[#t + 1] = w end
return #t, t[1], t[2]
"""

assert {[2, "^a", "^a"], _} = Lua.eval!(script)
end
end

describe "anchored Pattern.gsub/4" do
test "replaces at most once at the start" do
assert {"Yax", 1} = Pattern.gsub("xax", "^x", "Y")
assert {"aha", 0} = Pattern.gsub("aha", "^h", "H")
end

test "honours an explicit max_n of 0" do
assert {"xax", 0} = Pattern.gsub("xax", "^x", "Y", 0)
end
end
end