Skip to content

Commit cd1e8f1

Browse files
CopilotNickHu
authored andcommitted
feat(citations): add Org 9.5 citation support with BibTeX source, completions, and go-to-source
1 parent f3ea61f commit cd1e8f1

16 files changed

Lines changed: 777 additions & 2 deletions

File tree

docs/configuration.org

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2310,6 +2310,7 @@ these types:
23102310
- Planning keywords (=DEADLINE=, =SCHEDULED=, =CLOSED=)
23112311
- Orgfile special keywords (=#+TITLE=, =#+BEGIN_SRC=, =#+ARCHIVE=, etc.)
23122312
- Hyperlinks (=* - headlines=, =# - headlines with CUSTOM_ID property=, =headlines matching title=)
2313+
- Citation keys (inside =[cite:...]= blocks after =@=)
23132314

23142315
Autocompletion is context aware, which means that for example
23152316
tags autocompletion will kick in only when cursor is at the end of
@@ -2539,6 +2540,159 @@ require('orgmode').setup({
25392540
})
25402541
#+end_src
25412542

2543+
*** Citations
2544+
:PROPERTIES:
2545+
:CUSTOM_ID: citations
2546+
:END:
2547+
Orgmode supports [[https://orgmode.org/manual/Citation-handling.html][Org 9.5 citations]] using the =[cite:@key]= syntax.
2548+
For example:
2549+
2550+
#+begin_src org
2551+
See [cite:@smith2020] for details, or [cite/t:@jones2021;@doe2022] for a
2552+
textual citation.
2553+
#+end_src
2554+
2555+
The citation key under the cursor can be followed to its bibliography entry
2556+
using the same =org_open_at_point= mapping (=<Leader>oo= by default) that is
2557+
used for hyperlinks.
2558+
2559+
Citation key autocompletion is provided via the standard =omnifunc= (triggered
2560+
by =<C-x><C-o>= in insert mode). Completion is active inside any =[cite:...]=
2561+
or =[cite/style:...]=, after the =@= character.
2562+
2563+
**** Bibliography files
2564+
:PROPERTIES:
2565+
:CUSTOM_ID: citations-bibliography-files
2566+
:END:
2567+
The built-in BibTeX source reads citation keys from =.bib= files. Two
2568+
discovery mechanisms are available (both can be used simultaneously):
2569+
2570+
***** Global bibliography
2571+
:PROPERTIES:
2572+
:CUSTOM_ID: org_cite_global_bibliography
2573+
:END:
2574+
Set =citations.org_cite_global_bibliography= to a path or list of paths that
2575+
are always searched, regardless of which file is open:
2576+
2577+
#+begin_src lua
2578+
require('orgmode').setup({
2579+
citations = {
2580+
org_cite_global_bibliography = {
2581+
'~/references/global.bib',
2582+
'~/references/books.bib',
2583+
},
2584+
},
2585+
})
2586+
#+end_src
2587+
2588+
A single string is also accepted:
2589+
2590+
#+begin_src lua
2591+
require('orgmode').setup({
2592+
citations = {
2593+
org_cite_global_bibliography = '~/references/global.bib',
2594+
},
2595+
})
2596+
#+end_src
2597+
2598+
***** File-local bibliography
2599+
:PROPERTIES:
2600+
:CUSTOM_ID: citations-file-local-bibliography
2601+
:END:
2602+
Any =.org= file can declare its own bibliography with one or more
2603+
=#+bibliography:= directives. Paths are resolved relative to the org file:
2604+
2605+
#+begin_src org
2606+
#+bibliography: refs.bib
2607+
#+bibliography: /absolute/path/to/extra.bib
2608+
2609+
* Introduction
2610+
See [cite:@smith2020] for background.
2611+
#+end_src
2612+
2613+
**** Custom citation sources
2614+
:PROPERTIES:
2615+
:CUSTOM_ID: citations-custom-sources
2616+
:END:
2617+
Additional citation sources can be registered via =citations.sources=. Each
2618+
source is a table (or object) that implements the =OrgCitationSource=
2619+
interface:
2620+
2621+
- =get_name()= (required) — return a unique name string for the source.
2622+
- =get_items()= (required) — return a list of =OrgCitationItem= tables.
2623+
Each item must have a =key= field, and may optionally have =label= and
2624+
=description= fields used in completion menus.
2625+
- =follow(key)= (optional) — navigate to the entry for =key=; return =true=
2626+
if handled, =false= to fall through to the next source.
2627+
2628+
#+begin_src lua
2629+
require('orgmode').setup({
2630+
citations = {
2631+
sources = {
2632+
{
2633+
get_name = function() return 'my_source' end,
2634+
get_items = function()
2635+
return {
2636+
{ key = 'smith2020', description = 'Smith et al. 2020' },
2637+
{ key = 'jones2021' },
2638+
}
2639+
end,
2640+
follow = function(self, key)
2641+
vim.notify('Citation: ' .. key)
2642+
return true
2643+
end,
2644+
},
2645+
},
2646+
},
2647+
})
2648+
#+end_src
2649+
2650+
***** Example: Zotero Local API
2651+
:PROPERTIES:
2652+
:CUSTOM_ID: citations-zotero-local-api
2653+
:END:
2654+
[[https://www.zotero.org][Zotero]] exposes a local HTTP API on =http://localhost:23119= (requires the
2655+
Zotero desktop application to be running). The example below defines a
2656+
custom source that queries the local API for all library items and exposes
2657+
their citation keys for completion.
2658+
#+begin_src lua
2659+
local ZoteroSource = {}
2660+
2661+
function ZoteroSource:get_name()
2662+
return 'zotero'
2663+
end
2664+
2665+
function ZoteroSource:get_items()
2666+
-- Replace 0 with your numeric Zotero user ID if needed.
2667+
local url = 'http://localhost:23119/api/users/0/items?format=json'
2668+
local ok, result = pcall(vim.fn.system, { 'curl', '-s', url })
2669+
if not ok or vim.v.shell_error ~= 0 then
2670+
return {}
2671+
end
2672+
local data = vim.json.decode(result or '') or {}
2673+
local items = {}
2674+
for _, entry in ipairs(data) do
2675+
local d = entry.data or {}
2676+
if d.citationKey then
2677+
local creators = d.creators or {}
2678+
local author = (creators[1] or {}).lastName or ''
2679+
local year = (d.date or ''):match('%d%d%d%d') or ''
2680+
table.insert(items, {
2681+
key = d.citationKey,
2682+
description = author .. year .. ' ' .. (d.title or ''),
2683+
})
2684+
end
2685+
end
2686+
return items
2687+
end
2688+
2689+
require('orgmode').setup({
2690+
citations = {
2691+
sources = { ZoteroSource },
2692+
},
2693+
})
2694+
#+end_src
2695+
25422696
*** Notifications
25432697
:PROPERTIES:
25442698
:CUSTOM_ID: notifications

lua/orgmode/colors/highlights.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ function M.link_highlights()
6767
['@org.latex'] = '@markup.math',
6868
['@org.latex_env'] = '@markup.environment',
6969
['@org.footnote'] = '@markup.link.url',
70+
['@org.citation'] = '@markup.link',
71+
['@org.citation.reference'] = '@markup.link.url',
7072
-- Other
7173
['@org.table.delimiter'] = '@punctuation.special',
7274
['@org.table.heading'] = '@markup.heading',

lua/orgmode/config/defaults.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ local DefaultConfig = {
9696
hyperlinks = {
9797
sources = {},
9898
},
99+
citations = {
100+
sources = {},
101+
org_cite_global_bibliography = {},
102+
},
99103
mappings = {
100104
disable_all = false,
101105
org_return_uses_meta_return = false,

lua/orgmode/init.lua

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ local auto_instance_keys = {
1212
notifications = true,
1313
completion = true,
1414
links = true,
15+
citations = true,
1516
}
1617

1718
---@class Org
@@ -27,6 +28,7 @@ local auto_instance_keys = {
2728
---@field org_mappings OrgMappings
2829
---@field notifications OrgNotifications
2930
---@field links OrgLinks
31+
---@field citations OrgCitations
3032
local Org = {}
3133
setmetatable(Org, {
3234
__index = function(tbl, key)
@@ -60,6 +62,7 @@ function Org:init()
6062
})
6163
:load_sync(true, 20000)
6264
self.links = require('orgmode.org.links'):new({ files = self.files })
65+
self.citations = require('orgmode.org.citations'):new({ files = self.files })
6366
self.agenda = require('orgmode.agenda'):new({
6467
files = self.files,
6568
highlighter = self.highlighter,
@@ -68,12 +71,14 @@ function Org:init()
6871
self.capture = require('orgmode.capture'):new({
6972
files = self.files,
7073
})
71-
self.completion = require('orgmode.org.autocompletion'):new({ files = self.files, links = self.links })
74+
self.completion =
75+
require('orgmode.org.autocompletion'):new({ files = self.files, links = self.links, citations = self.citations })
7276
self.org_mappings = require('orgmode.org.mappings'):new({
7377
capture = self.capture,
7478
agenda = self.agenda,
7579
files = self.files,
7680
links = self.links,
81+
citations = self.citations,
7782
completion = self.completion,
7883
})
7984
self.clock = require('orgmode.clock'):new({

lua/orgmode/org/autocompletion/init.lua

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---@class OrgCompletion
22
---@field files OrgFiles
33
---@field links OrgLinks
4+
---@field citations OrgCitations
45
---@field private sources OrgCompletionSource[]
56
---@field private sources_by_name table<string, OrgCompletionSource>
67
---@field private fuzzy_match? boolean does completeopt has fuzzy option
@@ -10,11 +11,12 @@ local OrgCompletion = {
1011
}
1112
OrgCompletion.__index = OrgCompletion
1213

13-
---@param opts { files: OrgFiles, links: OrgLinks }
14+
---@param opts { files: OrgFiles, links: OrgLinks, citations: OrgCitations }
1415
function OrgCompletion:new(opts)
1516
local this = setmetatable({
1617
files = opts.files,
1718
links = opts.links,
19+
citations = opts.citations,
1820
sources = {},
1921
sources_by_name = {},
2022
fuzzy_match = vim.tbl_contains(vim.opt_local.completeopt:get(), 'fuzzy'),
@@ -31,6 +33,7 @@ function OrgCompletion:setup_builtin_sources()
3133
self:add_source(require('orgmode.org.autocompletion.sources.directives'):new())
3234
self:add_source(require('orgmode.org.autocompletion.sources.properties'):new({ completion = self }))
3335
self:add_source(require('orgmode.org.autocompletion.sources.hyperlinks'):new({ completion = self }))
36+
self:add_source(require('orgmode.org.autocompletion.sources.citations'):new({ completion = self }))
3437
end
3538

3639
---@param source OrgCompletionSource
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---@class OrgCompletionCitations:OrgCompletionSource
2+
---@field completion OrgCompletion
3+
---@field private pattern vim.regex
4+
local OrgCompletionCitations = {}
5+
OrgCompletionCitations.__index = OrgCompletionCitations
6+
7+
---@param opts { completion: OrgCompletion }
8+
function OrgCompletionCitations:new(opts)
9+
return setmetatable({
10+
completion = opts.completion,
11+
pattern = vim.regex([=[\[cite[/:][^\]]*@\zs[^ \]]*$]=]),
12+
}, OrgCompletionCitations)
13+
end
14+
15+
---@return string
16+
function OrgCompletionCitations:get_name()
17+
return 'citations'
18+
end
19+
20+
---@param context OrgCompletionContext
21+
---@return number | nil
22+
function OrgCompletionCitations:get_start(context)
23+
return self.pattern:match_str(context.line)
24+
end
25+
26+
---@param _ OrgCompletionContext
27+
---@return string[]
28+
function OrgCompletionCitations:get_results(_)
29+
local citations = self.completion.citations
30+
if not citations then
31+
return {}
32+
end
33+
local items = citations:get_items()
34+
return vim.tbl_map(function(item)
35+
return item.key
36+
end, items)
37+
end
38+
39+
return OrgCompletionCitations

lua/orgmode/org/autocompletion/sources/directives.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ function OrgCompletionDirectives:get_results(_)
3434
'#+begin_example',
3535
'#+end_src',
3636
'#+end_example',
37+
'#+bibliography',
3738
}
3839
end
3940

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---@meta
2+
3+
---@class OrgCitationItem
4+
---@field key string The citation key (e.g. "smith2020")
5+
---@field label? string Optional display label used in completion menus
6+
---@field description? string Optional human-readable description (author, title, year, etc.)
7+
8+
---@class OrgCitationSource
9+
---@field get_name fun(self: OrgCitationSource): string Return the unique name of this source
10+
---@field get_items fun(self: OrgCitationSource): OrgCitationItem[] Return all citation items
11+
---@field follow? fun(self: OrgCitationSource, key: string): boolean Navigate to the entry for the given key; return true if handled

0 commit comments

Comments
 (0)