From ed10935367248c2fcee0fedfe1beb3cf0a573f09 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 07:32:21 +0000 Subject: [PATCH 1/5] fix(pattern): honour ^ anchor in gsub and literal caret in gmatch Pattern.compile/1 returns an {anchored, elements} tuple, but only find/3 used the flag - gsub/4, gsub_stateful/5, and gmatch/2 discarded it. Because compile strips the leading ^, those entry points matched the remaining pattern at every position, so string.gsub("xax", "^x", "Y") returned "YaY", 2 instead of "Yax", 1, silently corrupting the common leading-trim idiom s:gsub("^%s+", ""). Route gsub and gsub_stateful through an anchored branch that tries the match only at position 0 and replaces at most once, mirroring PUC-Lua str_gsub's anchor flag. An n of 0 still suppresses the replacement, and an empty anchored match (e.g. ^a* on "bbb") replaces once at the start. gmatch needed the opposite treatment: per Lua 5.3 (and PUC-Lua's gmatch_aux, which never strips the caret), a leading ^ in gmatch is not an anchor but an ordinary literal character - so recompile the pattern keeping the caret literal instead of dropping it. Expected values in the regression tests are verified against PUC-Lua 5.3.6. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01F3LBtzmXgAMHA8QiGPa5Ek --- lib/lua/vm/stdlib/pattern.ex | 47 ++++++++++-- test/lua/vm/stdlib/pattern_anchor_test.exs | 89 ++++++++++++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 test/lua/vm/stdlib/pattern_anchor_test.exs diff --git a/lib/lua/vm/stdlib/pattern.ex b/lib/lua/vm/stdlib/pattern.ex index 953dc13f..42645708 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 00000000..fca7004f --- /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 From 9ea2616f9bb901b4389059fbe74bf10eb54f2253 Mon Sep 17 00:00:00 2001 From: Federico Meini Date: Mon, 27 Jul 2026 10:50:36 +0200 Subject: [PATCH 2/5] fix: bound cyclic table walks at the eval boundary The standard Lua OOP idiom (T.__index = T) creates tables that contain themselves. Both eval-boundary walks recursed into them forever, growing memory without bound: Value.decode in decode: true mode and Display.peek_table in decode: false mode. The walk now terminates at the point of recurrence: decode leaves the table's {:tref, id} reference there, mirroring how functions already pass through as opaque references, and Display renders a :circular peek. Shared non-cyclic references decode in full. --- lib/lua/vm/display.ex | 40 +++++++++---- lib/lua/vm/display/table.ex | 15 ++++- lib/lua/vm/value.ex | 35 +++++++---- test/lua/vm/cyclic_table_test.exs | 97 +++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 24 deletions(-) create mode 100644 test/lua/vm/cyclic_table_test.exs diff --git a/lib/lua/vm/display.ex b/lib/lua/vm/display.ex index 69595e79..27876aed 100644 --- a/lib/lua/vm/display.ex +++ b/lib/lua/vm/display.ex @@ -87,41 +87,54 @@ defmodule Lua.VM.Display do Wraps a single eval-result value for display. See `wrap_results/3` for the decode-mode matrix. + + Tables that (transitively) contain themselves get a `:circular` + peek at the point of recurrence rather than recursing forever — + see `Lua.VM.Display.Table`. """ @spec wrap_value(term(), State.t(), boolean()) :: term() - def wrap_value(value, state, decode?) + def wrap_value(value, state, decode?), do: wrap_value(value, state, decode?, MapSet.new()) # decode: true — only wrap closures/native; tables and userdata # have already been decoded and are passed through unchanged. - def wrap_value({:lua_closure, _, _} = ref, _state, _decode?) do + defp wrap_value({:lua_closure, _, _} = ref, _state, _decode?, _ancestors) do wrap_closure(ref) end - def wrap_value({:compiled_closure, _, _} = ref, _state, _decode?) do + defp wrap_value({:compiled_closure, _, _} = ref, _state, _decode?, _ancestors) do wrap_closure(ref) end - def wrap_value({:native_func, fun} = ref, _state, _decode?) do + defp wrap_value({:native_func, fun} = ref, _state, _decode?, _ancestors) do %NativeFunc{fun: fun, ref: ref} end # decode: false — wrap tref/udref too, and recurse into table peek. - def wrap_value({:tref, id} = ref, state, false) do - peek = peek_table(state, id, false) + # `ancestors` holds the tref ids currently being peeked higher up + # this walk; revisiting one means the table contains itself. + defp wrap_value({:tref, id} = ref, state, false, ancestors) do + peek = + if MapSet.member?(ancestors, id) do + :circular + else + peek_table(state, id, false, MapSet.put(ancestors, id)) + end + %DTable{id: id, peek: peek, ref: ref} end - def wrap_value({:udref, id} = ref, state, false) do + defp wrap_value({:udref, id} = ref, state, false, _ancestors) do term = State.get_userdata(state, ref) %Userdata{id: id, term: term, ref: ref} end # decode: true catch-all (already-decoded values pass through) - def wrap_value(value, _state, _decode?), do: value + defp wrap_value(value, _state, _decode?, _ancestors), do: value # ---- internal helpers ---- - defp wrap_closure({tag, proto, _upvalues} = ref) when tag in [:lua_closure, :compiled_closure] do + defp wrap_closure({tag, proto, _upvalues} = ref) + when tag in [:lua_closure, :compiled_closure] do {first_line, _last_line} = proto.lines || {0, 0} %Closure{ @@ -138,15 +151,18 @@ defmodule Lua.VM.Display do # (1..N keys) render as a list; mixed-key tables render as a map. # Nested tables/closures are recursively wrapped so `Inspect` does # not have to know about live VM state. - defp peek_table(state, id, decode?) do + defp peek_table(state, id, decode?, ancestors) do case Map.fetch(state.tables, id) do {:ok, table} -> data = Lua.VM.Table.to_map(table) if sequence_like?(data) do - Enum.map(1..map_size(data), &wrap_value(Map.fetch!(data, &1), state, decode?)) + Enum.map( + 1..map_size(data), + &wrap_value(Map.fetch!(data, &1), state, decode?, ancestors) + ) else - Map.new(data, fn {k, v} -> {k, wrap_value(v, state, decode?)} end) + Map.new(data, fn {k, v} -> {k, wrap_value(v, state, decode?, ancestors)} end) end :error -> diff --git a/lib/lua/vm/display/table.ex b/lib/lua/vm/display/table.ex index 56732034..3ab6ecab 100644 --- a/lib/lua/vm/display/table.ex +++ b/lib/lua/vm/display/table.ex @@ -15,7 +15,10 @@ defmodule Lua.VM.Display.Table do - `:peek` — a snapshot of the table's data as it was at the time the eval boundary was crossed, suitable for human display. May be a list (sequence-like tables) or a map (mixed-key tables). - Truncated to `Inspect.Opts.limit` entries when rendered. + Truncated to `Inspect.Opts.limit` entries when rendered. When a + table (transitively) contains itself — e.g. the `T.__index = T` + OOP idiom — the recurring occurrence carries `:circular` instead + of a snapshot, bounding an otherwise infinite walk. - `:ref` — the original `{:tref, id}` tuple so callers can round-trip the value back into the VM (via `Lua.set!/3`, `Lua.encode!/2`, etc.). @@ -25,7 +28,7 @@ defmodule Lua.VM.Display.Table do @type t :: %__MODULE__{ id: non_neg_integer(), - peek: list() | map(), + peek: list() | map() | :circular, ref: tuple() } @@ -34,6 +37,14 @@ defmodule Lua.VM.Display.Table do defimpl Inspect do import Inspect.Algebra + def inspect(%Lua.VM.Display.Table{id: id, peek: :circular}, _opts) do + concat([ + "#Lua.Table" + ]) + end + def inspect(%Lua.VM.Display.Table{id: id, peek: peek}, opts) do concat([ "#Lua.Table {k, decode(v, state)} end) + Enum.map(Lua.VM.Table.to_map(table), fn {k, v} -> {k, decode(v, state, ancestors)} end) + end end - def decode(value, _state), do: value + defp decode(value, _state, _ancestors), do: value @doc """ Decodes a list of Lua VM values. diff --git a/test/lua/vm/cyclic_table_test.exs b/test/lua/vm/cyclic_table_test.exs new file mode 100644 index 00000000..37b192c6 --- /dev/null +++ b/test/lua/vm/cyclic_table_test.exs @@ -0,0 +1,97 @@ +defmodule Lua.VM.CyclicTableTest do + @moduledoc """ + Cyclic tables crossing the eval boundary must terminate. + + The common Lua OOP idiom `T.__index = T` creates a table that + contains itself. Both boundary walks — `decode: true` (Value.decode) + and `decode: false` (Display peek) — previously recursed forever on + such values, growing memory without bound until the VM's + `max_heap_size` (when set) killed the process. + """ + + use ExUnit.Case, async: true + + alias Lua.VM.Display.Table, as: DTable + + @self_cycle """ + local T = {} + T.__index = T + return T + """ + + @mutual_cycle """ + local a = {} + local b = {a = a} + a.b = b + return a + """ + + describe "decode: false (Display peek)" do + test "self-referential table peeks as :circular at the recurrence" do + {[t], _} = Lua.eval!(Lua.new(), @self_cycle, decode: false) + + assert %DTable{id: id, peek: %{"__index" => inner}} = t + assert %DTable{id: ^id, peek: :circular} = inner + assert inspect(inner) == "#Lua.Table" + end + + test "mutually recursive tables terminate and render" do + {[t], _} = Lua.eval!(Lua.new(), @mutual_cycle, decode: false) + + assert %DTable{id: a_id, peek: %{"b" => %DTable{peek: %{"a" => inner_a}}}} = t + assert %DTable{id: ^a_id, peek: :circular} = inner_a + assert inspect(t) =~ "circular" + end + + test "shared non-cyclic references still peek fully" do + code = """ + local shared = {x = 1} + return {a = shared, b = shared} + """ + + {[t], _} = Lua.eval!(Lua.new(), code, decode: false) + + assert %DTable{ + peek: %{"a" => %DTable{peek: %{"x" => 1}}, "b" => %DTable{peek: %{"x" => 1}}} + } = + t + end + end + + describe "decode: true (Value.decode)" do + test "self-referential table terminates with the table's reference at the recurrence" do + {[decoded], _} = Lua.eval!(Lua.new(), @self_cycle) + + assert [{"__index", {:tref, id}}] = decoded + assert is_integer(id) + end + + test "mutually recursive tables terminate with a reference" do + {[decoded], _} = Lua.eval!(Lua.new(), @mutual_cycle) + + assert [{"b", [{"a", {:tref, _}}]}] = decoded + end + + test "shared non-cyclic references decode normally" do + code = """ + local shared = {x = 1} + return {a = shared, b = shared} + """ + + {[decoded], _} = Lua.eval!(Lua.new(), code) + + assert Enum.sort(decoded) == [{"a", [{"x", 1}]}, {"b", [{"x", 1}]}] + end + + test "a table appearing under multiple sibling keys is not a false-positive cycle" do + code = """ + local leaf = {v = 1} + local mid = {l = leaf, r = leaf} + return {left = mid, right = mid} + """ + + {[decoded], _} = Lua.eval!(Lua.new(), code) + assert is_list(decoded) + end + end +end From 8c6f4323150afb43b602ba28530e71457ab9ff2d Mon Sep 17 00:00:00 2001 From: Federico Meini Date: Mon, 27 Jul 2026 14:34:49 +0200 Subject: [PATCH 3/5] dialyzer: track the ancestor set as a plain map OTP 28's opacity checker false-positives on the MapSet the walkers capture in their entry closures (call_without_opaque on every MapSet call), failing CI. Specs on the private clauses don't appease it. A plain map with the ids as keys is the same structure MapSet wraps, so behavior is identical and there is no opaque type left to police. --- lib/lua/vm/display.ex | 9 ++++----- lib/lua/vm/value.ex | 9 ++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/lua/vm/display.ex b/lib/lua/vm/display.ex index 27876aed..f190a9af 100644 --- a/lib/lua/vm/display.ex +++ b/lib/lua/vm/display.ex @@ -93,7 +93,7 @@ defmodule Lua.VM.Display do see `Lua.VM.Display.Table`. """ @spec wrap_value(term(), State.t(), boolean()) :: term() - def wrap_value(value, state, decode?), do: wrap_value(value, state, decode?, MapSet.new()) + def wrap_value(value, state, decode?), do: wrap_value(value, state, decode?, %{}) # decode: true — only wrap closures/native; tables and userdata # have already been decoded and are passed through unchanged. @@ -114,10 +114,10 @@ defmodule Lua.VM.Display do # this walk; revisiting one means the table contains itself. defp wrap_value({:tref, id} = ref, state, false, ancestors) do peek = - if MapSet.member?(ancestors, id) do + if Map.has_key?(ancestors, id) do :circular else - peek_table(state, id, false, MapSet.put(ancestors, id)) + peek_table(state, id, false, Map.put(ancestors, id, true)) end %DTable{id: id, peek: peek, ref: ref} @@ -133,8 +133,7 @@ defmodule Lua.VM.Display do # ---- internal helpers ---- - defp wrap_closure({tag, proto, _upvalues} = ref) - when tag in [:lua_closure, :compiled_closure] do + defp wrap_closure({tag, proto, _upvalues} = ref) when tag in [:lua_closure, :compiled_closure] do {first_line, _last_line} = proto.lines || {0, 0} %Closure{ diff --git a/lib/lua/vm/value.ex b/lib/lua/vm/value.ex index 3cdfadb0..ef919cd4 100644 --- a/lib/lua/vm/value.ex +++ b/lib/lua/vm/value.ex @@ -235,8 +235,7 @@ defmodule Lua.VM.Value do def encode(value, state, _fun_wrapper) when is_binary(value), do: {value, state} def encode(value, state, _fun_wrapper) when is_atom(value), do: {Atom.to_string(value), state} - def encode(fun, state, fun_wrapper) when is_function(fun, 1) or is_function(fun, 2), - do: {fun_wrapper.(fun), state} + def encode(fun, state, fun_wrapper) when is_function(fun, 1) or is_function(fun, 2), do: {fun_wrapper.(fun), state} def encode({:userdata, value}, state, _fun_wrapper) do State.alloc_userdata(state, value) @@ -333,7 +332,7 @@ defmodule Lua.VM.Value do normally. """ @spec decode(term(), State.t()) :: term() - def decode(value, state), do: decode(value, state, MapSet.new()) + def decode(value, state), do: decode(value, state, %{}) defp decode(nil, _state, _ancestors), do: nil defp decode(value, _state, _ancestors) when is_boolean(value), do: value @@ -346,11 +345,11 @@ defmodule Lua.VM.Value do end defp decode({:tref, id} = ref, state, ancestors) do - if MapSet.member?(ancestors, id) do + if Map.has_key?(ancestors, id) do ref else table = Map.fetch!(state.tables, id) - ancestors = MapSet.put(ancestors, id) + ancestors = Map.put(ancestors, id, true) Enum.map(Lua.VM.Table.to_map(table), fn {k, v} -> {k, decode(v, state, ancestors)} end) end From bfab1ac1902e7a2e4d824d19da3c8204c53d6e64 Mon Sep 17 00:00:00 2001 From: Simon de Haan Date: Tue, 28 Jul 2026 10:13:04 +0200 Subject: [PATCH 4/5] fix(parser): allow comments between a bare return and its terminator A valueless `return` followed by a comment before the block terminator (`elseif`/`else`/`end`/`until`) failed to parse with "Expected expression". `parse_return/1` peeked at the raw next token to detect an empty return; a comment token is not a terminator, so it fell through to `parse_expr_list` and choked on the comment. Comments are whitespace in Lua, so peek past them (`skip_comments/1`) when deciding whether the return is bare. The comment tokens are left in the stream so the block parser still collects them as orphaned/trailing comments. Real Lua 5.3 and Luerl both accept this; the idiom shows up in `if ... then return -- note \n elseif ...` style branches. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/lua/parser.ex | 8 ++++++-- test/lua/parser/statement_test.exs | 33 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/lib/lua/parser.ex b/lib/lua/parser.ex index 4c43efef..38abe314 100644 --- a/lib/lua/parser.ex +++ b/lib/lua/parser.ex @@ -249,7 +249,11 @@ defmodule Lua.Parser do # Placeholder implementations for statements (Phase 3) defp parse_return([{:keyword, :return, pos} | rest]) do - case peek(rest) do + # Comments are whitespace between the `return` keyword and the block + # terminator, so look past them when deciding whether this is a bare + # (valueless) return. For terminator/EOF the comment tokens stay in `rest` + # for the block parser to collect as orphaned/trailing comments. + case peek(skip_comments(rest)) do # End of block or statement {:keyword, terminator, _} when terminator in [:end, :else, :elseif, :until] -> {:ok, %Statement.Return{values: [], meta: Meta.new(pos)}, rest} @@ -258,7 +262,7 @@ defmodule Lua.Parser do {:ok, %Statement.Return{values: [], meta: Meta.new(pos)}, rest} {:delimiter, :semicolon, _} -> - {_, rest2} = consume(rest) + {_, rest2} = consume(skip_comments(rest)) {:ok, %Statement.Return{values: [], meta: Meta.new(pos)}, rest2} _ -> diff --git a/test/lua/parser/statement_test.exs b/test/lua/parser/statement_test.exs index d59bae1b..f3dd5ead 100644 --- a/test/lua/parser/statement_test.exs +++ b/test/lua/parser/statement_test.exs @@ -201,6 +201,39 @@ defmodule Lua.Parser.StatementTest do } = chunk end + test "parses a bare return followed by a comment before elseif/else" do + # A comment sits between an empty `return` and the block terminator. + # Comments are whitespace to Lua, so the return is still empty and the + # branch continues normally. Regression: the parser used to treat the + # comment token as the start of a return expression and fail with + # "Expected expression". + assert {:ok, chunk} = + Parser.parse(""" + if x > 0 then + return + -- leading comment on elseif + elseif x < 0 then + return + -- leading comment on else + else + return + -- trailing comment before end + end + """) + + assert %{ + block: %{ + stmts: [ + %Statement.If{ + then_block: %{stmts: [%Statement.Return{values: []}]}, + elseifs: [{%Expr.BinOp{op: :lt}, %{stmts: [%Statement.Return{values: []}]}}], + else_block: %{stmts: [%Statement.Return{values: []}]} + } + ] + } + } = chunk + end + test "parses if with elseif" do assert {:ok, chunk} = Parser.parse(""" From af42f36d6e16c1ac8e028f19d030ae838862c0d3 Mon Sep 17 00:00:00 2001 From: benonymus Date: Thu, 17 Sep 2026 09:57:05 +0700 Subject: [PATCH 5/5] os_time match lua --- CHANGELOG.md | 5 +++ lib/lua/vm/stdlib/os.ex | 12 +++++-- test/lua/vm/stdlib/os_test.exs | 61 ++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5104040c..e018836b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,11 @@ is in the [`1.0.0-rc.0`](#100-rc0---2026-05-26) entry below. ## [Unreleased] +### Fixed +- `os.time` normalises out-of-range date-table fields the way C `mktime` does + (e.g. `day = 0` is the last day of the previous month, `month = 13` is + January of the next year) instead of raising a `MatchError`. + ## [1.0.2] - 2026-07-28 ### Changed diff --git a/lib/lua/vm/stdlib/os.ex b/lib/lua/vm/stdlib/os.ex index 3eea7a16..0e8a5d31 100644 --- a/lib/lua/vm/stdlib/os.ex +++ b/lib/lua/vm/stdlib/os.ex @@ -214,9 +214,17 @@ defmodule Lua.VM.Stdlib.Os do end end + # Lua 5.3 §6.9: date-table fields need not be within their valid ranges — + # C mktime normalises them (day = 0 is the last day of the previous month, + # month = 13 is January of the next year, hour = 25 rolls into the next day). + # Fold the month into the year, anchor on the 1st of that month, then add the + # remaining fields as a signed offset in seconds. defp naive_to_epoch(%{year: y, month: mo, day: d, hour: h, minute: mi, second: s}) do - {:ok, naive} = NaiveDateTime.new(y, mo, d, h, mi, s) - naive |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix(:second) + months = y * 12 + (mo - 1) + first_of_month = Date.new!(Integer.floor_div(months, 12), Integer.mod(months, 12) + 1, 1) + base = first_of_month |> DateTime.new!(~T[00:00:00], "Etc/UTC") |> DateTime.to_unix(:second) + + base + (d - 1) * 86_400 + h * 3_600 + mi * 60 + s end defp strip_utc_flag("!" <> rest), do: {rest, true} diff --git a/test/lua/vm/stdlib/os_test.exs b/test/lua/vm/stdlib/os_test.exs index 56a46410..25d07a9d 100644 --- a/test/lua/vm/stdlib/os_test.exs +++ b/test/lua/vm/stdlib/os_test.exs @@ -81,4 +81,65 @@ defmodule Lua.VM.Stdlib.OsTest do end end end + + # Lua 5.3 §6.9: date-table fields passed to os.time need not be within their + # valid ranges; they are normalised the way C mktime does. + describe "os.time field normalization" do + test "day = 0 is the last day of the previous month" do + assert normalize("year=2026, month=9, day=0") == {2026, 8, 31, 12, 0, 0} + assert normalize("year=2026, month=3, day=0") == {2026, 2, 28, 12, 0, 0} + assert normalize("year=2028, month=3, day=0") == {2028, 2, 29, 12, 0, 0} + end + + test "day = 0 of month 1 rolls back into December of the previous year" do + assert normalize("year=2026, month=1, day=0") == {2025, 12, 31, 12, 0, 0} + end + + test "month past December rolls into the next year" do + assert normalize("year=2026, month=13, day=1") == {2027, 1, 1, 12, 0, 0} + assert normalize("year=2026, month=12 + 1, day=0") == {2026, 12, 31, 12, 0, 0} + assert normalize("year=2026, month=14, day=1") == {2027, 2, 1, 12, 0, 0} + assert normalize("year=2026, month=24, day=1") == {2027, 12, 1, 12, 0, 0} + assert normalize("year=2026, month=25, day=0") == {2027, 12, 31, 12, 0, 0} + assert normalize("year=2026, month=25, day=1") == {2028, 1, 1, 12, 0, 0} + assert normalize("year=2026, month=100, day=1") == {2034, 4, 1, 12, 0, 0} + end + + test "month below January rolls into the previous year" do + assert normalize("year=2026, month=0, day=1") == {2025, 12, 1, 12, 0, 0} + assert normalize("year=2026, month=-1, day=1") == {2025, 11, 1, 12, 0, 0} + assert normalize("year=2026, month=-13, day=1") == {2024, 11, 1, 12, 0, 0} + end + + test "day past the end of the month overflows into the next month" do + assert normalize("year=2026, month=2, day=30") == {2026, 3, 2, 12, 0, 0} + assert normalize("year=2028, month=2, day=30") == {2028, 3, 1, 12, 0, 0} + assert normalize("year=2026, month=1, day=32") == {2026, 2, 1, 12, 0, 0} + assert normalize("year=2026, month=1, day=400") == {2027, 2, 4, 12, 0, 0} + end + + test "negative day counts back across months and years" do + assert normalize("year=2026, month=1, day=-40") == {2025, 11, 21, 12, 0, 0} + end + + test "time fields overflow and underflow across days" do + assert normalize("year=2026, month=12, day=31, hour=25") == {2027, 1, 1, 1, 0, 0} + assert normalize("year=2026, month=1, day=1, hour=0, min=0, sec=-10") == {2025, 12, 31, 23, 59, 50} + assert normalize("year=2026, month=1, day=1, hour=0, min=90") == {2026, 1, 1, 1, 30, 0} + end + + test "in-range fields are unchanged" do + assert normalize("year=2000, month=1, day=1, hour=0, min=0, sec=0") == {2000, 1, 1, 0, 0, 0} + end + end + + defp normalize(fields) do + code = """ + local t = os.date("!*t", os.time({#{fields}})) + return t.year, t.month, t.day, t.hour, t.min, t.sec + """ + + {[year, month, day, hour, min, sec], _} = Lua.eval!(code) + {year, month, day, hour, min, sec} + end end