diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 7ec8de4a..89016d31 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -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" diff --git a/doc/examples/single_line.lua b/doc/examples/single_line.lua new file mode 100644 index 00000000..a61c8b16 --- /dev/null +++ b/doc/examples/single_line.lua @@ -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, +}) diff --git a/doc/init.lua b/doc/init.lua index 7c1a5b04..9057602e 100644 --- a/doc/init.lua +++ b/doc/init.lua @@ -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, +-- }) diff --git a/doc/lua.md b/doc/lua.md index 72eddc64..55ace93a 100644 --- a/doc/lua.md +++ b/doc/lua.md @@ -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 ` 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 + +

Available from yay v13.1.0

+ +```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 + +

Available from yay v13.1.0

+ +```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, +}) +``` diff --git a/meta/yay.d.lua b/meta/yay.d.lua index c8c6f90a..7766410b 100644 --- a/meta/yay.d.lua +++ b/meta/yay.d.lua @@ -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 diff --git a/pkg/query/query_builder.go b/pkg/query/query_builder.go index 88c43355..66c0fb4c 100644 --- a/pkg/query/query_builder.go +++ b/pkg/query/query_builder.go @@ -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) +} diff --git a/pkg/query/query_builder_render_test.go b/pkg/query/query_builder_render_test.go new file mode 100644 index 00000000..6ff1d5b6 --- /dev/null +++ b/pkg/query/query_builder_render_test.go @@ -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)") +} diff --git a/pkg/query/types.go b/pkg/query/types.go index c4955d20..6f24717f 100644 --- a/pkg/query/types.go +++ b/pkg/query/types.go @@ -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 { diff --git a/pkg/settings/lua/autocmd.go b/pkg/settings/lua/autocmd.go index 8b889fe6..c8c18184 100644 --- a/pkg/settings/lua/autocmd.go +++ b/pkg/settings/lua/autocmd.go @@ -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 diff --git a/pkg/settings/lua/autocmd_render_test.go b/pkg/settings/lua/autocmd_render_test.go new file mode 100644 index 00000000..0bc342e8 --- /dev/null +++ b/pkg/settings/lua/autocmd_render_test.go @@ -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) + }) +}