Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[READY] Rust analyzer code action fixes #1771

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def Exit( self ):
'028e274d06f4a61cad4ffd56f89ef414a8f65613c6d05d9467651b7fb03dae7b'
)

DEFAULT_RUST_TOOLCHAIN = 'nightly-2024-06-11'
DEFAULT_RUST_TOOLCHAIN = 'nightly-2024-12-12'
RUST_ANALYZER_DIR = p.join( DIR_OF_THIRD_PARTY, 'rust-analyzer' )

BUILD_ERROR_MESSAGE = (
Expand Down
21 changes: 14 additions & 7 deletions ycmd/completers/language_server/language_server_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2988,22 +2988,29 @@ def _ResolveFixit( self, request_data, fixit ):
# 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 ) ):
not 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' ]
try:
code_action = self.GetConnection().GetResponse(
request_id,
msg,
REQUEST_TIMEOUT_COMMAND )[ 'result' ]
except ResponseFailedException:
# Even if resolving has failed, we might still be able to apply
# what we have previously received...
# See https://github.com/rust-lang/rust-analyzer/issues/18428
if not ( 'edit' in code_action or 'command' in code_action ):
raise

result = []
if 'edit' in code_action:
result.append( self.CodeActionLiteralToFixIt( request_data,
code_action ) )

if 'command' in code_action:
if command := code_action.get( 'command' ):
assert not result, 'Code actions with edit and command is not supported.'
if isinstance( code_action[ 'command' ], str ):
if isinstance( command, str ):
unresolved_command_fixit = self.CommandToFixIt( request_data,
code_action )
else:
Expand Down
33 changes: 33 additions & 0 deletions ycmd/tests/rust/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@

import functools
import os
import time
from pprint import pformat

from ycmd.tests.test_utils import ( BuildRequest,
ClearCompletionsCache,
IgnoreExtraConfOutsideTestsFolder,
IsolatedApp,
PollForMessagesTimeoutException,
SetUpApp,
StopCompleterServer,
WaitUntilCompleterServerReady )
Expand Down Expand Up @@ -52,6 +55,36 @@ def StartRustCompleterServerInDirectory( app, directory ):
WaitUntilCompleterServerReady( app, 'rust' )


def PollForMessages( app, request_data, timeout = 60 ):
expiration = time.time() + timeout
while True:
if time.time() > expiration:
raise PollForMessagesTimeoutException( 'Waited for diagnostics to be '
f'ready for { timeout } seconds, aborting.' )

default_args = {
'line_num' : 1,
'column_num': 1,
}
args = dict( default_args )
args.update( request_data )

response = app.post_json( '/receive_messages', BuildRequest( **args ) ).json

print( f'poll response: { pformat( response ) }' )

if isinstance( response, bool ):
if not response:
raise RuntimeError( 'The message poll was aborted by the server' )
elif isinstance( response, list ):
return response
else:
raise AssertionError(
f'Message poll response was wrong type: { type( response ).__name__ }' )

time.sleep( 0.25 )


def SharedYcmd( test ):
global shared_app

Expand Down
84 changes: 61 additions & 23 deletions ycmd/tests/rust/diagnostics_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@
import os

from ycmd.tests.rust import setUpModule, tearDownModule # noqa
from ycmd.tests.rust import PathToTestFile, SharedYcmd
from ycmd.tests.rust import PathToTestFile, SharedYcmd, PollForMessages
from ycmd.tests.test_utils import ( BuildRequest,
LocationMatcher,
PollForMessages,
PollForMessagesTimeoutException,
RangeMatcher,
WaitForDiagnosticsToBeReady,
Expand Down Expand Up @@ -74,10 +73,42 @@
( 21, 10 ) ) ),
'fixit_available': False
} ),
has_entries( {
'kind': 'ERROR',
'text': 'cannot assign twice to immutable variable `foo`\n'
'cannot assign twice to immutable variable [E0384]',
'location': LocationMatcher( MAIN_FILEPATH, 27, 5 ),
'location_extent': RangeMatcher( MAIN_FILEPATH, ( 27, 5 ), ( 27, 13 ) ),
'ranges': contains_exactly( RangeMatcher( MAIN_FILEPATH,
( 27, 5 ),
( 27, 13 ) ) ),
'fixit_available': False
} ),
has_entries( {
'kind': 'HINT',
'text': 'first assignment to `foo` [E0384]',
'location': LocationMatcher( MAIN_FILEPATH, 26, 9 ),
'location_extent': RangeMatcher( MAIN_FILEPATH, ( 26, 9 ), ( 26, 12 ) ),
'ranges': contains_exactly( RangeMatcher( MAIN_FILEPATH,
( 26, 9 ),
( 26, 12 ) ) ),
'fixit_available': False
} ),
has_entries( {
'kind': 'HINT',
'text': 'consider making this binding mutable: `mut ` [E0384]',
'location': LocationMatcher( MAIN_FILEPATH, 26, 9 ),
'location_extent': RangeMatcher( MAIN_FILEPATH, ( 26, 9 ), ( 26, 9 ) ),
'ranges': contains_exactly( RangeMatcher( MAIN_FILEPATH,
( 26, 9 ),
( 26, 9 ) ) ),
'fixit_available': False
} ),
),
TEST_FILEPATH: contains_inanyorder(
has_entries( {
'kind': 'WARNING',

'text': 'function cannot return without recursing\n'
'a `loop` may express intention better if this is '
'on purpose\n'
Expand Down Expand Up @@ -131,27 +162,34 @@ def test_Diagnostics_DetailedDiags( self, app ):
'no field `build_` on type `test::Builder`\nunknown field [E0609]' ) )


@WithRetry()
@SharedYcmd
def test_Diagnostics_FileReadyToParse( self, app ):
filepath = PathToTestFile( 'common', 'src', 'main.rs' )
contents = ReadFile( filepath )
with open( filepath, 'w' ) as f:
f.write( contents )
event_data = BuildRequest( event_name = 'FileSave',
contents = contents,
filepath = filepath,
filetype = 'rust' )
app.post_json( '/event_notification', event_data )

# It can take a while for the diagnostics to be ready.
results = WaitForDiagnosticsToBeReady( app, filepath, contents, 'rust' )
print( f'completer response: { pformat( results ) }' )

assert_that( results, DIAG_MATCHERS_PER_FILE[ filepath ] )


@WithRetry()
for filename in [ 'main.rs', 'test.rs' ]:
with self.subTest( filename = filename ):
@WithRetry()
def Test():
filepath = PathToTestFile( 'common', 'src', filename )
contents = ReadFile( filepath )
with open( filepath, 'w' ) as f:
f.write( contents )
event_data = BuildRequest( event_name = 'FileSave',
contents = contents,
filepath = filepath,
filetype = 'rust' )
app.post_json( '/event_notification', event_data )

# It can take a while for the diagnostics to be ready.
results = WaitForDiagnosticsToBeReady( app,
filepath,
contents,
'rust' )
print( f'completer response: { pformat( results ) }' )

assert_that( results, DIAG_MATCHERS_PER_FILE[ filepath ] )
Test()


@WithRetry( { 'reruns': 1000 } )
@SharedYcmd
def test_Diagnostics_Poll( self, app ):
project_dir = PathToTestFile( 'common' )
Expand All @@ -170,10 +208,10 @@ def test_Diagnostics_Poll( self, app ):
seen = {}

try:
for message in PollForMessages( app,
for message in reversed( PollForMessages( app,
{ 'filepath': filepath,
'contents': contents,
'filetype': 'rust' } ):
'filetype': 'rust' } ) ):
print( f'Message { pformat( message ) }' )
if 'diagnostics' in message:
if message[ 'diagnostics' ] == []:
Expand Down
33 changes: 22 additions & 11 deletions ycmd/tests/rust/get_completions_proc_macro_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
from hamcrest import ( assert_that,
has_item,
empty,
equal_to,
has_key,
is_not )
has_entry )
from pprint import pformat
from unittest import TestCase

