Skip to content

Commit 328f4b4

Browse files
authored
feat(lua): populate struct slice options from name-keyed tables (#2959)
1 parent 514b1df commit 328f4b4

2 files changed

Lines changed: 264 additions & 10 deletions

File tree

pkg/settings/lua/lua.go

Lines changed: 135 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"path/filepath"
77
"reflect"
8+
"slices"
89
"strings"
910

1011
"github.com/Jguer/yay/v13/pkg/text"
@@ -61,16 +62,7 @@ func (e *Engine) Apply(cfg any) (unknown []string, errs []error) {
6162
}
6263

6364
sv := v.Elem()
64-
st := sv.Type()
65-
66-
index := make(map[string]int, st.NumField())
67-
68-
for i := range st.NumField() {
69-
field := st.Field(i)
70-
if name := luaKeyForField(&field); name != "" {
71-
index[name] = i
72-
}
73-
}
65+
index := luaFieldIndex(sv.Type())
7466

7567
optTbl, ok := e.optTable()
7668
if !ok {
@@ -149,6 +141,20 @@ func luaKeyForField(field *reflect.StructField) string {
149141
return ""
150142
}
151143

144+
// luaFieldIndex maps each lua-tagged field name of st to its field index.
145+
func luaFieldIndex(st reflect.Type) map[string]int {
146+
index := make(map[string]int, st.NumField())
147+
148+
for i := range st.NumField() {
149+
field := st.Field(i)
150+
if name := luaKeyForField(&field); name != "" {
151+
index[name] = i
152+
}
153+
}
154+
155+
return index
156+
}
157+
152158
func assign(field reflect.Value, val lua.LValue) error {
153159
switch field.Kind() {
154160
case reflect.String:
@@ -172,9 +178,128 @@ func assign(field reflect.Value, val lua.LValue) error {
172178
}
173179

174180
field.SetInt(int64(n))
181+
case reflect.Slice:
182+
return assignStructSlice(field, val)
175183
default:
176184
return fmt.Errorf("unsupported field kind %s", field.Kind())
177185
}
178186

179187
return nil
180188
}
189+
190+
// assignStructSlice fills a []Struct field from a Lua table keyed by name, e.g.
191+
//
192+
// { ["core"] = { url = "..." }, ["extra"] = { url = "..." } }
193+
//
194+
// Each entry becomes one struct: the table key populates the element's
195+
// lua:"name" field and the sub-table populates the remaining fields. Entries
196+
// are sorted by name so the resulting slice is deterministic despite Lua's
197+
// unordered table iteration.
198+
func assignStructSlice(field reflect.Value, val lua.LValue) error {
199+
elemType := field.Type().Elem()
200+
if elemType.Kind() != reflect.Struct {
201+
return fmt.Errorf("unsupported slice element kind %s", elemType.Kind())
202+
}
203+
204+
tbl, ok := val.(*lua.LTable)
205+
if !ok {
206+
return fmt.Errorf("expected table, got %s", val.Type())
207+
}
208+
209+
// The name comes from the table key, so a "name" key inside the entry table
210+
// would silently override it and let two entries share one name. Drop it
211+
// from the assignable set so it is reported as an unknown key instead.
212+
fieldIndex := luaFieldIndex(elemType)
213+
nameIdx, hasName := fieldIndex["name"]
214+
delete(fieldIndex, "name")
215+
216+
type namedElem struct {
217+
name string
218+
elem reflect.Value
219+
}
220+
221+
var (
222+
entries []namedElem
223+
firstErr error
224+
)
225+
226+
tbl.ForEach(func(k, entry lua.LValue) {
227+
if firstErr != nil {
228+
return
229+
}
230+
231+
name, ok := k.(lua.LString)
232+
if !ok {
233+
firstErr = fmt.Errorf("entry keys must be strings, got %s", k.Type())
234+
return
235+
}
236+
237+
entryTbl, ok := entry.(*lua.LTable)
238+
if !ok {
239+
firstErr = fmt.Errorf("entry %q must be a table, got %s", string(name), entry.Type())
240+
return
241+
}
242+
243+
elem := reflect.New(elemType).Elem()
244+
if hasName {
245+
elem.Field(nameIdx).SetString(string(name))
246+
}
247+
248+
if err := assignStructFields(elem, entryTbl, fieldIndex); err != nil {
249+
firstErr = fmt.Errorf("entry %q: %w", string(name), err)
250+
return
251+
}
252+
253+
entries = append(entries, namedElem{name: string(name), elem: elem})
254+
})
255+
256+
if firstErr != nil {
257+
return firstErr
258+
}
259+
260+
// Sort by name so the resulting slice is deterministic despite Lua's
261+
// unordered table iteration.
262+
slices.SortFunc(entries, func(a, b namedElem) int {
263+
return strings.Compare(a.name, b.name)
264+
})
265+
266+
out := reflect.MakeSlice(field.Type(), len(entries), len(entries))
267+
for i, entry := range entries {
268+
out.Index(i).Set(entry.elem)
269+
}
270+
271+
field.Set(out)
272+
273+
return nil
274+
}
275+
276+
// assignStructFields assigns the entries of tbl onto struct value sv, matching
277+
// each key against the lua:"..." tags in index. Unknown keys are errors so
278+
// typos in nested option tables fail fast, mirroring top-level opt handling.
279+
func assignStructFields(sv reflect.Value, tbl *lua.LTable, index map[string]int) error {
280+
var firstErr error
281+
282+
tbl.ForEach(func(k, entry lua.LValue) {
283+
if firstErr != nil {
284+
return
285+
}
286+
287+
key, ok := k.(lua.LString)
288+
if !ok {
289+
firstErr = fmt.Errorf("keys must be strings, got %s", k.Type())
290+
return
291+
}
292+
293+
fieldIdx, found := index[string(key)]
294+
if !found {
295+
firstErr = fmt.Errorf("unknown key %q", string(key))
296+
return
297+
}
298+
299+
if err := assign(sv.Field(fieldIdx), entry); err != nil {
300+
firstErr = fmt.Errorf("%s: %w", string(key), err)
301+
}
302+
})
303+
304+
return firstErr
305+
}

pkg/settings/lua/lua_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,135 @@ func TestApplyAppliesAnswerOptionsFromLua(t *testing.T) {
8383
assert.Equal(t, "Installed", cfg.AnswerEdit)
8484
}
8585

86+
type namedSliceTestConfig struct {
87+
BuildDir string `lua:"build_dir"`
88+
Repos []namedSliceRepo `lua:"repos"`
89+
}
90+
91+
type namedSliceRepo struct {
92+
Name string `lua:"name"`
93+
URL string `lua:"url"`
94+
Depth int `lua:"depth"`
95+
}
96+
97+
func TestApplyNamedStructSlice(t *testing.T) {
98+
t.Parallel()
99+
e := New()
100+
t.Cleanup(e.Close)
101+
102+
require.NoError(t, e.L.DoString(`
103+
yay.opt.build_dir = "/tmp/yay"
104+
yay.opt.repos = {
105+
["yay-pkgbuild"] = {
106+
url = "https://github.com/Jguer/yay-PKGBUILD",
107+
depth = 2,
108+
},
109+
["local-repo"] = {
110+
url = "file:///srv/pkgbuilds",
111+
},
112+
}
113+
`))
114+
115+
cfg := &namedSliceTestConfig{}
116+
unknown, errs := e.Apply(cfg)
117+
118+
assert.Empty(t, unknown)
119+
assert.Empty(t, errs)
120+
assert.Equal(t, "/tmp/yay", cfg.BuildDir)
121+
122+
// The keyed table becomes a slice sorted by repo name for determinism.
123+
require.Len(t, cfg.Repos, 2)
124+
125+
assert.Equal(t, "local-repo", cfg.Repos[0].Name)
126+
assert.Equal(t, "file:///srv/pkgbuilds", cfg.Repos[0].URL)
127+
assert.Equal(t, 0, cfg.Repos[0].Depth)
128+
129+
assert.Equal(t, "yay-pkgbuild", cfg.Repos[1].Name)
130+
assert.Equal(t, "https://github.com/Jguer/yay-PKGBUILD", cfg.Repos[1].URL)
131+
assert.Equal(t, 2, cfg.Repos[1].Depth)
132+
}
133+
134+
func TestApplyNamedStructSliceRejectsUnknownKey(t *testing.T) {
135+
t.Parallel()
136+
e := New()
137+
t.Cleanup(e.Close)
138+
139+
require.NoError(t, e.L.DoString(`
140+
yay.opt.repos = {
141+
["yay-pkgbuild"] = {
142+
url = "https://github.com/Jguer/yay-PKGBUILD",
143+
nonsense = true,
144+
},
145+
}
146+
`))
147+
148+
cfg := &namedSliceTestConfig{}
149+
_, errs := e.Apply(cfg)
150+
151+
assert.Len(t, errs, 1)
152+
}
153+
154+
func TestApplyNamedStructSliceRejectsNameKeyInsideEntry(t *testing.T) {
155+
t.Parallel()
156+
e := New()
157+
t.Cleanup(e.Close)
158+
159+
require.NoError(t, e.L.DoString(`
160+
yay.opt.repos = {
161+
["yay-pkgbuild"] = {
162+
name = "something-else",
163+
url = "https://github.com/Jguer/yay-PKGBUILD",
164+
},
165+
}
166+
`))
167+
168+
cfg := &namedSliceTestConfig{}
169+
_, errs := e.Apply(cfg)
170+
171+
require.Len(t, errs, 1)
172+
assert.ErrorContains(t, errs[0], `unknown key "name"`)
173+
}
174+
175+
type nonStructSliceTestConfig struct {
176+
Tags []string `lua:"tags"`
177+
}
178+
179+
func TestApplyRejectsSliceOfNonStructs(t *testing.T) {
180+
t.Parallel()
181+
e := New()
182+
t.Cleanup(e.Close)
183+
184+
require.NoError(t, e.L.DoString(`
185+
yay.opt.tags = { "a", "b" }
186+
`))
187+
188+
cfg := &nonStructSliceTestConfig{}
189+
_, errs := e.Apply(cfg)
190+
191+
require.Len(t, errs, 1)
192+
assert.ErrorContains(t, errs[0], "unsupported slice element kind string")
193+
assert.Empty(t, cfg.Tags)
194+
}
195+
196+
func TestApplyNamedStructSliceRejectsArrayStyleTable(t *testing.T) {
197+
t.Parallel()
198+
e := New()
199+
t.Cleanup(e.Close)
200+
201+
require.NoError(t, e.L.DoString(`
202+
yay.opt.repos = {
203+
{ url = "a" },
204+
{ url = "b" },
205+
}
206+
`))
207+
208+
cfg := &namedSliceTestConfig{}
209+
_, errs := e.Apply(cfg)
210+
211+
require.Len(t, errs, 1)
212+
assert.ErrorContains(t, errs[0], "entry keys must be strings")
213+
}
214+
86215
func TestApplyRejectsNonPointer(t *testing.T) {
87216
t.Parallel()
88217
e := New()

0 commit comments

Comments
 (0)