diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 3868b4d..6a2fc58 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -13,7 +13,11 @@ jobs: fail-fast: false matrix: neovim: [stable, nightly] - picker: [snacks, fzf-lua] + picker: [snacks, fzf-lua, telescope] + include: + - neovim: v0.10.4 + picker: telescope + telescope_ref: 0.1.8 steps: - name: Checkout code @@ -30,17 +34,26 @@ jobs: attempt_delay: 15000 - name: Install picker dependency + env: + TELESCOPE_REF: ${{ matrix.telescope_ref || 'master' }} run: | mkdir -p .tests/deps if [ "${{ matrix.picker }}" = "snacks" ]; then git clone --depth=1 https://github.com/folke/snacks.nvim .tests/deps/snacks.nvim - else + elif [ "${{ matrix.picker }}" = "fzf-lua" ]; then sudo apt-get update sudo apt-get install -y fzf git clone --depth=1 https://github.com/ibhagwan/fzf-lua .tests/deps/fzf-lua + else + git clone --depth=1 https://github.com/nvim-lua/plenary.nvim .tests/deps/plenary.nvim + git clone --depth=1 --branch "$TELESCOPE_REF" https://github.com/nvim-telescope/telescope.nvim .tests/deps/telescope.nvim fi - name: Run smoke test run: nvim --headless -u NONE -l scripts/smoke_test.lua env: CPPMAN_PICKER_PROVIDER: ${{ matrix.picker }} + + - name: Test Telescope picker + if: matrix.picker == 'telescope' + run: nvim --headless -u NONE -l scripts/telescope_test.lua diff --git a/README.md b/README.md index 819e1de..9a657bb 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ not need to call the `cppman` command for every query. * [ibhagwan/fzf-lua](https://github.com/ibhagwan/fzf-lua) and the `fzf` binary * [folke/snacks.nvim](https://github.com/folke/snacks.nvim) + * [nvim-telescope/telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) + with [nvim-lua/plenary.nvim](https://github.com/nvim-lua/plenary.nvim) Install the external tools however you normally do: @@ -88,8 +90,32 @@ return { } ``` +Or, with `telescope.nvim`: + +Current Telescope requires Neovim 0.11.7 or newer. + +```lua +return { + "simonwinther/cppman.nvim", + version = "*", + cmd = "CPPMan", + dependencies = { + "nvim-lua/plenary.nvim", + "nvim-telescope/telescope.nvim", + }, + opts = { + picker = { + provider = "telescope", + }, + }, +} +``` + +For Neovim versions below 0.11.7, replace the Telescope dependency with +`{ "nvim-telescope/telescope.nvim", tag = "0.1.8" }`. + You can also leave the provider as `"auto"`. It tries `snacks.nvim` first, then -`fzf-lua`. +`fzf-lua`, then `telescope.nvim`. ## Usage @@ -146,7 +172,7 @@ require("cppman").setup({ }, picker = { - -- "auto", "fzf-lua", or "snacks" + -- "auto", "fzf-lua", "snacks", or "telescope" provider = "auto", width = 0.4, @@ -155,6 +181,7 @@ require("cppman").setup({ -- Passed through to the picker backend. snacks = {}, fzf_lua = {}, + telescope = {}, }, viewer = { diff --git a/doc/cppman.txt b/doc/cppman.txt index 10af698..1b272cf 100644 --- a/doc/cppman.txt +++ b/doc/cppman.txt @@ -39,6 +39,8 @@ REQUIREMENTS *cppman-cppman.nvim-requirements* - One picker backend: - ibhagwan/fzf-lua and the `fzf` binary - folke/snacks.nvim + - nvim-telescope/telescope.nvim + with nvim-lua/plenary.nvim Install the external tools however you normally do: @@ -103,8 +105,32 @@ If you already use `snacks.nvim`, use that instead: } < +Or, with `telescope.nvim`: + +Current Telescope requires Neovim 0.11.7 or newer. + +>lua + return { + "simonwinther/cppman.nvim", + version = "*", + cmd = "CPPMan", + dependencies = { + "nvim-lua/plenary.nvim", + "nvim-telescope/telescope.nvim", + }, + opts = { + picker = { + provider = "telescope", + }, + }, + } +< + +For Neovim versions below 0.11.7, replace the Telescope dependency with +`{ "nvim-telescope/telescope.nvim", tag = "0.1.8" }`. + You can also leave the provider as `"auto"`. It tries `snacks.nvim` first, then -`fzf-lua`. +`fzf-lua`, then `telescope.nvim`. USAGE *cppman-cppman.nvim-usage* @@ -164,7 +190,7 @@ Defaults: }, picker = { - -- "auto", "fzf-lua", or "snacks" + -- "auto", "fzf-lua", "snacks", or "telescope" provider = "auto", width = 0.4, @@ -173,6 +199,7 @@ Defaults: -- Passed through to the picker backend. snacks = {}, fzf_lua = {}, + telescope = {}, }, viewer = { diff --git a/lua/cppman/config.lua b/lua/cppman/config.lua index 70cb9ef..24c1d71 100644 --- a/lua/cppman/config.lua +++ b/lua/cppman/config.lua @@ -11,6 +11,7 @@ M.defaults = { height = 0.4, snacks = {}, fzf_lua = {}, + telescope = {}, }, viewer = { width = 0.8, diff --git a/lua/cppman/health.lua b/lua/cppman/health.lua index e1ecff0..ef9d028 100644 --- a/lua/cppman/health.lua +++ b/lua/cppman/health.lua @@ -34,7 +34,10 @@ function M.check() if active then h.ok("picker provider resolved to " .. statuses[active].label) else - h.error("no picker backend found", { "Install folke/snacks.nvim or ibhagwan/fzf-lua" }) + h.error( + "no picker backend found", + { "Install folke/snacks.nvim, ibhagwan/fzf-lua, or nvim-telescope/telescope.nvim" } + ) end for _, status in pairs(statuses) do if status.available then diff --git a/lua/cppman/picker.lua b/lua/cppman/picker.lua index 493c9f1..f507dd4 100644 --- a/lua/cppman/picker.lua +++ b/lua/cppman/picker.lua @@ -2,7 +2,7 @@ local M = {} local util = require("cppman.util") -local AUTO_ORDER = { "snacks", "fzf-lua" } +local AUTO_ORDER = { "snacks", "fzf-lua", "telescope" } local PROVIDERS = { snacks = { label = "snacks.nvim", @@ -12,6 +12,10 @@ local PROVIDERS = { label = "fzf-lua", module = "cppman.pickers.fzf_lua", }, + telescope = { + label = "telescope.nvim", + module = "cppman.pickers.telescope", + }, } local function picker_options() @@ -40,6 +44,9 @@ function M.normalize_provider(provider) if provider == "snacks.nvim" then return "snacks" end + if provider == "telescope.nvim" then + return "telescope" + end return provider end @@ -51,7 +58,7 @@ function M.provider_status(provider) name = provider, label = tostring(provider), available = false, - error = "expected one of: auto, snacks, fzf-lua", + error = "expected one of: auto, snacks, fzf-lua, telescope", } end @@ -101,12 +108,15 @@ function M.resolve_provider(provider) return candidate end end - return nil, "[cppman] no picker backend found (install folke/snacks.nvim or ibhagwan/fzf-lua)" + return nil, + "[cppman] no picker backend found (install folke/snacks.nvim, ibhagwan/fzf-lua, or nvim-telescope/telescope.nvim)" end if not PROVIDERS[provider] then return nil, - "[cppman] invalid picker provider: " .. tostring(provider) .. " (expected one of: auto, snacks, fzf-lua)" + "[cppman] invalid picker provider: " + .. tostring(provider) + .. " (expected one of: auto, snacks, fzf-lua, telescope)" end local status = M.provider_status(provider) diff --git a/lua/cppman/pickers/telescope.lua b/lua/cppman/pickers/telescope.lua new file mode 100644 index 0000000..0a63545 --- /dev/null +++ b/lua/cppman/pickers/telescope.lua @@ -0,0 +1,134 @@ +local M = {} + +local common = require("cppman.pickers.common") +local util = require("cppman.util") + +function M.is_available() + local ok = pcall(require, "telescope") + if not ok then + return false, "telescope.nvim not found" + end + local ok_plenary = pcall(require, "plenary") + if not ok_plenary then + return false, "plenary.nvim not found (required by telescope.nvim)" + end + local ok_pickers = pcall(require, "telescope.pickers") + if not ok_pickers then + return false, "telescope.nvim found but pickers module unavailable" + end + return true +end + +-- Builds an entry_maker for telescope's finder. When the search spans both +-- sources we append a dimmed source badge for parity with the other backends; +-- the badge is display-only and is not part of the ordinal used for matching. +local function make_entry_maker(source) + if source ~= "both" then + return function(item) + return { + value = item, + display = item.text, + ordinal = item.text, + } + end + end + + local entry_display = require("telescope.pickers.entry_display") + local displayer = entry_display.create({ + separator = "", + items = { + { remaining = true }, + { remaining = true }, + }, + }) + + return function(item) + return { + value = item, + ordinal = item.text, + display = function() + return displayer({ + { item.text, "Normal" }, + { common.source_badge(item.source), "Comment" }, + }) + end, + } + end +end + +function M.open(opts) + opts = opts or {} + local on_select = opts.on_select + local on_back = opts.on_back + local pattern = opts.search or "" + + local available, err = M.is_available() + if not available then + vim.notify("[cppman] picker provider telescope.nvim unavailable: " .. err, vim.log.levels.ERROR) + return + end + + local pickers = require("telescope.pickers") + local finders = require("telescope.finders") + local conf = require("telescope.config").values + local actions = require("telescope.actions") + local action_set = require("telescope.actions.set") + local action_state = require("telescope.actions.state") + + local config = require("cppman.config") + local picker_opts = config.options.picker or {} + local source = opts.source or config.options.source or "both" + + local prompt_title = "keyword search • " .. util.format_ms(opts.load_ms or 0) + + -- Caller-supplied telescope overrides win over our defaults (layout, theme, + -- sorter, etc.), mirroring how the snacks/fzf-lua backends pass through + -- picker_opts.snacks / picker_opts.fzf_lua. + local base_opts = { + previewer = false, + results_title = on_back and " back" or nil, + layout_config = { + width = picker_opts.width or 0.4, + height = picker_opts.height or 0.4, + }, + } + local telescope_opts = vim.tbl_deep_extend("force", base_opts, vim.deepcopy(picker_opts.telescope or {})) + + pickers + .new(telescope_opts, { + prompt_title = prompt_title, + default_text = pattern, + finder = finders.new_table({ + results = opts.items or {}, + entry_maker = make_entry_maker(source), + }), + sorter = conf.generic_sorter(telescope_opts), + attach_mappings = function(prompt_bufnr, map) + action_set.select:replace(function() + local entry = action_state.get_current_picker(prompt_bufnr):get_selection() + -- Capture the live prompt input before closing so the viewer + -- can record it in history, matching snacks/fzf-lua behavior. + local used_pattern = action_state.get_current_line() + actions.close(prompt_bufnr) + if entry and entry.value and on_select then + on_select(entry.value, used_pattern) + end + end) + + if on_back then + local function go_back() + actions.close(prompt_bufnr) + vim.schedule(on_back) + end + -- Overrides telescope's default (select_tab) for this picker. + map("i", "", go_back) + map("n", "", go_back) + end + + return true + end, + }) + :find() +end + +return M diff --git a/scripts/smoke_test.lua b/scripts/smoke_test.lua index a32dc91..1fde5e2 100644 --- a/scripts/smoke_test.lua +++ b/scripts/smoke_test.lua @@ -1,6 +1,8 @@ vim.opt.runtimepath:append(vim.fn.getcwd()) vim.opt.runtimepath:append(vim.fn.getcwd() .. "/.tests/deps/snacks.nvim") vim.opt.runtimepath:append(vim.fn.getcwd() .. "/.tests/deps/fzf-lua") +vim.opt.runtimepath:append(vim.fn.getcwd() .. "/.tests/deps/plenary.nvim") +vim.opt.runtimepath:append(vim.fn.getcwd() .. "/.tests/deps/telescope.nvim") local ok, cppman = pcall(require, "cppman") assert(ok, "failed to require cppman: " .. tostring(cppman)) @@ -35,6 +37,7 @@ for _, mod in ipairs({ "cppman.pickers.common", "cppman.pickers.snacks", "cppman.pickers.fzf_lua", + "cppman.pickers.telescope", "cppman.health", }) do local rok, rerr = pcall(require, mod) @@ -44,6 +47,7 @@ end local picker = require("cppman.picker") assert(picker.normalize_provider("fzf_lua") == "fzf-lua", "fzf_lua provider alias should normalize") assert(picker.normalize_provider("snacks.nvim") == "snacks", "snacks.nvim provider alias should normalize") +assert(picker.normalize_provider("telescope.nvim") == "telescope", "telescope.nvim provider alias should normalize") local bad_provider = picker.provider_status("unknown") assert(not bad_provider.available, "unknown picker provider should not be available") diff --git a/scripts/telescope_test.lua b/scripts/telescope_test.lua new file mode 100644 index 0000000..4d354f2 --- /dev/null +++ b/scripts/telescope_test.lua @@ -0,0 +1,191 @@ +vim.opt.runtimepath:append(vim.fn.getcwd()) +vim.opt.runtimepath:append(vim.fn.getcwd() .. "/.tests/deps/plenary.nvim") +vim.opt.runtimepath:append(vim.fn.getcwd() .. "/.tests/deps/telescope.nvim") +vim.o.columns = 120 +vim.o.lines = 40 +vim.cmd("runtime plugin/telescope.lua") + +require("telescope").setup({ defaults = { cache_picker = false } }) + +local cppman = require("cppman") +local backend = require("cppman.pickers.telescope") +local picker = require("cppman.picker") +local actions = require("telescope.actions") +local action_state = require("telescope.actions.state") +local items = { + { text = "std::vector", page = "std::vector", query = "vector", source = "cppreference.com" }, + { text = "std::vector", page = "std::vector", query = "vector", source = "cplusplus.com" }, +} + +local function wait_for_results(count) + local prompt_bufnr = vim.api.nvim_get_current_buf() + local current = action_state.get_current_picker(prompt_bufnr) + local completed = false + current:register_completion_callback(function() + completed = true + end) + assert( + vim.wait(3000, function() + return completed and current.manager:num_results() == count + end, 10), + "Telescope did not finish finding results" + ) + return prompt_bufnr, current +end + +local function mapped_action(prompt_bufnr, mode, key) + for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(prompt_bufnr, mode)) do + if mapping.lhs == key then + assert(type(mapping.callback) == "function", "expected a callback for " .. key) + return mapping.callback + end + end + error("missing mapping: " .. mode .. " " .. key) +end + +cppman.setup({ picker = { provider = "telescope" } }) +assert(picker.resolve_provider("auto") == "telescope", "auto should resolve to the installed Telescope backend") +assert(picker.resolve_provider("telescope.nvim") == "telescope", "Telescope alias did not resolve") + +for _, source in ipairs({ "both", "cppreference.com" }) do + for _, selection_action in ipairs({ "select_default", "select_horizontal", "select_vertical", "select_tab" }) do + local selected + backend.open({ + source = source, + items = items, + search = "vec", + on_select = function(item, pattern) + selected = { item = item, pattern = pattern } + end, + }) + local prompt_bufnr, current = wait_for_results(2) + for i, item in ipairs(items) do + local entry = current.manager:get_entry(i) + assert(entry.value == item, "same-name results must keep their source identity") + assert(entry.ordinal == item.text, "source badges must not affect matching") + local display = type(entry.display) == "function" and entry.display() or entry.display + local badge = source == "both" and (i == 1 and " [ref]" or " [c++]") or "" + assert(display == item.text .. badge, "incorrect source badge") + end + current:set_selection(current:get_row(2)) + local search_updated = false + current:register_completion_callback(function() + search_updated = true + end) + current:set_prompt("vector") + assert( + vim.wait(3000, function() + return search_updated and action_state.get_current_line() == "vector" + end, 10), + "Telescope did not update the search" + ) + local expected_item = action_state.get_selected_entry().value + actions[selection_action](prompt_bufnr) + assert( + selected and selected.item == expected_item, + selection_action .. " did not select the documentation entry" + ) + assert(selected.pattern == "vector", "selection must preserve the edited search") + assert(not vim.api.nvim_buf_is_valid(prompt_bufnr), "selection did not close the picker") + end +end + +for _, mode in ipairs({ "i", "n" }) do + local went_back = false + backend.open({ + items = items, + on_back = function() + went_back = true + end, + on_select = function() + error("back must not select a result") + end, + }) + local prompt_bufnr = wait_for_results(2) + mapped_action(prompt_bufnr, mode, "")() + assert( + vim.wait(1000, function() + return went_back + end, 10), + "back mapping did not run in " .. mode .. " mode" + ) + assert(not vim.api.nvim_buf_is_valid(prompt_bufnr), "back did not close the picker") +end + +local custom_mapping_called = false +local attached = false +cppman.setup({ + picker = { + provider = "telescope", + telescope = require("telescope.themes").get_dropdown({ + prompt_title = "C++ docs", + layout_config = { width = 70, height = 15 }, + attach_mappings = function(_, map) + attached = true + map("n", "", function() + custom_mapping_called = true + end) + return true + end, + }), + }, +}) +local selected_with_overrides = false +backend.open({ + items = items, + on_select = function() + selected_with_overrides = true + end, +}) +local prompt_bufnr, current = wait_for_results(2) +assert(current.prompt_title == "C++ docs", "prompt title override was ignored") +assert(current.layout_strategy == "center", "dropdown theme was ignored") +assert(current.layout_config.width == 70 and current.layout_config.height == 15, "layout overrides were ignored") +assert(attached, "custom attach_mappings did not run") +mapped_action(prompt_bufnr, "n", "")() +assert(custom_mapping_called, "custom mapping did not run") +actions.select_default(prompt_bufnr) +assert(selected_with_overrides, "custom mappings broke documentation selection") + +cppman.setup({ picker = { provider = "telescope" } }) +for _, search in ipairs({ "vec", "no_matching_documentation" }) do + backend.open({ + items = items, + search = search, + on_select = function() + error("empty results must not select a page") + end, + }) + if search == "vec" then + local _, active_picker = wait_for_results(2) + active_picker:set_prompt("no_matching_documentation") + end + prompt_bufnr = wait_for_results(0) + actions.select_default(prompt_bufnr) + assert(not vim.api.nvim_buf_is_valid(prompt_bufnr), "empty selection did not close the picker") +end + +local loaded_plenary = package.loaded.plenary +local preload_plenary = package.preload.plenary +package.loaded.plenary = nil +package.preload.plenary = function() + error("plenary is not installed") +end +local available, dependency_error = backend.is_available() +assert(not available and dependency_error:find("plenary.nvim", 1, true), "missing Plenary was not reported") +local notification +local notify = vim.notify +vim.notify = function(message) + notification = message +end +backend.open({ items = items }) +assert( + notification and notification:find("plenary.nvim", 1, true), + "opening without Plenary must report the dependency" +) +vim.notify = notify +package.loaded.plenary = loaded_plenary +package.preload.plenary = preload_plenary + +print("Telescope picker tests passed") +vim.cmd("qa!")