Expand All @@ -37,7 +38,7 @@

class GetCompletionsProcMacroTest( TestCase ):
@WithRetry()
@IsolatedYcmd()
@IsolatedYcmd( { 'max_num_candidates_to_detail': 0 } )
def test_GetCompletions_ProcMacro( self, app ):
StartRustCompleterServerInDirectory( app, PathToTestFile( 'macro' ) )

Expand Down Expand Up @@ -68,17 +69,27 @@ def test_GetCompletions_ProcMacro( self, app ):
)
)

# This completer does not require or support resolve
assert_that( results[ 0 ], is_not( has_key( 'resolve' ) ) )
assert_that( results[ 0 ], is_not( has_key( 'item' ) ) )
checked_candidate = None
for candidate in results:
if candidate[ 'insertion_text' ] == 'checkpoint':
checked_candidate = candidate
break

unresolved_item = checked_candidate[ 'extra_data' ]
assert_that( unresolved_item, has_key( 'resolve' ) )
assert_that( unresolved_item, has_key( 'item' ) )
assert_that( checked_candidate, has_entry( 'detailed_info',
'checkpoint\n\n' ) )

# So (erroneously) resolving an item returns the item
completion_data[ 'resolve' ] = 0
completion_data[ 'resolve' ] = unresolved_item[ 'resolve' ]
response = app.post_json( '/resolve_completion', completion_data ).json
print( f"Resolve resolve: { pformat( response ) }" )

