Repository: tv-labs/lua
Pull request #31 (2024-05-20 13:44:22)

Livebook guide


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cc6993b..b9d8f52 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,7 +7,7 @@ name: Test
 
 on:
   push:
-    branches: [ "main" ]
+    branches: ["main"]
   pull_request:
 
 permissions:
@@ -15,28 +15,27 @@ permissions:
 
 jobs:
   build:
-
     name: Build and test
     runs-on: ubuntu-latest
 
     steps:
-    - name: Checkout
-      uses: actions/checkout@v3
+      - name: Checkout
+        uses: actions/checkout@v3
 
-    - name: Set up Elixir
-      uses: erlef/setup-beam@v1
-      with:
-        elixir-version: '1.16.1'
-        otp-version: '26.0'
-    - name: Restore dependencies cache
-      uses: actions/cache@v3
-      with:
-        path: deps
-        key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
-        restore-keys: ${{ runner.os }}-mix-
-    - name: Install dependencies
-      run: mix deps.get
-    - name: Run tests
-      run: mix test
-    - name: Format
-      run: mix format --check-formatted
+      - name: Set up Elixir
+        uses: erlef/setup-beam@v1
+        with:
+          elixir-version: "1.17.0-rc.0"
+          otp-version: "27.0"
+      - name: Restore dependencies cache
+        uses: actions/cache@v3
+        with:
+          path: deps
+          key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
+          restore-keys: ${{ runner.os }}-mix-
+      - name: Install dependencies
+        run: mix deps.get
+      - name: Run tests
+        run: mix test
+      - name: Format
+        run: mix format --check-formatted
diff --git a/guides/working-with-lua.livemd b/guides/working-with-lua.livemd
new file mode 100644
index 0000000..ce5d77f
--- /dev/null
+++ b/guides/working-with-lua.livemd
@@ -0,0 +1,130 @@
+<!-- livebook:{"persist_outputs":true} -->
+
+# Working with Lua
+
+```elixir
+Mix.install([
+  {:lua, "~> 0.0.14"}
+])
+```
+
+## Setup
+
+```elixir
+import Lua, only: [sigil_LUA: 2]
+```
+
+<!-- livebook:{"output":true} -->
+
+```
+Lua
+```
+
+## Executing Lua
+
+```elixir
+code = ~LUA"""
+
+"""
+
+{value, %Lua{}} = Lua.eval!(code)
+```
+
+<!-- livebook:{"output":true} -->
+
+```
+{[], #Lua<>}
+```
+
+## Getting and Setting values
+
+```elixir
+lua =
+  Lua.new()
+  |> Lua.set!([:dave], "Lucia")
+
+{["Grohl", "Lucia"], %Lua{} = lua} =
+  Lua.eval!(lua, ~LUA"""
+  local original = dave;
+
+  dave = "Grohl"
+
+  return dave, original
+  """)
+
+Lua.get!(lua, [:dave])
+```
+
+<!-- livebook:{"output":true} -->
+
+```
+"Grohl"
+```
+
+## Exposing Elixir functions
+
+`Lua` provides a module-based abstraction for defining APIs that can be called from Lua functions. Using `use Lua.API` with `deflua` defines APIs that are then loaded into the environment with the `Lua.load_api/2` function.
+
+In the example below, we load the `my_print` variadic function (a function that can take a variable number of arguments), and then call it from Lua.
+
+```elixir
+defmodule Global do
+  use Lua.API
+
+  @variadic true
+  deflua my_print(args) do
+    IO.puts(Enum.join(args, " "))
+  end
+end
+
+Lua.new()
+|> Lua.load_api(Global)
+|> Lua.eval!(~LUA"""
+my_print("one", "two", "three")
+""")
+```
+
+<!-- livebook:{"output":true} -->
+
+```
+one two three
+```
+
+<!-- livebook:{"output":true} -->
+
+```
+{[], #Lua<>}
+```
+
+There is quite a bit of flexibility for how functions can be defined. The module itself can define a scope under which all functions will be defined. In the previous example, no scope was defined, so functions were exposed in the globalscope.
+
+Here we'll define a scope, "my.foo", and expose functions "bar" and "baz" underneath it
+
+```elixir
+defmodule MyFooAPI do
+  use Lua.API, scope: "my.foo"
+
+  deflua bar(string) do
+    "bar " <> string
+  end
+
+  deflua baz(string) do
+    "baz " <> string
+  end
+end
+
+{vals, %Lua{}} =
+  Lua.new()
+  |> Lua.load_api(MyFooAPI)
+  |> Lua.eval!(~LUA"""
+   return my.foo.bar("hi"), my.foo.baz("hello")
+  """)
+
+vals
+```
+
+<!-- livebook:{"output":true} -->
+
+```
+["bar hi", "baz hello"]
+```
diff --git a/lib/lua.ex b/lib/lua.ex
index c31d2b0..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
 
