Skip to content
Open
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
141 changes: 122 additions & 19 deletions ycmd/completers/language_server/language_server_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ def __init__( self,
self._stop_event = threading.Event()
self._notification_handler = notification_handler

self._collector = RejectCollector()
self._collector = UnsolicitedEditApplier()
self._observers = []


Expand Down Expand Up @@ -1675,7 +1675,7 @@ def GetDetailedDiagnostic( self, request_data ):
return responses.BuildDisplayMessageResponse(
'No diagnostics for current file.' )

# Prefer errors to warnings and warnings to infos.
# Prefer errors to warnings and warnings to infos and infos to hints.
diagnostics.sort( key = lambda d: d[ 'severity' ] )

# request_data uses 1-based offsets, but LSP diagnostics use 0-based.
Expand All @@ -1684,6 +1684,7 @@ def GetDetailedDiagnostic( self, request_data ):
GetFileLines( request_data, current_file )[ current_line_lsp ],
request_data[ 'column_codepoint' ] ) - 1
minimum_distance = None
diag = None

message = 'No diagnostics for current line.'
for diagnostic in diagnostics:
Expand All @@ -1694,20 +1695,27 @@ def GetDetailedDiagnostic( self, request_data ):
point = { 'line': current_line_lsp, 'character': current_column }
distance = _DistanceOfPointToRange( point, diagnostic[ 'range' ] )
if minimum_distance is None or distance < minimum_distance:
message = diagnostic[ 'message' ]
try:
code = diagnostic[ 'code' ]
message += f' [{ code }]' # noqa
except KeyError:
pass

diag = diagnostic
if distance == 0:
break
minimum_distance = distance

if diag is not None:
message = self.BuildDetailedDiagnostic( diag )
return responses.BuildDisplayMessageResponse( message )


def BuildDetailedDiagnostic( self, diagnostic ):
message = diagnostic[ 'message' ]
try:
code = diagnostic[ 'code' ]
message += f' [{ code }]' # noqa
except KeyError:
pass
return message



@abc.abstractmethod
def GetServerName( self ):
""" A string representing a human readable name of the server."""
Expand Down Expand Up @@ -2146,6 +2154,23 @@ def ConvertNotificationToMessage( self, request_data, notification ):
'Server reported: %s',
params[ 'message' ] )


# HACK: Not really a notification; we pretend it is because we send it
# (unsolicited) to our clients and pretend to the server that it was applied
# anyway.
if notification[ 'method' ] == 'workspace/applyEdit':
LOGGER.info( "Server requested to apply edit: %s",
notification )
fixit = WorkspaceEditToFixIt(
request_data,
notification[ 'params' ][ 'edit' ],
notification[ 'params' ].get( 'label' ) or None )

if fixit:
response = responses.BuildFixItResponse( [ fixit ] )
LOGGER.info( "Response resulting: %s", response )
return response

return None


Expand Down Expand Up @@ -2361,6 +2386,44 @@ def GetProjectDirectory( self, request_data ):
return os.path.dirname( filepath )


def FindProjectFromRootFiles( self,
filepath,
project_root_files,
nearest=True ):

project_folder = None
project_root_type = None

# First, find the nearest dir that has one of the root file types
for folder in utils.PathsToAllParentFolders( filepath ):
f = Path( folder )
for root_file in project_root_files:
if next( f.glob( root_file ), [] ):
# Found one, store the root file and the current nearest folder
project_root_type = root_file
project_folder = folder
break
if project_folder:
break

if not project_folder:
return None

# If asking for the nearest, return the one found
if nearest:
return str( project_folder )

# Otherwise keep searching up from the nearest until we don't find any more
for folder in utils.PathsToAllParentFolders( os.path.join( project_folder,
'..' ) ):
f = Path( folder )
if next( f.glob( project_root_type ), [] ):
project_folder = folder
else:
break
return project_folder


def GetWorkspaceForFilepath( self, filepath, strict = False ):
"""Return the workspace of the provided filepath. This could be a subproject
or a completely unrelated project to the root directory.
Expand All @@ -2371,12 +2434,12 @@ def GetWorkspaceForFilepath( self, filepath, strict = False ):
reuse this implementation.
"""
project_root_files = self.GetProjectRootFiles()
workspace = None
if project_root_files:
for folder in utils.PathsToAllParentFolders( filepath ):
for root_file in project_root_files:
if next( Path( folder ).glob( root_file ), [] ):
return folder
return None if strict else os.path.dirname( filepath )
workspace = self.FindProjectFromRootFiles( filepath,
project_root_files,
nearest = True )
return workspace or ( None if strict else os.path.dirname( filepath ) )


