From 077a8335b4a9fedff477f836513261d8f468f28d Mon Sep 17 00:00:00 2001 From: Yegappan Lakshmanan Date: Sat, 24 Jun 2023 11:08:01 -0700 Subject: [PATCH] Add support for displaying symbols in the current file in a popup menu --- README.md | 1 + autoload/lsp/lsp.vim | 18 +- autoload/lsp/lspserver.vim | 10 +- autoload/lsp/symbol.vim | 370 ++++++++++++++++++++++++++++++++++++- doc/lsp.txt | 30 +++ plugin/lsp.vim | 2 + 6 files changed, 419 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 892cc75..4657d6c 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ Command|Description :LspDiagNext|Jump to the next diagnostic message after the current position. :LspDiagPrev|Jump to the previous diagnostic message before the current position. :LspDiagShow|Display the diagnostics messages from the language server for the current buffer in a new location list. +:LspDocumentSymbol|Display the symbols in the current file in a popup menu and jump to the selected symbol. :LspFold|Fold the current file. :LspFormat|Format a range of lines in the current file using the language server. The **shiftwidth** and **expandtab** values set for the current buffer are used when format is applied. The default range is the entire file. :LspGotoDeclaration|Go to the declaration of the keyword under cursor. diff --git a/autoload/lsp/lsp.vim b/autoload/lsp/lsp.vim index 56c1b47..7ca4a54 100644 --- a/autoload/lsp/lsp.vim +++ b/autoload/lsp/lsp.vim @@ -45,6 +45,7 @@ def LspInitOnce() diag.InitOnce() inlayhints.InitOnce() signature.InitOnce() + symbol.InitOnce() :set ballooneval balloonevalterm lspInitializedOnce = true @@ -847,7 +848,7 @@ def g:LspRequestDocSymbols() return endif - lspserver.getDocSymbols(fname) + lspserver.getDocSymbols(fname, true) enddef # open a window and display all the symbols in a file (outline) @@ -856,6 +857,21 @@ export def Outline(cmdmods: string, winsize: number) g:LspRequestDocSymbols() enddef +# show all the symbols in a file in a popup menu +export def ShowDocSymbols() + var fname: string = @% + if fname->empty() + return + endif + + var lspserver: dict = buf.CurbufGetServer() + if lspserver->empty() || !lspserver.running || !lspserver.ready + return + endif + + lspserver.getDocSymbols(fname, false) +enddef + # Format the entire file export def TextDocFormat(range_args: number, line1: number, line2: number) if !&modifiable diff --git a/autoload/lsp/lspserver.vim b/autoload/lsp/lspserver.vim index d05ff37..cf4dbc1 100644 --- a/autoload/lsp/lspserver.vim +++ b/autoload/lsp/lspserver.vim @@ -143,7 +143,7 @@ def ServerInitReply(lspserver: dict, initResult: dict): void # if the outline window is opened, then request the symbols for the current # buffer if bufwinid('LSP-Outline') != -1 - lspserver.getDocSymbols(@%) + lspserver.getDocSymbols(@%, true) endif # Update the inlay hints (if enabled) @@ -984,7 +984,7 @@ enddef # Request: "textDocument/documentSymbol" # Param: DocumentSymbolParams -def GetDocSymbols(lspserver: dict, fname: string): void +def GetDocSymbols(lspserver: dict, fname: string, showOutline: bool): void # Check whether LSP server supports getting document symbol information if !lspserver.isDocumentSymbolProvider util.ErrMsg('LSP server does not support getting list of symbols') @@ -995,7 +995,11 @@ def GetDocSymbols(lspserver: dict, fname: string): void # interface TextDocumentIdentifier var params = {textDocument: {uri: util.LspFileToUri(fname)}} lspserver.rpc_a('textDocument/documentSymbol', params, (_, reply) => { - symbol.DocSymbolReply(lspserver, reply, fname) + if showOutline + symbol.DocSymbolOutline(lspserver, reply, fname) + else + symbol.DocSymbolPopup(lspserver, reply, fname) + endif }) enddef diff --git a/autoload/lsp/symbol.vim b/autoload/lsp/symbol.vim index b26057c..b4d92e2 100644 --- a/autoload/lsp/symbol.vim +++ b/autoload/lsp/symbol.vim @@ -10,6 +10,22 @@ import './options.vim' as opt import './util.vim' import './outline.vim' +# Initialize the highlight group and the text property type used for +# document symbol search +export def InitOnce() + # Use a high priority value to override other highlights in the line + hlset([{name: 'LspSymbolName', default: true, linksto: 'Search'}]) + prop_type_add('LspSymbolNameProp', {highlight: 'LspSymbolName', + combine: false, + override: true, + priority: 201}) + hlset([{name: 'LspSymbolRange', default: true, linksto: 'Visual'}]) + prop_type_add('LspSymbolRangeProp', {highlight: 'LspSymbolRange', + combine: false, + override: true, + priority: 200}) +enddef + # Handle keys pressed when the workspace symbol popup menu is displayed def FilterSymbols(lspserver: dict, popupID: number, key: string): bool var key_handled: bool = false @@ -104,7 +120,7 @@ def JumpToWorkspaceSymbol(cmdmods: string, popupID: number, result: number): voi exe $'confirm edit {symTbl[result - 1].file}' endif else - # If the target buffer is opened in the curent window, then don't + # If the target buffer is opened in the current window, then don't # change the window. if bufnr() != bnr # If the target buffer is opened in a window in the current tab @@ -160,7 +176,7 @@ def ShowSymbolMenu(lspserver: dict, query: string, cmdmods: string) enddef # Convert a file name to () format. -# Make sure the popup does't occupy the entire screen by reducing the width. +# Make sure the popup doesn't occupy the entire screen by reducing the width. def MakeMenuName(popupWidth: number, fname: string): string var filename: string = fname->fnamemodify(':t') var flen: number = filename->len() @@ -590,24 +606,24 @@ enddef # process the 'textDocument/documentSymbol' reply from the LSP server # Open a symbols window and display the symbols as a tree # Result: DocumentSymbol[] | SymbolInformation[] | null -export def DocSymbolReply(lspserver: dict, docsymbol: any, fname: string) +export def DocSymbolOutline(lspserver: dict, docSymbol: any, fname: string) + var bnr = fname->bufnr() var symbolTypeTable: dict>> = {} var symbolLineTable: list> = [] - var bnr = fname->bufnr() - if docsymbol->empty() + if docSymbol->empty() # No symbols defined for this file. Clear the outline window. outline.UpdateOutlineWindow(fname, symbolTypeTable, symbolLineTable) return endif - if docsymbol[0]->has_key('location') + if docSymbol[0]->has_key('location') # SymbolInformation[] - ProcessSymbolInfoTable(lspserver, bnr, docsymbol, symbolTypeTable, + ProcessSymbolInfoTable(lspserver, bnr, docSymbol, symbolTypeTable, symbolLineTable) else # DocumentSymbol[] - ProcessDocSymbolTable(lspserver, bnr, docsymbol, symbolTypeTable, + ProcessDocSymbolTable(lspserver, bnr, docSymbol, symbolTypeTable, symbolLineTable) endif @@ -616,4 +632,342 @@ export def DocSymbolReply(lspserver: dict, docsymbol: any, fname: string) outline.UpdateOutlineWindow(fname, symbolTypeTable, symbolLineTable) enddef +# Process the list of symbols (LSP interface "SymbolInformation") in +# "symbolInfoTable". For each symbol, create the name to display in the popup +# menu along with the symbol range and return the List. +def GetSymbolsInfoTable(lspserver: dict, + bnr: number, + symbolInfoTable: list>): list> + var symbolTable: list> = [] + var symbolType: string + var name: string + var containerName: string + var r: dict> + + for syminfo in symbolInfoTable + symbolType = SymbolKindToName(syminfo.kind) + name = $'{symbolType} : {syminfo.name}' + if syminfo->has_key('containerName') && !syminfo.containerName->empty() + name ..= $' [{syminfo.containerName}]' + endif + r = syminfo.location.range + lspserver.decodeRange(bnr, r) + + symbolTable->add({name: name, range: r, selectionRange: {}}) + endfor + + return symbolTable +enddef + +# Process the list of symbols (LSP interface "DocumentSymbol") in +# "docSymbolTable". For each symbol, create the name to display in the popup +# menu along with the symbol range and return the List in "symbolTable" +def GetSymbolsDocSymbol(lspserver: dict, + bnr: number, + docSymbolTable: list>, + symbolTable: list>, + parentName: string = '') + var symbolType: string + var name: string + var r: dict> + var sr: dict> + var symInfo: dict + + for syminfo in docSymbolTable + var symName = syminfo.name + symbolType = SymbolKindToName(syminfo.kind)->tolower() + sr = syminfo.selectionRange + lspserver.decodeRange(bnr, sr) + r = syminfo.range + lspserver.decodeRange(bnr, r) + name = $'{symbolType} : {symName}' + if parentName != '' + name ..= $' [{parentName}]' + endif + # TODO: Should include syminfo.detail? Will it clutter the menu? + symInfo = {name: name, range: r, selectionRange: sr} + symbolTable->add(symInfo) + + if syminfo->has_key('children') + # Process all the child symbols + GetSymbolsDocSymbol(lspserver, bnr, syminfo.children, symbolTable, + symName) + endif + endfor +enddef + +# Highlight the name and the range of lines for the symbol at symTbl[symIdx] +def SymbolHighlight(symTbl: list>, symIdx: number) + prop_remove({type: 'LspSymbolNameProp', all: true}) + prop_remove({type: 'LspSymbolRangeProp', all: true}) + if symTbl->empty() + return + endif + + var r = symTbl[symIdx].range + if r->empty() + return + endif + var rangeStart = r.start + var rangeEnd = r.end + var start_lnum = rangeStart.line + 1 + var start_col = rangeStart.character + 1 + var end_lnum = rangeEnd.line + 1 + var end_col: number + var last_lnum = line('$') + if end_lnum > line('$') + end_lnum = last_lnum + end_col = col([last_lnum, '$']) + else + end_col = rangeEnd.character + 1 + endif + prop_add(start_lnum, start_col, + {type: 'LspSymbolRangeProp', + end_lnum: end_lnum, + end_col: end_col}) + cursor(start_lnum, 1) + :normal! z. + + var sr = symTbl[symIdx].selectionRange + if sr->empty() + return + endif + rangeStart = sr.start + rangeEnd = sr.end + prop_add(rangeStart.line + 1, 1, + {type: 'LspSymbolNameProp', + start_col: rangeStart.character + 1, + end_lnum: rangeEnd.line + 1, + end_col: rangeEnd.character + 1}) +enddef + +# Callback invoked when an item is selected in the symbol popup menu +# "symTbl" - list of symbols +# "symInputPopup" - Symbol search input popup window ID +# "save_curpos" - Cursor position before invoking the symbol search. If the +# symbol search is canceled, restore the cursor to this +# position. +def SymbolMenuItemSelected(symPopupMenu: number, + result: number) + var symTblFiltered = symPopupMenu->getwinvar('symbolTableFiltered', []) + var symInputPopup = symPopupMenu->getwinvar('symbolInputPopup', 0) + var save_curpos = symPopupMenu->getwinvar('saveCurPos', []) + + # Restore the cursor to the location where the command was invoked + setpos('.', save_curpos) + + if result > 0 + # A symbol is selected in the popup menu + + # Set the previous cursor location mark. Instead of using setpos(), m' is + # used so that the current location is added to the jump list. + :normal m' + + # Jump to the selected symbol location + var r = symTblFiltered[result - 1].selectionRange + setcursorcharpos(r.start.line + 1, + util.GetCharIdxWithoutCompChar(bufnr(), r.start) + 1) + endif + symInputPopup->popup_close() + prop_remove({type: 'LspSymbolNameProp', all: true}) + prop_remove({type: 'LspSymbolRangeProp', all: true}) +enddef + +# Key filter function for the symbol popup menu. +def SymbolMenuFilterKey(symPopupMenu: number, + key: string): bool + var keyHandled = false + var updateInputPopup = false + var inputText = symPopupMenu->getwinvar('inputText', '') + var symInputPopup = symPopupMenu->getwinvar('symbolInputPopup', 0) + + if key == "\" || key == "\" + # Erase a character in the input popup + if inputText->len() >= 1 + inputText = inputText[: -2] + keyHandled = true + updateInputPopup = true + endif + elseif key == "\" + # Erase all the characters in the input popup + inputText = '' + keyHandled = true + updateInputPopup = true + elseif key == "\" + || key == "\" + || key == "\" + || key == "\" + || key == "\" + || key == "\" + || key == "\" + || key == "\" + # scroll the symbol popup window + var cmd: string = 'normal! ' .. (key == "\" ? 'j' : + key == "\" ? 'k' : key) + win_execute(symPopupMenu, cmd) + keyHandled = true + elseif key =~ '^\k$' + # A keyword character is typed. Add to the input text and update the + # popup + inputText ..= key + keyHandled = true + updateInputPopup = true + endif + + var symTblFiltered: list> = [] + symTblFiltered = symPopupMenu->getwinvar('symbolTableFiltered', []) + + if updateInputPopup + # Update the input popup with the new text and update the symbol popup + # window with the matching symbol names. + symInputPopup->popup_settext(inputText) + + var symbolTable = symPopupMenu->getwinvar('symbolTable') + symTblFiltered = symbolTable->deepcopy() + var symbolMatchPos: list> = [] + + # Get the list of symbols fuzzy matching the entered text + if inputText != '' + var t = symTblFiltered->matchfuzzypos(inputText, {key: 'name'}) + symTblFiltered = t[0] + symbolMatchPos = t[1] + endif + + var popupText: list> + var text: list> + if !symbolMatchPos->empty() + # Generate a list of symbol names and the corresponding text properties + # to highlight the matching characters. + popupText = symTblFiltered->mapnew((idx, val): dict => ({ + text: val.name, + props: symbolMatchPos[idx]->mapnew((_, w: number): dict => ({ + col: w + 1, + length: 1, + type: 'LspSymbolMatch'} + ))} + )) + else + popupText = symTblFiltered->mapnew((idx, val): dict => { + return {text: val.name} + }) + endif + symPopupMenu->popup_settext(popupText) + + # Select the first symbol and highlight the corresponding text range + win_execute(symPopupMenu, 'cursor(1, 1)') + SymbolHighlight(symTblFiltered, 0) + endif + + # Save the filtered symbol table and the search text in popup window + # variables + setwinvar(symPopupMenu, 'inputText', inputText) + setwinvar(symPopupMenu, 'symbolTableFiltered', symTblFiltered) + + if !keyHandled + # Use the default handler for the key + symPopupMenu->popup_filter_menu(key) + endif + + # Highlight the name and range of the selected symbol + var lnum = line('.', symPopupMenu) - 1 + if lnum >= 0 + SymbolHighlight(symTblFiltered, lnum) + endif + + return true +enddef + +# Display the symbols popup menu +def SymbolPopupMenu(symbolTable: list>) + var curLine = line('.') + var curSymIdx = 0 + + # Get the names of all the symbols. Also get the index of the symbol under + # the cursor. + var symNames = symbolTable->mapnew((idx, val): string => { + var r = val.range + if !r->empty() && curSymIdx == 0 + if curLine >= r.start.line + 1 && curLine <= r.end.line + 1 + curSymIdx = idx + endif + endif + return val.name + }) + + var symInputPopupAttr = { + title: 'Select Symbol', + wrap: false, + pos: 'topleft', + line: &lines - 14, + col: 10, + minwidth: 60, + minheight: 1, + maxheight: 1, + maxwidth: 60, + fixed: 1, + close: 'button', + border: [] + } + var symInputPopup = popup_create('', symInputPopupAttr) + + var symNamesPopupattr = { + wrap: false, + pos: 'topleft', + line: &lines - 11, + col: 10, + minwidth: 60, + minheight: 10, + maxheight: 10, + maxwidth: 60, + fixed: 1, + border: [0, 0, 0, 0], + callback: SymbolMenuItemSelected, + filter: SymbolMenuFilterKey, + } + var symPopupMenu = popup_menu(symNames, symNamesPopupattr) + + # Save the state in the popup menu window variables + setwinvar(symPopupMenu, 'symbolTable', symbolTable) + setwinvar(symPopupMenu, 'symbolTableFiltered', symbolTable->deepcopy()) + setwinvar(symPopupMenu, 'symbolInputPopup', symInputPopup) + setwinvar(symPopupMenu, 'saveCurPos', getcurpos()) + prop_type_add('LspSymbolMatch', {bufnr: symPopupMenu->winbufnr(), + highlight: 'Title', + override: true}) + + # Start with the symbol under the cursor + var cmds =<< trim eval END + [{curSymIdx + 1}, 1]->cursor() + :normal! z. + END + win_execute(symPopupMenu, cmds, 'silent!') + + # Highlight the name and range of the first symbol + SymbolHighlight(symbolTable, curSymIdx) +enddef + +# process the 'textDocument/documentSymbol' reply from the LSP server +# Result: DocumentSymbol[] | SymbolInformation[] | null +# Display the symbols in a popup window and jump to the selected symbol +export def DocSymbolPopup(lspserver: dict, docSymbol: any, fname: string) + var symList: list> = [] + + if docSymbol->empty() + return + endif + + var bnr = fname->bufnr() + + if docSymbol[0]->has_key('location') + # SymbolInformation[] + symList = GetSymbolsInfoTable(lspserver, bnr, docSymbol) + else + # DocumentSymbol[] + GetSymbolsDocSymbol(lspserver, bnr, docSymbol, symList) + endif + + :redraw! + SymbolPopupMenu(symList) +enddef + # vim: tabstop=8 shiftwidth=2 softtabstop=2 diff --git a/doc/lsp.txt b/doc/lsp.txt index 40a9622..910dcd1 100644 --- a/doc/lsp.txt +++ b/doc/lsp.txt @@ -91,6 +91,8 @@ The following commands are provided: current buffer before the current current position. :LspDiagShow Display the diagnostics messages from the language server for the current buffer in a location list. +:LspDocumentSymbol Display the symbols in the current file in a popup + menu and jump to the location of a selected symbol. :LspFold Fold the current file :LspFormat Format a range of lines in the current file using the language server. The default range is the entire @@ -692,6 +694,27 @@ can map these commands to keys and make it easier to invoke them. can use the Vim location list commands to browse the list. + *:LspDocumentSymbol* +:LspDocumentSymbol Display the symbols in the current file in a popup + menu. When a symbol is selected in the popup menu by + pressing or , jump to the location of + the symbol. + + The , , , , , , + , , , keys can be used to + scroll the popup menu. The or keys can + be used to cancel the popup menu. + + If one or more keyword characters are typed, then only + the symbols containing the keyword characters are + displayed in the popup menu. Fuzzy searching is used + to get the list of matching symbols. The key can + be used to erase the last typed character. The + key can be used to erase all the characters. + + When scrolling through the symbols in the popup menu, + the corresponding range of lines is highlighted. + *:LspFold* :LspFold Create folds for the current buffer. @@ -1363,6 +1386,13 @@ override them. *LspSigActiveParameter* Used to highlight the active signature parameter. By default, linked to the "LineNr" highlight group. +*LspSymbolName* Used to highlight the symbol name when using + the |:LspDocumentSymbol| command. By default, + linked to the "Search" highlight group. +*LspSymbolRange* Used to highlight the range of lines + containing a symbol when using the + |:LspDocumentSymbol| command. By default, + linked to the "Visual" highlight group. For example, to override the highlight used for diagnostics virtual text, you can use the following: > diff --git a/plugin/lsp.vim b/plugin/lsp.vim index f38f97a..ce9c704 100644 --- a/plugin/lsp.vim +++ b/plugin/lsp.vim @@ -67,6 +67,7 @@ command! -nargs=0 -bar -count=1 LspDiagNext lsp.JumpToDiag('next', ) command! -nargs=0 -bar -count=1 LspDiagPrev lsp.JumpToDiag('prev', ) command! -nargs=0 -bar LspDiagShow lsp.ShowDiagnostics() command! -nargs=0 -bar LspDiagHere lsp.JumpToDiag('here') +command! -nargs=0 -bar LspDocumentSymbol lsp.ShowDocSymbols() command! -nargs=0 -bar LspFold lsp.FoldDocument() command! -nargs=0 -bar -range=% LspFormat lsp.TextDocFormat(, , ) command! -nargs=0 -bar -count LspGotoDeclaration lsp.GotoDeclaration(v:false, , ) @@ -113,6 +114,7 @@ if has('gui_running') anoremenu L&sp.Show\ Detail :LspHover anoremenu L&sp.Outline :LspOutline + anoremenu L&sp.Goto\ Symbol :LspDocumentSymbol anoremenu L&sp.Symbol\ Search :LspSymbolSearch anoremenu L&sp.Outgoing\ Calls :LspOutgoingCalls anoremenu L&sp.Incoming\ Calls :LspIncomingCalls -- 2.44.0