From 00b01be9a3fb52c6ba39d4b323a9b8a8cf10ff58 Mon Sep 17 00:00:00 2001 From: Boris Staletic Date: Sun, 11 Aug 2024 07:36:16 +0200 Subject: [PATCH] Add support codeAction/resolve requests So... We advertise that we can handle CodeActions that are either missing an edit or a command (or both). We also advertise that we will preserve server `data` that we receive in a CodeAction, when we resolve the said fixit. This allows more servers to take advantage of the lazy code actions. The server chooses whether it supports either of the two. If it does, it advertises itself as a "code action resolve provider". For servers that are code action resolve providers: - If either edit or command is missing, we need to try resolving the code action via codeAction/resolve. - Unless we actually got a LSP Command, in which case codeAction/resove is skipped. - After resolving it that way we have a CodeAction in one of these forms: - A LSP Command - A LSP CodeAction with only an edit. - A LSP CodeAction with only a command. - A LSP CodeAction with both an edit and a command. - Edits are WorkspaceEdits and can easily be converted into ycmd FixIts. - Commands are to be executed, yielding ApplyEdits. A single ApplyEdit can be converted into a ycmd FixIt. For servers that are not code action resolve providers, the steps are the same, but we skip the codeAction/resolve route. One thing missing is properly defined handling of fixit resolutions that yield multiple fixits. That can happen in a few ways: - On /resolve_fixit, by a LSP command yielding multiple ApplyEdits. - When eagerly resolving a fixit, again by a LSP command yielding multiple ApplyEdits. - Even if all commands always yield a single ApplyEdit, if a CodeAction has both an edit and a command, that's again two fixits. The first two above don't seem to be used by any server ever. The LSP specs nudges servers away from doing that, but no promises. We are still not supporting any scenario where resolving a single fixit results in more than one fixit. Another scenario that does not seem to happen: - The server is a code action resove provider. - The received CodeAction has neither an edit nor a command. - After resolving, we get only a command. - We then need to execute the command and collect ApplyEdits. In practice, such code actions resolve to a CodeAction containing an edit. As for the ycmd<->client side of things... it's a bit ugly on the ycmd side, but we're completely preserving the API, so clients do not need to change a thing. Previously, clients got `{ 'fixits': [ { 'command': LSPCommand ... } ] }` for unresolved fixits. However, we have not given any guarantees about the value of `'command'`. We have only said that it should be returned to ycmd for the purposes of `/resolve_fixit`. With this pull request, we need to pass the entire CodeAction, but we're still putting it in the `command` key. --- ycmd/completers/java/java_completer.py | 17 +- .../language_server_completer.py | 85 ++++-- .../language_server_protocol.py | 11 +- ycmd/tests/clangd/subcommands_test.py | 3 +- ycmd/tests/go/subcommands_test.py | 29 +- ycmd/tests/java/subcommands_test.py | 275 +++++++++--------- ycmd/tests/rust/subcommands_test.py | 22 +- 7 files changed, 258 insertions(+), 184 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 3d023ec557..fd964e38b5 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -24,7 +24,6 @@ import threading from ycmd import responses, utils -from ycmd.completers.language_server import language_server_protocol as lsp from ycmd.completers.language_server import language_server_completer from ycmd.utils import LOGGER @@ -624,15 +623,13 @@ def GetDoc( self, request_data ): def OrganizeImports( self, request_data ): - fixit = { - 'resolve': True, - 'command': { - 'title': 'Organize Imports', - 'command': 'java.edit.organizeImports', - 'arguments': [ lsp.FilePathToUri( request_data[ 'filepath' ] ) ] - } - } - return self._ResolveFixit( request_data, fixit ) + fixits = super().GetCodeActions( request_data )[ 'fixits' ] + for fixit in fixits: + if fixit[ 'command' ][ 'kind' ] == 'source.organizeImports': + return self._ResolveFixit( request_data, fixit ) + # We should never get here. With codeAction/resolve support, + # JDT always sends the organizeImports code action. + raise RuntimeError( 'OrganizeImports not available.' ) def CodeActionCommandToFixIt( self, request_data, command ): diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 97d0933022..cd2b1317bc 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -2850,7 +2850,6 @@ def GetCodeActions( self, request_data ): cursor_range_ls, matched_diagnostics ), REQUEST_TIMEOUT_COMMAND ) - return self.CodeActionResponseToFixIts( request_data, code_actions[ 'result' ] ) @@ -2861,28 +2860,22 @@ def CodeActionResponseToFixIts( self, request_data, code_actions ): fixits = [] for code_action in code_actions: - if 'edit' in code_action: - # TODO: Start supporting a mix of WorkspaceEdits and Commands - # once there's a need for such - assert 'command' not in code_action - - # This is a WorkspaceEdit literal - fixits.append( self.CodeActionLiteralToFixIt( request_data, - code_action ) ) - continue - - # Either a CodeAction or a Command - assert 'command' in code_action - - action_command = code_action[ 'command' ] - if isinstance( action_command, dict ): - # CodeAction with a 'command' rather than 'edit' - fixits.append( self.CodeActionCommandToFixIt( request_data, - code_action ) ) + capabilities = self._server_capabilities[ 'codeActionProvider' ] + if ( ( isinstance( capabilities, dict ) and + capabilities.get( 'resolveProvider' ) ) or + 'command' in code_action ): + # If server is a code action resolve provider, either we are obligated + # to resolve, or we have a command in the code action response. + # If server does not want us to resolve, but sends a command anyway, + # we still need to lazily execute that command. + fixits.append( responses.UnresolvedFixIt( code_action, + code_action[ 'title' ], + code_action.get( 'kind' ) ) ) continue + # No resoving here - just a simple code action literal. + fixits.append( self.CodeActionLiteralToFixIt( request_data, + code_action ) ) - # It is a Command - fixits.append( self.CommandToFixIt( request_data, code_action ) ) # Show a list of actions to the user to select which one to apply. # This is (probably) a more common workflow for "code action". @@ -2986,10 +2979,44 @@ def Format( self, request_data ): def _ResolveFixit( self, request_data, fixit ): - if not fixit[ 'resolve' ]: - return { 'fixits': [ fixit ] } + code_action = fixit[ 'command' ] + capabilities = self._server_capabilities[ 'codeActionProvider' ] + if ( isinstance( capabilities, dict ) and + capabilities.get( 'resolveProvider' ) ): + # Resolve through codeAction/resolve request, before resolving commands. + # If the server is an asshole, it might be a code action resolve + # provider, but send a LSP Command instead. We can not resolve those with + # codeAction/resolve! + if ( 'command' not in code_action or + isinstance( code_action[ 'command' ], str ) ): + request_id = self.GetConnection().NextRequestId() + msg = lsp.CodeActionResolve( request_id, code_action ) + code_action = self.GetConnection().GetResponse( + request_id, + msg, + REQUEST_TIMEOUT_COMMAND )[ 'result' ] - unresolved_fixit = fixit[ 'command' ] + result = [] + if 'edit' in code_action: + result.append( self.CodeActionLiteralToFixIt( request_data, + code_action ) ) + + if 'command' in code_action: + assert not result, 'Code actions with edit and command is not supported.' + if isinstance( code_action[ 'command' ], str ): + unresolved_command_fixit = self.CommandToFixIt( request_data, + code_action ) + else: + unresolved_command_fixit = self.CodeActionCommandToFixIt( request_data, + code_action ) + result.append( self._ResolveFixitCommand( request_data, + unresolved_command_fixit ) ) + + return responses.BuildFixItResponse( result ) + + + def _ResolveFixitCommand( self, request_data, fixit ): + unresolved_fixit = fixit.command collector = EditCollector() with self.GetConnection().CollectApplyEdits( collector ): self.GetCommandResponse( @@ -3001,19 +3028,23 @@ def _ResolveFixit( self, request_data, fixit ): response = collector.requests assert len( response ) < 2 if not response: - return responses.BuildFixItResponse( [ responses.FixIt( + return responses.FixIt( responses.Location( request_data[ 'line_num' ], request_data[ 'column_num' ], request_data[ 'filepath' ] ), - [] ) ] ) + [] ) fixit = WorkspaceEditToFixIt( request_data, response[ 0 ][ 'edit' ], unresolved_fixit[ 'title' ] ) - return responses.BuildFixItResponse( [ fixit ] ) + return fixit def ResolveFixit( self, request_data ): + fixit = request_data[ 'fixit' ] + if 'command' not in fixit: + # Somebody has sent us an already resolved fixit. + return { 'fixits': [ fixit ] } return self._ResolveFixit( request_data, request_data[ 'fixit' ] ) diff --git a/ycmd/completers/language_server/language_server_protocol.py b/ycmd/completers/language_server/language_server_protocol.py index d0171a50e5..365f8a04c5 100644 --- a/ycmd/completers/language_server/language_server_protocol.py +++ b/ycmd/completers/language_server/language_server_protocol.py @@ -326,8 +326,13 @@ def Initialize( request_id, 'refactor.inline', 'refactor.rewrite', 'source', - 'source.organizeImports' ] + 'source.organizeImports', + 'source.fixAll' ] } + }, + 'dataSupport': True, + 'resolveSupport': { + 'properties': [ 'edit', 'command' ] } }, 'completion': { @@ -580,6 +585,10 @@ def CodeAction( request_id, request_data, best_match_range, diagnostics ): } ) +def CodeActionResolve( request_id, code_action ): + return BuildRequest( request_id, 'codeAction/resolve', code_action ) + + def Rename( request_id, request_data, new_name ): return BuildRequest( request_id, 'textDocument/rename', { 'textDocument': TextDocumentIdentifier( request_data ), diff --git a/ycmd/tests/clangd/subcommands_test.py b/ycmd/tests/clangd/subcommands_test.py index 478eca67a0..34bb8742f5 100644 --- a/ycmd/tests/clangd/subcommands_test.py +++ b/ycmd/tests/clangd/subcommands_test.py @@ -316,7 +316,8 @@ def FixIt_Check_cpp11_DelAdd( results ): has_entries( { 'text': 'Move function body to declaration', 'resolve': True, - 'command': has_entries( { 'command': 'clangd.applyTweak' } ) + 'command': has_entries( { 'command': has_entries( { + 'command': 'clangd.applyTweak' } ) } ) } ), ) } ) ) diff --git a/ycmd/tests/go/subcommands_test.py b/ycmd/tests/go/subcommands_test.py index 169276116a..354181d5be 100644 --- a/ycmd/tests/go/subcommands_test.py +++ b/ycmd/tests/go/subcommands_test.py @@ -71,7 +71,7 @@ def CombineRequest( request, data ): # We also ignore errors here, but then we check the response code # ourself. This is to allow testing of requests returning errors. response = app.post_json( - '/run_completer_command', + test.get( 'route', '/run_completer_command' ), CombineRequest( test[ 'request' ], { 'completer_target': 'filetype_default', 'contents': contents, @@ -91,8 +91,14 @@ def CombineRequest( request, data ): return response.json -def RunFixItTest( app, description, filepath, line, col, fixits_for_line ): - RunTest( app, { +def RunFixItTest( app, + description, + filepath, + line, + col, + fixits_for_line, + chosen_fixit = None ): + test = { 'description': description, 'request': { 'command': 'FixIt', @@ -104,7 +110,17 @@ def RunFixItTest( app, description, filepath, line, col, fixits_for_line ): 'response': requests.codes.ok, 'data': fixits_for_line, } - } ) + } + if chosen_fixit is not None: + test_no_expect = test.copy() + test_no_expect.pop( 'expect' ) + response = RunTest( app, test_no_expect ) + request = test[ 'request' ] + request.update( { + 'fixit': response[ 'fixits' ][ chosen_fixit ] + } ) + test[ 'route' ] = '/resolve_fixit' + RunTest( app, test ) def RunHierarchyTest( app, kind, direction, location, expected, code ): @@ -445,9 +461,6 @@ def test_Subcommands_FixIt_NullResponse( self, app ): filepath, 1, 1, has_entry( 'fixits', empty() ) ) - @ExpectedFailure( - 'Gopls bug. See https://github.com/golang/go/issues/68904', - matches_regexp( 'Browse free symbols' ) ) @SharedYcmd def test_Subcommands_FixIt_Simple( self, app ): filepath = PathToTestFile( 'fixit.go' ) @@ -464,7 +477,7 @@ def test_Subcommands_FixIt_Simple( self, app ): } ), ) } ) - RunFixItTest( app, 'Only one fixit returned', filepath, 1, 1, fixit ) + RunFixItTest( app, 'Only one fixit returned', filepath, 1, 1, fixit, 0 ) @SharedYcmd diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py index 253061490c..f47be8e2f2 100644 --- a/ycmd/tests/java/subcommands_test.py +++ b/ycmd/tests/java/subcommands_test.py @@ -102,7 +102,7 @@ def RunTest( app, test, contents = None ): while True: try: response = app.post_json( - '/run_completer_command', + test.get( 'route', '/run_completer_command' ), CombineRequest( test[ 'request' ], { 'completer_target': 'filetype_default', 'contents': contents, @@ -155,20 +155,36 @@ def RunHierarchyTest( app, kind, direction, location, expected, code ): RunTest( app, test ) -def RunFixItTest( app, description, filepath, line, col, fixits_for_line ): - RunTest( app, { +def RunFixItTest( app, + description, + filepath, + line, + col, + fixits_for_line, + extra_request_data = None ): + test = { 'description': description, 'request': { 'command': 'FixIt', + 'filepath': filepath, 'line_num': line, 'column_num': col, - 'filepath': filepath, }, - 'expect': { - 'response': requests.codes.ok, - 'data': fixits_for_line, - } - } ) + } + + if extra_request_data is not None: + test[ 'request' ].update( extra_request_data ) + + unresolved_fixits = RunTest( app, test ) + resolved_fixits = { 'fixits': [] } + for unresolved_fixit in unresolved_fixits[ 'fixits' ]: + test[ 'request' ][ 'fixit' ] = unresolved_fixit + test[ 'route' ] = '/resolve_fixit' + result = RunTest( app, test ) + if result[ 'fixits' ]: + resolved_fixits[ 'fixits' ].append( result[ 'fixits' ][ 0 ] ) + print( 'completer response: ', json.dumps( resolved_fixits ) ) + assert_that( resolved_fixits, fixits_for_line ) @WithRetry() @@ -1491,12 +1507,111 @@ def test_Subcommands_FixIt_Range( self, app ): 'com', 'test', 'TestLauncher.java' ) - RunTest( app, { - 'description': 'Formatting is applied on some part of the file ' - 'with tabs composed of 4 spaces', - 'request': { - 'command': 'FixIt', - 'filepath': filepath, + fixits_for_range = has_entries( { + 'fixits': contains_inanyorder( + has_entries( { + 'text': 'Extract to field', + 'kind': 'refactor.extract.field', + 'chunks': contains_exactly( + ChunkMatcher( + matches_regexp( + 'private String \\w+;\n' + '\n' + ' @Override\n' + ' public void launch\\(\\) {\n' + ' AbstractTestWidget w = ' + 'factory.getWidget\\( "Test" \\);\n' + ' ' + 'w.doSomethingVaguelyUseful\\(\\);\n' + '\n' + ' \\w+ = "Did something ' + 'useful: " \\+ w.getWidgetInfo\\(\\);\n' + ' System.out.println\\( \\w+' ), + LocationMatcher( filepath, 29, 7 ), + LocationMatcher( filepath, 34, 73 ) ), + ), + } ), + has_entries( { + 'text': 'Extract to method', + 'kind': 'refactor.extract.function', + 'chunks': contains_exactly( + # This one is a wall of text that rewrites 35 lines + ChunkMatcher( instance_of( str ), + LocationMatcher( filepath, 1, 1 ), + LocationMatcher( filepath, 35, 8 ) ), + ), + } ), + has_entries( { + 'text': 'Extract to local variable (replace all occurrences)', + 'kind': 'refactor.extract.variable', + 'chunks': contains_exactly( + ChunkMatcher( + matches_regexp( + 'String \\w+ = "Did something ' + 'useful: " \\+ w.getWidgetInfo\\(\\);\n' + ' System.out.println\\( \\w+' ), + LocationMatcher( filepath, 34, 9 ), + LocationMatcher( filepath, 34, 73 ) ), + ), + } ), + has_entries( { + 'text': 'Extract to local variable', + 'kind': 'refactor.extract.variable', + 'chunks': contains_exactly( + ChunkMatcher( + matches_regexp( + 'String \\w+ = "Did something ' + 'useful: " \\+ w.getWidgetInfo\\(\\);\n' + ' System.out.println\\( \\w+' ), + LocationMatcher( filepath, 34, 9 ), + LocationMatcher( filepath, 34, 73 ) ), + ), + } ), + has_entries( { + 'text': 'Introduce Parameter...', + 'kind': 'refactor.introduce.parameter', + 'chunks': contains_exactly( + ChunkMatcher( + 'String string) {\n' + ' AbstractTestWidget w = ' + 'factory.getWidget( "Test" );\n' + ' w.doSomethingVaguelyUseful();\n' + '\n' + ' System.out.println( string', + LocationMatcher( filepath, 30, 26 ), + LocationMatcher( filepath, 34, 73 ) ), + ), + } ), + has_entries( { + 'text': 'Organize imports', + 'chunks': instance_of( list ), + } ), + has_entries( { + 'text': 'Change modifiers to final where possible', + 'chunks': instance_of( list ), + } ), + has_entries( { + 'text': "Add Javadoc comment", + 'chunks': instance_of( list ), + } ), + has_entries( { + 'text': "Sort Members for 'TestLauncher.java'" + } ), + has_entries( { + 'text': 'Surround with try/catch', + 'chunks': instance_of( list ) + } ), + ) + } ) + RunFixItTest( + app, + 'Formatting is applied on some part of the ' + 'file with tabs composed of 4 spaces', + filepath, + 1, + 1, + fixits_for_range, + extra_request_data = { 'range': { 'start': { 'line_num': 34, @@ -1506,109 +1621,9 @@ def test_Subcommands_FixIt_Range( self, app ): 'line_num': 34, 'column_num': 73 } - }, - }, - 'expect': { - 'response': requests.codes.ok, - 'data': has_entries( { - 'fixits': contains_inanyorder( - has_entries( { - 'text': 'Extract to field', - 'kind': 'refactor.extract.field', - 'chunks': contains_exactly( - ChunkMatcher( - matches_regexp( - 'private String \\w+;\n' - '\n' - ' @Override\n' - ' public void launch\\(\\) {\n' - ' AbstractTestWidget w = ' - 'factory.getWidget\\( "Test" \\);\n' - ' ' - 'w.doSomethingVaguelyUseful\\(\\);\n' - '\n' - ' \\w+ = "Did something ' - 'useful: " \\+ w.getWidgetInfo\\(\\);\n' - ' System.out.println\\( \\w+' ), - LocationMatcher( filepath, 29, 7 ), - LocationMatcher( filepath, 34, 73 ) ), - ), - } ), - has_entries( { - 'text': 'Extract to method', - 'kind': 'refactor.extract.function', - 'chunks': contains_exactly( - # This one is a wall of text that rewrites 35 lines - ChunkMatcher( instance_of( str ), - LocationMatcher( filepath, 1, 1 ), - LocationMatcher( filepath, 35, 8 ) ), - ), - } ), - has_entries( { - 'text': 'Extract to local variable (replace all occurrences)', - 'kind': 'refactor.extract.variable', - 'chunks': contains_exactly( - ChunkMatcher( - matches_regexp( - 'String \\w+ = "Did something ' - 'useful: " \\+ w.getWidgetInfo\\(\\);\n' - ' System.out.println\\( \\w+' ), - LocationMatcher( filepath, 34, 9 ), - LocationMatcher( filepath, 34, 73 ) ), - ), - } ), - has_entries( { - 'text': 'Extract to local variable', - 'kind': 'refactor.extract.variable', - 'chunks': contains_exactly( - ChunkMatcher( - matches_regexp( - 'String \\w+ = "Did something ' - 'useful: " \\+ w.getWidgetInfo\\(\\);\n' - ' System.out.println\\( \\w+' ), - LocationMatcher( filepath, 34, 9 ), - LocationMatcher( filepath, 34, 73 ) ), - ), - } ), - has_entries( { - 'text': 'Introduce Parameter...', - 'kind': 'refactor.introduce.parameter', - 'chunks': contains_exactly( - ChunkMatcher( - 'String string) {\n' - ' AbstractTestWidget w = ' - 'factory.getWidget( "Test" );\n' - ' w.doSomethingVaguelyUseful();\n' - '\n' - ' System.out.println( string', - LocationMatcher( filepath, 30, 26 ), - LocationMatcher( filepath, 34, 73 ) ), - ), - } ), - has_entries( { - 'text': 'Organize imports', - 'chunks': instance_of( list ), - } ), - has_entries( { - 'text': 'Change modifiers to final where possible', - 'chunks': instance_of( list ), - } ), - has_entries( { - 'text': "Add Javadoc comment", - 'chunks': instance_of( list ), - } ), - has_entries( { - 'text': "Sort Members for 'TestLauncher.java'" - } ), - has_entries( { - 'text': 'Surround with try/catch', - 'chunks': instance_of( list ) - } ), - ) - } ) + } } - } ) - + ) @WithRetry() @@ -1787,19 +1802,13 @@ def test_Subcommands_FixIt_InvalidURI( self, app ): with patch( 'ycmd.completers.language_server.language_server_protocol.UriToFilePath', side_effect = lsp.InvalidUriException ): - RunTest( app, { - 'description': 'Invalid URIs do not make us crash', - 'request': { - 'command': 'FixIt', - 'line_num': 27, - 'column_num': 12, - 'filepath': filepath, - }, - 'expect': { - 'response': requests.codes.ok, - 'data': fixits, - } - } ) + RunFixItTest( + app, + 'Invalid URIs do not make us crash', + filepath, + 27, + 12, + fixits ) @WithRetry() @@ -2380,7 +2389,7 @@ def WriteJunkToServer( data ): @WithRetry() - @SharedYcmd + @IsolatedYcmd() def test_Subcommands_IndexOutOfRange( self, app ): RunTest( app, { 'description': 'Request with invalid position does not crash', @@ -2458,10 +2467,10 @@ def test_Subcommands_DifferentFileTypesUpdate( self, app ): 'fixits': has_items( has_entries( { 'text': 'Generate Getters and Setters', - 'chunks': instance_of( list ) } ), + 'resolve': True } ), has_entries( { 'text': 'Change modifiers to final where possible', - 'chunks': instance_of( list ) } ) + 'resolve': True } ) ) } ), diff --git a/ycmd/tests/rust/subcommands_test.py b/ycmd/tests/rust/subcommands_test.py index 30abf237a5..d7836e6bdd 100644 --- a/ycmd/tests/rust/subcommands_test.py +++ b/ycmd/tests/rust/subcommands_test.py @@ -64,7 +64,7 @@ def CombineRequest( request, data ): # is mainly because we (may) want to test scenarios where the completer # throws an exception and the easiest way to do that is to throw from # within the FlagsForFile function. - app.post_json( '/event_notification', + app.post_json( test.get( 'route', '/run_completer_command' ), CombineRequest( test[ 'request' ], { 'event_name': 'FileReadyToParse', 'contents': contents, @@ -79,7 +79,7 @@ def CombineRequest( request, data ): # We also ignore errors here, but then we check the response code # ourself. This is to allow testing of requests returning errors. response = app.post_json( - '/run_completer_command', + test.get( 'route', '/run_completer_command' ), CombineRequest( test[ 'request' ], { 'completer_target': 'filetype_default', 'contents': contents, @@ -99,6 +99,19 @@ def CombineRequest( request, data ): return response.json +def RunFixItTest( app, test ): + if 'chosen_fixit' in test: + test_no_expect = test.copy() + test_no_expect.pop( 'expect' ) + response = RunTest( app, test_no_expect ) + request = test[ 'request' ] + request.update( { + 'fixit': response[ 'fixits' ][ test[ 'chosen_fixit' ] ] + } ) + test[ 'route' ] = '/resolve_fixit' + RunTest( app, test ) + + def RunHierarchyTest( app, kind, direction, location, expected, code ): file, line, column = location request = { @@ -530,7 +543,7 @@ def test_Subcommands_RefactorRename_Invalid( self, app ): def test_Subcommands_FixIt_EmptyResponse( self, app ): filepath = PathToTestFile( 'common', 'src', 'main.rs' ) - RunTest( app, { + RunFixItTest( app, { 'description': 'FixIt on a line with no ' 'codeAction returns empty response', 'request': { @@ -550,8 +563,9 @@ def test_Subcommands_FixIt_EmptyResponse( self, app ): def test_Subcommands_FixIt_Basic( self, app ): filepath = PathToTestFile( 'common', 'src', 'main.rs' ) - RunTest( app, { + RunFixItTest( app, { 'description': 'Simple FixIt test', + 'chosen_fixit': 2, 'request': { 'command': 'FixIt', 'line_num': 18,