diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 3380395712..0102849a42 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -25,7 +25,9 @@ from collections import OrderedDict from ycmd import responses, utils -from ycmd.completers.language_server import language_server_completer +from ycmd.completers.java import java_utils +from ycmd.completers.language_server import ( language_server_protocol as lsp, + language_server_completer ) from ycmd.utils import LOGGER NO_DOCUMENTATION_MESSAGE = 'No documentation available for current context' @@ -328,6 +330,12 @@ def __init__( self, user_options ): def DefaultSettings( self, request_data ): return { 'bundles': self._bundles, + 'capabilities': { + 'definitionProvider': True + }, + 'extendedClientCapabilities': { + 'classFileContentsSupport': True + } # This disables re-checking every open file on every change to every file. # But can lead to stale diagnostics. Unfortunately, this can be kind of @@ -660,3 +668,79 @@ def Hierarchy( self, request_data, args ): *language_server_completer._LspLocationToLocationAndDescription( request_data, item[ 'from' ] ) ) return result + + + def GoTo( self, request_data, handlers ): + """Issues a GoTo request for each handler in |handlers| until it returns + multiple locations or a location the cursor does not belong since the user + wants to jump somewhere else. If that's the last handler, the location is + returned anyway.""" + if not self.ServerIsReady(): + raise RuntimeError( 'Server is initializing. Please wait.' ) + + self._UpdateServerWithFileContents( request_data ) + + # flake8 doesn't like long lines so we have to split it up + def HandlerCondition( result, request_data ): + return result and \ + not language_server_completer._CursorInsideLocation( request_data, + result[ 0 ] ) + + result = [] + for handler in handlers: + new_result = self._GoToRequest( request_data, handler ) + if new_result: + result = new_result + if len( result ) > 1 or HandlerCondition( result, request_data ): + break + + if not result: + raise RuntimeError( 'Cannot jump to location' ) + + first_result = result[ 0 ] + if java_utils.IsJdtContentUri( first_result[ 'uri' ] ): + contents = self.GetClassFileContents( first_result ) + response = first_result.copy() + response[ 'jdt_contents' ] = str( contents ) + + return response + + return language_server_completer._LocationListToGoTo( request_data, result ) + + + def GoToSymbol( self, request_data, args ): + result = super().GoToSymbol( request_data, args ) + + def locations_filter( filepath ): + return filepath and not java_utils.IsJdtContentUri( filepath ) + + if isinstance( result, list ): + result = sorted( + filter( lambda loc: locations_filter( loc[ 'filepath' ] ), result ), + key = lambda s: ( s[ 'extra_data' ][ 'kind' ], + s[ 'extra_data' ][ 'name' ] ) + ) + + return result + + + def GetClassFileContents( self, request_data ): + """Retrieves the contents of a Java class file from the language server + using the provided request data, ensuring the server is initialized before + proceeding; raises a RuntimeError if the server is not ready, sends a + request to obtain the class file contents, and returns the result if + available.""" + if not self._ServerIsInitialized(): + raise RuntimeError( 'Server is initializing. Please wait.' ) + + request_id = self.GetConnection().NextRequestId() + response = self.GetConnection().GetResponse( + request_id, + lsp.BuildRequest( request_id, 'java/classFileContents', { + 'uri': request_data[ 'uri' ] + } ), + language_server_completer.REQUEST_TIMEOUT_COMMAND ) + + result = response[ 'result' ] + if result: + return result diff --git a/ycmd/completers/java/java_utils.py b/ycmd/completers/java/java_utils.py new file mode 100644 index 0000000000..057c732867 --- /dev/null +++ b/ycmd/completers/java/java_utils.py @@ -0,0 +1,50 @@ +# Copyright (C) 2013-2020 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +import os +from urllib.parse import urljoin, urlparse, unquote +from urllib.request import pathname2url, url2pathname +from ..language_server.language_server_protocol import InvalidUriException + + +def FilePathToUri( file_name ): + if IsJdtContentUri( file_name ): + return file_name + + return urljoin( 'file:', pathname2url( file_name ) ) + + +def UriToFilePath( uri ): + if IsJdtContentUri( uri ): + return uri + + parsed_uri = urlparse( uri ) + if parsed_uri.scheme != 'file': + raise InvalidUriException( uri ) + + # url2pathname doesn't work as expected when uri.path is percent-encoded and + # is a windows path for ex: + # url2pathname('/C%3a/') == 'C:\\C:' + # whereas + # url2pathname('/C:/') == 'C:\\' + # Therefore first unquote pathname. + pathname = unquote( parsed_uri.path ) + return os.path.abspath( url2pathname( pathname ) ) + + +def IsJdtContentUri( uri ): + return isinstance( uri, str ) and uri[ : 5 ] == "jdt:/" diff --git a/ycmd/tests/java/debug_info_test.py b/ycmd/tests/java/debug_info_test.py index e0c9440cac..65a01e7bc6 100644 --- a/ycmd/tests/java/debug_info_test.py +++ b/ycmd/tests/java/debug_info_test.py @@ -109,10 +109,16 @@ def test_DebugInfo( self, app ): } ), has_entries( { 'key': 'Settings', - 'value': json.dumps( - { 'bundles': [] }, - indent = 2, - sort_keys = True ) + 'value': json.dumps( { + 'bundles': [], + 'capabilities': { + 'definitionProvider': True + }, + 'extendedClientCapabilities': { + 'classFileContentsSupport': True + } + }, + indent = 2 ) } ), has_entries( { 'key': 'Startup Status', 'value': 'Ready' } ), @@ -169,10 +175,17 @@ def test_DebugInfo_ExtraConf_SettingsValid( self, app ): } ), has_entries( { 'key': 'Settings', - 'value': json.dumps( - { 'java.rename.enabled': False, 'bundles': [] }, - indent = 2, - sort_keys = True ) + 'value': json.dumps( { + 'bundles': [], + 'capabilities': { + 'definitionProvider': True + }, + 'extendedClientCapabilities': { + 'classFileContentsSupport': True + }, + 'java.rename.enabled': False + }, + indent = 2 ) } ), has_entries( { 'key': 'Startup Status', 'value': 'Ready' } ), diff --git a/ycmd/tests/language_server/language_server_completer_test.py b/ycmd/tests/language_server/language_server_completer_test.py index 8db91ea36b..fffc8de088 100644 --- a/ycmd/tests/language_server/language_server_completer_test.py +++ b/ycmd/tests/language_server/language_server_completer_test.py @@ -47,8 +47,12 @@ RangeMatcher ) from ycmd.tests.language_server import IsolatedYcmd, PathToTestFile from ycmd import handlers, utils, responses +from ycmd.completers.java.java_utils import IsJdtContentUri +from ycmd.completers.java.java_completer import JavaCompleter import os +MESSAGE_INITIALIZING = 'Server is initializing. Please wait.' + class MockCompleter( lsc.LanguageServerCompleter, DummyCompleter ): def __init__( self, custom_options = {} ): @@ -90,6 +94,15 @@ def GetServerName( self ): return 'mock_completer' +class MockJavaCompleter( MockCompleter, JavaCompleter ): + def Language( self ): + return 'java' + + + def GetServerName( self ): + return 'mock_java_completer' + + def _TupleToLSPRange( tuple ): return { 'line': tuple[ 0 ], 'character': tuple[ 1 ] } @@ -365,6 +378,66 @@ def test_LanguageServerCompleter_Initialise_Shutdown( self, app ): assert_that( completer.ServerIsReady(), equal_to( False ) ) + @IsolatedYcmd() + def test_LanguageJavaServerCompleter_GoTo( self, app ): + completer = MockJavaCompleter() + completer._server_capabilities = { + 'definitionProvider': True, + 'declarationProvider': True, + 'typeDefinitionProvider': True, + 'implementationProvider': True, + 'referencesProvider': True + } + + if utils.OnWindows(): + filepath = 'C:\\test.test' + else: + filepath = '/test.test' + + contents = 'line1\nline2\nline3' + + request_data = RequestWrap( BuildRequest( + filetype = 'ycmtest', + filepath = filepath, + contents = contents, + line_num = 2, + column_num = 3 + ) ) + + jdt_response = { + 'uri': 'jdt://contents/stuff/Member.class', + 'jdt_contents': 'mock contents', + 'range': { + 'start': { 'line': 0, 'character': 0 }, + 'end': { 'line': 0, 'character': 0 }, + } + } + + @patch.object( completer, 'ServerIsReady', return_value = True ) + def Test( responses, command, exception, throws, *args ): + with patch.object( completer.GetConnection(), + 'GetResponse', + side_effect = responses ): + if throws: + assert_that( + calling( completer.OnUserCommand ).with_args( [ command ], + request_data ), + raises( exception ) + ) + else: + result = completer.OnUserCommand( [ command ], request_data ) + assert_that( result, exception ) + + + with patch( + 'ycmd.completers.java.java_completer.' + 'JavaCompleter.GetClassFileContents', + return_value = 'mock contents' ): + Test( [ { + 'result': jdt_response + } ], 'GoTo', equal_to( jdt_response ), False ) + + @IsolatedYcmd() def test_LanguageServerCompleter_GoTo( self, app ): if utils.OnWindows(): @@ -1576,3 +1649,43 @@ def test_LanguageServerCompleter_DistanceOfPointToRange_MultiLineRange( # Point to the right of range. # +1 because diags are half-open ranges. _Check_Distance( ( 3, 8 ), ( 0, 2 ), ( 3, 5 ) , 4 ) + + + @IsolatedYcmd() + def test_LanguageServerCompleter_GetClassFileContents_Success( self, app ): + completer = MockJavaCompleter() + + with patch.object( completer, '_ServerIsInitialized', return_value = True ): + with patch.object( completer.GetConnection(), 'GetResponse', + return_value = { 'result': 'mock contents' } ) as \ + get_response: + request_data = { 'uri': 'jdt://test' } + contents = completer.GetClassFileContents( request_data ) + + assert_that( contents, equal_to( 'mock contents' ) ) + get_response.assert_called_once() + + + @IsolatedYcmd() + def test_LanguageServerCompleter_GetClassFileContents_Uninit( self, *args ): + completer = MockJavaCompleter() + + with self.assertRaises( RuntimeError ) as context: + completer.GetClassFileContents( {} ) + + assert_that( str( context.exception ), equal_to( MESSAGE_INITIALIZING ) ) + + +class IsJdtContentUriTest( TestCase ): + + def test_IsJdtContentUri( self ): + for uri, result in [ + ( "jdt://example/class", True ), + ( "jdt:/contents/jdk.compiler", True ), + ( "file://example/class", False ), + ( "example/class", False ), + ( "jdt/example/class", False ), + ( 123, False ), + ]: + with self.subTest( uri = uri, result = result ): + self.assertEqual( IsJdtContentUri( uri ), result ) diff --git a/ycmd/tests/language_server/language_server_protocol_test.py b/ycmd/tests/language_server/language_server_protocol_test.py index 15c91a0001..ff5f2ff2d5 100644 --- a/ycmd/tests/language_server/language_server_protocol_test.py +++ b/ycmd/tests/language_server/language_server_protocol_test.py @@ -16,6 +16,7 @@ # along with ycmd. If not, see . from ycmd.completers.language_server import language_server_protocol as lsp +from ycmd.completers.java import java_utils from hamcrest import assert_that, equal_to, calling, is_not, raises from unittest import TestCase from ycmd.tests.test_utils import UnixOnly, WindowsOnly @@ -153,6 +154,8 @@ def test_UriToFilePath_Unix( self ): equal_to( '/usr/local/test/test.test' ) ) assert_that( lsp.UriToFilePath( 'file:///usr/local/test/test.test' ), equal_to( '/usr/local/test/test.test' ) ) + assert_that( java_utils.UriToFilePath( 'jdt://contents/Member.class' ), + equal_to( 'jdt://contents/Member.class' ) ) @WindowsOnly @@ -172,18 +175,24 @@ def test_UriToFilePath_Windows( self ): equal_to( 'C:\\usr\\local\\test\\test.test' ) ) assert_that( lsp.UriToFilePath( 'file:///c%3A/usr/local/test/test.test' ), equal_to( 'C:\\usr\\local\\test\\test.test' ) ) + assert_that( java_utils.UriToFilePath( 'jdt://contents/Member.class' ), + equal_to( 'jdt://contents/Member.class' ) ) @UnixOnly def test_FilePathToUri_Unix( self ): assert_that( lsp.FilePathToUri( '/usr/local/test/test.test' ), equal_to( 'file:///usr/local/test/test.test' ) ) + assert_that( java_utils.FilePathToUri( 'jdt://contents/Member.class' ), + equal_to( 'jdt://contents/Member.class' ) ) @WindowsOnly def test_FilePathToUri_Windows( self ): assert_that( lsp.FilePathToUri( 'C:\\usr\\local\\test\\test.test' ), equal_to( 'file:///C:/usr/local/test/test.test' ) ) + assert_that( java_utils.FilePathToUri( 'jdt://contents/Member.class' ), + equal_to( 'jdt://contents/Member.class' ) ) def test_CodepointsToUTF16CodeUnitsAndReverse( self ):