Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 27 additions & 2 deletions lib/lua/vm/table.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion lib/lua/vm/value.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions test/lua/table_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 27 additions & 0 deletions test/lua/vm/table_iteration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions test/lua/vm/value_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down