From 76cebfbaa7b80fc3201f0f4a5296ddf59c9c6125 Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 01:34:34 -0700 Subject: [PATCH 01/18] refactor: add initial rockspec parsing package --- packages/rocked/.gitignore | 3 + packages/rocked/lpm.json | 5 ++ packages/rocked/src/init.lua | 101 ++++++++++++++++++++++++++++++++ packages/rocked/tests/basic.lua | 44 ++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 packages/rocked/.gitignore create mode 100644 packages/rocked/lpm.json create mode 100644 packages/rocked/src/init.lua create mode 100644 packages/rocked/tests/basic.lua diff --git a/packages/rocked/.gitignore b/packages/rocked/.gitignore new file mode 100644 index 0000000..18d6dcd --- /dev/null +++ b/packages/rocked/.gitignore @@ -0,0 +1,3 @@ +/target/ +/lpm.lock # Reserved for future use +/lpm-lock.json \ No newline at end of file diff --git a/packages/rocked/lpm.json b/packages/rocked/lpm.json new file mode 100644 index 0000000..16073e1 --- /dev/null +++ b/packages/rocked/lpm.json @@ -0,0 +1,5 @@ +{ + "name": "rocked", + "version": "0.1.0", + "devDependencies": { "http": { "path": "../http" } } +} diff --git a/packages/rocked/src/init.lua b/packages/rocked/src/init.lua new file mode 100644 index 0000000..00c7fb4 --- /dev/null +++ b/packages/rocked/src/init.lua @@ -0,0 +1,101 @@ +local rocked = {} + +---@class rocked.raw.Description +---@field summary string +---@field detailed string +---@field homepage string +---@field license string + +---@alias rocked.raw.builtin.Build.Source +--- | string +--- | { sources: string[], libraries: string[]?, incdirs: string[]?, libdirs: string[]? } + +---@class rocked.raw.builtin.Build.Install +---@field lua table? +---@field bin table? +---@field lib table? +---@field conf table? + +---@class rocked.raw.builtin.Build +---@field type "builtin" +---@field modules table +---@field install rocked.raw.builtin.Build.Install? +---@field copy_directories string[]? + +---@class rocked.raw.module.Build +---@field type "module" +---@field modules table + +---@alias rocked.raw.Build +--- | rocked.raw.builtin.Build +--- | rocked.raw.module.Build + +---@class rocked.raw.Output +---@field version string +---@field package string +---@field description rocked.raw.Description +---@field source { url: string } +---@field dependencies string[] +---@field build rocked.raw.Build + +-- Things we'll provide to the rockspec sandbox +local baseChunkEnv = { + pairs = pairs, + ipairs = ipairs, + next = next +} + +---@overload fun(spec: string): false, string? +---@overload fun(spec: string): true, rocked.raw.Output +function rocked.raw(spec) + local unsafeChunk, err = loadstring(spec, "t") + if not unsafeChunk then + return false, err + end + + local oh, om, oc = debug.gethook() + debug.sethook(function() error("Rockspec took too long to run") end, "", 1e7) + + local chunkEnv = setmetatable({}, { __index = baseChunkEnv }) + local chunk = setfenv(unsafeChunk, chunkEnv) + + -- Debug hooks aren't guaranteed to run with JIT on, also it's safer this way + jit.off(chunk) + + local ok, out = pcall(chunk) + + debug.sethook(oh, om, oc) + + if not ok then + return false, out + end + + return true, chunkEnv +end + +local validRockTypes = { + ["builtin"] = true, + ["module"] = true +} + +---@overload fun(spec: string): false, string? +---@overload fun(spec: string): true, rocked.raw.Output +function rocked.parse(spec) + local ok, chunkEnv = rocked.raw(spec) + if not ok then + return false, chunkEnv + end ---@cast chunkEnv rocked.raw.Output + + local build = chunkEnv.build + if not build then + return false, "No build section found" + end + + if not validRockTypes[build.type] then + return false, "Invalid build type: " .. tostring(build.type) + end + + return true, chunkEnv +end + +return rocked diff --git a/packages/rocked/tests/basic.lua b/packages/rocked/tests/basic.lua new file mode 100644 index 0000000..b4d2b03 --- /dev/null +++ b/packages/rocked/tests/basic.lua @@ -0,0 +1,44 @@ +local test = require("lpm-test") +local rocked = require("rocked") +local http = require("http") + +test.it("should be able to parse busted's rockspec", function() + local spec, err = http.get( + "https://raw.githubusercontent.com/lunarmodules/busted/56e6d68204d1456afa77f1346bf4e050df65b629/rockspecs/busted-2.3.0-1.rockspec" + ) + + if not spec then + error("Failed to GET busted rockspec: " .. err) + end + + local ok, parsed = rocked.parse(spec) + if not ok then + error("Failed to parse rockspec: " .. parsed) + end +end) + +test.it("should be in a separate environment", function() + local spec = [[ + print('i shouldnt be able to print') + ]] + + local ok, parsed = rocked.parse(spec) + if ok then + error("Expected rockspec to fail, but it succeeded") + end ---@cast parsed string + + test.notEqual(parsed:find("attempt to call global 'print'"), nil) +end) + +test.it("shouldn't run for too long", function() + local spec = [[ + while true do end + ]] + + local ok, parsed = rocked.parse(spec) + if ok then + error("Expected rockspec to fail, but it succeeded") + end ---@cast parsed string + + test.notEqual(parsed:find("Rockspec took too long to run"), nil) +end) From e0e2857731c177fcf6c336a64e0c038867427f82 Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 01:41:40 -0700 Subject: [PATCH 02/18] refactor(build): move build script execution logic into its own function --- packages/lpm-core/src/package/build.lua | 5 ++--- packages/lpm-core/src/package/init.lua | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/lpm-core/src/package/build.lua b/packages/lpm-core/src/package/build.lua index c23d5a4..9b91a2e 100644 --- a/packages/lpm-core/src/package/build.lua +++ b/packages/lpm-core/src/package/build.lua @@ -20,11 +20,10 @@ local function buildPackage(package, destinationPath) fs.mkdir(target) end - local buildScriptPath = package:getBuildScriptPath() - if fs.exists(buildScriptPath) then + if package:hasBuildScript() then fs.copy(package:getSrcDir(), destinationPath) - local ok, err = package:runFile(buildScriptPath, nil, { LPM_OUTPUT_DIR = destinationPath }) + local ok, err = package:runBuildScript(destinationPath) if not ok then error("Build script failed for package '" .. package:getName() .. "': " .. err) end diff --git a/packages/lpm-core/src/package/init.lua b/packages/lpm-core/src/package/init.lua index 019d6d9..0788376 100644 --- a/packages/lpm-core/src/package/init.lua +++ b/packages/lpm-core/src/package/init.lua @@ -42,6 +42,20 @@ function Package:getConfigPath() return configPathAtDir(self.dir) end function Package:getLockfilePath() return path.join(self.dir, "lpm-lock.json") end +function Package:hasBuildScript() return fs.exists(self:getBuildScriptPath()) end + +---@param outputDir string +---@return boolean? ok +---@return string? err +function Package:runBuildScript(outputDir) + local buildScriptPath = self:getBuildScriptPath() + if not fs.exists(buildScriptPath) then + return nil, "No build script found: " .. buildScriptPath + end + + return self:runFile(buildScriptPath, nil, { LPM_OUTPUT_DIR = outputDir }) +end + ---@param dir string? ---@return lpm.Package?, string? function Package.open(dir) From 88f80b2c9ee6e2002c10242076ed51661dae32fd Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 02:12:23 -0700 Subject: [PATCH 03/18] refactor: build functions and initial rockspec import --- packages/lpm-core/lpm.json | 3 +- packages/lpm-core/src/package/init.lua | 119 +++++++++++++++++++++++-- packages/lpm/src/init.lua | 2 +- 3 files changed, 114 insertions(+), 10 deletions(-) diff --git a/packages/lpm-core/lpm.json b/packages/lpm-core/lpm.json index 1004c7a..f33deec 100644 --- a/packages/lpm-core/lpm.json +++ b/packages/lpm-core/lpm.json @@ -15,6 +15,7 @@ "http": { "path": "../http" }, "semver": { "path": "../semver" }, "lpm-test": { "path": "../lpm-test" }, - "git": { "path": "../git" } + "git": { "path": "../git" }, + "rocked": { "path": "../rocked" } } } diff --git a/packages/lpm-core/src/package/init.lua b/packages/lpm-core/src/package/init.lua index 0788376..94b4439 100644 --- a/packages/lpm-core/src/package/init.lua +++ b/packages/lpm-core/src/package/init.lua @@ -1,5 +1,6 @@ local Config = require("lpm-core.config") local Lockfile = require("lpm-core.lockfile") +local rocked = require("rocked") local global = require("lpm-core.global") @@ -13,6 +14,7 @@ local process = require("process") ---@field dir string ---@field cachedConfig lpm.Config? ---@field cachedConfigMtime number? +---@field buildfn (fun(outputDir: string): boolean, string?)? local Package = {} Package.__index = Package @@ -42,23 +44,31 @@ function Package:getConfigPath() return configPathAtDir(self.dir) end function Package:getLockfilePath() return path.join(self.dir, "lpm-lock.json") end -function Package:hasBuildScript() return fs.exists(self:getBuildScriptPath()) end - +---@param pkg lpm.Package ---@param outputDir string ----@return boolean? ok ----@return string? err -function Package:runBuildScript(outputDir) - local buildScriptPath = self:getBuildScriptPath() +local function defaultBuildFn(pkg, outputDir) + local buildScriptPath = pkg:getBuildScriptPath() if not fs.exists(buildScriptPath) then return nil, "No build script found: " .. buildScriptPath end - return self:runFile(buildScriptPath, nil, { LPM_OUTPUT_DIR = outputDir }) + return pkg:runFile(buildScriptPath, nil, { LPM_OUTPUT_DIR = outputDir }) +end + +function Package:hasBuildScript() + return self.buildfn ~= nil or fs.exists(self:getBuildScriptPath()) +end + +---@param outputDir string +---@return boolean? ok +---@return string? err +function Package:runBuildScript(outputDir) + return (self.buildfn or defaultBuildFn)(self, outputDir) end ---@param dir string? ---@return lpm.Package?, string? -function Package.open(dir) +function Package.openLPM(dir) dir = dir or env.cwd() local configPath = configPathAtDir(dir) @@ -69,6 +79,99 @@ function Package.open(dir) return setmetatable({ dir = dir }, Package), nil end +---@param dir string? +---@return lpm.Package?, string? +function Package.openRockspec(dir) + dir = dir or env.cwd() + + local rockspecPath + if fs.isdir(dir) then + for _, entry in ipairs(fs.scan(dir, "*.rockspec")) do + rockspecPath = path.join(dir, entry) + break + end + end + + if not rockspecPath then + return nil, "No rockspec found in directory: " .. dir + end + + local content = fs.read(rockspecPath) + if not content then + return nil, "Could not read rockspec: " .. rockspecPath + end + + local ok, spec = rocked.parse(content) + if not ok then + return nil, "Failed to parse rockspec: " .. (spec or rockspecPath) + end ---@cast spec rocked.raw.Output + + local pkg = setmetatable({ dir = dir }, Package) + + -- Collect pure-Lua module_name -> src_path from build.modules and build.install.lua + local modules = {} + if spec.build then + for modname, src in pairs(spec.build.modules or {}) do + if type(src) == "string" and src:match("%.lua$") then + modules[modname] = src + end + end + for modname, src in pairs((spec.build.install or {}).lua or {}) do + modules[modname] = src + end + end + + local entryModule = spec.package and spec.package:lower() + + pkg.buildfn = function(outputDir) + local modulesDir = path.dirname(outputDir) + + for modname, src in pairs(modules) do + local srcAbs = path.join(dir, src) + local destRel = modname:gsub("%.", path.separator) .. ".lua" + local destAbs = path.join(modulesDir, destRel) + local destDir = path.dirname(destAbs) + if not fs.isdir(destDir) then + fs.mkdir(destDir) + end + fs.copy(srcAbs, destAbs) + end + + local lines = {} + for modname in pairs(modules) do + table.insert(lines, string.format( + "package.preload[%q] = package.preload[%q] or function() return require(%q) end", + modname, modname, modname + )) + end + if entryModule then + table.insert(lines, string.format("return require(%q)", entryModule)) + end + + fs.write(path.join(outputDir, "init.lua"), table.concat(lines, "\n") .. "\n") + + return true + end + + pkg.readConfig = function() + return Config.new({ name = spec.package, version = spec.version }) + end + + return pkg, nil +end + +---@param dir string? +---@return lpm.Package?, string? +function Package.open(dir) + dir = dir or env.cwd() + + if fs.exists(configPathAtDir(dir)) then + return Package.openLPM(dir) + end + + return Package.openRockspec(dir) +end + ---@return lpm.Config function Package:readConfig() local configPath = self:getConfigPath() diff --git a/packages/lpm/src/init.lua b/packages/lpm/src/init.lua index 96aac38..aaf24d7 100755 --- a/packages/lpm/src/init.lua +++ b/packages/lpm/src/init.lua @@ -42,7 +42,7 @@ if os.getenv("BOOTSTRAP") then local pathPackages = { "ansi", "clap", "fs", "http", "env", "path", "json", "git", - "process", "sea", "semver", "util", "lpm-core", "lpm-test" + "process", "sea", "semver", "util", "lpm-core", "lpm-test", "rocked" } for _, pkg in ipairs(pathPackages) do From d35fc9322fbfd2264b61422ca40117237eaabc06 Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 02:22:18 -0700 Subject: [PATCH 04/18] fix: adjust fs.scan to find rockspec properly --- packages/lpm-core/src/package/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lpm-core/src/package/init.lua b/packages/lpm-core/src/package/init.lua index 94b4439..3b6d25c 100644 --- a/packages/lpm-core/src/package/init.lua +++ b/packages/lpm-core/src/package/init.lua @@ -86,7 +86,7 @@ function Package.openRockspec(dir) local rockspecPath if fs.isdir(dir) then - for _, entry in ipairs(fs.scan(dir, "*.rockspec")) do + for _, entry in ipairs(fs.scan(dir, "**.rockspec")) do rockspecPath = path.join(dir, entry) break end From d2f0cdeb77d016943927bef4efaf82dbe6a3d4a4 Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 02:22:39 -0700 Subject: [PATCH 05/18] refactor: take lpm.Package as first buildfn argument --- packages/lpm-core/src/package/init.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lpm-core/src/package/init.lua b/packages/lpm-core/src/package/init.lua index 3b6d25c..c92a36b 100644 --- a/packages/lpm-core/src/package/init.lua +++ b/packages/lpm-core/src/package/init.lua @@ -14,7 +14,7 @@ local process = require("process") ---@field dir string ---@field cachedConfig lpm.Config? ---@field cachedConfigMtime number? ----@field buildfn (fun(outputDir: string): boolean, string?)? +---@field buildfn (fun(pkg: lpm.Package, outputDir: string): boolean, string?)? local Package = {} Package.__index = Package @@ -123,7 +123,7 @@ function Package.openRockspec(dir) local entryModule = spec.package and spec.package:lower() - pkg.buildfn = function(outputDir) + pkg.buildfn = function(_, outputDir) local modulesDir = path.dirname(outputDir) for modname, src in pairs(modules) do From 165148310af7c3d069fbe1492d564f9dfee70c51 Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 02:25:13 -0700 Subject: [PATCH 06/18] tests: add basic rockspec test --- packages/lpm/tests/package.lua | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/lpm/tests/package.lua b/packages/lpm/tests/package.lua index 23233b5..5373701 100644 --- a/packages/lpm/tests/package.lua +++ b/packages/lpm/tests/package.lua @@ -183,3 +183,61 @@ test.it("Package tostring includes the directory", function() local s = tostring(pkg) test.equal(s, "Package(" .. dir .. ")") end) + +-- +-- Rockspec dependency +-- + +test.it("rockspec dep: can require(packagename) from a consumer package", function() + fs.mkdir(tmpBase) + + -- Create a fake rockspec package with files scattered in odd locations + local rockDir = path.join(tmpBase, "rock-dep") + fs.mkdir(rockDir) + fs.mkdir(path.join(rockDir, "src")) + fs.mkdir(path.join(rockDir, "src", "internal")) + fs.write(path.join(rockDir, "src", "core.lua"), 'return { value = 42 }') + fs.write(path.join(rockDir, "src", "internal", "util.lua"), 'return {}') + fs.write(path.join(rockDir, "rock-dep-1.0.0-1.rockspec"), [[ + package = "rock-dep" + version = "1.0.0-1" + source = { url = "git://example.com/rock-dep" } + build = { + type = "builtin", + modules = { + ["rock-dep"] = "src/core.lua", + ["rock-dep.util"] = "src/internal/util.lua", + } + } + ]]) + + -- Consumer lpm package that depends on the rockspec package via path + local appDir = path.join(tmpBase, "rock-consumer") + fs.mkdir(appDir) + fs.mkdir(path.join(appDir, "src")) + fs.write(path.join(appDir, "src", "init.lua"), [[ + local dep = require("rock-dep") + assert(dep.value == 42, "expected value 42, got " .. tostring(dep.value)) + ]]) + fs.write(path.join(appDir, "lpm.json"), json.encode({ + name = "rock-consumer", + version = "0.1.0", + dependencies = { + ["rock-dep"] = { path = "../rock-dep" } + } + })) + + local app = lpm.Package.open(appDir) + app:installDependencies() + app:build() + + -- buildfn should have copied modules to target/ at their require-able paths + test.truthy(fs.exists(path.join(appDir, "target", "rock-dep.lua"))) + test.truthy(fs.exists(path.join(appDir, "target", "rock-dep", "util.lua"))) + -- init.lua should have been generated in the package target dir + test.truthy(fs.exists(path.join(appDir, "target", "rock-dep", "init.lua"))) + + local ok, err = app:runFile() + if not ok then print(err) end + test.truthy(ok) +end) From 43b51536da88380c6b0bc4f02abcb39f21fb18db Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 02:38:18 -0700 Subject: [PATCH 07/18] feat: add 'rockspec' dep field to explicitly pass path Useful for projects that just put them wherever. --- packages/lpm-core/src/config.lua | 1 + packages/lpm-core/src/package/init.lua | 19 +++++++++++-------- packages/lpm-core/src/package/install.lua | 6 +++--- packages/lpm/lpm.schema.json | 8 ++++++++ 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/lpm-core/src/config.lua b/packages/lpm-core/src/config.lua index 2285fd7..b615e0c 100644 --- a/packages/lpm-core/src/config.lua +++ b/packages/lpm-core/src/config.lua @@ -20,6 +20,7 @@ end ---@class lpm.Config.BaseDependency ---@field name string? # The actual package name in the registry, when aliasing +---@field rockspec string? # Path to the rockspec file, relative to the dependency directory ---@class lpm.Config.GitDependency: lpm.Config.BaseDependency ---@field git string diff --git a/packages/lpm-core/src/package/init.lua b/packages/lpm-core/src/package/init.lua index c92a36b..d8d88fc 100644 --- a/packages/lpm-core/src/package/init.lua +++ b/packages/lpm-core/src/package/init.lua @@ -80,15 +80,17 @@ function Package.openLPM(dir) end ---@param dir string? +---@param rockspecPath string? # Path to the rockspec file; if nil, scanned from dir ---@return lpm.Package?, string? -function Package.openRockspec(dir) +function Package.openRockspec(dir, rockspecPath) dir = dir or env.cwd() - local rockspecPath - if fs.isdir(dir) then - for _, entry in ipairs(fs.scan(dir, "**.rockspec")) do - rockspecPath = path.join(dir, entry) - break + if not rockspecPath then + if fs.isdir(dir) then + for _, entry in ipairs(fs.scan(dir, "**.rockspec")) do + rockspecPath = path.join(dir, entry) + break + end end end @@ -161,15 +163,16 @@ function Package.openRockspec(dir) end ---@param dir string? +---@param rockspec string? # Path to rockspec, forwarded to openRockspec if no lpm.json ---@return lpm.Package?, string? -function Package.open(dir) +function Package.open(dir, rockspec) dir = dir or env.cwd() if fs.exists(configPathAtDir(dir)) then return Package.openLPM(dir) end - return Package.openRockspec(dir) + return Package.openRockspec(dir, rockspec) end ---@return lpm.Config diff --git a/packages/lpm-core/src/package/install.lua b/packages/lpm-core/src/package/install.lua index e043fe1..99021cd 100644 --- a/packages/lpm-core/src/package/install.lua +++ b/packages/lpm-core/src/package/install.lua @@ -25,7 +25,7 @@ local function dependencyToPackage(alias, depInfo, relativeTo) ---@type lpm.Lockfile.GitDependency local lockEntry = { git = depInfo.git, commit = resolvedCommit, branch = depInfo.branch, name = depInfo.name } - local pkg = Package.open(repoDir) + local pkg = Package.open(repoDir, depInfo.rockspec) if pkg and pkg:getName() == packageName then return pkg, lockEntry end for _, config in ipairs(fs.scan(repoDir, "**" .. path.separator .. "lpm.json")) do @@ -35,7 +35,7 @@ local function dependencyToPackage(alias, depInfo, relativeTo) error("No lpm.json with name '" .. packageName .. "' found in git repository") elseif depInfo.path then - local localPackage, err = Package.open(path.resolve(relativeTo, path.normalize(depInfo.path))) + local localPackage, err = Package.open(path.resolve(relativeTo, path.normalize(depInfo.path)), depInfo.rockspec) if not localPackage then error("Failed to load local dependency package for: " .. alias .. "\nError: " .. err) end @@ -54,7 +54,7 @@ local function dependencyToPackage(alias, depInfo, relativeTo) ---@type lpm.Lockfile.GitDependency local lockEntry = { git = portfile.git, commit = commit, branch = portfile.branch, name = depInfo.name } - local pkg = Package.open(repoDir) + local pkg = Package.open(repoDir, depInfo.rockspec) if pkg and pkg:getName() == packageName then return pkg, lockEntry end for _, config in ipairs(fs.scan(repoDir, "**" .. path.separator .. "lpm.json")) do diff --git a/packages/lpm/lpm.schema.json b/packages/lpm/lpm.schema.json index fbd0110..3f5fc2e 100644 --- a/packages/lpm/lpm.schema.json +++ b/packages/lpm/lpm.schema.json @@ -18,6 +18,10 @@ "package": { "type": "string", "description": "Actual package name at the path, when aliasing (key is the require name)" + }, + "rockspec": { + "type": "string", + "description": "Path to the rockspec file relative to the dependency directory" } }, "additionalProperties": false @@ -40,6 +44,10 @@ "package": { "type": "string", "description": "Actual package name in the repository, when aliasing (key is the require name)" + }, + "rockspec": { + "type": "string", + "description": "Path to the rockspec file relative to the dependency directory" } }, "additionalProperties": false From 4bb624cb0383658cbdabb2f7c363d64c304648f4 Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 02:42:48 -0700 Subject: [PATCH 08/18] fix: make rockspecPath relative to package dir when provided --- packages/lpm-core/src/package/init.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/lpm-core/src/package/init.lua b/packages/lpm-core/src/package/init.lua index d8d88fc..1c5b9c2 100644 --- a/packages/lpm-core/src/package/init.lua +++ b/packages/lpm-core/src/package/init.lua @@ -92,6 +92,8 @@ function Package.openRockspec(dir, rockspecPath) break end end + elseif not path.isAbsolute(rockspecPath) then + rockspecPath = path.join(dir, rockspecPath) end if not rockspecPath then From 1ecf572975e079520d2ce763c3a584c2ceb33a87 Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 02:42:55 -0700 Subject: [PATCH 09/18] tests: add middleclass test --- packages/lpm/tests/git.lua | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/lpm/tests/git.lua b/packages/lpm/tests/git.lua index 72de324..1da10a3 100644 --- a/packages/lpm/tests/git.lua +++ b/packages/lpm/tests/git.lua @@ -106,3 +106,37 @@ test.it("installDependencies respects a pinned commit in lpm.json", function() local fixturePath = path.join(dir, "target", FIXTURE_NAME, "init.lua") test.truthy(fs.exists(fixturePath)) end) + +test.it("rockspec git dep: middleclass can be required after install", function() + local dir = path.join(tmpBase, "middleclass-consumer") + fs.mkdir(dir) + fs.mkdir(path.join(dir, "src")) + fs.write(path.join(dir, "src", "init.lua"), [[ + local class = require("middleclass") + local Animal = class("Animal") + function Animal:initialize(name) self.name = name end + local a = Animal("cat") + assert(a.name == "cat", "expected name 'cat', got " .. tostring(a.name)) + ]]) + fs.write(path.join(dir, "lpm.json"), json.encode({ + name = "middleclass-consumer", + version = "0.1.0", + dependencies = { + middleclass = { + git = "https://github.com/kikito/middleclass", + branch = "master", + rockspec = "rockspecs/middleclass-4.1.1-0.rockspec" + } + } + })) + + local pkg = lpm.Package.open(dir) + pkg:installDependencies() + pkg:build() + + test.truthy(fs.exists(path.join(dir, "target", "middleclass.lua"))) + + local ok, err = pkg:runFile() + if not ok then print(err) end + test.truthy(ok) +end) From 669629685748bd27afdd61d83be44d95957dfaa4 Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 02:53:27 -0700 Subject: [PATCH 10/18] feat: initial C module compilation support --- packages/lpm-core/src/package/init.lua | 29 ++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/lpm-core/src/package/init.lua b/packages/lpm-core/src/package/init.lua index 1c5b9c2..57c0b25 100644 --- a/packages/lpm-core/src/package/init.lua +++ b/packages/lpm-core/src/package/init.lua @@ -114,10 +114,15 @@ function Package.openRockspec(dir, rockspecPath) -- Collect pure-Lua module_name -> src_path from build.modules and build.install.lua local modules = {} + local nativeModules = {} -- modname -> src_path (.c) if spec.build then for modname, src in pairs(spec.build.modules or {}) do - if type(src) == "string" and src:match("%.lua$") then - modules[modname] = src + if type(src) == "string" then + if src:match("%.lua$") then + modules[modname] = src + elseif src:match("%.c$") then + nativeModules[modname] = src + end end end for modname, src in pairs((spec.build.install or {}).lua or {}) do @@ -141,6 +146,26 @@ function Package.openRockspec(dir, rockspecPath) fs.copy(srcAbs, destAbs) end + for modname, src in pairs(nativeModules) do + local srcAbs = path.join(dir, src) + local ext = process.platform == "darwin" and "dylib" or "so" + local destRel = modname:gsub("%.", path.separator) .. "." .. ext + local destAbs = path.join(modulesDir, destRel) + local destDir = path.dirname(destAbs) + if not fs.isdir(destDir) then + fs.mkdir(destDir) + end + + local ok, err = process.exec("gcc", { + "-shared", "-fPIC", + srcAbs, + "-o", destAbs, + }) + if not ok then + return nil, "Failed to compile native module '" .. modname .. "': " .. (err or "") + end + end + local lines = {} for modname in pairs(modules) do table.insert(lines, string.format( From e75a4d48fbb28db71d5c5ae3f12c6858896caca2 Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 02:56:12 -0700 Subject: [PATCH 11/18] tests: add native c module test --- packages/lpm/tests/package.lua | 70 ++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/lpm/tests/package.lua b/packages/lpm/tests/package.lua index 5373701..4a80f36 100644 --- a/packages/lpm/tests/package.lua +++ b/packages/lpm/tests/package.lua @@ -241,3 +241,73 @@ test.it("rockspec dep: can require(packagename) from a consumer package", functi if not ok then print(err) end test.truthy(ok) end) + +test.it("rockspec native C module: can require and call a C function returning 52", function() + fs.mkdir(tmpBase) + + local rockDir = path.join(tmpBase, "native-rock") + fs.mkdir(rockDir) + fs.mkdir(path.join(rockDir, "csrc")) + + -- Minimal C module using raw Lua ABI, no headers needed + fs.write(path.join(rockDir, "csrc", "answer.c"), [[ +#include + +typedef struct lua_State lua_State; +typedef int (*lua_CFunction)(lua_State *L); + +extern void lua_pushinteger(lua_State *L, ptrdiff_t n); +extern void lua_createtable(lua_State *L, int narr, int nrec); +extern void lua_setfield(lua_State *L, int idx, const char *k); +extern void lua_pushcclosure(lua_State *L, lua_CFunction fn, int n); + +static int answer(lua_State *L) { + lua_pushinteger(L, 52); + return 1; +} + +int luaopen_answer(lua_State *L) { + lua_createtable(L, 0, 1); + lua_pushcclosure(L, answer, 0); + lua_setfield(L, -2, "answer"); + return 1; +} +]]) + + fs.write(path.join(rockDir, "native-rock-1.0.0-1.rockspec"), [[ + package = "native-rock" + version = "1.0.0-1" + source = { url = "git://example.com/native-rock" } + build = { + type = "builtin", + modules = { + answer = "csrc/answer.c", + } + } + ]]) + + local appDir = path.join(tmpBase, "native-consumer") + fs.mkdir(appDir) + fs.mkdir(path.join(appDir, "src")) + fs.write(path.join(appDir, "src", "init.lua"), [[ + local m = require("answer") + assert(m.answer() == 52, "expected 52, got " .. tostring(m.answer())) + ]]) + fs.write(path.join(appDir, "lpm.json"), json.encode({ + name = "native-consumer", + version = "0.1.0", + dependencies = { + ["native-rock"] = { path = "../native-rock" } + } + })) + + local app = lpm.Package.open(appDir) + app:installDependencies() + app:build() + + test.truthy(fs.exists(path.join(appDir, "target", "answer.so"))) + + local ok, err = app:runFile() + if not ok then print(err) end + test.truthy(ok) +end) From e1a3a24ec9e68b59ce479e4fe53ca803cd9d0e66 Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 02:58:18 -0700 Subject: [PATCH 12/18] feat(sea): export luajit symbols for macos --- packages/sea/src/init.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/sea/src/init.lua b/packages/sea/src/init.lua index c2ef006..05640c7 100644 --- a/packages/sea/src/init.lua +++ b/packages/sea/src/init.lua @@ -285,6 +285,8 @@ int main(int argc, char** argv) { args[#args + 1] = "-lm" args[#args + 1] = "-ldl" args[#args + 1] = "-Wl,--export-dynamic" -- expose lua symbols for lua dependencies + elseif process.platform == "darwin" then + args[#args + 1] = "-Wl,-export_dynamic" -- expose lua symbols for lua dependencies end local compiler = env.var("SEA_CC") or "gcc" From 013968c3aea09d1b185ecbdd56af6f653210d82d Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 03:00:19 -0700 Subject: [PATCH 13/18] tests: skip rockspec native test on windows Not applicable for now until a gcc alternative is used --- packages/lpm/tests/package.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lpm/tests/package.lua b/packages/lpm/tests/package.lua index 4a80f36..66c59c5 100644 --- a/packages/lpm/tests/package.lua +++ b/packages/lpm/tests/package.lua @@ -243,6 +243,7 @@ test.it("rockspec dep: can require(packagename) from a consumer package", functi end) test.it("rockspec native C module: can require and call a C function returning 52", function() + if jit.os == "Windows" then return end fs.mkdir(tmpBase) local rockDir = path.join(tmpBase, "native-rock") From 9d835aa9eb596a8d481d9929269a11f0dd0ca757 Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 03:09:06 -0700 Subject: [PATCH 14/18] tests: add luafilesystem test --- packages/lpm/tests/git.lua | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/lpm/tests/git.lua b/packages/lpm/tests/git.lua index 1da10a3..bd0f08f 100644 --- a/packages/lpm/tests/git.lua +++ b/packages/lpm/tests/git.lua @@ -140,3 +140,35 @@ test.it("rockspec git dep: middleclass can be required after install", function( if not ok then print(err) end test.truthy(ok) end) + +test.it("rockspec git dep: luafilesystem native C module works", function() + if jit.os == "Windows" then return end + + local dir = path.join(tmpBase, "lfs-consumer") + fs.mkdir(dir) + fs.mkdir(path.join(dir, "src")) + fs.write(path.join(dir, "src", "init.lua"), [[ + local lfs = require("lfs") + local attr = lfs.attributes(".") + assert(attr ~= nil, "lfs.attributes returned nil") + assert(attr.mode == "directory", "expected directory, got " .. tostring(attr.mode)) + ]]) + fs.write(path.join(dir, "lpm.json"), json.encode({ + name = "lfs-consumer", + version = "0.1.0", + dependencies = { + luafilesystem = { + git = "https://github.com/lunarmodules/luafilesystem", + branch = "master", + } + } + })) + + local pkg = lpm.Package.open(dir) + pkg:installDependencies() + pkg:build() + + local ok, err = pkg:runFile() + if not ok then print(err) end + test.truthy(ok) +end) From 69892397d855b8bbe4ff4df4c0620ab1d167821f Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 03:13:23 -0700 Subject: [PATCH 15/18] feat: download luajit and pass to module when compiling C modules Reuses SEA's logic w/ lj-dist --- packages/lpm-core/src/package/init.lua | 2 ++ packages/sea/src/init.lua | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/lpm-core/src/package/init.lua b/packages/lpm-core/src/package/init.lua index 57c0b25..6d69e59 100644 --- a/packages/lpm-core/src/package/init.lua +++ b/packages/lpm-core/src/package/init.lua @@ -1,6 +1,7 @@ local Config = require("lpm-core.config") local Lockfile = require("lpm-core.lockfile") local rocked = require("rocked") +local sea = require("sea") local global = require("lpm-core.global") @@ -158,6 +159,7 @@ function Package.openRockspec(dir, rockspecPath) local ok, err = process.exec("gcc", { "-shared", "-fPIC", + "-I" .. path.join(sea.getLuajitPath(), "include"), srcAbs, "-o", destAbs, }) diff --git a/packages/sea/src/init.lua b/packages/sea/src/init.lua index 05640c7..4457440 100644 --- a/packages/sea/src/init.lua +++ b/packages/sea/src/init.lua @@ -77,6 +77,8 @@ end +sea.getLuajitPath = getLuajitPath + local CEscapes = { ["\a"] = "\\a", ["\b"] = "\\b", From a471c822c506cdead3613e6be0e9abf02f3fd8ee Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 03:26:57 -0700 Subject: [PATCH 16/18] refactor: move src copying logic to default build fn So now build functions do not get the copied over src folder. --- packages/lpm-core/src/package/build.lua | 2 -- packages/lpm-core/src/package/init.lua | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lpm-core/src/package/build.lua b/packages/lpm-core/src/package/build.lua index 9b91a2e..00254f7 100644 --- a/packages/lpm-core/src/package/build.lua +++ b/packages/lpm-core/src/package/build.lua @@ -21,8 +21,6 @@ local function buildPackage(package, destinationPath) end if package:hasBuildScript() then - fs.copy(package:getSrcDir(), destinationPath) - local ok, err = package:runBuildScript(destinationPath) if not ok then error("Build script failed for package '" .. package:getName() .. "': " .. err) diff --git a/packages/lpm-core/src/package/init.lua b/packages/lpm-core/src/package/init.lua index 6d69e59..05de799 100644 --- a/packages/lpm-core/src/package/init.lua +++ b/packages/lpm-core/src/package/init.lua @@ -48,6 +48,8 @@ function Package:getLockfilePath() return path.join(self.dir, "lpm-lock.json") e ---@param pkg lpm.Package ---@param outputDir string local function defaultBuildFn(pkg, outputDir) + fs.copy(pkg:getSrcDir(), outputDir) + local buildScriptPath = pkg:getBuildScriptPath() if not fs.exists(buildScriptPath) then return nil, "No build script found: " .. buildScriptPath From f120772129b1acf7ca21a8751cd3d79205cc78e7 Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 03:41:34 -0700 Subject: [PATCH 17/18] feat: handle entrypoint collision case --- packages/lpm-core/src/package/init.lua | 27 +++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/lpm-core/src/package/init.lua b/packages/lpm-core/src/package/init.lua index 05de799..21fbcc4 100644 --- a/packages/lpm-core/src/package/init.lua +++ b/packages/lpm-core/src/package/init.lua @@ -141,12 +141,17 @@ function Package.openRockspec(dir, rockspecPath) for modname, src in pairs(modules) do local srcAbs = path.join(dir, src) local destRel = modname:gsub("%.", path.separator) .. ".lua" + -- Mangle if this would collide with the generated init.lua + if path.join(modulesDir, destRel) == path.join(outputDir, "init.lua") then + destRel = modname:gsub("%.", path.separator):gsub("init$", "__init") .. ".lua" + end local destAbs = path.join(modulesDir, destRel) local destDir = path.dirname(destAbs) if not fs.isdir(destDir) then fs.mkdir(destDir) end fs.copy(srcAbs, destAbs) + modules[modname] = { destRel = destRel, destAbs = destAbs } end for modname, src in pairs(nativeModules) do @@ -170,15 +175,27 @@ function Package.openRockspec(dir, rockspecPath) end end - local lines = {} - for modname in pairs(modules) do + local lines = { + "local _dir = debug.getinfo(1,'S').source:sub(2):match('^(.*/)') or './'", + } + for modname, info in pairs(modules) do table.insert(lines, string.format( - "package.preload[%q] = package.preload[%q] or function() return require(%q) end", - modname, modname, modname + "package.preload[%q] = package.preload[%q] or function() return dofile(_dir .. %q) end", + modname, modname, "../" .. info.destRel )) end if entryModule then - table.insert(lines, string.format("return require(%q)", entryModule)) + local info = modules[entryModule] or modules[entryModule .. ".init"] + if info then + table.insert(lines, string.format("return dofile(_dir .. %q)", "../" .. info.destRel)) + elseif nativeModules[entryModule] then + local ext = process.platform == "darwin" and "dylib" or "so" + table.insert(lines, string.format( + "return package.loadlib(_dir .. %q, %q)()", + "../" .. entryModule:gsub("%.", path.separator) .. "." .. ext, + "luaopen_" .. entryModule:gsub("%.", "_") + )) + end end fs.write(path.join(outputDir, "init.lua"), table.concat(lines, "\n") .. "\n") From 3d064df4c4cf25b1002b250d7e8510708fec0c2b Mon Sep 17 00:00:00 2001 From: David Cruz Date: Wed, 25 Mar 2026 03:45:28 -0700 Subject: [PATCH 18/18] tests: disable macos tests temporarily --- packages/lpm/tests/git.lua | 5 +++-- packages/lpm/tests/package.lua | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/lpm/tests/git.lua b/packages/lpm/tests/git.lua index bd0f08f..3332dd6 100644 --- a/packages/lpm/tests/git.lua +++ b/packages/lpm/tests/git.lua @@ -142,7 +142,8 @@ test.it("rockspec git dep: middleclass can be required after install", function( end) test.it("rockspec git dep: luafilesystem native C module works", function() - if jit.os == "Windows" then return end + -- TODO: Re-enable on MacOS when nightly exports LuaJIT symbols. + if jit.os == "Windows" or jit.os == "OSX" then return end local dir = path.join(tmpBase, "lfs-consumer") fs.mkdir(dir) @@ -159,7 +160,7 @@ test.it("rockspec git dep: luafilesystem native C module works", function() dependencies = { luafilesystem = { git = "https://github.com/lunarmodules/luafilesystem", - branch = "master", + branch = "master" } } })) diff --git a/packages/lpm/tests/package.lua b/packages/lpm/tests/package.lua index 66c59c5..adf6e66 100644 --- a/packages/lpm/tests/package.lua +++ b/packages/lpm/tests/package.lua @@ -243,7 +243,8 @@ test.it("rockspec dep: can require(packagename) from a consumer package", functi end) test.it("rockspec native C module: can require and call a C function returning 52", function() - if jit.os == "Windows" then return end + -- TODO: Re-enable on MacOS when nightly exports LuaJIT symbols. + if jit.os == "Windows" or jit.os == "OSX" then return end fs.mkdir(tmpBase) local rockDir = path.join(tmpBase, "native-rock")