fix(lua): change search paths to resolve lua modules in config dir (#2879)

change search paths to not resolve lua modules in cwd but in the config dir
This commit is contained in:
Jo
2026-06-19 12:04:12 +02:00
committed by GitHub
parent 8fcaff9e6f
commit 88c72b32b1
4 changed files with 153 additions and 0 deletions

View File

@@ -2,6 +2,8 @@
yay can optionally load a Lua configuration file, `init.lua`. `init.lua` overlays whatever is in `config.json`, and any command-line flag you pass still wins over `init.lua`.
yay uses the [Lua 5.1 interpreter](https://www.lua.org/manual/5.1/) to run `init.lua`. The Lua standard library is available.
## Location
`init.lua` is looked up, in order:
@@ -67,6 +69,44 @@ yay.log.error("policy check failed")
message and does not stop execution; use `yay.abort("message")` for controlled
hook stops.
## Requiring modules with `require()`
<p class="api-since">Available from yay v13.0.1</p>
`init.lua` can pull in other Lua files with the standard `require()`
function. yay prepends the directory that contains `init.lua` to
`package.path`, so modules resolve relative to your yay config directory
rather than the directory you run yay from.
Given this layout:
```
$XDG_CONFIG_HOME/yay/
init.lua
hooks/
maintainer_change.lua
```
`init.lua` can do:
```lua
require("hooks.maintainer_change")
```
`require("name")` looks up, in order:
1. `<config_dir>/name.lua`
2. `<config_dir>/name/init.lua`
Dotted module names map onto the filesystem, so
`require("hooks.maintainer_change")` loads
`<config_dir>/hooks/maintainer_change.lua`. This lets you split hooks and
helpers across multiple files and keep `init.lua` small.
Required modules run in the same Lua state as `init.lua`, so anything they
register through `yay.create_autocmd` or assign to `yay.opt` takes effect just
as if it were written inline.
## Upgrade selection hooks
<p class="api-since">Available from yay v13.0.0</p>

View File

@@ -3,6 +3,7 @@ package lua
import (
"errors"
"fmt"
"path/filepath"
"github.com/Jguer/yay/v13/pkg/text"
)
@@ -15,6 +16,7 @@ func Load(logger *text.Logger, path string, cfg any) (*Engine, error) {
}
engine := NewWithLogger(luaLogger)
engine.SetSearchDir(filepath.Dir(path))
if err := engine.L.DoFile(path); err != nil {
engine.Close()

View File

@@ -6,6 +6,7 @@ import (
"testing"
"github.com/stretchr/testify/require"
lua "github.com/yuin/gopher-lua"
)
func writeLuaFile(t *testing.T, body string) string {
@@ -75,3 +76,80 @@ func TestLoadReturnsLiveEngine(t *testing.T) {
require.Equal(t, "/tmp/yay", cfg.BuildDir)
require.True(t, engine.HasAutocmd(EventAURPreInstall))
}
func TestLoadIntoResolvesRequireRelativeToConfigDir(t *testing.T) {
t.Parallel()
dir := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(dir, "hooks"), 0o755))
hookBody := []byte(`
yay.create_autocmd("AURPreInstall", {
desc = "from required module",
callback = function() end,
})
`)
require.NoError(t, os.WriteFile(filepath.Join(dir, "hooks", "maintainer_change.lua"), hookBody, 0o600))
initBody := []byte(`
require("hooks.maintainer_change")
yay.opt.build_dir = "/tmp/yay"
`)
require.NoError(t, os.WriteFile(filepath.Join(dir, "init.lua"), initBody, 0o600))
cfg := &testConfig{}
// Run from a different working directory to prove require resolves
// relative to the config dir, not the process CWD.
wd, err := os.Getwd()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Chdir(wd) })
require.NoError(t, os.Chdir(t.TempDir()))
err = LoadInto(nil, filepath.Join(dir, "init.lua"), cfg)
require.NoError(t, err)
require.Equal(t, "/tmp/yay", cfg.BuildDir)
}
func TestLoadDoesNotResolveRequireFromCWD(t *testing.T) {
t.Parallel()
// A module that only exists in the process CWD must NOT be resolvable,
// since the CWD is not the location of init.lua.
cwd := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(cwd, "stray.lua"), []byte(`return {}`), 0o600))
wd, err := os.Getwd()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Chdir(wd) })
require.NoError(t, os.Chdir(cwd))
path := writeLuaFile(t, `require("stray")`)
cfg := &testConfig{}
err = LoadInto(nil, path, cfg)
require.Error(t, err)
require.Contains(t, err.Error(), "stray")
}
func TestSetSearchDirBuildsPackagePath(t *testing.T) {
t.Parallel()
engine := New()
t.Cleanup(engine.Close)
pkg := engine.L.GetGlobal("package")
original := string(engine.L.GetField(pkg, "path").(lua.LString))
const dir = "/etc/yay"
engine.SetSearchDir(dir)
got := string(engine.L.GetField(pkg, "path").(lua.LString))
// The produced path is the init.lua directory patterns followed by the
// absolute system entries — never the default "./?.lua" CWD entry.
expected := filepath.Join(dir, "?.lua") + ";" +
filepath.Join(dir, "?", "init.lua") + ";" +
absolutePatterns(original)
require.Equal(t, expected, got)
}

View File

@@ -3,7 +3,9 @@ package lua
import (
"fmt"
"path/filepath"
"reflect"
"strings"
"github.com/Jguer/yay/v13/pkg/text"
@@ -107,6 +109,37 @@ func (e *Engine) optTable() (*lua.LTable, bool) {
return optTbl, ok
}
// SetSearchDir makes require() resolve modules relative to dir (e.g.
// require("hooks.maintainer_change")) and drops the default "./?.lua" entry so
// require() searches only dir and the absolute system paths, never an unrelated
// CWD. When dir is the CWD, the prepended patterns already cover it.
func (e *Engine) SetSearchDir(dir string) {
pkg := e.L.GetGlobal("package")
current, ok := e.L.GetField(pkg, "path").(lua.LString)
if !ok {
return
}
pattern := filepath.Join(dir, "?.lua") + ";" + filepath.Join(dir, "?", "init.lua")
e.L.SetField(pkg, "path", lua.LString(pattern+";"+absolutePatterns(string(current))))
}
// absolutePatterns keeps only the absolute entries of a package.path string,
// dropping CWD-relative ones such as "./?.lua".
func absolutePatterns(path string) string {
segments := strings.Split(path, ";")
kept := segments[:0]
for _, seg := range segments {
if filepath.IsAbs(seg) {
kept = append(kept, seg)
}
}
return strings.Join(kept, ";")
}
func luaKeyForField(field *reflect.StructField) string {
name := field.Tag.Get("lua")
if name != "" && name != "-" {