├── README.md ├── autoload └── ncm2_ultisnips.vim ├── ncm2-plugin └── ncm2_ultisnips.py └── pythonx └── ncm2_lsp_snippet ├── .gitignore ├── LICENSE ├── __init__.py ├── parser.py └── utils.py /README.md: -------------------------------------------------------------------------------- 1 | [UltiSnips](https://github.com/SirVer/ultisnips) integration for 2 | [ncm2](https://github.com/ncm2/ncm2). 3 | 4 | ![rec](https://user-images.githubusercontent.com/4538941/42503042-2b7da088-846a-11e8-837d-17432a444d97.gif) 5 | 6 | ## Features 7 | 8 | - snippet completion source 9 | - trigger dynamic snippet of completed item, e.g. parameter expansion. 10 | 11 | ## Reaurements 12 | 13 | - `user_data` found in vim8/nvim's documentation `:help complete-item` 14 | 15 | ## Install 16 | 17 | ```vim 18 | " based on ultisnips 19 | Plug 'ncm2/ncm2-ultisnips' 20 | Plug 'SirVer/ultisnips' 21 | ``` 22 | 23 | ## Vimrc Example 24 | 25 | ```vim 26 | " Press enter key to trigger snippet expansion 27 | " The parameters are the same as `:help feedkeys()` 28 | inoremap ncm2_ultisnips#expand_or("\", 'n') 29 | 30 | " c-j c-k for moving in snippet 31 | " let g:UltiSnipsExpandTrigger = "(ultisnips_expand)" 32 | let g:UltiSnipsJumpForwardTrigger = "" 33 | let g:UltiSnipsJumpBackwardTrigger = "" 34 | let g:UltiSnipsRemoveSelectModeMappings = 0 35 | ``` 36 | 37 | `:help UltiSnips` for more information on using UltiSnips. 38 | 39 | ## API 40 | 41 | If you need more control over the completed item's snippet expansion, you 42 | might need these two APIs to help program your key mapping. 43 | 44 | `ncm2_ultisnips#completed_is_snippet()` 45 | 46 | Checks whether the `v:completed_item` is also a snippet. 47 | 48 | `(ncm2_ultisnips_expand_completed)` 49 | 50 | Use this key to expand the completed snippet. 51 | -------------------------------------------------------------------------------- /autoload/ncm2_ultisnips.vim: -------------------------------------------------------------------------------- 1 | if get(s:, 'loaded', 0) 2 | finish 3 | endif 4 | let s:loaded = 1 5 | 6 | inoremap (ncm2_ultisnips_expand_completed) =ncm2_ultisnips#_do_expand_completed() 7 | 8 | func! ncm2_ultisnips#expand_or(...) 9 | if !pumvisible() 10 | call call('feedkeys', a:000) 11 | return '' 12 | endif 13 | let s:or_key = a:000 14 | return "\\=ncm2_ultisnips#_do_expand_or()\" 15 | endfunc 16 | 17 | func! ncm2_ultisnips#_do_expand_or() 18 | if ncm2_ultisnips#completed_is_snippet() 19 | call feedkeys("\(ncm2_ultisnips_expand_completed)", "m") 20 | return '' 21 | endif 22 | call call('feedkeys', s:or_key) 23 | return '' 24 | endfunc 25 | 26 | func! ncm2_ultisnips#_do_expand_completed() 27 | if !ncm2_ultisnips#completed_is_snippet() 28 | echom "v:completed_item is not a snippet" 29 | return '' 30 | endif 31 | let completed = deepcopy(v:completed_item) 32 | let ud = json_decode(completed.user_data) 33 | let completed.user_data = ud 34 | if ud.snippet == '' 35 | " ultisnips builtin snippet 36 | call feedkeys("\(_ncm2_ultisnips_expand)", "im") 37 | return '' 38 | endif 39 | let &undolevels = &undolevels 40 | py3 from ncm2_lsp_snippet.utils import apply_additional_text_edits 41 | py3 import vim 42 | py3 apply_additional_text_edits(vim.eval('json_encode(l:completed)')) 43 | let snippet = ud.ultisnips_snippet 44 | let trigger = ud.snippet_word 45 | let ret = UltiSnips#Anon(snippet, trigger, 'i', 'i') 46 | call feedkeys("\(ncm2_skip_auto_trigger)", "m") 47 | return ret 48 | endfunc 49 | 50 | if !has("patch-8.0.1493") 51 | func! ncm2_ultisnips#_do_expand_or() 52 | call call('feedkeys', s:or_key) 53 | return '' 54 | endfunc 55 | endif 56 | 57 | func! ncm2_ultisnips#completed_is_snippet() 58 | if empty(v:completed_item) 59 | return 0 60 | endif 61 | silent! let ud = json_decode(v:completed_item.user_data) 62 | if empty(ud) || type(ud) != v:t_dict 63 | return 0 64 | endif 65 | return get(ud, 'is_snippet', 0) 66 | endfunc 67 | 68 | " completion source 69 | 70 | let g:ncm2_ultisnips#source = extend(get(g:, 'ncm2_ultisnips#source', {}), { 71 | \ 'name': 'ultisnips', 72 | \ 'priority': 7, 73 | \ 'mark': 'us', 74 | \ 'word_pattern': '\S+', 75 | \ 'on_complete': 'ncm2_ultisnips#on_complete', 76 | \ }, 'keep') 77 | 78 | func! ncm2_ultisnips#init() 79 | call ncm2#register_source(g:ncm2_ultisnips#source) 80 | 81 | exec 'imap (_ncm2_ultisnips_expand)' g:UltiSnipsExpandTrigger 82 | 83 | if !has("patch-8.0.1493") 84 | echohl ErrorMsg 85 | echom 'ncm2-ultisnips requires has("patch-8.0.1493")' 86 | \ ' https://github.com/neovim/neovim/pull/8003' 87 | echohl None 88 | endif 89 | endfunc 90 | 91 | func! ncm2_ultisnips#on_complete(ctx) 92 | let snips = UltiSnips#SnippetsInCurrentScope() 93 | let matches = map(keys(snips),'{"word":v:val, "dup":1, "icase":1, "menu": l:snips[v:val], "user_data": {"is_snippet": 1}}') 94 | call ncm2#complete(a:ctx, a:ctx['startccol'], matches) 95 | endfunc 96 | -------------------------------------------------------------------------------- /ncm2-plugin/ncm2_ultisnips.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | def wrap(): 5 | def ultisnips_text(txt): 6 | txt = txt.replace('\\', '\\\\') 7 | txt = txt.replace('$', r'\$') 8 | txt = txt.replace('{', r'\{') 9 | txt = txt.replace('}', r'\}') 10 | txt = txt.replace('`', r'\`') 11 | return txt 12 | 13 | def ultisnips_placeholder(num, txt=''): 14 | if txt: 15 | # : doesn't work in placeholder 16 | txt = ultisnips_text(txt) 17 | return '${%s:%s}' % (num, txt) 18 | else: 19 | return '${%s}' % (num) 20 | 21 | def to_ultisnips(ast): 22 | txt = '' 23 | for t, ele in ast: 24 | if t == 'text': 25 | txt += ultisnips_text(ele) 26 | elif t == 'tabstop': 27 | txt += "${%s}" % ele 28 | elif t == 'placeholder': 29 | tab, ph = ele 30 | txt += "${%s:%s}" % (tab, to_ultisnips(ph)) 31 | elif t == 'choice': 32 | # ultisnips doesn't support choices, replace it with placeholder 33 | tab, opts = ele 34 | txt += "${%s:%s}" % (tab, ultisnips_text(opts[0])) 35 | return txt 36 | 37 | from ncm2_core import ncm2_core 38 | from ncm2 import getLogger 39 | import vim 40 | from ncm2_lsp_snippet.parser import Parser 41 | import ncm2_lsp_snippet.utils as lsp_utils 42 | import re 43 | 44 | logger = getLogger(__name__) 45 | 46 | vim.command('call ncm2_ultisnips#init()') 47 | 48 | old_formalize = ncm2_core.match_formalize 49 | old_decorate = ncm2_core.matches_decorate 50 | 51 | parser = Parser() 52 | 53 | # convert lsp snippet into ultisnips snippet 54 | def formalize(ctx, item): 55 | item = old_formalize(ctx, item) 56 | item = lsp_utils.match_formalize(ctx, item) 57 | ud = item['user_data'] 58 | if not ud['is_snippet']: 59 | return item 60 | if not ud['snippet']: 61 | return item 62 | try: 63 | ast = parser.get_ast(ud['snippet']) 64 | ultisnips = to_ultisnips(ast) 65 | if ultisnips: 66 | ud['ultisnips_snippet'] = ultisnips 67 | ud['is_snippet'] = 1 68 | else: 69 | ud['is_snippet'] = 0 70 | except: 71 | ud['is_snippet'] = 0 72 | logger.exception("ncm2_lsp_snippet failed parsing item %s", item) 73 | return item 74 | 75 | # add [+] mark for snippets 76 | def decorate(data, matches): 77 | matches = old_decorate(data, matches) 78 | 79 | has_snippet = False 80 | 81 | for m in matches: 82 | ud = m['user_data'] 83 | if not ud.get('is_snippet', False): 84 | continue 85 | has_snippet = True 86 | 87 | if not has_snippet: 88 | return matches 89 | 90 | for m in matches: 91 | ud = m['user_data'] 92 | if ud.get('is_snippet', False): 93 | # [+] sign indicates that this completion item is 94 | # expandable 95 | if ud.get('ncm2_ultisnips_auto', False): 96 | m['menu'] = '(+) ' + m['menu'] 97 | else: 98 | m['menu'] = '[+] ' + m['menu'] 99 | else: 100 | m['menu'] = '[ ] ' + m['menu'] 101 | 102 | return matches 103 | 104 | ncm2_core.matches_decorate = decorate 105 | ncm2_core.match_formalize = formalize 106 | 107 | 108 | wrap() 109 | -------------------------------------------------------------------------------- /pythonx/ncm2_lsp_snippet/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | -------------------------------------------------------------------------------- /pythonx/ncm2_lsp_snippet/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2018 roxma@qq.com 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included 11 | in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 15 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 18 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 19 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /pythonx/ncm2_lsp_snippet/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ncm2/ncm2-ultisnips/a7462f3b7036dce045a472d8ec9d8fb9fb090212/pythonx/ncm2_lsp_snippet/__init__.py -------------------------------------------------------------------------------- /pythonx/ncm2_lsp_snippet/parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os.path 3 | 4 | tabstop_pat1 = re.compile(r'^\$(\d+)') 5 | tabstop_pat2 = re.compile(r'^\$\{(\d+)\}') 6 | 7 | placeholder_pat = re.compile(r'^\$\{(\d+):(.*?)\}') 8 | 9 | var_pat1 = re.compile(r'^\$([_a-zA-Z][_a-zA-Z0-9]*)') 10 | var_pat2 = re.compile(r'^\$\{([_a-zA-Z][_a-zA-Z0-9]*)\}') 11 | 12 | choice_pat = re.compile(r'^\$\{(\d+)\|(.*?[^\\])?\|\}') 13 | 14 | 15 | class Parser: 16 | 17 | def get_elements(self, s, pos, escs=['$', '\\'], loose_escs=['}']): 18 | elements = [] 19 | while True: 20 | if len(s) == pos: 21 | break 22 | ele, end = self.get_tabstop(s, pos) 23 | if ele is not None: 24 | elements.append(['tabstop', ele]) 25 | pos = end 26 | continue 27 | ele, end = self.get_placeholder(s, pos) 28 | if ele is not None: 29 | elements.append(['placeholder', ele]) 30 | pos = end 31 | continue 32 | ele, end = self.get_choice(s, pos) 33 | if ele is not None: 34 | elements.append(['choice', ele]) 35 | pos = end 36 | continue 37 | ele, end = self.get_variable(s, pos) 38 | if ele is not None: 39 | elements += ele 40 | pos = end 41 | continue 42 | ele, end = self.get_text(s, pos, escs, loose_escs) 43 | if ele is None: 44 | pos = end 45 | break 46 | pos = end 47 | elements.append(['text', ele]) 48 | if elements == []: 49 | return None, pos 50 | return elements, pos 51 | 52 | def get_text(self, s, pos, escs, loose_escs = []): 53 | s = s[pos:] 54 | ele = '' 55 | end = pos 56 | while len(s): 57 | esc = s[:2] 58 | if esc in ['\\'+e for e in escs + loose_escs]: 59 | ele += s[1] 60 | end += 2 61 | s = s[2:] 62 | continue 63 | # unexpected unescaped character 64 | if s[0] in escs: 65 | break 66 | end += 1 67 | ele += s[0] 68 | s = s[1:] 69 | if len(ele) == 0: 70 | return None, pos 71 | return ele, end 72 | 73 | def get_tabstop(self, s, pos): 74 | m = tabstop_pat1.search(s[pos:]) 75 | if m: 76 | return int(m.group(1)), pos + m.end() 77 | m = tabstop_pat2.search(s[pos:]) 78 | if m: 79 | return int(m.group(1)), pos + m.end() 80 | return None, pos 81 | 82 | def get_placeholder(self, s, pos): 83 | m = placeholder_pat.search(s[pos:]) 84 | if not m: 85 | return None, pos 86 | tab = int(m.group(1)) 87 | # NOTE specialcase, placeholder with empty text, not sure whether it is 88 | # valid placeholder 89 | if m.group(2) == '': 90 | return [tab, ["text", ""]], pos + m.end() 91 | subeles, pos = self.get_elements(s, pos + m.start(2), escs=['$', '}', '\\'], loose_escs = []) 92 | if pos == len(s) or s[pos] != '}': 93 | self.invalid_near(s, pos, "expecting '}' character") 94 | return [tab, subeles], pos + 1 95 | 96 | def get_choice(self, s, pos=0): 97 | m = choice_pat.search(s[pos:]) 98 | if not m: 99 | return None, pos 100 | tab = int(m.group(1)) 101 | # there's no nested opts 102 | end = pos + m.end() 103 | opts = [] 104 | # parse opts "one,two,three" 105 | opts_txt = m.group(2) or "" 106 | c_pos = 0 107 | while True: 108 | if c_pos == len(opts_txt): 109 | break 110 | cho, c_end = self.get_text(opts_txt, c_pos, ['$', '}', '\\', ',', '|']) 111 | if cho is None: 112 | self.invalid_near(s, pos + m.start(2) + c_pos, 113 | "get_text failed for choices") 114 | opts.append(cho) 115 | c_pos = c_end 116 | if c_pos == len(opts_txt): 117 | break 118 | if opts_txt[c_pos] != ',': 119 | self.invalid_near(s, pos + m.start(2) + 120 | c_pos, "expecting comma") 121 | c_pos += 1 122 | if c_pos == len(opts_txt): 123 | # FIXME empty choice ? 124 | opts.append('') 125 | break 126 | return opts, end 127 | 128 | def invalid_near(self, s, pos, reason): 129 | if pos < len(s): 130 | s = s[:pos] + '>>' + s[pos] + '<<' + s[pos+1:] 131 | raise Exception("encounter invalid syntax: [%s] %s" % (s, reason)) 132 | 133 | def get_variable(self, s, pos): 134 | # variable: '$' var | '${' var }' 135 | # FIXME These two format is tooo-complicated and not supported 136 | # | '${' var ':' any '}' 137 | # | '${' var '/' regex '/' (format | text)+ '/' options '}' 138 | m = var_pat1.search(s[pos:]) 139 | if m: 140 | return [["text", os.path.expandvars(m.group())]], pos + m.end() 141 | m = var_pat2.search(s[pos:]) 142 | if m: 143 | return [["text", os.path.expandvars(m.group())]], pos + m.end() 144 | return None, pos 145 | 146 | def get_ast(self, snippet): 147 | eles, pos = self.get_elements(snippet, 0) 148 | if pos != len(snippet): 149 | self.invalid_near(snippet, pos, "encounter invalid character") 150 | return eles 151 | 152 | 153 | 154 | # snippet = "hello $123 $HOME fooba" 155 | # snippet = """unshift(${1:newelt})${0}""" 156 | # ast = Parser().get_ast(snippet) 157 | # print(ast) 158 | # 159 | # 160 | # def snipmate_escape(txt): 161 | # txt = txt.replace('$', r'\$') 162 | # txt = txt.replace('{', r'\{') 163 | # txt = txt.replace('}', r'\}') 164 | # txt = txt.replace(':', r'\:') 165 | # return txt 166 | # 167 | # def to_snipmate(ast): 168 | # txt = '' 169 | # for t, ele in ast: 170 | # if t == 'text': 171 | # txt += snipmate_escape(ele) 172 | # elif t == 'tabstop': 173 | # txt += "${%s}" % ele 174 | # elif t == 'placeholder': 175 | # tab, ph = ele 176 | # txt += "${%s:%s}" % (tab, to_snipmate(ph)) 177 | # return txt 178 | # 179 | # print(to_snipmate(ast)) 180 | -------------------------------------------------------------------------------- /pythonx/ncm2_lsp_snippet/utils.py: -------------------------------------------------------------------------------- 1 | 2 | def apply_additional_text_edits(completed): 3 | import vim 4 | import json 5 | if type(completed) is str: 6 | completed = json.loads(completed) 7 | ud = completed['user_data'] 8 | lspitem = ud.get('ncm2_lspitem', None) 9 | if lspitem: 10 | apply_lsp_additional_text_edits(ud, lspitem) 11 | 12 | 13 | def apply_lsp_additional_text_edits(user_data, lspitem): 14 | import vim 15 | import json 16 | 17 | additional_text_edits = lspitem.get('additionalTextEdits', None) 18 | 19 | data = lspitem.get('data', None) 20 | if not additional_text_edits and data: 21 | if user_data.get('vim_lsp', None): 22 | # https://github.com/ncm2/ncm2-vim-lsp 23 | vim.vars['_ncm2_lsp_snippet_tmp'] = \ 24 | json.dumps([user_data, lspitem]) 25 | expr = r"json_encode(call('ncm2_vim_lsp#completionitem_resolve', json_decode(g:_ncm2_lsp_snippet_tmp)))" 26 | resolved = json.loads(vim.eval(expr)) 27 | if resolved: 28 | additional_text_edits = resolved.get('additionalTextEdits', None) 29 | else: 30 | # for vim8 compatibility 31 | vim.vars['_ncm2_lsp_snippet_tmp'] = json.dumps(lspitem) 32 | expr = r"json_encode(LanguageClient_runSync('LanguageClient#completionItem_resolve', json_decode(g:_ncm2_lsp_snippet_tmp), {}))" 33 | resolved = json.loads(vim.eval(expr)) 34 | if resolved: 35 | additional_text_edits = resolved.get('additionalTextEdits', None) 36 | 37 | if not additional_text_edits: 38 | return 39 | 40 | additional_text_edits.sort( 41 | key=lambda e: [- e['range']['start']['line'], 42 | - e['range']['start']['character']]) 43 | 44 | buf = vim.current.buffer 45 | 46 | i = 0 47 | num = len(additional_text_edits) 48 | while i < num: 49 | 50 | edit = additional_text_edits[i] 51 | start = edit['range']['start'] 52 | end = edit['range']['end'] 53 | new_text = edit['newText'] 54 | 55 | # (LSP spec) However, it is possible that multiple edits have the same 56 | # start position: multiple inserts, or any number of inserts followed by a 57 | # single remove or replace edit. If multiple inserts have the same 58 | # position, the order in the array defines the order in which the inserted 59 | # strings appear in the resulting text. 60 | # 61 | # merge multiple inserts 62 | while start == end and i+1 < num: 63 | editn = additional_text_edits[i+1] 64 | startn = editn['range']['start'] 65 | endn = editn['range']['end'] 66 | if startn == start: 67 | new_text += editn['newText'] 68 | i += 1 69 | else: 70 | break 71 | 72 | 73 | lines = buf[start['line']: end['line'] + 1] 74 | prefix = lines[0][: start['character']] 75 | postfix = lines[-1][end['character']:] 76 | new_text = prefix + new_text + postfix 77 | buf[start['line']: end['line'] + 1] = new_text.split("\n") 78 | 79 | # this is super stupid but I'm not sure there's a safe escape 80 | # function for vim, and I don't want external dependency either. 81 | vim.vars['_ncm2_lsp_snippet_tmp'] = "auto edit: " + edit['newText'] 82 | vim.command("echom g:_ncm2_lsp_snippet_tmp") 83 | del vim.vars['_ncm2_lsp_snippet_tmp'] 84 | 85 | i += 1 86 | 87 | 88 | def snippet_escape_text(txt): 89 | txt = txt.replace('\\', '\\\\') 90 | txt = txt.replace('$', r'\$') 91 | txt = txt.replace('}', r'\}') 92 | return txt 93 | 94 | 95 | def match_formalize_from_lspitem(ctx, item, lspitem): 96 | ud = item['user_data'] 97 | label = lspitem['label'] 98 | item['abbr'] = label 99 | 100 | is_snippet = lspitem.get('insertTextFormat', 1) == 2 101 | ud['is_snippet'] = is_snippet 102 | 103 | if 'insertText' in lspitem: 104 | item['word'] = lspitem['insertText'] 105 | else: 106 | item['word'] = label 107 | 108 | if is_snippet: 109 | # snippet plugins does not work well with spaces 110 | item['word'] = label.strip() 111 | 112 | ud['snippet'] = lspitem.get('insertText', label) 113 | 114 | # prefer text_edit 115 | te = lspitem.get('textEdit', None) 116 | if te: 117 | testart = te['range']['start'] 118 | teend = te['range']['end'] 119 | new_text = te['newText'] 120 | # Note from spec: 121 | # *Note:* The range of the edit must be a single line range and 122 | # it must contain the position at which completion has been 123 | # requested. 124 | if (testart['line'] == ctx['lnum'] - 1 and 125 | teend['character'] <= ctx['ccol'] - 1): 126 | if is_snippet: 127 | ud['snippet'] = new_text 128 | else: 129 | item['word'] = new_text 130 | ud['startccol'] = testart['character'] + 1 131 | 132 | if 'data' in lspitem: 133 | # snippet with additionalTextEdits after resolve 134 | ud['is_snippet'] = 1 135 | is_snippet = 1 136 | if not ud.get('snippet', None): 137 | ud['snippet'] = snippet_escape_text(item['word']) 138 | 139 | # we don't need lspitem anymore, in case LanguageClient-neovim. is 140 | # messing with it in CompleteDone 141 | if 'lspitem' in ud: 142 | ud['ncm2_lspitem'] = ud['lspitem'] 143 | del ud['lspitem'] 144 | 145 | 146 | def match_formalize(ctx, item): 147 | ud = item['user_data'] # type: dict 148 | lnum = ctx['lnum'] 149 | ccol = ctx['ccol'] 150 | 151 | if 'lspitem' in ud: 152 | match_formalize_from_lspitem(ctx, item, ud['lspitem']) 153 | 154 | ud.setdefault('snippet', '') 155 | is_snippet = ud.setdefault('is_snippet', 0) 156 | 157 | if is_snippet: 158 | ud.setdefault('snippet_word', item['word']) 159 | return item 160 | --------------------------------------------------------------------------------