From 0ed683baf91c07354829753195860a53ac35ac9d Mon Sep 17 00:00:00 2001 From: Boris Staletic Date: Mon, 12 Aug 2024 07:54:00 +0200 Subject: [PATCH] Add support for DocumentSymbol in document outline requests We are intentionally not advertising the capability. We do want a flat response, so receiving a DocumentSymbol is a pessimisation. Not advertising the capability means that conforming servers take the faster code path and the likes of OmniSharp, that assume capabilities, still work. Yes, it's messy, but so is LSP. --- .../language_server_completer.py | 49 +++++++++++++- .../language_server_completer_test.py | 65 +++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index cd2b1317bc..e47dc885e0 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -2655,10 +2655,10 @@ def GoToDocumentOutline( self, request_data ): result = response.get( 'result' ) or [] - # We should only receive SymbolInformation (not DocumentSymbol) if any( 'range' in s for s in result ): - raise ValueError( - "Invalid server response; DocumentSymbol not supported" ) + LOGGER.debug( 'Hierarchical DocumentSymbol not supported.' ) + result = _FlattenDocumentSymbolHierarchy( result ) + return _DocumentSymboListToGoTo( request_data, result ) return _SymbolInfoListToGoTo( request_data, result ) @@ -3427,6 +3427,49 @@ def BuildGoToLocationFromSymbol( symbol ): return locations +def _FlattenDocumentSymbolHierarchy( symbols ): + result = [] + for s in symbols: + partial_results = [ s ] + if s.get( 'children' ): + partial_results.extend( + _FlattenDocumentSymbolHierarchy( s[ 'children' ] ) ) + result.extend( partial_results ) + return result + + +def _DocumentSymboListToGoTo( request_data, symbols ): + """Convert a list of LSP DocumentSymbol into a YCM GoTo response""" + + def BuildGoToLocationFromSymbol( symbol ): + symbol[ 'uri' ] = lsp.FilePathToUri( request_data[ 'filepath' ] ) + location, line_value = _LspLocationToLocationAndDescription( + request_data, + symbol ) + + description = ( f'{ lsp.SYMBOL_KIND[ symbol[ "kind" ] ] }: ' + f'{ symbol[ "name" ] }' ) + + goto = responses.BuildGoToResponseFromLocation( location, + description ) + goto[ 'extra_data' ] = { + 'kind': lsp.SYMBOL_KIND[ symbol[ 'kind' ] ], + 'name': symbol[ 'name' ], + } + return goto + + locations = [ BuildGoToLocationFromSymbol( s ) for s in + sorted( symbols, + key = lambda s: ( s[ 'kind' ], s[ 'name' ] ) ) ] + + if not locations: + raise RuntimeError( "Symbol not found" ) + elif len( locations ) == 1: + return locations[ 0 ] + else: + return locations + + def _LspLocationToLocationAndDescription( request_data, location, range_property = 'range' ): diff --git a/ycmd/tests/language_server/language_server_completer_test.py b/ycmd/tests/language_server/language_server_completer_test.py index 56f4f51c94..57c340969d 100644 --- a/ycmd/tests/language_server/language_server_completer_test.py +++ b/ycmd/tests/language_server/language_server_completer_test.py @@ -103,6 +103,71 @@ def _Check_Distance( point, start, end, expected ): class LanguageServerCompleterTest( TestCase ): + @IsolatedYcmd() + def test_LanguageServerCompleter_DocumentSymbol_Hierarchical( self, app ): + completer = MockCompleter() + completer._server_capabilities = { 'documentSymbolProvider': True } + request_data = RequestWrap( BuildRequest( filepath = '/foo' ) ) + server_response = { + 'result': [ + { + "name": "testy", + "kind": 3, + "range": { + "start": { "line": 2, "character": 0 }, + "end": { "line": 12, "character": 1 } + }, + "children": [ + { + "name": "MainClass", + "kind": 5, + "range": { + "start": { "line": 4, "character": 1 }, + "end": { "line": 11, "character": 2 } + } + } + ] + }, + { + "name": "other", + "kind": 3, + "range": { + "start": { "line": 14, "character": 0 }, + "end": { "line": 15, "character": 1 } + }, + "children": [] + } + ] + } + + with patch.object( completer, '_ServerIsInitialized', return_value = True ): + with patch.object( completer.GetConnection(), + 'GetResponse', + return_value = server_response ): + document_outline = completer.GoToDocumentOutline( request_data ) + print( f'result: { document_outline }' ) + assert_that( document_outline, contains_exactly( + has_entries( { + 'line_num': 15, + 'column_num': 1, + 'filepath': '/foo', + 'description': 'Namespace: other', + } ), + has_entries( { + 'line_num': 3, + 'column_num': 1, + 'filepath': '/foo', + 'description': 'Namespace: testy', + } ), + has_entries( { + 'line_num': 5, + 'column_num': 2, + 'filepath': '/foo', + 'description': 'Class: MainClass', + } ) + ) ) + + @IsolatedYcmd( { 'global_ycm_extra_conf': PathToTestFile( 'extra_confs', 'settings_extra_conf.py' ) } ) def test_LanguageServerCompleter_ExtraConf_ServerReset( self, app ):