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/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/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 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/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) 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)