doc/lib.nvim-deps.txt — rendered from the plugin's own vimdoc
*lib.nvim-deps.txt* Declare, detect and install a plugin's external tools lib.nvim.deps *lib.nvim-deps* Plugins that degrade gracefully without optional CLI tools (pandoc, ImageMagick, tesseract, poppler-utils, ...) all end up hand-rolling the same two things: avim.fn.executableprobe inhealth.lua, and a hint string telling the user what to type.lib.nvim.depsreplaces both with a declared spec the plugin ships, and adds the step nobody had: composing the correct install command for the host's package manager. Nothing here ever executes an install command. See |lib.nvim-deps-safety|.
CONTENTS
1. Declaring tools ......................... |lib.nvim-deps-declaring| docs/INSTALL.md ....................... |lib.nvim-deps-markdown| docs/install.json ..................... |lib.nvim-deps-json| 2. Commands ................................. |lib.nvim-deps-commands| 3. Safety model ............................. |lib.nvim-deps-safety| 4. Submodules ............................... |lib.nvim-deps-submodules| deps.health ........................... |lib.nvim-deps-health| deps.spec ............................. |lib.nvim-deps-spec| deps.pm ............................... |lib.nvim-deps-pm| deps.install .......................... |lib.nvim-deps-install| deps.view ............................. |lib.nvim-deps-view| deps.first_run ........................ |lib.nvim-deps-first_run| deps.status ........................... |lib.nvim-deps-status| deps.require_tool ..................... |lib.nvim-deps-require_tool| deps.detect ........................... |lib.nvim-deps-detect| 5. Resolution ............................... |lib.nvim-deps-resolution| 6. Integrating a plugin ...................... |lib.nvim-deps-integrating|
1. DECLARING TOOLS
A plugin declares its optional external tools in ONE of two files, both under its owndocs/directory. Copyable templates live intemplates/deps/in the lib.nvim repo. Every tool entry needs three things, all enforced by validation: bin the executable probed on PATH why one sentence saying what this tool unlocks pkg package name per package manager, at least one entrywhybeing REQUIRED is the point of the whole format, not a nicety: a manifest entry that doesn't say why a tool matters produces a nameless "tool missing" report, which is what the hand-rolled hint strings already did.requireddefaults to false;seeis optional. docs/INSTALL.md *lib.nvim-deps-markdown* Ordinary Markdown prose, plus one fencedinstall-toolblock per tool. The block body is decoded withlib.lua.yaml's minimal subset — no anchors, no flow style, no block scalars, so keepwhyon one line. Renders as normal documentation on GitHub, which is this variant's reason to exist.
```install-tool
bin: pdftotext
required: false
why: "Enables the fast plain-text extraction backend."
pkg:
apt: poppler-utils
brew: poppler
pacman: poppler
```
docs/install.json *lib.nvim-deps-json* Same fields as JSON, decoded withvim.json.decode. No line-oriented parsing limits: multi-linewhy, arbitrary extra metadata (unknown fields are ignored, not rejected), and the fastest parse. Preferred automatically when a plugin ships both.
{
"tools": [
{
"bin": "pdftotext",
"required": false,
"why": "Enables the fast plain-text extraction backend.",
"pkg": { "apt": "poppler-utils", "brew": "poppler" }
}
]
}
2. COMMANDS
Registered byrequire("lib.nvim_usrcmds").setup()via itsdepsoption (default: true), as routes on the existing:Libverb rather than a second top-level command name. *:Lib-deps* :Lib deps show {plugin} Tools, each with itswhy, present/missing on this host, and the command that would install it. Opens a scratch split. Missing-and-required sorts first, then missing, then present. :Lib deps show List every plugin that ships a spec at all. :Lib deps status The same report across EVERY plugin at once, one line per tool no matter how many plugins want it (see:names them). The view for "I just rolled this config out on a new machine". See |lib.nvim-deps-status|. :Lib deps install {plugin} Compose the install command for this host's package manager, ask, then hand it to a terminal. See |lib.nvim-deps-safety|. :Lib deps install The same, for everything missing across every plugin.<Tab>completes plugin names that actually ship a spec.:Lib deps showopens a popup (via |lib.nvim-kit|'sviewer, when installed — falls back to a read-only scratch split otherwise) with three keymaps: i Install the tool under the cursor. Inline with live streamed output when the package manager needs no elevation (brew, scoop, most winget); otherwise the same confirm-then-terminal handoff as bulk install, since a backgrounded job has no interactive stdin for a sudo password. See |lib.nvim-deps-safety|. <CR> Expand/collapse that tool's install output. I Install everything missing (same as:Lib deps install). q/<Esc> Close. A tool's line flips from[missing]to[ok]the moment its inline install finishes successfully.
3. SAFETY MODEL
Requiring any module underlib.nvim.depsregisters nothing and touches nothing.plan()only probes. The single function that opens anything isdeps.install.run, and its contract is: 1. Reachable only from an explicit user action (:Lib deps install), never fromrequireor asetup(). 2. Asks for confirmation, showing the exact command first. 3. Opens a terminal running your shell and TYPES the command into it WITHOUT a trailing newline. You press Enter. Point 3 is why this does not usejobstart/vim.system: a backgrounded privileged install with its password prompt swallowed is the failure mode worth designing out. Asudoprompt is answered at a real terminal. Two further omissions, both deliberate: No-y/--noconfirmThe package manager keeps its own confirmation prompt. Seeing what apt is about to pull in beats suppressing it for tidiness. No elevation logic Beyond prefixingsudo(only when the manager needs root, the process is not already root, andsudois on PATH). On Windows, elevation is the package manager's own business.
4. SUBMODULES
deps.health *lib.nvim-deps-health* Replaces the hand-rolledcheck_exe/probeloop in a plugin's ownhealth.lua. Probes executables vialib.nvim.core's memoizedhas_exec, and Python modules by shelling out to the first of python3/python/py.
require("lib.nvim.deps.health").report({
{ bin = "pdftotext", required = false, hint = "install poppler-utils" },
{ python_module = "pdfplumber", hint = "pip install pdfplumber" },
})
-- or straight from a parsed spec, reusing each tool's `why` as the hint:
require("lib.nvim.deps.health").from_tools(result.tools)
deps.spec *lib.nvim-deps-spec*
local spec = require("lib.nvim.deps.spec")
spec.parse_markdown(text) --> { tools = {...}, errors = {...} }
spec.parse_json(text) --> same shape
spec.load(path) --> dispatches on the .json extension
spec.find("pdfport.nvim") --> path to that plugin's spec, or nil
spec.plugins() --> every plugin shipping a spec, sorted
errors is never nil: a malformed or incomplete entry is reported rather
than silently dropped, and a file can be partly valid.
deps.pm *lib.nvim-deps-pm*
local pm = require("lib.nvim.deps.pm")
pm.detect() --> the manager to use here, or nil
pm.available() --> every manager present, in this OS's preference order
pm.get("apt") --> a manager definition by id, installed or not
pm.commands(pm.get("brew"), { "poppler" }) --> { { "brew", "install", ... } }
pm.render(argv) --> a copy-pasteable shell string
Known ids, matching the keys a tool'spkgmap uses: apt, dnf, pacman, zypper, apk, brew, winget, scoop, choco. WSL is treated as plain Linux — inside WSL the distro's own manager is the right answer.commandsreturns a LIST of argv lists, not one.wingetcannot take several packages per invocation (extra tokens become query terms), so it gets one command per package while every other manager gets one combined command. Callers must not assume a single command. deps.install *lib.nvim-deps-install*
local install = require("lib.nvim.deps.install")
local plan = install.plan(result.tools)
-- plan.manager / present / missing / installable / unsupported
-- / packages / commands
install.run(plan) -- asks, then hands off to a terminal
plan()is pure.installablevsunsupportedsplits the missing tools by whether this host's manager has a declared package name, so a tool with nobrewentry is reported rather than quietly dropped from the command.run()returns false — with a notification, opening nothing — when there is no package manager, nothing missing, or nothing installable. deps.view *lib.nvim-deps-view*
require("lib.nvim.deps.view").lines("pdfport.nvim", result) --> string[]
require("lib.nvim.deps.view").show("pdfport.nvim", result)
lines()is pure and does the whole job;show()only puts its output in a named scratch split. Awhyunder 20 characters gets a visible nudge: the parser can enforce thatwhyexists, but not that it says anything useful, so that judgement is surfaced where the plugin author will see it instead of pretended to be validation. deps.first_run *lib.nvim-deps-first_run*
require("lib.nvim.deps").show_once("pdfport.nvim")
Shows a plugin's declared-tools popup once, ever — persisted across restarts vialib.nvim.cache.disk(namespacelib.nvim.deps.first_run). No-op on every call after the first, including when there was nothing to show (no spec, empty spec, or nothing declared is missing) — those still mark the plugin seen, since this is a one-time welcome, not an ongoing watcher. Call it from the CONSUMING PLUGIN's ownsetup(), never fromrequire— see |lib.nvim-deps-integrating|. require("lib.nvim.deps.first_run").seen(name) --> boolean require("lib.nvim.deps.first_run").mark_seen(name) require("lib.nvim.deps.first_run").reset(name) --> forget one, or all with no arg *:Lib-deps-reset-first-run* :Lib deps reset-first-run [plugin] Forget the "seen" state so the popup shows again on the nextshow_once. Opting out — set either anywhere in your own config, norequire("lib.nvim.deps")call needed:
vim.g.lib_nvim_deps_disable_first_run = true -- every plugin
vim.g.lib_nvim_deps_disabled_plugins = { "gopath.nvim" } -- just these
vim.gon purpose, not asetup()option: a user who installed one plugin alone (lib.nvimpulled in only as its transitive dependency) has nolib.nvimconfig block to put an option into, so the toggle must not require one. Read fresh on everyshow_oncecall — order-independent. Neither variable is retroactive: it stops FUTURE calls, it does not mark anything as already-seen, so removing the opt-out later resumes normally.
deps.status
Every declared tool, across every plugin that declares one, merged into a
single report. Backs :Lib deps status.
local status = require("lib.nvim.deps.status")
status.collect() -- { tools, sources, plugins, failed } -- the merge
status.lines() -- string[]
status.show() -- the popup `:Lib deps show` opens, with i / I
status.install() -- plan + confirm + terminal, everything missing
:Lib deps show {plugin}answers "what does THIS plugin want", which is the right question once you already suspect a plugin. It is the wrong one after cloning a config onto a new machine, where the question is "what is missing HERE" — and:Lib deps showwith no argument lists which plugins ship a spec, not what any of them lacks. It also fixes a timing problem: the first-run popup (|lib.nvim-deps-first_run|) rides on a plugin'ssetup(), so a lazy-loaded plugin shows it whenever it first happens to load.spec.findreads lazy.nvim's registry, so this report sees pending plugins without waiting for them (|lib.nvim-deps-resolution|). THE MERGE. One tool with several declarers (curlis commonly wanted by four or five) must appear ONCE — a report listing it five times buries what else is missing — while still saying who wants it: required true if ANY declarer requires it. Installing it satisfies everyone; skipping it breaks that plugin. pkg unioned, first declaration wins a key. Two plugins naming different packages for one binary is a bug in one of them; preferring the later would hide it. bin_alternatives unioned — the same program, whoever asked for it. why the first declarer's sentence. Concatenating several would be a paragraph per tool. see rewritten to "wanted by a.nvim, b.nvim". Rendering isdeps.view's, unchanged: the merge produces one tool list and one parse result, which is whatview.showalready takes — so the popup, thei/Ikeymaps and the live streaming work here without a second implementation. A pluginspec.plugins()lists but whose spec cannot be read lands infailedand is named in a warning rather than dropped: "absent from the report" and "declares nothing" would otherwise look identical.
deps.require_tool
The failure moment. Everything else here is forward-looking —|:checkhealth|, the first-run popup and:Lib deps showall answer "what might I need?", read before anything breaks. The moment that actually reaches a user is the one where a command does nothing.
local curl = require("lib.nvim.deps").require_tool("language.nvim", "curl")
if not curl then return end
[language.nvim] curl not found — Carries the thesaurus lookup requests.
install: sudo apt install curl
or run: :Lib deps install language.nvim
IT RETURNS THE NAME, NOT A BOOLEAN. A tool declaringbin_alternativesisgson Linux andgswin64con Windows, and a caller just told "yes, it's here" still has to know which spelling to spawn. The returned name answers both, and stays truthy for theif not ... then return endguard. Options:
silent -- answer without reporting (caller has its own error path)
throttle_ms -- quiet window per (plugin, tool); default 5000, <=0 off
manager -- compose the install line for this manager
The throttle is a BURST GUARD, not a mute: a caller checking one tool per file across a 200-file batch would otherwise produce 200 identical notifications, while a user who re-runs a command a minute later has asked twice and should be told twice. For failure paths that hand their error upwards instead of notifying — a callback taking(value, err), aresult.errorslist —linesgives the same wording with nothing attached:
local rt = require("lib.nvim.deps.require_tool")
on_done({ ok = false, err = table.concat(rt.lines("nvim", "pandoc"), " ") })
Works before a plugin ships a spec: the check still runs and still reports, it just has nowhyor install command to add — so call sites can move over first and the spec can follow. Nothing here installs anything: the message names the command and points at:Lib deps install. Offering to install AT the failure moment would answer a question the user did not ask. Convenience wrappers onrequire("lib.nvim.deps")itself:plugins()(re-export ofspec.plugins()),show(plugin_name)(resolve +view.show) andinstall_for(plugin_name)(resolve +install.plan/run) — each notifies and returnsfalse/nil rather than throwing when the plugin ships no spec or its spec file cannot be read.
deps.detect
Is a declared tool present on this host, under any of the names it goes by? A tool does not always have one binary name (Ghostscript isgson Linux/macOS,gswin64c/gswin32con Windows) —binstays canonical for display and for thepkgmap key;bin_alternativesonly widens the "is it here" probe. Used internally bydeps.health,deps.installanddeps.view.
local detect = require("lib.nvim.deps.detect")
detect.names(tool) --> { bin, ...bin_alternatives } (canonical first)
detect.found(tool) --> boolean
detect.found_as(tool) --> the name it was actually found under, or nil
detect.forget(tool) --> drop the memoized PATH probe for this tool
5. RESOLUTION
spec.findandspec.pluginsresolve in two steps: 1.'runtimepath'— manager-agnostic. Covers packer, vim-plug, mini.deps, a symlinked dev checkout, and every LOADED lazy.nvim plugin. The plugin name must match a whole path segment, sopdfport.nvimdoes not match a directory namedmy-pdfport.nvim-fork. 2. lazy.nvim's plugin registry, consulted throughpcallonly if step 1 missed and lazy.nvim is present. Step 2 is not redundant. lazy.nvim puts a plugin on'runtimepath'only once it actually LOADS. Measured against a real config while building this: 120 plugins configured, 44 loaded at startup, 76 pending — and all 76 were resolvable through lazy'sdirwhile being absent from'runtimepath'. A runtimepath-only lookup would have silently failed for the majority of a lazy-loading user's plugins, and "silently" is what makes it serious: a spec that cannot be found looks exactly like a plugin that ships none. lib.nvim does not take a dependency on lazy.nvim for this — step 1 remains the primary path, and other managers put plugins on'runtimepath'eagerly.
6. INTEGRATING A PLUGIN
Three independent, optional additions — pick any subset:
1. Ship docs/install.json (or docs/INSTALL.md), see
|lib.nvim-deps-declaring|. Required for the other two to have
anything to show.
2. In the plugin's own health.lua, inside M.check():
require("lib.nvim.deps.health").report_for("your-plugin.nvim")
One line, replaces re-listing the same tools by hand a second time.
3. In the plugin's own setup(), typically near the end:
function M.setup(opts)
...
require("lib.nvim.deps").show_once("your-plugin.nvim")
end
Shows the popup once, ever, the first time the plugin initializes
after being installed. See |lib.nvim-deps-first_run|.
None of these make a spec file mandatory, and none run at require time —
a plugin with no spec, or that skips (2)/(3) entirely, behaves exactly as
it did before lib.nvim.deps existed.