# We can't actually check the result because we don't know what completion
# resolve ID 0 actually is (could be anything), so we just check that we
# get 1 result, and that there are no errors.
assert_that( response[ 'completion' ], is_not( None ) )
assert_that( response[ 'errors' ], empty() )
assert_that( response[ 'completion' ][ 'detailed_info' ],
equal_to(
"checkpoint\n"
"\n"
"Validate that all current expectations for "
"all methods have\n"
"been satisfied, and discard them." ) )
2 changes: 1 addition & 1 deletion ycmd/tests/rust/inlay_hints_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def test_basic( self, app ):
has_entries( {
'kind': 'Type',
'position': LocationMatcher( filepath, 12, 10 ),
'label': ': Builder '
'label': ': Builder'
} ),
),
} )
Expand Down
51 changes: 29 additions & 22 deletions ycmd/tests/rust/subcommands_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -561,28 +561,35 @@ def test_Subcommands_FixIt_EmptyResponse( self, app ):
def test_Subcommands_FixIt_Basic( self, app ):
filepath = PathToTestFile( 'common', 'src', 'main.rs' )

RunFixItTest( app, {
'description': 'Simple FixIt test',
'chosen_fixit': 2,
'request': {
'command': 'FixIt',
'line_num': 18,
'column_num': 2,
'filepath': filepath
},
'expect': {
'response': requests.codes.ok,
'data': has_entries( {
'fixits': has_item( has_entries( {
'chunks': contains_exactly(
ChunkMatcher( 'pub(crate) ',
LocationMatcher( filepath, 18, 1 ),
LocationMatcher( filepath, 18, 1 ) )
)
} ) )
for line, column, choice, chunks in [
( 18, 2, 2, [
ChunkMatcher( 'pub(crate) ',
LocationMatcher( filepath, 18, 1 ),
LocationMatcher( filepath, 18, 1 ) ) ] ),
( 27, 5, 0, [
ChunkMatcher( 'mut ',
LocationMatcher( filepath, 26, 9 ),
LocationMatcher( filepath, 26, 9 ) ) ] ),
]:
with self.subTest( line = line, column = column, choice = choice ):
RunFixItTest( app, {
'description': 'Simple FixIt test',
'chosen_fixit': choice,
'request': {
'command': 'FixIt',
'line_num': line,
'column_num': column,
'filepath': filepath
},
'expect': {
'response': requests.codes.ok,
'data': has_entries( {
'fixits': has_item( has_entries( {
'chunks': contains_exactly( *chunks )
} ) )
} )
},
} )
},
} )


@IsolatedYcmd()
Expand All @@ -596,7 +603,7 @@ def test_Subcommands_GoTo_WorksAfterChangingProject( self, app ):
'macro'
),
(
{ 'req': ( 'main.rs', 14, 19 ), 'res': ( 'test.rs', 4, 12 ) },
{ 'req': ( 'main.rs', 9, 24 ), 'res': ( 'main.rs', 6, 8 ) },
'common'
),
]:
Expand Down
6 changes: 6 additions & 0 deletions ycmd/tests/rust/testdata/common/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@ fn format_test() {
a :
i32 = 5;
}

fn code_action_literal() -> i32 {
let foo = 5;
foo += 1;
foo
}
Loading