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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@ Configure via `require("peekstack").setup({ ... })`.

</details>

> [!NOTE]
> `setup()` never throws on bad config. Invalid values fall back to defaults
> with a `vim.notify` warning, and unknown or mistyped keys (e.g. `ui.popups`
> instead of `ui.popup`) are reported the same way so typos are easy to spot.

## 🧺 Picker backends (telescope / fzf-lua / snacks.nvim)

peekstack uses a picker when multiple locations are returned (e.g. references). The
Expand Down
4 changes: 4 additions & 0 deletions lua/peekstack/config/validate/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ local ui = require("peekstack.config.validate.rules.ui")
local picker = require("peekstack.config.validate.rules.picker")
local providers = require("peekstack.config.validate.rules.providers")
local persist = require("peekstack.config.validate.rules.persist")
local unknown = require("peekstack.config.validate.unknown")

local M = {}

---@param cfg table
---@param defaults PeekstackConfig
function M.run(cfg, defaults)
-- Detect unknown keys first, before field validators can replace invalid
-- subtrees with defaults (which would hide sibling typos).
unknown.detect(cfg, defaults)
ui.validate(cfg, defaults)
picker.validate(cfg, defaults)
providers.validate(cfg, defaults)
Expand Down
64 changes: 64 additions & 0 deletions lua/peekstack/config/validate/unknown.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
local notify = require("peekstack.util.notify")

local M = {}

-- Subtrees keyed by user-defined names (provider names, filetypes) rather than
-- a fixed schema. Unknown-key detection must not descend into these.
---@type table<string, boolean>
local OPEN_PATHS = {
["ui.title.icons.map"] = true,
["ui.title.context.node_types"] = true,
}

---@param defaults table
---@return boolean True when the table is a fixed record (not a list/array).
local function is_record(defaults)
return not vim.islist(defaults) and next(defaults) ~= nil
end

---@param defaults table
---@return string[] Sorted known keys at this level.
local function known_keys_of(defaults)
local keys = {}
for key in pairs(defaults) do
keys[#keys + 1] = tostring(key)
end
table.sort(keys)
return keys
end

---@param section table Merged config subtree.
---@param defaults table Default schema subtree.
---@param prefix string Dotted path of this subtree ("" at the root).
local function walk(section, defaults, prefix)
for key, value in pairs(section) do
local path = prefix == "" and tostring(key) or (prefix .. "." .. tostring(key))
if defaults[key] == nil then
notify.warn(
string.format(
"Unknown config key %q (ignored). Known keys: %s",
path,
table.concat(known_keys_of(defaults), ", ")
)
)
elseif
type(value) == "table"
and type(defaults[key]) == "table"
and is_record(defaults[key])
and not OPEN_PATHS[path]
then
walk(value, defaults[key], path)
end
end
end

---Warn about unknown/typo'd config keys (e.g. `ui.popups` instead of
---`ui.popup`). Compares the merged config against the default schema and
---reports keys the user supplied that peekstack does not recognise.
---@param cfg table Merged config.
---@param defaults PeekstackConfig
function M.detect(cfg, defaults)
walk(cfg, defaults, "")
end

return M
15 changes: 11 additions & 4 deletions lua/peekstack/ui/diagnostics.lua
Original file line number Diff line number Diff line change
Expand Up @@ -87,21 +87,28 @@ function M.decorate(popup)
end

