diff --git a/ycmd/completers/cpp/flags.py b/ycmd/completers/cpp/flags.py index 0aa2ca450b..79cb31b403 100644 --- a/ycmd/completers/cpp/flags.py +++ b/ycmd/completers/cpp/flags.py @@ -27,7 +27,8 @@ import inspect from future.utils import PY2, native from ycmd import extra_conf_store -from ycmd.utils import ( OnMac, +from ycmd.utils import ( AbsoluatePath, + OnMac, OnWindows, PathsToAllParentFolders, re, @@ -636,9 +637,7 @@ def _MakeRelativePathsInFlagsAbsolute( flags, working_directory ): if make_next_absolute: make_next_absolute = False - if not os.path.isabs( new_flag ): - new_flag = os.path.join( working_directory, flag ) - new_flag = os.path.normpath( new_flag ) + new_flag = AbsoluatePath( flag, working_directory ) else: for path_flag in path_flags: # Single dash argument alone, e.g. -isysroot @@ -650,10 +649,7 @@ def _MakeRelativePathsInFlagsAbsolute( flags, working_directory ): # or double-dash argument, e.g. --isysroot= if flag.startswith( path_flag ): path = flag[ len( path_flag ): ] - if not os.path.isabs( path ): - path = os.path.join( working_directory, path ) - path = os.path.normpath( path ) - + path = AbsoluatePath( path, working_directory ) new_flag = '{0}{1}'.format( path_flag, path ) break diff --git a/ycmd/completers/go/go_completer.py b/ycmd/completers/go/go_completer.py index 2e5577e45a..03f1a1e143 100644 --- a/ycmd/completers/go/go_completer.py +++ b/ycmd/completers/go/go_completer.py @@ -84,7 +84,7 @@ def SupportedFiletypes( self ): def GetDoc( self, request_data ): - assert self._settings[ 'hoverKind' ] == 'Structured' + assert self._settings[ 'ls' ][ 'hoverKind' ] == 'Structured' try: result = json.loads( self.GetHoverResponse( request_data )[ 'value' ] ) docs = result[ 'signature' ] + '\n' + result[ 'fullDocumentation' ] @@ -103,5 +103,7 @@ def GetType( self, request_data ): def DefaultSettings( self, request_data ): - return { 'hoverKind': 'Structured', - 'fuzzyMatching': False } + return { + 'hoverKind': 'Structured', + 'fuzzyMatching': False, + } diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 46e4d3bacb..8c5dea4265 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -448,6 +448,10 @@ def StartServer( self, if project_directory: self._java_project_dir = project_directory + elif 'project_directory' in self._settings: + self._java_project_dir = utils.AbsoluatePath( + self._settings[ 'project_directory' ], + self._extra_conf_dir ) else: self._java_project_dir = _FindProjectDir( os.path.dirname( request_data[ 'filepath' ] ) ) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 99314b3371..5e350bff25 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -815,6 +815,7 @@ def ServerReset( self ): self._resolve_completion_items = False self._project_directory = None self._settings = {} + self._extra_conf_dir = None self._server_started = False @@ -1283,6 +1284,10 @@ def GetSettings( self, module, request_data ): def _GetSettingsFromExtraConf( self, request_data ): + # The default settings method returns only the 'language server" ('ls') + # settings, but self._settings is a wider dict containing a 'ls' key and any + # other keys that we might want to add (e.g. 'project_directory', + # 'capabilities', etc.) ls = self.DefaultSettings( request_data ) # In case no module is found self._settings = { @@ -1292,6 +1297,7 @@ def _GetSettingsFromExtraConf( self, request_data ): module = extra_conf_store.ModuleForSourceFile( request_data[ 'filepath' ] ) if module: self._settings = self.GetSettings( module, request_data ) + # Overlay the supplied language server ('ls') settings on the defaults ls.update( self._settings.get( 'ls', {} ) ) self._settings[ 'ls' ] = ls @@ -1302,6 +1308,7 @@ def _GetSettingsFromExtraConf( self, request_data ): os.path.dirname( module.__file__ ) ) return os.path.dirname( module.__file__ ) + # No local extra conf return None @@ -1311,14 +1318,14 @@ def _StartAndInitializeServer( self, request_data, *args, **kwargs ): StartServer. In general, completers don't need to call this as it is called automatically in OnFileReadyToParse, but this may be used in completer subcommands that require restarting the underlying server.""" - extra_conf_dir = self._GetSettingsFromExtraConf( request_data ) + self._extra_conf_dir = self._GetSettingsFromExtraConf( request_data ) # Only attempt to start the server once. Set this after above call as it may # throw an exception self._server_started = True if self.StartServer( request_data, *args, **kwargs ): - self._SendInitialize( request_data, extra_conf_dir ) + self._SendInitialize( request_data ) def OnFileReadyToParse( self, request_data ): @@ -1659,18 +1666,29 @@ def GetProjectRootFiles( self ): GetProjectDirectory.""" return [] - def GetProjectDirectory( self, request_data, extra_conf_dir ): + + def GetProjectDirectory( self, request_data ): """Return the directory in which the server should operate. Language server protocol and most servers have a concept of a 'project directory'. Where a concrete completer can detect this better, it should override this method, but otherwise, we default as follows: + - If the user specified 'project_directory' in their extra conf + 'Settings', use that. - try to find files from GetProjectRootFiles and use the first directory from there - if there's an extra_conf file, use that directory - otherwise if we know the client's cwd, use that - otherwise use the diretory of the file that we just opened Note: None of these are ideal. Ycmd doesn't really have a notion of project - directory and therefore neither do any of our clients.""" + directory and therefore neither do any of our clients. + + NOTE: Must be called _after_ _GetSettingsFromExtraConf, as it uses + self._settings and self._extra_conf_dir + """ + + if 'project_directory' in self._settings: + return utils.AbsoluatePath( self._settings[ 'project_directory' ], + self._extra_conf_dir ) project_root_files = self.GetProjectRootFiles() if project_root_files: @@ -1679,8 +1697,8 @@ def GetProjectDirectory( self, request_data, extra_conf_dir ): if os.path.isfile( os.path.join( folder, root_file ) ): return folder - if extra_conf_dir: - return extra_conf_dir + if self._extra_conf_dir: + return self._extra_conf_dir if 'working_dir' in request_data: return request_data[ 'working_dir' ] @@ -1688,21 +1706,20 @@ def GetProjectDirectory( self, request_data, extra_conf_dir ): return os.path.dirname( request_data[ 'filepath' ] ) - def _SendInitialize( self, request_data, extra_conf_dir ): + def _SendInitialize( self, request_data ): """Sends the initialize request asynchronously. This must be called immediately after establishing the connection with the language server. Implementations must not issue further requests to the server until the initialize exchange has completed. This can be detected by calling this class's implementation of _ServerIsInitialized. - The extra_conf_dir parameter is the value returned from - _GetSettingsFromExtraConf, which must be called before calling this method. + _GetSettingsFromExtraConf must be called before calling this method, as this + method release on self._extra_conf_dir. It is called before starting the server in OnFileReadyToParse.""" with self._server_info_mutex: assert not self._initialize_response - self._project_directory = self.GetProjectDirectory( request_data, - extra_conf_dir ) + self._project_directory = self.GetProjectDirectory( request_data ) request_id = self.GetConnection().NextRequestId() # FIXME: According to the discussion on @@ -2073,6 +2090,10 @@ def RefactorRename( self, request_data, args ): def AdditionalFormattingOptions( self, request_data ): + # While we have the settings in self._settings[ 'formatting_options' ], we + # actually run Settings again here, which allows users to have different + # formatting options for different files etc. if they should decide that's + # appropriate. module = extra_conf_store.ModuleForSourceFile( request_data[ 'filepath' ] ) try: settings = self.GetSettings( module, request_data ) @@ -2200,10 +2221,11 @@ def ServerStateDescription(): ServerStateDescription() ), responses.DebugInfoItem( 'Project Directory', self._project_directory ), - responses.DebugInfoItem( 'Settings', - json.dumps( self._settings, - indent = 2, - sort_keys = True ) ) ] + responses.DebugInfoItem( + 'Settings', + json.dumps( self._settings.get( 'ls', {} ), + indent = 2, + sort_keys = True ) ) ] def _DistanceOfPointToRange( point, range ): diff --git a/ycmd/tests/java/__init__.py b/ycmd/tests/java/__init__.py index e231302820..decb44c659 100644 --- a/ycmd/tests/java/__init__.py +++ b/ycmd/tests/java/__init__.py @@ -67,10 +67,15 @@ def tearDownPackage(): def StartJavaCompleterServerInDirectory( app, directory ): + StartJavaCompleterServerWithFile( app, + os.path.join( directory, 'test.java' ) ) + + +def StartJavaCompleterServerWithFile( app, file_path ): app.post_json( '/event_notification', BuildRequest( - filepath = os.path.join( directory, 'test.java' ), event_name = 'FileReadyToParse', + filepath = file_path, filetype = 'java' ) ) WaitUntilCompleterServerReady( app, 'java', SERVER_STARTUP_TIMEOUT ) diff --git a/ycmd/tests/java/server_management_test.py b/ycmd/tests/java/server_management_test.py index 166daadcad..cf4e5138a1 100644 --- a/ycmd/tests/java/server_management_test.py +++ b/ycmd/tests/java/server_management_test.py @@ -34,7 +34,8 @@ from ycmd.tests.java import ( PathToTestFile, IsolatedYcmd, SharedYcmd, - StartJavaCompleterServerInDirectory ) + StartJavaCompleterServerInDirectory, + StartJavaCompleterServerWithFile ) from ycmd.tests.test_utils import ( BuildRequest, CompleterProjectDirectoryMatcher, ErrorMatcher, @@ -192,6 +193,29 @@ def ServerManagement_WipeWorkspace_WithConfig( app ): yield ServerManagement_WipeWorkspace_WithConfig +@IsolatedYcmd( { + 'extra_conf_globlist': PathToTestFile( 'multiple_projects', '*' ) +} ) +def ServerManagement_ProjectDetection_MultipleProjects_test( app ): + # The ycm_extra_conf.py file should set the project path to + # multiple_projects/src + project = PathToTestFile( 'multiple_projects', 'src' ) + StartJavaCompleterServerWithFile( app, + os.path.join( project, + 'core', + 'java', + 'com', + 'puremourning', + 'widget', + 'core', + 'Utils.java' ) ) + + # Run the debug info to check that we have the correct project dir + request_data = BuildRequest( filetype = 'java' ) + assert_that( app.post_json( '/debug_info', request_data ).json, + CompleterProjectDirectoryMatcher( project ) ) + + @IsolatedYcmd() def ServerManagement_ProjectDetection_EclipseParent_test( app ): StartJavaCompleterServerInDirectory( diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py index 1fde44b36b..9506f0a694 100644 --- a/ycmd/tests/java/subcommands_test.py +++ b/ycmd/tests/java/subcommands_test.py @@ -42,6 +42,7 @@ from ycmd.tests.java import ( DEFAULT_PROJECT_DIR, PathToTestFile, SharedYcmd, + StartJavaCompleterServerWithFile, IsolatedYcmd ) from ycmd.tests.test_utils import ( BuildRequest, ChunkMatcher, @@ -548,6 +549,52 @@ def Subcommands_GoToReferences_NoReferences_test( app ): 'Cannot jump to location' ) ) +@WithRetry +@IsolatedYcmd( { + 'extra_conf_globlist': PathToTestFile( 'multiple_projects', '*' ) +} ) +def Subcommands_GoToReferences_MultipleProjects_test( app ): + filepath = PathToTestFile( 'multiple_projects', + 'src', + 'core', + 'java', + 'com', + 'puremourning', + 'widget', + 'core', + 'Utils.java' ) + StartJavaCompleterServerWithFile( app, filepath ) + + + RunTest( app, { + 'description': 'GoToReferences works across multiple projects', + 'request': { + 'command': 'GoToReferences', + 'filepath': filepath, + 'line_num': 5, + 'column_num': 22, + }, + 'expect': { + 'response': requests.codes.ok, + 'data': contains_inanyorder( + LocationMatcher( filepath, 8, 35 ), + LocationMatcher( PathToTestFile( 'multiple_projects', + 'src', + 'input', + 'java', + 'com', + 'puremourning', + 'widget', + 'input', + 'InputApp.java' ), + 8, + 16 ) + ) + } + } ) + + + @WithRetry @SharedYcmd def Subcommands_GoToReferences_test( app ): diff --git a/ycmd/tests/java/testdata/multiple_projects/.ycm_extra_conf.py b/ycmd/tests/java/testdata/multiple_projects/.ycm_extra_conf.py new file mode 100644 index 0000000000..6d191c2606 --- /dev/null +++ b/ycmd/tests/java/testdata/multiple_projects/.ycm_extra_conf.py @@ -0,0 +1,4 @@ +def Settings( **kwargs ): + return { + 'project_directory': './src' + } diff --git a/ycmd/tests/java/testdata/multiple_projects/src/core/.classpath b/ycmd/tests/java/testdata/multiple_projects/src/core/.classpath new file mode 100644 index 0000000000..3854421f09 --- /dev/null +++ b/ycmd/tests/java/testdata/multiple_projects/src/core/.classpath @@ -0,0 +1,6 @@ + + + + + + diff --git a/ycmd/tests/java/testdata/multiple_projects/src/core/.project b/ycmd/tests/java/testdata/multiple_projects/src/core/.project new file mode 100644 index 0000000000..1fa57e7113 --- /dev/null +++ b/ycmd/tests/java/testdata/multiple_projects/src/core/.project @@ -0,0 +1,23 @@ + + + + com.puremourning.widget.core + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/ycmd/tests/java/testdata/multiple_projects/src/core/java/com/puremourning/widget/core/Utils.java b/ycmd/tests/java/testdata/multiple_projects/src/core/java/com/puremourning/widget/core/Utils.java new file mode 100644 index 0000000000..0acbbfa4bf --- /dev/null +++ b/ycmd/tests/java/testdata/multiple_projects/src/core/java/com/puremourning/widget/core/Utils.java @@ -0,0 +1,10 @@ +package com.puremourning.widget.core; + +public class Utils +{ + public static int Test = 100; + + public static void DoSomething() { + System.out.println( "test " + Test ); + } +} diff --git a/ycmd/tests/java/testdata/multiple_projects/src/input/.classpath b/ycmd/tests/java/testdata/multiple_projects/src/input/.classpath new file mode 100644 index 0000000000..67fa04b4bd --- /dev/null +++ b/ycmd/tests/java/testdata/multiple_projects/src/input/.classpath @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ycmd/tests/java/testdata/multiple_projects/src/input/.project b/ycmd/tests/java/testdata/multiple_projects/src/input/.project new file mode 100644 index 0000000000..b61c3c700e --- /dev/null +++ b/ycmd/tests/java/testdata/multiple_projects/src/input/.project @@ -0,0 +1,23 @@ + + + + com.puremourning.widget.input + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/ycmd/tests/java/testdata/multiple_projects/src/input/java/com/puremourning/widget/input/InputApp.java b/ycmd/tests/java/testdata/multiple_projects/src/input/java/com/puremourning/widget/input/InputApp.java new file mode 100644 index 0000000000..3738248cda --- /dev/null +++ b/ycmd/tests/java/testdata/multiple_projects/src/input/java/com/puremourning/widget/input/InputApp.java @@ -0,0 +1,12 @@ +package com.puremourning.widget.input; + +import com.puremourning.widget.core.Utils; + +public class InputApp { + public static void main( String[] args ) { + Utils.DoSomething(); + if ( Utils.Test == 1 ) { + + } + } +} diff --git a/ycmd/tests/language_server/language_server_completer_test.py b/ycmd/tests/language_server/language_server_completer_test.py index fe745d223e..ca5024967e 100644 --- a/ycmd/tests/language_server/language_server_completer_test.py +++ b/ycmd/tests/language_server/language_server_completer_test.py @@ -108,10 +108,10 @@ def LanguageServerCompleter_ExtraConf_ServerReset_test( app ): completer.OnFileReadyToParse( request_data ) assert_that( completer._project_directory, is_not( None ) ) - assert_that( completer._settings, is_not( empty() ) ) + assert_that( completer._settings.get( 'ls', {} ), is_not( empty() ) ) completer.ServerReset() - assert_that( completer._settings, empty() ) + assert_that( completer._settings.get( 'ls', {} ), empty() ) eq_( None, completer._project_directory ) @@ -125,7 +125,7 @@ def LanguageServerCompleter_ExtraConf_FileEmpty_test( app ): filetype = 'ycmtest', contents = '' ) ) completer.OnFileReadyToParse( request_data ) - eq_( {}, completer._settings ) + eq_( {}, completer._settings.get( 'ls', {} ) ) # Simulate receipt of response and initialization complete initialize_response = { @@ -134,7 +134,7 @@ def LanguageServerCompleter_ExtraConf_FileEmpty_test( app ): } } completer._HandleInitializeInPollThread( initialize_response ) - eq_( {}, completer._settings ) + eq_( {}, completer._settings.get( 'ls', {} ) ) # We shouldn't have used the extra_conf path for the project directory, but # that _also_ happens to be the path of the file we opened. eq_( PathToTestFile( 'extra_confs' ), completer._project_directory ) @@ -151,7 +151,7 @@ def LanguageServerCompleter_ExtraConf_SettingsReturnsNone_test( app ): filetype = 'ycmtest', contents = '' ) ) completer.OnFileReadyToParse( request_data ) - eq_( {}, completer._settings ) + eq_( {}, completer._settings.get( 'ls', {} ) ) # We shouldn't have used the extra_conf path for the project directory, but # that _also_ happens to be the path of the file we opened. eq_( PathToTestFile( 'extra_confs' ), completer._project_directory ) @@ -168,9 +168,9 @@ def LanguageServerCompleter_ExtraConf_SettingValid_test( app ): working_dir = PathToTestFile(), contents = '' ) ) - eq_( {}, completer._settings ) + eq_( {}, completer._settings.get( 'ls', {} ) ) completer.OnFileReadyToParse( request_data ) - eq_( { 'java.rename.enabled' : False }, completer._settings ) + eq_( { 'java.rename.enabled' : False }, completer._settings.get( 'ls', {} ) ) # We use the working_dir not the path to the global extra conf (which is # ignored) eq_( PathToTestFile(), completer._project_directory ) @@ -186,9 +186,9 @@ def LanguageServerCompleter_ExtraConf_NoExtraConf_test( app ): working_dir = PathToTestFile(), contents = '' ) ) - eq_( {}, completer._settings ) + eq_( {}, completer._settings.get( 'ls', {} ) ) completer.OnFileReadyToParse( request_data ) - eq_( {}, completer._settings ) + eq_( {}, completer._settings.get( 'ls', {} ) ) # Simulate receipt of response and initialization complete initialize_response = { @@ -197,7 +197,7 @@ def LanguageServerCompleter_ExtraConf_NoExtraConf_test( app ): } } completer._HandleInitializeInPollThread( initialize_response ) - eq_( {}, completer._settings ) + eq_( {}, completer._settings.get( 'ls', {} ) ) # We use the client working directory eq_( PathToTestFile(), completer._project_directory ) @@ -215,9 +215,9 @@ def LanguageServerCompleter_ExtraConf_NonGlobal_test( app ): working_dir = 'ignore_this', contents = '' ) ) - eq_( {}, completer._settings ) + eq_( {}, completer._settings.get( 'ls', {} ) ) completer.OnFileReadyToParse( request_data ) - eq_( { 'java.rename.enabled' : False }, completer._settings ) + eq_( { 'java.rename.enabled' : False }, completer._settings.get( 'ls', {} ) ) # Simulate receipt of response and initialization complete initialize_response = { diff --git a/ycmd/utils.py b/ycmd/utils.py index 90aed0564b..d4b3885db3 100644 --- a/ycmd/utils.py +++ b/ycmd/utils.py @@ -685,7 +685,7 @@ def UpdateDict( target, override ): dict.update, but recursive. i.e. if the existing element is a dict, then override elements of the sub-dict rather than wholesale replacing. - e.g. + e.g. UpdateDict( { @@ -710,7 +710,6 @@ def UpdateDict( target, override ): } """ - for key, value in iteritems( override ): current_value = target.get( key ) if not isinstance( current_value, collections_abc.Mapping ): @@ -721,3 +720,12 @@ def UpdateDict( target, override ): target[ key ] = value return target + + +def AbsoluatePath( path, relative_to ): + """Returns a normalised, absoluate path to |path|. If |path| is relative, it + is resolved relative to |relative_to|.""" + if not os.path.isabs( path ): + path = os.path.join( relative_to, path ) + + return os.path.normpath( path )