diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index bebc8d4..d0c1227 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -76,11 +76,29 @@ defmodule Lua.VM.Executor do value_type: nil end - def call_function(other, _args, _state) do - raise TypeError, - value: "attempt to call a #{Value.type_name(other)} value", - error_kind: :call_non_function, - value_type: value_type(other) + def call_function(other, args, state) do + # Check for __call metamethod + case get_metatable(other, state) do + nil -> + raise TypeError, + value: "attempt to call a #{Value.type_name(other)} value", + error_kind: :call_non_function, + value_type: value_type(other) + + {:tref, mt_id} -> + mt = Map.fetch!(state.tables, mt_id) + + case Map.get(mt.data, "__call") do + nil -> + raise TypeError, + value: "attempt to call a #{Value.type_name(other)} value", + error_kind: :call_non_function, + value_type: value_type(other) + + call_mm -> + call_function(call_mm, [other | args], state) + end + end end # Break instruction - signal to exit loop @@ -513,13 +531,34 @@ defmodule Lua.VM.Executor do value_type: nil other -> - raise TypeError, - value: "attempt to call a #{Value.type_name(other)} value", - source: proto.source, - call_stack: state.call_stack, - line: Map.get(state, :current_line), - error_kind: :call_non_function, - value_type: value_type(other) + # Check for __call metamethod + case get_metatable(other, state) do + nil -> + raise TypeError, + value: "attempt to call a #{Value.type_name(other)} value", + source: proto.source, + call_stack: state.call_stack, + line: Map.get(state, :current_line), + error_kind: :call_non_function, + value_type: value_type(other) + + {:tref, mt_id} -> + mt = Map.fetch!(state.tables, mt_id) + + case Map.get(mt.data, "__call") do + nil -> + raise TypeError, + value: "attempt to call a #{Value.type_name(other)} value", + source: proto.source, + call_stack: state.call_stack, + line: Map.get(state, :current_line), + error_kind: :call_non_function, + value_type: value_type(other) + + call_mm -> + call_function(call_mm, [other | args], state) + end + end end # result_count == -1 means "return all results" (used in return f() position) @@ -831,38 +870,10 @@ defmodule Lua.VM.Executor do # get_table — R[dest] = table[R[key_reg]] defp do_execute([{:get_table, dest, table_reg, key_reg} | rest], regs, upvalues, proto, state) do - {:tref, id} = elem(regs, table_reg) + table_val = elem(regs, table_reg) key = elem(regs, key_reg) - table = Map.fetch!(state.tables, id) - - value = - case Map.get(table.data, key) do - nil -> - # Key not found, check for __index metamethod - case table.metatable do - nil -> - nil - {:tref, mt_id} -> - mt = Map.fetch!(state.tables, mt_id) - - case Map.get(mt.data, "__index") do - nil -> - nil - - {:tref, _} = index_table -> - index_table_data = State.get_table(state, index_table).data - Map.get(index_table_data, key) - - _other -> - # __index can also be a function, but we don't support that yet - nil - end - end - - v -> - v - end + {value, state} = index_value(table_val, key, state) regs = put_elem(regs, dest, value) do_execute(rest, regs, upvalues, proto, state) @@ -870,88 +881,19 @@ defmodule Lua.VM.Executor do # set_table — table[R[key_reg]] = R[value_reg] defp do_execute([{:set_table, table_reg, key_reg, value_reg} | rest], regs, upvalues, proto, state) do - {:tref, id} = elem(regs, table_reg) + {:tref, _} = elem(regs, table_reg) key = elem(regs, key_reg) value = elem(regs, value_reg) - table = Map.fetch!(state.tables, id) - - state = - if Map.has_key?(table.data, key) do - # Key exists, update directly - State.update_table(state, {:tref, id}, fn table -> - %{table | data: Map.put(table.data, key, value)} - end) - else - # Key doesn't exist, check for __newindex metamethod - case table.metatable do - nil -> - # No metatable, just set the value - State.update_table(state, {:tref, id}, fn table -> - %{table | data: Map.put(table.data, key, value)} - end) - - {:tref, mt_id} -> - mt = Map.fetch!(state.tables, mt_id) - - case Map.get(mt.data, "__newindex") do - nil -> - # No __newindex, just set the value - State.update_table(state, {:tref, id}, fn table -> - %{table | data: Map.put(table.data, key, value)} - end) - - {:tref, _} = newindex_table -> - # __newindex is a table, set the value in that table - State.update_table(state, newindex_table, fn table -> - %{table | data: Map.put(table.data, key, value)} - end) - - _other -> - # __newindex can also be a function, but we don't support that yet - # For now, just set the value directly - State.update_table(state, {:tref, id}, fn table -> - %{table | data: Map.put(table.data, key, value)} - end) - end - end - end + state = table_newindex(elem(regs, table_reg), key, value, state) do_execute(rest, regs, upvalues, proto, state) end # get_field — R[dest] = table[name] (string key literal) defp do_execute([{:get_field, dest, table_reg, name} | rest], regs, upvalues, proto, state) do - {:tref, id} = elem(regs, table_reg) - table = Map.fetch!(state.tables, id) + table_val = elem(regs, table_reg) - value = - case Map.get(table.data, name) do - nil -> - # Key not found, check for __index metamethod - case table.metatable do - nil -> - nil - - {:tref, mt_id} -> - mt = Map.fetch!(state.tables, mt_id) - - case Map.get(mt.data, "__index") do - nil -> - nil - - {:tref, _} = index_table -> - index_table_data = State.get_table(state, index_table).data - Map.get(index_table_data, name) - - _other -> - # __index can also be a function, but we don't support that yet - nil - end - end - - v -> - v - end + {value, state} = index_value(table_val, name, state) regs = put_elem(regs, dest, value) do_execute(rest, regs, upvalues, proto, state) @@ -959,51 +901,10 @@ defmodule Lua.VM.Executor do # set_field — table[name] = R[value_reg] defp do_execute([{:set_field, table_reg, name, value_reg} | rest], regs, upvalues, proto, state) do - {:tref, id} = elem(regs, table_reg) + {:tref, _} = elem(regs, table_reg) value = elem(regs, value_reg) - table = Map.fetch!(state.tables, id) - - state = - if Map.has_key?(table.data, name) do - # Key exists, update directly - State.update_table(state, {:tref, id}, fn table -> - %{table | data: Map.put(table.data, name, value)} - end) - else - # Key doesn't exist, check for __newindex metamethod - case table.metatable do - nil -> - # No metatable, just set the value - State.update_table(state, {:tref, id}, fn table -> - %{table | data: Map.put(table.data, name, value)} - end) - - {:tref, mt_id} -> - mt = Map.fetch!(state.tables, mt_id) - - case Map.get(mt.data, "__newindex") do - nil -> - # No __newindex, just set the value - State.update_table(state, {:tref, id}, fn table -> - %{table | data: Map.put(table.data, name, value)} - end) - - {:tref, _} = newindex_table -> - # __newindex is a table, set the value in that table - State.update_table(state, newindex_table, fn table -> - %{table | data: Map.put(table.data, name, value)} - end) - - _other -> - # __newindex can also be a function, but we don't support that yet - # For now, just set the value directly - State.update_table(state, {:tref, id}, fn table -> - %{table | data: Map.put(table.data, name, value)} - end) - end - end - end + state = table_newindex(elem(regs, table_reg), name, value, state) do_execute(rest, regs, upvalues, proto, state) end @@ -1060,9 +961,8 @@ defmodule Lua.VM.Executor do # self — R[base+1] = R[obj_reg], R[base] = R[obj_reg]["method"] defp do_execute([{:self, base, obj_reg, method_name} | rest], regs, upvalues, proto, state) do obj = elem(regs, obj_reg) - {:tref, id} = obj - table = Map.fetch!(state.tables, id) - func = Map.get(table.data, method_name) + {func, state} = index_value(obj, method_name, state) + regs = put_elem(regs, base + 1, obj) regs = put_elem(regs, base, func) do_execute(rest, regs, upvalues, proto, state) @@ -1147,14 +1047,35 @@ defmodule Lua.VM.Executor do value_type: nil end - defp call_value(other, _args, proto, state) do - raise TypeError, - value: "attempt to call a #{Value.type_name(other)} value", - source: proto.source, - call_stack: state.call_stack, - line: Map.get(state, :current_line), - error_kind: :call_non_function, - value_type: value_type(other) + defp call_value(other, args, proto, state) do + # Check for __call metamethod + case get_metatable(other, state) do + nil -> + raise TypeError, + value: "attempt to call a #{Value.type_name(other)} value", + source: proto.source, + call_stack: state.call_stack, + line: Map.get(state, :current_line), + error_kind: :call_non_function, + value_type: value_type(other) + + {:tref, mt_id} -> + mt = Map.fetch!(state.tables, mt_id) + + case Map.get(mt.data, "__call") do + nil -> + raise TypeError, + value: "attempt to call a #{Value.type_name(other)} value", + source: proto.source, + call_stack: state.call_stack, + line: Map.get(state, :current_line), + error_kind: :call_non_function, + value_type: value_type(other) + + call_mm -> + call_value(call_mm, [other | args], proto, state) + end + end end # Coerce a value to a string for concatenation (Lua semantics: numbers become strings) @@ -1170,13 +1091,137 @@ defmodule Lua.VM.Executor do end # Metamethod support + + # Depth limit for metamethod chains (prevents infinite loops) + @metamethod_chain_limit 200 + defp get_metatable({:tref, id}, state) do table = Map.fetch!(state.tables, id) table.metatable end + defp get_metatable(value, state) when is_binary(value) do + Map.get(state.metatables, "string") + end + defp get_metatable(_value, _state), do: nil + # Index any value — dispatches to table_index or type metatable __index, or raises + defp index_value({:tref, _} = tref, key, state) do + table_index(tref, key, state) + end + + defp index_value(value, key, state) do + case get_metatable(value, state) do + nil -> + raise TypeError, + value: "attempt to index a #{Value.type_name(value)} value", + error_kind: :index_non_table, + value_type: value_type(value) + + {:tref, mt_id} -> + mt = Map.fetch!(state.tables, mt_id) + + case Map.get(mt.data, "__index") do + nil -> + raise TypeError, + value: "attempt to index a #{Value.type_name(value)} value", + error_kind: :index_non_table, + value_type: value_type(value) + + {:tref, _} = idx_tbl -> + table_index(idx_tbl, key, state) + + func when is_tuple(func) -> + {results, state} = call_function(func, [value, key], state) + {List.first(results), state} + end + end + end + + # Resolve table[key] with __index metamethod chain support + defp table_index({:tref, id}, key, state, depth \\ 0) do + if depth >= @metamethod_chain_limit do + raise RuntimeError, value: "'__index' chain too long; possible loop" + end + + table = Map.fetch!(state.tables, id) + + case Map.get(table.data, key) do + nil -> + # Key not found, check for __index metamethod + case table.metatable do + nil -> + {nil, state} + + {:tref, mt_id} -> + mt = Map.fetch!(state.tables, mt_id) + + case Map.get(mt.data, "__index") do + nil -> + {nil, state} + + {:tref, _} = index_table -> + # __index is a table, recursively look up in it + table_index(index_table, key, state, depth + 1) + + func when is_tuple(func) -> + # __index is a function, call it with (table, key) + {results, state} = call_function(func, [{:tref, id}, key], state) + {List.first(results), state} + end + end + + v -> + {v, state} + end + end + + # Resolve table[key] = value with __newindex metamethod chain support + defp table_newindex({:tref, id}, key, value, state, depth \\ 0) do + if depth >= @metamethod_chain_limit do + raise RuntimeError, value: "'__newindex' chain too long; possible loop" + end + + table = Map.fetch!(state.tables, id) + + if Map.has_key?(table.data, key) do + # Key exists, update directly (rawset) + State.update_table(state, {:tref, id}, fn t -> + %{t | data: Map.put(t.data, key, value)} + end) + else + # Key doesn't exist, check for __newindex metamethod + case table.metatable do + nil -> + # No metatable, just set the value + State.update_table(state, {:tref, id}, fn t -> + %{t | data: Map.put(t.data, key, value)} + end) + + {:tref, mt_id} -> + mt = Map.fetch!(state.tables, mt_id) + + case Map.get(mt.data, "__newindex") do + nil -> + # No __newindex, just set the value + State.update_table(state, {:tref, id}, fn t -> + %{t | data: Map.put(t.data, key, value)} + end) + + {:tref, _} = newindex_table -> + # __newindex is a table, set in that table (with chaining) + table_newindex(newindex_table, key, value, state, depth + 1) + + func when is_tuple(func) -> + # __newindex is a function, call it with (table, key, value) + {_results, state} = call_function(func, [{:tref, id}, key, value], state) + state + end + end + end + end + defp try_binary_metamethod(metamethod_name, a, b, state, default_fn) do # Try a's metatable first mt_a = get_metatable(a, state) diff --git a/lib/lua/vm/stdlib.ex b/lib/lua/vm/stdlib.ex index fb40a14..2aaae74 100644 --- a/lib/lua/vm/stdlib.ex +++ b/lib/lua/vm/stdlib.ex @@ -47,18 +47,45 @@ defmodule Lua.VM.Stdlib do |> install_global_g() end - # Install _G global table - a table that references the global environment + # Install _G global table as a proxy with __index/__newindex metamethods defp install_global_g(state) do - # Create a table containing all current globals - # Copy all globals into the _G table - g_data = state.globals + # Create empty proxy table for _G + {g_ref, state} = State.alloc_table(state) - {{:tref, _id} = g_ref, state} = State.alloc_table(state, g_data) + # Create __index function that reads from globals + index_fn = + {:native_func, + fn [_table, key], st -> + value = Map.get(st.globals, key) + {[value], st} + end} + + # Create __newindex function that writes to globals + newindex_fn = + {:native_func, + fn [_table, key, value], st -> + st = %{st | globals: Map.put(st.globals, key, value)} + {[], st} + end} - # Set _G to point to this table + # Create metatable with __index and __newindex + mt_data = %{ + "__index" => index_fn, + "__newindex" => newindex_fn + } + + {mt_ref, state} = State.alloc_table(state, mt_data) + + # Set the metatable on the _G proxy + state = + State.update_table(state, g_ref, fn table -> + %{table | metatable: mt_ref} + end) + + # Set _G global (the proxy table itself is stored in the raw data for _G._G == _G) state = State.set_global(state, "_G", g_ref) - # Also add _G to itself so _G._G == _G + # Store _G in the proxy's raw data so _G._G == _G works without hitting __index state = State.update_table(state, g_ref, fn table -> %{table | data: Map.put(table.data, "_G", g_ref)} @@ -72,7 +99,11 @@ defmodule Lua.VM.Stdlib do defp lua_type([], state), do: {["nil"], state} # tostring(v) — converts a value to its string representation - defp lua_tostring([value | _], state), do: {[Value.to_string(value)], state} + defp lua_tostring([value | _], state) do + {str, state} = value_to_string_with_mt(value, state) + {[str], state} + end + defp lua_tostring([], state), do: {["nil"], state} # tonumber(v [, base]) — converts a string to a number @@ -104,7 +135,13 @@ defmodule Lua.VM.Stdlib do # print(...) — prints values separated by tabs, followed by a newline defp lua_print(args, state) do - output = Enum.map_join(args, "\t", &Value.to_string/1) + {strings, state} = + Enum.reduce(args, {[], state}, fn val, {acc, st} -> + {str, st} = value_to_string_with_mt(val, st) + {[str | acc], st} + end) + + output = strings |> Enum.reverse() |> Enum.join("\t") IO.puts(output) {[], state} end @@ -365,6 +402,21 @@ defmodule Lua.VM.Stdlib do # setmetatable(table, metatable) — sets the metatable for a table defp lua_setmetatable([{:tref, _} = tref, metatable], state) do + # Check for __metatable protection on existing metatable + table = State.get_table(state, tref) + + case table.metatable do + {:tref, mt_id} -> + mt = Map.fetch!(state.tables, mt_id) + + if Map.has_key?(mt.data, "__metatable") do + raise RuntimeError, value: "cannot change a protected metatable" + end + + _ -> + :ok + end + # Validate metatable is nil or a table case metatable do nil -> @@ -396,17 +448,33 @@ defmodule Lua.VM.Stdlib do end # getmetatable(object) — returns the metatable of an object - defp lua_getmetatable([{:tref, _} = tref], state) do + defp lua_getmetatable([{:tref, _} = tref | _], state) do table = State.get_table(state, tref) case table.metatable do + nil -> + {[nil], state} + + {:tref, mt_id} = mt_ref -> + mt = Map.fetch!(state.tables, mt_id) + + # If metatable has __metatable field, return that instead + case Map.get(mt.data, "__metatable") do + nil -> {[mt_ref], state} + sentinel -> {[sentinel], state} + end + end + end + + defp lua_getmetatable([value | _], state) when is_binary(value) do + # For strings, return the string metatable if set + case Map.get(state.metatables, "string") do nil -> {[nil], state} mt_ref -> {[mt_ref], state} end end - defp lua_getmetatable([_other], state) do - # For non-tables, Lua returns nil (or the metatable set for that type, but we don't support that yet) + defp lua_getmetatable([_other | _], state) do {[nil], state} end @@ -530,4 +598,32 @@ defmodule Lua.VM.Stdlib do end end) end + + # Convert a value to string, checking for __tostring metamethod + defp value_to_string_with_mt(value, state) do + case value do + {:tref, id} -> + table = Map.fetch!(state.tables, id) + + case table.metatable do + {:tref, mt_id} -> + mt = Map.fetch!(state.tables, mt_id) + + case Map.get(mt.data, "__tostring") do + nil -> + {Value.to_string(value), state} + + tostring_fn -> + {results, state} = Executor.call_function(tostring_fn, [value], state) + {List.first(results) || "nil", state} + end + + _ -> + {Value.to_string(value), state} + end + + _ -> + {Value.to_string(value), state} + end + end end diff --git a/lib/lua/vm/stdlib/string.ex b/lib/lua/vm/stdlib/string.ex index 4f9737a..1394d03 100644 --- a/lib/lua/vm/stdlib/string.ex +++ b/lib/lua/vm/stdlib/string.ex @@ -57,7 +57,14 @@ defmodule Lua.VM.Stdlib.String do {tref, state} = State.alloc_table(state, string_table) # Register as global - State.set_global(state, "string", tref) + state = State.set_global(state, "string", tref) + + # Create string metatable with __index = string table + # This enables ("hello"):upper() syntax + {mt_ref, state} = State.alloc_table(state, %{"__index" => tref}) + + # Store as the type metatable for strings + %{state | metatables: Map.put(state.metatables, "string", mt_ref)} end # string.lower(s) - converts string to lowercase diff --git a/test/lua_test.exs b/test/lua_test.exs index 8943c39..8c22ef8 100644 --- a/test/lua_test.exs +++ b/test/lua_test.exs @@ -1445,9 +1445,7 @@ defmodule LuaTest do assert {[true], _} = Lua.eval!(lua, "return _G._G == _G") end - @tag :skip test "can set globals via _G", %{lua: lua} do - # Requires _G to be a live reference with __index/__newindex metamethods code = """ _G.myvar = 42 return myvar @@ -1456,9 +1454,7 @@ defmodule LuaTest do assert {[42], _} = Lua.eval!(lua, code) end - @tag :skip test "can read globals via _G", %{lua: lua} do - # Requires _G to be a live reference with __index metamethods code = """ myvar = 123 return _G.myvar @@ -1601,6 +1597,175 @@ defmodule LuaTest do end end + describe "function-valued __index and __newindex" do + setup do + %{lua: Lua.new(sandboxed: [])} + end + + test "function __index is called on missing key", %{lua: lua} do + code = """ + local t = {} + local mt = {__index = function(tbl, key) return key .. "!" end} + setmetatable(t, mt) + return t.hello, t.world + """ + + assert {["hello!", "world!"], _} = Lua.eval!(lua, code) + end + + test "function __newindex is called on new key", %{lua: lua} do + code = """ + local log = {} + local t = {} + local mt = {__newindex = function(tbl, key, val) log[#log + 1] = key .. "=" .. tostring(val) end} + setmetatable(t, mt) + t.x = 10 + t.y = 20 + return log[1], log[2] + """ + + assert {["x=10", "y=20"], _} = Lua.eval!(lua, code) + end + + test "__index chain follows tables", %{lua: lua} do + code = """ + local base = {greeting = "hello"} + local mid = {} + setmetatable(mid, {__index = base}) + local top = {} + setmetatable(top, {__index = mid}) + return top.greeting + """ + + assert {["hello"], _} = Lua.eval!(lua, code) + end + + test "existing keys bypass __index", %{lua: lua} do + code = """ + local t = {x = 1} + setmetatable(t, {__index = function() return 999 end}) + return t.x + """ + + assert {[1], _} = Lua.eval!(lua, code) + end + + test "existing keys bypass __newindex", %{lua: lua} do + code = """ + local called = false + local t = {x = 1} + setmetatable(t, {__newindex = function() called = true end}) + t.x = 2 + return t.x, called + """ + + assert {[2, false], _} = Lua.eval!(lua, code) + end + end + + describe "__call metamethod" do + setup do + %{lua: Lua.new(sandboxed: [])} + end + + test "table with __call can be called as function", %{lua: lua} do + code = """ + local t = {} + setmetatable(t, {__call = function(self, a, b) return a + b end}) + return t(3, 4) + """ + + assert {[7], _} = Lua.eval!(lua, code) + end + + test "__call receives self as first argument", %{lua: lua} do + code = """ + local t = {value = 10} + setmetatable(t, {__call = function(self) return self.value end}) + return t() + """ + + assert {[10], _} = Lua.eval!(lua, code) + end + end + + describe "__tostring metamethod" do + setup do + %{lua: Lua.new(sandboxed: [])} + end + + test "tostring uses __tostring metamethod", %{lua: lua} do + code = """ + local t = {name = "foo"} + setmetatable(t, {__tostring = function(self) return "MyObj(" .. self.name .. ")" end}) + return tostring(t) + """ + + assert {["MyObj(foo)"], _} = Lua.eval!(lua, code) + end + + test "print uses __tostring metamethod", %{lua: lua} do + code = """ + local t = {} + setmetatable(t, {__tostring = function() return "custom" end}) + return tostring(t) + """ + + assert {["custom"], _} = Lua.eval!(lua, code) + end + end + + describe "__metatable protection" do + setup do + %{lua: Lua.new(sandboxed: [])} + end + + test "getmetatable returns __metatable sentinel", %{lua: lua} do + code = """ + local t = {} + setmetatable(t, {__metatable = "protected"}) + return getmetatable(t) + """ + + assert {["protected"], _} = Lua.eval!(lua, code) + end + + test "setmetatable errors on protected metatable", %{lua: lua} do + code = """ + local t = {} + setmetatable(t, {__metatable = "protected"}) + local ok = pcall(setmetatable, t, {}) + return ok + """ + + assert {[false], _} = Lua.eval!(lua, code) + end + end + + describe "string metatable" do + setup do + %{lua: Lua.new(sandboxed: [])} + end + + test "string method syntax works", %{lua: lua} do + assert {["HELLO"], _} = Lua.eval!(lua, ~S[return ("hello"):upper()]) + end + + test "string.method via colon syntax", %{lua: lua} do + assert {["olleh"], _} = Lua.eval!(lua, ~S[return ("hello"):reverse()]) + end + + test "string indexing for methods", %{lua: lua} do + code = """ + local s = "hello" + local f = s.upper + return f(s) + """ + + assert {["HELLO"], _} = Lua.eval!(lua, code) + end + end + defp test_file(name) do Path.join(["test", "fixtures", name]) end