Skip to content

Commit 68840b4

Browse files
committed
Handle servers that refuse to resolve code action literals
The protocol expects us to call `codeAction/resolve` whenever a codeaction lacks either an `edit` or a `command`. Code action literals can lack the `data` field, which some servers (hint: rust-analyzer) require to respond to `codeAction/resolve`. However, we can still apply such code actions, so we do.
1 parent d8c3b69 commit 68840b4

File tree

8 files changed

+144
-52
lines changed

8 files changed

+144
-52
lines changed

build.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def Exit( self ):
9595
'028e274d06f4a61cad4ffd56f89ef414a8f65613c6d05d9467651b7fb03dae7b'
9696
)
9797

98-
DEFAULT_RUST_TOOLCHAIN = 'nightly-2024-06-11'
98+
DEFAULT_RUST_TOOLCHAIN = 'nightly-2024-12-02'
9999
RUST_ANALYZER_DIR = p.join( DIR_OF_THIRD_PARTY, 'rust-analyzer' )
100100

101101
BUILD_ERROR_MESSAGE = (

ycmd/completers/language_server/language_server_completer.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2988,22 +2988,29 @@ def _ResolveFixit( self, request_data, fixit ):
29882988
# provider, but send a LSP Command instead. We can not resolve those with
29892989
# codeAction/resolve!
29902990
if ( 'command' not in code_action or
2991-
isinstance( code_action[ 'command' ], str ) ):
2991+
not isinstance( code_action[ 'command' ], str ) ):
29922992
request_id = self.GetConnection().NextRequestId()
29932993
msg = lsp.CodeActionResolve( request_id, code_action )
2994-
code_action = self.GetConnection().GetResponse(
2995-
request_id,
2996-
msg,
2997-
REQUEST_TIMEOUT_COMMAND )[ 'result' ]
2994+
try:
2995+
code_action = self.GetConnection().GetResponse(
2996+
request_id,
2997+
msg,
2998+
REQUEST_TIMEOUT_COMMAND )[ 'result' ]
2999+
except ResponseFailedException:
3000+
# Even if resolving has failed, we might still be able to apply
3001+
# what we have previously received...
3002+
# See https://github.com/rust-lang/rust-analyzer/issues/18428
3003+
if not ( 'edit' in code_action or 'command' in code_action ):
3004+
raise
29983005

29993006
result = []
30003007
if 'edit' in code_action:
30013008
result.append( self.CodeActionLiteralToFixIt( request_data,
30023009
code_action ) )
30033010

3004-
if 'command' in code_action:
3011+
if command := code_action.get( 'command' ):
30053012
assert not result, 'Code actions with edit and command is not supported.'
3006-
if isinstance( code_action[ 'command' ], str ):
3013+
if isinstance( command, str ):
30073014
unresolved_command_fixit = self.CommandToFixIt( request_data,
30083015
code_action )
30093016
else:

ycmd/completers/rust/rust_completer.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,9 @@ def ExtraCapabilities( self ):
223223
def WorkspaceConfigurationResponse( self, request ):
224224
assert len( request[ 'params' ][ 'items' ] ) == 1
225225
return [ self._settings.get( 'ls', {} ).get( 'rust-analyzer' ) ]
226+
227+
228+
def _ShouldResolveCompletionItems( self ):
229+
# rust-analyzer does not advertise the required capability, but does want us
230+
# to resolve completion items.
231+
return True

ycmd/tests/rust/__init__.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@
1717

1818
import functools
1919
import os
20+
import time
21+
from pprint import pformat
2022

2123
from ycmd.tests.test_utils import ( BuildRequest,
2224
ClearCompletionsCache,
2325
IgnoreExtraConfOutsideTestsFolder,
2426
IsolatedApp,
27+
PollForMessagesTimeoutException,
2528
SetUpApp,
2629
StopCompleterServer,
2730
WaitUntilCompleterServerReady )
@@ -52,6 +55,36 @@ def StartRustCompleterServerInDirectory( app, directory ):
5255
WaitUntilCompleterServerReady( app, 'rust' )
5356

5457

