A Neovim plugin for inserting citations (from
.bibfiles) and cross-references (to R, Python, and Julia code chunks in Quarto / R Markdown / Rnoweb documents, and LaTeX\label{}inside figure/table environments).
citeref.nvim lets you search your BibTeX library and insert formatted citations without leaving Neovim, and browse the named code chunks and LaTeX labels in your documents to insert figure and table cross-references. It works with your existing fuzzy picker and/or completion plugin — no new UI to learn.
Citations are sourced from .bib files: any file in your working directory is picked up automatically, and you can point citeref at a global library too. A fuzzy picker (fzf-lua, telescope, snacks, mini.pick) lets you search by key, title, author, or journal with a live preview; a completion backend (blink.cmp, nvim-cmp) offers the same entries inline as you type @ or \*cite*{.
Cross-references are generated by scanning the named code chunks and LaTeX \label{} definitions in the current buffer and its sibling .rmd/.qmd/.rnw/.tex files. For code chunks, supported languages are R, Python, and Julia — all three are recognised in both R Markdown and Quarto documents; R chunks are additionally detected in Rnoweb (.rnw) documents using the <<...>>= syntax. LaTeX \label is detected inside figure, figure*, table, and table* environments, and the correct environment type (fig/tab) is automatically assigned so labels appear in the right picker. Select a label and citeref inserts the right reference syntax for your document type — \@ref(label) / \@ref(fig:label) for R Markdown, @label for Quarto's native cross-reference format, or \ref{label} for LaTeX / Rnoweb.
Contributions are very welcomed! See Contributing
- One picker or completion backend (required — no auto-detection):
- fzf-lua — full fuzzy picker with preview
- telescope.nvim — full fuzzy picker with preview
- snacks.nvim — full fuzzy picker with preview
- mini.pick — full fuzzy picker with preview
- blink.cmp — completion menu
- nvim-cmp — completion menu
The backend option is required. citeref will warn on startup if it is not set.
citeref self-activates via a FileType autocommand — setup() is optional for attachment, but required to set a backend. When you open a supported filetype, the plugin attaches to that buffer and sets buffer-local keymaps. No external modules are loaded at this point.
The backend is loaded lazily on your first keypress, not at startup. Picker backends (fzf, telescope, snacks, minipick) work in both insert and normal mode. Completion backends (blink, cmp) provide a menu and work in insert mode only — normal-mode keymaps will warn if a picker backend is not active.
Without setup(), only *.bib files in the current working directory are used for citations. Set bib_files in setup() to include a global library.
Neovim native package manager
vim.pack.add({
'https://github.com/urtzienriquez/citeref.nvim',
})
require("citeref").setup({
backend = "fzf", -- required: "fzf" | "telescope" | "snacks" | "minipick" | "blink" | "cmp"
bib_files = { "/path/to/your/library.bib" },
})lazy.nvim
{
"urtzienriquez/citeref.nvim",
ft = { "markdown", "rmd", "quarto", "rnoweb", "pandoc", "tex", "latex" },
config = function()
require("citeref").setup({
backend = "fzf", -- required: "fzf" | "telescope" | "snacks" | "minipick" | "blink" | "cmp"
bib_files = { "/path/to/your/library.bib" },
})
end,
}Register citeref as a blink.cmp provider. Note the module path points to the blink backend:
-- in your blink.cmp config:
sources = {
default = { "lsp", "path", "snippets", "buffer" },
providers = {
citeref = {
name = "citeref",
module = "citeref.backends.blink",
},
},
per_filetype = {
markdown = { inherit_defaults = true, "citeref" },
rmd = { inherit_defaults = true, "citeref" },
quarto = { inherit_defaults = true, "citeref" },
tex = { inherit_defaults = true, "citeref" },
latex = { inherit_defaults = true, "citeref" },
},
},-- in your nvim-cmp config:
-- Register the source before cmp.setup() so nvim-cmp knows about it:
require("citeref.backends.cmp").register()
sources = cmp.config.sources({
{ name = "nvim_lsp" },
{ name = "citeref" },
})When using a completion backend (blink or cmp), citeref activates automatically based on what you type — no keymap needed. The full set of triggers is:
| What you type | Filetype | Items offered |
|---|---|---|
@ |
R Markdown / Markdown | Citations only |
@ |
Quarto | Citations and chunk crossrefs |
\cite{ / \citep{ / \parencite{ etc. |
any | Citations (key only, to complete the {}) |
\ref{ |
tex / latex | All crossref labels (both fig and tab) |
\@ref( |
R Markdown | All chunk labels, inserting fig:label) or tab:label) |
\@ref(fig: |
R Markdown | Figure chunk labels only, inserting label) |
\@ref(tab: |
R Markdown | Table chunk labels only, inserting label) |
In Quarto, @ is the universal trigger for both citations and crossrefs — the menu offers both together and you filter by typing. The \@ref( trigger is R Markdown only because Quarto does not use that syntax.
The keymaps (<C-a>f, <C-a>t, etc.) are still available with completion backends as an alternative way to open the menu pre-filtered to a specific type.
All options have defaults. Call setup() to set a backend and override anything else.
require("citeref").setup({
-- REQUIRED. No auto-detection — set one explicitly:
-- "fzf" → fzf-lua: full picker with preview, insert + normal mode
-- "telescope" → telescope.nvim: full picker with preview, insert + normal mode
-- "snacks" → snacks.nvim: full picker with preview, insert + normal mode
-- "minipick" → mini.pick: full picker with preview, insert + normal mode
-- "blink" → blink.cmp: completion menu, insert mode only
-- "cmp" → nvim-cmp: completion menu, insert mode only
backend = "fzf",
-- Filetypes where citeref activates.
filetypes = {
"markdown", "rmd", "quarto", "rnoweb", "pandoc", "tex", "latex",
},
-- .bib files for citations.
-- cwd *.bib files are always included. This adds more sources on top.
-- Accepts:
-- string[] → explicit paths (~ expanded; missing files warned once)
-- fun():string[] → function called each time a picker opens (dynamic)
bib_files = { "/path/to/your/library.bib" },
-- Default LaTeX citation command used when "default" is selected from the
-- format prompt. A vim.ui.select dialog opens first to let you pick a
-- format; press <C-l> inside the picker to cycle through all formats.
-- Valid values: cite | citep | citet | citeauthor | citeyear | citealt |
-- textcite | parencite | footcite | autocite | nocite
default_latex_format = "cite",
-- Default MyST citation role used when "default" is selected from the
-- format prompt. A vim.ui.select dialog opens first to let you pick a
-- format; press <C-l> inside the picker to cycle between {cite:p} and {cite:t}.
-- Valid values: "cite:p" | "cite:t"
default_myst_format = "cite:p",
keymaps = {
-- Set to false to disable all default keymaps.
enabled = true,
-- Each action has an insert-mode (_i) and normal-mode (_n) variant.
-- Set any to false to disable that individual mapping.
-- Normal-mode keymaps require a picker backend; they warn otherwise.
cite_markdown_i = "<C-a>m", -- insert @key (insert mode)
cite_markdown_n = "<leader>am", -- insert @key (normal mode)
cite_latex_i = "<C-a>l", -- insert LaTeX citation — format prompt first (insert mode)
cite_latex_n = "<leader>al", -- insert LaTeX citation — format prompt first (normal mode)
cite_myst_i = "<C-a>s", -- insert MyST citation — format prompt first (insert mode)
cite_myst_n = "<leader>as", -- insert MyST citation — format prompt first (normal mode)
cite_replace_n = "<leader>ar", -- replace key under cursor (normal only)
crossref_figure_i = "<C-a>f", -- crossref figure (insert mode)
crossref_figure_n = "<leader>af", -- crossref figure (normal mode)
crossref_table_i = "<C-a>t", -- crossref table (insert mode)
crossref_table_n = "<leader>at", -- crossref table (normal mode)
},
-- Picker appearance.
-- `layout` meaning depends on the backend:
-- fzf / telescope → "vertical" | "horizontal"
-- snacks → any snacks preset name:
-- "default" | "vertical" | "horizontal" | "telescope" |
-- "ivy" | "ivy_split" | "select" | "sidebar" | "vscode"
-- minipick → not used (mini.pick layout is controlled via MiniPick.config)
-- `preview_size` is used by fzf and telescope only.
-- `rnoweb_labels` controls which crossref targets appear for .Rnw files:
-- "all" (default) — code chunks + LaTeX \label{} inside figure/table
-- "tex_only" — only LaTeX \label{} inside figure/table (skip code chunks)
picker = {
layout = "vertical",
preview_size = "50%",
rnoweb_labels = "all",
},
})require("citeref").setup({
backend = "fzf",
keymaps = { enabled = false },
})
vim.api.nvim_create_autocmd("FileType", {
pattern = { "markdown", "quarto", "rmd" },
callback = function()
local cr = require("citeref")
vim.keymap.set("i", "<M-c>", cr.cite_markdown, { buffer = true })
vim.keymap.set("i", "<M-r>", cr.crossref_figure, { buffer = true })
end,
})| Mode | Key | Action | Requires |
|---|---|---|---|
| insert | <C-a>m |
Insert citation @key |
any backend |
| normal | <leader>am |
Insert citation @key |
picker backend |
| insert | <C-a>l |
Insert LaTeX citation | any backend |
| normal | <leader>al |
Insert LaTeX citation | picker backend |
| insert | <C-a>s |
Insert MyST citation | picker backend |
| normal | <leader>as |
Insert MyST citation | picker backend |
| normal | <leader>ar |
Replace citation under cursor | picker backend |
| insert | <C-a>f |
Insert figure crossref | any backend |
| normal | <leader>af |
Insert figure crossref | picker backend |
| insert | <C-a>t |
Insert table crossref | any backend |
| normal | <leader>at |
Insert table crossref | picker backend |
The inserted crossref syntax depends on the filetype — see Cross-references below.
Normal-mode keymaps with a completion backend show a warning — there is no picker equivalent for normal mode without fzf-lua, telescope, snacks, or mini.pick.
When using a picker backend, both the LaTeX and MyST citation pickers first show a vim.ui.select dialog to choose the citation format. This lets you quickly pick a specific format without cycling through the list.
The first option is always default — selecting it (or pressing <Esc>) opens the picker with your configured default format:
| Citation type | Config option | Default |
|---|---|---|
| LaTeX | default_latex_format |
\cite{} |
| MyST | default_myst_format |
{cite:p} |
Selecting any other format opens the picker with that format active.
Once the picker is open, press <C-l> to cycle through all available formats. The current format is shown in the picker title; a notification confirms each cycle.
| Command | Output |
|---|---|
\cite{} |
\cite{key} |
\citep{} |
\citep{key} |
\citet{} |
\citet{key} |
\citeauthor{} |
\citeauthor{key} |
\citeyear{} |
\citeyear{key} |
\citealt{} |
\citealt{key} |
\textcite{} |
\textcite{key} |
\parencite{} |
\parencite{key} |
\footcite{} |
\footcite{key} |
\autocite{} |
\autocite{key} |
\nocite{} |
\nocite{key} |
| Command | Output |
|---|---|
{cite:p} |
{cite:p}key`` |
{cite:t} |
{cite:t}key`` |
Set the default in setup():
require("citeref").setup({
backend = "fzf",
default_latex_format = "citep",
default_myst_format = "cite:t",
})Or change it on the fly at any time:
require("citeref").set_latex_format("citep")
require("citeref").set_myst_format("cite:t")These update the session default directly without triggering the backend configuration warning — useful for switching formats per-document.
Completion backends (blink, cmp) trigger on @ for markdown and \cite{ for LaTeX. They do not support format selection or cycling — the format is determined by what you type.
Without setup(), citeref scans only *.bib files in the current working directory. If none are found it warns once when you trigger a citation.
With setup({ bib_files = { ... } }), the configured files are merged with any cwd *.bib files (duplicates removed). Missing configured files produce a one-time warning.
-- Static global library + any project-local .bib automatically included
require("citeref").setup({
backend = "fzf",
bib_files = { "/path/to/your/library.bib" },
})
-- Dynamic: re-evaluated on every picker open
require("citeref").setup({
backend = "fzf",
bib_files = function()
return vim.fn.globpath(vim.fn.getcwd(), "**/*.bib", false, true)
end,
})The crossref pickers scan:
- The current buffer
- All
*.{rmd,Rmd,qmd,Qmd,rnw,Rnw}files in the same directory
For LaTeX (.tex) buffers, they scan the current buffer plus all sibling .tex files for \label{...} definitions inside figure, figure*, table, and table* environments. The environment type is tracked automatically — figure labels appear only in the figure picker, table labels only in the table picker.
citeref detects cross-reference targets from two sources:
Code chunks in R, Python, and Julia — all three are detected in both R Markdown and Quarto documents. Labels always come from the #| label: YAML option for Python and Julia; R additionally supports the legacy inline label on the fence line. Rnoweb (.rnw) documents are also supported — R chunks use the <<...>>= syntax and labels are extracted from the chunk header.
LaTeX \label{...} inside figure, figure*, table, and table* environments — the environment type is tracked automatically as the parser walks the \begin/\end stack, so figure labels only appear in the figure picker and table labels only in the table picker. Commented-out environments (% \begin{figure}) are ignored. Labels outside any recognised environment are skipped.
R Markdown / knitr inline label (R only — label on the fence line):
```{r myplot, fig.cap="My caption"}
# referenced as \@ref(fig:myplot)
```Quarto YAML options (all languages — label as a #| comment inside the chunk):
```{r}
#| label: myplot
#| fig-cap: "My caption"
```
```{python}
#| label: fig-scatter
#| fig-cap: "A scatter plot"
import matplotlib.pyplot as plt
plt.plot(x, y)
plt.show()
```
```{julia}
#| label: fig-lineplot
#| fig-cap: "A line plot"
using Plots
plot([1, 2, 3], [1, 4, 9])
```All three styles are detected and shown together in the picker, regardless of which file type you are editing.
Rnw chunks use <<...>>= headers. The label can be a standalone name, an explicit label= option, or the first token before options:
<<my-plot>>=
plot(1:10)
@
<<label=my-table>>=
knitr::kable(head(mtcars))
@
<<echo=FALSE, label=fig-line>>=
plot(1:10, type="l")
@
<<setup, include=FALSE>>=
knitr::opts_chunk$set(echo = TRUE)
@
<<>>=
# unnamed — no label
x <- 1
@Labels inside figure/figure* and table/table* environments are detected in any filetype:
\begin{figure}[htbp]
\centering
\includegraphics{birds.jpg}
\caption{The birds}
\label{fig:birds}
\end{figure}
\begin{table}
\centering
\begin{tabular}{cc}
A & B \\
\end{tabular}
\caption{My table}
\label{mytab}
\end{table}The environment type is tracked automatically: fig:birds appears in the figure picker, mytab in the table picker. Using a fig:/tab: prefix is optional — citeref preserves the label as-is. When inserting a reference, the prefix is never added or stripped:
The reference format inserted on selection depends on the filetype of the buffer you are editing:
| Filetype | Source | Figure crossref | Table crossref |
|---|---|---|---|
quarto (.qmd) |
code chunk | @fig-example |
@tbl-example |
quarto (.qmd) |
LaTeX \label{} |
@label (echoed as-is) |
@label (echoed as-is) |
rmd, markdown |
code chunk | \@ref(fig:example) |
\@ref(tab:example) |
rmd, markdown |
LaTeX \label{} |
\@ref(label) (echoed as-is) |
\@ref(label) (echoed as-is) |
rnoweb (.rnw) |
code chunk | \ref{example} |
\ref{example} |
rnoweb (.rnw) |
LaTeX \label{} |
\ref{label} (echoed as-is) |
\ref{label} (echoed as-is) |
tex, latex |
LaTeX \label{} |
\ref{label} (echoed as-is) |
\ref{label} (echoed as-is) |
LaTeX \label{}: the label is echoed verbatim — if you write \label{fig:myfig}, the reference becomes \ref{fig:myfig} (tex / rnoweb) or \@ref(fig:myfig) (rmd). If you write \label{myfig}, the reference becomes \ref{myfig} or \@ref(myfig) with no added prefix. Using fig: / tab: prefixes in your labels is not required but is a recommended convention to keep your labels organised — citeref preserves whatever prefix you use without adding or removing anything.
For Quarto documents, citeref inserts @label and leaves the prefix (fig-, tbl-) entirely up to you — Quarto requires that labels follow the fig-* / tbl-* naming convention for cross-referencing to work, but enforcing that is your responsibility, not the plugin's.
Unnamed chunks appear in the list with a warning label; selecting one produces a notification telling you to add a label first.
```{python}
# unnamed — add #| label: to use in a cross-reference
x = 42
```citeref uses a backend registry. You can register any table as a backend — from your own config, from a separate plugin, or to override a built-in.
require("citeref").register_backend("my_picker", {
-- Called for citation insertion (picker backends)
pick_citation = function(format, entries, ctx, cmd)
-- format: "markdown" | "latex" | "myst"
-- entries: CiterefEntry[] (key, title, author, year, journaltitle, abstract)
-- ctx: saved cursor context from util.save_context()
-- cmd: optional — initial format command for latex/myst, or nil for default
end,
-- Called for crossref insertion (picker backends)
pick_crossref = function(ref_type, chunks, ctx)
-- ref_type: "fig" | "tab"
-- chunks: CiterefChunk[] (label, display, line, file, is_current, header)
-- ctx: also carries ctx.bufnr — the buffer being edited
end,
-- Called for replacing a citation key under the cursor (picker backends)
replace = function(entries, info)
-- info: { key, start_col, end_col, style ("markdown"|"latex"), ... }
end,
-- Completion backends only: register your source with the engine once
register = function() end,
-- Completion backends only: open the menu in a specific mode
show = function(mode, format)
-- mode: "citation" | "crossref_fig" | "crossref_tab" | "all"
-- format: "markdown" | "latex"
end,
})Only implement the functions your backend supports. citeref checks for nil before calling and warns if a required function is missing. Built-in backends live in lua/citeref/backends/ and are a good reference for implementation.
The shared parsers are available for reuse:
local parse = require("citeref.parse")
parse.load_entries() -- resolve bib files + parse → CiterefEntry[]
parse.load_chunks() -- scan current buf + siblings → CiterefChunk[]
parse.entry_display(entry) -- "key │ title │ author"
parse.entry_preview(entry) -- multi-line preview string
parse.format_citation(keys) -- "@key1; @key2" (markdown only)
parse.format_crossref(ref_type, label, bufnr) -- filetype-aware: "@label" or "\@ref(fig:label)"
parse.citation_under_cursor() -- detect citation at cursor → info table or nilNote: for LaTeX citations, use "\\cite{" .. cmd .. "}{" .. table.concat(keys, ", ") .. "}" directly — format_citation is markdown-only. The available commands are defined in lua/citeref/latex_formats.lua.
lua/citeref/
init.lua Public API, keymap setup, buffer attach, register_backend()
config.lua Options, defaults, validation
parse.lua Bib parser, chunk parser (R/Python/Julia), shared display helpers
util.lua Cursor context save/restore, text insertion
latex_formats.lua LaTeX citation command definitions (single source of truth)
backends/
init.lua Backend registry and lazy loader
fzf.lua fzf-lua picker (citations, crossrefs, replace)
telescope.lua telescope.nvim picker (citations, crossrefs, replace)
snacks.lua snacks.nvim picker (citations, crossrefs, replace)
minipick.lua mini.pick picker (citations, crossrefs, replace)
blink.lua blink.cmp completion source
cmp.lua nvim-cmp completion source
plugin/
citeref.lua FileType autocommand (startup entry point)
citeref is designed to have zero startup impact:
plugin/citeref.luaonly registers aFileTypeautocmd — no modules load.- On buffer attach, only
citeref.configandciteref.initload (tiny pure-Lua, no external dependencies). - The backend module (
citeref.backends.fzfetc.) loads on your first keypress only.
local citeref = require("citeref")
citeref.cite_markdown() -- insert @key
citeref.cite_latex() -- insert \cite{key}
citeref.cite_replace() -- replace citation key under cursor (picker backends only)
citeref.crossref_figure() -- insert figure crossref (format depends on filetype)
citeref.crossref_table() -- insert table crossref (format depends on filetype)
citeref.set_latex_format(cmd) -- change default LaTeX format on the fly (e.g. "citep")
citeref.set_myst_format(cmd) -- change default MyST format on the fly (e.g. "cite:t")
citeref.register_backend(name, backend) -- register a custom backend
citeref.debug() -- print backend, attachment status, and active keymapsContributions of any kind are very welcome — bug reports, feature suggestions, documentation improvements, and especially code. If you have an idea for a new backend, a fix, or a quality-of-life improvement, please open an issue or pull request. This plugin is small and the codebase is meant to be easy to navigate, so diving in should be straightforward.
If you want to contribute code, the Architecture section at the bottom of this README is a good starting point for orientation. Custom backends are particularly easy to add without touching core files — see Extending citeref with a custom backend.
-
zotcite — the most feature-rich option if you use Zotero. Queries the Zotero database directly (no
.bibexport needed), provides omnicompletion, opens PDF attachments, and can extract annotations and notes. Requires Python 3,sqlite3, andnvim-treesitter. Supports Markdown, Quarto, RMarkdown, LaTeX, Rnoweb, Typst, and vimwiki. -
telescope-zotero.nvim — a Telescope extension that browses your Zotero library and inserts the selected reference into a
.bibfile. Requires Zotero, Better BibTeX, andsqlite.lua. The intended workflow keeps citation insertion (via this plugin) separate from in-document completion (handled by a companion cmp source). -
zotex.nvim — an nvim-cmp completion source that imports references directly from the Zotero SQLite database. Lightweight and focused: configure it as a cmp source for your filetypes and it will surface Zotero entries as you type. Requires
sqlite.lua.
GNU General Public License v3.0 — see LICENSE.