diff --git a/ftplugin/org.vim b/ftplugin/org.vim index 91d688cdf..96e08b459 100644 --- a/ftplugin/org.vim +++ b/ftplugin/org.vim @@ -12,7 +12,7 @@ function! OrgmodeFoldText() endfunction function! OrgmodeOmni(findstart, base) - return luaeval('require("orgmode.org.autocompletion.omni")(_A[1], _A[2])', [a:findstart, a:base]) + return luaeval('require("orgmode.org.autocompletion.omni").omnifunc(_A[1], _A[2])', [a:findstart, a:base]) endfunction function! OrgmodeFormatExpr() diff --git a/lua/orgmode/org/autocompletion/cmp.lua b/lua/orgmode/org/autocompletion/cmp.lua index 0f5c70f99..f62450a70 100644 --- a/lua/orgmode/org/autocompletion/cmp.lua +++ b/lua/orgmode/org/autocompletion/cmp.lua @@ -3,7 +3,7 @@ if not has_cmp then return end -local OrgmodeOmniCompletion = require('orgmode.org.autocompletion.omni') +local Omni = require('orgmode.org.autocompletion.omni') local Source = {} @@ -25,9 +25,9 @@ function Source:get_trigger_characters(_) end function Source:complete(params, callback) - local offset = OrgmodeOmniCompletion(1, '') + 1 + local offset = Omni.find_start() + 1 local input = string.sub(params.context.cursor_before_line, offset) - local results = OrgmodeOmniCompletion(0, input) + local results = Omni.get_completions(input) local items = {} for _, item in ipairs(results) do table.insert(items, { diff --git a/lua/orgmode/org/autocompletion/compe.lua b/lua/orgmode/org/autocompletion/compe.lua index 372690b68..f844246d9 100644 --- a/lua/orgmode/org/autocompletion/compe.lua +++ b/lua/orgmode/org/autocompletion/compe.lua @@ -3,7 +3,7 @@ if not has_compe then return end -local OrgmodeOmniCompletion = require('orgmode.org.autocompletion.omni') +local Omni = require('orgmode.org.autocompletion.omni') local CompeSource = {} @@ -22,7 +22,7 @@ function CompeSource.get_metadata() end function CompeSource.determine(_, context) - local offset = OrgmodeOmniCompletion(1, '') + 1 + local offset = Omni.find_start() + 1 if offset > 0 then return { keyword_pattern_offset = offset, @@ -32,7 +32,7 @@ function CompeSource.determine(_, context) end function CompeSource.complete(_, context) - local items = OrgmodeOmniCompletion(0, context.input) + local items = Omni.get_completions(context.input) context.callback({ items = items, incomplete = true, diff --git a/lua/orgmode/org/autocompletion/omni.lua b/lua/orgmode/org/autocompletion/omni.lua index 470fb3edd..c45ab3dbf 100644 --- a/lua/orgmode/org/autocompletion/omni.lua +++ b/lua/orgmode/org/autocompletion/omni.lua @@ -2,7 +2,6 @@ local Files = require('orgmode.parser.files') local config = require('orgmode.config') local Hyperlinks = require('orgmode.org.hyperlinks') local Url = require('orgmode.objects.url') -local Link = require('orgmode.objects.link') local data = { directives = { '#+title', '#+author', '#+email', '#+name', '#+filetags', '#+archive', '#+options', '#+category' }, @@ -33,8 +32,8 @@ local properties = { } local links = { - line_rgx = vim.regex([[\(\(^\|\s\+\)\[\[\)\@<=\(\*\|\#\|file:\)\?\(\(\w\|\/\|\.\|\\\|-\|_\|\d\)\+\)\?]]), - rgx = vim.regex([[\(\*\|\#\|file:\)\?\(\(\w\|\/\|\.\|\\\|-\|_\|\d\)\+\)\?$]]), + line_rgx = vim.regex([[\(\(^\|\s\+\)\[\[\)\@<=\(\*\|#\|file:\)\?\(\(\w\|\/\|\.\|\\\|-\)\+\)\?]]), + rgx = vim.regex([[\(\*\|#\|file:\)\?\(\(\w\|\/\|\.\|\\\|-\)\+\)\?$]]), fetcher = function(url) local hyperlinks, mapper = Hyperlinks.find_matching_links(url) return mapper(hyperlinks) @@ -91,54 +90,84 @@ local headline_contexts = { todo_keywords, } +local Omni = {} + +---@return string: the line before the current cursor position +function Omni.get_line_content_before_cursor() + return vim.api.nvim_get_current_line():sub(1, vim.api.nvim_call_function('col', { '.' }) - 1) +end + +function Omni.is_headline() + return Omni.get_line_content_before_cursor():match('^%*+%s+') +end + +---@return Table +function Omni.get_all_contexts() + return Omni.is_headline() and headline_contexts or contexts +end + ---Determines an URL for link handling. Handles a couple of corner-cases ---@param base string The string to complete ---@return string -local function get_url_str(line, base) +function Omni.get_url_str(line, base) local line_base = line:match('%[%[(.-)$') or line line_base = line_base:gsub(base .. '$', '') return (line_base or '') .. (base or '') end ---- This function is registered to omnicompletion in ftplugin/org.vim. ---- ---- If the user want to use it in his completion plugin (like cmp) he has to do ---- that in the configuration of that plugin. ----@return table -local function omni(findstart, base) - local line = vim.api.nvim_get_current_line():sub(1, vim.api.nvim_call_function('col', { '.' }) - 1) - local is_headline = line:match('^%*+%s+') - local ctx = is_headline and headline_contexts or contexts - if findstart == 1 then - for _, context in ipairs(ctx) do - local word = context.rgx:match_str(line) - if word and (not context.extra_cond or context.extra_cond(line, base)) then - return word - end +--- Is true and only true, if all given regex in the context match appropriatly +--- line_rgx and extra_cond are optional, but if the context defines them, they must match. +--- The basic rgx must always match the base, because it is used to determine the start position for +--- the completion. +---@param context table: the context candidate +---@param line string: characters left to the cursor +---@param base string: characters after the trigger (filter) +function Omni.all_ctx_conditions_apply(context, line, base) + return (not context.line_rgx or context.line_rgx:match_str(line)) + and context.rgx:match_str(base) + and (not context.extra_cond or context.extra_cond(line, base)) +end + +---@param base? string +---@return number +function Omni.find_start(base) + local line = Omni.get_line_content_before_cursor() + for _, context in ipairs(Omni.get_all_contexts()) do + local word = context.rgx:match_str(line) + if word and (not context.extra_cond or context.extra_cond(line, base)) then + return word end - return -1 end + return -1 +end - local url = Url.new(get_url_str(line, base)) - local results = {} +---@param base string +---@return table +function Omni.get_completions(base) + -- Workaround for the corner case of matching custom_ids to file paths without file: prefix + -- Bug is probably in the regex, but hard to fix, because the regex is so hard to read + base = base:match('^:#') and base:gsub('^:', '') or base - for _, context in ipairs(ctx) do - if - (not context.line_rgx or context.line_rgx:match_str(line)) - and context.rgx:match_str(base) - and (not context.extra_cond or context.extra_cond(line, base)) - then + local line = Omni.get_line_content_before_cursor() + local url = Url.new(Omni.get_url_str(line, base)) + local results = {} + for _, context in ipairs(Omni.get_all_contexts()) do + if Omni.all_ctx_conditions_apply(context, line, base) then local items = {} + + -- fetch or just take context specific completion candidates if context.fetcher then items = context.fetcher(url) else items = { unpack(context.list) } end + -- incrementally limit candidates to what the user has already been typed items = vim.tbl_filter(function(i) return i:find('^' .. vim.pesc(base)) end, items) + -- craft the actual completion entries and append them to the overall results for _, item in ipairs(items) do table.insert(results, { word = item, menu = '[Org]' }) end @@ -148,4 +177,8 @@ local function omni(findstart, base) return results end -return omni +function Omni.omnifunc(findstart, base) + return findstart == 1 and Omni.find_start(base) or Omni.get_completions(base) +end + +return Omni diff --git a/tests/plenary/org/autocompletion_spec.lua b/tests/plenary/org/autocompletion_spec.lua index 9636cfe40..c6067772c 100644 --- a/tests/plenary/org/autocompletion_spec.lua +++ b/tests/plenary/org/autocompletion_spec.lua @@ -1,5 +1,5 @@ local mock = require('luassert.mock') -local OrgmodeOmniCompletion = require('orgmode.org.autocompletion.omni') +local Omni = require('orgmode.org.autocompletion.omni') local Files = require('orgmode.parser.files') local fs = require('orgmode.utils.fs') @@ -8,89 +8,131 @@ local function mock_line(api, content) api.nvim_call_function.returns(content:len() + 5) end -describe('Autocompletion', function() - it('should properly find start offset for omni autocompletion', function() - local api = mock(vim.api, true) +describe('Autocompletion should properly find start offset for omni autocompletion', function() + local api + before_each(function() + api = mock(vim.api, true) + end) + after_each(function() + mock.revert(api) + end) + it('for an empty line', function() mock_line(api, '') - local result = OrgmodeOmniCompletion(1, '') + local result = Omni.find_start() assert.are.same(0, result) - + end) + it('for an empty headline', function() mock_line(api, '* ') - result = OrgmodeOmniCompletion(1, '') + local result = Omni.find_start() assert.are.same(2, result) - + end) + it('within TODO in headline', function() mock_line(api, '* TO') - result = OrgmodeOmniCompletion(1, '') + local result = Omni.find_start() assert.are.same(2, result) mock_line(api, '* TODO') - result = OrgmodeOmniCompletion(1, '') + result = Omni.find_start() assert.are.same(2, result) - + end) + it('in the middle of a headline', function() mock_line(api, '* TODO some text ') - result = OrgmodeOmniCompletion(1, '') + local result = Omni.find_start() assert.are.same(17, result) - + end) + it('within tag in headline', function() mock_line(api, '* TODO tags goes at the end :') - result = OrgmodeOmniCompletion(1, '') + local result = Omni.find_start() assert.are.same(28, result) mock_line(api, '* TODO tags goes at the end :SOMET') - result = OrgmodeOmniCompletion(1, '') + result = Omni.find_start() assert.are.same(28, result) + end) + it('after tag in headline', function() mock_line(api, '* TODO tags goes at the end :SOMETAG:') - result = OrgmodeOmniCompletion(1, '') + local result = Omni.find_start() assert.are.same(36, result) - + end) + it('within special directives (#+)', function() mock_line(api, '#') - result = OrgmodeOmniCompletion(1, '') + local result = Omni.find_start() assert.are.same(0, result) mock_line(api, '#+') - result = OrgmodeOmniCompletion(1, '') + result = Omni.find_start() assert.are.same(0, result) mock_line(api, '#+ar') - result = OrgmodeOmniCompletion(1, '') + result = Omni.find_start() assert.are.same(0, result) - + end) + it('within properties', function() mock_line(api, ':') - result = OrgmodeOmniCompletion(1, '') + local result = Omni.find_start() assert.are.same(0, result) mock_line(api, ' :') - result = OrgmodeOmniCompletion(1, '') + result = Omni.find_start() assert.are.same(2, result) mock_line(api, ' :PROP') - result = OrgmodeOmniCompletion(1, '') + result = Omni.find_start() assert.are.same(2, result) mock_line(api, ' :PROPERTI') - result = OrgmodeOmniCompletion(1, '') + result = Omni.find_start() assert.are.same(2, result) - + end) + it('within hyperlinks', function() mock_line(api, ' [[') - result = OrgmodeOmniCompletion(1, '') + local result = Omni.find_start() assert.are.same(4, result) mock_line(api, ' [[*some') - result = OrgmodeOmniCompletion(1, '') + result = Omni.find_start() assert.are.same(4, result) mock_line(api, ' [[#val') - result = OrgmodeOmniCompletion(1, '') + result = Omni.find_start() assert.are.same(4, result) mock_line(api, ' [[test') - result = OrgmodeOmniCompletion(1, '') + result = Omni.find_start() assert.are.same(4, result) mock_line(api, ' [[file:') - result = OrgmodeOmniCompletion(1, '') + result = Omni.find_start() assert.are.same(4, result) - - mock.revert(api) + end) + it('within file hyperlink anchors (file: prefix)', function() + mock_line(api, ' [[file:./some/path/file.org::*') + local result = Omni.find_start() + assert.are.same(31, result) + + mock_line(api, ' [[file:./some/path/file.org::#') + result = Omni.find_start() + assert.are.same(31, result) + + mock_line(api, ' [[file:./some/path/file.org::') + result = Omni.find_start() + assert.are.same(31, result) + end) + it('within file hyperlink anchors (./ prefix, headline)', function() + mock_line(api, ' [[./1-34_some/path/file.org::*') + local result = Omni.find_start() + assert.are.same(31, result) + end) + --TODO These tests expose a bug. Actually the expected start should be 31 as in the tests before + it('within file hyperlink anchors (./ prefix, custom_id)', function() + mock_line(api, ' [[./1-34_some/path/file.org::#') + local result = Omni.find_start() + assert.are.same(30, result) + end) + it('within file hyperlink anchors (./ prefix, dedicated anchor)', function() + mock_line(api, ' [[./1-34_some/path/file.org::') + local result = Omni.find_start() + assert.are.same(30, result) end) end) @@ -108,20 +150,20 @@ describe('Autocompletion', function() it('should return an empty table when base is empty', function() api = mock(vim.api, true) mock_line(api, '') - local result = OrgmodeOmniCompletion(0, '') + local result = Omni.get_completions('') assert.are.same({}, result) end) it('should return DEADLINE: when base is D', function() -- Metadata - local result = OrgmodeOmniCompletion(0, 'D') + local result = Omni.get_completions('D') assert.are.same({ { menu = '[Org]', word = 'DEADLINE:' }, }, result) end) it('should return defined keywords when base is :', function() - local result = OrgmodeOmniCompletion(0, ':') + local result = Omni.get_completions(':') local props = { { menu = '[Org]', word = ':PROPERTIES:' }, { menu = '[Org]', word = ':END:' }, @@ -135,13 +177,13 @@ describe('Autocompletion', function() end) it('should filter keywords down', function() - local result = OrgmodeOmniCompletion(0, ':C') + local result = Omni.get_completions(':C') assert.are.same({ { menu = '[Org]', word = ':CUSTOM_ID:' }, { menu = '[Org]', word = ':CATEGORY:' }, }, result) - result = OrgmodeOmniCompletion(0, ':CA') + result = Omni.get_completions(':CA') assert.are.same({ { menu = '[Org]', word = ':CATEGORY:' }, }, result) @@ -149,7 +191,7 @@ describe('Autocompletion', function() it('should find and filter down export options when base is #', function() -- Directives - local result = OrgmodeOmniCompletion(0, '#') + local result = Omni.get_completions('#') local directives = { { menu = '[Org]', word = '#+title' }, { menu = '[Org]', word = '#+author' }, @@ -166,10 +208,10 @@ describe('Autocompletion', function() } assert.are.same(directives, result) - result = OrgmodeOmniCompletion(0, '#+') + result = Omni.get_completions('#+') assert.are.same(directives, result) - result = OrgmodeOmniCompletion(0, '#+b') + result = Omni.get_completions('#+b') assert.are.same({ { menu = '[Org]', word = '#+begin_src' }, { menu = '[Org]', word = '#+begin_example' }, @@ -189,14 +231,14 @@ describe('Autocompletion', function() end) it('should find and filter down TODO keywords at the beginning of a headline', function() - local result = OrgmodeOmniCompletion(0, '') + local result = Omni.get_completions('') assert.are.same({ { menu = '[Org]', word = 'TODO' }, { menu = '[Org]', word = 'DONE' }, }, result) mock_line(api, '* T') - result = OrgmodeOmniCompletion(0, 'T') + result = Omni.get_completions('T') assert.are.same({ { menu = '[Org]', word = 'TODO' }, }, result) @@ -205,31 +247,31 @@ describe('Autocompletion', function() it('should find defined tags', function() Files.tags = { 'OFFICE', 'PRIVATE' } mock_line(api, '* TODO tags go at the end :') - local result = OrgmodeOmniCompletion(0, ':') + local result = Omni.get_completions(':') assert.are.same({ { menu = '[Org]', word = ':OFFICE:' }, { menu = '[Org]', word = ':PRIVATE:' }, }, result) mock_line(api, '* TODO tags go at the end :') - result = OrgmodeOmniCompletion(0, ':OFF') + result = Omni.get_completions(':OFF') assert.are.same({ { menu = '[Org]', word = ':OFFICE:' }, }, result) mock_line(api, '* TODO tags go at the end :OFFICE:') - result = OrgmodeOmniCompletion(0, ':') + result = Omni.get_completions(':') assert.are.same({ { menu = '[Org]', word = ':OFFICE:' }, { menu = '[Org]', word = ':PRIVATE:' }, }, result) mock_line(api, '#+filetags: ') - result = OrgmodeOmniCompletion(0, '') + result = Omni.get_completions('') assert.are.same({}, result) mock_line(api, '#+filetags: :') - result = OrgmodeOmniCompletion(0, ':') + result = Omni.get_completions(':') assert.are.same({ { menu = '[Org]', word = ':OFFICE:' }, { menu = '[Org]', word = ':PRIVATE:' }, @@ -276,7 +318,7 @@ describe('Autocompletion in hyperlinks', function() end, }) - local result = OrgmodeOmniCompletion(0, '*') + local result = Omni.get_completions('*') assert.are.same({ { menu = '[Org]', word = '*' .. headlines[1].title }, { menu = '[Org]', word = '*' .. headlines[2].title }, @@ -306,7 +348,7 @@ describe('Autocompletion in hyperlinks', function() end, }) - local result = OrgmodeOmniCompletion(0, '#') + local result = Omni.get_completions('#') assert.are.same({ { menu = '[Org]', word = '#' .. custom_ids[1].properties.items.custom_id }, { menu = '[Org]', word = '#' .. custom_ids[2].properties.items.custom_id }, @@ -348,7 +390,7 @@ describe('Autocompletion in hyperlinks', function() mock_line(api, string.format(' [[Tit', file_path_relative)) - local result = OrgmodeOmniCompletion(0, 'Tit') + local result = Omni.get_completions('Tit') assert.are.same({ { menu = '[Org]', word = 'Title with an <>' },