diff --git a/autoload/youcompleteme.vim b/autoload/youcompleteme.vim index 386e4edb..6d1ecd5d 100644 --- a/autoload/youcompleteme.vim +++ b/autoload/youcompleteme.vim @@ -41,6 +41,10 @@ let s:pollers = { \ 'server_ready': { \ 'id': -1, \ 'wait_milliseconds': 100 + \ }, + \ 'receive_messages': { + \ 'id': -1, + \ 'wait_milliseconds': 100 \ } \ } @@ -71,6 +75,29 @@ function! s:Pyeval( eval_string ) endfunction +function! s:StartMessagePoll() + if s:pollers.receive_messages.id < 0 + let s:pollers.receive_messages.id = timer_start( + \ s:pollers.receive_messages.wait_milliseconds, + \ function( 's:ReceiveMessages' ) ) + endif +endfunction + + +function! s:ReceiveMessages( timer_id ) + let poll_again = s:Pyeval( 'ycm_state.OnPeriodicTick()' ) + + if poll_again + let s:pollers.receive_messages.id = timer_start( + \ s:pollers.receive_messages.wait_milliseconds, + \ function( 's:ReceiveMessages' ) ) + else + " Don't poll again until we open another buffer + let s:pollers.receive_messages.id = -1 + endif +endfunction + + function! youcompleteme#Enable() call s:SetUpBackwardsCompatibility() @@ -451,6 +478,7 @@ function! s:OnFileTypeSet() call s:SetUpCompleteopt() call s:SetCompleteFunc() + call s:StartMessagePoll() exec s:python_command "ycm_state.OnBufferVisit()" call s:OnFileReadyToParse( 1 ) @@ -464,6 +492,7 @@ function! s:OnBufferEnter() call s:SetUpCompleteopt() call s:SetCompleteFunc() + call s:StartMessagePoll() exec s:python_command "ycm_state.OnBufferVisit()" " Last parse may be outdated because of changes from other buffers. Force a @@ -801,6 +830,10 @@ endfunction function! s:RestartServer() exec s:python_command "ycm_state.RestartServer()" + + call timer_stop( s:pollers.receive_messages.id ) + let s:pollers.receive_messages.id = -1 + call timer_stop( s:pollers.server_ready.id ) let s:pollers.server_ready.id = timer_start( \ s:pollers.server_ready.wait_milliseconds, @@ -828,11 +861,11 @@ endfunction function! s:CompleterCommand(...) - " CompleterCommand will call the OnUserCommand function of a completer. - " If the first arguments is of the form "ft=..." it can be used to specify the - " completer to use (for example "ft=cpp"). Else the native filetype completer - " of the current buffer is used. If no native filetype completer is found and - " no completer was specified this throws an error. You can use + " CompleterCommand will call the OnUserCommand function of a completer. If + " the first arguments is of the form "ft=..." it can be used to specify the + " completer to use (for example "ft=cpp"). Else the native filetype + " completer of the current buffer is used. If no native filetype completer + " is found and no completer was specified this throws an error. You can use " "ft=ycm:ident" to select the identifier completer. " The remaining arguments will be passed to the completer. let arguments = copy(a:000) diff --git a/python/ycm/buffer.py b/python/ycm/buffer.py index 111a140a..f87cbc9b 100644 --- a/python/ycm/buffer.py +++ b/python/ycm/buffer.py @@ -27,17 +27,23 @@ from ycm.client.event_notification import EventNotification from ycm.diagnostic_interface import DiagnosticInterface +DIAGNOSTIC_UI_FILETYPES = set( [ 'cpp', 'cs', 'c', 'objc', 'objcpp', + 'typescript' ] ) +DIAGNOSTIC_UI_ASYNC_FILETYPES = set( [ 'java' ] ) + + # Emulates Vim buffer # Used to store buffer related information like diagnostics, latest parse # request. Stores buffer change tick at the parse request moment, allowing # to effectively determine whether reparse is needed for the buffer. class Buffer( object ): - def __init__( self, bufnr, user_options ): + def __init__( self, bufnr, user_options, async_diags ): self.number = bufnr self._parse_tick = 0 self._handled_tick = 0 self._parse_request = None + self._async_diags = async_diags self._diag_interface = DiagnosticInterface( bufnr, user_options ) @@ -60,9 +66,18 @@ class Buffer( object ): return self._parse_tick != self._ChangedTick() - def UpdateDiagnostics( self ): - self._diag_interface.UpdateWithNewDiagnostics( - self._parse_request.Response() ) + def UpdateDiagnostics( self, force=False ): + if force or not self._async_diags: + self.UpdateWithNewDiagnostics( self._parse_request.Response() ) + else: + # We need to call the response method, because it might throw an exception + # or require extra config confirmation, even if we don't actually use the + # diagnostics. + self._parse_request.Response() + + + def UpdateWithNewDiagnostics( self, diagnostics ): + self._diag_interface.UpdateWithNewDiagnostics( diagnostics ) def PopulateLocationList( self ): @@ -105,5 +120,11 @@ class BufferDict( dict ): def __missing__( self, key ): # Python does not allow to return assignment operation result directly - new_value = self[ key ] = Buffer( key, self._user_options ) + new_value = self[ key ] = Buffer( + key, + self._user_options, + any( [ x in DIAGNOSTIC_UI_ASYNC_FILETYPES + for x in + vimsupport.GetBufferFiletypes( key ) ] ) ) + return new_value diff --git a/python/ycm/client/messages_request.py b/python/ycm/client/messages_request.py new file mode 100644 index 00000000..12c87ad8 --- /dev/null +++ b/python/ycm/client/messages_request.py @@ -0,0 +1,97 @@ +# Copyright (C) 2017 YouCompleteMe contributors +# +# This file is part of YouCompleteMe. +# +# YouCompleteMe 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. +# +# YouCompleteMe 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 YouCompleteMe. If not, see . + +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +from ycm.vimsupport import PostVimMessage + +from ycm.client.base_request import ( BaseRequest, BuildRequestData, + JsonFromFuture, HandleServerException ) + +import logging + +_logger = logging.getLogger( __name__ ) + +# Looooong poll +TIMEOUT_SECONDS = 60 + + +class MessagesPoll( BaseRequest ): + def __init__( self ): + super( MessagesPoll, self ).__init__() + self._request_data = BuildRequestData() + self._response_future = None + + + def _SendRequest( self ): + self._response_future = self.PostDataToHandlerAsync( + self._request_data, + 'receive_messages', + timeout = TIMEOUT_SECONDS ) + return + + + def Poll( self, diagnostics_handler ): + """This should be called regularly to check for new messages in this buffer. + Returns True if Poll should be called again in a while. Returns False when + the completer or server indicated that further polling should not be done + for the requested file.""" + + if self._response_future is None: + # First poll + self._SendRequest() + return True + + if not self._response_future.done(): + # Nothing yet... + return True + + with HandleServerException( display = False ): + response = JsonFromFuture( self._response_future ) + + poll_again = _HandlePollResponse( response, diagnostics_handler ) + if poll_again: + self._SendRequest() + return True + + return False + + +def _HandlePollResponse( response, diagnostics_handler ): + if isinstance( response, list ): + for notification in response: + if 'message' in notification: + PostVimMessage( notification[ 'message' ], + warning = False, + truncate = True ) + elif 'diagnostics' in notification: + diagnostics_handler.UpdateWithNewDiagnosticsForFile( + notification[ 'filepath' ], + notification[ 'diagnostics' ] ) + elif response is False: + # Don't keep polling for this file + return False + # else any truthy response means "nothing to see here; poll again in a + # while" + + # Start the next poll (only if the last poll didn't raise an exception) + return True diff --git a/python/ycm/diagnostic_interface.py b/python/ycm/diagnostic_interface.py index 6c2b2a60..969b2ea3 100644 --- a/python/ycm/diagnostic_interface.py +++ b/python/ycm/diagnostic_interface.py @@ -123,7 +123,8 @@ class DiagnosticInterface( object ): def _UpdateLocationList( self ): - vimsupport.SetLocationList( + vimsupport.SetLocationListForBuffer( + self._bufnr, vimsupport.ConvertDiagnosticsToQfList( self._diagnostics ) ) diff --git a/python/ycm/tests/client/messages_request_test.py b/python/ycm/tests/client/messages_request_test.py new file mode 100644 index 00000000..40f0c700 --- /dev/null +++ b/python/ycm/tests/client/messages_request_test.py @@ -0,0 +1,142 @@ +# Copyright (C) 2017 YouCompleteMe Contributors +# +# This file is part of YouCompleteMe. +# +# YouCompleteMe 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. +# +# YouCompleteMe 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 YouCompleteMe. If not, see . + + +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +from ycm.tests.test_utils import MockVimModule +MockVimModule() + +from hamcrest import assert_that, equal_to +from mock import patch, call + +from ycm.client.messages_request import _HandlePollResponse +from ycm.tests.test_utils import ExtendedMock + + +def HandlePollResponse_NoMessages_test(): + assert_that( _HandlePollResponse( True, None ), equal_to( True ) ) + + # Other non-False responses mean the same thing + assert_that( _HandlePollResponse( '', None ), equal_to( True ) ) + assert_that( _HandlePollResponse( 1, None ), equal_to( True ) ) + assert_that( _HandlePollResponse( {}, None ), equal_to( True ) ) + + +def HandlePollResponse_PollingNotSupported_test(): + assert_that( _HandlePollResponse( False, None ), equal_to( False ) ) + + # 0 is not False + assert_that( _HandlePollResponse( 0, None ), equal_to( True ) ) + + +@patch( 'ycm.client.messages_request.PostVimMessage', + new_callable = ExtendedMock ) +def HandlePollResponse_SingleMessage_test( post_vim_message ): + assert_that( _HandlePollResponse( [ { 'message': 'this is a message' } ] , + None ), + equal_to( True ) ) + + post_vim_message.assert_has_exact_calls( [ + call( 'this is a message', warning=False, truncate=True ) + ] ) + + +@patch( 'ycm.client.messages_request.PostVimMessage', + new_callable = ExtendedMock ) +def HandlePollResponse_MultipleMessages_test( post_vim_message ): + assert_that( _HandlePollResponse( [ { 'message': 'this is a message' }, + { 'message': 'this is another one' } ] , + None ), + equal_to( True ) ) + + post_vim_message.assert_has_exact_calls( [ + call( 'this is a message', warning=False, truncate=True ), + call( 'this is another one', warning=False, truncate=True ) + ] ) + + +def HandlePollResponse_SingleDiagnostic_test(): + diagnostics_handler = ExtendedMock() + messages = [ + { 'filepath': 'foo', 'diagnostics': [ 'PLACEHOLDER' ] }, + ] + assert_that( _HandlePollResponse( messages, diagnostics_handler ), + equal_to( True ) ) + diagnostics_handler.UpdateWithNewDiagnosticsForFile.assert_has_exact_calls( [ + call( 'foo', [ 'PLACEHOLDER' ] ) + ] ) + + +def HandlePollResponse_MultipleDiagnostics_test(): + diagnostics_handler = ExtendedMock() + messages = [ + { 'filepath': 'foo', 'diagnostics': [ 'PLACEHOLDER1' ] }, + { 'filepath': 'bar', 'diagnostics': [ 'PLACEHOLDER2' ] }, + { 'filepath': 'baz', 'diagnostics': [ 'PLACEHOLDER3' ] }, + { 'filepath': 'foo', 'diagnostics': [ 'PLACEHOLDER4' ] }, + ] + assert_that( _HandlePollResponse( messages, diagnostics_handler ), + equal_to( True ) ) + diagnostics_handler.UpdateWithNewDiagnosticsForFile.assert_has_exact_calls( [ + call ( 'foo', [ 'PLACEHOLDER1' ] ), + call ( 'bar', [ 'PLACEHOLDER2' ] ), + call ( 'baz', [ 'PLACEHOLDER3' ] ), + call ( 'foo', [ 'PLACEHOLDER4' ] ) + ] ) + + +@patch( 'ycm.client.messages_request.PostVimMessage', + new_callable = ExtendedMock ) +def HandlePollResponse_MultipleMessagesAndDiagnostics_test( post_vim_message ): + diagnostics_handler = ExtendedMock() + messages = [ + { 'filepath': 'foo', 'diagnostics': [ 'PLACEHOLDER1' ] }, + { 'message': 'On the first day of Christmas, my VimScript gave to me' }, + { 'filepath': 'bar', 'diagnostics': [ 'PLACEHOLDER2' ] }, + { 'message': 'A test file in a Command-T' }, + { 'filepath': 'baz', 'diagnostics': [ 'PLACEHOLDER3' ] }, + { 'message': 'On the second day of Christmas, my VimScript gave to me' }, + { 'filepath': 'foo', 'diagnostics': [ 'PLACEHOLDER4' ] }, + { 'message': 'Two popup menus, and a test file in a Command-T' }, + ] + assert_that( _HandlePollResponse( messages, diagnostics_handler ), + equal_to( True ) ) + diagnostics_handler.UpdateWithNewDiagnosticsForFile.assert_has_exact_calls( [ + call ( 'foo', [ 'PLACEHOLDER1' ] ), + call ( 'bar', [ 'PLACEHOLDER2' ] ), + call ( 'baz', [ 'PLACEHOLDER3' ] ), + call ( 'foo', [ 'PLACEHOLDER4' ] ) + ] ) + + post_vim_message.assert_has_exact_calls( [ + call( 'On the first day of Christmas, my VimScript gave to me', + warning=False, + truncate=True ), + call( 'A test file in a Command-T', warning=False, truncate=True ), + call( 'On the second day of Christmas, my VimScript gave to me', + warning=False, + truncate=True ), + call( 'Two popup menus, and a test file in a Command-T', + warning=False, + truncate=True ), + ] ) diff --git a/python/ycm/tests/mock_utils.py b/python/ycm/tests/mock_utils.py new file mode 100644 index 00000000..40ffd3ae --- /dev/null +++ b/python/ycm/tests/mock_utils.py @@ -0,0 +1,113 @@ +# Copyright (C) 2017 YouCompleteMe contributors +# +# This file is part of YouCompleteMe. +# +# YouCompleteMe 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. +# +# YouCompleteMe 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 YouCompleteMe. If not, see . +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +import mock +import requests + + +class FakeResponse( object ): + """A fake version of a requests response object, just about suitable for + mocking a server response. Not usually used directly. See + MockServerResponse* methods""" + def __init__( self, response, exception ): + self._json = response + self._exception = exception + self.status_code = requests.codes.ok + self.text = not exception + + def json( self ): + if self._exception: + return None + return self._json + + + def raise_for_status( self ): + if self._exception: + raise self._exception + + +class FakeFuture( object ): + """A fake version of a future response object, just about suitable for + mocking a server response as generated by PostDataToHandlerAsync. + Not usually used directly. See MockAsyncServerResponse* methods""" + def __init__( self, done, response = None, exception = None ): + self._done = done + + if not done: + self._result = None + else: + self._result = FakeResponse( response, exception ) + + + def done( self ): + return self._done + + + def result( self ): + return self._result + + +def MockAsyncServerResponseDone( response ): + """Return a fake future object that is complete with the supplied response + message. Suitable for mocking a response future within a client request. For + example: + + server_message = { + 'message': 'this message came from the server' + } + + with patch.object( ycm._message_poll_request, + '_response_future', + new = MockAsyncServerResponseDone( [] ) ) as mock_future: + ycm.OnPeriodicTick() # Uses ycm._message_poll_request ... + """ + return mock.MagicMock( wraps = FakeFuture( True, response ) ) + + +def MockAsyncServerResponseInProgress(): + """Return a fake future object that is incomplete. Suitable for mocking a + response future within a client request. For example: + + with patch.object( ycm._message_poll_request, + '_response_future', + new = MockAsyncServerResponseInProgress() ): + ycm.OnPeriodicTick() # Uses ycm._message_poll_request ... + """ + return mock.MagicMock( wraps = FakeFuture( False ) ) + + +def MockAsyncServerResponseException( exception ): + """Return a fake future object that is complete, but raises an exception. + Suitable for mocking a response future within a client request. For example: + + exception = RuntimeError( 'Check client handles exception' ) + with patch.object( ycm._message_poll_request, + '_response_future', + new = MockAsyncServerResponseException( exception ) ): + ycm.OnPeriodicTick() # Uses ycm._message_poll_request ... + """ + return mock.MagicMock( wraps = FakeFuture( True, None, exception ) ) + + +# TODO: In future, implement MockServerResponse and MockServerResponseException +# for synchronous cases when such test cases are needed. diff --git a/python/ycm/tests/vimsupport_test.py b/python/ycm/tests/vimsupport_test.py index 7c36c6a0..0da1ce18 100644 --- a/python/ycm/tests/vimsupport_test.py +++ b/python/ycm/tests/vimsupport_test.py @@ -39,6 +39,74 @@ import os import json +@patch( 'vim.eval', new_callable = ExtendedMock ) +def SetLocationListForBuffer_Current_test( vim_eval ): + diagnostics = [ { + 'bufnr': 3, + 'filename': 'some_filename', + 'lnum': 5, + 'col': 22, + 'type': 'E', + 'valid': 1 + } ] + current_buffer = VimBuffer( '/test', number = 3, window = 7 ) + with MockVimBuffers( [ current_buffer ], current_buffer, ( 1, 1 ) ): + vimsupport.SetLocationListForBuffer( 3, diagnostics ) + + # We asked for the buffer which is current, so we use winnr 0 + vim_eval.assert_has_exact_calls( [ + call( 'setloclist( 0, {0} )'.format( json.dumps( diagnostics ) ) ) + ] ) + + +@patch( 'vim.eval', new_callable = ExtendedMock, side_effect = [ 8, 1 ] ) +def SetLocationListForBuffer_NotCurrent_test( vim_eval ): + diagnostics = [ { + 'bufnr': 3, + 'filename': 'some_filename', + 'lnum': 5, + 'col': 22, + 'type': 'E', + 'valid': 1 + } ] + current_buffer = VimBuffer( '/test', number = 3, window = 7 ) + other_buffer = VimBuffer( '/notcurrent', number = 1, window = 8 ) + with MockVimBuffers( [ current_buffer, other_buffer ], + current_buffer, + ( 1, 1 ) ): + vimsupport.SetLocationListForBuffer( 1, diagnostics ) + + # We asked for a buffer which is not current, so we find the window + vim_eval.assert_has_exact_calls( [ + call( 'bufwinnr(1)' ), # returns 8 due to side_effect + call( 'setloclist( 8, {0} )'.format( json.dumps( diagnostics ) ) ) + ] ) + + +@patch( 'vim.eval', new_callable = ExtendedMock, side_effect = [ -1, 1 ] ) +def SetLocationListForBuffer_NotVisible_test( vim_eval ): + diagnostics = [ { + 'bufnr': 3, + 'filename': 'some_filename', + 'lnum': 5, + 'col': 22, + 'type': 'E', + 'valid': 1 + } ] + current_buffer = VimBuffer( '/test', number = 3, window = 7 ) + other_buffer = VimBuffer( '/notcurrent', number = 1, window = 8 ) + with MockVimBuffers( [ current_buffer, other_buffer ], + current_buffer, + ( 1, 1 ) ): + vimsupport.SetLocationListForBuffer( 1, diagnostics ) + + # We asked for a buffer which is not current, so we find the window + vim_eval.assert_has_exact_calls( [ + call( 'bufwinnr(1)' ), # returns -1 due to side_effect + call( 'setloclist( 0, {0} )'.format( json.dumps( diagnostics ) ) ) + ] ) + + @patch( 'vim.eval', new_callable = ExtendedMock ) def SetLocationList_test( vim_eval ): diagnostics = [ { @@ -49,9 +117,36 @@ def SetLocationList_test( vim_eval ): 'type': 'E', 'valid': 1 } ] - vimsupport.SetLocationList( diagnostics ) - vim_eval.assert_called_once_with( - 'setloclist( 0, {0} )'.format( json.dumps( diagnostics ) ) ) + current_buffer = VimBuffer( '/test', number = 3, window = 7 ) + with MockVimBuffers( [ current_buffer ], current_buffer, ( 1, 1 ) ): + vimsupport.SetLocationList( diagnostics ) + + vim_eval.assert_has_calls( [ + call( 'setloclist( 0, {0} )'.format( json.dumps( diagnostics ) ) ), + ] ) + + +@patch( 'vim.eval', new_callable = ExtendedMock ) +def SetLocationList_NotCurrent_test( vim_eval ): + diagnostics = [ { + 'bufnr': 3, + 'filename': 'some_filename', + 'lnum': 5, + 'col': 22, + 'type': 'E', + 'valid': 1 + } ] + current_buffer = VimBuffer( '/test', number = 3, window = 7 ) + other_buffer = VimBuffer( '/notcurrent', number = 1, window = 8 ) + with MockVimBuffers( [ current_buffer, other_buffer ], + current_buffer, + ( 1, 1 ) ): + vimsupport.SetLocationList( diagnostics ) + + # This version does not check the current buffer and just sets the current win + vim_eval.assert_has_exact_calls( [ + call( 'setloclist( 0, {0} )'.format( json.dumps( diagnostics ) ) ), + ] ) @patch( 'ycm.vimsupport.VariableExists', return_value = True ) diff --git a/python/ycm/tests/youcompleteme_test.py b/python/ycm/tests/youcompleteme_test.py index 9f80f488..33e6c688 100644 --- a/python/ycm/tests/youcompleteme_test.py +++ b/python/ycm/tests/youcompleteme_test.py @@ -38,6 +38,12 @@ from ycm.tests import ( MakeUserOptions, StopServer, test_utils, WaitUntilReady, YouCompleteMeInstance ) from ycm.client.base_request import _LoadExtraConfFile from ycmd.responses import ServerError +from ycm.tests.mock_utils import ( MockAsyncServerResponseDone, + MockAsyncServerResponseInProgress, + MockAsyncServerResponseException ) + + +from ycm import buffer as ycm_buffer_module @YouCompleteMeInstance() @@ -394,11 +400,11 @@ def YouCompleteMe_ShowDiagnostics_FiletypeNotSupported_test( ycm, @patch( 'ycm.youcompleteme.YouCompleteMe.FiletypeCompleterExistsForFiletype', return_value = True ) @patch( 'ycm.vimsupport.PostVimMessage', new_callable = ExtendedMock ) -@patch( 'ycm.vimsupport.SetLocationList', new_callable = ExtendedMock ) +@patch( 'ycm.vimsupport.SetLocationListForWindow', new_callable = ExtendedMock ) def YouCompleteMe_ShowDiagnostics_NoDiagnosticsDetected_test( - ycm, set_location_list, post_vim_message, *args ): + ycm, set_location_list_for_window, post_vim_message, *args ): - current_buffer = VimBuffer( 'buffer', filetype = 'cpp' ) + current_buffer = VimBuffer( 'buffer', filetype = 'cpp', window = 99 ) with MockVimBuffers( [ current_buffer ], current_buffer ): with patch( 'ycm.client.event_notification.EventNotification.Response', return_value = {} ): @@ -410,7 +416,7 @@ def YouCompleteMe_ShowDiagnostics_NoDiagnosticsDetected_test( call( 'Diagnostics refreshed', warning = False ), call( 'No warnings or errors detected.', warning = False ) ] ) - set_location_list.assert_called_once_with( [] ) + set_location_list_for_window.assert_called_once_with( 0, [] ) @YouCompleteMeInstance( { 'log_level': 'debug', @@ -419,9 +425,9 @@ def YouCompleteMe_ShowDiagnostics_NoDiagnosticsDetected_test( @patch( 'ycm.youcompleteme.YouCompleteMe.FiletypeCompleterExistsForFiletype', return_value = True ) @patch( 'ycm.vimsupport.PostVimMessage', new_callable = ExtendedMock ) -@patch( 'ycm.vimsupport.SetLocationList', new_callable = ExtendedMock ) +@patch( 'ycm.vimsupport.SetLocationListForWindow', new_callable = ExtendedMock ) def YouCompleteMe_ShowDiagnostics_DiagnosticsFound_DoNotOpenLocationList_test( - ycm, set_location_list, post_vim_message, *args ): + ycm, set_location_list_for_window, post_vim_message, *args ): diagnostic = { 'kind': 'ERROR', @@ -433,7 +439,10 @@ def YouCompleteMe_ShowDiagnostics_DiagnosticsFound_DoNotOpenLocationList_test( } } - current_buffer = VimBuffer( 'buffer', filetype = 'cpp', number = 3 ) + current_buffer = VimBuffer( 'buffer', + filetype = 'cpp', + number = 3, + window = 99 ) with MockVimBuffers( [ current_buffer ], current_buffer ): with patch( 'ycm.client.event_notification.EventNotification.Response', return_value = [ diagnostic ] ): @@ -444,7 +453,7 @@ def YouCompleteMe_ShowDiagnostics_DiagnosticsFound_DoNotOpenLocationList_test( warning = False ), call( 'Diagnostics refreshed', warning = False ) ] ) - set_location_list.assert_called_once_with( [ { + set_location_list_for_window.assert_called_once_with( 0, [ { 'bufnr': 3, 'lnum': 19, 'col': 2, @@ -458,10 +467,14 @@ def YouCompleteMe_ShowDiagnostics_DiagnosticsFound_DoNotOpenLocationList_test( @patch( 'ycm.youcompleteme.YouCompleteMe.FiletypeCompleterExistsForFiletype', return_value = True ) @patch( 'ycm.vimsupport.PostVimMessage', new_callable = ExtendedMock ) -@patch( 'ycm.vimsupport.SetLocationList', new_callable = ExtendedMock ) +@patch( 'ycm.vimsupport.SetLocationListForWindow', new_callable = ExtendedMock ) @patch( 'ycm.vimsupport.OpenLocationList', new_callable = ExtendedMock ) def YouCompleteMe_ShowDiagnostics_DiagnosticsFound_OpenLocationList_test( - ycm, open_location_list, set_location_list, post_vim_message, *args ): + ycm, + open_location_list, + set_location_list_for_window, + post_vim_message, + *args ): diagnostic = { 'kind': 'ERROR', @@ -473,7 +486,10 @@ def YouCompleteMe_ShowDiagnostics_DiagnosticsFound_OpenLocationList_test( } } - current_buffer = VimBuffer( 'buffer', filetype = 'cpp', number = 3 ) + current_buffer = VimBuffer( 'buffer', + filetype = 'cpp', + number = 3, + window = 99 ) with MockVimBuffers( [ current_buffer ], current_buffer ): with patch( 'ycm.client.event_notification.EventNotification.Response', return_value = [ diagnostic ] ): @@ -484,7 +500,7 @@ def YouCompleteMe_ShowDiagnostics_DiagnosticsFound_OpenLocationList_test( warning = False ), call( 'Diagnostics refreshed', warning = False ) ] ) - set_location_list.assert_called_once_with( [ { + set_location_list_for_window.assert_called_once_with( 0, [ { 'bufnr': 3, 'lnum': 19, 'col': 2, @@ -641,3 +657,361 @@ def YouCompleteMe_UpdateDiagnosticInterface_PrioritizeErrorsOverWarnings_test( call( 'sign place 2 name=YcmWarning line=3 buffer=5' ), call( 'try | exec "sign unplace 1 buffer=5" | catch /E158/ | endtry' ) ] ) + + +@YouCompleteMeInstance( { 'echo_current_diagnostic': 1, + 'always_populate_location_list': 1 } ) +@patch.object( ycm_buffer_module, + 'DIAGNOSTIC_UI_ASYNC_FILETYPES', + [ 'ycmtest' ] ) +@patch( 'ycm.youcompleteme.YouCompleteMe.FiletypeCompleterExistsForFiletype', + return_value = True ) +@patch( 'ycm.vimsupport.PostVimMessage', new_callable = ExtendedMock ) +def YouCompleteMe_AsyncDiagnosticUpdate_SingleFile_test( ycm, + post_vim_message, + *args ): + + # This test simulates asynchronous diagnostic updates associated with a single + # file (e.g. Translation Unit), but where the actual errors refer to other + # open files and other non-open files. This is not strictly invalid, nor is it + # completely normal, but it is supported and does work. + + # Contrast with the next test which sends the diagnostics filewise, which is + # what the language server protocol will do. + + diagnostics = [ + { + 'kind': 'ERROR', + 'text': 'error text in current buffer', + 'location': { + 'filepath': '/current', + 'line_num': 1, + 'column_num': 1 + }, + }, + { + 'kind': 'ERROR', + 'text': 'error text in hidden buffer', + 'location': { + 'filepath': '/has_diags', + 'line_num': 4, + 'column_num': 2 + }, + }, + { + 'kind': 'ERROR', + 'text': 'error text in buffer not open in Vim', + 'location': { + 'filepath': '/not_open', + 'line_num': 8, + 'column_num': 4 + }, + }, + ] + + current_buffer = VimBuffer( '/current', + filetype = 'ycmtest', + number = 1, + window = 10 ) + buffers = [ + current_buffer, + VimBuffer( '/no_diags', + filetype = 'ycmtest', + number = 2, + window = 9 ), + VimBuffer( '/has_diags', + filetype = 'ycmtest', + number = 3, + window = 8 ), + ] + + # Register each buffer internally with YCM + for current in buffers: + with MockVimBuffers( buffers, current, ( 1, 1 ) ): + ycm.OnFileReadyToParse() + + with patch( 'ycm.vimsupport.SetLocationListForWindow', + new_callable = ExtendedMock ) as set_location_list_for_window: + with MockVimBuffers( buffers, current_buffer, ( 1, 1 ) ): + ycm.UpdateWithNewDiagnosticsForFile( '/current', diagnostics ) + + # We update the diagnostic on the current cursor position + post_vim_message.assert_has_exact_calls( [ + call( "error text in current buffer", truncate = True, warning = False ), + ] ) + + # Ensure we included all the diags though + set_location_list_for_window.assert_has_exact_calls( [ + call( 0, [ + { + 'lnum': 1, + 'col': 1, + 'bufnr': 1, + 'valid': 1, + 'type': 'E', + 'text': 'error text in current buffer', + }, + { + 'lnum': 4, + 'col': 2, + 'bufnr': 3, + 'valid': 1, + 'type': 'E', + 'text': 'error text in hidden buffer', + }, + { + 'lnum': 8, + 'col': 4, + 'bufnr': -1, # sic: Our mocked bufnr function actually returns -1, + # even though YCM is passing "create if needed". + # FIXME? we shouldn't do that, and we should pass + # filename instead + 'valid': 1, + 'type': 'E', + 'text': 'error text in buffer not open in Vim' + } + ] ) + ] ) + + +@YouCompleteMeInstance( { 'echo_current_diagnostic': 1, + 'always_populate_location_list': 1 } ) +@patch.object( ycm_buffer_module, + 'DIAGNOSTIC_UI_ASYNC_FILETYPES', + [ 'ycmtest' ] ) +@patch( 'ycm.youcompleteme.YouCompleteMe.FiletypeCompleterExistsForFiletype', + return_value = True ) +@patch( 'ycm.vimsupport.PostVimMessage', new_callable = ExtendedMock ) +def YouCompleteMe_AsyncDiagnosticUpdate_PerFile_test( ycm, + post_vim_message, + *args ): + + # This test simulates asynchronous diagnostic updates which are delivered per + # file, including files which are open and files which are not. + + # Ordered to ensure that the calls to update are deterministic + diagnostics_per_file = [ + ( '/current', [ { + 'kind': 'ERROR', + 'text': 'error text in current buffer', + 'location': { + 'filepath': '/current', + 'line_num': 1, + 'column_num': 1 + }, }, ] ), + ( '/has_diags', [ { + 'kind': 'ERROR', + 'text': 'error text in hidden buffer', + 'location': { + 'filepath': '/has_diags', + 'line_num': 4, + 'column_num': 2 + }, }, ] ), + ( '/not_open', [ { + 'kind': 'ERROR', + 'text': 'error text in buffer not open in Vim', + 'location': { + 'filepath': '/not_open', + 'line_num': 8, + 'column_num': 4 + }, }, ] ) + ] + + current_buffer = VimBuffer( '/current', + filetype = 'ycmtest', + number = 1, + window = 10 ) + buffers = [ + current_buffer, + VimBuffer( '/no_diags', + filetype = 'ycmtest', + number = 2, + window = 9 ), + VimBuffer( '/has_diags', + filetype = 'ycmtest', + number = 3, + window = 8 ), + ] + + # Register each buffer internally with YCM + for current in buffers: + with MockVimBuffers( buffers, current, ( 1, 1 ) ): + ycm.OnFileReadyToParse() + + with patch( 'ycm.vimsupport.SetLocationListForWindow', + new_callable = ExtendedMock ) as set_location_list_for_window: + with MockVimBuffers( buffers, current_buffer, ( 1, 1 ) ): + for filename, diagnostics in diagnostics_per_file: + ycm.UpdateWithNewDiagnosticsForFile( filename, diagnostics ) + + # We update the diagnostic on the current cursor position + post_vim_message.assert_has_exact_calls( [ + call( "error text in current buffer", truncate = True, warning = False ), + ] ) + + # Ensure we included all the diags though + set_location_list_for_window.assert_has_exact_calls( [ + call( 0, [ + { + 'lnum': 1, + 'col': 1, + 'bufnr': 1, + 'valid': 1, + 'type': 'E', + 'text': 'error text in current buffer', + }, + ] ), + + call( 8, [ + { + 'lnum': 4, + 'col': 2, + 'bufnr': 3, + 'valid': 1, + 'type': 'E', + 'text': 'error text in hidden buffer', + }, + ] ) + ] ) + + +@YouCompleteMeInstance() +def YouCompleteMe_OnPeriodicTick_ServerNotRunning_test( ycm, *args ): + with patch.object( ycm, 'IsServerAlive', return_value = False ): + assert_that( ycm.OnPeriodicTick(), equal_to( False ) ) + + +@YouCompleteMeInstance() +def YouCompleteMe_OnPeriodicTick_ServerNotReady_test( ycm, *args ): + with patch.object( ycm, 'IsServerAlive', return_value = True ): + with patch.object( ycm, 'IsServerReady', return_value = False ): + assert_that( ycm.OnPeriodicTick(), equal_to( True ) ) + + +@YouCompleteMeInstance() +@patch.object( ycm_buffer_module, + 'DIAGNOSTIC_UI_ASYNC_FILETYPES', + [ 'ycmtest' ] ) +@patch( 'ycm.youcompleteme.YouCompleteMe.FiletypeCompleterExistsForFiletype', + return_value = True ) +@patch( 'ycm.client.base_request._ValidateResponseObject', return_value = True ) +@patch( 'ycm.client.base_request.BaseRequest.PostDataToHandlerAsync' ) +def YouCompelteMe_OnPeriodicTick_DontRetry_test( ycm, + post_data_to_handler_async, + *args ): + + current_buffer = VimBuffer( '/current', + filetype = 'ycmtest', + number = 1, + window = 10 ) + buffers = [ current_buffer ] + + # Create the request and make the first poll; we expect no response + with MockVimBuffers( buffers, current_buffer, ( 1, 1 ) ): + assert_that( ycm.OnPeriodicTick(), equal_to( True ) ) + post_data_to_handler_async.assert_called() + + assert ycm._message_poll_request is not None + post_data_to_handler_async.reset_mock() + + # OK that sent the request, now poll to check if it is complete (say it is + # not) + with patch.object( ycm._message_poll_request, + '_response_future', + new = MockAsyncServerResponseInProgress() ) as mock_future: + poll_again = ycm.OnPeriodicTick() + mock_future.done.assert_called() + mock_future.result.assert_not_called() + assert_that( poll_again, equal_to( True ) ) + + # Poll again, but return a response (telling us to stop polling) + with patch.object( ycm._message_poll_request, + '_response_future', + new = MockAsyncServerResponseDone( False ) ) \ + as mock_future: + poll_again = ycm.OnPeriodicTick() + mock_future.done.assert_called() + mock_future.result.assert_called() + post_data_to_handler_async.assert_not_called() + # We reset and don't poll anymore + assert_that( ycm._message_poll_request is None ) + assert_that( poll_again, equal_to( False ) ) + + + +@YouCompleteMeInstance() +@patch.object( ycm_buffer_module, + 'DIAGNOSTIC_UI_ASYNC_FILETYPES', + [ 'ycmtest' ] ) +@patch( 'ycm.youcompleteme.YouCompleteMe.FiletypeCompleterExistsForFiletype', + return_value = True ) +@patch( 'ycm.client.base_request._ValidateResponseObject', return_value = True ) +@patch( 'ycm.client.base_request.BaseRequest.PostDataToHandlerAsync' ) +def YouCompelteMe_OnPeriodicTick_Exception_test( ycm, + post_data_to_handler_async, + *args ): + + current_buffer = VimBuffer( '/current', + filetype = 'ycmtest', + number = 1, + window = 10 ) + buffers = [ current_buffer ] + + # Create the request and make the first poll; we expect no response + with MockVimBuffers( buffers, current_buffer, ( 1, 1 ) ): + assert_that( ycm.OnPeriodicTick(), equal_to( True ) ) + post_data_to_handler_async.assert_called() + + post_data_to_handler_async.reset_mock() + + # Poll again, but return an exception response + mock_response = MockAsyncServerResponseException( RuntimeError( 'test' ) ) + with patch.object( ycm._message_poll_request, + '_response_future', + new = mock_response ) as mock_future: + assert_that( ycm.OnPeriodicTick(), equal_to( False ) ) + mock_future.done.assert_called() + mock_future.result.assert_called() + post_data_to_handler_async.assert_not_called() + # We reset and don't poll anymore + assert_that( ycm._message_poll_request is None ) + + +@YouCompleteMeInstance() +@patch.object( ycm_buffer_module, + 'DIAGNOSTIC_UI_ASYNC_FILETYPES', + [ 'ycmtest' ] ) +@patch( 'ycm.youcompleteme.YouCompleteMe.FiletypeCompleterExistsForFiletype', + return_value = True ) +@patch( 'ycm.client.base_request._ValidateResponseObject', return_value = True ) +@patch( 'ycm.client.base_request.BaseRequest.PostDataToHandlerAsync' ) +@patch( 'ycm.client.messages_request._HandlePollResponse' ) +def YouCompelteMe_OnPeriodicTick_ValidResponse_test( ycm, + handle_poll_response, + post_data_to_handler_async, + *args ): + + current_buffer = VimBuffer( '/current', + filetype = 'ycmtest', + number = 1, + window = 10 ) + buffers = [ current_buffer ] + + # Create the request and make the first poll; we expect no response + with MockVimBuffers( buffers, current_buffer, ( 1, 1 ) ): + assert_that( ycm.OnPeriodicTick(), equal_to( True ) ) + post_data_to_handler_async.assert_called() + + post_data_to_handler_async.reset_mock() + + # Poll again, and return a _proper_ response (finally!). + # Note, _HandlePollResponse is tested independently (for simplicity) + with patch.object( ycm._message_poll_request, + '_response_future', + new = MockAsyncServerResponseDone( [] ) ) as mock_future: + assert_that( ycm.OnPeriodicTick(), equal_to( True ) ) + handle_poll_response.assert_called() + mock_future.done.assert_called() + mock_future.result.assert_called() + post_data_to_handler_async.assert_called() # Poll again! + assert_that( ycm._message_poll_request is not None ) diff --git a/python/ycm/vimsupport.py b/python/ycm/vimsupport.py index 7070d55f..13c8ed8a 100644 --- a/python/ycm/vimsupport.py +++ b/python/ycm/vimsupport.py @@ -235,9 +235,47 @@ def LineAndColumnNumbersClamped( line_num, column_num ): def SetLocationList( diagnostics ): + """Set the location list for the current window to the supplied diagnostics""" + SetLocationListForWindow( 0, diagnostics ) + + +def GetWindowNumberForBufferDiagnostics( buffer_number ): + """Return an appropriate window number to use for displaying diagnostics + associated with the buffer number supplied. Always returns a valid window + number or 0 meaning the current window.""" + + # Location lists are associated with _windows_ not _buffers_. This makes a lot + # of sense, but YCM associates diagnostics with _buffers_, because it is the + # buffer that actually gets parsed. + # + # The heuristic we use is to determine any open window for a specified buffer, + # and set that. If there is no such window on the current tab page, we use the + # current window (by passing 0 as the window number) + + if buffer_number == vim.current.buffer.number: + return 0 + + window_number = GetIntValue( "bufwinnr({0})".format( buffer_number ) ) + + if window_number < 0: + return 0 + + return window_number + + +def SetLocationListForBuffer( buffer_number, diagnostics ): + """Populate the location list of an apppropriate window for the supplied + buffer number. See SetLocationListForWindow for format of diagnostics.""" + return SetLocationListForWindow( + GetWindowNumberForBufferDiagnostics( buffer_number ), + diagnostics ) + + +def SetLocationListForWindow( window_number, diagnostics ): """Populate the location list with diagnostics. Diagnostics should be in qflist format; see ":h setqflist" for details.""" - vim.eval( 'setloclist( 0, {0} )'.format( json.dumps( diagnostics ) ) ) + vim.eval( 'setloclist( {0}, {1} )'.format( window_number, + json.dumps( diagnostics ) ) ) def OpenLocationList( focus = False, autoclose = False ): diff --git a/python/ycm/youcompleteme.py b/python/ycm/youcompleteme.py index 2b958303..84df240d 100644 --- a/python/ycm/youcompleteme.py +++ b/python/ycm/youcompleteme.py @@ -32,7 +32,9 @@ import vim from subprocess import PIPE from tempfile import NamedTemporaryFile from ycm import base, paths, vimsupport -from ycm.buffer import BufferDict +from ycm.buffer import ( BufferDict, + DIAGNOSTIC_UI_FILETYPES, + DIAGNOSTIC_UI_ASYNC_FILETYPES ) from ycmd import utils from ycmd import server_utils from ycmd.request_wrap import RequestWrap @@ -50,6 +52,7 @@ from ycm.client.debug_info_request import ( SendDebugInfoRequest, from ycm.client.omni_completion_request import OmniCompletionRequest from ycm.client.event_notification import SendEventNotificationAsync from ycm.client.shutdown_request import SendShutdownRequest +from ycm.client.messages_request import MessagesPoll def PatchNoProxy(): @@ -97,8 +100,6 @@ CORE_OUTDATED_MESSAGE = ( 'YCM core library too old; PLEASE RECOMPILE by running the install.py ' 'script. See the documentation for more details.' ) SERVER_IDLE_SUICIDE_SECONDS = 1800 # 30 minutes -DIAGNOSTIC_UI_FILETYPES = set( [ 'cpp', 'cs', 'c', 'objc', 'objcpp', - 'typescript' ] ) CLIENT_LOGFILE_FORMAT = 'ycm_' SERVER_LOGFILE_FORMAT = 'ycmd_{port}_{std}_' @@ -136,6 +137,7 @@ class YouCompleteMe( object ): self._user_notified_about_crash = False self._filetypes_with_keywords_loaded = set() self._server_is_ready_with_cache = False + self._message_poll_request = None hmac_secret = os.urandom( HMAC_SECRET_LENGTH ) options_dict = dict( self._user_options ) @@ -364,6 +366,59 @@ class YouCompleteMe( object ): return self.CurrentBuffer().NeedsReparse() + def UpdateWithNewDiagnosticsForFile( self, filepath, diagnostics ): + bufnr = vimsupport.GetBufferNumberForFilename( filepath ) + if bufnr in self._buffers and vimsupport.BufferIsVisible( bufnr ): + # Note: We only update location lists, etc. for visible buffers, because + # otherwise we defualt to using the curren location list and the results + # are that non-visible buffer errors clobber visible ones. + self._buffers[ bufnr ].UpdateWithNewDiagnostics( diagnostics ) + else: + # The project contains errors in file "filepath", but that file is not + # open in any buffer. This happens for Language Server Protocol-based + # completers, as they return diagnostics for the entire "project" + # asynchronously (rather than per-file in the response to the parse + # request). + # + # There are a number of possible approaches for + # this, but for now we simply ignore them. Other options include: + # - Use the QuickFix list to report project errors? + # - Use a special buffer for project errors + # - Put them in the location list of whatever the "current" buffer is + # - Store them in case the buffer is opened later + # - add a :YcmProjectDiags command + # - Add them to errror/warning _counts_ but not any actual location list + # or other + # - etc. + # + # However, none of those options are great, and lead to their own + # complexities. So for now, we just ignore these diagnostics for files not + # open in any buffer. + pass + + + def OnPeriodicTick( self ): + if not self.IsServerAlive(): + # Server has died. We'll reset when the server is started again. + return False + elif not self.IsServerReady(): + # Try again in a jiffy + return True + + if not self._message_poll_request: + self._message_poll_request = MessagesPoll() + + if not self._message_poll_request.Poll( self ): + # Don't poll again until some event which might change the server's mind + # about whether to provide messages for the current buffer (e.g. buffer + # visit, file ready to parse, etc.) + self._message_poll_request = None + return False + + # Poll again in a jiffy + return True + + def OnFileReadyToParse( self ): if not self.IsServerAlive(): self.NotifyUserIfServerCrashed() @@ -534,7 +589,8 @@ class YouCompleteMe( object ): def DiagnosticUiSupportedForCurrentFiletype( self ): - return any( [ x in DIAGNOSTIC_UI_FILETYPES + return any( [ x in DIAGNOSTIC_UI_FILETYPES or + x in DIAGNOSTIC_UI_ASYNC_FILETYPES for x in vimsupport.CurrentFiletypes() ] ) @@ -566,7 +622,9 @@ class YouCompleteMe( object ): self.NativeFiletypeCompletionUsable() ): if self.ShouldDisplayDiagnostics(): - current_buffer.UpdateDiagnostics() + # Forcefuly update the location list, etc. from the parse request when + # doing something like :YcmDiags + current_buffer.UpdateDiagnostics( block is True ) else: # YCM client has a hard-coded list of filetypes which are known # to support diagnostics, self.DiagnosticUiSupportedForCurrentFiletype()