spotlight.nvim · Debug & inspect · vimdoc

:help spotlight

Persistent multi-token highlighting for Neovim logs

doc/spotlight.txt — rendered from the plugin's own vimdoc

*spotlight.txt*             Persistent multi-token highlighting for Neovim logs
                                                              *spotlight.nvim*

CONTENTS *spotlight-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-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 :split shows
    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() *spotlight-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 same BufWinEnter fill 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 on re-fills it the
same way. :Spotlight winopt toggle (the default with no argument) flips
it; :Spotlight winopt status reports 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*

                                                           *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,
    }
ft is 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 *spotlight-keymaps*

Bound when keymaps.preset is 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>sK are 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/K
diverge at that very character, so <leader>sk and <leader>sK sit fine
side by side.

The normal-mode <leader>sK (and the bare :Spotlight/:Spotlight toggle
command path) is dot-repeatable via lib.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>sK does nothing special)
— unlike 3]k, a count on a toggle has no established meaning to borrow.

Each key is its own config value. Setting one to false frees 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
own desc, 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 what toggle
(<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/\%c are evaluated by Vim's own machinery — |matchadd()|,
|search()| — so rendering and ]k/[k need 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>sk again on
the same occurrence removes it; a different occurrence of the same word is
an independent spotlight, even alongside a <leader>sK spotlight 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.pattern
stays 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 below match.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 needs line_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/\%c
are 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 line rather 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-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 toggle works 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 *spotlight-config*

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 shrink
cursor.patterns or palette.colors instead of only extending them.

match.ignore_case = false writes \C into 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 *spotlight-cursor*

<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.patterns is 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 \> — so error does
    not light up inside errors.
  - Anything else cannot have them. \<192.168.1.1\> matches nothing, because
    \< asserts a word start and 1 after . is not one.

Deriving this from the shape rather than from the matching pattern is what keeps
match.word_boundaries meaningful — 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 add are always literal: selecting err out
of error is an explicit request to match that substring, and adding boundaries
would produce a spotlight that highlights nothing.

8. NAVIGATION *spotlight-nav*

]k and [k jump 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.

With nav.scope = "auto" (the default), a cursor sitting inside one spotlight's
match navigates that spotlight only: pressing ]k on 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]k is
three one-step jumps, not "search three times faster". A step that finds
nothing (wrapping with no match, or reaching the end with nav.wrap = false)
stops the loop early rather than erroring — a partial jump still counts.

nav.wrap = false stops at the end of the buffer instead of wrapping.
nav.center = false skips the |zz| after a jump.

9. THE LIST *spotlight-list*

<leader>sL or :Spotlight list opens 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 remove opens the same list with selection bound to
removal instead, :Spotlight list lock with it bound to the slot lock, and
:Spotlight list line with 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|). Above list.count_max_lines (default
200000) the scan is skipped and the count shows ?; that is meaningfully
different from 0 and the title says so. Set list.count = false to 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 exceeds
list.count_max_lines is skipped from the sum rather than making the whole
count unknown; the row then shows N+ (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 a vim.ui.select override
(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-map*

:Spotlight map scans 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 clear
removes 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 map leaves 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_entries caps how many marks one scan can place, independent of
quickfix.max_entries.

11. QUICKFIX FILTER *spotlight-quickfix*

<leader>sq or :Spotlight qf puts 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 all is the same filter across every loaded, ordinary file
buffer, merged into one list. quickfix.max_entries is 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-yank*

:Spotlight yank is 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 the quickfix.max_entries cap 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 *spotlight-persist*

State lives in lib.nvim.store.project under the key spotlight/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 :qa is never the one lost. Loading happens once, on |VimEnter| — not
directly from setup(), because a session or :cd plugin 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 *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 *spotlight-colors*

                *Spotlight1* *Spotlight2* *Spotlight3* *Spotlight4*
                *Spotlight5* *Spotlight6* *Spotlight7* *Spotlight8*

Eight highlight groups, each with an explicit bg and fg. 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.colors for dark themes, palette.colors_light for
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 redefining SpotlightN yourself 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 *spotlight-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 with notify = false).

spotlight.refresh() is also the escape hatch if another plugin has cleared the
current window's matches with clearmatches().

17. HEALTH *spotlight-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 *spotlight-debug*

    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 in cursor.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 any
    matchadd() 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| under
    nav.scope = "auto", and it is invisible from the outside.

Records go to lib.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.

With debug = false (the default) a log call costs one table lookup, so the
instrumentation is not conditional on a build.

19. SECURITY MODEL *spotlight-security*

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, no io.*, no jobs, no network. The only vim.cmd calls are two
occurrences of the fixed literal normal! zz in 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\V plus 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>c ahead 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 from text rather 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 a v$ 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/passwd
is 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, and list.swatch has 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 off is the switch that keeps them out of it
(see |spotlight-persist|).

20. LIB.NVIM INTEGRATION *spotlight-lib*

"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.