Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ Configure via `require("peekstack").setup({ ... })`.
},
},
picker = {
backend = "builtin",
backend = "builtin", -- "builtin" | "telescope" | "fzf-lua" | "snacks" | <custom name>
builtin = {
preview_lines = 1,
},
Expand Down Expand Up @@ -307,6 +307,25 @@ Candidate labels are shown in a readable unified format:

If the chosen plugin is not installed, a warning is shown and the picker will not open.

You can also plug in your own picker with `register_picker(name, mod)` and select it
with `picker.backend = name`. The module must implement `pick(locations, opts, cb)`
and call `cb` with the chosen location (or `nil` to cancel). Registration works
before or after `setup()` and survives `setup()` re-runs; if the configured name is
not registered, peekstack falls back to `builtin`.

```lua
require("peekstack").register_picker("my_picker", {
pick = function(locations, opts, cb)
vim.ui.select(locations, {
format_item = function(loc)
return vim.uri_to_fname(loc.uri)
end,
}, cb)
end,
})
require("peekstack").setup({ picker = { backend = "my_picker" } })
```

## 🔌 Extensions (push from external pickers)

Push results from external pickers (telescope / fzf-lua / snacks.nvim) directly
Expand Down Expand Up @@ -358,7 +377,8 @@ Auto persist only runs inside a git repository and always uses the repository se
## 🔁 Re-running setup

Calling `require("peekstack").setup()` again replaces config, re-registers providers, commands,
autocmds, picker backends, and auto-persist hooks.
autocmds, picker backends, and auto-persist hooks. Providers and pickers registered with
`register_provider()` / `register_picker()` are kept.

It does not migrate existing popup windows, stack entries, or history in place. Updated settings apply
to future actions, and to existing stacks only after those popups are reopened, restored, or recreated.
Expand Down
24 changes: 22 additions & 2 deletions doc/peekstack.txt
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ With options:
},
},
picker = {
backend = "builtin", -- "builtin" | "telescope" | "fzf-lua" | "snacks"
backend = "builtin", -- "builtin" | "telescope" | "fzf-lua" | "snacks" | <custom name>
builtin = {
preview_lines = 1,
},
Expand Down Expand Up @@ -146,6 +146,7 @@ Configure the backend with `picker.backend`:
telescope (requires nvim-telescope/telescope.nvim)
fzf-lua (requires ibhagwan/fzf-lua)
snacks (requires folke/snacks.nvim)
<name> (a picker registered with `register_picker(name, mod)`)

>lua
require("peekstack").setup({
Expand Down Expand Up @@ -409,6 +410,23 @@ Built-in provider names:
Register a custom picker backend.
The module must implement `pick(locations, opts, cb)` and call `cb` with the
chosen location (or nil to cancel).
Select it with `picker.backend = name`. Registration can happen before or
after `setup()`; registered pickers and providers are kept across
`setup()` re-runs and take precedence over builtin entries with the same
name. If the configured backend is not registered, peekstack falls back to
`builtin` (`:checkhealth peekstack` reports this).
>lua
require("peekstack").register_picker("my_picker", {
pick = function(locations, opts, cb)
vim.ui.select(locations, {
format_item = function(loc)
return vim.uri_to_fname(loc.uri)
end,
}, cb)
end,
})
require("peekstack").setup({ picker = { backend = "my_picker" } })
<

`require("peekstack").stack`
Proxy to the stack module. Key functions:
Expand Down Expand Up @@ -466,6 +484,8 @@ SETUP RELOAD *peekstack-setup-reload*

Calling `require("peekstack").setup()` again replaces config and re-registers
providers, commands, autocmds, picker backends, and auto-persist hooks.
Providers and pickers registered with `register_provider()` /
`register_picker()` are kept.

It does not migrate existing popup windows, stack entries, or history in
place. Updated settings apply to future actions, and to existing stacks only
Expand Down Expand Up @@ -594,7 +614,7 @@ HEALTH *peekstack-health*
Run `:checkhealth peekstack` to verify requirements:
- Neovim >= 0.12
- `rg` executable (optional, for grep.search)
- Configured picker backend availability (telescope / fzf-lua / snacks)
- Configured picker backend availability (telescope / fzf-lua / snacks / custom)
- Persist setup and git repository detection
- Tree-sitter context parser availability (when `ui.title.context` is enabled)

Expand Down
21 changes: 18 additions & 3 deletions lua/peekstack/config/validate/rules/picker.lua
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
local notify = require("peekstack.util.notify")
local shared = require("peekstack.config.validate.shared")

local M = {}

---@type string[]
local KNOWN_BACKENDS = { "builtin", "telescope", "fzf-lua", "snacks" }
---Any non-empty string is accepted so pickers registered through
---`register_picker()` can be selected. Availability of the backend is
---checked at pick time (falling back to `builtin`) and by `:checkhealth`.
---@param path string
---@param value any
---@param default string
---@return string
local function validate_backend(path, value, default)
if type(value) ~= "string" or value == "" then
notify.warn(
string.format("%s must be a non-empty string, got %s. Falling back to %q", path, vim.inspect(value), default)
)
return default
end
return value
end

---@type PeekstackConfigFieldRule[]
local PICKER_RULES = {
{ key = "backend", validate = shared.field_enum(KNOWN_BACKENDS), require_truthy = true },
{ key = "backend", validate = validate_backend },
}

---@type PeekstackConfigFieldRule[]
Expand Down
11 changes: 9 additions & 2 deletions lua/peekstack/health.lua
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ end

---@param cfg PeekstackConfig
local function report_picker(cfg)
local registry = require("peekstack.registry")
local backend = cfg.picker and cfg.picker.backend or "builtin"
if backend == "builtin" then
if registry.has_user_picker(backend) then
vim.health.ok("picker backend '" .. backend .. "' registered via register_picker()")
elseif backend == "builtin" then
vim.health.ok("picker backend 'builtin'")
else
local plugin_name = PICKER_MODULES[backend]
Expand All @@ -70,7 +73,11 @@ local function report_picker(cfg)
vim.health.warn("picker backend '" .. backend .. "' is configured but the plugin is not installed")
end
else
vim.health.warn("unknown picker backend '" .. backend .. "'")
vim.health.warn(
"picker backend '"
.. backend
.. "' is not registered; register it with register_picker() (falls back to 'builtin')"
)
end
end

Expand Down
54 changes: 44 additions & 10 deletions lua/peekstack/registry.lua
Original file line number Diff line number Diff line change
@@ -1,45 +1,79 @@
local M = {}

---Providers and pickers registered by `setup()`. Cleared on every `setup()`
---so a re-run reflects the current config (e.g. a provider group disabled).
---@type table<string, fun(ctx: PeekstackProviderContext, cb: fun(locations: PeekstackLocation[]))>
local providers = {}
local builtin_providers = {}
---@type table<string, PeekstackPicker>
local builtin_pickers = {}

---Providers and pickers registered through the public API. These survive
---`setup()` re-runs and take precedence over builtin entries with the same name.
---@type table<string, fun(ctx: PeekstackProviderContext, cb: fun(locations: PeekstackLocation[]))>
local user_providers = {}
---@type table<string, PeekstackPicker>
local pickers = {}
local user_pickers = {}

---Clear builtin registrations. User registrations are kept.
function M.reset()
providers = {}
pickers = {}
builtin_providers = {}
builtin_pickers = {}
end

---@param name string
---@param fn fun(ctx: PeekstackProviderContext, cb: fun(locations: PeekstackLocation[]))
function M.register_provider(name, fn)
providers[name] = fn
user_providers[name] = fn
end

---@param name string
---@param fn fun(ctx: PeekstackProviderContext, cb: fun(locations: PeekstackLocation[]))
function M.register_builtin_provider(name, fn)
builtin_providers[name] = fn
end

---@return string[]
function M.list_providers()
local names = vim.tbl_keys(providers)
local seen = {}
for name in pairs(builtin_providers) do
seen[name] = true
end
for name in pairs(user_providers) do
seen[name] = true
end
local names = vim.tbl_keys(seen)
table.sort(names)
return names
end

---@param name string
---@return fun(ctx: PeekstackProviderContext, cb: fun(locations: PeekstackLocation[]))?
function M.get_provider(name)
return providers[name]
return user_providers[name] or builtin_providers[name]
end

---@param name string
---@param fn PeekstackPicker
function M.register_picker(name, fn)
pickers[name] = fn
user_pickers[name] = fn
end

---@param name string
---@param fn PeekstackPicker
function M.register_builtin_picker(name, fn)
builtin_pickers[name] = fn
end

---@param name string
---@return boolean
function M.has_user_picker(name)
return user_pickers[name] ~= nil
end

---@param name string
---@return PeekstackPicker?
function M.get_picker(name)
return pickers[name]
return user_pickers[name] or builtin_pickers[name]
end

---@param prefix string
Expand All @@ -49,7 +83,7 @@ function M.register_provider_group(prefix, provider_mod, names)
for _, name in ipairs(names) do
local fn = provider_mod[name]
if fn then
M.register_provider(prefix .. name, fn)
M.register_builtin_provider(prefix .. name, fn)
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions lua/peekstack/setup.lua
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ local PROVIDER_GROUPS = {

---@param cfg PeekstackConfig
local function register_picker_backends(cfg)
registry.register_picker("builtin", require("peekstack.picker.builtin"))
registry.register_builtin_picker("builtin", require("peekstack.picker.builtin"))

local backend = cfg.picker.backend
if backend == "builtin" then
Expand All @@ -82,7 +82,7 @@ local function register_picker_backends(cfg)

local ok, picker_mod = pcall(require, mod_name)
if ok then
registry.register_picker(backend, picker_mod)
registry.register_builtin_picker(backend, picker_mod)
end
end

Expand Down
2 changes: 1 addition & 1 deletion lua/peekstack/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@
---@field preview_lines integer

---@class PeekstackConfigPicker
---@field backend "builtin"|"telescope"|"fzf-lua"|"snacks"
---@field backend string # "builtin" | "telescope" | "fzf-lua" | "snacks" | name passed to register_picker()
---@field builtin PeekstackConfigPickerBuiltin

---@class PeekstackConfigProviderEntry
Expand Down
98 changes: 98 additions & 0 deletions tests/custom_picker_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
describe("custom picker backend", function()
local peekstack = require("peekstack")
local config = require("peekstack.config")
local registry = require("peekstack.registry")

local original_notify
local notifications

before_each(function()
original_notify = vim.notify
notifications = {}
vim.notify = function(msg, level)
table.insert(notifications, { msg = msg, level = level })
end
end)

after_each(function()
vim.notify = original_notify
registry.register_picker("my_picker", nil)
peekstack.setup({})
end)

local function has_message(pattern)
for _, item in ipairs(notifications) do
if tostring(item.msg):find(pattern, 1, true) then
return true
end
end
return false
end

it("accepts an arbitrary backend name in config", function()
local cfg = config.setup({ picker = { backend = "my_picker" } })
assert.equals("my_picker", cfg.picker.backend)
assert.is_false(has_message("picker.backend"))
end)

it("rejects non-string backend and falls back to builtin", function()
for _, value in ipairs({ 42, false, "" }) do
notifications = {}
local cfg = config.setup({ picker = { backend = value } })
assert.equals("builtin", cfg.picker.backend)
assert.is_true(has_message("picker.backend must be a non-empty string"))
end
end)

it("keeps a picker registered before setup()", function()
local picker = { pick = function() end }
peekstack.register_picker("my_picker", picker)
peekstack.setup({ picker = { backend = "my_picker" } })
assert.equals(picker, registry.get_picker("my_picker"))
end)

it("keeps a picker registered after setup() across a re-run", function()
local picker = { pick = function() end }
peekstack.setup({})
peekstack.register_picker("my_picker", picker)
peekstack.setup({ picker = { backend = "my_picker" } })
assert.equals(picker, registry.get_picker("my_picker"))
end)

it("dispatches multi-location results to the custom picker", function()
local received
peekstack.register_picker("my_picker", {
pick = function(locations, _opts, cb)
received = locations
cb(nil)
end,
})
peekstack.register_provider("test.custom_picker", function(_ctx, cb)
cb({
{
uri = vim.uri_from_fname("/tmp/a.lua"),
range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } },
},
{
uri = vim.uri_from_fname("/tmp/b.lua"),
range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } },
},
})
end)
peekstack.setup({ picker = { backend = "my_picker" } })

peekstack.peek("test.custom_picker", {})

assert.is_not_nil(received)
assert.equals(2, #received)
end)

it("keeps user providers across setup() re-runs", function()
peekstack.register_provider("test.keep", function(_ctx, cb)
cb({})
end)
peekstack.setup({})
assert.is_not_nil(registry.get_provider("test.keep"))
assert.is_true(vim.list_contains(registry.list_providers(), "test.keep"))
end)
end)
Loading