diff --git a/python/ycm/client/command_request.py b/python/ycm/client/command_request.py index dc6abfb1..405e4f47 100644 --- a/python/ycm/client/command_request.py +++ b/python/ycm/client/command_request.py @@ -80,8 +80,8 @@ class CommandRequest( BaseRequest ): def _HandleGotoResponse( self ): if isinstance( self._response, list ): - defs = [ _BuildQfListItem( x ) for x in self._response ] - vim.eval( 'setqflist( %s )' % repr( defs ) ) + vimsupport.SetQuickFixList( + [ _BuildQfListItem( x ) for x in self._response ] ) vim.eval( 'youcompleteme#OpenGoToList()' ) else: vimsupport.JumpToLocation( self._response[ 'filepath' ], @@ -94,12 +94,10 @@ class CommandRequest( BaseRequest ): vimsupport.EchoText( "No fixits found for current line" ) else: chunks = self._response[ 'fixits' ][ 0 ][ 'chunks' ] - - vimsupport.ReplaceChunksList( chunks ) - - vimsupport.EchoTextVimWidth( "FixIt applied " - + str( len( chunks ) ) - + " changes" ) + try: + vimsupport.ReplaceChunks( chunks ) + except RuntimeError as e: + vimsupport.PostMultiLineNotice( e.message ) def _HandleBasicResponse( self ): diff --git a/python/ycm/client/tests/command_request_test.py b/python/ycm/client/tests/command_request_test.py index cf8b2058..d62518ba 100644 --- a/python/ycm/client/tests/command_request_test.py +++ b/python/ycm/client/tests/command_request_test.py @@ -18,7 +18,9 @@ from ycm.test_utils import MockVimModule MockVimModule() +import json from mock import patch, call +from nose.tools import ok_ from ycm.client.command_request import CommandRequest @@ -85,6 +87,177 @@ class GoToResponse_QuickFix_test: self._request.RunPostCommandActionsIfNeeded() vim_eval.assert_has_calls( [ - call( 'setqflist( {0} )'.format( repr( expected_qf_list ) ) ), + call( 'setqflist( {0} )'.format( json.dumps( expected_qf_list ) ) ), call( 'youcompleteme#OpenGoToList()' ), ] ) + + +class Response_Detection_test: + + def BasicResponse_test( self ): + def _BasicResponseTest( command, response ): + with patch( 'vim.command' ) as vim_command: + request = CommandRequest( [ command ] ) + request._response = response + request.RunPostCommandActionsIfNeeded() + vim_command.assert_called_with( "echom '{0}'".format( response ) ) + + tests = [ + [ 'AnythingYouLike', True ], + [ 'GoToEvenWorks', 10 ], + [ 'FixItWorks', 'String!' ], + [ 'and8434fd andy garbag!', 10.3 ], + ] + + for test in tests: + yield _BasicResponseTest, test[ 0 ], test[ 1 ] + + + def FixIt_Response_Empty_test( self ): + # Ensures we recognise and handle fixit responses which indicate that there + # are no fixits available + def EmptyFixItTest( command ): + with patch( 'ycm.vimsupport.ReplaceChunks' ) as replace_chunks: + with patch( 'ycm.vimsupport.EchoText' ) as echo_text: + request = CommandRequest( [ command ] ) + request._response = { + 'fixits': [] + } + request.RunPostCommandActionsIfNeeded() + + echo_text.assert_called_with( 'No fixits found for current line' ) + replace_chunks.assert_not_called() + + for test in [ 'FixIt', 'Refactor', 'GoToHell', 'any_old_garbade!!!21' ]: + yield EmptyFixItTest, test + + + def FixIt_Response_test( self ): + # Ensures we recognise and handle fixit responses with some dummy chunk data + def FixItTest( command, response, chunks ): + with patch( 'ycm.vimsupport.ReplaceChunks' ) as replace_chunks: + with patch( 'ycm.vimsupport.EchoText' ) as echo_text: + request = CommandRequest( [ command ] ) + request._response = response + request.RunPostCommandActionsIfNeeded() + + replace_chunks.assert_called_with( chunks ) + echo_text.assert_not_called() + + basic_fixit = { + 'fixits': [ { + 'chunks': [ { + 'dummy chunk contents': True + } ] + } ] + } + basic_fixit_chunks = basic_fixit[ 'fixits' ][ 0 ][ 'chunks' ] + + multi_fixit = { + 'fixits': [ { + 'chunks': [ { + 'dummy chunk contents': True + } ] + }, { + 'additional fixits are ignored currently': True + } ] + } + multi_fixit_first_chunks = multi_fixit[ 'fixits' ][ 0 ][ 'chunks' ] + + tests = [ + [ 'AnythingYouLike', basic_fixit, basic_fixit_chunks ], + [ 'GoToEvenWorks', basic_fixit, basic_fixit_chunks ], + [ 'FixItWorks', basic_fixit, basic_fixit_chunks ], + [ 'and8434fd andy garbag!', basic_fixit, basic_fixit_chunks ], + [ 'additional fixits ignored', multi_fixit, multi_fixit_first_chunks ], + ] + + for test in tests: + yield FixItTest, test[ 0 ], test[ 1 ], test[ 2 ] + + + def Message_Response_test( self ): + # Ensures we correctly recognise and handle responses with a message to show + # to the user + + def MessageTest( command, message ): + with patch( 'ycm.vimsupport.EchoText' ) as echo_text: + request = CommandRequest( [ command ] ) + request._response = { 'message': message } + request.RunPostCommandActionsIfNeeded() + echo_text.assert_called_with( message ) + + tests = [ + [ '___________', 'This is a message' ], + [ '', 'this is also a message' ], + [ 'GetType', 'std::string' ], + ] + + for test in tests: + yield MessageTest, test[ 0 ], test[ 1 ] + + + def Detailed_Info_test( self ): + # Ensures we correctly detect and handle detailed_info responses which are + # used to display information in the preview window + + def DetailedInfoTest( command, info ): + with patch( 'ycm.vimsupport.WriteToPreviewWindow' ) as write_to_preview: + request = CommandRequest( [ command ] ) + request._response = { 'detailed_info': info } + request.RunPostCommandActionsIfNeeded() + write_to_preview.assert_called_with( info ) + + tests = [ + [ '___________', 'This is a message' ], + [ '', 'this is also a message' ], + [ 'GetDoc', 'std::string\netc\netc' ], + ] + + for test in tests: + yield DetailedInfoTest, test[ 0 ], test[ 1 ] + + + def GoTo_Single_test( self ): + # Ensures we handle any unknown type of response as a GoTo response + + def GoToTest( command, response ): + with patch( 'ycm.vimsupport.JumpToLocation' ) as jump_to_location: + request = CommandRequest( [ command ] ) + request._response = response + request.RunPostCommandActionsIfNeeded() + jump_to_location.assert_called_with( + response[ 'filepath' ], + response[ 'line_num' ], + response[ 'column_num' ] ) + + def GoToListTest( command, response ): + # Note: the detail of these called are tested by + # GoToResponse_QuickFix_test, so here we just check that the right call is + # made + with patch( 'ycm.vimsupport.SetQuickFixList' ) as set_qf_list: + with patch( 'vim.eval' ) as vim_eval: + request = CommandRequest( [ command ] ) + request._response = response + request.RunPostCommandActionsIfNeeded() + ok_( set_qf_list.called ) + ok_( vim_eval.called ) + + basic_goto = { + 'filepath': 'test', + 'line_num': 10, + 'column_num': 100, + } + + tests = [ + [ GoToTest, 'AnythingYouLike', basic_goto ], + [ GoToTest, 'GoTo', basic_goto ], + [ GoToTest, 'FindAThing', basic_goto ], + [ GoToTest, 'FixItGoto', basic_goto ], + [ GoToListTest, 'AnythingYouLike', [ basic_goto ] ], + [ GoToListTest, 'GoTo', [] ], + [ GoToListTest, 'FixItGoto', [ basic_goto, basic_goto ] ], + ] + + for test in tests: + yield test[ 0 ], test[ 1 ], test[ 2 ] diff --git a/python/ycm/tests/vimsupport_test.py b/python/ycm/tests/vimsupport_test.py index 967cb7f7..7f8de3c5 100644 --- a/python/ycm/tests/vimsupport_test.py +++ b/python/ycm/tests/vimsupport_test.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with YouCompleteMe. If not, see . -from ycm.test_utils import MockVimModule, MockVimCommand +from ycm.test_utils import ExtendedMock, MockVimModule, MockVimCommand MockVimModule() from ycm import vimsupport @@ -23,6 +23,7 @@ from nose.tools import eq_ from hamcrest import assert_that, calling, raises, none from mock import MagicMock, call, patch import os +import json def ReplaceChunk_SingleLine_Repl_1_test(): @@ -239,9 +240,9 @@ def ReplaceChunk_SingleToMultipleLines_test(): # now make another change to the "2nd" line start, end = _BuildLocations( 2, 3, 2, 4 ) - ( new_line_offset, new_char_offset ) = vimsupport.ReplaceChunk( - start, - end, + ( new_line_offset, new_char_offset ) = vimsupport.ReplaceChunk( + start, + end, 'cccc', line_offset, char_offset, @@ -343,7 +344,6 @@ def ReplaceChunk_SingleToMultipleLinesReplace_2_test(): eq_( char_offset, 4 ) - def ReplaceChunk_MultipleLinesToSingleLine_test(): result_buffer = [ "aAa", "aBa", "aCaaaa" ] start, end = _BuildLocations( 2, 2, 3, 2 ) @@ -539,41 +539,517 @@ def _BuildLocations( start_line, start_column, end_line, end_column ): } -def ReplaceChunksList_SortedChunks_test(): +def ReplaceChunksInBuffer_SortedChunks_test(): chunks = [ _BuildChunk( 1, 4, 1, 4, '('), _BuildChunk( 1, 11, 1, 11, ')' ) ] result_buffer = [ "CT<10 >> 2> ct" ] - vimsupport.ReplaceChunksList( chunks, result_buffer ) + vimsupport.ReplaceChunksInBuffer( chunks, result_buffer, None ) expected_buffer = [ "CT<(10 >> 2)> ct" ] eq_( expected_buffer, result_buffer ) -def ReplaceChunksList_UnsortedChunks_test(): +def ReplaceChunksInBuffer_UnsortedChunks_test(): chunks = [ _BuildChunk( 1, 11, 1, 11, ')'), _BuildChunk( 1, 4, 1, 4, '(' ) ] result_buffer = [ "CT<10 >> 2> ct" ] - vimsupport.ReplaceChunksList( chunks, result_buffer ) + vimsupport.ReplaceChunksInBuffer( chunks, result_buffer, None ) expected_buffer = [ "CT<(10 >> 2)> ct" ] eq_( expected_buffer, result_buffer ) -def _BuildChunk( start_line, start_column, end_line, end_column, - replacement_text ): +class MockBuffer( ): + """An object that looks like a vim.buffer object, enough for ReplaceChunk to + generate a location list""" + + def __init__( self, lines, name, number ): + self.lines = lines + self.name = name + self.number = number + + + def __getitem__( self, index ): + return self.lines[ index ] + + + def __len__( self ): + return len( self.lines ) + + + def __setitem__( self, key, value ): + return self.lines.__setitem__( key, value ) + + +@patch( 'ycm.vimsupport.GetBufferNumberForFilename', + return_value=1, + new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.BufferIsVisible', + return_value=True, + new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.OpenFilename' ) +@patch( 'ycm.vimsupport.EchoTextVimWidth', new_callable=ExtendedMock ) +@patch( 'vim.eval', new_callable=ExtendedMock ) +@patch( 'vim.command', new_callable=ExtendedMock ) +def ReplaceChunks_SingleFile_Open_test( vim_command, + vim_eval, + echo_text_vim_width, + open_filename, + buffer_is_visible, + get_buffer_number_for_filename ): + + chunks = [ + _BuildChunk( 1, 1, 2, 1, 'replacement', 'single_file' ) + ] + + result_buffer = MockBuffer( [ + 'line1', + 'line2', + 'line3', + ], 'single_file', 1 ) + + with patch( 'vim.buffers', [ None, result_buffer, None ] ): + vimsupport.ReplaceChunks( chunks ) + + # Ensure that we applied the replacement correctly + eq_( result_buffer.lines, [ + 'replacementline2', + 'line3', + ] ) + + # GetBufferNumberForFilename is called twice: + # - once to the check if we would require opening the file (so that we can + # raise a warning) + # - once whilst applying the changes + get_buffer_number_for_filename.assert_has_exact_calls( [ + call( 'single_file', False ), + call( 'single_file', False ), + ] ) + + # BufferIsVisible is called twice for the same reasons as above + buffer_is_visible.assert_has_exact_calls( [ + call( 1 ), + call( 1 ), + ] ) + + # we don't attempt to open any files + open_filename.assert_not_called() + + # But we do set the quickfix list + vim_eval.assert_has_exact_calls( [ + call( 'setqflist( {0} )'.format( json.dumps( [ { + 'bufnr': 1, + 'filename': 'single_file', + 'lnum': 1, + 'col': 1, + 'text': 'replacement', + 'type': 'F' + } ] ) ) ), + ] ) + vim_command.assert_has_calls( [ + call( 'copen 1' ) + ] ) + + # And it is ReplaceChunks that prints the message showing the number of + # changes + echo_text_vim_width.assert_has_exact_calls( [ + call( 'Applied 1 changes' ), + ] ) + + +@patch( 'ycm.vimsupport.GetBufferNumberForFilename', + side_effect=[ -1, -1, 1 ], + new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.BufferIsVisible', + side_effect=[ False, False, True ], + new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.OpenFilename', + new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.EchoTextVimWidth', new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.Confirm', + return_value=True, + new_callable=ExtendedMock ) +@patch( 'vim.eval', return_value=10, new_callable=ExtendedMock ) +@patch( 'vim.command', new_callable=ExtendedMock ) +def ReplaceChunks_SingleFile_NotOpen_test( vim_command, + vim_eval, + confirm, + echo_text_vim_width, + open_filename, + buffer_is_visible, + get_buffer_number_for_filename ): + + chunks = [ + _BuildChunk( 1, 1, 2, 1, 'replacement', 'single_file' ) + ] + + result_buffer = MockBuffer( [ + 'line1', + 'line2', + 'line3', + ], 'single_file', 1 ) + + with patch( 'vim.buffers', [ None, result_buffer, None ] ): + vimsupport.ReplaceChunks( chunks ) + + # We checked if it was OK to open the file + confirm.assert_has_exact_calls( [ + call( vimsupport.FIXIT_OPENING_BUFFERS_MESSAGE_FORMAT.format( 1 ) ) + ] ) + + # Ensure that we applied the replacement correctly + eq_( result_buffer.lines, [ + 'replacementline2', + 'line3', + ] ) + + # GetBufferNumberForFilename is called 3 times. The return values are set in + # the @patch call above: + # - once to the check if we would require opening the file (so that we can + # raise a warning) (-1 return) + # - once whilst applying the changes (-1 return) + # - finally after calling OpenFilename (1 return) + get_buffer_number_for_filename.assert_has_exact_calls( [ + call( 'single_file', False ), + call( 'single_file', False ), + call( 'single_file', False ), + ] ) + + # BufferIsVisible is called 3 times for the same reasons as above, with the + # return of each one + buffer_is_visible.assert_has_exact_calls( [ + call( -1 ), + call( -1 ), + call( 1 ), + ] ) + + # We open 'single_file' as expected. + open_filename.assert_called_with( 'single_file', { + 'focus': True, + 'fix': True, + 'size': 10 + } ) + + # And close it again, then show the preview window (note, we don't check exact + # calls because there are other calls which are checked elsewhere) + vim_command.assert_has_calls( [ + call( 'lclose' ), + call( 'hide' ), + call( 'copen 1' ), + ] ) + + # And update the quickfix list + vim_eval.assert_has_exact_calls( [ + call( '&previewheight' ), + call( 'setqflist( {0} )'.format( json.dumps( [ { + 'bufnr': 1, + 'filename': 'single_file', + 'lnum': 1, + 'col': 1, + 'text': 'replacement', + 'type': 'F' + } ] ) ) ), + ] ) + + # And it is ReplaceChunks that prints the message showing the number of + # changes + echo_text_vim_width.assert_has_exact_calls( [ + call( 'Applied 1 changes' ), + ] ) + + +@patch( 'ycm.vimsupport.GetBufferNumberForFilename', + side_effect=[ -1, -1, 1 ], + new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.BufferIsVisible', + side_effect=[ False, False, True ], + new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.OpenFilename', + new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.EchoTextVimWidth', + new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.Confirm', + return_value=False, + new_callable=ExtendedMock ) +@patch( 'vim.eval', + return_value=10, + new_callable=ExtendedMock ) +@patch( 'vim.command', new_callable=ExtendedMock ) +def ReplaceChunks_User_Declines_To_Open_File_test( + vim_command, + vim_eval, + confirm, + echo_text_vim_width, + open_filename, + buffer_is_visible, + get_buffer_number_for_filename ): + + # Same as above, except the user selects Cancel when asked if they should + # allow us to open lots of (ahem, 1) file. + + chunks = [ + _BuildChunk( 1, 1, 2, 1, 'replacement', 'single_file' ) + ] + + result_buffer = MockBuffer( [ + 'line1', + 'line2', + 'line3', + ], 'single_file', 1 ) + + with patch( 'vim.buffers', [ None, result_buffer, None ] ): + vimsupport.ReplaceChunks( chunks ) + + # We checked if it was OK to open the file + confirm.assert_has_exact_calls( [ + call( vimsupport.FIXIT_OPENING_BUFFERS_MESSAGE_FORMAT.format( 1 ) ) + ] ) + + # Ensure that buffer is not changed + eq_( result_buffer.lines, [ + 'line1', + 'line2', + 'line3', + ] ) + + # GetBufferNumberForFilename is called once. The return values are set in + # the @patch call above: + # - once to the check if we would require opening the file (so that we can + # raise a warning) (-1 return) + get_buffer_number_for_filename.assert_has_exact_calls( [ + call( 'single_file', False ), + ] ) + + # BufferIsVisible is called once for the above file, which wasn't visible. + buffer_is_visible.assert_has_exact_calls( [ + call( -1 ), + ] ) + + # We don't attempt to open any files or update any quickfix list or anything + # like that + open_filename.assert_not_called() + vim_eval.assert_not_called() + vim_command.assert_not_called() + echo_text_vim_width.assert_not_called() + + +@patch( 'ycm.vimsupport.GetBufferNumberForFilename', + side_effect=[ -1, -1, 1 ], + new_callable=ExtendedMock ) +# Key difference is here: In the final check, BufferIsVisible returns False +@patch( 'ycm.vimsupport.BufferIsVisible', + side_effect=[ False, False, False ], + new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.OpenFilename', + new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.EchoTextVimWidth', + new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.Confirm', + return_value=True, + new_callable=ExtendedMock ) +@patch( 'vim.eval', + return_value=10, + new_callable=ExtendedMock ) +@patch( 'vim.command', + new_callable=ExtendedMock ) +def ReplaceChunks_User_Aborts_Opening_File_test( + vim_command, + vim_eval, + confirm, + echo_text_vim_width, + open_filename, + buffer_is_visible, + get_buffer_number_for_filename ): + + # Same as above, except the user selects Abort or Quick during the + # "swap-file-found" dialog + + chunks = [ + _BuildChunk( 1, 1, 2, 1, 'replacement', 'single_file' ) + ] + + result_buffer = MockBuffer( [ + 'line1', + 'line2', + 'line3', + ], 'single_file', 1 ) + + with patch( 'vim.buffers', [ None, result_buffer, None ] ): + assert_that( calling( vimsupport.ReplaceChunks ).with_args( chunks ), + raises( RuntimeError, + 'Unable to open file: single_file\nFixIt/Refactor operation ' + 'aborted prior to completion. Your files have not been ' + 'fully updated. Please use undo commands to revert the ' + 'applied changes.' ) ) + + # We checked if it was OK to open the file + confirm.assert_has_exact_calls( [ + call( vimsupport.FIXIT_OPENING_BUFFERS_MESSAGE_FORMAT.format( 1 ) ) + ] ) + + # Ensure that buffer is not changed + eq_( result_buffer.lines, [ + 'line1', + 'line2', + 'line3', + ] ) + + # We tried to open this file + open_filename.assert_called_with( "single_file", { + 'focus': True, + 'fix': True, + 'size': 10 + } ) + vim_eval.assert_called_with( "&previewheight" ) + + # But raised an exception before issuing the message at the end + echo_text_vim_width.assert_not_called() + + +@patch( 'ycm.vimsupport.GetBufferNumberForFilename', side_effect=[ + 22, # first_file (check) + -1, # another_file (check) + 22, # first_file (apply) + -1, # another_file (apply) + 19, # another_file (check after open) + ], + new_callable=ExtendedMock ) +@patch( 'ycm.vimsupport.BufferIsVisible', side_effect=[ + True, # first_file (check) + False, # second_file (check) + True, # first_file (apply) + False, # second_file (apply) + True, # side_effect (check after open) + ], + new_callable=ExtendedMock) +@patch( 'ycm.vimsupport.OpenFilename', + new_callable=ExtendedMock) +@patch( 'ycm.vimsupport.EchoTextVimWidth', + new_callable=ExtendedMock) +@patch( 'ycm.vimsupport.Confirm', return_value=True, + new_callable=ExtendedMock) +@patch( 'vim.eval', return_value=10, + new_callable=ExtendedMock) +@patch( 'vim.command', + new_callable=ExtendedMock) +def ReplaceChunks_MultiFile_Open_test( vim_command, + vim_eval, + confirm, + echo_text_vim_width, + open_filename, + buffer_is_visible, + get_buffer_number_for_filename ): + + # Chunks are split across 2 files, one is already open, one isn't + + chunks = [ + _BuildChunk( 1, 1, 2, 1, 'first_file_replacement ', '1_first_file' ), + _BuildChunk( 2, 1, 2, 1, 'second_file_replacement ', '2_another_file' ), + ] + + first_file = MockBuffer( [ + 'line1', + 'line2', + 'line3', + ], '1_first_file', 22 ) + another_file = MockBuffer( [ + 'another line1', + 'ACME line2', + ], '2_another_file', 19 ) + + vim_buffers = [ None ] * 23 + vim_buffers[ 22 ] = first_file + vim_buffers[ 19 ] = another_file + + with patch( 'vim.buffers', vim_buffers ): + vimsupport.ReplaceChunks( chunks ) + + # We checked for the right file names + get_buffer_number_for_filename.assert_has_exact_calls( [ + call( '1_first_file', False ), + call( '2_another_file', False ), + call( '1_first_file', False ), + call( '2_another_file', False ), + call( '2_another_file', False ), + ] ) + + # We checked if it was OK to open the file + confirm.assert_has_exact_calls( [ + call( vimsupport.FIXIT_OPENING_BUFFERS_MESSAGE_FORMAT.format( 1 ) ) + ] ) + + # Ensure that buffers are updated + eq_( another_file.lines, [ + 'another line1', + 'second_file_replacement ACME line2', + ] ) + eq_( first_file.lines, [ + 'first_file_replacement line2', + 'line3', + ] ) + + # We open '2_another_file' as expected. + open_filename.assert_called_with( '2_another_file', { + 'focus': True, + 'fix': True, + 'size': 10 + } ) + + # And close it again, then show the preview window (note, we don't check exact + # calls because there are other calls which are checked elsewhere) + vim_command.assert_has_calls( [ + call( 'lclose' ), + call( 'hide' ), + call( 'copen 2' ), + ] ) + + # And update the quickfix list with each entry + vim_eval.assert_has_exact_calls( [ + call( '&previewheight' ), + call( 'setqflist( {0} )'.format( json.dumps( [ { + 'bufnr': 22, + 'filename': '1_first_file', + 'lnum': 1, + 'col': 1, + 'text': 'first_file_replacement ', + 'type': 'F' + }, { + 'bufnr': 19, + 'filename': '2_another_file', + 'lnum': 2, + 'col': 1, + 'text': 'second_file_replacement ', + 'type': 'F' + } ] ) ) ), + ] ) + + # And it is ReplaceChunks that prints the message showing the number of + # changes + echo_text_vim_width.assert_has_exact_calls( [ + call( 'Applied 2 changes' ), + ] ) + + +def _BuildChunk( start_line, + start_column, + end_line, + end_column, + replacement_text, filepath='test_file_name' ): return { 'range': { 'start': { + 'filepath': filepath, 'line_num': start_line, 'column_num': start_column, }, 'end': { + 'filepath': filepath, 'line_num': end_line, 'column_num': end_column, }, @@ -582,14 +1058,14 @@ def _BuildChunk( start_line, start_column, end_line, end_column, } -@patch( 'vim.command' ) -@patch( 'vim.current' ) +@patch( 'vim.command', new_callable=ExtendedMock ) +@patch( 'vim.current', new_callable=ExtendedMock) def WriteToPreviewWindow_test( vim_current, vim_command ): vim_current.window.options.__getitem__ = MagicMock( return_value = True ) vimsupport.WriteToPreviewWindow( "test" ) - vim_command.assert_has_calls( [ + vim_command.assert_has_exact_calls( [ call( 'silent! pclose!' ), call( 'silent! pedit! _TEMP_FILE_' ), call( 'silent! wincmd P' ), @@ -598,7 +1074,9 @@ def WriteToPreviewWindow_test( vim_current, vim_command ): vim_current.buffer.__setitem__.assert_called_with( slice( None, None, None ), [ 'test' ] ) - vim_current.buffer.options.__setitem__.assert_has_calls( [ + vim_current.buffer.options.__setitem__.assert_has_exact_calls( [ + call( 'modifiable', True ), + call( 'readonly', False ), call( 'buftype', 'nofile' ), call( 'swapfile', False ), call( 'modifiable', False ), @@ -616,14 +1094,14 @@ def WriteToPreviewWindow_MultiLine_test( vim_current ): slice( None, None, None ), [ 'test', 'test2' ] ) -@patch( 'vim.command' ) -@patch( 'vim.current' ) +@patch( 'vim.command', new_callable=ExtendedMock ) +@patch( 'vim.current', new_callable=ExtendedMock ) def WriteToPreviewWindow_JumpFail_test( vim_current, vim_command ): vim_current.window.options.__getitem__ = MagicMock( return_value = False ) vimsupport.WriteToPreviewWindow( "test" ) - vim_command.assert_has_calls( [ + vim_command.assert_has_exact_calls( [ call( 'silent! pclose!' ), call( 'silent! pedit! _TEMP_FILE_' ), call( 'silent! wincmd P' ), @@ -634,15 +1112,15 @@ def WriteToPreviewWindow_JumpFail_test( vim_current, vim_command ): vim_current.buffer.options.__setitem__.assert_not_called() -@patch( 'vim.command' ) -@patch( 'vim.current' ) +@patch( 'vim.command', new_callable=ExtendedMock ) +@patch( 'vim.current', new_callable=ExtendedMock ) def WriteToPreviewWindow_JumpFail_MultiLine_test( vim_current, vim_command ): vim_current.window.options.__getitem__ = MagicMock( return_value = False ) vimsupport.WriteToPreviewWindow( "test\ntest2" ) - vim_command.assert_has_calls( [ + vim_command.assert_has_exact_calls( [ call( 'silent! pclose!' ), call( 'silent! pedit! _TEMP_FILE_' ), call( 'silent! wincmd P' ), @@ -689,7 +1167,9 @@ def BufferIsVisibleForFilename_test(): eq_( vimsupport.BufferIsVisibleForFilename( 'another_filename' ), False ) -@patch( 'vim.command', side_effect = MockVimCommand ) +@patch( 'vim.command', + side_effect = MockVimCommand, + new_callable=ExtendedMock ) def CloseBuffersForFilename_test( vim_command ): buffers = [ { @@ -709,14 +1189,14 @@ def CloseBuffersForFilename_test( vim_command ): with patch( 'vim.buffers', buffers ): vimsupport.CloseBuffersForFilename( 'some_filename' ) - vim_command.assert_has_calls( [ + vim_command.assert_has_exact_calls( [ call( 'silent! bwipeout! 2' ), call( 'silent! bwipeout! 5' ) ], any_order = True ) -@patch( 'vim.command' ) -@patch( 'vim.current' ) +@patch( 'vim.command', new_callable=ExtendedMock ) +@patch( 'vim.current', new_callable=ExtendedMock ) def OpenFilename_test( vim_current, vim_command ): # Options used to open a logfile options = { @@ -728,18 +1208,18 @@ def OpenFilename_test( vim_current, vim_command ): vimsupport.OpenFilename( __file__, options ) - vim_command.assert_has_calls( [ - call( 'silent! 12split {0}'.format( __file__ ) ), + vim_command.assert_has_exact_calls( [ + call( '12split {0}'.format( __file__ ) ), call( "exec " "'au BufEnter :silent! checktime {0}'".format( __file__ ) ), call( 'silent! normal G zz' ), call( 'silent! wincmd p' ) ] ) - vim_current.buffer.options.__setitem__.assert_has_calls( [ + vim_current.buffer.options.__setitem__.assert_has_exact_calls( [ call( 'autoread', True ), ] ) - vim_current.window.options.__setitem__.assert_has_calls( [ + vim_current.window.options.__setitem__.assert_has_exact_calls( [ call( 'winfixheight', True ) ] ) diff --git a/python/ycm/vimsupport.py b/python/ycm/vimsupport.py index 64bf0978..015312b2 100644 --- a/python/ycm/vimsupport.py +++ b/python/ycm/vimsupport.py @@ -20,6 +20,7 @@ import os import tempfile import json import re +from collections import defaultdict from ycmd.utils import ToUtf8IfNeeded from ycmd import user_options_store @@ -28,6 +29,13 @@ BUFFER_COMMAND_MAP = { 'same-buffer' : 'edit', 'vertical-split' : 'vsplit', 'new-tab' : 'tabedit' } +FIXIT_OPENING_BUFFERS_MESSAGE_FORMAT = ( + 'The requested operation will apply changes to {0} files which are not ' + 'currently open. This will therefore open {0} new files in the hidden ' + 'buffers. The quickfix list can then be used to review the changes. No ' + 'files will be written to disk. Do you wish to continue?' ) + + def CurrentLineAndColumn(): """Returns the 0-based current line and 0-based current column.""" # See the comment in CurrentColumn about the calculation for the line and @@ -236,6 +244,15 @@ def SetLocationList( diagnostics ): vim.eval( 'setloclist( 0, {0} )'.format( json.dumps( diagnostics ) ) ) +def SetQuickFixList( quickfix_list, display=False ): + """list should be in qflist format: see ":h setqflist" for details""" + vim.eval( 'setqflist( {0} )'.format( json.dumps( quickfix_list ) ) ) + + if display: + vim.command( 'copen {0}'.format( len( quickfix_list ) ) ) + JumpToPreviousWindow() + + def ConvertDiagnosticsToQfList( diagnostics ): def ConvertDiagnosticToQfFormat( diagnostic ): # See :h getqflist for a description of the dictionary fields. @@ -428,6 +445,8 @@ def PresentDialog( message, choices, default_choice_index = 0 ): def Confirm( message ): + """Display |message| with Ok/Cancel operations. Returns True if the user + selects Ok""" return bool( PresentDialog( message, [ "Ok", "Cancel" ] ) == 0 ) @@ -449,6 +468,7 @@ def EchoTextVimWidth( text ): old_ruler = GetIntValue( '&ruler' ) old_showcmd = GetIntValue( '&showcmd' ) vim.command( 'set noruler noshowcmd' ) + vim.command( 'redraw' ) EchoText( truncated_text, False ) @@ -490,9 +510,145 @@ def GetIntValue( variable ): return int( vim.eval( variable ) ) -def ReplaceChunksList( chunks, vim_buffer = None ): - if vim_buffer is None: - vim_buffer = vim.current.buffer +def _SortChunksByFile( chunks ): + """Sort the members of the list |chunks| (which must be a list of dictionaries + conforming to ycmd.responses.FixItChunk) by their filepath. Returns a new + list in arbitrary order.""" + + chunks_by_file = defaultdict( list ) + + for chunk in chunks: + filepath = chunk[ 'range' ][ 'start' ][ 'filepath' ] + chunks_by_file[ filepath ].append( chunk ) + + return chunks_by_file + + +def _GetNumNonVisibleFiles( file_list ): + """Returns the number of file in the iterable list of files |file_list| which + are not curerntly open in visible windows""" + return len( + [ f for f in file_list + if not BufferIsVisible( GetBufferNumberForFilename( f, False ) ) ] ) + + +def _OpenFileInSplitIfNeeded( filepath ): + """Ensure that the supplied filepath is open in a visible window, opening a + new split if required. Returns the buffer number of the file and an indication + of whether or not a new split was opened. + + If the supplied filename is already open in a visible window, return just + return its buffer number. If the supplied file is not visible in a window + in the current tab, opens it in a new vertical split. + + Returns a tuple of ( buffer_num, split_was_opened ) indicating the buffer + number and whether or not this method created a new split. If the user opts + not to open a file, or if opening fails, this method raises RuntimeError, + otherwise, guarantees to return a visible buffer number in buffer_num.""" + + buffer_num = GetBufferNumberForFilename( filepath, False ) + + # We only apply changes in the current tab page (i.e. "visible" windows). + # Applying changes in tabs does not lead to a better user experience, as the + # quickfix list no longer works as you might expect (doesn't jump into other + # tabs), and the complexity of choosing where to apply edits is significant. + if BufferIsVisible( buffer_num ): + # file is already open and visible, just return that buffer number (and an + # idicator that we *didn't* open a split) + return ( buffer_num, False ) + + # The file is not open in a visible window, so we open it in a split. + # We open the file with a small, fixed height. This means that we don't + # make the current buffer the smallest after a series of splits. + OpenFilename( filepath, { + 'focus': True, + 'fix': True, + 'size': GetIntValue( '&previewheight' ), + } ) + + # OpenFilename returns us to the original cursor location. This is what we + # want, because we don't want to disorientate the user, but we do need to + # know the (now open) buffer number for the filename + buffer_num = GetBufferNumberForFilename( filepath, False ) + if not BufferIsVisible( buffer_num ): + # This happens, for example, if there is a swap file and the user + # selects the "Quit" or "Abort" options. We just raise an exception to + # make it clear to the user that the abort has left potentially + # partially-applied changes. + raise RuntimeError( + 'Unable to open file: {0}\nFixIt/Refactor operation ' + 'aborted prior to completion. Your files have not been ' + 'fully updated. Please use undo commands to revert the ' + 'applied changes.'.format( filepath ) ) + + # We opened this file in a split + return ( buffer_num, True ) + + +def ReplaceChunks( chunks ): + """Apply the source file deltas supplied in |chunks| to arbitrary files. + |chunks| is a list of changes defined by ycmd.responses.FixItChunk, + which may apply arbitrary modifications to arbitrary files. + + If a file specified in a particular chunk is not currently open in a visible + buffer (i.e., one in a window visible in the current tab), we: + - issue a warning to the user that we're going to open new files (and offer + her the option to abort cleanly) + - open the file in a new split, make the changes, then hide the buffer. + + If for some reason a file could not be opened or changed, raises RuntimeError. + Otherwise, returns no meaningful value.""" + + # We apply the edits file-wise for efficiency, and because we must track the + # file-wise offset deltas (caused by the modifications to the text). + chunks_by_file = _SortChunksByFile( chunks ) + + # We sort the file list simply to enable repeatable testing + sorted_file_list = sorted( chunks_by_file.iterkeys() ) + + # Make sure the user is prepared to have her screen mutilated by the new + # buffers + num_files_to_open = _GetNumNonVisibleFiles( sorted_file_list ) + + if num_files_to_open > 0: + if not Confirm( + FIXIT_OPENING_BUFFERS_MESSAGE_FORMAT.format( num_files_to_open ) ): + return + + # Store the list of locations where we applied changes. We use this to display + # the quickfix window showing the user where we applied changes. + locations = [] + + for filepath in sorted_file_list: + ( buffer_num, close_window ) = _OpenFileInSplitIfNeeded( filepath ) + + ReplaceChunksInBuffer( chunks_by_file[ filepath ], + vim.buffers[ buffer_num ], + locations ) + + # When opening tons of files, we don't want to have a split for each new + # file, as this simply does not scale, so we open the window, make the + # edits, then hide the window. + if close_window: + # Some plugins (I'm looking at you, syntastic) might open a location list + # for the window we just opened. We don't want that location list hanging + # around, so we close it. lclose is a no-op if there is no location list. + vim.command( 'lclose' ) + + # Note that this doesn't lose our changes. It simply "hides" the buffer, + # which can later be re-accessed via the quickfix list or `:ls` + vim.command( 'hide' ) + + # Open the quickfix list, populated with entries for each location we changed. + if locations: + SetQuickFixList( locations, True ) + + EchoTextVimWidth( "Applied " + str( len( chunks ) ) + " changes" ) + + +def ReplaceChunksInBuffer( chunks, vim_buffer, locations ): + """Apply changes in |chunks| to the buffer-like object |buffer|. Append each + chunk's start to the list |locations|""" # We need to track the difference in length, but ensuring we apply fixes # in ascending order of insertion point. @@ -519,7 +675,8 @@ def ReplaceChunksList( chunks, vim_buffer = None ): chunk[ 'range' ][ 'end' ], chunk[ 'replacement_text' ], line_delta, char_delta, - vim_buffer ) + vim_buffer, + locations ) line_delta += new_line_delta char_delta += new_char_delta @@ -534,11 +691,12 @@ def ReplaceChunksList( chunks, vim_buffer = None ): # returns the delta (in lines and characters) that any position after the end # needs to be adjusted by. def ReplaceChunk( start, end, replacement_text, line_delta, char_delta, - vim_buffer ): + vim_buffer, locations = None ): # ycmd's results are all 1-based, but vim's/python's are all 0-based # (so we do -1 on all of the values) start_line = start[ 'line_num' ] - 1 + line_delta end_line = end[ 'line_num' ] - 1 + line_delta + source_lines_count = end_line - start_line + 1 start_column = start[ 'column_num' ] - 1 + char_delta end_column = end[ 'column_num' ] - 1 @@ -563,6 +721,17 @@ def ReplaceChunk( start, end, replacement_text, line_delta, char_delta, vim_buffer[ start_line : end_line + 1 ] = replacement_lines[:] + if locations is not None: + locations.append( { + 'bufnr': vim_buffer.number, + 'filename': vim_buffer.name, + # line and column numbers are 1-based in qflist + 'lnum': start_line + 1, + 'col': start_column + 1, + 'text': replacement_text, + 'type': 'F', + } ) + new_line_delta = replacement_lines_count - source_lines_count return ( new_line_delta, new_char_delta ) @@ -710,30 +879,39 @@ def OpenFilename( filename, options = {} ): size = ( options.get( 'size', '' ) if command in [ 'split', 'vsplit' ] else '' ) focus = options.get( 'focus', False ) - watch = options.get( 'watch', False ) - position = options.get( 'position', 'start' ) # There is no command in Vim to return to the previous tab so we need to # remember the current tab if needed. if not focus and command is 'tabedit': previous_tab = GetIntValue( 'tabpagenr()' ) + else: + previous_tab = None # Open the file CheckFilename( filename ) - vim.command( 'silent! {0}{1} {2}'.format( size, command, filename ) ) + try: + vim.command( '{0}{1} {2}'.format( size, command, filename ) ) + # When the file we are trying to jump to has a swap file, + # Vim opens swap-exists-choices dialog and throws vim.error with E325 error, + # or KeyboardInterrupt after user selects one of the options which actually + # opens the file (Open read-only/Edit anyway). + except vim.error as e: + if 'E325' not in str( e ): + raise - if command is 'split': - vim.current.window.options[ 'winfixheight' ] = options.get( 'fix', False ) - if command is 'vsplit': - vim.current.window.options[ 'winfixwidth' ] = options.get( 'fix', False ) + # Otherwise, the user might have chosen Quit. This is detectable by the + # current file not being the target file + if filename != GetCurrentBufferFilepath(): + return + except KeyboardInterrupt: + # Raised when the user selects "Abort" after swap-exists-choices + return - if watch: - vim.current.buffer.options[ 'autoread' ] = True - vim.command( "exec 'au BufEnter :silent! checktime {0}'" - .format( filename ) ) - - if position is 'end': - vim.command( 'silent! normal G zz' ) + _SetUpLoadedBuffer( command, + filename, + options.get( 'fix', False ), + options.get( 'position', 'start' ), + options.get( 'watch', False ) ) # Vim automatically set the focus to the opened file so we need to get the # focus back (if the focus option is disabled) when opening a new tab or @@ -743,3 +921,22 @@ def OpenFilename( filename, options = {} ): JumpToTab( previous_tab ) if command in [ 'split', 'vsplit' ]: JumpToPreviousWindow() + + +def _SetUpLoadedBuffer( command, filename, fix, position, watch ): + """After opening a buffer, configure it according to the supplied options, + which are as defined by the OpenFilename method.""" + + if command is 'split': + vim.current.window.options[ 'winfixheight' ] = fix + if command is 'vsplit': + vim.current.window.options[ 'winfixwidth' ] = fix + + if watch: + vim.current.buffer.options[ 'autoread' ] = True + vim.command( "exec 'au BufEnter :silent! checktime {0}'" + .format( filename ) ) + + if position is 'end': + vim.command( 'silent! normal G zz' ) +