if #virt_lines > 0 then
local id = vim.api.nvim_buf_set_extmark(bufnr, NS, line, 0, {
-- pcall guards against out-of-range coordinates raised by the API.
local ok, id = pcall(vim.api.nvim_buf_set_extmark, bufnr, NS, line, 0, {
virt_lines = virt_lines,
virt_lines_above = true,
})
table.insert(ids, id)
if ok then
table.insert(ids, id)
end
end

local underline = severity_hl(location.kind, "DiagnosticUnderline")
if underline ~= "" then
local id = vim.api.nvim_buf_set_extmark(bufnr, NS, line, col, {
-- end_col may exceed the line length when the popup buffer is truncated;
-- pcall keeps decoration best-effort instead of erroring on the whole popup.
local ok, id = pcall(vim.api.nvim_buf_set_extmark, bufnr, NS, line, col, {
end_row = end_line,
end_col = end_col,
hl_group = underline,
})
table.insert(ids, id)
if ok then
table.insert(ids, id)
end
end

if #ids == 0 then
Expand Down
10 changes: 8 additions & 2 deletions lua/peekstack/ui/feedback.lua
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ function M.highlight_origin(origin)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
return
end
local row = math.max((origin.row or 1) - 1, 0)
vim.api.nvim_buf_set_extmark(bufnr, ns, row, 0, {
local line_count = vim.api.nvim_buf_line_count(bufnr)
if line_count == 0 then
return
end
-- Clamp the origin row in case the buffer shrank since the popup opened.
local row = math.min(math.max((origin.row or 1) - 1, 0), line_count - 1)
-- Guard against transient API failures (e.g. concurrent buffer edits).
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, row, 0, {
end_row = row + 1,
hl_group = "PeekstackOrigin",
})
Expand Down
2 changes: 1 addition & 1 deletion lua/peekstack/ui/stack_view/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ function M.open()
keymaps.close_help(s, opts, keymap_deps())
end,
ensure_non_header_cursor = function()
keymaps.ensure_non_header_cursor(s)
state.ensure_non_header_cursor(s)
end,
})

Expand Down
25 changes: 4 additions & 21 deletions lua/peekstack/ui/stack_view/keymaps.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ local location = require("peekstack.core.location")
local str = require("peekstack.util.str")
local notify = require("peekstack.util.notify")
local keymap_spec = require("peekstack.ui.keymap_spec")
local state = require("peekstack.ui.stack_view.state")

local M = {}

Expand Down Expand Up @@ -57,24 +58,6 @@ local function entry_lines(s)
return lines
end

---@param s PeekstackStackViewState
function M.ensure_non_header_cursor(s)
if not (s.winid and vim.api.nvim_win_is_valid(s.winid) and s.bufnr and vim.api.nvim_buf_is_valid(s.bufnr)) then
return
end

local line_count = vim.api.nvim_buf_line_count(s.bufnr)
if line_count <= 0 then
return
end

local min_line = math.min((s.header_lines or 0) + 1, line_count)
local cursor = vim.api.nvim_win_get_cursor(s.winid)[1]
if cursor < min_line then
vim.api.nvim_win_set_cursor(s.winid, { min_line, 0 })
end
end

---@param s PeekstackStackViewState
---@param step integer
local function move_cursor_by_stack_item(s, step)
Expand All @@ -84,7 +67,7 @@ local function move_cursor_by_stack_item(s, step)

local lines = entry_lines(s)
if #lines == 0 then
M.ensure_non_header_cursor(s)
state.ensure_non_header_cursor(s)
return
end

Expand Down Expand Up @@ -364,7 +347,7 @@ local function build_specs(s, deps)
rhs = function()
local lines = entry_lines(s)
if #lines == 0 then
M.ensure_non_header_cursor(s)
state.ensure_non_header_cursor(s)
return
end
vim.api.nvim_win_set_cursor(s.winid, { lines[1], 0 })
Expand All @@ -375,7 +358,7 @@ local function build_specs(s, deps)
rhs = function()
local lines = entry_lines(s)
if #lines == 0 then
M.ensure_non_header_cursor(s)
state.ensure_non_header_cursor(s)
return
end
vim.api.nvim_win_set_cursor(s.winid, { lines[#lines], 0 })
Expand Down
21 changes: 2 additions & 19 deletions lua/peekstack/ui/stack_view/render.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,10 @@ local config = require("peekstack.config")
local location = require("peekstack.core.location")
local diff = require("peekstack.ui.stack_view.diff")
local pipeline = require("peekstack.ui.stack_view.pipeline")
local state = require("peekstack.ui.stack_view.state")

local M = {}

---@param s PeekstackStackViewState
local function ensure_non_header_cursor(s)
if not (s.winid and vim.api.nvim_win_is_valid(s.winid) and s.bufnr and vim.api.nvim_buf_is_valid(s.bufnr)) then
return
end

local line_count = vim.api.nvim_buf_line_count(s.bufnr)
if line_count <= s.header_lines then
return
end

local min_line = s.header_lines + 1
local cursor = vim.api.nvim_win_get_cursor(s.winid)[1]
if cursor < min_line then
vim.api.nvim_win_set_cursor(s.winid, { min_line, 0 })
end
end

---@param s PeekstackStackViewState
---@param is_ready fun(s: PeekstackStackViewState): boolean
function M.render(s, is_ready)
Expand Down Expand Up @@ -59,7 +42,7 @@ function M.render(s, is_ready)
s.line_to_id = model.line_to_id
s.header_lines = model.header_lines
s.render_keys = diff.apply(s.bufnr, s.render_keys or {}, model, s.preview_ts_cache)
ensure_non_header_cursor(s)
state.ensure_non_header_cursor(s)
end

return M
21 changes: 21 additions & 0 deletions lua/peekstack/ui/stack_view/state.lua
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,27 @@ function M.reset_open_state(s)
s.preview_ts_cache = {}
end

---Move the cursor below the header rows when it would otherwise sit on a
---header line. No-op when the buffer holds only header lines.
---@param s PeekstackStackViewState
function M.ensure_non_header_cursor(s)
if not (s.winid and vim.api.nvim_win_is_valid(s.winid) and s.bufnr and vim.api.nvim_buf_is_valid(s.bufnr)) then
return
end

local line_count = vim.api.nvim_buf_line_count(s.bufnr)
local header_lines = s.header_lines or 0
if line_count <= header_lines then
return
end

local min_line = header_lines + 1
local cursor = vim.api.nvim_win_get_cursor(s.winid)[1]
if cursor < min_line then
vim.api.nvim_win_set_cursor(s.winid, { min_line, 0 })
end
end

local function cleanup_invalid_states()
for tabpage, s in pairs(states) do
if not vim.api.nvim_tabpage_is_valid(tabpage) then
Expand Down
13 changes: 9 additions & 4 deletions lua/peekstack/ui/viewport.lua
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,24 @@ function M.decorate(popup)
local ids = {}

if viewport.skipped_before and viewport.skipped_before > 0 then
local id = vim.api.nvim_buf_set_extmark(bufnr, NS, 0, 0, {
-- pcall guards against out-of-range coordinates raised by the API.
local ok, id = pcall(vim.api.nvim_buf_set_extmark, bufnr, NS, 0, 0, {
virt_lines = { { { format_marker(viewport.skipped_before, "above"), "PeekstackViewportTruncated" } } },
virt_lines_above = true,
})
ids[#ids + 1] = id
if ok then
ids[#ids + 1] = id
end
end

if viewport.skipped_after and viewport.skipped_after > 0 then
local last = line_count - 1
local id = vim.api.nvim_buf_set_extmark(bufnr, NS, last, 0, {
local ok, id = pcall(vim.api.nvim_buf_set_extmark, bufnr, NS, last, 0, {
virt_lines = { { { format_marker(viewport.skipped_after, "below"), "PeekstackViewportTruncated" } } },
})
ids[#ids + 1] = id
if ok then
ids[#ids + 1] = id
end
end

if #ids == 0 then
Expand Down
53 changes: 53 additions & 0 deletions tests/config_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -778,5 +778,58 @@ describe("config", function()
assert.is_true(has_message("persist.session must be a table"))
assert.equals("table", type(cfg.persist.session))
end)

describe("unknown keys", function()
it("warns on an unknown top-level key", function()
config.setup({ unknown_top = true })
assert.is_true(has_message("Unknown config key"))
assert.is_true(has_message("unknown_top"))
end)

it("warns on a mistyped nested section key", function()
config.setup({ ui = { popups = { editable = true } } })
assert.is_true(has_message("ui.popups"))
end)

it("warns on a mistyped keymap action name", function()
config.setup({ ui = { keys = { focus_nxt = "<C-n>" } } })
assert.is_true(has_message("ui.keys.focus_nxt"))
end)

it("does not warn for valid config", function()
config.setup({
ui = { popup = { editable = true }, keys = { close = "x" } },
picker = { backend = "telescope", builtin = { preview_lines = 3 } },
providers = { marks = { enable = true } },
persist = { enabled = true, auto = { enabled = true } },
})
assert.is_false(has_message("Unknown config key"))
end)

it("allows custom provider icons in ui.title.icons.map", function()
config.setup({
ui = { title = { icons = { map = { custom_provider = "" } } } },
})
assert.is_false(has_message("Unknown config key"))
end)

it("allows custom filetypes in ui.title.context.node_types", function()
config.setup({
ui = { title = { context = { node_types = { mylang = { "func" } } } } },
})
assert.is_false(has_message("Unknown config key"))
end)

it("does not flag extended close_events list entries", function()
config.setup({
ui = {
inline_preview = {
close_events = { "CursorMoved", "InsertEnter", "BufLeave", "WinLeave", "FocusLost" },
},
},
})
assert.is_false(has_message("Unknown config key"))
end)
end)
end)
end)
Loading