diff --git a/lua/peekstack/config.lua b/lua/peekstack/config.lua index 3815d88..81d2ac8 100644 --- a/lua/peekstack/config.lua +++ b/lua/peekstack/config.lua @@ -212,6 +212,14 @@ local function validate_event_list(path, value) end end +---@alias PeekstackConfigFieldValidator fun(path: string, value: any, default: any): any + +---@class PeekstackConfigFieldRule +---@field key string +---@field validate PeekstackConfigFieldValidator +---@field assign? boolean +---@field require_truthy? boolean + ---@param path string ---@param value any ---@param known string[] @@ -227,332 +235,370 @@ local function validate_enum(path, value, known, default) return value end ----@param cfg table -local function validate(cfg) - if cfg.ui and cfg.ui.keys ~= nil then - if type(cfg.ui.keys) ~= "table" then - notify.warn(string.format("ui.keys must be a table, got %s. Falling back to defaults", type(cfg.ui.keys))) - cfg.ui.keys = vim.deepcopy(M.defaults.ui.keys) - else - local defaults = M.defaults.ui.keys - for name, val in pairs(cfg.ui.keys) do - if type(val) ~= "string" then - notify.warn( - string.format( - "ui.keys.%s must be a string, got %s. Falling back to %s", - name, - type(val), - tostring(defaults[name]) - ) - ) - cfg.ui.keys[name] = defaults[name] - end - end - end +---@param expected_type string +---@return PeekstackConfigFieldValidator +local function field_type(expected_type) + return function(path, value, default) + return validate_type(path, expected_type, value, default) end +end - if cfg.ui and cfg.ui.popup and cfg.ui.popup.editable ~= nil then - cfg.ui.popup.editable = - validate_type("ui.popup.editable", "boolean", cfg.ui.popup.editable, M.defaults.ui.popup.editable) +---@param known string[] +---@return PeekstackConfigFieldValidator +local function field_enum(known) + return function(path, value, default) + return validate_enum(path, value, known, default) end +end - if cfg.ui and cfg.ui.path then - local path = cfg.ui.path - if path.base then - path.base = validate_enum("ui.path.base", path.base, KNOWN_PATH_BASES, M.defaults.ui.path.base) - end - if path.max_width ~= nil then - if type(path.max_width) ~= "number" then - notify.warn(string.format("ui.path.max_width must be a number, got %s", type(path.max_width))) - elseif path.max_width < 0 then - notify.warn(string.format("ui.path.max_width must be >= 0, got %s", path.max_width)) - path.max_width = M.defaults.ui.path.max_width - end - end +---@param opts { min: number?, max: number? } +---@return PeekstackConfigFieldValidator +local function field_number_range(opts) + return function(path, value, default) + return validate_number_range(path, value, default, opts) end +end - if cfg.ui and cfg.ui.stack_view ~= nil then - if type(cfg.ui.stack_view) ~= "table" then - notify.warn( - string.format("ui.stack_view must be a table, got %s. Falling back to defaults", type(cfg.ui.stack_view)) - ) - cfg.ui.stack_view = vim.deepcopy(M.defaults.ui.stack_view) - else - local stack_view = cfg.ui.stack_view - if stack_view.position ~= nil then - stack_view.position = validate_enum( - "ui.stack_view.position", - stack_view.position, - KNOWN_STACK_VIEW_POSITIONS, - M.defaults.ui.stack_view.position - ) - end - end +---@return PeekstackConfigFieldValidator +local function field_ratio() + return function(path, value, default) + return validate_ratio(path, value, default) end +end - -- Validate buffer_mode - if cfg.ui and cfg.ui.popup and cfg.ui.popup.buffer_mode then - cfg.ui.popup.buffer_mode = validate_enum( - "ui.popup.buffer_mode", - cfg.ui.popup.buffer_mode, - KNOWN_BUFFER_MODES, - M.defaults.ui.popup.buffer_mode - ) +---@return PeekstackConfigFieldValidator +local function field_event_list() + return function(path, value, _default) + validate_event_list(path, value) + return value end +end - -- Validate source mode settings - if cfg.ui and cfg.ui.popup and cfg.ui.popup.source then - local source = cfg.ui.popup.source - if source.prevent_auto_close_if_modified ~= nil then - source.prevent_auto_close_if_modified = validate_type( - "ui.popup.source.prevent_auto_close_if_modified", - "boolean", - source.prevent_auto_close_if_modified, - M.defaults.ui.popup.source.prevent_auto_close_if_modified - ) +---@param path string +---@param value any +---@param default number +---@return number +local function 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 value any +---@return table? +local function as_table(value) + if type(value) == "table" then + return value + end + return nil +end + +---@param parent table +---@param key string +---@param path string +---@param defaults table +---@param opts? { fallback: boolean?, message: string? } +---@return table? +local function ensure_table_field(parent, key, path, defaults, opts) + local value = parent[key] + if value == nil then + return nil + end + if type(value) == "table" then + 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 + parent[key] = vim.deepcopy(defaults) + return parent[key] +end + +---@param section table +---@param path string +---@param defaults table +---@param rules PeekstackConfigFieldRule[] +local function apply_rules(section, path, defaults, rules) + for _, rule in ipairs(rules) do + local value = section[rule.key] + local should_apply = value ~= nil + if rule.require_truthy then + should_apply = not not value end - if source.confirm_on_close ~= nil then - source.confirm_on_close = validate_type( - "ui.popup.source.confirm_on_close", - "boolean", - source.confirm_on_close, - M.defaults.ui.popup.source.confirm_on_close - ) + if should_apply then + local validated = rule.validate(path .. "." .. rule.key, value, defaults[rule.key]) + if rule.assign ~= false then + section[rule.key] = validated + end end end +end - -- Validate history settings - if cfg.ui and cfg.ui.popup and cfg.ui.popup.history then - local history = cfg.ui.popup.history - if history.max_items ~= nil then - history.max_items = validate_number_range( - "ui.popup.history.max_items", - history.max_items, - M.defaults.ui.popup.history.max_items, - { min = 1 } - ) - end - if history.restore_position then - history.restore_position = validate_enum( - "ui.popup.history.restore_position", - history.restore_position, - KNOWN_RESTORE_POSITIONS, - M.defaults.ui.popup.history.restore_position +---@type PeekstackConfigFieldRule[] +local UI_PATH_RULES = { + { key = "base", validate = field_enum(KNOWN_PATH_BASES), require_truthy = true }, + { key = "max_width", validate = validate_non_negative_number }, +} + +---@type PeekstackConfigFieldRule[] +local STACK_VIEW_RULES = { + { key = "position", validate = field_enum(KNOWN_STACK_VIEW_POSITIONS) }, +} + +---@type PeekstackConfigFieldRule[] +local POPUP_RULES = { + { key = "editable", validate = field_type("boolean") }, + { key = "buffer_mode", validate = field_enum(KNOWN_BUFFER_MODES), require_truthy = true }, +} + +---@type PeekstackConfigFieldRule[] +local POPUP_SOURCE_RULES = { + { key = "prevent_auto_close_if_modified", validate = field_type("boolean") }, + { key = "confirm_on_close", validate = field_type("boolean") }, +} + +---@type PeekstackConfigFieldRule[] +local POPUP_HISTORY_RULES = { + { key = "max_items", validate = field_number_range({ min = 1 }) }, + { key = "restore_position", validate = field_enum(KNOWN_RESTORE_POSITIONS), require_truthy = true }, +} + +---@type PeekstackConfigFieldRule[] +local INLINE_PREVIEW_RULES = { + { key = "enabled", validate = field_type("boolean") }, + { key = "max_lines", validate = field_number_range({ min = 1 }) }, + { key = "hl_group", validate = field_type("string") }, + { key = "close_events", validate = field_event_list() }, +} + +---@type PeekstackConfigFieldRule[] +local QUICK_PEEK_RULES = { + { key = "close_events", validate = field_event_list() }, +} + +---@type PeekstackConfigFieldRule[] +local TITLE_ICON_RULES = { + { key = "enabled", validate = field_type("boolean") }, +} + +---@type PeekstackConfigFieldRule[] +local PICKER_RULES = { + { key = "backend", validate = field_enum(KNOWN_BACKENDS), require_truthy = true }, +} + +---@type PeekstackConfigFieldRule[] +local LAYOUT_RULES = { + { key = "style", validate = field_enum(KNOWN_LAYOUT_STYLES), require_truthy = true }, + { key = "max_ratio", validate = field_ratio() }, +} + +---@type PeekstackConfigFieldRule[] +local LAYOUT_MIN_SIZE_RULES = { + { key = "w", validate = field_number_range({ min = 1 }) }, + { key = "h", validate = field_number_range({ min = 1 }) }, +} + +---@type PeekstackConfigFieldRule[] +local LAYOUT_SHRINK_RULES = { + { key = "w", validate = field_number_range({ min = 0 }) }, + { key = "h", validate = field_number_range({ min = 0 }) }, +} + +---@type PeekstackConfigFieldRule[] +local LAYOUT_OFFSET_RULES = { + { key = "row", validate = field_number_range({ min = 0 }) }, + { key = "col", validate = field_number_range({ min = 0 }) }, +} + +---@type PeekstackConfigFieldRule[] +local MARKS_RULES = { + { key = "scope", validate = field_enum(KNOWN_MARK_SCOPES), require_truthy = true }, + { key = "include", validate = field_type("string") }, + { key = "include_special", validate = field_type("boolean") }, +} + +---@type PeekstackConfigFieldRule[] +local PERSIST_RULES = { + { key = "max_items", validate = field_number_range({ min = 1 }) }, +} + +---@type PeekstackConfigFieldRule[] +local PERSIST_SESSION_RULES = { + { key = "default_name", validate = field_type("string") }, + { key = "prompt_if_missing", validate = field_type("boolean") }, +} + +---@type PeekstackConfigFieldRule[] +local PERSIST_AUTO_RULES = { + { key = "enabled", validate = field_type("boolean") }, + { key = "session_name", validate = field_type("string") }, + { key = "restore", validate = field_type("boolean") }, + { key = "save", validate = field_type("boolean") }, + { key = "restore_if_empty", validate = field_type("boolean") }, + { key = "debounce_ms", validate = field_type("number") }, + { key = "save_on_leave", validate = field_type("boolean") }, +} + +---@param cfg table +local function validate_ui_keys(cfg) + local ui = as_table(cfg.ui) + if not ui or ui.keys == nil then + return + end + + local keys = ensure_table_field(ui, "keys", "ui.keys", M.defaults.ui.keys) + if not keys then + return + end + + local defaults = M.defaults.ui.keys + for name, val in pairs(keys) do + if type(val) ~= "string" then + notify.warn( + string.format( + "ui.keys.%s must be a string, got %s. Falling back to %s", + name, + type(val), + tostring(defaults[name]) + ) ) + keys[name] = defaults[name] end end +end - if cfg.ui and cfg.ui.inline_preview then - local inline_preview = cfg.ui.inline_preview - if inline_preview.enabled ~= nil then - inline_preview.enabled = validate_type( - "ui.inline_preview.enabled", - "boolean", - inline_preview.enabled, - M.defaults.ui.inline_preview.enabled - ) +---@param cfg table +local function validate(cfg) + validate_ui_keys(cfg) + + local ui = as_table(cfg.ui) + if ui then + local popup = as_table(ui.popup) + if popup then + apply_rules(popup, "ui.popup", M.defaults.ui.popup, POPUP_RULES) + + local source = as_table(popup.source) + if source then + apply_rules(source, "ui.popup.source", M.defaults.ui.popup.source, POPUP_SOURCE_RULES) + end + + local history = as_table(popup.history) + if history then + apply_rules(history, "ui.popup.history", M.defaults.ui.popup.history, POPUP_HISTORY_RULES) + end end - if inline_preview.max_lines ~= nil then - inline_preview.max_lines = validate_number_range( - "ui.inline_preview.max_lines", - inline_preview.max_lines, - M.defaults.ui.inline_preview.max_lines, - { min = 1 } - ) + + local path = as_table(ui.path) + if path then + apply_rules(path, "ui.path", M.defaults.ui.path, UI_PATH_RULES) end - if inline_preview.hl_group ~= nil then - inline_preview.hl_group = validate_type( - "ui.inline_preview.hl_group", - "string", - inline_preview.hl_group, - M.defaults.ui.inline_preview.hl_group - ) + + if ui.stack_view ~= nil then + local stack_view = ensure_table_field(ui, "stack_view", "ui.stack_view", M.defaults.ui.stack_view) + if stack_view then + apply_rules(stack_view, "ui.stack_view", M.defaults.ui.stack_view, STACK_VIEW_RULES) + end end - if inline_preview.close_events ~= nil then - validate_event_list("ui.inline_preview.close_events", inline_preview.close_events) + + local inline_preview = as_table(ui.inline_preview) + if inline_preview then + apply_rules(inline_preview, "ui.inline_preview", M.defaults.ui.inline_preview, INLINE_PREVIEW_RULES) end - end - if cfg.ui and cfg.ui.quick_peek then - local quick_peek = cfg.ui.quick_peek - if quick_peek.close_events ~= nil then - validate_event_list("ui.quick_peek.close_events", quick_peek.close_events) + local quick_peek = as_table(ui.quick_peek) + if quick_peek then + apply_rules(quick_peek, "ui.quick_peek", M.defaults.ui.quick_peek, QUICK_PEEK_RULES) end - end - -- Validate ui.title.icons - if cfg.ui and cfg.ui.title then - if cfg.ui.title.icons ~= nil and type(cfg.ui.title.icons) ~= "table" then - notify.warn("ui.title.icons must be a table, got " .. type(cfg.ui.title.icons) .. ". Falling back to defaults") - cfg.ui.title.icons = vim.deepcopy(M.defaults.ui.title.icons) - elseif type(cfg.ui.title.icons) == "table" then - local icons = cfg.ui.title.icons - if icons.enabled ~= nil then - icons.enabled = - validate_type("ui.title.icons.enabled", "boolean", icons.enabled, M.defaults.ui.title.icons.enabled) - end - if icons.map ~= nil and type(icons.map) ~= "table" then - notify.warn("ui.title.icons.map must be a table, got " .. type(icons.map) .. ". Falling back to defaults") - icons.map = vim.deepcopy(M.defaults.ui.title.icons.map) + local title = as_table(ui.title) + if title then + if title.icons ~= nil and type(title.icons) ~= "table" then + notify.warn("ui.title.icons must be a table, got " .. type(title.icons) .. ". Falling back to defaults") + title.icons = vim.deepcopy(M.defaults.ui.title.icons) + elseif type(title.icons) == "table" then + local icons = title.icons + apply_rules(icons, "ui.title.icons", M.defaults.ui.title.icons, TITLE_ICON_RULES) + if icons.map ~= nil and type(icons.map) ~= "table" then + notify.warn("ui.title.icons.map must be a table, got " .. type(icons.map) .. ". Falling back to defaults") + icons.map = vim.deepcopy(M.defaults.ui.title.icons.map) + end end end - end - -- Validate picker backend (fallback to default on invalid value) - if cfg.picker and cfg.picker.backend then - cfg.picker.backend = validate_enum("picker.backend", cfg.picker.backend, KNOWN_BACKENDS, M.defaults.picker.backend) - end + if ui.layout ~= nil then + local layout = ensure_table_field(ui, "layout", "ui.layout", M.defaults.ui.layout) + if layout then + apply_rules(layout, "ui.layout", M.defaults.ui.layout, LAYOUT_RULES) - -- Validate layout style (fallback to default on invalid value) - if cfg.ui and cfg.ui.layout ~= nil then - if type(cfg.ui.layout) ~= "table" then - notify.warn(string.format("ui.layout must be a table, got %s. Falling back to defaults", type(cfg.ui.layout))) - cfg.ui.layout = vim.deepcopy(M.defaults.ui.layout) - else - local layout = cfg.ui.layout - if layout.style then - layout.style = validate_enum("ui.layout.style", layout.style, KNOWN_LAYOUT_STYLES, M.defaults.ui.layout.style) - end - if layout.max_ratio ~= nil then - layout.max_ratio = validate_ratio("ui.layout.max_ratio", layout.max_ratio, M.defaults.ui.layout.max_ratio) - end - if layout.min_size ~= nil then - if type(layout.min_size) ~= "table" then - notify.warn( - string.format("ui.layout.min_size must be a table, got %s. Falling back to defaults", type(layout.min_size)) - ) - layout.min_size = vim.deepcopy(M.defaults.ui.layout.min_size) - else - layout.min_size.w = - validate_number_range("ui.layout.min_size.w", layout.min_size.w, M.defaults.ui.layout.min_size.w, { - min = 1, - }) - layout.min_size.h = - validate_number_range("ui.layout.min_size.h", layout.min_size.h, M.defaults.ui.layout.min_size.h, { - min = 1, - }) + if layout.min_size ~= nil then + local min_size = ensure_table_field(layout, "min_size", "ui.layout.min_size", M.defaults.ui.layout.min_size) + if min_size then + apply_rules(min_size, "ui.layout.min_size", M.defaults.ui.layout.min_size, LAYOUT_MIN_SIZE_RULES) + end end - end - if layout.shrink ~= nil then - if type(layout.shrink) ~= "table" then - notify.warn( - string.format("ui.layout.shrink must be a table, got %s. Falling back to defaults", type(layout.shrink)) - ) - layout.shrink = vim.deepcopy(M.defaults.ui.layout.shrink) - else - layout.shrink.w = - validate_number_range("ui.layout.shrink.w", layout.shrink.w, M.defaults.ui.layout.shrink.w, { min = 0 }) - layout.shrink.h = - validate_number_range("ui.layout.shrink.h", layout.shrink.h, M.defaults.ui.layout.shrink.h, { min = 0 }) + + if layout.shrink ~= nil then + local shrink = ensure_table_field(layout, "shrink", "ui.layout.shrink", M.defaults.ui.layout.shrink) + if shrink then + apply_rules(shrink, "ui.layout.shrink", M.defaults.ui.layout.shrink, LAYOUT_SHRINK_RULES) + end end - end - if layout.offset ~= nil then - if type(layout.offset) ~= "table" then - notify.warn( - string.format("ui.layout.offset must be a table, got %s. Falling back to defaults", type(layout.offset)) - ) - layout.offset = vim.deepcopy(M.defaults.ui.layout.offset) - else - layout.offset.row = validate_number_range( - "ui.layout.offset.row", - layout.offset.row, - M.defaults.ui.layout.offset.row, - { min = 0 } - ) - layout.offset.col = validate_number_range( - "ui.layout.offset.col", - layout.offset.col, - M.defaults.ui.layout.offset.col, - { min = 0 } - ) + + if layout.offset ~= nil then + local offset = ensure_table_field(layout, "offset", "ui.layout.offset", M.defaults.ui.layout.offset) + if offset then + apply_rules(offset, "ui.layout.offset", M.defaults.ui.layout.offset, LAYOUT_OFFSET_RULES) + end end end end end - -- Validate marks provider settings - if cfg.providers and cfg.providers.marks then - local marks = cfg.providers.marks - if marks.scope then - marks.scope = - validate_enum("providers.marks.scope", marks.scope, KNOWN_MARK_SCOPES, M.defaults.providers.marks.scope) - end - if marks.include ~= nil then - marks.include = - validate_type("providers.marks.include", "string", marks.include, M.defaults.providers.marks.include) - end - if marks.include_special ~= nil then - marks.include_special = validate_type( - "providers.marks.include_special", - "boolean", - marks.include_special, - M.defaults.providers.marks.include_special - ) - end + local picker = as_table(cfg.picker) + if picker then + apply_rules(picker, "picker", M.defaults.picker, PICKER_RULES) end - if cfg.persist and cfg.persist.max_items ~= nil then - cfg.persist.max_items = - validate_number_range("persist.max_items", cfg.persist.max_items, M.defaults.persist.max_items, { min = 1 }) + local providers = as_table(cfg.providers) + if providers then + local marks = as_table(providers.marks) + if marks then + apply_rules(marks, "providers.marks", M.defaults.providers.marks, MARKS_RULES) + end end - if cfg.persist and cfg.persist.session then - local session = cfg.persist.session - if session.default_name ~= nil then - session.default_name = validate_type( - "persist.session.default_name", - "string", - session.default_name, - M.defaults.persist.session.default_name - ) - end - if session.prompt_if_missing ~= nil then - session.prompt_if_missing = validate_type( - "persist.session.prompt_if_missing", - "boolean", - session.prompt_if_missing, - M.defaults.persist.session.prompt_if_missing - ) + local persist = as_table(cfg.persist) + if persist then + apply_rules(persist, "persist", M.defaults.persist, PERSIST_RULES) + + local session = as_table(persist.session) + if session then + apply_rules(session, "persist.session", M.defaults.persist.session, PERSIST_SESSION_RULES) end - end - if cfg.persist and cfg.persist.auto ~= nil then - if type(cfg.persist.auto) ~= "table" then - notify.warn("persist.auto must be a table") - else - local auto = cfg.persist.auto - if auto.enabled ~= nil then - auto.enabled = validate_type("persist.auto.enabled", "boolean", auto.enabled, M.defaults.persist.auto.enabled) - end - if auto.session_name ~= nil then - auto.session_name = - validate_type("persist.auto.session_name", "string", auto.session_name, M.defaults.persist.auto.session_name) - end - if auto.restore ~= nil then - auto.restore = validate_type("persist.auto.restore", "boolean", auto.restore, M.defaults.persist.auto.restore) - end - if auto.save ~= nil then - auto.save = validate_type("persist.auto.save", "boolean", auto.save, M.defaults.persist.auto.save) - end - if auto.restore_if_empty ~= nil then - auto.restore_if_empty = validate_type( - "persist.auto.restore_if_empty", - "boolean", - auto.restore_if_empty, - M.defaults.persist.auto.restore_if_empty - ) - end - if auto.debounce_ms ~= nil then - auto.debounce_ms = - validate_type("persist.auto.debounce_ms", "number", auto.debounce_ms, M.defaults.persist.auto.debounce_ms) - end - if auto.save_on_leave ~= nil then - auto.save_on_leave = validate_type( - "persist.auto.save_on_leave", - "boolean", - auto.save_on_leave, - M.defaults.persist.auto.save_on_leave - ) + if persist.auto ~= nil then + local auto = ensure_table_field( + persist, + "auto", + "persist.auto", + M.defaults.persist.auto, + { fallback = false, message = "persist.auto must be a table" } + ) + if auto then + apply_rules(auto, "persist.auto", M.defaults.persist.auto, PERSIST_AUTO_RULES) end end end diff --git a/lua/peekstack/core/location.lua b/lua/peekstack/core/location.lua index df36637..6fa734b 100644 --- a/lua/peekstack/core/location.lua +++ b/lua/peekstack/core/location.lua @@ -2,18 +2,121 @@ local fs = require("peekstack.util.fs") local str = require("peekstack.util.str") local M = {} + +local REALPATH_CACHE_MAX = 512 + +---@type table local realpath_cache = {} +---@type table +local realpath_cache_nodes = {} +---@type string? +local realpath_cache_head = nil +---@type string? +local realpath_cache_tail = nil +local realpath_cache_size = 0 + +---@param key string +local function cache_detach(key) + local node = realpath_cache_nodes[key] + if not node then + return + end + + local prev = node.prev + local next = node.next + + if prev then + realpath_cache_nodes[prev].next = next + else + realpath_cache_head = next + end + + if next then + realpath_cache_nodes[next].prev = prev + else + realpath_cache_tail = prev + end + + node.prev = nil + node.next = nil +end + +---@param key string +local function cache_append_tail(key) + if not realpath_cache_nodes[key] then + realpath_cache_nodes[key] = {} + end + + local node = realpath_cache_nodes[key] + node.prev = realpath_cache_tail + node.next = nil + + if realpath_cache_tail then + realpath_cache_nodes[realpath_cache_tail].next = key + else + realpath_cache_head = key + end + realpath_cache_tail = key +end + +---@param key string +---@param is_new boolean +local function cache_touch(key, is_new) + if not is_new and realpath_cache_tail == key then + return + end + + if not is_new then + cache_detach(key) + else + realpath_cache_size = realpath_cache_size + 1 + end + cache_append_tail(key) +end + +local function cache_evict_if_needed() + while realpath_cache_size > REALPATH_CACHE_MAX do + local evict_key = realpath_cache_head + if not evict_key then + break + end + cache_detach(evict_key) + realpath_cache_nodes[evict_key] = nil + realpath_cache[evict_key] = nil + realpath_cache_size = realpath_cache_size - 1 + end +end + +local function cache_clear() + realpath_cache = {} + realpath_cache_nodes = {} + realpath_cache_head = nil + realpath_cache_tail = nil + realpath_cache_size = 0 +end ---@param fname string ---@param cache? table ---@return string local function resolve_realpath(fname, cache) - local store = cache or realpath_cache - if store[fname] then - return store[fname] + if cache then + if cache[fname] then + return cache[fname] + end + local resolved = vim.uv.fs_realpath(fname) or fname + cache[fname] = resolved + return resolved + end + + if realpath_cache[fname] then + cache_touch(fname, false) + return realpath_cache[fname] end + local resolved = vim.uv.fs_realpath(fname) or fname - store[fname] = resolved + realpath_cache[fname] = resolved + cache_touch(fname, true) + cache_evict_if_needed() return resolved end @@ -205,4 +308,14 @@ function M.is_same_position(location, uri, line, character, opts) return true end +---Reset internal caches (for testing). +function M._reset() + cache_clear() +end + +---@return integer +function M._realpath_cache_limit() + return REALPATH_CACHE_MAX +end + return M diff --git a/lua/peekstack/core/stack.lua b/lua/peekstack/core/stack.lua index 30bc236..e550761 100644 --- a/lua/peekstack/core/stack.lua +++ b/lua/peekstack/core/stack.lua @@ -28,6 +28,128 @@ local stacks = {} local ephemerals = {} ---@type table local stack_view_wins = {} +---@class PeekstackPopupLookupEntry +---@field popup PeekstackPopupModel +---@field root_winid integer? +---@type table +local popup_by_id = {} +---@type table +local popup_by_winid = {} + +---@param model PeekstackPopupModel +local function unindex_popup(model) + if not model then + return + end + + local removed = false + + local id = model.id + if id ~= nil then + local entry_by_id = popup_by_id[id] + if entry_by_id and entry_by_id.popup == model then + popup_by_id[id] = nil + removed = true + end + end + + local winid = model.winid + if winid ~= nil then + local entry_by_winid = popup_by_winid[winid] + if entry_by_winid and entry_by_winid.popup == model then + popup_by_winid[winid] = nil + removed = true + end + end + + if removed then + return + end + + -- Guard against tests mutating id/winid directly. + for popup_id, entry in pairs(popup_by_id) do + if entry.popup == model then + popup_by_id[popup_id] = nil + end + end + for wid, entry in pairs(popup_by_winid) do + if entry.popup == model then + popup_by_winid[wid] = nil + end + end +end + +---@param model PeekstackPopupModel +---@param root_winid integer? +local function index_popup(model, root_winid) + unindex_popup(model) + + local entry = { + popup = model, + root_winid = root_winid, + } + if model.id ~= nil then + popup_by_id[model.id] = entry + end + if model.winid ~= nil then + popup_by_winid[model.winid] = entry + end +end + +---@param id integer +---@return PeekstackPopupLookupEntry? +local function lookup_by_id(id) + local entry = popup_by_id[id] + if entry and entry.popup and entry.popup.id == id then + return entry + end + popup_by_id[id] = nil + + for root_winid, stack in pairs(stacks) do + for _, item in ipairs(stack.popups) do + if item.id == id then + index_popup(item, root_winid) + return popup_by_id[id] + end + end + end + + local ephemeral = ephemerals[id] + if ephemeral then + index_popup(ephemeral, nil) + return popup_by_id[id] + end + + return nil +end + +---@param winid integer +---@return PeekstackPopupLookupEntry? +local function lookup_by_winid(winid) + local entry = popup_by_winid[winid] + if entry and entry.popup and entry.popup.winid == winid then + return entry + end + popup_by_winid[winid] = nil + + for root_winid, stack in pairs(stacks) do + for _, item in ipairs(stack.popups) do + if item.winid == winid then + index_popup(item, root_winid) + return popup_by_winid[winid] + end + end + end + + for _, item in pairs(ephemerals) do + if item.winid == winid then + index_popup(item, nil) + return popup_by_winid[winid] + end + end + + return nil +end ---@param winid integer function M._register_stack_view_win(winid) @@ -37,10 +159,15 @@ end ---@param model PeekstackPopupModel local function register_ephemeral(model) ephemerals[model.id] = model + index_popup(model, nil) end ---@param id integer local function unregister_ephemeral(id) + local model = ephemerals[id] + if model then + unindex_popup(model) + end ephemerals[id] = nil end @@ -50,10 +177,9 @@ local function find_ephemeral(id) if ephemerals[id] then return id, ephemerals[id] end - for eid, model in pairs(ephemerals) do - if model.winid == id then - return eid, model - end + local entry = lookup_by_winid(id) + if entry and entry.root_winid == nil then + return entry.popup.id, entry.popup end return nil end @@ -72,14 +198,10 @@ local function get_root_winid(winid) if ok_root and type(root_winid) == "number" and vim.api.nvim_win_is_valid(root_winid) then return root_winid end - -- Current window is floating – look for the origin window stored in the - -- popup model that owns this float. - for _, stack in pairs(stacks) do - for _, item in ipairs(stack.popups) do - if item.winid == wid then - return stack.root_winid - end - end + -- Current window is floating – resolve the owner stack from the popup index. + local owner = lookup_by_winid(wid) + if owner and owner.root_winid and vim.api.nvim_win_is_valid(owner.root_winid) then + return owner.root_winid end -- Fallback: pick the first non-floating window in the current tabpage. for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do @@ -158,6 +280,7 @@ function M.push(location, opts) return nil end table.insert(stack.popups, model) + index_popup(model, stack.root_winid) stack.focused_id = model.id layout.reflow(stack) @@ -189,6 +312,7 @@ local function close_stack_item(stack, idx, item) -- Remove from popups BEFORE closing the window to prevent -- WinClosed autocmd from re-entering and processing the same popup. table.remove(stack.popups, idx) + unindex_popup(item) feedback.highlight_origin(item.origin) popup.close(item) @@ -240,6 +364,19 @@ function M.close_by_id(id, winid) return true end + local indexed = lookup_by_id(id) + if indexed and indexed.root_winid then + local owner_stack = stacks[indexed.root_winid] + if owner_stack then + for idx, item in ipairs(owner_stack.popups) do + if item.id == id then + close_stack_item(owner_stack, idx, item) + return true + end + end + end + end + local stack = ensure_stack(winid) for idx, item in ipairs(stack.popups) do if item.id == id then @@ -259,6 +396,19 @@ function M.close(id, winid) return true end + local indexed = lookup_by_winid(id) + if indexed and indexed.root_winid then + local owner_stack = stacks[indexed.root_winid] + if owner_stack then + for idx, item in ipairs(owner_stack.popups) do + if item.winid == id then + close_stack_item(owner_stack, idx, item) + return true + end + end + end + end + local stack = ensure_stack(winid) for idx, item in ipairs(stack.popups) do if item.winid == id then @@ -320,33 +470,25 @@ end ---@param winid integer ---@return PeekstackStackModel?, PeekstackPopupModel? function M.find_by_winid(winid) - for _, stack in pairs(stacks) do - for _, item in ipairs(stack.popups) do - if item.winid == winid then - return stack, item - end - end + local entry = lookup_by_winid(winid) + if not entry then + return nil end - for _, item in pairs(ephemerals) do - if item.winid == winid then - return nil, item + if entry.root_winid then + local stack = stacks[entry.root_winid] + if stack then + return stack, entry.popup end end - return nil + return nil, entry.popup end ---@param id integer ---@return PeekstackPopupModel? function M.find_by_id(id) - for _, stack in pairs(stacks) do - for _, item in ipairs(stack.popups) do - if item.id == id then - return item - end - end - end - if ephemerals[id] then - return ephemerals[id] + local entry = lookup_by_id(id) + if entry then + return entry.popup end return nil end @@ -404,7 +546,9 @@ function M.reopen_by_id(id, winid) model.pinned = item.pinned or false vim.b[model.bufnr].peekstack_popup_id = model.id vim.w[model.winid].peekstack_popup_id = model.id + unindex_popup(item) stack.popups[idx] = model + index_popup(model, stack.root_winid) layout.reflow(stack) return model end @@ -464,6 +608,7 @@ function M.handle_win_closed(winid) emit_popup_event("PeekstackClose", item, root_winid) history.push_entry(stack, history.build_entry(item, idx)) table.remove(stack.popups, idx) + unindex_popup(item) popup.close(item) end stacks[root_winid] = nil @@ -479,6 +624,7 @@ function M.handle_win_closed(winid) emit_popup_event("PeekstackClose", item, root_winid) feedback.highlight_origin(item.origin) table.remove(stack.popups, idx) + unindex_popup(item) popup.close(item) removed = true end @@ -541,6 +687,7 @@ function M.handle_buf_wipeout(bufnr) for idx = #stack.popups, 1, -1 do local item = stack.popups[idx] if item.bufnr == bufnr then + unindex_popup(item) table.remove(stack.popups, idx) end end @@ -551,13 +698,9 @@ end ---Update last_active_at for a popup (when user interacts with it) ---@param winid integer function M.touch(winid) - for _, stack in pairs(stacks) do - for _, item in ipairs(stack.popups) do - if item.winid == winid then - item.last_active_at = vim.uv.now() - return - end - end + local owner_stack, popup_model = M.find_by_winid(winid) + if owner_stack and popup_model then + popup_model.last_active_at = vim.uv.now() end end @@ -621,6 +764,7 @@ function M.handle_origin_wipeout(bufnr) local item = stack.popups[idx] if should_close_for_origin(item) then popup.close(item) + unindex_popup(item) table.remove(stack.popups, idx) end end @@ -643,6 +787,7 @@ function M.close_ephemerals() local item = stack.popups[idx] if item.ephemeral then popup.close(item) + unindex_popup(item) table.remove(stack.popups, idx) removed = true end @@ -679,6 +824,7 @@ function M.close_all(winid) history.push_entry(stack, history.build_entry(item, idx)) + unindex_popup(item) table.remove(stack.popups, idx) end stack.focused_id = nil @@ -696,6 +842,9 @@ end function M._reset() stacks = {} ephemerals = {} + stack_view_wins = {} + popup_by_id = {} + popup_by_winid = {} end ---Get ephemeral popups (for testing). diff --git a/lua/peekstack/types.lua b/lua/peekstack/types.lua index 230e751..d178bde 100644 --- a/lua/peekstack/types.lua +++ b/lua/peekstack/types.lua @@ -151,6 +151,7 @@ ---@field winid integer? ---@field root_winid integer? ---@field line_to_id table +---@field render_keys string[] ---@field filter string? ---@field header_lines integer ---@field help_bufnr integer? diff --git a/lua/peekstack/ui/stack_view/init.lua b/lua/peekstack/ui/stack_view/init.lua index 87c7499..57a0437 100644 --- a/lua/peekstack/ui/stack_view/init.lua +++ b/lua/peekstack/ui/stack_view/init.lua @@ -61,6 +61,7 @@ local function get_state() winid = nil, root_winid = nil, line_to_id = {}, + render_keys = {}, filter = nil, header_lines = 0, help_bufnr = nil, @@ -150,6 +151,7 @@ local function reset_open_state(s) s.winid = nil s.bufnr = nil s.root_winid = nil + s.render_keys = {} s.autoclose_suspended = 0 s.help_augroup = nil end @@ -231,6 +233,7 @@ function M.open() s.root_winid = find_root_winid() s.bufnr = vim.api.nvim_create_buf(false, true) s.winid = vim.api.nvim_open_win(s.bufnr, true, stack_view_win_config()) + s.render_keys = {} vim.wo[s.winid].cursorline = true vim.wo[s.winid].winhighlight = "CursorLine:PeekstackStackViewCursorLine" diff --git a/lua/peekstack/ui/stack_view/render.lua b/lua/peekstack/ui/stack_view/render.lua index c173993..33553f7 100644 --- a/lua/peekstack/ui/stack_view/render.lua +++ b/lua/peekstack/ui/stack_view/render.lua @@ -315,6 +315,110 @@ local function get_preview_line(source_bufnr, line, max_width, preview_prefix) } end +---@param line string +---@param line_hls PeekstackStackViewHighlight[] +---@param preview PeekstackStackViewPreviewLine? +---@return string +local function line_render_key(line, line_hls, preview) + local parts = { line } + for _, hl in ipairs(line_hls) do + parts[#parts + 1] = string.format("%d:%d:%s", hl.col_start, hl.col_end, hl.hl_group) + end + if preview then + parts[#parts + 1] = string.format( + "preview:%d:%d:%d:%d:%d", + preview.source_bufnr, + preview.source_line, + preview.source_col_start, + preview.source_col_end, + preview.preview_col_start + ) + end + return table.concat(parts, "|") +end + +---@param items string[] +---@param start_idx integer +---@param end_idx integer +---@return string[] +local function slice_lines(items, start_idx, end_idx) + if end_idx < start_idx then + return {} + end + + ---@type string[] + local slice = {} + for idx = start_idx, end_idx do + slice[#slice + 1] = items[idx] + end + return slice +end + +---@param old_keys string[] +---@param new_keys string[] +---@return integer?, integer?, integer? +local function diff_range(old_keys, new_keys) + local old_count = #old_keys + local new_count = #new_keys + local start_idx = 1 + + while start_idx <= old_count and start_idx <= new_count and old_keys[start_idx] == new_keys[start_idx] do + start_idx = start_idx + 1 + end + + if start_idx > old_count and start_idx > new_count then + return nil, nil, nil + end + + local old_end = old_count + local new_end = new_count + while old_end >= start_idx and new_end >= start_idx and old_keys[old_end] == new_keys[new_end] do + old_end = old_end - 1 + new_end = new_end - 1 + end + + return start_idx, old_end, new_end +end + +---@param bufnr integer +---@param highlights PeekstackStackViewHighlight[][] +---@param preview_lines table +---@param start_idx integer +---@param end_idx integer +local function apply_highlights_in_range(bufnr, highlights, preview_lines, start_idx, end_idx) + if end_idx < start_idx then + return + end + + for line_idx = start_idx, end_idx do + for _, hl in ipairs(highlights[line_idx] or {}) do + local opts = { + end_col = hl.col_end, + hl_group = hl.hl_group, + } + if hl.hl_group == "PeekstackStackViewPreview" then + opts.priority = PREVIEW_BASE_HL_PRIORITY + end + vim.api.nvim_buf_set_extmark(bufnr, NS, line_idx - 1, hl.col_start, { + end_col = opts.end_col, + hl_group = opts.hl_group, + priority = opts.priority, + }) + end + end + + ---@type table + local changed_previews = {} + for line_idx = start_idx, end_idx do + if preview_lines[line_idx] then + changed_previews[line_idx] = preview_lines[line_idx] + end + end + if next(changed_previews) then + apply_preview_treesitter_highlights(bufnr, changed_previews) + end +end + ---@param s PeekstackStackViewState ---@param is_ready fun(s: PeekstackStackViewState): boolean function M.render(s, is_ready) @@ -478,29 +582,25 @@ function M.render(s, is_ready) end end - vim.bo[s.bufnr].modifiable = true - vim.api.nvim_buf_set_lines(s.bufnr, 0, -1, false, lines) - vim.bo[s.bufnr].modifiable = false - - vim.api.nvim_buf_clear_namespace(s.bufnr, NS, 0, -1) - for line_idx, line_hls in ipairs(highlights) do - for _, hl in ipairs(line_hls) do - local opts = { - end_col = hl.col_end, - hl_group = hl.hl_group, - } - if hl.hl_group == "PeekstackStackViewPreview" then - opts.priority = PREVIEW_BASE_HL_PRIORITY - end - vim.api.nvim_buf_set_extmark(s.bufnr, NS, line_idx - 1, hl.col_start, { - end_col = opts.end_col, - hl_group = opts.hl_group, - priority = opts.priority, - }) - end + ---@type string[] + local line_keys = {} + for line_idx, line in ipairs(lines) do + line_keys[line_idx] = line_render_key(line, highlights[line_idx] or {}, preview_lines[line_idx]) end - apply_preview_treesitter_highlights(s.bufnr, preview_lines) + local old_keys = s.render_keys or {} + local start_idx, old_end, new_end = diff_range(old_keys, line_keys) + if start_idx then + local replace = slice_lines(lines, start_idx, new_end) + + vim.bo[s.bufnr].modifiable = true + vim.api.nvim_buf_set_lines(s.bufnr, start_idx - 1, old_end, false, replace) + vim.bo[s.bufnr].modifiable = false + + vim.api.nvim_buf_clear_namespace(s.bufnr, NS, start_idx - 1, old_end) + apply_highlights_in_range(s.bufnr, highlights, preview_lines, start_idx, new_end) + end + s.render_keys = line_keys if s.winid and vim.api.nvim_win_is_valid(s.winid) and s.bufnr and vim.api.nvim_buf_is_valid(s.bufnr) then local line_count = vim.api.nvim_buf_line_count(s.bufnr) diff --git a/tests/config_spec.lua b/tests/config_spec.lua index 5278764..cd5aca6 100644 --- a/tests/config_spec.lua +++ b/tests/config_spec.lua @@ -232,6 +232,18 @@ describe("config", function() assert.equals(80, cfg.ui.path.max_width) end) + it("falls back when ui.path.max_width has invalid type", function() + local cfg = config.setup({ + ui = { + path = { + max_width = "wide", + }, + }, + }) + assert.is_true(has_message("ui.path.max_width must be a number")) + assert.equals(config.defaults.ui.path.max_width, cfg.ui.path.max_width) + end) + it("falls back on invalid stack view position", function() local cfg = config.setup({ ui = { diff --git a/tests/location_spec.lua b/tests/location_spec.lua index e68aa4d..67cd8b9 100644 --- a/tests/location_spec.lua +++ b/tests/location_spec.lua @@ -191,4 +191,80 @@ describe("location", function() assert(vim.uv.fs_rmdir(tmpdir)) end) end) + + describe("is_same_position cache", function() + ---@param path string + ---@return PeekstackLocation + local function location_for(path) + return { + uri = vim.uri_from_fname(path), + range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } }, + } + end + + after_each(function() + location._reset() + end) + + it("clears cached realpath entries via _reset", function() + local original_fs_realpath = vim.uv.fs_realpath + local calls = 0 + + local ok, err = pcall(function() + vim.uv.fs_realpath = function(path) + calls = calls + 1 + return path + end + + local path = "/tmp/peekstack-location-reset.lua" + local loc = location_for(path) + + assert.is_true(location.is_same_position(loc, loc.uri, 0, 0)) + assert.is_true(location.is_same_position(loc, loc.uri, 0, 0)) + assert.equals(1, calls) + + location._reset() + + assert.is_true(location.is_same_position(loc, loc.uri, 0, 0)) + assert.equals(2, calls) + end) + + vim.uv.fs_realpath = original_fs_realpath + if not ok then + error(err) + end + end) + + it("evicts least recently used realpath entries when cache exceeds the limit", function() + local original_fs_realpath = vim.uv.fs_realpath + local calls = 0 + + local ok, err = pcall(function() + vim.uv.fs_realpath = function(path) + calls = calls + 1 + return path + end + + local limit = location._realpath_cache_limit() + local oldest = location_for("/tmp/peekstack-location-lru-0.lua") + + assert.is_true(location.is_same_position(oldest, oldest.uri, 0, 0)) + assert.equals(1, calls) + + for idx = 1, limit do + local loc = location_for(string.format("/tmp/peekstack-location-lru-%d.lua", idx)) + assert.is_true(location.is_same_position(loc, loc.uri, 0, 0)) + end + assert.equals(limit + 1, calls) + + assert.is_true(location.is_same_position(oldest, oldest.uri, 0, 0)) + assert.equals(limit + 2, calls) + end) + + vim.uv.fs_realpath = original_fs_realpath + if not ok then + error(err) + end + end) + end) end) diff --git a/tests/stack_spec.lua b/tests/stack_spec.lua index d2b7b8f..f6af28d 100644 --- a/tests/stack_spec.lua +++ b/tests/stack_spec.lua @@ -326,6 +326,62 @@ describe("stack.close_by_id", function() end) end) +describe("stack lookup indexes", function() + before_each(function() + stack._reset() + config.setup({}) + end) + + after_each(function() + local s = stack.current_stack() + for i = #s.popups, 1, -1 do + stack.close(s.popups[i].id) + end + stack._reset() + end) + + it("finds stack popups by id and winid", function() + local loc = helpers.make_location() + local model = stack.push(loc) + assert.is_not_nil(model) + + local by_id = stack.find_by_id(model.id) + assert.is_not_nil(by_id) + assert.equals(model.id, by_id.id) + + local owner, by_winid = stack.find_by_winid(model.winid) + assert.is_not_nil(owner) + assert.is_not_nil(by_winid) + assert.equals(model.id, by_winid.id) + end) + + it("returns ephemeral popups for winid lookup", function() + local loc = helpers.make_location() + local ephemeral = stack.push(loc, { stack = false }) + assert.is_not_nil(ephemeral) + + local owner, by_winid = stack.find_by_winid(ephemeral.winid) + assert.is_nil(owner) + assert.is_not_nil(by_winid) + assert.equals(ephemeral.id, by_winid.id) + end) + + it("clears id and winid lookups when a popup is closed", function() + local loc = helpers.make_location() + local model = stack.push(loc) + assert.is_not_nil(model) + local winid = model.winid + local id = model.id + + assert.is_true(stack.close(id)) + assert.is_nil(stack.find_by_id(id)) + + local owner, by_winid = stack.find_by_winid(winid) + assert.is_nil(owner) + assert.is_nil(by_winid) + end) +end) + describe("stack focus reopen", function() before_each(function() stack._reset() diff --git a/tests/stack_view_spec.lua b/tests/stack_view_spec.lua index d7c0412..f02a531 100644 --- a/tests/stack_view_spec.lua +++ b/tests/stack_view_spec.lua @@ -84,6 +84,73 @@ describe("peekstack.ui.stack_view", function() assert.is_true(lines[1]:find("Stack: 2", 1, true) ~= nil) end) + it("skips line updates when rendered content is unchanged", function() + local root_winid = vim.api.nvim_get_current_win() + local s = stack.current_stack(root_winid) + s.popups = { + { id = 1, title = "Alpha", location = location_for("/tmp/alpha.lua"), pinned = false }, + { id = 2, title = "Beta", location = location_for("/tmp/beta.lua"), pinned = false }, + } + + stack_view.open() + local state = stack_view._get_state() + stack_view._render(state) + + local original_set_lines = vim.api.nvim_buf_set_lines + local calls = 0 + local ok, err = pcall(function() + vim.api.nvim_buf_set_lines = function(...) + calls = calls + 1 + return original_set_lines(...) + end + + stack_view._render(state) + assert.equals(0, calls) + end) + vim.api.nvim_buf_set_lines = original_set_lines + if not ok then + error(err) + end + end) + + it("updates only changed line range when one entry changes", function() + local root_winid = vim.api.nvim_get_current_win() + local s = stack.current_stack(root_winid) + s.popups = { + { id = 1, title = "Alpha", location = location_for("/tmp/alpha.lua"), pinned = false }, + { id = 2, title = "Beta", location = location_for("/tmp/beta.lua"), pinned = false }, + } + + stack_view.open() + local state = stack_view._get_state() + stack_view._render(state) + + local original_set_lines = vim.api.nvim_buf_set_lines + local calls = {} + local ok, err = pcall(function() + vim.api.nvim_buf_set_lines = function(bufnr, start, finish, strict_indexing, replacement) + table.insert(calls, { + start = start, + finish = finish, + count = #replacement, + }) + return original_set_lines(bufnr, start, finish, strict_indexing, replacement) + end + + s.popups[2].title = "Beta updated" + stack_view._render(state) + + assert.equals(1, #calls) + assert.equals(2, calls[1].start) + assert.equals(3, calls[1].finish) + assert.equals(1, calls[1].count) + end) + vim.api.nvim_buf_set_lines = original_set_lines + if not ok then + error(err) + end + end) + it("renders empty state with header", function() stack_view.open() local state = stack_view._get_state()