def _SendInitialize( self, request_data ):
Expand Down Expand Up @@ -3572,12 +3635,15 @@ def _BuildDiagnostic( contents, uri, diag ):
# code field doesn't exist.
pass

severity = diag.get( 'severity' ) or 1
return responses.Diagnostic(
ranges = [ r ],
location = r.start_,
location_extent = r,
text = diag_text,
kind = lsp.SEVERITY[ diag.get( 'severity' ) or 1 ].upper() )
kind = lsp.SEVERITY[ severity ].upper(),
severity = severity
)


def TextEditToChunks( request_data, uri, text_edit ):
Expand Down Expand Up @@ -3619,10 +3685,39 @@ def WorkspaceEditToFixIt( request_data,
workspace_edit[ 'changes' ][ uri ] ) )
else:
chunks = []
for text_document_edit in workspace_edit[ 'documentChanges' ]:
uri = text_document_edit[ 'textDocument' ][ 'uri' ]
edits = text_document_edit[ 'edits' ]
chunks.extend( TextEditToChunks( request_data, uri, edits ) )
for document_change in workspace_edit[ 'documentChanges' ]:
if 'kind' in document_change:
# File operation
try:
if document_change[ 'kind' ] == 'create':
chunks.append( responses.FixItResourceOp( {
'op': 'create',
'uri': lsp.UriToFilePath( document_change[ 'uri' ] ),
'options': document_change.get( 'options', {} ),
} ) )
elif document_change[ 'kind' ] == 'rename':
chunks.append( responses.FixItResourceOp( {
'op': 'rename',
'old_filepath': lsp.UriToFilePath( document_change[ 'oldUri' ] ),
'new_filepath': lsp.UriToFilePath( document_change[ 'newUri' ] ),
'options': document_change.get( 'options', {} ),
} ) )
elif document_change[ 'kind' ] == 'delete':
chunks.append( responses.FixItResourceOp( {
'op': 'delete',
'uri': lsp.UriToFilePath( document_change[ 'uri' ] ),
'options': document_change.get( 'options', {} ),
} ) )
except lsp.InvalidUriException:
LOGGER.debug( 'Invalid filepath received in TextEdit create' )
continue
else:
# Text document edit
chunks.extend(
TextEditToChunks( request_data,
document_change[ 'textDocument' ][ 'uri' ],
document_change[ 'edits' ] ) )

