From 7c4b1d8444c3674ac394ab73b66766a4068bbbec Mon Sep 17 00:00:00 2001 From: w0rp Date: Wed, 12 Aug 2020 22:11:33 +0100 Subject: [PATCH] Close #3274 - Handle basic LSP markdown formatting --- autoload/ale/hover.vim | 182 ++++++++++++++++++++++++++++------ autoload/ale/preview.vim | 4 + autoload/ale/util.vim | 15 ++- test/test_hover.vader | 38 ++++--- test/test_hover_parsing.vader | 173 ++++++++++++++++++++++++++++++++ 5 files changed, 360 insertions(+), 52 deletions(-) create mode 100644 test/test_hover_parsing.vader diff --git a/autoload/ale/hover.vim b/autoload/ale/hover.vim index a6462227..fe4e5da7 100644 --- a/autoload/ale/hover.vim +++ b/autoload/ale/hover.vim @@ -56,6 +56,137 @@ function! ale#hover#HandleTSServerResponse(conn_id, response) abort endif endfunction +" Convert a language name to another one. +" The language name could be an empty string or v:null +function! s:ConvertLanguageName(language) abort + return a:language +endfunction + +function! ale#hover#ParseLSPResult(contents) abort + let l:includes = {} + let l:highlights = [] + let l:lines = [] + let l:list = type(a:contents) is v:t_list ? a:contents : [a:contents] + let l:region_index = 0 + + for l:item in l:list + if !empty(l:lines) + call add(l:lines, '') + endif + + if type(l:item) is v:t_dict && has_key(l:item, 'kind') + if l:item.kind is# 'markdown' + " Handle markdown values as we handle strings below. + let l:item = get(l:item, 'value', '') + elseif l:item.kind is# 'plaintext' + " We shouldn't try to parse plaintext as markdown. + " Pass the lines on and skip parsing them. + call extend(l:lines, split(get(l:item, 'value', ''), "\n")) + + continue + endif + endif + + let l:marked_list = [] + + " If the item is a string, then we should parse it as Markdown text. + if type(l:item) is v:t_string + let l:fence_language = v:null + let l:fence_lines = [] + + for l:line in split(l:item, "\n") + if l:fence_language is v:null + " Look for the start of a code fence. (```python, etc.) + let l:match = matchlist(l:line, '^```\(.*\)$') + + if !empty(l:match) + let l:fence_language = l:match[1] + + if !empty(l:marked_list) + call add(l:fence_lines, '') + endif + else + if !empty(l:marked_list) + \&& l:marked_list[-1][0] isnot v:null + call add(l:marked_list, [v:null, ['']]) + endif + + call add(l:marked_list, [v:null, [l:line]]) + endif + elseif l:line =~# '^```$' + " When we hit the end of a code fence, pass the fenced + " lines on to the next steps below. + call add(l:marked_list, [l:fence_language, l:fence_lines]) + let l:fence_language = v:null + let l:fence_lines = [] + else + " Gather lines inside of a code fence. + call add(l:fence_lines, l:line) + endif + endfor + " If the result from the LSP server is a {language: ..., value: ...} + " Dictionary, then that should be interpreted as if it was: + " + " ```${language} + " ${value} + " ``` + elseif type(l:item) is v:t_dict + \&& has_key(l:item, 'language') + \&& type(l:item.language) is v:t_string + \&& has_key(l:item, 'value') + \&& type(l:item.value) is v:t_string + call add( + \ l:marked_list, + \ [l:item.language, split(l:item.value, "\n")], + \) + endif + + for [l:language, l:marked_lines] in l:marked_list + if l:language is v:null + " NOTE: We could handle other Markdown formatting here. + call map( + \ l:marked_lines, + \ 'substitute(v:val, ''\\_'', ''_'', ''g'')', + \) + else + let l:language = s:ConvertLanguageName(l:language) + + if !empty(l:language) + let l:includes[l:language] = printf( + \ 'syntax/%s.vim', + \ l:language, + \) + + let l:start = len(l:lines) + 1 + let l:end = l:start + len(l:marked_lines) + let l:region_index += 1 + + call add(l:highlights, 'syntax region' + \ . ' ALE_hover_' . l:region_index + \ . ' start=/\%' . l:start . 'l/' + \ . ' end=/\%' . l:end . 'l/' + \ . ' contains=@ALE_hover_' . l:language + \) + endif + endif + + call extend(l:lines, l:marked_lines) + endfor + endfor + + let l:include_commands = [] + + for [l:language, l:lang_path] in sort(items(l:includes)) + call add(l:include_commands, 'unlet! b:current_syntax') + call add( + \ l:include_commands, + \ printf('syntax include @ALE_hover_%s %s', l:language, l:lang_path), + \) + endfor + + return [l:include_commands + l:highlights, l:lines] +endfunction + function! ale#hover#HandleLSPResponse(conn_id, response) abort if has_key(a:response, 'id') \&& has_key(s:hover_map, a:response.id) @@ -82,40 +213,25 @@ function! ale#hover#HandleLSPResponse(conn_id, response) abort return endif - let l:result = l:result.contents + let [l:commands, l:lines] = ale#hover#ParseLSPResult(l:result.contents) - if type(l:result) is v:t_string - " The result can be just a string. - let l:result = [l:result] - endif - - if type(l:result) is v:t_dict - " If the result is an object, then it's markup content. - let l:result = has_key(l:result, 'value') ? [l:result.value] : [] - endif - - if type(l:result) is v:t_list - " Replace objects with text values. - call filter(l:result, '!(type(v:val) is v:t_dict && !has_key(v:val, ''value''))') - call map(l:result, 'type(v:val) is v:t_string ? v:val : v:val.value') - let l:str = join(l:result, "\n") - let l:str = substitute(l:str, '^\s*\(.\{-}\)\s*$', '\1', '') - - if !empty(l:str) - if get(l:options, 'hover_from_balloonexpr', 0) - \&& exists('*balloon_show') - \&& ale#Var(l:options.buffer, 'set_balloons') - call balloon_show(l:str) - elseif get(l:options, 'truncated_echo', 0) - call ale#cursor#TruncatedEcho(split(l:str, "\n")[0]) - elseif g:ale_hover_to_preview - call ale#preview#Show(split(l:str, "\n"), { - \ 'filetype': 'ale-preview.message', - \ 'stay_here': 1, - \}) - else - call ale#util#ShowMessage(l:str) - endif + if !empty(l:lines) + if get(l:options, 'hover_from_balloonexpr', 0) + \&& exists('*balloon_show') + \&& ale#Var(l:options.buffer, 'set_balloons') + call balloon_show(join(l:lines, "\n")) + elseif get(l:options, 'truncated_echo', 0) + call ale#cursor#TruncatedEcho(l:lines[0]) + elseif g:ale_hover_to_preview + call ale#preview#Show(l:lines, { + \ 'filetype': 'ale-preview.message', + \ 'stay_here': 1, + \ 'commands': l:commands, + \}) + else + call ale#util#ShowMessage(join(l:lines, "\n"), { + \ 'commands': l:commands, + \}) endif endif endif diff --git a/autoload/ale/preview.vim b/autoload/ale/preview.vim index 7902ec63..faf45cb0 100644 --- a/autoload/ale/preview.vim +++ b/autoload/ale/preview.vim @@ -31,6 +31,10 @@ function! ale#preview#Show(lines, ...) abort setlocal readonly let &l:filetype = get(l:options, 'filetype', 'ale-preview') + for l:command in get(l:options, 'commands', []) + call execute(l:command) + endfor + if get(l:options, 'stay_here') wincmd p endif diff --git a/autoload/ale/util.vim b/autoload/ale/util.vim index ee62af28..bb9c1961 100644 --- a/autoload/ale/util.vim +++ b/autoload/ale/util.vim @@ -16,7 +16,9 @@ endfunction " Vim 8 does not support echoing long messages from asynchronous callbacks, " but NeoVim does. Small messages can be echoed in Vim 8, and larger messages " have to be shown in preview windows. -function! ale#util#ShowMessage(string) abort +function! ale#util#ShowMessage(string, ...) abort + let l:options = get(a:000, 0, {}) + if !has('nvim') call ale#preview#CloseIfTypeMatches('ale-preview.message') endif @@ -25,10 +27,13 @@ function! ale#util#ShowMessage(string) abort if has('nvim') || (a:string !~? "\n" && len(a:string) < &columns) execute 'echo a:string' else - call ale#preview#Show(split(a:string, "\n"), { - \ 'filetype': 'ale-preview.message', - \ 'stay_here': 1, - \}) + call ale#preview#Show(split(a:string, "\n"), extend( + \ { + \ 'filetype': 'ale-preview.message', + \ 'stay_here': 1, + \ }, + \ l:options, + \)) endif endfunction diff --git a/test/test_hover.vader b/test/test_hover.vader index 8b4cf7bd..9689cda2 100644 --- a/test/test_hover.vader +++ b/test/test_hover.vader @@ -5,7 +5,7 @@ Before: let g:Callback = 0 let g:message_list = [] let g:item_list = [] - let g:echo_list = [] + let g:show_message_arg_list = [] runtime autoload/ale/linter.vim runtime autoload/ale/lsp.vim @@ -27,8 +27,8 @@ Before: return 42 endfunction - function! ale#util#ShowMessage(string) abort - call add(g:echo_list, a:string) + function! ale#util#ShowMessage(string, ...) abort + call add(g:show_message_arg_list, [a:string] + a:000) endfunction function! HandleValidLSPResult(result) abort @@ -58,7 +58,7 @@ After: unlet! g:Callback unlet! g:message_list unlet! b:ale_linters - unlet! g:echo_list + unlet! g:show_message_arg_list delfunction HandleValidLSPResult @@ -112,31 +112,31 @@ Execute(tsserver quickinfo displayString values should be displayed): \ } \) - AssertEqual ['foo bar'], g:echo_list + AssertEqual [['foo bar']], g:show_message_arg_list AssertEqual {}, ale#hover#GetMap() Execute(LSP hover responses with just a string should be handled): call HandleValidLSPResult({'contents': 'foobar'}) - AssertEqual ['foobar'], g:echo_list + AssertEqual [['foobar', {'commands': []}]], g:show_message_arg_list AssertEqual {}, ale#hover#GetMap() Execute(LSP hover null responses should be handled): call HandleValidLSPResult(v:null) - AssertEqual [], g:echo_list + AssertEqual [], g:show_message_arg_list AssertEqual {}, ale#hover#GetMap() Execute(LSP hover responses with markup content should be handled): - call HandleValidLSPResult({'contents': {'kind': 'something', 'value': 'markup'}}) + call HandleValidLSPResult({'contents': {'kind': 'markdown', 'value': 'markup'}}) - AssertEqual ['markup'], g:echo_list + AssertEqual [['markup', {'commands': []}]], g:show_message_arg_list AssertEqual {}, ale#hover#GetMap() Execute(LSP hover responses with markup content missing values should be handled): - call HandleValidLSPResult({'contents': {'kind': 'something'}}) + call HandleValidLSPResult({'contents': {'kind': 'markdown'}}) - AssertEqual [], g:echo_list + AssertEqual [], g:show_message_arg_list AssertEqual {}, ale#hover#GetMap() Execute(LSP hover response with lists of strings should be handled): @@ -145,17 +145,27 @@ Execute(LSP hover response with lists of strings should be handled): \ "bar\n", \]}) - AssertEqual ["foo\n\nbar\n"], g:echo_list + AssertEqual [["foo\n\nbar", {'commands': []}]], g:show_message_arg_list AssertEqual {}, ale#hover#GetMap() Execute(LSP hover response with lists of strings and marked strings should be handled): call HandleValidLSPResult({'contents': [ \ {'language': 'rust', 'value': 'foo'}, - \ {'language': 'foobar'}, \ "bar\n", \]}) - AssertEqual ["foo\nbar\n"], g:echo_list + AssertEqual [ + \ [ + \ "foo\n\nbar", + \ { + \ 'commands': [ + \ 'unlet! b:current_syntax', + \ 'syntax include @ALE_hover_rust syntax/rust.vim', + \ 'syntax region ALE_hover_1 start=/\%1l/ end=/\%2l/ contains=@ALE_hover_rust', + \ ], + \ }, + \ ], + \], g:show_message_arg_list AssertEqual {}, ale#hover#GetMap() Execute(tsserver responses for documentation requests should be handled): diff --git a/test/test_hover_parsing.vader b/test/test_hover_parsing.vader new file mode 100644 index 00000000..4129c26a --- /dev/null +++ b/test/test_hover_parsing.vader @@ -0,0 +1,173 @@ +Execute(Invalid results should be handled): + AssertEqual [[], []], ale#hover#ParseLSPResult(0) + AssertEqual [[], []], ale#hover#ParseLSPResult([0]) + AssertEqual [[], []], ale#hover#ParseLSPResult('') + AssertEqual [[], []], ale#hover#ParseLSPResult({}) + AssertEqual [[], []], ale#hover#ParseLSPResult([{}]) + AssertEqual [[], []], ale#hover#ParseLSPResult(['']) + AssertEqual [[], []], ale#hover#ParseLSPResult({'value': ''}) + AssertEqual [[], []], ale#hover#ParseLSPResult([{'value': ''}]) + AssertEqual [[], []], ale#hover#ParseLSPResult({'kind': 'markdown'}) + AssertEqual [[], []], ale#hover#ParseLSPResult({'kind': 'plaintext'}) + AssertEqual [[], []], ale#hover#ParseLSPResult({'kind': 'x', 'value': 'xxx'}) + +Execute(A string with a code fence should be handled): + AssertEqual + \ [ + \ [ + \ 'unlet! b:current_syntax', + \ 'syntax include @ALE_hover_python syntax/python.vim', + \ 'syntax region ALE_hover_1 start=/\%1l/ end=/\%3l/ contains=@ALE_hover_python', + \ ], + \ [ + \ 'def foo():', + \ ' pass', + \ ], + \ ], + \ ale#hover#ParseLSPResult(join([ + \ '```python', + \ 'def foo():', + \ ' pass', + \ '```', + \ ], "\n")) + + AssertEqual + \ [ + \ [ + \ 'unlet! b:current_syntax', + \ 'syntax include @ALE_hover_python syntax/python.vim', + \ 'unlet! b:current_syntax', + \ 'syntax include @ALE_hover_typescript syntax/typescript.vim', + \ 'syntax region ALE_hover_1 start=/\%1l/ end=/\%3l/ contains=@ALE_hover_python', + \ 'syntax region ALE_hover_2 start=/\%5l/ end=/\%8l/ contains=@ALE_hover_python', + \ 'syntax region ALE_hover_3 start=/\%8l/ end=/\%10l/ contains=@ALE_hover_typescript', + \ ], + \ [ + \ 'def foo():', + \ ' pass', + \ '', + \ 'middle line', + \ '', + \ 'def bar():', + \ ' pass', + \ '', + \ 'const baz = () => undefined', + \ ], + \ ], + \ ale#hover#ParseLSPResult(join([ + \ '```python', + \ 'def foo():', + \ ' pass', + \ '```', + \ 'middle line', + \ '```python', + \ 'def bar():', + \ ' pass', + \ '```', + \ '```typescript', + \ 'const baz = () => undefined', + \ '```', + \ ], "\n")) + +Execute(Multiple strings with fences should be handled): + AssertEqual + \ [ + \ [ + \ 'unlet! b:current_syntax', + \ 'syntax include @ALE_hover_python syntax/python.vim', + \ 'unlet! b:current_syntax', + \ 'syntax include @ALE_hover_typescript syntax/typescript.vim', + \ 'syntax region ALE_hover_1 start=/\%1l/ end=/\%3l/ contains=@ALE_hover_python', + \ 'syntax region ALE_hover_2 start=/\%5l/ end=/\%8l/ contains=@ALE_hover_python', + \ 'syntax region ALE_hover_3 start=/\%8l/ end=/\%10l/ contains=@ALE_hover_typescript', + \ ], + \ [ + \ 'def foo():', + \ ' pass', + \ '', + \ 'middle line', + \ '', + \ 'def bar():', + \ ' pass', + \ '', + \ 'const baz = () => undefined', + \ ], + \ ], + \ ale#hover#ParseLSPResult([ + \ join([ + \ '```python', + \ 'def foo():', + \ ' pass', + \ '```', + \ ], "\n"), + \ join([ + \ 'middle line', + \ '```python', + \ 'def bar():', + \ ' pass', + \ '```', + \ '```typescript', + \ 'const baz = () => undefined', + \ '```', + \ ], "\n"), + \ ]) + +Execute(Objects with kinds should be handled): + AssertEqual + \ [ + \ [ + \ 'unlet! b:current_syntax', + \ 'syntax include @ALE_hover_python syntax/python.vim', + \ 'syntax region ALE_hover_1 start=/\%1l/ end=/\%3l/ contains=@ALE_hover_python', + \ ], + \ [ + \ 'def foo():', + \ ' pass', + \ '', + \ '```typescript', + \ 'const baz = () => undefined', + \ '```', + \ ], + \ ], + \ ale#hover#ParseLSPResult([ + \ { + \ 'kind': 'markdown', + \ 'value': join([ + \ '```python', + \ 'def foo():', + \ ' pass', + \ '```', + \ ], "\n"), + \ }, + \ { + \ 'kind': 'plaintext', + \ 'value': join([ + \ '```typescript', + \ 'const baz = () => undefined', + \ '```', + \ ], "\n"), + \ }, + \ ]) + +Execute(Simple markdown formatting should be handled): + AssertEqual + \ [ + \ [ + \ 'unlet! b:current_syntax', + \ 'syntax include @ALE_hover_python syntax/python.vim', + \ 'syntax region ALE_hover_1 start=/\%1l/ end=/\%3l/ contains=@ALE_hover_python', + \ ], + \ [ + \ 'def foo():', + \ ' pass', + \ '', + \ 'formatted _ line _', + \ ], + \ ], + \ ale#hover#ParseLSPResult(join([ + \ '```python', + \ 'def foo():', + \ ' pass', + \ '```', + \ 'formatted \_ line \_', + \ ], "\n"))