From e607f40d88ae11910aa061c2ed9d0dc1005ad180 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Fri, 23 Aug 2024 12:10:36 -0400 Subject: [PATCH] Add support for Lua chunks Adds chunk support, which allows users to precompile Lua statements and then execute them later, as opposed re-parsing and code generating at eval time. We achieve this with the following two features 1. The ability to return chunks from the Lua sigil with the 'c' modifier ```elixir ~LUA""" print("hello world") """c ``` 2. The ability to load and evaluate chunks ```elixir {[4], _} = Lua.eval!(~LUA[return 2 + 2]c) ``` --- lib/lua.ex | 78 ++++++++++++++++++++++++++++++++++++++--------- lib/lua/chunk.ex | 10 ++++++ test/lua_test.exs | 17 +++++++++++ 3 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 lib/lua/chunk.ex diff --git a/lib/lua.ex b/lib/lua.ex index 0cf7d32..3bac632 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -9,8 +9,6 @@ defmodule Lua do defstruct [:state] - alias Luerl.New, as: Luerl - alias Lua.Util @default_sandbox [ @@ -74,7 +72,6 @@ defmodule Lua do @doc """ Write Lua code that is parsed at compile-time. - iex> ~LUA"return 2 + 2" "return 2 + 2" @@ -82,17 +79,28 @@ defmodule Lua do #iex> ~LUA":not_lua" ** (Lua.CompilerException) Failed to compile Lua! + + As an optimization, the `c` modifier can be used to return a pre-compiled Lua chunk + + iex> ~LUA"return 2 + 2"c """ - defmacro sigil_LUA(code, _opts) do + defmacro sigil_LUA(code, opts) do code = case code do {:<<>>, _, [literal]} -> literal _ -> raise "~Lua only accepts string literals, received:\n\n#{Macro.to_string(code)}" end - raise_on_invalid_lua!(code) + chunk = + case :luerl_comp.string(code, [:return]) do + {:ok, chunk} -> %Lua.Chunk{instructions: chunk} + {:error, error, _warnings} -> raise Lua.CompilerException, error + end - code + case opts do + [?c] -> Macro.escape(chunk) + _ -> code + end end @doc """ @@ -230,11 +238,44 @@ defmodule Lua do iex> {[42], _} = Lua.eval!(Lua.new(), "return 42") + + `eval!/2` can also evaluate chunks by passing instead of a script. As a + peformance optimization, it is recommended to call `load_chunk/2` if you + will be executing a chunk many times, but it is not necessary. + + iex> {[4], _} = Lua.eval!(~LUA[return 2 + 2]c) + """ def eval!(state \\ new(), script) def eval!(%__MODULE__{state: state} = lua, script) when is_binary(script) do - case Luerl.do_dec(state, script) do + case :luerl_new.do_dec(script, state) do + {:ok, result, new_state} -> + {result, %__MODULE__{lua | state: new_state}} + + {:lua_error, _e, _state} = error -> + raise Lua.RuntimeException, error + + {:error, [error | _], _} -> + raise Lua.CompilerException, error + end + rescue + e in [UndefinedFunctionError] -> + reraise Lua.RuntimeException, + Util.format_function([e.module, e.function], e.arity), + __STACKTRACE__ + + e in [Lua.RuntimeException, Lua.CompilerException] -> + reraise e, __STACKTRACE__ + + e -> + reraise Lua.RuntimeException, e, __STACKTRACE__ + end + + def eval!(%__MODULE__{} = lua, %Lua.Chunk{} = chunk) do + {chunk, lua} = load_chunk(lua, chunk) + + case :luerl_new.call_chunk(chunk.ref, lua.state) do {:ok, result, new_state} -> {result, %__MODULE__{lua | state: new_state}} @@ -257,6 +298,22 @@ defmodule Lua do reraise Lua.RuntimeException, e, __STACKTRACE__ end + @doc """ + Similar to `:luerl_new.load/3`, it takes a `t:Lua.Chunk.t`, and loads + it into state so that it can be evaluated via `eval!/2` + + iex> {%Lua.Chunk{}, %Lua{}} = Lua.load_chunk(Lua.new(), ~LUA[return 2 + 2]c) + """ + def load_chunk(%__MODULE__{state: state} = lua, %Lua.Chunk{ref: nil} = chunk) do + {ref, state} = :luerl_emul.load_chunk(chunk.instructions, state) + + {%Lua.Chunk{chunk | ref: ref}, %__MODULE__{lua | state: state}} + end + + def load_chunk(%__MODULE__{} = lua, %Lua.Chunk{} = chunk) do + {chunk, lua} + end + @doc """ Calls a function in Lua's state @@ -455,11 +512,4 @@ defmodule Lua do end defp wrap(state), do: %__MODULE__{state: state} - - defp raise_on_invalid_lua!(code) when is_binary(code) do - case :luerl_comp.string(code, [:return]) do - {:ok, _chunk} -> :ok - {:error, error, _warnings} -> raise Lua.CompilerException, error - end - end end diff --git a/lib/lua/chunk.ex b/lib/lua/chunk.ex new file mode 100644 index 0000000..8223e62 --- /dev/null +++ b/lib/lua/chunk.ex @@ -0,0 +1,10 @@ +defmodule Lua.Chunk do + @moduledoc """ + A pre-compiled [chunk](https://www.lua.org/pil/1.1.html) of Lua code + that can be executed at a future point in time + """ + + @type t :: %__MODULE__{} + + defstruct [:instructions, :ref] +end diff --git a/test/lua_test.exs b/test/lua_test.exs index cbfba39..67b47b2 100644 --- a/test/lua_test.exs +++ b/test/lua_test.exs @@ -42,6 +42,14 @@ defmodule LuaTest do end end + test "it returns the Lua string by default" do + assert ~LUA[print("hello")] == ~S[print("hello")] + end + + test "it returns chunks with the ~LUA c option" do + assert %Lua.Chunk{} = ~LUA[print("hello")]c + end + test "it can handle multi-line programs" do message = """ Failed to compile Lua! @@ -166,6 +174,15 @@ defmodule LuaTest do assert {[2], %Lua{}} = Lua.eval!(lua, "return foo(1)") end + test "it can evaluate chunks" do + assert %Lua.Chunk{} = chunk = ~LUA[return 2 + 2]c + lua = Lua.new() + + assert {ref, lua} = :luerl_emul.load_chunk(chunk.instructions, lua.state) + + assert {:ok, [4], _} = :luerl_new.call_chunk(ref, lua) + end + test "invalid functions raise" do lua = Lua.new()