From 5bbc31f9cd1cd1542e8cdd65a29c795e5e752dd1 Mon Sep 17 00:00:00 2001 From: Kristijan Husak Date: Tue, 12 Aug 2025 19:12:59 +0200 Subject: [PATCH] feat(autocomplete): Fuzzy match autocompletion --- lua/orgmode/org/autocompletion/_meta.lua | 2 +- lua/orgmode/org/autocompletion/blink.lua | 1 + lua/orgmode/org/autocompletion/cmp.lua | 1 + lua/orgmode/org/autocompletion/init.lua | 23 ++++++++++++-- .../org/autocompletion/sources/hyperlinks.lua | 2 +- lua/orgmode/org/links/_meta.lua | 2 +- lua/orgmode/org/links/init.lua | 12 +++---- lua/orgmode/org/links/types/custom_id.lua | 6 ++-- lua/orgmode/org/links/types/headline.lua | 10 +++--- .../org/links/types/headline_search.lua | 31 +++++++------------ lua/orgmode/utils/fs.lua | 31 +++++++++++++++++++ 11 files changed, 81 insertions(+), 40 deletions(-) diff --git a/lua/orgmode/org/autocompletion/_meta.lua b/lua/orgmode/org/autocompletion/_meta.lua index d73d752cf..d07c99838 100644 --- a/lua/orgmode/org/autocompletion/_meta.lua +++ b/lua/orgmode/org/autocompletion/_meta.lua @@ -1,6 +1,6 @@ ---@meta ----@alias OrgCompletionContext { line: string, base?: string } +---@alias OrgCompletionContext { line: string, base?: string, fuzzy?: boolean, matcher?: fun(value?: string, pattern?: string): boolean } ---@alias OrgCompletionItem { word: string, menu: string } ---@class OrgCompletionSource diff --git a/lua/orgmode/org/autocompletion/blink.lua b/lua/orgmode/org/autocompletion/blink.lua index 55d2ba555..4f4755fea 100644 --- a/lua/orgmode/org/autocompletion/blink.lua +++ b/lua/orgmode/org/autocompletion/blink.lua @@ -21,6 +21,7 @@ function Source:get_completions(ctx, callback) local results = org.completion:complete({ line = line, base = base, + fuzzy = true, }) local cb = function(items) diff --git a/lua/orgmode/org/autocompletion/cmp.lua b/lua/orgmode/org/autocompletion/cmp.lua index fcac2a90b..d2595144b 100644 --- a/lua/orgmode/org/autocompletion/cmp.lua +++ b/lua/orgmode/org/autocompletion/cmp.lua @@ -30,6 +30,7 @@ function Source:complete(params, callback) local results = org.completion:complete({ line = params.context.cursor_before_line, base = base, + fuzzy = true, }) local items = {} for _, item in ipairs(results) do diff --git a/lua/orgmode/org/autocompletion/init.lua b/lua/orgmode/org/autocompletion/init.lua index 72dcaf34d..71b0ac921 100644 --- a/lua/orgmode/org/autocompletion/init.lua +++ b/lua/orgmode/org/autocompletion/init.lua @@ -44,6 +44,19 @@ end ---@return OrgCompletionItem function OrgCompletion:complete(context) local results = {} + context.base = context.base or '' + if not context.matcher then + context.matcher = function(value, pattern) + pattern = pattern or '' + if pattern == '' then + return true + end + if context.fuzzy then + return #vim.fn.matchfuzzy({ value }, pattern) > 0 + end + return value:find('^' .. vim.pesc(pattern)) ~= nil + end + end for _, source in ipairs(self.sources) do if source:get_start(context) then vim.list_extend(results, self:_get_valid_results(source:get_results(context), context)) @@ -53,12 +66,13 @@ function OrgCompletion:complete(context) return results end +---@param results string[] +---@param context OrgCompletionContext +---@return OrgCompletionItem[] function OrgCompletion:_get_valid_results(results, context) - local base = context.base or '' - local valid_results = {} for _, item in ipairs(results) do - if base == '' or item:find('^' .. vim.pesc(base)) then + if context.matcher(item, context.base) then table.insert(valid_results, { word = item, menu = self.menu, @@ -89,6 +103,9 @@ function OrgCompletion:omnifunc(findstart, base) self._context = self._context or { line = self:get_line() } self._context.base = base + if vim.tbl_contains(vim.opt_local.completeopt:get(), 'fuzzy') then + self._context.fuzzy = true + end return self:complete(self._context) end diff --git a/lua/orgmode/org/autocompletion/sources/hyperlinks.lua b/lua/orgmode/org/autocompletion/sources/hyperlinks.lua index d542daee4..d7826cad4 100644 --- a/lua/orgmode/org/autocompletion/sources/hyperlinks.lua +++ b/lua/orgmode/org/autocompletion/sources/hyperlinks.lua @@ -25,7 +25,7 @@ end ---@param context OrgCompletionContext ---@return string[] function OrgCompletionHyperlinks:get_results(context) - return self.completion.links:autocomplete(context.base) + return self.completion.links:autocomplete(context) end return OrgCompletionHyperlinks diff --git a/lua/orgmode/org/links/_meta.lua b/lua/orgmode/org/links/_meta.lua index 9130c9f2c..60dd11a5b 100644 --- a/lua/orgmode/org/links/_meta.lua +++ b/lua/orgmode/org/links/_meta.lua @@ -3,4 +3,4 @@ ---@class OrgLinkType ---@field get_name fun(self: OrgLinkType): string ---@field follow fun(self: OrgLinkType, link: string): boolean ----@field autocomplete fun(self: OrgLinkType, link: string): string[] +---@field autocomplete fun(self: OrgLinkType, context: OrgCompletionContext): string[] diff --git a/lua/orgmode/org/links/init.lua b/lua/orgmode/org/links/init.lua index c8b118ed5..63cb7b1a6 100644 --- a/lua/orgmode/org/links/init.lua +++ b/lua/orgmode/org/links/init.lua @@ -56,22 +56,20 @@ function OrgLinks:follow(link) return self.headline_search:follow(link) end ----@param link string +---@param context OrgCompletionContext ---@return string[] -function OrgLinks:autocomplete(link) - local pattern = '^' .. vim.pesc(link:lower()) - +function OrgLinks:autocomplete(context) local items = vim.tbl_filter(function(stored_link) - return stored_link:lower():match(pattern) + return context.matcher(stored_link, context.base) end, vim.tbl_keys(self.stored_links)) for _, source in ipairs(self.types) do if source.autocomplete then - utils.concat(items, source:autocomplete(link)) + utils.concat(items, source:autocomplete(context)) end end - utils.concat(items, self.headline_search:autocomplete(link)) + utils.concat(items, self.headline_search:autocomplete(context)) return items end diff --git a/lua/orgmode/org/links/types/custom_id.lua b/lua/orgmode/org/links/types/custom_id.lua index 45123c13a..e8d96e2aa 100644 --- a/lua/orgmode/org/links/types/custom_id.lua +++ b/lua/orgmode/org/links/types/custom_id.lua @@ -42,10 +42,10 @@ function OrgLinkCustomId:follow(link) return link_utils.open_file_and_search(opts.file_path, opts.custom_id) end ----@param link string +---@param context OrgCompletionContext ---@return string[] -function OrgLinkCustomId:autocomplete(link) - local opts = self:_parse(link) +function OrgLinkCustomId:autocomplete(context) + local opts = self:_parse(context.base) if not opts then return {} end diff --git a/lua/orgmode/org/links/types/headline.lua b/lua/orgmode/org/links/types/headline.lua index 465e33985..df654b14d 100644 --- a/lua/orgmode/org/links/types/headline.lua +++ b/lua/orgmode/org/links/types/headline.lua @@ -38,10 +38,10 @@ function OrgLinkHeadline:follow(link) return link_utils.open_file_and_search(opts.file_path, opts.headline) end ----@param link string +---@param context OrgCompletionContext ---@return string[] -function OrgLinkHeadline:autocomplete(link) - local opts = self:_parse(link) +function OrgLinkHeadline:autocomplete(context) + local opts = self:_parse(context.base) if not opts then return {} end @@ -52,7 +52,9 @@ function OrgLinkHeadline:autocomplete(link) return {} end - local headlines = file:find_headlines_by_title(opts.headline) + local headlines = vim.tbl_filter(function(headline) + return context.matcher(headline:get_title(), opts.headline) + end, file:get_headlines()) local prefix = opts.type == 'internal' and '' or opts.link_url:get_path_with_protocol() .. '::' return vim.tbl_map(function(headline) diff --git a/lua/orgmode/org/links/types/headline_search.lua b/lua/orgmode/org/links/types/headline_search.lua index 5878dc53e..d485057ab 100644 --- a/lua/orgmode/org/links/types/headline_search.lua +++ b/lua/orgmode/org/links/types/headline_search.lua @@ -59,10 +59,10 @@ function OrgLinkHeadlineSearch:follow(link) return link_utils.open_file_and_search(opts.file_path, search_text) end ----@param link string +---@param context OrgCompletionContext ---@return string[] -function OrgLinkHeadlineSearch:autocomplete(link) - local opts = self:_parse(link) +function OrgLinkHeadlineSearch:autocomplete(context) + local opts = self:_parse(context.base) if not opts then return {} end @@ -71,22 +71,9 @@ function OrgLinkHeadlineSearch:autocomplete(link) local filenames = self.files:filenames() local valid_filenames = {} for _, f in ipairs(filenames) do - if f:find('^' .. opts.file_path) then - f = f:gsub('^' .. opts.file_path, opts.link_url.path) - table.insert(valid_filenames, f) - end - - local real_path = opts.link_url:get_real_path() - - if not real_path then - local substitute_path = fs.substitute_path(opts.file_path) - if substitute_path then - local full_path = vim.fn.fnamemodify(substitute_path, ':p') - if f:find('^' .. full_path) then - f = f:gsub('^' .. full_path, opts.link_url.path) - table.insert(valid_filenames, f) - end - end + local converted_path = fs.convert_path(opts.link_url.path, f) + if context.matcher(converted_path, opts.link_url.path) then + table.insert(valid_filenames, converted_path) end end @@ -108,11 +95,15 @@ function OrgLinkHeadlineSearch:autocomplete(link) return headline:get_title() end, file:find_headlines_matching_search_term(pattern, true)) + local matching_headlines = vim.tbl_filter(function(headline) + return context.matcher(headline:get_title(), opts.headline_text) + end, file:get_headlines()) + utils.concat( headlines, vim.tbl_map(function(headline) return headline:get_title() - end, file:find_headlines_by_title(opts.headline_text)), + end, matching_headlines), true ) local prefix = opts.type == 'internal' and '' or opts.link_url:get_path_with_protocol() .. '::' diff --git a/lua/orgmode/utils/fs.lua b/lua/orgmode/utils/fs.lua index b3d4bc94b..d06c2883e 100644 --- a/lua/orgmode/utils/fs.lua +++ b/lua/orgmode/utils/fs.lua @@ -20,6 +20,37 @@ function M.substitute_path(path_str) return false end +--Convert absolute path to the same format as source path +--If source path has relative parts, like ~, ./ or ../, +--apply same to the long path and return +---@param source_path string +---@param long_path string +function M.convert_path(source_path, long_path) + if source_path:match('^/') then + return long_path + end + + if source_path:match('^~/') then + local home_path = os.getenv('HOME') + if home_path then + return long_path:gsub('^' .. vim.pesc(home_path), '~') + end + return long_path + end + + if source_path:match('^%./') then + local base = vim.fn.fnamemodify(utils.current_file_path(), ':p:h') + return long_path:gsub('^' .. vim.pesc(base), '.') + end + + if source_path:match('^%.%./') then + local base = vim.fn.fnamemodify(utils.current_file_path(), ':p:h:h') + return long_path:gsub('^' .. vim.pesc(base), '..') + end + + return long_path +end + ---@param filepath string function M.get_real_path(filepath) if not filepath then