Files
yay/pkg/settings/lua/autocmd.go
Jo 4869306719 feat(search): implement render-hooks for search (#2896)
* add render hooks

* re-render hook per event

* add example with single_line and simplify built rendering

* update docs availability

* remove comments
2026-07-06 14:15:57 +02:00

671 lines
18 KiB
Go

package lua
import (
"fmt"
mapset "github.com/deckarep/golang-set/v2"
glua "github.com/yuin/gopher-lua"
)
const (
EventAURPreInstall = "AURPreInstall"
EventAURPostDownload = "AURPostDownload"
EventUpgradeSelect = "UpgradeSelect"
EventPostInstall = "PostInstall"
EventSearchFilter = "SearchFilter"
EventRenderAUR = "RenderAUR"
EventRenderSync = "RenderSync"
)
type Autocmd struct {
Event string
Desc string
callback *glua.LFunction
}
type AURPreInstallEvent struct {
Base string
Dir string
PKGBUILDPath string
SRCINFOPath string
PKGBUILD string
Version string
LastModified int64
Installed bool
Packages []AURPreInstallPackage
SRCINFO AURPreInstallSRCINFO
}
type AURPreInstallPackage struct {
Name string
Version string
LocalVersion string
Reason string
Upgrade bool
Devel bool
}
type AURPreInstallSRCINFO struct {
Pkgbase string
Pkgver string
Pkgrel string
Epoch string
Version string
Pkgdesc string
URL string
Arch []string
License []string
Depends []string
MakeDepends []string
CheckDepends []string
OptDepends []string
Provides []string
Conflicts []string
Replaces []string
}
type UpgradeSelectEvent struct {
Upgrades []UpgradeSelectPackage
PulledDependencies []UpgradeSelectPackage
}
type UpgradeSelectPackage struct {
ID int
Name string
Base string
Repository string
LocalVersion string
RemoteVersion string
Reason string
LastModified int64
Maintainer string
}
type UpgradeSelectResult struct {
Exclude []string
SkipMenu bool
}
type PostInstallEvent struct {
Packages []PostInstallPackage
}
type PostInstallPackage struct {
Name string
Version string
LocalVersion string
Source string
Reason string
}
type SearchFilterEvent struct {
Results []SearchResultPackage
}
type SearchResultPackage struct {
Source string
Name string
Description string
Base string
Votes int
Popularity float64
FirstSubmitted int
LastModified int
}
type SearchResultRef struct {
Source string
Name string
}
type RenderAUREvent struct {
Name string
Version string
Description string
Base string
Votes int
Popularity float64
Maintainer string
OutOfDate int
FirstSubmitted int
LastModified int
LocalVersion string
}
type RenderSyncEvent struct {
Repository string
Name string
Description string
Version string
Groups []string
LocalVersion string
}
func (e *Engine) createAutocmd(state *glua.LState) int {
event := state.CheckString(1)
if event != EventAURPreInstall && event != EventAURPostDownload &&
event != EventUpgradeSelect && event != EventPostInstall && event != EventSearchFilter &&
event != EventRenderAUR && event != EventRenderSync {
state.ArgError(1, fmt.Sprintf("unsupported event %q", event))
return 0
}
opts := state.CheckTable(2)
callback := state.GetField(opts, "callback")
fn, ok := callback.(*glua.LFunction)
if !ok {
state.ArgError(2, "callback must be a function")
return 0
}
desc := ""
if val := state.GetField(opts, "desc"); val != glua.LNil {
str, ok := val.(glua.LString)
if !ok {
state.ArgError(2, "desc must be a string")
return 0
}
desc = string(str)
}
e.autocmds[event] = append(e.autocmds[event], Autocmd{
Event: event,
Desc: desc,
callback: fn,
})
return 0
}
func (e *Engine) HasAutocmd(event string) bool {
return e != nil && len(e.autocmds[event]) > 0
}
func (e *Engine) RunAURPreInstall(event *AURPreInstallEvent) error {
return e.runAUREvent(EventAURPreInstall, event)
}
func (e *Engine) RunAURPostDownload(event *AURPreInstallEvent) error {
return e.runAUREvent(EventAURPostDownload, event)
}
func (e *Engine) runAUREvent(eventName string, event *AURPreInstallEvent) error {
if !e.HasAutocmd(eventName) {
return nil
}
for _, autocmd := range e.autocmds[eventName] {
if err := e.L.CallByParam(glua.P{
Fn: autocmd.callback,
NRet: 0,
Protect: true,
}, e.aurEventTable(eventName, event)); err != nil {
return fmt.Errorf("%s %s: %w", eventName, event.Base, wrapLuaErr(err))
}
}
return nil
}
func (e *Engine) RunUpgradeSelect(event *UpgradeSelectEvent) (UpgradeSelectResult, error) {
var result UpgradeSelectResult
if !e.HasAutocmd(EventUpgradeSelect) {
return result, nil
}
validExcludes := mapset.NewThreadUnsafeSetWithSize[string](len(event.Upgrades))
for i := range event.Upgrades {
validExcludes.Add(event.Upgrades[i].Name)
}
seenExcludes := mapset.NewThreadUnsafeSet[string]()
for _, autocmd := range e.autocmds[EventUpgradeSelect] {
if err := e.L.CallByParam(glua.P{
Fn: autocmd.callback,
NRet: 1,
Protect: true,
}, e.upgradeSelectTable(event)); err != nil {
return result, fmt.Errorf("%s: %w", EventUpgradeSelect, wrapLuaErr(err))
}
value := e.L.Get(-1)
e.L.Pop(1)
hookResult, err := e.parseUpgradeSelectResult(value, validExcludes)
if err != nil {
return result, fmt.Errorf("%s: %w", EventUpgradeSelect, err)
}
for _, name := range hookResult.Exclude {
if !seenExcludes.Add(name) {
continue
}
result.Exclude = append(result.Exclude, name)
}
if hookResult.SkipMenu {
result.SkipMenu = true
}
}
return result, nil
}
func (e *Engine) newEventTable(eventName string) (eventTable, data *glua.LTable) {
eventTable = e.L.NewTable()
data = e.L.NewTable()
eventTable.RawSetString("event", glua.LString(eventName))
eventTable.RawSetString("data", data)
return eventTable, data
}
func (e *Engine) aurEventTable(eventName string, event *AURPreInstallEvent) *glua.LTable {
eventTable, data := e.newEventTable(eventName)
eventTable.RawSetString("match", glua.LString(event.Base))
data.RawSetString("base", glua.LString(event.Base))
data.RawSetString("dir", glua.LString(event.Dir))
data.RawSetString("pkgbuild_path", glua.LString(event.PKGBUILDPath))
data.RawSetString("srcinfo_path", glua.LString(event.SRCINFOPath))
data.RawSetString("pkgbuild", glua.LString(event.PKGBUILD))
data.RawSetString("version", glua.LString(event.Version))
data.RawSetString("last_modified", glua.LNumber(event.LastModified))
data.RawSetString("installed", glua.LBool(event.Installed))
data.RawSetString("packages", e.packagesTable(event.Packages))
data.RawSetString("srcinfo", e.srcinfoTable(&event.SRCINFO))
return eventTable
}
func (e *Engine) upgradeSelectTable(event *UpgradeSelectEvent) *glua.LTable {
eventTable, data := e.newEventTable(EventUpgradeSelect)
data.RawSetString("upgrades", e.upgradeSelectPackagesTable(event.Upgrades))
data.RawSetString("pulled_dependencies", e.upgradeSelectPackagesTable(event.PulledDependencies))
return eventTable
}
func (e *Engine) packagesTable(packages []AURPreInstallPackage) *glua.LTable {
state := e.L
tbl := state.NewTable()
for _, pkg := range packages {
pkgTbl := state.NewTable()
pkgTbl.RawSetString("name", glua.LString(pkg.Name))
pkgTbl.RawSetString("version", glua.LString(pkg.Version))
pkgTbl.RawSetString("local_version", glua.LString(pkg.LocalVersion))
pkgTbl.RawSetString("reason", glua.LString(pkg.Reason))
pkgTbl.RawSetString("upgrade", glua.LBool(pkg.Upgrade))
pkgTbl.RawSetString("devel", glua.LBool(pkg.Devel))
tbl.Append(pkgTbl)
}
return tbl
}
func (e *Engine) upgradeSelectPackagesTable(packages []UpgradeSelectPackage) *glua.LTable {
state := e.L
tbl := state.NewTable()
for i := range packages {
pkg := &packages[i]
pkgTbl := state.NewTable()
pkgTbl.RawSetString("id", glua.LNumber(pkg.ID))
pkgTbl.RawSetString("name", glua.LString(pkg.Name))
pkgTbl.RawSetString("base", glua.LString(pkg.Base))
pkgTbl.RawSetString("repository", glua.LString(pkg.Repository))
pkgTbl.RawSetString("local_version", glua.LString(pkg.LocalVersion))
pkgTbl.RawSetString("remote_version", glua.LString(pkg.RemoteVersion))
pkgTbl.RawSetString("reason", glua.LString(pkg.Reason))
pkgTbl.RawSetString("last_modified", glua.LNumber(pkg.LastModified))
pkgTbl.RawSetString("maintainer", glua.LString(pkg.Maintainer))
tbl.Append(pkgTbl)
}
return tbl
}
func (e *Engine) srcinfoTable(srcinfo *AURPreInstallSRCINFO) *glua.LTable {
state := e.L
tbl := state.NewTable()
tbl.RawSetString("pkgbase", glua.LString(srcinfo.Pkgbase))
tbl.RawSetString("pkgver", glua.LString(srcinfo.Pkgver))
tbl.RawSetString("pkgrel", glua.LString(srcinfo.Pkgrel))
tbl.RawSetString("epoch", glua.LString(srcinfo.Epoch))
tbl.RawSetString("version", glua.LString(srcinfo.Version))
tbl.RawSetString("pkgdesc", glua.LString(srcinfo.Pkgdesc))
tbl.RawSetString("url", glua.LString(srcinfo.URL))
tbl.RawSetString("arch", e.stringArray(srcinfo.Arch))
tbl.RawSetString("license", e.stringArray(srcinfo.License))
tbl.RawSetString("depends", e.stringArray(srcinfo.Depends))
tbl.RawSetString("makedepends", e.stringArray(srcinfo.MakeDepends))
tbl.RawSetString("checkdepends", e.stringArray(srcinfo.CheckDepends))
tbl.RawSetString("optdepends", e.stringArray(srcinfo.OptDepends))
tbl.RawSetString("provides", e.stringArray(srcinfo.Provides))
tbl.RawSetString("conflicts", e.stringArray(srcinfo.Conflicts))
tbl.RawSetString("replaces", e.stringArray(srcinfo.Replaces))
return tbl
}
func (e *Engine) parseUpgradeSelectResult(value glua.LValue, validExcludes mapset.Set[string]) (UpgradeSelectResult, error) {
var result UpgradeSelectResult
if value == glua.LNil {
return result, nil
}
tbl, ok := value.(*glua.LTable)
if !ok {
return result, fmt.Errorf("callback must return nil or table, got %s", value.Type())
}
if excludeValue := tbl.RawGetString("exclude"); excludeValue != glua.LNil {
excludeTbl, ok := excludeValue.(*glua.LTable)
if !ok {
return result, fmt.Errorf("exclude must be a table")
}
var parseErr error
excludeTbl.ForEach(func(_ glua.LValue, val glua.LValue) {
if parseErr != nil {
return
}
lname, ok := val.(glua.LString)
if !ok {
parseErr = fmt.Errorf("exclude entries must be strings")
return
}
name := string(lname)
if !validExcludes.Contains(name) {
parseErr = fmt.Errorf("unknown upgrade exclusion %q", name)
return
}
result.Exclude = append(result.Exclude, name)
})
if parseErr != nil {
return result, parseErr
}
}
if skipMenuValue := tbl.RawGetString("skip_menu"); skipMenuValue != glua.LNil {
skipMenu, ok := skipMenuValue.(glua.LBool)
if !ok {
return result, fmt.Errorf("skip_menu must be a boolean")
}
result.SkipMenu = bool(skipMenu)
}
return result, nil
}
func (e *Engine) stringArray(values []string) *glua.LTable {
tbl := e.L.NewTable()
for _, value := range values {
tbl.Append(glua.LString(value))
}
return tbl
}
func (e *Engine) RunPostInstall(event *PostInstallEvent) error {
if !e.HasAutocmd(EventPostInstall) {
return nil
}
for _, autocmd := range e.autocmds[EventPostInstall] {
if err := e.L.CallByParam(glua.P{
Fn: autocmd.callback,
NRet: 0,
Protect: true,
}, e.postInstallTable(event)); err != nil {
return fmt.Errorf("%s: %w", EventPostInstall, wrapLuaErr(err))
}
}
return nil
}
func (e *Engine) RunSearchFilter(event *SearchFilterEvent) ([]SearchResultRef, error) {
if !e.HasAutocmd(EventSearchFilter) {
return nil, nil
}
active := event.Results
for _, autocmd := range e.autocmds[EventSearchFilter] {
// Rebuild the validation map from the current active set so that a
// later hook cannot reintroduce packages that an earlier hook dropped.
activeByRef := make(map[SearchResultRef]int, len(active))
for i, pkg := range active {
activeByRef[SearchResultRef{Source: pkg.Source, Name: pkg.Name}] = i
}
if err := e.L.CallByParam(glua.P{
Fn: autocmd.callback,
NRet: 1,
Protect: true,
}, e.searchFilterTable(active)); err != nil {
return nil, fmt.Errorf("%s: %w", EventSearchFilter, wrapLuaErr(err))
}
value := e.L.Get(-1)
e.L.Pop(1)
refs, returned, err := parseSearchFilterResult(value, activeByRef)
if err != nil {
return nil, fmt.Errorf("%s: %w", EventSearchFilter, err)
}
if !returned {
continue
}
next := make([]SearchResultPackage, 0, len(refs))
for _, ref := range refs {
next = append(next, active[activeByRef[ref]])
}
active = next
}
result := make([]SearchResultRef, len(active))
for i, pkg := range active {
result[i] = SearchResultRef{Source: pkg.Source, Name: pkg.Name}
}
return result, nil
}
func (e *Engine) postInstallTable(event *PostInstallEvent) *glua.LTable {
eventTable, data := e.newEventTable(EventPostInstall)
data.RawSetString("packages", e.postInstallPackagesTable(event.Packages))
return eventTable
}
func (e *Engine) postInstallPackagesTable(packages []PostInstallPackage) *glua.LTable {
tbl := e.L.NewTable()
for i := range packages {
pkg := &packages[i]
pkgTbl := e.L.NewTable()
pkgTbl.RawSetString("name", glua.LString(pkg.Name))
pkgTbl.RawSetString("version", glua.LString(pkg.Version))
pkgTbl.RawSetString("local_version", glua.LString(pkg.LocalVersion))
pkgTbl.RawSetString("source", glua.LString(pkg.Source))
pkgTbl.RawSetString("reason", glua.LString(pkg.Reason))
tbl.Append(pkgTbl)
}
return tbl
}
func (e *Engine) searchFilterTable(packages []SearchResultPackage) *glua.LTable {
eventTable, data := e.newEventTable(EventSearchFilter)
data.RawSetString("results", e.searchResultPackagesTable(packages))
return eventTable
}
func (e *Engine) searchResultPackagesTable(packages []SearchResultPackage) *glua.LTable {
tbl := e.L.NewTable()
for i := range packages {
pkg := &packages[i]
pkgTbl := e.L.NewTable()
pkgTbl.RawSetString("source", glua.LString(pkg.Source))
pkgTbl.RawSetString("name", glua.LString(pkg.Name))
pkgTbl.RawSetString("description", glua.LString(pkg.Description))
pkgTbl.RawSetString("base", glua.LString(pkg.Base))
pkgTbl.RawSetString("votes", glua.LNumber(pkg.Votes))
pkgTbl.RawSetString("popularity", glua.LNumber(pkg.Popularity))
pkgTbl.RawSetString("first_submitted", glua.LNumber(pkg.FirstSubmitted))
pkgTbl.RawSetString("last_modified", glua.LNumber(pkg.LastModified))
tbl.Append(pkgTbl)
}
return tbl
}
func (e *Engine) renderAURTable(event *RenderAUREvent) *glua.LTable {
eventTable, data := e.newEventTable(EventRenderAUR)
data.RawSetString("name", glua.LString(event.Name))
data.RawSetString("version", glua.LString(event.Version))
data.RawSetString("description", glua.LString(event.Description))
data.RawSetString("base", glua.LString(event.Base))
data.RawSetString("votes", glua.LNumber(event.Votes))
data.RawSetString("popularity", glua.LNumber(event.Popularity))
data.RawSetString("maintainer", glua.LString(event.Maintainer))
data.RawSetString("out_of_date", glua.LNumber(event.OutOfDate))
data.RawSetString("first_submitted", glua.LNumber(event.FirstSubmitted))
data.RawSetString("last_modified", glua.LNumber(event.LastModified))
data.RawSetString("local_version", glua.LString(event.LocalVersion))
return eventTable
}
func (e *Engine) renderSyncTable(event *RenderSyncEvent) *glua.LTable {
eventTable, data := e.newEventTable(EventRenderSync)
data.RawSetString("repository", glua.LString(event.Repository))
data.RawSetString("name", glua.LString(event.Name))
data.RawSetString("description", glua.LString(event.Description))
data.RawSetString("version", glua.LString(event.Version))
data.RawSetString("groups", e.stringArray(event.Groups))
data.RawSetString("local_version", glua.LString(event.LocalVersion))
return eventTable
}
func (e *Engine) RunRenderAUR(event *RenderAUREvent) (rendered string, ok bool, err error) {
if !e.HasAutocmd(EventRenderAUR) {
return "", false, nil
}
return e.runRender(EventRenderAUR, func() *glua.LTable {
return e.renderAURTable(event)
})
}
func (e *Engine) RunRenderSync(event *RenderSyncEvent) (rendered string, ok bool, err error) {
if !e.HasAutocmd(EventRenderSync) {
return "", false, nil
}
return e.runRender(EventRenderSync, func() *glua.LTable {
return e.renderSyncTable(event)
})
}
func (e *Engine) runRender(eventName string, newEventTable func() *glua.LTable) (rendered string, ok bool, err error) {
for _, autocmd := range e.autocmds[eventName] {
if err := e.L.CallByParam(glua.P{
Fn: autocmd.callback,
NRet: 1,
Protect: true,
}, newEventTable()); err != nil {
return "", false, fmt.Errorf("%s: %w", eventName, wrapLuaErr(err))
}
value := e.L.Get(-1)
e.L.Pop(1)
if value == glua.LNil {
continue
}
str, isStr := value.(glua.LString)
if !isStr {
return "", false, fmt.Errorf("%s: callback must return a string or nil, got %s", eventName, value.Type())
}
rendered = string(str)
ok = true
}
return rendered, ok, nil
}
func parseSearchFilterResult(value glua.LValue, valid map[SearchResultRef]int) ([]SearchResultRef, bool, error) {
if value == glua.LNil {
return nil, false, nil
}
tbl, ok := value.(*glua.LTable)
if !ok {
return nil, false, fmt.Errorf("callback must return nil or a table, got %s", value.Type())
}
var (
refs []SearchResultRef
parseErr error
)
seen := mapset.NewThreadUnsafeSet[SearchResultRef]()
tbl.ForEach(func(_ glua.LValue, val glua.LValue) {
if parseErr != nil {
return
}
entry, ok := val.(*glua.LTable)
if !ok {
parseErr = fmt.Errorf("each result must be a table")
return
}
source, ok := entry.RawGetString("source").(glua.LString)
if !ok {
parseErr = fmt.Errorf("result source must be a string")
return
}
name, ok := entry.RawGetString("name").(glua.LString)
if !ok {
parseErr = fmt.Errorf("result name must be a string")
return
}
ref := SearchResultRef{Source: string(source), Name: string(name)}
if _, exists := valid[ref]; !exists {
parseErr = fmt.Errorf("unknown search result %s/%s", ref.Source, ref.Name)
return
}
if !seen.Add(ref) {
return
}
refs = append(refs, ref)
})
if parseErr != nil {
return nil, false, parseErr
}
return refs, true, nil
}