58+
def PollForMessages( app, request_data, timeout = 60 ):
59+
expiration = time.time() + timeout
60+
while True:
61+
if time.time() > expiration:
62+
raise PollForMessagesTimeoutException( 'Waited for diagnostics to be '
63+
f'ready for { timeout } seconds, aborting.' )
64+
65+
default_args = {
66+
'line_num' : 1,
67+
'column_num': 1,
68+
}
69+
args = dict( default_args )
70+
args.update( request_data )
71+
72+
response = app.post_json( '/receive_messages', BuildRequest( **args ) ).json
73+
74+
print( f'poll response: { pformat( response ) }' )
75+
76+
if isinstance( response, bool ):
77+
if not response:
78+
raise RuntimeError( 'The message poll was aborted by the server' )
79+
elif isinstance( response, list ):
80+
return response
81+
else:
82+
raise AssertionError(
83+
f'Message poll response was wrong type: { type( response ).__name__ }' )
84+
85+
time.sleep( 0.25 )
86+
87+
5588
def SharedYcmd( test ):
5689
global shared_app
5790

ycmd/tests/rust/diagnostics_test.py

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,9 @@
2626
import os
2727

2828
from ycmd.tests.rust import setUpModule, tearDownModule # noqa
29-
from ycmd.tests.rust import PathToTestFile, SharedYcmd
29+
from ycmd.tests.rust import PathToTestFile, SharedYcmd, PollForMessages
3030
from ycmd.tests.test_utils import ( BuildRequest,
3131
LocationMatcher,
32-
PollForMessages,
3332
PollForMessagesTimeoutException,
3433
RangeMatcher,
3534
WaitForDiagnosticsToBeReady,
@@ -74,10 +73,42 @@
7473
( 21, 10 ) ) ),
7574
'fixit_available': False
7675
} ),
76+
has_entries( {
77+
'kind': 'ERROR',
78+
'text': 'cannot assign twice to immutable variable `foo`\n'
79+
'cannot assign twice to immutable variable [E0384]',
80+
'location': LocationMatcher( MAIN_FILEPATH, 27, 5 ),
81+
'location_extent': RangeMatcher( MAIN_FILEPATH, ( 27, 5 ), ( 27, 13 ) ),
82+
'ranges': contains_exactly( RangeMatcher( MAIN_FILEPATH,
83+
( 27, 5 ),
84+
( 27, 13 ) ) ),
85+
'fixit_available': False
86+
} ),
87+
has_entries( {
88+
'kind': 'HINT',
89+
'text': 'first assignment to `foo` [E0384]',
90+
'location': LocationMatcher( MAIN_FILEPATH, 26, 9 ),
91+
'location_extent': RangeMatcher( MAIN_FILEPATH, ( 26, 9 ), ( 26, 12 ) ),
92+
'ranges': contains_exactly( RangeMatcher( MAIN_FILEPATH,
93+
( 26, 9 ),
94+
( 26, 12 ) ) ),
95+
'fixit_available': False
96+
} ),
97+
has_entries( {
98+
'kind': 'HINT',
99+
'text': 'consider making this binding mutable: `mut ` [E0384]',
100+
'location': LocationMatcher( MAIN_FILEPATH, 26, 9 ),
101+
'location_extent': RangeMatcher( MAIN_FILEPATH, ( 26, 9 ), ( 26, 9 ) ),
102+
'ranges': contains_exactly( RangeMatcher( MAIN_FILEPATH,
103+
( 26, 9 ),
104+
( 26, 9 ) ) ),
105+
'fixit_available': False
106+
} ),
77107
),
78108
TEST_FILEPATH: contains_inanyorder(
79109
has_entries( {
80110
'kind': 'WARNING',
111+
81112
'text': 'function cannot return without recursing\n'
82113
'a `loop` may express intention better if this is '
83114
'on purpose\n'
@@ -134,24 +165,26 @@ def test_Diagnostics_DetailedDiags( self, app ):
134165
@WithRetry()
135166
@SharedYcmd
136167
def test_Diagnostics_FileReadyToParse( self, app ):
137-
filepath = PathToTestFile( 'common', 'src', 'main.rs' )
138-
contents = ReadFile( filepath )
139-
with open( filepath, 'w' ) as f:
140-
f.write( contents )
141-
event_data = BuildRequest( event_name = 'FileSave',
142-
contents = contents,
143-
filepath = filepath,
144-
filetype = 'rust' )
145-
app.post_json( '/event_notification', event_data )
146-
147-
# It can take a while for the diagnostics to be ready.
148-
results = WaitForDiagnosticsToBeReady( app, filepath, contents, 'rust' )
149-
print( f'completer response: { pformat( results ) }' )
150-
151-
assert_that( results, DIAG_MATCHERS_PER_FILE[ filepath ] )
152-
153-
154-
@WithRetry()
168+
for filename in [ 'main.rs', 'test.rs' ]:
169+
with self.subTest( filename = filename ):
170+
filepath = PathToTestFile( 'common', 'src', filename )
171+
contents = ReadFile( filepath )
172+
with open( filepath, 'w' ) as f:
173+
f.write( contents )
174+
event_data = BuildRequest( event_name = 'FileSave',
175+
contents = contents,
176+
filepath = filepath,
177+
filetype = 'rust' )
178+
app.post_json( '/event_notification', event_data )
179+
180+
# It can take a while for the diagnostics to be ready.
181+
results = WaitForDiagnosticsToBeReady( app, filepath, contents, 'rust' )
182+
print( f'completer response: { pformat( results ) }' )
183+
184+
assert_that( results, DIAG_MATCHERS_PER_FILE[ filepath ] )
185+
186+
187+
@WithRetry( { 'reruns': 1000 } )
155188
@SharedYcmd
156189
def test_Diagnostics_Poll( self, app ):
157190
project_dir = PathToTestFile( 'common' )
@@ -170,10 +203,10 @@ def test_Diagnostics_Poll( self, app ):
170203
seen = {}
171204

172205
try:
173-
for message in PollForMessages( app,
206+
for message in reversed( PollForMessages( app,
174207
{ 'filepath': filepath,
175208
'contents': contents,
176-
'filetype': 'rust' } ):
209+
'filetype': 'rust' } ) ):
177210
print( f'Message { pformat( message ) }' )
178211
if 'diagnostics' in message:
179212
if message[ 'diagnostics' ] == []:

ycmd/tests/rust/inlay_hints_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def test_basic( self, app ):
107107
has_entries( {
108108
'kind': 'Type',
109109
'position': LocationMatcher( filepath, 12, 10 ),
110-
'label': ': Builder '
110+
'label': ': Builder'
111111
} ),
112112
),
113113
} )

