diff --git a/autoload/coc/compat.vim b/autoload/coc/compat.vim index 4e6866e6c0b..8ff08db7f67 100644 --- a/autoload/coc/compat.vim +++ b/autoload/coc/compat.vim @@ -88,7 +88,7 @@ function! coc#compat#buf_del_var(bufnr, name) abort if exists('*nvim_buf_del_var') silent! call nvim_buf_del_var(a:bufnr, a:name) else - if bufnr == bufnr('%') + if a:bufnr == bufnr('%') execute 'unlet! b:'.a:name elseif exists('*win_execute') let winid = coc#compat#buf_win_id(a:bufnr) @@ -122,6 +122,14 @@ function! coc#compat#matchaddgroups(winid, groups) abort endif endfunction +function! coc#compat#del_var(name) abort + if exists('*nvim_del_var') + silent! call nvim_del_var(a:name) + else + execute 'unlet! '.a:name + endif +endfunction + " remove keymap for specific buffer function! coc#compat#buf_del_keymap(bufnr, mode, lhs) abort if !bufloaded(a:bufnr) @@ -142,7 +150,7 @@ function! coc#compat#buf_del_keymap(bufnr, mode, lhs) abort if exists('*win_execute') let winid = coc#compat#buf_win_id(a:bufnr) if winid != -1 - call win_execute(winid, 'silent! '.a:mode.'unmap '.a:lhs) + call win_execute(winid, a:mode.'unmap '.a:lhs, 'silent!') endif endif endfunction diff --git a/autoload/coc/highlight.vim b/autoload/coc/highlight.vim index bf194d6d4d7..1b4e7f8af5c 100644 --- a/autoload/coc/highlight.vim +++ b/autoload/coc/highlight.vim @@ -333,7 +333,7 @@ function! coc#highlight#ranges(bufnr, key, hlGroup, ranges, ...) abort endif " TODO don't know how to count UTF16 code point, should work most cases. let colStart = lnum == start['line'] + 1 ? strlen(strcharpart(line, 0, start['character'])) : 0 - let colEnd = lnum == end['line'] + 1 ? strlen(strcharpart(line, 0, end['character'])) : -1 + let colEnd = lnum == end['line'] + 1 ? strlen(strcharpart(line, 0, end['character'])) : strlen(line) if colStart == colEnd continue endif diff --git a/autoload/coc/snippet.vim b/autoload/coc/snippet.vim index bb63637e103..67ecc2cb23a 100644 --- a/autoload/coc/snippet.vim +++ b/autoload/coc/snippet.vim @@ -1,6 +1,7 @@ scriptencoding utf-8 let s:is_vim = !has('nvim') let s:map_next = 1 +let s:cmd_mapping = has('nvim') || has('patch-8.2.1978') function! coc#snippet#_select_mappings() if !get(g:, 'coc_selectmode_mapping', 1) @@ -38,7 +39,6 @@ function! coc#snippet#enable() return endif let b:coc_snippet_active = 1 - silent! unlet g:coc_selected_text call coc#snippet#_select_mappings() let nextkey = get(g:, 'coc_snippet_next', '') let prevkey = get(g:, 'coc_snippet_prev', '') @@ -48,9 +48,10 @@ function! coc#snippet#enable() if s:map_next execute 'inoremap '.nextkey." =coc#rpc#request('snippetNext', [])" endif + let pre = s:cmd_mapping ? '' : '' execute 'inoremap '.prevkey." =coc#rpc#request('snippetPrev', [])" - execute 'snoremap '.prevkey." :call coc#rpc#request('snippetPrev', [])" - execute 'snoremap '.nextkey." :call coc#rpc#request('snippetNext', [])" + execute 'snoremap '.prevkey." ".pre.":call coc#rpc#request('snippetPrev', [])" + execute 'snoremap '.nextkey." ".pre.":call coc#rpc#request('snippetNext', [])" endfunction function! coc#snippet#disable() diff --git a/autoload/health/coc.vim b/autoload/health/coc.vim index cf226cba5d0..b1cad5be3d2 100644 --- a/autoload/health/coc.vim +++ b/autoload/health/coc.vim @@ -44,6 +44,9 @@ function! s:checkEnvironment() abort silent pyx print("") catch /.*/ call health#report_warn('pyx command not work, some extensions may fail to work, checkout ":h pythonx"') + if has('nvim') + call health#report_warn('Install pynvim by command: pip install pynvim --upgrade') + endif endtry endif return valid diff --git a/data/schema.json b/data/schema.json index 77becbbdc6c..3639cbc10c3 100644 --- a/data/schema.json +++ b/data/schema.json @@ -1393,6 +1393,11 @@ "default": "SNIP", "description": "Text shown in statusline to indicate snippet session is activated." }, + "coc.preferences.snippetHighlight": { + "type": "boolean", + "description": "Use highlight group 'CocSnippetVisual' to highlight placeholders with same index of current one.", + "default": true + }, "coc.preferences.currentFunctionSymbolAutoUpdate": { "type": "boolean", "description": "Automatically update the value of b:coc_current_function on CursorHold event", diff --git a/doc/coc.txt b/doc/coc.txt index e49f6bd73a9..0d4ad07bcf3 100644 --- a/doc/coc.txt +++ b/doc/coc.txt @@ -898,6 +898,13 @@ Built-in configurations:~ Valid options: ["daily","weekly","never"] +"coc.preferences.snippetHighlight":~ + + Use highlight group 'CocSnippetVisual' to highlight placeholders with + same index of current one. + + default: `true` + "coc.preferences.snippetStatusText":~ Text shown in 'statusline' to indicate snippet session is activate. @@ -3036,6 +3043,7 @@ Others~ *CocMenuSel* for current menu item in menu dialog, works on neovim only since vim doesn't support change highlight group of cursorline inside popup. *CocSelectedRange* for highlight ranges of outgoing calls. +*CocSnippetVisual* for highlight snippet placeholders. Semantic highlights~ *coc-semantic-highlights* diff --git a/package.json b/package.json index 863d5949766..3f8bb5234a5 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "vscode-languageserver": "7.0.0" }, "dependencies": { - "@chemzqm/neovim": "^5.7.4", + "@chemzqm/neovim": "^5.7.5", "ansi-styles": "^5.0.0", "bytes": "^3.1.0", "cli-table": "^0.3.4", diff --git a/plugin/coc.vim b/plugin/coc.vim index bc4754c495e..e711bf97128 100644 --- a/plugin/coc.vim +++ b/plugin/coc.vim @@ -386,6 +386,8 @@ function! s:Hi() abort hi default link CocCursorRange Search hi default link CocHighlightRead CocHighlightText hi default link CocHighlightWrite CocHighlightText + " Snippet + hi default link CocSnippetVisual Visual " Tree view highlights hi default link CocTreeTitle Title hi default link CocTreeDescription Comment diff --git a/src/__tests__/core/editors.test.ts b/src/__tests__/core/editors.test.ts index 48b75b05cbe..1e5d336fe0b 100644 --- a/src/__tests__/core/editors.test.ts +++ b/src/__tests__/core/editors.test.ts @@ -48,7 +48,7 @@ describe('editors', () => { resolve(e) }, null, disposables) }) - await nvim.command('vs') + await nvim.command('sp') let editor = await promise let winid = await nvim.call('win_getid') expect(editor.winid).toBe(winid) diff --git a/src/__tests__/core/terminals.test.ts b/src/__tests__/core/terminals.test.ts index e1b1b889959..a345e82f772 100644 --- a/src/__tests__/core/terminals.test.ts +++ b/src/__tests__/core/terminals.test.ts @@ -104,6 +104,7 @@ describe('create terminal', () => { }) let res = await terminal.show(true) expect(res).toBe(true) + expect(typeof terminal.bufnr).toBe('number') let winid = await nvim.call('bufwinid', [terminal.bufnr]) let curr = await nvim.call('win_getid', []) expect(winid != curr).toBe(true) diff --git a/src/__tests__/handler/outline.test.ts b/src/__tests__/handler/outline.test.ts index 2f6bd21e4f0..715eb132377 100644 --- a/src/__tests__/handler/outline.test.ts +++ b/src/__tests__/handler/outline.test.ts @@ -314,8 +314,8 @@ describe('symbols outline', () => { strictIndexing: false }) await doc.synchronize() - await helper.wait(200) buf = await getOutlineBuffer() + await helper.waitFor('eval', [`getbufline(${buf.id},1)[0]`], /No\sresults/) let lines = await buf.lines expect(lines).toEqual([ 'No results', @@ -355,10 +355,10 @@ describe('symbols outline', () => { await createBuffer() let bufnr = await nvim.call('bufnr', ['%']) await symbols.showOutline(0) - await helper.waitFor('getline', [1], 'OUTLINE') + await helper.waitFor('getline', [3], /fun1/) await nvim.command('exe 3') await nvim.input('') - await helper.wait(50) + await helper.waitFloat() await nvim.input('') await helper.waitFor('mode', [], 'v') let buf = await nvim.buffer diff --git a/src/__tests__/handler/semanticTokens.test.ts b/src/__tests__/handler/semanticTokens.test.ts index 10fadcfcafe..1998c22027c 100644 --- a/src/__tests__/handler/semanticTokens.test.ts +++ b/src/__tests__/handler/semanticTokens.test.ts @@ -401,16 +401,16 @@ describe('semanticTokens', () => { describe('rangeProvider', () => { it('should invoke range provider first time when both kind exists', async () => { - let t = 0 + let fn = jest.fn() disposables.push(registerRangeProvider('rust', () => { - t++ + fn() return [] })) let buf = await createRustBuffer() let item = highlighter.getItem(buf.id) await item.waitRefresh() await helper.wait(30) - expect(t).toBe(1) + expect(fn).toBeCalled() }) it('should do range highlight first time', async () => { @@ -445,7 +445,7 @@ describe('semanticTokens', () => { return [] })) await nvim.command('normal! G') - await helper.wait(50) + await helper.wait(100) expect(r).toBeDefined() expect(r.end).toEqual({ line: 201, character: 0 }) }) diff --git a/src/__tests__/handler/signature.test.ts b/src/__tests__/handler/signature.test.ts index 299169a2231..d36dea01fbd 100644 --- a/src/__tests__/handler/signature.test.ts +++ b/src/__tests__/handler/signature.test.ts @@ -89,39 +89,6 @@ describe('signatureHelp', () => { let lines = await helper.getWinLines(win.id) expect(lines.join('\n')).toMatch(/description/) }) - - it('should consider coc_last_placeholder on select mode', async () => { - let pos: Position - disposables.push(languages.registerSignatureHelpProvider([{ scheme: 'file' }], { - provideSignatureHelp: (_doc, position) => { - pos = position - return { - signatures: [ - SignatureInformation.create('foo(a, b)', 'my signature', ParameterInformation.create('a', 'description')), - ], - activeParameter: 1, - activeSignature: null - } - } - }, [])) - let doc = await helper.createDocument() - let line = await nvim.call('line', ['.']) - await nvim.setLine(' fn(abc, def)') - await nvim.command('normal! 0fave') - await nvim.input('') - let placeholder = { - bufnr: doc.bufnr, - start: Position.create(line - 1, 5), - end: Position.create(line - 1, 8) - } - await nvim.setVar('coc_last_placeholder', placeholder) - let m = await nvim.mode - expect(m.mode).toBe('s') - await signature.triggerSignatureHelp() - let win = await helper.getFloat() - expect(win).toBeDefined() - expect(pos).toEqual(Position.create(0, 5)) - }) }) describe('events', () => { diff --git a/src/__tests__/helper.ts b/src/__tests__/helper.ts index 946ae475d67..6b80e81b3d4 100644 --- a/src/__tests__/helper.ts +++ b/src/__tests__/helper.ts @@ -48,6 +48,15 @@ export class Helper extends EventEmitter { this.setMaxListeners(99) } + public setupNvim(): void { + const vimrc = path.resolve(__dirname, 'vimrc') + let proc = this.proc = cp.spawn('nvim', ['-u', vimrc, '-i', 'NONE', '--embed'], { + cwd: __dirname + }) + let plugin = attach({ proc }) + this.nvim = plugin.nvim + } + public setup(): Promise { const vimrc = path.resolve(__dirname, 'vimrc') let proc = this.proc = cp.spawn('nvim', ['-u', vimrc, '-i', 'NONE', '--embed'], { @@ -79,7 +88,7 @@ export class Helper extends EventEmitter { } public async shutdown(): Promise { - this.plugin.dispose() + if (this.plugin) this.plugin.dispose() this.nvim.removeAllListeners() this.nvim = null if (this.proc) { @@ -278,7 +287,7 @@ export class Helper extends EventEmitter { for (let i = 0; i < 40; i++) { await this.wait(50) let res = await this.nvim.call(method, args) as T - if (res == value) { + if (res == value || (value instanceof RegExp && value.test(res.toString()))) { find = true break } diff --git a/src/__tests__/modules/completion.test.ts b/src/__tests__/modules/completion.test.ts index 1d284592fc5..3ac7325270f 100644 --- a/src/__tests__/modules/completion.test.ts +++ b/src/__tests__/modules/completion.test.ts @@ -2,6 +2,7 @@ import { Neovim } from '@chemzqm/neovim' import { Disposable } from 'vscode-jsonrpc' import { CompletionItem, CompletionList, InsertTextFormat, Position, Range, TextEdit } from 'vscode-languageserver-types' import completion from '../../completion' +import events from '../../events' import languages from '../../languages' import { CompletionItemProvider } from '../../provider' import snippetManager from '../../snippets/manager' @@ -9,7 +10,6 @@ import sources from '../../sources' import { CompleteOption, CompleteResult, ISource, SourceType } from '../../types' import { disposeAll } from '../../util' import workspace from '../../workspace' -import events from '../../events' import helper from '../helper' let nvim: Neovim @@ -130,7 +130,6 @@ describe('completion resumeCompletion', () => { it('should stop if no filtered items', async () => { await nvim.setLine('foo ') - await helper.wait(50) await nvim.input('Af') await helper.waitPopup() expect(completion.isActivated).toBe(true) @@ -482,9 +481,7 @@ describe('completion TextChangedP', () => { await nvim.input('i?') await helper.waitPopup() await nvim.eval('feedkeys("\\", "in")') - await helper.wait(200) - let line = await nvim.line - expect(line).toBe('?foo') + await helper.waitFor('getline', ['.'], '?foo') }) it('should fix cursor position with snippet on additionalTextEdits', async () => { @@ -503,16 +500,7 @@ describe('completion TextChangedP', () => { let res = await helper.getItems() let idx = res.findIndex(o => o.menu == '[edit]') await helper.selectCompleteItem(idx) - let line: string - for (let i = 0; i < 40; i++) { - await helper.wait(50) - line = await nvim.line - if (line == 'bar if()') break - } - expect(line).toBe('bar if()') - let [, lnum, col] = await nvim.call('getcurpos') - expect(lnum).toBe(1) - expect(col).toBe(8) + await helper.waitFor('col', ['.'], 8) }) it('should fix cursor position with plain text snippet on additionalTextEdits', async () => { @@ -553,10 +541,8 @@ describe('completion TextChangedP', () => { await nvim.input('if') await helper.waitPopup() await helper.selectCompleteItem(0) - await helper.wait(200) - let line = await nvim.line + await helper.waitFor('getline', ['.'], 'bar func(do)') let [, lnum, col] = await nvim.call('getcurpos') - expect(line).toBe('bar func(do)') expect(lnum).toBe(1) expect(col).toBe(12) }) @@ -582,7 +568,7 @@ describe('completion TextChangedP', () => { await helper.selectCompleteItem(idx) await helper.waitFor('getline', ['.'], 'foo = foo0bar1') await helper.wait(50) - expect(snippetManager.isActived(doc.bufnr)).toBe(true) + expect(snippetManager.session).toBeDefined() let [, lnum, col] = await nvim.call('getcurpos') expect(lnum).toBe(1) expect(col).toBe(3) @@ -839,9 +825,8 @@ describe('completion TextChangedI', () => { helper.updateConfiguration('suggest.acceptSuggestionOnCommitCharacter', true) helper.updateConfiguration('suggest.noselect', false) let source: ISource = { - priority: 0, enable: true, - name: 'slow', + name: 'commit', sourceType: SourceType.Service, triggerCharacters: ['.'], doComplete: (opt: CompleteOption): Promise => { @@ -856,20 +841,6 @@ describe('completion TextChangedI', () => { await nvim.input('if') await helper.waitPopup() await nvim.input('.') - await helper.wait(100) - let line = await nvim.line - expect(line).toBe('foo.') - }) - - it('should cancel completion with same pretext', async () => { - await nvim.setLine('foo') - await nvim.input('of') - await helper.waitPopup() - await nvim.input('') - await helper.wait(100) - let line = await nvim.line - let visible = await nvim.call('pumvisible') - expect(line).toBe('f') - expect(visible).toBe(0) + await helper.waitFor('getline', ['.'], 'foo.') }) }) diff --git a/src/__tests__/modules/floatFactory.test.ts b/src/__tests__/modules/floatFactory.test.ts index ee0d6a060ac..a8a5c43b6cf 100644 --- a/src/__tests__/modules/floatFactory.test.ts +++ b/src/__tests__/modules/floatFactory.test.ts @@ -197,9 +197,7 @@ describe('FloatFactory', () => { }] await floatFactory.show(docs) await nvim.command(`edit foo`) - await helper.wait(50) - let hasFloat = await nvim.call('coc#float#has_float') - expect(hasFloat).toBe(0) + await helper.waitFor('coc#float#has_float', [], 0) }) it('should hide on CursorMoved', async () => { @@ -210,13 +208,9 @@ describe('FloatFactory', () => { content: 'foo' }] await floatFactory.show(docs) - let hasFloat = await nvim.call('coc#float#has_float') - expect(hasFloat).toBe(1) - await helper.wait(30) + await helper.waitFloat() await nvim.input('$') - await helper.wait(200) - hasFloat = await nvim.call('coc#float#has_float') - expect(hasFloat).toBe(0) + await helper.waitFor('coc#float#has_float', [], 0) }) it('should not hide when cursor position not changed', async () => { @@ -231,9 +225,8 @@ describe('FloatFactory', () => { await nvim.call('cursor', [1, 2]) await helper.wait(10) await nvim.call('cursor', cursor) - await helper.wait(200) - let hasFloat = await nvim.call('coc#float#has_float') - expect(hasFloat).toBe(1) + await helper.wait(10) + await helper.waitFor('coc#float#has_float', [], 1) }) it('should preserve float when autohide disable and not overlap with pum', async () => { @@ -253,9 +246,7 @@ describe('FloatFactory', () => { let activated = await floatFactory.activated() expect(activated).toBe(true) await nvim.input('') - await helper.wait(100) - let pumvisible = await helper.pumvisible() - expect(pumvisible).toBe(true) + await helper.waitPopup() activated = await floatFactory.activated() expect(activated).toBe(true) }) diff --git a/src/__tests__/modules/sources.test.ts b/src/__tests__/modules/sources.test.ts index a16ff8eb8aa..d9f8b825599 100644 --- a/src/__tests__/modules/sources.test.ts +++ b/src/__tests__/modules/sources.test.ts @@ -193,10 +193,6 @@ describe('sources#getTriggerSources()', () => { await nvim.input('Af') await helper.wait(100) let visible = await nvim.call('pumvisible') - if (visible) { - let items = await helper.getItems() - console.log(items) - } expect(visible).toBe(0) }) }) diff --git a/src/__tests__/modules/util.test.ts b/src/__tests__/modules/util.test.ts index 1ecb4f97c64..8a5581246d6 100644 --- a/src/__tests__/modules/util.test.ts +++ b/src/__tests__/modules/util.test.ts @@ -290,11 +290,16 @@ describe('Arrays', () => { assert.ok(!arrays.intersect([1, 2, 3], [4, 5])) }) - test('group', async () => { + test('group', () => { let res = arrays.group([1, 2, 3, 4, 5], 3) assert.deepStrictEqual(res, [[1, 2, 3], [4, 5]]) }) + test('groupBy', () => { + let res = arrays.groupBy([0, 0, 3, 4], v => v != 0) + assert.deepStrictEqual(res, [[3, 4], [0, 0]]) + }) + test('lastIndex', () => { let res = arrays.lastIndex([1, 2, 3], x => x < 3) assert.strictEqual(res, 1) diff --git a/src/__tests__/modules/window.test.ts b/src/__tests__/modules/window.test.ts index 225c0b5d901..5ea5253e5c2 100644 --- a/src/__tests__/modules/window.test.ts +++ b/src/__tests__/modules/window.test.ts @@ -115,18 +115,13 @@ describe('window functions', () => { disposables.push(emitter) disposables.push(treeView) await treeView.show() - await helper.wait(50) - await nvim.command('exe 2') - await nvim.input('t') - await helper.wait(50) - let lines = await nvim.call('getline', [1, '$']) - expect(lines).toEqual(['files', '- a', ' b.js']) + let filetype = await nvim.eval('&filetype') + expect(filetype).toBe('coctree') }) it('should show outputChannel', async () => { window.createOutputChannel('channel') window.showOutputChannel('channel') - await helper.wait(50) let buf = await nvim.buffer let name = await buf.name expect(name).toMatch('channel') diff --git a/src/__tests__/snippets/manager.test.ts b/src/__tests__/snippets/manager.test.ts index 9746e387ae4..406d27f49c7 100644 --- a/src/__tests__/snippets/manager.test.ts +++ b/src/__tests__/snippets/manager.test.ts @@ -1,7 +1,7 @@ import { Neovim } from '@chemzqm/neovim' import path from 'path' -import { InsertTextMode, Range } from 'vscode-languageserver-protocol' -import { URI } from 'vscode-uri' +import { InsertTextMode, Range, TextEdit } from 'vscode-languageserver-protocol' +import commandManager from '../../commands' import Document from '../../model/document' import snippetManager from '../../snippets/manager' import { SnippetString } from '../../snippets/string' @@ -28,30 +28,35 @@ beforeEach(async () => { }) describe('snippet provider', () => { + describe('insertSnippet command', () => { + it('should insert ultisnips snippet', async () => { + await nvim.setLine('foo') + let edit = TextEdit.replace(Range.create(0, 0, 0, 3), '${1:`echo "bar"`}') + await commandManager.executeCommand('editor.action.insertSnippet', edit, true) + let line = await nvim.line + expect(line).toBe('bar') + edit = TextEdit.replace(Range.create(0, 0, 0, 3), '${1:`echo "foo"`}') + await commandManager.executeCommand('editor.action.insertSnippet', edit, { regex: '' }) + line = await nvim.line + expect(line).toBe('foo') + }) + }) + describe('insertSnippet()', () => { it('should not active when insert plain snippet', async () => { await snippetManager.insertSnippet('foo') let line = await nvim.line expect(line).toBe('foo') - expect(snippetManager.session).toBe(null) + expect(snippetManager.session).toBe(undefined) expect(snippetManager.getSession(doc.bufnr)).toBeUndefined() - expect(snippetManager.isActived(doc.bufnr)).toBe(false) - }) - - it('should resolve variables', async () => { - await snippetManager.insertSnippet('${foo:abcdef} ${bar}') - let line = await nvim.line - expect(line).toBe('abcdef bar') }) it('should start new session if session exists', async () => { await nvim.setLine('bar') await snippetManager.insertSnippet('${1:foo} ') - await helper.wait(100) await nvim.input('') await nvim.command('stopinsert') await nvim.input('A') - await helper.wait(100) let active = await snippetManager.insertSnippet('${2:bar}') expect(active).toBe(true) let line = await nvim.getLine() @@ -61,42 +66,10 @@ describe('snippet provider', () => { it('should start nest session', async () => { await snippetManager.insertSnippet('${1:foo} ${2:bar}') await nvim.input('') - await helper.wait(100) let active = await snippetManager.insertSnippet('${1:x} $1') expect(active).toBe(true) }) - it('should not consider plaintext as placeholder', async () => { - await snippetManager.insertSnippet('${1} ${2:bar}') - await nvim.input('$foo;') - await helper.wait(100) - await snippetManager.insertSnippet('${1:x}', false, Range.create(0, 5, 0, 6)) - await helper.wait(100) - let line = await nvim.line - expect(line).toBe('$foo;xbar') - }) - - it('should insert nest plain snippet', async () => { - await snippetManager.insertSnippet('${1:foo} ${2:bar}') - await nvim.input('') - await helper.wait(100) - let active = await snippetManager.insertSnippet('bar') - expect(active).toBe(true) - let cursor = await nvim.call('coc#cursor#position') - expect(cursor).toEqual([0, 3]) - }) - - it('should work with nest snippet', async () => { - let buf = await helper.edit() - let snip = '\n$0\n' - await snippetManager.insertSnippet(snip) - await helper.wait(30) - await nvim.input('abcde') - await helper.wait(100) - let lines = await buf.lines - expect(lines).toEqual(['', '', '']) - }) - it('should insert snippetString', async () => { let snippetString = new SnippetString() .appendTabstop(1) @@ -104,11 +77,9 @@ describe('snippet provider', () => { .appendPlaceholder('bar', 2) await snippetManager.insertSnippet(snippetString) await nvim.input('$foo;') - await helper.wait(100) snippetString = new SnippetString() .appendVariable('foo', 'x') await snippetManager.insertSnippet(snippetString, false, Range.create(0, 5, 0, 6)) - await helper.wait(100) let line = await nvim.line expect(line).toBe('$foo;xbar') }) @@ -118,7 +89,6 @@ describe('snippet provider', () => { it('should goto next placeholder', async () => { await snippetManager.insertSnippet('${1:a} ${2:b}') await snippetManager.nextPlaceholder() - await helper.wait(30) let col = await nvim.call('col', '.') expect(col).toBe(3) }) @@ -126,10 +96,22 @@ describe('snippet provider', () => { it('should remove keymap on nextPlaceholder when session not exits', async () => { await nvim.call('coc#snippet#enable') await snippetManager.nextPlaceholder() - await helper.wait(60) let val = await doc.buffer.getVar('coc_snippet_active') expect(val).toBe(0) }) + + it('should respect preferCompleteThanJumpPlaceholder', async () => { + let config = workspace.getConfiguration('suggest') + config.update('preferCompleteThanJumpPlaceholder', true) + await nvim.setLine('foo') + await nvim.input('o') + await snippetManager.insertSnippet('${1:foo} ${2:bar}') + await nvim.input('f') + await helper.waitPopup() + await snippetManager.nextPlaceholder() + await helper.waitFor('getline', ['.'], 'foo bar') + config.update('preferCompleteThanJumpPlaceholder', false) + }) }) describe('previousPlaceholder()', () => { @@ -144,7 +126,6 @@ describe('snippet provider', () => { it('should remove keymap on previousPlaceholder when session not exits', async () => { await nvim.call('coc#snippet#enable') await snippetManager.previousPlaceholder() - await helper.wait(60) let val = await doc.buffer.getVar('coc_snippet_active') expect(val).toBe(0) }) @@ -154,12 +135,21 @@ describe('snippet provider', () => { it('should check position on InsertEnter', async () => { await nvim.input('ibar') await snippetManager.insertSnippet('${1:foo} $1 ') - await helper.wait(60) await nvim.input('A') - await helper.wait(60) - expect(snippetManager.session).toBeNull() + await helper.wait(50) + expect(snippetManager.session).toBeUndefined() }) + it('should change status item on editor change', async () => { + await nvim.command('tabe') + await nvim.input('i') + await snippetManager.insertSnippet('${1:foo} $1 ') + let val = await nvim.getVar('coc_status') + expect(val).toBeDefined() + await nvim.setTabpage(nvim.createTabpage(1)) + val = await nvim.getVar('coc_status') as string + expect(val.includes('SNIP')).toBeFalsy() + }) }) describe('cancel()', () => { @@ -167,31 +157,12 @@ describe('snippet provider', () => { let buffer = doc.buffer await nvim.call('coc#snippet#enable') snippetManager.cancel() - await helper.wait(60) let val = await buffer.getVar('coc_snippet_active') expect(val).toBe(0) let active = await snippetManager.insertSnippet('${1:foo}') expect(active).toBe(true) snippetManager.cancel() - expect(snippetManager.session).toBeNull() - }) - }) - - describe('configuration', () => { - it('should respect preferCompleteThanJumpPlaceholder', async () => { - let config = workspace.getConfiguration('suggest') - config.update('preferCompleteThanJumpPlaceholder', true) - await nvim.setLine('foo') - await nvim.input('o') - await snippetManager.insertSnippet('${1:foo} ${2:bar}') - await helper.wait(10) - await nvim.input('f') - await helper.waitPopup() - await nvim.input('') - await helper.wait(200) - let line = await nvim.getLine() - expect(line).toBe('foo bar') - config.update('preferCompleteThanJumpPlaceholder', false) + expect(snippetManager.session).toBeUndefined() }) }) @@ -202,9 +173,9 @@ describe('snippet provider', () => { let jumpable = snippetManager.jumpable() expect(jumpable).toBe(true) await snippetManager.nextPlaceholder() - await helper.wait(30) + jumpable = snippetManager.jumpable() + expect(jumpable).toBe(true) await snippetManager.nextPlaceholder() - await helper.wait(30) jumpable = snippetManager.jumpable() expect(jumpable).toBe(false) }) @@ -212,10 +183,10 @@ describe('snippet provider', () => { describe('synchronize text', () => { it('should update placeholder on placeholder update', async () => { - await snippetManager.insertSnippet('$1\n${1/,/|/g}', true, undefined, InsertTextMode.adjustIndentation, true) + await snippetManager.insertSnippet('$1\n${1/,/|/g}', true, undefined, InsertTextMode.adjustIndentation, {}) await nvim.input('a,b') - await doc.synchronize() - await helper.wait(50) + let s = snippetManager.getSession(doc.bufnr) + await s.forceSynchronize() let lines = await nvim.call('getline', [1, '$']) expect(lines).toEqual(['a,b', 'a|b']) }) @@ -225,9 +196,9 @@ describe('snippet provider', () => { await snippetManager.insertSnippet('${1/..*/ -> /}$1') let line = await nvim.line expect(line).toBe('') - await helper.wait(60) await nvim.input('x') - await helper.wait(400) + let s = snippetManager.getSession(doc.bufnr) + await s.forceSynchronize() line = await nvim.line expect(line).toBe(' -> x') let col = await nvim.call('col', '.') @@ -240,57 +211,21 @@ describe('snippet provider', () => { expect(res).toBe(true) await nvim.input('abc') await nvim.input('') - await helper.wait(50) - await doc.patchChange() + let s = snippetManager.getSession(doc.bufnr) + await s.forceSynchronize() let line = await nvim.line expect(line).toBe('abcemptyabc') }) - - it('should fix edit to current placeholder', async () => { - await nvim.command('startinsert') - let res = await snippetManager.insertSnippet('()$1$0', true) - expect(res).toBe(true) - await nvim.input('(') - await nvim.input(')') - await nvim.input('') - await helper.wait(50) - await doc.patchChange() - await helper.wait(200) - expect(snippetManager.session).toBeDefined() - }) }) - describe('resolveSnippet', () => { - it('should resolve snippet', async () => { - let fsPath = URI.parse(doc.uri).fsPath - let res = await snippetManager.resolveSnippet(`$TM_FILENAME`) - expect(res.toString()).toBe(path.basename(fsPath)) - res = await snippetManager.resolveSnippet(`$TM_FILENAME_BASE`) - expect(res.toString()).toBe(path.basename(fsPath, path.extname(fsPath))) - res = await snippetManager.resolveSnippet(`$TM_DIRECTORY`) - expect(res.toString()).toBe(path.dirname(fsPath)) - res = await snippetManager.resolveSnippet(`$TM_FILEPATH`) - expect(res.toString()).toBe(fsPath) - await nvim.call('setreg', ['""', 'foo']) - res = await snippetManager.resolveSnippet(`$YANK`) - expect(res.toString()).toBe('foo') - res = await snippetManager.resolveSnippet(`$TM_LINE_INDEX`) - expect(res.toString()).toBe('0') - res = await snippetManager.resolveSnippet(`$TM_LINE_NUMBER`) - expect(res.toString()).toBe('1') - await nvim.setLine('foo') - res = await snippetManager.resolveSnippet(`$TM_CURRENT_LINE`) - expect(res.toString()).toBe('foo') - res = await snippetManager.resolveSnippet(`$TM_CURRENT_WORD`) - expect(res.toString()).toBe('foo') - await nvim.call('setreg', ['*', 'foo']) - res = await snippetManager.resolveSnippet(`$CLIPBOARD`) - expect(res.toString()).toBe('foo') - let d = new Date() - res = await snippetManager.resolveSnippet(`$CURRENT_YEAR`) - expect(res.toString()).toBe(d.getFullYear().toString()) - res = await snippetManager.resolveSnippet(`$NOT_EXISTS`) - expect(res.toString()).toBe('NOT_EXISTS') + describe('resolveSnippet()', () => { + it('should resolve snippet text', async () => { + let pyfile = path.join(__dirname, '../ultisnips.py') + await nvim.command(`execute 'pyxfile '.fnameescape('${pyfile}')`) + let snippet = await snippetManager.resolveSnippet('${1:foo}') + expect(snippet.toString()).toBe('foo') + snippet = await snippetManager.resolveSnippet('${1:foo} ${2:`!p snip.rv = "foo"`}', {}) + expect(snippet.toString()).toBe('foo foo') }) }) @@ -299,7 +234,7 @@ describe('snippet provider', () => { let active = await snippetManager.insertSnippet('${1:foo}') expect(active).toBe(true) snippetManager.dispose() - expect(snippetManager.session).toBe(null) + expect(snippetManager.session).toBeUndefined() }) }) }) diff --git a/src/__tests__/snippets/parser.test.ts b/src/__tests__/snippets/parser.test.ts index 639c0d58833..59ca67938e1 100644 --- a/src/__tests__/snippets/parser.test.ts +++ b/src/__tests__/snippets/parser.test.ts @@ -1,7 +1,7 @@ /* eslint-disable */ import * as assert from 'assert' -import { Scanner, transformEscapes, TokenType, SnippetParser, Text, Placeholder, Variable, Marker, TextmateSnippet, Choice, FormatString, Transform } from '../../snippets/parser' -import { Range } from 'vscode-languageserver-types' +import { EvalKind } from '../../snippets/eval' +import { Choice, CodeBlock, FormatString, Marker, Placeholder, Scanner, SnippetParser, Text, TextmateSnippet, TokenType, Transform, transformEscapes, Variable } from '../../snippets/parser' describe('SnippetParser', () => { @@ -95,8 +95,8 @@ describe('SnippetParser', () => { assert.equal(scanner.next().type, TokenType.EOF) }) - function assertText(value: string, expected: string) { - const p = new SnippetParser() + function assertText(value: string, expected: string, ultisnip = false) { + const p = new SnippetParser(ultisnip) const actual = p.text(value) assert.equal(actual, expected) } @@ -138,6 +138,34 @@ describe('SnippetParser', () => { assertEscaped('$', '\\$') }) + test('Parser, isPlainText()', function() { + const s = (input: string, res: boolean) => { + assert.equal(SnippetParser.isPlainText(input), res) + } + s('abc', true) + s('abc$0', true) + s('ab$0chh', false) + s('ab$1chh', false) + }) + + test('Parser, first placeholder / variable', function() { + const first = (input: string): Marker => { + const p = new SnippetParser(true) + let s = p.parse(input, true) + return s.first + } + const assertPlaceholder = (m: any, index: number) => { + assert.equal(m instanceof Placeholder, true) + assert.equal(m.index, index) + } + assertPlaceholder(first('foo'), 0) + assertPlaceholder(first('${1:foo}'), 1) + assertPlaceholder(first('${2:foo}'), 2) + let f = first('$foo $bar') as Variable + assert.equal(f instanceof Variable, true) + assert.equal(f.name, 'foo') + }) + test('Parser, text', () => { assertText('$', '$') assertText('\\\\$', '\\$') @@ -245,11 +273,140 @@ describe('SnippetParser', () => { }) + test('Parse, parse code block', () => { + assertText('aa \\`echo\\`', 'aa `echo`', true) + assertText('aa `xyz`', 'aa ', true) + assertText('aa `!v xyz`', 'aa ', true) + assertText('aa `!p xyz`', 'aa ', true) + assertText('aa `!p foo\nbar`', 'aa ', true) + const c = text => { + return (new SnippetParser(true)).parse(text) + } + assertMarker(c('`foo`'), CodeBlock) + assertMarker(c('`!v bar`'), CodeBlock) + assertMarker(c('`!p python`'), CodeBlock) + const assertPlaceholder = (text: string, kind: EvalKind, code: string) => { + let p = c(text).children[0] + assert.ok(p instanceof Placeholder) + let m = p.children[0] as CodeBlock + assert.ok(m instanceof CodeBlock) + assert.equal(m.kind, kind) + assert.equal(m.code, code) + } + assertPlaceholder('${1:` foo `}', 'shell', 'foo') + assertPlaceholder('${1:`!v bar`}', 'vim', 'bar') + assertPlaceholder('${1:`!p python`}', 'python', 'python') + assertPlaceholder('${1:`!p x\\`y`}', 'python', 'x\\`y') + assertPlaceholder('${1:`!p x\ny`}', 'python', 'x\ny') + assertPlaceholder('${1:`!p \nx\ny`}', 'python', 'x\ny') + }) + + test('Parser, CodeBlock toTextmateString', () => { + const c = text => { + return (new SnippetParser(true)).parse(text) + } + expect(c('`foo`').toTextmateString()).toBe('`foo`') + expect(c('`!p snip.rv`').toTextmateString()).toBe('`!p snip.rv`') + expect(c('`!v "var"`').toTextmateString()).toBe('`!v "var"`') + }) + + test('Parser, placeholder with CodeBlock primary', () => { + const c = text => { + return (new SnippetParser(true)).parse(text) + } + let s = c('${1/^_(.*)/$1/} $1 aa ${1:`!p snip.rv = "_foo"`}') + let arr = s.placeholders + arr = arr.filter(o => o.index == 1) + assert.equal(arr.length, 3) + let filtered = arr.filter(o => o.primary === true) + assert.equal(filtered.length, 1) + assert.equal(filtered[0], arr[2]) + let childs = arr.map(o => o.children[0]) + assert.ok(childs[0] instanceof Text) + assert.ok(childs[1] instanceof Text) + assert.ok(childs[2] instanceof CodeBlock) + }) + + test('Parser, placeholder with CodeBlock not primary', () => { + const c = text => { + return (new SnippetParser(true)).parse(text) + } + let s = c('${1/^_(.*)/$1/} ${1:_foo} ${2:bar} $1 $3 ${1:`!p snip.rv = "three"`}') + let arr = s.placeholders + arr = arr.filter(o => o.index == 1) + assert.equal(arr.length, 4) + assert.ok(arr[0].transform) + assert.equal(arr[1].primary, true) + assert.equal(arr[2].toString(), '_foo') + assert.equal(arr[3].toString(), '_foo') + assert.deepEqual(s.values, { '0': '', '1': '_foo', '2': 'bar', '3': '' }) + }) + + test('Parser, python CodeBlock with related', () => { + const c = text => { + return (new SnippetParser(true)).parse(text) + } + let s = c('${1:_foo} ${2:bar} $1 $3 ${3:`!p snip.rv = str(t[1]) + str(t[2])`}') + let b = s.pyBlocks[0] + expect(b).toBeDefined() + expect(b.related).toEqual([1, 2]) + }) + + test('Parser, python CodeBlock by sequence', () => { + const c = text => { + return (new SnippetParser(true)).parse(text) + } + let s = c('${2:\{${3:`!p foo`}\}} ${1:`!p bar`}') + let arr = s.pyBlocks + expect(arr[0].code).toBe('foo') + expect(arr[1].code).toBe('bar') + }) + + test('Parser, hasPython()', () => { + const c = text => { + return (new SnippetParser(true)).parse(text) + } + assert.equal(c('${1:`!p foo`}').hasPython, true) + assert.equal(c('`!p foo`').hasPython, true) + assert.equal(c('$1').hasPython, false) + }) + + test('Parser, hasCodeBlock()', () => { + const c = text => { + return (new SnippetParser(true)).parse(text) + } + assert.equal(c('${1:`!p foo`}').hasCodeBlock, true) + assert.equal(c('`!p foo`').hasCodeBlock, true) + assert.equal(c('$1').hasCodeBlock, false) + }) + + test('Parser, resolved variable', () => { + const c = text => { + return (new SnippetParser(true)).parse(text) + } + let s = c('${1:${VISUAL}} $1') + assert.ok(s.children[0] instanceof Placeholder) + assert.ok(s.children[0].children[0] instanceof Variable) + let v = s.children[0].children[0] as Variable + assert.equal(v.name, 'VISUAL') + }) + + test('Parser variable with code', () => { + // not allowed on ultisnips. + const c = text => { + return (new SnippetParser(true)).parse(text) + } + let s = c('${foo:`!p snip.rv = "bar"`}') + assert.ok(s.children[0] instanceof Variable) + assert.ok(s.children[0].children[0] instanceof CodeBlock) + }) + test('Parser, transform condition if text', () => { const p = new SnippetParser(true) let snip = p.parse('begin|${1:t}${1/(t)$|(a)$|(.*)/(?1:abular)(?2:rray)/}') expect(snip.toString()).toBe('begin|tabular') - snip.updatePlaceholder(1, 'a') + let m = snip.placeholders.find(o => o.index == 1 && o.primary) + snip.resetMarker(m, 'a') expect(snip.toString()).toBe('begin|array') }) @@ -271,11 +428,26 @@ describe('SnippetParser', () => { expect(snip.toString()).toBe('\\n \\naa') }) + test('Parser, ultisnips transform replacement', () => { + const p = new SnippetParser(true) + let snip = p.parse('${1:foo} ${1/^\\w/$0_/}') + expect(snip.toString()).toBe('foo f_oo') + snip = p.parse('${1:foo} ${1/^\\w//}') + expect(snip.toString()).toBe('foo oo') + }) + + test('Parser, convert ultisnips regex', () => { + const p = new SnippetParser(true) + let snip = p.parse('${1:foo} ${1/^\\A/_/}') + expect(snip.toString()).toBe('foo _foo') + }) + test('Parser, transform condition else text', () => { const p = new SnippetParser(true) let snip = p.parse('${1:foo} ${1/^(f)(b?)/(?2:_:two)/}') expect(snip.toString()).toBe('foo twooo') - snip.updatePlaceholder(1, 'fb') + let m = snip.placeholders.find(o => o.index == 1 && o.primary) + snip.resetMarker(m, 'fb') expect(snip.toString()).toBe('fb _') }) @@ -292,9 +464,12 @@ describe('SnippetParser', () => { }) test('Parser, transform with ascii option', () => { - const p = new SnippetParser() - const snip = p.parse('${1:pêche}\n${1/.*/$0/a}') + let p = new SnippetParser() + let snip = p.parse('${1:pêche}\n${1/.*/$0/a}') expect(snip.toString()).toBe('pêche\npeche') + p = new SnippetParser() + snip = p.parse('${1/.*/$0/a}\n${1:pêche}') + expect(snip.toString()).toBe('peche\npêche') }) test('Parser, placeholder with transform', () => { @@ -602,6 +777,16 @@ describe('SnippetParser', () => { assert.deepEqual(snippet.enclosingPlaceholders(second), [first]) }) + test('TextmateSnippet#getTextBefore', () => { + let snippet = new SnippetParser().parse('This ${1:is ${2:nested}}$0', true) + expect(snippet.getTextBefore(snippet, undefined)).toBe('') + let [first, second] = snippet.placeholders + expect(snippet.getTextBefore(second, first)).toBe('is ') + snippet = new SnippetParser().parse('This ${1:foo ${2:is ${3:nested}}} $0', true) + let arr = snippet.placeholders + expect(snippet.getTextBefore(arr[2], arr[0])).toBe('foo is ') + }) + test('TextmateSnippet#offset', () => { let snippet = new SnippetParser().parse('te$1xt', true) assert.equal(snippet.offset(snippet.children[0]), 0) @@ -645,21 +830,12 @@ describe('SnippetParser', () => { const enclosing = snippet.enclosingPlaceholders(second) assert.equal(enclosing.length, 1) assert.equal(enclosing[0].index, '1') - + let marker = snippet.placeholders.find(o => o.index == 2) let nested = new SnippetParser().parse('ddd$1eee$0', true) - snippet.replace(second, nested.children) + snippet.replace(marker, nested.children) assert.equal(snippet.toString(), 'aaabbbdddeee') - assert.equal(snippet.placeholders.length, 4) - assert.equal(snippet.placeholders[0].index, '1') - assert.equal(snippet.placeholders[1].index, '1') - assert.equal(snippet.placeholders[2].index, '0') - assert.equal(snippet.placeholders[3].index, '0') - - const newEnclosing = snippet.enclosingPlaceholders(snippet.placeholders[1]) - assert.ok(newEnclosing[0] === snippet.placeholders[0]) - assert.equal(newEnclosing.length, 1) - assert.equal(newEnclosing[0].index, '1') + assert.equal(snippet.placeholders.length, 5) }) test('TextmateSnippet#replace 2/2', function() { @@ -673,34 +849,15 @@ describe('SnippetParser', () => { snippet.replace(second, nested.children) assert.equal(snippet.toString(), 'aaabbbdddeee') - assert.equal(snippet.placeholders.length, 3) + assert.equal(snippet.placeholders.length, 4) }) test('TextmateSnippet#insertSnippet', function() { - let snippet = new SnippetParser().parse('${1:aaa} ${1:aaa} bbb ${2:ccc}}$0', true) - snippet.insertSnippet('|${1:dd} ${2:ff}|', 1, Range.create(0, 0, 0, 0)) - const [one, two, three] = snippet.placeholders - assert.equal(one.index, 1) - assert.equal(one.toString(), 'aaa') - assert.equal(two.index, 2) - assert.equal(two.toString(), 'dd') - assert.equal(three.index, 3) - assert.equal(three.toString(), 'ff') - }) - - test('TextmateSnippet#updatePlaceholder', function() { - let snippet = new SnippetParser().parse('aaa${1:bbb} ${1:bbb}', true) - snippet.updatePlaceholder(0, 'ccc') - let p = snippet.placeholders[0] - assert.equal(p.toString(), 'ccc') - }) - - test('Snippet order for placeholders, #28185', function() { - - const _10 = new Placeholder(10) - const _2 = new Placeholder(2) - - assert.equal(Placeholder.compareByIndex(_10, _2), 1) + let snippet = new SnippetParser().parse('${1:aaa} bbb ${2:ccc}}$0', true) + let marker = snippet.placeholders.find(o => o.index == 1) + snippet.insertSnippet('${1:dd} ${2:ff}', marker, ['', 'aaa']) + let arr = snippet.placeholders.map(p => p.index) + expect(arr).toEqual([1, 2, 3, 4, 5, 0]) }) test('Maximum call stack size exceeded, #28983', function() { diff --git a/src/__tests__/snippets/session.test.ts b/src/__tests__/snippets/session.test.ts index d787dc23a30..53293e7db3d 100644 --- a/src/__tests__/snippets/session.test.ts +++ b/src/__tests__/snippets/session.test.ts @@ -1,8 +1,8 @@ -import { Range } from 'vscode-languageserver-protocol' import { Neovim } from '@chemzqm/neovim' -import workspace from '../../workspace' -import window from '../../window' +import { Position, Range } from 'vscode-languageserver-protocol' import { SnippetSession } from '../../snippets/session' +import window from '../../window' +import workspace from '../../workspace' import helper from '../helper' let nvim: Neovim @@ -19,519 +19,428 @@ afterEach(async () => { await helper.reset() }) -describe('SnippetSession#start', () => { - - it('should start with plain snippet', async () => { - let buf = await helper.edit() - await helper.wait(30) - await nvim.input('i') - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('bar$0') - expect(res).toBe(false) - let pos = await window.getCursorPosition() - expect(pos).toEqual({ line: 0, character: 3 }) - }) - - it('should start with range replaced', async () => { - let buf = await helper.edit() - await helper.wait(30) - await nvim.setLine('foo') - await nvim.input('i') - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('bar$0', true, Range.create(0, 0, 0, 3)) - expect(res).toBe(false) - let line = await nvim.line - expect(line).toBe('bar') - }) - - it('should insert placeholder with default value', async () => { - let buf = await helper.edit() - await helper.wait(30) - await nvim.input('i') - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('a${TM_SELECTED_TEXT:return}b') - expect(res).toBe(false) - let line = await nvim.line - expect(line).toBe('areturnb') - }) +describe('SnippetSession', () => { + describe('start()', () => { + it('should not start on invalid range', async () => { + let r = Range.create(3, 0, 3, 0) + await nvim.input('i') + let session = new SnippetSession(nvim, workspace.bufnr) + let res = await session.start('bar$0', false, r) + expect(res).toBe(false) + }) - it('should fix indent of next line when necessary', async () => { - let buf = await helper.edit() - await nvim.setLine(' ab') - await nvim.input('i') - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('x\n') - expect(res).toBe(false) - let lines = await buf.lines - expect(lines).toEqual([' ax', ' b']) - }) + it('should start with plain snippet', async () => { + await nvim.input('i') + let session = new SnippetSession(nvim, workspace.bufnr) + let res = await session.start('bar$0') + expect(res).toBe(false) + let pos = await window.getCursorPosition() + expect(pos).toEqual({ line: 0, character: 3 }) + }) - it('should start with final position for plain snippet', async () => { - let buf = await helper.edit() - await nvim.command('startinsert') - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('bar$0') - expect(res).toBe(false) - let pos = await window.getCursorPosition() - expect(pos).toEqual({ line: 0, character: 3 }) - }) + it('should start with range replaced', async () => { + await nvim.setLine('foo') + await nvim.input('i') + let session = new SnippetSession(nvim, workspace.bufnr) + let res = await session.start('bar$0', true, Range.create(0, 0, 0, 3)) + expect(res).toBe(false) + let line = await nvim.line + expect(line).toBe('bar') + }) - it('should insert indent for snippet endsWith line break', async () => { - let buf = await helper.edit() - await nvim.setLine(' bar') - await helper.wait(10) - await nvim.command('startinsert') - await nvim.call('cursor', [1, 3]) - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('foo\n') - expect(res).toBe(false) - let lines = await buf.lines - expect(lines).toEqual([' foo', ' bar']) - }) + it('should fix indent of next line when necessary', async () => { + let buf = await nvim.buffer + await nvim.setLine(' ab') + await nvim.input('i') + let session = new SnippetSession(nvim, buf.id) + let res = await session.start('${1:x}\n') + expect(res).toBe(true) + let lines = await buf.lines + expect(lines).toEqual([' ax', ' b']) + }) - it('should insert resolved variable', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('${TM_LINE_NUMBER}') - expect(res).toBe(false) - let line = await nvim.line - expect(line).toBe('1') - }) + it('should insert indent for snippet endsWith line break', async () => { + let buf = await nvim.buffer + await nvim.setLine(' bar') + await nvim.command('startinsert') + await nvim.call('cursor', [1, 3]) + let session = new SnippetSession(nvim, buf.id) + let res = await session.start('${1:foo}\n') + expect(res).toBe(true) + let lines = await buf.lines + expect(lines).toEqual([' foo', ' bar']) + }) - it('should use default value of unresolved variable', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('${TM_SELECTION:abc}') - expect(res).toBe(false) - let line = await nvim.line - expect(line).toBe('abc') - }) + it('should start without select placeholder', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + let res = await session.start(' ${1:aa} ', false) + expect(res).toBe(true) + let { mode } = await nvim.mode + expect(mode).toBe('n') + await session.selectCurrentPlaceholder() + await helper.waitFor('mode', [], 's') + }) - it('should start with snippet insert', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - let res = await session.start(' ${1:aa} bb $1') - expect(res).toBe(true) - let line = await nvim.getLine() - expect(line).toBe(' aa bb aa') - let { mode } = await nvim.mode - expect(mode).toBe('s') - let pos = await window.getCursorPosition() - expect(pos).toEqual({ line: 0, character: 2 }) - }) + it('should start with variable selected', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + let res = await session.start('${foo:bar}', false) + expect(res).toBe(true) + let line = await nvim.getLine() + expect(line).toBe('bar') + await session.selectCurrentPlaceholder() + await helper.waitFor('mode', [], 's') + }) - it('should start without select placeholder', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - let res = await session.start(' ${1:aa} ', false) - expect(res).toBe(true) - let line = await nvim.getLine() - expect(line).toBe(' aa ') - let { mode } = await nvim.mode - expect(mode).toBe('n') - await session.selectCurrentPlaceholder() - await helper.wait(100) - let m = await nvim.mode - expect(m.mode).toBe('s') - }) + it('should select none transform placeholder', async () => { + await nvim.command('startinsert') + let session = new SnippetSession(nvim, workspace.bufnr) + await session.start('${1/..*/ -> /}xy$1') + let col = await nvim.call('col', '.') + expect(col).toBe(3) + }) - it('should start with variable selected', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('${foo:bar}', false) - expect(res).toBe(true) - let line = await nvim.getLine() - expect(line).toBe('bar') - await session.selectCurrentPlaceholder() - await helper.wait(100) - let m = await nvim.mode - expect(m.mode).toBe('s') - }) + it('should indent multiple lines variable text', async () => { + let buf = await nvim.buffer + let text = 'abc\n def' + await nvim.setVar('coc_selected_text', text) + await nvim.input('i') + let session = new SnippetSession(nvim, buf.id) + await session.start('fun\n ${0:${TM_SELECTED_TEXT:return}}\nend') + let lines = await buf.lines + expect(lines.length).toBe(4) + expect(lines).toEqual([ + 'fun', ' abc', ' def', 'end' + ]) + }) - it('should start with nest snippet', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('${1:a} ${2:b}', false) - let line = await nvim.getLine() - expect(line).toBe('a b') - expect(res).toBe(true) - let { placeholder } = session - expect(placeholder.index).toBe(1) - res = await session.start('${1:foo} ${2:bar}') - expect(res).toBe(true) - placeholder = session.placeholder - let { snippet } = session - expect(placeholder.index).toBe(2) - line = await nvim.getLine() - expect(line).toBe('foo bara b') - expect(snippet.toString()).toBe('foo bara b') - }) + it('should resolve VISUAL', async () => { + let text = 'abc' + await nvim.setVar('coc_selected_text', text) + let session = new SnippetSession(nvim, workspace.bufnr) + await session.start('$VISUAL') + let line = await nvim.line + expect(line).toBe('abc') + }) - it('should jump to nested snippet placeholder', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await session.start('${1} ${2:b}', false) - await session.start('${1:foo} ${2:bar}') - await session.nextPlaceholder() - await session.nextPlaceholder() - await session.nextPlaceholder() - let pos = await window.getCursorPosition() - expect(pos).toEqual({ line: 0, character: 8 }) + it('should resolve default value of VISUAL', async () => { + await nvim.setVar('coc_selected_text', '') + let session = new SnippetSession(nvim, workspace.bufnr) + await session.start('${VISUAL:foo}') + let line = await nvim.line + expect(line).toBe('foo') + }) }) - it('should jump to variable placeholder', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await session.start('${foo} ${bar}', false) - await session.selectCurrentPlaceholder() - await helper.wait(100) - await session.nextPlaceholder() - let pos = await window.getCursorPosition() - expect(pos).toEqual({ line: 0, character: 6 }) - }) + describe('nested snippet', () => { + it('should start with nest snippet', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + let res = await session.start('${1:a} ${2:b}', false) + let line = await nvim.getLine() + expect(line).toBe('a b') + expect(res).toBe(true) + let { placeholder } = session + expect(placeholder.index).toBe(1) + res = await session.start('${1:foo} ${2:bar}') + expect(res).toBe(true) + placeholder = session.placeholder + expect(placeholder.index).toBe(2) + line = await nvim.getLine() + expect(line).toBe('foo bara b') + expect(session.snippet.text).toBe('foo bara b') + await session.nextPlaceholder() + placeholder = session.placeholder + expect(placeholder.index).toBe(3) + expect(session.placeholder.value).toBe('bar') + let col = await nvim.call('col', ['.']) + expect(col).toBe(7) + await session.nextPlaceholder() + await session.nextPlaceholder() + expect(session.placeholder.index).toBe(5) + expect(session.placeholder.value).toBe('b') + }) - it('should jump to variable placeholder after number placeholder', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await session.start('${foo} ${1:bar}', false) - await session.selectCurrentPlaceholder() - await session.nextPlaceholder() - let pos = await window.getCursorPosition() - expect(pos).toEqual({ line: 0, character: 2 }) + it('should start nest snippet without select', async () => { + let buf = await nvim.buffer + await nvim.command('startinsert') + let session = new SnippetSession(nvim, buf.id) + let res = await session.start('${1:a} ${2:b}') + let line = await nvim.call('getline', ['.']) + res = await session.start('${1:foo} ${2:bar}', false) + expect(res).toBe(true) + line = await nvim.line + expect(line).toBe('foo bara b') + }) }) - it('should jump to variable placeholder with same name only once', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await session.start('${foo} ${foo} ${2:bar}', false) - await session.selectCurrentPlaceholder() - await session.nextPlaceholder() - await session.nextPlaceholder() - let pos = await window.getCursorPosition() - expect(pos).toEqual({ line: 0, character: 11 }) - }) + describe('sychronize()', () => { + it('should cancel when change after snippet', async () => { + let buf = await nvim.buffer + let session = new SnippetSession(nvim, buf.id) + await nvim.setLine(' x') + await nvim.input('i') + await session.start('${1:foo }bar') + await nvim.setLine('foo bar y') + await session.forceSynchronize() + expect(session.isActive).toBe(false) + }) - it('should start nest snippet without select', async () => { - let buf = await helper.edit() - await nvim.command('startinsert') - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('${1:a} ${2:b}') - await helper.wait(30) - await nvim.input('') - res = await session.start('${1:foo} ${2:bar}', false) - await helper.wait(30) - expect(res).toBe(true) - let line = await nvim.line - expect(line).toBe('foo bar b') - }) + it('should reset position when change before snippet', async () => { + let buf = await nvim.buffer + let session = new SnippetSession(nvim, buf.id) + await nvim.setLine('x') + await nvim.input('a') + await session.start('${1:foo} bar') + await nvim.setLine('yfoo bar') + await session.forceSynchronize() + expect(session.isActive).toBe(true) + let start = session.snippet.start + expect(start).toEqual(Position.create(0, 1)) + }) - it('should select none transform placeholder', async () => { - let buf = await helper.edit() - await nvim.command('startinsert') - let session = new SnippetSession(nvim, buf.id) - await session.start('${1/..*/ -> /}xy$1') - await helper.wait(30) - let col = await nvim.call('col', '.') - expect(col).toBe(3) - }) + it('should cancel when before and body changed', async () => { + let buf = await nvim.buffer + let session = new SnippetSession(nvim, buf.id) + await nvim.setLine('x') + await nvim.input('a') + await session.start('${1:foo }bar') + await nvim.setLine('yfoo bar') + await session.forceSynchronize() + expect(session.isActive).toBe(false) + }) - it('should indent multiple lines variable text', async () => { - let text = 'abc\n def' - await nvim.setVar('coc_selected_text', text) - let buf = await helper.edit() - await nvim.input('i') - let session = new SnippetSession(nvim, buf.id) - await session.start('fun\n ${0:${TM_SELECTED_TEXT:return}}\nend') - await helper.wait(30) - let lines = await buf.lines - expect(lines.length).toBe(4) - expect(lines).toEqual([ - 'fun', ' abc', ' def', 'end' - ]) - }) -}) + it('should cancel when unable to find placeholder', async () => { + let buf = await nvim.buffer + let session = new SnippetSession(nvim, buf.id) + await nvim.input('i') + await session.start('${1:foo} bar') + await nvim.setLine('foobar') + await session.forceSynchronize() + expect(session.isActive).toBe(false) + }) -describe('SnippetSession#deactivate', () => { - - it('should deactivate on invalid change', async () => { - let doc = await helper.createDocument() - await nvim.input('i') - let session = new SnippetSession(nvim, doc.bufnr) - let res = await session.start('${1:a}bc') - expect(res).toBe(true) - let edit = { - range: Range.create(0, 0, 0, 2), - newText: '' - } - await doc.applyEdits([edit]) - await session.synchronizeUpdatedPlaceholders({ range: edit.range, text: edit.newText }) - expect(session.isActive).toBe(false) - }) + it('should prefer range contains current cursor', async () => { + let buf = await nvim.buffer + let session = new SnippetSession(nvim, buf.id) + await nvim.input('i') + await session.start('$1 $2') + await nvim.input('A') + await nvim.input(' ') + await session.forceSynchronize() + expect(session.isActive).toBe(true) + let p = session.placeholder + expect(p.index).toBe(2) + }) - it('should deactivate on cursor outside', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('a${1:a}b') - expect(res).toBe(true) - await buf.append(['foo', 'bar']) - await nvim.call('cursor', [2, 1]) - await session.checkPosition() - expect(session.isActive).toBe(false) - }) + it('should update cursor column after sychronize', async () => { + let buf = await nvim.buffer + let session = new SnippetSession(nvim, buf.id) + await nvim.input('i') + await session.start('${1} ${1:foo}') + await nvim.input('b') + await session.forceSynchronize() + let pos = await window.getCursorPosition() + expect(pos).toEqual(Position.create(0, 3)) + await nvim.input('a') + await session.forceSynchronize() + pos = await window.getCursorPosition() + expect(pos).toEqual(Position.create(0, 5)) + await nvim.input('') + await session.forceSynchronize() + pos = await window.getCursorPosition() + expect(pos).toEqual(Position.create(0, 3)) + }) - it('should cancel keymap on jump final placeholder', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await nvim.input('i') - await session.start('$0x${1:a}b$0') - let line = await nvim.line - expect(line).toBe('xab') - let map = await nvim.call('maparg', ['', 'i']) as string - expect(map).toMatch('snippetNext') - await session.nextPlaceholder() - map = await nvim.call('maparg', ['', 'i']) as string - expect(map).toBe('') + it('should update cursor line after sychronize', async () => { + let buf = await nvim.buffer + let session = new SnippetSession(nvim, buf.id) + await nvim.input('i') + await session.start('${1} ${1:foo}') + await nvim.input('b') + await session.forceSynchronize() + let pos = await window.getCursorPosition() + expect(pos).toEqual(Position.create(0, 3)) + await nvim.input('') + await session.forceSynchronize() + expect(session.isActive).toBe(true) + pos = await window.getCursorPosition() + let lines = await buf.lines + expect(lines).toEqual(['b', ' b', '']) + expect(pos).toEqual(Position.create(2, 0)) + }) }) -}) -describe('SnippetSession#nextPlaceholder', () => { + describe('deactivate()', () => { - it('should goto next placeholder', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('${1:a} ${2:b} c') - expect(res).toBe(true) - await session.nextPlaceholder() - let { placeholder } = session - expect(placeholder.index).toBe(2) - }) + it('should deactivate on cursor outside', async () => { + let buf = await nvim.buffer + let session = new SnippetSession(nvim, buf.id) + let res = await session.start('a${1:a}b') + expect(res).toBe(true) + await buf.append(['foo', 'bar']) + await nvim.call('cursor', [2, 1]) + await session.checkPosition() + expect(session.isActive).toBe(false) + }) - it('should jump to none transform placeholder', async () => { - let buf = await helper.edit() - await helper.wait(60) - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('${1} ${2/^_(.*)/$2/}bar$2') - expect(res).toBe(true) - let line = await nvim.line - expect(line).toBe(' bar') - await session.nextPlaceholder() - await helper.wait(60) - let col = await nvim.call('col', '.') - expect(col).toBe(5) - }) -}) + it('should not throw when jump on deactivate session', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + session.deactivate() + await session.start('${1:foo} $0') + await session.selectPlaceholder(undefined, true) + await session.forceSynchronize() + await session.previousPlaceholder() + await session.nextPlaceholder() + }) -describe('SnippetSession#previousPlaceholder', () => { - - it('should goto previous placeholder', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('${1:foo} ${2:bar}') - expect(res).toBe(true) - await session.nextPlaceholder() - await helper.wait(60) - expect(session.placeholder.index).toBe(2) - await session.previousPlaceholder() - await helper.wait(60) - expect(session.placeholder.index).toBe(1) + it('should cancel keymap on jump final placeholder', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + await nvim.input('i') + await session.start('$0x${1:a}b$0') + let line = await nvim.line + expect(line).toBe('xab') + let map = await nvim.call('maparg', ['', 'i']) as string + expect(map).toMatch('snippetNext') + await session.nextPlaceholder() + map = await nvim.call('maparg', ['', 'i']) as string + expect(map).toBe('') + }) }) -}) -describe('SnippetSession#synchronizeUpdatedPlaceholders', () => { - - it('should adjust with line changed before start position', async () => { - let buf = await helper.edit() - await nvim.setLine('abd') - await nvim.input('o') - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('${1:foo}') - await helper.wait(30) - expect(res).toBe(true) - await session.synchronizeUpdatedPlaceholders({ - range: Range.create(0, 0, 0, 3), - text: 'def' - }) - expect(session.isActive).toBe(true) - }) + describe('nextPlaceholder()', () => { + it('should jump to variable placeholder', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + await session.start('${foo} ${bar}', false) + await session.selectCurrentPlaceholder() + await session.nextPlaceholder() + let pos = await window.getCursorPosition() + expect(pos).toEqual({ line: 0, character: 6 }) + }) - it('should adjust for variable placeholders', async () => { - let buf = await helper.edit() - await nvim.input('i') - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('${foo} ${foo}') - expect(res).toBe(true) - await session.selectCurrentPlaceholder() - await helper.wait(100) - await nvim.input('bar') - await helper.wait(100) - await session.synchronizeUpdatedPlaceholders({ - range: Range.create(0, 0, 0, 3), - text: 'bar' - }) - let line = await nvim.getLine() - expect(line).toBe('bar bar') - }) + it('should jump to variable placeholder after number placeholder', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + await session.start('${foo} ${1:bar}', false) + await session.selectCurrentPlaceholder() + await session.nextPlaceholder() + let pos = await window.getCursorPosition() + expect(pos).toEqual({ line: 0, character: 2 }) + }) - it('should adjust with previous line change', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('${1:foo}') - await nvim.input('Obar') - await helper.wait(30) - expect(res).toBe(true) - await session.synchronizeUpdatedPlaceholders({ - range: Range.create(0, 0, 0, 0), - text: 'bar\n' - }) - expect(session.isActive).toBe(true) - let { start } = session.snippet.range - expect(start).toEqual({ line: 1, character: 0 }) - }) + it('should jump to first placeholder', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + await session.start('${foo} ${foo} ${2:bar}', false) + await session.selectCurrentPlaceholder() + let pos = await window.getCursorPosition() + expect(pos).toEqual({ line: 0, character: 10 }) + await session.nextPlaceholder() + pos = await window.getCursorPosition() + expect(pos).toEqual({ line: 0, character: 2 }) + await session.nextPlaceholder() + pos = await window.getCursorPosition() + expect(pos).toEqual({ line: 0, character: 11 }) + }) - it('should adjust with previous character change', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('foo ${1:foo}') - await nvim.input('Ibar') - await helper.wait(30) - expect(res).toBe(true) - await session.synchronizeUpdatedPlaceholders({ - range: Range.create(0, 0, 0, 0), - text: 'bar' - }) - expect(session.isActive).toBe(true) - let { start } = session.snippet.range - expect(start).toEqual({ line: 0, character: 3 }) - }) + it('should goto next placeholder', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + let res = await session.start('${1:a} ${2:b} c') + expect(res).toBe(true) + await session.nextPlaceholder() + let { placeholder } = session + expect(placeholder.index).toBe(2) + }) - it('should deactivate when content add after snippet', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('${1:foo} $0 ') - await nvim.input('Abar') - await helper.wait(100) - expect(res).toBe(true) - await session.synchronizeUpdatedPlaceholders({ - range: Range.create(0, 5, 0, 5), - text: 'bar' - }) - expect(session.isActive).toBe(false) + it('should jump to none transform placeholder', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + let res = await session.start('${1} ${2/^_(.*)/$2/}bar$2') + expect(res).toBe(true) + let line = await nvim.line + expect(line).toBe(' bar') + await session.nextPlaceholder() + let col = await nvim.call('col', '.') + expect(col).toBe(5) + }) }) - it('should not deactivate when content remove after snippet', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - let res = await session.start('${1:foo}') - expect(res).toBe(true) - await nvim.input('Abar') - await helper.wait(30) - await session.synchronizeUpdatedPlaceholders({ - range: Range.create(0, 5, 0, 6), - text: '' - }) - await helper.wait(30) - expect(session.isActive).toBe(true) - }) + describe('previousPlaceholder()', () => { - it('should deactivate when change outside placeholder', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await session.start('a${1:b}c') - let doc = await workspace.document - await doc.applyEdits([{ - range: Range.create(0, 0, 0, 1), - newText: '' - }]) - await session.synchronizeUpdatedPlaceholders({ - range: Range.create(0, 0, 0, 1), - text: '' - }) - expect(session.isActive).toBe(false) + it('should goto previous placeholder', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + let res = await session.start('${1:foo} ${2:bar}') + expect(res).toBe(true) + await session.nextPlaceholder() + expect(session.placeholder.index).toBe(2) + await session.previousPlaceholder() + expect(session.placeholder.index).toBe(1) + }) }) - it('should deactivate when jump to single final placeholder', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await session.start(' $0 ${1:a}') - await session.nextPlaceholder() - expect(session.isActive).toBe(false) - }) -}) + describe('checkPosition()', () => { -describe('SnippetSession#checkPosition', () => { + it('should cancel snippet if position out of range', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + await nvim.setLine('bar') + await session.start('${1:foo}') + await nvim.call('cursor', [1, 5]) + await session.checkPosition() + expect(session.isActive).toBe(false) + }) - it('should cancel snippet if position out of range', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await nvim.setLine('bar') - await session.start('${1:foo}') - await nvim.call('cursor', [1, 5]) - await session.checkPosition() - expect(session.isActive).toBe(false) + it('should not cancel snippet if position in range', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + await session.start('${1:foo}') + await nvim.call('cursor', [1, 3]) + await session.checkPosition() + expect(session.isActive).toBe(true) + }) }) - it('should not cancel snippet if position in range', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await session.start('${1:foo}') - await nvim.call('cursor', [1, 3]) - await session.checkPosition() - expect(session.isActive).toBe(true) - }) -}) + describe('findPlaceholder()', () => { -describe('SnippetSession#findPlaceholder', () => { + it('should find current placeholder if possible', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + await session.start('${1:abc}${2:def}') + let placeholder = session.findPlaceholder(Range.create(0, 3, 0, 3)) + expect(placeholder.index).toBe(1) + }) - it('should find current placeholder if possible', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await session.start('${1:abc}${2:def}') - let placeholder = session.findPlaceholder(Range.create(0, 3, 0, 3)) - expect(placeholder.index).toBe(1) + it('should return null if placeholder not found', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + await session.start('${1:abc}xyz${2:def}') + let placeholder = session.findPlaceholder(Range.create(0, 4, 0, 4)) + expect(placeholder).toBeNull() + }) }) - it('should return null if placeholder not found', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await session.start('${1:abc}xyz${2:def}') - let placeholder = session.findPlaceholder(Range.create(0, 4, 0, 4)) - expect(placeholder).toBeNull() - }) -}) + describe('selectPlaceholder()', () => { -describe('SnippetSession#selectPlaceholder', () => { - - it('should select range placeholder', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await session.start('${1:abc}') - let mode = await nvim.mode - expect(mode.mode).toBe('s') - await nvim.input('') - let line = await nvim.line - expect(line).toBe('') - }) + it('should select range placeholder', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + await session.start('${1:abc}') + let mode = await nvim.mode + expect(mode.mode).toBe('s') + await nvim.input('') + let line = await nvim.line + expect(line).toBe('') + }) - it('should select empty placeholder', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await session.start('a ${1} ${2}') - let mode = await nvim.mode - expect(mode.mode).toBe('i') - let col = await nvim.call('col', '.') - expect(col).toBe(3) - }) + it('should select empty placeholder', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + await session.start('a ${1} ${2}') + let mode = await nvim.mode + expect(mode.mode).toBe('i') + let col = await nvim.call('col', '.') + expect(col).toBe(3) + }) - it('should select choice placeholder', async () => { - let buf = await helper.edit() - let session = new SnippetSession(nvim, buf.id) - await nvim.input('i') - await session.start('${1|one,two,three|}') - await helper.wait(60) - let line = await nvim.line - expect(line).toBe('one') - let val = await nvim.eval('g:coc#_context') as any - expect(val.start).toBe(0) - expect(val.candidates).toEqual(['one', 'two', 'three']) + it('should select choice placeholder', async () => { + let session = new SnippetSession(nvim, workspace.bufnr) + await nvim.input('i') + await session.start('${1|one,two,three|}') + let line = await nvim.line + expect(line).toBe('one') + await helper.waitPopup() + let val = await nvim.eval('g:coc#_context') as any + expect(val.start).toBe(0) + expect(val.candidates).toEqual(['one', 'two', 'three']) + }) }) }) diff --git a/src/__tests__/snippets/snippet.test.ts b/src/__tests__/snippets/snippet.test.ts new file mode 100644 index 00000000000..097768687c7 --- /dev/null +++ b/src/__tests__/snippets/snippet.test.ts @@ -0,0 +1,582 @@ +import { Neovim } from '@chemzqm/neovim' +import path from 'path' +import { CancellationTokenSource } from 'vscode-jsonrpc' +import { Position, Range, TextEdit } from 'vscode-languageserver-types' +import { URI } from 'vscode-uri' +import { LinesTextDocument } from '../../model/textdocument' +import { addPythonTryCatch, convertRegex, executePythonCode, UltiSnippetContext } from '../../snippets/eval' +import { Placeholder, TextmateSnippet } from '../../snippets/parser' +import { checkContentBefore, CocSnippet, getContentBefore, getEnd, getEndPosition, getParts, normalizeSnippetString, reduceTextEdit, shouldFormat } from '../../snippets/snippet' +import { parseComments, parseCommentstring, SnippetVariableResolver } from '../../snippets/variableResolve' +import { UltiSnippetOption } from '../../types' +import workspace from '../../workspace' +import helper from '../helper' + +let nvim: Neovim +beforeAll(async () => { + await helper.setup() + nvim = helper.nvim + let pyfile = path.join(__dirname, '../ultisnips.py') + await nvim.command(`execute 'pyxfile '.fnameescape('${pyfile}')`) +}) + +afterAll(async () => { + await helper.shutdown() +}) + +async function createSnippet(snippet: string, opts?: UltiSnippetOption, range = Range.create(0, 0, 0, 0), line = '') { + let snip = new CocSnippet(snippet, Position.create(0, 0), nvim, new SnippetVariableResolver(nvim, workspace.workspaceFolderControl)) + let context: UltiSnippetContext + if (opts) context = { range, line, ...opts, } + await snip.init(context) + return snip +} + +function createTextDocument(text: string): LinesTextDocument { + return new LinesTextDocument('file://a', 'txt', 1, text.split('\n'), 1, true) +} + +describe('CocSnippet', () => { + async function assertResult(snip: string, resolved: string) { + let c = await createSnippet(snip, {}) + expect(c.text).toBe(resolved) + } + + async function asssertPyxValue(code: string, res: any) { + let val = await nvim.call(`pyxeval`, code) as string + if (typeof res === 'number' || typeof res === 'string' || typeof res === 'boolean') { + expect(val).toBe(res) + } else if (res instanceof RegExp) { + expect(val).toMatch(res) + } else { + expect(val).toEqual(res) + } + } + + describe('resolveVariables()', () => { + it('should resolve uppercase variables', async () => { + let doc = await helper.createDocument() + let fsPath = URI.parse(doc.uri).fsPath + await assertResult('$TM_FILENAME', path.basename(fsPath)) + await assertResult('$TM_FILENAME_BASE', path.basename(fsPath, path.extname(fsPath))) + await assertResult('$TM_DIRECTORY', path.dirname(fsPath)) + await assertResult('$TM_FILEPATH', fsPath) + await nvim.call('setreg', ['""', 'foo']) + await assertResult('$YANK', 'foo') + await assertResult('$TM_LINE_INDEX', '0') + await assertResult('$TM_LINE_NUMBER', '1') + await nvim.setLine('foo') + await assertResult('$TM_CURRENT_LINE', 'foo') + await nvim.call('setreg', ['*', 'foo']) + await assertResult('$CLIPBOARD', 'foo') + let d = new Date() + await assertResult('$CURRENT_YEAR', d.getFullYear().toString()) + await assertResult('$NOT_EXISTS', 'NOT_EXISTS') + }) + + it('should resolve new VSCode variables', async () => { + let doc = await helper.createDocument() + await doc.buffer.setOption('comments', 's1:/*,mb:*,ex:*/,://,b:#,:%,:XCOMM,n:>,fb:-') + await doc.buffer.setOption('commentstring', '') + let fsPath = URI.parse(doc.uri).fsPath + let c = await createSnippet('$RANDOM') + expect(c.text.length).toBe(6) + c = await createSnippet('$RANDOM_HEX') + expect(c.text.length).toBe(6) + c = await createSnippet('$UUID') + expect(c.text).toMatch('-') + c = await createSnippet('$RELATIVE_FILEPATH') + expect(c.text).toMatch(path.basename(fsPath)) + c = await createSnippet('$WORKSPACE_NAME') + expect(c.text.length).toBeGreaterThan(0) + c = await createSnippet('$WORKSPACE_FOLDER') + expect(c.text.length).toBeGreaterThan(0) + await assertResult('$LINE_COMMENT', '//') + await assertResult('$BLOCK_COMMENT_START', '/*') + await assertResult('$BLOCK_COMMENT_END', '*/') + await doc.buffer.setOption('comments', '') + await doc.buffer.setOption('commentstring', '// %s') + await assertResult('$LINE_COMMENT', '//') + await assertResult('$BLOCK_COMMENT_START', '') + await assertResult('$BLOCK_COMMENT_END', '') + }) + + it('should resolve variables in placeholders', async () => { + await nvim.setLine('foo') + await assertResult('$1 ${1:$TM_CURRENT_LINE}', 'foo foo') + await assertResult('$1 ${1:$TM_CURRENT_LINE bar}', 'foo bar foo bar') + await assertResult('$2 ${2:|${1:$TM_CURRENT_LINE}|}', '|foo| |foo|') + await assertResult('$1 $2 ${2:${1:|$TM_CURRENT_LINE|}}', '|foo| |foo| |foo|') + }) + + it('should resolve variables with default value', async () => { + await assertResult('$1 ${1:${VISUAL:foo}}', 'foo foo') + }) + + it('should resolve for lower case variables', async () => { + await assertResult('${foo:abcdef} ${bar}', 'abcdef bar') + await assertResult('${1:${foo:abcdef}} ${1/^\\w\\w(.*)/$1/}', 'abcdef cdef') + }) + }) + + describe('code block initialize', () => { + it('should init shell code block', async () => { + await assertResult('`echo "hello"` world', 'hello world') + }) + + it('should init vim block', async () => { + await assertResult('`!v eval("1 + 1")` = 2', '2 = 2') + await nvim.setLine(' ') + await assertResult('${1:`!v indent(".")`} "$1"', '2 "2"') + }) + + it('should init code block in placeholders', async () => { + await assertResult('f ${1:`echo "b"`}', 'f b') + await assertResult('f ${1:`!v "b"`}', 'f b') + await assertResult('f ${1:`!p snip.rv = "b"`}', 'f b') + }) + + it('should setup python globals', async () => { + await helper.edit('t.js') + await createSnippet('`!p snip.rv = fn`', {}) + await asssertPyxValue('fn', 't.js') + await asssertPyxValue('path', /t\.js$/) + await asssertPyxValue('t', ['']) + await asssertPyxValue('context', true) + await createSnippet('`!p snip.rv = fn`', { + regex: '^(im)', + context: 'False' + }, Range.create(0, 0, 0, 2), 'im') + await asssertPyxValue('context', false) + await asssertPyxValue('match.group(0)', 'im') + await asssertPyxValue('match.group(1)', 'im') + }) + + it('should setup python match', async () => { + let c = await createSnippet('\\\\frac{`!p snip.rv = match.group(1)`}{$1}$0', { + regex: '((\\d+)|(\\d*)(\\\\)?([A-Za-z]+)((\\^|_)(\\{\\d+\\}|\\d))*)/', + context: 'True' + }, Range.create(0, 0, 0, 3), '20/') + await asssertPyxValue('context', true) + await asssertPyxValue('match.group(1)', '20') + expect(c.text).toBe('\\frac{20}{}') + }) + + it('should work with methods of snip', async () => { + await nvim.command('setl shiftwidth=4 ft=txt tabstop=4 expandtab') + await createSnippet('`!p snip.rv = "a"`', {}, Range.create(0, 4, 0, 8), ' abcd') + await executePythonCode(nvim, [ + 'snip.shift(1)', + // ultisnip indent only when there's '\n' in snip.rv + 'snip += ""', + 'newLine = snip.mkline("foo")' + ]) + await asssertPyxValue('newLine', ' foo') + await executePythonCode(nvim, [ + 'snip.unshift(1)', + 'newLine = snip.mkline("b")' + ]) + await asssertPyxValue('newLine', ' b') + await executePythonCode(nvim, [ + 'snip.shift(1)', + 'snip.reset_indent()', + 'newLine = snip.mkline("f")' + ]) + await asssertPyxValue('newLine', ' f') + await executePythonCode(nvim, [ + 'fff = snip.opt("&fff", "foo")', + 'ft = snip.opt("&ft", "ft")', + ]) + await asssertPyxValue('fff', 'foo') + await asssertPyxValue('ft', 'txt') + }) + + it('should init python code block', async () => { + await assertResult('`!p snip.rv = "a"` = a', 'a = a') + await assertResult('`!p snip.rv = t[1]` = ${1:a}', 'a = a') + await assertResult('`!p snip.rv = t[1]` = ${1:`!v eval("\'a\'")`}', 'a = a') + await assertResult('`!p snip.rv = t[1] + t[2]` = ${1:a} ${2:b}', 'ab = a b') + }) + + it('should init python placeholder', async () => { + await assertResult('foo ${1/^\\|(.*)\\|$/$1/} ${1:|`!p snip.rv = "a"`|}', 'foo a |a|') + await assertResult('foo $1 ${1:`!p snip.rv = "a"`}', 'foo a a') + await assertResult('${1/^_(.*)/$1/} $1 aa ${1:`!p snip.rv = "_foo"`}', 'foo _foo aa _foo') + }) + + it('should init nested python placeholder', async () => { + await assertResult('${1:foo`!p snip.rv = t[2]`} ${2:bar} $1', 'foobar bar foobar') + await assertResult('${3:f${2:oo${1:b`!p snip.rv = "ar"`}}} `!p snip.rv = t[3]`', 'foobar foobar') + }) + + it('should recursive init python placeholder', async () => { + await assertResult('${1:`!p snip.rv = t[2]`} ${2:`!p snip.rv = t[3]`} ${3:`!p snip.rv = t[4][0]`} ${4:bar}', 'b b b bar') + await assertResult('${1:foo} ${2:`!p snip.rv = t[1][0]`} ${3:`!p snip.rv = ""`} ${4:`!p snip.rv = t[2]`}', 'foo f f') + }) + + it('should update python block from placeholder', async () => { + await assertResult('`!p snip.rv = t[1][0] if len(t[1]) > 0 else ""` ${1:`!p snip.rv = t[2]`} ${2:foo}', 'f foo foo') + }) + + it('should update nested placeholder values', async () => { + let c = await createSnippet('${2:foo ${1:`!p snip.rv = "bar"`}} ${2/^\\w//} `!p snip.rv = t[2]`', {}) + expect(c.text).toBe('foo bar oo bar foo bar') + }) + + }) + + describe('getContentBefore()', () => { + it('should get text before marker', async () => { + let c = await createSnippet('${1:foo} ${2:bar}', {}) + let markers = c.placeholders + let p = markers[0].parent + expect(p instanceof TextmateSnippet).toBe(true) + expect(getContentBefore(p)).toBe('') + expect(getContentBefore(markers[0])).toBe('') + expect(getContentBefore(markers[1])).toBe('foo ') + }) + + it('should get text before nested marker', async () => { + let c = await createSnippet('${1:foo} ${2:is nested with $4} $3 bar', {}) + let markers = c.placeholders as Placeholder[] + let p = markers.find(o => o.index == 4) + expect(getContentBefore(p)).toBe('foo is nested with ') + p = markers.find(o => o.index == 0) + expect(getContentBefore(p)).toBe('foo is nested with bar') + }) + + it('should consider normal line break', async () => { + let c = await createSnippet('${1:foo}\n${2:is nested with $4}', {}) + let markers = c.placeholders as Placeholder[] + let p = markers.find(o => o.index == 4) + expect(getContentBefore(p)).toBe('is nested with ') + }) + + it('should consider line break after update', async () => { + let c = await createSnippet('${1:foo} ${2}', {}) + let p = c.getPlaceholder(1) + await c.tmSnippet.update(nvim, p.marker, 'abc\ndef') + let markers = c.placeholders as Placeholder[] + let placeholder = markers.find(o => o.index == 2) + expect(getContentBefore(placeholder)).toBe('def ') + }) + }) + + describe('getSortedPlaceholders()', () => { + it('should get sorted placeholders', async () => { + const assert = (snip: CocSnippet, index: number | undefined, indexes: number[]) => { + let curr = index == null ? undefined : snip.getPlaceholder(index) + let res = snip.getSortedPlaceholders(curr) + expect(res.map(o => o.index)).toEqual(indexes) + } + let c = await createSnippet('${1:foo} ${2/^\\w//} ${2:bar} ', {}) + assert(c, undefined, [1, 2, 0]) + assert(c, 1, [1, 2, 0]) + assert(c, 2, [2, 1, 0]) + }) + }) + + describe('getNewText()', () => { + it('should getNewText for placeholder', async () => { + let c = await createSnippet('before ${1:foo} after$2', {}) + let p = c.getPlaceholder(1) + expect(c.getNewText(p, `fff`)).toBe(undefined) + expect(c.getNewText(p, `before foo `)).toBe(undefined) + expect(c.getNewText(p, `before foo afteralll`)).toBe(undefined) + expect(c.getNewText(p, `before bar after`)).toBe('bar') + p = c.getPlaceholder(2) + expect(c.getNewText(p, `before foo afterbar`)).toBe('bar') + }) + }) + + describe('updatePlaceholder()', () => { + async function assertUpdate(text: string, value: string, result: string, index = 1): Promise { + let c = await createSnippet(text, {}) + let p = c.getPlaceholder(index) + expect(p != null).toBe(true) + await c.tmSnippet.update(nvim, p.marker, value) + expect(c.tmSnippet.toString()).toBe(result) + return c + } + + it('should work with snip.c', async () => { + let code = [ + '#ifndef ${1:`!p', + 'if not snip.c:', + ' import random, string', + " name = re.sub(r'[^A-Za-z0-9]+','_', snip.fn).upper()", + " rand = ''.join(random.sample(string.ascii_letters+string.digits, 8))", + " snip.rv = ('%s_%s' % (name,rand)).upper()", + "else:", + " snip.rv = snip.c + t[2]`}", + '#define $1', + '$2' + ].join('\n') + let c = await createSnippet(code, {}) + let first = c.text.split('\n')[0] + let p = c.getPlaceholder(2) + expect(p).toBeDefined() + await c.tmSnippet.update(nvim, p.marker, 'foo') + let t = c.tmSnippet.toString() + expect(t.startsWith(first)).toBe(true) + expect(t.split('\n').map(s => s.endsWith('foo'))).toEqual([true, true, true]) + }) + + it('should calculate delta', async () => { + // TODO + }) + + it('should update variable placeholders', async () => { + await assertUpdate('${foo} ${foo}', 'bar', 'bar bar') + await assertUpdate('${foo} ${foo:x}', 'bar', 'bar bar') + await assertUpdate('${1:${foo:x}} $1', 'bar', 'bar bar') + }) + + it('should update placeholder with code blocks', async () => { + await assertUpdate('${1:`echo "foo"`} $1', 'bar', 'bar bar') + await assertUpdate('${2:${1:`echo "foo"`}} $2', 'bar', 'bar bar') + await assertUpdate('${1:`!v "foo"`} $1', 'bar', 'bar bar') + await assertUpdate('${1:`!p snip.rv = "foo"`} $1', 'bar', 'bar bar') + }) + + it('should update related python blocks', async () => { + // multiple + await assertUpdate('`!p snip.rv = t[1]` ${1:`!p snip.rv = "foo"`} `!p snip.rv = t[1]`', 'bar', 'bar bar bar') + // parent + await assertUpdate('`!p snip.rv = t[2]` ${2:foo ${1:`!p snip.rv = "foo"`}}', 'bar', 'foo bar foo bar') + // related placeholders + await assertUpdate('${2:foo `!p snip.rv = t[1]`} ${1:`!p snip.rv = "foo"`}', 'bar', 'foo bar bar') + }) + + it('should update python code blocks with normal placeholder values', async () => { + await assertUpdate('`!p snip.rv = t[1]` $1 `!p snip.rv = t[1]`', 'bar', 'bar bar bar') + await assertUpdate('`!p snip.rv = t[2]` ${2:foo $1}', 'bar', 'foo bar foo bar') + await assertUpdate('${2:foo `!p snip.rv = t[1]`} $1', 'bar', 'foo bar bar') + }) + + it('should reset values for removed placeholders', async () => { + // Keep remained placeholder this is same behavior of VSCode. + let s = await assertUpdate('${2:bar${1:foo}} $2 $1', 'bar', 'bar bar foo', 2) + let prev = s.getPrevPlaceholder(2) + expect(prev).toBeDefined() + expect(prev.value).toBe('foo') + // python placeholder, reset to empty value + await assertUpdate('${2:bar${1:foo}} $2 `!p snip.rv = t[1]`', 'bar', 'bar bar ', 2) + // not reset since $1 still exists + await assertUpdate('${2:bar${1:foo}} $2 $1 `!p snip.rv = t[1]`', 'bar', 'bar bar foo foo', 2) + }) + }) + + describe('getRanges()', () => { + it('should get ranges of placeholder', async () => { + let c = await createSnippet('${2:${1:x} $1}\n$2', {}) + let p = c.getPlaceholder(1) + let arr = c.getRanges(p) + expect(arr.length).toBe(4) + expect(arr[0]).toEqual(Range.create(0, 0, 0, 1)) + expect(arr[1]).toEqual(Range.create(0, 2, 0, 3)) + expect(arr[2]).toEqual(Range.create(1, 0, 1, 1)) + expect(arr[3]).toEqual(Range.create(1, 2, 1, 3)) + expect(c.text).toBe('x x\nx x') + }) + }) + + describe('insertSnippet()', () => { + it('should update indexes of python blocks', async () => { + let c = await createSnippet('${1:a} ${2:b} ${3:`!p snip.rv=t[2]`}', {}) + let p = c.getPlaceholder(1) + await c.insertSnippet(p, '${1:foo} ${2:bar}', ['', '']) + expect(c.text).toBe('foo bar b b') + p = c.getPlaceholder(5) + expect(p.after).toBe(' b') + let source = new CancellationTokenSource() + let res = await c.updatePlaceholder(p, Position.create(0, 9), 'xyz', source.token) + expect(res.text).toBe('foo bar xyz xyz') + }) + + it('should insert nested placeholder', async () => { + let c = await createSnippet('${1:foo}\n$1', {}) + let p = c.getPlaceholder(1) + let marker = await c.insertSnippet(p, '${1:x} $1', ['', '']) as Placeholder + p = c.getPlaceholder(marker.index) + let source = new CancellationTokenSource() + let res = await c.updatePlaceholder(p, Position.create(0, 3), 'bar', source.token) + expect(res.text).toBe('bar bar\nbar bar') + expect(res.delta).toEqual(Position.create(0, 0)) + }) + + it('should insert nested python snippet', async () => { + let c = await createSnippet('${1:foo}\n`!p snip.rv = t[1]`', {}) + let p = c.getPlaceholder(1) + let line = await nvim.line + let marker = await c.insertSnippet(p, '${1:x} `!p snip.rv = t[1]`', ['', ''], { line, range: Range.create(0, 0, 0, 3) }) as Placeholder + p = c.getPlaceholder(marker.index) + expect(c.text).toBe('x x\nx x') + let source = new CancellationTokenSource() + let res = await c.updatePlaceholder(p, Position.create(0, 1), 'bar', source.token) + expect(res.text).toBe('bar bar\nbar bar') + await executePythonCode(nvim, [`snip = ContextSnippet()`]) + let val = await nvim.call('pyxeval', 'snip.last_placeholder.current_text') + expect(val).toBe('foo') + }) + }) + + describe('utils', () => { + function assertThrow(fn: () => void) { + let err + try { + fn() + } catch (e) { + err = e + } + expect(err).toBeDefined() + } + + it('should check shouldFormat', () => { + expect(shouldFormat(' f')).toBe(true) + expect(shouldFormat('a\nb')).toBe(true) + expect(shouldFormat('foo')).toBe(false) + }) + + it('should normalizeSnippetString', () => { + expect(normalizeSnippetString('a\n\n\tb', ' ', { + insertSpaces: true, + tabSize: 2 + })).toBe('a\n\n b') + expect(normalizeSnippetString('a\n\n b', '\t', { + insertSpaces: false, + tabSize: 2 + })).toBe('a\n\n\t\tb') + }) + + it('should throw for invalid regex', async () => { + assertThrow(() => { + convertRegex('\\z') + }) + assertThrow(() => { + convertRegex('(?s)') + }) + assertThrow(() => { + convertRegex('(?x)') + }) + assertThrow(() => { + convertRegex('a\nb') + }) + assertThrow(() => { + convertRegex('(<)?(\\w+@\\w+(?:\\.\\w+)+)(?(1)>|$)') + }) + assertThrow(() => { + convertRegex('(<)?(\\w+@\\w+(?:\\.\\w+)+)(?(1)>|)') + }) + }) + + it('should convert regex', async () => { + // \\A + expect(convertRegex('\\A')).toBe('^') + expect(convertRegex('f(?#abc)b')).toBe('fb') + expect(convertRegex('f(?Pdef)b')).toBe('f(?def)b') + expect(convertRegex('f(?P=abc)b')).toBe('f\\kb') + }) + + it('should catch error with executePythonCode', async () => { + let err + try { + await executePythonCode(nvim, ['INVALID_CODE']) + } catch (e) { + err = e + } + expect(err).toBeDefined() + expect(err.stack).toMatch('INVALID_CODE') + }) + + it('should set error with addPythonTryCatch', async () => { + let code = addPythonTryCatch('INVALID_CODE', true) + await nvim.command(`pyx ${code}`) + let msg = await nvim.getVar('errmsg') + expect(msg).toBeDefined() + expect(msg).toMatch('INVALID_CODE') + }) + + it('should parse comments', async () => { + expect(parseCommentstring('a%sb')).toBeUndefined() + expect(parseCommentstring('// %s')).toBe('//') + expect(parseComments('')).toEqual({ + start: undefined, + end: undefined, + single: undefined + }) + expect(parseComments('s:/*')).toEqual({ + start: '/*', + end: undefined, + single: undefined + }) + expect(parseComments('e:*/')).toEqual({ + end: '*/', + start: undefined, + single: undefined + }) + expect(parseComments(':#,:b')).toEqual({ + end: undefined, + start: undefined, + single: '#' + }) + }) + + it('should get start end position by content', () => { + expect(getEnd(Position.create(0, 0), 'foo')).toEqual({ line: 0, character: 3 }) + expect(getEnd(Position.create(0, 1), 'foo\nbar')).toEqual({ line: 1, character: 3 }) + }) + + it('should reduce TextEdit', () => { + let e: TextEdit + e = TextEdit.replace(Range.create(0, 0, 0, 3), 'foo') + expect(reduceTextEdit(e, '')).toEqual(e) + e = TextEdit.replace(Range.create(0, 0, 0, 3), 'foo\nbar') + expect(reduceTextEdit(e, 'bar')).toEqual( + TextEdit.replace(Range.create(0, 0, 0, 0), 'foo\n') + ) + e = TextEdit.replace(Range.create(0, 0, 0, 3), 'foo\nbar') + expect(reduceTextEdit(e, 'foo')).toEqual( + TextEdit.replace(Range.create(0, 3, 0, 3), '\nbar') + ) + e = TextEdit.replace(Range.create(0, 0, 0, 3), 'def') + expect(reduceTextEdit(e, 'daf')).toEqual( + TextEdit.replace(Range.create(0, 1, 0, 2), 'e') + ) + e = TextEdit.replace(Range.create(2, 0, 3, 0), 'ascii ascii bar\n') + expect(reduceTextEdit(e, 'xyz ascii bar\n')).toEqual( + TextEdit.replace(Range.create(2, 0, 2, 3), 'ascii') + ) + }) + + it('should get new end position', () => { + let assert = (pos: Position, oldText: string, newText: string, res: Position) => { + expect(getEndPosition(pos, createTextDocument(oldText), createTextDocument(newText))).toEqual(res) + } + assert(Position.create(0, 0), 'foo', 'bar', undefined) + assert(Position.create(0, 0), 'foo\nbar', 'bar', undefined) + assert(Position.create(0, 0), 'foo\nbar', 'x\nfoo\nba', undefined) + assert(Position.create(0, 0), 'foo\nbar', 'x\nfoo\nbar', Position.create(1, 0)) + assert(Position.create(0, 0), 'foo', 'foo', Position.create(0, 0)) + }) + + it('should check content before position', () => { + let assert = (pos: Position, oldText: string, newText: string, res: boolean) => { + expect(checkContentBefore(pos, createTextDocument(oldText), createTextDocument(newText))).toBe(res) + } + assert(Position.create(1, 0), 'foo\nbar', 'foo', true) + assert(Position.create(1, 1), 'foo\nbar', 'foo', false) + assert(Position.create(2, 0), 'foo\nbar\n', 'foo', false) + assert(Position.create(1, 1), 'foo\nbar', 'foo\nbd', true) + assert(Position.create(1, 1), 'foo\nbar', 'foo\nab', false) + assert(Position.create(1, 1), 'foo\nbar', 'aoo\nbb', false) + }) + + it('should getParts by range', async () => { + expect(getParts('abcdef', Range.create(1, 5, 1, 11), Range.create(1, 6, 1, 10))).toEqual(['a', 'f']) + expect(getParts('abc\nfoo\ndef', Range.create(0, 5, 2, 3), Range.create(1, 1, 1, 2))).toEqual(['abc\nf', 'o\ndef']) + expect(getParts('abc\ndef', Range.create(0, 1, 2, 3), Range.create(0, 1, 2, 3))).toEqual(['', '']) + }) + }) + +}) diff --git a/src/__tests__/ultisnips.py b/src/__tests__/ultisnips.py new file mode 100644 index 00000000000..8ab3d0e24b6 --- /dev/null +++ b/src/__tests__/ultisnips.py @@ -0,0 +1,280 @@ +import re, os, vim, string, random +from collections import deque, namedtuple + +_Placeholder = namedtuple("_FrozenPlaceholder", ["current_text", "start", "end"]) +_VisualContent = namedtuple("_VisualContent", ["mode", "text"]) +_Position = namedtuple("_Position", ["line", "col"]) + + +class _SnippetUtilCursor(object): + def __init__(self, cursor): + self._cursor = [cursor[0] - 1, cursor[1]] + self._set = False + + def preserve(self): + self._set = True + self._cursor = [vim.buf.cursor[0], vim.buf.cursor[1]] + + def is_set(self): + return self._set + + def set(self, line, column): + self.__setitem__(0, line) + self.__setitem__(1, column) + + def to_vim_cursor(self): + return (self._cursor[0] + 1, self._cursor[1]) + + def __getitem__(self, index): + return self._cursor[index] + + def __setitem__(self, index, value): + self._set = True + self._cursor[index] = value + + def __len__(self): + return 2 + + def __str__(self): + return str((self._cursor[0], self._cursor[1])) + + +class IndentUtil(object): + + """Utility class for dealing properly with indentation.""" + + def __init__(self): + self.reset() + + def reset(self): + """Gets the spacing properties from Vim.""" + self.shiftwidth = int( + vim.eval("exists('*shiftwidth') ? shiftwidth() : &shiftwidth") + ) + self._expandtab = vim.eval("&expandtab") == "1" + self._tabstop = int(vim.eval("&tabstop")) + + def ntabs_to_proper_indent(self, ntabs): + """Convert 'ntabs' number of tabs to the proper indent prefix.""" + line_ind = ntabs * self.shiftwidth * " " + line_ind = self.indent_to_spaces(line_ind) + line_ind = self.spaces_to_indent(line_ind) + return line_ind + + def indent_to_spaces(self, indent): + """Converts indentation to spaces respecting Vim settings.""" + indent = indent.expandtabs(self._tabstop) + right = (len(indent) - len(indent.rstrip(" "))) * " " + indent = indent.replace(" ", "") + indent = indent.replace("\t", " " * self._tabstop) + return indent + right + + def spaces_to_indent(self, indent): + """Converts spaces to proper indentation respecting Vim settings.""" + if not self._expandtab: + indent = indent.replace(" " * self._tabstop, "\t") + return indent + + +class SnippetUtil(object): + + """Provides easy access to indentation, etc. + + This is the 'snip' object in python code. + + """ + + def __init__(self, _initial_indent, start, end, context): + self._ind = IndentUtil() + self._visual = _VisualContent( + vim.eval("visualmode()"), vim.eval('get(g:,"coc_selected_text","")') + ) + self._initial_indent = _initial_indent + self._reset("") + self._start = start + self._end = end + self._context = context + + def _reset(self, cur): + """Gets the snippet ready for another update. + + :cur: the new value for c. + + """ + self._ind.reset() + self._cur = cur + self._rv = "" + self._changed = False + self.reset_indent() + + def shift(self, amount=1): + """Shifts the indentation level. Note that this uses the shiftwidth + because thats what code formatters use. + + :amount: the amount by which to shift. + + """ + self.indent += " " * self._ind.shiftwidth * amount + + def unshift(self, amount=1): + """Unshift the indentation level. Note that this uses the shiftwidth + because thats what code formatters use. + + :amount: the amount by which to unshift. + + """ + by = -self._ind.shiftwidth * amount + try: + self.indent = self.indent[:by] + except IndexError: + self.indent = "" + + def mkline(self, line="", indent=None): + """Creates a properly set up line. + + :line: the text to add + :indent: the indentation to have at the beginning + if None, it uses the default amount + + """ + if indent is None: + indent = self.indent + # this deals with the fact that the first line is + # already properly indented + if "\n" not in self._rv: + try: + indent = indent[len(self._initial_indent) :] + except IndexError: + indent = "" + indent = self._ind.spaces_to_indent(indent) + + return indent + line + + def reset_indent(self): + """Clears the indentation.""" + self.indent = self._initial_indent + + # Utility methods + @property + def fn(self): # pylint:disable=no-self-use,invalid-name + """The filename.""" + return vim.eval('expand("%:t")') or "" + + @property + def basename(self): # pylint:disable=no-self-use + """The filename without extension.""" + return vim.eval('expand("%:t:r")') or "" + + @property + def ft(self): # pylint:disable=invalid-name + """The filetype.""" + return self.opt("&filetype", "") + + @property + def rv(self): # pylint:disable=invalid-name + """The return value. + + The text to insert at the location of the placeholder. + + """ + return self._rv + + @rv.setter + def rv(self, value): # pylint:disable=invalid-name + """See getter.""" + self._changed = True + self._rv = value + + @property + def _rv_changed(self): + """True if rv has changed.""" + return self._changed + + @property + def c(self): # pylint:disable=invalid-name + """The current text of the placeholder.""" + return self._cur + + @property + def v(self): # pylint:disable=invalid-name + """Content of visual expansions.""" + return self._visual + + @property + def p(self): + if "coc_last_placeholder" in vim.vars: + p = vim.vars["coc_last_placeholder"] + start = _Position(p["start"]["line"], p["start"]["col"]) + end = _Position(p["end"]["line"], p["end"]["col"]) + return _Placeholder(p["current_text"], start, end) + return None + + @property + def context(self): + return self._context + + def opt(self, option, default=None): # pylint:disable=no-self-use + """Gets a Vim variable.""" + if vim.eval("exists('%s')" % option) == "1": + try: + return vim.eval(option) + except vim.error: + pass + return default + + def __add__(self, value): + """Appends the given line to rv using mkline.""" + self.rv += "\n" # pylint:disable=invalid-name + self.rv += self.mkline(value) + return self + + def __lshift__(self, other): + """Same as unshift.""" + self.unshift(other) + + def __rshift__(self, other): + """Same as shift.""" + self.shift(other) + + @property + def snippet_start(self): + """ + Returns start of the snippet in format (line, column). + """ + return self._start + + @property + def snippet_end(self): + """ + Returns end of the snippet in format (line, column). + """ + return self._end + + @property + def buffer(self): + return vim.buf + + +class ContextSnippet(object): + def __init__(self): + self.buffer = vim.current.buffer + self.window = vim.current.window + self.cursor = _SnippetUtilCursor(vim.current.window.cursor) + self.line = vim.current.window.cursor[0] - 1 + self.column = vim.current.window.cursor[1] - 1 + self.before = vim.eval('strpart(getline("."), 0, col(".") - 1)') + line = vim.eval('line(".")') + self.after = line[self.column :] + if "coc_selected_text" in vim.vars: + self.visual_mode = vim.eval("visualmode()") + self.visual_text = vim.vars["coc_selected_text"] + else: + self.visual_mode = None + self.visual_text = "" + if "coc_last_placeholder" in vim.vars: + p = vim.vars["coc_last_placeholder"] + start = _Position(p["start"]["line"], p["start"]["col"]) + end = _Position(p["end"]["line"], p["end"]["col"]) + self.last_placeholder = _Placeholder(p["current_text"], start, end) + else: + self.last_placeholder = None diff --git a/src/commands.ts b/src/commands.ts index bb0a84ed6ba..1006044e437 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -6,6 +6,7 @@ import diagnosticManager from './diagnostic/manager' import Mru from './model/mru' import Plugin from './plugin' import snipetsManager from './snippets/manager' +import { UltiSnippetOption } from './types' import { wait } from './util' import window from './window' import workspace from './workspace' @@ -59,9 +60,10 @@ export class CommandManager implements Disposable { }, true) this.register({ id: 'editor.action.insertSnippet', - execute: async (edit: TextEdit, ultisnip = false) => { + execute: async (edit: TextEdit, ultisnip?: UltiSnippetOption) => { await nvim.call('coc#_cancel', []) - return await snipetsManager.insertSnippet(edit.newText, true, edit.range, InsertTextMode.adjustIndentation, ultisnip) + const opts = ultisnip === true ? {} : ultisnip + return await snipetsManager.insertSnippet(edit.newText, true, edit.range, InsertTextMode.adjustIndentation, opts ? opts : undefined) } }, true) this.register({ diff --git a/src/handler/signature.ts b/src/handler/signature.ts index 0cada13406d..00544d2778e 100644 --- a/src/handler/signature.ts +++ b/src/handler/signature.ts @@ -90,21 +90,9 @@ export default class Signature { } public async triggerSignatureHelp(): Promise { - let { doc, position, mode } = await this.handler.getCurrentState() + let { doc, position } = await this.handler.getCurrentState() if (!languages.hasProvider('signature', doc.textDocument)) return false - let offset = 0 - let character = position.character - if (mode == 's') { - let placeholder = await this.nvim.getVar('coc_last_placeholder') as any - if (placeholder) { - let { start, end, bufnr } = placeholder - if (bufnr == doc.bufnr && start.line == end.line && start.line == position.line) { - position = Position.create(start.line, start.character) - offset = character - position.character - } - } - } - return await this._triggerSignatureHelp(doc, position, true, offset) + return await this._triggerSignatureHelp(doc, position, true, 0) } private async _triggerSignatureHelp(doc: Document, position: Position, invoke = true, offset = 0): Promise { diff --git a/src/model/document.ts b/src/model/document.ts index 13f6be1721b..3dd097c5c7b 100644 --- a/src/model/document.ts +++ b/src/model/document.ts @@ -315,7 +315,6 @@ export default class Document { } return diff }) - // console.log(JSON.stringify(sortedEdits, null, 2)) changes = sortedEdits.reverse().map(o => { let r = o.range let sl = this.getline(r.start.line) diff --git a/src/model/status.ts b/src/model/status.ts index 86533416986..b4bef947656 100644 --- a/src/model/status.ts +++ b/src/model/status.ts @@ -25,11 +25,13 @@ export default class StatusLine implements Disposable { private interval: NodeJS.Timer constructor(private nvim: Neovim) { this.interval = setInterval(() => { - this.setStatusText().logError() + this.setStatusText() }, 100) } public dispose(): void { + this.items.clear() + this.shownIds.clear() clearInterval(this.interval) } @@ -42,13 +44,16 @@ export default class StatusLine implements Disposable { isProgress, show: () => { this.shownIds.add(uid) + this.setStatusText() }, hide: () => { this.shownIds.delete(uid) + this.setStatusText() }, dispose: () => { this.shownIds.delete(uid) this.items.delete(uid) + this.setStatusText() } } this.items.set(uid, item) @@ -77,7 +82,7 @@ export default class StatusLine implements Disposable { return text } - private async setStatusText(): Promise { + private setStatusText(): void { let text = this.getText() let { nvim } = this if (text != this._text) { @@ -85,7 +90,7 @@ export default class StatusLine implements Disposable { nvim.pauseNotification() this.nvim.setVar('coc_status', text, true) this.nvim.call('coc#util#do_autocmd', ['CocStatusChange'], true) - await nvim.resumeNotification(false, true) + void nvim.resumeNotification(false, true) } } } diff --git a/src/snippets/eval.ts b/src/snippets/eval.ts new file mode 100644 index 00000000000..86bd07351c6 --- /dev/null +++ b/src/snippets/eval.ts @@ -0,0 +1,155 @@ +import { Neovim } from '@chemzqm/neovim' +import { Range } from '@chemzqm/neovim/lib/types' +import { exec } from 'child_process' +import { promisify } from 'util' +export type EvalKind = 'vim' | 'python' | 'shell' +const logger = require('../util/logger')('snippets-eval') +const isVim = process.env.VIM_NODE_RPC == '1' + +export interface UltiSnippetContext { + /** + * line on insert + */ + line: string + /** + * Range to replace, start.line should equal end.line + */ + range: Range + /** + * Context python code. + */ + context?: string + /** + * Regex trigger (python code) + */ + regex?: string +} + +/** + * Eval code for code placeholder. + */ +export async function evalCode(nvim: Neovim, kind: EvalKind, code: string, curr = ''): Promise { + if (kind == 'vim') { + let res = await nvim.eval(code) + return res.toString() + } + + if (kind == 'shell') { + let res = await promisify(exec)(code) + return res.stdout.replace(/\s*$/, '') || res.stderr + } + + let lines = [`snip._reset("${escapeString(curr)}")`] + lines.push(...code.split(/\r?\n/).map(line => line.replace(/\t/g, ' '))) + await executePythonCode(nvim, lines) + let res = await nvim.call(`pyxeval`, 'snip.rv') as string + return res.toString() +} + +export function preparePythonCodes(snip: UltiSnippetContext): string[] { + let { range, context, regex, line } = snip + let pyCodes: string[] = [ + 'import re, os, vim, string, random', + `path = vim.eval('expand("%:p")') or ""`, + `fn = os.path.basename(path)`, + ] + if (context) { + pyCodes.push(`snip = ContextSnippet()`) + pyCodes.push(`context = ${context}`) + } else { + pyCodes.push(`context = True`) + } + if (regex) { + let converted = convertRegex(regex) + pyCodes.push(`pattern = re.compile("${escapeString(converted)}")`) + pyCodes.push(`match = pattern.search("${escapeString(line.slice(0, range.end.character))}")`) + } else { + pyCodes.push(`match = None`) + } + let start = `(${range.start.line},${Buffer.byteLength(line.slice(0, range.start.character))})` + let end = `(${range.start.line},${Buffer.byteLength(line.slice(0, range.end.character))})` + let indent = line.match(/^\s*/)[0] + pyCodes.push(`snip = SnippetUtil("${escapeString(indent)}", ${start}, ${end}, context)`) + return pyCodes +} + +export async function executePythonCode(nvim: Neovim, codes: string[]) { + try { + await nvim.command(`pyx ${addPythonTryCatch(codes.join('\n'))}`) + } catch (e) { + let err = new Error(e.message) + err.stack = `Error on execute python code:\n${codes.join('\n')}\n` + e.stack + throw err + } +} + +export function getVariablesCode(values: { [index: number]: string }): string { + let keys = Object.keys(values) + let maxIndex = keys.length ? Math.max.apply(null, keys) : 0 + let vals = (new Array(maxIndex)).fill('""') + for (let [idx, val] of Object.entries(values)) { + vals[idx] = `"${escapeString(val)}"` + } + return `t = (${vals.join(',')},)` +} + +/** + * vim8 doesn't throw any python error with :py command + * we have to use g:errmsg since v:errmsg can't be changed in python script. + */ +export function addPythonTryCatch(code: string, force = false): string { + if (!isVim && force === false) return code + let lines = [ + 'import traceback, vim', + `vim.vars['errmsg'] = ''`, + 'try:', + ] + lines.push(...code.split('\n').map(line => ' ' + line)) + lines.push('except Exception as e:') + lines.push(` vim.vars['errmsg'] = traceback.format_exc()`) + return lines.join('\n') +} + +function escapeString(input: string): string { + return input + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\t/g, '\\t') + .replace(/\n/g, '\\n') +} + +const stringStartRe = /\\A/ +const conditionRe = /\(\?\(\w+\).+\|/ +const commentRe = /\(\?#.*?\)/ +const namedCaptureRe = /\(\?P<\w+>.*?\)/ +const namedReferenceRe = /\(\?P=(\w+)\)/ +const regex = new RegExp(`${commentRe.source}|${stringStartRe.source}|${namedCaptureRe.source}|${namedReferenceRe.source}`, 'g') + +/** + * Convert python regex to javascript regex, + * throw error when unsupported pattern found + */ +export function convertRegex(str: string): string { + if (str.indexOf('\\z') !== -1) { + throw new Error('pattern \\z not supported') + } + if (str.indexOf('(?s)') !== -1) { + throw new Error('pattern (?s) not supported') + } + if (str.indexOf('(?x)') !== -1) { + throw new Error('pattern (?x) not supported') + } + if (str.indexOf('\n') !== -1) { + throw new Error('pattern \\n not supported') + } + if (conditionRe.test(str)) { + throw new Error('pattern (?id/name)yes-pattern|no-pattern not supported') + } + return str.replace(regex, (match, p1) => { + if (match == '\\A') return '^' + if (match.startsWith('(?#')) return '' + if (match.startsWith('(?P<')) return '(?' + match.slice(3) + if (match.startsWith('(?P=')) return `\\k<${p1}>` + return '' + }) +} diff --git a/src/snippets/manager.ts b/src/snippets/manager.ts index 05227789a17..59f08b9a841 100644 --- a/src/snippets/manager.ts +++ b/src/snippets/manager.ts @@ -1,11 +1,10 @@ import { Disposable, InsertTextMode, Range } from 'vscode-languageserver-protocol' import events from '../events' import { StatusBarItem } from '../model/status' -import workspace from '../workspace' +import { UltiSnippetOption } from '../types' import window from '../window' -import * as Snippets from "./parser" +import workspace from '../workspace' import { SnippetSession } from './session' -import { SnippetVariableResolver } from './variableResolve' import { SnippetString } from './string' const logger = require('../util/logger')('snippets-manager') @@ -15,33 +14,34 @@ export class SnippetManager { private statusItem: StatusBarItem constructor() { - workspace.onDidChangeTextDocument(async e => { - let session = this.getSession(e.bufnr) - if (session) { - let firstLine = e.originalLines[e.contentChanges[0].range.start.line] || '' - await session.synchronizeUpdatedPlaceholders(e.contentChanges[0], firstLine) - } + events.on(['TextChanged', 'TextChangedI', 'TextChangedP'], bufnr => { + let session = this.getSession(bufnr as number) + if (session) session.sychronize() }, null, this.disposables) - - workspace.onDidCloseTextDocument(ev => { - let session = this.getSession(ev.bufnr) - if (session) session.deactivate() + events.on('CompleteDone', () => { + let session = this.getSession(workspace.bufnr) + if (session) session.sychronize() }, null, this.disposables) - - events.on('BufEnter', async bufnr => { + events.on('InsertCharPre', (_, bufnr) => { let session = this.getSession(bufnr) + if (session) session.sychronize() + }, null, this.disposables) + events.on('BufUnload', bufnr => { + let session = this.getSession(bufnr) + if (session) session.deactivate() + }, null, this.disposables) + window.onDidChangeActiveTextEditor(e => { if (!this.statusItem) return - if (session && session.isActive) { + let session = this.getSession(e.document.bufnr) + if (session) { this.statusItem.show() } else { this.statusItem.hide() } }, null, this.disposables) - - events.on('InsertEnter', async () => { - let { session } = this - if (!session) return - await session.checkPosition() + events.on('InsertEnter', async bufnr => { + let session = this.getSession(bufnr) + if (session) await session.checkPosition() }, null, this.disposables) } @@ -54,22 +54,27 @@ export class SnippetManager { /** * Insert snippet at current cursor position */ - public async insertSnippet(snippet: string | SnippetString, select = true, range?: Range, insertTextMode?: InsertTextMode, ultisnip = false): Promise { + public async insertSnippet(snippet: string | SnippetString, select = true, range?: Range, insertTextMode?: InsertTextMode, ultisnip?: UltiSnippetOption): Promise { let { bufnr } = workspace + let doc = workspace.getDocument(bufnr) + if (!doc || !doc.attached) return false let session = this.getSession(bufnr) if (!session) { session = new SnippetSession(workspace.nvim, bufnr) this.sessionMap.set(bufnr, session) session.onCancel(() => { this.sessionMap.delete(bufnr) - if (workspace.bufnr == bufnr) { - this.statusItem.hide() - } + this.statusItem.hide() }) } let snippetStr = SnippetString.isSnippetString(snippet) ? snippet.value : snippet let isActive = await session.start(snippetStr, select, range, insertTextMode, ultisnip) - if (isActive) this.statusItem.show() + if (isActive) { + this.statusItem.show() + } else { + this.statusItem.hide() + this.sessionMap.delete(bufnr) + } return isActive } @@ -108,35 +113,21 @@ export class SnippetManager { } public get session(): SnippetSession { - let session = this.getSession(workspace.bufnr) - return session && session.isActive ? session : null + return this.getSession(workspace.bufnr) } - public isActived(bufnr: number): boolean { - let session = this.getSession(bufnr) - return session && session.isActive ? true : false + public getSession(bufnr: number): SnippetSession { + return this.sessionMap.get(bufnr) } public jumpable(): boolean { let { session } = this if (!session) return false - let placeholder = session.placeholder - if (placeholder && !placeholder.isFinalTabstop) { - return true - } - return false - } - - public getSession(bufnr: number): SnippetSession { - return this.sessionMap.get(bufnr) + return session.placeholder != null && session.placeholder.index != 0 } - public async resolveSnippet(body: string, ultisnip = false): Promise { - let parser = new Snippets.SnippetParser(ultisnip) - const snippet = parser.parse(body, true) - const resolver = new SnippetVariableResolver() - await snippet.resolveVariables(resolver) - return snippet + public async resolveSnippet(snippetString: string, ultisnip?: UltiSnippetOption): Promise { + return await SnippetSession.resolveSnippet(workspace.nvim, snippetString, ultisnip) } public dispose(): void { diff --git a/src/snippets/parser.ts b/src/snippets/parser.ts index 8170e39a15e..01b330e1508 100644 --- a/src/snippets/parser.ts +++ b/src/snippets/parser.ts @@ -3,10 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CharCode } from '../util/charCode' -import { rangeParts, getCharIndexes } from '../util/string' -import { Range } from 'vscode-languageserver-protocol' +import { Neovim } from '@chemzqm/neovim' import unidecode from 'unidecode' +import { groupBy } from '../util/array' +import { CharCode } from '../util/charCode' +import { getCharIndexes } from '../util/string' +import { convertRegex, evalCode, EvalKind, executePythonCode, getVariablesCode } from './eval' const logger = require('../util/logger')('snippets-parser') const knownRegexOptions = ['d', 'g', 'i', 'm', 's', 'u', 'y'] @@ -28,6 +30,8 @@ export const enum TokenType { EOF, OpenParen, CloseParen, + BackTick, + ExclamationMark, } export interface Token { @@ -52,6 +56,8 @@ export class Scanner { [CharCode.QuestionMark]: TokenType.QuestionMark, [CharCode.OpenParen]: TokenType.OpenParen, [CharCode.CloseParen]: TokenType.CloseParen, + [CharCode.BackTick]: TokenType.BackTick, + [CharCode.ExclamationMark]: TokenType.ExclamationMark, } public static isDigitCharacter(ch: number): boolean { @@ -162,18 +168,11 @@ export abstract class Marker { this._children = [child] } - public replace(child: Marker, others: Marker[]): void { - const { parent } = child - const idx = parent.children.indexOf(child) - const newChildren = parent.children.slice(0) - newChildren.splice(idx, 1, ...others) - parent._children = newChildren - ; (function _fixParent(children: Marker[], parent: Marker): void { - for (const child of children) { - child.parent = parent - _fixParent(child.children, child) - } - })(others, parent) + public replaceChildren(children: Marker[]): void { + for (const child of children) { + child.parent = this + } + this._children = children } public get children(): Marker[] { @@ -205,13 +204,6 @@ export abstract class Marker { return 0 } - public get next(): Marker | null { - let { parent } = this - let { children } = parent - let idx = children.indexOf(this) - return children[idx + 1] - } - public abstract clone(): Marker } @@ -239,26 +231,92 @@ export class Text extends Marker { } } +export class CodeBlock extends Marker { + + private _value = '' + private _related: number[] = [] + + constructor(public code: string, public readonly kind: EvalKind, value?: string) { + super() + if (kind === 'python') { + let { _related } = this + let arr + let re = /\bt\[(\d+)\]/g + // eslint-disable-next-line no-constant-condition + while (true) { + arr = re.exec(code) + if (arr == null) break + let n = parseInt(arr[1], 10) + if (!_related.includes(n)) _related.push(n) + } + } + if (value !== undefined) this._value = value + } + + public get related(): number[] { + return this._related + } + + public update(map: Map): void { + if (this.kind !== 'python') return + let related: Set = new Set() + this.code = this.code.replace(/\bt\[(\d+)\]/g, (_, p1) => { + let idx = Number(p1) + let id = map.has(idx) ? map.get(idx) : idx + related.add(id) + return `t[${id}]` + }) + this._related = Array.from(related) + } + + public get index(): number | undefined { + if (this.parent instanceof Placeholder) { + return this.parent.index + } + return undefined + } + + public async resolve(nvim: Neovim): Promise { + if (!this.code.length) return + let res = await evalCode(nvim, this.kind, this.code, this._value) + if (res != null) this._value = res + } + + public len(): number { + return this._value.length + } + + public toString(): string { + return this._value + } + + public get value(): string { + return this._value + } + + public toTextmateString(): string { + let t = '' + if (this.kind == 'python') { + t = '!p ' + } else if (this.kind == 'shell') { + t = '' + } else if (this.kind == 'vim') { + t = '!v ' + } + return '`' + t + (this.code) + '`' + } + + public clone(): CodeBlock { + return new CodeBlock(this.code, this.kind, this.value) + } +} + export abstract class TransformableMarker extends Marker { public transform: Transform } export class Placeholder extends TransformableMarker { - public static compareByIndex(a: Placeholder, b: Placeholder): number { - if (a.index === b.index) { - return 0 - } else if (a.isFinalTabstop) { - return 1 - } else if (b.isFinalTabstop) { - return -1 - } else if (a.index < b.index) { - return -1 - } else if (a.index > b.index) { - return 1 - } else { - return 0 - } - } + public primary = false constructor(public index: number) { super() @@ -479,13 +537,22 @@ export class FormatString extends Marker { } export class Variable extends TransformableMarker { + private _resolved = false - constructor(public name: string) { + constructor(public name: string, resolved?: boolean) { super() + if (typeof resolved === 'boolean') { + this._resolved = resolved + } + } + + public get resovled(): boolean { + return this._resolved } public async resolve(resolver: VariableResolver): Promise { let value = await resolver.resolve(this) + this._resolved = true if (value && value.includes('\n')) { // get indent from previous texts let indent = '' @@ -530,7 +597,7 @@ export class Variable extends TransformableMarker { } public clone(): Variable { - const ret = new Variable(this.name) + const ret = new Variable(this.name, this.resovled) if (this.transform) { ret.transform = this.transform.clone() } @@ -543,6 +610,13 @@ export interface VariableResolver { resolve(variable: Variable): Promise } +export interface PlaceholderInfo { + placeholders: Placeholder[] + variables: Variable[] + pyBlocks: CodeBlock[] + otherBlocks: CodeBlock[] +} + function walk(marker: Marker[], visitor: (marker: Marker) => boolean): void { const stack = [...marker] while (stack.length > 0) { @@ -558,44 +632,212 @@ function walk(marker: Marker[], visitor: (marker: Marker) => boolean): void { export class TextmateSnippet extends Marker { public readonly ultisnip: boolean - private _placeholders?: { all: Placeholder[]; last?: Placeholder } - private _variables?: Variable[] + private _placeholders?: PlaceholderInfo + private _values?: { [index: number]: string } constructor(ultisnip?: boolean) { super() this.ultisnip = ultisnip === true } - public get placeholderInfo(): { all: Placeholder[]; last?: Placeholder } { + public get hasPython(): boolean { + if (!this.ultisnip) return false + return this.pyBlocks.length > 0 + } + + public get hasCodeBlock(): boolean { + if (!this.ultisnip) return false + let { pyBlocks, otherBlocks } = this + return pyBlocks.length > 0 || otherBlocks.length > 0 + } + + /** + * Values for each placeholder index + */ + public get values(): { [index: number]: string } { + if (this._values) return this._values + let values: { [index: number]: string } = {} + let maxIndexNumber = 0 + this.placeholders.forEach(c => { + maxIndexNumber = Math.max(c.index, maxIndexNumber) + if (c.transform != null) return + if (c.primary || values[c.index] === undefined) values[c.index] = c.toString() + }) + for (let i = 0; i <= maxIndexNumber; i++) { + if (values[i] === undefined) values[i] = '' + } + this._values = values + return values + } + + public get orderedPyIndexBlocks(): CodeBlock[] { + let res: CodeBlock[] = [] + let filtered = this.pyBlocks.filter(o => typeof o.index === 'number') + if (filtered.length == 0) return res + let allIndexes = filtered.map(o => o.index) + let usedIndexes: number[] = [] + const checkBlock = (b: CodeBlock): boolean => { + let { related } = b + if (related.length == 0 + || related.every(idx => !allIndexes.includes(idx) || usedIndexes.includes(idx))) { + usedIndexes.push(b.index) + res.push(b) + return true + } + return false + } + while (filtered.length > 0) { + let c = false + for (let b of filtered) { + if (checkBlock(b)) { + c = true + } + } + if (!c) { + // recuisive dependencies detected + break + } + filtered = filtered.filter(o => !usedIndexes.includes(o.index)) + } + return res + } + + public async evalCodeBlocks(nvim: Neovim, prepareCodes: string[]): Promise { + let { pyBlocks, otherBlocks } = this + // update none python blocks + await Promise.all(otherBlocks.map(block => { + let pre = block.value + return block.resolve(nvim).then(() => { + if (block.parent instanceof Placeholder && pre !== block.value) { + // update placeholder with same index + this.onPlaceholderUpdate(block.parent) + } + }) + })) + if (pyBlocks.length) { + // run all python code by sequence + const variableCode = getVariablesCode(this.values) + await executePythonCode(nvim, [...prepareCodes, variableCode]) + for (let block of pyBlocks) { + let pre = block.value + await block.resolve(nvim) + if (pre === block.value) continue + if (block.parent instanceof Placeholder) { + // update placeholder with same index + this.onPlaceholderUpdate(block.parent) + await executePythonCode(nvim, [getVariablesCode(this.values)]) + } + } + for (let block of this.orderedPyIndexBlocks) { + await this.updatePyIndexBlock(nvim, block) + } + // update normal python block with related. + let filtered = pyBlocks.filter(o => o.index === undefined && o.related.length > 0) + if (filtered.length) await Promise.all(filtered.map(block => block.resolve(nvim))) + } + } + + /** + * Update python blocks after user change Placeholder with index + */ + public async updatePythonCodes(nvim: Neovim, marker: Marker): Promise { + let index: number | undefined + if (marker instanceof Placeholder) { + index = marker.index + } else { + while (marker.parent) { + if (marker instanceof Placeholder) { + index = marker.index + break + } + marker = marker.parent + } + } + if (index === undefined) return + // update related placeholders + let blocks = this.getDependentPyIndexBlocks(index) + await executePythonCode(nvim, [getVariablesCode(this.values)]) + for (let block of blocks) { + await this.updatePyIndexBlock(nvim, block) + } + // update normal py codes. + let filtered = this.pyBlocks.filter(o => o.index === undefined && o.related.length > 0) + if (filtered.length) await Promise.all(filtered.map(block => block.resolve(nvim))) + } + + private getDependentPyIndexBlocks(index: number): CodeBlock[] { + const res: CodeBlock[] = [] + const taken: number[] = [] + let filtered = this.pyBlocks.filter(o => typeof o.index === 'number') + const search = (idx: number) => { + let blocks = filtered.filter(o => !taken.includes(o.index) && o.related.includes(idx)) + if (blocks.length > 0) { + res.push(...blocks) + blocks.forEach(b => { + search(b.index) + }) + } + } + search(index) + return res + } + + /** + * Update single index block + */ + private async updatePyIndexBlock(nvim: Neovim, block: CodeBlock): Promise { + let pre = block.value + await block.resolve(nvim) + if (pre === block.value) return + if (block.parent instanceof Placeholder) { + this.onPlaceholderUpdate(block.parent) + } + await executePythonCode(nvim, [getVariablesCode(this.values)]) + } + + public get placeholderInfo(): PlaceholderInfo { if (!this._placeholders) { - this._variables = [] + const variables = [] + const pyBlocks: CodeBlock[] = [] + const otherBlocks: CodeBlock[] = [] // fill in placeholders - let all: Placeholder[] = [] - let last: Placeholder | undefined + let placeholders: Placeholder[] = [] this.walk(candidate => { if (candidate instanceof Placeholder) { - all.push(candidate) - last = !last || last.index < candidate.index ? candidate : last + placeholders.push(candidate) } else if (candidate instanceof Variable) { let first = candidate.name.charCodeAt(0) // not jumpover for uppercase variable. if (first < 65 || first > 90) { - this._variables.push(candidate) + variables.push(candidate) + } + } else if (candidate instanceof CodeBlock) { + if (candidate.kind === 'python') { + pyBlocks.push(candidate) + } else { + otherBlocks.push(candidate) } } return true }) - this._placeholders = { all, last } + this._placeholders = { placeholders, pyBlocks, otherBlocks, variables } } return this._placeholders } public get variables(): Variable[] { - return this._variables + return this.placeholderInfo.variables } public get placeholders(): Placeholder[] { - const { all } = this.placeholderInfo - return all + return this.placeholderInfo.placeholders + } + + public get pyBlocks(): CodeBlock[] { + return this.placeholderInfo.pyBlocks + } + + public get otherBlocks(): CodeBlock[] { + return this.placeholderInfo.otherBlocks } public get maxIndexNumber(): number { @@ -603,77 +845,116 @@ export class TextmateSnippet extends Marker { return placeholders.reduce((curr, p) => Math.max(curr, p.index), 0) } - public get minIndexNumber(): number { - let { placeholders } = this - let nums = placeholders.map(p => p.index) - nums.sort((a, b) => a - b) - if (nums.length > 1 && nums[0] == 0) return nums[1] - return nums[0] || 0 + public get first(): Placeholder | Variable { + let { placeholders, variables } = this + let [normals, finals] = groupBy(placeholders.filter(p => !p.transform), v => v.index !== 0) + if (normals.length) { + let minIndex = Math.min.apply(null, normals.map(o => o.index)) + let arr = normals.filter(v => v.index == minIndex) + return arr.find(p => p.primary) ?? arr[0] + } + if (variables.length) return variables[0] + return finals.find(o => o.primary) ?? finals[0] } - public insertSnippet(snippet: string, id: number, range: Range, ultisnip = false): number { - let placeholder = this.placeholders[id] - if (!placeholder) return - let { index } = placeholder - let [before, after] = rangeParts(placeholder.toString(), range) + public insertSnippet(snippet: string, marker: Placeholder | Variable, parts: [string, string], ultisnip = false): Placeholder | Variable { + let index = marker instanceof Placeholder ? marker.index : this.maxIndexNumber + 1 + let [before, after] = parts let nested = new SnippetParser(ultisnip).parse(snippet, true) let maxIndexAdded = nested.maxIndexNumber + 1 - let indexes: number[] = [] + let changed: Map = new Map() for (let p of nested.placeholders) { + let idx = p.index if (p.isFinalTabstop) { p.index = maxIndexAdded + index } else { p.index = p.index + index } - indexes.push(p.index) + changed.set(idx, p.index) } + if (ultisnip) { + nested.pyBlocks.forEach(b => { + b.update(changed) + }) + } + let map: Map = new Map() this.walk(m => { if (m instanceof Placeholder && m.index > index) { + let idx = m.index m.index = m.index + maxIndexAdded + map.set(idx, m.index) } return true }) - let children = nested.children + if (this.hasPython) { + this.walk(m => { + if (m instanceof CodeBlock) { + m.update(map) + } + return true + }) + } + const select = nested.first + let children = nested.children.slice() if (before) children.unshift(new Text(before)) if (after) children.push(new Text(after)) - this.replace(placeholder, children) - return Math.min.apply(null, indexes) - } - - public updatePlaceholder(id: number, val: string): void { - const placeholder = this.placeholders[id] - for (let p of this.placeholders) { - if (p.index == placeholder.index) { - let child = p.children[0] - let newText = p.transform ? p.transform.resolve(val) : val - if (child) { - p.setOnlyChild(new Text(newText)) - } else { - p.appendChild(new Text(newText)) - } - } + this.replace(marker, children) + return select + } + + public async update(nvim: Neovim, marker: Placeholder | Variable, value: string): Promise { + this.resetMarker(marker, value) + if (this.hasPython) { + await this.updatePythonCodes(nvim, marker) } - this._placeholders = undefined } - public updateVariable(id: number, val: string): void { - const find = this.variables[id - this.maxIndexNumber - 1] - if (find) { - let variables = this.variables.filter(o => o.name == find.name) - for (let variable of variables) { - let newText = variable.transform ? variable.transform.resolve(val) : val - variable.setOnlyChild(new Text(newText)) - } + public resetMarker(marker: Placeholder | Variable, val: string): void { + let markers: (Placeholder | Variable)[] + if (marker instanceof Placeholder) { + markers = this.placeholders.filter(o => o.index == marker.index) + } else { + markers = this.variables.filter(o => o.name == marker.name) } + for (let p of markers) { + let newText = p.transform ? p.transform.resolve(val) : val + p.setOnlyChild(new Text(newText || '')) + } + this.sychronizeParents(markers) + this.reset() } /** - * newText after update with value + * Reflact changes for related markers. */ - public getPlaceholderText(id: number, value: string): string { - const placeholder = this.placeholders[id] - if (!placeholder) return value - return placeholder.transform ? placeholder.transform.resolve(value) : value + public onPlaceholderUpdate(marker: Placeholder | Variable): void { + let val = marker.toString() + let markers: Placeholder[] | Variable[] + if (marker instanceof Placeholder) { + this.values[marker.index] = val + markers = this.placeholders.filter(o => o.index == marker.index) + } else { + markers = this.variables.filter(o => o.name == marker.name) + } + for (let p of markers) { + if (p === marker) continue + let newText = p.transform ? p.transform.resolve(val) : val + p.setOnlyChild(new Text(newText || '')) + } + this.sychronizeParents(markers) + } + + public sychronizeParents(markers: Marker[]): void { + let arr: Placeholder[] = [] + markers.forEach(m => { + let p = m.parent + if (p instanceof Placeholder && !arr.includes(p)) { + arr.push(p) + } + }) + arr.forEach(p => { + this.onPlaceholderUpdate(p) + }) } public offset(marker: Marker): number { @@ -703,7 +984,25 @@ export class TextmateSnippet extends Marker { return ret } - public enclosingPlaceholders(placeholder: Placeholder): Placeholder[] { + public getTextBefore(marker: Marker, parent: Placeholder): string { + let res = '' + const calc = (m: Marker): void => { + let p = m.parent + if (!p) return + let s = '' + for (let b of p.children) { + if (b === m) break + s = s + b.toString() + } + res = s + res + if (p == parent) return + calc(p) + } + calc(marker) + return res + } + + public enclosingPlaceholders(placeholder: Placeholder | Variable): Placeholder[] { let ret: Placeholder[] = [] let { parent } = placeholder while (parent) { @@ -718,22 +1017,36 @@ export class TextmateSnippet extends Marker { public async resolveVariables(resolver: VariableResolver): Promise { let items: Variable[] = [] this.walk(candidate => { - if (candidate instanceof Variable) { + if (candidate instanceof Variable && !candidate.resovled) { items.push(candidate) } return true }) - await Promise.all(items.map(o => o.resolve(resolver))) + if (items.length) { + await Promise.all(items.map(o => o.resolve(resolver))) + this.sychronizeParents(items) + } } public appendChild(child: Marker): this { - this._placeholders = undefined + this.reset() return super.appendChild(child) } - public replace(child: Marker, others: Marker[]): void { + public replace(marker: Marker, children: Marker[]): void { + marker.replaceChildren(children) + if (marker instanceof Placeholder || marker instanceof Variable) { + this.onPlaceholderUpdate(marker) + } + this.reset() + } + + /** + * Used on replace happens. + */ + public reset(): void { this._placeholders = undefined - return super.replace(child, others) + this._values = undefined } public toTextmateString(): string { @@ -742,7 +1055,7 @@ export class TextmateSnippet extends Marker { public clone(): TextmateSnippet { let ret = new TextmateSnippet(this.ultisnip) - this._children = this.children.map(child => child.clone()) + ret._children = this.children.map(child => child.clone()) return ret } @@ -752,13 +1065,20 @@ export class TextmateSnippet extends Marker { } export class SnippetParser { - constructor(private ultisnip?: boolean) { + constructor( + private ultisnip?: boolean + ) { } public static escape(value: string): string { return value.replace(/\$|}|\\/g, '\\$&') } + public static isPlainText(value: string): boolean { + let s = new SnippetParser().parse(value.replace(/\$0$/, ''), false) + return s.children.length == 1 && s.children[0] instanceof Text + } + private _scanner = new Scanner() private _token: Token @@ -778,45 +1098,53 @@ export class SnippetParser { // fill in values for placeholders. the first placeholder of an index // that has a value defines the value for all placeholders with that index - const placeholderDefaultValues = new Map() + const defaultValues = new Map() const incompletePlaceholders: Placeholder[] = [] + let complexPlaceholders: Placeholder[] = [] + let hasFinal = false snippet.walk(marker => { if (marker instanceof Placeholder) { - if (marker.isFinalTabstop) { - placeholderDefaultValues.set(0, undefined) - } else if (!placeholderDefaultValues.has(marker.index) && marker.children.length > 0) { - placeholderDefaultValues.set(marker.index, marker.children) + if (marker.index == 0) hasFinal = true + if (marker.children.some(o => o instanceof Placeholder)) { + complexPlaceholders.push(marker) + } else if (!defaultValues.has(marker.index) && marker.children.length > 0) { + marker.primary = true + defaultValues.set(marker.index, marker.toString()) } else { incompletePlaceholders.push(marker) } } return true }) + for (const placeholder of incompletePlaceholders) { - if (placeholderDefaultValues.has(placeholder.index)) { - const clone = new Placeholder(placeholder.index) - clone.transform = placeholder.transform - for (const child of placeholderDefaultValues.get(placeholder.index)) { - let marker = child.clone() - if (clone.transform) { - if (marker instanceof Text) { - marker = new Text(clone.transform.resolve(marker.value)) - } else { - for (let child of marker.children) { - if (child instanceof Text) { - marker.replace(child, [new Text(clone.transform.resolve(child.value))]) - break - } - } - } + // avoid transform and replace since no value exists. + if (defaultValues.has(placeholder.index)) { + let val = defaultValues.get(placeholder.index) + let text = new Text(placeholder.transform ? placeholder.transform.resolve(val) : val) + placeholder.setOnlyChild(text) + } + } + const resolveComplex = () => { + let resolved: Set = new Set() + for (let p of complexPlaceholders) { + if (p.children.every(o => !(o instanceof Placeholder) || defaultValues.has(o.index))) { + let val = p.toString() + defaultValues.set(p.index, val) + for (let placeholder of incompletePlaceholders.filter(o => o.index == p.index)) { + let text = new Text(placeholder.transform ? placeholder.transform.resolve(val) : val) + placeholder.setOnlyChild(text) } - clone.appendChild(marker) + resolved.add(p.index) } - snippet.replace(placeholder, [clone]) } + complexPlaceholders = complexPlaceholders.filter(p => !resolved.has(p.index)) + if (complexPlaceholders.length == 0 || !resolved.size) return + resolveComplex() } + resolveComplex() - if (!placeholderDefaultValues.has(0) && insertFinalTabstop) { + if (!hasFinal && insertFinalTabstop) { // the snippet uses placeholders but has no // final tabstop defined -> insert at the end snippet.appendChild(new Placeholder(0)) @@ -862,6 +1190,7 @@ export class SnippetParser { private _parse(marker: Marker): boolean { return this._parseEscaped(marker) + || this._parseCodeBlock(marker) || this._parseTabstopOrVariableName(marker) || this._parseComplexPlaceholder(marker) || this._parseComplexVariable(marker) @@ -877,6 +1206,7 @@ export class SnippetParser { value = this._accept(TokenType.Dollar, true) || this._accept(TokenType.CurlyClose, true) || this._accept(TokenType.Backslash, true) + || (this.ultisnip && this._accept(TokenType.BackTick, true)) || value marker.appendChild(new Text(value)) @@ -1139,7 +1469,7 @@ export class SnippetParser { if (c == 'a') { ascii = true } else { - if (knownRegexOptions.includes(c)) { + if (!knownRegexOptions.includes(c)) { logger.error(`Unknown regex option: ${c}`) } regexOptions += c @@ -1151,6 +1481,7 @@ export class SnippetParser { try { if (ascii) transform.ascii = true + if (this.ultisnip) regexValue = convertRegex(regexValue) transform.regexp = new RegExp(regexValue, regexOptions) } catch (e) { return false @@ -1286,6 +1617,49 @@ export class SnippetParser { return false } + private _parseCodeBlock(parent: Marker): boolean { + if (!this.ultisnip) return false + const token = this._token + if (!this._accept(TokenType.BackTick)) { + return false + } + let text = this._until(TokenType.BackTick, true) + // `shell code` `!v` `!p` + if (text) { + if (!text.startsWith('!')) { + let marker = new CodeBlock(text.trim(), 'shell') + parent.appendChild(marker) + return true + } + if (text.startsWith('!v')) { + let marker = new CodeBlock(text.slice(2).trim(), 'vim') + parent.appendChild(marker) + return true + } + if (text.startsWith('!p')) { + let code = text.slice(2) + if (code.indexOf('\n') == -1) { + let marker = new CodeBlock(code.trim(), 'python') + parent.appendChild(marker) + } else { + let codes = code.split(/\r?\n/) + codes = codes.filter(s => !/^\s*$/.test(s)) + // format multi line code + let ind = codes[0] ? codes[0].match(/^\s*/)[0] : '' + if (ind.length && codes.every(s => s.startsWith(ind))) { + codes = codes.map(s => s.slice(ind.length)) + } + if (ind == ' ' && codes[0].startsWith(ind)) codes[0] = codes[0].slice(1) + let marker = new CodeBlock(codes.join('\n'), 'python') + parent.appendChild(marker) + } + return true + } + } + this._backTo(token) + return false + } + private _parseAnything(marker: Marker): boolean { if (this._token.type !== TokenType.EOF) { let text = this._scanner.tokenText(this._token) diff --git a/src/snippets/session.ts b/src/snippets/session.ts index bd08ed90467..7dd2f71ce17 100644 --- a/src/snippets/session.ts +++ b/src/snippets/session.ts @@ -1,26 +1,31 @@ import { Neovim } from '@chemzqm/neovim' -import { FormattingOptions } from 'jsonc-parser' -import { Emitter, Event, InsertTextMode, Range, TextDocumentContentChangeEvent, TextEdit } from 'vscode-languageserver-protocol' +import { CancellationTokenSource, Emitter, Event, InsertTextMode, Position, Range, TextEdit } from 'vscode-languageserver-protocol' import completion from '../completion' +import events from '../events' import Document from '../model/document' -import { comparePosition, isSingleLine, positionInRange, rangeInRange } from '../util/position' -import { byteLength, characterIndex } from '../util/string' -import workspace from '../workspace' +import { LinesTextDocument } from '../model/textdocument' +import { UltiSnippetOption } from '../types' +import { equals } from '../util/object' +import { comparePosition, positionInRange, rangeInRange } from '../util/position' +import { byteLength } from '../util/string' import window from '../window' -import events from '../events' -import { CocSnippet, CocSnippetPlaceholder } from "./snippet" +import workspace from '../workspace' +import { UltiSnippetContext } from './eval' +import { Marker, Placeholder, SnippetParser } from './parser' +import { checkContentBefore, checkCursor, CocSnippet, CocSnippetPlaceholder, getEnd, getEndPosition, getParts, normalizeSnippetString, reduceTextEdit, shouldFormat } from "./snippet" import { SnippetVariableResolver } from "./variableResolve" -import { singleLineEdit } from '../util/textedit' const logger = require('../util/logger')('snippets-session') +const NAME_SPACE = 'snippets' export class SnippetSession { private _isActive = false - private _currId = 0 - // Get state of line where we inserted - private applying = false + private current: Marker + private textDocument: LinesTextDocument private preferComplete = false private _snippet: CocSnippet = null private _onCancelEvent = new Emitter() + private tokenSource: CancellationTokenSource + private timer: NodeJS.Timer public readonly onCancel: Event = this._onCancelEvent.event constructor(private nvim: Neovim, public readonly bufnr: number) { @@ -28,65 +33,68 @@ export class SnippetSession { this.preferComplete = suggest.get('preferCompleteThanJumpPlaceholder', false) } - public async start(snippetString: string, select = true, range?: Range, insertTextMode?: InsertTextMode, ultisnip = false): Promise { + public async start(snippetString: string, select = true, range?: Range, insertTextMode?: InsertTextMode, ultisnip?: UltiSnippetOption): Promise { + range = await this.getEditRange(range) + let position = range.start const { document } = this if (!document || !document.attached) return false + await this.forceSynchronize() + if (positionInRange(position, Range.create(0, 0, document.lineCount + 1, 0)) !== 0) return false void events.fire('InsertSnippet', []) - if (!range) { - let position = await window.getCursorPosition() - range = Range.create(position, position) - } - let position = range.start - await document.patchChange() const currentLine = document.getline(position.line) - const currentIndent = currentLine.match(/^\s*/)[0] - let inserted = '' - if (insertTextMode === InsertTextMode.asIs) { - inserted = snippetString - } else { - const formatOptions = await workspace.getFormatOptions(this.document.uri) - inserted = normalizeSnippetString(snippetString, currentIndent, formatOptions) - } - const resolver = new SnippetVariableResolver() - const snippet = new CocSnippet(inserted, position, resolver, ultisnip) - await snippet.init() - const edit = TextEdit.replace(range, snippet.toString()) - if (snippetString.endsWith('\n') - && currentLine.slice(position.character).length) { - // make next line same indent - edit.newText = edit.newText + currentIndent - inserted = inserted + currentIndent + const inserted = await this.normalizeInsertText(snippetString, currentLine, insertTextMode) + if (!this.isActive && SnippetParser.isPlainText(snippetString)) { + await this.insertPlainText(inserted, range) + return false } - this.applying = true - await document.applyEdits([edit]) - this.applying = false - if (this._isActive) { - // find valid placeholder - let placeholder = this.findPlaceholder(range) - // insert to placeholder - if (placeholder && !placeholder.isFinalTabstop) { - // don't repeat snippet insert - let index = this.snippet.insertSnippet(placeholder, inserted, range, ultisnip) - let p = this.snippet.getPlaceholder(index) - this._currId = p.id - if (select) await this.selectPlaceholder(p) - return true + let context: UltiSnippetContext + if (ultisnip) context = Object.assign({ range, line: currentLine }, ultisnip) + const placeholder = this.getReplacePlaceholder(range) + const edits: TextEdit[] = [] + if (placeholder) { + // update all snippet. + let r = this.snippet.range + let parts = getParts(placeholder.value, placeholder.range, range) + this.current = await this.snippet.insertSnippet(placeholder, inserted, parts, context) + edits.push(TextEdit.replace(r, this.snippet.text)) + } else { + const resolver = new SnippetVariableResolver(this.nvim, workspace.workspaceFolderControl) + let snippet = new CocSnippet(inserted, position, this.nvim, resolver) + await snippet.init(context) + this._snippet = snippet + this.current = snippet.firstPlaceholder?.marker + edits.push(TextEdit.replace(range, snippet.text)) + // try fix indent of remain text + if (inserted.replace(/\$0$/, '').endsWith('\n')) { + const remain = currentLine.slice(range.end.character) + if (remain.length) { + let s = range.end.character + let l = remain.match(/^\s*/)[0].length + let r = Range.create(range.end.line, s, range.end.line, s + l) + edits.push(TextEdit.replace(r, currentLine.match(/^\s*/)[0])) + } } } - if (snippet.isPlainText) { - this.deactivate() - let placeholder = snippet.finalPlaceholder - await window.moveTo(placeholder.range.start) - return false - } - // new snippet - this._snippet = snippet - this._currId = snippet.firstPlaceholder.id - if (select) await this.selectPlaceholder(snippet.firstPlaceholder) + await document.applyEdits(edits) + this.textDocument = document.textDocument this.activate() + if (select && this.current) { + let placeholder = this.snippet.getPlaceholderByMarker(this.current) + await this.selectPlaceholder(placeholder, true) + } return true } + /** + * Get valid placeholder to insert + */ + private getReplacePlaceholder(range: Range): CocSnippetPlaceholder | undefined { + if (!this.snippet) return undefined + let placeholder = this.findPlaceholder(range) + if (!placeholder || placeholder.index == 0) return undefined + return placeholder + } + private activate(): void { if (this._isActive) return this._isActive = true @@ -94,14 +102,16 @@ export class SnippetSession { } public deactivate(): void { - if (this._isActive) { - this._isActive = false - this._snippet = null - this.nvim.call('coc#snippet#disable', [], true) - logger.debug("[SnippetManager::cancel]") - } + this.cancel() + if (!this._isActive) return + this._isActive = false + this.current = null + this.textDocument = undefined + this.nvim.call('coc#snippet#disable', [], true) + this.nvim.call('coc#highlight#clear_highlight', [this.bufnr, NAME_SPACE, 0, -1], true) this._onCancelEvent.fire(void 0) this._onCancelEvent.dispose() + logger.debug(`session ${this.bufnr} cancelled`) } public get isActive(): boolean { @@ -109,84 +119,31 @@ export class SnippetSession { } public async nextPlaceholder(): Promise { - if (!this.isActive) return - await this.document.patchChange() + await this.forceSynchronize() let curr = this.placeholder - let next = this.snippet.getNextPlaceholder(curr.index) - await this.selectPlaceholder(next) + if (curr) { + let next = this.snippet.getNextPlaceholder(curr.index) + if (next) await this.selectPlaceholder(next) + } else { + this.deactivate() + } } public async previousPlaceholder(): Promise { - if (!this.isActive) return - await this.document.patchChange() + await this.forceSynchronize() let curr = this.placeholder - let prev = this.snippet.getPrevPlaceholder(curr.index) - await this.selectPlaceholder(prev) - } - - public async synchronizeUpdatedPlaceholders(change: TextDocumentContentChangeEvent, changedLine?: string): Promise { - if (!this.isActive || !this.document || this.applying) return - let edit: TextEdit = { range: (change as any).range, newText: change.text } - let { snippet } = this - // change outside range - let adjusted = snippet.adjustTextEdit(edit, changedLine) - if (adjusted) return - let currRange = this.placeholder.range - if (changedLine != null - && edit.range.start.line == currRange.start.line - && singleLineEdit(edit) - && !rangeInRange(edit.range, currRange) - && isSingleLine(currRange) - && changedLine.slice(currRange.start.character, currRange.end.character) == this.placeholder.value - && events.cursor - && events.cursor.bufnr == this.bufnr - && events.cursor.lnum == edit.range.start.line + 1) { - let col = events.cursor.col - // split changedLine with currRange - let preText = changedLine.slice(0, currRange.start.character) - let postText = changedLine.slice(currRange.end.character) - let newLine = this.document.getline(edit.range.start.line) - if (newLine.startsWith(preText) && newLine.endsWith(postText)) { - let endCharacter = newLine.length - postText.length - let cursorIdx = characterIndex(newLine, col - 1) - // make sure cursor in range - if (cursorIdx >= preText.length && cursorIdx <= endCharacter) { - let newText = newLine.slice(preText.length, endCharacter) - edit = TextEdit.replace(currRange, newText) - } - } - } - if (comparePosition(edit.range.start, snippet.range.end) > 0) { - if (!edit.newText) return - logger.info('Content change after snippet, cancelling snippet session') - this.deactivate() - return - } - let placeholder = this.findPlaceholder(edit.range) - if (!placeholder) { - logger.info('Change outside placeholder, cancelling snippet session') - this.deactivate() - return - } - if (placeholder.isFinalTabstop && snippet.finalCount <= 1) { - logger.info('Change final placeholder, cancelling snippet session') + if (curr) { + let prev = this.snippet.getPrevPlaceholder(curr.index) + if (prev) await this.selectPlaceholder(prev) + } else { this.deactivate() - return - } - this._currId = placeholder.id - let { edits, delta } = snippet.updatePlaceholder(placeholder, edit) - if (!edits.length) return - this.applying = true - await this.document.applyEdits(edits) - this.applying = false - if (delta) { - await this.nvim.call('coc#cursor#move_by_col', delta) } } public async selectCurrentPlaceholder(triggerAutocmd = true): Promise { + await this.forceSynchronize() if (!this.snippet) return - let placeholder = this.snippet.getPlaceholderById(this._currId) + let placeholder = this.snippet.getPlaceholderByMarker(this.current) if (placeholder) await this.selectPlaceholder(placeholder, triggerAutocmd) } @@ -196,17 +153,42 @@ export class SnippetSession { let { start, end } = placeholder.range const len = end.character - start.character const col = byteLength(document.getline(start.line).slice(0, start.character)) + 1 - this._currId = placeholder.id - if (placeholder.choice) { - await nvim.call('coc#snippet#show_choices', [start.line + 1, col, len, placeholder.choice]) + let marker = this.current = placeholder.marker + if (marker instanceof Placeholder + && marker.choice + && marker.choice.options.length + ) { + let arr = marker.choice.options.map(o => o.value) + await nvim.call('coc#snippet#show_choices', [start.line + 1, col, len, arr]) if (triggerAutocmd) nvim.call('coc#util#do_autocmd', ['CocJumpPlaceholder'], true) } else { + let finalCount = this.snippet.finalCount await this.select(placeholder, triggerAutocmd) + this.highlights(placeholder) + if (placeholder.index == 0) { + if (finalCount == 1) { + logger.info('Jump to final placeholder, cancelling snippet session') + this.deactivate() + } else { + nvim.call('coc#snippet#disable', [], true) + } + } + } + } + + private highlights(placeholder: CocSnippetPlaceholder): void { + let buf = this.nvim.createBuffer(this.bufnr) + this.nvim.pauseNotification() + buf.clearNamespace(NAME_SPACE) + let ranges = this.snippet.getRanges(placeholder) + if (ranges.length) { + buf.highlightRanges(NAME_SPACE, 'CocSnippetVisual', ranges) } + void this.nvim.resumeNotification(true, true) } private async select(placeholder: CocSnippetPlaceholder, triggerAutocmd = true): Promise { - let { range, value, isFinalTabstop } = placeholder + let { range } = placeholder let { document, nvim } = this let { start, end } = range let { textDocument } = document @@ -215,12 +197,6 @@ export class SnippetSession { let col = line ? byteLength(line.slice(0, start.character)) : 0 let endLine = document.getline(end.line) let endCol = endLine ? byteLength(endLine.slice(0, end.character)) : 0 - nvim.setVar('coc_last_placeholder', { - bufnr: document.bufnr, - current_text: value, - start: { line: start.line, col, character: start.character }, - end: { line: end.line, col: endCol, character: end.character } - }, true) let [ve, selection, pumvisible, mode] = await nvim.eval('[&virtualedit, &selection, pumvisible(), mode()]') as [string, string, number, string] let move_cmd = '' if (pumvisible && this.preferComplete) { @@ -266,14 +242,6 @@ export class SnippetSession { nvim.call('coc#_cancel', [], true) } nvim.setOption('virtualedit', ve, true) - if (isFinalTabstop) { - if (this.snippet.finalCount == 1) { - logger.info('Jump to final placeholder, cancelling snippet session') - this.deactivate() - } else { - nvim.call('coc#snippet#disable', [], true) - } - } await nvim.resumeNotification(true) if (triggerAutocmd) nvim.call('coc#util#do_autocmd', ['CocJumpPlaceholder'], true) } @@ -293,15 +261,126 @@ export class SnippetSession { } public findPlaceholder(range: Range): CocSnippetPlaceholder | null { - if (!this.snippet) return null let { placeholder } = this if (placeholder && rangeInRange(range, placeholder.range)) return placeholder return this.snippet.getPlaceholderByRange(range) || null } - public get placeholder(): CocSnippetPlaceholder { - if (!this.snippet) return null - return this.snippet.getPlaceholderById(this._currId) + public sychronize(): void { + this.cancel() + this.timer = setTimeout(async () => { + if (events.pumvisible) return + let { document } = this + if (!document || !document.attached) return + if (document.dirty) return this.sychronize() + try { + await this._synchronize() + } catch (e) { + this.nvim.echoError(e) + } + }, 200) + } + + private async _synchronize(): Promise { + let { document, textDocument } = this + if (!document || !document.attached || !textDocument) return + let start = Date.now() + let d = document.textDocument + if (d.version == textDocument.version || equals(textDocument.lines, d.lines)) { + return + } + let { range, text } = this.snippet + let end = getEndPosition(range.end, textDocument, d) + if (!end) { + logger.info('Content change after snippet, cancel snippet session') + this.deactivate() + return + } + let checked = checkContentBefore(range.start, textDocument, d) + if (!checked) { + let content = d.getText(Range.create(Position.create(0, 0), end)) + if (content.endsWith(text)) { + let pos = d.positionAt(content.length - text.length) + this.snippet.resetStartPosition(pos) + logger.info('Content change before snippet, reset snippet position') + return + } + logger.info('Before and snippet body changed, cancel snippet session') + this.deactivate() + return + } + let tokenSource = this.tokenSource = new CancellationTokenSource() + let cursor = await window.getCursorPosition() + if (tokenSource.token.isCancellationRequested) return + let inserted = d.getText(Range.create(range.start, end)) + let newText: string | undefined + let placeholder: CocSnippetPlaceholder + for (let p of this.snippet.getSortedPlaceholders(this.placeholder)) { + if (comparePosition(cursor, p.range.start) < 0) continue + newText = this.snippet.getNewText(p, inserted) + // p.range.start + newText + if (typeof newText === 'string' && checkCursor(p.range.start, cursor, newText)) { + placeholder = p + break + } + } + if (!placeholder && inserted.endsWith(text)) { + let pos = getEnd(range.start, inserted.slice(0, text.length - inserted.length)) + this.snippet.resetStartPosition(pos) + logger.info('Content change before snippet, reset snippet position') + return + } + if (!placeholder) { + logger.info('Unable to find changed placeholder, cancel snippet session') + this.deactivate() + return + } + let res = await this.snippet.updatePlaceholder(placeholder, cursor, newText, tokenSource.token) + if (!res) return + this.current = placeholder.marker + if (res.text !== inserted) { + let edit = reduceTextEdit({ + range: Range.create(this.snippet.start, end), + newText: res.text + }, inserted) + await this.document.applyEdits([edit]) + this.highlights(placeholder) + let { delta } = res + if (delta.line != 0 || delta.character != 0) { + this.nvim.call(`coc#cursor#move_to`, [cursor.line + delta.line, cursor.character + delta.character], true) + } + } else { + this.highlights(placeholder) + } + this.nvim.redrawVim() + logger.debug('update cost:', Date.now() - start, res.delta) + this.textDocument = this.document.textDocument + } + + public async forceSynchronize(): Promise { + this.cancel() + let { document } = this + if (document && document.attached) { + await document.patchChange() + await this._synchronize() + } + } + + public cancel(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = undefined + } + if (this.tokenSource) { + this.tokenSource.cancel() + this.tokenSource.dispose() + this.tokenSource = null + } + } + + public get placeholder(): CocSnippetPlaceholder | undefined { + if (!this.snippet) return undefined + return this.snippet.getPlaceholderByMarker(this.current) } public get snippet(): CocSnippet { @@ -311,22 +390,47 @@ export class SnippetSession { private get document(): Document { return workspace.getDocument(this.bufnr) } -} -export function normalizeSnippetString(snippet: string, indent: string, opts: FormattingOptions): string { - let lines = snippet.split(/\r?\n/) - let ind = opts.insertSpaces ? ' '.repeat(opts.tabSize) : '\t' - let tabSize = opts.tabSize || 2 - lines = lines.map((line, idx) => { - let space = line.match(/^\s*/)[0] - let pre = space - let isTab = space.startsWith('\t') - if (isTab && opts.insertSpaces) { - pre = ind.repeat(space.length) - } else if (!isTab && !opts.insertSpaces) { - pre = ind.repeat(space.length / tabSize) + public async insertPlainText(inserted: string, range: Range): Promise { + let { document } = this + let text = inserted.replace(/\$0$/, '') + let edits = [TextEdit.replace(range, text)] + await document.applyEdits(edits) + let lines = text.split(/\r?\n/) + let len = lines.length + let pos = { + line: range.start.line + len - 1, + character: len == 1 ? range.start.character + text.length : lines[len - 1].length } - return (idx == 0 || line.length == 0 ? '' : indent) + pre + line.slice(space.length) - }) - return lines.join('\n') + await window.moveTo(pos) + } + + public async getEditRange(range?: Range): Promise { + if (range) return range + let pos = await window.getCursorPosition() + return Range.create(pos, pos) + } + + public async normalizeInsertText(snippetString: string, currentLine: string, insertTextMode: InsertTextMode): Promise { + const currentIndent = currentLine.match(/^\s*/)[0] + let inserted = '' + if (insertTextMode === InsertTextMode.asIs || !shouldFormat(snippetString)) { + inserted = snippetString + } else { + const formatOptions = await workspace.getFormatOptions(this.document.uri) + inserted = normalizeSnippetString(snippetString, currentIndent, formatOptions) + } + return inserted + } + + public static async resolveSnippet(nvim: Neovim, snippetString: string, ultisnip?: UltiSnippetOption): Promise { + let position = await window.getCursorPosition() + let line = await nvim.line + let context: UltiSnippetContext + if (ultisnip) context = Object.assign({ range: Range.create(position, position), line }, ultisnip) + const resolver = new SnippetVariableResolver(nvim, workspace.workspaceFolderControl) + let snippet = new CocSnippet(snippetString, position, nvim, resolver) + await snippet.init(context, false) + return snippet.text + } } diff --git a/src/snippets/snippet.ts b/src/snippets/snippet.ts index 995e6b2825f..8f4646a774b 100644 --- a/src/snippets/snippet.ts +++ b/src/snippets/snippet.ts @@ -1,222 +1,243 @@ -import { Position, Range, TextEdit } from 'vscode-languageserver-protocol' +import { Neovim } from '@chemzqm/neovim' +import { CancellationToken, FormattingOptions, Position, Range, TextEdit } from 'vscode-languageserver-protocol' import { TextDocument } from 'vscode-languageserver-textdocument' -import { adjustPosition, comparePosition, editRange, getChangedPosition, rangeInRange, isSingleLine } from '../util/position' +import { LinesTextDocument } from '../model/textdocument' +import { emptyRange, getChangedPosition, positionInRange, rangeInRange } from '../util/position' +import { preparePythonCodes, UltiSnippetContext } from './eval' import * as Snippets from "./parser" import { VariableResolver } from './parser' -import { byteLength } from '../util/string' const logger = require('../util/logger')('snippets-snipet') export interface CocSnippetPlaceholder { - index: number - id: number // unique index - line: number - // range in current buffer - range: Range + index: number | undefined + marker: Snippets.Placeholder | Snippets.Variable value: string - isFinalTabstop: boolean + primary: boolean transform: boolean - isVariable: boolean - choice?: string[] + // range in current buffer + range: Range + // snippet text before + before: string + // snippet text after + after: string } export class CocSnippet { - private _parser: Snippets.SnippetParser private _placeholders: CocSnippetPlaceholder[] - private tmSnippet: Snippets.TextmateSnippet + private _text: string | undefined + public tmSnippet: Snippets.TextmateSnippet - constructor(private _snippetString: string, + constructor(private snippetString: string, private position: Position, - private _variableResolver?: VariableResolver, - _ultisnip = false + private nvim: Neovim, + private resolver?: VariableResolver, ) { - this._parser = new Snippets.SnippetParser(_ultisnip) } - public async init(): Promise { - const snippet = this._parser.parse(this._snippetString, true) - let { _variableResolver } = this - if (_variableResolver) { - await snippet.resolveVariables(_variableResolver) - } + public async init(ultisnip?: UltiSnippetContext, clear = true): Promise { + const parser = new Snippets.SnippetParser(!!ultisnip) + const snippet = parser.parse(this.snippetString, true) this.tmSnippet = snippet - this.update() + await this.resolve(ultisnip) + this.sychronize() + if (clear) { + this.nvim.call('coc#compat#del_var', ['coc_selected_text'], true) + this.nvim.call('coc#compat#del_var', ['coc_last_placeholder'], true) + } } - public adjustPosition(characterCount: number, lineCount: number): void { - let { line, character } = this.position - this.position = { - line: line + lineCount, - character: character + characterCount + public getRanges(placeholder: CocSnippetPlaceholder): Range[] { + let marker = placeholder.marker + if (placeholder.value.length == 0) return [] + let placeholders = this._placeholders.filter(o => o.index == placeholder.index) + let ranges = placeholders.map(o => o.range) + let parents = this.tmSnippet.enclosingPlaceholders(marker) + let markers: Snippets.Marker[] + let p = marker.parent + if (marker instanceof Snippets.Placeholder) { + let index = marker.index + markers = this.tmSnippet.placeholders.filter(o => o.index == index && o.parent == p) + } else { + let name = marker.name + markers = this.tmSnippet.variables.filter(o => o.name == name && o.parent == p) } - this.update() + parents.forEach(p => { + let arr = this._placeholders.filter(o => o.index == p.index && o.marker !== p) + if (!arr.length) return + for (let m of markers) { + let before = this.tmSnippet.getTextBefore(m, p) + arr.forEach(item => { + if (item.transform) { + ranges.push(item.range) + } else { + let s = item.range.start + ranges.push(Range.create(getEnd(s, before), getEnd(s, before + m.toString()))) + } + }) + } + }) + return ranges.filter(r => !emptyRange(r)) } - // adjust for edit before snippet - public adjustTextEdit(edit: TextEdit, changedLine?: string): boolean { - let { range, newText } = edit - if (comparePosition(this.range.start, range.end) < 0) { - let { start, end } = range - let overlaped = end.character - this.range.start.character - // shift single line range to left as far as possible - if (changedLine && comparePosition(this.range.start, start) > 0 - && isSingleLine(range) - && start.character - overlaped >= 0 - && changedLine.slice(start.character - overlaped, start.character) == - changedLine.slice(this.range.start.character, this.range.start.character + overlaped)) { - edit.range = range = Range.create(start.line, start.character - overlaped, end.line, end.character - overlaped) - } else { - return false - } - } + public getSortedPlaceholders(curr?: CocSnippetPlaceholder | undefined): CocSnippetPlaceholder[] { + let res = curr ? [curr] : [] + let arr = this._placeholders.filter(o => o !== curr && !o.transform) + arr.sort((a, b) => { + if (a.primary !== b.primary) return a.primary ? -1 : 1 + if (a.index == 0 || b.index == 0) return a.index == 0 ? 1 : -1 + return a.index - b.index + }) + res.push(...arr) + return res + } - // check change of placeholder at beginning - if (!newText.includes('\n') - && comparePosition(range.start, range.end) == 0 - && comparePosition(this.range.start, range.start) == 0) { - let idx = this._placeholders.findIndex(o => comparePosition(o.range.start, range.start) == 0) - if (idx !== -1) return false + private async resolve(ultisnip?: UltiSnippetContext): Promise { + let { snippet } = this.tmSnippet + let { resolver, nvim } = this + if (resolver) { + await snippet.resolveVariables(resolver) + } + if (ultisnip) { + let pyCodes: string[] = [] + if (snippet.hasPython) pyCodes = preparePythonCodes(ultisnip) + await snippet.evalCodeBlocks(nvim, pyCodes) } - let changed = getChangedPosition(this.range.start, edit) - if (changed.line == 0 && changed.character == 0) return true - this.adjustPosition(changed.character, changed.line) - return true } - public get isPlainText(): boolean { - if (this._placeholders.length > 1) return false - return this._placeholders.every(o => o.value == '') + public resetStartPosition(pos: Position): void { + this.position = pos + this.sychronize() } - public get finalCount(): number { - return this._placeholders.filter(o => o.isFinalTabstop).length + public get start(): Position { + return Object.assign({}, this.position) } - public toString(): string { - return this.tmSnippet.toString() + public get range(): Range { + return Range.create(this.position, getEnd(this.position, this._text)) } - public get range(): Range { - let { position } = this - const content = this.tmSnippet.toString() - const doc = TextDocument.create('untitled:/1', 'snippet', 0, content) - const pos = doc.positionAt(content.length) - const end = pos.line == 0 ? position.character + pos.character : pos.character - return Range.create(position, Position.create(position.line + pos.line, end)) + public get text(): string { + return this._text } - public get firstPlaceholder(): CocSnippetPlaceholder | null { - let index = 0 - for (let p of this._placeholders) { - if (p.index == 0) continue - if (index == 0 || p.index < index) { - index = p.index - } - } - return this.getPlaceholder(index) + public get finalCount(): number { + return this._placeholders.filter(o => o.index == 0).length } - public get lastPlaceholder(): CocSnippetPlaceholder { + public get placeholders(): ReadonlyArray { + return this._placeholders.map(o => o.marker) + } + + public get firstPlaceholder(): CocSnippetPlaceholder | undefined { let index = 0 for (let p of this._placeholders) { - if (index == 0 || p.index > index) { + if (p.index == 0 || p.transform) continue + if (index == 0 || p.index < index) { index = p.index } } return this.getPlaceholder(index) } - public getPlaceholderById(id: number): CocSnippetPlaceholder { - return this._placeholders.find(o => o.id == id) + public getPlaceholderByMarker(marker: Snippets.Marker): CocSnippetPlaceholder { + return this._placeholders.find(o => o.marker === marker) } public getPlaceholder(index: number): CocSnippetPlaceholder { - let placeholders = this._placeholders.filter(o => o.index == index) - let filtered = placeholders.filter(o => !o.transform) - return filtered.length ? filtered[0] : placeholders[0] + let filtered = this._placeholders.filter(o => o.index == index && !o.transform) + let find = filtered.find(o => o.primary) || filtered[0] + return find ?? filtered[0] } - public getPrevPlaceholder(index: number): CocSnippetPlaceholder { - if (index == 0) return this.lastPlaceholder - let prev = this.getPlaceholder(index - 1) - if (!prev) return this.getPrevPlaceholder(index - 1) - return prev + public getPrevPlaceholder(index: number): CocSnippetPlaceholder | undefined { + if (index <= 1) return undefined + let placeholders = this._placeholders.filter(o => o.index < index && o.index != 0 && !o.transform) + let find: CocSnippetPlaceholder + while (index > 1) { + index = index - 1 + let arr = placeholders.filter(o => o.index == index) + if (arr.length) { + find = arr.find(o => o.primary) || arr[0] + break + } + } + return find } - public getNextPlaceholder(index: number): CocSnippetPlaceholder { - let indexes = this._placeholders.map(o => o.index) + public getNextPlaceholder(index: number): CocSnippetPlaceholder | undefined { + let placeholders = this._placeholders.filter(o => !o.transform) + let find: CocSnippetPlaceholder + let indexes = placeholders.map(o => o.index) let max = Math.max.apply(null, indexes) - if (index >= max) return this.finalPlaceholder - let next = this.getPlaceholder(index + 1) - if (!next) return this.getNextPlaceholder(index + 1) - return next - } - - public get finalPlaceholder(): CocSnippetPlaceholder { - return this._placeholders.find(o => o.isFinalTabstop) + for (let i = index + 1; i <= max + 1; i++) { + let idx = i == max + 1 ? 0 : i + let arr = placeholders.filter(o => o.index == idx) + if (arr.length) { + find = arr.find(o => o.primary) || arr[0] + break + } + } + return find } public getPlaceholderByRange(range: Range): CocSnippetPlaceholder { return this._placeholders.find(o => rangeInRange(range, o.range)) } - public insertSnippet(placeholder: CocSnippetPlaceholder, snippet: string, range: Range, ultisnip = false): number { - let { start } = placeholder.range - // let offset = position.character - start.character - let editStart = Position.create( - range.start.line - start.line, - range.start.line == start.line ? range.start.character - start.character : range.start.character - ) - let editEnd = Position.create( - range.end.line - start.line, - range.end.line == start.line ? range.end.character - start.character : range.end.character - ) - let editRange = Range.create(editStart, editEnd) - let first = this.tmSnippet.insertSnippet(snippet, placeholder.id, editRange, ultisnip) - this.update() - return first + public async insertSnippet(placeholder: CocSnippetPlaceholder, snippet: string, parts: [string, string], ultisnip?: UltiSnippetContext): Promise { + if (ultisnip) { + let { start, end } = placeholder.range + this.nvim.setVar('coc_last_placeholder', { + current_text: placeholder.value, + start: { line: start.line, col: start.character, character: start.character }, + end: { line: end.line, col: end.character, character: end.character } + }, true) + } + let select = this.tmSnippet.insertSnippet(snippet, placeholder.marker, parts, !!ultisnip) + await this.resolve(ultisnip) + this.sychronize() + return select } - // update internal positions, no change of buffer - // return TextEdit list when needed - public updatePlaceholder(placeholder: CocSnippetPlaceholder, edit: TextEdit): { edits: TextEdit[]; delta: number } { - // let { start, end } = edit.range - let { range } = this - let { value, id, index } = placeholder - let newText = editRange(placeholder.range, value, edit) - let delta = 0 - if (!newText.includes('\n')) { - for (let p of this._placeholders) { - if (p.index == index && - p.id < id && - p.line == placeholder.range.start.line) { - let text = this.tmSnippet.getPlaceholderText(p.id, newText) - delta = delta + byteLength(text) - byteLength(p.value) - } - } - } - if (placeholder.isVariable) { - this.tmSnippet.updateVariable(id, newText) - } else { - this.tmSnippet.updatePlaceholder(id, newText) - } - let endPosition = adjustPosition(range.end, edit) - let snippetEdit: TextEdit = { - range: Range.create(range.start, endPosition), - newText: this.tmSnippet.toString() - } - this.update() - return { edits: [snippetEdit], delta } + /** + * Check newText for placeholder. + */ + public getNewText(placeholder: CocSnippetPlaceholder, inserted: string): string | undefined { + let { before, after } = placeholder + if (!inserted.startsWith(before)) return undefined + if (inserted.length < before.length + after.length) return undefined + if (!inserted.endsWith(after)) return undefined + if (!after.length) return inserted.slice(before.length) + return inserted.slice(before.length, - after.length) + } + + public async updatePlaceholder(placeholder: CocSnippetPlaceholder, cursor: Position, newText: string, token: CancellationToken): Promise<{ text: string; delta: Position } | undefined> { + let start = this.position + let { marker, before } = placeholder + let cloned = this.tmSnippet.clone() + let disposable = token.onCancellationRequested(() => { + this.tmSnippet = cloned + }) + // range before placeholder + let r = Range.create(start, getEnd(start, before)) + await this.tmSnippet.update(this.nvim, marker, newText) + disposable.dispose() + if (token.isCancellationRequested) return undefined + this.sychronize() + let after = this._placeholders.find(o => o.marker == marker).before + return { text: this._text, delta: getChangedPosition(cursor, TextEdit.replace(r, after)) } } - private update(): void { + private sychronize(): void { const snippet = this.tmSnippet const { line, character } = this.position const document = TextDocument.create('untitled:/1', 'snippet', 0, snippet.toString()) - const { placeholders, variables, maxIndexNumber } = snippet + let { placeholders, variables, maxIndexNumber } = snippet const variableIndexMap: Map = new Map() let variableIndex = maxIndexNumber + 1 - this._placeholders = [...placeholders, ...variables].map((p, idx) => { + this._placeholders = [...placeholders, ...variables].map(p => { const offset = snippet.offset(p) const position = document.positionAt(offset) const start: Position = { @@ -233,35 +254,202 @@ export class CocSnippet { index = variableIndex variableIndex = variableIndex + 1 } - // variableIndex = variableIndex + 1 } else { index = p.index } const value = p.toString() - const lines = value.split(/\r?\n/) + const end = getEnd(position, value) let res: CocSnippetPlaceholder = { - range: Range.create(start, { - line: start.line + lines.length - 1, - character: lines.length == 1 ? start.character + value.length : lines[lines.length - 1].length - }), - transform: p.transform != null, - line: start.line, - id: idx, index, value, - isVariable: p instanceof Snippets.Variable, - isFinalTabstop: (p as Snippets.Placeholder).index === 0 - } - Object.defineProperty(res, 'snippet', { - enumerable: false - }) - if (p instanceof Snippets.Placeholder && p.choice) { - let { options } = p.choice - if (options && options.length) { - res.choice = options.map(o => o.value) - } + marker: p, + transform: !!p.transform, + range: Range.create(start, getEnd(start, value)), + before: document.getText(Range.create(Position.create(0, 0), position)), + after: document.getText(Range.create(end, Position.create(document.lineCount, 0))), + primary: p instanceof Snippets.Placeholder && p.primary === true } return res }) + this._text = this.tmSnippet.toString() + } +} + +/** + * Current line text before marker + */ +export function getContentBefore(marker: Snippets.Marker): string { + let res = '' + const calc = (m: Snippets.Marker): void => { + let p = m.parent + if (!p) return + let s = '' + for (let b of p.children) { + if (b === m) break + s = s + b.toString() + } + if (s.indexOf('\n') !== -1) { + let arr = s.split(/\n/) + res = arr[arr.length - 1] + res + return + } + res = s + res + calc(p) + } + calc(marker) + return res +} + +/* + * Avoid change unnecessary range of text. + */ +export function reduceTextEdit(edit: TextEdit, oldText: string): TextEdit { + let { range, newText } = edit + let ol = oldText.length + let nl = newText.length + if (ol === 0 || nl === 0) return edit + let { start, end } = range + let bo = 0 + for (let i = 1; i <= Math.min(nl, ol); i++) { + if (newText[i - 1] === oldText[i - 1]) { + bo = i + } else { + break + } + } + let eo = 0 + let t = Math.min(nl - bo, ol - bo) + if (t > 0) { + for (let i = 1; i <= t; i++) { + if (newText[nl - i] === oldText[ol - i]) { + eo = i + } else { + break + } + } + } + let text = eo == 0 ? newText.slice(bo) : newText.slice(bo, -eo) + if (bo > 0) start = getEnd(start, newText.slice(0, bo)) + if (eo > 0) end = getEnd(range.start, oldText.slice(0, -eo)) + return TextEdit.replace(Range.create(start, end), text) +} + +/* + * Get end position by content + */ +export function getEnd(start: Position, content: string): Position { + const lines = content.split(/\r?\n/) + const len = lines.length + const lastLine = lines[len - 1] + const end = len == 1 ? start.character + content.length : lastLine.length + return Position.create(start.line + len - 1, end) +} + +/* + * Check if cursor inside + */ +export function checkCursor(start: Position, cursor: Position, newText: string): boolean { + let r = Range.create(start, getEnd(start, newText)) + return positionInRange(cursor, r) == 0 +} + +/* + * Check if textDocument have same text before position. + */ +export function checkContentBefore(position: Position, oldTextDocument: LinesTextDocument, textDocument: LinesTextDocument): boolean { + let lines = textDocument.lines + if (lines.length < position.line) return false + let checked = true + for (let i = position.line; i >= 0; i--) { + let newLine = textDocument.lines[i] ?? '' + if (i === position.line) { + let before = oldTextDocument.lines[i].slice(0, position.character) + if (!newLine.startsWith(before)) { + checked = false + break + } + } else if (newLine !== oldTextDocument.lines[i]) { + checked = false + break + } + } + return checked +} + +/** + * Get new end position by old end position and new TextDocument + */ +export function getEndPosition(position: Position, oldTextDocument: LinesTextDocument, textDocument: LinesTextDocument): Position | undefined { + let total = oldTextDocument.lines.length + if (textDocument.lines.length < total - position.line) return undefined + let end: Position + let cl = textDocument.lines.length - total + for (let i = position.line; i < total; i++) { + let newLine = textDocument.lines[i + cl] + if (i == position.line) { + let text = oldTextDocument.lines[i].slice(position.character) + if (text.length && !newLine.endsWith(text)) break + end = Position.create(i + cl, newLine.length - text.length) + } else if (newLine !== oldTextDocument.lines[i]) { + end = undefined + break + } + } + return end +} + +/* + * r in range + */ +export function getParts(text: string, range: Range, r: Range): [string, string] { + let before: string[] = [] + let after: string[] = [] + let lines = text.split('\n') + let d = r.start.line - range.start.line + for (let i = 0; i <= d; i++) { + let s = lines[i] ?? '' + if (i == d) { + before.push(i == 0 ? s.substring(0, r.start.character - range.start.character) : s.substring(0, r.start.character)) + } else { + before.push(s) + } + } + d = range.end.line - r.end.line + for (let i = 0; i <= d; i++) { + let s = lines[r.end.line - range.start.line + i] ?? '' + if (i == 0) { + if (d == 0) { + after.push(range.end.character == r.end.character ? '' : s.slice(r.end.character - range.end.character)) + } else { + after.push(s.substring(r.end.character)) + } + } else { + after.push(s) + } } + return [before.join('\n'), after.join('\n')] +} + +export function normalizeSnippetString(snippet: string, indent: string, opts: FormattingOptions): string { + let lines = snippet.split(/\r?\n/) + let ind = opts.insertSpaces ? ' '.repeat(opts.tabSize) : '\t' + let tabSize = opts.tabSize || 2 + lines = lines.map((line, idx) => { + let space = line.match(/^\s*/)[0] + let pre = space + let isTab = space.startsWith('\t') + if (isTab && opts.insertSpaces) { + pre = ind.repeat(space.length) + } else if (!isTab && !opts.insertSpaces) { + pre = ind.repeat(space.length / tabSize) + } + return (idx == 0 || line.length == 0 ? '' : indent) + pre + line.slice(space.length) + }) + return lines.join('\n') +} + +export function shouldFormat(snippet: string): boolean { + if (/^\s/.test(snippet)) return true + if (snippet.indexOf('\n') !== -1) return true + return false } diff --git a/src/snippets/variableResolve.ts b/src/snippets/variableResolve.ts index e5d7a45236a..4326d442491 100644 --- a/src/snippets/variableResolve.ts +++ b/src/snippets/variableResolve.ts @@ -1,16 +1,47 @@ +import { Neovim } from '@chemzqm/neovim' import path from 'path' -import window from '../window' import { Variable, VariableResolver } from "./parser" +import WorkspaceFolderController from '../core/workspaceFolder' +import { v4 as uuid } from 'uuid' +import { URI } from 'vscode-uri' const logger = require('../util/logger')('snippets-variable') function padZero(n: number): string { return n < 10 ? '0' + n : n.toString() } +export function parseComments(comments: string): { start?: string, end?: string, single?: string } { + let start: string + let end: string + let single: string + let parts = comments.split(',') + for (let s of parts) { + if (start && end && single) break + if (!s.includes(':')) continue + let [flag, str] = s.split(':') + if (flag.includes('s')) { + start = str + } else if (flag.includes('e')) { + end = str + } else if (!single && flag == '') { + single = str + } + } + return { start, end, single } +} + +/* + * Get single line comment text + */ +export function parseCommentstring(commentstring: string): string | undefined { + if (commentstring.endsWith('%s')) return commentstring.slice(0, -2).trim() + return undefined +} + export class SnippetVariableResolver implements VariableResolver { private _variableToValue: { [key: string]: string } = {} - constructor() { + constructor(private nvim: Neovim, private workspaceFolder: WorkspaceFolderController) { const currentDate = new Date() const fullyear = currentDate.getFullYear().toString() Object.assign(this._variableToValue, { @@ -35,62 +66,98 @@ export class SnippetVariableResolver implements VariableResolver { TM_CURRENT_LINE: null, TM_CURRENT_WORD: null, TM_SELECTED_TEXT: null, - CLIPBOARD: null + VISUAL: null, + CLIPBOARD: null, + RELATIVE_FILEPATH: null, + RANDOM: null, + RANDOM_HEX: null, + UUID: null, + BLOCK_COMMENT_START: null, + BLOCK_COMMENT_END: null, + LINE_COMMENT: null, + WORKSPACE_NAME: null, + WORKSPACE_FOLDER: null }) } private async resolveValue(name: string): Promise { - let { nvim } = window + let { nvim } = this if (['TM_FILENAME', 'TM_FILENAME_BASE', 'TM_DIRECTORY', 'TM_FILEPATH'].includes(name)) { let filepath = await nvim.eval('expand("%:p")') as string - if (name == 'TM_FILENAME') return path.basename(filepath) - if (name == 'TM_FILENAME_BASE') return path.basename(filepath, path.extname(filepath)) - if (name == 'TM_DIRECTORY') return path.dirname(filepath) - if (name == 'TM_FILEPATH') return filepath + if (name === 'TM_FILENAME') return path.basename(filepath) + if (name === 'TM_FILENAME_BASE') return path.basename(filepath, path.extname(filepath)) + if (name === 'TM_DIRECTORY') return path.dirname(filepath) + if (name === 'TM_FILEPATH') return filepath } - if (name == 'YANK') { - let yank = await nvim.call('getreg', ['""']) as string - return yank + if (name === 'YANK') { + return await nvim.call('getreg', ['""']) as string } - if (name == 'TM_LINE_INDEX') { + if (name === 'TM_LINE_INDEX') { let lnum = await nvim.call('line', ['.']) as number return (lnum - 1).toString() } - if (name == 'TM_LINE_NUMBER') { + if (name === 'TM_LINE_NUMBER') { let lnum = await nvim.call('line', ['.']) as number return lnum.toString() } - if (name == 'TM_CURRENT_LINE') { - let line = await nvim.call('getline', ['.']) as string - return line + if (name === 'TM_CURRENT_LINE') { + return await nvim.call('getline', ['.']) as string } - if (name == 'TM_CURRENT_WORD') { - let word = await nvim.eval(`expand('')`) as string - return word + if (name === 'TM_CURRENT_WORD') { + return await nvim.eval(`expand('')`) as string } - if (name == 'TM_SELECTED_TEXT') { - let text = await nvim.eval(`get(g:,'coc_selected_text', '')`) as string - return text + if (name === 'TM_SELECTED_TEXT' || name == 'VISUAL') { + return await nvim.eval(`get(g:,'coc_selected_text', v:null)`) as string } - if (name == 'CLIPBOARD') { + if (name === 'CLIPBOARD') { return await nvim.eval('@*') as string } + if (name === 'RANDOM') { + return Math.random().toString().slice(-6) + } + if (name === 'RANDOM_HEX') { + return Math.random().toString(16).slice(-6) + } + if (name === 'UUID') { + return uuid() + } + if (['RELATIVE_FILEPATH', 'WORKSPACE_NAME', 'WORKSPACE_FOLDER'].includes(name)) { + let filepath = await nvim.eval('expand("%:p")') as string + let folder = this.workspaceFolder.getWorkspaceFolder(URI.file(filepath)) + if (name === 'RELATIVE_FILEPATH') return this.workspaceFolder.getRelativePath(filepath) + if (name === 'WORKSPACE_NAME') return folder.name + if (name === 'WORKSPACE_FOLDER') return URI.parse(folder.uri).fsPath + } + if (name === 'LINE_COMMENT') { + let commentstring = await nvim.eval('&commentstring') as string + let s = parseCommentstring(commentstring) + if (s) return s + let comments = await nvim.eval('&comments') as string + let { single } = parseComments(comments) + return single ?? '' + } + if (['BLOCK_COMMENT_START', 'BLOCK_COMMENT_END'].includes(name)) { + let comments = await nvim.eval('&comments') as string + let { start, end } = parseComments(comments) + if (name === 'BLOCK_COMMENT_START') return start ?? '' + if (name === 'BLOCK_COMMENT_END') return end ?? '' + } } public async resolve(variable: Variable): Promise { const name = variable.name let resolved = this._variableToValue[name] if (resolved != null) return resolved.toString() - // resolve value from vim - let value = await this.resolveValue(name) - if (value) return value - // use default value when resolved is undefined - if (variable.children && variable.children.length) { - return variable.toString() - } - if (!this._variableToValue.hasOwnProperty(name)) { - return name + // resolve known value + if (this._variableToValue.hasOwnProperty(name)) { + let value = await this.resolveValue(name) + if (!value && variable.children.length) { + return variable.toString() + } + return value == null ? '' : value.toString() } - return '' + if (variable.children.length) return variable.toString() + // VSCode behavior + return name } } diff --git a/src/sources/source-language.ts b/src/sources/source-language.ts index 2827b8fb19f..6d35b6120bc 100644 --- a/src/sources/source-language.ts +++ b/src/sources/source-language.ts @@ -4,9 +4,9 @@ import { CompletionItemProvider } from '../provider' import snippetManager from '../snippets/manager' import { SnippetParser } from '../snippets/parser' import { CompleteOption, CompleteResult, ExtendedCompleteItem, ISource, SourceType } from '../types' -import { getChangedFromEdits, rangeOverlap } from '../util/position' +import { fuzzyMatch, getCharCodes } from '../util/fuzzy' +import { getChangedFromEdits, getChangedPosition, rangeOverlap } from '../util/position' import { byteIndex, byteLength, byteSlice, characterIndex } from '../util/string' -import { getCharCodes, fuzzyMatch } from '../util/fuzzy' import window from '../window' import workspace from '../workspace' const logger = require('../util/logger')('source-language') @@ -28,7 +28,6 @@ export default class LanguageSource implements ISource { public sourceType: SourceType.Service private _enabled = true private filetype: string - private resolvedIndexes: Set = new Set() private completeItems: CompletionItem[] = [] constructor( public readonly name: string, @@ -62,7 +61,6 @@ export default class LanguageSource implements ISource { let { provider, name } = this let { triggerCharacter, bufnr } = opt this.filetype = opt.filetype - this.resolvedIndexes.clear() this.completeItems = [] let triggerKind: CompletionTriggerKind = this.getTriggerKind(opt) let position = this.getPosition(opt) @@ -106,30 +104,16 @@ export default class LanguageSource implements ISource { public async onCompleteResolve(item: ExtendedCompleteItem, token: CancellationToken): Promise { let { index } = item - let resolving = this.completeItems[index] - if (!resolving || this.resolvedIndexes.has(index)) return + let completeItem = this.completeItems[index] + if (!completeItem || item.resolved) return let hasResolve = typeof this.provider.resolveCompletionItem === 'function' if (hasResolve) { - this.resolvedIndexes.add(index) - let disposable = token.onCancellationRequested(() => { - this.resolvedIndexes.delete(index) - }) - try { - let resolved = await Promise.resolve(this.provider.resolveCompletionItem(Object.assign({}, resolving), token)) - disposable.dispose() - if (token.isCancellationRequested) return - if (!resolved) { - this.resolvedIndexes.delete(index) - } else if (resolved !== resolving) { - Object.assign(resolving, resolved) - } - } catch (e) { - this.resolvedIndexes.delete(index) - logger.error(`Error on complete resolve: ${e.message}`, e.stack) - } + let resolved = await Promise.resolve(this.provider.resolveCompletionItem(completeItem, token)) + if (token.isCancellationRequested || !resolved) return + Object.assign(completeItem, resolved) } if (typeof item.documentation === 'undefined') { - let { documentation, detail } = resolving + let { documentation, detail } = completeItem if (!documentation && !detail) return let docs = [] if (detail && !item.detailShown && detail != item.word) { @@ -149,6 +133,7 @@ export default class LanguageSource implements ISource { }) } } + item.resolved = true item.documentation = docs } } @@ -156,14 +141,7 @@ export default class LanguageSource implements ISource { public async onCompleteDone(vimItem: ExtendedCompleteItem, opt: CompleteOption): Promise { let item = this.completeItems[vimItem.index] if (!item) return - let line = opt.linenr - 1 - if (item.insertText && !item.textEdit) { - item.textEdit = { - range: Range.create(line, characterIndex(opt.line, opt.col), line, characterIndex(opt.line, opt.colnr - 1)), - newText: item.insertText - } - } - if (vimItem.line) Object.assign(opt, { line: vimItem.line }) + if (typeof vimItem.line === 'string') Object.assign(opt, { line: vimItem.line }) try { let isSnippet = await this.applyTextEdit(item, vimItem.word, opt) let { additionalTextEdits } = item @@ -189,49 +167,47 @@ export default class LanguageSource implements ISource { } private async applyTextEdit(item: CompletionItem, word: string, option: CompleteOption): Promise { - let { nvim } = workspace + let { line, bufnr, linenr, colnr, col } = option + let doc = workspace.getDocument(bufnr) + let pos = await window.getCursorPosition() + if (pos.line != linenr - 1 || !doc.attached) return let { textEdit } = item + let currline = doc.getline(linenr - 1) + // before CompleteDone + let beginIdx = characterIndex(line, colnr - 1) + if (!textEdit && item.insertText) { + textEdit = { + range: Range.create(pos.line, characterIndex(line, col), pos.line, beginIdx), + newText: item.insertText + } + } if (!textEdit) return false - let { line, bufnr, linenr, colnr } = option - let doc = workspace.getDocument(bufnr) - if (!doc) return false let newText = textEdit.newText let range = InsertReplaceEdit.is(textEdit) ? textEdit.replace : textEdit.range - let characterIndex = byteSlice(line, 0, colnr - 1).length + // adjust range by indent + let n = fixIndent(line, currline, range) + if (n) beginIdx += n // attampt to fix range from textEdit, range should include trigger position - if (range.end.character < characterIndex) { - range.end.character = characterIndex - } + if (range.end.character < beginIdx) range.end.character = beginIdx + // fix range by count cursor moved to replace insernt word on complete done. + if (pos.character > beginIdx) range.end.character += pos.character - beginIdx + let isSnippet = item.insertTextFormat === InsertTextFormat.Snippet - // replace inserted word - let start = line.slice(0, range.start.character) - let end = line.slice(range.end.character) if (isSnippet && this.completeConfig.snippetsSupport === false) { // could be wrong, but maybe best we can do. isSnippet = false newText = word } if (isSnippet) { - let currline = doc.getline(linenr - 1) - let endCharacter = currline.length - end.length - let r = Range.create(linenr - 1, range.start.character, linenr - 1, endCharacter) + let opts = item.data?.ultisnip === true ? {} : item.data?.ultisnip // can't select, since additionalTextEdits would break selection - return await snippetManager.insertSnippet(newText, false, r, item.insertTextMode, item.data?.ultisnip === true) + return await snippetManager.insertSnippet(newText, false, range, item.insertTextMode, opts ? opts : undefined) } - let newLines = `${start}${newText}${end}`.split(/\r?\n/) - if (newLines.length == 1) { - await nvim.call('coc#util#setline', [linenr, newLines[0]]) - await window.moveTo(Position.create(linenr - 1, (start + newText).length)) - } else { - let buffer = nvim.createBuffer(bufnr) - await buffer.setLines(newLines, { - start: linenr - 1, - end: linenr, - strictIndexing: false - }) - let line = linenr - 1 + newLines.length - 1 - let character = newLines[newLines.length - 1].length - end.length - await window.moveTo({ line, character }) + let edit = { range, newText } + await doc.applyEdits([edit]) + let changed = getChangedPosition(pos, edit) + if (changed.line != 0 || changed.character != 0) { + await window.moveTo({ line: pos.line + changed.line, character: pos.character + changed.character }) } return false } @@ -305,7 +281,7 @@ export default class LanguageSource implements ISource { obj.info = '' } if (obj.word == '') obj.empty = 1 - if (item.textEdit) obj.line = opt.line + obj.line = opt.line if (item.kind == CompletionItemKind.Folder && !obj.abbr.endsWith('/')) { obj.abbr = obj.abbr + '/' } @@ -406,3 +382,13 @@ export function getValidWord(text: string, invalidChars: string[], start = 2): s } return text } + +export function fixIndent(line: string, currline: string, range: Range): number { + let oldIndent = line.match(/^\s*/)[0] + let newIndent = currline.match(/^\s*/)[0] + if (oldIndent == newIndent) return + let d = newIndent.length - oldIndent.length + range.start.character += d + range.end.character += d + return d +} diff --git a/src/types.ts b/src/types.ts index eac5511e8b4..b2b23f47861 100644 --- a/src/types.ts +++ b/src/types.ts @@ -244,6 +244,11 @@ export interface Autocmd { callback: Function } +export interface UltiSnippetOption { + regex?: string + context?: string +} + export interface IWorkspace { readonly nvim: Neovim readonly cwd: string @@ -582,6 +587,7 @@ export interface ExtendedCompleteItem extends VimCompleteItem { // used for preview documentation?: Documentation[] detailShown?: number + resolved?: boolean // saved line for apply TextEdit line?: string } diff --git a/src/util/array.ts b/src/util/array.ts index d123c055824..08700bb536b 100644 --- a/src/util/array.ts +++ b/src/util/array.ts @@ -33,6 +33,18 @@ export function group(array: T[], size: number): T[][] { return res } +export function groupBy(array: T[], fn: (v: T) => boolean): [T[], T[]] { + let res: [T[], T[]] = [[], []] + array.forEach(v => { + if (fn(v)) { + res[0].push(v) + } else { + res[1].push(v) + } + }) + return res +} + /** * Removes duplicates from the given array. The optional keyFn allows to specify * how elements are checked for equalness by returning a unique string for each. diff --git a/src/util/position.ts b/src/util/position.ts index 9d247a291c4..1557710f86a 100644 --- a/src/util/position.ts +++ b/src/util/position.ts @@ -79,49 +79,6 @@ export function getChangedPosition(start: Position, edit: TextEdit): { line: num return { line: 0, character: 0 } } -export function adjustPosition(pos: Position, edit: TextEdit): Position { - let { range, newText } = edit - if (comparePosition(range.start, pos) > 1) return pos - let { start, end } = range - let newLines = newText.split('\n') - let delta = (end.line - start.line) - newLines.length + 1 - let lastLine = newLines[newLines.length - 1] - let line = pos.line - delta - if (pos.line != end.line) return { line, character: pos.character } - let pre = newLines.length == 1 && start.line != end.line ? start.character : 0 - let removed = start.line == end.line && newLines.length == 1 ? end.character - start.character : end.character - let character = pre + pos.character + lastLine.length - removed - return { - line, - character - } -} - -export function positionToOffset(lines: string[], line: number, character: number): number { - let offset = 0 - for (let i = 0; i <= line; i++) { - if (i == line) { - offset += character - } else { - offset += lines[i].length + 1 - } - } - return offset -} - -// edit a range to newText -export function editRange(range: Range, text: string, edit: TextEdit): string { - // outof range - if (!rangeInRange(edit.range, range)) return text - let { start, end } = edit.range - let lines = text.split('\n') - let character = start.line == range.start.line ? start.character - range.start.character : start.character - let startOffset = positionToOffset(lines, start.line - range.start.line, character) - character = end.line == range.start.line ? end.character - range.start.character : end.character - let endOffset = positionToOffset(lines, end.line - range.start.line, character) - return `${text.slice(0, startOffset)}${edit.newText}${text.slice(endOffset, text.length)}` -} - export function getChangedFromEdits(start: Position, edits: TextEdit[]): Position | null { let changed = { line: 0, character: 0 } for (let edit of edits) { diff --git a/typings/index.d.ts b/typings/index.d.ts index 0ac147ba844..c96bba8d422 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -4904,7 +4904,7 @@ declare module 'coc.nvim' { * * @param edit Contains snippet text and range to replace. */ - export function executeCommand(command: 'editor.action.insertSnippet', edit: TextEdit, ultisnip?: boolean): Promise + export function executeCommand(command: 'editor.action.insertSnippet', edit: TextEdit, ultisnip?: UltiSnippetOption): Promise /** * Invoke specified code action. @@ -8232,9 +8232,11 @@ declare module 'coc.nvim' { isActive: boolean } - export interface TextmateSnippet { - toString(): string + export interface UltiSnippetOption { + regex?: string + context?: string } + /** * A snippet string is a template which allows to insert text * and to control the editor cursor when insertion happens. @@ -8320,9 +8322,9 @@ declare module 'coc.nvim' { */ export function getSession(bufnr: number): SnippetSession | undefined /** - * Parse snippet string to TextmateSnippet. + * Resolve snippet string to text. */ - export function resolveSnippet(body: string, ultisnip?: boolean): Promise + export function resolveSnippet(body: string, ultisnip?: UltiSnippetOption): Promise /** * Insert snippet at current buffer. * diff --git a/yarn.lock b/yarn.lock index 2723003b0c8..8e525465d11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -411,10 +411,10 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@chemzqm/neovim@^5.7.4": - version "5.7.4" - resolved "https://registry.yarnpkg.com/@chemzqm/neovim/-/neovim-5.7.4.tgz#0f5bcd30a3526db1a8ee13d89bf9a206ea361328" - integrity sha512-ajh2G+EhzqAHkAMsXsiNUG2cpGROO3RiAJbFQ0Px1cf9zE2YqWVZ8h7dFd7aj/zit+la9uTA1OPQkn4FZrHrjw== +"@chemzqm/neovim@^5.7.5": + version "5.7.5" + resolved "https://registry.yarnpkg.com/@chemzqm/neovim/-/neovim-5.7.5.tgz#05a0b2a6a932438af63910a2fb944836599fe7fe" + integrity sha512-BlwPjCDjeSdwIk7vP3wU6kFI+ZJBji8j5QWy631r+aQM1t74AclUOWNQZT8/GX3y2hqmC9LZnFT/bTQtVxw24g== dependencies: msgpack-lite "^0.1.26"