diff --git a/CHANGELOG.md b/CHANGELOG.md index 5104040..448b894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,16 @@ is in the [`1.0.0-rc.0`](#100-rc0---2026-05-26) entry below. ## [Unreleased] +### Fixed +- Decoding a table now preserves Lua iteration order. `Lua.VM.Value.decode/2` + previously materialized tables through an Erlang map, which reorders integer + keys once a table crosses Erlang's 32-entry flatmap/hashmap threshold — so a + sequence longer than 32 elements decoded as a scrambled, integer-keyed list + of pairs that no longer started at key `1`, and `Lua.Table.deep_cast/1` then + mis-cast it to a map instead of a list. Decoding now walks the table in + `pairs/1` order via the new `Lua.VM.Table.to_list/1`, so sequences of any + size round-trip as ordered, 1-indexed lists. + ## [1.0.2] - 2026-07-28 ### Changed diff --git a/lib/lua/vm/table.ex b/lib/lua/vm/table.ex index 4a2b5bc..a0aceb5 100644 --- a/lib/lua/vm/table.ex +++ b/lib/lua/vm/table.ex @@ -516,8 +516,9 @@ defmodule Lua.VM.Table do @doc """ Materializes the full table contents (array + hash) as a single flat map. - Used by code paths that genuinely need the whole table as a map (decode, - display, `string.gsub` replacement lookups). Walks the array once. + Used by code paths that genuinely need the whole table as a map (display, + `string.gsub` replacement lookups). Walks the array once. Order is not + preserved — for ordering-stable pairs use `to_list/1`. """ @spec to_map(t()) :: map() def to_map(%__MODULE__{arr: :undefined, data: data}), do: data @@ -531,6 +532,30 @@ defmodule Lua.VM.Table do end) end + @doc """ + Materializes the full table contents as a list of `{key, value}` pairs in + Lua iteration order: array keys `1..arr_n` first in index order, then hash + keys in insertion order (identical to a `pairs/1` traversal). + + Unlike `to_map/1`, this preserves iteration order. Decoding through an Erlang + map (as `to_map/1` produces) reorders integer keys once a table crosses + Erlang's 32-entry flatmap/hashmap threshold, so a sequence larger than 32 + would no longer start at key `1`. Callers that need ordering-stable pairs + (`Lua.VM.Value.decode/2`) use this; callers that only need membership + (display, replacement lookups) can keep using `to_map/1`. + """ + @spec to_list(t()) :: [{term(), term()}] + def to_list(%__MODULE__{} = table) do + table |> flush_order() |> collect_entries(nil, []) + end + + defp collect_entries(table, key, acc) do + case next_entry(table, key) do + nil -> Enum.reverse(acc) + {k, v} -> collect_entries(table, k, [{k, v} | acc]) + end + end + @doc """ Returns the next key/value pair in iteration order after `key`. diff --git a/lib/lua/vm/value.ex b/lib/lua/vm/value.ex index cd55225..d2a9814 100644 --- a/lib/lua/vm/value.ex +++ b/lib/lua/vm/value.ex @@ -363,7 +363,7 @@ defmodule Lua.VM.Value do table = Map.fetch!(state.tables, id) ancestors = Map.put(ancestors, id, true) - Enum.map(Lua.VM.Table.to_map(table), fn {k, v} -> {k, decode(v, state, ancestors)} end) + Enum.map(Lua.VM.Table.to_list(table), fn {k, v} -> {k, decode(v, state, ancestors)} end) end end diff --git a/test/lua/table_test.exs b/test/lua/table_test.exs index 5118edc..9d85d69 100644 --- a/test/lua/table_test.exs +++ b/test/lua/table_test.exs @@ -87,4 +87,22 @@ defmodule Lua.TableTest do assert assert_table(table) == "{a = 1, b = {c = 3}}" end end + + describe "deep_cast over decoded sequences" do + test "a sequence larger than 32 entries casts to an ordered list, not a map" do + # End-to-end guard for the real-world symptom: a Lua sequence longer than + # 32 elements used to decode as a scrambled integer-keyed map (Erlang's + # flatmap->hashmap switch reordered the pairs so they no longer started at + # key 1), and deep_cast/1 - which detects a list by its leading {1, _} + # pair - then mis-cast it to a map. Ordered decoding keeps it a list. + {[decoded], _lua} = + Lua.eval!(~LUA""" + local t = {} + for i = 1, 41 do t[i] = i * 10 end + return t + """) + + assert Lua.Table.deep_cast(decoded) == Enum.map(1..41, fn i -> i * 10 end) + end + end end diff --git a/test/lua/vm/table_iteration_test.exs b/test/lua/vm/table_iteration_test.exs index 86a3c6d..c37e6f3 100644 --- a/test/lua/vm/table_iteration_test.exs +++ b/test/lua/vm/table_iteration_test.exs @@ -305,6 +305,33 @@ defmodule Lua.VM.TableIterationTest do end end + describe "to_list/1" do + test "returns every live pair in iteration order (equals a pairs walk)" do + table = + %Table{} + |> Table.put(1, "one") + |> Table.put("x", "ex") + |> Table.put(2, "two") + |> Table.put("y", "why") + + assert Table.to_list(table) == walk(Table.flush_order(table)) + assert Table.to_list(table) == [{1, "one"}, {2, "two"}, {"x", "ex"}, {"y", "why"}] + end + + test "keeps a dense sequence larger than 32 entries in index order" do + # A >32 sequence is where a map-based materialization reorders integer + # keys (Erlang's flatmap->hashmap switch). to_list/1 must stay 1-indexed + # and ordered so decoded sequences remain proper lists. + table = Enum.reduce(1..41, %Table{}, fn i, acc -> Table.put(acc, i, i * 10) end) + + assert Table.to_list(table) == Enum.map(1..41, fn i -> {i, i * 10} end) + end + + test "empty table returns an empty list" do + assert Table.to_list(%Table{}) == [] + end + end + describe "iteration properties (StreamData)" do property "a full walk visits exactly the live key set, once each, with matching values" do check all(ops <- entries_gen()) do diff --git a/test/lua/vm/value_test.exs b/test/lua/vm/value_test.exs index 35da188..39a54cb 100644 --- a/test/lua/vm/value_test.exs +++ b/test/lua/vm/value_test.exs @@ -198,6 +198,18 @@ defmodule Lua.VM.ValueTest do result = Value.decode(tref, state) assert Enum.sort(result) == [{1, "a"}, {2, "b"}, {3, "c"}] end + + test "decodes a dense sequence larger than 32 entries in sequence order" do + # A dense integer sequence must decode as an ordered, 1-indexed list of + # pairs regardless of size. Materializing through an Erlang map reorders + # integer keys once a table crosses the 32-entry flatmap->hashmap + # threshold, so a >32 sequence would otherwise come back scrambled and no + # longer start at key 1. + {[decoded], _lua} = + Lua.eval!(Lua.new(), "t = {}\nfor i = 1, 41 do t[i] = i * 10 end\nreturn t") + + assert decoded == Enum.map(1..41, fn i -> {i, i * 10} end) + end end describe "decode/2 functions" do