quickfix.pro

a neovim plugin · enhances the native window · mit

A finer edge on the quickfix window.

The list stays the list. quickfix-pro works on the window: an aligned display line, a palette resolved against the colorscheme you already chose, and an editing grammar that behaves like the rest of Vim. Everything that fills a quickfix list still fills it. Take the plugin off and you are back to stock, on the identical lists.

vim.pack vim.pack.add({ "https://github.com/vim-pro/quickfix-pro.nvim" })
lazy.nvim { "vim-pro/quickfix-pro.nvim", opts = {} }

Zero config — the plugin self-initializes once it is on 'runtimepath'. require("quickfix-pro").setup{} is optional and only tweaks defaults. Neovim 0.10+ (0.12+ for the vim.pack line).

01

The display line, squared up

One global 'quickfixtextfunc' lays out every list that doesn't bring its own: path:lnum padded to the widest location in the batch, so the entry text starts in the same column on every row — plus a one-letter type-flag column when the list carries types.

[Quickfix List] :make — 4 items
src/parser.rs:118 E expected `;`, found `}`
src/parser.rs:204 W unused variable: `tok`
202 let mut it = src.chars().peekable();
203 while let Some(c) = it.peek() {
204 let tok = self.scan(&mut it);
205 if c.is_whitespace() {
206 it.next();
src/lexer.rs:57 W variable does not need to be mutable
src/main.rs:12 N consider importing this trait

An illustration, not a screenshot. Row 1 is the current entry — it keeps its selected band and its segment colors. Row 2 is expanded with <Tab>, showing two lines of file context either side.

QfProFilethe path — navigation
QfProSeparatorthe : — punctuation, recedes
QfProLineNrthe line number — metadata
QfProTextthe text — what you actually scan

The formatter is polite about territory. It installs itself only when 'quickfixtextfunc' is empty, so a formatter you already set is never clobbered; another plugin's per-list formatter still wins for its own lists. Rows that aren't real locations — headings, invalid matches — render as bare text and recede via QfProInvalid.

Nothing about this is a new window. :cnext, :cdo, :colder, :cc and the rest operate on exactly the list they always did. Location lists get the same treatment as quickfix lists.

02

Color that fits the scheme you already chose

Stock qf syntax only parses Vim's file|lnum col|text shape. Give it any other layout and it paints the whole row one color. quickfix-pro highlights each segment itself, as extmarks — and picks the colors by measurement rather than by hopeful linking.

Why fixed links don't work

Colorschemes disagree wildly about which groups carry a color at all. Neovim's stock scheme defines Number, Constant, Delimiter, Type and Statement as exactly Normal's foreground. Link a line number to Number there and it comes out the same white as the entry text — a row that is technically four-color and visibly one.

So each adaptive group names an ordered list of candidates, semantically best first, and resolution walks it until something is genuinely its own color:

QfProFileDirectory → Identifier → Comment
QfProLineNrNumber → DiagnosticWarn → String → Comment
QfProTextString → Identifier → Operator → Normal
QfProSeparatorComment → LineNr   (dim by design — exempt from the hue test)

The two tests

  • Not plain text. A candidate whose foreground is within 16 levels per channel of Normal's is rejected. Equality alone would let an off-white through.
  • Not the neighbor's hue. A candidate within 30° of hue of a segment already claimed is rejected. Cream text beside an amber line number is about one degree apart and reads as a single color. Greys are exempt — they have no hue to clash with.

Segments resolve in order — file, line number, text, then the separator — and each one claims its color against the ones before it. Resolution reruns on ColorScheme, and :checkhealth quickfix-pro prints what each group actually landed on.

Worked example. Under Neovim's stock scheme, QfProLineNr tries Number, measures it as Normal's foreground, rejects it, and takes DiagnosticWarn instead — a real amber. Under a scheme where Number is distinct, it keeps Number.

The current entry

QuickFixLine is a window-level line highlight and outranks buffer extmarks at any priority, so it would flatten the selected row back to one color. quickfix-pro remaps it per-window to the background-only QfProCurrent: the selected band stays, the segment colors show through. A QuickFixLine entry already in your own 'winhighlight' is left alone, and highlight = false disables the whole mechanism.

Overriding

Every group is a default link, so a colorscheme or your config wins outright. Setting a group yourself pins it — adaptivity only applies to groups you haven't touched.

lua
vim.api.nvim_set_hl(0, "QfProLineNr", { fg = "#7aa2f7" })
GroupPaintsDefault
QfProFilethe file pathadaptive — Directory first
QfProSeparatorthe : between path and line numberadaptive — Comment first
QfProLineNrthe line numberadaptive — Number first
QfProTextthe entry textadaptive — String first
QfProTypeEtype flag EDiagnosticError
QfProTypeWtype flag WDiagnosticWarn
QfProTypeItype flag IDiagnosticInfo
QfProTypeNtype flag NDiagnosticHint
QfProTypeHtype flag HDiagnosticHint
QfProCurrentthe current entry's bandCursorLine
QfProInvalidrows that aren't real locationsComment
QfProExpandLnumline numbers inside an expansionLineNr
QfProExpandContextcontext lines inside an expansionComment
QfProExpandFocusthe entry's own line(s) in an expansionNormal

QfProCurrent wants to be background-only. If your scheme gives CursorLine a foreground, override it with a group that doesn't.

03

An editing grammar, in a window you can't edit

The quickfix buffer isn't modifiable, so its most obvious keys were sitting idle. quickfix-pro gives them the meanings you'd guess — with counts, with visual mode, and with an undo that actually restores.

KeyDoes
ddDelete the entry under the cursor from the list, in place.
3ddCounts work. Three entries, starting at the cursor row.
d (visual)Delete the selected rows as one batch.
uRestore the last deleted batch at its original position — repeatable back through the delete history.
<Tab> / zaToggle the entry under the cursor open.
zRExpand every entry.
zMCollapse every entry.

Deleting keeps everything else

The list is modified in place: same list id, same title, same context, and every surviving entry's data — user_data included — written back verbatim. Location lists behave identically.

Pruning is not a one-way door

u puts the batch back where it was, user_data intact, and keeps going back through the history — up to 50 batches per list.

Not the same as :Cfilter

dd edits the current list; :Cfilter pushes a new one onto the stack and you use :colder to get back. Both are available — the bundled cfilter plugin is :packadded for you.

Clients can object

A registered client is consulted before any delete and may veto it — useful when an entry owns something in flight. See the client API.

Every one of these maps is buffer-local to the quickfix window, so nothing you press anywhere else changes meaning. Set any of them to false to skip it.

04

Inline expansion

<Tab> on a row shows the entry's line plus context_lines either side, as virtual lines under the row. No client needed — it works on any grep or make list.

  • Keyed to identity, not to a row number. Expansion state carries a {bufnr, text} fingerprint, so it survives deleting rows above it — and collapses cleanly, rather than decorating the wrong row, when the list is rebuilt into something else.
  • Never cached. The content is re-read on every repaint, so what you see is the file as it is now, not as it was when the list was built.
  • Overridable per entry. A registered client can supply its own expansion content for the entries it owns.
05

:QfAdd — for when you are the search

Vim builds lists from greps and compilers. This covers the other case: you are reading code, you find something, and you want it on the list.

vim
:QfAdd                     " the current line
:QfAdd needs a docstring   " ...with your own note as the entry text
:5,9QfAdd                  " lines 5-9 as ONE entry spanning the region
:'<,'>QfAdd!               " one entry per line in the selection

A ranged :QfAdd records end_lnum and end_col, so the entry is a region, not a point — a consumer that reads it can act on the whole span. Without a range, or with the bang, entries are single lines. The entry text defaults to a trimmed summary of the first line, annotated with how many more the region covers.

It appends to the current quickfix list, keeping its id and every existing entry, and creates one if there is none. Exact duplicates — same buffer, same lnum, same end_lnum — are refused.

No global keymap is claimed by default. Grabbing a key outside the quickfix window is your call, not the plugin's — set keymaps.add to opt in, and you get both a normal-mode and a visual-mode binding.
06

The client API

Other plugins can decorate and expand the entries they own. The result is rendered as extmarks in the native window — quickfix-pro never writes the list to do it, so changedtick stays put and anything else watching the list is undisturbed.

lua
local qfpro = require("quickfix-pro")

local dispose = qfpro.register("myplugin", {
  -- Per-entry status: sign icon, highlight, whole-line hl, end-of-line text.
  status = function(entry)
    if not (entry.user_data and entry.user_data.myplugin) then return nil end
    return { icon = "●", hl = "DiagnosticOk", eol = "done" }
  end,
  -- Inline expansion content (overrides the builtin file-context expander).
  expand = function(entry)
    if not (entry.user_data and entry.user_data.myplugin) then return nil end
    return { "custom", "virtual", "lines" }
  end,
  -- Consulted before a delete; return false to veto.
  on_delete = function(entries) return true end,
})

qfpro.refresh()                 -- client state changed: repaint, debounced
qfpro.refresh({ id = list_id }) -- ...limited to windows showing that list
qfpro.unregister("myplugin")  -- or call dispose()
[Quickfix List] decorated by a client
● src/api/users.ts:44 add input validation applied
◐ src/api/orders.ts:91 add input validation running
src/api/billing.ts:7 add input validation queued

A client supplies the sign, its highlight, and the end-of-line text. The rows underneath are ordinary quickfix entries.

The contract

  • entry is the raw getqflist() item plus idx (1-based position) and qf_id (the stable list id). Entries are shared tables — read them, don't mutate them.
  • status returns { icon, hl, line_hl, eol }, all optional: a 1–2 cell sign with its highlight, a whole-line highlight, and end-of-line virtual text (a string, or extmark chunks).
  • expand returns a list of virtual lines — each a string, or a list of {text, hl} chunks.
  • status and expand resolve first-non-nil in registration order; return nil for entries you don't own. on_delete consults all clients and any explicit false vetoes.
  • Every callback is error-isolated. A throwing client degrades to stock rendering and warns once per callback, per session — it never breaks the window. Re-registering a name replaces it, so reloading a plugin is safe.
  • refresh() repaints decorations only. No 'quickfixtextfunc' re-run, no changedtick bump, nothing for other list-watching tools to react to. It's debounced — cheap to call from async callbacks.

The first client is conjurer.nvim, which drives multi-site edits through a quickfix list: each entry shows its per-site status live, expands to a before/after diff, and cancels its in-flight edit when you dd it.

07

Configuration

Optional, in full. Nothing here needs setting for the plugin to work.

lua — defaults shown
require("quickfix-pro").setup({
  -- "auto": install the improved display line only if 'quickfixtextfunc'
  -- is empty. true forces it, false leaves stock, a function installs yours.
  format = "auto",

  -- Colorize file / : / lnum / type / text separately.
  highlight = true,

  -- Lines of file context shown above/below an expanded entry.
  context_lines = 2,

  -- Repaints are coalesced within this window (ms).
  debounce_ms = 30,

  -- Lists larger than this are left undecorated (a performance fuse).
  max_entries = 2000,

  -- Buffer-local to the quickfix window; false skips a map.
  keymaps = {
    delete = "dd",        -- takes a count
    delete_visual = "d",
    undo = "u",           -- restore the last deleted batch
    toggle = "<Tab>",
    toggle_alias = "za",
    expand_all = "zR",
    collapse_all = "zM",
    add = false,          -- a GLOBAL key for :QfAdd; opt in
  },
})
One thing worth knowing. format and keymaps.add are read once, when the plugin is sourced at startup — they decide whether to write 'quickfixtextfunc' and whether to claim a global key. The rest (highlight, context_lines, debounce_ms, max_entries, the buffer-local maps) are read live, on every repaint or window attach.

To skip the plugin entirely, set vim.g.quickfix_pro_disable = 1 before startup.

08

Fit and tolerances

What it costs, where it stops, and how to check it's seated properly.

One trigger

FileType qf is the only hook. It fires when a quickfix or location window is filled — including on every list mutation while it's open — so open, refill and update are all the same code path. Nothing heavy loads until a quickfix window actually appears.

One repaint

Every trigger funnels into a single debounced flush, so a :cdo or a burst of client updates costs one repaint rather than one per change.

A fuse

Lists longer than max_entries are left as stock quickfix. They still work — they just aren't decorated.

Graceful all the way down

Set highlight = false and the colors go. Set format = false and the display line goes. Set vim.g.quickfix_pro_disable and the plugin goes. Each step lands you on plain quickfix, on the same lists.

:checkhealth quickfix-pro reports the Neovim version, whether the bootstrap loaded, which formatter is active, what each adaptive highlight group resolved to, whether :Cfilter is available, and how many clients are registered.

The full manual is :h quickfix-pro.