diff --git a/pkg/settings/lua/lua.go b/pkg/settings/lua/lua.go index de63c7c2..de5285cb 100644 --- a/pkg/settings/lua/lua.go +++ b/pkg/settings/lua/lua.go @@ -5,6 +5,7 @@ import ( "fmt" "path/filepath" "reflect" + "slices" "strings" "github.com/Jguer/yay/v13/pkg/text" @@ -61,16 +62,7 @@ func (e *Engine) Apply(cfg any) (unknown []string, errs []error) { } sv := v.Elem() - st := sv.Type() - - index := make(map[string]int, st.NumField()) - - for i := range st.NumField() { - field := st.Field(i) - if name := luaKeyForField(&field); name != "" { - index[name] = i - } - } + index := luaFieldIndex(sv.Type()) optTbl, ok := e.optTable() if !ok { @@ -149,6 +141,20 @@ func luaKeyForField(field *reflect.StructField) string { return "" } +// luaFieldIndex maps each lua-tagged field name of st to its field index. +func luaFieldIndex(st reflect.Type) map[string]int { + index := make(map[string]int, st.NumField()) + + for i := range st.NumField() { + field := st.Field(i) + if name := luaKeyForField(&field); name != "" { + index[name] = i + } + } + + return index +} + func assign(field reflect.Value, val lua.LValue) error { switch field.Kind() { case reflect.String: @@ -172,9 +178,128 @@ func assign(field reflect.Value, val lua.LValue) error { } field.SetInt(int64(n)) + case reflect.Slice: + return assignStructSlice(field, val) default: return fmt.Errorf("unsupported field kind %s", field.Kind()) } return nil } + +// assignStructSlice fills a []Struct field from a Lua table keyed by name, e.g. +// +// { ["core"] = { url = "..." }, ["extra"] = { url = "..." } } +// +// Each entry becomes one struct: the table key populates the element's +// lua:"name" field and the sub-table populates the remaining fields. Entries +// are sorted by name so the resulting slice is deterministic despite Lua's +// unordered table iteration. +func assignStructSlice(field reflect.Value, val lua.LValue) error { + elemType := field.Type().Elem() + if elemType.Kind() != reflect.Struct { + return fmt.Errorf("unsupported slice element kind %s", elemType.Kind()) + } + + tbl, ok := val.(*lua.LTable) + if !ok { + return fmt.Errorf("expected table, got %s", val.Type()) + } + + // The name comes from the table key, so a "name" key inside the entry table + // would silently override it and let two entries share one name. Drop it + // from the assignable set so it is reported as an unknown key instead. + fieldIndex := luaFieldIndex(elemType) + nameIdx, hasName := fieldIndex["name"] + delete(fieldIndex, "name") + + type namedElem struct { + name string + elem reflect.Value + } + + var ( + entries []namedElem + firstErr error + ) + + tbl.ForEach(func(k, entry lua.LValue) { + if firstErr != nil { + return + } + + name, ok := k.(lua.LString) + if !ok { + firstErr = fmt.Errorf("entry keys must be strings, got %s", k.Type()) + return + } + + entryTbl, ok := entry.(*lua.LTable) + if !ok { + firstErr = fmt.Errorf("entry %q must be a table, got %s", string(name), entry.Type()) + return + } + + elem := reflect.New(elemType).Elem() + if hasName { + elem.Field(nameIdx).SetString(string(name)) + } + + if err := assignStructFields(elem, entryTbl, fieldIndex); err != nil { + firstErr = fmt.Errorf("entry %q: %w", string(name), err) + return + } + + entries = append(entries, namedElem{name: string(name), elem: elem}) + }) + + if firstErr != nil { + return firstErr + } + + // Sort by name so the resulting slice is deterministic despite Lua's + // unordered table iteration. + slices.SortFunc(entries, func(a, b namedElem) int { + return strings.Compare(a.name, b.name) + }) + + out := reflect.MakeSlice(field.Type(), len(entries), len(entries)) + for i, entry := range entries { + out.Index(i).Set(entry.elem) + } + + field.Set(out) + + return nil +} + +// assignStructFields assigns the entries of tbl onto struct value sv, matching +// each key against the lua:"..." tags in index. Unknown keys are errors so +// typos in nested option tables fail fast, mirroring top-level opt handling. +func assignStructFields(sv reflect.Value, tbl *lua.LTable, index map[string]int) error { + var firstErr error + + tbl.ForEach(func(k, entry lua.LValue) { + if firstErr != nil { + return + } + + key, ok := k.(lua.LString) + if !ok { + firstErr = fmt.Errorf("keys must be strings, got %s", k.Type()) + return + } + + fieldIdx, found := index[string(key)] + if !found { + firstErr = fmt.Errorf("unknown key %q", string(key)) + return + } + + if err := assign(sv.Field(fieldIdx), entry); err != nil { + firstErr = fmt.Errorf("%s: %w", string(key), err) + } + }) + + return firstErr +} diff --git a/pkg/settings/lua/lua_test.go b/pkg/settings/lua/lua_test.go index a5dd979c..ff279a1b 100644 --- a/pkg/settings/lua/lua_test.go +++ b/pkg/settings/lua/lua_test.go @@ -83,6 +83,135 @@ func TestApplyAppliesAnswerOptionsFromLua(t *testing.T) { assert.Equal(t, "Installed", cfg.AnswerEdit) } +type namedSliceTestConfig struct { + BuildDir string `lua:"build_dir"` + Repos []namedSliceRepo `lua:"repos"` +} + +type namedSliceRepo struct { + Name string `lua:"name"` + URL string `lua:"url"` + Depth int `lua:"depth"` +} + +func TestApplyNamedStructSlice(t *testing.T) { + t.Parallel() + e := New() + t.Cleanup(e.Close) + + require.NoError(t, e.L.DoString(` + yay.opt.build_dir = "/tmp/yay" + yay.opt.repos = { + ["yay-pkgbuild"] = { + url = "https://github.com/Jguer/yay-PKGBUILD", + depth = 2, + }, + ["local-repo"] = { + url = "file:///srv/pkgbuilds", + }, + } + `)) + + cfg := &namedSliceTestConfig{} + unknown, errs := e.Apply(cfg) + + assert.Empty(t, unknown) + assert.Empty(t, errs) + assert.Equal(t, "/tmp/yay", cfg.BuildDir) + + // The keyed table becomes a slice sorted by repo name for determinism. + require.Len(t, cfg.Repos, 2) + + assert.Equal(t, "local-repo", cfg.Repos[0].Name) + assert.Equal(t, "file:///srv/pkgbuilds", cfg.Repos[0].URL) + assert.Equal(t, 0, cfg.Repos[0].Depth) + + assert.Equal(t, "yay-pkgbuild", cfg.Repos[1].Name) + assert.Equal(t, "https://github.com/Jguer/yay-PKGBUILD", cfg.Repos[1].URL) + assert.Equal(t, 2, cfg.Repos[1].Depth) +} + +func TestApplyNamedStructSliceRejectsUnknownKey(t *testing.T) { + t.Parallel() + e := New() + t.Cleanup(e.Close) + + require.NoError(t, e.L.DoString(` + yay.opt.repos = { + ["yay-pkgbuild"] = { + url = "https://github.com/Jguer/yay-PKGBUILD", + nonsense = true, + }, + } + `)) + + cfg := &namedSliceTestConfig{} + _, errs := e.Apply(cfg) + + assert.Len(t, errs, 1) +} + +func TestApplyNamedStructSliceRejectsNameKeyInsideEntry(t *testing.T) { + t.Parallel() + e := New() + t.Cleanup(e.Close) + + require.NoError(t, e.L.DoString(` + yay.opt.repos = { + ["yay-pkgbuild"] = { + name = "something-else", + url = "https://github.com/Jguer/yay-PKGBUILD", + }, + } + `)) + + cfg := &namedSliceTestConfig{} + _, errs := e.Apply(cfg) + + require.Len(t, errs, 1) + assert.ErrorContains(t, errs[0], `unknown key "name"`) +} + +type nonStructSliceTestConfig struct { + Tags []string `lua:"tags"` +} + +func TestApplyRejectsSliceOfNonStructs(t *testing.T) { + t.Parallel() + e := New() + t.Cleanup(e.Close) + + require.NoError(t, e.L.DoString(` + yay.opt.tags = { "a", "b" } + `)) + + cfg := &nonStructSliceTestConfig{} + _, errs := e.Apply(cfg) + + require.Len(t, errs, 1) + assert.ErrorContains(t, errs[0], "unsupported slice element kind string") + assert.Empty(t, cfg.Tags) +} + +func TestApplyNamedStructSliceRejectsArrayStyleTable(t *testing.T) { + t.Parallel() + e := New() + t.Cleanup(e.Close) + + require.NoError(t, e.L.DoString(` + yay.opt.repos = { + { url = "a" }, + { url = "b" }, + } + `)) + + cfg := &namedSliceTestConfig{} + _, errs := e.Apply(cfg) + + require.Len(t, errs, 1) + assert.ErrorContains(t, errs[0], "entry keys must be strings") +} + func TestApplyRejectsNonPointer(t *testing.T) { t.Parallel() e := New()