return responses.FixIt(
responses.Location( request_data[ 'line_num' ],
request_data[ 'column_num' ],
Expand Down Expand Up @@ -3678,6 +3773,14 @@ def CollectApplyEdit( self, request, connection ):
connection.SendResponse( lsp.ApplyEditResponse( request, False ) )


class UnsolicitedEditApplier:
def CollectApplyEdit( self, request, connection: LanguageServerConnection ):
# Pretend this event is a notification and let the LSP implenentation handle
# it. This is a hack to forward these requests to the client.
connection._AddNotificationToQueue( request )
connection.SendResponse( lsp.ApplyEditResponse( request, True ) )


class EditCollector:
def __init__( self ):
self.requests = []
Expand Down
9 changes: 8 additions & 1 deletion ycmd/completers/language_server/language_server_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,14 @@ def Initialize( request_id,
'didChangeWatchedFiles': {
'dynamicRegistration': True
},
'workspaceEdit': { 'documentChanges': True, },
'workspaceEdit': {
'documentChanges': True,
'resourceOperations': [
'create',
'rename',
'delete'
],
},
'symbol': {
'symbolKind': {
'valueSet': list( range( 1, len( SYMBOL_KIND ) ) ),
Expand Down
122 changes: 110 additions & 12 deletions ycmd/completers/rust/rust_completer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
import logging
import os
from subprocess import PIPE
from pathlib import Path

from ycmd import responses, utils
from ycmd.completers.language_server import language_server_completer
from ycmd.completers.language_server import language_server_protocol as lsp
from ycmd.utils import LOGGER, re


Expand Down Expand Up @@ -106,12 +108,44 @@ def GetServerEnvironment( self ):
return env


def GetProjectRootFiles( self ):
# Without LSP workspaces support, RA relies on the rootUri to detect a
def GetWorkspaceForFilepath( self, filepath, strict = False ):
# For every unique workspace, rust analyzer launches a nuclear
# weapon^h^h^h^h new server and indexes the internet. Try to minimise the
# number of such launches.

# If filepath is a subdirectory of the manually-specified project root, use
# the project root
if 'project_directory' in self._settings:
project_root = utils.AbsolutePath( self._settings[ 'project_directory' ],
self._extra_conf_dir )

prp = Path( project_root )
for parent in Path( filepath ).absolute().parents:
if parent == prp:
return project_root

# Otherwise, we might not have one configured, or it' a totally different
# project.
# TODO: add support for LSP workspaces to allow users to change project
# without having to restart RA.
return [ 'Cargo.toml' ]
#
# Our main heuristic is:
# - find the nearest Cargo.lock, and assume that's the root
# - otherwise find the _furthest_ Cargo.toml and assume that's the root
# - otherwise use the project root directory that we previously calculated.
#
# We never use the directory of the file as that could just be anything
# random, and we might as well just use the original project in that case
if candidate := self.FindProjectFromRootFiles( filepath,
[ 'Cargo.lock' ],
nearest = True ):
return candidate

if candidate := self.FindProjectFromRootFiles( filepath,
[ 'Cargo.toml' ],
nearest = False ):
return candidate

# Never use the
return None if strict else self._project_directory


def ServerIsReady( self ):
Expand Down Expand Up @@ -203,13 +237,9 @@ def GetDoc( self, request_data ):
hover_response = self.GetHoverResponse( request_data )
except language_server_completer.NoHoverInfoException:
raise RuntimeError( 'No documentation available.' )

# Strips all empty lines and lines starting with "```" to make the hover
# response look like plain text. For the format, see the comment in GetType.
lines = hover_response[ 'value' ].split( '\n' )
documentation = '\n'.join(
line for line in lines if line and not line.startswith( '```' ) ).strip()
return responses.BuildDetailedInfoResponse( documentation )
return responses.BuildDetailedInfoResponse( hover_response[ 'value' ] ) | {
'filetype': 'markdown',
}


def ExtraCapabilities( self ):
Expand All @@ -220,6 +250,74 @@ def ExtraCapabilities( self ):
}


def GetCustomSubcommands( self ):
return {
'RebuildProcMacros': (
lambda self, request_data, args: self._RebuildProcMacros( request_data )
),
'ExpandMacro': (
lambda self, request_data, args: self._ExpandMacro( request_data )
),
}


def _RebuildProcMacros( self, request_data ):
if not self.ServerIsReady():
raise RuntimeError( 'Server is initializing. Please wait.' )

self._UpdateServerWithFileContents( request_data )

request_id = self.GetConnection().NextRequestId()
message = lsp.BuildRequest( request_id, 'rust-analyzer/rebuildProcMacros' )
self.GetConnection().GetResponse(
request_id,
message,
language_server_completer.REQUEST_TIMEOUT_COMMAND )
return ""


def _ExpandMacro( self, request_data ):
if not self.ServerIsReady():
raise RuntimeError( 'Server is initializing. Please wait.' )

self._UpdateServerWithFileContents( request_data )

request_id = self.GetConnection().NextRequestId()
message = lsp.BuildRequest( request_id, 'rust-analyzer/expandMacro', {
'textDocument': lsp.TextDocumentIdentifier( request_data ),
'position': lsp.Position( request_data[ 'line_num' ],
request_data[ 'line_value' ],
request_data[ 'column_codepoint' ] ),
} )

response = self.GetConnection().GetResponse(
request_id,
message,
language_server_completer.REQUEST_TIMEOUT_COMMAND )[ 'result' ]

if not response:
raise RuntimeError( 'No macro expansion available.' )

return responses.BuildDetailedInfoResponse(
f'''
Name: { response[ "name" ] }

Expansion:
{ response[ "expansion" ] }
'''
)


def BuildDetailedDiagnostic( self, diagnostic ):
if 'data' in diagnostic and 'rendered' in diagnostic[ 'data' ]:
rendered = diagnostic[ 'data' ][ 'rendered' ]
if rendered:
# The rendered message is already formatted.
return rendered
return super().BuildDetailedDiagnostic( diagnostic )



def WorkspaceConfigurationResponse( self, request ):
assert len( request[ 'params' ][ 'items' ] ) == 1
return [ self._settings.get( 'ls', {} ).get( 'rust-analyzer' ) ]
Loading
Loading