From 094f6df36e9813db5efea2cfb67c448cf003bdb7 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Wed, 3 Jun 2026 08:38:10 +0900 Subject: [PATCH 1/3] fix(ui): harden extmark decorations against out-of-range coordinates Origin highlight, diagnostic underline, and viewport truncation markers now wrap nvim_buf_set_extmark in pcall and clamp the origin row to the buffer line count, so a stale row or an end_col past a truncated line drops only that decoration instead of raising and breaking the popup. --- lua/peekstack/ui/diagnostics.lua | 15 +++++++++--- lua/peekstack/ui/feedback.lua | 10 ++++++-- lua/peekstack/ui/viewport.lua | 13 +++++++--- tests/diagnostics_popup_spec.lua | 27 ++++++++++++++++++++ tests/feedback_spec.lua | 42 ++++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 tests/feedback_spec.lua diff --git a/lua/peekstack/ui/diagnostics.lua b/lua/peekstack/ui/diagnostics.lua index dd03384..5e75167 100644 --- a/lua/peekstack/ui/diagnostics.lua +++ b/lua/peekstack/ui/diagnostics.lua @@ -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 diff --git a/lua/peekstack/ui/feedback.lua b/lua/peekstack/ui/feedback.lua index b9c2cba..3aa8596 100644 --- a/lua/peekstack/ui/feedback.lua +++ b/lua/peekstack/ui/feedback.lua @@ -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", }) diff --git a/lua/peekstack/ui/viewport.lua b/lua/peekstack/ui/viewport.lua index aa6760a..facd2e1 100644 --- a/lua/peekstack/ui/viewport.lua +++ b/lua/peekstack/ui/viewport.lua @@ -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 diff --git a/tests/diagnostics_popup_spec.lua b/tests/diagnostics_popup_spec.lua index c689d6e..4644c96 100644 --- a/tests/diagnostics_popup_spec.lua +++ b/tests/diagnostics_popup_spec.lua @@ -53,6 +53,33 @@ describe("peekstack.ui.diagnostics", function() vim.fn.delete(tmpfile) end) + it("does not error when diagnostic end_col exceeds the line length", function() + local diagnostics = require("peekstack.ui.diagnostics") + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "ab" }) + + local model = { + bufnr = bufnr, + line_offset = 0, + location = { + provider = "diagnostics.under_cursor", + text = "boom", + kind = vim.diagnostic.severity.ERROR, + range = { + start = { line = 0, character = 0 }, + ["end"] = { line = 0, character = 999 }, + }, + }, + } + + local ok, result = pcall(diagnostics.decorate, model) + assert.is_true(ok) + -- The virt_lines marker is still added even though the underline is dropped. + assert.is_not_nil(result) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + it("clears diagnostic extmarks on close in source mode", function() local tmpfile = vim.fn.tempname() .. ".lua" vim.fn.writefile({ "line1", "line2" }, tmpfile) diff --git a/tests/feedback_spec.lua b/tests/feedback_spec.lua new file mode 100644 index 0000000..eb84e8a --- /dev/null +++ b/tests/feedback_spec.lua @@ -0,0 +1,42 @@ +describe("peekstack.ui.feedback", function() + local config = require("peekstack.config") + local feedback = require("peekstack.ui.feedback") + + before_each(function() + config.setup({}) + end) + + it("highlights the origin row", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "line1", "line2", "line3" }) + local winid = vim.api.nvim_get_current_win() + + feedback.highlight_origin({ winid = winid, bufnr = bufnr, row = 2, col = 0 }) + + local ns = vim.api.nvim_get_namespaces()["peekstack_origin"] + assert.is_not_nil(ns) + local marks = vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, {}) + assert.equals(1, #marks) + assert.equals(1, marks[1][2]) -- row 2 (1-based) -> extmark row 1 (0-based) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it("does not error when the origin row is beyond the buffer", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "only line" }) + local winid = vim.api.nvim_get_current_win() + + local ok = pcall(feedback.highlight_origin, { winid = winid, bufnr = bufnr, row = 1000, col = 0 }) + assert.is_true(ok) + + -- The row is clamped, so the highlight still lands on the last line. + local ns = vim.api.nvim_get_namespaces()["peekstack_origin"] + assert.is_not_nil(ns) + local marks = vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, {}) + assert.equals(1, #marks) + assert.equals(0, marks[1][2]) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) +end) From afd53843efc6c6ffef32722d32c5895e6f3b06c1 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Wed, 3 Jun 2026 08:38:18 +0900 Subject: [PATCH 2/3] refactor(stack_view): consolidate ensure_non_header_cursor into state The render and keymap modules carried slightly diverged copies of the header-cursor guard. Move a single implementation to stack_view.state and call it from render, keymaps, and init, keeping the header-only no-op behaviour and the nil-safe header_lines handling. --- lua/peekstack/ui/stack_view/init.lua | 2 +- lua/peekstack/ui/stack_view/keymaps.lua | 25 ++++--------------------- lua/peekstack/ui/stack_view/render.lua | 21 ++------------------- lua/peekstack/ui/stack_view/state.lua | 21 +++++++++++++++++++++ 4 files changed, 28 insertions(+), 41 deletions(-) diff --git a/lua/peekstack/ui/stack_view/init.lua b/lua/peekstack/ui/stack_view/init.lua index 88de44f..cc2bbe0 100644 --- a/lua/peekstack/ui/stack_view/init.lua +++ b/lua/peekstack/ui/stack_view/init.lua @@ -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, }) diff --git a/lua/peekstack/ui/stack_view/keymaps.lua b/lua/peekstack/ui/stack_view/keymaps.lua index c30f11f..4e7ee32 100644 --- a/lua/peekstack/ui/stack_view/keymaps.lua +++ b/lua/peekstack/ui/stack_view/keymaps.lua @@ -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 = {} @@ -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) @@ -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 @@ -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 }) @@ -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 }) diff --git a/lua/peekstack/ui/stack_view/render.lua b/lua/peekstack/ui/stack_view/render.lua index c30f3ea..6d2e077 100644 --- a/lua/peekstack/ui/stack_view/render.lua +++ b/lua/peekstack/ui/stack_view/render.lua @@ -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) @@ -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 diff --git a/lua/peekstack/ui/stack_view/state.lua b/lua/peekstack/ui/stack_view/state.lua index 67cbc43..ea2d377 100644 --- a/lua/peekstack/ui/stack_view/state.lua +++ b/lua/peekstack/ui/stack_view/state.lua @@ -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 From ead353b953c37704975e56473b1d11e4b5fc16ed Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Wed, 3 Jun 2026 08:38:18 +0900 Subject: [PATCH 3/3] feat(config): warn on unknown or mistyped config keys setup() now compares the merged config against the default schema and reports keys it does not recognise (e.g. ui.popups instead of ui.popup) via vim.notify, while skipping user-keyed subtrees such as the icon map and node_types so custom providers and filetypes are not flagged. --- README.md | 5 ++ lua/peekstack/config/validate/init.lua | 4 ++ lua/peekstack/config/validate/unknown.lua | 64 +++++++++++++++++++++++ tests/config_spec.lua | 53 +++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 lua/peekstack/config/validate/unknown.lua diff --git a/README.md b/README.md index e6932f9..d4068e1 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,11 @@ Configure via `require("peekstack").setup({ ... })`. +> [!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 diff --git a/lua/peekstack/config/validate/init.lua b/lua/peekstack/config/validate/init.lua index 14ec46a..df35e34 100644 --- a/lua/peekstack/config/validate/init.lua +++ b/lua/peekstack/config/validate/init.lua @@ -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) diff --git a/lua/peekstack/config/validate/unknown.lua b/lua/peekstack/config/validate/unknown.lua new file mode 100644 index 0000000..0a2019f --- /dev/null +++ b/lua/peekstack/config/validate/unknown.lua @@ -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 +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 diff --git a/tests/config_spec.lua b/tests/config_spec.lua index 47676a6..1c2fdea 100644 --- a/tests/config_spec.lua +++ b/tests/config_spec.lua @@ -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 = "" } } }) + 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)