ycmd/tests/rust/subcommands_test.py

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -563,28 +563,35 @@ def test_Subcommands_FixIt_EmptyResponse( self, app ):
563563
def test_Subcommands_FixIt_Basic( self, app ):
564564
filepath = PathToTestFile( 'common', 'src', 'main.rs' )
565565

566-
RunFixItTest( app, {
567-
'description': 'Simple FixIt test',
568-
'chosen_fixit': 2,
569-
'request': {
570-
'command': 'FixIt',
571-
'line_num': 18,
572-
'column_num': 2,
573-
'filepath': filepath
574-
},
575-
'expect': {
576-
'response': requests.codes.ok,
577-
'data': has_entries( {
578-
'fixits': has_item( has_entries( {
579-
'chunks': contains_exactly(
580-
ChunkMatcher( 'pub(crate) ',
581-
LocationMatcher( filepath, 18, 1 ),
582-
LocationMatcher( filepath, 18, 1 ) )
583-
)
584-
} ) )
566+
for line, column, choice, chunks in [
567+
( 18, 2, 2, [
568+
ChunkMatcher( 'pub(crate) ',
569+
LocationMatcher( filepath, 18, 1 ),
570+
LocationMatcher( filepath, 18, 1 ) ) ] ),
571+
( 27, 5, 0, [
572+
ChunkMatcher( 'mut ',
573+
LocationMatcher( filepath, 26, 9 ),
574+
LocationMatcher( filepath, 26, 9 ) ) ] ),
575+
]:
576+
with self.subTest( line = line, column = column, choice = choice ):
577+
RunFixItTest( app, {
578+
'description': 'Simple FixIt test',
579+
'chosen_fixit': choice,
580+
'request': {
581+
'command': 'FixIt',
582+
'line_num': line,
583+
'column_num': column,
584+
'filepath': filepath
585+
},
586+
'expect': {
587+
'response': requests.codes.ok,
588+
'data': has_entries( {
589+
'fixits': has_item( has_entries( {
590+
'chunks': contains_exactly( *chunks )
591+
} ) )
592+
} )
593+
},
585594
} )
586-
},
587-
} )
588595

589596

590597
@WithRetry()

ycmd/tests/rust/testdata/common/src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,9 @@ fn format_test() {
2121
a :
2222
i32 = 5;
2323
}
24+
25+
fn code_action_literal() -> i32 {
26+
let foo = 5;
27+
foo += 1;
28+
foo
29+
}

0 commit comments

Comments
 (0)