doc/diff.txt — rendered from the plugin's own vimdoc
*diff.txt* Flexible diffing for Neovim *diff.nvim* Author: Stefan Bartl Version: 0.1.0
CONTENTS
1. Introduction .............. |diff-intro| 2. Requirements .............. |diff-requirements| 3. Installation .............. |diff-installation| 4. Configuration ............. |diff-config| 4.1 Picker resolution ..... |diff-picker-resolution| 4.2 Exit scope ............ |diff-exit-scope| 5. Commands .................. |diff-commands| 5.1 :Diff ................. |:Diff| URL sources ........... |diff-url-sources| Three-way diff ........ |diff-three-way| 5.2 :DiffClear ............ |:DiffClear| 5.3 :DiffBuffers .......... |:DiffBuffers| 5.4 :DiffOrig ............. |:DiffOrig| 5.5 :DiffExit ............. |:DiffExit| 5.6 :DiffHistory .......... |:DiffHistory| 6. Tab completion ............ |diff-completion| 7. Lua API ................... |diff-api| 8. Health check .............. |diff-health| 9. Architecture .............. |diff-architecture|
1. INTRODUCTION
diff.nvim provides a single flexible:Diffcommand that compares arbitrary sources — the current buffer, a file, a buffer number, or the system clipboard — and delivers the result in several ways: a side-by-side split, an inline unified buffer, the message prompt, a temp file, or the clipboard. Cross-platform (Windows + Unix). All diffing goes throughvim.diff(libvim) — no shell commands.git:<rev>sources usevim.systemto call git directly (still no shell);http(s)://sources fetch asynchronously viacurl, same way — see |diff-url-sources|. Notifications go through lib.nvim, the only dependency.
2. REQUIREMENTS
- Neovim 0.9 or later (0.10+ forgit:<rev>andhttp(s)://sources/targets) - lib.nvim (used for notifications) - Optional: agitexecutable on PATH forgit:<rev>sources/targets - Optional: acurlexecutable on PATH forhttp(s)://sources/targets
3. INSTALLATION
lazy.nvim:
{
"StefanBartl/diff.nvim",
cmd = { "Diff", "DiffClear", "DiffBuffers", "DiffOrig", "DiffHistory", "DiffExit" },
opts = {},
}
packer.nvim:
use {
"StefanBartl/diff.nvim",
cmd = { "Diff", "DiffClear", "DiffBuffers", "DiffOrig", "DiffHistory", "DiffExit" },
config = function()
require("diff").setup({})
end,
}
vim-plug:
Plug 'StefanBartl/diff.nvim'
Then, in an init.lua sourced later:
require("diff").setup({})
4. CONFIGURATION
require("diff").setup({
features = {
diff = true, -- register :Diff / :DiffClear / :DiffBuffers
diff_origin = true, -- register :DiffOrig
diff_history = true, -- register :DiffHistory
diff_exit = true, -- register :DiffExit + exit keymap
},
diff = {
default_view = "vsplit", -- "vsplit"|"split"|"tab"|"inline"|"float"
default_output = "buffer", -- "buffer"|"prompt"|"file"|"clipboard"|"stat"
default_source = "current", -- "current"|"clipboard"|"ask"|"git:<rev>"|"http(s)://…"|path|bufnr
default_orig_view = "vsplit", -- "vsplit"|"split" — :DiffOrig split direction
algorithm = "histogram", -- vim.diff algorithm
ctxlen = 3, -- context lines per hunk
word_diff = true, -- word/char DiffText highlighting in view=inline/float
url_timeout_ms = 10000, -- fetch timeout for http(s):// sources/targets
image_compare = true, -- show two raster-image paths via images.nvim
},
exit = {
key = "<Esc><Esc>", -- exit mapping
scope = "buffer", -- "buffer"|"global"|false
native_diffthis = false, -- also mirror the key onto native :diffthis buffers
},
commands = {
diff = "Diff",
diff_clear = "DiffClear",
diff_buffers = "DiffBuffers",
diff_orig = "DiffOrig",
diff_history = "DiffHistory",
diff_exit = "DiffExit",
},
select_fn = nil, -- optional vim.ui.select replacement
use_pickers_nvim = true, -- auto-detect pickers.nvim as the picker engine
})
features.diff
Register the |:Diff|, |:DiffClear|, and |:DiffBuffers| commands.
Default: true.
features.diff_origin
Register the |:DiffOrig| command. Default: true.
features.diff_history
Register the |:DiffHistory| command. Default: true.
features.diff_exit
Register |:DiffExit| and the exit keymap. Default: true.
diff.default_view
Layout used whenview=is omitted. Default:"vsplit".
diff.default_output
Delivery used whenoutput=is omitted. Default:"buffer".
diff.default_source
Left-hand source used whensource=is omitted. Default:"current".
diff.default_orig_view
Split direction used by |:DiffOrig| ("vsplit"for side-by-side,"split"for stacked). Kept separate fromdefault_viewbecause |:DiffOrig| always opens a native diffmode split — it never supports"inline". Default:"vsplit".
diff.algorithm
Algorithm passed tovim.diff:"myers","minimal","patience", or"histogram". Default:"histogram".
diff.ctxlen
Number of context lines around each hunk in unified output. Default: 3.
diff.word_diff
Highlights the exact changed byte span within each paired removed/added line inview=inline/view=float, using the sameDiffTextgroup Neovim's native diffmode uses for intra-line changes. Only applies to runs where the removed and added line counts match (an unambiguous 1:1 pairing). Default:true.
diff.url_timeout_ms
Timeout in milliseconds forhttp(s)://sources/targets before the fetch is cancelled and reported as an error. See |diff-url-sources|. Default:10000.
diff.image_compare
When bothsource=andtarget=are readable raster-image file paths (png/jpg/jpeg/gif/webp/bmp — not svg, which is text and diffs fine as text), show them side by side via images.nvim (https://github.com/StefanBartl/images.nvim,images.gallery) instead of text-diffing raw bytes, which produces meaningless output. Everyview=/output=value is ignored in this case. Without images.nvim installed, a clear warning is shown instead of silently falling through to the meaningless text diff. Set tofalseto restore the old behavior unconditionally. No relative scaling between the two images, unlike images.nvim's own:Image compare— seelua/diff/features/image_compare.lua's moduledoc for why. Default:true.
select_fn
Optional replacement forvim.ui.select, injected for the target/source picker. Useful to wire a custom UI. Default:nil. When unset, see |diff-picker-resolution| for what actually gets used.
use_pickers_nvim
Auto-detect pickers.nvim (https://github.com/StefanBartl/pickers.nvim) as the picker engine whenselect_fnis unset. Setfalseto always use the ui.kit chooser instead (which still honors a realvim.ui.selectoverride), even if pickers.nvim is installed. Default:true.setup()validates opts before merging them over the defaults above: an unknown key or a value that doesn't fit its option is dropped instead of silently reaching the merge (the default applies to that field, the rest of opts still merges normally), and every dropped entry is listed under|:checkhealth|diff.setup()itself never aborts or errors over a validation issue.
4.1 Picker resolution
The target/source picker (shown whentarget=/source=is omitted or set toask) resolves in this order: 1.select_fn, if set — an explicit override always wins. 2. pickers.nvim, if installed anduse_pickers_nvimisn'tfalse— its fuzzy engine (telescope.nvim, fzf-lua, or snacks.nvim, whichever pickers.nvim already resolved) is used automatically. No configuration needed on diff.nvim's side. 3.ui.kit's own chooser — the always-available fallback. It still defers to a realvim.ui.selectoverride (telescope-ui-select, dressing.nvim, …) when one is installed. Detection is soft: if pickers.nvim isn't installed, or has no picker engine available, diff.nvim silently falls back to the kit chooser — nothing errors. Note that pickers.nvim's engines have no reliable cross-engine cancel signal, so cancelling that picker (<Esc>) does not show the usual "Diff cancelled" message the way cancelling the kit /vim.ui.selectpicker does.
4.2 Exit scope
The original global <Esc><Esc> mapping noticeably delayed a plain <Esc>
because Neovim had to wait for a possible second key everywhere. diff.nvim
fixes this:
exit.scope = "buffer" (default)
The exit key is bound buffer-locally, only on buffers diff.nvim itself puts into diffmode. No global delay. Press it inside the diff (scratch) window to leave; |:DiffExit| works from anywhere.
exit.scope = "global"
Legacy behaviour: a global normal-mode mapping.
exit.scope = false
No mapping at all; rely on |:DiffExit|.
Native :diffthis
By default the buffer-local exit key is only attached to buffers diff.nvim itself puts into diffmode — a plain:diffthison some other buffer (outside diff.nvim's workflow) won't have it. Setexit.native_diffthis = true(requiresscope = "buffer") to mirror the key onto any buffer that enters or leaves diffmode, native:diffthis/:diffoff!included, via anOptionSetwatcher on the window-local'diff'option. Off by default: it changes buffer-local keymaps outside diff.nvim's own workflow, which could surprise a config that already binds its own key on native:diffthisbuffers, or uses:diffthisfor something unrelated to diff.nvim entirely.
5. COMMANDS
5.1 :Diff
:[range]Diff [target=…] [source=…] [base=…] [view=…] [output=…]
Compare a source (left) with a target (right). Arguments use akey=valuegrammar in any order. A key outside target=/source=/base=/view=/output= has no effect and is warned about (typically a typo, e.g. veiw=inline), rather than being silently indistinguishable from not typing it at all. Adding base= turns this into a three-way diff — see |diff-three-way|. When invoked with a range (e.g. a visual selection,:'<,'>Diff) andsource=current(the default), only the selected lines are used as the source instead of the whole buffer. The range applies to the source side only; the target is always taken in full.
target=
The "other" material to compare against.
clipboard Content from the system clipboard register (+)
ask Force the interactive picker (same as omitting target=)
git:{rev} The current file at a git revision (see below)
http(s)://… Content fetched from a URL, async (see |diff-url-sources|)
{path} A file (tab-completed)
{number} An already-open buffer number
When omitted, an interactive picker is shown (see |diff-picker-resolution|).
source=
The left-hand side. Default: current.
current The buffer active when :Diff was invoked
clipboard System clipboard
ask Force the interactive picker (offers "current buffer" too)
git:{rev} The current file at a git revision (see below)
http(s)://… Content fetched from a URL, async (see |diff-url-sources|)
{path} A file
{number} A buffer number
git:{rev}
Resolves the *current file* at a git revision — e.g.git:HEAD,git:HEAD~1,git:<sha>, orgit:<branch>. Requires Neovim 0.10+ (vim.system), agitexecutable on PATH, and a file-backed buffer inside a git repository. Runsgit show <rev>:<relpath>off the main loop (async); no shell is spawned.
http(s)://{url}
Fetches the URL's content asynchronously viacurland diffs against it. Requires Neovim 0.10+ (vim.system) and acurlexecutable on PATH. See |diff-url-sources| for the timeout setting and examples.
Image files
When both source= and target= are readable raster-image file paths
(png/jpg/jpeg/gif/webp/bmp; svg is excluded — it's text and diffs fine as
text), :Diff shows them side by side via images.nvim instead of
text-diffing raw bytes — every view=/output= value is ignored in this
case. Without images.nvim installed, a clear warning is shown instead of
silently falling through to a meaningless text diff. See |diff-config|
(diff.image_compare) to disable this.
base=
Optional — turns :Diff into a three-way diff. Accepts the same grammar as
target= (clipboard, ask, git:{rev}, http(s)://{url}, a file path, or a
buffer number). Requires output=buffer and view=vsplit/split/tab — see
|diff-three-way|.
view=
Layout foroutput=buffer. Default:vsplit. vsplit Vertical split + native diffmode (side-by-side) split Horizontal split + native diffmode tab Side-by-side native diffmode in a new tab inline Single scratch buffer holding the unified diff (ft=diff), word-level DiffText highlighting on changed spans float Same as inline, in a floating window (press q or <Esc> to close) For vsplit/split/tab the left-hand pane is the origin window's own live buffer — and stays editable, so :diffget/:diffput write into the file you will save — but only when the source is that buffer in full, i.e.source=current(the default) without a range. Any other source= and any range are materialized into their own read-only scratch buffer and get a window of their own; the origin window keeps its buffer and stays out of the diff. Side labels: the unified-diff header (--- <source>/+++ <target>) and the scratch-buffer names ([Diff:source] …,[Diff:target] …,[Diff:base] …) use the specifier as written — which reads well for a file path, clipboard, git:{rev} or a URL. A buffer number is the exception (--- 7says nothing), so it is labelled by that buffer's own name, shortened relative to the cwd/$HOME; an unnamed buffer falls back tobuf:{N}.source=currentis labelledbuf:{N}, plus@{line1}-{line2}when a range narrowed it. A label is always folded to a single line: a buffer name may legally contain a newline, and a diff header is two lines. Line endings: a side that arrives as raw text (clipboard, http(s)://, git:{rev}) is normalized to the shape a buffer or a file already has — a trailing CR is dropped from every line, and a trailing newline terminates the last line rather than starting an empty one. Without that, a clipboard filled by a Windows application, a URL serving a CRLF document, or a repository with core.autocrlf=true would make two identical sides differ in every line. Line-ending differences are therefore not reported by :Diff; Neovim keeps that in'fileformat', and a buffer side could never have shown it either.
output=
Where the result goes. Default:buffer. buffer Interactive diff in a split (see view=) prompt Unified diff echoed to the message area file Unified diff written to a temp file clipboard Unified diff copied to the clipboard register (+) stat Report+N -M, K hunksas a notification only (no window) URL sources ~ *diff-url-sources*target=http(s)://…/source=http(s)://…fetch content asynchronously viacurl(a direct argv exec throughvim.system, never a shell string) — the editor stays responsive while the fetch is in flight, bounded bydiff.url_timeout_ms(default 10000ms). Non-2xx HTTP responses are reported as errors, not diffed as content. Requires Neovim 0.10+ and acurlexecutable on PATH (both checked by |diff-health|). See docs/url-sources.md in the repository for requirements, configuration, and a set of real-world usage examples (dotfiles drift, vendored-code drift, gists, API schema checks, verifying a script before running it, …). Three-way diff ~ *diff-three-way*base=opens a native *three-window* diffmode instead of two — the layout merge-conflict tools use. The current buffer stays the local side, live and editable, in the origin window (:diffget/:diffput write straight into the file you'll save).base=(the common ancestor) andtarget=(the remote/incoming version) each get a read-only scratch buffer. Neovim's diffmode natively diffs 3+ windows against each other — nothing custom is computed. Requiresoutput=buffer(the default),view=vsplit/split/tabandsource=current(the default) —prompt/file/clipboard/statandinline/floatare all two-input concepts with no three-way equivalent, and local is always the origin window's live buffer, so there is no window to put an explicitsource=in. All three are rejected with an error if combined withbase=, rather than accepted and ignored. A configureddefault_sourceis not affected; only asource=you typed is checked. See docs/three-way-diff.md in the repository for the full picture, layout diagrams, and merge-conflict-resolution examples. Examples:
:Diff
:Diff target=clipboard
:Diff target=42
:Diff target=src/old.lua
:Diff target=clipboard output=prompt
:Diff target=clipboard view=inline
:Diff target=a.lua source=b.lua
:Diff target=clipboard output=clipboard
:Diff target=src/old.lua output=stat
:'<,'>Diff target=clipboard
:Diff target=clipboard view=float
:Diff target=git:HEAD
:Diff target=git:HEAD~1 output=stat
:Diff target=https://raw.githubusercontent.com/user/repo/main/f.lua
:Diff target=git:MERGE_HEAD base=git:HEAD
:Diff target=new.png source=old.png
5.2 :DiffClear
Close every scratch buffer diff.nvim created and disable diffmode in all windows.
5.3 :DiffBuffers
:DiffBuffers [view=…] [output=…]
Diff the current buffer against another open buffer, chosen from a picker of all other listed, loaded buffers (uses the same picker as |:Diff|, see |diff-picker-resolution|). A convenience wrapper over:Diff target={number}; the source is always the current buffer, so onlyview=andoutput=apply.
5.4 :DiffOrig
Diff the current buffer against its last-saved version on disk — "what changed since the last save". The snapshot buffer is tracked and cleaned up by |:DiffClear|.
5.5 :DiffExit
Leave diff mode from anywhere (diffoff!). Works regardless of the configured
|diff-exit-scope|.
5.6 :DiffHistory
:DiffHistory [path] [view=…] [output=…]
List the commits that touched a file (git log --follow, so a rename is tracked back through it), newest first, in a picker (uses the same picker as |:Diff|, see |diff-picker-resolution|). Picking one diffs that commit against its parent;view=/output=apply exactly as they do for |:Diff|.pathdefaults to the current buffer's file. Across a rename, the diff still compares the right two paths — the file's name at the picked commit against its name at the parent — via thegit:{rev}:{path}form of a git source/target (an explicit path, relative to the repo root, instead of the current buffer's own path). Capped atopts.diff.history_max_entriescommits (default 200). The very first commit of a file's history has no parent to diff against and reports an error rather than diffing against an empty tree. Requires the samegit/vim.systemavailability asgit:{rev}sources.
6. TAB COMPLETION
:Diffcompletes thekey=valuegrammar context-sensitively, built via lib.nvim.bindings.usercmd.composer (Route.kv). The value lists below are completion hints, not a closed set — any string is still accepted as target=/source=/ base= (e.g. a literal file path); only view=/output= are drawn from a fixed set of modes:
:Diff <Tab> target= source= base= view= output=
:Diff view=<Tab> view=vsplit view=split view=tab view=inline view=float
:Diff output=<Tab> output=buffer output=prompt output=file output=clipboard output=stat
:Diff source=<Tab> source=current source=clipboard source=ask source=git:HEAD
:Diff target=<Tab> target=clipboard target=ask target=git:HEAD
:Diff base=<Tab> base=clipboard base=ask base=git:HEAD
:DiffHistory <Tab> a file path, then view= output=
7. LUA API
local diff = require("diff")
setup({opts}) *diff.setup()*
Configure and activate. Idempotent.
enable({opts}) *diff.enable()*
Alias for setup() — matches the legacy custom.diff signature.
run({raw_args}, {opts}) *diff.run()*
Run a diff. {raw_args} uses the same grammar as |:Diff|. {opts} is an
optional table of caller-side options — see |diff-on-done|.
clear() *diff.clear()*
Close all diff windows and disable diffmode.
diff_buffers({raw_args}, {opts}) *diff.diff_buffers()*
Diff the current buffer against another open buffer chosen from a picker.
{raw_args} accepts the same view=/output= grammar as |:Diff|; {opts}
works as it does for |diff.run()|.
diff_origin() *diff.diff_origin()*
Diff the current buffer against its on-disk saved version.
exit() *diff.exit()*
Leave diff mode from anywhere.
status({opts}) *diff.status()*
Statusline component. Returns a short string (default diff:N, where N is
the number of active diff.nvim scratch buffers) while a diff is active, or
an empty string when none is. {opts.prefix} overrides the diff: prefix.
Example:
vim.o.statusline = "%f %{v:lua.require'diff'.status()}"
KNOWING WHEN A DIFF HAS FINISHED *diff-on-done*
opts.on_done is called exactly once when the diff has finished:
require("diff").run("source=7 target=8 view=vsplit", {
on_done = function(result, err)
if not result then return end
-- Closing the windows is enough; the scratch buffers are
-- bufhidden=wipe and go with them.
for _, win in ipairs(result.windows) do
pcall(vim.api.nvim_win_close, win, true)
end
end,
})
It fires on the asynchronous paths too (http(s)://fetches,git:<rev>, the interactive picker), which is why this is a callback and not a return value from run(): a return value could only be filled in for the synchronous specifiers and would be silently empty for the rest. Errors are notified as before; on_done is in addition to that, not instead of it. An on_done that raises is caught, so a caller's error cannot surface inside a URL fetch. {result} is nil (and {err} says why) when nothing was produced: an unresolvable side, a rejected option combination, or a cancelled picker. Otherwise it carries: output The output= that produced this run view The layout that was applied; nil when none was (the text outputs, and image/directory comparisons, which ignore view=) buffers Scratch buffers diff.nvim created windows Windows diff.nvim opened path The file written, for output=file (nil otherwise)windowslists only windows diff.nvim opened. The window |:Diff| was invoked from is never included, even when it is part of the diff — withsource=currentand a side-by-side view that window keeps the user's live, editable buffer as the left-hand side. It belongs to the caller, and closing everything inwindowsmust not close the window they were working in. Both lists can be empty on success: output=prompt/clipboard/stat create nothing, and two identical sides produce a result with nothing in it rather than an error. Check#result.windows, notresult, to decide whether anything is on screen. See docs/api.md for the per-mode table. "Nothing to show" and "it could not be produced" stay apart, because they need opposite handling: a diff that could not be computed, a file that could not be written or a layout that could not be opened is nil plus a reason, exactly like an unresolvable side, never an empty result. A window inwindowsmay be showing a buffer that is not yours to touch.view=tabopens its own tab and puts the left-hand side in it, so withsource=currentone of the two windows shows the user's own live buffer. Closing that window is correct and is whatwindowsis for; deleting the buffer behind it is not, and with unsaved changes it destroys the user's work. Close windows; never nvim_buf_delete() whatever nvim_win_get_buf() hands back.buffersis safe by construction — it only ever lists scratch buffers diff.nvim created, never the user's. It is there to be read, not deleted: those buffers arebufhidden=wipeand go away with their windows, and |:DiffClear| takes down everything diff.nvim tracks.
8. HEALTH CHECK
:checkhealth diff
Checks: - Neovim >= 0.9 -vim.diffavailable -vim.ui.selectavailable - clipboard provider present - git +vim.systemavailable (forgit:<rev>sources/targets) - curl +vim.systemavailable (forhttp(s)://sources/targets) - pickers.nvim detected (informational — falls back to ui.kit) - plugin loaded (guard flag set)
9. ARCHITECTURE
plugin/diff.lua Load guard
lua/diff/
init.lua Public API, setup()/enable()
@types.lua LuaLS type definitions
config/
DEFAULTS.lua Immutable default configuration
init.lua Merge + access to active config
util/
notify.lua "[diff] " prefixed vim.notify wrapper
validate.lua Pure validation helpers
core/
init.lua Orchestration: run(), run_buffers(), execute(), picker
resolve.lua Specifier → lines, argument parsing
git.lua git:<rev> resolution via vim.system git-show
url.lua http(s):// async fetch via curl + timeout timer
pickers_bridge.lua Optional select_fn adapter for pickers.nvim
scratch.lua Scratch-buffer lifecycle + cleanup_all() + active_count()
render.lua Output renderers (buffer/prompt/file/clipboard/stat/three_way)
features/
origin.lua :DiffOrig logic
exit.lua :DiffExit logic + exit-behaviour config
native_diffthis.lua Opt-in exit-key mirroring onto native :diffthis buffers
bindings/
usrcmds.lua :Diff/:DiffClear/:DiffBuffers/:DiffOrig/:DiffExit (lib.nvim.bindings.usercmd.composer) + completion
keymaps.lua Exit-keymap wiring (global + buffer-local)
autocmds.lua VimLeavePre cleanup
init.lua Orchestrates the three above
health.lua checkhealth provider
Load order: util -> config -> core -> features -> bindings -> init
Every keymap, user command, and autocmd is also cataloged in
docs/BINDINGS.md.