From 88c72b32b1517d79ec718dcf768eeef0df90cedd Mon Sep 17 00:00:00 2001 From: Jo Date: Fri, 19 Jun 2026 12:04:12 +0200 Subject: [PATCH] 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 --- doc/lua.md | 40 ++++++++++++++++++ pkg/settings/lua/load.go | 2 + pkg/settings/lua/load_test.go | 78 +++++++++++++++++++++++++++++++++++ pkg/settings/lua/lua.go | 33 +++++++++++++++ 4 files changed, 153 insertions(+) diff --git a/doc/lua.md b/doc/lua.md index 4a01cc91..366c3f70 100644 --- a/doc/lua.md +++ b/doc/lua.md @@ -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()` + +

Available from yay v13.0.1

+ +`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. `/name.lua` +2. `/name/init.lua` + +Dotted module names map onto the filesystem, so +`require("hooks.maintainer_change")` loads +`/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

Available from yay v13.0.0

diff --git a/pkg/settings/lua/load.go b/pkg/settings/lua/load.go index 74901c01..cb5c4c0c 100644 --- a/pkg/settings/lua/load.go +++ b/pkg/settings/lua/load.go @@ -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() diff --git a/pkg/settings/lua/load_test.go b/pkg/settings/lua/load_test.go index b2c13e9a..f4b50894 100644 --- a/pkg/settings/lua/load_test.go +++ b/pkg/settings/lua/load_test.go @@ -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) +} diff --git a/pkg/settings/lua/lua.go b/pkg/settings/lua/lua.go index d1a41196..de63c7c2 100644 --- a/pkg/settings/lua/lua.go +++ b/pkg/settings/lua/lua.go @@ -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 != "-" {