diff --git a/docs/configuration.org b/docs/configuration.org index da4ba9c4b..97a180966 100644 --- a/docs/configuration.org +++ b/docs/configuration.org @@ -2310,6 +2310,7 @@ these types: - Planning keywords (=DEADLINE=, =SCHEDULED=, =CLOSED=) - Orgfile special keywords (=#+TITLE=, =#+BEGIN_SRC=, =#+ARCHIVE=, etc.) - Hyperlinks (=* - headlines=, =# - headlines with CUSTOM_ID property=, =headlines matching title=) +- Citation keys (inside =[cite:...]= blocks after =@=) Autocompletion is context aware, which means that for example tags autocompletion will kick in only when cursor is at the end of @@ -2539,6 +2540,162 @@ require('orgmode').setup({ }) #+end_src +*** Citations +:PROPERTIES: +:CUSTOM_ID: citations +:END: +Orgmode supports [[https://orgmode.org/manual/Citation-handling.html][Org 9.5 citations]] using the =[cite:@key]= syntax. +For example: + +#+begin_src org +See [cite:@smith2020] for details, or [cite/t:@jones2021;@doe2022] for a +textual citation. +#+end_src + +The citation key under the cursor can be followed to its bibliography entry +using the same =org_open_at_point= mapping (=oo= by default) that is +used for hyperlinks. + +Citation key autocompletion is provided via the standard =omnifunc= (triggered +by == in insert mode). Completion is active inside any =[cite:...]= +or =[cite/style:...]=, after the =@= character. + +**** Bibliography files +:PROPERTIES: +:CUSTOM_ID: citations-bibliography-files +:END: +The built-in BibTeX source reads citation keys from =.bib= files. Two +discovery mechanisms are available (both can be used simultaneously): + +***** Global bibliography +:PROPERTIES: +:CUSTOM_ID: org_cite_global_bibliography +:END: +Set =citations.org_cite_global_bibliography= to a path or list of paths that +are always searched, regardless of which file is open: + +#+begin_src lua +require('orgmode').setup({ + citations = { + org_cite_global_bibliography = { + '~/references/global.bib', + '~/references/books.bib', + }, + }, +}) +#+end_src + +A single string is also accepted: + +#+begin_src lua +require('orgmode').setup({ + citations = { + org_cite_global_bibliography = '~/references/global.bib', + }, +}) +#+end_src + +***** File-local bibliography +:PROPERTIES: +:CUSTOM_ID: citations-file-local-bibliography +:END: +Any =.org= file can declare its own bibliography with one or more +=#+bibliography:= directives. Paths are resolved relative to the org file: + +#+begin_src org +#+bibliography: refs.bib +#+bibliography: /absolute/path/to/extra.bib + +* Introduction +See [cite:@smith2020] for background. +#+end_src + +**** Custom citation sources +:PROPERTIES: +:CUSTOM_ID: citations-custom-sources +:END: +Additional citation sources can be registered via =citations.sources=. Each +source is a table (or object) that implements the =OrgCitationSource= +interface: + +- =get_name()= (required) — return a unique name string for the source. +- =get_items()= (required) — return a list of =OrgCitationItem= tables. + Each item must have a =key= field, and may optionally have =label= and + =description= fields used in completion menus. +- =follow(key)= (optional) — navigate to the entry for =key=; return =true= + if handled, =false= to fall through to the next source. + +#+begin_src lua +require('orgmode').setup({ + citations = { + sources = { + { + get_name = function() return 'my_source' end, + get_items = function() + return { + { key = 'smith2020', description = 'Smith et al. 2020' }, + { key = 'jones2021' }, + } + end, + follow = function(self, key) + vim.notify('Citation: ' .. key) + return true + end, + }, + }, + }, +}) +#+end_src + +***** Example: Zotero Local API +:PROPERTIES: +:CUSTOM_ID: citations-zotero-local-api +:END: +[[https://www.zotero.org][Zotero]] exposes a local HTTP API on =http://localhost:23119= (requires the +Zotero desktop application to be running). The example below defines a +custom source that queries the local API for all library items and exposes +their citation keys for completion. +#+begin_src lua +local ZoteroSource = {} + +function ZoteroSource:get_name() + return 'zotero' +end + +function ZoteroSource:get_items() + local curl = require('plenary.curl') + local res = curl.get { + url = 'http://localhost:23119/api/users/0/items?format=json', + accept = 'application/json', + } + if res.status == 200 then + local data = vim.json.decode(res.body or '') or {} + local items = {} + for _, entry in ipairs(data) do + local d = entry.data or {} + if d.citationKey then + local creators = d.creators or {} + local author = (creators[1] or {}).lastName or '' + local year = (d.date or ''):match('%d%d%d%d') or '' + table.insert(items, { + key = d.citationKey, + description = author .. year .. ' ' .. (d.title or ''), + }) + end + end + return items + else + return {} + end +end + +require('orgmode').setup({ + citations = { + sources = { ZoteroSource }, + }, +}) +#+end_src + *** Notifications :PROPERTIES: :CUSTOM_ID: notifications diff --git a/lua/orgmode/colors/highlights.lua b/lua/orgmode/colors/highlights.lua index dc0885a73..9f3082493 100644 --- a/lua/orgmode/colors/highlights.lua +++ b/lua/orgmode/colors/highlights.lua @@ -67,6 +67,8 @@ function M.link_highlights() ['@org.latex'] = '@markup.math', ['@org.latex_env'] = '@markup.environment', ['@org.footnote'] = '@markup.link.url', + ['@org.citation'] = '@markup.link', + ['@org.citation.reference'] = '@markup.link.url', -- Other ['@org.table.delimiter'] = '@punctuation.special', ['@org.table.heading'] = '@markup.heading', diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua index 2a5ebd64f..d31f7cd25 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -96,6 +96,10 @@ local DefaultConfig = { hyperlinks = { sources = {}, }, + citations = { + sources = {}, + org_cite_global_bibliography = {}, + }, mappings = { disable_all = false, org_return_uses_meta_return = false, diff --git a/lua/orgmode/init.lua b/lua/orgmode/init.lua index 0d12e6cf1..d54e2a99f 100644 --- a/lua/orgmode/init.lua +++ b/lua/orgmode/init.lua @@ -12,6 +12,7 @@ local auto_instance_keys = { notifications = true, completion = true, links = true, + citations = true, } ---@class Org @@ -27,6 +28,7 @@ local auto_instance_keys = { ---@field org_mappings OrgMappings ---@field notifications OrgNotifications ---@field links OrgLinks +---@field citations OrgCitations local Org = {} setmetatable(Org, { __index = function(tbl, key) @@ -60,6 +62,7 @@ function Org:init() }) :load_sync(true, 20000) self.links = require('orgmode.org.links'):new({ files = self.files }) + self.citations = require('orgmode.org.citations'):new({ files = self.files }) self.agenda = require('orgmode.agenda'):new({ files = self.files, highlighter = self.highlighter, @@ -68,12 +71,14 @@ function Org:init() self.capture = require('orgmode.capture'):new({ files = self.files, }) - self.completion = require('orgmode.org.autocompletion'):new({ files = self.files, links = self.links }) + self.completion = + require('orgmode.org.autocompletion'):new({ files = self.files, links = self.links, citations = self.citations }) self.org_mappings = require('orgmode.org.mappings'):new({ capture = self.capture, agenda = self.agenda, files = self.files, links = self.links, + citations = self.citations, completion = self.completion, }) self.clock = require('orgmode.clock'):new({ diff --git a/lua/orgmode/org/autocompletion/init.lua b/lua/orgmode/org/autocompletion/init.lua index ff37260ce..bbe9d7b7e 100644 --- a/lua/orgmode/org/autocompletion/init.lua +++ b/lua/orgmode/org/autocompletion/init.lua @@ -1,6 +1,7 @@ ---@class OrgCompletion ---@field files OrgFiles ---@field links OrgLinks +---@field citations OrgCitations ---@field private sources OrgCompletionSource[] ---@field private sources_by_name table ---@field private fuzzy_match? boolean does completeopt has fuzzy option @@ -10,11 +11,12 @@ local OrgCompletion = { } OrgCompletion.__index = OrgCompletion ----@param opts { files: OrgFiles, links: OrgLinks } +---@param opts { files: OrgFiles, links: OrgLinks, citations: OrgCitations } function OrgCompletion:new(opts) local this = setmetatable({ files = opts.files, links = opts.links, + citations = opts.citations, sources = {}, sources_by_name = {}, fuzzy_match = vim.tbl_contains(vim.opt_local.completeopt:get(), 'fuzzy'), @@ -31,6 +33,7 @@ function OrgCompletion:setup_builtin_sources() self:add_source(require('orgmode.org.autocompletion.sources.directives'):new()) self:add_source(require('orgmode.org.autocompletion.sources.properties'):new({ completion = self })) self:add_source(require('orgmode.org.autocompletion.sources.hyperlinks'):new({ completion = self })) + self:add_source(require('orgmode.org.autocompletion.sources.citations'):new({ completion = self })) end ---@param source OrgCompletionSource diff --git a/lua/orgmode/org/autocompletion/sources/citations.lua b/lua/orgmode/org/autocompletion/sources/citations.lua new file mode 100644 index 000000000..1e7711cb2 --- /dev/null +++ b/lua/orgmode/org/autocompletion/sources/citations.lua @@ -0,0 +1,39 @@ +---@class OrgCompletionCitations:OrgCompletionSource +---@field completion OrgCompletion +---@field private pattern vim.regex +local OrgCompletionCitations = {} +OrgCompletionCitations.__index = OrgCompletionCitations + +---@param opts { completion: OrgCompletion } +function OrgCompletionCitations:new(opts) + return setmetatable({ + completion = opts.completion, + pattern = vim.regex([=[\[cite[/:][^\]]*@\zs[^ \]]*$]=]), + }, OrgCompletionCitations) +end + +---@return string +function OrgCompletionCitations:get_name() + return 'citations' +end + +---@param context OrgCompletionContext +---@return number | nil +function OrgCompletionCitations:get_start(context) + return self.pattern:match_str(context.line) +end + +---@param _ OrgCompletionContext +---@return string[] +function OrgCompletionCitations:get_results(_) + local citations = self.completion.citations + if not citations then + return {} + end + local items = citations:get_items() + return vim.tbl_map(function(item) + return item.key + end, items) +end + +return OrgCompletionCitations diff --git a/lua/orgmode/org/autocompletion/sources/directives.lua b/lua/orgmode/org/autocompletion/sources/directives.lua index 8945f39f2..70efc25a9 100644 --- a/lua/orgmode/org/autocompletion/sources/directives.lua +++ b/lua/orgmode/org/autocompletion/sources/directives.lua @@ -34,6 +34,7 @@ function OrgCompletionDirectives:get_results(_) '#+begin_example', '#+end_src', '#+end_example', + '#+bibliography', } end diff --git a/lua/orgmode/org/citations/_meta.lua b/lua/orgmode/org/citations/_meta.lua new file mode 100644 index 000000000..4d146db64 --- /dev/null +++ b/lua/orgmode/org/citations/_meta.lua @@ -0,0 +1,11 @@ +---@meta + +---@class OrgCitationItem +---@field key string The citation key (e.g. "smith2020") +---@field label? string Optional display label used in completion menus +---@field description? string Optional human-readable description (author, title, year, etc.) + +---@class OrgCitationSource +---@field get_name fun(self: OrgCitationSource): string Return the unique name of this source +---@field get_items fun(self: OrgCitationSource): OrgCitationItem[] Return all citation items +---@field follow? fun(self: OrgCitationSource, key: string): boolean Navigate to the entry for the given key; return true if handled diff --git a/lua/orgmode/org/citations/bibtex.lua b/lua/orgmode/org/citations/bibtex.lua new file mode 100644 index 000000000..2a2c9ec20 --- /dev/null +++ b/lua/orgmode/org/citations/bibtex.lua @@ -0,0 +1,155 @@ +local config = require('orgmode.config') + +---@type table +local _cache = {} + +---@param content string +---@return OrgCitationItem[] +local function parse_bibtex(content) + local items = {} + for entry_type, key in content:gmatch('@(%a%w*)%s*[{(]%s*([^%s,}%)]+)') do + local lt = entry_type:lower() + if lt ~= 'string' and lt ~= 'preamble' and lt ~= 'comment' then + table.insert(items, { key = key }) + end + end + return items +end + +---@param path string +---@return OrgCitationItem[] +local function parse_file(path) + local stat = vim.uv.fs_stat(path) + if not stat then + return {} + end + local mtime_sec = stat.mtime.sec + local cached = _cache[path] + if cached and cached.mtime_sec == mtime_sec then + return cached.items + end + local lines = vim.fn.readfile(path) + local items = parse_bibtex(table.concat(lines, '\n')) + _cache[path] = { mtime_sec = mtime_sec, items = items } + return items +end + +---@param raw string +---@param base_dir? string +---@return string +local function resolve_path(raw, base_dir) + raw = vim.trim(raw) + if raw:sub(1, 1) == '~' then + return vim.fn.expand(raw) + end + if raw:sub(1, 1) ~= '/' then + local base = base_dir or vim.fn.getcwd() + return vim.fn.fnamemodify(base .. '/' .. raw, ':p') + end + return raw +end + +---@class OrgCitationBibtex:OrgCitationSource +---@field private files OrgFiles | nil +local OrgCitationBibtex = {} +OrgCitationBibtex.__index = OrgCitationBibtex + +---@param opts { files: OrgFiles | nil } +function OrgCitationBibtex:new(opts) + return setmetatable({ files = opts and opts.files or nil }, OrgCitationBibtex) +end + +---@return string +function OrgCitationBibtex:get_name() + return 'bibtex' +end + +---@return OrgCitationItem[] +function OrgCitationBibtex:get_items() + local items = {} + for _, path in ipairs(self:_get_bib_paths()) do + vim.list_extend(items, parse_file(path)) + end + return items +end + +---Open the .bib file at the entry for the given key. +---@param key string +---@return boolean +function OrgCitationBibtex:follow(key) + for _, path in ipairs(self:_get_bib_paths()) do + local lnum = self:_find_key_line(path, key) + if lnum then + vim.cmd('edit ' .. vim.fn.fnameescape(path)) + vim.fn.cursor(lnum, 1) + return true + end + end + return false +end + +---Collect readable .bib paths from the global config and file-local #+bibliography: directives. +---@private +---@return string[] +function OrgCitationBibtex:_get_bib_paths() + local paths = {} + local seen = {} + + local function add(raw, base_dir) + local resolved = resolve_path(raw, base_dir) + if not seen[resolved] and vim.fn.filereadable(resolved) == 1 then + seen[resolved] = true + table.insert(paths, resolved) + end + end + + local global = config.citations.org_cite_global_bibliography + if global then + if type(global) == 'string' then + add(global, nil) + else + for _, p in ipairs(global) do + add(p, nil) + end + end + end + + if self.files then + local current_filename = vim.fn.expand('%:p') + if current_filename ~= '' then + local file = self.files:load_file_sync(current_filename) + if file then + local file_dir = vim.fn.fnamemodify(file.filename, ':p:h') + local directives = file:_get_directive('bibliography', true) + if directives then + if type(directives) == 'string' then + directives = { directives } + end + for _, raw in ipairs(directives) do + add(raw, file_dir) + end + end + end + end + end + + return paths +end + +---@private +---@param path string +---@param key string +---@return number | nil +function OrgCitationBibtex:_find_key_line(path, key) + local lines = vim.fn.readfile(path) + local escaped = vim.pesc(key) + local suffix_pat = '[%s,}%)]' + for i, line in ipairs(lines) do + if line:match('@%a%w*%s*[{(]%s*' .. escaped .. suffix_pat) or line:match('@%a%w*%s*[{(]%s*' .. escaped .. '$') then + return i + end + end + return nil +end + +return OrgCitationBibtex diff --git a/lua/orgmode/org/citations/init.lua b/lua/orgmode/org/citations/init.lua new file mode 100644 index 000000000..6ccfd3e09 --- /dev/null +++ b/lua/orgmode/org/citations/init.lua @@ -0,0 +1,82 @@ +local ts_utils = require('orgmode.utils.treesitter') + +---@class OrgCitations +---@field private sources OrgCitationSource[] +---@field private sources_by_name table +---@field private files OrgFiles | nil +local OrgCitations = {} +OrgCitations.__index = OrgCitations + +---@param opts? { files?: OrgFiles } +function OrgCitations:new(opts) + opts = opts or {} + local this = setmetatable({ + sources = {}, + sources_by_name = {}, + files = opts.files, + }, OrgCitations) + this:_setup_builtin_sources() + this:_add_custom_sources() + return this +end + +---@param source OrgCitationSource +function OrgCitations:add_source(source) + if self.sources_by_name[source:get_name()] then + error('Citation source ' .. source:get_name() .. ' already exists', 0) + end + self.sources_by_name[source:get_name()] = source + table.insert(self.sources, source) +end + +---@return OrgCitationItem[] +function OrgCitations:get_items() + local items = {} + for _, source in ipairs(self.sources) do + vim.list_extend(items, source:get_items()) + end + return items +end + +---@param key string +---@return boolean +function OrgCitations:follow(key) + for _, source in ipairs(self.sources) do + if source.follow and source:follow(key) then + return true + end + end + return false +end + +---@return string | nil +function OrgCitations:at_cursor() + local node = ts_utils.closest_node(ts_utils.get_node(), { 'citation_reference' }) + if not node then + return nil + end + local key_node = node:field('key')[1] + if not key_node then + return nil + end + return vim.treesitter.get_node_text(key_node, 0) +end + +---@private +function OrgCitations:_setup_builtin_sources() + self:add_source(require('orgmode.org.citations.bibtex'):new({ files = self.files })) +end + +---@private +function OrgCitations:_add_custom_sources() + local config = require('orgmode.config') + for i, source in ipairs(config.citations.sources) do + if type(source.get_name) == 'function' then + self:add_source(source) + else + vim.notify(('Citation source at index %d must have a get_name method'):format(i), vim.log.levels.ERROR) + end + end +end + +return OrgCitations diff --git a/lua/orgmode/org/mappings.lua b/lua/orgmode/org/mappings.lua index 2e882764b..8104deda0 100644 --- a/lua/orgmode/org/mappings.lua +++ b/lua/orgmode/org/mappings.lua @@ -22,6 +22,7 @@ local Footnote = require('orgmode.objects.footnote') ---@field agenda OrgAgenda ---@field files OrgFiles ---@field links OrgLinks +---@field citations OrgCitations ---@field completion OrgCompletion local OrgMappings = {} @@ -33,6 +34,7 @@ function OrgMappings:new(data) opts.agenda = data.agenda opts.files = data.files opts.links = data.links + opts.citations = data.citations opts.completion = data.completion setmetatable(opts, self) self.__index = self @@ -888,6 +890,13 @@ function OrgMappings:open_at_point() if footnote then return self:_jump_to_footnote(footnote) end + + if self.citations then + local citation_key = self.citations:at_cursor() + if citation_key then + return self.citations:follow(citation_key) + end + end end ---@param footnote_reference OrgFootnote diff --git a/lua/orgmode/utils/treesitter/install.lua b/lua/orgmode/utils/treesitter/install.lua index 293dc7444..da911a774 100644 --- a/lua/orgmode/utils/treesitter/install.lua +++ b/lua/orgmode/utils/treesitter/install.lua @@ -5,7 +5,7 @@ local M = { compilers = { 'tree-sitter', vim.fn.getenv('CC'), 'cc', 'gcc', 'clang', 'cl', 'zig' }, } -local required_version = '2.0.2' +local required_version = '2.0.3' function M.install() local version_info = M.get_version_info() diff --git a/queries/org/highlights.scm b/queries/org/highlights.scm index 377bac96b..445baabab 100644 --- a/queries/org/highlights.scm +++ b/queries/org/highlights.scm @@ -50,3 +50,5 @@ (link "[[" @_link_open "]]" @_link_close (#set! conceal "")) (link_desc "[[" @_link_open "][" @_link_separator "]]" @_link_close (#set! conceal "")) ((link_desc url: (expr)+ @_link_url (#set! @_link_url conceal "")) @_link (#set! @_link url @_link_url)) +(citation) @org.citation +(citation_reference) @org.citation.reference diff --git a/tests/plenary/fixtures/citations/extra.bib b/tests/plenary/fixtures/citations/extra.bib new file mode 100644 index 000000000..a9e5144d0 --- /dev/null +++ b/tests/plenary/fixtures/citations/extra.bib @@ -0,0 +1,5 @@ +@article{extra2023, + author = {Extra, Author}, + title = {An Extra Reference}, + year = {2023}, +} diff --git a/tests/plenary/fixtures/citations/refs.bib b/tests/plenary/fixtures/citations/refs.bib new file mode 100644 index 000000000..569cf4995 --- /dev/null +++ b/tests/plenary/fixtures/citations/refs.bib @@ -0,0 +1,31 @@ +@article{smith2020, + author = {Smith, John and Doe, Jane}, + title = {A Survey of Something}, + journal = {Journal of Stuff}, + year = {2020}, + volume = {1}, + pages = {1--10}, +} + +@book{jones2021, + author = {Jones, Alice}, + title = {The Big Book}, + publisher = {Some Press}, + year = {2021}, +} + +@misc{doe2022, + author = {Doe, John}, + title = {A Miscellaneous Entry}, + year = {2022}, + note = {Online}, +} + +@string{acm = {ACM Press}} + +@inproceedings{wang-2019, + author = {Wang, Bob}, + title = {Hyphenated key example}, + booktitle = {Proceedings}, + year = {2019}, +} diff --git a/tests/plenary/org/autocompletion_spec.lua b/tests/plenary/org/autocompletion_spec.lua index 7fedf0e72..413c6d384 100644 --- a/tests/plenary/org/autocompletion_spec.lua +++ b/tests/plenary/org/autocompletion_spec.lua @@ -202,6 +202,7 @@ describe('Autocompletion', function() { menu = '[Org]', word = '#+begin_example' }, { menu = '[Org]', word = '#+end_src' }, { menu = '[Org]', word = '#+end_example' }, + { menu = '[Org]', word = '#+bibliography' }, } assert.are.same(directives, result) @@ -212,6 +213,7 @@ describe('Autocompletion', function() assert.are.same({ { menu = '[Org]', word = '#+begin_src' }, { menu = '[Org]', word = '#+begin_example' }, + { menu = '[Org]', word = '#+bibliography' }, }, result) end) diff --git a/tests/plenary/org/citations/bibtex_spec.lua b/tests/plenary/org/citations/bibtex_spec.lua new file mode 100644 index 000000000..b80f3bec3 --- /dev/null +++ b/tests/plenary/org/citations/bibtex_spec.lua @@ -0,0 +1,169 @@ +local OrgCitationBibtex = require('orgmode.org.citations.bibtex') +local OrgCompletionCitations = require('orgmode.org.autocompletion.sources.citations') +local helpers = require('tests.plenary.helpers') + +local fixture_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h:h:h') .. '/fixtures/citations' + +local refs_bib = fixture_dir .. '/refs.bib' +local extra_bib = fixture_dir .. '/extra.bib' + +describe('OrgCitationBibtex', function() + describe('get_items from global bibliography config', function() + it('should return keys from a configured .bib file', function() + local source = OrgCitationBibtex:new({ files = nil }) + local config = require('orgmode.config') + local old = config.citations.org_cite_global_bibliography + config.citations.org_cite_global_bibliography = refs_bib + local items = source:get_items() + config.citations.org_cite_global_bibliography = old + + local keys = vim.tbl_map(function(i) + return i.key + end, items) + assert.truthy(vim.tbl_contains(keys, 'smith2020')) + assert.truthy(vim.tbl_contains(keys, 'jones2021')) + assert.truthy(vim.tbl_contains(keys, 'doe2022')) + assert.truthy(vim.tbl_contains(keys, 'wang-2019')) + -- @string should be skipped + assert.falsy(vim.tbl_contains(keys, 'acm')) + end) + + it('should accept an array of bibliography paths', function() + local source = OrgCitationBibtex:new({ files = nil }) + local config = require('orgmode.config') + local old = config.citations.org_cite_global_bibliography + config.citations.org_cite_global_bibliography = { refs_bib, extra_bib } + local items = source:get_items() + config.citations.org_cite_global_bibliography = old + + local keys = vim.tbl_map(function(i) + return i.key + end, items) + assert.truthy(vim.tbl_contains(keys, 'smith2020')) + assert.truthy(vim.tbl_contains(keys, 'extra2023')) + end) + + it('should return empty list when no bibliography is configured', function() + local source = OrgCitationBibtex:new({ files = nil }) + local config = require('orgmode.config') + local old = config.citations.org_cite_global_bibliography + config.citations.org_cite_global_bibliography = nil + local items = source:get_items() + config.citations.org_cite_global_bibliography = old + assert.are.same(0, #items) + end) + end) + + describe('get_items from file-local #+bibliography: directive', function() + it('should return keys from #+bibliography: path relative to the org file', function() + -- Use an absolute path in #+bibliography: to avoid CWD issues in tests + helpers.create_agenda_file({ + '#+bibliography: ' .. refs_bib, + '', + '* Headline', + }) + + local source = OrgCitationBibtex:new({ files = require('orgmode').files }) + + local config = require('orgmode.config') + local old = config.citations.org_cite_global_bibliography + config.citations.org_cite_global_bibliography = nil + local items = source:get_items() + config.citations.org_cite_global_bibliography = old + + local keys = vim.tbl_map(function(i) + return i.key + end, items) + assert.truthy(vim.tbl_contains(keys, 'smith2020')) + assert.truthy(vim.tbl_contains(keys, 'jones2021')) + end) + + it('should combine global and file-local bibliographies', function() + helpers.create_agenda_file({ + '#+bibliography: ' .. refs_bib, + '', + '* Headline', + }) + + local source = OrgCitationBibtex:new({ files = require('orgmode').files }) + + local config = require('orgmode.config') + local old = config.citations.org_cite_global_bibliography + config.citations.org_cite_global_bibliography = extra_bib + local items = source:get_items() + config.citations.org_cite_global_bibliography = old + + local keys = vim.tbl_map(function(i) + return i.key + end, items) + assert.truthy(vim.tbl_contains(keys, 'smith2020')) -- from file-local + assert.truthy(vim.tbl_contains(keys, 'extra2023')) -- from global + end) + end) + + describe('follow', function() + it('should open the .bib file and jump to the entry line', function() + local source = OrgCitationBibtex:new({ files = nil }) + local config = require('orgmode.config') + local old = config.citations.org_cite_global_bibliography + config.citations.org_cite_global_bibliography = refs_bib + local result = source:follow('jones2021') + config.citations.org_cite_global_bibliography = old + + assert.is_true(result) + -- Verify the current buffer is the bib file and cursor is on the entry + assert.are.same(refs_bib, vim.api.nvim_buf_get_name(0)) + local line = vim.fn.getline('.') + assert.truthy(line:match('jones2021')) + + vim.cmd('bwipeout!') + end) + + it('should return false for an unknown key', function() + local source = OrgCitationBibtex:new({ files = nil }) + local config = require('orgmode.config') + local old = config.citations.org_cite_global_bibliography + config.citations.org_cite_global_bibliography = refs_bib + local result = source:follow('nosuchkey_xyz') + config.citations.org_cite_global_bibliography = old + assert.is_false(result) + end) + end) +end) + +describe('OrgCompletionCitations regex', function() + it('should be instantiable', function() + local completion_mock = { citations = nil } + local ok, result = pcall(OrgCompletionCitations.new, OrgCompletionCitations, { + completion = completion_mock, + }) + assert.is_true(ok, result) + assert.truthy(result.pattern) + end) + + it('should match a citation line and return the @ offset', function() + local completion_mock = { citations = nil } + local source = OrgCompletionCitations:new({ completion = completion_mock }) + local context = { line = '[cite:@smith' } + local start = source:get_start(context) + assert.truthy(start) + assert.are.same(7, start) + end) + + it('should match a styled citation line', function() + local completion_mock = { citations = nil } + local source = OrgCompletionCitations:new({ completion = completion_mock }) + local context = { line = '[cite/t:@doe' } + local start = source:get_start(context) + assert.truthy(start) + assert.are.same(9, start) + end) + + it('should not match plain text', function() + local completion_mock = { citations = nil } + local source = OrgCompletionCitations:new({ completion = completion_mock }) + local context = { line = 'some @text here' } + local start = source:get_start(context) + assert.falsy(start) + end) +end) diff --git a/tests/plenary/org/citations/citations_spec.lua b/tests/plenary/org/citations/citations_spec.lua new file mode 100644 index 000000000..618f2d51b --- /dev/null +++ b/tests/plenary/org/citations/citations_spec.lua @@ -0,0 +1,103 @@ +local OrgCitations = require('orgmode.org.citations') + +--- Build a simple in-memory citation source for testing. +---@param name string +---@param items OrgCitationItem[] +---@param follow_handler? fun(key: string): boolean +local function make_source(name, items, follow_handler) + local source = {} + function source:get_name() + return name + end + function source:get_items() + return items + end + if follow_handler then + source.follow = follow_handler + end + return source +end + +describe('OrgCitations', function() + describe('add_source', function() + it('should register a citation source', function() + local citations = OrgCitations:new() + citations:add_source(make_source('test', {})) + -- 'bibtex' is registered by default; 'test' is the extra one + assert.truthy(citations.sources_by_name['bibtex']) + assert.truthy(citations.sources_by_name['test']) + end) + + it('should error when registering a source with a duplicate name', function() + local citations = OrgCitations:new() + citations:add_source(make_source('test', {})) + assert.has_error(function() + citations:add_source(make_source('test', {})) + end) + end) + end) + + describe('get_items', function() + it('should return items from all registered sources', function() + local citations = OrgCitations:new() + citations:add_source(make_source('src1', { + { key = 'smith2020', label = 'Smith 2020' }, + { key = 'jones2021' }, + })) + citations:add_source(make_source('src2', { + { key = 'doe2022', description = 'Doe et al. 2022' }, + })) + + local items = citations:get_items() + assert.are.same(3, #items) + assert.are.same('smith2020', items[1].key) + assert.are.same('jones2021', items[2].key) + assert.are.same('doe2022', items[3].key) + end) + + it('should return empty list when no sources are registered', function() + local citations = OrgCitations:new() + assert.are.same(0, #citations:get_items()) + end) + end) + + describe('follow', function() + it('should return false when no source handles the key', function() + local citations = OrgCitations:new() + citations:add_source(make_source('src', { { key = 'key1' } })) + assert.is_false(citations:follow('missing')) + end) + + it('should return true when a source handles the key', function() + local citations = OrgCitations:new() + local followed = nil + citations:add_source(make_source('src', { { key = 'key1' } }, function(_, key) + followed = key + return true + end)) + local result = citations:follow('key1') + assert.is_true(result) + assert.are.same('key1', followed) + end) + + it('should try sources in order and stop at the first match', function() + local citations = OrgCitations:new() + local calls = {} + citations:add_source(make_source('src1', {}, function(_, key) + table.insert(calls, 'src1:' .. key) + return false + end)) + citations:add_source(make_source('src2', {}, function(_, key) + table.insert(calls, 'src2:' .. key) + return true + end)) + citations:add_source(make_source('src3', {}, function(_, key) + table.insert(calls, 'src3:' .. key) + return true + end)) + + citations:follow('k') + assert.are.same({ 'src1:k', 'src2:k' }, calls) + end) + end) +end)