Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@

[![GitHub release (latest by date)](https://img.shields.io/github/v/release/urtzienriquez/citeref.nvim)](https://github.com/urtzienriquez/citeref.nvim/releases) [![Tests](https://github.com/urtzienriquez/citeref.nvim/actions/workflows/test.yml/badge.svg)](https://github.com/urtzienriquez/citeref.nvim/actions/workflows/test.yml)

> A Neovim plugin for inserting **citations** (from `.bib` files) and **cross-references** (to R, Python, and Julia code chunks in Quarto / R Markdown documents, and LaTeX `\label{}` inside figure/table environments).
> A Neovim plugin for inserting **citations** (from `.bib` files) and **cross-references** (to R, Python, and Julia code chunks in Quarto / R Markdown / Rnoweb documents, and LaTeX `\label{}` inside figure/table environments).

</div>

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`/`.tex` files. For code chunks, supported languages are **R**, **Python**, and **Julia** — all three are recognised in both R Markdown and Quarto documents. 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.
**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](#contributing)

Expand Down Expand Up @@ -317,15 +317,15 @@ require("citeref").setup({
The crossref pickers scan:

1. The **current buffer**
2. All `*.{rmd,Rmd,qmd,Qmd}` files in the same directory
2. 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.

### Supported sources

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

Expand Down Expand Up @@ -365,6 +365,33 @@ 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.

### Rnoweb (`.rnw`) chunk syntax

Rnw chunks use `<<...>>=` headers. The label can be a standalone name, an explicit `label=` option, or the first token before options:

````latex
<<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
@
````

### LaTeX label syntax

Labels inside `figure`/`figure*` and `table`/`table*` environments are detected in any filetype:
Expand Down Expand Up @@ -399,9 +426,11 @@ The reference format inserted on selection depends on the filetype of the buffer
| `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) 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.
**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.

Expand Down
29 changes: 24 additions & 5 deletions lua/citeref/parse.lua
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ function M.format_crossref(ref_type, label, bufnr, source)
if ft == "quarto" then
return "@" .. label
end
if ft == "tex" or ft == "latex" then
if ft == "tex" or ft == "latex" or ft == "rnoweb" then
return string.format("\\ref{%s}", label)
end
if source == "latex" then
Expand Down Expand Up @@ -396,12 +396,31 @@ end

local CHUNK_LANGS = { r = true, python = true, julia = true, ojs = true, observable = true }

--- Check if a line is a chunk fence opener (```{r ...} or ```{r}).
--- Check if a line is a chunk fence opener for Rmd/Quarto (```{r ...}) or Rnw (<<...>>=).
--- Returns true if so, along with any inline label found on that line.
---@param line string
---@return boolean is_fence
---@return string inline_label empty string if none on this line
local function is_chunk_fence(line)
-- Rnw chunk: <<name>>=, <<label=name>>=, <<opt, label=name, ...>>=
-- The first token before any comma is the chunk name (acts as label) if it
-- contains no `=`. An explicit `label=name` option overrides this.
local rnw_content = line:match("^%s*<<(.-)>>=%s*$")
if rnw_content then
rnw_content = rnw_content:gsub("^%s+", ""):gsub("%s+$", "")
local explicit_label = rnw_content:match("label%s*=%s*([^,%s]+)")
if explicit_label then
return true, explicit_label
end
local first_token = rnw_content:match("^([^,]+)")
if first_token and not first_token:find("=") then
first_token = first_token:gsub("^%s+", ""):gsub("%s+$", "")
return true, first_token
end
return true, ""
end

-- R Markdown / Quarto fence: ```{r ...} etc.
local lang = line:match("^```{(%a+)")
if not lang or not CHUNK_LANGS[lang:lower()] then
return false, ""
Expand Down Expand Up @@ -530,15 +549,15 @@ local function chunks_from_file(filepath)
return chunks
end

--- Return all chunks from the current buffer + sibling rmd/qmd files.
--- Return all chunks from the current buffer + sibling rmd/qmd/rnw files.
---@return CiterefChunk[]
function M.load_chunks()
local bufnr = vim.api.nvim_get_current_buf()
local cur_file = vim.api.nvim_buf_get_name(bufnr)
local cur_dir = vim.fn.fnamemodify(cur_file, ":h")

local result = chunks_from_buf(bufnr)
local rmd_files = vim.fn.globpath(cur_dir, "*.{rmd,Rmd,qmd,Qmd}", false, true)
local rmd_files = vim.fn.globpath(cur_dir, "*.{rmd,Rmd,qmd,Qmd,rnw,Rnw}", false, true)
for _, f in ipairs(rmd_files) do
if f ~= cur_file then
vim.list_extend(result, chunks_from_file(f))
Expand Down Expand Up @@ -691,7 +710,7 @@ function M.load_labels(ref_type)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
vim.list_extend(result, labels_from_lines(lines, cur_file, true, ref_type))

local rmd_files = vim.fn.globpath(cur_dir, "*.{rmd,Rmd,qmd,Qmd}", false, true)
local rmd_files = vim.fn.globpath(cur_dir, "*.{rmd,Rmd,qmd,Qmd,rnw,Rnw}", false, true)
for _, f in ipairs(rmd_files) do
if f ~= cur_file then
vim.list_extend(result, labels_from_file(f, ref_type))
Expand Down
29 changes: 29 additions & 0 deletions tests/fixtures/sample.rnw
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
\documentclass{article}
\begin{document}

Some text here.

<<setup, include=FALSE>>=
knitr::opts_chunk$set(echo = TRUE)
@

<<my-plot>>=
plot(1:10)
@

<<label=my-table>>=
knitr::kable(head(mtcars))
@

<<echo=TRUE, label=fig-line>>=
plot(1:10, type="l")
@

<<>>=
x <- 1
@

<<echo=TRUE>>=
y <- 2
@
\end{document}
92 changes: 91 additions & 1 deletion tests/parse_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -174,19 +174,21 @@ end)
-- ─────────────────────────────────────────────────────────────

describe("format_crossref", function()
local qmd_buf, rmd_buf, md_buf, tex_buf, latex_buf
local qmd_buf, rmd_buf, md_buf, tex_buf, latex_buf, rnoweb_buf

before_each(function()
qmd_buf = vim.api.nvim_create_buf(false, true)
rmd_buf = vim.api.nvim_create_buf(false, true)
md_buf = vim.api.nvim_create_buf(false, true)
tex_buf = vim.api.nvim_create_buf(false, true)
latex_buf = vim.api.nvim_create_buf(false, true)
rnoweb_buf = vim.api.nvim_create_buf(false, true)
vim.bo[qmd_buf].filetype = "quarto"
vim.bo[rmd_buf].filetype = "rmd"
vim.bo[md_buf].filetype = "markdown"
vim.bo[tex_buf].filetype = "tex"
vim.bo[latex_buf].filetype = "latex"
vim.bo[rnoweb_buf].filetype = "rnoweb"
end)

after_each(function()
Expand All @@ -195,6 +197,7 @@ describe("format_crossref", function()
vim.api.nvim_buf_delete(md_buf, { force = true })
vim.api.nvim_buf_delete(tex_buf, { force = true })
vim.api.nvim_buf_delete(latex_buf, { force = true })
vim.api.nvim_buf_delete(rnoweb_buf, { force = true })
end)

it("returns @label for quarto buffers (fig)", function()
Expand Down Expand Up @@ -236,6 +239,10 @@ describe("format_crossref", function()
it("uses \\@ref(fig:label) for rmd when source is 'latex' with existing prefix", function()
assert.equals("\\@ref(fig:myfig)", parse.format_crossref("fig", "fig:myfig", rmd_buf, "latex"))
end)

it("returns \\ref{label} for rnoweb buffers", function()
assert.equals("\\ref{myfig}", parse.format_crossref("fig", "myfig", rnoweb_buf))
end)
end)

-- ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -716,3 +723,86 @@ describe("load_labels with tex/latex buffers", function()
assert.equals("figincap", labels[1].label)
end)
end)

-- ─────────────────────────────────────────────────────────────
-- chunk parsing from rnw file
-- ─────────────────────────────────────────────────────────────

describe("chunk parsing from rnw file", function()
local chunks

before_each(function()
local path = FIXTURES .. "sample.rnw"
local buf = vim.api.nvim_create_buf(false, true)
local lines = vim.fn.readfile(path)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.api.nvim_buf_set_name(buf, path)

local save_ei = vim.o.eventignore
vim.o.eventignore = "all"
vim.bo[buf].filetype = "rnoweb"
vim.api.nvim_set_current_buf(buf)
vim.o.eventignore = save_ei

local all = require("citeref.parse").load_chunks()
chunks = {}
for _, c in ipairs(all) do
if c.is_current then
chunks[#chunks + 1] = c
end
end
end)

after_each(function()
local buf = vim.fn.bufnr(vim.fn.fnamemodify("tests/fixtures/sample.rnw", ":p"))
if buf ~= -1 then
vim.api.nvim_buf_delete(buf, { force = true })
end
end)

it("finds the correct total number of chunks", function()
assert.equals(6, #chunks)
end)

it("parses standalone name labels (<<name>>=)", function()
local labels = {}
for _, c in ipairs(chunks) do
labels[#labels + 1] = c.label
end
assert.truthy(vim.tbl_contains(labels, "setup"))
assert.truthy(vim.tbl_contains(labels, "my-plot"))
end)

it("parses explicit label= option (<<label=name>>=)", function()
local labels = {}
for _, c in ipairs(chunks) do
labels[#labels + 1] = c.label
end
assert.truthy(vim.tbl_contains(labels, "my-table"))
end)

it("parses label among other options (<<echo=TRUE, label=name>>=)", function()
local labels = {}
for _, c in ipairs(chunks) do
labels[#labels + 1] = c.label
end
assert.truthy(vim.tbl_contains(labels, "fig-line"))
end)

it("marks empty-header chunks as unnamed (<<>>=)", function()
local unnamed = vim.tbl_filter(function(c)
return c.label == ""
end, chunks)
assert.equals(2, #unnamed)
end)

it("stores the correct file path", function()
assert.matches("sample%.rnw", chunks[1].file)
end)

it("stores a positive line number", function()
for _, c in ipairs(chunks) do
assert.is_true(c.line > 0)
end
end)
end)
Loading