From 5de7052540b2831d9ab22a0e250aa72225dcb017 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 | 61 ++++++++++------- .../language_server_completer_test.py | 66 +++++++++++++++++++ 2 files changed, 104 insertions(+), 23 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index cd2b1317bc..deb9ca476a 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -2638,7 +2638,7 @@ def GoToSymbol( self, request_data, args ): REQUEST_TIMEOUT_COMMAND ) result = response.get( 'result' ) or [] - return _SymbolInfoListToGoTo( request_data, result ) + return _LspSymbolListToGoTo( request_data, result ) def GoToDocumentOutline( self, request_data ): @@ -2655,12 +2655,11 @@ 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 _SymbolInfoListToGoTo( request_data, result ) + return _LspSymbolListToGoTo( request_data, result ) def InitialHierarchy( self, request_data, args ): @@ -3396,26 +3395,10 @@ def _LocationListToGoTo( request_data, positions ): raise RuntimeError( 'Cannot jump to location' ) -def _SymbolInfoListToGoTo( request_data, symbols ): +def _LspSymbolListToGoTo( request_data, symbols ): """Convert a list of LSP SymbolInformation into a YCM GoTo response""" - def BuildGoToLocationFromSymbol( symbol ): - location, line_value = _LspLocationToLocationAndDescription( - request_data, - symbol[ 'location' ] ) - - 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 + locations = [ _BuildGoToLocationFromSymbol( s, request_data ) for s in sorted( symbols, key = lambda s: ( s[ 'kind' ], s[ 'name' ] ) ) ] @@ -3427,6 +3410,38 @@ def BuildGoToLocationFromSymbol( symbol ): return locations +def _FlattenDocumentSymbolHierarchy( symbols ): + result = [] + for s in symbols: + result.append( s ) + if children := s.get( 'children' ): + result.extend( _FlattenDocumentSymbolHierarchy( children ) ) + return result + + +def _BuildGoToLocationFromSymbol( symbol, request_data ): + """ Convert a LSP SymbolInfo or DocumentSymbol into a YCM GoTo response""" + lsp_location = symbol.get( 'location' ) + if not lsp_location: # This is a DocumentSymbol + lsp_location = symbol + lsp_location[ 'uri' ] = lsp.FilePathToUri( request_data[ 'filepath' ] ) + + location, line_value = _LspLocationToLocationAndDescription( + request_data, + lsp_location ) + + 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 + + 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..8db91ea36b 100644 --- a/ycmd/tests/language_server/language_server_completer_test.py +++ b/ycmd/tests/language_server/language_server_completer_test.py @@ -23,6 +23,7 @@ empty, ends_with, equal_to, + instance_of, contains_exactly, has_entries, has_entry, @@ -103,6 +104,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': instance_of( str ), + 'description': 'Namespace: other', + } ), + has_entries( { + 'line_num': 3, + 'column_num': 1, + 'filepath': instance_of( str ), + 'description': 'Namespace: testy', + } ), + has_entries( { + 'line_num': 5, + 'column_num': 2, + 'filepath': instance_of( str ), + 'description': 'Class: MainClass', + } ) + ) ) + + @IsolatedYcmd( { 'global_ycm_extra_conf': PathToTestFile( 'extra_confs', 'settings_extra_conf.py' ) } ) def test_LanguageServerCompleter_ExtraConf_ServerReset( self, app ):