@@ -370,6 +427,8 @@ defmodule Lua do
     funcs = :functions |> module.__info__() |> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
     scope = scope || module.scope()
 
+    lua = ensure_scope!(lua, scope)
+
     module.__lua_functions__()
     |> Enum.reduce(lua, fn {name, with_state?, variadic?}, lua ->
       arities = Map.get(funcs, name)
@@ -444,12 +503,13 @@ defmodule Lua do
        "Value thrown during function '#{function_name}' execution: #{inspect(thrown_value)}"}
   end
 
-  defp wrap(state), do: %__MODULE__{state: state}
+  defp ensure_scope!(lua, []) do
+    lua
+  end
 
-  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
+  defp ensure_scope!(lua, scope) do
+    set!(lua, scope, %{})
   end
+
+  defp wrap(state), do: %__MODULE__{state: state}
 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/mix.exs b/mix.exs
index 0fe819d..e5f2f91 100644
--- a/mix.exs
+++ b/mix.exs
@@ -24,7 +24,7 @@ defmodule Lua.MixProject do
         main: "Lua",
         source_url: @url,
         source_ref: "v#{@version}",
-        extras: []
+        extras: ["guides/working-with-lua.livemd"]
       ]
     ]
   end


Test patch:
diff --git a/test/lua_test.exs b/test/lua_test.exs
index 5074ebf..6990e46 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,12 @@ 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
+
+      assert {[4], _} = Lua.eval!(chunk)
+    end
+
     test "invalid functions raise" do
       lua = Lua.new()
 
@@ -206,6 +220,23 @@ defmodule LuaTest do
     end
   end
 
+  describe "load_chunk/2" do
+    test "loads a chunk into state" do
+      assert %Lua.Chunk{ref: nil} = chunk = ~LUA[print("hello")]c
+      assert {%Lua.Chunk{} = chunk, %Lua{}} = Lua.load_chunk(Lua.new(), chunk)
+      assert chunk.ref
+    end
+
+    test "chunks can be loaded multiple times" do
+      lua = Lua.new()
+      chunk = ~LUA[print("hello")]c
+
+      assert {chunk, lua} = Lua.load_chunk(lua, chunk)
+      assert {chunk, lua} = Lua.load_chunk(lua, chunk)
+      assert {_chunk, _lua} = Lua.load_chunk(lua, chunk)
+    end
+  end
+
   describe "call_function/3" do
     test "can call standard library functions" do
       assert {["hello robert"], %Lua{}} =
@@ -551,6 +582,14 @@ defmodule LuaTest do
       end
     end
 
+    defmodule NoFuncsScope do
+      use Lua.API, scope: "scope"
+    end
+
+    defmodule NoFuncsGlobal do
+      use Lua.API
+    end
+
     setup do
       {:ok, lua: Lua.new()}
     end
@@ -581,6 +620,20 @@ defmodule LuaTest do
       assert {["a default"], _} = Lua.eval!(lua, "return scope.test(\"a\")")
       assert {["a b"], _} = Lua.eval!(lua, "return scope.test(\"a\", \"b\")")
     end
+
+    test "if no functions are exposed, it still creates the scope", %{lua: lua} do
+      lua = Lua.load_api(lua, NoFuncsScope)
+
+      assert {["table"], _} = Lua.eval!(lua, "return type(scope)")
+    end
+
+    test "if the api has no scope defined, it doesnt break anything" do
+      {[], lua} = Lua.eval!("global_var = 22")
+
+      lua = Lua.load_api(lua, NoFuncsGlobal)
+
+      {[22], _} = Lua.eval!(lua, "return global_var")
+    end
   end
 
   describe "examples" do