From 55d42b53b8f388cc35c7ff56f0feb8de5776e637 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Fri, 6 Feb 2026 10:46:42 -0500 Subject: [PATCH] feat: LuaRocks mix tasks Adds support for fetching dependencies from LuaRocks via a mix task --- .gitignore | 3 + lib/lua.ex | 36 ++++ lib/lua/rocks.ex | 258 ++++++++++++++++++++++++++++ lib/mix/tasks/lua.rocks.clean.ex | 28 +++ lib/mix/tasks/lua.rocks.install.ex | 129 ++++++++++++++ test/fixtures/empty.rockspec | 6 + test/fixtures/test.rockspec | 16 ++ test/lua/rocks_integration_test.exs | 84 +++++++++ test/lua/rocks_test.exs | 90 ++++++++++ 9 files changed, 650 insertions(+) create mode 100644 lib/lua/rocks.ex create mode 100644 lib/mix/tasks/lua.rocks.clean.ex create mode 100644 lib/mix/tasks/lua.rocks.install.ex create mode 100644 test/fixtures/empty.rockspec create mode 100644 test/fixtures/test.rockspec create mode 100644 test/lua/rocks_integration_test.exs create mode 100644 test/lua/rocks_test.exs diff --git a/.gitignore b/.gitignore index 55cb67c..e190776 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ lua-*.tar # Temporary files, for example, from tests. /tmp/ + +# Vendored Lua dependencies (installed via mix lua.rocks.install) +/priv/lua_deps/ diff --git a/lib/lua.ex b/lib/lua.ex index a5460b8..8a6c47b 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -141,6 +141,42 @@ defmodule Lua do set!(lua, ["package", "path"], paths) end + @doc """ + Configures the Lua VM to find vendored LuaRocks packages. + + This is a convenience wrapper around `Lua.set_lua_paths/2` that + automatically constructs the correct path patterns for the vendored + tree installed by `mix lua.rocks.install`. + + iex> lua = Lua.new(exclude: [[:package], [:require]]) + iex> Lua.with_rocks(lua) + + You can also pass additional paths that will be appended: + + iex> lua = Lua.new(exclude: [[:package], [:require]]) + iex> Lua.with_rocks(lua, extra_paths: ["priv/lua/?.lua"]) + + ## Options + * `:tree` - path to the vendored tree (default: `"priv/lua_deps"`) + * `:root` - project root directory (default: `File.cwd!()`) + * `:extra_paths` - additional Lua path patterns to include (default: `[]`) + + > #### Warning {: .warning} + > In order to use `Lua.with_rocks/2`, the following functions cannot be sandboxed: + > * `[:package]` + > * `[:require]` + > + > By default these are sandboxed, see the `:exclude` option in `Lua.new/1` to allow them. + """ + def with_rocks(%__MODULE__{} = lua, opts \\ []) do + opts = Keyword.validate!(opts, tree: "priv/lua_deps", root: File.cwd!(), extra_paths: []) + + rocks_paths = Lua.Rocks.lua_paths(tree: opts[:tree], root: opts[:root]) + all_paths = rocks_paths ++ opts[:extra_paths] + + set_lua_paths(lua, all_paths) + end + @doc """ Sets a table value in Lua. Nested keys will allocate intermediate tables diff --git a/lib/lua/rocks.ex b/lib/lua/rocks.ex new file mode 100644 index 0000000..80da940 --- /dev/null +++ b/lib/lua/rocks.ex @@ -0,0 +1,258 @@ +defmodule Lua.Rocks do + @moduledoc """ + Manages Lua dependencies via LuaRocks. + + Provides functions for parsing rockspec files, installing pure-Lua + packages from LuaRocks, and configuring the Lua VM to find vendored modules. + + ## Prerequisites + + The `luarocks` CLI must be installed and available on your PATH. + + ## Usage + + Specify dependencies in a `.rockspec` file at your project root: + + -- myapp-dev-1.rockspec + package = "myapp" + version = "dev-1" + source = { url = "." } + dependencies = { + "lua >= 5.1", + "inspect >= 3.0", + } + + Then install them: + + $ mix lua.rocks.install + + And use them in your Lua VM: + + lua = + Lua.new(exclude: [[:package], [:require]]) + |> Lua.with_rocks() + + {[result], _lua} = Lua.eval!(lua, "local inspect = require('inspect'); return inspect({1,2,3})") + """ + + @default_tree "priv/lua_deps" + + @doc """ + Returns the default vendor tree path. + """ + def default_tree, do: @default_tree + + @doc """ + Checks that the `luarocks` CLI is available on the system PATH. + + Returns `{:ok, version_string}` or `{:error, :not_found}`. + """ + def check_luarocks do + case System.cmd("luarocks", ["--version"], stderr_to_stdout: true) do + {output, 0} -> {:ok, output |> String.split("\n") |> hd() |> String.trim()} + _ -> {:error, :not_found} + end + rescue + ErlangError -> {:error, :not_found} + end + + @doc """ + Finds a rockspec file in the given directory. + + Returns `{:ok, path}` or `{:error, :not_found}`. + """ + def find_rockspec(dir \\ ".") do + case Path.wildcard(Path.join(dir, "*.rockspec")) do + [path | _] -> {:ok, path} + [] -> {:error, :not_found} + end + end + + @doc """ + Parses a rockspec file and extracts the `dependencies` list. + + Uses Luerl to evaluate the rockspec (which is valid Lua), then extracts + the `dependencies` table. The `"lua >= X.Y"` entry is filtered out since + it refers to the Lua runtime itself, not a package. + + Returns `{:ok, deps}` where deps is a list of `{name, constraint}` tuples, + or `{:error, reason}`. + + ## Examples + + iex> Lua.Rocks.parse_rockspec("myapp.rockspec") + {:ok, [{"inspect", ">= 3.0"}, {"middleclass", ">= 3.0"}]} + """ + def parse_rockspec(path) do + with {:ok, contents} <- File.read(path) do + lua = Lua.new(sandboxed: []) + {_, lua} = Lua.eval!(lua, contents) + + case Lua.get!(lua, [:dependencies]) do + nil -> + {:ok, []} + + deps when is_list(deps) -> + parsed = + deps + |> Lua.Table.as_list(sort: true) + |> Enum.map(&parse_dep_string/1) + |> Enum.reject(fn {name, _} -> name == "lua" end) + + {:ok, parsed} + end + end + rescue + e -> {:error, Exception.message(e)} + end + + defp parse_dep_string(str) when is_binary(str) do + case String.split(str, " ", parts: 2) do + [name, constraint] -> {String.trim(name), String.trim(constraint)} + [name] -> {String.trim(name), ""} + end + end + + @doc """ + Installs a single LuaRocks package into the given tree directory. + + ## Options + + * `:tree` - installation directory (default: `"priv/lua_deps"`) + * `:version` - exact version string, e.g. `"3.1.3"` (optional) + + Returns `:ok` or `{:error, reason}`. + """ + def install(package, opts \\ []) do + tree = Keyword.get(opts, :tree, @default_tree) + version = Keyword.get(opts, :version) + + args = + ["install", "--tree", tree, package] ++ + if(version, do: [version], else: []) + + case System.cmd("luarocks", args, stderr_to_stdout: true) do + {_output, 0} -> :ok + {output, _} -> {:error, String.trim(output)} + end + end + + @doc """ + Installs all dependencies from a rockspec file. + + ## Options + + * `:tree` - installation directory (default: `"priv/lua_deps"`) + * `:rockspec` - path to rockspec file (default: auto-detected from project root) + + Returns `{:ok, results}` or `{:error, reason}` where results is a list of + `{:ok, name}` or `{:error, name, reason}` tuples. + """ + def install_deps(opts \\ []) do + tree = Keyword.get(opts, :tree, @default_tree) + + rockspec_path = + case Keyword.get(opts, :rockspec) do + nil -> + case find_rockspec() do + {:ok, path} -> path + {:error, :not_found} -> raise "No .rockspec file found in project root" + end + + path -> + path + end + + case parse_rockspec(rockspec_path) do + {:ok, deps} -> + results = + Enum.map(deps, fn {name, _constraint} -> + case install(name, tree: tree) do + :ok -> {:ok, name} + {:error, reason} -> {:error, name, reason} + end + end) + + case validate_tree(tree: tree) do + :ok -> :ok + {:warning, paths} -> {:warning, paths} + end + + {:ok, results} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Removes the vendored tree directory entirely. + """ + def clean(opts \\ []) do + tree = Keyword.get(opts, :tree, @default_tree) + File.rm_rf!(tree) + :ok + end + + @doc """ + Returns the `package.path` patterns for the vendored tree. + + These patterns are suitable for passing to `Lua.set_lua_paths/2`. + + The Lua version subdirectory is auto-detected by scanning the tree. + If no tree exists yet, defaults to `5.3` (Luerl's Lua version). + + ## Options + + * `:tree` - path to the vendored tree (default: `"priv/lua_deps"`) + * `:root` - project root directory (default: `File.cwd!()`) + """ + def lua_paths(opts \\ []) do + tree = Keyword.get(opts, :tree, @default_tree) + root = Keyword.get(opts, :root, File.cwd!()) + base = Path.join(root, tree) + + lua_version = detect_lua_version(base) + + [ + Path.join([base, "share", "lua", lua_version, "?.lua"]), + Path.join([base, "share", "lua", lua_version, "?", "init.lua"]) + ] + end + + defp detect_lua_version(base) do + share_lua = Path.join([base, "share", "lua"]) + + case File.ls(share_lua) do + {:ok, [version | _]} -> version + _ -> "5.3" + end + end + + @doc """ + Scans the vendored tree for C extensions (`.so`, `.dll`, `.dylib` files) + and returns a list of paths to any found. + """ + def detect_c_extensions(opts \\ []) do + tree = Keyword.get(opts, :tree, @default_tree) + lib_dir = Path.join(tree, "lib") + + if File.dir?(lib_dir) do + Path.wildcard(Path.join(lib_dir, "**/*.{so,dll,dylib}")) + else + [] + end + end + + @doc """ + Validates the vendored tree has no C extensions. + + Returns `:ok` or `{:warning, paths}` with the list of C extension file paths. + """ + def validate_tree(opts \\ []) do + case detect_c_extensions(opts) do + [] -> :ok + paths -> {:warning, paths} + end + end +end diff --git a/lib/mix/tasks/lua.rocks.clean.ex b/lib/mix/tasks/lua.rocks.clean.ex new file mode 100644 index 0000000..b76d18e --- /dev/null +++ b/lib/mix/tasks/lua.rocks.clean.ex @@ -0,0 +1,28 @@ +defmodule Mix.Tasks.Lua.Rocks.Clean do + @shortdoc "Removes the vendored Lua dependencies directory" + @moduledoc """ + Removes the vendored Lua dependencies directory. + + ## Usage + + $ mix lua.rocks.clean [options] + + ## Options + + * `--tree` - Directory to clean (default: `priv/lua_deps/`) + """ + use Mix.Task + + @impl Mix.Task + def run(args) do + {opts, _, _} = OptionParser.parse(args, strict: [tree: :string]) + tree = Keyword.get(opts, :tree, Lua.Rocks.default_tree()) + + if File.dir?(tree) do + Lua.Rocks.clean(tree: tree) + Mix.shell().info("Removed #{tree}/") + else + Mix.shell().info("Nothing to clean (#{tree}/ does not exist)") + end + end +end diff --git a/lib/mix/tasks/lua.rocks.install.ex b/lib/mix/tasks/lua.rocks.install.ex new file mode 100644 index 0000000..01e7eb6 --- /dev/null +++ b/lib/mix/tasks/lua.rocks.install.ex @@ -0,0 +1,129 @@ +defmodule Mix.Tasks.Lua.Rocks.Install do + @shortdoc "Installs Lua dependencies from a rockspec file via LuaRocks" + @moduledoc """ + Installs Lua dependencies specified in a rockspec file. + + The `luarocks` CLI must be installed and available on your PATH. + + ## Usage + + $ mix lua.rocks.install [options] + + ## Options + + * `--rockspec` - Path to the rockspec file (default: auto-detected from project root) + * `--tree` - Directory for vendored packages (default: `priv/lua_deps/`) + + ## Examples + + # Install from auto-detected rockspec + $ mix lua.rocks.install + + # Install from a specific rockspec with a custom tree + $ mix lua.rocks.install --rockspec deps.rockspec --tree vendor/lua + """ + use Mix.Task + + @impl Mix.Task + def run(args) do + {opts, _, _} = + OptionParser.parse(args, + strict: [rockspec: :string, tree: :string] + ) + + case Lua.Rocks.check_luarocks() do + {:ok, version} -> + Mix.shell().info("Using #{version}") + + {:error, :not_found} -> + Mix.raise(""" + luarocks CLI not found on PATH. + + Install it from https://luarocks.org or via your package manager: + + brew install luarocks # macOS + apt install luarocks # Debian/Ubuntu + """) + end + + tree = Keyword.get(opts, :tree, Lua.Rocks.default_tree()) + + rockspec_path = + case Keyword.get(opts, :rockspec) do + nil -> + case Lua.Rocks.find_rockspec() do + {:ok, path} -> + path + + {:error, :not_found} -> + Mix.raise( + "No .rockspec file found in project root. Create one or specify --rockspec path" + ) + end + + path -> + path + end + + Mix.shell().info("Reading #{rockspec_path}...") + + case Lua.Rocks.parse_rockspec(rockspec_path) do + {:ok, []} -> + Mix.shell().info("No dependencies found in rockspec.") + + {:ok, deps} -> + install_dependencies(deps, tree) + + {:error, reason} -> + Mix.raise("Failed to parse rockspec: #{reason}") + end + end + + defp install_dependencies(deps, tree) do + Mix.shell().info("Installing #{length(deps)} Lua package(s) to #{tree}/...\n") + + results = + Enum.map(deps, fn {name, constraint} -> + label = if constraint != "", do: "#{name} (#{constraint})", else: name + Mix.shell().info(" * #{label}") + + case Lua.Rocks.install(name, tree: tree) do + :ok -> + {:ok, name} + + {:error, reason} -> + Mix.shell().error(" Failed: #{reason}") + {:error, name, reason} + end + end) + + errors = for {:error, name, reason} <- results, do: {name, reason} + successful = for {:ok, name} <- results, do: name + + if errors != [] do + Mix.shell().error("\nFailed to install #{length(errors)} package(s):") + + for {name, reason} <- errors do + Mix.shell().error(" * #{name}: #{reason}") + end + end + + case Lua.Rocks.validate_tree(tree: tree) do + :ok -> + :ok + + {:warning, paths} -> + Mix.shell().info("") + Mix.shell().info("WARNING: C extensions detected!") + Mix.shell().info("The following files will NOT work with Luerl:") + + for path <- paths do + Mix.shell().info(" #{path}") + end + + Mix.shell().info("\nOnly pure Lua packages are compatible with Luerl.") + end + + Mix.shell().info("\nInstalled #{length(successful)} package(s) to #{tree}/") + end +end diff --git a/test/fixtures/empty.rockspec b/test/fixtures/empty.rockspec new file mode 100644 index 0000000..6c2e2cc --- /dev/null +++ b/test/fixtures/empty.rockspec @@ -0,0 +1,6 @@ +package = "empty-project" +version = "1.0-1" + +source = { + url = "." +} diff --git a/test/fixtures/test.rockspec b/test/fixtures/test.rockspec new file mode 100644 index 0000000..8712175 --- /dev/null +++ b/test/fixtures/test.rockspec @@ -0,0 +1,16 @@ +package = "test-project" +version = "1.0-1" + +source = { + url = "." +} + +description = { + summary = "A test rockspec for unit tests", +} + +dependencies = { + "lua >= 5.1", + "inspect >= 3.0", + "middleclass >= 4.0", +} diff --git a/test/lua/rocks_integration_test.exs b/test/lua/rocks_integration_test.exs new file mode 100644 index 0000000..f7baf1e --- /dev/null +++ b/test/lua/rocks_integration_test.exs @@ -0,0 +1,84 @@ +defmodule Lua.RocksIntegrationTest do + use ExUnit.Case + + @moduletag :integration + + @tree "test/tmp/lua_deps" + + setup do + File.rm_rf!(@tree) + on_exit(fn -> File.rm_rf!(@tree) end) + :ok + end + + describe "install/2" do + test "installs a pure Lua package" do + assert :ok = Lua.Rocks.install("inspect", tree: @tree) + + lua_files = Path.wildcard(Path.join([@tree, "**", "*.lua"])) + assert length(lua_files) > 0 + end + + test "returns error for nonexistent package" do + assert {:error, _reason} = + Lua.Rocks.install("this-package-does-not-exist-abc123", tree: @tree) + end + end + + describe "end-to-end: install and require" do + test "can install and require a pure Lua package through Luerl" do + assert :ok = Lua.Rocks.install("inspect", tree: @tree) + + lua = + Lua.new(exclude: [[:package], [:require]]) + |> Lua.with_rocks(tree: @tree) + + # Verify the module loads successfully via require + {[result], _lua} = + Lua.eval!(lua, """ + local inspect = require("inspect") + return type(inspect) + """) + + assert result == "function" or result == "table" + end + end + + describe "install_deps/1" do + test "installs all dependencies from a rockspec" do + {:ok, results} = + Lua.Rocks.install_deps( + rockspec: "test/fixtures/test.rockspec", + tree: @tree + ) + + successful = for {:ok, name} <- results, do: name + assert "inspect" in successful + end + end + + describe "clean/1" do + test "removes the tree directory" do + Lua.Rocks.install("inspect", tree: @tree) + assert File.dir?(@tree) + + Lua.Rocks.clean(tree: @tree) + refute File.dir?(@tree) + end + end + + describe "validate_tree/1" do + test "detects C extensions when present" do + Lua.Rocks.install("luafilesystem", tree: @tree) + + case Lua.Rocks.validate_tree(tree: @tree) do + {:warning, paths} -> + assert length(paths) > 0 + + :ok -> + # On some systems it may fail to compile C extensions + :ok + end + end + end +end diff --git a/test/lua/rocks_test.exs b/test/lua/rocks_test.exs new file mode 100644 index 0000000..ca0dd48 --- /dev/null +++ b/test/lua/rocks_test.exs @@ -0,0 +1,90 @@ +defmodule Lua.RocksTest do + use ExUnit.Case, async: true + + describe "parse_rockspec/1" do + test "parses dependencies from a valid rockspec" do + {:ok, deps} = Lua.Rocks.parse_rockspec("test/fixtures/test.rockspec") + + assert {"inspect", ">= 3.0"} in deps + assert {"middleclass", ">= 4.0"} in deps + assert length(deps) == 2 + end + + test "filters out the lua runtime dependency" do + {:ok, deps} = Lua.Rocks.parse_rockspec("test/fixtures/test.rockspec") + + refute Enum.any?(deps, fn {name, _} -> name == "lua" end) + end + + test "returns empty list when no dependencies specified" do + {:ok, deps} = Lua.Rocks.parse_rockspec("test/fixtures/empty.rockspec") + + assert deps == [] + end + + test "returns error for missing file" do + assert {:error, _reason} = Lua.Rocks.parse_rockspec("nonexistent.rockspec") + end + end + + describe "lua_paths/1" do + test "returns two path patterns" do + paths = Lua.Rocks.lua_paths(tree: "my_deps", root: "/project") + + assert length(paths) == 2 + assert Enum.any?(paths, &String.ends_with?(&1, "?.lua")) + assert Enum.any?(paths, &String.contains?(&1, "init.lua")) + end + + test "includes the tree and root in paths" do + paths = Lua.Rocks.lua_paths(tree: "vendor/lua", root: "/app") + + assert Enum.all?(paths, &String.starts_with?(&1, "/app/vendor/lua/")) + end + + test "defaults to 5.3 when tree does not exist" do + paths = Lua.Rocks.lua_paths(tree: "nonexistent_tree", root: "/app") + + assert Enum.any?(paths, &String.contains?(&1, "/5.3/")) + end + end + + describe "detect_c_extensions/1" do + test "returns empty list when tree does not exist" do + assert [] == Lua.Rocks.detect_c_extensions(tree: "nonexistent_tree") + end + end + + describe "validate_tree/1" do + test "returns :ok when no C extensions present" do + assert :ok == Lua.Rocks.validate_tree(tree: "nonexistent_tree") + end + end + + describe "find_rockspec/1" do + test "finds rockspec in fixtures directory" do + {:ok, path} = Lua.Rocks.find_rockspec("test/fixtures") + + assert String.ends_with?(path, ".rockspec") + end + + test "returns error when no rockspec found" do + assert {:error, :not_found} = Lua.Rocks.find_rockspec("test/support") + end + end + + describe "check_luarocks/0" do + test "returns ok or not_found" do + case Lua.Rocks.check_luarocks() do + {:ok, version} -> assert is_binary(version) + {:error, :not_found} -> :ok + end + end + end + + describe "default_tree/0" do + test "returns the default tree path" do + assert Lua.Rocks.default_tree() == "priv/lua_deps" + end + end +end