diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml
index 91a9fd8..141c76d 100644
--- a/.github/workflows/smoke.yml
+++ b/.github/workflows/smoke.yml
@@ -13,6 +13,7 @@ jobs:
fail-fast: false
matrix:
neovim: [stable, nightly]
+ picker: [snacks, fzf-lua]
steps:
- name: Checkout code
@@ -24,10 +25,18 @@ jobs:
neovim: true
version: ${{ matrix.neovim }}
- - name: Install snacks.nvim
+ - name: Install picker dependency
run: |
mkdir -p .tests/deps
- git clone --depth=1 https://github.com/folke/snacks.nvim .tests/deps/snacks.nvim
+ if [ "${{ matrix.picker }}" = "snacks" ]; then
+ git clone --depth=1 https://github.com/folke/snacks.nvim .tests/deps/snacks.nvim
+ else
+ sudo apt-get update
+ sudo apt-get install -y fzf
+ git clone --depth=1 https://github.com/ibhagwan/fzf-lua .tests/deps/fzf-lua
+ fi
- name: Run smoke test
- run: nvim --headless -u NONE -c "set rtp+=$PWD,$PWD/.tests/deps/snacks.nvim" -l scripts/smoke_test.lua
+ run: nvim --headless -u NONE -l scripts/smoke_test.lua
+ env:
+ CPPMAN_PICKER_PROVIDER: ${{ matrix.picker }}
diff --git a/README.md b/README.md
index 640a3a8..3adf8fa 100644
--- a/README.md
+++ b/README.md
@@ -10,26 +10,47 @@
(Demo recorded in version v0.0.4)
-A Neovim plugin for the [cppman CLI](https://github.com/aitjcize/cppman), so you can search cplusplus.com and cppreference.com without leaving Neovim.
+A small Neovim plugin for the [cppman CLI](https://github.com/aitjcize/cppman).
+
+It lets you search C++ docs from inside Neovim and opens the result in a
+floating viewer. Search reads cppman's local SQLite index, so the picker does
+not need to call the `cppman` command for every query.
## Requirements
-- Neovim 0.10+ (uses `vim.keycode`, `vim.system`, `vim.uv`)
-- [cppman](https://github.com/aitjcize/cppman) - for rendering documentation pages
-- `sqlite3` CLI - for querying the keyword index (`apt install sqlite3`, `brew install sqlite`)
-- [folke/snacks.nvim](https://github.com/folke/snacks.nvim) - for the fuzzy picker UI
+* Neovim 0.10+
+* [cppman](https://github.com/aitjcize/cppman)
+* `sqlite3`
+* One picker backend:
-## Installation
+ * [ibhagwan/fzf-lua](https://github.com/ibhagwan/fzf-lua) and the `fzf` binary
+ * [folke/snacks.nvim](https://github.com/folke/snacks.nvim)
-Install with [lazy.nvim](https://github.com/folke/lazy.nvim).
+Install the external tools however you normally do:
-Use `version = "*"` to follow the latest stable release.
+```sh
+brew install cppman sqlite fzf
+# or
+sudo apt install cppman sqlite3 fzf
+```
+
+## Installation
+
+With [lazy.nvim](https://github.com/folke/lazy.nvim), using `fzf-lua`:
```lua
-{
+return {
"simonwinther/cppman.nvim",
version = "*",
cmd = "CPPMan",
+ dependencies = {
+ "ibhagwan/fzf-lua",
+ },
+ opts = {
+ picker = {
+ provider = "fzf-lua",
+ },
+ },
keys = {
{
"cu",
@@ -46,111 +67,154 @@ Use `version = "*"` to follow the latest stable release.
desc = "[C++] keyword search",
},
},
+}
+```
+
+If you already use `snacks.nvim`, use that instead:
+
+```lua
+return {
+ "simonwinther/cppman.nvim",
+ version = "*",
+ cmd = "CPPMan",
dependencies = {
"folke/snacks.nvim",
},
- opts = {},
+ opts = {
+ picker = {
+ provider = "snacks",
+ },
+ },
}
```
+You can also leave the provider as `"auto"`. It tries `snacks.nvim` first, then
+`fzf-lua`.
+
## Usage
-Run `:CPPMan` to open the keyword search picker, or pass a term directly: `:CPPMan std::array`
+Open the search picker:
+
+```vim
+:CPPMan
+```
+
+Open a page directly:
+
+```vim
+:CPPMan std::vector
+```
+
+From Lua:
+
+```lua
+require("cppman").search()
+require("cppman").open_for(vim.fn.expand(""))
+```
+
+## Moving Around
+
+The docs viewer is just a normal Neovim buffer in a float. Regular movement
+still works: `j`, `k`, ``, ``, `/`, `n`, and so on.
+
+cppman.nvim adds a few mappings on top:
+
+* `K`, ``, or double-click: follow the word under the cursor
+* `K` on a table-of-contents entry: jump to that section
+* `` or right-click: go back to the previous cppman page/search
+* ``: go forward again after going back
+* `q`: close the viewer
-With the lazy.nvim spec above, the plugin also loads on these keymaps:
-* `cu` - open CPPMan for the word under the cursor.
-* `ck` - open the CPPMan search picker.
+Visual mode works too:
+
+* Select text, then press `K` or `` to open docs for the selection
## Configuration
-All options with their defaults:
+Defaults:
```lua
require("cppman").setup({
- -- Documentation source: "both", "cppreference.com", or "cplusplus.com"
+ -- "both", "cppreference.com", or "cplusplus.com"
source = "both",
+
index = {
- -- Explicit path to cppman's index.db (optional override)
- -- If nil, the plugin auto-discovers a valid DB
+ -- Set this if cppman's index.db is somewhere unusual.
+ -- If nil, cppman.nvim tries to find a usable one.
db_path = nil,
},
+
picker = {
- width = 0.4, -- fraction of editor width
- height = 0.4, -- fraction of editor height
+ -- "auto", "fzf-lua", or "snacks"
+ provider = "auto",
+
+ width = 0.4,
+ height = 0.4,
+
+ -- Passed through to the picker backend.
+ snacks = {},
+ fzf_lua = {},
},
+
viewer = {
- width = 0.8, -- fraction of editor width
- height = 0.6, -- fraction of editor height
+ width = 0.8,
+ height = 0.6,
},
})
```
-### Customizing keymaps
-
-Keymaps live in your lazy.nvim spec instead of `setup()` so they can trigger
-lazy loading:
+Example with a slightly larger `fzf-lua` picker:
```lua
-{
- "simonwinther/cppman.nvim",
- version = "*",
- cmd = "CPPMan",
- keys = {
- {
- "mu",
- function()
- require("cppman").open_for(vim.fn.expand(""))
- end,
- desc = "[C++] open under cursor",
- },
- {
- "mk",
- function()
- require("cppman").search()
- end,
- desc = "[C++] keyword search",
- },
+require("cppman").setup({
+ picker = {
+ provider = "fzf-lua",
+ width = 0.5,
+ height = 0.5,
},
- dependencies = { "folke/snacks.nvim" },
- opts = {},
-}
+})
```
-## How it works
+If you only want one docs source:
-Search is powered by a local SQLite index that ships with cppman. The plugin queries it once per session via `sqlite3` and passes the keyword list to `snacks.picker` for fast in-memory fuzzy matching. The `cppman` CLI is only used to render documentation pages, not to search.
-
-If `source = "both"`, the plugin merges cppreference and cplusplus results into one in-memory picker list. Exact identical matches are deduplicated, and the picker shows a small source badge so you can see which site a result will open from.
+```lua
+require("cppman").setup({
+ source = "cppreference.com",
+})
+```
-If you prefer one site only, set `source = "cppreference.com"` or `source = "cplusplus.com"`.
+## How Search Works
-If the user's local `~/.cache/cppman/index.db` is empty or corrupt (which can happen when `cppman -r` fails due to missing Python dependencies), the plugin falls back to the packaged index.db bundled with the cppman Python package.
+`cppman.nvim` reads cppman's local `index.db` with `sqlite3` and keeps the
+keyword list in memory for the current Neovim session.
-## Performance
+The `cppman` command itself is only used when opening a documentation page.
-Search is fast because the plugin loads cppman's SQLite index once and keeps the results in memory for picker matching. In `source = "both"` mode, the merged cross-source list is also built once per session and then reused.
+When `source = "both"`, results from cppreference and cplusplus are merged.
+Duplicate exact matches are removed where possible, and the picker shows a small
+source label so you know where the page will open from.
-Documentation rendering is optimized for cached pages. If a man page already exists in cppman's `.3.gz` cache, the plugin renders that file directly through cppman's pager script instead of starting the full `cppman` CLI search path again. That avoids most of the Python startup and lookup overhead while keeping the same rendered output. If the page is not cached yet, the plugin falls back to normal `cppman` behavior.
+If your local `~/.cache/cppman/index.db` is empty or broken, the plugin tries to
+use the packaged `index.db` from the cppman Python package instead.
-Rendered pages are also cached in memory for the current Neovim session, so reopening the same page at the same width is effectively instant.
+## Page Rendering
-## Navigation
+If cppman already has the `.3.gz` page cached, `cppman.nvim` renders that file
+through cppman's pager script. If not, it falls back to normal cppman behavior.
-In normal mode, the docs viewer uses the same navigation as standalone `cppman`:
+Rendered pages are also cached in memory for the current Neovim session.
-* **K**, **\**, **\<2-LeftMouse\>** - follow the word under the cursor (when the cursor is on a TOC entry, jumps to that section instead)
-* **\**, **\** - go back to the previous page (cursor position is restored)
-* **\** - go forward (undo a back navigation; cursor position is restored)
-* **q** - close the viewer
+## Health Check
-In visual mode inside the viewer:
+Run:
-* **K**, **\** - follow the selected text
-
-## Health check
+```vim
+:checkhealth cppman
+```
-Run `:checkhealth cppman` to verify that `cppman`, `sqlite3`, `snacks.nvim`, and a valid `index.db` are all present.
+It checks Neovim, `cppman`, `sqlite3`, your picker backend, and the resolved
+`index.db`.
-## Repo analytics
+## Repo Analytics

diff --git a/assets/demo.mp4 b/assets/demo.mp4
deleted file mode 100644
index 7f893f3..0000000
Binary files a/assets/demo.mp4 and /dev/null differ
diff --git a/doc/cppman.txt b/doc/cppman.txt
index bb97577..bc70f63 100644
--- a/doc/cppman.txt
+++ b/doc/cppman.txt
@@ -33,7 +33,9 @@ REQUIREMENTS *cppman-cppman.nvim-requirements*
- Neovim 0.10+ (uses `vim.keycode`, `vim.system`, `vim.uv`)
- cppman - for rendering documentation pages
- `sqlite3` CLI - for querying the keyword index (`apt install sqlite3`, `brew install sqlite`)
-- folke/snacks.nvim - for the fuzzy picker UI
+- One fuzzy picker backend:
+ - ibhagwan/fzf-lua and `fzf`
+ - folke/snacks.nvim
INSTALLATION *cppman-cppman.nvim-installation*
@@ -63,10 +65,32 @@ Use `version = "*"` to follow the latest stable release.
desc = "[C++] keyword search",
},
},
+ dependencies = {
+ "ibhagwan/fzf-lua",
+ },
+ opts = {
+ picker = {
+ provider = "fzf-lua",
+ },
+ },
+ }
+<
+
+If you already use `snacks.nvim`, use it as the picker backend instead:
+
+>lua
+ {
+ "simonwinther/cppman.nvim",
+ version = "*",
+ cmd = "CPPMan",
dependencies = {
"folke/snacks.nvim",
},
- opts = {},
+ opts = {
+ picker = {
+ provider = "snacks",
+ },
+ },
}
<
@@ -95,8 +119,14 @@ All options with their defaults:
db_path = nil,
},
picker = {
- width = 0.4, -- fraction of editor width
- height = 0.4, -- fraction of editor height
+ -- Picker backend: "auto", "fzf-lua", or "snacks"
+ -- "auto" keeps existing behavior by preferring snacks.nvim when present,
+ -- then falls back to fzf-lua.
+ provider = "auto",
+ width = 0.4, -- fraction of editor width
+ height = 0.4, -- fraction of editor height
+ snacks = {}, -- extra options passed to snacks.picker.pick()
+ fzf_lua = {}, -- extra options passed to require("fzf-lua").fzf_exec()
},
viewer = {
width = 0.8, -- fraction of editor width
@@ -132,8 +162,12 @@ lazy loading:
desc = "[C++] keyword search",
},
},
- dependencies = { "folke/snacks.nvim" },
- opts = {},
+ dependencies = { "ibhagwan/fzf-lua" },
+ opts = {
+ picker = {
+ provider = "fzf-lua",
+ },
+ },
}
<
@@ -142,8 +176,8 @@ HOW IT WORKS *cppman-cppman.nvim-how-it-works*
Search is powered by a local SQLite index that ships with cppman. The plugin
queries it once per session via `sqlite3` and passes the keyword list to
-`snacks.picker` for fast in-memory fuzzy matching. The `cppman` CLI is only
-used to render documentation pages, not to search.
+the configured picker backend for fast in-memory fuzzy matching. The `cppman`
+CLI is only used to render documentation pages, not to search.
If `source = "both"`, the plugin merges cppreference and cplusplus results into
one in-memory picker list. Exact identical matches are deduplicated, and the
@@ -192,8 +226,8 @@ In visual mode inside the viewer:
HEALTH CHECK *cppman-cppman.nvim-health-check*
-Run `:checkhealth cppman` to verify that `cppman`, `sqlite3`, `snacks.nvim`,
-and a valid `index.db` are all present.
+Run `:checkhealth cppman` to verify that `cppman`, `sqlite3`, a supported
+picker backend, and a valid `index.db` are all present.
REPO ANALYTICS *cppman-cppman.nvim-repo-analytics*
diff --git a/lua/cppman/config.lua b/lua/cppman/config.lua
index ec2b7c8..937548b 100644
--- a/lua/cppman/config.lua
+++ b/lua/cppman/config.lua
@@ -6,8 +6,11 @@ M.defaults = {
db_path = nil,
},
picker = {
+ provider = "auto",
width = 0.4,
height = 0.4,
+ snacks = {},
+ fzf_lua = {},
},
viewer = {
width = 0.8,
diff --git a/lua/cppman/health.lua b/lua/cppman/health.lua
index cd29825..9c3167b 100644
--- a/lua/cppman/health.lua
+++ b/lua/cppman/health.lua
@@ -23,19 +23,43 @@ function M.check()
h.error("sqlite3 not found", { "Install sqlite3 (e.g. apt install sqlite3, brew install sqlite)" })
end
- local snacks_ok, Snacks = pcall(require, "snacks")
- if snacks_ok and Snacks.picker then
- h.ok("snacks.nvim with picker found")
- elseif snacks_ok then
- h.error(
- "snacks.nvim found but picker module unavailable",
- { "Upgrade snacks.nvim to a version with picker support" }
- )
+ local config = require("cppman.config")
+ local picker = require("cppman.picker")
+ local picker_opts = config.options.picker or {}
+ local provider = picker.normalize_provider(picker_opts.provider or "auto")
+ local statuses = picker.provider_statuses()
+
+ if provider == "auto" then
+ local active = picker.resolve_provider(provider)
+ 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" })
+ end
+ for _, status in pairs(statuses) do
+ if status.available then
+ h.info(status.label .. " available")
+ else
+ h.info(status.label .. " unavailable: " .. (status.error or "missing dependency"))
+ end
+ end
else
- h.error("snacks.nvim not found", { "Add folke/snacks.nvim as a dependency" })
+ local status = picker.provider_status(provider)
+ if status.available then
+ h.ok("picker provider found: " .. status.label)
+ else
+ h.error(
+ "picker provider unavailable: " .. status.label,
+ { status.error or "Install the configured picker backend" }
+ )
+ end
+ end
+
+ local fzf_status = statuses["fzf-lua"]
+ if fzf_status and fzf_status.available and vim.fn.executable("fzf") == 0 and not picker_opts.fzf_lua.fzf_bin then
+ h.warn("fzf executable not found", { "Install fzf, or configure fzf-lua to use another fzf-compatible binary" })
end
- local config = require("cppman.config")
local index = require("cppman.index")
local source = config.options.source or "both"
local db = index.resolve_db(index.get_sources(source)[1])
diff --git a/lua/cppman/picker.lua b/lua/cppman/picker.lua
index d7541af..f689a58 100644
--- a/lua/cppman/picker.lua
+++ b/lua/cppman/picker.lua
@@ -1,7 +1,18 @@
local M = {}
local uv = vim.uv or vim.loop
-local NBSP = vim.fn.nr2char(160)
+
+local AUTO_ORDER = { "snacks", "fzf-lua" }
+local PROVIDERS = {
+ snacks = {
+ label = "snacks.nvim",
+ module = "cppman.pickers.snacks",
+ },
+ ["fzf-lua"] = {
+ label = "fzf-lua",
+ module = "cppman.pickers.fzf_lua",
+ },
+}
M.last_pattern = ""
@@ -9,28 +20,118 @@ local function now_ms()
return uv.hrtime() / 1e6
end
-local function format_timing(elapsed)
- if elapsed < 10 then
- return string.format("%.1fms", elapsed)
+local function picker_options()
+ local config = require("cppman.config")
+ return config.options.picker or {}
+end
+
+local function load_backend(provider)
+ local spec = PROVIDERS[provider]
+ if not spec then
+ return nil, "unknown picker provider: " .. tostring(provider)
+ end
+
+ local ok, backend = pcall(require, spec.module)
+ if not ok then
+ return nil, backend
+ end
+ return backend
+end
+
+function M.normalize_provider(provider)
+ provider = provider or "auto"
+ if provider == "fzf_lua" then
+ return "fzf-lua"
+ end
+ if provider == "snacks.nvim" then
+ return "snacks"
+ end
+ return provider
+end
+
+function M.provider_status(provider)
+ provider = M.normalize_provider(provider)
+ local spec = PROVIDERS[provider]
+ if not spec then
+ return {
+ name = provider,
+ label = tostring(provider),
+ available = false,
+ error = "expected one of: auto, snacks, fzf-lua",
+ }
+ end
+
+ local backend, load_err = load_backend(provider)
+ if not backend then
+ return {
+ name = provider,
+ label = spec.label,
+ available = false,
+ error = tostring(load_err),
+ }
+ end
+
+ local ok, available, err = pcall(backend.is_available)
+ if not ok then
+ return {
+ name = provider,
+ label = spec.label,
+ available = false,
+ error = tostring(available),
+ }
+ end
+
+ return {
+ name = provider,
+ label = spec.label,
+ available = available == true,
+ error = err,
+ }
+end
+
+function M.provider_statuses()
+ local statuses = {}
+ for _, provider in ipairs(AUTO_ORDER) do
+ statuses[provider] = M.provider_status(provider)
end
- return string.format("%dms", math.floor(elapsed + 0.5))
+ return statuses
end
-local function source_badge(source)
- if source == "cppreference.com" then
- return " [ref]"
+function M.resolve_provider(provider)
+ provider = M.normalize_provider(provider or picker_options().provider or "auto")
+
+ if provider == "auto" then
+ for _, candidate in ipairs(AUTO_ORDER) do
+ local status = M.provider_status(candidate)
+ if status.available then
+ return candidate
+ end
+ end
+ return nil, "[cppman] no picker backend found (install folke/snacks.nvim or ibhagwan/fzf-lua)"
end
- if source == "cplusplus.com" then
- return " [c++]"
+
+ if not PROVIDERS[provider] then
+ return nil,
+ "[cppman] invalid picker provider: " .. tostring(provider) .. " (expected one of: auto, snacks, fzf-lua)"
end
- return ""
+
+ local status = M.provider_status(provider)
+ if status.available then
+ return provider
+ end
+
+ return nil,
+ "[cppman] picker provider " .. status.label .. " unavailable: " .. (status.error or "missing dependency")
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 provider, provider_err = M.resolve_provider()
+ if not provider then
+ vim.notify(provider_err, vim.log.levels.ERROR)
+ return
+ end
local config = require("cppman.config")
local source = opts.source or config.options.source or "both"
@@ -40,92 +141,20 @@ function M.open(opts)
local items = index.load(source)
local load_ms = now_ms() - t0
if #items == 0 then
- vim.notify("[cppman] no items loaded — check cppman and sqlite3 installation", vim.log.levels.ERROR)
- return
- end
-
- local ok, Snacks = pcall(require, "snacks")
- if not ok or not Snacks.picker then
- vim.notify("[cppman] snacks.nvim with picker support is required", vim.log.levels.ERROR)
+ vim.notify("[cppman] no items loaded - check cppman and sqlite3 installation", vim.log.levels.ERROR)
return
end
- -- snacks.picker.Text is {[1]: string, [2]: string?} — positional, not named fields
- local actions = {}
- local extra_keys = {}
- if on_back then
- actions.cppman_go_back = function(picker)
- picker:close()
- vim.schedule(on_back)
- end
- extra_keys = {
- input = { keys = { [""] = { "cppman_go_back", mode = { "i", "n" } } } },
- list = { keys = { [""] = { "cppman_go_back", mode = { "n" } } } },
- }
- end
-
- local title = {
- { "keyword", "Title" },
- { NBSP .. "search • " .. format_timing(load_ms), "Comment" },
- { " ", "FloatTitle" },
- }
- local footer = {
- { " enter ", "SpecialChar" },
- { "to search ", "Comment" },
- }
- if on_back then
- footer[#footer + 1] = { " C-T ", "SpecialChar" }
- footer[#footer + 1] = { "back ", "Comment" }
- end
-
- Snacks.picker.pick({
- source = "cppman",
+ local backend = assert(load_backend(provider))
+ backend.open(vim.tbl_extend("force", opts, {
+ provider = provider,
+ source = source,
items = items,
- format = function(item)
- if source == "both" then
- return {
- { item.text, "Normal" },
- { source_badge(item.source), "Comment" },
- }
- end
- return { { item.text, "Normal" } }
- end,
- confirm = function(picker, item)
- if item then
- -- snacks.picker has no public accessor for the live input; reach into
- -- internals defensively so a snacks rename only loses the breadcrumb.
- local ok, current = pcall(function()
- return picker.input.filter.pattern
- end)
- M.last_pattern = (ok and current) or pattern
- end
- picker:close()
- if item and on_select then
- local used_pattern = M.last_pattern
- on_select(item, used_pattern)
- end
+ load_ms = load_ms,
+ set_last_pattern = function(pattern)
+ M.last_pattern = pattern or ""
end,
- actions = actions,
- win = extra_keys,
- pattern = pattern,
- layout = {
- layout = {
- box = "vertical",
- backdrop = false,
- border = "rounded",
- title = title,
- title_pos = "center",
- footer = footer,
- footer_pos = "right",
- width = config.options.picker.width or 0.4,
- min_width = 40,
- height = config.options.picker.height or 0.4,
- min_height = 10,
- { win = "input", height = 1, border = "none" },
- { win = "list", border = "none" },
- },
- },
- })
+ }))
end
return M
diff --git a/lua/cppman/pickers/common.lua b/lua/cppman/pickers/common.lua
new file mode 100644
index 0000000..d1db057
--- /dev/null
+++ b/lua/cppman/pickers/common.lua
@@ -0,0 +1,37 @@
+local M = {}
+
+local NBSP = vim.fn.nr2char(160)
+
+function M.format_timing(elapsed)
+ if elapsed < 10 then
+ return string.format("%.1fms", elapsed)
+ end
+ return string.format("%dms", math.floor(elapsed + 0.5))
+end
+
+function M.source_badge(source)
+ if source == "cppreference.com" then
+ return " [ref]"
+ end
+ if source == "cplusplus.com" then
+ return " [c++]"
+ end
+ return ""
+end
+
+function M.item_label(item, source)
+ if source == "both" then
+ return item.text .. M.source_badge(item.source)
+ end
+ return item.text
+end
+
+function M.search_title(load_ms)
+ return {
+ { "keyword", "Title" },
+ { NBSP .. "search • " .. M.format_timing(load_ms), "Comment" },
+ { " ", "FloatTitle" },
+ }
+end
+
+return M
diff --git a/lua/cppman/pickers/fzf_lua.lua b/lua/cppman/pickers/fzf_lua.lua
new file mode 100644
index 0000000..34e7153
--- /dev/null
+++ b/lua/cppman/pickers/fzf_lua.lua
@@ -0,0 +1,116 @@
+local M = {}
+
+local common = require("cppman.pickers.common")
+
+function M.is_available()
+ local ok, FzfLua = pcall(require, "fzf-lua")
+ if not ok then
+ return false, "fzf-lua not found"
+ end
+ if type(FzfLua.fzf_exec) ~= "function" then
+ return false, "fzf-lua found but fzf_exec is unavailable"
+ end
+ return true
+end
+
+local function make_entries(items, source)
+ local entries = {}
+ local by_id = {}
+
+ for i, item in ipairs(items) do
+ entries[i] = tostring(i) .. "\t" .. common.item_label(item, source)
+ by_id[i] = item
+ end
+
+ return entries, by_id
+end
+
+local function selected_item(selected, by_id)
+ local line = selected and selected[1]
+ if not line then
+ return nil
+ end
+
+ local id = tonumber(line:match("^(%d+)\t"))
+ return id and by_id[id] or nil
+end
+
+local function last_query(FzfLua, fallback)
+ local ok, query = pcall(FzfLua.get_last_query)
+ if ok and type(query) == "string" then
+ return query
+ end
+ return fallback or ""
+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 ok, FzfLua = pcall(require, "fzf-lua")
+ if not ok or type(FzfLua.fzf_exec) ~= "function" then
+ vim.notify("[cppman] fzf-lua is required for picker.provider = 'fzf-lua'", vim.log.levels.ERROR)
+ return
+ end
+
+ local config = require("cppman.config")
+ local picker_opts = config.options.picker or {}
+ local source = opts.source or config.options.source or "both"
+ local entries, by_id = make_entries(opts.items or {}, source)
+
+ local actions = {
+ default = function(selected)
+ local item = selected_item(selected, by_id)
+ if not item then
+ return
+ end
+
+ local used_pattern = last_query(FzfLua, pattern)
+ if opts.set_last_pattern then
+ opts.set_last_pattern(used_pattern)
+ end
+ if on_select then
+ on_select(item, used_pattern)
+ end
+ end,
+ }
+
+ if on_back then
+ actions["ctrl-t"] = function()
+ vim.schedule(on_back)
+ end
+ end
+
+ local header = "enter: search"
+ if on_back then
+ header = header .. " | ctrl-t: back"
+ end
+
+ local call_opts = vim.tbl_deep_extend("force", {
+ prompt = "keyword> ",
+ query = pattern,
+ previewer = false,
+ header = header,
+ fzf_opts = {
+ ["--with-nth"] = "2..",
+ },
+ winopts = {
+ backdrop = 100,
+ border = "rounded",
+ title = " keyword search - " .. common.format_timing(opts.load_ms or 0) .. " ",
+ title_pos = "center",
+ width = picker_opts.width or 0.4,
+ height = picker_opts.height or 0.4,
+ preview = {
+ hidden = true,
+ },
+ },
+ }, vim.deepcopy(picker_opts.fzf_lua or {}))
+ call_opts.actions = vim.tbl_deep_extend("force", call_opts.actions or {}, actions)
+
+ FzfLua.fzf_exec(entries, call_opts)
+end
+
+return M
diff --git a/lua/cppman/pickers/snacks.lua b/lua/cppman/pickers/snacks.lua
new file mode 100644
index 0000000..825b840
--- /dev/null
+++ b/lua/cppman/pickers/snacks.lua
@@ -0,0 +1,108 @@
+local M = {}
+
+local common = require("cppman.pickers.common")
+
+function M.is_available()
+ local ok, Snacks = pcall(require, "snacks")
+ if not ok then
+ return false, "snacks.nvim not found"
+ end
+ if not Snacks.picker then
+ return false, "snacks.nvim found but picker module unavailable"
+ end
+ return true
+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 ok, Snacks = pcall(require, "snacks")
+ if not ok or not Snacks.picker then
+ vim.notify("[cppman] snacks.nvim with picker support is required", vim.log.levels.ERROR)
+ return
+ end
+
+ local config = require("cppman.config")
+ local picker_opts = config.options.picker or {}
+ local source = opts.source or config.options.source or "both"
+
+ local actions = {}
+ local extra_keys = {}
+ if on_back then
+ actions.cppman_go_back = function(picker)
+ picker:close()
+ vim.schedule(on_back)
+ end
+ extra_keys = {
+ input = { keys = { [""] = { "cppman_go_back", mode = { "i", "n" } } } },
+ list = { keys = { [""] = { "cppman_go_back", mode = { "n" } } } },
+ }
+ end
+
+ local footer = {
+ { " enter ", "SpecialChar" },
+ { "to search ", "Comment" },
+ }
+ if on_back then
+ footer[#footer + 1] = { " C-T ", "SpecialChar" }
+ footer[#footer + 1] = { "back ", "Comment" }
+ end
+
+ local call_opts = vim.tbl_deep_extend("force", vim.deepcopy(picker_opts.snacks or {}), {
+ source = "cppman",
+ items = opts.items,
+ format = function(item)
+ if source == "both" then
+ return {
+ { item.text, "Normal" },
+ { common.source_badge(item.source), "Comment" },
+ }
+ end
+ return { { item.text, "Normal" } }
+ end,
+ confirm = function(picker, item)
+ if item then
+ local ok_pattern, current = pcall(function()
+ return picker.input.filter.pattern
+ end)
+ local used_pattern = (ok_pattern and current) or pattern
+ if opts.set_last_pattern then
+ opts.set_last_pattern(used_pattern)
+ end
+ picker:close()
+ if on_select then
+ on_select(item, used_pattern)
+ end
+ return
+ end
+ picker:close()
+ end,
+ actions = actions,
+ win = extra_keys,
+ pattern = pattern,
+ layout = {
+ layout = {
+ box = "vertical",
+ backdrop = false,
+ border = "rounded",
+ title = common.search_title(opts.load_ms or 0),
+ title_pos = "center",
+ footer = footer,
+ footer_pos = "right",
+ width = picker_opts.width or 0.4,
+ min_width = 40,
+ height = picker_opts.height or 0.4,
+ min_height = 10,
+ { win = "input", height = 1, border = "none" },
+ { win = "list", border = "none" },
+ },
+ },
+ })
+
+ Snacks.picker.pick(call_opts)
+end
+
+return M
diff --git a/scripts/smoke_test.lua b/scripts/smoke_test.lua
index 1ad6873..e4bb5ce 100644
--- a/scripts/smoke_test.lua
+++ b/scripts/smoke_test.lua
@@ -1,10 +1,17 @@
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")
local ok, cppman = pcall(require, "cppman")
assert(ok, "failed to require cppman: " .. tostring(cppman))
-local setup_ok, setup_err = pcall(cppman.setup, {})
+local requested_provider = vim.env.CPPMAN_PICKER_PROVIDER
+local setup_opts = {}
+if requested_provider and requested_provider ~= "" then
+ setup_opts.picker = { provider = requested_provider }
+end
+
+local setup_ok, setup_err = pcall(cppman.setup, setup_opts)
assert(setup_ok, "setup failed: " .. tostring(setup_err))
assert(vim.fn.exists(":CPPMan") == 2, "CPPMan command was not created")
@@ -20,11 +27,79 @@ assert(history_ok, "failed to require cppman.history: " .. tostring(history))
local index_ok, index = pcall(require, "cppman.index")
assert(index_ok, "failed to require cppman.index")
-for _, mod in ipairs({ "cppman.render", "cppman.sections", "cppman.viewer", "cppman.picker", "cppman.health" }) do
+for _, mod in ipairs({
+ "cppman.render",
+ "cppman.sections",
+ "cppman.viewer",
+ "cppman.picker",
+ "cppman.pickers.common",
+ "cppman.pickers.snacks",
+ "cppman.pickers.fzf_lua",
+ "cppman.health",
+}) do
local rok, rerr = pcall(require, mod)
assert(rok, "failed to require " .. mod .. ": " .. tostring(rerr))
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")
+
+local bad_provider = picker.provider_status("unknown")
+assert(not bad_provider.available, "unknown picker provider should not be available")
+
+if requested_provider and requested_provider ~= "" then
+ local normalized = picker.normalize_provider(requested_provider)
+ local resolved, resolve_err = picker.resolve_provider(requested_provider)
+ if normalized == "auto" then
+ assert(resolved ~= nil, "auto picker provider did not resolve: " .. tostring(resolve_err))
+ else
+ assert(resolved == normalized, "requested picker provider did not resolve: " .. tostring(resolve_err))
+ end
+end
+
+do
+ local loaded_fzf_lua = package.loaded["fzf-lua"]
+ local preload_fzf_lua = package.preload["fzf-lua"]
+ local captured
+ package.loaded["fzf-lua"] = {
+ fzf_exec = function(entries, opts)
+ captured = { entries = entries, opts = opts }
+ end,
+ get_last_query = function()
+ return "vec"
+ end,
+ }
+ package.preload["fzf-lua"] = nil
+
+ local selected
+ require("cppman.pickers.fzf_lua").open({
+ source = "both",
+ items = {
+ {
+ text = "std::vector",
+ page = "std::vector",
+ query = "std::vector",
+ source = "cppreference.com",
+ },
+ },
+ on_select = function(item, pattern)
+ selected = { item = item, pattern = pattern }
+ end,
+ })
+
+ assert(captured, "fzf-lua backend did not call fzf_exec")
+ assert(captured.opts.fzf_opts["--nth"] == nil, "fzf-lua backend must not restrict matching with --nth")
+ assert(captured.opts.fzf_opts["--delimiter"] == nil, "fzf-lua backend must not set a custom delimiter")
+ assert(captured.opts.fzf_opts["--with-nth"] == "2..", "fzf-lua backend should hide the internal id column")
+ captured.opts.actions.default({ captured.entries[1] })
+ assert(selected and selected.item.text == "std::vector", "fzf-lua selection did not map back to the index item")
+ assert(selected.pattern == "vec", "fzf-lua selection did not preserve the last query")
+
+ package.loaded["fzf-lua"] = loaded_fzf_lua
+ package.preload["fzf-lua"] = preload_fzf_lua
+end
+
-- Source whitelist guards against arbitrary input.
local bad_ok, bad_err = pcall(index.load, "evil; DROP TABLE x")
assert(not bad_ok, "expected source whitelist to reject bad input")