diff --git a/README.md b/README.md index 1570ee6..1b29323 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,18 @@ setting `g:asyncomplete_remove_duplicates` to 1. let g:asyncomplete_remove_duplicates = 1 ``` +### Smart Completion + +To enable fuzzy smart completion: + +```vim +let g:asyncomplete_smart_completion = 1 +let g:asyncomplete_auto_popup = 1 +``` + +Refer to docs to checks if your vim or neovim supports smart completion. +Auto popup is required to support smart completion. + #### Preview Window To disable preview window: diff --git a/autoload/asyncomplete.vim b/autoload/asyncomplete.vim index b947a2b..4c3cde0 100644 --- a/autoload/asyncomplete.vim +++ b/autoload/asyncomplete.vim @@ -18,7 +18,15 @@ let s:has_popped_up = 0 let s:complete_timer_ctx = {} let s:already_setup = 0 let s:next_tick_single_exec_metadata = {} +let s:has_lua = has('lua') || has('nvim-0.2.2') let s:supports_getbufinfo = exists('*getbufinfo') +let s:supports_smart_completion = exists('##TextChangedP') +let s:asyncomplete_folder = fnamemodify(expand(':p:h') . '/../', ':p:h') + +function! s:init_lua() abort + exec 'lua asyncomplete_folder="' . s:asyncomplete_folder . '"' + exec 'luafile ' . s:asyncomplete_folder . '/lua/asyncomplete.lua' +endfunction function! asyncomplete#log(...) abort if !empty(g:asyncomplete_log_file) @@ -34,6 +42,9 @@ augroup END function! asyncomplete#enable_for_buffer() abort if !s:already_setup + if s:has_lua + call s:init_lua() + endif doautocmd User asyncomplete_setup let s:already_setup = 1 endif @@ -262,13 +273,15 @@ function! s:remote_refresh(ctx, force) abort let l:startpos = l:matchpos[1] let l:endpos = l:matchpos[2] - call asyncomplete#log('core', 's:remote_refresh', l:matchpos, a:ctx) + call asyncomplete#log('core', 's:remote_refresh', l:name, l:matchpos, a:ctx) let l:typed_len = l:endpos - l:startpos if l:typed_len == 1 call add(l:sources_to_notify, l:name) elseif has_key(s:matches, l:name) && s:matches[l:name]['refresh'] call add(l:sources_to_notify, l:name) + elseif s:supports_smart_completion() + call s:python_refresh_completions(a:ctx) endif endfor @@ -427,7 +440,11 @@ function! s:python_refresh_completions(ctx) abort let l:normalizedcurmatches += [l:e] endfor - let l:filtered_matches += s:filter_completion_items(l:prefix, l:normalizedcurmatches) + if s:supports_smart_completion() + let l:filtered_matches += l:normalizedcurmatches + else + let l:filtered_matches += s:filter_completion_items(l:prefix, l:normalizedcurmatches) + endif endfor call s:core_complete(a:ctx, l:startcol, l:filtered_matches, s:matches) @@ -473,7 +490,18 @@ function! s:core_complete(ctx, startcol, matches, allmatches) abort setlocal completeopt+=noselect endif - call complete(a:startcol, a:matches) + call asyncomplete#log('core', 's:core_complete') + + let l:candidates = s:supports_smart_completion() ? s:custom_filter_completion_items(a:ctx['typed'][a:startcol-1 : col('.') - 1], a:matches) : a:matches + call complete(a:startcol, l:candidates) +endfunction + +function! s:supports_smart_completion() abort + return s:supports_smart_completion && g:asyncomplete_smart_completion +endfunction + +function! s:custom_filter_completion_items(prefix, matches) abort + return luaeval('asyncomplete.filter_completion_items(_A.prefix, _A.matches)', { 'prefix': a:prefix, 'matches': a:matches }) endfunction function! s:complete_timeout(timer) abort diff --git a/doc/asyncomplete.txt b/doc/asyncomplete.txt index 3a81419..ed96a64 100644 --- a/doc/asyncomplete.txt +++ b/doc/asyncomplete.txt @@ -40,13 +40,27 @@ g:asyncomplete_auto_popup *g:asyncomplete_auto_popup* Automatically show the autocomplete popup menu as you start typing. -g:asyncomplete_default_refresh_pattern *g:asyncomplete_default_refresh_pattern* +g:asyncomplete_default_refresh_pattern *g:asyncomplete_default_refresh_pattern* Type: |String| Default: `'\(\k\+$\|\.$\|:$\)'` Default refresh pattern to use for when to show the popup menu. +g:asyncomplete_smart_completion *g:asyncomplete_smart_completion* + + Type: |Number| + Default: 0 + + Enable smart completion i.e. fuzzy match. + Requirements: + * Vim8 with lua support `:echo has('lua')` or Neovim 0.2.2+ + * TextChangedP autocmd support - `:echo exists('##TextChangedP')` + * `let g:asyncomplete_auto_popup = 1` + + Feel free to suggest or send pull requests to improve the smart completion + algorithm. + g:asyncomplete_force_refresh_on_context_changed *g:asyncomplete_force_refresh_on_context_changed* Type: |Number| diff --git a/lua/asyncomplete.lua b/lua/asyncomplete.lua new file mode 100644 index 0000000..f4d9c0b --- /dev/null +++ b/lua/asyncomplete.lua @@ -0,0 +1,81 @@ +local module = {} +local fts = dofile(asyncomplete_folder .. '/lua/fts_fuzzy_match.lua') + +local is_neovim = vim['api'] ~= nil and vim.api['nvim_eval'] ~= nil + +if is_neovim then + function to_vim_list(obj) + return obj + end + function dump(o) + if type(o) == 'table' then + return vim.api.nvim_call_function('string', o) + else + return tostring(o) + end + end +else + function dump(o) + if type(o) == 'table' then + local s = '{ ' + for k,v in pairs(o) do + if type(k) ~= 'number' then k = '"'..k..'"' end + s = s .. '['..k..'] = ' .. asyncomplete.dump(v) .. ',' + end + return s .. '} ' + else + return tostring(o) + end + end + + function to_vim_list(obj) + return vim.list(obj) + end +end + +function module.filter_completion_items(prefix, matches) + local result = {} + local result = {} + local index = 0 + local unsorted_matches = {} + for i = 0, #matches - 1 do + local match = matches[i] + if match ~= nil then + local word = match['word'] + local matched, score, matchedIndices = fts.fuzzy_match(prefix, word) + if matched == true then + table.insert(unsorted_matches, { score = score, match = match }) + end + end + end + for k,v in spairs(unsorted_matches, function(t,a,b) return t[b].score < t[a].score end) do + table.insert(result, v.match) + end + + return to_vim_list(result) +end + +function spairs(t, order) + -- collect the keys + local keys = {} + for k in pairs(t) do keys[#keys+1] = k end + + -- if order function given, sort by it by passing the table and keys a, b, + -- otherwise just sort the keys + if order then + table.sort(keys, function(a,b) return order(t, a, b) end) + else + table.sort(keys) + end + + -- return the iterator function + local i = 0 + return function() + i = i + 1 + if keys[i] then + return keys[i], t[keys[i]] + end + end +end + +asyncomplete = module diff --git a/lua/fts_fuzzy_match.lua b/lua/fts_fuzzy_match.lua new file mode 100644 index 0000000..5ae86c8 --- /dev/null +++ b/lua/fts_fuzzy_match.lua @@ -0,0 +1,156 @@ +-- LICENSE +-- +-- This software is dual-licensed to the public domain and under the following +-- license: you are granted a perpetual, irrevocable license to copy, modify, +-- publish, and distribute this file as you see fit. + +-- VERSION 0.1.0 + +-- Author: Forrest Smith (github.com/forrestthewoods/lib_fts) +-- Translated to Lua by Blake Mealey (github.com/blake-mealey) + +local module = {} + +-- Returns true if each character in pattern is found sequentially within str +function module.fuzzy_match_simple(pattern, str) + local patternIdx = 1 + local strIdx = 1 + local patternLength = #pattern + local strLength = #str + + while (patternIdx <= patternLength and strIdx <= strLength) do + local patternChar = pattern:sub(patternIdx, patternIdx):lower() + local strChar = str:sub(strIdx, strIdx):lower() + if patternChar == strChar then + patternIdx = patternIdx + 1 + end + strIdx = strIdx + 1 + end + + return patternLength ~= 0 and strLength ~= 0 and (patternIdx - 1) == patternLength +end + +-- Returns [bool, score, matchedIndices] +-- bool: true if each character in pattern is found sequentially within str +-- score: integer; higher is better match. Value has no intrinsic meaning. Range localies with pattern. +-- Can only compare scores with same search pattern. +-- matchedIndices: the indices of characters that were matched in str +function module.fuzzy_match(pattern, str) + + -- Score consts + local adjacency_bonus = 5 -- bonus for adjacent matches + local separator_bonus = 10 -- bonus if match occurs after a separator + local camel_bonus = 10 -- bonus if match is uppercase and prev is lower + local leading_letter_penalty = -3 -- penalty applied for every letter in str before the first match + local max_leading_letter_penalty = -9 -- maximum penalty for leading letters + local unmatched_letter_penalty = -1 -- penalty for every letter that doesn't matter + + -- Loop localiables + local score = 0 + local patternIdx = 1 + local patternLength = #pattern + local strIdx = 1 + local strLength = #str + local prevMatched = false + local prevLower = false + local prevSeparator = true -- true so if first letter match gets separator bonus + + -- Use "best" matched letter if multiple string letters match the pattern + local bestLetter = nil + local bestLower = nil + local bestLetterIdx = nil + local bestLetterScore = 0 + + local matchedIndices = {} + + -- Loop over strings + while (strIdx <= strLength) do + local patternChar = patternIdx <= patternLength and pattern:sub(patternIdx, patternIdx) or nil + local strChar = str:sub(strIdx, strIdx) + + local patternLower = patternChar and patternChar:lower() or nil + local strLower = strChar:lower() + local strUpper = strChar:upper() + + local nextMatch = patternChar and patternLower == strLower + local rematch = bestLetter and bestLower == strLower + + local advanced = nextMatch and bestLetter + local patternRepeat = bestLetter and patternChar and bestLower == patternLower + if advanced or patternRepeat then + score = score + bestLetterScore + table.insert(matchedIndices, bestLetterIdx) + bestLetter = nil + bestLower = nil + bestLetterIdx = nil + bestLetterScore = 0 + end + + if nextMatch or rematch then + local newScore = 0 + + -- Apply penalty for each letter before the first pattern match + -- Note: std::max because penalties are negative values. So max is smallest penalty. + if patternIdx == 0 then + local penalty = math.max(strIdx * leading_letter_penalty, max_leading_letter_penalty) + score = score + penalty + end + + -- Apply bonus for consecutive bonuses + if prevMatched then + newScore = newScore + adjacency_bonus + end + + -- Apply bonus for matches after a separator + if prevSeparator then + newScore = newScore + separator_bonus + end + + -- Apply bonus across camel case boundaries. Includes "clever" isLetter check. + if prevLower and strChar == strUpper and strLower ~= strUpper then + newScore = newScore + camel_bonus + end + + -- Update patter index IFF the next pattern letter was matched + if nextMatch then + patternIdx = patternIdx + 1 + end + + -- Update best letter in str which may be for a "next" letter or a "rematch" + if newScore >= bestLetterScore then + + -- Apply penalty for now skipped letter + if bestLetter then + score = score + unmatched_letter_penalty + end + + bestLetter = strChar + bestLower = bestLetter:lower() + bestLetterIdx = strIdx + bestLetterScore = newScore + end + + prevMatched = true + else + score = score + unmatched_letter_penalty + prevMatched = false + end + + -- Includes "clever" isLetter check. + prevLower = strChar == strLower and strLower ~= strUpper + prevSeparator = strChar == '_' or strChar == ' ' + + strIdx = strIdx + 1 + end + + -- Apply score for last match + if bestLetter then + score = score + bestLetterScore + table.insert(matchedIndices, bestLetterIdx) + end + + local matched = patternIdx - 1 == patternLength + return matched, score, matchedIndices +end + +return module diff --git a/plugin/asyncomplete.vim b/plugin/asyncomplete.vim index 7187feb..f7ea235 100644 --- a/plugin/asyncomplete.vim +++ b/plugin/asyncomplete.vim @@ -3,6 +3,8 @@ if exists('g:asyncomplete_loaded') endif let g:asyncomplete_loaded = 1 +let s:has_lua = has('lua') || has('nvim-0.2.2') + if get(g:, 'asyncomplete_enable_for_all', 1) augroup asyncomplete_enable au! @@ -14,6 +16,7 @@ let g:asyncomplete_auto_popup = get(g:, 'asyncomplete_auto_popup', 1) let g:asyncomplete_completion_delay = get(g:, 'asyncomplete_completion_delay', 100) let g:asyncomplete_default_refresh_pattern = get(g:, 'asyncomplete_default_refresh_pattern', '\(\k\+$\|\.$\|>$\|:$\)') let g:asyncomplete_log_file = get(g:, 'asyncomplete_log_file', '') +let g:asyncomplete_smart_completion = get(g:, 'asyncomplete_smart_completion', s:has_lua && exists('##TextChangedP')) let g:asyncomplete_remove_duplicates = get(g:, 'asyncomplete_remove_duplicates', 0) " Setting it to true may slow/hang vim especially on slow are sources such as asyncomplete-lsp.vim