From fb9e0a14a268b8a33bb1a3eb34984a2d7ddcd612 Mon Sep 17 00:00:00 2001 From: Yegappan Lakshmanan Date: Fri, 25 Dec 2020 19:47:38 -0800 Subject: [PATCH] Add support formatting text and use the tag stack for jumping to definition/declaration --- autoload/lsp.vim | 339 +++++++++++++++++++++++++++++++++++++++++++++- plugin/lsp.vim | 25 ++-- test/test_lsp.vim | 124 +++++++++++++++++ 3 files changed, 472 insertions(+), 16 deletions(-) create mode 100644 test/test_lsp.vim diff --git a/autoload/lsp.vim b/autoload/lsp.vim index 8efaaa7..dcc25d0 100644 --- a/autoload/lsp.vim +++ b/autoload/lsp.vim @@ -107,6 +107,11 @@ enddef def s:processDefDeclReply(lspserver: dict, req: dict, reply: dict): void if reply.result->empty() WarnMsg("Error: definition is not found") + # pop the tag stack + var tagstack: dict = gettagstack() + if tagstack.length > 0 + settagstack(winnr(), {'curidx': tagstack.length}, 't') + endif return endif @@ -408,6 +413,229 @@ def s:processDocSymbolReply(lspserver: dict, req: dict, reply: dict): number + # LSP's line and characters are 0-indexed + # Vim's line and columns are 1-indexed + var col: number = pos.character + # When on the first character, we can ignore the difference between byte and + # character + if col > 0 + if !bnr->bufloaded() + bnr->bufload() + endif + + var ltext: list = bnr->getbufline(pos.line + 1) + if !ltext->empty() + var bidx = ltext[0]->byteidx(col) + if bidx != -1 + return bidx + endif + endif + endif + + return col +enddef + +# sort the list of edit operations in the descending order of line and column +# numbers. +# 'a': {'A': [lnum, col], 'B': [lnum, col]} +# 'b': {'A': [lnum, col], 'B': [lnum, col]} +def s:edit_sort_func(a: dict, b: dict): number + # line number + if a.A[0] != b.A[0] + return b.A[0] - a.A[0] + endif + # column number + if a.A[1] != b.A[1] + return b.A[1] - a.A[1] + endif + + return 0 +enddef + +# Replaces text in a range with new text. +# +# CAUTION: Changes in-place! +# +# 'lines': Original list of strings +# 'A': Start position; [line, col] +# 'B': End position [line, col] +# 'new_lines' A list of strings to replace the original +# +# returns the modified 'lines' +def s:set_lines(lines: list, A: list, B: list, + new_lines: list): list + var i_0: number = A[0] + + # If it extends past the end, truncate it to the end. This is because the + # way the LSP describes the range including the last newline is by + # specifying a line number after what we would call the last line. + var numlines: number = lines->len() + var i_n = [B[0], numlines - 1]->min() + + if i_0 < 0 || i_0 >= numlines || i_n < 0 || i_n >= numlines + WarnMsg("set_lines: Invalid range, A = " .. string(A) + .. ", B = " .. string(B) .. ", numlines = " .. numlines + .. ", new lines = " .. string(new_lines)) + return lines + endif + + # save the prefix and suffix text before doing the replacements + var prefix: string = '' + var suffix: string = lines[i_n][B[1] :] + if A[1] > 0 + prefix = lines[i_0][0 : A[1] - 1] + endif + + var new_lines_len: number = new_lines->len() + + #echomsg 'i_0 = ' .. i_0 .. ', i_n = ' .. i_n .. ', new_lines = ' .. string(new_lines) + var n: number = i_n - i_0 + 1 + if n != new_lines_len + if n > new_lines_len + # remove the deleted lines + lines->remove(i_0, i_0 + n - new_lines_len - 1) + else + # add empty lines for newly the added lines (will be replaced with the + # actual lines below) + lines->extend(repeat([''], new_lines_len - n), i_0) + endif + endif + #echomsg "lines(1) = " .. string(lines) + + # replace the previous lines with the new lines + for i in range(new_lines_len) + lines[i_0 + i] = new_lines[i] + endfor + #echomsg "lines(2) = " .. string(lines) + + # append the suffix (if any) to the last line + if suffix != '' + var i = i_0 + new_lines_len - 1 + lines[i] = lines[i] .. suffix + endif + #echomsg "lines(3) = " .. string(lines) + + # prepend the prefix (if any) to the first line + if prefix != '' + lines[i_0] = prefix .. lines[i_0] + endif + #echomsg "lines(4) = " .. string(lines) + + return lines +enddef + +# Apply set of text edits to the specified buffer +# The text edit logic is ported from the Neovim lua implementation +def s:apply_text_edits(bnr: number, text_edits: list>): void + if text_edits->empty() + return + endif + + # if the buffer is not loaded, load it and make it a listed buffer + if !bnr->bufloaded() + bnr->bufload() + endif + bnr->setbufvar('&buflisted', v:true) + + var start_line: number = 4294967295 # 2 ^ 32 + var finish_line: number = -1 + var updated_edits: list> = [] + var start_row: number + var start_col: number + var end_row: number + var end_col: number + + # create a list of buffer positions where the edits have to be applied. + for e in text_edits + # Adjust the start and end columns for multibyte characters + start_row = e.range.start.line + start_col = s:get_line_byte_from_position(bnr, e.range.start) + end_row = e.range.end.line + end_col = s:get_line_byte_from_position(bnr, e.range.end) + start_line = [e.range.start.line, start_line]->min() + finish_line = [e.range.end.line, finish_line]->max() + + updated_edits->add({'A': [start_row, start_col], + 'B': [end_row, end_col], + 'lines': e.newText->split("\n", v:true)}) + endfor + + # Reverse sort the edit operations by descending line and column numbers so + # that they can be applied without interfering with each other. + updated_edits->sort('s:edit_sort_func') + + var lines: list = bnr->getbufline(start_line + 1, finish_line + 1) + var fix_eol: number = bnr->getbufvar('&fixeol') + var set_eol = fix_eol && bnr->getbufinfo()[0].linecount <= finish_line + 1 + if set_eol && lines[-1]->len() != 0 + lines->add('') + endif + + #echomsg 'lines(1) = ' .. string(lines) + #echomsg updated_edits + + for e in updated_edits + var A: list = [e.A[0] - start_line, e.A[1]] + var B: list = [e.B[0] - start_line, e.B[1]] + lines = s:set_lines(lines, A, B, e.lines) + endfor + + #echomsg 'lines(2) = ' .. string(lines) + + # If the last line is empty and we need to set EOL, then remove it. + if set_eol && lines[-1]->len() == 0 + lines->remove(-1) + endif + + #echomsg 'apply_text_edits: start_line = ' .. start_line .. ', finish_line = ' .. finish_line + #echomsg 'lines = ' .. string(lines) + + # Delete all the lines that need to be modified + bnr->deletebufline(start_line + 1, finish_line + 1) + + # if the buffer is empty, appending lines before the first line adds an + # extra empty line at the end. Delete the empty line after appending the + # lines. + var dellastline: bool = v:false + if start_line == 0 && bnr->getbufinfo()[0].linecount == 1 && + bnr->getbufline(1)[0] == '' + dellastline = v:true + endif + + # Append the updated lines + appendbufline(bnr, start_line, lines) + + if dellastline + bnr->deletebufline(bnr->getbufinfo()[0].linecount) + endif +enddef + +# process the 'textDocument/formatting' reply from the LSP server +def s:processFormatReply(lspserver: dict, req: dict, reply: dict) + if reply.result->empty() + # nothing to format + return + endif + + # result: TextEdit[] + + var fname: string = LspUriToFile(req.params.textDocument.uri) + var bnr: number = bufnr(fname) + if bnr == -1 + # file is already removed + return + endif + + # interface TextEdit + # Apply each of the text edit operations + var save_cursor: list = getcurpos() + s:apply_text_edits(bnr, reply.result) + save_cursor->setpos('.') +enddef + # Process various reply messages from the LSP server def s:processReply(lspserver: dict, req: dict, reply: dict): void var lsp_reply_handlers: dict = @@ -422,7 +650,9 @@ def s:processReply(lspserver: dict, req: dict, reply: dict): void 'textDocument/hover': function('s:processHoverReply'), 'textDocument/references': function('s:processReferencesReply'), 'textDocument/documentHighlight': function('s:processDocHighlightReply'), - 'textDocument/documentSymbol': function('s:processDocSymbolReply') + 'textDocument/documentSymbol': function('s:processDocSymbolReply'), + 'textDocument/formatting': function('s:processFormatReply'), + 'textDocument/rangeFormatting': function('s:processFormatReply') } if lsp_reply_handlers->has_key(req.method) @@ -717,6 +947,14 @@ def lsp#gotoDefinition() return endif + # push the current location on to the tag stack + settagstack(winnr(), {'items': + [{'bufnr': bufnr(), + 'from': getpos('.'), + 'matchnr': 1, + 'tagname': expand('')} + ]}, 'a') + var lnum: number = line('.') - 1 var col: number = col('.') - 1 @@ -760,6 +998,14 @@ def lsp#gotoDeclaration() return endif + # push the current location on to the tag stack + settagstack(winnr(), {'items': + [{'bufnr': bufnr(), + 'from': getpos('.'), + 'matchnr': 1, + 'tagname': expand('')} + ]}, 'a') + var lnum: number = line('.') - 1 var col: number = col('.') - 1 @@ -803,6 +1049,14 @@ def lsp#gotoTypedef() return endif + # push the current location on to the tag stack + settagstack(winnr(), {'items': + [{'bufnr': bufnr(), + 'from': getpos('.'), + 'matchnr': 1, + 'tagname': expand('')} + ]}, 'a') + var lnum: number = line('.') - 1 var col: number = col('.') - 1 @@ -846,6 +1100,14 @@ def lsp#gotoImplementation() return endif + # push the current location on to the tag stack + settagstack(winnr(), {'items': + [{'bufnr': bufnr(), + 'from': getpos('.'), + 'matchnr': 1, + 'tagname': expand('')} + ]}, 'a') + var lnum: number = line('.') - 1 var col: number = col('.') - 1 @@ -909,7 +1171,7 @@ enddef # buffer change notification listener def lsp#bufchange_listener(bnum: number, start: number, end: number, added: number, changes: list>) - var ftype = getbufvar(bnum, '&filetype') + var ftype = bnum->getbufvar('&filetype') var lspserver: dict = LspGetServer(ftype) if lspserver->empty() || !lspserver.running return @@ -922,7 +1184,7 @@ def lsp#bufchange_listener(bnum: number, start: number, end: number, added: numb var vtdid: dict = {} vtdid.uri = LspFileToUri(bufname(bnum)) # Use Vim 'changedtick' as the LSP document version number - vtdid.version = getbufvar(bnum, 'changedtick') + vtdid.version = bnum->getbufvar('changedtick') notif.params->extend({'textDocument': vtdid}) # interface TextDocumentContentChangeEvent var changeset: list> @@ -1356,4 +1618,73 @@ def lsp#showDocSymbols() lspserver.sendMessage(req) enddef -# vim: shiftwidth=2 sts=2 expandtab +# Format the entire file +def lsp#textDocFormat(range_args: number, line1: number, line2: number) + if !&modifiable + ErrMsg('Error: Current file is not a modifiable file') + return + endif + + var ftype = &filetype + if ftype == '' + return + endif + + var lspserver: dict = LspGetServer(ftype) + if lspserver->empty() + ErrMsg('Error: LSP server for "' .. ftype .. '" filetype is not found') + return + endif + if !lspserver.running + ErrMsg('Error: LSP server for "' .. ftype .. '" filetype is not running') + return + endif + + # Check whether LSP server supports getting reference information + if !lspserver.caps->has_key('documentFormattingProvider') + || !lspserver.caps.documentFormattingProvider + ErrMsg("Error: LSP server does not support formatting documents") + return + endif + + var fname = @% + if fname == '' + return + endif + + var cmd: string + if range_args > 0 + cmd = 'textDocument/rangeFormatting' + else + cmd = 'textDocument/formatting' + endif + var req = lspserver.createRequest(cmd) + + # interface DocumentFormattingParams + # interface TextDocumentIdentifier + req.params->extend({'textDocument': {'uri': LspFileToUri(fname)}}) + var tabsz: number + if &sts > 0 + tabsz = &sts + elseif &sts < 0 + tabsz = &shiftwidth + else + tabsz = &tabstop + endif + # interface FormattingOptions + var fmtopts: dict = { + tabSize: tabsz, + insertSpaces: &expandtab ? v:true : v:false, + } + req.params->extend({'options': fmtopts}) + if range_args > 0 + var r: dict> = { + 'start': {'line': line1 - 1, 'character': 0}, + 'end': {'line': line2, 'character': 0}} + req.params->extend({'range': r}) + endif + + lspserver.sendMessage(req) +enddef + +# vim: shiftwidth=2 softtabstop=2 diff --git a/plugin/lsp.vim b/plugin/lsp.vim index 347d365..e1ed9dd 100644 --- a/plugin/lsp.vim +++ b/plugin/lsp.vim @@ -5,7 +5,7 @@ if v:version < 802 || !has('patch-8.2.2082') finish endif -autocmd BufReadPost * call lsp#addFile(expand('') + 0, &filetype) +autocmd BufNewFile,BufReadPost * call lsp#addFile(expand('') + 0, &filetype) autocmd BufWipeOut * call lsp#removeFile(expand(':p'), &filetype) " TODO: Is it needed to shutdown all the LSP servers when exiting Vim? @@ -13,15 +13,16 @@ autocmd BufWipeOut * call lsp#removeFile(expand(':p'), &filetype) " autocmd VimLeavePre * call lsp#stopAllServers() " LSP commands -command! -nargs=0 LspShowServers call lsp#showServers() -command! -nargs=0 LspGotoDefinition call lsp#gotoDefinition() -command! -nargs=0 LspGotoDeclaration call lsp#gotoDeclaration() -command! -nargs=0 LspGotoTypeDef call lsp#gotoTypedef() -command! -nargs=0 LspGotoImpl call lsp#gotoImplementation() -command! -nargs=0 LspShowSignature call lsp#showSignature() -command! -nargs=0 LspShowDiagnostics call lsp#showDiagnostics() -command! -nargs=0 LspShowReferences call lsp#showReferences() -command! -nargs=0 LspHighlight call lsp#docHighlight() -command! -nargs=0 LspHighlightClear call lsp#docHighlightClear() -command! -nargs=0 LspShowSymbols call lsp#showDocSymbols() +command! -nargs=0 -bar LspShowServers call lsp#showServers() +command! -nargs=0 -bar LspGotoDefinition call lsp#gotoDefinition() +command! -nargs=0 -bar LspGotoDeclaration call lsp#gotoDeclaration() +command! -nargs=0 -bar LspGotoTypeDef call lsp#gotoTypedef() +command! -nargs=0 -bar LspGotoImpl call lsp#gotoImplementation() +command! -nargs=0 -bar LspShowSignature call lsp#showSignature() +command! -nargs=0 -bar LspShowDiagnostics call lsp#showDiagnostics() +command! -nargs=0 -bar LspShowReferences call lsp#showReferences() +command! -nargs=0 -bar LspHighlight call lsp#docHighlight() +command! -nargs=0 -bar LspHighlightClear call lsp#docHighlightClear() +command! -nargs=0 -bar LspShowSymbols call lsp#showDocSymbols() +command! -nargs=0 -bar -range=% LspFormat call lsp#textDocFormat(, , ) diff --git a/test/test_lsp.vim b/test/test_lsp.vim new file mode 100644 index 0000000..2a5374c --- /dev/null +++ b/test/test_lsp.vim @@ -0,0 +1,124 @@ +vim9script +# Tests for Vim Language Server Protocol (LSP) client +# To run the tests, just source this file + +# Test for formatting a file using LSP +def Test_lsp_formatting() + :silent! edit Xtest.c + setline(1, [' int i;', ' int j;']) + :redraw! + :LspFormat + :sleep 1 + assert_equal(['int i;', 'int j;'], getline(1, '$')) + + deletebufline('', 1, '$') + setline(1, ['int f1(int i)', '{', 'int j = 10; return j;', '}']) + :redraw! + :LspFormat + :sleep 1 + assert_equal(['int f1(int i) {', ' int j = 10;', ' return j;', '}'], + getline(1, '$')) + + deletebufline('', 1, '$') + setline(1, ['', 'int i;']) + :redraw! + :LspFormat + :sleep 1 + assert_equal(['', 'int i;'], getline(1, '$')) + + deletebufline('', 1, '$') + setline(1, [' int i;']) + :redraw! + :LspFormat + :sleep 1 + assert_equal(['int i;'], getline(1, '$')) + + deletebufline('', 1, '$') + setline(1, [' int i; ']) + :redraw! + :LspFormat + :sleep 1 + assert_equal(['int i;'], getline(1, '$')) + + deletebufline('', 1, '$') + setline(1, ['int i;', '', '', '']) + :redraw! + :LspFormat + :sleep 1 + assert_equal(['int i;'], getline(1, '$')) + + deletebufline('', 1, '$') + setline(1, ['int f1(){int x;int y;x=1;y=2;return x+y;}']) + :redraw! + :LspFormat + :sleep 1 + var expected: list =<< trim END + int f1() { + int x; + int y; + x = 1; + y = 2; + return x + y; + } + END + assert_equal(expected, getline(1, '$')) + + deletebufline('', 1, '$') + setline(1, ['', '', '', '']) + :redraw! + :LspFormat + :sleep 1 + assert_equal([''], getline(1, '$')) + + deletebufline('', 1, '$') + var lines: list =<< trim END + int f1() { + int i, j; + for (i = 1; i < 10; i++) { j++; } + for (j = 1; j < 10; j++) { i++; } + } + END + setline(1, lines) + :redraw! + :4LspFormat + :sleep 1 + expected =<< trim END + int f1() { + int i, j; + for (i = 1; i < 10; i++) { j++; } + for (j = 1; j < 10; j++) { + i++; + } + } + END + assert_equal(expected, getline(1, '$')) + + :%bw! +enddef + +def LspRunTests() + # Edit a dummy C file to start the LSP server + :edit Xtest.c + :sleep 1 + :%bw! + + var fns: list = execute('function /Test_') + ->split("\n") + ->map("v:val->substitute('^def \\d\\+_', '', '')") + for f in fns + v:errors = [] + exe f + if v:errors->len() != 0 + new Lsp-Test-Results + setline(1, ["Error: Test " .. f .. " failed"]->extend(v:errors)) + setbufvar('', '&modified', 0) + return + endif + endfor + + echomsg "Success: All LSP tests have passed" +enddef + +LspRunTests() + +# vim: shiftwidth=2 softtabstop=2 noexpandtab -- 2.48.1