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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,8 @@ 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`
> with a `vim.notify` warning naming the default, counts, sizes and durations
> must be integers (fractions, NaN and infinities are rejected), 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)
Expand Down Expand Up @@ -357,6 +358,10 @@ for a name instead of using the default.
> The storage is resolved when a save, delete, or rename is requested, so changing directory while
> it is still being written does not move it to another repository.

If the storage file cannot be read (an I/O error, invalid JSON, or a version this release does not
know), peekstack warns and refuses to save, delete, or rename sessions in it instead of replacing
it with an empty store. Fix or move the file to recover.

> [!IMPORTANT]
> Sessions are written as plain JSON under `vim.fn.stdpath("state") .. "/peekstack/"`. Each entry
> stores the file URI, line/column range, title, provider name, pin/buffer-mode flags, parent
Expand All @@ -376,7 +381,8 @@ When `persist.auto.enabled = true`, peekstack can automatically restore and save
The auto session holds a single stack: the one that changed most recently, saved to the repository
that was current when it changed. Leaving Neovim saves that stack even if the cursor is in another
window, skips the save if no stack changed during the session, and keeps the stored session as-is
once that stack's window has been closed.
once that stack's window has been closed. The save on leave waits up to one second for earlier
saves to finish; if they are still writing, it is skipped with a warning rather than racing them.

Auto persist only runs inside a git repository and always uses the repository session storage. Make sure
`persist.enabled = true` as well.
Expand Down
13 changes: 12 additions & 1 deletion doc/peekstack.txt
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ With options:
})
<

`setup()` never throws on bad config. Invalid values fall back to their
defaults with a warning that names the default. Counts, sizes and durations
must be integers; fractions, NaN and infinities are rejected.

==============================================================================
PICKER *peekstack-picker*

Expand Down Expand Up @@ -456,6 +460,11 @@ cwd-based storage. The storage is resolved when a save, delete, or rename
is requested, so changing directory while it is still being written does
not move it to another repository.

If the storage file cannot be read (an I/O error, invalid JSON, or a
version this release does not know), peekstack warns and refuses to save,
delete, or rename sessions in it instead of replacing it with an empty
store. Fix or move the file to recover.

`persist.session.default_name` is used when a name is omitted, unless
`persist.session.prompt_if_missing` is true.

Expand All @@ -472,7 +481,9 @@ The auto session holds a single stack: the one that changed most
recently, saved to the repository that was current when it changed.
Leaving Neovim saves that stack even if the cursor is in another window,
skips the save if no stack changed during the session, and keeps the
stored session as-is once that stack's window has been closed.
stored session as-is once that stack's window has been closed. The save on
leave waits up to one second for earlier saves to finish; if they are
still writing, it is skipped with a warning rather than racing them.

Auto persistence runs only inside a git repository and always uses
the repository session storage.
Expand Down
12 changes: 3 additions & 9 deletions lua/peekstack/config/validate/rules/persist.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ local M = {}
---@type PeekstackConfigFieldRule[]
local PERSIST_RULES = {
{ key = "enabled", validate = shared.field_type("boolean") },
{ key = "max_items", validate = shared.field_number_range({ min = 1 }) },
{ key = "max_items", validate = shared.field_integer_range({ min = 1 }) },
}

---@type PeekstackConfigFieldRule[]
Expand All @@ -21,7 +21,7 @@ local PERSIST_AUTO_RULES = {
{ key = "restore", validate = shared.field_type("boolean") },
{ key = "save", validate = shared.field_type("boolean") },
{ key = "restore_if_empty", validate = shared.field_type("boolean") },
{ key = "debounce_ms", validate = shared.field_number_range({ min = 0, max = 600000 }) },
{ key = "debounce_ms", validate = shared.field_integer_range({ min = 0, max = 600000 }) },
{ key = "save_on_leave", validate = shared.field_type("boolean") },
}

Expand All @@ -43,13 +43,7 @@ function M.validate(cfg, defaults)
end

if persist.auto ~= nil then
local auto = shared.ensure_table_field(
persist,
"auto",
"persist.auto",
defaults.persist.auto,
{ fallback = false, message = "persist.auto must be a table" }
)
local auto = shared.ensure_table_field(persist, "auto", "persist.auto", defaults.persist.auto)
if auto then
shared.apply_rules(auto, "persist.auto", defaults.persist.auto, PERSIST_AUTO_RULES)
end
Expand Down
2 changes: 1 addition & 1 deletion lua/peekstack/config/validate/rules/picker.lua
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ local PICKER_RULES = {

---@type PeekstackConfigFieldRule[]
local PICKER_BUILTIN_RULES = {
{ key = "preview_lines", validate = shared.field_number_range({ min = 0 }) },
{ key = "preview_lines", validate = shared.field_integer_range({ min = 0 }) },
}

---@param cfg table
Expand Down
26 changes: 13 additions & 13 deletions lua/peekstack/config/validate/rules/ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ local KNOWN_STACK_VIEW_POSITIONS = { "left", "right", "bottom" }
---@type PeekstackConfigFieldRule[]
local UI_PATH_RULES = {
{ key = "base", validate = shared.field_enum(KNOWN_PATH_BASES), require_truthy = true },
{ key = "max_width", validate = shared.validate_non_negative_number },
{ key = "max_width", validate = shared.field_integer_range({ min = 0 }) },
}

---@type PeekstackConfigFieldRule[]
Expand All @@ -43,14 +43,14 @@ local POPUP_SOURCE_RULES = {

---@type PeekstackConfigFieldRule[]
local POPUP_HISTORY_RULES = {
{ key = "max_items", validate = shared.field_number_range({ min = 1 }) },
{ key = "max_items", validate = shared.field_integer_range({ min = 1 }) },
{ key = "restore_position", validate = shared.field_enum(KNOWN_RESTORE_POSITIONS), require_truthy = true },
}

---@type PeekstackConfigFieldRule[]
local INLINE_PREVIEW_RULES = {
{ key = "enabled", validate = shared.field_type("boolean") },
{ key = "max_lines", validate = shared.field_number_range({ min = 1 }) },
{ key = "max_lines", validate = shared.field_integer_range({ min = 1 }) },
{ key = "hl_group", validate = shared.field_type("string") },
{ key = "close_events", validate = shared.field_event_list() },
}
Expand All @@ -63,8 +63,8 @@ local QUICK_PEEK_RULES = {
---@type PeekstackConfigFieldRule[]
local POPUP_AUTO_CLOSE_RULES = {
{ key = "enabled", validate = shared.field_type("boolean") },
{ key = "idle_ms", validate = shared.field_number_range({ min = 1 }) },
{ key = "check_interval_ms", validate = shared.field_number_range({ min = 1 }) },
{ key = "idle_ms", validate = shared.field_integer_range({ min = 1 }) },
{ key = "check_interval_ms", validate = shared.field_integer_range({ min = 1 }) },
{ key = "ignore_pinned", validate = shared.field_type("boolean") },
}

Expand All @@ -87,7 +87,7 @@ local TITLE_RULES = {
---@type PeekstackConfigFieldRule[]
local TITLE_CONTEXT_RULES = {
{ key = "enabled", validate = shared.field_type("boolean") },
{ key = "max_depth", validate = shared.field_number_range({ min = 1 }) },
{ key = "max_depth", validate = shared.field_integer_range({ min = 1 }) },
{ key = "separator", validate = shared.field_type("string") },
}

Expand All @@ -100,25 +100,25 @@ local TITLE_ICON_RULES = {
local LAYOUT_RULES = {
{ key = "style", validate = shared.field_enum(KNOWN_LAYOUT_STYLES), require_truthy = true },
{ key = "max_ratio", validate = shared.field_ratio() },
{ key = "zindex_base", validate = shared.field_number_range({ min = 1 }) },
{ key = "zindex_base", validate = shared.field_integer_range({ min = 1 }) },
}

---@type PeekstackConfigFieldRule[]
local LAYOUT_MIN_SIZE_RULES = {
{ key = "w", validate = shared.field_number_range({ min = 1 }) },
{ key = "h", validate = shared.field_number_range({ min = 1 }) },
{ key = "w", validate = shared.field_integer_range({ min = 1 }) },
{ key = "h", validate = shared.field_integer_range({ min = 1 }) },
}

---@type PeekstackConfigFieldRule[]
local LAYOUT_SHRINK_RULES = {
{ key = "w", validate = shared.field_number_range({ min = 0 }) },
{ key = "h", validate = shared.field_number_range({ min = 0 }) },
{ key = "w", validate = shared.field_integer_range({ min = 0 }) },
{ key = "h", validate = shared.field_integer_range({ min = 0 }) },
}

---@type PeekstackConfigFieldRule[]
local LAYOUT_OFFSET_RULES = {
{ key = "row", validate = shared.field_number_range({ min = 0 }) },
{ key = "col", validate = shared.field_number_range({ min = 0 }) },
{ key = "row", validate = shared.field_integer_range({ min = 0 }) },
{ key = "col", validate = shared.field_integer_range({ min = 0 }) },
}

---@param map table
Expand Down
61 changes: 23 additions & 38 deletions lua/peekstack/config/validate/shared.lua
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,24 @@ function M.validate_type(path, expected_type, value, default)
return value
end

---Whether `value` is an integral number. NaN and infinities fail because
---`x % 1` is NaN for them.
---@param value any
---@return boolean
local function is_integer(value)
return type(value) == "number" and value % 1 == 0
end

---Validate counts, sizes and durations: finite integers within bounds.
---@param path string
---@param value any
---@param default number
---@param opts? { min: number?, max: number? }
---@return number
function M.validate_number_range(path, value, default, opts)
if type(value) ~= "number" then
notify.warn(string.format("%s must be a number, got %s. Falling back to %s", path, type(value), default))
---@param default integer
---@param opts? { min: integer?, max: integer? }
---@return integer
function M.validate_integer_range(path, value, default, opts)
if not is_integer(value) then
local got = type(value) == "number" and tostring(value) or type(value)
notify.warn(string.format("%s must be an integer, got %s. Falling back to %s", path, got, default))
return default
end
if opts and opts.min ~= nil and value < opts.min then
Expand All @@ -64,7 +74,8 @@ function M.validate_ratio(path, value, default)
notify.warn(string.format("%s must be a number, got %s. Falling back to %s", path, type(value), default))
return default
end
if value <= 0 or value > 1 then
-- `not (value > 0)` also rejects NaN, which fails every comparison.
if not (value > 0) or value > 1 then
notify.warn(string.format("%s must be in (0, 1], got %s. Falling back to %s", path, value, default))
return default
end
Expand Down Expand Up @@ -142,22 +153,6 @@ function M.validate_enum(path, value, known, default)
return value
end

---@param path string
---@param value any
---@param default number
---@return number
function M.validate_non_negative_number(path, value, default)
if type(value) ~= "number" then
notify.warn(string.format("%s must be a number, got %s", path, type(value)))
return default
end
if value < 0 then
notify.warn(string.format("%s must be >= 0, got %s", path, value))
return default
end
return value
end

---@param expected_type string
---@return PeekstackConfigFieldValidator
function M.field_type(expected_type)
Expand All @@ -174,11 +169,11 @@ function M.field_enum(known)
end
end

---@param opts { min: number?, max: number? }
---@param opts { min: integer?, max: integer? }
---@return PeekstackConfigFieldValidator
function M.field_number_range(opts)
function M.field_integer_range(opts)
return function(path, value, default)
return M.validate_number_range(path, value, default, opts)
return M.validate_integer_range(path, value, default, opts)
end
end

Expand Down Expand Up @@ -209,9 +204,8 @@ end
---@param key string
---@param path string
---@param defaults table
---@param opts? { fallback: boolean?, message: string? }
---@return table?
function M.ensure_table_field(parent, key, path, defaults, opts)
function M.ensure_table_field(parent, key, path, defaults)
local value = parent[key]
if value == nil then
return nil
Expand All @@ -220,16 +214,7 @@ function M.ensure_table_field(parent, key, path, defaults, opts)
return value
end

if opts and opts.message then
notify.warn(opts.message)
else
notify.warn(string.format("%s must be a table, got %s. Falling back to defaults", path, type(value)))
end

if opts and opts.fallback == false then
return nil
end

notify.warn(string.format("%s must be a table, got %s. Falling back to defaults", path, type(value)))
parent[key] = vim.deepcopy(defaults)
return parent[key]
end
Expand Down
10 changes: 6 additions & 4 deletions lua/peekstack/persist/codec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,20 @@ function M.encode(data)
return encoded
end

---Decode store file content. Empty content is an empty store; content that
---is not a JSON object returns nil so the caller does not overwrite it.
---@param path string
---@param data string?
---@return PeekstackStoreData
---@param data string
---@return PeekstackStoreData?
function M.decode(path, data)
if not data or data == "" then
if data == "" then
return M.empty_data()
end

local ok, decoded = pcall(vim.json.decode, data)
if not ok or type(decoded) ~= "table" then
notify.warn("Failed to decode session data: " .. path)
return M.empty_data()
return nil
end

return decoded
Expand Down
7 changes: 7 additions & 0 deletions lua/peekstack/persist/migrate.lua
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ local function normalize_session(session)
return session
end

---Whether `version` is a store format this module can read.
---@param version any
---@return boolean
function M.supports(version)
return version == 1 or version == 2
end

---Ensure data is in the correct format (migration helper)
---@param data any
---@return PeekstackStoreData
Expand Down
Loading
Loading