diff --git a/README.md b/README.md index a3c424c..a1b6ef7 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,8 @@ default backend is `builtin`. To use an external picker, install the plugin and `picker.backend` to one of: `telescope`, `fzf-lua`, `snacks`. When using these external backends, the picker preview window shows the selected file content around the target location. +Candidate labels are shown in a readable unified format: +` - ::` (or `::` when text is empty). ```lua { diff --git a/doc/peekstack.txt b/doc/peekstack.txt index dce77a1..1cdeffb 100644 --- a/doc/peekstack.txt +++ b/doc/peekstack.txt @@ -155,6 +155,10 @@ Configure the backend with `picker.backend`: For external backends (`telescope`, `fzf-lua`, `snacks`), picker preview windows show file content around the selected location. +Picker labels use a unified readable format: + - :: +or, when text is empty: + :: If the chosen plugin is not installed, a warning is shown and the picker will not open. diff --git a/lua/peekstack/picker/fzf_lua.lua b/lua/peekstack/picker/fzf_lua.lua index 80e5f66..9f2f562 100644 --- a/lua/peekstack/picker/fzf_lua.lua +++ b/lua/peekstack/picker/fzf_lua.lua @@ -18,10 +18,18 @@ function M.pick(locations, opts, cb) item.index = idx end + local user_opts = opts or {} + local fzf_opts = vim.tbl_extend("force", user_opts.fzf_opts or {}, { + ["--delimiter"] = "\t", + ["--with-nth"] = "2", + ["--nth"] = "2", + }) + ---@type table - local exec_opts = vim.tbl_extend("force", opts or {}, { + local exec_opts = vim.tbl_deep_extend("force", user_opts, { prompt = "Peekstack> ", previewer = "builtin", + fzf_opts = fzf_opts, actions = { ["default"] = function(selected) if not selected or not selected[1] then @@ -41,7 +49,8 @@ function M.pick(locations, opts, cb) for _, item in ipairs(items) do local file = item.file or "" local label = item.label:gsub("[%r\n\t]", " ") - table.insert(lines, string.format("%s:%d:%d:%s\t%d", file, item.lnum, item.col, label, item.index)) + local loc = string.format("%s:%d:%d", file, item.lnum, item.col) + table.insert(lines, string.format("%s\t%s\t%d", loc, label, item.index)) end return lines end, exec_opts) diff --git a/lua/peekstack/picker/snacks.lua b/lua/peekstack/picker/snacks.lua index 61b140d..3421629 100644 --- a/lua/peekstack/picker/snacks.lua +++ b/lua/peekstack/picker/snacks.lua @@ -2,6 +2,35 @@ local picker_util = require("peekstack.util.picker") local M = {} +---@param item table +---@return table +local function format_item(item) + local chunks = {} + local symbol = item.symbol + if type(symbol) == "string" and symbol ~= "" then + chunks[#chunks + 1] = { symbol, "SnacksPickerLabel" } + chunks[#chunks + 1] = { " - ", "SnacksPickerDelim" } + end + + local path = item.path + if type(path) ~= "string" or path == "" then + path = item.text or "" + end + picker_util.append_path_chunks(chunks, path, "SnacksPickerDir", "SnacksPickerFile") + + if type(item.display_lnum) == "number" and item.display_lnum > 0 then + chunks[#chunks + 1] = { ":", "SnacksPickerDelim" } + chunks[#chunks + 1] = { tostring(item.display_lnum), "SnacksPickerRow" } + end + + if type(item.display_col) == "number" and item.display_col > 0 then + chunks[#chunks + 1] = { ":", "SnacksPickerDelim" } + chunks[#chunks + 1] = { tostring(item.display_col), "SnacksPickerCol" } + end + + return chunks +end + ---Pick a location using snacks.nvim picker ---@param locations PeekstackLocation[] ---@param opts? table @@ -20,6 +49,10 @@ function M.pick(locations, opts, cb) local start = loc.range and loc.range.start or {} table.insert(items, { text = item.label, + symbol = item.symbol, + path = item.path, + display_lnum = item.display_lnum, + display_col = item.display_col, file = item.file or loc.uri, pos = { item.lnum, start.character or 0 }, peekstack_loc = loc, @@ -29,7 +62,7 @@ function M.pick(locations, opts, cb) local picker_opts = vim.tbl_extend("force", opts or {}, { title = "Peekstack", items = items, - format = "file", + format = format_item, confirm = function(picker, item) if item and item.peekstack_loc then picker:close() diff --git a/lua/peekstack/picker/telescope.lua b/lua/peekstack/picker/telescope.lua index f5bdedc..03fcd0e 100644 --- a/lua/peekstack/picker/telescope.lua +++ b/lua/peekstack/picker/telescope.lua @@ -2,6 +2,47 @@ local picker_util = require("peekstack.util.picker") local M = {} +---@param primary string +---@param fallback string +---@return string +local function hl(primary, fallback) + if vim.fn.hlexists(primary) == 1 then + return primary + end + return fallback +end + +---@param displayer fun(chunks: table): string +---@param item PeekstackPickerExternalItem +---@return string +local function display_entry(displayer, item) + local chunks = {} + if type(item.symbol) == "string" and item.symbol ~= "" then + chunks[#chunks + 1] = { item.symbol, hl("TelescopeResultsIdentifier", "Function") } + chunks[#chunks + 1] = { " - ", hl("TelescopeResultsComment", "Comment") } + end + + local path = item.path or item.label + picker_util.append_path_chunks( + chunks, + path, + hl("TelescopeResultsComment", "Comment"), + hl("TelescopeResultsIdentifier", "Directory") + ) + + if type(item.display_lnum) == "number" and item.display_lnum > 0 then + chunks[#chunks + 1] = { ":", hl("TelescopeResultsComment", "Comment") } + chunks[#chunks + 1] = { tostring(item.display_lnum), hl("TelescopeResultsNumber", "Number") } + end + + if type(item.display_col) == "number" and item.display_col > 0 then + chunks[#chunks + 1] = { ":", hl("TelescopeResultsComment", "Comment") } + chunks[#chunks + 1] = { tostring(item.display_col), hl("TelescopeResultsNumber", "Number") } + end + + return displayer(chunks) +end + ---Pick a location using Telescope ---@param locations PeekstackLocation[] ---@param opts? table @@ -13,16 +54,25 @@ function M.pick(locations, opts, cb) return end local finders = require("telescope.finders") + local entry_display = require("telescope.pickers.entry_display") local conf = require("telescope.config").values local telescope_opts = opts or {} + local displayer = entry_display.create({ + separator = "", + items = { + { remaining = true }, + }, + }) local items = picker_util.build_external_items(locations, 1) local entries = {} for _, item in ipairs(items) do table.insert(entries, { value = item.value, - display = item.label, - ordinal = item.label, + display = function() + return display_entry(displayer, item) + end, + ordinal = string.format("%s %s", item.label, item.file or ""), filename = item.file, lnum = item.lnum, col = item.col, diff --git a/lua/peekstack/providers/lsp.lua b/lua/peekstack/providers/lsp.lua index 5c99512..8e22678 100644 --- a/lua/peekstack/providers/lsp.lua +++ b/lua/peekstack/providers/lsp.lua @@ -17,16 +17,11 @@ local function append_document_symbol(symbol, uri, provider, out) local start_pos = range and range.start local end_pos = range and range["end"] if start_pos and end_pos then - local text = symbol.name - if type(text) ~= "string" then - text = nil - end - if type(symbol.detail) == "string" and symbol.detail ~= "" then - if text and text ~= "" then - text = string.format("%s - %s", text, symbol.detail) - else - text = symbol.detail - end + local text + if type(symbol.name) == "string" and symbol.name ~= "" then + text = symbol.name + elseif type(symbol.detail) == "string" and symbol.detail ~= "" then + text = symbol.detail end table.insert(out, { diff --git a/lua/peekstack/types.lua b/lua/peekstack/types.lua index c8b5a7c..230e751 100644 --- a/lua/peekstack/types.lua +++ b/lua/peekstack/types.lua @@ -118,6 +118,10 @@ ---@field file? string ---@field lnum integer ---@field col integer +---@field symbol? string +---@field path? string +---@field display_lnum? integer +---@field display_col? integer ---@class PeekstackHistoryEntry ---@field location PeekstackLocation diff --git a/lua/peekstack/util/picker.lua b/lua/peekstack/util/picker.lua index 020d28a..c92d2a9 100644 --- a/lua/peekstack/util/picker.lua +++ b/lua/peekstack/util/picker.lua @@ -4,6 +4,71 @@ local fs = require("peekstack.util.fs") local M = {} +---@param chunks table +---@param path string +---@param dir_hl? string +---@param file_hl? string +function M.append_path_chunks(chunks, path, dir_hl, file_hl) + local file_group = file_hl or "Directory" + local dir, base = path:match("^(.*[/\\])(.+)$") + if dir and base then + if type(dir_hl) == "string" and dir_hl ~= "" then + chunks[#chunks + 1] = { dir, dir_hl } + else + chunks[#chunks + 1] = { dir, file_group } + end + chunks[#chunks + 1] = { base, file_group } + return + end + chunks[#chunks + 1] = { path, file_group } +end + +---@param text? string +---@return string +local function normalize_label_text(text) + if type(text) ~= "string" then + return "" + end + local normalized = text:gsub("[\r\n\t]+", " "):gsub("%s+", " ") + return vim.trim(normalized) +end + +---@param suffix string +---@return string, integer, integer +local function parse_suffix_location(suffix) + local path, line, col = suffix:match("^(.*):(%d+):(%d+)$") + if not path then + return suffix, 0, 0 + end + return path, tonumber(line) or 0, tonumber(col) or 0 +end + +---@param loc PeekstackLocation +---@param preview_lines integer +---@param opts PeekstackDisplayTextOpts +---@return { label: string, symbol: string, path: string, display_lnum: integer, display_col: integer } +local function build_location_label_payload(loc, preview_lines, opts) + local suffix = location.display_text(loc, 0, opts) + local path, display_lnum, display_col = parse_suffix_location(suffix) + local symbol = preview_lines > 0 and normalize_label_text(loc.text) or "" + if symbol == "" then + return { + label = suffix, + symbol = "", + path = path, + display_lnum = display_lnum, + display_col = display_col, + } + end + return { + label = string.format("%s - %s", symbol, suffix), + symbol = symbol, + path = path, + display_lnum = display_lnum, + display_col = display_col, + } +end + ---@return PeekstackDisplayTextOpts local function display_text_opts() local ui_path = config.get().ui.path or {} @@ -24,8 +89,9 @@ function M.build_items(locations, preview_lines) local opts = display_text_opts() local items = {} for _, loc in ipairs(locations) do + local payload = build_location_label_payload(loc, preview_lines, opts) table.insert(items, { - label = location.display_text(loc, preview_lines, opts), + label = payload.label, value = loc, }) end @@ -40,8 +106,13 @@ function M.build_external_items(locations, preview_lines) local items = {} for _, loc in ipairs(locations) do local start = loc.range and loc.range.start or {} + local payload = build_location_label_payload(loc, preview_lines, opts) table.insert(items, { - label = location.display_text(loc, preview_lines, opts), + label = payload.label, + symbol = payload.symbol, + path = payload.path, + display_lnum = payload.display_lnum, + display_col = payload.display_col, value = loc, file = fs.uri_to_fname(loc.uri), lnum = (start.line or 0) + 1, diff --git a/tests/lsp_provider_spec.lua b/tests/lsp_provider_spec.lua index a27553f..ac7c89f 100644 --- a/tests/lsp_provider_spec.lua +++ b/tests/lsp_provider_spec.lua @@ -87,7 +87,7 @@ describe("peekstack.providers.lsp", function() assert.equals(vim.uri_from_bufnr(ctx.bufnr), received[1].uri) assert.equals(1, received[1].range.start.line) assert.equals(2, received[1].range.start.character) - assert.equals("Parent - class", received[1].text) + assert.equals("Parent", received[1].text) assert.equals(5, received[1].kind) assert.equals("lsp.symbols_document", received[1].provider) diff --git a/tests/picker_fzf_lua_spec.lua b/tests/picker_fzf_lua_spec.lua index 7dbfbef..ea74179 100644 --- a/tests/picker_fzf_lua_spec.lua +++ b/tests/picker_fzf_lua_spec.lua @@ -10,8 +10,8 @@ describe("peekstack.picker.fzf_lua", function() fzf_exec = function(source, opts) captured_opts = opts local lines = source() - assert.is_true(lines[1]:match("^/tmp/same.lua:1:1:") ~= nil) - assert.is_true(lines[2]:match("^/tmp/same.lua:1:1:") ~= nil) + assert.is_true(lines[1]:match("^/tmp/same.lua:1:1\t") ~= nil) + assert.is_true(lines[2]:match("^/tmp/same.lua:1:1\t") ~= nil) assert.is_true(lines[1]:match("\t1$") ~= nil) assert.is_true(lines[2]:match("\t2$") ~= nil) opts.actions["default"]({ lines[2] }) @@ -36,6 +36,9 @@ describe("peekstack.picker.fzf_lua", function() assert.are.same(loc2, picked) assert.equals("builtin", captured_opts.previewer) assert.equals("Peekstack> ", captured_opts.prompt) + assert.equals("\t", captured_opts.fzf_opts["--delimiter"]) + assert.equals("2", captured_opts.fzf_opts["--with-nth"]) + assert.equals("2", captured_opts.fzf_opts["--nth"]) package.loaded["fzf-lua"] = original end) diff --git a/tests/picker_snacks_spec.lua b/tests/picker_snacks_spec.lua index cb0adc6..e032aab 100644 --- a/tests/picker_snacks_spec.lua +++ b/tests/picker_snacks_spec.lua @@ -27,7 +27,7 @@ describe("peekstack.picker.snacks", function() assert.is_true(warned) end) - it("passes file format items and confirms selected location", function() + it("passes text format items and confirms selected location", function() local picked = nil local closed = false local captured = nil @@ -45,6 +45,7 @@ describe("peekstack.picker.snacks", function() local loc1 = { uri = "file:///tmp/a.lua", range = { start = { line = 3, character = 4 }, ["end"] = { line = 3, character = 4 } }, + text = "Alpha", provider = "test", } local loc2 = { @@ -58,9 +59,24 @@ describe("peekstack.picker.snacks", function() end) assert.equals("Peekstack", captured.title) - assert.equals("file", captured.format) + assert.equals("function", type(captured.format)) assert.equals("/tmp/a.lua", captured.items[1].file) assert.same({ 4, 4 }, captured.items[1].pos) + assert.equals("Alpha - /tmp/a.lua:4:5", captured.items[1].text) + assert.equals("Alpha", captured.items[1].symbol) + assert.equals("/tmp/a.lua", captured.items[1].path) + assert.equals(4, captured.items[1].display_lnum) + assert.equals(5, captured.items[1].display_col) + assert.same({ + { "Alpha", "SnacksPickerLabel" }, + { " - ", "SnacksPickerDelim" }, + { "/tmp/", "SnacksPickerDir" }, + { "a.lua", "SnacksPickerFile" }, + { ":", "SnacksPickerDelim" }, + { "4", "SnacksPickerRow" }, + { ":", "SnacksPickerDelim" }, + { "5", "SnacksPickerCol" }, + }, captured.format(captured.items[1])) assert.is_nil(captured.items[1].loc) assert.are.same(loc1, captured.items[1].peekstack_loc) assert.are.same(loc2, picked) diff --git a/tests/picker_telescope_spec.lua b/tests/picker_telescope_spec.lua index 59e931e..7351e8d 100644 --- a/tests/picker_telescope_spec.lua +++ b/tests/picker_telescope_spec.lua @@ -16,6 +16,7 @@ describe("peekstack.picker.telescope", function() before_each(function() save_module("telescope.pickers") save_module("telescope.finders") + save_module("telescope.pickers.entry_display") save_module("telescope.config") save_module("telescope.actions") save_module("telescope.actions.state") @@ -46,6 +47,18 @@ describe("peekstack.picker.telescope", function() return opts end, } + package.loaded["telescope.pickers.entry_display"] = { + create = function(_opts) + return function(chunks) + captured.display_chunks = chunks + local texts = {} + for _, chunk in ipairs(chunks) do + texts[#texts + 1] = chunk[1] + end + return table.concat(texts) + end + end, + } package.loaded["telescope.actions"] = { close = function(bufnr) captured.closed_bufnr = bufnr @@ -76,6 +89,7 @@ describe("peekstack.picker.telescope", function() local loc1 = { uri = "file:///tmp/a.lua", range = { start = { line = 1, character = 2 }, ["end"] = { line = 1, character = 2 } }, + text = "Alpha", provider = "test", } local loc2 = { @@ -90,6 +104,18 @@ describe("peekstack.picker.telescope", function() assert.equals("sorter", captured.spec.sorter) assert.equals("previewer", captured.spec.previewer) + assert.equals("Alpha - /tmp/a.lua:2:3", captured.finder.results[1].display()) + assert.same({ + { "Alpha", "Function" }, + { " - ", "Comment" }, + { "/tmp/", "Comment" }, + { "a.lua", "Directory" }, + { ":", "Comment" }, + { "2", "Number" }, + { ":", "Comment" }, + { "3", "Number" }, + }, captured.display_chunks) + assert.equals("Alpha - /tmp/a.lua:2:3 /tmp/a.lua", captured.finder.results[1].ordinal) assert.equals("/tmp/a.lua", captured.finder.results[1].filename) assert.equals(2, captured.finder.results[1].lnum) assert.equals(3, captured.finder.results[1].col) diff --git a/tests/picker_util_spec.lua b/tests/picker_util_spec.lua new file mode 100644 index 0000000..56fa343 --- /dev/null +++ b/tests/picker_util_spec.lua @@ -0,0 +1,93 @@ +local config = require("peekstack.config") +local picker_util = require("peekstack.util.picker") + +describe("peekstack.util.picker", function() + before_each(function() + config.setup({ + ui = { + path = { + base = "absolute", + max_width = 200, + }, + }, + }) + end) + + it("builds symbol-first label when preview lines are enabled", function() + local location = { + uri = "file:///tmp/sample.lua", + range = { + start = { line = 2, character = 4 }, + ["end"] = { line = 2, character = 4 }, + }, + text = "MyFunc\nDetail\tInfo", + provider = "test", + } + + local items = picker_util.build_external_items({ location }, 1) + assert.equals("MyFunc Detail Info - /tmp/sample.lua:3:5", items[1].label) + assert.equals("MyFunc Detail Info", items[1].symbol) + assert.equals("/tmp/sample.lua", items[1].path) + assert.equals(3, items[1].display_lnum) + assert.equals(5, items[1].display_col) + end) + + it("falls back to path label when preview lines are disabled", function() + local location = { + uri = "file:///tmp/sample.lua", + range = { + start = { line = 4, character = 1 }, + ["end"] = { line = 4, character = 1 }, + }, + text = "Hidden", + provider = "test", + } + + local items = picker_util.build_items({ location }, 0) + assert.equals("/tmp/sample.lua:5:2", items[1].label) + end) + + it("falls back to path label when text is blank", function() + local location = { + uri = "file:///tmp/sample.lua", + range = { + start = { line = 7, character = 0 }, + ["end"] = { line = 7, character = 0 }, + }, + text = " \n\t ", + provider = "test", + } + + local items = picker_util.build_external_items({ location }, 1) + assert.equals("/tmp/sample.lua:8:1", items[1].label) + assert.equals("", items[1].symbol) + assert.equals("/tmp/sample.lua", items[1].path) + assert.equals(8, items[1].display_lnum) + assert.equals(1, items[1].display_col) + end) + + it("keeps r characters when normalizing text", function() + local location = { + uri = "file:///tmp/sample.lua", + range = { + start = { line = 9, character = 2 }, + ["end"] = { line = 9, character = 2 }, + }, + text = "errMsgFailedToStartServer", + provider = "test", + } + + local items = picker_util.build_external_items({ location }, 1) + assert.equals("errMsgFailedToStartServer - /tmp/sample.lua:10:3", items[1].label) + end) + + it("appends split path chunks with given highlight groups", function() + local chunks = {} + picker_util.append_path_chunks(chunks, "/tmp/sample.lua", "DirHl", "FileHl") + + assert.same({ + { "/tmp/", "DirHl" }, + { "sample.lua", "FileHl" }, + }, chunks) + end) +end)