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
This commit is contained in:
Jo authored and GitHub committed 2026-07-06 14:15:57 +02:00
1 parent 19cd422953
commit 4869306719
10 files changed
+884 -31

No files matched your search

+3 -4
View File
@@ -12,21 +12,20 @@ RUN set -eux; \
sed -i 's/^#\?DisableSandbox.*/DisableSandbox/' /etc/pacman.conf || true; \
sed -i 's/^#\?DownloadUser.*/#DownloadUser = alpm/' /etc/pacman.conf || true; \
pacman -Syu --noconfirm --needed \
pacman-contrib zsh git-delta openssh bat go github-cli \
pacman-contrib zsh git-delta openssh bat go github-cli ripgrep \
archlinux-keyring git gcc make sudo asciidoc doxygen meson curl; \
curl -sSfL https://golangci-lint.run/install.sh | sh -s v2.12.2; \
go mod download; \
rm -rf /var/lib/pacman/sync/* /var/cache/pacman/* /tmp/* /var/tmp/*; \
rm -rf /usr/share/man/* /usr/share/doc/* || true
# Non-root user with passwordless sudo
RUN useradd -m -s /bin/bash docker \
&& echo "docker ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/docker
USER docker
# Install xgotext
RUN go install github.com/leonelquinteros/gotext/cli/xgotext@latest
RUN go install github.com/leonelquinteros/gotext/cli/xgotext@latest \
&& go install golang.org/x/tools/gopls@latest
ENV PATH="/app/bin:/home/docker/go/bin:$PATH"
+103
View File
@@ -0,0 +1,103 @@
-- Reproduce yay.opt.single_line_results via RenderAUR/RenderSync.
--
-- yay.opt.single_line_results only changes one thing in the built-in
-- renderer: the description is joined onto the result line with a tab
-- instead of being printed on its own indented line below. These two hooks
-- rebuild that same layout (plus the bold/color accents) from scratch, so
-- you have a starting point for further tweaks (drop fields, change colors,
-- add links, etc.). yay does not expose a color/text helper API to Lua, so
-- every ANSI escape below is built by hand with plain Lua string functions.
local ESC = string.char(27)
local SEPARATOR = "\t" -- the one change that makes a result single-line
local MINUTE, HOUR, DAY = 60, 3600, 24 * 3600
local function sgr(code, s)
return ESC .. "[" .. code .. "m" .. s .. ESC .. "[0m"
end
local function bold(s) return sgr("1", s) end
local function red(s) return sgr("31", s) end
local function green(s) return sgr("32", s) end
local function yellow(s) return sgr("33", s) end
local function cyan(s) return sgr("36", s) end
-- A given repo/package name always renders in the same one of six colors,
-- same hashing idea as yay's internal ColorHash.
local function color_hash(name)
local hash = 0
for i = 1, #name do
hash = (string.byte(name, i) + hash * 32 + hash) % 2147483648
end
return sgr(tostring(hash % 6 + 31), name)
end
-- Common "repo/name version" prefix shared by AUR and sync result lines.
local function header(repo, name, version)
return bold(color_hash(repo)) .. "/" .. bold(name) .. " " .. cyan(version)
end
-- Appends " tag" to line, unless tag is empty/nil/false.
local function append(line, tag)
if not tag or tag == "" then
return line
end
return line .. " " .. tag
end
-- "[Xm]"/"[Xh]"/"[Xd]" age badge, colored red/yellow/cyan the newer it is.
local function age_tag(last_modified)
if not last_modified or last_modified == 0 then
return ""
end
local age = os.time() - last_modified
if age < HOUR then
return bold(red("[" .. math.floor(age / MINUTE) .. "m]"))
elseif age < DAY then
return bold(red("[" .. math.floor(age / HOUR) .. "h]"))
elseif age < 7 * DAY then
return bold(yellow("[" .. math.floor(age / DAY) .. "d]"))
end
return cyan("[" .. math.floor(age / DAY) .. "d]")
end
local function installed_tag(version, local_version)
if local_version == "" then
return ""
elseif local_version == version then
return bold(green("(Installed)"))
end
return bold(green("(Installed: " .. local_version .. ")"))
end
yay.create_autocmd("RenderAUR", {
desc = "single-line, colored AUR search results (mirrors single_line_results)",
callback = function(event)
local d = event.data
local line = header("aur", d.name, d.version) ..
bold(" (+" .. d.votes) .. " " .. bold(string.format("%.2f", d.popularity) .. ")")
line = append(line, age_tag(d.last_modified))
line = append(line, d.maintainer == "" and bold(red("(Orphaned)")))
line = append(line, d.out_of_date ~= 0 and bold(red("(Out-of-date)")))
line = append(line, installed_tag(d.version, d.local_version))
return line .. SEPARATOR .. d.description
end,
})
yay.create_autocmd("RenderSync", {
desc = "single-line, colored sync search results (mirrors single_line_results)",
callback = function(event)
local d = event.data
local line = header(d.repository, d.name, d.version)
line = append(line, #d.groups > 0 and table.concat(d.groups, " "))
line = append(line, installed_tag(d.version, d.local_version))
return line .. SEPARATOR .. d.description
end,
})
+26
View File
@@ -131,3 +131,29 @@ yay.opt.double_confirm = true
-- return out
-- end,
-- })
--
-- Render a custom string for each AUR result row during -Ss / -S number menu.
-- Return a string to use it; return nil (or nothing) to fall back to default.
--
-- yay.create_autocmd("RenderAUR", {
-- desc = "compact AUR search lines",
-- callback = function(event)
-- local d = event.data
-- local tag = d.local_version ~= "" and (" [installed: " .. d.local_version .. "]") or ""
-- return string.format("aur/%s %s (+%d)%s\n %s",
-- d.name, d.version, d.votes, tag, d.description)
-- end,
-- })
--
-- Render a custom string for each sync/repo result row during -Ss / -S number menu.
-- Return a string to use it; return nil (or nothing) to fall back to default.
--
-- yay.create_autocmd("RenderSync", {
-- desc = "compact sync search lines",
-- callback = function(event)
-- local d = event.data
-- local tag = d.local_version ~= "" and (" [installed: " .. d.local_version .. "]") or ""
-- return string.format("%s/%s %s%s\n %s",
-- d.repository, d.name, d.version, tag, d.description)
-- end,
-- })
+83
View File
@@ -483,3 +483,86 @@ yay.create_autocmd("SearchFilter", {
end,
})
```
## Render hooks
`RenderAUR` and `RenderSync` fire once per result row during `yay -Ss` and
the `yay <pkg>` number menu, after results are ranked, sorted, and (optionally)
filtered. The callback receives per-package metadata and may return a `string`
that fully replaces what yay would print for that row. Returning `nil` (or
nothing) falls back to yay's built-in formatting.
The menu number prefix (magenta index shown in the number menu) is always
prepended by yay itself; the callback controls only the package portion of the
line. `-q`/`--quiet` (minimal verbosity) is unaffected — name-only output
never calls these hooks. Hook errors are logged and that row falls back to
default rendering rather than aborting the command.
Multiple hooks of the same event **run in registration order**; the **last**
hook that returns a non-`nil` string wins. A later hook returning `nil` does
not undo an earlier hook's string.
### RenderAUR event
<p class="api-since">Available from yay v13.1.0</p>
```lua
{
event = "RenderAUR",
data = {
name = "pkgname",
version = "1.0-1",
description = "A useful package",
base = "pkgbase",
votes = 42,
popularity = 1.23,
maintainer = "alice",
out_of_date = 0, -- 0 if current; Unix timestamp if flagged
first_submitted = 1700000000,
last_modified = 1700000001,
local_version = "", -- installed version, or "" when not installed
},
}
```
### RenderSync event
<p class="api-since">Available from yay v13.1.0</p>
```lua
{
event = "RenderSync",
data = {
repository = "extra", -- pacman DB name (e.g. "core", "extra")
name = "pkgname",
description = "A useful package",
version = "2.0-1",
groups = {}, -- array of group strings, often empty
local_version = "", -- installed version, or "" when not installed
},
}
```
### Example
```lua
yay.create_autocmd("RenderAUR", {
desc = "compact AUR search lines",
callback = function(event)
local d = event.data
local tag = d.local_version ~= "" and (" [installed: " .. d.local_version .. "]") or ""
return string.format("aur/%s %s (+%d)%s\n %s",
d.name, d.version, d.votes, tag, d.description)
end,
})
yay.create_autocmd("RenderSync", {
desc = "compact sync search lines",
callback = function(event)
local d = event.data
local tag = d.local_version ~= "" and (" [installed: " .. d.local_version .. "]") or ""
return string.format("%s/%s %s%s\n %s",
d.repository, d.name, d.version, tag, d.description)
end,
})
```
+44 -1
View File
@@ -13,7 +13,7 @@
---@alias yay.menuAnswer "" | "All" | "None" | "Installed" | "NotInstalled" | "abort"
---@alias yay.Event "AURPreInstall" | "AURPostDownload" | "UpgradeSelect" | "PostInstall" | "SearchFilter"
---@alias yay.Event "AURPreInstall" | "AURPostDownload" | "UpgradeSelect" | "PostInstall" | "SearchFilter" | "RenderAUR" | "RenderSync"
-- Options: yay.opt
@@ -209,11 +209,54 @@
---@field desc? string
---@field callback fun(event: yay.SearchFilterEvent): yay.SearchResultRef[]?
-- Event payloads: RenderAUR
---@class yay.RenderAURData
---@field name string
---@field version string
---@field description string
---@field base string
---@field votes integer
---@field popularity number
---@field maintainer string
---@field out_of_date integer
---@field first_submitted integer
---@field last_modified integer
---@field local_version string
---@class yay.RenderAUREvent
---@field event "RenderAUR"
---@field data yay.RenderAURData
---@class yay.RenderAUROpts
---@field desc? string
---@field callback fun(event: yay.RenderAUREvent): string?
-- Event payloads: RenderSync
---@class yay.RenderSyncData
---@field repository string
---@field name string
---@field description string
---@field version string
---@field groups string[]
---@field local_version string
---@class yay.RenderSyncEvent
---@field event "RenderSync"
---@field data yay.RenderSyncData
---@class yay.RenderSyncOpts
---@field desc? string
---@field callback fun(event: yay.RenderSyncEvent): string?
---@overload fun(event: "AURPreInstall", opts: yay.AURPreInstallOpts)
---@overload fun(event: "AURPostDownload", opts: yay.AURPostDownloadOpts)
---@overload fun(event: "UpgradeSelect", opts: yay.UpgradeSelectOpts)
---@overload fun(event: "PostInstall", opts: yay.PostInstallOpts)
---@overload fun(event: "SearchFilter", opts: yay.SearchFilterOpts)
---@overload fun(event: "RenderAUR", opts: yay.RenderAUROpts)
---@overload fun(event: "RenderSync", opts: yay.RenderSyncOpts)
---@class yay.create_autocmd
-- The yay global
+59 -2
View File
@@ -293,10 +293,10 @@ func (s *SourceQueryBuilder) Results(dbExecutor db.Executor, verboseSearch Searc
if s.results[i].source == "aur" {
pkg := s.aurQueryMap[s.results[i].name]
toPrint += aurPkgSearchString(pkg, dbExecutor, s.singleLineResults)
toPrint += s.renderAUR(pkg, dbExecutor)
} else {
pkg := s.repoQueryMap[s.results[i].source+"/"+s.results[i].name]
toPrint += syncPkgSearchString(pkg, dbExecutor, s.singleLineResults)
toPrint += s.renderSync(pkg, dbExecutor)
}
s.logger.Println(toPrint)
@@ -395,3 +395,60 @@ func (s *SourceQueryBuilder) applySearchFilter(results []abstractResult) []abstr
return filtered
}
func (s *SourceQueryBuilder) renderAUR(pkg *aur.Pkg, dbExecutor db.Executor) string {
var localVersion string
if localPkg := dbExecutor.LocalPackage(pkg.Name); localPkg != nil {
localVersion = localPkg.Version()
}
if s.lua != nil && s.lua.HasAutocmd(settingslua.EventRenderAUR) {
rendered, ok, err := s.lua.RunRenderAUR(&settingslua.RenderAUREvent{
Name: pkg.Name,
Version: pkg.Version,
Description: pkg.Description,
Base: pkg.PackageBase,
Votes: pkg.NumVotes,
Popularity: pkg.Popularity,
Maintainer: pkg.Maintainer,
OutOfDate: pkg.OutOfDate,
FirstSubmitted: pkg.FirstSubmitted,
LastModified: pkg.LastModified,
LocalVersion: localVersion,
})
if err != nil {
s.logger.Errorln(err)
} else if ok {
return rendered
}
}
return aurPkgSearchStringResolved(pkg, localVersion, s.singleLineResults)
}
func (s *SourceQueryBuilder) renderSync(pkg alpm.Package, dbExecutor db.Executor) string {
var localVersion string
if localPkg := dbExecutor.LocalPackage(pkg.Name()); localPkg != nil {
localVersion = localPkg.Version()
}
groups := dbExecutor.PackageGroups(pkg)
if s.lua != nil && s.lua.HasAutocmd(settingslua.EventRenderSync) {
rendered, ok, err := s.lua.RunRenderSync(&settingslua.RenderSyncEvent{
Repository: pkg.DB().Name(),
Name: pkg.Name(),
Description: pkg.Description(),
Version: pkg.Version(),
Groups: groups,
LocalVersion: localVersion,
})
if err != nil {
s.logger.Errorln(err)
} else if ok {
return rendered
}
}
return syncPkgSearchStringResolved(pkg, groups, localVersion, s.singleLineResults)
}
+199
View File
@@ -0,0 +1,199 @@
//go:build !integration
package query
import (
"io"
"strings"
"testing"
aurc "github.com/Jguer/aur"
dbmock "github.com/Jguer/yay/v13/pkg/db/mock"
settingslua "github.com/Jguer/yay/v13/pkg/settings/lua"
"github.com/Jguer/yay/v13/pkg/settings/parser"
"github.com/Jguer/yay/v13/pkg/text"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSourceQueryBuilderRenderAURHook verifies that a RenderAUR hook replaces
// the default AUR row rendering while sync rows keep their default formatting.
func TestSourceQueryBuilderRenderAURHook(t *testing.T) {
t.Parallel()
mockDB, mockAUR := newYayQueryBuilderMocks()
w := &strings.Builder{}
logger := text.NewLogger(w, io.Discard, strings.NewReader(""), false, "test")
e := settingslua.New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("RenderAUR", {
callback = function(event)
return "AURLINE:" .. event.data.name
end,
})
`))
qb := NewSourceQueryBuilder(mockAUR, logger, "", parser.ModeAny, "", false, false, false)
qb.SetLua(e)
qb.Execute(t.Context(), mockDB, []string{"yay"})
err := qb.Results(mockDB, Detailed)
require.NoError(t, err)
out := w.String()
// Both AUR packages should use the hook's output.
assert.Contains(t, out, "AURLINE:yay", "hook output for yay must appear")
assert.Contains(t, out, "AURLINE:yay-git", "hook output for yay-git must appear")
// Default AUR rendering includes the votes marker; hook replaces it entirely.
assert.NotContains(t, out, "(+2461", "default AUR rendering must not appear when hook is active")
// Sync package should keep its default rendering (no RenderSync hook registered).
assert.Contains(t, out, "ruby-yard", "sync package must still appear with default rendering")
}
// TestSourceQueryBuilderRenderSyncHook verifies that a RenderSync hook replaces
// the default sync row rendering.
func TestSourceQueryBuilderRenderSyncHook(t *testing.T) {
t.Parallel()
mockDB, mockAUR := newYayQueryBuilderMocks()
w := &strings.Builder{}
logger := text.NewLogger(w, io.Discard, strings.NewReader(""), false, "test")
e := settingslua.New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("RenderSync", {
callback = function(event)
return "SYNCLINE:" .. event.data.name .. "/" .. event.data.repository
end,
})
`))
qb := NewSourceQueryBuilder(mockAUR, logger, "", parser.ModeAny, "", false, false, false)
qb.SetLua(e)
qb.Execute(t.Context(), mockDB, []string{"yay"})
err := qb.Results(mockDB, Detailed)
require.NoError(t, err)
out := w.String()
assert.Contains(t, out, "SYNCLINE:ruby-yard/extra", "hook output for ruby-yard must appear")
}
// TestSourceQueryBuilderRenderAURNilFallsBack verifies that a RenderAUR hook
// that returns nil causes the default formatter to be used for that row.
func TestSourceQueryBuilderRenderAURNilFallsBack(t *testing.T) {
t.Parallel()
mockDB, mockAUR := newYayQueryBuilderMocks()
w := &strings.Builder{}
logger := text.NewLogger(w, io.Discard, strings.NewReader(""), false, "test")
e := settingslua.New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("RenderAUR", {
callback = function(event)
return nil
end,
})
`))
qb := NewSourceQueryBuilder(mockAUR, logger, "", parser.ModeAny, "", false, false, false)
qb.SetLua(e)
qb.Execute(t.Context(), mockDB, []string{"yay"})
err := qb.Results(mockDB, Detailed)
require.NoError(t, err)
out := w.String()
// Default AUR rendering must appear when hook returns nil.
assert.Contains(t, out, "(+2461", "default AUR rendering must appear when hook returns nil")
}
func TestSourceQueryBuilderRenderAURHookReceivesLocalVersionWhenSame(t *testing.T) {
t.Parallel()
e := settingslua.New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("RenderAUR", {
callback = function(event)
return "LOCAL:" .. event.data.local_version
end,
})
`))
mockDB := &dbmock.DBExecutor{
LocalPackageFn: func(string) dbmock.IPackage {
return &dbmock.Package{PName: "yay", PVersion: "12.5.7-1"}
},
}
logger := text.NewLogger(io.Discard, io.Discard, strings.NewReader(""), false, "test")
qb := NewSourceQueryBuilder(nil, logger, "", parser.ModeAny, "", false, false, false)
qb.SetLua(e)
rendered := qb.renderAUR(&aurc.Pkg{Name: "yay", Version: "12.5.7-1"}, mockDB)
require.Equal(t, "LOCAL:12.5.7-1", rendered)
}
func TestSourceQueryBuilderRenderSyncHookReceivesLocalVersionWhenSame(t *testing.T) {
t.Parallel()
e := settingslua.New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("RenderSync", {
callback = function(event)
return "LOCAL:" .. event.data.local_version
end,
})
`))
mockDB := &dbmock.DBExecutor{
LocalPackageFn: func(string) dbmock.IPackage {
return &dbmock.Package{PName: "ruby-yard", PVersion: "0.9.34-5"}
},
}
pkg := &dbmock.Package{PDB: dbmock.NewDB("extra"), PName: "ruby-yard", PVersion: "0.9.34-5"}
logger := text.NewLogger(io.Discard, io.Discard, strings.NewReader(""), false, "test")
qb := NewSourceQueryBuilder(nil, logger, "", parser.ModeAny, "", false, false, false)
qb.SetLua(e)
rendered := qb.renderSync(pkg, mockDB)
require.Equal(t, "LOCAL:0.9.34-5", rendered)
}
func TestSourceQueryBuilderDefaultRenderUsesShortInstalledTagWhenSame(t *testing.T) {
t.Parallel()
mockDB := &dbmock.DBExecutor{
LocalPackageFn: func(string) dbmock.IPackage {
return &dbmock.Package{PName: "yay", PVersion: "12.5.7-1"}
},
}
logger := text.NewLogger(io.Discard, io.Discard, strings.NewReader(""), false, "test")
qb := NewSourceQueryBuilder(nil, logger, "", parser.ModeAny, "", false, false, false)
rendered := qb.renderAUR(&aurc.Pkg{Name: "yay", Version: "12.5.7-1"}, mockDB)
assert.Contains(t, rendered, "(Installed)")
assert.NotContains(t, rendered, "(Installed: 12.5.7-1)")
}
+18 -23
View File
@@ -8,7 +8,6 @@ import (
alpm "github.com/Jguer/dyalpm"
"github.com/leonelquinteros/gotext"
"github.com/Jguer/yay/v13/pkg/db"
"github.com/Jguer/yay/v13/pkg/text"
)
@@ -47,11 +46,9 @@ func getSearchBy(value string) aur.By {
}
}
func aurPkgSearchString(
pkg *aur.Pkg,
dbExecutor db.Executor,
singleLineResults bool,
) string {
// aurPkgSearchStringResolved renders a search result for an AUR package.
// It accepts pre-resolved installed state to avoid a second LocalPackage call.
func aurPkgSearchStringResolved(pkg *aur.Pkg, localVersion string, singleLineResults bool) string {
linkText := text.Bold(text.ColorHash("aur")) + "/" + text.Bold(pkg.Name)
toPrint := text.CreateRepoLink("aur", "", pkg.Name, linkText) +
" " + text.Cyan(pkg.Version) +
@@ -70,12 +67,11 @@ func aurPkgSearchString(
toPrint += text.Bold(text.Red(gotext.Get("(Out-of-date: %s)", text.FormatTime(pkg.OutOfDate)))) + " "
}
if localPkg := dbExecutor.LocalPackage(pkg.Name); localPkg != nil {
if localPkg.Version() != pkg.Version {
toPrint += text.Bold(text.Green(gotext.Get("(Installed: %s)", localPkg.Version())))
} else {
toPrint += text.Bold(text.Green(gotext.Get("(Installed)")))
}
switch {
case localVersion == pkg.Version:
toPrint += text.Bold(text.Green(gotext.Get("(Installed)")))
case localVersion != "":
toPrint += text.Bold(text.Green(gotext.Get("(Installed: %s)", localVersion)))
}
if singleLineResults {
@@ -89,25 +85,24 @@ func aurPkgSearchString(
return toPrint
}
// PrintSearch receives a RepoSearch type and outputs pretty text.
func syncPkgSearchString(pkg alpm.Package, dbExecutor db.Executor, singleLineResults bool) string {
// syncPkgSearchStringResolved renders a search result for a sync package.
// It accepts pre-resolved groups and local version state to avoid second DB calls.
func syncPkgSearchStringResolved(pkg alpm.Package, groups []string, localVersion string, singleLineResults bool) string {
linkText := text.Bold(text.ColorHash(pkg.DB().Name())) + "/" + text.Bold(pkg.Name())
toPrint := text.CreateRepoLink(pkg.DB().Name(), pkg.Architecture(), pkg.Name(), linkText) +
" " + text.Cyan(pkg.Version()) +
text.Bold(" ("+text.Human(pkg.Size())+
" "+text.Human(pkg.ISize())+") ")
packageGroups := dbExecutor.PackageGroups(pkg)
if len(packageGroups) != 0 {
toPrint += fmt.Sprint(packageGroups, " ")
if len(groups) != 0 {
toPrint += fmt.Sprint(groups, " ")
}
if localPkg := dbExecutor.LocalPackage(pkg.Name()); localPkg != nil {
if localPkg.Version() != pkg.Version() {
toPrint += text.Bold(text.Green(gotext.Get("(Installed: %s)", localPkg.Version())))
} else {
toPrint += text.Bold(text.Green(gotext.Get("(Installed)")))
}
switch {
case localVersion == pkg.Version():
toPrint += text.Bold(text.Green(gotext.Get("(Installed)")))
case localVersion != "":
toPrint += text.Bold(text.Green(gotext.Get("(Installed: %s)", localVersion)))
}
if singleLineResults {
+103 -1
View File
@@ -13,6 +13,8 @@ const (
EventUpgradeSelect = "UpgradeSelect"
EventPostInstall = "PostInstall"
EventSearchFilter = "SearchFilter"
EventRenderAUR = "RenderAUR"
EventRenderSync = "RenderSync"
)
type Autocmd struct {
@@ -116,10 +118,34 @@ type SearchResultRef struct {
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 != EventUpgradeSelect && event != EventPostInstall && event != EventSearchFilter &&
event != EventRenderAUR && event != EventRenderSync {
state.ArgError(1, fmt.Sprintf("unsupported event %q", event))
return 0
}
@@ -507,6 +533,82 @@ func (e *Engine) searchResultPackagesTable(packages []SearchResultPackage) *glua
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
+246
View File
@@ -0,0 +1,246 @@
package lua
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRunRenderAUREventShapeAndStringReturn(t *testing.T) {
t.Parallel()
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("RenderAUR", {
callback = function(event)
if event.event ~= "RenderAUR" then error("bad event name: " .. tostring(event.event)) end
local d = event.data
if d.name ~= "pkgA" then error("bad name") end
if d.version ~= "1.0-1" then error("bad version") end
if d.description ~= "A great package" then error("bad description") end
if d.base ~= "pkgA-base" then error("bad base") end
if d.votes ~= 42 then error("bad votes") end
if math.abs(d.popularity - 3.14) > 0.001 then error("bad popularity") end
if d.maintainer ~= "alice" then error("bad maintainer") end
if d.out_of_date ~= 0 then error("bad out_of_date") end
if d.first_submitted ~= 1000 then error("bad first_submitted") end
if d.last_modified ~= 2000 then error("bad last_modified") end
if d.local_version ~= "" then error("bad local_version") end
return "X:" .. d.name
end,
})
`))
rendered, ok, err := e.RunRenderAUR(&RenderAUREvent{
Name: "pkgA",
Version: "1.0-1",
Description: "A great package",
Base: "pkgA-base",
Votes: 42,
Popularity: 3.14,
Maintainer: "alice",
OutOfDate: 0,
FirstSubmitted: 1000,
LastModified: 2000,
LocalVersion: "",
})
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, "X:pkgA", rendered)
}
func TestRunRenderAURNilReturnFallsBack(t *testing.T) {
t.Parallel()
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("RenderAUR", {
callback = function(event)
return nil
end,
})
`))
rendered, ok, err := e.RunRenderAUR(&RenderAUREvent{Name: "pkgA"})
require.NoError(t, err)
require.False(t, ok)
require.Equal(t, "", rendered)
}
func TestRunRenderAURNonStringReturnIsError(t *testing.T) {
t.Parallel()
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("RenderAUR", {
callback = function(event)
return 42
end,
})
`))
_, ok, err := e.RunRenderAUR(&RenderAUREvent{Name: "pkgA"})
require.Error(t, err)
require.False(t, ok)
require.Contains(t, err.Error(), "callback must return a string or nil")
}
func TestRunRenderAURHooksReceiveIndependentEventTables(t *testing.T) {
t.Parallel()
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("RenderAUR", {
callback = function(event)
event.data.name = "mutated"
return nil
end,
})
yay.create_autocmd("RenderAUR", {
callback = function(event)
return event.data.name
end,
})
`))
rendered, ok, err := e.RunRenderAUR(&RenderAUREvent{Name: "pkgA"})
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, "pkgA", rendered)
}
func TestRunRenderAURLastHookWins(t *testing.T) {
t.Parallel()
// Sub-case 1: second hook returns "B" — last non-nil wins.
t.Run("second_returns_B", func(t *testing.T) {
t.Parallel()
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("RenderAUR", {
callback = function(event) return "A" end,
})
yay.create_autocmd("RenderAUR", {
callback = function(event) return "B" end,
})
`))
rendered, ok, err := e.RunRenderAUR(&RenderAUREvent{Name: "pkgA"})
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, "B", rendered)
})
// Sub-case 2: second hook returns nil — first hook's "A" is retained.
t.Run("second_returns_nil_keeps_first", func(t *testing.T) {
t.Parallel()
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("RenderAUR", {
callback = function(event) return "A" end,
})
yay.create_autocmd("RenderAUR", {
callback = function(event) return nil end,
})
`))
rendered, ok, err := e.RunRenderAUR(&RenderAUREvent{Name: "pkgA"})
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, "A", rendered)
})
}
func TestRunRenderAURAbort(t *testing.T) {
t.Parallel()
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("RenderAUR", {
callback = function(event)
yay.abort("blocked")
end,
})
`))
_, _, err := e.RunRenderAUR(&RenderAUREvent{Name: "pkgA"})
require.EqualError(t, err, "RenderAUR: blocked")
}
func TestRunRenderSyncEventShapeAndStringReturn(t *testing.T) {
t.Parallel()
e := New()
defer e.Close()
require.NoError(t, e.L.DoString(`
yay.create_autocmd("RenderSync", {
callback = function(event)
if event.event ~= "RenderSync" then error("bad event name") end
local d = event.data
if d.repository ~= "extra" then error("bad repository") end
if d.name ~= "mypkg" then error("bad name") end
if d.version ~= "2.0-1" then error("bad version") end
if d.groups[1] ~= "base" then error("bad groups[1]") end
if d.local_version ~= "1.9-2" then error("bad local_version") end
return "SYNC:" .. d.name .. "/" .. d.repository
end,
})
`))
rendered, ok, err := e.RunRenderSync(&RenderSyncEvent{
Repository: "extra",
Name: "mypkg",
Description: "My sync package",
Version: "2.0-1",
Groups: []string{"base"},
LocalVersion: "1.9-2",
})
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, "SYNC:mypkg/extra", rendered)
}
func TestRunRenderNoHookReturnsFalse(t *testing.T) {
t.Parallel()
t.Run("aur", func(t *testing.T) {
t.Parallel()
e := New()
defer e.Close()
rendered, ok, err := e.RunRenderAUR(&RenderAUREvent{Name: "pkgA"})
require.NoError(t, err)
require.False(t, ok)
require.Equal(t, "", rendered)
})
t.Run("sync", func(t *testing.T) {
t.Parallel()
e := New()
defer e.Close()
rendered, ok, err := e.RunRenderSync(&RenderSyncEvent{Name: "mypkg"})
require.NoError(t, err)
require.False(t, ok)
require.Equal(t, "", rendered)
})
}