diff --git a/cpp/ycm/ClangCompleter/FixIt.h b/cpp/ycm/ClangCompleter/FixIt.h index 4bf2f59fd3..4ceae17052 100644 --- a/cpp/ycm/ClangCompleter/FixIt.h +++ b/cpp/ycm/ClangCompleter/FixIt.h @@ -52,6 +52,8 @@ struct FixIt { /// multiple diagnostics offering different fixit options. The text is /// displayed to the user, allowing them choose which diagnostic to apply. std::string text; + + bool is_completion{ false }; }; } // namespace YouCompleteMe diff --git a/cpp/ycm/ycm_core.cpp b/cpp/ycm/ycm_core.cpp index 554771b07b..854ba20648 100644 --- a/cpp/ycm/ycm_core.cpp +++ b/cpp/ycm/ycm_core.cpp @@ -201,7 +201,8 @@ PYBIND11_MODULE( ycm_core, mod ) .def_readonly( "text", &FixIt::text ) .def_property_readonly( "kind", [](const py::handle) { return py::none(); - }); + }) + .def_readonly( "is_completion", &FixIt::is_completion ); py::bind_vector< std::vector< FixIt > >( mod, "FixItVector" ); diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 0788404478..371667fb08 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -1446,7 +1446,7 @@ def _CandidatesFromCompletionItems( self, this_tem_is_resolved = True try: - insertion_text, extra_data, start_codepoint = ( + insertion_text, snippet, extra_data, start_codepoint = ( _InsertionTextForItem( request_data, item ) ) except IncompatibleCompletionException: LOGGER.exception( 'Ignoring incompatible completion suggestion %s', @@ -1464,6 +1464,13 @@ def _CandidatesFromCompletionItems( self, # We'll use this later to do the resolve. extra_data[ 'item' ] = item + if snippet: + extra_data = {} if extra_data is None else extra_data + extra_data[ 'snippet' ] = { + 'snippet': snippet, + 'trigger_string': insertion_text + } + min_start_codepoint = min( min_start_codepoint, start_codepoint ) # Build a ycmd-compatible completion for the text as we received it. Later @@ -2308,9 +2315,11 @@ def _SendInitialize( self, request_data ): # the settings on the Initialize request are somehow subtly different from # the settings supplied in didChangeConfiguration, though it's not exactly # clear how/where that is specified. + extra_capabilities = self._settings.get( 'capabilities' , {} ) + extra_capabilities.update( self.ExtraCapabilities() ) msg = lsp.Initialize( request_id, self._project_directory, - self.ExtraCapabilities(), + extra_capabilities, self._settings.get( 'ls', {} ) ) def response_handler( response, message ): @@ -2985,32 +2994,32 @@ def _InsertionTextForItem( request_data, item ): Returns a tuple ( - insertion_text = the text to insert + - snippet = optional snippet text - fixits = ycmd fixit which needs to be applied additionally when selecting this completion - start_codepoint = the start column at which the text should be inserted )""" - # We do not support completion types of "Snippet". This is implicit in that we - # don't say it is a "capability" in the initialize request. - # Abort this request if the server is buggy and ignores us. - assert lsp.INSERT_TEXT_FORMAT[ - item.get( 'insertTextFormat' ) or 1 ] == 'PlainText' - - fixits = None - start_codepoint = request_data[ 'start_codepoint' ] + label = item[ 'label' ] + insertion_text_is_snippet = False # We will always have one of insertText or label if 'insertText' in item and item[ 'insertText' ]: + # 1 = PlainText + # 2 = Snippet + if lsp.INSERT_TEXT_FORMAT[ item.get( 'insertTextFormat', 1 ) ] == 'Snippet': + insertion_text_is_snippet = True + insertion_text = item[ 'insertText' ] else: insertion_text = item[ 'label' ] - additional_text_edits = [] + fixits = [] + filepath = request_data[ 'filepath' ] + contents = None - # Per the protocol, textEdit takes precedence over insertText, and must be - # on the same line (and containing) the originally requested position. These - # are a pain, and require fixing up later in some cases, as most of our - # clients won't be able to apply arbitrary edits (only 'completion', as - # opposed to 'content assist'). + # Per the protocol, textEdit takes precedence over insertText, and the initial + # range of the edit must be on the same line (and containing) the originally + # requested position. if 'textEdit' in item and item[ 'textEdit' ]: text_edit = item[ 'textEdit' ] start_codepoint = _GetCompletionItemStartCodepointOrReject( text_edit, @@ -3018,26 +3027,80 @@ def _InsertionTextForItem( request_data, item ): insertion_text = text_edit[ 'newText' ] - if '\n' in insertion_text: - # jdt.ls can return completions which generate code, such as - # getters/setters and entire anonymous classes. + if '\n' in insertion_text and not insertion_text_is_snippet: + # FIXME: If this logic actually worked, then we should do it for + # everything and not just hte multi-line completions ? Would that allow us + # to avoid the _GetCompletionItemStartCodepointOrReject logic ? Possibly, + # but it would look strange in the UI cycling through items. In any case, + # doing both should be complete. + + # servers can return completions which generate code, such as + # getters/setters and entire anonymous classes. These contain newlines in + # the generated textEdit. This is irksome because ycmd's clients don't + # necessarily support that. Certainly, Vim doesn't. # - # In order to support this we would need to do something like: - # - invent some insertion_text based on label/insertText (or perhaps - # '' - # - insert a textEdit in additionalTextEdits which deletes this - # insertion - # - or perhaps just modify this textEdit to undo that change? - # - or perhaps somehow support insertion_text of '' (this doesn't work - # because of filtering/sorting, etc.). - # - insert this textEdit in additionalTextEdits + # However, we do have 'fixits' in completion responses which we can lean + # on. # - # These textEdits would need a lot of fixing up and is currently out of - # scope. + # In order to support this we: + # - use the item's label as the intial insertion text with the start + # codepoint set to the query codepoint + # - insert a textEdit in additionalTextEdits which deletes this + # insertion + # - insert another textEdit in additionalTextEdits which applies this + # textedit # - # These sorts of completions aren't really in the spirit of ycmd at the - # moment anyway. So for now, we just ignore this candidate. - raise IncompatibleCompletionException( insertion_text ) + # On the other hand, if the insertion text is a snippet, then the snippet + # system will handle the expansion. + insertion_text = item[ 'label' ] + start_codepoint = request_data[ 'start_codepoint' ] + + # FIXME: + # So forced-completion breaks here, as the textEdit is formulated to + # remove the existing "prefix". E.g. typing getT, the edit + # attempts to replace getT with the new code. + + # Add a fixit which removes the inserted label + # + # TODO: Perhaps we should actually supply a completion with an empty + # insertion_text, than have the _client_ deal with this. One key advantage + # to that is that the client can then decide where to put the cursor. + # Currently, the Vim client puts the cursor in the wrong place (i.e. + # before the text, rather than after it). + completion_fixit_chunks = [ + responses.FixItChunk( + '', + responses.Range( + responses.Location( request_data[ 'line_num' ], + start_codepoint, + filepath ), + responses.Location( request_data[ 'line_num' ], + start_codepoint + len( insertion_text ), + filepath ), + ) + ) + ] + # FIXME: The problem with this is that it _might_ break the offsets in any + # additionalTextEdits + fixits.append( + responses.FixIt( completion_fixit_chunks[ 0 ].range.start_, + completion_fixit_chunks, + item[ 'label' ] ) + ) + # Add a fixit which applies this textEdit + contents = GetFileLines( request_data, filepath ) + completion_fixit_chunks = [ + responses.FixItChunk( + text_edit[ 'newText' ], # FIXME: This could also be a Snippet + _BuildRange( contents, filepath, text_edit[ 'range' ] ) + ) + ] + fixits.append( + responses.FixIt( completion_fixit_chunks[ 0 ].range.start_, + completion_fixit_chunks, + item[ 'label' ], + is_completion = True ) + ) else: # Calculate the start codepoint based on the overlapping text in the # insertion text and the existing text. This is the behavior of Visual @@ -3046,21 +3109,27 @@ def _InsertionTextForItem( request_data, item ): start_codepoint -= FindOverlapLength( request_data[ 'prefix' ], insertion_text ) - additional_text_edits.extend( item.get( 'additionalTextEdits' ) or [] ) - + additional_text_edits = item.get( 'additionalTextEdits' ) or [] if additional_text_edits: - filepath = request_data[ 'filepath' ] - contents = GetFileLines( request_data, filepath ) + + # We might have already extracted the contents + if contents is None: + contents = GetFileLines( request_data, filepath ) + chunks = [ responses.FixItChunk( e[ 'newText' ], _BuildRange( contents, filepath, e[ 'range' ] ) ) for e in additional_text_edits ] - fixits = responses.BuildFixItResponse( - [ responses.FixIt( chunks[ 0 ].range.start_, chunks ) ] ) + fixits.append( responses.FixIt( chunks[ 0 ].range.start_, chunks ) ) + + extra_data = responses.BuildFixItResponse( fixits ) if fixits else None + + if insertion_text_is_snippet: + return label, insertion_text, extra_data, start_codepoint - return insertion_text, fixits, start_codepoint + return insertion_text, None, extra_data, start_codepoint def FindOverlapLength( line_value, insertion_text ): diff --git a/ycmd/responses.py b/ycmd/responses.py index 42e86c784d..0370ea36fb 100644 --- a/ycmd/responses.py +++ b/ycmd/responses.py @@ -253,12 +253,18 @@ class Kind: REFACTOR = 'refactor' - def __init__( self, location: Location, chunks, text = '', kind = None ): + def __init__( self, + location: Location, + chunks, + text = '', + kind = None, + is_completion = False ): """location of type Location, chunks of type list""" self.location = location self.chunks = chunks self.text = text self.kind = kind + self.is_completion = is_completion class FixItChunk: @@ -319,7 +325,7 @@ def BuildFixItData( fixit ): 'command': fixit.command, 'text': fixit.text, 'kind': fixit.kind, - 'resolve': fixit.resolve + 'resolve': fixit.resolve, } else: result = { @@ -327,7 +333,8 @@ def BuildFixItData( fixit ): 'chunks' : [ BuildFixitChunkData( x ) for x in fixit.chunks ], 'text': fixit.text, 'kind': fixit.kind, - 'resolve': False + 'resolve': False, + 'is_completion': fixit.is_completion, } if result[ 'kind' ] is None: diff --git a/ycmd/utils.py b/ycmd/utils.py index 74c352b2b9..9d321a4bcc 100644 --- a/ycmd/utils.py +++ b/ycmd/utils.py @@ -25,6 +25,7 @@ import tempfile import time import threading +import collections.abc as collections_abc LOGGER = logging.getLogger( 'ycmd' ) ROOT_DIR = os.path.normpath( os.path.join( os.path.dirname( __file__ ), '..' ) )