diff --git a/lib/lua/vm/stdlib/pattern.ex b/lib/lua/vm/stdlib/pattern.ex index 953dc13..4264570 100644 --- a/lib/lua/vm/stdlib/pattern.ex +++ b/lib/lua/vm/stdlib/pattern.ex @@ -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 @@ -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 @@ -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 @@ -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* diff --git a/test/lua/vm/stdlib/pattern_anchor_test.exs b/test/lua/vm/stdlib/pattern_anchor_test.exs new file mode 100644 index 0000000..fca7004 --- /dev/null +++ b/test/lua/vm/stdlib/pattern_anchor_test.exs @@ -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 {["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