doc/spotlight.txt — rendered from the plugin's own vimdoc
*spotlight.txt* Persistent multi-token highlighting for Neovim logs *spotlight.nvim*
CONTENTS
1. Introduction ............................. |spotlight-introduction| 2. Why matchadd() ........................... |spotlight-matchadd| 3. Setup .................................... |spotlight-setup| 4. Keymaps .................................. |spotlight-keymaps| 5. Commands ................................. |spotlight-commands| 6. Configuration ............................ |spotlight-config| 7. Token resolution ......................... |spotlight-cursor| 8. Navigation ............................... |spotlight-nav| 9. The list ................................. |spotlight-list| 10. Occurrence density (sign column) ......... |spotlight-map| 11. Quickfix filter .......................... |spotlight-quickfix| 12. Yank to a register ....................... |spotlight-yank| 13. Persistence .............................. |spotlight-persist| 14. Spotlight sets ........................... |spotlight-sets| 15. Colors ................................... |spotlight-colors| 16. Lua API .................................. |spotlight-api| 17. Health ................................... |spotlight-health| 18. Debugging ................................ |spotlight-debug| 19. Security model ........................... |spotlight-security| 20. lib.nvim integration ..................... |spotlight-lib|
1. INTRODUCTION
spotlight.nvim highlights any number of tokens at once, in colors you can tell apart, permanently: the highlighting survives searches, scrolling, window switches and:split, and it is restored the next time you open the project. The use case it is built for is reading a log. You find a request id, a PID, an IP or an error code, and you want every other occurrence visible immediately — several of them at the same time. What Neovim offers on its own does not cover it: -*and'hlsearch'handle one token, and collide with your real search. -|matchadd()|is the right primitive but is window-local: a:splitshows the same buffer with no highlights. -|:match|has three slots, no list, and no persistence. Requires Neovim 0.9+, "StefanBartl/lib.nvim" and "StefanBartl/ui.nvim". No Treesitter. No|TextChanged|or|CursorMoved|autocommand — see |spotlight-matchadd|.
2. WHY MATCHADD()
The decision the plugin is built around, and the reason it stays usable on a log too big to open in anything else. Extmarks store *positions*. Setting them means scanning the buffer: O(file size) on every add, and again on every text change. On a 200 MB log that is not a slow path, it is an unusable one.|matchadd()|stores the pattern and hands it to Vim's renderer, which evaluates it in C over the visible lines only. Cost is proportional to the window, not to the file, and a text change needs no invalidation at all, because nothing position-shaped was ever stored. The price is that a match is window-local, so the plugin keeps a ledger (window -> { spotlight id -> match id }) and fills new windows from three autocommands (|WinNew|,|BufWinEnter|,|TabNewEntered|). That is the whole trade. *spotlight-winopt* A window can opt out of this entirely with:Spotlight winopt off— "do not spotlight in this window", e.g. a reference file kept open in a split. The flag is window-sticky (vim.w[win].spotlight_disabled): it lives on the window itself, not on whichever buffer happened to be current when it was set, so it survives that window later showing a different buffer for free — the sameBufWinEnterfill pass that already runs on every buffer switch re-checks it. Opting out strips the window's current matches immediately rather than only gating future fills;:Spotlight winopt onre-fills it the same way.:Spotlight winopt toggle(the default with no argument) flips it;:Spotlight winopt statusreports it. Session-only — a window id means nothing across a restart. Two consequences are visible in the interface: - Match counts are computed only when the list is opened (|spotlight-list|), never maintained. Keeping them current would reintroduce the whole-buffer scan that this choice avoids. - The quickfix filter (|spotlight-quickfix|) is an explicit command rather than something continuous, for the same reason.
3. SETUP
*spotlight.setup()*
require("spotlight").setup()
No arguments needed: the defaults are the intended everyday configuration.
setup() merges your options over them, defines the |Spotlight1| ... palette
groups, registers the |:Spotlight| command and the autocommands, and — unless
keymaps.preset is false — binds the preset keys.
With lazy.nvim:
{
"StefanBartl/spotlight.nvim",
dependencies = { "StefanBartl/lib.nvim", "StefanBartl/ui.nvim" },
event = "VeryLazy",
config = function()
require("spotlight").setup()
end,
}
ftis not a useful gate: logs arrive with every filetype, and often with none. setup() is safe to call once. Invalid config values are degraded to their defaults rather than raising, and reported by|:checkhealth|— one bad line should not stop the plugin from loading.
4. KEYMAPS
Bound whenkeymaps.presetis true (the default): <leader>sk n toggle a spotlight on only this occurrence <leader>sk x toggle a spotlight on only this occurrence (selection) <leader>sK n toggle a spotlight on every occurrence of the token (dot-repeatable,.re-resolves the cursor token) <leader>sK x toggle a spotlight on every occurrence (selection) <leader>sL n open the spotlight list <leader>sC n remove every spotlight <leader>sq n matching lines -> quickfix <leader>sW n toggle whole-line rendering for one spotlight ]k n next occurrence (count-repeatable, see |spotlight-nav|) [k n previous occurrence (count-repeatable, see |spotlight-nav|)<leader>sk/<leader>sKare the pair the plugin is organized around: same key, lowercase for the narrower action (this occurrence only, pinned to that exact spot — see |spotlight-here|), shifted for the wider one (every occurrence, everywhere the text appears). None of these is a prefix of another, deliberately: a mapping that is also the prefix of a longer one costs a'timeoutlen'pause on every press.k/Kdiverge at that very character, so<leader>skand<leader>sKsit fine side by side. The normal-mode<leader>sK(and the bare:Spotlight/:Spotlight togglecommand path) is dot-repeatable vialib.nvim.dotrepeat: press it on one token, move elsewhere, and.toggles whatever the cursor is on then — it re-resolves fresh rather than repeating the original toggle, so.on an already-lit token removes it, same as pressing the key on it again would. There is deliberately no count multiplier (3<leader>sKdoes nothing special) — unlike3]k, a count on a toggle has no established meaning to borrow. Each key is its own config value. Setting one tofalsefrees that lhs while keeping the rest of the preset:
require("spotlight").setup({
keymaps = {
toggle_here = "<leader>hh",
toggle = "<leader>hH",
next = false, -- leave ]k alone
},
})
Or bind the actions yourself — they are plain functions (|spotlight-api|):
require("spotlight").setup({ keymaps = { preset = false } })
vim.keymap.set("n", "<leader>hh", require("spotlight").toggle_here)
vim.keymap.set("n", "<leader>hH", require("spotlight").toggle)
which-key is a soft dependency. When installed, the preset's leader prefix is labelled as a "Spotlight" group; per-key descriptions come from each mapping's owndesc, so nothing else is registered. *spotlight-here*toggle_here(<leader>sk) marks only the exact occurrence the cursor or selection is on — not every occurrence of that text, which is whattoggle(<leader>sK) does. Useful when the text is common enough (error,null, a short reused id) that lighting up every instance would be noise. The pattern handed to|matchadd()|is anchored to the exact line and column (\%<line>l\%<col>c, ahead of the literal text) rather than built from the token alone, so a duplicate of the same text elsewhere cannot satisfy it. A position only means anything against the buffer it came from, so rendering is restricted to windows currently showing that buffer — the one place this differs from how a global spotlight works, which is deliberately visible everywhere the text is (see |spotlight-matchadd|). One consequence follows directly: a "this occurrence only" spotlight is session-only. It is excluded from the persisted snapshot (a line/column pin does not mean anything after a restart the way a text pattern does — see |spotlight-persist|), and it is dropped automatically if its buffer is wiped out or a window switches away from that buffer. Whether the rest needs special handling depends on which engine reads the pattern.\%l/\%care evaluated by Vim's own machinery —|matchadd()|,|search()|— so rendering and]k/[kneed nothing: a position-anchored pattern is just a valid Vim regex to them.|lua-regex|(vim.regex) does not evaluate them, and returns no match on every line including the pinned one. Counting and the quickfix filter are built on it, so they resolve such a spotlight from the position recorded on it instead of searching for it. Toggle identity is the position, not the text: pressing<leader>skagain on the same occurrence removes it; a different occurrence of the same word is an independent spotlight, even alongside a<leader>sKspotlight for the same text. *spotlight-line*<leader>sW(or:Spotlight line [text]) switches one spotlight from "color the token" to "color the whole line the token sits on" — for when the unit you are actually reading is the log entry, not the id inside it. Per spotlight, so one can paint its lines while the others stay on their tokens. It acts on a spotlight that already exists: pointing at an unlit token is refused rather than silently creating one. It is a rendering flag, not a different spotlight.Spotlight.Item.patternstays the token pattern; only the string handed to|matchadd()|is widened (\_^\.\*...\.\*\_$around it — the\_anchors, because plain^/$are special only at the very start and end of a pattern). That split is what keeps the rest of the plugin honest: match counts still count occurrences and not lines, the quickfix and yank scans still report the token's own column, and the occurrence map's earliest-column tie-break still works — a line pattern always matches at column 1 and would collapse it. The widened match is registered one priority belowmatch.priority. A whole-line highlight covers every token highlight on its line, and the palette exists precisely so several spotlights stay apart; at equal priority the line color would swallow the token colors of every other spotlight on that line.|:checkhealth|reports the effective priority per line-mode spotlight, since "my other color vanished" is the one confusing symptom this can produce. Two limits follow from |spotlight-matchadd| rather than from oversight: - The highlight ends where the line's text ends. It does not run to the window's right edge — that needsline_hl_group, which is an extmark, which is a position, which is the one thing this plugin does not store. - Two line-mode spotlights on the same line resolve to one winner rather than blending. Works on a "this occurrence only" spotlight (|spotlight-here|) too:\%l/\%care zero-width assertions, so the leading.*walks up to them and the whole line of that occurrence lights up. Such a spotlight has no text identity, so it is reached with:Spotlight list linerather than:Spotlight line {text}. The flag is persisted (|spotlight-persist|) — unlike a position pin, it means the same thing after a restart — and shown as(whole line)in the list.
5. COMMANDS
*:Spotlight* One verb with <Tab>-completed subcommands, built on lib.nvim's user-command composer — completion, argument validation and the generated docs all come from the same route tree. :Spotlight toggle every occurrence of the token :Spotlight toggle [text] toggle every occurrence: the cursor token, a range selection, or the explicit {text} :Spotlight here toggle only this occurrence: the cursor token, or a range selection :Spotlight add {text} add a spotlight for the literal {text} :Spotlight remove {text} remove the spotlight matching {text} :Spotlight clear remove every spotlight :Spotlight list [jump|remove|lock|line] open the list; "remove" deletes on select, "lock" toggles the lock, "line" toggles whole-line rendering :Spotlight next jump to the next occurrence :Spotlight prev jump to the previous occurrence :Spotlight qf [text] matching lines -> quickfix (current buffer) :Spotlight qf all [text] same, across every loaded buffer :Spotlight yank [text] matching lines -> unnamed register :Spotlight map [text] mark matching lines in the sign column :Spotlight map clear clear the sign-column occurrence map :Spotlight lock [text] toggle whether a spotlight keeps its slot permanently ({text}, or the cursor token) :Spotlight line [text] toggle whole-line rendering for a spotlight ({text}, or the cursor token) :Spotlight persist {state} on | off | default | status :Spotlight sets save {name} save the active spotlights as {name} :Spotlight sets switch {name} clear + restore the saved set {name} :Spotlight sets delete {name} delete the saved set {name} :Spotlight sets list list every saved set :Spotlight winopt [state] on | off | toggle (default) | status :Spotlight refresh redefine the palette, re-apply everything:'<,'>Spotlight toggleworks from a visual selection: the selection is read from the '< and '> marks, which become valid exactly when a:command runs. Every keymap action has a command, and vice versa. There is no feature that exists only on a key.
6. CONFIGURATION
Defaults shown. Every key is typed (Spotlight.Config in
lua/spotlight/@types/), so lua_ls completes and checks the table.
require("spotlight").setup({
hover = true, -- register a preview with hover.nvim
palette = {
colors = { { bg = "#ffd75f", fg = "#1c1c1c" }, ... }, -- dark themes
colors_light = { { bg = "#b58900", fg = "#ffffff" }, ... },
bold = true,
reapply_on_colorscheme = true,
},
match = {
priority = 10, -- matchadd() priority; >0 is above 'hlsearch'
ignore_case = false, -- false pins \C into the pattern
word_boundaries = true, -- \<...\> around all-word-character tokens
max = 64,
max_text_len = 512, -- see |spotlight-security|
},
cursor = {
patterns = { ... }, -- Lua patterns, highest priority first
fallback_cword = true,
max_line_len = 8192, -- see |spotlight-security|
},
nav = {
scope = "auto", -- "auto" | "all"
wrap = true,
center = true,
},
list = {
count = true,
count_max_lines = 200000,
count_scope = "buffer", -- "buffer" | "loaded", see |spotlight-list|
swatch = " ",
},
map = {
sign_text = "▪", -- <=2 display cells, see |spotlight-map|
max_entries = 10000,
},
quickfix = {
open = true,
title = "Spotlight",
max_entries = 10000, -- see |spotlight-security|
},
persist = {
enable = true,
default = true,
debounce_ms = 500,
},
keymaps = {
preset = true,
toggle_here = "<leader>sk", -- only this occurrence
toggle = "<leader>sK", -- every occurrence
list = "<leader>sL",
clear = "<leader>sC",
quickfix = "<leader>sq",
line = "<leader>sW", -- whole-line rendering
next = "]k",
prev = "[k",
},
menu = {
enable = true, -- contribute entries to an nvzone/menu host
},
integrations = {
ui_menu = true, -- let ui.nvim's right-click menu show the fly-out
},
notify = true,
debug = false, -- see |spotlight-debug|
})
Arrays are replaced wholesale rather than merged, so you can genuinely shrinkcursor.patternsorpalette.colorsinstead of only extending them.match.ignore_case = falsewrites\Cinto every pattern. This is deliberate: a spotlight then keeps matching the same text after you toggle'ignorecase'or'smartcase', instead of silently changing meaning between sessions. Logs are case-sensitive data.
7. TOKEN RESOLUTION
<cword>is the obvious answer and the wrong one here: it splits on'iskeyword', so 550e8400-e29b-41d4-a716-446655440000 is five words, 192.168.1.1 is four and 0x1f4a is two. The tokens worth tracking in a log are precisely the ones it cannot see. Instead,cursor.patternsis a list of Lua patterns in priority order. Each is searched across the whole cursor line, and the first pattern with a match that *spans the cursor column* wins. The defaults cover, in order: UUID, ISO 8601 timestamp, bare clock time, IPv4 with port, IPv4, hex literal, long hex blob (git sha / trace id), user@host, dotted identifier, number, generic token including dashes.<cword>is the last resort. Order matters and is yours to control: a broad pattern placed early shadows every specific one after it. Patterns Lua rejects are dropped at setup() and reported by|:checkhealth|rather than throwing later, from inside an unrelated keystroke. *spotlight-boundaries* Whether the generated regex gets word boundaries follows the token's own *shape*, not which pattern produced it: - A token made only of word characters gets\<and\>— soerrordoes not light up insideerrors. - Anything else cannot have them.\<192.168.1.1\>matches nothing, because\<asserts a word start and1after.is not one. Deriving this from the shape rather than from the matching pattern is what keepsmatch.word_boundariesmeaningful — tying it to pattern ordering would make the broad generic-token entry shadow<cword>and leave the boundary case unreachable. A visual selection and:Spotlight addare always literal: selectingerrout oferroris an explicit request to match that substring, and adding boundaries would produce a spotlight that highlights nothing.
8. NAVIGATION
]kand[kjump one occurrence, using|search()|— so a jump costs the distance travelled, not the size of the file. The search register and'hlsearch'are left untouched; spotlights are a parallel marking system. Withnav.scope = "auto"(the default), a cursor sitting inside one spotlight's match navigates that spotlight only: pressing]kon a request id means "follow this id", not "land on the next unrelated PID two lines down". Off any match, every spotlight is in scope, searched as one alternation.nav.scope = "all"always uses every spotlight. A count prefix repeats the jump that many times, unimpaired-style:3]kis three one-step jumps, not "search three times faster". A step that finds nothing (wrapping with no match, or reaching the end withnav.wrap = false) stops the loop early rather than erroring — a partial jump still counts.nav.wrap = falsestops at the end of the buffer instead of wrapping.nav.center = falseskips the|zz|after a jump.
9. THE LIST
<leader>sLor:Spotlight listopens a chooser with one row per spotlight: <color swatch> <token> <match count> Selecting a row jumps to that spotlight's first occurrence in the current buffer.:Spotlight list removeopens the same list with selection bound to removal instead,:Spotlight list lockwith it bound to the slot lock, and:Spotlight list linewith it bound to whole-line rendering (|spotlight-line|). A "this occurrence only" spotlight (|spotlight-here|) is tagged(this occurrence only)in its row, since it is otherwise indistinguishable from a global one with the same text; a locked slot is tagged(locked)and a line-mode spotlight(whole line), for the same reason. The swatch is painted in the spotlight's own |Spotlight1| ... group, via ui.kit.select's rich items, so there is no rendering code in this plugin. Match counts are computed when the list opens, and only then — the one O(buffer) operation here (see |spotlight-matchadd|). Abovelist.count_max_lines(default 200000) the scan is skipped and the count shows?; that is meaningfully different from0and the title says so. Setlist.count = falseto skip counting entirely.list.count_scope = "loaded"(default"buffer") sums the count across every loaded, ordinary file buffer instead of just the one the list was opened from — "how many total" rather than "how many here". A buffer that alone exceedslist.count_max_linesis skipped from the sum rather than making the whole count unknown; the row then showsN+(a lower bound) and the title notes it. Opt-in: it multiplies the one O(buffer) scan by however many buffers are loaded. The list deliberately does not honour avim.ui.selectoverride (telescope-ui-select, fzf-lua, dressing): a foreign picker only understands plain strings, so delegating would drop the per-row colors that are the point.
10. OCCURRENCE DENSITY (SIGN COLUMN)
:Spotlight mapscans the current buffer once and places a sign on every matching line, painted in the matching spotlight's own color — "where does this token cluster", a shape the highlighting itself cannot show, since|matchadd()|renders only what is currently visible. With:Spotlight map {text}, only that spotlight's lines.:Spotlight map clearremoves the marks from the current buffer. Deliberately one-shot and explicit, not live: the whole plugin exists to keep cost independent of file size and off every keystroke (see |spotlight-matchadd|), and a density map that stayed current would need exactly the invalidation that principle avoids. Editing the buffer after:Spotlight mapleaves the marks exactly where they were; run it again to refresh them. No keymap is bound by default — a bound key here would undercut the "cost visible and opt-in" point of the feature. Per-buffer, not global: showing the map in a different buffer never touches marks placed in another one, and nothing needs cleaning up on|BufWipeout|— Neovim drops a wiped buffer's extmarks with it, so this feature adds zero new autocommands.map.sign_text(default a single square marker) is capped at 2 display cells — Neovim's own sign-text limit — and validated against it at setup().map.max_entriescaps how many marks one scan can place, independent ofquickfix.max_entries.
11. QUICKFIX FILTER
<leader>sqor:Spotlight qfputs every line of the current buffer that matches a spotlight into the quickfix list — "show me only the lines with this request id". With:Spotlight qf {text}, only that one spotlight's lines. A line matched by several spotlights appears once: the question is which lines, not which highlights.:Spotlight qf allis the same filter across every loaded, ordinary file buffer, merged into one list.quickfix.max_entriesis a global cap here, not per-buffer: each buffer gets whatever budget is left after earlier ones, and scanning stops outright — not just the current buffer's contribution — the moment the cap is hit.quickfix.open = true(the default) runs|:copen|and then hands focus back to the window you ran it from, so the filtered list sits alongside the log. Running it from inside the quickfix window is refused rather than filtering the list into itself. The spotlight colors render inside the quickfix window too — it is an ordinary window as far as the ledger is concerned.
12. YANK TO A REGISTER
:Spotlight yankis the quickfix filter's sibling for "I just want the text, not a navigable list": every line in the current buffer matching a spotlight — or one specific spotlight's, with:Spotlight yank {text}— yanked into the unnamed register, one line per match, in buffer order. Reuses the exact same scan as:Spotlight qf(core.count.matching_lines), so thequickfix.max_entriescap and the "each line reported once, even if several spotlights hit it" guarantee are identical — only the destination differs. Deliberately narrow for now: always the unnamed register ("), always raw line text with no line-number prefix. A register argument or a line-numbered variant is a plausible follow-up, not something this needed to guess at up front.
13. PERSISTENCE
State lives in lib.nvim.store.project under the keyspotlight/state, keyed by *git root* — so it works when you open the project from a subdirectory, and follows the checkout to another machine. Writes are debounced (a burst of toggles is one logical change) and flushed on|VimLeavePre|, so the last toggle before:qais never the one lost. Loading happens once, on|VimEnter|— not directly from setup(), because a session or:cdplugin may not have settled the project root yet. Two independent switches cover both directions:
persist = { default = true } -- opt-out (default): everything persists
persist = { default = false } -- opt-in: nothing persists unless said so
Per file:
:Spotlight persist off " this file: do not persist
:Spotlight persist on " this file: do persist
:Spotlight persist default " drop the override, follow the global default
:Spotlight persist status " what applies here, and why
The override is recorded against the project-relative file path, not the buffer
number, so it survives closing and reopening the file.
*spotlight-exception*
What a per-file exception suppresses, precisely: spotlights are session-global
while an exception names a file, so this needs stating.
An exception suppresses the spotlights that were *created while looking at*
that file. Each spotlight records its origin once, when you make it.
It does not mean "spotlights that appear in this file". That is not
implementable and not well-defined: knowing where a token appears would mean
scanning every file in the project on every save — the exact cost this plugin
exists to avoid — and the answer would change every time a log rotates.
So a spotlight created in worker.log stays persisted even if the same string
also occurs in an excluded secrets.log. That matches the use case: "this
customer log is full of tokens I do not want written to my cache directory" is a
statement about where the tokens came from.
The exception list itself is always persisted, including for excluded files.
Otherwise :Spotlight persist off would not survive a restart.
Spotlights are filtered against the current rules on load as well as on save, so
flipping persist.default in your config takes effect immediately rather than
only for spotlights created afterwards.
"This occurrence only" spotlights (|spotlight-here|) are excluded from every
snapshot regardless of these rules — a line/column pin only means something
against the exact buffer state it was recorded from, and that guarantee does
not survive a restart.
14. SPOTLIGHT SETS
Named, saved snapshots of the registry, switched one at a time:
:Spotlight sets save {name} save the active spotlights as {name}
(overwrites if {name} already exists)
:Spotlight sets switch {name} clear the active spotlights, restore
the saved set {name}
:Spotlight sets delete {name} delete the saved set {name} (does not
touch the active spotlights)
:Spotlight sets list report every saved set and how many
spotlights it holds
Exclusive, not additive: switch replaces the active spotlights rather than
layering one investigation's tokens on top of another's — closer to opening
a saved workspace than to tagging. Nothing stops adding more spotlights
after switching; only the switch itself replaces. switch/delete
tab-complete from the names that currently exist.
Switching to an unknown or mistyped name is refused outright — a no-op, not
data loss — since the active registry would otherwise be fully replaced by
nothing.
Persisted under a second, independent |lib.nvim.store.project| key
(spotlight/sets, alongside the main spotlight/state used by
|spotlight-persist|), written synchronously on every save/switch/
delete rather than debounced — these are rare, deliberate commands, not a
hot toggle path. "This occurrence only" spotlights (|spotlight-here|) are
excluded from a saved set, for the same reason they are excluded from
regular persistence: a line/column pin means nothing outside the exact
buffer state it was recorded from.
15. COLORS
*Spotlight1* *Spotlight2* *Spotlight3* *Spotlight4* *Spotlight5* *Spotlight6* *Spotlight7* *Spotlight8* Eight highlight groups, each with an explicitbgandfg. Setting only a background is the usual mistake: the foreground then comes from whatever the colorscheme left there, which is how a readable marker becomes yellow-on-yellow after:colorscheme. Both channels are pinned, so contrast is a property of the plugin rather than of the theme. Two arrays exist —palette.colorsfor dark themes,palette.colors_lightfor light — and changing'background'switches between them. The groups are redefined on|ColorScheme|, because a colorscheme clears groups it does not know about; that also means redefiningSpotlightNyourself afterwards will be overwritten. Configure the colors through setup() instead. Slot allocation is round-robin from the last one handed out, skipping slots still in use while any are free. Plain round-robin would hand out a color already on screen while three others sit unused, and two identically colored spotlights are exactly the confusion the palette exists to prevent. *spotlight-lock* A spotlight can lock its slot with:Spotlight lock [text](or:Spotlight list lock) — "keep this one on slot 1 forever", for a token that has become the one you always look for. A locked slot is skipped by round-robin the same way an in-use one is, but stays skipped even once every other slot fills up and reuse becomes unavoidable: it is never handed to a different spotlight. Locking does not move a spotlight to a new slot, only stops it from losing the one it already has. The lock is part of the persisted snapshot, so it survives a restart.'termguicolors'should be on. Without it the hex values are approximated to the terminal's 256-color cube and slots get harder to tell apart;|:checkhealth|warns about this.
16. LUA API
Every action is a plain function on the spotlight module, so it can be bound
to any key or called from your own code:
local spotlight = require("spotlight")
spotlight.toggle() -- every occurrence of the token under the cursor
spotlight.toggle_selection() -- every occurrence of the selection (visual mode)
spotlight.toggle_here() -- only this occurrence of the token under the cursor
spotlight.toggle_here_selection() -- only this occurrence of the selection (visual mode)
spotlight.toggle_here_at(text, pos) -- only the occurrence at an explicit { buf, row1, col1 }
spotlight.add(text) -- literal text
spotlight.remove(text) -- by exact text
spotlight.clear() -- all
spotlight.list() -- the list, selection jumps
spotlight.list_remove() -- the list, selection removes
spotlight.list_lock() -- the list, selection toggles the lock
spotlight.lock_set(text, value) -- set the slot lock for `text`
spotlight.lock_toggle(text|nil) -- toggle it, for `text` or the cursor token
spotlight.list_line() -- the list, selection toggles whole-line mode
spotlight.line_set(text, value) -- set whole-line rendering for `text`
spotlight.line_toggle(text|nil) -- toggle it, for `text` or the cursor token
spotlight.next() -- next occurrence
spotlight.prev() -- previous occurrence
spotlight.quickfix(text|nil) -- matching lines -> quickfix (current buffer)
spotlight.quickfix_all(text|nil) -- same, across every loaded buffer
spotlight.yank(text|nil) -- matching lines -> unnamed register
spotlight.persist_set(bool|nil)-- per-file override for the current file
spotlight.persist_status() -- report it
spotlight.sets_save(name) -- save the active spotlights as `name`
spotlight.sets_switch(name) -- clear + restore the saved set `name`
spotlight.sets_delete(name) -- delete the saved set `name`
spotlight.sets_list() -- report every saved set
spotlight.map(text|nil) -- mark matching lines in the sign column
spotlight.map_clear() -- clear the sign-column occurrence map
spotlight.winopt_set(bool, win|nil) -- set the per-window opt-out
spotlight.winopt_toggle(win|nil) -- toggle it
spotlight.winopt_status(win|nil) -- report it
spotlight.refresh() -- redefine palette, re-apply everything
spotlight.spotlights() -- a snapshot of the Spotlight.Item[] list
Each returns a boolean saying whether anything happened, and reports its own outcome (suppress those messages withnotify = false).spotlight.refresh()is also the escape hatch if another plugin has cleared the current window's matches withclearmatches().
17. HEALTH
:checkhealth spotlight
Reports the Neovim version; 'termguicolors'; each lib.nvim module separately
with what it is used for (a missing usercmd.composer and a missing debounce are
very different problems); every config value that failed validation and what it
fell back to; the resolved match/cursor/keymap settings; and the live state —
active spotlights with their slots, how many windows carry matches, the project
root, and every per-file persistence override.
18. DEBUGGING
require("spotlight").setup({ debug = true })
The only question this plugin ever really gets asked is "why did nothing light up", so the debug switch logs exactly the four decisions that answer it: - Which resolver pattern won, with its index incursor.patterns. A surprising token almost always means a broader pattern sits ahead of the specific one you expected. - Which windows the ledger applied to or skipped as ineligible, and anymatchadd()call Vim rejected — the one way a spotlight can silently fail to appear at all. - What the snapshot filter kept and dropped, which is the answer to "my spotlights did not come back". - Whether navigation narrowed to the spotlight under the cursor or searched all of them. That is the entire behavioral difference of|]k|undernav.scope = "auto", and it is invisible from the outside. Records go tolib.nvim.logger(one instance named "spotlight", inspectable with:LibLogger), falling back to|vim.notify()|at DEBUG level when that is not installed — there is no native substitute for a structured logger, but the output should still be visible rather than silently dropped. Withdebug = false(the default) a log call costs one table lookup, so the instrumentation is not conditional on a build.
19. SECURITY MODEL
Not a claim of hardness — a plugin that highlights text is not a security boundary — but a statement of what is and is not trusted, so the limits below have a reason rather than a vibe. Nothing is executed, nothing is fetched, nothing is written outside the cache. No shell-outs, noio.*, no jobs, no network. The onlyvim.cmdcalls are two occurrences of the fixed literalnormal! zzin spotlight/nav.lua, with nothing interpolated; the quickfix window is opened through lib.nvim.ui.list rather than by composing a command string. The only file written is the state snapshot, through lib.nvim.store.project. Regexes cannot backtrack pathologically. Every pattern handed to Vim is built as\C\Vplus escaped literal text — no quantifiers, no groups, no alternation inside a branch — so no input, however crafted, can produce catastrophic backtracking. A "this occurrence only" spotlight (|spotlight-here|) adds only\%<line>l\%<col>cahead of that same literal body — fixed, zero-width position assertions, not quantified or user-controlled beyond the two numbers themselves, so the same guarantee holds. The snapshot is treated as external input. It is JSON in the cache directory: writable by anything running as this user, and hand-editable. Every field is re-validated on load (type, non-empty, length, dedup, count cap, slot clamped), and the regex is rebuilt fromtextrather than read from the file — so a crafted snapshot cannot inject a pattern. Three limits exist because the size of the input is not the plugin's to control: match.max_text_len 512 Longest token accepted. Covers av$on a minified single-line file and any snapshot field; without it either reaches|matchadd()|as a multi-megabyte pattern re-evaluated on every redraw. cursor.max_line_len 8192 Above this, the resolver's Lua-pattern scan is skipped and<cword>answers instead. The scan is O(line) per pattern and a user pattern may backtrack; a minified single-line log makes that product enough to hang on a keypress. quickfix.max_entries 10000 Unlike counting, filtering produces memory — one entry per matching line, each holding the full line text. Truncation is reported in the notification and in the list title. Persistence exception keys are data, never paths. A key like../../../etc/passwdis stored and compared as an opaque table key and JSON field; the plugin opens no files of its own, so there is nothing for a traversal to traverse. Config values are sanitized rather than trusted: invalid colors and unparseable Lua patterns are dropped, numbers range-checked, andlist.swatchhas newlines stripped (it is written into a chooser buffer line, where|nvim_buf_set_lines()|treats an embedded newline as a hard error). One thing to be aware of rather than reassured about: spotlighted tokens are written to the cache directory by default. When reading a log full of credentials,:Spotlight persist offis the switch that keeps them out of it (see |spotlight-persist|).
20. LIB.NVIM INTEGRATION
"StefanBartl/lib.nvim" and "StefanBartl/ui.nvim" are both required
dependencies. Four modules are hard requirements:
lib.nvim.bindings.usercmd.composer the :Spotlight verb
ui.kit.select (from ui.nvim) the spotlight list (rich items)
lib.nvim.ui.list the quickfix filter (:Spotlight qf / yank)
lib.nvim.bindings.keymap the keymap preset
The rest are used when present and fall back to a native equivalent otherwise,
so a partial install degrades a message rather than breaking a feature:
lib.nvim.store.project per-project persistence
lib.nvim.debounce coalesced state saves
lib.nvim.notify namespaced notifications
lib.nvim.logger structured debug logs (|spotlight-debug|)
lib.nvim.bindings.autocmd guarded autocommands
lib.nvim.ui.hl highlight definition
lib.nvim.cross.platform Windows path-case detection
lib.nvim.dotrepeat . repeats the normal-mode toggle
|:checkhealth| lists each one with its status.