diff --git a/python/ycm/client/base_request.py b/python/ycm/client/base_request.py index 7932bca4..ddd2b2c8 100644 --- a/python/ycm/client/base_request.py +++ b/python/ycm/client/base_request.py @@ -24,15 +24,11 @@ from retries import retries from requests_futures.sessions import FuturesSession from concurrent.futures import ThreadPoolExecutor from ycm import vimsupport +from ycm.server.responses import ServerError, UnknownExtraConf HEADERS = {'content-type': 'application/json'} EXECUTOR = ThreadPoolExecutor( max_workers = 4 ) -class ServerError( Exception ): - def __init__( self, message ): - super( ServerError, self ).__init__( message ) - - class BaseRequest( object ): def __init__( self ): pass @@ -103,7 +99,7 @@ def BuildRequestData( start_column = None, query = None ): def JsonFromFuture( future ): response = future.result() if response.status_code == requests.codes.server_error: - raise ServerError( response.json()[ 'message' ] ) + _RaiseExceptionForData( response.json() ) # We let Requests handle the other status types, we only handle the 500 # error code. @@ -137,3 +133,10 @@ def _CheckServerIsHealthyWithCache(): except: return False + +def _RaiseExceptionForData( data ): + if data[ 'exception' ][ 'TYPE' ] == UnknownExtraConf.__name__: + raise UnknownExtraConf( data[ 'exception' ][ 'extra_conf_file' ] ) + + raise ServerError( '{0}: {1}'.format( data[ 'exception' ][ 'TYPE' ], + data[ 'message' ] ) ) diff --git a/python/ycm/client/completion_request.py b/python/ycm/client/completion_request.py index 977a1122..e8af21a7 100644 --- a/python/ycm/client/completion_request.py +++ b/python/ycm/client/completion_request.py @@ -57,7 +57,7 @@ class CompletionRequest( BaseRequest ): for x in JsonFromFuture( self._response_future ) ] except Exception as e: vimsupport.PostVimMessage( str( e ) ) - return [] + return [] def _ConvertCompletionDataToVimData( completion_data ): diff --git a/python/ycm/client/event_notification.py b/python/ycm/client/event_notification.py index 5f79b4a5..9652413f 100644 --- a/python/ycm/client/event_notification.py +++ b/python/ycm/client/event_notification.py @@ -18,9 +18,11 @@ # along with YouCompleteMe. If not, see . from ycm import vimsupport +from ycm.server.responses import UnknownExtraConf from ycm.client.base_request import ( BaseRequest, BuildRequestData, JsonFromFuture ) + class EventNotification( BaseRequest ): def __init__( self, event_name, extra_data = None ): super( EventNotification, self ).__init__() @@ -51,10 +53,13 @@ class EventNotification( BaseRequest ): return [] try: - self._cached_response = JsonFromFuture( self._response_future ) + try: + self._cached_response = JsonFromFuture( self._response_future ) + except UnknownExtraConf as e: + if vimsupport.Confirm( str( e ) ): + _LoadExtraConfFile( e.extra_conf_file ) except Exception as e: vimsupport.PostVimMessage( str( e ) ) - return [] if not self._cached_response: return [] @@ -83,3 +88,6 @@ def SendEventNotificationAsync( event_name, extra_data = None ): event = EventNotification( event_name, extra_data ) event.Start() +def _LoadExtraConfFile( filepath ): + BaseRequest.PostDataToHandler( { 'filepath': filepath }, + 'load_extra_conf_file' ) diff --git a/python/ycm/completers/cpp/clang_completer.py b/python/ycm/completers/cpp/clang_completer.py index 7f50a7ba..154452fa 100644 --- a/python/ycm/completers/cpp/clang_completer.py +++ b/python/ycm/completers/cpp/clang_completer.py @@ -79,6 +79,9 @@ class ClangCompleter( Completer ): if self._completer.UpdatingTranslationUnit( ToUtf8IfNeeded( filename ) ): self._logger.info( PARSING_FILE_MESSAGE ) + # TODO: For this exception and the NO_COMPILE_FLAGS one, use a special + # exception class so that the client can be more silent about these + # messages. raise RuntimeError( PARSING_FILE_MESSAGE ) flags = self._FlagsForRequest( request_data ) diff --git a/python/ycm/completers/cpp/flags.py b/python/ycm/completers/cpp/flags.py index a896986f..385bdc03 100644 --- a/python/ycm/completers/cpp/flags.py +++ b/python/ycm/completers/cpp/flags.py @@ -37,6 +37,7 @@ class Flags( object ): # It's caches all the way down... self.flags_for_file = {} self.special_clang_flags = _SpecialClangIncludes() + self.no_extra_conf_file_warning_posted = False def FlagsForFile( self, filename, add_special_clang_flags = True ): @@ -45,7 +46,10 @@ class Flags( object ): except KeyError: module = extra_conf_store.ModuleForSourceFile( filename ) if not module: - raise RuntimeError( NO_EXTRA_CONF_FILENAME_MESSAGE ) + if not self.no_extra_conf_file_warning_posted: + self.no_extra_conf_file_warning_posted = True + raise RuntimeError( NO_EXTRA_CONF_FILENAME_MESSAGE ) + return None results = module.FlagsForFile( filename ) diff --git a/python/ycm/extra_conf_store.py b/python/ycm/extra_conf_store.py index 277b9d61..5253e021 100644 --- a/python/ycm/extra_conf_store.py +++ b/python/ycm/extra_conf_store.py @@ -24,27 +24,29 @@ import imp import random import string import sys +from threading import Lock from ycm import user_options_store +from ycm.server.responses import UnknownExtraConf from fnmatch import fnmatch # Constants YCM_EXTRA_CONF_FILENAME = '.ycm_extra_conf.py' -CONFIRM_CONF_FILE_MESSAGE = ('Found {0}. Load? \n\n(Question can be turned ' - 'off with options, see YCM docs)') # Singleton variables _module_for_module_file = {} +_module_for_module_file_lock = Lock() _module_file_for_source_file = {} +_module_file_for_source_file_lock = Lock() -class UnknownExtraConf( Exception ): - def __init__( self, extra_conf_file ): - message = CONFIRM_CONF_FILE_MESSAGE.format( extra_conf_file ) - super( UnknownExtraConf, self ).__init__( message ) - self.extra_conf_file = extra_conf_file + +def Reset(): + global _module_for_module_file, _module_file_for_source_file + _module_for_module_file = {} + _module_file_for_source_file = {} def ModuleForSourceFile( filename ): - return _Load( ModuleFileForSourceFile( filename ) ) + return Load( ModuleFileForSourceFile( filename ) ) def ModuleFileForSourceFile( filename ): @@ -52,11 +54,12 @@ def ModuleFileForSourceFile( filename ): order and return the filename of the first module that was allowed to load. If no module was found or allowed to load, None is returned.""" - if not filename in _module_file_for_source_file: - for module_file in _ExtraConfModuleSourceFilesForFile( filename ): - if _Load( module_file ): - _module_file_for_source_file[ filename ] = module_file - break + with _module_file_for_source_file_lock: + if not filename in _module_file_for_source_file: + for module_file in _ExtraConfModuleSourceFilesForFile( filename ): + if Load( module_file ): + _module_file_for_source_file[ filename ] = module_file + break return _module_file_for_source_file.setdefault( filename ) @@ -84,7 +87,8 @@ def _CallExtraConfMethod( function_name ): def _Disable( module_file ): """Disables the loading of a module for the current session.""" - _module_for_module_file[ module_file ] = None + with _module_for_module_file_lock: + _module_for_module_file[ module_file ] = None def _ShouldLoad( module_file ): @@ -102,10 +106,15 @@ def _ShouldLoad( module_file ): if _MatchesGlobPattern( module_file, glob.lstrip('!') ): return not is_blacklisted + # We disable the file if it's unknown so that we don't ask the user about it + # repeatedly. Raising UnknownExtraConf should result in the client sending + # another request to load the module file if the user explicitly chooses to do + # that. + _Disable( module_file ) raise UnknownExtraConf( module_file ) -def _Load( module_file, force = False ): +def Load( module_file, force = False ): """Load and return the module contained in a file. Using force = True the module will be loaded regardless of the criteria in _ShouldLoad. @@ -115,11 +124,13 @@ def _Load( module_file, force = False ): return None if not force: - if module_file in _module_for_module_file: - return _module_for_module_file[ module_file ] + with _module_for_module_file_lock: + if module_file in _module_for_module_file: + return _module_for_module_file[ module_file ] if not _ShouldLoad( module_file ): - return _Disable( module_file ) + _Disable( module_file ) + return None # This has to be here because a long time ago, the ycm_extra_conf.py files # used to import clang_helpers.py from the cpp folder. This is not needed @@ -129,7 +140,8 @@ def _Load( module_file, force = False ): module = imp.load_source( _RandomName(), module_file ) del sys.path[ 0 ] - _module_for_module_file[ module_file ] = module + with _module_for_module_file_lock: + _module_for_module_file[ module_file ] = module return module diff --git a/python/ycm/server/responses.py b/python/ycm/server/responses.py index 450106d9..f0ca6364 100644 --- a/python/ycm/server/responses.py +++ b/python/ycm/server/responses.py @@ -19,6 +19,21 @@ import os +CONFIRM_CONF_FILE_MESSAGE = ('Found {0}. Load? \n\n(Question can be turned ' + 'off with options, see YCM docs)') + +class ServerError( Exception ): + def __init__( self, message ): + super( ServerError, self ).__init__( message ) + + +class UnknownExtraConf( ServerError ): + def __init__( self, extra_conf_file ): + message = CONFIRM_CONF_FILE_MESSAGE.format( extra_conf_file ) + super( UnknownExtraConf, self ).__init__( message ) + self.extra_conf_file = extra_conf_file + + def BuildGoToResponse( filepath, line_num, column_num, description = None ): response = { @@ -80,9 +95,10 @@ def BuildDiagnosticData( filepath, } -def BuildExceptionResponse( error_message, traceback ): +def BuildExceptionResponse( exception, traceback ): return { - 'message': error_message, + 'exception': exception, + 'message': str( exception ), 'traceback': traceback } diff --git a/python/ycm/server/tests/basic_test.py b/python/ycm/server/tests/basic_test.py index 0ba9e1b1..8c2cdc5b 100644 --- a/python/ycm/server/tests/basic_test.py +++ b/python/ycm/server/tests/basic_test.py @@ -17,9 +17,11 @@ # You should have received a copy of the GNU General Public License # along with YouCompleteMe. If not, see . +import os +import httplib from webtest import TestApp from .. import ycmd -from ..responses import BuildCompletionData +from ..responses import BuildCompletionData, UnknownExtraConf from nose.tools import ok_, eq_, with_setup from hamcrest import ( assert_that, has_items, has_entry, contains, contains_string, has_entries ) @@ -73,6 +75,15 @@ def Setup(): ycmd.SetServerStateToDefaults() +def PathToTestDataDir(): + dir_of_current_script = os.path.dirname( os.path.abspath( __file__ ) ) + return os.path.join( dir_of_current_script, 'testdata' ) + + +def PathToTestFile( test_basename ): + return os.path.join( PathToTestDataDir(), test_basename ) + + @with_setup( Setup ) def GetCompletions_IdentifierCompleter_Works_test(): app = TestApp( ycmd.app ) @@ -91,7 +102,7 @@ def GetCompletions_IdentifierCompleter_Works_test(): @with_setup( Setup ) -def GetCompletions_ClangCompleter_Works_test(): +def GetCompletions_ClangCompleter_WorksWithExplicitFlags_test(): app = TestApp( ycmd.app ) contents = """ struct Foo { @@ -122,6 +133,45 @@ int main() CompletionEntryMatcher( 'y' ) ) ) +@with_setup( Setup ) +def GetCompletions_ClangCompleter_UnknownExtraConfException_test(): + app = TestApp( ycmd.app ) + filepath = PathToTestFile( 'basic.cpp' ) + completion_data = BuildRequest( filepath = filepath, + filetype = 'cpp', + contents = open( filepath ).read(), + force_semantic = True ) + + response = app.post_json( '/completions', + completion_data, + expect_errors = True ) + + eq_( response.status_code, httplib.INTERNAL_SERVER_ERROR ) + assert_that( response.json, + has_entry( 'exception', + has_entry( 'TYPE', UnknownExtraConf.__name__ ) ) ) + + +@with_setup( Setup ) +def GetCompletions_ClangCompleter_WorksWhenExtraConfExplicitlyAllowed_test(): + app = TestApp( ycmd.app ) + app.post_json( '/load_extra_conf_file', + { 'filepath': PathToTestFile( '.ycm_extra_conf.py' ) } ) + + filepath = PathToTestFile( 'basic.cpp' ) + completion_data = BuildRequest( filepath = filepath, + filetype = 'cpp', + contents = open( filepath ).read(), + line_num = 10, + column_num = 6, + start_column = 6 ) + + results = app.post_json( '/completions', completion_data ).json + assert_that( results, has_items( CompletionEntryMatcher( 'c' ), + CompletionEntryMatcher( 'x' ), + CompletionEntryMatcher( 'y' ) ) ) + + @with_setup( Setup ) def GetCompletions_ForceSemantic_Works_test(): app = TestApp( ycmd.app ) diff --git a/python/ycm/server/tests/testdata/basic.cpp b/python/ycm/server/tests/testdata/basic.cpp new file mode 100644 index 00000000..a20e2dd0 --- /dev/null +++ b/python/ycm/server/tests/testdata/basic.cpp @@ -0,0 +1,13 @@ +struct Foo { + int x; + int y; + char c; +}; + +int main() +{ + Foo foo; + // The location after the dot is line 11, col 7 + foo. +} + diff --git a/python/ycm/server/ycmd.py b/python/ycm/server/ycmd.py index 889b2b7d..0619c193 100755 --- a/python/ycm/server/ycmd.py +++ b/python/ycm/server/ycmd.py @@ -36,17 +36,19 @@ utils.AddThirdPartyFoldersToSysPath() import logging import json import bottle +import argparse +import httplib from bottle import run, request, response import server_state from ycm import user_options_store from ycm.server.responses import BuildExceptionResponse -import argparse -import httplib +from ycm import extra_conf_store # num bytes for the request body buffer; request.json only works if the request # size is less than this bottle.Request.MEMFILE_MAX = 300 * 1024 +# TODO: rename these to _lower_case SERVER_STATE = None LOGGER = None app = bottle.Bottle() @@ -72,7 +74,6 @@ def EventNotification(): return _JsonResponse( response_data ) - @app.post( '/run_completer_command' ) def RunCompleterCommand(): LOGGER.info( 'Received command request' ) @@ -141,6 +142,13 @@ def GetDetailedDiagnostic(): return _JsonResponse( completer.GetDetailedDiagnostic( request_data ) ) +@app.post( '/load_extra_conf_file' ) +def LoadExtraConfFile(): + LOGGER.info( 'Received extra conf load request' ) + request_data = request.json + extra_conf_store.Load( request_data[ 'filepath' ], force = True ) + + @app.post( '/debug_info' ) def DebugInfo(): # This can't be at the top level because of possible extra conf preload @@ -167,13 +175,19 @@ def DebugInfo(): # The type of the param is Bottle.HTTPError @app.error( httplib.INTERNAL_SERVER_ERROR ) def ErrorHandler( httperror ): - return _JsonResponse( BuildExceptionResponse( str( httperror.exception ), + return _JsonResponse( BuildExceptionResponse( httperror.exception, httperror.traceback ) ) def _JsonResponse( data ): response.set_header( 'Content-Type', 'application/json' ) - return json.dumps( data ) + return json.dumps( data, default = _UniversalSerialize ) + + +def _UniversalSerialize( obj ): + serialized = obj.__dict__.copy() + serialized[ 'TYPE' ] = type( obj ).__name__ + return serialized def _GetCompleterForRequestData( request_data ): @@ -205,6 +219,7 @@ def SetServerStateToDefaults(): LOGGER = logging.getLogger( __name__ ) user_options_store.LoadDefaults() SERVER_STATE = server_state.ServerState( user_options_store.GetAll() ) + extra_conf_store.Reset() def Main():