From abea266af311dab4de07386604844792874d8851 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sun, 11 Dec 2016 19:12:27 +0000 Subject: [PATCH 01/51] Basic framework for Java Language Server connection Build the jdt.ls server in build.py Start the server Connect to the server using stdio Send the initialise request Basic support for completions (prototype) Update the file contents in the server Use a workspace dir for each invocation. --- .gitignore | 4 + .gitmodules | 3 + build.py | 51 +- third_party/eclipse.jdt.ls | 1 + ycmd/completers/java/__init__.py | 0 ycmd/completers/java/hook.py | 33 ++ ycmd/completers/java/java_completer.py | 300 ++++++++++ ycmd/completers/java/test/test_server.py | 47 ++ ycmd/completers/language_server/__init__.py | 0 .../language_server_completer.py | 534 ++++++++++++++++++ ycmd/completers/language_server/lsapi.py | 130 +++++ 11 files changed, 1093 insertions(+), 10 deletions(-) create mode 160000 third_party/eclipse.jdt.ls create mode 100644 ycmd/completers/java/__init__.py create mode 100644 ycmd/completers/java/hook.py create mode 100644 ycmd/completers/java/java_completer.py create mode 100755 ycmd/completers/java/test/test_server.py create mode 100644 ycmd/completers/language_server/__init__.py create mode 100644 ycmd/completers/language_server/language_server_completer.py create mode 100644 ycmd/completers/language_server/lsapi.py diff --git a/.gitignore b/.gitignore index ab68152069..1508269176 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,7 @@ coverage.xml # API docs docs/node_modules docs/package-lock.json + +# The JDT java completer requires a workspace (for no explicable reason other +# than "eclipse") +third_party/eclipse.jdt.ls-workspace diff --git a/.gitmodules b/.gitmodules index 8ae61da77c..01d6342647 100644 --- a/.gitmodules +++ b/.gitmodules @@ -32,3 +32,6 @@ [submodule "third_party/python-future"] path = third_party/python-future url = https://github.com/PythonCharmers/python-future +[submodule "third_party/eclipse.jdt.ls"] + path = third_party/eclipse.jdt.ls + url = https://github.com/eclipse/eclipse.jdt.ls diff --git a/build.py b/build.py index ee44cd2d93..8d7fbb0fce 100755 --- a/build.py +++ b/build.py @@ -85,6 +85,17 @@ def OnCiService(): return 'CI' in os.environ +def FindExecutableOrDie( executable, message ): + path = FindExecutable( executable ) + + if not path: + sys.exit( "ERROR: Unabel to find executable '{0}'. {1}".format( + executable, + message ) ) + + return path + + # On Windows, distutils.spawn.find_executable only works for .exe files # but .bat and .cmd files are also executables, so we use our own # implementation. @@ -263,6 +274,8 @@ def ParseArguments(): help = 'Enable Rust semantic completion engine.' ) parser.add_argument( '--js-completer', action = 'store_true', help = 'Enable JavaScript semantic completion engine.' ), + parser.add_argument( '--java-completer', action = 'store_true', + help = 'Enable Java semantic completion engine.' ), parser.add_argument( '--system-boost', action = 'store_true', help = 'Use the system boost instead of bundled one. ' 'NOT RECOMMENDED OR SUPPORTED!') @@ -466,24 +479,23 @@ def EnableCsCompleter(): def EnableGoCompleter(): - if not FindExecutable( 'go' ): - sys.exit( 'ERROR: go is required to build gocode.' ) + go = FindExecutableOrDie( 'go', 'go is required to build gocode.' ) os.chdir( p.join( DIR_OF_THIS_SCRIPT, 'third_party', 'gocode' ) ) - CheckCall( [ 'go', 'build' ] ) + CheckCall( [ go, 'build' ] ) os.chdir( p.join( DIR_OF_THIS_SCRIPT, 'third_party', 'godef' ) ) - CheckCall( [ 'go', 'build', 'godef.go' ] ) + CheckCall( [ go, 'build', 'godef.go' ] ) def EnableRustCompleter(): """ Build racerd. This requires a reasonably new version of rustc/cargo. """ - if not FindExecutable( 'cargo' ): - sys.exit( 'ERROR: cargo is required for the Rust completer.' ) + cargo = FindExecutableOrDie( 'cargo', + 'cargo is required for the Rust completer.' ) os.chdir( p.join( DIR_OF_THIRD_PARTY, 'racerd' ) ) - args = [ 'cargo', 'build' ] + args = [ cargo, 'build' ] # We don't use the --release flag on CI services because it makes building # racerd 2.5x slower and we don't care about the speed of the produced racerd. if not OnCiService(): @@ -496,9 +508,7 @@ def EnableJavaScriptCompleter(): node = PathToFirstExistingExecutable( [ 'nodejs', 'node' ] ) if not node: sys.exit( 'ERROR: node is required to set up Tern.' ) - npm = FindExecutable( 'npm' ) - if not npm: - sys.exit( 'ERROR: npm is required to set up Tern.' ) + npm = FindExecutableOrDie( 'npm', 'ERROR: npm is required to set up Tern.' ) # We install Tern into a runtime directory. This allows us to control # precisely the version (and/or git commit) that is used by ycmd. We use a @@ -522,6 +532,25 @@ def EnableJavaScriptCompleter(): CheckCall( [ npm, 'install', '--production' ] ) +def EnableJavaCompleter(): + os.chdir( p.join( DIR_OF_THIS_SCRIPT, + 'third_party', + 'eclipse.jdt.ls' ) ) + + if OnWindows(): + mvnw = 'mvnw.cmd' + else: + mvnw = './mvnw' + + # Maven actually just straight up sucks. There is seemingly no way to do + # working, reliable incremental builds. It also takes _forever_ doing things + # that you _don't want it to do_, like downloading the internet. + # Alas, I'm not aware of a better way, and these are the instructions provided + # by the people that made JDT language server, so we waste the user's time + # (somewhat) unnecessarily. + CheckCall( [ mvnw, 'clean', 'package' ] ) + + def WritePythonUsedDuringBuild(): path = p.join( DIR_OF_THIS_SCRIPT, 'PYTHON_USED_DURING_BUILDING' ) with open( path, 'w' ) as f: @@ -542,6 +571,8 @@ def Main(): EnableJavaScriptCompleter() if args.rust_completer or args.racer_completer or args.all_completers: EnableRustCompleter() + if args.java_completer or args.all_completers: + EnableJavaCompleter() if __name__ == '__main__': diff --git a/third_party/eclipse.jdt.ls b/third_party/eclipse.jdt.ls new file mode 160000 index 0000000000..aec31fbb8f --- /dev/null +++ b/third_party/eclipse.jdt.ls @@ -0,0 +1 @@ +Subproject commit aec31fbb8fa88bc5c6c205ac517a770ad035b38d diff --git a/ycmd/completers/java/__init__.py b/ycmd/completers/java/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ycmd/completers/java/hook.py b/ycmd/completers/java/hook.py new file mode 100644 index 0000000000..6af9c47a7e --- /dev/null +++ b/ycmd/completers/java/hook.py @@ -0,0 +1,33 @@ +# Copyright (C) 2016 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +from ycmd.completers.java.java_completer import ( + ShouldEnableJavaCompleter, JavaCompleter ) + + +def GetCompleter( user_options ): + if not ShouldEnableJavaCompleter(): + return None + + return JavaCompleter( user_options ) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py new file mode 100644 index 0000000000..4a477c377d --- /dev/null +++ b/ycmd/completers/java/java_completer.py @@ -0,0 +1,300 @@ +# Copyright (C) 2016 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +import logging +import os +import threading +import glob + +from shutil import rmtree +from subprocess import PIPE + +from ycmd import ( utils, responses ) + +from ycmd.completers.language_server import language_server_completer + +_logger = logging.getLogger( __name__ ) + +LANGUAGE_SERVER_HOME = os.path.join( os.path.dirname( __file__ ), + '..', + '..', + '..', + 'third_party', + 'eclipse.jdt.ls', + 'org.eclipse.jdt.ls.product', + 'target', + 'repository') + +PATH_TO_JAVA = utils.PathToFirstExistingExecutable( [ 'java' ] ) + +# TODO: If there are multiple instances of ycmd running, they will _share_ this +# path. I don't think (from memory) that eclipse actually supports that and +# probably aborts +WORKSPACE_PATH_BASE = os.path.join( os.path.dirname( __file__ ), + '..', + '..', + '..', + 'third_party', + 'eclipse.jdt.ls-workspace' ) + + +def ShouldEnableJavaCompleter(): + _logger.info( 'Looking for java language server (eclipse.jdt.ls)' ) + if not PATH_TO_JAVA: + _logger.warning( "Not enabling java completion: Couldn't find java" ) + return False + + if not os.path.exists( LANGUAGE_SERVER_HOME ): + _logger.warning( 'Not using java completion: not installed' ) + return False + + if not _PathToLauncherJar(): + _logger.warning( 'Not using java completion: jdt.ls is not built' ) + return False + + return True + + +def _PathToLauncherJar(): + # The file name changes between version of eclipse, so we use a glob as + # recommended by the language server developers. There should only be one. + # TODO: sort ? + p = glob.glob( + os.path.abspath( + os.path.join( + LANGUAGE_SERVER_HOME, + 'plugins', + 'org.eclipse.equinox.launcher_*.jar' ) ) ) + + _logger.debug( 'Found launchers: {0}'.format( p ) ) + + if not p: + return None + + return p[ 0 ] + + +def _LauncherConfiguration(): + if utils.OnMac(): + config = 'config_mac' + elif utils.OnWindows(): + config = 'config_win' + else: + config = 'config_linux' + + return os.path.abspath( os.path.join( LANGUAGE_SERVER_HOME, config ) ) + + +class JavaCompleter( language_server_completer.LanguageServerCompleter ): + def __init__( self, user_options ): + super( JavaCompleter, self ).__init__( user_options ) + + self._server_keep_logfiles = user_options[ 'server_keep_logfiles' ] + + # Used to ensure that starting/stopping of the server is synchronised + self._server_state_mutex = threading.RLock() + + with self._server_state_mutex: + self._server = None + self._server_handle = None + self._server_stderr = None + self._workspace_path = os.path.join( + os.path.abspath( WORKSPACE_PATH_BASE ), + str( os.getpid() ) ) + + self._Reset() + try : + self._StartServer() + except: + _logger.exception( "The java language server failed to start." ) + self._StopServer() + + + def GetServer( self ): + return self._server + + + def SupportedFiletypes( self ): + return [ 'java' ] + + + def DebugInfo( self, request_data ): + return responses.BuildDebugInfoResponse( + name = "Java", + servers = [ + responses.DebugInfoServer( + name = "Java Language Server", + handle = self._server_handle, + executable = LANGUAGE_SERVER_HOME, + logfiles = [ + self._server_stderr, + os.path.join( self._workspace_path, '.metadata', '.log' ) + ] ) + ], + items = [ + responses.DebugInfoItem( 'Workspace Path', self._workspace_path ), + responses.DebugInfoItem( 'Java Path', PATH_TO_JAVA ), + responses.DebugInfoItem( 'jdt.ls Path', _PathToLauncherJar() ), + responses.DebugInfoItem( 'Launcher Config.', _LauncherConfiguration() ), + ] ) + + + def Shutdown( self ): + self._StopServer() + + + def ServerIsHealthy( self, request_data = {} ): + if not self._ServerIsRunning(): + return False + + return True + + + def ShouldUseNowInner( self, request_data ): + if not self.ServerIsReady(): + return False + + return super( JavaCompleter, self ).ShouldUseNowInner( request_data ) + + + def _Reset( self ): + if not self._server_keep_logfiles: + if self._server_stderr: + utils.RemoveIfExists( self._server_stderr ) + self._server_stderr = None + + self._server_handle = None + + try: + rmtree( self._workspace_path ) + except OSError: + # We actually just ignore the error because on startup it won't exist + _logger.exception( 'Failed to remove workspace path: {0}'.format( + self._workspace_path ) ) + + self._server = None + + + def _StartServer( self ): + with self._server_state_mutex: + _logger.info( 'Starting JDT Language Server...' ) + + command = [ + PATH_TO_JAVA, + '-Declipse.application=org.eclipse.jdt.ls.core.id1', + '-Dosgi.bundles.defaultStartLevel=4', + '-Declipse.product=org.eclipse.jdt.ls.core.product', + '-Dlog.level=ALL', + '-jar', + _PathToLauncherJar(), + '-configuration', + _LauncherConfiguration(), + '-data', + self._workspace_path + ] + + _logger.debug( 'Starting java-server with the following command: ' + '{0}'.format( ' '.join( command ) ) ) + + LOGFILE_FORMAT = 'java_language_server_{pid}_{std}_' + + self._server_stderr = utils.CreateLogfile( + LOGFILE_FORMAT.format( pid = os.getpid(), std = 'stderr' ) ) + + with utils.OpenForStdHandle( self._server_stderr ) as stderr: + self._server_handle = utils.SafePopen( command, + stdin = PIPE, + stdout = PIPE, + stderr = stderr ) + + if self._ServerIsRunning(): + _logger.info( 'JDT Language Server started' ) + else: + _logger.warning( 'JDT Language Server failed to start' ) + return + + self._server = ( + language_server_completer.StandardIOLanguageServerConnection( + self._server_handle.stdin, + self._server_handle.stdout ) + ) + + self._server.start() + + # Awaiting connection + try: + self._server.TryServerConnection() + except language_server_completer.LanguageServerConnectionTimeout: + _logger.warn( 'Java language server failed to start, or did not ' + 'connect successfully' ) + self._StopServer() + return + + self._WaitForInitiliase() + + + def _StopServer( self ): + with self._server_state_mutex: + if self._ServerIsRunning(): + # We don't use utils.CloseStandardStreams, because the stdin/out is + # connected to our server connector. Just close stderr. + if self._server_handle and self._server_handle.stderr: + self._server_handle.stderr.close() + + self._server.stop() + + _logger.info( 'Stopping java server with PID {0}'.format( + self._server_handle.pid ) ) + + self._server_handle.terminate() + + try: + utils.WaitUntilProcessIsTerminated( self._server_handle, + timeout = 5 ) + + self._server.join() + + _logger.info( 'JDT Language server stopped' ) + except RuntimeError: + _logger.exception( 'Error while stopping java server' ) + + + self._Reset() + + + def GetSubcommandsMap( self ): + return { + 'RestartServer': ( lambda self, request_data, args: + self._RestartServer() ), + } + + + def _RestartServer( self ): + with self._server_state_mutex: + self._StopServer() + self._StartServer() + + + def _ServerIsRunning( self ): + return utils.ProcessIsRunning( self._server_handle ) diff --git a/ycmd/completers/java/test/test_server.py b/ycmd/completers/java/test/test_server.py new file mode 100755 index 0000000000..946568a0e2 --- /dev/null +++ b/ycmd/completers/java/test/test_server.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python + +from __future__ import absolute_import +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +# Other imports from `future` must be placed after SetUpPythonPath. + +import sys +import os + +# TODO: Java 8 required (validate this) +PATH_TO_YCMD = os.path.join( os.path.dirname( __file__ ), + '..', + '..', + '..', + '..' ) + +sys.path.insert( 0, os.path.abspath( PATH_TO_YCMD ) ) +from ycmd.server_utils import SetUpPythonPath +SetUpPythonPath() + +from future import standard_library +standard_library.install_aliases() +from builtins import * # noqa + +import logging + + +def SetUpLogging( log_level ): + numeric_level = getattr( logging, log_level.upper(), None ) + if not isinstance( numeric_level, int ): + raise ValueError( 'Invalid log level: %s' % log_level ) + + # Has to be called before any call to logging.getLogger() + logging.basicConfig( format = '%(asctime)s - %(levelname)s - %(message)s', + level = numeric_level ) + + +if __name__ == '__main__': + SetUpLogging( 'debug' ) + + from ycmd.completers.java.hook import GetCompleter + from ycmd.user_options_store import DefaultOptions + completer = GetCompleter( DefaultOptions() ) + + completer.Shutdown() diff --git a/ycmd/completers/language_server/__init__.py b/ycmd/completers/language_server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py new file mode 100644 index 0000000000..8799f33a96 --- /dev/null +++ b/ycmd/completers/language_server/language_server_completer.py @@ -0,0 +1,534 @@ +# Copyright (C) 2016 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +from future.utils import iteritems, iterkeys +import logging +import threading +import os +import queue +import json + +from ycmd.completers.completer import Completer +from ycmd import utils +from ycmd import responses + +from ycmd.completers.language_server import lsapi + +_logger = logging.getLogger( __name__ ) + + +class Response( object ): + def __init__( self ): + self._event = threading.Event() + self._message = None + + + def ResponseReceived( self, message ): + self._message = message + self._event.set() + + + def AwaitResponse( self, timeout ): + self._event.wait( timeout ) + + if not self._event.isSet(): + raise RuntimeError( 'Response Timeout' ) + + if 'error' in self._message: + error = self._message[ 'error' ] + raise RuntimeError( 'Request failed: {0}: {1}'.format( + error.get( 'code', 0 ), + error.get( 'message', 'No message' ) ) ) + + return self._message + + +class LanguageServerConnectionTimeout( Exception ): + pass + + +class LanguageServerConnectionStopped( Exception ): + pass + + +class LanguageServerConnection( object ): + """ + Abstract language server communication object. + + Implementations are required to provide the following methods: + - _TryServerConnectionBlocking: Connect to the server and return when the + connection is established + - _Write: Write some data to the server + - _Read: Read some data from the server, blocking until some data is + available + """ + def __init__( self ): + super( LanguageServerConnection, self ).__init__() + + self._lastId = 0 + self._responses = {} + self._responseMutex = threading.Lock() + self._notifications = queue.Queue() + + self._connection_event = threading.Event() + self._stop_event = threading.Event() + + + def stop( self ): + # Note lowercase stop() to match threading.Thread.start() + self._Stop() + self._stop_event.set() + + + def IsStopped( self ): + return self._stop_event.is_set() + + + def NextRequestId( self ): + with self._responseMutex: + self._lastId += 1 + return str( self._lastId ) + + + def GetResponse( self, request_id, message, timeout=1 ): + response = Response() + + with self._responseMutex: + assert request_id not in self._responses + self._responses[ request_id ] = response + + _logger.debug( 'TX: Sending message {0}'.format( message ) ) + + self._Write( message ) + return response.AwaitResponse( timeout ) + + + def SendNotification( self, message ): + _logger.debug( 'TX: Sending Notification {0}'.format( message ) ) + + self._Write( message ) + + + def TryServerConnection( self ): + self._connection_event.wait( timeout = 5 ) + + if not self._connection_event.isSet(): + raise LanguageServerConnectionTimeout( + 'Timed out waiting for server to connect' ) + + + def _run_loop( self ): + # Wait for the connection to fully establish (this runs in the thread + # context, so we block until a connection is received or there is a timeout, + # which throws an exception) + try: + self._TryServerConnectionBlocking() + + self._connection_event.set() + + # Blocking loop which reads whole messages and calls _DespatchMessage + self._ReadMessages( ) + except LanguageServerConnectionStopped: + _logger.debug( 'Connection was closed cleanly' ) + pass + + + def _ReadHeaders( self, data ): + headers_complete = False + prefix = bytes( b'' ) + headers = {} + + while not headers_complete: + read_bytes = 0 + last_line = 0 + if len( data ) == 0: + data = self._Read() + + while read_bytes < len( data ): + if utils.ToUnicode( data[ read_bytes: ] )[ 0 ] == '\n': + line = prefix + data[ last_line : read_bytes ].strip() + prefix = bytes( b'' ) + last_line = read_bytes + + if not line.strip(): + _logger.debug( "Headers complete" ) + headers_complete = True + read_bytes += 1 + break + else: + _logger.debug( "Header line: {0}".format( line ) ) + key, value = utils.ToUnicode( line ).split( ':', 1 ) + headers[ key.strip() ] = value.strip() + + read_bytes += 1 + + if not headers_complete: + prefix = data[ last_line : ] + data = bytes( b'' ) + + + return ( data, read_bytes, headers ) + + + def _ReadMessages( self ): + data = bytes( b'' ) + while True: + ( data, read_bytes, headers ) = self._ReadHeaders( data ) + + if 'Content-Length' not in headers: + raise RuntimeError( "Missing 'Content-Length' header" ) + + content_length = int( headers[ 'Content-Length' ] ) + + # We need to read content_length bytes for the payload of this message. + # This may be in the remainder of `data`, but equally we may need to read + # more data from the socket. + content = bytes( b'' ) + content_read = 0 + if read_bytes < len( data ): + # There are bytes left in data, use them + data = data[ read_bytes: ] + + # Read up to content_length bytes from data + content_to_read = min( content_length, len( data ) ) + content += data[ : content_to_read ] + content_read += len( content ) + read_bytes = content_to_read + + while content_read < content_length: + # There is more content to read, but data is exhausted - read more from + # the socket + data = self._Read( content_length - content_read ) + content_to_read = min( content_length - content_read, len( data ) ) + content += data[ : content_to_read ] + content_read += len( content ) + read_bytes = content_to_read + + # lsapi will convert content to unicode + self._DespatchMessage( lsapi.Parse( content ) ) + + # We only consumed len( content ) of data. If there is more, we start + # again with the remainder and look for headers + data = data[ read_bytes : ] + + + def _DespatchMessage( self, message ): + _logger.debug( 'RX: Received message: {0}'.format( message ) ) + if 'id' in message: + with self._responseMutex: + assert str( message[ 'id' ] ) in self._responses + self._responses[ str( message[ 'id' ] ) ].ResponseReceived( message ) + else: + self._notifications.put( message ) + + + def _TryServerConnectionBlocking( self ): + raise RuntimeError( 'Not implemented' ) + + + def _Stop( self ): + raise RuntimeError( 'Not implemented' ) + + + def _Write( self, data ): + raise RuntimeError( 'Not implemented' ) + + + def _Read( self, size=-1 ): + raise RuntimeError( 'Not implemented' ) + + +class StandardIOLanguageServerConnection( LanguageServerConnection, + threading.Thread ): + def __init__( self, server_stdin, server_stdout ): + super( StandardIOLanguageServerConnection, self ).__init__() + + self.server_stdin = server_stdin + self.server_stdout = server_stdout + + + def run( self ): + self._run_loop() + + + def _TryServerConnectionBlocking( self ): + # standard in/out don't need to wait for the server to connect to us + return True + + + def _Stop( self ): + self.server_stdin.close() + + + def _Write( self, data ): + to_write = data + utils.ToBytes( '\r\n' ) + _logger.debug( 'Writing: ' + utils.ToUnicode( to_write ) ) + self.server_stdin.write( to_write ) + self.server_stdin.flush() + + + def _Read( self, size=-1 ): + if size > -1: + data = self.server_stdout.read( size ) + else: + data = self.server_stdout.readline() + + if self.IsStopped(): + raise LanguageServerConnectionStopped() + + if not data: + # No data means the connection was severed. Connection severed when (not + # self.IsStopped()) means the server died unexpectedly. + raise RuntimeError( "Connection to server died" ) + + _logger.debug( "Data!!: {0}".format( data ) ) + return data + + +class LanguageServerCompleter( Completer ): + def __init__( self, user_options): + super( LanguageServerCompleter, self ).__init__( user_options ) + self._latest_diagnostics = { + 'uri': None, + 'diagnostics': [] + } + self._syncType = 'Full' + + self._serverFileState = {} + self._fileStateMutex = threading.Lock() + + + def GetServer( sefl ): + """Method that must be implemented by derived classes to return an instance + of LanguageServerConnection appropriate for the language server in + question""" + # TODO: I feel like abc.abstractmethod could be used here, but I'm not sure + # if it is totally python2/3 safe, and TBH it doesn't do a lot more than + # this simple raise here, so... + raise NameError( "GetServer must be implemented in LanguageServerCompleter " + "subclasses" ) + + + def ComputeCandidatesInner( self, request_data ): + if not self.ServerIsHealthy(): + return None + + # Need to update the file contents. TODO: so inefficient (and doesn't work + # for the eclipse based completer for some reason - possibly because it + # is busy parsing the file when it actually should be providing + # completions)! + self._RefreshFiles( request_data ) + + request_id = self.GetServer().NextRequestId() + msg = lsapi.Completion( request_id, request_data ) + response = self.GetServer().GetResponse( request_id, msg ) + + do_resolve = ( + 'completionProvider' in self._server_capabilities and + self._server_capabilities[ 'completionProvider' ].get( 'resolveProvider', + False ) ) + + def MakeCompletion( item ): + # First, resolve the completion. + # TODO: Maybe we need some way to do this based on a trigger + # TODO: Need a better API around request IDs. We no longer care about them + # _at all_ here. + + if do_resolve: + resolve_id = self.GetServer().NextRequestId() + resolve = lsapi.ResolveCompletion( resolve_id, item ) + response = self.GetServer().GetResponse( resolve_id, resolve ) + item = response[ 'result' ] + + ITEM_KIND = [ + None, # 1-based + 'Text', + 'Method', + 'Function', + 'Constructor', + 'Field', + 'Variable', + 'Class', + 'Interface', + 'Module', + 'Property', + 'Unit', + 'Value', + 'Enum', + 'Keyword', + 'Snippet', + 'Color', + 'File', + 'Reference', + ] + + if 'textEdit' in item and item[ 'textEdit' ]: + # TODO: This is a very annoying way to supply completions, but YCM could + # technically support it via FixIt + insertion_text = item[ 'textEdit' ][ 'newText' ] + elif 'insertText' in item and item[ 'insertText' ]: + insertion_text = item[ 'insertText' ] + else: + insertion_text = item[ 'label' ] + + return responses.BuildCompletionData( + insertion_text, + None, + None, + item[ 'label' ], + ITEM_KIND[ item.get( 'kind', 0 ) ], + None ) + + if isinstance( response[ 'result' ], list ): + items = response[ 'result' ] + else: + items = response[ 'result' ][ 'items' ] + return [ MakeCompletion( i ) for i in items ] + + + def OnFileReadyToParse( self, request_data ): + if self.ServerIsReady(): + self._RefreshFiles( request_data ) + + def BuildLocation( filename, loc ): + # TODO: Look at tern complete, requires file contents to convert codepoint + # offset to byte offset + return responses.Location( line = loc[ 'line' ] + 1, + column = loc[ 'character' ] + 1, + filename = os.path.realpath( filename ) ) + + def BuildRange( filename, r ): + return responses.Range( BuildLocation( filename, r[ 'start' ] ), + BuildLocation( filename, r[ 'end' ] ) ) + + + def BuildDiagnostic( filename, diag ): + filename = lsapi.UriToFilePath( filename ) + r = BuildRange( filename, diag[ 'range' ] ) + SEVERITY = [ + None, + 'Error', + 'Warning', + 'Information', + 'Hint', + ] + SEVERITY_TO_YCM_SEVERITY = { + 'Error': 'ERROR', + 'Warning': 'WARNING', + 'Information': 'WARNING', + 'Hint': 'WARNING' + } + + return responses.BuildDiagnosticData ( responses.Diagnostic( + ranges = [ r ], + location = r.start_, + location_extent = r, + text = diag[ 'message' ], + kind = SEVERITY_TO_YCM_SEVERITY[ SEVERITY[ diag[ 'severity' ] ] ] ) ) + + # TODO: Maybe we need to prevent duplicates? Anyway, handle all of the + # notification messages + latest_diagnostics = None + try: + while True: + if not self.GetServer(): + # The server isn't running or something. Don't re-poll. + return False + + notification = self._server._notifications.get_nowait() + _logger.debug( 'notification {0}: {1}'.format( + notification[ 'method' ], + json.dumps( notification[ 'params' ], indent = 2 ) ) ) + + if notification[ 'method' ] == 'textDocument/publishDiagnostics': + _logger.debug( 'latest_diagnostics updated' ) + latest_diagnostics = notification + except queue.Empty: + pass + + if latest_diagnostics is not None: + _logger.debug( 'new diagnostics, updating latest received' ) + self._latest_diagnostics = latest_diagnostics[ 'params' ] + else: + _logger.debug( 'No new diagnostics, using latest received' ) + + diags = [ BuildDiagnostic( self._latest_diagnostics[ 'uri' ], x ) + for x in self._latest_diagnostics[ 'diagnostics' ] ] + _logger.debug( 'Diagnostics: {0}'.format( diags ) ) + return diags + + + def _RefreshFiles( self, request_data ): + with self._fileStateMutex: + for file_name, file_data in iteritems( request_data[ 'file_data' ] ): + file_state = 'New' + if file_name in self._serverFileState: + file_state = self._serverFileState[ file_name ] + + if file_state == 'New' or self._syncType == 'Full': + msg = lsapi.DidOpenTextDocument( file_name, + file_data[ 'filetypes' ], + file_data[ 'contents' ] ) + else: + # FIXME: DidChangeTextDocument doesn't actally do anything different + # from DidOpenTextDocument because we don't actually have a mechanism + # for generating the diffs (which would just be a waste of time) + # + # One option would be to just replcae the entire file, but some + # servers (i'm looking at you javac completer) don't update + # diagnostics until you open or save a document. Sigh. + msg = lsapi.DidChangeTextDocument( file_name, + file_data[ 'filetypes' ], + file_data[ 'contents' ] ) + + self._serverFileState[ file_name ] = 'Open' + self.GetServer().SendNotification( msg ) + + for file_name in iterkeys(self._serverFileState ): + if file_name not in request_data[ 'file_data' ]: + msg = lsapi.DidCloseTextDocument( file_name ) + del self._serverFileState[ file_name ] + self.GetServer().SendNotification( msg ) + + + def _WaitForInitiliase( self ): + request_id = self.GetServer().NextRequestId() + + msg = lsapi.Initialise( request_id ) + response = self.GetServer().GetResponse( request_id, + msg, + timeout = 3 ) + + self._server_capabilities = response[ 'result' ][ 'capabilities' ] + + if 'textDocumentSync' in response[ 'result' ][ 'capabilities' ]: + SYNC_TYPE = [ + 'None', + 'Full', + 'Incremental' + ] + self._syncType = SYNC_TYPE[ + response[ 'result' ][ 'capabilities' ][ 'textDocumentSync' ] ] + _logger.info( 'Language Server requires sync type of {0}'.format( + self._syncType ) ) diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py new file mode 100644 index 0000000000..c60725b4ac --- /dev/null +++ b/ycmd/completers/language_server/lsapi.py @@ -0,0 +1,130 @@ +# Copyright (C) 2016 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +import os +import json +from urllib import parse as urlparse + +from collections import defaultdict + +from ycmd.utils import ToBytes, ToUnicode + + +# TODO: Need a whole document management system! +LAST_VERSION = defaultdict( int ) + + +def BuildRequest( request_id, method, parameters ): + return _Message( { + 'id': request_id, + 'method': method, + 'params': parameters, + } ) + + +def BuildNotification( method, parameters ): + return _Message( { + 'method': method, + 'params': parameters, + } ) + + +def Initialise( request_id ): + return BuildRequest( request_id, 'initialize', { + 'processId': os.getpid(), + 'rootPath': os.getcwd(), # deprecated + 'rootUri': _MakeUriForFile( os.getcwd() ), + 'initializationOptions': { }, + 'capabilities': { } + } ) + + +def DidOpenTextDocument( file_name, file_types, file_contents ): + LAST_VERSION[ file_name ] = LAST_VERSION[ file_name ] + 1 + return BuildNotification( 'textDocument/didOpen', { + 'textDocument': { + 'uri': _MakeUriForFile( file_name ), + 'languageId': '/'.join( file_types ), + 'version': LAST_VERSION[ file_name ], + 'text': file_contents + } + } ) + + +def DidChangeTextDocument( file_name, file_types, file_contents ): + # FIXME: The servers seem to all state they want incremental updates. It + # remains to be seen if they really do. + return DidOpenTextDocument( file_name, file_types, file_contents ) + + +def DidCloseTextDocument( file_name ): + return BuildNotification( 'textDocument/didClose', { + 'textDocument': { + 'uri': _MakeUriForFile( file_name ), + 'version': LAST_VERSION[ file_name ], + }, + } ) + + +def Completion( request_id, request_data ): + return BuildRequest( request_id, 'textDocument/completion', { + 'textDocument': { + 'uri': _MakeUriForFile( request_data[ 'filepath' ] ), + }, + 'position': { + # TODO: The API asks for 0-based offsets. These -1's are not good enough + # when using multi-byte characters. See the tern completer for an + # approach. + 'line': request_data[ 'line_num' ] - 1, + 'character': request_data[ 'start_column' ] - 1, + } + } ) + + +def ResolveCompletion( request_id, completion ): + return BuildRequest( request_id, 'completionItem/resolve', completion ) + + +def _MakeUriForFile( file_name ): + return 'file://{0}'.format( file_name ) + + +def UriToFilePath( uri ): + # TODO: This assumes file:// + # TODO: work out how urlparse works with __future__ + return urlparse.urlparse( uri ).path + + +def _Message( message ): + message[ 'jsonrpc' ] = '2.0' + data = ToBytes( json.dumps( message, sort_keys=True ) ) + packet = ToBytes( 'Content-Length: {0}\r\n' + 'Content-Type: application/vscode-jsonrpc;charset=utf8\r\n' + '\r\n' + .format( len(data) ) ) + data + return packet + + +def Parse( data ): + return json.loads( ToUnicode( data ) ) From b32aa86187b4f19b6c9d1a838023dc47b43058ae Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sat, 18 Feb 2017 18:21:30 +0000 Subject: [PATCH 02/51] Support for basic completions Apply textEdits in completion items Language server protocol requires that the client can apply textEdits, rather than just simple text. This is not an optional feature, but ycmd clients do not have this ability. The protocol, however, restricts that the edit must include the original requested completion position, so we can perform some simple text manipulation to apply the edit to the current line and determine the completion start column based on that. In particualr, the jdt.ls server returns textEdits that replace the entered text for import completions, which is one of the most useful completions. TODO: this doesn't currently work where there is a mixture of start positions. We also include experimental support for "additionalTextEdits" which allow automatic insertion of, e.g., import statements when selecting completion items. These are sent on the completion response as an additional completer data item called 'fixits'. The client applies the same logic as a standard FixIt once the selected completion item is inserted. --- .../language_server_completer.py | 164 +++++++++++++----- ycmd/completers/language_server/lsapi.py | 2 + 2 files changed, 123 insertions(+), 43 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 8799f33a96..896d9f00a0 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -30,6 +30,7 @@ import json from ycmd.completers.completer import Completer +# from ycmd.completers.completer_utils import GetFileContents from ycmd import utils from ycmd import responses @@ -361,44 +362,50 @@ def MakeCompletion( item ): response = self.GetServer().GetResponse( resolve_id, resolve ) item = response[ 'result' ] + # Note Vim only displays the first character, so we map them to the + # documented Vim kinds: + # + # v variable + # f function or method + # m member of a struct or class + # t typedef + # d #define or macro + # + # FIXME: I'm not happy with this completely. We're losing useful info, + # perhaps unnecessarily. ITEM_KIND = [ None, # 1-based - 'Text', - 'Method', - 'Function', - 'Constructor', - 'Field', - 'Variable', - 'Class', - 'Interface', - 'Module', - 'Property', - 'Unit', - 'Value', - 'Enum', - 'Keyword', - 'Snippet', - 'Color', - 'File', - 'Reference', + 'd', # 'Text', + 'f', # 'Method', + 'f', # 'Function', + 'f', # 'Constructor', + 'm', # 'Field', + 'm', # 'Variable', + 't', # 'Class', + 't', # 'Interface', + 't', # 'Module', + 't', # 'Property', + 't', # 'Unit', + 'd', # 'Value', + 't', # 'Enum', + 'd', # 'Keyword', + 'd', # 'Snippet', + 'd', # 'Color', + 'd', # 'File', + 'd', # 'Reference', ] - if 'textEdit' in item and item[ 'textEdit' ]: - # TODO: This is a very annoying way to supply completions, but YCM could - # technically support it via FixIt - insertion_text = item[ 'textEdit' ][ 'newText' ] - elif 'insertText' in item and item[ 'insertText' ]: - insertion_text = item[ 'insertText' ] - else: - insertion_text = item[ 'label' ] + ( insertion_text, fixits ) = self._GetInsertionText( request_data, item ) return responses.BuildCompletionData( insertion_text, - None, - None, - item[ 'label' ], - ITEM_KIND[ item.get( 'kind', 0 ) ], - None ) + extra_menu_info = item.get( 'detail', None ), + detailed_info = ( item[ 'label' ] + + '\n\n' + + item.get( 'documentation', '' ) ), + menu_text = item[ 'label' ], + kind = ITEM_KIND[ item.get( 'kind', 0 ) ], + extra_data = fixits ) if isinstance( response[ 'result' ], list ): items = response[ 'result' ] @@ -411,18 +418,6 @@ def OnFileReadyToParse( self, request_data ): if self.ServerIsReady(): self._RefreshFiles( request_data ) - def BuildLocation( filename, loc ): - # TODO: Look at tern complete, requires file contents to convert codepoint - # offset to byte offset - return responses.Location( line = loc[ 'line' ] + 1, - column = loc[ 'character' ] + 1, - filename = os.path.realpath( filename ) ) - - def BuildRange( filename, r ): - return responses.Range( BuildLocation( filename, r[ 'start' ] ), - BuildLocation( filename, r[ 'end' ] ) ) - - def BuildDiagnostic( filename, diag ): filename = lsapi.UriToFilePath( filename ) r = BuildRange( filename, diag[ 'range' ] ) @@ -486,6 +481,8 @@ def _RefreshFiles( self, request_data ): if file_name in self._serverFileState: file_state = self._serverFileState[ file_name ] + _logger.debug( 'Refreshing file {0}: State is {1}'.format( + file_name, file_state ) ) if file_state == 'New' or self._syncType == 'Full': msg = lsapi.DidOpenTextDocument( file_name, file_data[ 'filetypes' ], @@ -532,3 +529,84 @@ def _WaitForInitiliase( self ): response[ 'result' ][ 'capabilities' ][ 'textDocumentSync' ] ] _logger.info( 'Language Server requires sync type of {0}'.format( self._syncType ) ) + + + def _GetInsertionText( self, request_data, item ): + # TODO: We probably need to implement this and (at least) strip out the + # snippet parts? + INSERT_TEXT_FORMAT = [ + None, # 1-based + 'PlainText', + 'Snippet' + ] + + fixits = None + + # We will alwyas have one of insertText or label + if 'insertText' in item and item[ 'insertText' ]: + insertion_text = item[ 'insertText' ] + else: + insertion_text = item[ 'label' ] + + # Per the protocol, textEdit takes precedence over insertText, and must be + # on the same line (and containing) the originally requested position + if 'textEdit' in item and item[ 'textEdit' ]: + new_range = item[ 'textEdit' ][ 'range' ] + additional_text_edits = [] + + if ( new_range[ 'start' ][ 'line' ] != new_range[ 'end' ][ 'line' ] or + new_range[ 'start' ][ 'line' ] + 1 != request_data[ 'line_num' ] ): + # We can't support completions that span lines. The protocol forbids it + raise RuntimeError( 'Invalid textEdit supplied. Must be on a single ' + 'line' ) + elif '\n' in item[ 'textEdit' ][ 'newText' ]: + # The insertion text contains newlines. This is tricky: most clients + # (i.e. Vim) won't support this. So we cheat. Set the insertable text to + # the simple text, and put and additionalTextEdit instead. We manipulate + # the real textEdit so that it replaces the inserted text with the real + # textEdit. + fixup_textedit = dict( item[ 'textEdit' ] ) + fixup_textedit[ 'range' ][ 'end' ][ 'character' ] = ( + fixup_textedit[ 'range' ][ 'end' ][ 'character' ] + len( + insertion_text ) ) + additional_text_edits.append( fixup_textedit ) + else: + request_data[ 'start_codepoint' ] = ( + new_range[ 'start' ][ 'character' ] + 1 ) + insertion_text = item[ 'textEdit' ][ 'newText' ] + + additional_text_edits.extend( item.get( 'additionalTextEdits', [] ) ) + + if additional_text_edits: + chunks = [ responses.FixItChunk( e[ 'newText' ], + BuildRange( request_data[ 'filepath' ], + e[ 'range' ] ) ) + for e in additional_text_edits ] + + fixits = responses.BuildFixItResponse( + [ responses.FixIt( chunks[ 0].range.start_, chunks ) ] ) + + + if 'insertTextFormat' in item and item[ 'insertTextFormat' ]: + text_format = INSERT_TEXT_FORMAT[ item[ 'insertTextFormat' ] ] + else: + text_format = 'PlainText' + + if text_format != 'PlainText': + raise ValueError( 'Snippet completions are not supported and should not' + ' be returned by the language server.' ) + + return ( insertion_text, fixits ) + + +def BuildLocation( filename, loc ): + # TODO: Look at tern completer, requires file contents to convert + # codepoint offset to byte offset + return responses.Location( line = loc[ 'line' ] + 1, + column = loc[ 'character' ] + 1, + filename = os.path.realpath( filename ) ) + + +def BuildRange( filename, r ): + return responses.Range( BuildLocation( filename, r[ 'start' ] ), + BuildLocation( filename, r[ 'end' ] ) ) diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index c60725b4ac..dd02ebaa4f 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -96,6 +96,8 @@ def Completion( request_id, request_data ): # TODO: The API asks for 0-based offsets. These -1's are not good enough # when using multi-byte characters. See the tern completer for an # approach. + # + # FIXME: start_codepoint! 'line': request_data[ 'line_num' ] - 1, 'character': request_data[ 'start_column' ] - 1, } From 0f64bbd42f2892c912a77bc807ba87d353d0edbe Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sat, 11 Feb 2017 19:41:33 +0000 Subject: [PATCH 03/51] Add a long-polling endpoint for asyncronous message delivery A new API endpoint '/receive_messages' has been added which allows the server to return messages for display to the user at arbitrary times. Previously it was only possible for the ycmd server to communicate and action or update on receipt of a synchronous request from the client. However, the language server protocol inherently supports asynchronous message delivery, and it is necessary to provide some of these message to clients. In particular, the jdt.ls server takes a very long time to start up and provides some "status" information. We use a "display message" response to inform the client to show the user a progress update. Fully asyncronous delivery of diagnostics. Implement a proper ready handler (precursor to adding tests) --- ycmd/completers/completer.py | 9 + ycmd/completers/java/java_completer.py | 18 ++ .../language_server_completer.py | 180 ++++++++++++------ ycmd/completers/language_server/lsapi.py | 6 +- ycmd/handlers.py | 16 ++ 5 files changed, 168 insertions(+), 61 deletions(-) diff --git a/ycmd/completers/completer.py b/ycmd/completers/completer.py index 06c3235930..8b51b23486 100644 --- a/ycmd/completers/completer.py +++ b/ycmd/completers/completer.py @@ -378,6 +378,15 @@ def ServerIsHealthy( self ): return True + def PollForMessages( self, request_data ): + return self.PollForMessagesInner( request_data ) + + + def PollForMessagesInner( self, request_data ): + # Most completers don't implement this + return False + + class CompletionsCache( object ): """Completions for a particular request. Importantly, columns are byte offsets, not unicode codepoints.""" diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 4a477c377d..37cf60abfd 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -171,6 +171,10 @@ def ServerIsHealthy( self, request_data = {} ): return True + def ServerIsReady( self ): + return self.ServerIsHealthy() and self._received_ready_message + + def ShouldUseNowInner( self, request_data ): if not self.ServerIsReady(): return False @@ -185,6 +189,7 @@ def _Reset( self ): self._server_stderr = None self._server_handle = None + self._received_ready_message = False try: rmtree( self._workspace_path ) @@ -290,6 +295,19 @@ def GetSubcommandsMap( self ): } + def HandleServerMessage( self, request_data, notification ): + if notification[ 'method' ] == 'language/status': + message = notification[ 'params' ][ 'message' ] + message_type = notification[ 'params' ][ 'type' ] + if message_type == 'Started': + self._received_ready_message = True + + return responses.BuildDisplayMessageResponse( + 'Language server status: {0}'.format( message ) ) + + return None + + def _RestartServer( self ): with self._server_state_mutex: self._StopServer() diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 896d9f00a0..79255d58f0 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -173,12 +173,10 @@ def _ReadHeaders( self, data ): last_line = read_bytes if not line.strip(): - _logger.debug( "Headers complete" ) headers_complete = True read_bytes += 1 break else: - _logger.debug( "Header line: {0}".format( line ) ) key, value = utils.ToUnicode( line ).split( ':', 1 ) headers[ key.strip() ] = value.strip() @@ -284,7 +282,6 @@ def _Stop( self ): def _Write( self, data ): to_write = data + utils.ToBytes( '\r\n' ) - _logger.debug( 'Writing: ' + utils.ToUnicode( to_write ) ) self.server_stdin.write( to_write ) self.server_stdin.flush() @@ -303,21 +300,18 @@ def _Read( self, size=-1 ): # self.IsStopped()) means the server died unexpectedly. raise RuntimeError( "Connection to server died" ) - _logger.debug( "Data!!: {0}".format( data ) ) return data class LanguageServerCompleter( Completer ): def __init__( self, user_options): super( LanguageServerCompleter, self ).__init__( user_options ) - self._latest_diagnostics = { - 'uri': None, - 'diagnostics': [] - } + self._syncType = 'Full' self._serverFileState = {} self._fileStateMutex = threading.Lock() + self._server = LanguageServerConnection() def GetServer( sefl ): @@ -418,63 +412,104 @@ def OnFileReadyToParse( self, request_data ): if self.ServerIsReady(): self._RefreshFiles( request_data ) - def BuildDiagnostic( filename, diag ): - filename = lsapi.UriToFilePath( filename ) - r = BuildRange( filename, diag[ 'range' ] ) - SEVERITY = [ - None, - 'Error', - 'Warning', - 'Information', - 'Hint', - ] - SEVERITY_TO_YCM_SEVERITY = { - 'Error': 'ERROR', - 'Warning': 'WARNING', - 'Information': 'WARNING', - 'Hint': 'WARNING' - } - - return responses.BuildDiagnosticData ( responses.Diagnostic( - ranges = [ r ], - location = r.start_, - location_extent = r, - text = diag[ 'message' ], - kind = SEVERITY_TO_YCM_SEVERITY[ SEVERITY[ diag[ 'severity' ] ] ] ) ) - - # TODO: Maybe we need to prevent duplicates? Anyway, handle all of the - # notification messages - latest_diagnostics = None + # NOTE: We also return diagnostics asynchronously via the long-polling + # mechanism to avoid timing issues with the servers asynchronous publication + # of diagnostics. + # However, we _also_ return them here to refresh diagnostics after, say + # changing the active file in the editor. + uri = lsapi.MakeUriForFile( request_data[ 'filepath' ] ) + if self._latest_diagnostics[ uri ]: + return [ BuildDiagnostic( request_data, uri, diag ) + for diag in self._latest_diagnostics[ uri ] ] + + + def _PollForMessagesNoBlock( self, request_data, messages ): + notification = self.GetServer()._notifications.get_nowait( ) + message = self._ConvertNotificationToMessage( request_data, + notification ) + if message: + messages.append( message ) + + + def _PollForMessagesBlock( self, request_data ): try: while True: if not self.GetServer(): # The server isn't running or something. Don't re-poll. return False - notification = self._server._notifications.get_nowait() - _logger.debug( 'notification {0}: {1}'.format( - notification[ 'method' ], - json.dumps( notification[ 'params' ], indent = 2 ) ) ) + notification = self.GetServer()._notifications.get( timeout=10 ) + message = self._ConvertNotificationToMessage( request_data, + notification ) + if message: + return [ message ] + except queue.Empty: + return True + + + def PollForMessagesInner( self, request_data ): + messages = list() + + # scoop up any pending messages into one big list + try: + while True: + if not self.GetServer(): + # The server isn't running or something. Don't re-poll. + return False - if notification[ 'method' ] == 'textDocument/publishDiagnostics': - _logger.debug( 'latest_diagnostics updated' ) - latest_diagnostics = notification + self._PollForMessagesNoBlock( request_data, messages ) except queue.Empty: + # We drained the queue pass - if latest_diagnostics is not None: - _logger.debug( 'new diagnostics, updating latest received' ) - self._latest_diagnostics = latest_diagnostics[ 'params' ] - else: - _logger.debug( 'No new diagnostics, using latest received' ) + # If we found some messages, return them immediately + if messages: + return messages + + # otherwise, block until we get one + return self._PollForMessagesBlock( request_data ) + + + def HandleServerMessage( self, request_data, notification ): + return None + + + def _ConvertNotificationToMessage( self, request_data, notification ): + response = self.HandleServerMessage( request_data, notification ) + + if response: + return response + elif notification[ 'method' ] == 'window/showMessage': + return responses.BuildDisplayMessageResponse( + notification[ 'params' ][ 'message' ] ) + elif notification[ 'method' ] == 'textDocument/publishDiagnostics': + # Diagnostics are a little special. We only return diagnostics for the + # currently open file. The language server actually might sent us + # diagnostics for any file in the project, but (for now) we only show the + # current file. + # + # TODO(Ben): We should actually group up all the diagnostics messages, + # populating _latest_diagnostics, and then always send a single message if + # _any_ publishDiagnostics message was pending. + params = notification[ 'params' ] + uri = params[ 'uri' ] - diags = [ BuildDiagnostic( self._latest_diagnostics[ 'uri' ], x ) - for x in self._latest_diagnostics[ 'diagnostics' ] ] - _logger.debug( 'Diagnostics: {0}'.format( diags ) ) - return diags + # TODO(Ben): Does realpath break symlinks? + # e.g. we putting symlinks in the testdata for the source does not work + if os.path.realpath( lsapi.UriToFilePath( uri ) ) == os.path.realpath( + request_data[ 'filepath' ] ): + response = { + 'diagnostics': [ BuildDiagnostic( request_data, uri, x ) + for x in params[ 'diagnostics' ] ] + } + return response + + return None def _RefreshFiles( self, request_data ): + # FIXME: Provide a Reset method which clears this state. Restarting + # downstream servers would leave this cache in the incorrect state. with self._fileStateMutex: for file_name, file_data in iteritems( request_data[ 'file_data' ] ): file_state = 'New' @@ -488,12 +523,12 @@ def _RefreshFiles( self, request_data ): file_data[ 'filetypes' ], file_data[ 'contents' ] ) else: - # FIXME: DidChangeTextDocument doesn't actally do anything different + # FIXME: DidChangeTextDocument doesn't actually do anything different # from DidOpenTextDocument because we don't actually have a mechanism # for generating the diffs (which would just be a waste of time) # - # One option would be to just replcae the entire file, but some - # servers (i'm looking at you javac completer) don't update + # One option would be to just replace the entire file, but some + # servers (I'm looking at you javac completer) don't update # diagnostics until you open or save a document. Sigh. msg = lsapi.DidChangeTextDocument( file_name, file_data[ 'filetypes' ], @@ -502,12 +537,18 @@ def _RefreshFiles( self, request_data ): self._serverFileState[ file_name ] = 'Open' self.GetServer().SendNotification( msg ) - for file_name in iterkeys(self._serverFileState ): + stale_files = list() + for file_name in iterkeys( self._serverFileState ): if file_name not in request_data[ 'file_data' ]: + stale_files.append( file_name ) + + # We can't change the dictionary entries while using iterkeys, so we do + # that in a separate loop. + # TODO(Ben): Is this better than just not using iterkeys? + for file_name in stale_files: msg = lsapi.DidCloseTextDocument( file_name ) - del self._serverFileState[ file_name ] self.GetServer().SendNotification( msg ) - + del self._serverFileState[ file_name ] def _WaitForInitiliase( self ): request_id = self.GetServer().NextRequestId() @@ -610,3 +651,28 @@ def BuildLocation( filename, loc ): def BuildRange( filename, r ): return responses.Range( BuildLocation( filename, r[ 'start' ] ), BuildLocation( filename, r[ 'end' ] ) ) + + +def BuildDiagnostic( filename, diag ): + filename = lsapi.UriToFilePath( filename ) + r = BuildRange( filename, diag[ 'range' ] ) + SEVERITY = [ + None, + 'Error', + 'Warning', + 'Information', + 'Hint', + ] + SEVERITY_TO_YCM_SEVERITY = { + 'Error': 'ERROR', + 'Warning': 'WARNING', + 'Information': 'WARNING', + 'Hint': 'WARNING' + } + + return responses.BuildDiagnosticData ( responses.Diagnostic( + ranges = [ r ], + location = r.start_, + location_extent = r, + text = diag[ 'message' ], + kind = SEVERITY_TO_YCM_SEVERITY[ SEVERITY[ diag[ 'severity' ] ] ] ) ) diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index dd02ebaa4f..c7f1057b97 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -56,7 +56,7 @@ def Initialise( request_id ): 'rootPath': os.getcwd(), # deprecated 'rootUri': _MakeUriForFile( os.getcwd() ), 'initializationOptions': { }, - 'capabilities': { } + 'capabilities': { 'trace': 'verbose' } } ) @@ -96,10 +96,8 @@ def Completion( request_id, request_data ): # TODO: The API asks for 0-based offsets. These -1's are not good enough # when using multi-byte characters. See the tern completer for an # approach. - # - # FIXME: start_codepoint! 'line': request_data[ 'line_num' ] - 1, - 'character': request_data[ 'start_column' ] - 1, + 'character': request_data[ 'start_codepoint' ] - 1, } } ) diff --git a/ycmd/handlers.py b/ycmd/handlers.py index 02d1d378d1..5089971103 100644 --- a/ycmd/handlers.py +++ b/ycmd/handlers.py @@ -250,6 +250,22 @@ def Shutdown(): return _JsonResponse( True ) +@app.post( '/receive_messages' ) +def ReceiveMessages(): + # Receive messages is a "long-poll" handler. + # The client makes the request with a long timeout (1 hour). + # When we have data to send, we send it and close the socket. + # The client then sends a new request. + request_data = RequestWrap( request.json ) + try: + completer = _GetCompleterForRequestData( request_data ) + except: + # No semantic completer for this filetype, don't requery + return _JsonResponse( False ) + + return _JsonResponse( completer.PollForMessages( request_data ) ) + + # The type of the param is Bottle.HTTPError def ErrorHandler( httperror ): body = _JsonResponse( BuildExceptionResponse( httperror.exception, From 72cb4318d2501160248502a037424747db8ba3d5 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sat, 11 Feb 2017 19:40:55 +0000 Subject: [PATCH 04/51] Update example client to do some java and long polling --- examples/example_client.py | 45 ++++++++++++++++++++++++++++++++- examples/samples/some_java.java | 7 +++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 examples/samples/some_java.java diff --git a/examples/example_client.py b/examples/example_client.py index 6cf79fe015..c61eaf49c7 100755 --- a/examples/example_client.py +++ b/examples/example_client.py @@ -43,12 +43,13 @@ MAX_SERVER_WAIT_TIME_SECONDS = 5 # Set this to True to see ycmd's output interleaved with the client's -INCLUDE_YCMD_OUTPUT = False +INCLUDE_YCMD_OUTPUT = True DEFINED_SUBCOMMANDS_HANDLER = '/defined_subcommands' CODE_COMPLETIONS_HANDLER = '/completions' COMPLETER_COMMANDS_HANDLER = '/run_completer_command' EVENT_HANDLER = '/event_notification' EXTRA_CONF_HANDLER = '/load_extra_conf_file' +RECEIVE_MESSAGES_HANDLER = '/receive_messages' DIR_OF_THIS_SCRIPT = os.path.dirname( os.path.abspath( __file__ ) ) PATH_TO_YCMD = os.path.join( DIR_OF_THIS_SCRIPT, '..', 'ycmd' ) PATH_TO_EXTRA_CONF = os.path.join( DIR_OF_THIS_SCRIPT, '.ycm_extra_conf.py' ) @@ -186,6 +187,13 @@ def SendEventNotification( self, self.PostToHandlerAndLog( EVENT_HANDLER, request_json ) + def ReceiveMessages( self, test_filename, filetype ): + request_json = BuildRequestData( test_filename = test_filename, + filetype = filetype ) + print( '==== Sending Messages request ====' ) + self.PostToHandlerAndLog( RECEIVE_MESSAGES_HANDLER, request_json ) + + def LoadExtraConfFile( self, extra_conf_filename ): request_json = { 'filepath': extra_conf_filename } self.PostToHandlerAndLog( EXTRA_CONF_HANDLER, request_json ) @@ -468,6 +476,40 @@ def CsharpSemanticCompletionResults( server ): column_num = 15 ) +def JavaMessages( server ): + # NOTE: The server will return diagnostic information about an error in the + # some_java.java file that we placed there intentionally (as an example). + # It is _not_returned in the FileReadyToParse, but the ReceiveMessages poll + server.SendEventNotification( Event.FileReadyToParse, + test_filename = 'some_java.java', + filetype = 'java' ) + + # Send the long poll 10 times (only the first N will return any useful + # messages) + for i in range(1, 6): + server.ReceiveMessages( test_filename = 'some_java.java', + filetype = 'java' ) + + # Send a code complete request + server.SendCodeCompletionRequest( test_filename = 'some_java.java', + filetype = 'java', + line_num = 5, + column_num = 8 ) + + # NOTE: The server will return diagnostic information about an error in the + # some_java.java file that we placed there intentionally (as an example). + # It is _not_returned in the FileReadyToParse, but the ReceiveMessages poll + server.SendEventNotification( Event.FileReadyToParse, + test_filename = 'some_java.java', + filetype = 'java' ) + + # Send the long poll 10 times (only the first N will return any useful + # messages) + for i in range(1, 6): + server.ReceiveMessages( test_filename = 'some_java.java', + filetype = 'java' ) + + def Main(): print( 'Trying to start server...' ) server = YcmdHandle.StartYcmdAndReturnHandle() @@ -477,6 +519,7 @@ def Main(): PythonSemanticCompletionResults( server ) CppSemanticCompletionResults( server ) CsharpSemanticCompletionResults( server ) + JavaMessages( server ) # This will ask the server for a list of subcommands supported by a given # language completer. diff --git a/examples/samples/some_java.java b/examples/samples/some_java.java new file mode 100644 index 0000000000..5c6cda4136 --- /dev/null +++ b/examples/samples/some_java.java @@ -0,0 +1,7 @@ +public class some_java { + private int an_int = 1.0f; + public static void main( String[] args ) { + some_java j; + j.an + } +} From 53b759234cc1c76d223d8d308c1cb40e3a20bc1b Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Mon, 2 Oct 2017 22:41:36 +0100 Subject: [PATCH 05/51] GetType, GoToDefinition, GoToDeclaration Prototype --- ycmd/completers/java/java_completer.py | 7 +++ .../language_server_completer.py | 55 +++++++++++++++++++ ycmd/completers/language_server/lsapi.py | 24 ++++++++ 3 files changed, 86 insertions(+) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 37cf60abfd..02d368cf6f 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -292,6 +292,13 @@ def GetSubcommandsMap( self ): return { 'RestartServer': ( lambda self, request_data, args: self._RestartServer() ), + + # TODO: We should be able to determine the set of things available from + # the capabilities supplied on initialise + 'GetType': (lambda self, request_data, args: + self._GetType( request_data ) ), + 'GoToDeclaration': (lambda self, request_data, args: + self._GoToDeclaration( request_data ) ), } diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 79255d58f0..629022ca50 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -572,6 +572,61 @@ def _WaitForInitiliase( self ): self._syncType ) ) + def _GetType( self, request_data ): + request_id = self._server.NextRequestId() + response = self._server.GetResponse( request_id, + lsapi.Hover( request_id, + request_data ) ) + + if isinstance( response[ 'result' ][ 'contents' ], list ): + if len( response[ 'result' ][ 'contents' ] ): + info = response[ 'result' ][ 'contents' ][ 0 ] + else: + raise RuntimeError( 'No information' ) + else: + info = response[ 'result' ][ 'contents' ] + + + + return responses.BuildDisplayMessageResponse( str( info ) ) + + + def _GoToDeclaration( self, request_data ): + request_id = self.GetServer().NextRequestId() + response = self.GetServer().GetResponse( request_id, + lsapi.Definition( request_id, + request_data ) ) + + if isinstance( response[ 'result' ], list ): + if len( response[ 'result' ] ) > 1: + positions = response[ 'result' ] + return [ + responses.BuildGoToResponseFromLocation( + # TODO: Codepoint to byte offset + responses.Location( + position[ 'range' ][ 'start' ][ 'line' ] + 1, + position[ 'range' ][ 'start' ][ 'character' ] + 1, + lsapi.UriToFilePath( position[ 'uri' ] ) ) + ) for position in positions + ] + else: + position = response[ 'result' ][ 0 ] + return responses.BuildGoToResponseFromLocation( + # TODO: Codepoint to byte offset + responses.Location( position[ 'range' ][ 'start' ][ 'line' ] + 1, + position[ 'range' ][ 'start' ][ 'character' ] + 1, + lsapi.UriToFilePath( position[ 'uri' ] ) ) + ) + else: + position = response[ 'result' ] + return responses.BuildGoToResponseFromLocation( + # TODO: Codepoint to byte offset + responses.Location( position[ 'range' ][ 'start' ][ 'line' ] + 1, + position[ 'range' ][ 'start' ][ 'character' ] + 1, + lsapi.UriToFilePath( position[ 'uri' ] ) ) + ) + + def _GetInsertionText( self, request_data, item ): # TODO: We probably need to implement this and (at least) strip out the # snippet parts? diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index c7f1057b97..00147da64e 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -106,6 +106,30 @@ def ResolveCompletion( request_id, completion ): return BuildRequest( request_id, 'completionItem/resolve', completion ) +def Hover( request_id, request_data ): + return BuildRequest( request_id, + 'textDocument/hover', + BuildTextDocumentPositionParams( request_data ) ) + + +def Definition( request_id, request_data ): + return BuildRequest( request_id, + 'textDocument/definition', + BuildTextDocumentPositionParams( request_data ) ) + + +def BuildTextDocumentPositionParams( request_data ): + return { + 'textDocument': { + 'uri': _MakeUriForFile( request_data[ 'filepath' ] ), + }, + 'position': { + 'line': request_data[ 'line_num' ] - 1, + 'character': request_data[ 'start_column' ] - 1, + }, + } + + def _MakeUriForFile( file_name ): return 'file://{0}'.format( file_name ) From 5f54b5143160390ac6a605a73c65f03d059450ec Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Mon, 7 Aug 2017 17:21:56 +0100 Subject: [PATCH 06/51] Add subcommands: FixIt, RefactorRename, GoToReferences, GetDoc, GetType Support for CodeActions via FixIts Support for RefactorRename Support for GoToReferences Add GoTo and GoToDeclaration aliases for GoToDefinition Implement GetDoc and GetType GetType is removed from language_server_completer.py since a server is allowed to return anything upon textDocument/hover request. GetType and GetDoc are Implemented in java_completer.py with a server specific modifications to the content of the response to the textDocument/hover request. --- ycmd/completers/java/java_completer.py | 61 +++++- .../language_server_completer.py | 187 ++++++++++++++---- ycmd/completers/language_server/lsapi.py | 31 +++ 3 files changed, 241 insertions(+), 38 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 02d368cf6f..d4389d3498 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -295,10 +295,22 @@ def GetSubcommandsMap( self ): # TODO: We should be able to determine the set of things available from # the capabilities supplied on initialise - 'GetType': (lambda self, request_data, args: - self._GetType( request_data ) ), - 'GoToDeclaration': (lambda self, request_data, args: - self._GoToDeclaration( request_data ) ), + 'GetDoc': ( lambda self, request_data, args: + self.GetDoc( request_data ) ), + 'GetType': ( lambda self, request_data, args: + self.GetType( request_data ) ), + 'GoToDeclaration': ( lambda self, request_data, args: + self._GoToDeclaration( request_data ) ), + 'GoTo': ( lambda self, request_data, args: + self._GoToDeclaration( request_data ) ), + 'GoToDefinition': ( lambda self, request_data, args: + self._GoToDeclaration( request_data ) ), + 'GoToReferences': ( lambda self, request_data, args: + self._GoToReferences( request_data ) ), + 'FixIt': ( lambda self, request_data, args: + self._CodeAction( request_data, args ) ), + 'RefactorRename': ( lambda self, request_data, args: + self._Rename( request_data, args ) ), } @@ -315,6 +327,47 @@ def HandleServerMessage( self, request_data, notification ): return None + def GetType( self, request_data ): + hover_response = self._GetHoverResponse( request_data ) + + if isinstance( hover_response, list ): + if len( hover_response ): + get_type_java = hover_response[ 0 ][ 'value' ] + else: + raise RuntimeError( 'No information' ) + else: + get_type_java = hover_response + + return responses.BuildDisplayMessageResponse( get_type_java ) + + + def GetDoc( self, request_data ): + hover_response = self._GetHoverResponse( request_data ) + + if isinstance( hover_response, list ): + if len( hover_response ): + get_doc_java = '' + for docstring in hover_response: + if not isinstance( docstring, dict ): + get_doc_java += docstring + '\n' + else: + raise RuntimeError( 'No information' ) + else: + get_doc_java = hover_response + + return responses.BuildDisplayMessageResponse( get_doc_java.rstrip() ) + + + def HandleServerCommand( self, request_data, command ): + if command[ 'command' ] == "java.apply.workspaceEdit": + return language_server_completer.WorkspaceEditToFixIt( + request_data, + command[ 'arguments' ][ 0 ], + text = command[ 'title' ] ) + + return None + + def _RestartServer( self ): with self._server_state_mutex: self._StopServer() diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 629022ca50..3b447f388a 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -23,11 +23,12 @@ from builtins import * # noqa from future.utils import iteritems, iterkeys +import abc +import collections import logging -import threading import os import queue -import json +import threading from ycmd.completers.completer import Completer # from ycmd.completers.completer_utils import GetFileContents @@ -311,7 +312,7 @@ def __init__( self, user_options): self._serverFileState = {} self._fileStateMutex = threading.Lock() - self._server = LanguageServerConnection() + self._latest_diagnostics = collections.defaultdict( list ) def GetServer( sefl ): @@ -493,6 +494,7 @@ def _ConvertNotificationToMessage( self, request_data, notification ): # _any_ publishDiagnostics message was pending. params = notification[ 'params' ] uri = params[ 'uri' ] + self._latest_diagnostics[ uri ] = params[ 'diagnostics' ] # TODO(Ben): Does realpath break symlinks? # e.g. we putting symlinks in the testdata for the source does not work @@ -572,23 +574,35 @@ def _WaitForInitiliase( self ): self._syncType ) ) - def _GetType( self, request_data ): - request_id = self._server.NextRequestId() - response = self._server.GetResponse( request_id, + def _GetHoverResponse( self, request_data ): + request_id = self.GetServer().NextRequestId() + response = self.GetServer().GetResponse( request_id, lsapi.Hover( request_id, request_data ) ) - if isinstance( response[ 'result' ][ 'contents' ], list ): - if len( response[ 'result' ][ 'contents' ] ): - info = response[ 'result' ][ 'contents' ][ 0 ] - else: - raise RuntimeError( 'No information' ) - else: - info = response[ 'result' ][ 'contents' ] - + return response[ 'result' ][ 'contents' ] - return responses.BuildDisplayMessageResponse( str( info ) ) + def LocationListToGoTo( self, response ): + if len( response[ 'result' ] ) > 1: + positions = response[ 'result' ] + return [ + responses.BuildGoToResponseFromLocation( + # TODO: Codepoint to byte offset + responses.Location( + position[ 'range' ][ 'start' ][ 'line' ] + 1, + position[ 'range' ][ 'start' ][ 'character' ] + 1, + lsapi.UriToFilePath( position[ 'uri' ] ) ) + ) for position in positions + ] + else: + position = response[ 'result' ][ 0 ] + return responses.BuildGoToResponseFromLocation( + # TODO: Codepoint to byte offset + responses.Location( position[ 'range' ][ 'start' ][ 'line' ] + 1, + position[ 'range' ][ 'start' ][ 'character' ] + 1, + lsapi.UriToFilePath( position[ 'uri' ] ) ) + ) def _GoToDeclaration( self, request_data ): @@ -598,25 +612,7 @@ def _GoToDeclaration( self, request_data ): request_data ) ) if isinstance( response[ 'result' ], list ): - if len( response[ 'result' ] ) > 1: - positions = response[ 'result' ] - return [ - responses.BuildGoToResponseFromLocation( - # TODO: Codepoint to byte offset - responses.Location( - position[ 'range' ][ 'start' ][ 'line' ] + 1, - position[ 'range' ][ 'start' ][ 'character' ] + 1, - lsapi.UriToFilePath( position[ 'uri' ] ) ) - ) for position in positions - ] - else: - position = response[ 'result' ][ 0 ] - return responses.BuildGoToResponseFromLocation( - # TODO: Codepoint to byte offset - responses.Location( position[ 'range' ][ 'start' ][ 'line' ] + 1, - position[ 'range' ][ 'start' ][ 'character' ] + 1, - lsapi.UriToFilePath( position[ 'uri' ] ) ) - ) + return self.LocationListToGoTo( response ) else: position = response[ 'result' ] return responses.BuildGoToResponseFromLocation( @@ -627,6 +623,104 @@ def _GoToDeclaration( self, request_data ): ) + def _GoToReferences( self, request_data ): + request_id = self.GetServer().NextRequestId() + response = self.GetServer().GetResponse( request_id, + lsapi.References( request_id, + request_data ) ) + + return self.LocationListToGoTo( response ) + + + def _CodeAction( self, request_data, args ): + # FIXME: We need to do this for all such requests + self._RefreshFiles( request_data ) + + line_num_ls = request_data[ 'line_num' ] - 1 + + def WithinRange( diag ): + r = diag[ 'range' ] + + start = r[ 'start' ] + end = r[ 'end' ] + + if line_num_ls < start[ 'line' ] or line_num_ls > end[ 'line' ]: + return False + + return True + + # TODO: Do we need to do this? I mean, could we just send the whole current + # line as the range, as this is effectively what we do for other completers + # + # TODO: HACK: using internal lsapi method + file_diagnostics = self._latest_diagnostics[ + lsapi._MakeUriForFile( request_data[ 'filepath' ] ) ] + + matched_diagnostics = [ + d for d in file_diagnostics if WithinRange( d ) + ] + + request_id = self.GetServer().NextRequestId() + if matched_diagnostics: + code_actions = self.GetServer().GetResponse( + request_id, + lsapi.CodeAction( request_id, + request_data, + matched_diagnostics[ 0 ][ 'range' ], + matched_diagnostics ) ) + + else: + code_actions = self.GetServer().GetResponse( + request_id, + lsapi.CodeAction( + request_id, + request_data, + # Use the whole line + { + 'start': { + 'line': line_num_ls, + 'character': 0, + }, + 'end': { + 'line': line_num_ls, + 'character': len( request_data[ 'line_value' ] ) - 1, + } + }, + [] ) ) + + response = [ self.HandleServerCommand( request_data, c ) + for c in code_actions[ 'result' ] ] + + # Else, show a list of actions to the user to select which one to apply. + # This is (probably) a more common workflow for "code action". + return responses.BuildFixItResponse( [ r for r in response if r ] ) + + + @abc.abstractmethod + def HandleServerCommand( self, request_data, command ): + _logger.debug( 'What is going on?' ) + return None + + + def _Rename( self, request_data, args ): + if len( args ) != 1: + raise ValueError( 'Please specify a new name to rename it to.\n' + 'Usage: RefactorRename ' ) + + new_name = args[ 0 ] + + request_id = self.GetServer().NextRequestId() + response = self.GetServer().GetResponse( + request_id, + lsapi.Rename( request_id, + request_data, + new_name ), + timeout = 30 ) + + return responses.BuildFixItResponse( + [ WorkspaceEditToFixIt( request_data, response[ 'result' ] ) ] ) + + def _GetInsertionText( self, request_data, item ): # TODO: We probably need to implement this and (at least) strip out the # snippet parts? @@ -731,3 +825,28 @@ def BuildDiagnostic( filename, diag ): location_extent = r, text = diag[ 'message' ], kind = SEVERITY_TO_YCM_SEVERITY[ SEVERITY[ diag[ 'severity' ] ] ] ) ) + + +def TextEditToChunks( uri, text_edit ): + filepath = lsapi.UriToFilePath( uri ) + return [ + responses.FixItChunk( change[ 'newText' ], + BuildRange( filepath, change[ 'range' ] ) ) + for change in text_edit + ] + + +def WorkspaceEditToFixIt( request_data, workspace_edit, text='' ): + if 'changes' not in workspace_edit: + return None + + chunks = list() + for uri in iterkeys( workspace_edit[ 'changes' ] ): + chunks.extend( TextEditToChunks( uri, workspace_edit[ 'changes' ][ uri ] ) ) + + return responses.FixIt( + responses.Location( request_data[ 'line_num' ], + request_data[ 'column_num' ], + request_data[ 'filepath' ] ), + chunks, + text ) diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index 00147da64e..5805973e90 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -118,6 +118,31 @@ def Definition( request_id, request_data ): BuildTextDocumentPositionParams( request_data ) ) +def CodeAction( request_id, request_data, best_match_range, diagnostics ): + return BuildRequest( request_id, 'textDocument/codeAction', { + 'textDocument': { + 'uri': _MakeUriForFile( request_data[ 'filepath' ] ), + }, + 'range': best_match_range, + 'context': { + 'diagnostics': diagnostics, + }, + } ) + + +def Rename( request_id, request_data, new_name ): + return BuildRequest( request_id, 'textDocument/rename', { + 'textDocument': { + 'uri': _MakeUriForFile( request_data[ 'filepath' ] ), + }, + 'position': { + 'line': request_data[ 'line_num' ] - 1, + 'character': request_data[ 'start_column' ] - 1, + }, + 'newName': new_name, + } ) + + def BuildTextDocumentPositionParams( request_data ): return { 'textDocument': { @@ -130,6 +155,12 @@ def BuildTextDocumentPositionParams( request_data ): } +def References( request_id, request_data ): + request = BuildTextDocumentPositionParams( request_data ) + request[ 'context' ] = { 'includeDeclaration': True } + return BuildRequest( request_id, 'textDocument/references', request ) + + def _MakeUriForFile( file_name ): return 'file://{0}'.format( file_name ) From cc67757908163c888a2873908abe70d23e802b6b Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sun, 3 Sep 2017 00:59:48 +0100 Subject: [PATCH 07/51] Finalize language server protocol implementation Use url2pathname and pathname2url from urllib to convert URI to filepath and vice versa in a portable way. --- .../language_server_completer.py | 85 +++++++++---------- ycmd/completers/language_server/lsapi.py | 80 +++++++++-------- ycmd/utils.py | 9 +- 3 files changed, 90 insertions(+), 84 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 3b447f388a..0d565b0178 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -31,7 +31,7 @@ import threading from ycmd.completers.completer import Completer -# from ycmd.completers.completer_utils import GetFileContents +from ycmd.completers.completer_utils import GetFileContents from ycmd import utils from ycmd import responses @@ -418,7 +418,7 @@ def OnFileReadyToParse( self, request_data ): # of diagnostics. # However, we _also_ return them here to refresh diagnostics after, say # changing the active file in the editor. - uri = lsapi.MakeUriForFile( request_data[ 'filepath' ] ) + uri = lsapi.FilePathToUri( request_data[ 'filepath' ] ) if self._latest_diagnostics[ uri ]: return [ BuildDiagnostic( request_data, uri, diag ) for diag in self._latest_diagnostics[ uri ] ] @@ -583,26 +583,18 @@ def _GetHoverResponse( self, request_data ): return response[ 'result' ][ 'contents' ] - def LocationListToGoTo( self, response ): + def LocationListToGoTo( self, request_data, response ): if len( response[ 'result' ] ) > 1: positions = response[ 'result' ] return [ responses.BuildGoToResponseFromLocation( - # TODO: Codepoint to byte offset - responses.Location( - position[ 'range' ][ 'start' ][ 'line' ] + 1, - position[ 'range' ][ 'start' ][ 'character' ] + 1, - lsapi.UriToFilePath( position[ 'uri' ] ) ) - ) for position in positions + _PositionToLocation( request_data, + position ) ) for position in positions ] else: position = response[ 'result' ][ 0 ] return responses.BuildGoToResponseFromLocation( - # TODO: Codepoint to byte offset - responses.Location( position[ 'range' ][ 'start' ][ 'line' ] + 1, - position[ 'range' ][ 'start' ][ 'character' ] + 1, - lsapi.UriToFilePath( position[ 'uri' ] ) ) - ) + _PositionToLocation( request_data, position ) ) def _GoToDeclaration( self, request_data ): @@ -612,15 +604,11 @@ def _GoToDeclaration( self, request_data ): request_data ) ) if isinstance( response[ 'result' ], list ): - return self.LocationListToGoTo( response ) + return self.LocationListToGoTo( request_data, response ) else: position = response[ 'result' ] return responses.BuildGoToResponseFromLocation( - # TODO: Codepoint to byte offset - responses.Location( position[ 'range' ][ 'start' ][ 'line' ] + 1, - position[ 'range' ][ 'start' ][ 'character' ] + 1, - lsapi.UriToFilePath( position[ 'uri' ] ) ) - ) + _PositionToLocation( request_data, position ) ) def _GoToReferences( self, request_data ): @@ -629,7 +617,7 @@ def _GoToReferences( self, request_data ): lsapi.References( request_id, request_data ) ) - return self.LocationListToGoTo( response ) + return self.LocationListToGoTo( request_data, response ) def _CodeAction( self, request_data, args ): @@ -639,22 +627,16 @@ def _CodeAction( self, request_data, args ): line_num_ls = request_data[ 'line_num' ] - 1 def WithinRange( diag ): - r = diag[ 'range' ] - - start = r[ 'start' ] - end = r[ 'end' ] + start = diag[ 'range' ][ 'start' ] + end = diag[ 'range' ][ 'end' ] if line_num_ls < start[ 'line' ] or line_num_ls > end[ 'line' ]: return False return True - # TODO: Do we need to do this? I mean, could we just send the whole current - # line as the range, as this is effectively what we do for other completers - # - # TODO: HACK: using internal lsapi method file_diagnostics = self._latest_diagnostics[ - lsapi._MakeUriForFile( request_data[ 'filepath' ] ) ] + lsapi.FilePathToUri( request_data[ 'filepath' ] ) ] matched_diagnostics = [ d for d in file_diagnostics if WithinRange( d ) @@ -769,7 +751,8 @@ def _GetInsertionText( self, request_data, item ): if additional_text_edits: chunks = [ responses.FixItChunk( e[ 'newText' ], - BuildRange( request_data[ 'filepath' ], + BuildRange( request_data, + request_data[ 'filepath' ], e[ 'range' ] ) ) for e in additional_text_edits ] @@ -789,22 +772,30 @@ def _GetInsertionText( self, request_data, item ): return ( insertion_text, fixits ) -def BuildLocation( filename, loc ): - # TODO: Look at tern completer, requires file contents to convert - # codepoint offset to byte offset - return responses.Location( line = loc[ 'line' ] + 1, - column = loc[ 'character' ] + 1, - filename = os.path.realpath( filename ) ) +def _PositionToLocation( request_data, position ): + return BuildLocation( request_data, + lsapi.UriToFilePath( position[ 'uri' ] ), + position[ 'range' ][ 'start' ] ) + + +def BuildLocation( request_data, filename, loc ): + line_contents = utils.SplitLines( GetFileContents( request_data, filename ) ) + return responses.Location( + line = loc[ 'line' ] + 1, + column = utils.CodepointOffsetToByteOffset( line_contents, + loc[ 'character' ] + 1 ), + # FIXME: Does realpath break symlinks? + filename = os.path.realpath( filename ) ) -def BuildRange( filename, r ): - return responses.Range( BuildLocation( filename, r[ 'start' ] ), - BuildLocation( filename, r[ 'end' ] ) ) +def BuildRange( request_data, filename, r ): + return responses.Range( BuildLocation( request_data, filename, r[ 'start' ] ), + BuildLocation( request_data, filename, r[ 'end' ] ) ) -def BuildDiagnostic( filename, diag ): +def BuildDiagnostic( request_data, filename, diag ): filename = lsapi.UriToFilePath( filename ) - r = BuildRange( filename, diag[ 'range' ] ) + r = BuildRange( request_data, filename, diag[ 'range' ] ) SEVERITY = [ None, 'Error', @@ -827,11 +818,13 @@ def BuildDiagnostic( filename, diag ): kind = SEVERITY_TO_YCM_SEVERITY[ SEVERITY[ diag[ 'severity' ] ] ] ) ) -def TextEditToChunks( uri, text_edit ): +def TextEditToChunks( request_data, uri, text_edit ): filepath = lsapi.UriToFilePath( uri ) return [ responses.FixItChunk( change[ 'newText' ], - BuildRange( filepath, change[ 'range' ] ) ) + BuildRange( request_data, + filepath, + change[ 'range' ] ) ) for change in text_edit ] @@ -842,7 +835,9 @@ def WorkspaceEditToFixIt( request_data, workspace_edit, text='' ): chunks = list() for uri in iterkeys( workspace_edit[ 'changes' ] ): - chunks.extend( TextEditToChunks( uri, workspace_edit[ 'changes' ][ uri ] ) ) + chunks.extend( TextEditToChunks( request_data, + uri, + workspace_edit[ 'changes' ][ uri ] ) ) return responses.FixIt( responses.Location( request_data[ 'line_num' ], diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index 5805973e90..34e0c7733b 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -24,19 +24,22 @@ import os import json -from urllib import parse as urlparse from collections import defaultdict +from ycmd.utils import ( pathname2url, ToBytes, ToUnicode, url2pathname, + urljoin ) -from ycmd.utils import ToBytes, ToUnicode - -# TODO: Need a whole document management system! +# FIXME: We might need a whole document management system eventually. For now, +# we just update the file version every time we refresh a file (even if it +# hasn't changed). LAST_VERSION = defaultdict( int ) def BuildRequest( request_id, method, parameters ): - return _Message( { + """Builds a JSON RPC request message with the supplied ID, method and method + parameters""" + return _BuildMessageData( { 'id': request_id, 'method': method, 'params': parameters, @@ -44,17 +47,20 @@ def BuildRequest( request_id, method, parameters ): def BuildNotification( method, parameters ): - return _Message( { + """Builds a JSON RPC notification message with the supplied method and + method parameters""" + return _BuildMessageData( { 'method': method, 'params': parameters, } ) def Initialise( request_id ): + """Build the Language Server initialise request""" return BuildRequest( request_id, 'initialize', { 'processId': os.getpid(), 'rootPath': os.getcwd(), # deprecated - 'rootUri': _MakeUriForFile( os.getcwd() ), + 'rootUri': FilePathToUri( os.getcwd() ), 'initializationOptions': { }, 'capabilities': { 'trace': 'verbose' } } ) @@ -64,7 +70,7 @@ def DidOpenTextDocument( file_name, file_types, file_contents ): LAST_VERSION[ file_name ] = LAST_VERSION[ file_name ] + 1 return BuildNotification( 'textDocument/didOpen', { 'textDocument': { - 'uri': _MakeUriForFile( file_name ), + 'uri': FilePathToUri( file_name ), 'languageId': '/'.join( file_types ), 'version': LAST_VERSION[ file_name ], 'text': file_contents @@ -74,14 +80,18 @@ def DidOpenTextDocument( file_name, file_types, file_contents ): def DidChangeTextDocument( file_name, file_types, file_contents ): # FIXME: The servers seem to all state they want incremental updates. It - # remains to be seen if they really do. + # remains to be seen if they really do. So far, no actual server tested has + # actually required this, but it might be necessary for performance reasons in + # some cases. However, as the logic for diffing previous file versions, etc. + # is highly complex, it should only be implemented if it becomes strictly + # necessary (perhaps with client support). return DidOpenTextDocument( file_name, file_types, file_contents ) def DidCloseTextDocument( file_name ): return BuildNotification( 'textDocument/didClose', { 'textDocument': { - 'uri': _MakeUriForFile( file_name ), + 'uri': FilePathToUri( file_name ), 'version': LAST_VERSION[ file_name ], }, } ) @@ -90,15 +100,9 @@ def DidCloseTextDocument( file_name ): def Completion( request_id, request_data ): return BuildRequest( request_id, 'textDocument/completion', { 'textDocument': { - 'uri': _MakeUriForFile( request_data[ 'filepath' ] ), + 'uri': FilePathToUri( request_data[ 'filepath' ] ), }, - 'position': { - # TODO: The API asks for 0-based offsets. These -1's are not good enough - # when using multi-byte characters. See the tern completer for an - # approach. - 'line': request_data[ 'line_num' ] - 1, - 'character': request_data[ 'start_codepoint' ] - 1, - } + 'position': Position( request_data ), } ) @@ -121,7 +125,7 @@ def Definition( request_id, request_data ): def CodeAction( request_id, request_data, best_match_range, diagnostics ): return BuildRequest( request_id, 'textDocument/codeAction', { 'textDocument': { - 'uri': _MakeUriForFile( request_data[ 'filepath' ] ), + 'uri': FilePathToUri( request_data[ 'filepath' ] ), }, 'range': best_match_range, 'context': { @@ -133,25 +137,18 @@ def CodeAction( request_id, request_data, best_match_range, diagnostics ): def Rename( request_id, request_data, new_name ): return BuildRequest( request_id, 'textDocument/rename', { 'textDocument': { - 'uri': _MakeUriForFile( request_data[ 'filepath' ] ), + 'uri': FilePathToUri( request_data[ 'filepath' ] ), }, - 'position': { - 'line': request_data[ 'line_num' ] - 1, - 'character': request_data[ 'start_column' ] - 1, - }, - 'newName': new_name, + 'position': Position( request_data ), } ) def BuildTextDocumentPositionParams( request_data ): return { 'textDocument': { - 'uri': _MakeUriForFile( request_data[ 'filepath' ] ), - }, - 'position': { - 'line': request_data[ 'line_num' ] - 1, - 'character': request_data[ 'start_column' ] - 1, + 'uri': FilePathToUri( request_data[ 'filepath' ] ), }, + 'position': Position( request_data ), } @@ -161,18 +158,28 @@ def References( request_id, request_data ): return BuildRequest( request_id, 'textDocument/references', request ) -def _MakeUriForFile( file_name ): - return 'file://{0}'.format( file_name ) +def Position( request_data ): + # The API requires 0-based unicode offsets. + return { + 'line': request_data[ 'line_num' ] - 1, + 'character': request_data[ 'start_column' ] - 1, + } + + +def FilePathToUri( file_name ): + return urljoin( 'file:', pathname2url( file_name ) ) def UriToFilePath( uri ): - # TODO: This assumes file:// - # TODO: work out how urlparse works with __future__ - return urlparse.urlparse( uri ).path + # NOTE: This assumes the URI starts with file: + return url2pathname( uri[ 5 : ] ) -def _Message( message ): +def _BuildMessageData( message ): message[ 'jsonrpc' ] = '2.0' + # NOTE: sort_keys=True is needed to workaround a 'limitation' of clangd where + # it requires keys to be in a specific order, due to a somewhat naive + # json/yaml parser. data = ToBytes( json.dumps( message, sort_keys=True ) ) packet = ToBytes( 'Content-Length: {0}\r\n' 'Content-Type: application/vscode-jsonrpc;charset=utf8\r\n' @@ -182,4 +189,5 @@ def _Message( message ): def Parse( data ): + """Reads the raw language server data |data| into a Python dictionary""" return json.loads( ToUnicode( data ) ) diff --git a/ycmd/utils.py b/ycmd/utils.py index 5ff9426afa..df8c4153db 100644 --- a/ycmd/utils.py +++ b/ycmd/utils.py @@ -33,15 +33,18 @@ import time -# Idiom to import urljoin and urlparse on Python 2 and 3. By exposing these -# functions here, we can import them directly from this module: +# Idiom to import pathname2url, url2pathname, urljoin, and urlparse on Python 2 +# and 3. By exposing these functions here, we can import them directly from this +# module: # -# from ycmd.utils import urljoin, urlparse +# from ycmd.utils import pathname2url, url2pathname, urljoin, urlparse # if PY2: from urlparse import urljoin, urlparse + from urllib import pathname2url, url2pathname else: from urllib.parse import urljoin, urlparse # noqa + from urllib.request import pathname2url, url2pathname # noqa # Creation flag to disable creating a console window on Windows. See From 2c69c51b1e46f49c1097b9a7e7026e645e7e3123 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sun, 3 Sep 2017 01:02:03 +0100 Subject: [PATCH 08/51] Add tests and fix issues Initial test framework and StopServer subcommand Implement StopServer subcommand so the server is start/stopped by the tests. Note: This exposes a number of bugs in the shutdown handling. Adds basic eclipse and maven test projects Adds basic tests for completion Add timeouts appropriate for the test environment (Travis) Test for snippets with completions Tests for GetType, GetDoc, GoToReferences, RefactorRename, diagnostics For now the diagnostics tests use os.path.normpath. This doesn't work, but it is required due to extra slashes on filepaths returned by url2filepath Make fixit responses stable across files, for the tests --- run_tests.py | 5 + ycmd/completers/java/java_completer.py | 6 +- .../language_server_completer.py | 90 ++-- ycmd/completers/language_server/lsapi.py | 14 +- ycmd/tests/java/__init__.py | 146 ++++++ ycmd/tests/java/debug_info_test.py | 51 ++ ycmd/tests/java/diagnostics_test.py | 204 ++++++++ ycmd/tests/java/get_completions_test.py | 284 +++++++++++ ycmd/tests/java/subcommands_test.py | 477 ++++++++++++++++++ ycmd/tests/java/testdata/.gitignore | 1 + .../simple_eclipse_project/.classpath | 6 + .../simple_eclipse_project/.gitignore | 1 + .../testdata/simple_eclipse_project/.project | 24 + .../src/com/test/AbstractTestWidget.java | 18 + .../src/com/test/TestFactory.java | 32 ++ .../src/com/test/TestLauncher.java | 17 + .../src/com/test/TestWidgetImpl.java | 28 + .../src/com/test/wobble/Wibble.java | 7 + .../testdata/simple_maven_project/.gitignore | 4 + .../testdata/simple_maven_project/pom.xml | 18 + .../java/com/test/AbstractTestWidget.java | 18 + .../src/main/java/com/test/TestFactory.java | 17 + .../src/main/java/com/test/TestLauncher.java | 17 + .../main/java/com/test/TestWidgetImpl.java | 27 + .../src/test/java/com/test/AppTest.java | 38 ++ ycmd/tests/server_utils_test.py | 4 +- 26 files changed, 1514 insertions(+), 40 deletions(-) create mode 100644 ycmd/tests/java/__init__.py create mode 100644 ycmd/tests/java/debug_info_test.py create mode 100644 ycmd/tests/java/diagnostics_test.py create mode 100644 ycmd/tests/java/get_completions_test.py create mode 100644 ycmd/tests/java/subcommands_test.py create mode 100644 ycmd/tests/java/testdata/.gitignore create mode 100644 ycmd/tests/java/testdata/simple_eclipse_project/.classpath create mode 100644 ycmd/tests/java/testdata/simple_eclipse_project/.gitignore create mode 100644 ycmd/tests/java/testdata/simple_eclipse_project/.project create mode 100644 ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/AbstractTestWidget.java create mode 100644 ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestFactory.java create mode 100644 ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java create mode 100644 ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestWidgetImpl.java create mode 100644 ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/Wibble.java create mode 100644 ycmd/tests/java/testdata/simple_maven_project/.gitignore create mode 100644 ycmd/tests/java/testdata/simple_maven_project/pom.xml create mode 100644 ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/AbstractTestWidget.java create mode 100644 ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/TestFactory.java create mode 100644 ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/TestLauncher.java create mode 100644 ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/TestWidgetImpl.java create mode 100644 ycmd/tests/java/testdata/simple_maven_project/src/test/java/com/test/AppTest.java diff --git a/run_tests.py b/run_tests.py index 613be17a07..9984130254 100755 --- a/run_tests.py +++ b/run_tests.py @@ -85,6 +85,11 @@ def RunFlake8(): 'test': [ '--exclude-dir=ycmd/tests/python' ], 'aliases': [ 'jedi', 'jedihttp', ] }, + 'java': { + 'build': [ '--java-completer' ], + 'test': [ '--exclude-dir=ycmd/tests/java' ], + 'aliases': [ 'jdt' ], + }, } diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index d4389d3498..92beccec13 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -292,6 +292,8 @@ def GetSubcommandsMap( self ): return { 'RestartServer': ( lambda self, request_data, args: self._RestartServer() ), + 'StopServer': ( lambda self, request_data, args: + self._StopServer() ), # TODO: We should be able to determine the set of things available from # the capabilities supplied on initialise @@ -318,11 +320,13 @@ def HandleServerMessage( self, request_data, notification ): if notification[ 'method' ] == 'language/status': message = notification[ 'params' ][ 'message' ] message_type = notification[ 'params' ][ 'type' ] + if message_type == 'Started': + _logger.info( 'Java Language Server initialised successfully.' ) self._received_ready_message = True return responses.BuildDisplayMessageResponse( - 'Language server status: {0}'.format( message ) ) + 'Initialising Java completer: {0}'.format( message ) ) return None diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 0d565b0178..73d1793b9d 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -40,6 +40,13 @@ _logger = logging.getLogger( __name__ ) +REQUEST_TIMEOUT_COMPLETION = 1 +REQUEST_TIMEOUT_INITIALISE = 30 +REQUEST_TIMEOUT_COMMAND = 30 +CONNECTION_TIMEOUT = 5 +MESSAGE_POLL_TIMEOUT = 10 + + class Response( object ): def __init__( self ): self._event = threading.Event() @@ -113,7 +120,7 @@ def NextRequestId( self ): return str( self._lastId ) - def GetResponse( self, request_id, message, timeout=1 ): + def GetResponse( self, request_id, message, timeout ): response = Response() with self._responseMutex: @@ -133,7 +140,7 @@ def SendNotification( self, message ): def TryServerConnection( self ): - self._connection_event.wait( timeout = 5 ) + self._connection_event.wait( timeout = CONNECTION_TIMEOUT ) if not self._connection_event.isSet(): raise LanguageServerConnectionTimeout( @@ -330,15 +337,13 @@ def ComputeCandidatesInner( self, request_data ): if not self.ServerIsHealthy(): return None - # Need to update the file contents. TODO: so inefficient (and doesn't work - # for the eclipse based completer for some reason - possibly because it - # is busy parsing the file when it actually should be providing - # completions)! self._RefreshFiles( request_data ) request_id = self.GetServer().NextRequestId() msg = lsapi.Completion( request_id, request_data ) - response = self.GetServer().GetResponse( request_id, msg ) + response = self.GetServer().GetResponse( request_id, + msg, + REQUEST_TIMEOUT_COMPLETION ) do_resolve = ( 'completionProvider' in self._server_capabilities and @@ -354,7 +359,9 @@ def MakeCompletion( item ): if do_resolve: resolve_id = self.GetServer().NextRequestId() resolve = lsapi.ResolveCompletion( resolve_id, item ) - response = self.GetServer().GetResponse( resolve_id, resolve ) + response = self.GetServer().GetResponse( resolve_id, + resolve, + REQUEST_TIMEOUT_COMPLETION ) item = response[ 'result' ] # Note Vim only displays the first character, so we map them to the @@ -436,10 +443,12 @@ def _PollForMessagesBlock( self, request_data ): try: while True: if not self.GetServer(): - # The server isn't running or something. Don't re-poll. + # The server isn't running or something. Don't re-poll, as this will + # just cause errors. return False - notification = self.GetServer()._notifications.get( timeout=10 ) + notification = self.GetServer()._notifications.get( + timeout = MESSAGE_POLL_TIMEOUT ) message = self._ConvertNotificationToMessage( request_data, notification ) if message: @@ -449,9 +458,9 @@ def _PollForMessagesBlock( self, request_data ): def PollForMessagesInner( self, request_data ): - messages = list() # scoop up any pending messages into one big list + messages = list() try: while True: if not self.GetServer(): @@ -485,13 +494,11 @@ def _ConvertNotificationToMessage( self, request_data, notification ): notification[ 'params' ][ 'message' ] ) elif notification[ 'method' ] == 'textDocument/publishDiagnostics': # Diagnostics are a little special. We only return diagnostics for the - # currently open file. The language server actually might sent us - # diagnostics for any file in the project, but (for now) we only show the - # current file. - # - # TODO(Ben): We should actually group up all the diagnostics messages, - # populating _latest_diagnostics, and then always send a single message if - # _any_ publishDiagnostics message was pending. + # requested file, but store them for every file. Language servers can + # return diagnostics for the whole project, but this request is + # specifically for a particular file. + # Any messages we handle which are for other files are returned in the + # OnFileReadyToParse request. params = notification[ 'params' ] uri = params[ 'uri' ] self._latest_diagnostics[ uri ] = params[ 'diagnostics' ] @@ -552,13 +559,14 @@ def _RefreshFiles( self, request_data ): self.GetServer().SendNotification( msg ) del self._serverFileState[ file_name ] + def _WaitForInitiliase( self ): request_id = self.GetServer().NextRequestId() msg = lsapi.Initialise( request_id ) response = self.GetServer().GetResponse( request_id, msg, - timeout = 3 ) + REQUEST_TIMEOUT_INITIALISE ) self._server_capabilities = response[ 'result' ][ 'capabilities' ] @@ -576,14 +584,19 @@ def _WaitForInitiliase( self ): def _GetHoverResponse( self, request_data ): request_id = self.GetServer().NextRequestId() - response = self.GetServer().GetResponse( request_id, - lsapi.Hover( request_id, - request_data ) ) + response = self.GetServer().GetResponse( + request_id, + lsapi.Hover( request_id, + request_data ), + REQUEST_TIMEOUT_COMMAND ) return response[ 'result' ][ 'contents' ] def LocationListToGoTo( self, request_data, response ): + if not response: + raise RuntimeError( 'Cannot jump to location' ) + if len( response[ 'result' ] ) > 1: positions = response[ 'result' ] return [ @@ -599,9 +612,11 @@ def LocationListToGoTo( self, request_data, response ): def _GoToDeclaration( self, request_data ): request_id = self.GetServer().NextRequestId() - response = self.GetServer().GetResponse( request_id, - lsapi.Definition( request_id, - request_data ) ) + response = self.GetServer().GetResponse( + request_id, + lsapi.Definition( request_id, + request_data ), + REQUEST_TIMEOUT_COMMAND ) if isinstance( response[ 'result' ], list ): return self.LocationListToGoTo( request_data, response ) @@ -613,9 +628,11 @@ def _GoToDeclaration( self, request_data ): def _GoToReferences( self, request_data ): request_id = self.GetServer().NextRequestId() - response = self.GetServer().GetResponse( request_id, - lsapi.References( request_id, - request_data ) ) + response = self.GetServer().GetResponse( + request_id, + lsapi.References( request_id, + request_data ), + REQUEST_TIMEOUT_COMMAND ) return self.LocationListToGoTo( request_data, response ) @@ -649,7 +666,8 @@ def WithinRange( diag ): lsapi.CodeAction( request_id, request_data, matched_diagnostics[ 0 ][ 'range' ], - matched_diagnostics ) ) + matched_diagnostics ), + REQUEST_TIMEOUT_COMMAND ) else: code_actions = self.GetServer().GetResponse( @@ -668,7 +686,8 @@ def WithinRange( diag ): 'character': len( request_data[ 'line_value' ] ) - 1, } }, - [] ) ) + [] ), + REQUEST_TIMEOUT_COMMAND ) response = [ self.HandleServerCommand( request_data, c ) for c in code_actions[ 'result' ] ] @@ -697,7 +716,7 @@ def _Rename( self, request_data, args ): lsapi.Rename( request_id, request_data, new_name ), - timeout = 30 ) + REQUEST_TIMEOUT_COMMAND ) return responses.BuildFixItResponse( [ WorkspaceEditToFixIt( request_data, response[ 'result' ] ) ] ) @@ -793,8 +812,8 @@ def BuildRange( request_data, filename, r ): BuildLocation( request_data, filename, r[ 'end' ] ) ) -def BuildDiagnostic( request_data, filename, diag ): - filename = lsapi.UriToFilePath( filename ) +def BuildDiagnostic( request_data, uri, diag ): + filename = lsapi.UriToFilePath( uri ) r = BuildRange( request_data, filename, diag[ 'range' ] ) SEVERITY = [ None, @@ -834,7 +853,10 @@ def WorkspaceEditToFixIt( request_data, workspace_edit, text='' ): return None chunks = list() - for uri in iterkeys( workspace_edit[ 'changes' ] ): + # We sort the filenames to make the response stable. Edits are applied in + # strict sequence within a file, but apply to files in arbitrary order. + # However, it's important for the response to be stable for the tests. + for uri in sorted( iterkeys( workspace_edit[ 'changes' ] ) ): chunks.extend( TextEditToChunks( request_data, uri, workspace_edit[ 'changes' ][ uri ] ) ) diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index 34e0c7733b..345a3ed486 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -27,7 +27,7 @@ from collections import defaultdict from ycmd.utils import ( pathname2url, ToBytes, ToUnicode, url2pathname, - urljoin ) + urljoin, GetCurrentDirectory ) # FIXME: We might need a whole document management system eventually. For now, @@ -57,10 +57,15 @@ def BuildNotification( method, parameters ): def Initialise( request_id ): """Build the Language Server initialise request""" + + # FIXME: We actually need the project_directory passed in, e.g. from the + # request_data. For now, just get the current working directory of the server + project_directory = GetCurrentDirectory() + return BuildRequest( request_id, 'initialize', { 'processId': os.getpid(), - 'rootPath': os.getcwd(), # deprecated - 'rootUri': FilePathToUri( os.getcwd() ), + 'rootPath': project_directory, + 'rootUri': FilePathToUri( project_directory ), 'initializationOptions': { }, 'capabilities': { 'trace': 'verbose' } } ) @@ -139,6 +144,7 @@ def Rename( request_id, request_data, new_name ): 'textDocument': { 'uri': FilePathToUri( request_data[ 'filepath' ] ), }, + 'newName': new_name, 'position': Position( request_data ), } ) @@ -172,7 +178,7 @@ def FilePathToUri( file_name ): def UriToFilePath( uri ): # NOTE: This assumes the URI starts with file: - return url2pathname( uri[ 5 : ] ) + return os.path.normpath( url2pathname( uri[ 5 : ] ) ) def _BuildMessageData( message ): diff --git a/ycmd/tests/java/__init__.py b/ycmd/tests/java/__init__.py new file mode 100644 index 0000000000..178ceefe42 --- /dev/null +++ b/ycmd/tests/java/__init__.py @@ -0,0 +1,146 @@ +# Copyright (C) 2017 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +import functools +import os +import time + +from ycmd import handlers +from ycmd.tests.test_utils import ( ClearCompletionsCache, + CurrentWorkingDirectory, + SetUpApp, + StopCompleterServer, + BuildRequest ) +from ycmd.utils import GetCurrentDirectory + +shared_app = None +shared_current_dir = None +DEFAULT_PROJECT_DIR = 'simple_eclipse_project' + + +def PathToTestFile( *args ): + dir_of_current_script = os.path.dirname( os.path.abspath( __file__ ) ) + return os.path.join( dir_of_current_script, 'testdata', *args ) + + +def setUpPackage(): + """Initializes the ycmd server as a WebTest application that will be shared + by all tests using the SharedYcmd decorator in this package. Additional + configuration that is common to these tests, like starting a semantic + subserver, should be done here.""" + global shared_app, shared_current_dir + + shared_current_dir = GetCurrentDirectory() + + # By default, we use the eclipse project for convenience. This means we don't + # have to @IsolatedYcmdInDirectory( DEFAULT_PROJECT_DIR ) for every test + os.chdir( PathToTestFile( DEFAULT_PROJECT_DIR ) ) + + shared_app = SetUpApp() + WaitUntilCompleterServerReady( shared_app ) + + +def tearDownPackage(): + """Cleans up the tests using the SharedYcmd decorator in this package. It is + executed once after running all the tests in the package.""" + global shared_app, shared_current_dir + + StopCompleterServer( shared_app, 'java' ) + os.chdir( shared_current_dir ) + + +def SharedYcmd( test ): + """Defines a decorator to be attached to tests of this package. This decorator + passes the shared ycmd application as a parameter. + + Do NOT attach it to test generators but directly to the yielded tests.""" + global shared_app + + @functools.wraps( test ) + def Wrapper( *args, **kwargs ): + ClearCompletionsCache() + return test( shared_app, *args, **kwargs ) + return Wrapper + + +def IsolatedYcmd( test ): + """Defines a decorator to be attached to tests of this package. This decorator + passes a unique ycmd application as a parameter. It should be used on tests + that change the server state in a irreversible way (ex: a semantic subserver + is stopped or restarted) or expect a clean state (ex: no semantic subserver + started, no .ycm_extra_conf.py loaded, etc). + + Do NOT attach it to test generators but directly to the yielded tests.""" + return IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) + + +def IsolatedYcmdInDirectory( directory ): + """Defines a decorator to be attached to tests of this package. This decorator + passes a unique ycmd application as a parameter running in the directory + supplied. It should be used on tests that change the server state in a + irreversible way (ex: a semantic subserver is stopped or restarted) or expect + a clean state (ex: no semantic subserver started, no .ycm_extra_conf.py + loaded, etc). + + Do NOT attach it to test generators but directly to the yielded tests.""" + def Decorator( test ): + @functools.wraps( test ) + def Wrapper( *args, **kwargs ): + old_server_state = handlers._server_state + app = SetUpApp() + try: + with CurrentWorkingDirectory( directory ): + test( app, *args, **kwargs ) + finally: + StopCompleterServer( app, 'java' ) + handlers._server_state = old_server_state + return Wrapper + + return Decorator + + +def WaitUntilCompleterServerReady( app, timeout = 30 ): + expiration = time.time() + timeout + filetype = 'java' + while True: + if time.time() > expiration: + raise RuntimeError( 'Waited for the {0} subserver to be ready for ' + '{1} seconds, aborting.'.format( filetype, timeout ) ) + + if app.get( '/ready', { 'subserver': filetype } ).json: + return + else: + # Poll for messages. The server requires this to handle async messages, + # and will not become ready without them. + # FIXME: Is this really what we want? It's tricky to actually handle these + # things without some trigger. + app.post_json( '/receive_messages', BuildRequest( **{ + 'filetype' : 'java', + 'filepath' : PathToTestFile( 'DEFAULT_PROJECT_DIR' ), + 'line_num' : 1, + 'column_num': 1, + 'contents': '' + } ) ) + + time.sleep( 0.1 ) diff --git a/ycmd/tests/java/debug_info_test.py b/ycmd/tests/java/debug_info_test.py new file mode 100644 index 0000000000..cd72a5d75f --- /dev/null +++ b/ycmd/tests/java/debug_info_test.py @@ -0,0 +1,51 @@ +# Copyright (C) 2017 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import print_function +from __future__ import absolute_import +from __future__ import unicode_literals +from __future__ import division +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +from hamcrest import ( assert_that, + contains, + has_entry, + has_entries, + instance_of ) + +from ycmd.tests.java import SharedYcmd +from ycmd.tests.test_utils import BuildRequest + + +@SharedYcmd +def DebugInfo_test( app ): + request_data = BuildRequest( filetype = 'java' ) + assert_that( + app.post_json( '/debug_info', request_data ).json, + has_entry( 'completer', has_entries( { + 'name': 'Java', + 'servers': contains( has_entries( { + 'name': 'Java Language Server', + 'is_running': instance_of( bool ), + 'executable': instance_of( str ), + 'pid': instance_of( int ), + 'logfiles': contains( instance_of( str ), + instance_of( str ) ) + } ) ) + } ) ) + ) diff --git a/ycmd/tests/java/diagnostics_test.py b/ycmd/tests/java/diagnostics_test.py new file mode 100644 index 0000000000..fe073f0f46 --- /dev/null +++ b/ycmd/tests/java/diagnostics_test.py @@ -0,0 +1,204 @@ +# Copyright (C) 2017 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import print_function +from __future__ import absolute_import +from __future__ import unicode_literals +from __future__ import division +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +from hamcrest import ( assert_that, contains, has_entries ) +from nose.tools import eq_ + +from ycmd.tests.java import ( PathToTestFile, SharedYcmd, DEFAULT_PROJECT_DIR ) +from ycmd.tests.test_utils import ( BuildRequest ) +from ycmd.utils import ReadFile + +import time +from pprint import pformat + + +# TODO: Replace with ChunkMatcher, LocationMatcher, etc. +def PositionMatch( line, column ): + return has_entries( { + 'line_num': line, + 'column_num': column + } ) + + +def RangeMatch( start, end ): + return has_entries( { + 'start': PositionMatch( *start ), + 'end': PositionMatch( *end ), + } ) + + +def Merge( request, data ): + kw = dict( request ) + kw.update( data ) + return kw + + +def PollForMessages( app, request_data, drain=True ): + expiration = time.time() + 5 + while True: + if time.time() > expiration: + raise RuntimeError( 'Waited for diagnostics to be ready for ' + '10 seconds, aborting.' ) + + # Poll for messages. The server requires this to handle async messages, + # and will not become ready without them. + # FIXME: Is this really what we want? It's tricky to actually handle these + # things without some trigger. + response = app.post_json( '/receive_messages', BuildRequest( **Merge ( { + 'filetype' : 'java', + 'line_num' : 1, + 'column_num': 1, + }, request_data ) ) ).json + + print( 'poll response: {0}'.format( pformat( response ) ) ) + + if isinstance( response, bool ): + if not response: + raise RuntimeError( 'The message poll was aborted by the server' ) + elif drain: + return + elif isinstance( response, list ): + for message in response: + yield message + else: + raise AssertionError( 'Message poll response was wrong type' ) + + time.sleep( 0.25 ) + + +@SharedYcmd +def FileReadyToParse_Diagnostics_Simple_test( app ): + filepath = PathToTestFile( DEFAULT_PROJECT_DIR, + 'src', + 'com', + 'test', + 'TestFactory.java' ) + contents = ReadFile( filepath ) + + # During server initialisation, jdtls reads the project files off the disk. + # This means that when the test module initialised (waiting for the /ready + # response), we actually already handled the messages, so they should be in + # the diagnostics cache. + event_data = BuildRequest( event_name = 'FileReadyToParse', + contents = contents, + filepath = filepath, + filetype = 'java' ) + + results = app.post_json( '/event_notification', event_data ).json + + print( 'completer response: {0}'.format( pformat( results ) ) ) + + assert_that( + results, + contains( + has_entries( { + 'kind': 'WARNING', + 'text': 'The value of the field TestFactory.Bar.testString is not used', + 'location': PositionMatch( 15, 19 ), + 'location_extent': RangeMatch( ( 15, 19 ), ( 15, 29 ) ), + 'ranges': contains( RangeMatch( ( 15, 19 ), ( 15, 29 ) ) ), + 'fixit_available': False + } ), + has_entries( { + 'kind': 'ERROR', + 'text': 'Wibble cannot be resolved to a type', + 'location': PositionMatch( 18, 24 ), + 'location_extent': RangeMatch( ( 18, 24 ), ( 18, 30 ) ), + 'ranges': contains( RangeMatch( ( 18, 24 ), ( 18, 30 ) ) ), + 'fixit_available': False + } ), + has_entries( { + 'kind': 'ERROR', + 'text': 'Wibble cannot be resolved to a variable', + 'location': PositionMatch( 19, 15 ), + 'location_extent': RangeMatch( ( 19, 15 ), ( 19, 21 ) ), + 'ranges': contains( RangeMatch( ( 19, 15 ), ( 19, 21 ) ) ), + 'fixit_available': False + } ), + has_entries( { + 'kind': 'ERROR', + 'text': 'Type mismatch: cannot convert from int to boolean', + 'location': PositionMatch( 27, 10 ), + 'location_extent': RangeMatch( ( 27, 10 ), ( 27, 16 ) ), + 'ranges': contains( RangeMatch( ( 27, 10 ), ( 27, 16 ) ) ), + 'fixit_available': False + } ), + ) + ) + + +@SharedYcmd +def FileReadyToParse_Diagnostics_FileNotOnDisk_test( app ): + contents = ''' + package com.test; + class Test { + public String test + } + ''' + filepath = PathToTestFile( DEFAULT_PROJECT_DIR, + 'src', + 'com', + 'test', + 'Test.java' ) + + # During server initialisation, jdtls reads the project files off the disk. + # This means that when the test module initialised (waiting for the /ready + # response), we actually already handled the messages, so they should be in + # the diagnostics cache. + event_data = BuildRequest( event_name = 'FileReadyToParse', + contents = contents, + filepath = filepath, + filetype = 'java' ) + + results = app.post_json( '/event_notification', event_data ).json + + # This is a new file, so the diagnostics can't possibly be available. + eq_( results, {} ) + + diag_matcher = contains( has_entries( { + 'kind': 'ERROR', + 'text': 'Syntax error, insert ";" to complete ClassBodyDeclarations', + 'location': PositionMatch( 4, 21 ), + 'location_extent': RangeMatch( ( 4, 21 ), ( 4, 25 ) ), + 'ranges': contains( RangeMatch( ( 4, 21 ), ( 4, 25 ) ) ), + 'fixit_available': False + } ) ) + + # Poll unti we receive the diags asyncronously + for message in PollForMessages( app, + { 'filepath': filepath, + 'contents': contents }, + drain=True ): + print( 'Message {0}'.format( pformat( message ) ) ) + if 'diagnostics' in message: + assert_that( message, has_entries( { + 'diagnostics': diag_matcher + } ) ) + break + + # Now confirm that we _also_ get these from the FileReadyToParse request + results = app.post_json( '/event_notification', event_data ).json + print( 'completer response: {0}'.format( pformat( results ) ) ) + + assert_that( results, diag_matcher ) diff --git a/ycmd/tests/java/get_completions_test.py b/ycmd/tests/java/get_completions_test.py new file mode 100644 index 0000000000..96a1172a7b --- /dev/null +++ b/ycmd/tests/java/get_completions_test.py @@ -0,0 +1,284 @@ +# Copyright (C) 2017 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import print_function +from __future__ import absolute_import +from __future__ import unicode_literals +from __future__ import division +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +from hamcrest import ( assert_that, + contains, + contains_inanyorder, + empty, + matches_regexp, + has_entries ) +from nose.tools import eq_ + +from pprint import pformat +import requests + +from ycmd.tests.java import ( PathToTestFile, SharedYcmd ) +from ycmd.tests.test_utils import ( BuildRequest, + CompletionEntryMatcher ) +from ycmd.utils import ReadFile + + +def _CombineRequest( request, data ): + return BuildRequest( **_Merge( request, data ) ) + + +def _Merge( request, data ): + kw = dict( request ) + kw.update( data ) + return kw + + +def RunTest( app, test ): + """ + Method to run a simple completion test and verify the result + + test is a dictionary containing: + 'request': kwargs for BuildRequest + 'expect': { + 'response': server response code (e.g. httplib.OK) + 'data': matcher for the server response json + } + """ + + contents = ReadFile( test[ 'request' ][ 'filepath' ] ) + + app.post_json( '/event_notification', + _CombineRequest( test[ 'request' ], { + 'event_name': 'FileReadyToParse', + 'contents': contents, + } ), + expect_errors = True ) + + # We ignore errors here and we check the response code ourself. + # This is to allow testing of requests returning errors. + response = app.post_json( '/completions', + _CombineRequest( test[ 'request' ], { + 'contents': contents + } ), + expect_errors = True ) + + print( 'completer response: {0}'.format( pformat( response.json ) ) ) + + eq_( response.status_code, test[ 'expect' ][ 'response' ] ) + + assert_that( response.json, test[ 'expect' ][ 'data' ] ) + + +OBJECT_METHODS = [ + CompletionEntryMatcher( 'equals', 'Object' ), + CompletionEntryMatcher( 'getClass', 'Object' ), + CompletionEntryMatcher( 'hashCode', 'Object' ), + CompletionEntryMatcher( 'notify', 'Object' ), + CompletionEntryMatcher( 'notifyAll', 'Object' ), + CompletionEntryMatcher( 'toString', 'Object' ), + CompletionEntryMatcher( 'wait', 'Object', { + 'menu_text': matches_regexp( 'wait\\(long .*, int .*\\) : void' ), + } ), + CompletionEntryMatcher( 'wait', 'Object', { + 'menu_text': matches_regexp( 'wait\\(long .*\\) : void' ), + } ), + CompletionEntryMatcher( 'wait', 'Object', { + 'menu_text': 'wait() : void', + } ), +] + + +# The zealots that designed java made everything inherit from Object (except, +# possibly Object, or Class, or whichever one they used to break the Smalltalk +# infinite recursion problem). Anyway, that means that we get a lot of noise +# suggestions from the Object Class interface. This allows us to write: +# +# contains_inanyorder( *WithObjectMethods( CompletionEntryMatcher( ... ) ) ) +# +# and focus on what we care about. +def WithObjectMethods( *args ): + return list( OBJECT_METHODS ) + list( args ) + + +@SharedYcmd +def GetCompletions_NoQuery_test( app ): + RunTest( app, { + 'description': 'semantic completion works for builtin types (no query)', + 'request': { + 'filetype' : 'java', + 'filepath' : PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestFactory.java' ), + 'line_num' : 27, + 'column_num': 12, + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries( { + 'completions': contains_inanyorder( + *WithObjectMethods( + CompletionEntryMatcher( 'test', 'TestFactory.Bar' ), + CompletionEntryMatcher( 'testString', 'TestFactory.Bar' ) + ) + ), + 'errors': empty(), + } ) + }, + } ) + + +@SharedYcmd +def GetCompletions_WithQuery_test( app ): + RunTest( app, { + 'description': 'semantic completion works for builtin types (no query)', + 'request': { + 'filetype' : 'java', + 'filepath' : PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestFactory.java' ), + 'line_num' : 27, + 'column_num': 15, + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries( { + 'completions': contains_inanyorder( + CompletionEntryMatcher( 'test', 'TestFactory.Bar' ), + CompletionEntryMatcher( 'testString', 'TestFactory.Bar' ) + ), + 'errors': empty(), + } ) + }, + } ) + + +@SharedYcmd +def GetCompletions_WithSnippet_test( app ): + RunTest( app, { + 'description': 'semantic completion works for builtin types (no query)', + 'request': { + 'filetype' : 'java', + 'filepath' : PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestFactory.java' ), + 'line_num' : 19, + 'column_num': 25, + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries( { + 'completions': contains_inanyorder( CompletionEntryMatcher( + 'CUTHBERT', + 'com.test.wobble.Wibble', + # FIXME: Replace with ChunkMatcher, LocationMatcher, etc. + { + 'extra_data': has_entries( { + 'fixits': contains( has_entries( { + 'chunks': contains( + # For some reason, jdtls feels it's OK to replace the text + # before the cursor. Perhaps it does this to canonicalise the + # path ? + has_entries( { + 'replacement_text': 'Wibble', + 'range': has_entries( { + 'start': has_entries( { + 'line_num': 19, + 'column_num': 15, + } ), + 'end': has_entries( { + 'line_num': 19, + 'column_num': 21, + } ) + } ), + } ), + # When doing an import, eclipse likes to add two newlines + # after the package. I suppose this is config in real eclipse, + # but there's no mechanism to configure this in jdtl afaik. + has_entries( { + 'replacement_text': '\n\n', + 'range': has_entries( { + 'start': has_entries( { + 'line_num': 1, + 'column_num': 18, + } ), + 'end': has_entries( { + 'line_num': 1, + 'column_num': 18, + } ) + } ), + } ), + # OK, so it inserts the import + has_entries( { + 'replacement_text': 'import com.test.wobble.Wibble;', + 'range': has_entries( { + 'start': has_entries( { + 'line_num': 1, + 'column_num': 18, + } ), + 'end': has_entries( { + 'line_num': 1, + 'column_num': 18, + } ) + } ), + } ), + # More newlines. Who doesn't like newlines?! + has_entries( { + 'replacement_text': '\n\n', + 'range': has_entries( { + 'start': has_entries( { + 'line_num': 1, + 'column_num': 18, + } ), + 'end': has_entries( { + 'line_num': 1, + 'column_num': 18, + } ) + } ), + } ), + # For reasons known only to the eclipse JDT developers, it + # seems to want to delete the lines after the package first. + # + has_entries( { + 'replacement_text': '', + 'range': has_entries( { + 'start': has_entries( { + 'line_num': 1, + 'column_num': 18, + } ), + 'end': has_entries( { + 'line_num': 3, + 'column_num': 1, + } ) + } ), + } ), + ), + } ) ), + } ), + } ), + ), + 'errors': empty(), + } ) + }, + } ) diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py new file mode 100644 index 0000000000..e5e8159304 --- /dev/null +++ b/ycmd/tests/java/subcommands_test.py @@ -0,0 +1,477 @@ +# Copyright (C) 2015 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import absolute_import +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +from hamcrest import assert_that, contains, has_entries +from nose.tools import eq_ +from pprint import pformat +import requests + +from ycmd.utils import ReadFile +from ycmd.tests.java import PathToTestFile, SharedYcmd +from ycmd.tests.test_utils import ( BuildRequest, + ChunkMatcher, + ErrorMatcher, + LocationMatcher ) + + +@SharedYcmd +def Subcommands_DefinedSubcommands_test( app ): + subcommands_data = BuildRequest( completer_target = 'java' ) + + eq_( sorted( [ 'FixIt', + 'GoToDeclaration', + 'GoToDefinition', + 'GoTo', + 'GetDoc', + 'GetType', + 'GoToReferences', + 'RefactorRename', + 'RestartServer' ] ), + app.post_json( '/defined_subcommands', + subcommands_data ).json ) + + +def RunTest( app, test, contents = None ): + if not contents: + contents = ReadFile( test[ 'request' ][ 'filepath' ] ) + + def CombineRequest( request, data ): + kw = request + request.update( data ) + return BuildRequest( **kw ) + + # Because we aren't testing this command, we *always* ignore errors. This + # 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', + CombineRequest( test[ 'request' ], { + 'event_name': 'FileReadyToParse', + 'contents': contents, + 'filetype': 'java', + } ), + expect_errors = True ) + + # 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', + CombineRequest( test[ 'request' ], { + 'completer_target': 'filetype_default', + 'contents': contents, + 'filetype': 'java', + 'command_arguments': ( [ test[ 'request' ][ 'command' ] ] + + test[ 'request' ].get( 'arguments', [] ) ) + } ), + expect_errors = True + ) + + print( 'completer response: {0}'.format( pformat( response.json ) ) ) + + eq_( response.status_code, test[ 'expect' ][ 'response' ] ) + + assert_that( response.json, test[ 'expect' ][ 'data' ] ) + + +@SharedYcmd +def Subcommands_GetDoc_Method_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'AbstractTestWidget.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 17, + column_num = 17, + contents = contents, + command_arguments = [ 'GetDoc' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', event_data ).json + + eq_( response, { + 'message': 'Return runtime debugging info. ' + 'Useful for finding the actual code which is useful.' + } ) + + +@SharedYcmd +def Subcommands_GetDoc_Class_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestWidgetImpl.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 11, + column_num = 7, + contents = contents, + command_arguments = [ 'GetDoc' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', event_data ).json + + eq_( response, { + 'message': 'This is the actual code that matters.' + ' This concrete implementation is the equivalent' + ' of the main function in other languages' + } ) + + +@SharedYcmd +def Subcommands_GetType_Class_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestWidgetImpl.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 11, + column_num = 7, + contents = contents, + command_arguments = [ 'GetType' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', event_data ).json + + eq_( response, { + 'message': 'com.test.TestWidgetImpl' + } ) + + +@SharedYcmd +def Subcommands_GetType_Constructor_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestWidgetImpl.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 14, + column_num = 3, + contents = contents, + command_arguments = [ 'GetType' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', event_data ).json + + eq_( response, { + 'message': 'com.test.TestWidgetImpl.TestWidgetImpl(String info)' + } ) + + +@SharedYcmd +def Subcommands_GetType_ClassMemberVariable_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestWidgetImpl.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 12, + column_num = 18, + contents = contents, + command_arguments = [ 'GetType' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', event_data ).json + + eq_( response, { + 'message': 'String info' + } ) + + +@SharedYcmd +def Subcommands_GetType_MethodArgument_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestWidgetImpl.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 16, + column_num = 17, + contents = contents, + command_arguments = [ 'GetType' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', event_data ).json + + eq_( response, { + 'message': 'String info - ' + 'com.test.TestWidgetImpl.TestWidgetImpl(String)' + } ) + + +@SharedYcmd +def Subcommands_GetType_MethodVariable_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestWidgetImpl.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 15, + column_num = 9, + contents = contents, + command_arguments = [ 'GetType' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', event_data ).json + + eq_( response, { + 'message': 'int a - ' + 'com.test.TestWidgetImpl.TestWidgetImpl(String)' + } ) + + +@SharedYcmd +def Subcommands_GetType_Method_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestWidgetImpl.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 20, + column_num = 15, + contents = contents, + command_arguments = [ 'GetType' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', event_data ).json + + eq_( response, { + 'message': 'void com.test.TestWidgetImpl.doSomethingVaguelyUseful()' + } ) + +# Commented out because of an overlooked corner case +# @SharedYcmd +# def Subcommands_GetType_Class_test( app ): +# filepath = PathToTestFile( 'simple_eclipse_project', +# 'src', +# 'com', +# 'test', +# 'TestWidgetImpl.java' ) +# contents = ReadFile( filepath ) +# +# event_data = BuildRequest( filepath = filepath, +# filetype = 'java', +# line_num = 15, +# column_num = 13, +# contents = contents, +# command_arguments = [ 'GetType' ], +# completer_target = 'filetype_default' ) +# +# response = app.post_json( '/run_completer_command', event_data ).json +# +# eq_( response, { +# 'message': '' +# } ) + + +@SharedYcmd +def Subcommands_GoToReferences_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'AbstractTestWidget.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 10, + column_num = 15, + contents = contents, + command_arguments = [ 'GoToReferences' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', event_data ).json + + eq_( response, [ + { + 'filepath': PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestFactory.java' ), + 'column_num': 9, + # 'description': '', + 'line_num': 28 + }, + { + 'filepath': PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestLauncher.java' ), + 'column_num': 7, + # 'description': '', + 'line_num': 8 + } ] ) + + +@SharedYcmd +def Subcommands_RefactorRename_Simple_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestLauncher.java' ) + RunTest( app, { + 'description': 'RefactorRename works within a single scope/file', + 'request': { + 'command': 'RefactorRename', + 'arguments': [ 'renamed_l' ], + 'filepath': filepath, + 'line_num': 15, + 'column_num': 5, + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries ( { + 'fixits': contains( has_entries( { + 'chunks': contains( + ChunkMatcher( 'renamed_l', + LocationMatcher( filepath, 14, 18 ), + LocationMatcher( filepath, 14, 19 ) ), + ChunkMatcher( 'renamed_l', + LocationMatcher( filepath, 15, 5 ), + LocationMatcher( filepath, 15, 6 ) ), + ), + 'location': LocationMatcher( filepath, 15, 5 ) + } ) ) + } ) + } + } ) + + +@SharedYcmd +def Subcommands_RefactorRename_MultipleFiles_test( app ): + AbstractTestWidget = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'AbstractTestWidget.java' ) + TestFactory = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestFactory.java' ) + TestLauncher = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestLauncher.java' ) + TestWidgetImpl = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestWidgetImpl.java' ) + + RunTest( app, { + 'description': 'RefactorRename works across files', + 'request': { + 'command': 'RefactorRename', + 'arguments': [ 'a-quite-long-string' ], + 'filepath': TestLauncher, + 'line_num': 8, + 'column_num': 7, + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries ( { + 'fixits': contains( has_entries( { + 'chunks': contains( + ChunkMatcher( + 'a-quite-long-string', + LocationMatcher( AbstractTestWidget, 10, 15 ), + LocationMatcher( AbstractTestWidget, 10, 39 ) ), + ChunkMatcher( + 'a-quite-long-string', + LocationMatcher( TestFactory, 28, 9 ), + LocationMatcher( TestFactory, 28, 33 ) ), + ChunkMatcher( + 'a-quite-long-string', + LocationMatcher( TestLauncher, 8, 7 ), + LocationMatcher( TestLauncher, 8, 31 ) ), + ChunkMatcher( + 'a-quite-long-string', + LocationMatcher( TestWidgetImpl, 20, 15 ), + LocationMatcher( TestWidgetImpl, 20, 39 ) ), + ), + 'location': LocationMatcher( TestLauncher, 8, 7 ) + } ) ) + } ) + } + } ) + + +@SharedYcmd +def Subcommands_RefactorRename_Missing_New_Name_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestLauncher.java' ) + RunTest( app, { + 'description': 'RefactorRename raises an error without new name', + 'request': { + 'command': 'RefactorRename', + 'line_num': 15, + 'column_num': 5, + 'filepath': filepath, + }, + 'expect': { + 'response': requests.codes.internal_server_error, + 'data': ErrorMatcher( ValueError, + 'Please specify a new name to rename it to.\n' + 'Usage: RefactorRename ' ), + } + } ) diff --git a/ycmd/tests/java/testdata/.gitignore b/ycmd/tests/java/testdata/.gitignore new file mode 100644 index 0000000000..6b468b62a9 --- /dev/null +++ b/ycmd/tests/java/testdata/.gitignore @@ -0,0 +1 @@ +*.class diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/.classpath b/ycmd/tests/java/testdata/simple_eclipse_project/.classpath new file mode 100644 index 0000000000..1752df00b6 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_eclipse_project/.classpath @@ -0,0 +1,6 @@ + + + + + + diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/.gitignore b/ycmd/tests/java/testdata/simple_eclipse_project/.gitignore new file mode 100644 index 0000000000..2f7896d1d1 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_eclipse_project/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/.project b/ycmd/tests/java/testdata/simple_eclipse_project/.project new file mode 100644 index 0000000000..02727f85e6 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_eclipse_project/.project @@ -0,0 +1,24 @@ + + + + Test + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/AbstractTestWidget.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/AbstractTestWidget.java new file mode 100644 index 0000000000..9f37902bc1 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/AbstractTestWidget.java @@ -0,0 +1,18 @@ +package com.test; + +public interface AbstractTestWidget { + /** + * Do the actually useful stuff. + * + * Eventually, you have to find the code which is useful, as opposed to just + * boilerplate. + */ + public void doSomethingVaguelyUseful(); + + /** + * Return runtime debugging info. + * + * Useful for finding the actual code which is useful. + */ + public String getWidgetInfo(); +}; diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestFactory.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestFactory.java new file mode 100644 index 0000000000..8930c48336 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestFactory.java @@ -0,0 +1,32 @@ +package com.test; + +/** + * @title TestFactory + * + * TestFactory is a pointless thing that OO programmers think is necessary + * because they read about it in a book. + * + * All it does is instantiate the (one and only) concrete AbstractTestWidget + * implementation + */ +public class TestFactory { + private static class Bar { + public int test; + public String testString; + } + + private void Wimble( Wibble w ) { + if ( w == Wibble.CUTHBERT ) { + } + } + + public AbstractTestWidget getWidget( String info ) { + AbstractTestWidget w = new TestWidgetImpl( info ); + Bar b = new Bar(); + + if ( b.test ) { + w.doSomethingVaguelyUseful(); + } + return w; + } +} diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java new file mode 100644 index 0000000000..f6acdb84e8 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java @@ -0,0 +1,17 @@ +package com.test; + +class TestLauncher { + private TestFactory factory = new TestFactory(); + + private void Run() { + AbstractTestWidget w = factory.getWidget( "Test" ); + w.doSomethingVaguelyUseful(); + + System.out.println( "Did something useful: " + w.getWidgetInfo() ); + } + + public static void main( String[] args ) { + TestLauncher l = new TestLauncher(); + l.Run(); + } +} diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestWidgetImpl.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestWidgetImpl.java new file mode 100644 index 0000000000..019cbbdc46 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestWidgetImpl.java @@ -0,0 +1,28 @@ +package com.test; + +/** + * This is the actual code that matters. + * + * This concrete implementation is the equivalent of the main function in other + * languages + */ + + +class TestWidgetImpl implements AbstractTestWidget { + private String info; + + TestWidgetImpl( String info ) { + int a = 5; // just for testing + this.info = info; + } + + @Override + public void doSomethingVaguelyUseful() { + System.out.println( "42" ); + } + + @Override + public String getWidgetInfo() { + return this.info; + } +} diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/Wibble.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/Wibble.java new file mode 100644 index 0000000000..a453dcdfa9 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/Wibble.java @@ -0,0 +1,7 @@ +package com.test.wobble; + +public enum Wibble { + CUTHBERT, + DIBBLE, + TRUMP +} diff --git a/ycmd/tests/java/testdata/simple_maven_project/.gitignore b/ycmd/tests/java/testdata/simple_maven_project/.gitignore new file mode 100644 index 0000000000..b6cd1d6c12 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_maven_project/.gitignore @@ -0,0 +1,4 @@ +target/ +.classpath +.project +.settings diff --git a/ycmd/tests/java/testdata/simple_maven_project/pom.xml b/ycmd/tests/java/testdata/simple_maven_project/pom.xml new file mode 100644 index 0000000000..6bd98aaa7f --- /dev/null +++ b/ycmd/tests/java/testdata/simple_maven_project/pom.xml @@ -0,0 +1,18 @@ + + 4.0.0 + com.test + simple_maven_project + jar + 1.0-SNAPSHOT + simple_maven_project + http://maven.apache.org + + + junit + junit + 3.8.1 + test + + + diff --git a/ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/AbstractTestWidget.java b/ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/AbstractTestWidget.java new file mode 100644 index 0000000000..9f37902bc1 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/AbstractTestWidget.java @@ -0,0 +1,18 @@ +package com.test; + +public interface AbstractTestWidget { + /** + * Do the actually useful stuff. + * + * Eventually, you have to find the code which is useful, as opposed to just + * boilerplate. + */ + public void doSomethingVaguelyUseful(); + + /** + * Return runtime debugging info. + * + * Useful for finding the actual code which is useful. + */ + public String getWidgetInfo(); +}; diff --git a/ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/TestFactory.java b/ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/TestFactory.java new file mode 100644 index 0000000000..0c01bb8f9e --- /dev/null +++ b/ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/TestFactory.java @@ -0,0 +1,17 @@ +package com.test; + +/** + * @title TestFactory + * + * TestFactory is a pointless thing that OO programmers think is necessary + * because they read about it in a book. + * + * All it does is instantiate the (one and only) concrete AbstractTestWidget + * implementation + */ +public class TestFactory { + public AbstractTestWidget getWidget( String info ) { + AbstractTestWidget w = new TestWidgetImpl( info ); + return w; + } +} diff --git a/ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/TestLauncher.java b/ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/TestLauncher.java new file mode 100644 index 0000000000..f6acdb84e8 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/TestLauncher.java @@ -0,0 +1,17 @@ +package com.test; + +class TestLauncher { + private TestFactory factory = new TestFactory(); + + private void Run() { + AbstractTestWidget w = factory.getWidget( "Test" ); + w.doSomethingVaguelyUseful(); + + System.out.println( "Did something useful: " + w.getWidgetInfo() ); + } + + public static void main( String[] args ) { + TestLauncher l = new TestLauncher(); + l.Run(); + } +} diff --git a/ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/TestWidgetImpl.java b/ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/TestWidgetImpl.java new file mode 100644 index 0000000000..a16d7bf37c --- /dev/null +++ b/ycmd/tests/java/testdata/simple_maven_project/src/main/java/com/test/TestWidgetImpl.java @@ -0,0 +1,27 @@ +package com.test; + +/** + * This is the actual code that matters. + * + * This concrete implentation is the equivalent of the main function in other + * languages + */ + + +class TestWidgetImpl implements AbstractTestWidget { + private String info; + + TestWidgetImpl( String info ) { + this.info = info; + } + + @Override + public void doSomethingVaguelyUseful() { + System.out.println( "42" ); + } + + @Override + public String getWidgetInfo() { + return this.info; + } +} diff --git a/ycmd/tests/java/testdata/simple_maven_project/src/test/java/com/test/AppTest.java b/ycmd/tests/java/testdata/simple_maven_project/src/test/java/com/test/AppTest.java new file mode 100644 index 0000000000..2dd7cf8cb6 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_maven_project/src/test/java/com/test/AppTest.java @@ -0,0 +1,38 @@ +package com.test; + +import junit.framework.Test; +import junit.framework.TestCase; +import junit.framework.TestSuite; + +/** + * Unit test for simple App. + */ +public class AppTest + extends TestCase +{ + /** + * Create the test case + * + * @param testName name of the test case + */ + public AppTest( String testName ) + { + super( testName ); + } + + /** + * @return the suite of tests being tested + */ + public static Test suite() + { + return new TestSuite( AppTest.class ); + } + + /** + * Rigourous Test :-) + */ + public void testApp() + { + assertTrue( true ); + } +} diff --git a/ycmd/tests/server_utils_test.py b/ycmd/tests/server_utils_test.py index fb51981d1e..c3555eb9e3 100644 --- a/ycmd/tests/server_utils_test.py +++ b/ycmd/tests/server_utils_test.py @@ -48,7 +48,9 @@ os.path.join( DIR_OF_THIRD_PARTY, 'racerd' ), os.path.join( DIR_OF_THIRD_PARTY, 'requests' ), os.path.join( DIR_OF_THIRD_PARTY, 'tern_runtime' ), - os.path.join( DIR_OF_THIRD_PARTY, 'waitress' ) + os.path.join( DIR_OF_THIRD_PARTY, 'waitress' ), + os.path.join( DIR_OF_THIRD_PARTY, 'eclipse.jdt.ls' ), + os.path.join( DIR_OF_THIRD_PARTY, 'eclipse.jdt.ls-workspace' ), ) From 565a97964bef5ca2b9732285e69c6e03ee0b4c2b Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Mon, 11 Sep 2017 15:32:15 +0100 Subject: [PATCH 09/51] Don't _require_ that someone is listening to the message poll to handle the messages we care about --- ycmd/completers/java/java_completer.py | 14 ++++++-- .../language_server_completer.py | 32 ++++++++++++------- ycmd/tests/java/__init__.py | 15 +-------- ycmd/tests/java/diagnostics_test.py | 6 +--- ycmd/tests/shutdown_test.py | 2 ++ 5 files changed, 35 insertions(+), 34 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 92beccec13..6bea959500 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -239,10 +239,15 @@ def _StartServer( self ): _logger.warning( 'JDT Language Server failed to start' ) return + + def notification_handler( server, message ): + self._HandleNotificationInPollThread( message ) + self._server = ( language_server_completer.StandardIOLanguageServerConnection( self._server_handle.stdin, - self._server_handle.stdout ) + self._server_handle.stdout, + notification_handler ) ) self._server.start() @@ -316,15 +321,18 @@ def GetSubcommandsMap( self ): } - def HandleServerMessage( self, request_data, notification ): + def _HandleNotificationInPollThread( self, notification ): if notification[ 'method' ] == 'language/status': - message = notification[ 'params' ][ 'message' ] message_type = notification[ 'params' ][ 'type' ] if message_type == 'Started': _logger.info( 'Java Language Server initialised successfully.' ) self._received_ready_message = True + + def HandleServerMessage( self, request_data, notification ): + if notification[ 'method' ] == 'language/status': + message = notification[ 'params' ][ 'message' ] return responses.BuildDisplayMessageResponse( 'Initialising Java completer: {0}'.format( message ) ) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 73d1793b9d..bc499a9255 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -92,7 +92,7 @@ class LanguageServerConnection( object ): - _Read: Read some data from the server, blocking until some data is available """ - def __init__( self ): + def __init__( self, notification_handler = None ): super( LanguageServerConnection, self ).__init__() self._lastId = 0 @@ -102,6 +102,7 @@ def __init__( self ): self._connection_event = threading.Event() self._stop_event = threading.Event() + self._notification_handler = notification_handler def stop( self ): @@ -249,27 +250,37 @@ def _DespatchMessage( self, message ): else: self._notifications.put( message ) + if self._notification_handler: + self._notification_handler( self, message ) + + @abc.abstractmethod def _TryServerConnectionBlocking( self ): - raise RuntimeError( 'Not implemented' ) + pass + @abc.abstractmethod def _Stop( self ): - raise RuntimeError( 'Not implemented' ) + pass + @abc.abstractmethod def _Write( self, data ): - raise RuntimeError( 'Not implemented' ) + pass + @abc.abstractmethod def _Read( self, size=-1 ): - raise RuntimeError( 'Not implemented' ) + pass class StandardIOLanguageServerConnection( LanguageServerConnection, threading.Thread ): - def __init__( self, server_stdin, server_stdout ): - super( StandardIOLanguageServerConnection, self ).__init__() + def __init__( self, server_stdin, + server_stdout, + notification_handler = None ): + super( StandardIOLanguageServerConnection, self ).__init__( + notification_handler ) self.server_stdin = server_stdin self.server_stdout = server_stdout @@ -322,15 +333,12 @@ def __init__( self, user_options): self._latest_diagnostics = collections.defaultdict( list ) + @abc.abstractmethod def GetServer( sefl ): """Method that must be implemented by derived classes to return an instance of LanguageServerConnection appropriate for the language server in question""" - # TODO: I feel like abc.abstractmethod could be used here, but I'm not sure - # if it is totally python2/3 safe, and TBH it doesn't do a lot more than - # this simple raise here, so... - raise NameError( "GetServer must be implemented in LanguageServerCompleter " - "subclasses" ) + pass def ComputeCandidatesInner( self, request_data ): diff --git a/ycmd/tests/java/__init__.py b/ycmd/tests/java/__init__.py index 178ceefe42..7ee70fb427 100644 --- a/ycmd/tests/java/__init__.py +++ b/ycmd/tests/java/__init__.py @@ -30,8 +30,7 @@ from ycmd.tests.test_utils import ( ClearCompletionsCache, CurrentWorkingDirectory, SetUpApp, - StopCompleterServer, - BuildRequest ) + StopCompleterServer ) from ycmd.utils import GetCurrentDirectory shared_app = None @@ -130,17 +129,5 @@ def WaitUntilCompleterServerReady( app, timeout = 30 ): if app.get( '/ready', { 'subserver': filetype } ).json: return - else: - # Poll for messages. The server requires this to handle async messages, - # and will not become ready without them. - # FIXME: Is this really what we want? It's tricky to actually handle these - # things without some trigger. - app.post_json( '/receive_messages', BuildRequest( **{ - 'filetype' : 'java', - 'filepath' : PathToTestFile( 'DEFAULT_PROJECT_DIR' ), - 'line_num' : 1, - 'column_num': 1, - 'contents': '' - } ) ) time.sleep( 0.1 ) diff --git a/ycmd/tests/java/diagnostics_test.py b/ycmd/tests/java/diagnostics_test.py index fe073f0f46..378a0f4727 100644 --- a/ycmd/tests/java/diagnostics_test.py +++ b/ycmd/tests/java/diagnostics_test.py @@ -61,10 +61,6 @@ def PollForMessages( app, request_data, drain=True ): raise RuntimeError( 'Waited for diagnostics to be ready for ' '10 seconds, aborting.' ) - # Poll for messages. The server requires this to handle async messages, - # and will not become ready without them. - # FIXME: Is this really what we want? It's tricky to actually handle these - # things without some trigger. response = app.post_json( '/receive_messages', BuildRequest( **Merge ( { 'filetype' : 'java', 'line_num' : 1, @@ -185,7 +181,7 @@ class Test { 'fixit_available': False } ) ) - # Poll unti we receive the diags asyncronously + # Poll until we receive the diags asynchronously for message in PollForMessages( app, { 'filepath': filepath, 'contents': contents }, diff --git a/ycmd/tests/shutdown_test.py b/ycmd/tests/shutdown_test.py index afa531449c..7916022254 100644 --- a/ycmd/tests/shutdown_test.py +++ b/ycmd/tests/shutdown_test.py @@ -47,6 +47,7 @@ def FromHandlerWithSubservers_test( self ): filetypes = [ 'cs', 'go', + 'java', 'javascript', 'python', 'typescript', @@ -77,6 +78,7 @@ def FromWatchdogWithSubservers_test( self ): filetypes = [ 'cs', 'go', + 'java', 'javascript', 'python', 'typescript', From 09fca85d7e985f13964a52f438823da4240ab8db Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Mon, 11 Sep 2017 16:23:29 +0100 Subject: [PATCH 10/51] Initialise asyncronously to avoid blocking when the server takes ages to start or fails to start --- ycmd/completers/java/java_completer.py | 15 ++-- .../language_server_completer.py | 85 ++++++++++++++++--- 2 files changed, 80 insertions(+), 20 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 6bea959500..1fa7f1ba23 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -124,6 +124,7 @@ def __init__( self, user_options ): str( os.getpid() ) ) self._Reset() + try : self._StartServer() except: @@ -172,7 +173,9 @@ def ServerIsHealthy( self, request_data = {} ): def ServerIsReady( self ): - return self.ServerIsHealthy() and self._received_ready_message + return ( self.ServerIsHealthy() and + self._received_ready_message.is_set() and + super( JavaCompleter, self ).ServerIsReady() ) def ShouldUseNowInner( self, request_data ): @@ -189,7 +192,7 @@ def _Reset( self ): self._server_stderr = None self._server_handle = None - self._received_ready_message = False + self._received_ready_message = threading.Event() try: rmtree( self._workspace_path ) @@ -200,6 +203,8 @@ def _Reset( self ): self._server = None + self._ServerReset() + def _StartServer( self ): with self._server_state_mutex: @@ -239,7 +244,6 @@ def _StartServer( self ): _logger.warning( 'JDT Language Server failed to start' ) return - def notification_handler( server, message ): self._HandleNotificationInPollThread( message ) @@ -252,7 +256,6 @@ def notification_handler( server, message ): self._server.start() - # Awaiting connection try: self._server.TryServerConnection() except language_server_completer.LanguageServerConnectionTimeout: @@ -261,7 +264,7 @@ def notification_handler( server, message ): self._StopServer() return - self._WaitForInitiliase() + self._SendInitialiseAsync() def _StopServer( self ): @@ -327,7 +330,7 @@ def _HandleNotificationInPollThread( self, notification ): if message_type == 'Started': _logger.info( 'Java Language Server initialised successfully.' ) - self._received_ready_message = True + self._received_ready_message.set() def HandleServerMessage( self, request_data, notification ): diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index bc499a9255..1aab2f1dde 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -48,14 +48,17 @@ class Response( object ): - def __init__( self ): + def __init__( self, response_callback=None ): self._event = threading.Event() self._message = None + self._response_callback = response_callback def ResponseReceived( self, message ): self._message = message self._event.set() + if self._response_callback: + self._response_callback( self, message ) def AwaitResponse( self, timeout ): @@ -121,8 +124,8 @@ def NextRequestId( self ): return str( self._lastId ) - def GetResponse( self, request_id, message, timeout ): - response = Response() + def GetResponseAsync( self, request_id, message, response_callback=None ): + response = Response( response_callback ) with self._responseMutex: assert request_id not in self._responses @@ -131,6 +134,11 @@ def GetResponse( self, request_id, message, timeout ): _logger.debug( 'TX: Sending message {0}'.format( message ) ) self._Write( message ) + return response + + + def GetResponse( self, request_id, message, timeout ): + response = self.GetResponseAsync( request_id, message ) return response.AwaitResponse( timeout ) @@ -325,12 +333,18 @@ def _Read( self, size=-1 ): class LanguageServerCompleter( Completer ): def __init__( self, user_options): super( LanguageServerCompleter, self ).__init__( user_options ) + self._fileStateMutex = threading.Lock() + self._ServerReset() - self._syncType = 'Full' - self._serverFileState = {} - self._fileStateMutex = threading.Lock() + def _ServerReset( self ): + with self._fileStateMutex: + self._serverFileState = {} + self._latest_diagnostics = collections.defaultdict( list ) + self._syncType = 'Full' + self._initialise_response = None + self._initialise_event = threading.Event() @abc.abstractmethod @@ -341,8 +355,21 @@ def GetServer( sefl ): pass + def ServerIsReady( self ): + if self._initialise_event.is_set(): + # We already got the initialise response + return True + + if self._initialise_response is None: + # We never sent the initialise response + return False + + # Initialise request in progress. Will be handled asynchronously. + return False + + def ComputeCandidatesInner( self, request_data ): - if not self.ServerIsHealthy(): + if not self.ServerIsReady(): return None self._RefreshFiles( request_data ) @@ -425,8 +452,10 @@ def MakeCompletion( item ): def OnFileReadyToParse( self, request_data ): - if self.ServerIsReady(): - self._RefreshFiles( request_data ) + if not self.ServerIsReady(): + return + + self._RefreshFiles( request_data ) # NOTE: We also return diagnostics asynchronously via the long-polling # mechanism to avoid timing issues with the servers asynchronous publication @@ -568,14 +597,24 @@ def _RefreshFiles( self, request_data ): del self._serverFileState[ file_name ] - def _WaitForInitiliase( self ): - request_id = self.GetServer().NextRequestId() + def _SendInitialiseAsync( self ): + if self._initialise_response: + raise AssertionError( 'Attempt to send multiple initialise requests' ) + request_id = self.GetServer().NextRequestId() msg = lsapi.Initialise( request_id ) - response = self.GetServer().GetResponse( request_id, - msg, - REQUEST_TIMEOUT_INITIALISE ) + def response_handler( response, message ): + self._HandleInitialiseInPollThread( message ) + + self._initialise_response = self.GetServer().GetResponseAsync( + request_id, + msg, + response_handler ) + + + def _HandleInitialiseInPollThread( self, response ): + # TODO: Mutex self._server_capabilities = response[ 'result' ][ 'capabilities' ] if 'textDocumentSync' in response[ 'result' ][ 'capabilities' ]: @@ -589,8 +628,14 @@ def _WaitForInitiliase( self ): _logger.info( 'Language Server requires sync type of {0}'.format( self._syncType ) ) + self._initialise_event.set() + self._initialise_response = None + def _GetHoverResponse( self, request_data ): + if not self.ServerIsReady(): + raise RuntimeError( 'Server is initialising. Please wait.' ) + request_id = self.GetServer().NextRequestId() response = self.GetServer().GetResponse( request_id, @@ -619,6 +664,9 @@ def LocationListToGoTo( self, request_data, response ): def _GoToDeclaration( self, request_data ): + if not self.ServerIsReady(): + raise RuntimeError( 'Server is initialising. Please wait.' ) + request_id = self.GetServer().NextRequestId() response = self.GetServer().GetResponse( request_id, @@ -635,6 +683,9 @@ def _GoToDeclaration( self, request_data ): def _GoToReferences( self, request_data ): + if not self.ServerIsReady(): + raise RuntimeError( 'Server is initialising. Please wait.' ) + request_id = self.GetServer().NextRequestId() response = self.GetServer().GetResponse( request_id, @@ -646,6 +697,9 @@ def _GoToReferences( self, request_data ): def _CodeAction( self, request_data, args ): + if not self.ServerIsReady(): + raise RuntimeError( 'Server is initialising. Please wait.' ) + # FIXME: We need to do this for all such requests self._RefreshFiles( request_data ) @@ -712,6 +766,9 @@ def HandleServerCommand( self, request_data, command ): def _Rename( self, request_data, args ): + if not self.ServerIsReady(): + raise RuntimeError( 'Server is initialising. Please wait.' ) + if len( args ) != 1: raise ValueError( 'Please specify a new name to rename it to.\n' 'Usage: RefactorRename ' ) From ad36c3f432f68cf1a35c65f9c2463f649d77ccdb Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Mon, 11 Sep 2017 19:18:00 +0100 Subject: [PATCH 11/51] Return diagnostics for all files asynchronously _and_ on parse request. This changes the diagnostics delivery so that we return the diagnostics to the client in the same way that the language server does, i.e. per-file. The client then fans them out or does whatever makes sense for the client. In order to be relatively compatible with other clients, we also return diagnostics on the file-ready-to-parse event, even though they might be out of date wrt the code. The client is responsible for ignoring these diagnostics when it handles the asynchronously delivered ones. --- ycmd/completers/java/java_completer.py | 28 +-- .../language_server_completer.py | 124 ++++++------ ycmd/tests/java/diagnostics_test.py | 180 +++++++++++------- 3 files changed, 188 insertions(+), 144 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 1fa7f1ba23..c6eb07a292 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -244,14 +244,11 @@ def _StartServer( self ): _logger.warning( 'JDT Language Server failed to start' ) return - def notification_handler( server, message ): - self._HandleNotificationInPollThread( message ) - self._server = ( language_server_completer.StandardIOLanguageServerConnection( self._server_handle.stdin, self._server_handle.stdout, - notification_handler ) + self._GetDefaultNotificationHandler() ) ) self._server.start() @@ -269,14 +266,15 @@ def notification_handler( server, message ): def _StopServer( self ): with self._server_state_mutex: - if self._ServerIsRunning(): - # We don't use utils.CloseStandardStreams, because the stdin/out is - # connected to our server connector. Just close stderr. - if self._server_handle and self._server_handle.stderr: - self._server_handle.stderr.close() + # We don't use utils.CloseStandardStreams, because the stdin/out is + # connected to our server connector. Just close stderr. + if self._server_handle and self._server_handle.stderr: + self._server_handle.stderr.close() + if self._server: self._server.stop() + if self._ServerIsRunning(): _logger.info( 'Stopping java server with PID {0}'.format( self._server_handle.pid ) ) @@ -286,13 +284,13 @@ def _StopServer( self ): utils.WaitUntilProcessIsTerminated( self._server_handle, timeout = 5 ) - self._server.join() + if self._server: + self._server.join() _logger.info( 'JDT Language server stopped' ) except RuntimeError: _logger.exception( 'Error while stopping java server' ) - self._Reset() @@ -332,14 +330,18 @@ def _HandleNotificationInPollThread( self, notification ): _logger.info( 'Java Language Server initialised successfully.' ) self._received_ready_message.set() + super( JavaCompleter, self )._HandleNotificationInPollThread( notification ) + - def HandleServerMessage( self, request_data, notification ): + def _ConvertNotificationToMessage( self, request_data, notification ): if notification[ 'method' ] == 'language/status': message = notification[ 'params' ][ 'message' ] return responses.BuildDisplayMessageResponse( 'Initialising Java completer: {0}'.format( message ) ) - return None + return super( JavaCompleter, self )._ConvertNotificationToMessage( + request_data, + notification ) def GetType( self, request_data ): diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 1aab2f1dde..043d39c177 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -333,18 +333,17 @@ def _Read( self, size=-1 ): class LanguageServerCompleter( Completer ): def __init__( self, user_options): super( LanguageServerCompleter, self ).__init__( user_options ) - self._fileStateMutex = threading.Lock() + self._mutex = threading.Lock() self._ServerReset() def _ServerReset( self ): - with self._fileStateMutex: + with self._mutex: self._serverFileState = {} - - self._latest_diagnostics = collections.defaultdict( list ) - self._syncType = 'Full' - self._initialise_response = None - self._initialise_event = threading.Event() + self._latest_diagnostics = collections.defaultdict( list ) + self._syncType = 'Full' + self._initialise_response = None + self._initialise_event = threading.Event() @abc.abstractmethod @@ -463,9 +462,10 @@ def OnFileReadyToParse( self, request_data ): # However, we _also_ return them here to refresh diagnostics after, say # changing the active file in the editor. uri = lsapi.FilePathToUri( request_data[ 'filepath' ] ) - if self._latest_diagnostics[ uri ]: - return [ BuildDiagnostic( request_data, uri, diag ) - for diag in self._latest_diagnostics[ uri ] ] + with self._mutex: + if uri in self._latest_diagnostics: + return [ BuildDiagnostic( request_data, uri, diag ) + for diag in self._latest_diagnostics[ uri ] ] def _PollForMessagesNoBlock( self, request_data, messages ): @@ -517,38 +517,36 @@ def PollForMessagesInner( self, request_data ): return self._PollForMessagesBlock( request_data ) - def HandleServerMessage( self, request_data, notification ): - return None + def _GetDefaultNotificationHandler( self ): + def handler( server, notification ): + self._HandleNotificationInPollThread( notification ) + return handler - def _ConvertNotificationToMessage( self, request_data, notification ): - response = self.HandleServerMessage( request_data, notification ) + def _HandleNotificationInPollThread( self, notification ): + if notification[ 'method' ] == 'textDocument/publishDiagnostics': + # Some clients might not use a message poll, so we must store the + # diagnostics and return them in OnFileReadyToParse + params = notification[ 'params' ] + uri = params[ 'uri' ] + with self._mutex: + self._latest_diagnostics[ uri ] = params[ 'diagnostics' ] - if response: - return response - elif notification[ 'method' ] == 'window/showMessage': + + def _ConvertNotificationToMessage( self, request_data, notification ): + if notification[ 'method' ] == 'window/showMessage': return responses.BuildDisplayMessageResponse( notification[ 'params' ][ 'message' ] ) elif notification[ 'method' ] == 'textDocument/publishDiagnostics': - # Diagnostics are a little special. We only return diagnostics for the - # requested file, but store them for every file. Language servers can - # return diagnostics for the whole project, but this request is - # specifically for a particular file. - # Any messages we handle which are for other files are returned in the - # OnFileReadyToParse request. params = notification[ 'params' ] uri = params[ 'uri' ] - self._latest_diagnostics[ uri ] = params[ 'diagnostics' ] - - # TODO(Ben): Does realpath break symlinks? - # e.g. we putting symlinks in the testdata for the source does not work - if os.path.realpath( lsapi.UriToFilePath( uri ) ) == os.path.realpath( - request_data[ 'filepath' ] ): - response = { - 'diagnostics': [ BuildDiagnostic( request_data, uri, x ) - for x in params[ 'diagnostics' ] ] - } - return response + filepath = lsapi.UriToFilePath( uri ) + response = { + 'diagnostics': [ BuildDiagnostic( request_data, uri, x ) + for x in params[ 'diagnostics' ] ], + 'filepath': filepath + } + return response return None @@ -556,7 +554,7 @@ def _ConvertNotificationToMessage( self, request_data, notification ): def _RefreshFiles( self, request_data ): # FIXME: Provide a Reset method which clears this state. Restarting # downstream servers would leave this cache in the incorrect state. - with self._fileStateMutex: + with self._mutex: for file_name, file_data in iteritems( request_data[ 'file_data' ] ): file_state = 'New' if file_name in self._serverFileState: @@ -598,38 +596,39 @@ def _RefreshFiles( self, request_data ): def _SendInitialiseAsync( self ): - if self._initialise_response: - raise AssertionError( 'Attempt to send multiple initialise requests' ) + with self._mutex: + if self._initialise_response: + raise AssertionError( 'Attempt to send multiple initialise requests' ) - request_id = self.GetServer().NextRequestId() - msg = lsapi.Initialise( request_id ) + request_id = self.GetServer().NextRequestId() + msg = lsapi.Initialise( request_id ) - def response_handler( response, message ): - self._HandleInitialiseInPollThread( message ) + def response_handler( response, message ): + self._HandleInitialiseInPollThread( message ) - self._initialise_response = self.GetServer().GetResponseAsync( - request_id, - msg, - response_handler ) + self._initialise_response = self.GetServer().GetResponseAsync( + request_id, + msg, + response_handler ) def _HandleInitialiseInPollThread( self, response ): - # TODO: Mutex - self._server_capabilities = response[ 'result' ][ 'capabilities' ] - - if 'textDocumentSync' in response[ 'result' ][ 'capabilities' ]: - SYNC_TYPE = [ - 'None', - 'Full', - 'Incremental' - ] - self._syncType = SYNC_TYPE[ - response[ 'result' ][ 'capabilities' ][ 'textDocumentSync' ] ] - _logger.info( 'Language Server requires sync type of {0}'.format( - self._syncType ) ) + with self._mutex: + self._server_capabilities = response[ 'result' ][ 'capabilities' ] + + if 'textDocumentSync' in response[ 'result' ][ 'capabilities' ]: + SYNC_TYPE = [ + 'None', + 'Full', + 'Incremental' + ] + self._syncType = SYNC_TYPE[ + response[ 'result' ][ 'capabilities' ][ 'textDocumentSync' ] ] + _logger.info( 'Language Server requires sync type of {0}'.format( + self._syncType ) ) - self._initialise_event.set() - self._initialise_response = None + self._initialise_response = None + self._initialise_event.set() def _GetHoverResponse( self, request_data ): @@ -714,8 +713,9 @@ def WithinRange( diag ): return True - file_diagnostics = self._latest_diagnostics[ - lsapi.FilePathToUri( request_data[ 'filepath' ] ) ] + with self._mutex: + file_diagnostics = list( self._latest_diagnostics[ + lsapi.FilePathToUri( request_data[ 'filepath' ] ) ] ) matched_diagnostics = [ d for d in file_diagnostics if WithinRange( d ) diff --git a/ycmd/tests/java/diagnostics_test.py b/ycmd/tests/java/diagnostics_test.py index 378a0f4727..4821010955 100644 --- a/ycmd/tests/java/diagnostics_test.py +++ b/ycmd/tests/java/diagnostics_test.py @@ -22,10 +22,16 @@ # Not installing aliases from python-future; it's unreliable and slow. from builtins import * # noqa +from future.utils import iterkeys from hamcrest import ( assert_that, contains, has_entries ) from nose.tools import eq_ -from ycmd.tests.java import ( PathToTestFile, SharedYcmd, DEFAULT_PROJECT_DIR ) +from ycmd.tests.java import ( DEFAULT_PROJECT_DIR, + IsolatedYcmdInDirectory, + PathToTestFile, + SharedYcmd, + WaitUntilCompleterServerReady ) + from ycmd.tests.test_utils import ( BuildRequest ) from ycmd.utils import ReadFile @@ -48,18 +54,75 @@ def RangeMatch( start, end ): } ) +def ProjectPath( *args ): + return PathToTestFile( DEFAULT_PROJECT_DIR, + 'src', + 'com', + 'test', + *args ) + + +DIAG_MATCHERS_PER_FILE = { + ProjectPath( 'TestFactory.java' ): contains( + has_entries( { + 'kind': 'WARNING', + 'text': 'The value of the field TestFactory.Bar.testString is not used', + 'location': PositionMatch( 15, 19 ), + 'location_extent': RangeMatch( ( 15, 19 ), ( 15, 29 ) ), + 'ranges': contains( RangeMatch( ( 15, 19 ), ( 15, 29 ) ) ), + 'fixit_available': False + } ), + has_entries( { + 'kind': 'ERROR', + 'text': 'Wibble cannot be resolved to a type', + 'location': PositionMatch( 18, 24 ), + 'location_extent': RangeMatch( ( 18, 24 ), ( 18, 30 ) ), + 'ranges': contains( RangeMatch( ( 18, 24 ), ( 18, 30 ) ) ), + 'fixit_available': False + } ), + has_entries( { + 'kind': 'ERROR', + 'text': 'Wibble cannot be resolved to a variable', + 'location': PositionMatch( 19, 15 ), + 'location_extent': RangeMatch( ( 19, 15 ), ( 19, 21 ) ), + 'ranges': contains( RangeMatch( ( 19, 15 ), ( 19, 21 ) ) ), + 'fixit_available': False + } ), + has_entries( { + 'kind': 'ERROR', + 'text': 'Type mismatch: cannot convert from int to boolean', + 'location': PositionMatch( 27, 10 ), + 'location_extent': RangeMatch( ( 27, 10 ), ( 27, 16 ) ), + 'ranges': contains( RangeMatch( ( 27, 10 ), ( 27, 16 ) ) ), + 'fixit_available': False + } ), + ), + ProjectPath( 'TestWidgetImpl.java' ): contains( + has_entries( { + 'kind': 'WARNING', + 'text': 'The value of the local variable a is not used', + 'location': PositionMatch( 15, 9 ), + 'location_extent': RangeMatch( ( 15, 9 ), ( 15, 10 ) ), + 'ranges': contains( RangeMatch( ( 15, 9 ), ( 15, 10 ) ) ), + 'fixit_available': False + } ), + ), +} + + def Merge( request, data ): kw = dict( request ) kw.update( data ) return kw -def PollForMessages( app, request_data, drain=True ): - expiration = time.time() + 5 +def PollForMessages( app, request_data ): + TIMEOUT = 30 + expiration = time.time() + TIMEOUT while True: if time.time() > expiration: raise RuntimeError( 'Waited for diagnostics to be ready for ' - '10 seconds, aborting.' ) + '{0} seconds, aborting.'.format( TIMEOUT ) ) response = app.post_json( '/receive_messages', BuildRequest( **Merge ( { 'filetype' : 'java', @@ -72,8 +135,6 @@ def PollForMessages( app, request_data, drain=True ): if isinstance( response, bool ): if not response: raise RuntimeError( 'The message poll was aborted by the server' ) - elif drain: - return elif isinstance( response, list ): for message in response: yield message @@ -85,17 +146,9 @@ def PollForMessages( app, request_data, drain=True ): @SharedYcmd def FileReadyToParse_Diagnostics_Simple_test( app ): - filepath = PathToTestFile( DEFAULT_PROJECT_DIR, - 'src', - 'com', - 'test', - 'TestFactory.java' ) + filepath = ProjectPath( 'TestFactory.java' ) contents = ReadFile( filepath ) - # During server initialisation, jdtls reads the project files off the disk. - # This means that when the test module initialised (waiting for the /ready - # response), we actually already handled the messages, so they should be in - # the diagnostics cache. event_data = BuildRequest( event_name = 'FileReadyToParse', contents = contents, filepath = filepath, @@ -105,63 +158,21 @@ def FileReadyToParse_Diagnostics_Simple_test( app ): print( 'completer response: {0}'.format( pformat( results ) ) ) - assert_that( - results, - contains( - has_entries( { - 'kind': 'WARNING', - 'text': 'The value of the field TestFactory.Bar.testString is not used', - 'location': PositionMatch( 15, 19 ), - 'location_extent': RangeMatch( ( 15, 19 ), ( 15, 29 ) ), - 'ranges': contains( RangeMatch( ( 15, 19 ), ( 15, 29 ) ) ), - 'fixit_available': False - } ), - has_entries( { - 'kind': 'ERROR', - 'text': 'Wibble cannot be resolved to a type', - 'location': PositionMatch( 18, 24 ), - 'location_extent': RangeMatch( ( 18, 24 ), ( 18, 30 ) ), - 'ranges': contains( RangeMatch( ( 18, 24 ), ( 18, 30 ) ) ), - 'fixit_available': False - } ), - has_entries( { - 'kind': 'ERROR', - 'text': 'Wibble cannot be resolved to a variable', - 'location': PositionMatch( 19, 15 ), - 'location_extent': RangeMatch( ( 19, 15 ), ( 19, 21 ) ), - 'ranges': contains( RangeMatch( ( 19, 15 ), ( 19, 21 ) ) ), - 'fixit_available': False - } ), - has_entries( { - 'kind': 'ERROR', - 'text': 'Type mismatch: cannot convert from int to boolean', - 'location': PositionMatch( 27, 10 ), - 'location_extent': RangeMatch( ( 27, 10 ), ( 27, 16 ) ), - 'ranges': contains( RangeMatch( ( 27, 10 ), ( 27, 16 ) ) ), - 'fixit_available': False - } ), - ) - ) + assert_that( results, DIAG_MATCHERS_PER_FILE[ filepath ] ) -@SharedYcmd +@IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) def FileReadyToParse_Diagnostics_FileNotOnDisk_test( app ): + WaitUntilCompleterServerReady( app ) + contents = ''' package com.test; class Test { public String test } ''' - filepath = PathToTestFile( DEFAULT_PROJECT_DIR, - 'src', - 'com', - 'test', - 'Test.java' ) - - # During server initialisation, jdtls reads the project files off the disk. - # This means that when the test module initialised (waiting for the /ready - # response), we actually already handled the messages, so they should be in - # the diagnostics cache. + filepath = ProjectPath( 'Test.java' ) + event_data = BuildRequest( event_name = 'FileReadyToParse', contents = contents, filepath = filepath, @@ -169,7 +180,8 @@ class Test { results = app.post_json( '/event_notification', event_data ).json - # This is a new file, so the diagnostics can't possibly be available. + # This is a new file, so the diagnostics can't possibly be available when the + # initial parse request is sent. We receive these asynchronously. eq_( results, {} ) diag_matcher = contains( has_entries( { @@ -181,15 +193,15 @@ class Test { 'fixit_available': False } ) ) - # Poll until we receive the diags asynchronously + # Poll until we receive the diags for message in PollForMessages( app, { 'filepath': filepath, - 'contents': contents }, - drain=True ): + 'contents': contents } ): print( 'Message {0}'.format( pformat( message ) ) ) - if 'diagnostics' in message: + if 'diagnostics' in message and message[ 'filepath' ] == filepath: assert_that( message, has_entries( { - 'diagnostics': diag_matcher + 'diagnostics': diag_matcher, + 'filepath': filepath } ) ) break @@ -198,3 +210,33 @@ class Test { print( 'completer response: {0}'.format( pformat( results ) ) ) assert_that( results, diag_matcher ) + + +@SharedYcmd +def Poll_Diagnostics_ProjectWide_test( app ): + filepath = ProjectPath( 'TestLauncher.java' ) + contents = ReadFile( filepath ) + + # Poll until we receive _all_ the diags asynchronously + to_see = sorted( iterkeys( DIAG_MATCHERS_PER_FILE ) ) + seen = dict() + for message in PollForMessages( app, + { 'filepath': filepath, + 'contents': contents } ): + print( 'Message {0}'.format( pformat( message ) ) ) + if 'diagnostics' in message: + seen[ message[ 'filepath' ] ] = True + if message[ 'filepath' ] not in DIAG_MATCHERS_PER_FILE: + raise AssertionError( + 'Received diagnostics for unexpected file {0}. ' + 'Only expected {1}'.format( message[ 'filepath' ], to_see ) ) + assert_that( message, has_entries( { + 'diagnostics': DIAG_MATCHERS_PER_FILE[ message[ 'filepath' ] ], + 'filepath': message[ 'filepath' ] + } ) ) + + if sorted( iterkeys( seen ) ) == to_see: + break + + # Eventually PollForMessages will throw a timeout exception and we'll fail + # if we don't see all of the expected diags From 03b750fcb90ab94dc531a6049b609535d64c7e01 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Tue, 12 Sep 2017 00:49:39 +0100 Subject: [PATCH 12/51] Properly shut down the language server Close server standard output on close Close all the sockets when the read loop exits --- ycmd/completers/java/java_completer.py | 60 ++++++++++---- .../language_server_completer.py | 82 ++++++++++++++++--- ycmd/completers/language_server/lsapi.py | 8 ++ 3 files changed, 122 insertions(+), 28 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index c6eb07a292..a081720d14 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -264,6 +264,45 @@ def _StartServer( self ): self._SendInitialiseAsync() + def _StopServerCleanly( self ): + # Try and shutdown cleanly + if self._ServerIsRunning(): + _logger.info( 'Stopping java server with PID {0}'.format( + self._server_handle.pid ) ) + + self._ShutdownServer() + + try: + utils.WaitUntilProcessIsTerminated( self._server_handle, + timeout = 5 ) + + if self._server: + self._server.join() + + _logger.info( 'JDT Language server stopped' ) + except Exception: + _logger.exception( 'Error while stopping java server' ) + + + def _StopServerForecefully( self ): + if self._ServerIsRunning(): + _logger.info( 'Killing java server with PID {0}'.format( + self._server_handle.pid ) ) + + self._server_handle.terminate() + + try: + utils.WaitUntilProcessIsTerminated( self._server_handle, + timeout = 5 ) + + if self._server: + self._server.join() + + _logger.info( 'JDT Language server killed' ) + except Exception: + _logger.exception( 'Error while killing java server' ) + + def _StopServer( self ): with self._server_state_mutex: # We don't use utils.CloseStandardStreams, because the stdin/out is @@ -271,26 +310,17 @@ def _StopServer( self ): if self._server_handle and self._server_handle.stderr: self._server_handle.stderr.close() + # Tell the connection to expect the server to disconnect if self._server: self._server.stop() - if self._ServerIsRunning(): - _logger.info( 'Stopping java server with PID {0}'.format( - self._server_handle.pid ) ) - - self._server_handle.terminate() - - try: - utils.WaitUntilProcessIsTerminated( self._server_handle, - timeout = 5 ) - - if self._server: - self._server.join() + # Tell the server to exit using the shutdown request. + self._StopServerCleanly() - _logger.info( 'JDT Language server stopped' ) - except RuntimeError: - _logger.exception( 'Error while stopping java server' ) + # If the server is still running, e.g. due to erros, kill it + self._StopServerForecefully() + # Tidy up our internal state self._Reset() diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 043d39c177..068a71300f 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -47,6 +47,18 @@ MESSAGE_POLL_TIMEOUT = 10 +class ResponseTimeoutException( Exception ): + pass + + +class ResponseAbortedException( Exception ): + pass + + +class ResponseFailedException( Exception ): + pass + + class Response( object ): def __init__( self, response_callback=None ): self._event = threading.Event() @@ -61,15 +73,22 @@ def ResponseReceived( self, message ): self._response_callback( self, message ) + def Abort( self ): + self.ResponseReceived( None ) + + def AwaitResponse( self, timeout ): self._event.wait( timeout ) if not self._event.isSet(): - raise RuntimeError( 'Response Timeout' ) + raise ResponseTimeoutException( 'Response Timeout' ) + + if self._message is None: + raise ResponseAbortedException( 'Response Aborted' ) if 'error' in self._message: error = self._message[ 'error' ] - raise RuntimeError( 'Request failed: {0}: {1}'.format( + raise ResponseFailedException( 'Request failed: {0}: {1}'.format( error.get( 'code', 0 ), error.get( 'message', 'No message' ) ) ) @@ -110,7 +129,6 @@ def __init__( self, notification_handler = None ): def stop( self ): # Note lowercase stop() to match threading.Thread.start() - self._Stop() self._stop_event.set() @@ -168,8 +186,15 @@ def _run_loop( self ): # Blocking loop which reads whole messages and calls _DespatchMessage self._ReadMessages( ) except LanguageServerConnectionStopped: + # Abort any outstanding requests + with self._responseMutex: + for _, response in iteritems( self._responses ): + response.Abort() + self._responses.clear() + _logger.debug( 'Connection was closed cleanly' ) - pass + + self._Close() def _ReadHeaders( self, data ): @@ -213,7 +238,7 @@ def _ReadMessages( self ): ( data, read_bytes, headers ) = self._ReadHeaders( data ) if 'Content-Length' not in headers: - raise RuntimeError( "Missing 'Content-Length' header" ) + raise ValueError( "Missing 'Content-Length' header" ) content_length = int( headers[ 'Content-Length' ] ) @@ -255,6 +280,7 @@ def _DespatchMessage( self, message ): with self._responseMutex: assert str( message[ 'id' ] ) in self._responses self._responses[ str( message[ 'id' ] ) ].ResponseReceived( message ) + del self._responses[ str( message[ 'id' ] ) ] else: self._notifications.put( message ) @@ -268,7 +294,7 @@ def _TryServerConnectionBlocking( self ): @abc.abstractmethod - def _Stop( self ): + def _Close( self ): pass @@ -298,15 +324,19 @@ def run( self ): self._run_loop() + def _Close( self ): + if not self.server_stdin.closed: + self.server_stdin.close() + + if not self.server_stdout.closed: + self.server_stdout.close() + + def _TryServerConnectionBlocking( self ): # standard in/out don't need to wait for the server to connect to us return True - def _Stop( self ): - self.server_stdin.close() - - def _Write( self, data ): to_write = data + utils.ToBytes( '\r\n' ) self.server_stdin.write( to_write ) @@ -346,6 +376,30 @@ def _ServerReset( self ): self._initialise_event = threading.Event() + def _ShutdownServer( self ): + if self.ServerIsReady(): + request_id = self.GetServer().NextRequestId() + msg = lsapi.Shutdown( request_id ) + + try: + self.GetServer().GetResponse( request_id, + msg, + REQUEST_TIMEOUT_INITIALISE ) + except ResponseAbortedException: + # When the language server (heinously) dies handling the shutdown + # request, it is aborted. Just return - we're done. + return + except Exception: + # Ignore other exceptions from the server and send the exit request + # anyway + _logger.exception( 'Shutdown request failed. Ignoring.' ) + + # Assuming that worked, send the exit notification + if self.ServerIsHealthy(): + self.GetServer().SendNotification( lsapi.Exit() ) + + + @abc.abstractmethod def GetServer( sefl ): """Method that must be implemented by derived classes to return an instance @@ -597,13 +651,15 @@ def _RefreshFiles( self, request_data ): def _SendInitialiseAsync( self ): with self._mutex: - if self._initialise_response: - raise AssertionError( 'Attempt to send multiple initialise requests' ) + assert not self._initialise_response request_id = self.GetServer().NextRequestId() msg = lsapi.Initialise( request_id ) def response_handler( response, message ): + if message is None: + raise ResponseAbortedException( 'Initialise request aborted' ) + self._HandleInitialiseInPollThread( message ) self._initialise_response = self.GetServer().GetResponseAsync( @@ -813,7 +869,7 @@ def _GetInsertionText( self, request_data, item ): if ( new_range[ 'start' ][ 'line' ] != new_range[ 'end' ][ 'line' ] or new_range[ 'start' ][ 'line' ] + 1 != request_data[ 'line_num' ] ): # We can't support completions that span lines. The protocol forbids it - raise RuntimeError( 'Invalid textEdit supplied. Must be on a single ' + raise ValueError( 'Invalid textEdit supplied. Must be on a single ' 'line' ) elif '\n' in item[ 'textEdit' ][ 'newText' ]: # The insertion text contains newlines. This is tricky: most clients diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index 345a3ed486..ee6528c7fb 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -71,6 +71,14 @@ def Initialise( request_id ): } ) +def Shutdown( request_id ): + return BuildRequest( request_id, 'shutdown', None ) + + +def Exit(): + return BuildNotification( 'exit', None ) + + def DidOpenTextDocument( file_name, file_types, file_contents ): LAST_VERSION[ file_name ] = LAST_VERSION[ file_name ] + 1 return BuildNotification( 'textDocument/didOpen', { From d2434a1f0030356017e8a73780d93249bcdc5ef3 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Tue, 12 Sep 2017 11:05:41 +0100 Subject: [PATCH 13/51] Improve handling of the workspace directory The workspace dir can be legitimately empty Use a tmpdir for the workspace, as the tests can end up with multiple instances using the same one which leads to race conditions Create new workspace on server restart Delete the project, implementing the log handler to populate and put in ycmd's log --- build.py | 3 +- ycmd/completers/java/java_completer.py | 34 +++++++------------ .../language_server_completer.py | 14 ++++++++ ycmd/tests/java/__init__.py | 2 +- ycmd/tests/server_utils_test.py | 3 +- 5 files changed, 31 insertions(+), 25 deletions(-) diff --git a/build.py b/build.py index 8d7fbb0fce..3b6eb9d926 100755 --- a/build.py +++ b/build.py @@ -35,7 +35,8 @@ if p.isdir( abs_folder_path ) and not os.listdir( abs_folder_path ): sys.exit( 'ERROR: some folders in {0} are empty; you probably forgot to run:\n' - '\tgit submodule update --init --recursive\n'.format( DIR_OF_THIRD_PARTY ) + '\tgit submodule update --init --recursive\n'.format( + DIR_OF_THIRD_PARTY ) ) sys.path.insert( 1, p.abspath( p.join( DIR_OF_THIRD_PARTY, 'argparse' ) ) ) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index a081720d14..67598154f6 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -26,6 +26,7 @@ import os import threading import glob +import tempfile from shutil import rmtree from subprocess import PIPE @@ -48,16 +49,6 @@ PATH_TO_JAVA = utils.PathToFirstExistingExecutable( [ 'java' ] ) -# TODO: If there are multiple instances of ycmd running, they will _share_ this -# path. I don't think (from memory) that eclipse actually supports that and -# probably aborts -WORKSPACE_PATH_BASE = os.path.join( os.path.dirname( __file__ ), - '..', - '..', - '..', - 'third_party', - 'eclipse.jdt.ls-workspace' ) - def ShouldEnableJavaCompleter(): _logger.info( 'Looking for java language server (eclipse.jdt.ls)' ) @@ -115,14 +106,12 @@ def __init__( self, user_options ): # Used to ensure that starting/stopping of the server is synchronised self._server_state_mutex = threading.RLock() + with self._server_state_mutex: self._server = None self._server_handle = None self._server_stderr = None - self._workspace_path = os.path.join( - os.path.abspath( WORKSPACE_PATH_BASE ), - str( os.getpid() ) ) - + self._workspace_path = None self._Reset() try : @@ -191,16 +180,19 @@ def _Reset( self ): utils.RemoveIfExists( self._server_stderr ) self._server_stderr = None + if self._workspace_path: + try: + rmtree( self._workspace_path ) + except OSError: + # We actually just ignore the error because there's really not much + # else we can do + _logger.exception( 'Failed to remove workspace path: {0}'.format( + self._workspace_path ) ) + + self._workspace_path = tempfile.mkdtemp() self._server_handle = None self._received_ready_message = threading.Event() - try: - rmtree( self._workspace_path ) - except OSError: - # We actually just ignore the error because on startup it won't exist - _logger.exception( 'Failed to remove workspace path: {0}'.format( - self._workspace_path ) ) - self._server = None self._ServerReset() diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 068a71300f..722c3484d6 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -39,6 +39,7 @@ _logger = logging.getLogger( __name__ ) +SERVER_LOG_PREFIX = 'Server reported: ' REQUEST_TIMEOUT_COMPLETION = 1 REQUEST_TIMEOUT_INITIALISE = 30 @@ -588,9 +589,22 @@ def _HandleNotificationInPollThread( self, notification ): def _ConvertNotificationToMessage( self, request_data, notification ): + + log_level = [ + None, # 1-based enum from LSP + logging.ERROR, + logging.WARNING, + logging.INFO, + logging.DEBUG, + ] + if notification[ 'method' ] == 'window/showMessage': return responses.BuildDisplayMessageResponse( notification[ 'params' ][ 'message' ] ) + elif notification[ 'method' ] == 'window/logMessage': + params = notification[ 'params' ] + _logger.log( log_level[ int( params[ 'type' ] ) ], + SERVER_LOG_PREFIX + params[ 'message' ] ) elif notification[ 'method' ] == 'textDocument/publishDiagnostics': params = notification[ 'params' ] uri = params[ 'uri' ] diff --git a/ycmd/tests/java/__init__.py b/ycmd/tests/java/__init__.py index 7ee70fb427..f5dd1c8293 100644 --- a/ycmd/tests/java/__init__.py +++ b/ycmd/tests/java/__init__.py @@ -130,4 +130,4 @@ def WaitUntilCompleterServerReady( app, timeout = 30 ): if app.get( '/ready', { 'subserver': filetype } ).json: return - time.sleep( 0.1 ) + time.sleep( 0.5 ) diff --git a/ycmd/tests/server_utils_test.py b/ycmd/tests/server_utils_test.py index c3555eb9e3..50a461d28c 100644 --- a/ycmd/tests/server_utils_test.py +++ b/ycmd/tests/server_utils_test.py @@ -49,8 +49,7 @@ os.path.join( DIR_OF_THIRD_PARTY, 'requests' ), os.path.join( DIR_OF_THIRD_PARTY, 'tern_runtime' ), os.path.join( DIR_OF_THIRD_PARTY, 'waitress' ), - os.path.join( DIR_OF_THIRD_PARTY, 'eclipse.jdt.ls' ), - os.path.join( DIR_OF_THIRD_PARTY, 'eclipse.jdt.ls-workspace' ), + os.path.join( DIR_OF_THIRD_PARTY, 'eclipse.jdt.ls' ) ) From abd04b0c1b3c731a6c076099ce8f58edbc03d916 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Tue, 12 Sep 2017 17:36:58 +0100 Subject: [PATCH 14/51] Actually send modify requests, as these should be better for lifecycle management - we still replace the entire file --- .../language_server_completer.py | 1 + ycmd/completers/language_server/lsapi.py | 17 ++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 722c3484d6..8806aab93e 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -657,6 +657,7 @@ def _RefreshFiles( self, request_data ): # We can't change the dictionary entries while using iterkeys, so we do # that in a separate loop. # TODO(Ben): Is this better than just not using iterkeys? + # TODO(Ben): Isn't there a client->server event when a buffer is closed? for file_name in stale_files: msg = lsapi.DidCloseTextDocument( file_name ) self.GetServer().SendNotification( msg ) diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index ee6528c7fb..371f2caf95 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -92,13 +92,16 @@ def DidOpenTextDocument( file_name, file_types, file_contents ): def DidChangeTextDocument( file_name, file_types, file_contents ): - # FIXME: The servers seem to all state they want incremental updates. It - # remains to be seen if they really do. So far, no actual server tested has - # actually required this, but it might be necessary for performance reasons in - # some cases. However, as the logic for diffing previous file versions, etc. - # is highly complex, it should only be implemented if it becomes strictly - # necessary (perhaps with client support). - return DidOpenTextDocument( file_name, file_types, file_contents ) + LAST_VERSION[ file_name ] = LAST_VERSION[ file_name ] + 1 + return BuildNotification( 'textDocument/didChange', { + 'textDocument': { + 'uri': FilePathToUri( file_name ), + 'version': LAST_VERSION[ file_name ], + }, + 'contentChanges': [ + { 'text': file_contents }, + ], + } ) def DidCloseTextDocument( file_name ): From 8511daff86802e7295021e937805a35d87fede6d Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Tue, 12 Sep 2017 21:49:46 +0100 Subject: [PATCH 15/51] Improve code clarity and organisation Rename legacy-named 'server' to 'connection' General code tidy and organisation. Document interface. Consistent use (or not) of _ prefix. Take methods out of class that don't rely on self. --- ycmd/completers/java/java_completer.py | 177 +++--- .../language_server_completer.py | 571 ++++++++++-------- ycmd/completers/language_server/lsapi.py | 2 +- 3 files changed, 416 insertions(+), 334 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 67598154f6..23990fff74 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -70,20 +70,19 @@ def ShouldEnableJavaCompleter(): def _PathToLauncherJar(): # The file name changes between version of eclipse, so we use a glob as # recommended by the language server developers. There should only be one. - # TODO: sort ? - p = glob.glob( + launcher_jars = glob.glob( os.path.abspath( os.path.join( LANGUAGE_SERVER_HOME, 'plugins', 'org.eclipse.equinox.launcher_*.jar' ) ) ) - _logger.debug( 'Found launchers: {0}'.format( p ) ) + _logger.debug( 'Found launchers: {0}'.format( launcher_jars ) ) - if not p: + if not launcher_jars: return None - return p[ 0 ] + return launcher_jars[ 0 ] def _LauncherConfiguration(): @@ -108,7 +107,7 @@ def __init__( self, user_options ): with self._server_state_mutex: - self._server = None + self._connection = None self._server_handle = None self._server_stderr = None self._workspace_path = None @@ -121,14 +120,53 @@ def __init__( self, user_options ): self._StopServer() - def GetServer( self ): - return self._server - - def SupportedFiletypes( self ): return [ 'java' ] + def GetSubcommandsMap( self ): + return { + # Handled by base class + 'GoToDeclaration': ( + lambda self, request_data, args: self.GoToDeclaration( request_data ) + ), + 'GoTo': ( + lambda self, request_data, args: self.GoToDeclaration( request_data ) + ), + 'GoToDefinition': ( + lambda self, request_data, args: self.GoToDeclaration( request_data ) + ), + 'GoToReferences': ( + lambda self, request_data, args: self.GoToReferences( request_data ) + ), + 'FixIt': ( + lambda self, request_data, args: self.CodeAction( request_data, + args ) + ), + 'RefactorRename': ( + lambda self, request_data, args: self.Rename( request_data, args ) + ), + + # Handled by us + 'RestartServer': ( + lambda self, request_data, args: self._RestartServer( + ) ), + 'StopServer': ( + lambda self, request_data, args: self._StopServer( + ) ), + 'GetDoc': ( + lambda self, request_data, args: self.GetDoc( request_data ) + ), + 'GetType': ( + lambda self, request_data, args: self.GetType( request_data ) + ), + } + + + def GetConnection( self ): + return self._connection + + def DebugInfo( self, request_data ): return responses.BuildDebugInfoResponse( name = "Java", @@ -167,11 +205,14 @@ def ServerIsReady( self ): super( JavaCompleter, self ).ServerIsReady() ) - def ShouldUseNowInner( self, request_data ): - if not self.ServerIsReady(): - return False + def _ServerIsRunning( self ): + return utils.ProcessIsRunning( self._server_handle ) - return super( JavaCompleter, self ).ShouldUseNowInner( request_data ) + + def _RestartServer( self ): + with self._server_state_mutex: + self._StopServer() + self._StartServer() def _Reset( self ): @@ -193,9 +234,9 @@ def _Reset( self ): self._server_handle = None self._received_ready_message = threading.Event() - self._server = None + self._connection = None - self._ServerReset() + self.ServerReset() def _StartServer( self ): @@ -236,24 +277,45 @@ def _StartServer( self ): _logger.warning( 'JDT Language Server failed to start' ) return - self._server = ( + self._connection = ( language_server_completer.StandardIOLanguageServerConnection( self._server_handle.stdin, self._server_handle.stdout, - self._GetDefaultNotificationHandler() ) + self.GetDefaultNotificationHandler() ) ) - self._server.start() + self._connection.start() try: - self._server.TryServerConnection() + self._connection.AwaitServerConnection() except language_server_completer.LanguageServerConnectionTimeout: _logger.warn( 'Java language server failed to start, or did not ' 'connect successfully' ) self._StopServer() return - self._SendInitialiseAsync() + self.SendInitialise() + + + def _StopServer( self ): + with self._server_state_mutex: + # We don't use utils.CloseStandardStreams, because the stdin/out is + # connected to our server connector. Just close stderr. + if self._server_handle and self._server_handle.stderr: + self._server_handle.stderr.close() + + # Tell the connection to expect the server to disconnect + if self._connection: + self._connection.stop() + + # Tell the server to exit using the shutdown request. + self._StopServerCleanly() + + # If the server is still running, e.g. due to erros, kill it + self._StopServerForecefully() + + # Tidy up our internal state + self._Reset() def _StopServerCleanly( self ): @@ -262,14 +324,14 @@ def _StopServerCleanly( self ): _logger.info( 'Stopping java server with PID {0}'.format( self._server_handle.pid ) ) - self._ShutdownServer() + self.ShutdownServer() try: utils.WaitUntilProcessIsTerminated( self._server_handle, timeout = 5 ) - if self._server: - self._server.join() + if self._connection: + self._connection.join() _logger.info( 'JDT Language server stopped' ) except Exception: @@ -287,63 +349,14 @@ def _StopServerForecefully( self ): utils.WaitUntilProcessIsTerminated( self._server_handle, timeout = 5 ) - if self._server: - self._server.join() + if self._connection: + self._connection.join() _logger.info( 'JDT Language server killed' ) except Exception: _logger.exception( 'Error while killing java server' ) - def _StopServer( self ): - with self._server_state_mutex: - # We don't use utils.CloseStandardStreams, because the stdin/out is - # connected to our server connector. Just close stderr. - if self._server_handle and self._server_handle.stderr: - self._server_handle.stderr.close() - - # Tell the connection to expect the server to disconnect - if self._server: - self._server.stop() - - # Tell the server to exit using the shutdown request. - self._StopServerCleanly() - - # If the server is still running, e.g. due to erros, kill it - self._StopServerForecefully() - - # Tidy up our internal state - self._Reset() - - - def GetSubcommandsMap( self ): - return { - 'RestartServer': ( lambda self, request_data, args: - self._RestartServer() ), - 'StopServer': ( lambda self, request_data, args: - self._StopServer() ), - - # TODO: We should be able to determine the set of things available from - # the capabilities supplied on initialise - 'GetDoc': ( lambda self, request_data, args: - self.GetDoc( request_data ) ), - 'GetType': ( lambda self, request_data, args: - self.GetType( request_data ) ), - 'GoToDeclaration': ( lambda self, request_data, args: - self._GoToDeclaration( request_data ) ), - 'GoTo': ( lambda self, request_data, args: - self._GoToDeclaration( request_data ) ), - 'GoToDefinition': ( lambda self, request_data, args: - self._GoToDeclaration( request_data ) ), - 'GoToReferences': ( lambda self, request_data, args: - self._GoToReferences( request_data ) ), - 'FixIt': ( lambda self, request_data, args: - self._CodeAction( request_data, args ) ), - 'RefactorRename': ( lambda self, request_data, args: - self._Rename( request_data, args ) ), - } - - def _HandleNotificationInPollThread( self, notification ): if notification[ 'method' ] == 'language/status': message_type = notification[ 'params' ][ 'type' ] @@ -367,7 +380,7 @@ def _ConvertNotificationToMessage( self, request_data, notification ): def GetType( self, request_data ): - hover_response = self._GetHoverResponse( request_data ) + hover_response = self.GetHoverResponse( request_data ) if isinstance( hover_response, list ): if len( hover_response ): @@ -381,7 +394,7 @@ def GetType( self, request_data ): def GetDoc( self, request_data ): - hover_response = self._GetHoverResponse( request_data ) + hover_response = self.GetHoverResponse( request_data ) if isinstance( hover_response, list ): if len( hover_response ): @@ -405,13 +418,3 @@ def HandleServerCommand( self, request_data, command ): text = command[ 'title' ] ) return None - - - def _RestartServer( self ): - with self._server_state_mutex: - self._StopServer() - self._StartServer() - - - def _ServerIsRunning( self ): - return utils.ProcessIsRunning( self._server_handle ) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 8806aab93e..6cf8bb9167 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -26,7 +26,6 @@ import abc import collections import logging -import os import queue import threading @@ -104,17 +103,70 @@ class LanguageServerConnectionStopped( Exception ): pass -class LanguageServerConnection( object ): +class LanguageServerConnection( threading.Thread ): """ Abstract language server communication object. - Implementations are required to provide the following methods: + This connection runs as a thread and is generally only used directly by + LanguageServerCompleter, but is instantiated, startd and stopped by Concrete + LanguageServerCompleter implementations. + + Implementations of this class are required to provide the following methods: - _TryServerConnectionBlocking: Connect to the server and return when the connection is established + - _Close: Close any sockets or channels prior to the thread exit - _Write: Write some data to the server - _Read: Read some data from the server, blocking until some data is available + + Using this class in concrete LanguageServerCompleter implementations: + + Startup + + - Call start() and AwaitServerConnection() + - AwaitServerConnection() throws LanguageServerConnectionTimeout if the + server fails to connect in a reasonable time. + + Shutdown + + - Call stop() prior to shutting down the downstream server (see + LanguageServerCompleter.ShutdownServer to do that part) + - Call join() after closing down the downstream server to wait for this + thread to exit + + Footnote: Why does this interface exist? + + Language servers are at liberty to provide their communication interface + over any transport. Typically, this is either stdio or a socket (though some + servers require multiple sockets). This interface abstracts the + implementation detail of the communication from the transport, allowing + concrete completers to choose the right transport according to the + downstream server (i.e. whatever works best). + + If in doubt, use the StandardIOLanguageServerConnection as that is the + simplest. Socket-based connections often require the server to connect back + to us, which can lead to complexity and possibly blocking. """ + @abc.abstractmethod + def _TryServerConnectionBlocking( self ): + pass + + + @abc.abstractmethod + def _Close( self ): + pass + + + @abc.abstractmethod + def _Write( self, data ): + pass + + + @abc.abstractmethod + def _Read( self, size=-1 ): + pass + + def __init__( self, notification_handler = None ): super( LanguageServerConnection, self ).__init__() @@ -128,6 +180,28 @@ def __init__( self, notification_handler = None ): self._notification_handler = notification_handler + def run( self ): + try: + # Wait for the connection to fully establish (this runs in the thread + # context, so we block until a connection is received or there is a + # timeout, which throws an exception) + self._TryServerConnectionBlocking() + self._connection_event.set() + + # Blocking loop which reads whole messages and calls _DespatchMessage + self._ReadMessages( ) + except LanguageServerConnectionStopped: + # Abort any outstanding requests + with self._responseMutex: + for _, response in iteritems( self._responses ): + response.Abort() + self._responses.clear() + + _logger.debug( 'Connection was closed cleanly' ) + + self._Close() + + def stop( self ): # Note lowercase stop() to match threading.Thread.start() self._stop_event.set() @@ -167,7 +241,7 @@ def SendNotification( self, message ): self._Write( message ) - def TryServerConnection( self ): + def AwaitServerConnection( self ): self._connection_event.wait( timeout = CONNECTION_TIMEOUT ) if not self._connection_event.isSet(): @@ -175,29 +249,6 @@ def TryServerConnection( self ): 'Timed out waiting for server to connect' ) - def _run_loop( self ): - # Wait for the connection to fully establish (this runs in the thread - # context, so we block until a connection is received or there is a timeout, - # which throws an exception) - try: - self._TryServerConnectionBlocking() - - self._connection_event.set() - - # Blocking loop which reads whole messages and calls _DespatchMessage - self._ReadMessages( ) - except LanguageServerConnectionStopped: - # Abort any outstanding requests - with self._responseMutex: - for _, response in iteritems( self._responses ): - response.Abort() - self._responses.clear() - - _logger.debug( 'Connection was closed cleanly' ) - - self._Close() - - def _ReadHeaders( self, data ): headers_complete = False prefix = bytes( b'' ) @@ -289,66 +340,45 @@ def _DespatchMessage( self, message ): self._notification_handler( self, message ) - @abc.abstractmethod - def _TryServerConnectionBlocking( self ): - pass - - - @abc.abstractmethod - def _Close( self ): - pass - - - @abc.abstractmethod - def _Write( self, data ): - pass - - - @abc.abstractmethod - def _Read( self, size=-1 ): - pass - +class StandardIOLanguageServerConnection( LanguageServerConnection ): + """Concrete language server connection using stdin/stdout to communicate with + the server. This should be the default choice for concrete completers.""" -class StandardIOLanguageServerConnection( LanguageServerConnection, - threading.Thread ): - def __init__( self, server_stdin, + def __init__( self, + server_stdin, server_stdout, notification_handler = None ): super( StandardIOLanguageServerConnection, self ).__init__( notification_handler ) - self.server_stdin = server_stdin - self.server_stdout = server_stdout + self._server_stdin = server_stdin + self._server_stdout = server_stdout - def run( self ): - self._run_loop() + def _TryServerConnectionBlocking( self ): + # standard in/out don't need to wait for the server to connect to us + return True def _Close( self ): - if not self.server_stdin.closed: - self.server_stdin.close() - - if not self.server_stdout.closed: - self.server_stdout.close() + if not self._server_stdin.closed: + self._server_stdin.close() - - def _TryServerConnectionBlocking( self ): - # standard in/out don't need to wait for the server to connect to us - return True + if not self._server_stdout.closed: + self._server_stdout.close() def _Write( self, data ): - to_write = data + utils.ToBytes( '\r\n' ) - self.server_stdin.write( to_write ) - self.server_stdin.flush() + bytes_to_write = data + utils.ToBytes( '\r\n' ) + self._server_stdin.write( bytes_to_write ) + self._server_stdin.flush() def _Read( self, size=-1 ): if size > -1: - data = self.server_stdout.read( size ) + data = self._server_stdout.read( size ) else: - data = self.server_stdout.readline() + data = self._server_stdout.readline() if self.IsStopped(): raise LanguageServerConnectionStopped() @@ -362,13 +392,79 @@ def _Read( self, size=-1 ): class LanguageServerCompleter( Completer ): + """ + Abstract completer implementation for Language Server Protocol. Concrete + implementations are required to: + - Handle downstream server state and create a LanguageServerConnection, + returning it in GetConnection + - Set its notification handler to self.GetDefaultNotificationHandler() + - See below for Startup/Shutdown instructions + - Implement any server-specific Commands in HandleServerCommand + - Implement the following Completer abstract methods: + - SupportedFiletypes + - DebugInfo + - Shutdown + - ServerIsHealthy : Return True if the server is _running_ + - GetSubcommandsMap + + Startup + + - After starting and connecting to the server, call SendInitialise + - See also LanguageServerConnection requirements + + Shutdown + + - Call ShutdownServer and wait for the downstream server to exit + - Call ServerReset to clear down state + - See also LanguageServerConnection requirements + + Completions + + - The implementation should not require any code to support completions + + Diagnostics + + - The implementation should not require any code to support diagnostics + + Subcommands + + - The subcommands map is bespoke to the implementation, but generally, this + class attempts to provide all of the pieces where it can generically. + - The following commands typically don't require any special handling, just + call the base implementation as below: + Subcommands -> Handler + - GoToDeclaration -> GoToDeclaration + - GoTo -> GoToDeclaration + - GoToReferences -> GoToReferences + - RefactorRename -> Rename + - GetType/GetDoc are bespoke to the downstream server, though this class + provides GetHoverResponse which is useful in this context. + - FixIt requests are handled by CodeAction, but the responses are passed to + HandleServerCommand, which must return a FixIt. See WorkspaceEditToFixIt and + TextEditToChunks for some helpers. If the server returns other types of + command that aren't FixIt, either throw an exception or update the ycmd + protocol to handle it :) + """ + @abc.abstractmethod + def GetConnection( sefl ): + """Method that must be implemented by derived classes to return an instance + of LanguageServerConnection appropriate for the language server in + question""" + pass + + + @abc.abstractmethod + def HandleServerCommand( self, request_data, command ): + pass + + def __init__( self, user_options): super( LanguageServerCompleter, self ).__init__( user_options ) self._mutex = threading.Lock() - self._ServerReset() + self.ServerReset() - def _ServerReset( self ): + def ServerReset( self ): with self._mutex: self._serverFileState = {} self._latest_diagnostics = collections.defaultdict( list ) @@ -377,15 +473,15 @@ def _ServerReset( self ): self._initialise_event = threading.Event() - def _ShutdownServer( self ): + def ShutdownServer( self ): if self.ServerIsReady(): - request_id = self.GetServer().NextRequestId() + request_id = self.GetConnection().NextRequestId() msg = lsapi.Shutdown( request_id ) try: - self.GetServer().GetResponse( request_id, - msg, - REQUEST_TIMEOUT_INITIALISE ) + self.GetConnection().GetResponse( request_id, + msg, + REQUEST_TIMEOUT_INITIALISE ) except ResponseAbortedException: # When the language server (heinously) dies handling the shutdown # request, it is aborted. Just return - we're done. @@ -395,21 +491,14 @@ def _ShutdownServer( self ): # anyway _logger.exception( 'Shutdown request failed. Ignoring.' ) - # Assuming that worked, send the exit notification if self.ServerIsHealthy(): - self.GetServer().SendNotification( lsapi.Exit() ) - - - - @abc.abstractmethod - def GetServer( sefl ): - """Method that must be implemented by derived classes to return an instance - of LanguageServerConnection appropriate for the language server in - question""" - pass + self.GetConnection().SendNotification( lsapi.Exit() ) def ServerIsReady( self ): + if not self.ServerIsHealthy(): + return False + if self._initialise_event.is_set(): # We already got the initialise response return True @@ -422,17 +511,21 @@ def ServerIsReady( self ): return False + def ShouldUseNowInner( self, request_data ): + return self.ServerIsReady() + + def ComputeCandidatesInner( self, request_data ): if not self.ServerIsReady(): return None self._RefreshFiles( request_data ) - request_id = self.GetServer().NextRequestId() + request_id = self.GetConnection().NextRequestId() msg = lsapi.Completion( request_id, request_data ) - response = self.GetServer().GetResponse( request_id, - msg, - REQUEST_TIMEOUT_COMPLETION ) + response = self.GetConnection().GetResponse( request_id, + msg, + REQUEST_TIMEOUT_COMPLETION ) do_resolve = ( 'completionProvider' in self._server_capabilities and @@ -446,11 +539,12 @@ def MakeCompletion( item ): # _at all_ here. if do_resolve: - resolve_id = self.GetServer().NextRequestId() + resolve_id = self.GetConnection().NextRequestId() resolve = lsapi.ResolveCompletion( resolve_id, item ) - response = self.GetServer().GetResponse( resolve_id, - resolve, - REQUEST_TIMEOUT_COMPLETION ) + response = self.GetConnection().GetResponse( + resolve_id, + resolve, + REQUEST_TIMEOUT_COMPLETION ) item = response[ 'result' ] # Note Vim only displays the first character, so we map them to the @@ -486,7 +580,7 @@ def MakeCompletion( item ): 'd', # 'Reference', ] - ( insertion_text, fixits ) = self._GetInsertionText( request_data, item ) + ( insertion_text, fixits ) = InsertionTextForItem( request_data, item ) return responses.BuildCompletionData( insertion_text, @@ -523,8 +617,30 @@ def OnFileReadyToParse( self, request_data ): for diag in self._latest_diagnostics[ uri ] ] + def PollForMessagesInner( self, request_data ): + # scoop up any pending messages into one big list + messages = list() + try: + while True: + if not self.GetConnection(): + # The server isn't running or something. Don't re-poll. + return False + + self._PollForMessagesNoBlock( request_data, messages ) + except queue.Empty: + # We drained the queue + pass + + # If we found some messages, return them immediately + if messages: + return messages + + # otherwise, block until we get one + return self._PollForMessagesBlock( request_data ) + + def _PollForMessagesNoBlock( self, request_data, messages ): - notification = self.GetServer()._notifications.get_nowait( ) + notification = self.GetConnection()._notifications.get_nowait( ) message = self._ConvertNotificationToMessage( request_data, notification ) if message: @@ -534,12 +650,12 @@ def _PollForMessagesNoBlock( self, request_data, messages ): def _PollForMessagesBlock( self, request_data ): try: while True: - if not self.GetServer(): + if not self.GetConnection(): # The server isn't running or something. Don't re-poll, as this will # just cause errors. return False - notification = self.GetServer()._notifications.get( + notification = self.GetConnection()._notifications.get( timeout = MESSAGE_POLL_TIMEOUT ) message = self._ConvertNotificationToMessage( request_data, notification ) @@ -549,30 +665,7 @@ def _PollForMessagesBlock( self, request_data ): return True - def PollForMessagesInner( self, request_data ): - - # scoop up any pending messages into one big list - messages = list() - try: - while True: - if not self.GetServer(): - # The server isn't running or something. Don't re-poll. - return False - - self._PollForMessagesNoBlock( request_data, messages ) - except queue.Empty: - # We drained the queue - pass - - # If we found some messages, return them immediately - if messages: - return messages - - # otherwise, block until we get one - return self._PollForMessagesBlock( request_data ) - - - def _GetDefaultNotificationHandler( self ): + def GetDefaultNotificationHandler( self ): def handler( server, notification ): self._HandleNotificationInPollThread( notification ) return handler @@ -589,19 +682,18 @@ def _HandleNotificationInPollThread( self, notification ): def _ConvertNotificationToMessage( self, request_data, notification ): - - log_level = [ - None, # 1-based enum from LSP - logging.ERROR, - logging.WARNING, - logging.INFO, - logging.DEBUG, - ] - if notification[ 'method' ] == 'window/showMessage': return responses.BuildDisplayMessageResponse( notification[ 'params' ][ 'message' ] ) elif notification[ 'method' ] == 'window/logMessage': + log_level = [ + None, # 1-based enum from LSP + logging.ERROR, + logging.WARNING, + logging.INFO, + logging.DEBUG, + ] + params = notification[ 'params' ] _logger.log( log_level[ int( params[ 'type' ] ) ], SERVER_LOG_PREFIX + params[ 'message' ] ) @@ -620,8 +712,6 @@ def _ConvertNotificationToMessage( self, request_data, notification ): def _RefreshFiles( self, request_data ): - # FIXME: Provide a Reset method which clears this state. Restarting - # downstream servers would leave this cache in the incorrect state. with self._mutex: for file_name, file_data in iteritems( request_data[ 'file_data' ] ): file_state = 'New' @@ -647,7 +737,7 @@ def _RefreshFiles( self, request_data ): file_data[ 'contents' ] ) self._serverFileState[ file_name ] = 'Open' - self.GetServer().SendNotification( msg ) + self.GetConnection().SendNotification( msg ) stale_files = list() for file_name in iterkeys( self._serverFileState ): @@ -656,19 +746,18 @@ def _RefreshFiles( self, request_data ): # We can't change the dictionary entries while using iterkeys, so we do # that in a separate loop. - # TODO(Ben): Is this better than just not using iterkeys? # TODO(Ben): Isn't there a client->server event when a buffer is closed? for file_name in stale_files: msg = lsapi.DidCloseTextDocument( file_name ) - self.GetServer().SendNotification( msg ) + self.GetConnection().SendNotification( msg ) del self._serverFileState[ file_name ] - def _SendInitialiseAsync( self ): + def SendInitialise( self ): with self._mutex: assert not self._initialise_response - request_id = self.GetServer().NextRequestId() + request_id = self.GetConnection().NextRequestId() msg = lsapi.Initialise( request_id ) def response_handler( response, message ): @@ -677,7 +766,7 @@ def response_handler( response, message ): self._HandleInitialiseInPollThread( message ) - self._initialise_response = self.GetServer().GetResponseAsync( + self._initialise_response = self.GetConnection().GetResponseAsync( request_id, msg, response_handler ) @@ -702,12 +791,12 @@ def _HandleInitialiseInPollThread( self, response ): self._initialise_event.set() - def _GetHoverResponse( self, request_data ): + def GetHoverResponse( self, request_data ): if not self.ServerIsReady(): raise RuntimeError( 'Server is initialising. Please wait.' ) - request_id = self.GetServer().NextRequestId() - response = self.GetServer().GetResponse( + request_id = self.GetConnection().NextRequestId() + response = self.GetConnection().GetResponse( request_id, lsapi.Hover( request_id, request_data ), @@ -716,57 +805,40 @@ def _GetHoverResponse( self, request_data ): return response[ 'result' ][ 'contents' ] - def LocationListToGoTo( self, request_data, response ): - if not response: - raise RuntimeError( 'Cannot jump to location' ) - - if len( response[ 'result' ] ) > 1: - positions = response[ 'result' ] - return [ - responses.BuildGoToResponseFromLocation( - _PositionToLocation( request_data, - position ) ) for position in positions - ] - else: - position = response[ 'result' ][ 0 ] - return responses.BuildGoToResponseFromLocation( - _PositionToLocation( request_data, position ) ) - - - def _GoToDeclaration( self, request_data ): + def GoToDeclaration( self, request_data ): if not self.ServerIsReady(): raise RuntimeError( 'Server is initialising. Please wait.' ) - request_id = self.GetServer().NextRequestId() - response = self.GetServer().GetResponse( + request_id = self.GetConnection().NextRequestId() + response = self.GetConnection().GetResponse( request_id, lsapi.Definition( request_id, request_data ), REQUEST_TIMEOUT_COMMAND ) if isinstance( response[ 'result' ], list ): - return self.LocationListToGoTo( request_data, response ) + return LocationListToGoTo( request_data, response ) else: position = response[ 'result' ] return responses.BuildGoToResponseFromLocation( - _PositionToLocation( request_data, position ) ) + PositionToLocation( request_data, position ) ) - def _GoToReferences( self, request_data ): + def GoToReferences( self, request_data ): if not self.ServerIsReady(): raise RuntimeError( 'Server is initialising. Please wait.' ) - request_id = self.GetServer().NextRequestId() - response = self.GetServer().GetResponse( + request_id = self.GetConnection().NextRequestId() + response = self.GetConnection().GetResponse( request_id, lsapi.References( request_id, request_data ), REQUEST_TIMEOUT_COMMAND ) - return self.LocationListToGoTo( request_data, response ) + return LocationListToGoTo( request_data, response ) - def _CodeAction( self, request_data, args ): + def CodeAction( self, request_data, args ): if not self.ServerIsReady(): raise RuntimeError( 'Server is initialising. Please wait.' ) @@ -792,9 +864,9 @@ def WithinRange( diag ): d for d in file_diagnostics if WithinRange( d ) ] - request_id = self.GetServer().NextRequestId() + request_id = self.GetConnection().NextRequestId() if matched_diagnostics: - code_actions = self.GetServer().GetResponse( + code_actions = self.GetConnection().GetResponse( request_id, lsapi.CodeAction( request_id, request_data, @@ -803,7 +875,7 @@ def WithinRange( diag ): REQUEST_TIMEOUT_COMMAND ) else: - code_actions = self.GetServer().GetResponse( + code_actions = self.GetConnection().GetResponse( request_id, lsapi.CodeAction( request_id, @@ -830,13 +902,7 @@ def WithinRange( diag ): return responses.BuildFixItResponse( [ r for r in response if r ] ) - @abc.abstractmethod - def HandleServerCommand( self, request_data, command ): - _logger.debug( 'What is going on?' ) - return None - - - def _Rename( self, request_data, args ): + def Rename( self, request_data, args ): if not self.ServerIsReady(): raise RuntimeError( 'Server is initialising. Please wait.' ) @@ -846,8 +912,8 @@ def _Rename( self, request_data, args ): new_name = args[ 0 ] - request_id = self.GetServer().NextRequestId() - response = self.GetServer().GetResponse( + request_id = self.GetConnection().NextRequestId() + response = self.GetConnection().GetResponse( request_id, lsapi.Rename( request_id, request_data, @@ -858,76 +924,90 @@ def _Rename( self, request_data, args ): [ WorkspaceEditToFixIt( request_data, response[ 'result' ] ) ] ) - def _GetInsertionText( self, request_data, item ): - # TODO: We probably need to implement this and (at least) strip out the - # snippet parts? - INSERT_TEXT_FORMAT = [ - None, # 1-based - 'PlainText', - 'Snippet' - ] - - fixits = None +def InsertionTextForItem( request_data, item ): + INSERT_TEXT_FORMAT = [ + None, # 1-based + 'PlainText', + 'Snippet' + ] - # We will alwyas have one of insertText or label - if 'insertText' in item and item[ 'insertText' ]: - insertion_text = item[ 'insertText' ] + fixits = None + + # We will alwyas have one of insertText or label + if 'insertText' in item and item[ 'insertText' ]: + insertion_text = item[ 'insertText' ] + else: + insertion_text = item[ 'label' ] + + # Per the protocol, textEdit takes precedence over insertText, and must be + # on the same line (and containing) the originally requested position + if 'textEdit' in item and item[ 'textEdit' ]: + new_range = item[ 'textEdit' ][ 'range' ] + additional_text_edits = [] + + if ( new_range[ 'start' ][ 'line' ] != new_range[ 'end' ][ 'line' ] or + new_range[ 'start' ][ 'line' ] + 1 != request_data[ 'line_num' ] ): + # We can't support completions that span lines. The protocol forbids it + raise ValueError( 'Invalid textEdit supplied. Must be on a single ' + 'line' ) + elif '\n' in item[ 'textEdit' ][ 'newText' ]: + # The insertion text contains newlines. This is tricky: most clients + # (i.e. Vim) won't support this. So we cheat. Set the insertable text to + # the simple text, and put and additionalTextEdit instead. We manipulate + # the real textEdit so that it replaces the inserted text with the real + # textEdit. + fixup_textedit = dict( item[ 'textEdit' ] ) + fixup_textedit[ 'range' ][ 'end' ][ 'character' ] = ( + fixup_textedit[ 'range' ][ 'end' ][ 'character' ] + len( + insertion_text ) ) + additional_text_edits.append( fixup_textedit ) else: - insertion_text = item[ 'label' ] - - # Per the protocol, textEdit takes precedence over insertText, and must be - # on the same line (and containing) the originally requested position - if 'textEdit' in item and item[ 'textEdit' ]: - new_range = item[ 'textEdit' ][ 'range' ] - additional_text_edits = [] - - if ( new_range[ 'start' ][ 'line' ] != new_range[ 'end' ][ 'line' ] or - new_range[ 'start' ][ 'line' ] + 1 != request_data[ 'line_num' ] ): - # We can't support completions that span lines. The protocol forbids it - raise ValueError( 'Invalid textEdit supplied. Must be on a single ' - 'line' ) - elif '\n' in item[ 'textEdit' ][ 'newText' ]: - # The insertion text contains newlines. This is tricky: most clients - # (i.e. Vim) won't support this. So we cheat. Set the insertable text to - # the simple text, and put and additionalTextEdit instead. We manipulate - # the real textEdit so that it replaces the inserted text with the real - # textEdit. - fixup_textedit = dict( item[ 'textEdit' ] ) - fixup_textedit[ 'range' ][ 'end' ][ 'character' ] = ( - fixup_textedit[ 'range' ][ 'end' ][ 'character' ] + len( - insertion_text ) ) - additional_text_edits.append( fixup_textedit ) - else: - request_data[ 'start_codepoint' ] = ( - new_range[ 'start' ][ 'character' ] + 1 ) - insertion_text = item[ 'textEdit' ][ 'newText' ] - - additional_text_edits.extend( item.get( 'additionalTextEdits', [] ) ) - - if additional_text_edits: - chunks = [ responses.FixItChunk( e[ 'newText' ], - BuildRange( request_data, - request_data[ 'filepath' ], - e[ 'range' ] ) ) - for e in additional_text_edits ] - - fixits = responses.BuildFixItResponse( - [ responses.FixIt( chunks[ 0].range.start_, chunks ) ] ) - - - if 'insertTextFormat' in item and item[ 'insertTextFormat' ]: - text_format = INSERT_TEXT_FORMAT[ item[ 'insertTextFormat' ] ] - else: - text_format = 'PlainText' + request_data[ 'start_codepoint' ] = ( + new_range[ 'start' ][ 'character' ] + 1 ) + insertion_text = item[ 'textEdit' ][ 'newText' ] + + additional_text_edits.extend( item.get( 'additionalTextEdits', [] ) ) + + if additional_text_edits: + chunks = [ responses.FixItChunk( e[ 'newText' ], + BuildRange( request_data, + request_data[ 'filepath' ], + e[ 'range' ] ) ) + for e in additional_text_edits ] - if text_format != 'PlainText': - raise ValueError( 'Snippet completions are not supported and should not' - ' be returned by the language server.' ) + fixits = responses.BuildFixItResponse( + [ responses.FixIt( chunks[ 0].range.start_, chunks ) ] ) - return ( insertion_text, fixits ) + if 'insertTextFormat' in item and item[ 'insertTextFormat' ]: + text_format = INSERT_TEXT_FORMAT[ item[ 'insertTextFormat' ] ] + else: + text_format = 'PlainText' + + if text_format != 'PlainText': + raise ValueError( 'Snippet completions are not supported and should not' + ' be returned by the language server.' ) + + return ( insertion_text, fixits ) + + +def LocationListToGoTo( request_data, response ): + if not response: + raise RuntimeError( 'Cannot jump to location' ) + + if len( response[ 'result' ] ) > 1: + positions = response[ 'result' ] + return [ + responses.BuildGoToResponseFromLocation( + PositionToLocation( request_data, + position ) ) for position in positions + ] + else: + position = response[ 'result' ][ 0 ] + return responses.BuildGoToResponseFromLocation( + PositionToLocation( request_data, position ) ) -def _PositionToLocation( request_data, position ): +def PositionToLocation( request_data, position ): return BuildLocation( request_data, lsapi.UriToFilePath( position[ 'uri' ] ), position[ 'range' ][ 'start' ] ) @@ -939,8 +1019,7 @@ def BuildLocation( request_data, filename, loc ): line = loc[ 'line' ] + 1, column = utils.CodepointOffsetToByteOffset( line_contents, loc[ 'character' ] + 1 ), - # FIXME: Does realpath break symlinks? - filename = os.path.realpath( filename ) ) + filename = filename ) def BuildRange( request_data, filename, r ): diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index 371f2caf95..926d9c77e0 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -189,7 +189,7 @@ def FilePathToUri( file_name ): def UriToFilePath( uri ): # NOTE: This assumes the URI starts with file: - return os.path.normpath( url2pathname( uri[ 5 : ] ) ) + return os.path.abspath( url2pathname( uri[ 5 : ] ) ) def _BuildMessageData( message ): From 9ddcd1293844cf9a47f5b0fd7af2e6395c9c0222 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Tue, 12 Sep 2017 23:03:42 +0100 Subject: [PATCH 16/51] Improve workspace handling and test stability Run the startup/shutdown tests in clean directory to prevent java completer from scanning itself and timing out Reinstate clean workspace, but provide option for reuse Don't create unused and uncleaned temporary directories on Reset Use the project directory calculated by the implementation --- build.py | 7 + ycmd/completers/java/java_completer.py | 123 +++++++++++++++--- .../language_server_completer.py | 6 +- ycmd/completers/language_server/lsapi.py | 18 ++- ycmd/default_settings.json | 3 +- ycmd/tests/client_test.py | 9 ++ ycmd/tests/server_utils_test.py | 3 +- 7 files changed, 135 insertions(+), 34 deletions(-) diff --git a/build.py b/build.py index 3b6eb9d926..a24140b222 100755 --- a/build.py +++ b/build.py @@ -30,7 +30,14 @@ DIR_OF_THIS_SCRIPT = p.dirname( p.abspath( __file__ ) ) DIR_OF_THIRD_PARTY = p.join( DIR_OF_THIS_SCRIPT, 'third_party' ) +POSSIBLY_EMPTY_THIRD_PARTY_DIRS = [ + 'eclipse.jdt.ls-workspace' +] + for folder in os.listdir( DIR_OF_THIRD_PARTY ): + if folder in POSSIBLY_EMPTY_THIRD_PARTY_DIRS: + continue + abs_folder_path = p.join( DIR_OF_THIRD_PARTY, folder ) if p.isdir( abs_folder_path ) and not os.listdir( abs_folder_path ): sys.exit( diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 23990fff74..d7f3d7c6d3 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -22,17 +22,16 @@ # Not installing aliases from python-future; it's unreliable and slow. from builtins import * # noqa +import glob +import hashlib import logging import os -import threading -import glob +import shutil import tempfile - -from shutil import rmtree +import threading from subprocess import PIPE from ycmd import ( utils, responses ) - from ycmd.completers.language_server import language_server_completer _logger = logging.getLogger( __name__ ) @@ -49,6 +48,40 @@ PATH_TO_JAVA = utils.PathToFirstExistingExecutable( [ 'java' ] ) +PROJECT_FILE_TAILS = [ + '.project', + 'pom.xml', + 'build.gradle' +] + +WORKSPACE_ROOT_PATH = os.path.join( os.path.dirname( __file__ ), + '..', + '..', + '..', + 'third_party', + 'eclipse.jdt.ls-workspace' ) + +# The authors of jdt.ls say that we should re-use workspaces. They also say that +# occasionally, the workspace becomes corrupt, and has to be deleted. This is +# frustrating. +# +# Pros for re-use: +# - Startup time is significantly improved. This could be very meaningful on +# larger projects +# +# Cons: +# - A little more complexity (we has the project path to create the workspace +# dir) +# - It breaks our tests which expect the logs to be deleted +# - It can lead to multiple jdt.js instances using the same workspace (BAD) +# - It breaks our tests which do exactly that +# +# So: +# - By _default_ we use a clean workspace (see default_settings.json) on each +# Vim instance +# - An option is available to re-use workspaces +CLEAN_WORKSPACE_OPTION = 'java_jdtls_use_clean_workspace' + def ShouldEnableJavaCompleter(): _logger.info( 'Looking for java language server (eclipse.jdt.ls)' ) @@ -96,11 +129,42 @@ def _LauncherConfiguration(): return os.path.abspath( os.path.join( LANGUAGE_SERVER_HOME, config ) ) +def _MakeProjectFilesForPath( path ): + for tail in PROJECT_FILE_TAILS: + yield os.path.join( path, tail ) + + +def _FindProjectDir( starting_dir ): + for path in utils.PathsToAllParentFolders( starting_dir ): + for project_file in _MakeProjectFilesForPath( path ): + if os.path.isfile( project_file ): + return path + + return starting_dir + + +def _WorkspaceDirForProject( project_dir, use_clean_workspace ): + if use_clean_workspace: + temp_path = os.path.join( WORKSPACE_ROOT_PATH, 'temp' ) + + try: + os.makedirs( temp_path ) + except OSError: + pass + + return tempfile.mkdtemp( dir=temp_path ) + + project_dir_hash = hashlib.sha256( utils.ToBytes( project_dir ) ) + return os.path.join( WORKSPACE_ROOT_PATH, + utils.ToUnicode( project_dir_hash.hexdigest() ) ) + + class JavaCompleter( language_server_completer.LanguageServerCompleter ): def __init__( self, user_options ): super( JavaCompleter, self ).__init__( user_options ) self._server_keep_logfiles = user_options[ 'server_keep_logfiles' ] + self._use_clean_workspace = user_options[ CLEAN_WORKSPACE_OPTION ] # Used to ensure that starting/stopping of the server is synchronised self._server_state_mutex = threading.RLock() @@ -168,24 +232,31 @@ def GetConnection( self ): def DebugInfo( self, request_data ): + items = [ + responses.DebugInfoItem( 'Java Path', PATH_TO_JAVA ), + responses.DebugInfoItem( 'Launcher Config.', self._launcher_config ), + ] + + if self._project_dir: + items.append( responses.DebugInfoItem( 'Project Directory', + self._project_dir ) ) + + if self._workspace_path: + items.append( responses.DebugInfoItem( 'Workspace Path', + self._workspace_path ) ) return responses.BuildDebugInfoResponse( name = "Java", servers = [ responses.DebugInfoServer( name = "Java Language Server", handle = self._server_handle, - executable = LANGUAGE_SERVER_HOME, + executable = self._launcher_path, logfiles = [ self._server_stderr, os.path.join( self._workspace_path, '.metadata', '.log' ) ] ) ], - items = [ - responses.DebugInfoItem( 'Workspace Path', self._workspace_path ), - responses.DebugInfoItem( 'Java Path', PATH_TO_JAVA ), - responses.DebugInfoItem( 'jdt.ls Path', _PathToLauncherJar() ), - responses.DebugInfoItem( 'Launcher Config.', _LauncherConfiguration() ), - ] ) + items = items ) def Shutdown( self ): @@ -205,6 +276,10 @@ def ServerIsReady( self ): super( JavaCompleter, self ).ServerIsReady() ) + def _GetProjectDirectory( self ): + return self._project_dir + + def _ServerIsRunning( self ): return utils.ProcessIsRunning( self._server_handle ) @@ -221,19 +296,20 @@ def _Reset( self ): utils.RemoveIfExists( self._server_stderr ) self._server_stderr = None - if self._workspace_path: + if self._workspace_path and self._use_clean_workspace: try: - rmtree( self._workspace_path ) + shutil.rmtree( self._workspace_path ) except OSError: - # We actually just ignore the error because there's really not much - # else we can do - _logger.exception( 'Failed to remove workspace path: {0}'.format( + _logger.exception( 'Failed to clean up workspace dir {0}'.format( self._workspace_path ) ) - self._workspace_path = tempfile.mkdtemp() - self._server_handle = None + self._launcher_path = _PathToLauncherJar() + self._launcher_config = _LauncherConfiguration() + self._workspace_path = None + self._project_dir = None self._received_ready_message = threading.Event() + self._server_handle = None self._connection = None self.ServerReset() @@ -243,6 +319,11 @@ def _StartServer( self ): with self._server_state_mutex: _logger.info( 'Starting JDT Language Server...' ) + self._project_dir = _FindProjectDir( utils.GetCurrentDirectory() ) + self._workspace_path = _WorkspaceDirForProject( + self._project_dir, + self._use_clean_workspace ) + command = [ PATH_TO_JAVA, '-Declipse.application=org.eclipse.jdt.ls.core.id1', @@ -250,9 +331,9 @@ def _StartServer( self ): '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Dlog.level=ALL', '-jar', - _PathToLauncherJar(), + self._launcher_path, '-configuration', - _LauncherConfiguration(), + self._launcher_config, '-data', self._workspace_path ] diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 6cf8bb9167..dee30d9f26 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -753,12 +753,16 @@ def _RefreshFiles( self, request_data ): del self._serverFileState[ file_name ] + def _GetProjectDirectory( self ): + return utils.GetCurrentDirectory() + + def SendInitialise( self ): with self._mutex: assert not self._initialise_response request_id = self.GetConnection().NextRequestId() - msg = lsapi.Initialise( request_id ) + msg = lsapi.Initialise( request_id, self._GetProjectDirectory() ) def response_handler( response, message ): if message is None: diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index 926d9c77e0..ff14d4b0a2 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -26,8 +26,11 @@ import json from collections import defaultdict -from ycmd.utils import ( pathname2url, ToBytes, ToUnicode, url2pathname, - urljoin, GetCurrentDirectory ) +from ycmd.utils import ( pathname2url, + ToBytes, + ToUnicode, + url2pathname, + urljoin ) # FIXME: We might need a whole document management system eventually. For now, @@ -55,13 +58,9 @@ def BuildNotification( method, parameters ): } ) -def Initialise( request_id ): +def Initialise( request_id, project_directory ): """Build the Language Server initialise request""" - # FIXME: We actually need the project_directory passed in, e.g. from the - # request_data. For now, just get the current working directory of the server - project_directory = GetCurrentDirectory() - return BuildRequest( request_id, 'initialize', { 'processId': os.getpid(), 'rootPath': project_directory, @@ -200,11 +199,10 @@ def _BuildMessageData( message ): data = ToBytes( json.dumps( message, sort_keys=True ) ) packet = ToBytes( 'Content-Length: {0}\r\n' 'Content-Type: application/vscode-jsonrpc;charset=utf8\r\n' - '\r\n' - .format( len(data) ) ) + data + '\r\n'.format( len(data) ) ) + data return packet def Parse( data ): - """Reads the raw language server data |data| into a Python dictionary""" + """Reads the raw language server message payload into a Python dictionary""" return json.loads( ToUnicode( data ) ) diff --git a/ycmd/default_settings.json b/ycmd/default_settings.json index f7f0692faa..d6ece45bb3 100644 --- a/ycmd/default_settings.json +++ b/ycmd/default_settings.json @@ -44,5 +44,6 @@ "godef_binary_path": "", "rust_src_path": "", "racerd_binary_path": "", - "python_binary_path": "" + "python_binary_path": "", + "java_jdtls_use_clean_workspace": 1 } diff --git a/ycmd/tests/client_test.py b/ycmd/tests/client_test.py index 8f793037a6..7c73b698a2 100644 --- a/ycmd/tests/client_test.py +++ b/ycmd/tests/client_test.py @@ -32,8 +32,10 @@ import psutil import requests import subprocess +import shutil import sys import time +import tempfile from ycmd.hmac_utils import CreateHmac, CreateRequestHmac, SecureBytesEqual from ycmd.tests import PathToTestFile @@ -69,6 +71,10 @@ def setUp( self ): self._options_dict[ 'hmac_secret' ] = ToUnicode( b64encode( self._hmac_secret ) ) + self._orig_working_dir = os.getcwd() + self._working_dir = tempfile.mkdtemp() + os.chdir( self._working_dir ) + def tearDown( self ): for server in self._servers: @@ -78,6 +84,9 @@ def tearDown( self ): for logfile in self._logfiles: RemoveIfExists( logfile ) + os.chdir( self._orig_working_dir ) + shutil.rmtree( self._working_dir ) + def Start( self, idle_suicide_seconds = 60, check_interval_seconds = 60 * 10 ): diff --git a/ycmd/tests/server_utils_test.py b/ycmd/tests/server_utils_test.py index 50a461d28c..c3555eb9e3 100644 --- a/ycmd/tests/server_utils_test.py +++ b/ycmd/tests/server_utils_test.py @@ -49,7 +49,8 @@ os.path.join( DIR_OF_THIRD_PARTY, 'requests' ), os.path.join( DIR_OF_THIRD_PARTY, 'tern_runtime' ), os.path.join( DIR_OF_THIRD_PARTY, 'waitress' ), - os.path.join( DIR_OF_THIRD_PARTY, 'eclipse.jdt.ls' ) + os.path.join( DIR_OF_THIRD_PARTY, 'eclipse.jdt.ls' ), + os.path.join( DIR_OF_THIRD_PARTY, 'eclipse.jdt.ls-workspace' ), ) From 435bd5cec130cfa13928c77c3cc6733ff6724842 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Wed, 13 Sep 2017 19:02:29 +0100 Subject: [PATCH 17/51] Put the extra data in the completer object, not the server one --- ycmd/completers/java/java_completer.py | 7 ++++--- ycmd/tests/java/debug_info_test.py | 12 +++++++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index d7f3d7c6d3..155e8925fa 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -254,9 +254,10 @@ def DebugInfo( self, request_data ): logfiles = [ self._server_stderr, os.path.join( self._workspace_path, '.metadata', '.log' ) - ] ) - ], - items = items ) + ], + extras = items + ) + ] ) def Shutdown( self ): diff --git a/ycmd/tests/java/debug_info_test.py b/ycmd/tests/java/debug_info_test.py index cd72a5d75f..6441e65d2f 100644 --- a/ycmd/tests/java/debug_info_test.py +++ b/ycmd/tests/java/debug_info_test.py @@ -45,7 +45,17 @@ def DebugInfo_test( app ): 'executable': instance_of( str ), 'pid': instance_of( int ), 'logfiles': contains( instance_of( str ), - instance_of( str ) ) + instance_of( str ) ), + 'extras': contains( + has_entries( { 'key': 'Java Path', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Launcher Config.', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Project Directory', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Workspace Path', + 'value': instance_of( str ) } ) + ) } ) ) } ) ) ) From 5fddce34a2f0f7cfc965202c2dd40bf2c1ff6757 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Wed, 13 Sep 2017 23:19:45 +0100 Subject: [PATCH 18/51] More tests and test fixes Add missing test dir Fix subcommands offsets fix diagnostics test --- .../language_server_completer.py | 79 ++++++------ ycmd/completers/language_server/lsapi.py | 2 +- ycmd/tests/java/diagnostics_test.py | 35 +++++- ycmd/tests/java/get_completions_test.py | 114 +++++++++++++++--- ycmd/tests/java/subcommands_test.py | 26 ++-- .../src/com/test/TestLauncher.java | 24 +++- .../src/com/youcompleteme/Test.java | 5 + .../src/com/youcompleteme/testing/Tset.java | 12 ++ 8 files changed, 215 insertions(+), 82 deletions(-) create mode 100644 ycmd/tests/java/testdata/simple_eclipse_project/src/com/youcompleteme/Test.java create mode 100644 ycmd/tests/java/testdata/simple_eclipse_project/src/com/youcompleteme/testing/Tset.java diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index dee30d9f26..6be428bcb5 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -40,7 +40,7 @@ SERVER_LOG_PREFIX = 'Server reported: ' -REQUEST_TIMEOUT_COMPLETION = 1 +REQUEST_TIMEOUT_COMPLETION = 5 REQUEST_TIMEOUT_INITIALISE = 30 REQUEST_TIMEOUT_COMMAND = 30 CONNECTION_TIMEOUT = 5 @@ -539,13 +539,17 @@ def MakeCompletion( item ): # _at all_ here. if do_resolve: - resolve_id = self.GetConnection().NextRequestId() - resolve = lsapi.ResolveCompletion( resolve_id, item ) - response = self.GetConnection().GetResponse( - resolve_id, - resolve, - REQUEST_TIMEOUT_COMPLETION ) - item = response[ 'result' ] + try: + resolve_id = self.GetConnection().NextRequestId() + resolve = lsapi.ResolveCompletion( resolve_id, item ) + response = self.GetConnection().GetResponse( + resolve_id, + resolve, + REQUEST_TIMEOUT_COMPLETION ) + item = response[ 'result' ] + except ResponseFailedException: + _logger.exception( 'A completion item could not be resolved. Using ' + 'basic data.' ) # Note Vim only displays the first character, so we map them to the # documented Vim kinds: @@ -943,44 +947,33 @@ def InsertionTextForItem( request_data, item ): else: insertion_text = item[ 'label' ] + additional_text_edits = [] + # Per the protocol, textEdit takes precedence over insertText, and must be # on the same line (and containing) the originally requested position if 'textEdit' in item and item[ 'textEdit' ]: - new_range = item[ 'textEdit' ][ 'range' ] - additional_text_edits = [] - - if ( new_range[ 'start' ][ 'line' ] != new_range[ 'end' ][ 'line' ] or - new_range[ 'start' ][ 'line' ] + 1 != request_data[ 'line_num' ] ): - # We can't support completions that span lines. The protocol forbids it - raise ValueError( 'Invalid textEdit supplied. Must be on a single ' - 'line' ) - elif '\n' in item[ 'textEdit' ][ 'newText' ]: - # The insertion text contains newlines. This is tricky: most clients - # (i.e. Vim) won't support this. So we cheat. Set the insertable text to - # the simple text, and put and additionalTextEdit instead. We manipulate - # the real textEdit so that it replaces the inserted text with the real - # textEdit. - fixup_textedit = dict( item[ 'textEdit' ] ) - fixup_textedit[ 'range' ][ 'end' ][ 'character' ] = ( - fixup_textedit[ 'range' ][ 'end' ][ 'character' ] + len( - insertion_text ) ) - additional_text_edits.append( fixup_textedit ) - else: - request_data[ 'start_codepoint' ] = ( - new_range[ 'start' ][ 'character' ] + 1 ) - insertion_text = item[ 'textEdit' ][ 'newText' ] - - additional_text_edits.extend( item.get( 'additionalTextEdits', [] ) ) - - if additional_text_edits: - chunks = [ responses.FixItChunk( e[ 'newText' ], - BuildRange( request_data, - request_data[ 'filepath' ], - e[ 'range' ] ) ) - for e in additional_text_edits ] - - fixits = responses.BuildFixItResponse( - [ responses.FixIt( chunks[ 0].range.start_, chunks ) ] ) + # The insertion text contains newlines or starts before the start + # position. This is tricky: most clients (i.e. Vim) won't support this. So + # we cheat. Set the insertable text to the simple text, and put and + # additionalTextEdit instead. We manipulate the real textEdit so that it + # replaces the inserted text with the real textEdit. + fixup_textedit = dict( item[ 'textEdit' ] ) + fixup_textedit[ 'range' ][ 'end' ][ 'character' ] = ( + fixup_textedit[ 'range' ][ 'end' ][ 'character' ] + len( + insertion_text ) ) + additional_text_edits.append( fixup_textedit ) + + additional_text_edits.extend( item.get( 'additionalTextEdits', [] ) ) + + if additional_text_edits: + chunks = [ responses.FixItChunk( e[ 'newText' ], + BuildRange( request_data, + request_data[ 'filepath' ], + e[ 'range' ] ) ) + for e in additional_text_edits ] + + fixits = responses.BuildFixItResponse( + [ responses.FixIt( chunks[ 0].range.start_, chunks ) ] ) if 'insertTextFormat' in item and item[ 'insertTextFormat' ]: text_format = INSERT_TEXT_FORMAT[ item[ 'insertTextFormat' ] ] diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index ff14d4b0a2..7602289dc1 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -178,7 +178,7 @@ def Position( request_data ): # The API requires 0-based unicode offsets. return { 'line': request_data[ 'line_num' ] - 1, - 'character': request_data[ 'start_column' ] - 1, + 'character': request_data[ 'start_codepoint' ] - 1, } diff --git a/ycmd/tests/java/diagnostics_test.py b/ycmd/tests/java/diagnostics_test.py index 4821010955..880e30141c 100644 --- a/ycmd/tests/java/diagnostics_test.py +++ b/ycmd/tests/java/diagnostics_test.py @@ -23,7 +23,7 @@ from builtins import * # noqa from future.utils import iterkeys -from hamcrest import ( assert_that, contains, has_entries ) +from hamcrest import ( assert_that, contains, contains_inanyorder, has_entries ) from nose.tools import eq_ from ycmd.tests.java import ( DEFAULT_PROJECT_DIR, @@ -63,7 +63,7 @@ def ProjectPath( *args ): DIAG_MATCHERS_PER_FILE = { - ProjectPath( 'TestFactory.java' ): contains( + ProjectPath( 'TestFactory.java' ): contains_inanyorder( has_entries( { 'kind': 'WARNING', 'text': 'The value of the field TestFactory.Bar.testString is not used', @@ -97,7 +97,7 @@ def ProjectPath( *args ): 'fixit_available': False } ), ), - ProjectPath( 'TestWidgetImpl.java' ): contains( + ProjectPath( 'TestWidgetImpl.java' ): contains_inanyorder( has_entries( { 'kind': 'WARNING', 'text': 'The value of the local variable a is not used', @@ -107,6 +107,35 @@ def ProjectPath( *args ): 'fixit_available': False } ), ), + ProjectPath( 'TestLauncher.java' ): contains_inanyorder ( + has_entries( { + 'kind': 'ERROR', + 'text': 'The type new TestLauncher.Launchable(){} must implement the ' + 'inherited abstract method TestLauncher.Launchable.launch(' + 'TestFactory)', + 'location': PositionMatch( 21, 16 ), + 'location_extent': RangeMatch( ( 21, 16 ), ( 21, 28 ) ), + 'ranges': contains( RangeMatch( ( 21, 16 ), ( 21, 28 ) ) ), + 'fixit_available': False + } ), + has_entries( { + 'kind': 'ERROR', + 'text': 'The method launch() of type new TestLauncher.Launchable(){} ' + 'must override or implement a supertype method', + 'location': PositionMatch( 23, 19 ), + 'location_extent': RangeMatch( ( 23, 19 ), ( 23, 27 ) ), + 'ranges': contains( RangeMatch( ( 23, 19 ), ( 23, 27 ) ) ), + 'fixit_available': False + } ), + has_entries( { + 'kind': 'ERROR', + 'text': 'Cannot make a static reference to the non-static field factory', + 'location': PositionMatch( 24, 32 ), + 'location_extent': RangeMatch( ( 24, 32 ), ( 24, 39 ) ), + 'ranges': contains( RangeMatch( ( 24, 32 ), ( 24, 39 ) ) ), + 'fixit_available': False + } ), + ), } diff --git a/ycmd/tests/java/get_completions_test.py b/ycmd/tests/java/get_completions_test.py index 96a1172a7b..1039e790f4 100644 --- a/ycmd/tests/java/get_completions_test.py +++ b/ycmd/tests/java/get_completions_test.py @@ -27,13 +27,14 @@ contains_inanyorder, empty, matches_regexp, - has_entries ) + has_entries, + instance_of ) from nose.tools import eq_ from pprint import pformat import requests -from ycmd.tests.java import ( PathToTestFile, SharedYcmd ) +from ycmd.tests.java import ( DEFAULT_PROJECT_DIR, PathToTestFile, SharedYcmd ) from ycmd.tests.test_utils import ( BuildRequest, CompletionEntryMatcher ) from ycmd.utils import ReadFile @@ -49,6 +50,14 @@ def _Merge( request, data ): return kw +def ProjectPath( *args ): + return PathToTestFile( DEFAULT_PROJECT_DIR, + 'src', + 'com', + 'test', + *args ) + + def RunTest( app, test ): """ Method to run a simple completion test and verify the result @@ -122,11 +131,7 @@ def GetCompletions_NoQuery_test( app ): 'description': 'semantic completion works for builtin types (no query)', 'request': { 'filetype' : 'java', - 'filepath' : PathToTestFile( 'simple_eclipse_project', - 'src', - 'com', - 'test', - 'TestFactory.java' ), + 'filepath' : ProjectPath( 'TestFactory.java' ), 'line_num' : 27, 'column_num': 12, }, @@ -151,11 +156,7 @@ def GetCompletions_WithQuery_test( app ): 'description': 'semantic completion works for builtin types (no query)', 'request': { 'filetype' : 'java', - 'filepath' : PathToTestFile( 'simple_eclipse_project', - 'src', - 'com', - 'test', - 'TestFactory.java' ), + 'filepath' : ProjectPath( 'TestFactory.java' ), 'line_num' : 27, 'column_num': 15, }, @@ -172,17 +173,96 @@ def GetCompletions_WithQuery_test( app ): } ) +@SharedYcmd +def GetCompletions_Package_test( app ): + RunTest( app, { + 'description': 'completion works for package statements', + 'request': { + 'filetype' : 'java', + 'filepath' : ProjectPath( 'wobble', 'Wibble.java' ), + 'line_num' : 1, + 'column_num': 18, + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries( { + 'completion_start_column': 18, + 'completions': contains( + CompletionEntryMatcher( 'com.test.wobble', None, { + 'extra_data': has_entries( { + 'fixits': instance_of( list ) + } ) + } ), + ), + 'errors': empty(), + } ) + }, + } ) + + +@SharedYcmd +def GetCompletions_Import_Class_test( app ): + RunTest( app, { + 'description': 'completion works for import statements', + 'request': { + 'filetype' : 'java', + 'filepath' : ProjectPath( 'TestLauncher.java' ), + 'line_num' : 4, + 'column_num': 34, + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries( { + 'completion_start_column': 34, + 'completions': contains_inanyorder( + CompletionEntryMatcher( 'Tset;', None, { + 'menu_text': 'Tset - com.youcompleteme.testing', + } ), + ), + 'errors': empty(), + } ) + }, + } ) + + +@SharedYcmd +def GetCompletions_Import_Module_test( app ): + RunTest( app, { + 'description': 'completion works for import statements', + 'request': { + 'filetype' : 'java', + 'filepath' : ProjectPath( 'TestLauncher.java' ), + 'line_num' : 3, + 'column_num': 26, + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries( { + 'completion_start_column': 26, + 'completions': contains( + CompletionEntryMatcher( 'testing', None, { + 'menu_text': 'com.youcompleteme.testing', + 'extra_data': has_entries( { + 'fixits': instance_of( list ) + } ), + } ), + CompletionEntryMatcher( 'Test;', None, { + 'menu_text': 'Test - com.youcompleteme', + } ), + ), + 'errors': empty(), + } ) + }, + } ) + + @SharedYcmd def GetCompletions_WithSnippet_test( app ): RunTest( app, { 'description': 'semantic completion works for builtin types (no query)', 'request': { 'filetype' : 'java', - 'filepath' : PathToTestFile( 'simple_eclipse_project', - 'src', - 'com', - 'test', - 'TestFactory.java' ), + 'filepath' : ProjectPath( 'TestFactory.java' ), 'line_num' : 19, 'column_num': 25, }, diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py index e5e8159304..fbead96f09 100644 --- a/ycmd/tests/java/subcommands_test.py +++ b/ycmd/tests/java/subcommands_test.py @@ -351,9 +351,9 @@ def Subcommands_GoToReferences_test( app ): 'com', 'test', 'TestLauncher.java' ), - 'column_num': 7, + 'column_num': 13, # 'description': '', - 'line_num': 8 + 'line_num': 25 } ] ) @@ -370,7 +370,7 @@ def Subcommands_RefactorRename_Simple_test( app ): 'command': 'RefactorRename', 'arguments': [ 'renamed_l' ], 'filepath': filepath, - 'line_num': 15, + 'line_num': 21, 'column_num': 5, }, 'expect': { @@ -379,13 +379,13 @@ def Subcommands_RefactorRename_Simple_test( app ): 'fixits': contains( has_entries( { 'chunks': contains( ChunkMatcher( 'renamed_l', - LocationMatcher( filepath, 14, 18 ), - LocationMatcher( filepath, 14, 19 ) ), + LocationMatcher( filepath, 20, 18 ), + LocationMatcher( filepath, 20, 19 ) ), ChunkMatcher( 'renamed_l', - LocationMatcher( filepath, 15, 5 ), - LocationMatcher( filepath, 15, 6 ) ), + LocationMatcher( filepath, 21, 5 ), + LocationMatcher( filepath, 21, 6 ) ), ), - 'location': LocationMatcher( filepath, 15, 5 ) + 'location': LocationMatcher( filepath, 21, 5 ) } ) ) } ) } @@ -421,8 +421,8 @@ def Subcommands_RefactorRename_MultipleFiles_test( app ): 'command': 'RefactorRename', 'arguments': [ 'a-quite-long-string' ], 'filepath': TestLauncher, - 'line_num': 8, - 'column_num': 7, + 'line_num': 25, + 'column_num': 13, }, 'expect': { 'response': requests.codes.ok, @@ -439,14 +439,14 @@ def Subcommands_RefactorRename_MultipleFiles_test( app ): LocationMatcher( TestFactory, 28, 33 ) ), ChunkMatcher( 'a-quite-long-string', - LocationMatcher( TestLauncher, 8, 7 ), - LocationMatcher( TestLauncher, 8, 31 ) ), + LocationMatcher( TestLauncher, 25, 13 ), + LocationMatcher( TestLauncher, 25, 37 ) ), ChunkMatcher( 'a-quite-long-string', LocationMatcher( TestWidgetImpl, 20, 15 ), LocationMatcher( TestWidgetImpl, 20, 39 ) ), ), - 'location': LocationMatcher( TestLauncher, 8, 7 ) + 'location': LocationMatcher( TestLauncher, 25, 13 ) } ) ) } ) } diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java index f6acdb84e8..dd5502af71 100644 --- a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java @@ -1,17 +1,31 @@ package com.test; +import com.youcompleteme.*; +import com.youcompleteme.testing.Tset; + class TestLauncher { private TestFactory factory = new TestFactory(); + private Tset tset = new Tset(); - private void Run() { - AbstractTestWidget w = factory.getWidget( "Test" ); - w.doSomethingVaguelyUseful(); + private interface Launchable { + public void launch( TestFactory f ); + } - System.out.println( "Did something useful: " + w.getWidgetInfo() ); + private void Run( Launchable l ) { + tset.getTset().add( new Test() ); + l.launch( factory ); } public static void main( String[] args ) { TestLauncher l = new TestLauncher(); - l.Run(); + l.Run( new Launchable() { + @Override + public void launch() { + AbstractTestWidget w = factory.getWidget( "Test" ); + w.doSomethingVaguelyUseful(); + + System.out.println( "Did something useful: " + w.getWidgetInfo() ); + } + }); } } diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/youcompleteme/Test.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/youcompleteme/Test.java new file mode 100644 index 0000000000..aa758f57d9 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/youcompleteme/Test.java @@ -0,0 +1,5 @@ +package com.youcompleteme; + +public class Test { + public String test; +} diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/youcompleteme/testing/Tset.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/youcompleteme/testing/Tset.java new file mode 100644 index 0000000000..aa0feeda44 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/youcompleteme/testing/Tset.java @@ -0,0 +1,12 @@ +package com.youcompleteme.testing; + +import java.util.Set; +import com.youcompleteme.*; + +public class Tset { + Set tset; + + public Set getTset() { + return tset; + } +} From f377e3510a37327092c10ba7941937e2876166f5 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Tue, 3 Oct 2017 00:59:06 +0100 Subject: [PATCH 19/51] Correctly handle textEdit completions We now no longer support completions with newlines. We now correctly return completions where the entry is a TextEdit and multiple completions have different start columns. We do this super inefficiently by attempting to normalise the TextEdits into insertion_texts with the same start_codepoint. This is necessary particularly due to the way that eclipse returns import completions for packages. --- ycmd/completers/java/java_completer.py | 9 +- .../language_server_completer.py | 189 ++++++++++++------ ycmd/tests/java/get_completions_test.py | 155 +++++++------- ycmd/tests/java/subcommands_test.py | 6 +- .../src/com/test/TestLauncher.java | 2 +- .../src/com/test/wobble/A.java | 5 + .../test/wobble/A_Very_Long_Class_Here.java | 5 + .../src/com/test/wobble/Waggle.java | 5 + 8 files changed, 212 insertions(+), 164 deletions(-) create mode 100644 ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/A.java create mode 100644 ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/A_Very_Long_Class_Here.java create mode 100644 ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/Waggle.java diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 155e8925fa..ed8e9684d9 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -331,12 +331,9 @@ def _StartServer( self ): '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Dlog.level=ALL', - '-jar', - self._launcher_path, - '-configuration', - self._launcher_config, - '-data', - self._workspace_path + '-jar', self._launcher_path, + '-configuration', self._launcher_config, + '-data', self._workspace_path, ] _logger.debug( 'Starting java-server with the following command: ' diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 6be428bcb5..63871fb5f8 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -26,6 +26,7 @@ import abc import collections import logging +import os import queue import threading @@ -59,6 +60,10 @@ class ResponseFailedException( Exception ): pass +class IncompatibleCompletionException( Exception ): + pass + + class Response( object ): def __init__( self, response_callback=None ): self._event = threading.Event() @@ -512,7 +517,9 @@ def ServerIsReady( self ): def ShouldUseNowInner( self, request_data ): - return self.ServerIsReady() + return ( self.ServerIsReady() and + super( LanguageServerCompleter, self ).ShouldUseNowInner( + request_data ) ) def ComputeCandidatesInner( self, request_data ): @@ -532,12 +539,59 @@ def ComputeCandidatesInner( self, request_data ): self._server_capabilities[ 'completionProvider' ].get( 'resolveProvider', False ) ) - def MakeCompletion( item ): - # First, resolve the completion. - # TODO: Maybe we need some way to do this based on a trigger - # TODO: Need a better API around request IDs. We no longer care about them - # _at all_ here. + if isinstance( response[ 'result' ], list ): + items = response[ 'result' ] + else: + items = response[ 'result' ][ 'items' ] + + return self.ResolveCompletionItems( items, request_data, do_resolve ) + + + def ResolveCompletionItems( self, items, request_data, do_resolve ): + _logger.debug( 'Completion Start: {0}'.format( request_data[ + 'start_codepoint' ] ) ) + # Note Vim only displays the first character, so we map them to the + # documented Vim kinds: + # + # v variable + # f function or method + # m member of a struct or class + # t typedef + # d #define or macro + # + # FIXME: I'm not happy with this completely. We're losing useful info, + # perhaps unnecessarily. + ITEM_KIND = [ + None, # 1-based + 'd', # 'Text', + 'f', # 'Method', + 'f', # 'Function', + 'f', # 'Constructor', + 'm', # 'Field', + 'm', # 'Variable', + 't', # 'Class', + 't', # 'Interface', + 't', # 'Module', + 't', # 'Property', + 't', # 'Unit', + 'd', # 'Value', + 't', # 'Enum', + 'd', # 'Keyword', + 'd', # 'Snippet', + 'd', # 'Color', + 'd', # 'File', + 'd', # 'Reference', + ] + completions = list() + start_codepoints = list() + min_start_codepoint = request_data[ 'start_codepoint' ] + + # First generate all of the completion items and store their + # start_codepoints. Then, we fix-up the completion texts to use the + # earliest start_codepoint by borrowing text from the original line. + for item in items: + # First, resolve the completion. if do_resolve: try: resolve_id = self.GetConnection().NextRequestId() @@ -551,42 +605,17 @@ def MakeCompletion( item ): _logger.exception( 'A completion item could not be resolved. Using ' 'basic data.' ) - # Note Vim only displays the first character, so we map them to the - # documented Vim kinds: - # - # v variable - # f function or method - # m member of a struct or class - # t typedef - # d #define or macro - # - # FIXME: I'm not happy with this completely. We're losing useful info, - # perhaps unnecessarily. - ITEM_KIND = [ - None, # 1-based - 'd', # 'Text', - 'f', # 'Method', - 'f', # 'Function', - 'f', # 'Constructor', - 'm', # 'Field', - 'm', # 'Variable', - 't', # 'Class', - 't', # 'Interface', - 't', # 'Module', - 't', # 'Property', - 't', # 'Unit', - 'd', # 'Value', - 't', # 'Enum', - 'd', # 'Keyword', - 'd', # 'Snippet', - 'd', # 'Color', - 'd', # 'File', - 'd', # 'Reference', - ] + try: + ( insertion_text, fixits, start_codepoint ) = ( + InsertionTextForItem( request_data, item ) ) + except IncompatibleCompletionException: + _logger.exception( 'Ignoring incompatible completion suggestion ' + '{0}'.format( item ) ) + continue - ( insertion_text, fixits ) = InsertionTextForItem( request_data, item ) + min_start_codepoint = min( min_start_codepoint, start_codepoint ) - return responses.BuildCompletionData( + completions.append( responses.BuildCompletionData( insertion_text, extra_menu_info = item.get( 'detail', None ), detailed_info = ( item[ 'label' ] + @@ -594,13 +623,18 @@ def MakeCompletion( item ): item.get( 'documentation', '' ) ), menu_text = item[ 'label' ], kind = ITEM_KIND[ item.get( 'kind', 0 ) ], - extra_data = fixits ) + extra_data = fixits ) ) + start_codepoints.append( start_codepoint ) - if isinstance( response[ 'result' ], list ): - items = response[ 'result' ] - else: - items = response[ 'result' ][ 'items' ] - return [ MakeCompletion( i ) for i in items ] + if ( len( completions ) > 1 and + min_start_codepoint != request_data[ 'start_codepoint' ] ): + return FixUpCompletionPrefixes( completions, + start_codepoints, + request_data, + min_start_codepoint ) + + request_data[ 'start_codepoint' ] = min_start_codepoint + return completions def OnFileReadyToParse( self, request_data ): @@ -932,15 +966,46 @@ def Rename( self, request_data, args ): [ WorkspaceEditToFixIt( request_data, response[ 'result' ] ) ] ) +def FixUpCompletionPrefixes( completions, + start_codepoints, + request_data, + min_start_codepoint ): + # Fix up the insertion texts so they share the same start_codepoint by + # borrowing text from the source + line = request_data[ 'line_value' ] + for completion, start_codepoint in zip( completions, start_codepoints ): + to_borrow = start_codepoint - min_start_codepoint + if to_borrow > 0: + borrow = line[ start_codepoint - to_borrow - 1 : start_codepoint - 1 ] + new_insertion_text = borrow + completion[ 'insertion_text' ] + completion[ 'insertion_text' ] = new_insertion_text + + # Finally, remove any common prefix + common_prefix_len = len( os.path.commonprefix( + [ c[ 'insertion_text' ] for c in completions ] ) ) + for completion in completions: + completion[ 'insertion_text' ] = completion[ 'insertion_text' ][ + common_prefix_len : ] + + # The start column is the earliest start point that we fixed up plus the + # length of the common prefix that we subsequently removed. + # + # Phew. That was hard work. + request_data[ 'start_codepoint' ] = min_start_codepoint + common_prefix_len + return completions + + def InsertionTextForItem( request_data, item ): INSERT_TEXT_FORMAT = [ None, # 1-based 'PlainText', 'Snippet' ] + assert INSERT_TEXT_FORMAT[ item.get( 'insertTextFormat', 1 ) ] == 'PlainText' fixits = None + start_codepoint = request_data[ 'start_codepoint' ] # We will alwyas have one of insertText or label if 'insertText' in item and item[ 'insertText' ]: insertion_text = item[ 'insertText' ] @@ -952,16 +1017,17 @@ def InsertionTextForItem( request_data, item ): # Per the protocol, textEdit takes precedence over insertText, and must be # on the same line (and containing) the originally requested position if 'textEdit' in item and item[ 'textEdit' ]: - # The insertion text contains newlines or starts before the start - # position. This is tricky: most clients (i.e. Vim) won't support this. So - # we cheat. Set the insertable text to the simple text, and put and - # additionalTextEdit instead. We manipulate the real textEdit so that it - # replaces the inserted text with the real textEdit. - fixup_textedit = dict( item[ 'textEdit' ] ) - fixup_textedit[ 'range' ][ 'end' ][ 'character' ] = ( - fixup_textedit[ 'range' ][ 'end' ][ 'character' ] + len( - insertion_text ) ) - additional_text_edits.append( fixup_textedit ) + textEdit = item[ 'textEdit' ] + edit_range = textEdit[ 'range' ] + start_codepoint = edit_range[ 'start' ][ 'character' ] + 1 + assert edit_range[ 'start' ][ 'line' ] == edit_range[ 'end' ][ 'line' ] + assert start_codepoint <= request_data[ 'start_codepoint' ] + + insertion_text = textEdit[ 'newText' ] + + if '\n' in textEdit[ 'newText' ]: + # Most clients won't support this, at least not for now + raise IncompatibleCompletionException( textEdit[ 'newText' ] ) additional_text_edits.extend( item.get( 'additionalTextEdits', [] ) ) @@ -975,16 +1041,7 @@ def InsertionTextForItem( request_data, item ): fixits = responses.BuildFixItResponse( [ responses.FixIt( chunks[ 0].range.start_, chunks ) ] ) - if 'insertTextFormat' in item and item[ 'insertTextFormat' ]: - text_format = INSERT_TEXT_FORMAT[ item[ 'insertTextFormat' ] ] - else: - text_format = 'PlainText' - - if text_format != 'PlainText': - raise ValueError( 'Snippet completions are not supported and should not' - ' be returned by the language server.' ) - - return ( insertion_text, fixits ) + return ( insertion_text, fixits, start_codepoint ) def LocationListToGoTo( request_data, response ): diff --git a/ycmd/tests/java/get_completions_test.py b/ycmd/tests/java/get_completions_test.py index 1039e790f4..137174520a 100644 --- a/ycmd/tests/java/get_completions_test.py +++ b/ycmd/tests/java/get_completions_test.py @@ -27,8 +27,7 @@ contains_inanyorder, empty, matches_regexp, - has_entries, - instance_of ) + has_entries ) from nose.tools import eq_ from pprint import pformat @@ -36,7 +35,9 @@ from ycmd.tests.java import ( DEFAULT_PROJECT_DIR, PathToTestFile, SharedYcmd ) from ycmd.tests.test_utils import ( BuildRequest, - CompletionEntryMatcher ) + ChunkMatcher, + CompletionEntryMatcher, + LocationMatcher ) from ycmd.utils import ReadFile @@ -186,13 +187,9 @@ def GetCompletions_Package_test( app ): 'expect': { 'response': requests.codes.ok, 'data': has_entries( { - 'completion_start_column': 18, + 'completion_start_column': 9, 'completions': contains( - CompletionEntryMatcher( 'com.test.wobble', None, { - 'extra_data': has_entries( { - 'fixits': instance_of( list ) - } ) - } ), + CompletionEntryMatcher( 'com.test.wobble', None ), ), 'errors': empty(), } ) @@ -214,9 +211,44 @@ def GetCompletions_Import_Class_test( app ): 'response': requests.codes.ok, 'data': has_entries( { 'completion_start_column': 34, - 'completions': contains_inanyorder( + 'completions': contains( CompletionEntryMatcher( 'Tset;', None, { - 'menu_text': 'Tset - com.youcompleteme.testing', + 'menu_text': 'Tset - com.youcompleteme.testing' + } ) + ), + 'errors': empty(), + } ) + }, + } ) + + +@SharedYcmd +def GetCompletions_Import_Classes_test( app ): + filepath = ProjectPath( 'TestLauncher.java' ) + RunTest( app, { + 'description': 'completion works for import statements', + 'request': { + 'filetype' : 'java', + 'filepath' : filepath, + 'line_num' : 3, + 'column_num': 52, + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries( { + 'completion_start_column': 52, + 'completions': contains( + CompletionEntryMatcher( 'A;', None, { + 'menu_text': 'A - com.test.wobble', + } ), + CompletionEntryMatcher( 'A_Very_Long_Class_Here;', None, { + 'menu_text': 'A_Very_Long_Class_Here - com.test.wobble', + } ), + CompletionEntryMatcher( 'Waggle;', None, { + 'menu_text': 'Waggle - com.test.wobble', + } ), + CompletionEntryMatcher( 'Wibble;', None, { + 'menu_text': 'Wibble - com.test.wobble', } ), ), 'errors': empty(), @@ -226,12 +258,13 @@ def GetCompletions_Import_Class_test( app ): @SharedYcmd -def GetCompletions_Import_Module_test( app ): +def GetCompletions_Import_ModuleAndClass_test( app ): + filepath = ProjectPath( 'TestLauncher.java' ) RunTest( app, { 'description': 'completion works for import statements', 'request': { 'filetype' : 'java', - 'filepath' : ProjectPath( 'TestLauncher.java' ), + 'filepath' : filepath, 'line_num' : 3, 'column_num': 26, }, @@ -240,11 +273,8 @@ def GetCompletions_Import_Module_test( app ): 'data': has_entries( { 'completion_start_column': 26, 'completions': contains( - CompletionEntryMatcher( 'testing', None, { + CompletionEntryMatcher( 'testing.*;', None, { 'menu_text': 'com.youcompleteme.testing', - 'extra_data': has_entries( { - 'fixits': instance_of( list ) - } ), } ), CompletionEntryMatcher( 'Test;', None, { 'menu_text': 'Test - com.youcompleteme', @@ -258,21 +288,21 @@ def GetCompletions_Import_Module_test( app ): @SharedYcmd def GetCompletions_WithSnippet_test( app ): + filepath = ProjectPath( 'TestFactory.java' ) RunTest( app, { 'description': 'semantic completion works for builtin types (no query)', 'request': { 'filetype' : 'java', - 'filepath' : ProjectPath( 'TestFactory.java' ), + 'filepath' : filepath, 'line_num' : 19, 'column_num': 25, }, 'expect': { 'response': requests.codes.ok, 'data': has_entries( { - 'completions': contains_inanyorder( CompletionEntryMatcher( - 'CUTHBERT', - 'com.test.wobble.Wibble', - # FIXME: Replace with ChunkMatcher, LocationMatcher, etc. + 'completion_start_column': 22, + 'completions': contains_inanyorder( + CompletionEntryMatcher( 'CUTHBERT', 'com.test.wobble.Wibble', { 'extra_data': has_entries( { 'fixits': contains( has_entries( { @@ -280,79 +310,28 @@ def GetCompletions_WithSnippet_test( app ): # For some reason, jdtls feels it's OK to replace the text # before the cursor. Perhaps it does this to canonicalise the # path ? - has_entries( { - 'replacement_text': 'Wibble', - 'range': has_entries( { - 'start': has_entries( { - 'line_num': 19, - 'column_num': 15, - } ), - 'end': has_entries( { - 'line_num': 19, - 'column_num': 21, - } ) - } ), - } ), + ChunkMatcher( 'Wibble', + LocationMatcher( filepath, 19, 15 ), + LocationMatcher( filepath, 19, 21 ) ), # When doing an import, eclipse likes to add two newlines # after the package. I suppose this is config in real eclipse, # but there's no mechanism to configure this in jdtl afaik. - has_entries( { - 'replacement_text': '\n\n', - 'range': has_entries( { - 'start': has_entries( { - 'line_num': 1, - 'column_num': 18, - } ), - 'end': has_entries( { - 'line_num': 1, - 'column_num': 18, - } ) - } ), - } ), + ChunkMatcher( '\n\n', + LocationMatcher( filepath, 1, 18 ), + LocationMatcher( filepath, 1, 18 ) ), # OK, so it inserts the import - has_entries( { - 'replacement_text': 'import com.test.wobble.Wibble;', - 'range': has_entries( { - 'start': has_entries( { - 'line_num': 1, - 'column_num': 18, - } ), - 'end': has_entries( { - 'line_num': 1, - 'column_num': 18, - } ) - } ), - } ), + ChunkMatcher( 'import com.test.wobble.Wibble;', + LocationMatcher( filepath, 1, 18 ), + LocationMatcher( filepath, 1, 18 ) ), # More newlines. Who doesn't like newlines?! - has_entries( { - 'replacement_text': '\n\n', - 'range': has_entries( { - 'start': has_entries( { - 'line_num': 1, - 'column_num': 18, - } ), - 'end': has_entries( { - 'line_num': 1, - 'column_num': 18, - } ) - } ), - } ), + ChunkMatcher( '\n\n', + LocationMatcher( filepath, 1, 18 ), + LocationMatcher( filepath, 1, 18 ) ), # For reasons known only to the eclipse JDT developers, it # seems to want to delete the lines after the package first. - # - has_entries( { - 'replacement_text': '', - 'range': has_entries( { - 'start': has_entries( { - 'line_num': 1, - 'column_num': 18, - } ), - 'end': has_entries( { - 'line_num': 3, - 'column_num': 1, - } ) - } ), - } ), + ChunkMatcher( '', + LocationMatcher( filepath, 1, 18 ), + LocationMatcher( filepath, 3, 1 ) ), ), } ) ), } ), diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py index fbead96f09..952e508d83 100644 --- a/ycmd/tests/java/subcommands_test.py +++ b/ycmd/tests/java/subcommands_test.py @@ -351,7 +351,7 @@ def Subcommands_GoToReferences_test( app ): 'com', 'test', 'TestLauncher.java' ), - 'column_num': 13, + 'column_num': 11, # 'description': '', 'line_num': 25 } ] ) @@ -439,8 +439,8 @@ def Subcommands_RefactorRename_MultipleFiles_test( app ): LocationMatcher( TestFactory, 28, 33 ) ), ChunkMatcher( 'a-quite-long-string', - LocationMatcher( TestLauncher, 25, 13 ), - LocationMatcher( TestLauncher, 25, 37 ) ), + LocationMatcher( TestLauncher, 25, 11 ), + LocationMatcher( TestLauncher, 25, 35 ) ), ChunkMatcher( 'a-quite-long-string', LocationMatcher( TestWidgetImpl, 20, 15 ), diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java index dd5502af71..69e20d2bea 100644 --- a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java @@ -1,6 +1,6 @@ package com.test; -import com.youcompleteme.*; +import com.youcompleteme.*; import com.test.wobble.*; import com.youcompleteme.testing.Tset; class TestLauncher { diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/A.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/A.java new file mode 100644 index 0000000000..84b6fd697b --- /dev/null +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/A.java @@ -0,0 +1,5 @@ +package com.test.wobble; + +public class A { + +} diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/A_Very_Long_Class_Here.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/A_Very_Long_Class_Here.java new file mode 100644 index 0000000000..27dd3ee578 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/A_Very_Long_Class_Here.java @@ -0,0 +1,5 @@ +package com.test.wobble; + +public class A_Very_Long_Class_Here { + +} diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/Waggle.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/Waggle.java new file mode 100644 index 0000000000..942e34764d --- /dev/null +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/wobble/Waggle.java @@ -0,0 +1,5 @@ +package com.test.wobble; + +public interface Waggle { + +} From b57e72d5950f67a326d7c90ca35f1eb18a01afbf Mon Sep 17 00:00:00 2001 From: Boris Staletic Date: Fri, 15 Sep 2017 01:26:56 +0200 Subject: [PATCH 20/51] GetType GetDoc and GetReferences failure test --- ycmd/completers/java/java_completer.py | 13 +- .../language_server_completer.py | 25 +-- ycmd/tests/java/subcommands_test.py | 146 +++++++++++++++--- 3 files changed, 148 insertions(+), 36 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index ed8e9684d9..fb21427a39 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -34,6 +34,8 @@ from ycmd import ( utils, responses ) from ycmd.completers.language_server import language_server_completer +NO_DOCUMENTATION_MESSAGE = 'No documentation available for current context' + _logger = logging.getLogger( __name__ ) LANGUAGE_SERVER_HOME = os.path.join( os.path.dirname( __file__ ), @@ -463,12 +465,16 @@ def GetType( self, request_data ): if isinstance( hover_response, list ): if len( hover_response ): - get_type_java = hover_response[ 0 ][ 'value' ] + try: + get_type_java = hover_response[ 0 ][ 'value' ] + except( TypeError ): + raise RuntimeError( 'No information' ) else: raise RuntimeError( 'No information' ) else: get_type_java = hover_response + return responses.BuildDisplayMessageResponse( get_type_java ) @@ -486,6 +492,11 @@ def GetDoc( self, request_data ): else: get_doc_java = hover_response + get_doc_java = get_doc_java.rstrip() + + if get_doc_java == '': + raise ValueError( NO_DOCUMENTATION_MESSAGE ) + return responses.BuildDisplayMessageResponse( get_doc_java.rstrip() ) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 63871fb5f8..c4554c4206 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -1048,17 +1048,20 @@ def LocationListToGoTo( request_data, response ): if not response: raise RuntimeError( 'Cannot jump to location' ) - if len( response[ 'result' ] ) > 1: - positions = response[ 'result' ] - return [ - responses.BuildGoToResponseFromLocation( - PositionToLocation( request_data, - position ) ) for position in positions - ] - else: - position = response[ 'result' ][ 0 ] - return responses.BuildGoToResponseFromLocation( - PositionToLocation( request_data, position ) ) + try: + if len( response[ 'result' ] ) > 1: + positions = response[ 'result' ] + return [ + responses.BuildGoToResponseFromLocation( + PositionToLocation( request_data, + position ) ) for position in positions + ] + else: + position = response[ 'result' ][ 0 ] + return responses.BuildGoToResponseFromLocation( + PositionToLocation( request_data, position ) ) + except( IndexError ): + raise RuntimeError( 'Cannot jump to location' ) def PositionToLocation( request_data, position ): diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py index 952e508d83..bd81aee9c0 100644 --- a/ycmd/tests/java/subcommands_test.py +++ b/ycmd/tests/java/subcommands_test.py @@ -26,14 +26,22 @@ from nose.tools import eq_ from pprint import pformat import requests +import logging from ycmd.utils import ReadFile -from ycmd.tests.java import PathToTestFile, SharedYcmd +from ycmd.completers.java.java_completer import NO_DOCUMENTATION_MESSAGE +from ycmd.tests.java import ( PathToTestFile, + SharedYcmd, + IsolatedYcmdInDirectory, + WaitUntilCompleterServerReady, + DEFAULT_PROJECT_DIR ) from ycmd.tests.test_utils import ( BuildRequest, ChunkMatcher, ErrorMatcher, LocationMatcher ) +_logger = logging.getLogger( __name__ ) + @SharedYcmd def Subcommands_DefinedSubcommands_test( app ): @@ -94,6 +102,34 @@ def CombineRequest( request, data ): assert_that( response.json, test[ 'expect' ][ 'data' ] ) +@IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) +def Subcommands_GetDoc_NoDoc_test( app ): + WaitUntilCompleterServerReady( app ) + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'AbstractTestWidget.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 18, + column_num = 1, + contents = contents, + command_arguments = [ 'GetDoc' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', + event_data, + expect_errors = True ) + + eq_( response.status_code, requests.codes.internal_server_error ) + + assert_that( response.json, + ErrorMatcher( ValueError, NO_DOCUMENTATION_MESSAGE ) ) + + @SharedYcmd def Subcommands_GetDoc_Method_test( app ): filepath = PathToTestFile( 'simple_eclipse_project', @@ -145,6 +181,35 @@ def Subcommands_GetDoc_Class_test( app ): } ) +@IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) +def Subcommands_GetType_NoKnownType_test( app ): + WaitUntilCompleterServerReady( app ) + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestWidgetImpl.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 28, + column_num = 1, + contents = contents, + command_arguments = [ 'GetType' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', + event_data, + expect_errors = True ) + + eq_( response.status_code, requests.codes.internal_server_error ) + + assert_that( response.json, + ErrorMatcher( RuntimeError, + 'No information' ) ) + + @SharedYcmd def Subcommands_GetType_Class_test( app ): filepath = PathToTestFile( 'simple_eclipse_project', @@ -290,29 +355,62 @@ def Subcommands_GetType_Method_test( app ): 'message': 'void com.test.TestWidgetImpl.doSomethingVaguelyUseful()' } ) -# Commented out because of an overlooked corner case -# @SharedYcmd -# def Subcommands_GetType_Class_test( app ): -# filepath = PathToTestFile( 'simple_eclipse_project', -# 'src', -# 'com', -# 'test', -# 'TestWidgetImpl.java' ) -# contents = ReadFile( filepath ) -# -# event_data = BuildRequest( filepath = filepath, -# filetype = 'java', -# line_num = 15, -# column_num = 13, -# contents = contents, -# command_arguments = [ 'GetType' ], -# completer_target = 'filetype_default' ) -# -# response = app.post_json( '/run_completer_command', event_data ).json -# -# eq_( response, { -# 'message': '' -# } ) + +@SharedYcmd +def Subcommands_GetType_LiteralValue_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestWidgetImpl.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 15, + column_num = 13, + contents = contents, + command_arguments = [ 'GetType' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', + event_data, + expect_errors = True ) + + eq_( response.status_code, requests.codes.internal_server_error ) + + assert_that( response.json, + ErrorMatcher( RuntimeError, + 'No information' ) ) + + +@IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) +def Subcommands_GoToReferences_NoReferences_test( app ): + WaitUntilCompleterServerReady( app ) + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'AbstractTestWidget.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 18, + column_num = 1, + contents = contents, + command_arguments = [ 'GoToReferences' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', + event_data, + expect_errors = True ) + + eq_( response.status_code, requests.codes.internal_server_error ) + + assert_that( response.json, + ErrorMatcher( RuntimeError, + 'Cannot jump to location' ) ) @SharedYcmd From a4ba2e0c67a95d62cc39be8e2c4659520eaf4415 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Fri, 15 Sep 2017 00:10:11 +0100 Subject: [PATCH 21/51] Use LocationMatcher from test utils --- ycmd/tests/java/diagnostics_test.py | 81 ++++++++++++++--------------- 1 file changed, 39 insertions(+), 42 deletions(-) diff --git a/ycmd/tests/java/diagnostics_test.py b/ycmd/tests/java/diagnostics_test.py index 880e30141c..ff433171de 100644 --- a/ycmd/tests/java/diagnostics_test.py +++ b/ycmd/tests/java/diagnostics_test.py @@ -32,25 +32,18 @@ SharedYcmd, WaitUntilCompleterServerReady ) -from ycmd.tests.test_utils import ( BuildRequest ) +from ycmd.tests.test_utils import ( BuildRequest, + LocationMatcher ) from ycmd.utils import ReadFile import time from pprint import pformat -# TODO: Replace with ChunkMatcher, LocationMatcher, etc. -def PositionMatch( line, column ): +def RangeMatch( filepath, start, end ): return has_entries( { - 'line_num': line, - 'column_num': column - } ) - - -def RangeMatch( start, end ): - return has_entries( { - 'start': PositionMatch( *start ), - 'end': PositionMatch( *end ), + 'start': LocationMatcher( filepath, *start ), + 'end': LocationMatcher( filepath, *end ), } ) @@ -62,77 +55,81 @@ def ProjectPath( *args ): *args ) +TestFactory = ProjectPath( 'TestFactory.java' ) +TestLauncher = ProjectPath( 'TestLauncher.java' ) +TestWidgetImpl = ProjectPath( 'TestWidgetImpl.java' ) + DIAG_MATCHERS_PER_FILE = { - ProjectPath( 'TestFactory.java' ): contains_inanyorder( + TestFactory: contains_inanyorder( has_entries( { 'kind': 'WARNING', 'text': 'The value of the field TestFactory.Bar.testString is not used', - 'location': PositionMatch( 15, 19 ), - 'location_extent': RangeMatch( ( 15, 19 ), ( 15, 29 ) ), - 'ranges': contains( RangeMatch( ( 15, 19 ), ( 15, 29 ) ) ), + 'location': LocationMatcher( TestFactory, 15, 19 ), + 'location_extent': RangeMatch( TestFactory, ( 15, 19 ), ( 15, 29 ) ), + 'ranges': contains( RangeMatch( TestFactory, ( 15, 19 ), ( 15, 29 ) ) ), 'fixit_available': False } ), has_entries( { 'kind': 'ERROR', 'text': 'Wibble cannot be resolved to a type', - 'location': PositionMatch( 18, 24 ), - 'location_extent': RangeMatch( ( 18, 24 ), ( 18, 30 ) ), - 'ranges': contains( RangeMatch( ( 18, 24 ), ( 18, 30 ) ) ), + 'location': LocationMatcher( TestFactory, 18, 24 ), + 'location_extent': RangeMatch( TestFactory, ( 18, 24 ), ( 18, 30 ) ), + 'ranges': contains( RangeMatch( TestFactory, ( 18, 24 ), ( 18, 30 ) ) ), 'fixit_available': False } ), has_entries( { 'kind': 'ERROR', 'text': 'Wibble cannot be resolved to a variable', - 'location': PositionMatch( 19, 15 ), - 'location_extent': RangeMatch( ( 19, 15 ), ( 19, 21 ) ), - 'ranges': contains( RangeMatch( ( 19, 15 ), ( 19, 21 ) ) ), + 'location': LocationMatcher( TestFactory, 19, 15 ), + 'location_extent': RangeMatch( TestFactory, ( 19, 15 ), ( 19, 21 ) ), + 'ranges': contains( RangeMatch( TestFactory, ( 19, 15 ), ( 19, 21 ) ) ), 'fixit_available': False } ), has_entries( { 'kind': 'ERROR', 'text': 'Type mismatch: cannot convert from int to boolean', - 'location': PositionMatch( 27, 10 ), - 'location_extent': RangeMatch( ( 27, 10 ), ( 27, 16 ) ), - 'ranges': contains( RangeMatch( ( 27, 10 ), ( 27, 16 ) ) ), + 'location': LocationMatcher( TestFactory, 27, 10 ), + 'location_extent': RangeMatch( TestFactory, ( 27, 10 ), ( 27, 16 ) ), + 'ranges': contains( RangeMatch( TestFactory, ( 27, 10 ), ( 27, 16 ) ) ), 'fixit_available': False } ), ), - ProjectPath( 'TestWidgetImpl.java' ): contains_inanyorder( + TestWidgetImpl: contains_inanyorder( has_entries( { 'kind': 'WARNING', 'text': 'The value of the local variable a is not used', - 'location': PositionMatch( 15, 9 ), - 'location_extent': RangeMatch( ( 15, 9 ), ( 15, 10 ) ), - 'ranges': contains( RangeMatch( ( 15, 9 ), ( 15, 10 ) ) ), + 'location': LocationMatcher( TestWidgetImpl, 15, 9 ), + 'location_extent': RangeMatch( TestWidgetImpl, ( 15, 9 ), ( 15, 10 ) ), + 'ranges': contains( RangeMatch( TestWidgetImpl, ( 15, 9 ), ( 15, 10 ) ) ), 'fixit_available': False } ), ), - ProjectPath( 'TestLauncher.java' ): contains_inanyorder ( + TestLauncher: contains_inanyorder ( has_entries( { 'kind': 'ERROR', 'text': 'The type new TestLauncher.Launchable(){} must implement the ' 'inherited abstract method TestLauncher.Launchable.launch(' 'TestFactory)', - 'location': PositionMatch( 21, 16 ), - 'location_extent': RangeMatch( ( 21, 16 ), ( 21, 28 ) ), - 'ranges': contains( RangeMatch( ( 21, 16 ), ( 21, 28 ) ) ), + 'location': LocationMatcher( TestLauncher, 21, 16 ), + 'location_extent': RangeMatch( TestLauncher, ( 21, 16 ), ( 21, 28 ) ), + 'ranges': contains( RangeMatch( TestLauncher, ( 21, 16 ), ( 21, 28 ) ) ), 'fixit_available': False } ), has_entries( { 'kind': 'ERROR', 'text': 'The method launch() of type new TestLauncher.Launchable(){} ' 'must override or implement a supertype method', - 'location': PositionMatch( 23, 19 ), - 'location_extent': RangeMatch( ( 23, 19 ), ( 23, 27 ) ), - 'ranges': contains( RangeMatch( ( 23, 19 ), ( 23, 27 ) ) ), + 'location': LocationMatcher( TestLauncher, 23, 19 ), + 'location_extent': RangeMatch( TestLauncher, ( 23, 19 ), ( 23, 27 ) ), + 'ranges': contains( RangeMatch( TestLauncher, ( 23, 19 ), ( 23, 27 ) ) ), 'fixit_available': False } ), has_entries( { 'kind': 'ERROR', 'text': 'Cannot make a static reference to the non-static field factory', - 'location': PositionMatch( 24, 32 ), - 'location_extent': RangeMatch( ( 24, 32 ), ( 24, 39 ) ), - 'ranges': contains( RangeMatch( ( 24, 32 ), ( 24, 39 ) ) ), + 'location': LocationMatcher( TestLauncher, 24, 32 ), + 'location_extent': RangeMatch( TestLauncher, ( 24, 32 ), ( 24, 39 ) ), + 'ranges': contains( RangeMatch( TestLauncher, ( 24, 32 ), ( 24, 39 ) ) ), 'fixit_available': False } ), ), @@ -216,9 +213,9 @@ class Test { diag_matcher = contains( has_entries( { 'kind': 'ERROR', 'text': 'Syntax error, insert ";" to complete ClassBodyDeclarations', - 'location': PositionMatch( 4, 21 ), - 'location_extent': RangeMatch( ( 4, 21 ), ( 4, 25 ) ), - 'ranges': contains( RangeMatch( ( 4, 21 ), ( 4, 25 ) ) ), + 'location': LocationMatcher( filepath, 4, 21 ), + 'location_extent': RangeMatch( filepath, ( 4, 21 ), ( 4, 25 ) ), + 'ranges': contains( RangeMatch( filepath, ( 4, 21 ), ( 4, 25 ) ) ), 'fixit_available': False } ) ) From ac930d2f4e83109d34f45bfd5dade33065357784 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Fri, 15 Sep 2017 01:00:26 +0100 Subject: [PATCH 22/51] Add some more conservative validation and a test for ignored completions --- .../language_server_completer.py | 46 +++++++++++++++++-- ycmd/tests/java/get_completions_test.py | 29 ++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index c4554c4206..3d0f9b8d6b 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -1015,18 +1015,54 @@ def InsertionTextForItem( request_data, item ): additional_text_edits = [] # Per the protocol, textEdit takes precedence over insertText, and must be - # on the same line (and containing) the originally requested position + # 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'). if 'textEdit' in item and item[ 'textEdit' ]: textEdit = item[ 'textEdit' ] edit_range = textEdit[ 'range' ] start_codepoint = edit_range[ 'start' ][ 'character' ] + 1 - assert edit_range[ 'start' ][ 'line' ] == edit_range[ 'end' ][ 'line' ] - assert start_codepoint <= request_data[ 'start_codepoint' ] + end_codepoint = edit_range[ 'end' ][ 'character' ] + 1 + + # Conservatively rejecting candidates that breach the protocol + if edit_range[ 'start' ][ 'line' ] != edit_range[ 'end' ][ 'line' ]: + raise IncompatibleCompletionException( + "The TextEdit '{0}' spans multiple lines".format( + textEdit[ 'newText' ] ) ) + + if start_codepoint > request_data[ 'start_codepoint' ]: + raise IncompatibleCompletionException( + "The TextEdit '{0}' starts after the start position".format( + textEdit[ 'newText' ] ) ) + + if end_codepoint < request_data[ 'start_codepoint' ]: + raise IncompatibleCompletionException( + "The TextEdit '{0}' ends before the start position".format( + textEdit[ 'newText' ] ) ) + insertion_text = textEdit[ 'newText' ] if '\n' in textEdit[ 'newText' ]: - # Most clients won't support this, at least not for now + # jdt.ls can return completions which generate code, such as + # getters/setters and entire anonymous classes. + # + # 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 + # + # These textEdits would need a lot of fixing up and is currently out of + # scope. + # + # 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( textEdit[ 'newText' ] ) additional_text_edits.extend( item.get( 'additionalTextEdits', [] ) ) @@ -1039,7 +1075,7 @@ def InsertionTextForItem( request_data, item ): for e in additional_text_edits ] fixits = responses.BuildFixItResponse( - [ responses.FixIt( chunks[ 0].range.start_, chunks ) ] ) + [ responses.FixIt( chunks[ 0 ].range.start_, chunks ) ] ) return ( insertion_text, fixits, start_codepoint ) diff --git a/ycmd/tests/java/get_completions_test.py b/ycmd/tests/java/get_completions_test.py index 137174520a..eb0f57c041 100644 --- a/ycmd/tests/java/get_completions_test.py +++ b/ycmd/tests/java/get_completions_test.py @@ -341,3 +341,32 @@ def GetCompletions_WithSnippet_test( app ): } ) }, } ) + + +@SharedYcmd +def GetCompletions_RejectMultiLineInsertion_test( app ): + filepath = ProjectPath( 'TestLauncher.java' ) + RunTest( app, { + 'description': 'completion works for import statements', + 'request': { + 'filetype' : 'java', + 'filepath' : filepath, + 'line_num' : 21, + 'column_num' : 16, + 'force_semantic': True + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries( { + 'completion_start_column': 16, + 'completions': contains( + CompletionEntryMatcher( 'TestLauncher', 'com.test.TestLauncher' ) + # Note: There would be a suggestion here for the _real_ thing we want, + # which is a TestLauncher.Launchable, but this would generate the code + # for an anonymous inner class via a completion TextEdit (not + # AdditionalTextEdit) which we don't support. + ), + 'errors': empty(), + } ) + }, + } ) From 5c028f953376b93746b894d99e5f1a6f7b243a92 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Fri, 15 Sep 2017 21:44:50 +0100 Subject: [PATCH 23/51] Tests for FixIts --- .../language_server_completer.py | 4 +- ycmd/tests/java/diagnostics_test.py | 18 ++ ycmd/tests/java/get_completions_test.py | 2 +- ycmd/tests/java/subcommands_test.py | 266 +++++++++++++++++- .../src/com/test/TestFactory.java | 1 + 5 files changed, 287 insertions(+), 4 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 3d0f9b8d6b..1ece6d6f7f 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -884,7 +884,7 @@ def CodeAction( self, request_data, args ): if not self.ServerIsReady(): raise RuntimeError( 'Server is initialising. Please wait.' ) - # FIXME: We need to do this for all such requests + # FIXME: We (probably) need to do this for all such requests self._RefreshFiles( request_data ) line_num_ls = request_data[ 'line_num' ] - 1 @@ -939,7 +939,7 @@ def WithinRange( diag ): response = [ self.HandleServerCommand( request_data, c ) for c in code_actions[ 'result' ] ] - # Else, show a list of actions to the user to select which one to apply. + # Show a list of actions to the user to select which one to apply. # This is (probably) a more common workflow for "code action". return responses.BuildFixItResponse( [ r for r in response if r ] ) diff --git a/ycmd/tests/java/diagnostics_test.py b/ycmd/tests/java/diagnostics_test.py index ff433171de..18ed3f3320 100644 --- a/ycmd/tests/java/diagnostics_test.py +++ b/ycmd/tests/java/diagnostics_test.py @@ -93,6 +93,24 @@ def ProjectPath( *args ): 'ranges': contains( RangeMatch( TestFactory, ( 27, 10 ), ( 27, 16 ) ) ), 'fixit_available': False } ), + has_entries( { + 'kind': 'ERROR', + 'text': 'Type mismatch: cannot convert from int to boolean', + 'location': LocationMatcher( TestFactory, 30, 10 ), + 'location_extent': RangeMatch( TestFactory, ( 30, 10 ), ( 30, 16 ) ), + 'ranges': contains( RangeMatch( TestFactory, ( 30, 10 ), ( 30, 16 ) ) ), + 'fixit_available': False + } ), + has_entries( { + 'kind': 'ERROR', + 'text': 'The method doSomethingVaguelyUseful() in the type ' + 'AbstractTestWidget is not applicable for the arguments ' + '(TestFactory.Bar)', + 'location': LocationMatcher( TestFactory, 30, 23 ), + 'location_extent': RangeMatch( TestFactory, ( 30, 23 ), ( 30, 47 ) ), + 'ranges': contains( RangeMatch( TestFactory, ( 30, 23 ), ( 30, 47 ) ) ), + 'fixit_available': False + } ), ), TestWidgetImpl: contains_inanyorder( has_entries( { diff --git a/ycmd/tests/java/get_completions_test.py b/ycmd/tests/java/get_completions_test.py index eb0f57c041..83a6793465 100644 --- a/ycmd/tests/java/get_completions_test.py +++ b/ycmd/tests/java/get_completions_test.py @@ -287,7 +287,7 @@ def GetCompletions_Import_ModuleAndClass_test( app ): @SharedYcmd -def GetCompletions_WithSnippet_test( app ): +def GetCompletions_WithFixIt_test( app ): filepath = ProjectPath( 'TestFactory.java' ) RunTest( app, { 'description': 'semantic completion works for builtin types (no query)', diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py index bd81aee9c0..58ad3b27a6 100644 --- a/ycmd/tests/java/subcommands_test.py +++ b/ycmd/tests/java/subcommands_test.py @@ -22,7 +22,14 @@ # Not installing aliases from python-future; it's unreliable and slow. from builtins import * # noqa -from hamcrest import assert_that, contains, has_entries +from hamcrest import ( + assert_that, + contains, + contains_inanyorder, + empty, + has_entries, + instance_of, +) from nose.tools import eq_ from pprint import pformat import requests @@ -573,3 +580,260 @@ def Subcommands_RefactorRename_Missing_New_Name_test( app ): 'Usage: RefactorRename ' ), } } ) + + +@SharedYcmd +def RunFixItTest( app, description, filepath, line, col, fixits_for_line ): + RunTest( app, { + 'description': description, + 'request': { + 'command': 'FixIt', + 'line_num': line, + 'column_num': col, + 'filepath': filepath, + }, + 'expect': { + 'response': requests.codes.ok, + 'data': fixits_for_line, + } + } ) + + +def Subcommands_FixIt_SingleDiag_MultipleOption_Insertion_test(): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestFactory.java' ) + + # Note: The code actions for creating variables are really not very useful. + # The import is, however, and the FixIt almost exactly matches the one + # supplied when completing 'CUTHBERT' and auto-inserting. + fixits_for_line = has_entries ( { + 'fixits': contains_inanyorder( + has_entries( { + 'text': "Import 'Wibble' (com.test.wobble)", + 'chunks': contains( + # When doing an import, eclipse likes to add two newlines + # after the package. I suppose this is config in real eclipse, + # but there's no mechanism to configure this in jdtl afaik. + ChunkMatcher( '\n\n', + LocationMatcher( filepath, 1, 18 ), + LocationMatcher( filepath, 1, 18 ) ), + # OK, so it inserts the import + ChunkMatcher( 'import com.test.wobble.Wibble;', + LocationMatcher( filepath, 1, 18 ), + LocationMatcher( filepath, 1, 18 ) ), + # More newlines. Who doesn't like newlines?! + ChunkMatcher( '\n\n', + LocationMatcher( filepath, 1, 18 ), + LocationMatcher( filepath, 1, 18 ) ), + # For reasons known only to the eclipse JDT developers, it + # seems to want to delete the lines after the package first. + ChunkMatcher( '', + LocationMatcher( filepath, 1, 18 ), + LocationMatcher( filepath, 3, 1 ) ), + ), + } ), + has_entries( { + 'text': "Create field 'Wibble'", + 'chunks': contains ( + ChunkMatcher( '\n\n', + LocationMatcher( filepath, 16, 4 ), + LocationMatcher( filepath, 16, 4 ) ), + ChunkMatcher( 'private Object Wibble;', + LocationMatcher( filepath, 16, 4 ), + LocationMatcher( filepath, 16, 4 ) ), + ), + } ), + has_entries( { + 'text': "Create constant 'Wibble'", + 'chunks': contains ( + ChunkMatcher( '\n\n', + LocationMatcher( filepath, 16, 4 ), + LocationMatcher( filepath, 16, 4 ) ), + ChunkMatcher( 'private static final String Wibble = null;', + LocationMatcher( filepath, 16, 4 ), + LocationMatcher( filepath, 16, 4 ) ), + ), + } ), + has_entries( { + 'text': "Create parameter 'Wibble'", + 'chunks': contains ( + ChunkMatcher( ', ', + LocationMatcher( filepath, 18, 32 ), + LocationMatcher( filepath, 18, 32 ) ), + ChunkMatcher( 'Object Wibble', + LocationMatcher( filepath, 18, 32 ), + LocationMatcher( filepath, 18, 32 ) ), + ), + } ), + has_entries( { + 'text': "Create local variable 'Wibble'", + 'chunks': contains ( + ChunkMatcher( 'Object Wibble;', + LocationMatcher( filepath, 19, 5 ), + LocationMatcher( filepath, 19, 5 ) ), + ChunkMatcher( '\n ', + LocationMatcher( filepath, 19, 5 ), + LocationMatcher( filepath, 19, 5 ) ), + ), + } ), + ) + } ) + + yield ( RunFixItTest, 'FixIt works at the first char of the line', + filepath, 19, 1, fixits_for_line ) + + yield ( RunFixItTest, 'FixIt works at the begin of the range of the diag.', + filepath, 19, 15, fixits_for_line ) + + yield ( RunFixItTest, 'FixIt works at the end of the range of the diag.', + filepath, 19, 20, fixits_for_line ) + + yield ( RunFixItTest, 'FixIt works at the end of line', + filepath, 19, 34, fixits_for_line ) + + +def Subcommands_FixIt_SingleDiag_SingleOption_Modify_test(): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestFactory.java' ) + + # TODO: As there is only one option, we automatically apply it. + # In Java case this might not be the right thing. It's a code assist, not a + # FixIt really. Perhaps we should change the client to always ask for + # confirmation? + fixits = has_entries ( { + 'fixits': contains( + has_entries( { + 'text': "Change type of 'test' to 'boolean'", + 'chunks': contains( + # For some reason, eclipse returns modifies as deletes + adds, + # although overlapping ranges aren't allowed. + ChunkMatcher( 'boolean', + LocationMatcher( filepath, 14, 12 ), + LocationMatcher( filepath, 14, 12 ) ), + ChunkMatcher( '', + LocationMatcher( filepath, 14, 12 ), + LocationMatcher( filepath, 14, 15 ) ), + ), + } ), + ) + } ) + + yield ( RunFixItTest, 'FixIts can change lines as well as add them', + filepath, 27, 12, fixits ) + + +def Subcommands_FixIt_SingleDiag_MultiOption_Delete_test(): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestFactory.java' ) + + fixits = has_entries ( { + 'fixits': contains_inanyorder( + has_entries( { + 'text': "Remove 'testString', keep assignments with side effects", + 'chunks': contains( + ChunkMatcher( '', + LocationMatcher( filepath, 14, 21 ), + LocationMatcher( filepath, 15, 5 ) ), + ChunkMatcher( '', + LocationMatcher( filepath, 15, 5 ), + LocationMatcher( filepath, 15, 30 ) ), + ), + } ), + has_entries( { + 'text': "Create getter and setter for 'testString'...", + # The edit reported for this is juge and uninteresting really. Manual + # testing can show that it works. This test is really about the previous + # FixIt (and nonetheless, the previous tests ensure that we correctly + # populate the chunks list; the contents all come from jdt.ls) + 'chunks': instance_of( list ) + } ), + ) + } ) + + yield ( RunFixItTest, 'FixIts can change lines as well as add them', + filepath, 15, 29, fixits ) + + +def Subcommands_FixIt_MultipleDiags_test(): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestFactory.java' ) + + fixits = has_entries ( { + 'fixits': contains( + has_entries( { + 'text': "Change type of 'test' to 'boolean'", + 'chunks': contains( + # For some reason, eclipse returns modifies as deletes + adds, + # although overlapping ranges aren't allowed. + ChunkMatcher( 'boolean', + LocationMatcher( filepath, 14, 12 ), + LocationMatcher( filepath, 14, 12 ) ), + ChunkMatcher( '', + LocationMatcher( filepath, 14, 12 ), + LocationMatcher( filepath, 14, 15 ) ), + ), + } ), + has_entries( { + 'text': "Remove argument to match 'doSomethingVaguelyUseful()'", + 'chunks': contains( + ChunkMatcher( '', + LocationMatcher( filepath, 30, 48 ), + LocationMatcher( filepath, 30, 50 ) ), + ), + } ), + has_entries( { + 'text': "Change method 'doSomethingVaguelyUseful()': Add parameter " + "'Bar'", + # Again, this produces quite a lot of fussy little changes (that + # actually lead to broken code, but we can't really help that), and + # having them in this test would just be brittle without proving + # anything about our code + 'chunks': instance_of( list ), + } ), + has_entries( { + 'text': "Create method 'doSomethingVaguelyUseful(Bar)' in type " + "'AbstractTestWidget'", + # Again, this produces quite a lot of fussy little changes (that + # actually lead to broken code, but we can't really help that), and + # having them in this test would just be brittle without proving + # anything about our code + 'chunks': instance_of( list ), + } ), + ) + } ) + + yield ( RunFixItTest, 'diags are merged in FixIt options - start of line', + filepath, 30, 1, fixits ) + yield ( RunFixItTest, 'diags are merged in FixIt options - start of diag 1', + filepath, 30, 10, fixits ) + yield ( RunFixItTest, 'diags are merged in FixIt options - end of diag 1', + filepath, 30, 15, fixits ) + yield ( RunFixItTest, 'diags are merged in FixIt options - start of diag 2', + filepath, 30, 23, fixits ) + yield ( RunFixItTest, 'diags are merged in FixIt options - end of diag 2', + filepath, 30, 46, fixits ) + yield ( RunFixItTest, 'diags are merged in FixIt options - end of line', + filepath, 30, 55, fixits ) + + +def Subcommands_FixIt_NoDiagnostics_test(): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestFactory.java' ) + + yield ( RunFixItTest, "no FixIts means you gotta code it yo' self", + filepath, 1, 1, has_entries( { 'fixits': empty() } ) ) diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestFactory.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestFactory.java index 8930c48336..b4df9a0323 100644 --- a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestFactory.java +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestFactory.java @@ -27,6 +27,7 @@ public AbstractTestWidget getWidget( String info ) { if ( b.test ) { w.doSomethingVaguelyUseful(); } + if ( b.test ) { w.doSomethingVaguelyUseful( b ); } return w; } } From 8e50a9d13521bfaf185703cca997fc50e65cfc81 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Fri, 15 Sep 2017 22:53:05 +0100 Subject: [PATCH 24/51] Refresh files on subcommands --- .../language_server/language_server_completer.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 1ece6d6f7f..83df7fea58 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -837,6 +837,8 @@ def GetHoverResponse( self, request_data ): if not self.ServerIsReady(): raise RuntimeError( 'Server is initialising. Please wait.' ) + self._RefreshFiles( request_data ) + request_id = self.GetConnection().NextRequestId() response = self.GetConnection().GetResponse( request_id, @@ -851,6 +853,8 @@ def GoToDeclaration( self, request_data ): if not self.ServerIsReady(): raise RuntimeError( 'Server is initialising. Please wait.' ) + self._RefreshFiles( request_data ) + request_id = self.GetConnection().NextRequestId() response = self.GetConnection().GetResponse( request_id, @@ -870,6 +874,8 @@ def GoToReferences( self, request_data ): if not self.ServerIsReady(): raise RuntimeError( 'Server is initialising. Please wait.' ) + self._RefreshFiles( request_data ) + request_id = self.GetConnection().NextRequestId() response = self.GetConnection().GetResponse( request_id, @@ -884,7 +890,6 @@ def CodeAction( self, request_data, args ): if not self.ServerIsReady(): raise RuntimeError( 'Server is initialising. Please wait.' ) - # FIXME: We (probably) need to do this for all such requests self._RefreshFiles( request_data ) line_num_ls = request_data[ 'line_num' ] - 1 @@ -952,6 +957,9 @@ def Rename( self, request_data, args ): raise ValueError( 'Please specify a new name to rename it to.\n' 'Usage: RefactorRename ' ) + + self._RefreshFiles( request_data ) + new_name = args[ 0 ] request_id = self.GetConnection().NextRequestId() From 041eab4141a07a46aff9f545f30704545dce667f Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Fri, 15 Sep 2017 23:15:24 +0100 Subject: [PATCH 25/51] Remove early testing script --- ycmd/completers/java/test/test_server.py | 47 ------------------------ 1 file changed, 47 deletions(-) delete mode 100755 ycmd/completers/java/test/test_server.py diff --git a/ycmd/completers/java/test/test_server.py b/ycmd/completers/java/test/test_server.py deleted file mode 100755 index 946568a0e2..0000000000 --- a/ycmd/completers/java/test/test_server.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python - -from __future__ import absolute_import -from __future__ import unicode_literals -from __future__ import print_function -from __future__ import division -# Other imports from `future` must be placed after SetUpPythonPath. - -import sys -import os - -# TODO: Java 8 required (validate this) -PATH_TO_YCMD = os.path.join( os.path.dirname( __file__ ), - '..', - '..', - '..', - '..' ) - -sys.path.insert( 0, os.path.abspath( PATH_TO_YCMD ) ) -from ycmd.server_utils import SetUpPythonPath -SetUpPythonPath() - -from future import standard_library -standard_library.install_aliases() -from builtins import * # noqa - -import logging - - -def SetUpLogging( log_level ): - numeric_level = getattr( logging, log_level.upper(), None ) - if not isinstance( numeric_level, int ): - raise ValueError( 'Invalid log level: %s' % log_level ) - - # Has to be called before any call to logging.getLogger() - logging.basicConfig( format = '%(asctime)s - %(levelname)s - %(message)s', - level = numeric_level ) - - -if __name__ == '__main__': - SetUpLogging( 'debug' ) - - from ycmd.completers.java.hook import GetCompleter - from ycmd.user_options_store import DefaultOptions - completer = GetCompleter( DefaultOptions() ) - - completer.Shutdown() From 6d9bce04146521659630202e6228f9a2e251765e Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Fri, 15 Sep 2017 23:15:48 +0100 Subject: [PATCH 26/51] Use the kind from LSP directly. CLients can do with this what they desire --- .../language_server_completer.py | 49 +++++++------------ ycmd/tests/java/get_completions_test.py | 49 ++++++++++++++----- 2 files changed, 54 insertions(+), 44 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 83df7fea58..44f5def315 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -548,39 +548,26 @@ def ComputeCandidatesInner( self, request_data ): def ResolveCompletionItems( self, items, request_data, do_resolve ): - _logger.debug( 'Completion Start: {0}'.format( request_data[ - 'start_codepoint' ] ) ) - # Note Vim only displays the first character, so we map them to the - # documented Vim kinds: - # - # v variable - # f function or method - # m member of a struct or class - # t typedef - # d #define or macro - # - # FIXME: I'm not happy with this completely. We're losing useful info, - # perhaps unnecessarily. ITEM_KIND = [ None, # 1-based - 'd', # 'Text', - 'f', # 'Method', - 'f', # 'Function', - 'f', # 'Constructor', - 'm', # 'Field', - 'm', # 'Variable', - 't', # 'Class', - 't', # 'Interface', - 't', # 'Module', - 't', # 'Property', - 't', # 'Unit', - 'd', # 'Value', - 't', # 'Enum', - 'd', # 'Keyword', - 'd', # 'Snippet', - 'd', # 'Color', - 'd', # 'File', - 'd', # 'Reference', + 'Text', + 'Method', + 'Function', + 'Constructor', + 'Field', + 'Variable', + 'Class', + 'Interface', + 'Module', + 'Property', + 'Unit', + 'Value', + 'Enum', + 'Keyword', + 'Snippet', + 'Color', + 'File', + 'Reference', ] completions = list() diff --git a/ycmd/tests/java/get_completions_test.py b/ycmd/tests/java/get_completions_test.py index 83a6793465..3a897d37e2 100644 --- a/ycmd/tests/java/get_completions_test.py +++ b/ycmd/tests/java/get_completions_test.py @@ -96,20 +96,23 @@ def RunTest( app, test ): OBJECT_METHODS = [ - CompletionEntryMatcher( 'equals', 'Object' ), - CompletionEntryMatcher( 'getClass', 'Object' ), - CompletionEntryMatcher( 'hashCode', 'Object' ), - CompletionEntryMatcher( 'notify', 'Object' ), - CompletionEntryMatcher( 'notifyAll', 'Object' ), - CompletionEntryMatcher( 'toString', 'Object' ), + CompletionEntryMatcher( 'equals', 'Object', { 'kind': 'Function' } ), + CompletionEntryMatcher( 'getClass', 'Object', { 'kind': 'Function' } ), + CompletionEntryMatcher( 'hashCode', 'Object', { 'kind': 'Function' } ), + CompletionEntryMatcher( 'notify', 'Object', { 'kind': 'Function' } ), + CompletionEntryMatcher( 'notifyAll', 'Object', { 'kind': 'Function' } ), + CompletionEntryMatcher( 'toString', 'Object', { 'kind': 'Function' } ), CompletionEntryMatcher( 'wait', 'Object', { 'menu_text': matches_regexp( 'wait\\(long .*, int .*\\) : void' ), + 'kind': 'Function', } ), CompletionEntryMatcher( 'wait', 'Object', { 'menu_text': matches_regexp( 'wait\\(long .*\\) : void' ), + 'kind': 'Function', } ), CompletionEntryMatcher( 'wait', 'Object', { 'menu_text': 'wait() : void', + 'kind': 'Function', } ), ] @@ -141,8 +144,12 @@ def GetCompletions_NoQuery_test( app ): 'data': has_entries( { 'completions': contains_inanyorder( *WithObjectMethods( - CompletionEntryMatcher( 'test', 'TestFactory.Bar' ), - CompletionEntryMatcher( 'testString', 'TestFactory.Bar' ) + CompletionEntryMatcher( 'test', 'TestFactory.Bar', { + 'kind': 'Field' + } ), + CompletionEntryMatcher( 'testString', 'TestFactory.Bar', { + 'kind': 'Field' + } ) ) ), 'errors': empty(), @@ -165,8 +172,12 @@ def GetCompletions_WithQuery_test( app ): 'response': requests.codes.ok, 'data': has_entries( { 'completions': contains_inanyorder( - CompletionEntryMatcher( 'test', 'TestFactory.Bar' ), - CompletionEntryMatcher( 'testString', 'TestFactory.Bar' ) + CompletionEntryMatcher( 'test', 'TestFactory.Bar', { + 'kind': 'Field' + } ), + CompletionEntryMatcher( 'testString', 'TestFactory.Bar', { + 'kind': 'Field' + } ) ), 'errors': empty(), } ) @@ -189,7 +200,9 @@ def GetCompletions_Package_test( app ): 'data': has_entries( { 'completion_start_column': 9, 'completions': contains( - CompletionEntryMatcher( 'com.test.wobble', None ), + CompletionEntryMatcher( 'com.test.wobble', None, { + 'kind': 'Module' + } ), ), 'errors': empty(), } ) @@ -213,7 +226,8 @@ def GetCompletions_Import_Class_test( app ): 'completion_start_column': 34, 'completions': contains( CompletionEntryMatcher( 'Tset;', None, { - 'menu_text': 'Tset - com.youcompleteme.testing' + 'menu_text': 'Tset - com.youcompleteme.testing', + 'kind': 'Class', } ) ), 'errors': empty(), @@ -240,15 +254,19 @@ def GetCompletions_Import_Classes_test( app ): 'completions': contains( CompletionEntryMatcher( 'A;', None, { 'menu_text': 'A - com.test.wobble', + 'kind': 'Class', } ), CompletionEntryMatcher( 'A_Very_Long_Class_Here;', None, { 'menu_text': 'A_Very_Long_Class_Here - com.test.wobble', + 'kind': 'Class', } ), CompletionEntryMatcher( 'Waggle;', None, { 'menu_text': 'Waggle - com.test.wobble', + 'kind': 'Class', } ), CompletionEntryMatcher( 'Wibble;', None, { 'menu_text': 'Wibble - com.test.wobble', + 'kind': 'Class', } ), ), 'errors': empty(), @@ -275,9 +293,11 @@ def GetCompletions_Import_ModuleAndClass_test( app ): 'completions': contains( CompletionEntryMatcher( 'testing.*;', None, { 'menu_text': 'com.youcompleteme.testing', + 'kind': 'Module', } ), CompletionEntryMatcher( 'Test;', None, { 'menu_text': 'Test - com.youcompleteme', + 'kind': 'Class', } ), ), 'errors': empty(), @@ -304,6 +324,7 @@ def GetCompletions_WithFixIt_test( app ): 'completions': contains_inanyorder( CompletionEntryMatcher( 'CUTHBERT', 'com.test.wobble.Wibble', { + 'kind': 'Field', 'extra_data': has_entries( { 'fixits': contains( has_entries( { 'chunks': contains( @@ -360,7 +381,9 @@ def GetCompletions_RejectMultiLineInsertion_test( app ): 'data': has_entries( { 'completion_start_column': 16, 'completions': contains( - CompletionEntryMatcher( 'TestLauncher', 'com.test.TestLauncher' ) + CompletionEntryMatcher( 'TestLauncher', 'com.test.TestLauncher', { + 'kind': 'Constructor' + } ) # Note: There would be a suggestion here for the _real_ thing we want, # which is a TestLauncher.Launchable, but this would generate the code # for an anonymous inner class via a completion TextEdit (not From c346d0f4cd5f0c930b838b44eb9594d3bd3b2f4e Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Fri, 15 Sep 2017 23:21:45 +0100 Subject: [PATCH 27/51] Code style and clarity Tidy some FIXME/TODO Fix 80 chars in README.md Remove unused logging --- README.md | 17 +++++++++-------- .../language_server_completer.py | 11 ++++------- ycmd/tests/java/subcommands_test.py | 3 --- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7eba100b21..ceb3940881 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,9 @@ Building **If you're looking to develop ycmd, see the [instructions for setting up a dev environment][dev-setup] and for [running the tests][test-setup].** -This is all for Ubuntu Linux. Details on getting ycmd running on other OS's can be -found in [YCM's instructions][ycm-install] (ignore the Vim-specific parts). Note -that **ycmd runs on Python 2.6, 2.7 and 3.3+.** +This is all for Ubuntu Linux. Details on getting ycmd running on other OS's can +be found in [YCM's instructions][ycm-install] (ignore the Vim-specific parts). +Note that **ycmd runs on Python 2.6, 2.7 and 3.3+.** First, install the minimal dependencies: ``` @@ -74,7 +74,8 @@ API notes header. The HMAC is computed from the shared secret passed to the server on startup and the request/response body. The digest algorithm is SHA-256. The server will also include the HMAC in its responses; you _must_ verify it - before using the response. See [`example_client.py`][example-client] to see how it's done. + before using the response. See [`example_client.py`][example-client] to see + how it's done. How ycmd works -------------- @@ -86,10 +87,10 @@ provided previously and any tags files produced by ctags. This engine is non-semantic. There are also several semantic engines in YCM. There's a libclang-based -completer that provides semantic completion for C-family languages. There's also a -Jedi-based completer for semantic completion for Python, an OmniSharp-based -completer for C#, a [Gocode][gocode]-based completer for Go (using [Godef][godef] -for jumping to definitions), and a TSServer-based +completer that provides semantic completion for C-family languages. There's +also a Jedi-based completer for semantic completion for Python, an +OmniSharp-based completer for C#, a [Gocode][gocode]-based completer for Go +(using [Godef][godef] for jumping to definitions), and a TSServer-based completer for TypeScript. More will be added with time. There are also other completion engines, like the filepath completer (part of diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 44f5def315..4f817478d2 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -751,12 +751,10 @@ def _RefreshFiles( self, request_data ): file_data[ 'contents' ] ) else: # FIXME: DidChangeTextDocument doesn't actually do anything different - # from DidOpenTextDocument because we don't actually have a mechanism - # for generating the diffs (which would just be a waste of time) - # - # One option would be to just replace the entire file, but some - # servers (I'm looking at you javac completer) don't update - # diagnostics until you open or save a document. Sigh. + # from DidOpenTextDocument other than send the right message, because + # we don't actually have a mechanism for generating the diffs or + # proper document versioning or lifecycle management. This isn't + # strictly necessary, but might lead to performance problems. msg = lsapi.DidChangeTextDocument( file_name, file_data[ 'filetypes' ], file_data[ 'contents' ] ) @@ -771,7 +769,6 @@ def _RefreshFiles( self, request_data ): # We can't change the dictionary entries while using iterkeys, so we do # that in a separate loop. - # TODO(Ben): Isn't there a client->server event when a buffer is closed? for file_name in stale_files: msg = lsapi.DidCloseTextDocument( file_name ) self.GetConnection().SendNotification( msg ) diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py index 58ad3b27a6..64194e8050 100644 --- a/ycmd/tests/java/subcommands_test.py +++ b/ycmd/tests/java/subcommands_test.py @@ -33,7 +33,6 @@ from nose.tools import eq_ from pprint import pformat import requests -import logging from ycmd.utils import ReadFile from ycmd.completers.java.java_completer import NO_DOCUMENTATION_MESSAGE @@ -47,8 +46,6 @@ ErrorMatcher, LocationMatcher ) -_logger = logging.getLogger( __name__ ) - @SharedYcmd def Subcommands_DefinedSubcommands_test( app ): From 6b1f990043915e2aa73d05b4c9d2800807cf3886 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sat, 16 Sep 2017 17:13:27 +0100 Subject: [PATCH 28/51] Support changing projects by restarting the completer server --- ycmd/completers/java/java_completer.py | 25 ++-- ycmd/tests/java/__init__.py | 48 +++++-- ycmd/tests/java/diagnostics_test.py | 36 +----- ycmd/tests/java/subcommands_test.py | 165 +++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 56 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index fb21427a39..1a48259b24 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -180,6 +180,12 @@ def __init__( self, user_options ): self._Reset() try : + # When we start the server initially, we don't have the request data, so + # we use the ycmd working directory. The RestartServer subcommand uses + # the client's working directory if it is supplied. + # + # FIXME: We could start the server in the FileReadyToParse event, though + # this requires some additional complexity and state management. self._StartServer() except: _logger.exception( "The java language server failed to start." ) @@ -215,11 +221,11 @@ def GetSubcommandsMap( self ): # Handled by us 'RestartServer': ( - lambda self, request_data, args: self._RestartServer( - ) ), + lambda self, request_data, args: self._RestartServer( request_data ) + ), 'StopServer': ( - lambda self, request_data, args: self._StopServer( - ) ), + lambda self, request_data, args: self._StopServer() + ), 'GetDoc': ( lambda self, request_data, args: self.GetDoc( request_data ) ), @@ -287,10 +293,10 @@ def _ServerIsRunning( self ): return utils.ProcessIsRunning( self._server_handle ) - def _RestartServer( self ): + def _RestartServer( self, request_data ): with self._server_state_mutex: self._StopServer() - self._StartServer() + self._StartServer( request_data.get( 'working_dir' ) ) def _Reset( self ): @@ -318,11 +324,12 @@ def _Reset( self ): self.ServerReset() - def _StartServer( self ): + def _StartServer( self, working_dir=None ): with self._server_state_mutex: _logger.info( 'Starting JDT Language Server...' ) - self._project_dir = _FindProjectDir( utils.GetCurrentDirectory() ) + self._project_dir = _FindProjectDir( + working_dir if working_dir else utils.GetCurrentDirectory() ) self._workspace_path = _WorkspaceDirForProject( self._project_dir, self._use_clean_workspace ) @@ -392,7 +399,7 @@ def _StopServer( self ): # Tell the server to exit using the shutdown request. self._StopServerCleanly() - # If the server is still running, e.g. due to erros, kill it + # If the server is still running, e.g. due to errors, kill it self._StopServerForecefully() # Tidy up our internal state diff --git a/ycmd/tests/java/__init__.py b/ycmd/tests/java/__init__.py index f5dd1c8293..facf436d78 100644 --- a/ycmd/tests/java/__init__.py +++ b/ycmd/tests/java/__init__.py @@ -25,9 +25,11 @@ import functools import os import time +from pprint import pformat from ycmd import handlers -from ycmd.tests.test_utils import ( ClearCompletionsCache, +from ycmd.tests.test_utils import ( BuildRequest, + ClearCompletionsCache, CurrentWorkingDirectory, SetUpApp, StopCompleterServer ) @@ -83,17 +85,6 @@ def Wrapper( *args, **kwargs ): return Wrapper -def IsolatedYcmd( test ): - """Defines a decorator to be attached to tests of this package. This decorator - passes a unique ycmd application as a parameter. It should be used on tests - that change the server state in a irreversible way (ex: a semantic subserver - is stopped or restarted) or expect a clean state (ex: no semantic subserver - started, no .ycm_extra_conf.py loaded, etc). - - Do NOT attach it to test generators but directly to the yielded tests.""" - return IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) - - def IsolatedYcmdInDirectory( directory ): """Defines a decorator to be attached to tests of this package. This decorator passes a unique ycmd application as a parameter running in the directory @@ -131,3 +122,36 @@ def WaitUntilCompleterServerReady( app, timeout = 30 ): return time.sleep( 0.5 ) + + +def PollForMessages( app, request_data ): + TIMEOUT = 30 + expiration = time.time() + TIMEOUT + while True: + if time.time() > expiration: + raise RuntimeError( 'Waited for diagnostics to be ready for ' + '{0} seconds, aborting.'.format( TIMEOUT ) ) + + default_args = { + 'filetype' : 'java', + 'line_num' : 1, + 'column_num': 1, + } + args = dict( default_args ) + args.update( request_data ) + + response = app.post_json( '/receive_messages', BuildRequest( **args ) ).json + + print( 'poll response: {0}'.format( pformat( response ) ) ) + + if isinstance( response, bool ): + if not response: + raise RuntimeError( 'The message poll was aborted by the server' ) + elif isinstance( response, list ): + for message in response: + yield message + else: + raise AssertionError( 'Message poll response was wrong type: {0}'.format( + type( response ).__name__ ) ) + + time.sleep( 0.25 ) diff --git a/ycmd/tests/java/diagnostics_test.py b/ycmd/tests/java/diagnostics_test.py index 18ed3f3320..a158fe34f2 100644 --- a/ycmd/tests/java/diagnostics_test.py +++ b/ycmd/tests/java/diagnostics_test.py @@ -29,6 +29,7 @@ from ycmd.tests.java import ( DEFAULT_PROJECT_DIR, IsolatedYcmdInDirectory, PathToTestFile, + PollForMessages, SharedYcmd, WaitUntilCompleterServerReady ) @@ -36,7 +37,6 @@ LocationMatcher ) from ycmd.utils import ReadFile -import time from pprint import pformat @@ -154,40 +154,6 @@ def ProjectPath( *args ): } -def Merge( request, data ): - kw = dict( request ) - kw.update( data ) - return kw - - -def PollForMessages( app, request_data ): - TIMEOUT = 30 - expiration = time.time() + TIMEOUT - while True: - if time.time() > expiration: - raise RuntimeError( 'Waited for diagnostics to be ready for ' - '{0} seconds, aborting.'.format( TIMEOUT ) ) - - response = app.post_json( '/receive_messages', BuildRequest( **Merge ( { - 'filetype' : 'java', - 'line_num' : 1, - 'column_num': 1, - }, request_data ) ) ).json - - print( 'poll response: {0}'.format( pformat( response ) ) ) - - if isinstance( response, bool ): - if not response: - raise RuntimeError( 'The message poll was aborted by the server' ) - elif isinstance( response, list ): - for message in response: - yield message - else: - raise AssertionError( 'Message poll response was wrong type' ) - - time.sleep( 0.25 ) - - @SharedYcmd def FileReadyToParse_Diagnostics_Simple_test( app ): filepath = ProjectPath( 'TestFactory.java' ) diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py index 64194e8050..b80da6935f 100644 --- a/ycmd/tests/java/subcommands_test.py +++ b/ycmd/tests/java/subcommands_test.py @@ -28,6 +28,7 @@ contains_inanyorder, empty, has_entries, + has_entry, instance_of, ) from nose.tools import eq_ @@ -834,3 +835,167 @@ def Subcommands_FixIt_NoDiagnostics_test(): yield ( RunFixItTest, "no FixIts means you gotta code it yo' self", filepath, 1, 1, has_entries( { 'fixits': empty() } ) ) + + +@IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project' ) ) +def Subcommands_RestartServer_test( app ): + WaitUntilCompleterServerReady( app ) + + eclipse_project = PathToTestFile( 'simple_eclipse_project' ) + maven_project = PathToTestFile( 'simple_maven_project' ) + + # Run the debug info to check that we have the correct project dir + request_data = BuildRequest( filetype = 'java' ) + assert_that( + app.post_json( '/debug_info', request_data ).json, + has_entry( 'completer', has_entries( { + 'name': 'Java', + 'servers': contains( has_entries( { + 'name': 'Java Language Server', + 'is_running': instance_of( bool ), + 'executable': instance_of( str ), + 'pid': instance_of( int ), + 'logfiles': contains( instance_of( str ), + instance_of( str ) ), + 'extras': contains( + has_entries( { 'key': 'Java Path', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Launcher Config.', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Project Directory', + 'value': eclipse_project } ), + has_entries( { 'key': 'Workspace Path', + 'value': instance_of( str ) } ) + ) + } ) ) + } ) ) + ) + + # Restart the server with a different client working directory + filepath = PathToTestFile( 'simple_maven_project', + 'src', + 'main', + 'java', + 'com', + 'test', + 'TestFactory.java' ) + + app.post_json( + '/run_completer_command', + BuildRequest( + filepath = filepath, + filetype = 'java', + working_dir = maven_project, + command_arguments = [ 'RestartServer' ], + ), + ) + + WaitUntilCompleterServerReady( app ) + + app.post_json( + '/event_notification', + BuildRequest( + filepath = filepath, + filetype = 'java', + working_dir = maven_project, + event_name = 'FileReadyToParse', + ) + ) + + # Run the debug info to check that we have the correct project dir + request_data = BuildRequest( filetype = 'java' ) + assert_that( + app.post_json( '/debug_info', request_data ).json, + has_entry( 'completer', has_entries( { + 'name': 'Java', + 'servers': contains( has_entries( { + 'name': 'Java Language Server', + 'is_running': instance_of( bool ), + 'executable': instance_of( str ), + 'pid': instance_of( int ), + 'logfiles': contains( instance_of( str ), + instance_of( str ) ), + 'extras': contains( + has_entries( { 'key': 'Java Path', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Launcher Config.', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Project Directory', + 'value': maven_project } ), + has_entries( { 'key': 'Workspace Path', + 'value': instance_of( str ) } ) + ) + } ) ) + } ) ) + ) + + +@IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project', 'src' ) ) +def Subcommands_ProjectDetection_EclipseParent( app ): + WaitUntilCompleterServerReady( app ) + + project = PathToTestFile( 'simple_eclipse_project' ) + + # Run the debug info to check that we have the correct project dir + request_data = BuildRequest( filetype = 'java' ) + assert_that( + app.post_json( '/debug_info', request_data ).json, + has_entry( 'completer', has_entries( { + 'name': 'Java', + 'servers': contains( has_entries( { + 'name': 'Java Language Server', + 'is_running': instance_of( bool ), + 'executable': instance_of( str ), + 'pid': instance_of( int ), + 'logfiles': contains( instance_of( str ), + instance_of( str ) ), + 'extras': contains( + has_entries( { 'key': 'Java Path', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Launcher Config.', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Project Directory', + 'value': project } ), + has_entries( { 'key': 'Workspace Path', + 'value': instance_of( str ) } ) + ) + } ) ) + } ) ) + ) + + +@IsolatedYcmdInDirectory( PathToTestFile( 'simple_maven_project', + 'src', + 'java', + 'test' ) ) +def Subcommands_ProjectDetection_MavenParent( app ): + WaitUntilCompleterServerReady( app ) + + project = PathToTestFile( 'simple_maven_project' ) + + # Run the debug info to check that we have the correct project dir + request_data = BuildRequest( filetype = 'java' ) + assert_that( + app.post_json( '/debug_info', request_data ).json, + has_entry( 'completer', has_entries( { + 'name': 'Java', + 'servers': contains( has_entries( { + 'name': 'Java Language Server', + 'is_running': instance_of( bool ), + 'executable': instance_of( str ), + 'pid': instance_of( int ), + 'logfiles': contains( instance_of( str ), + instance_of( str ) ), + 'extras': contains( + has_entries( { 'key': 'Java Path', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Launcher Config.', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Project Directory', + 'value': project } ), + has_entries( { 'key': 'Workspace Path', + 'value': instance_of( str ) } ) + ) + } ) ) + } ) ) + ) From 76edefecbb714c5742806fd3697bd4d88d59036d Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sat, 16 Sep 2017 18:52:02 +0100 Subject: [PATCH 29/51] More tests and fixes Add gradle test project Move server management tests to their own file. Add gradle test. GoTo tests Working tests for GoTo, GoToDeclaration and GoToDefinition. A test for unicode characters is missing. Fix KeyError bubbling to users when jdt.ls returns dodgy defnition Unicode tests, and fixes --- ycmd/completers/java/java_completer.py | 27 +- .../language_server_completer.py | 30 +- ycmd/completers/language_server/lsapi.py | 7 +- ycmd/tests/java/diagnostics_test.py | 39 +- ycmd/tests/java/get_completions_test.py | 43 +- ycmd/tests/java/server_management_test.py | 235 ++++++++++ ycmd/tests/java/subcommands_test.py | 403 ++++++++++-------- .../src/com/test/TestLauncher.java | 12 +- .../src/com/youcompleteme/Test.java | 21 + .../testdata/simple_gradle_project/.gitignore | 5 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54708 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + .../testdata/simple_gradle_project/gradlew | 172 ++++++++ .../simple_gradle_project/gradlew.bat | 84 ++++ .../simple_gradle_project/settings.gradle | 18 + .../java/com/test/AbstractTestWidget.java | 18 + .../src/main/java/com/test/TestFactory.java | 17 + .../src/main/java/com/test/TestLauncher.java | 17 + .../main/java/com/test/TestWidgetImpl.java | 27 ++ .../src/test/java/com/test/AppTest.java | 38 ++ 20 files changed, 1008 insertions(+), 210 deletions(-) create mode 100644 ycmd/tests/java/server_management_test.py create mode 100644 ycmd/tests/java/testdata/simple_gradle_project/.gitignore create mode 100644 ycmd/tests/java/testdata/simple_gradle_project/gradle/wrapper/gradle-wrapper.jar create mode 100644 ycmd/tests/java/testdata/simple_gradle_project/gradle/wrapper/gradle-wrapper.properties create mode 100755 ycmd/tests/java/testdata/simple_gradle_project/gradlew create mode 100644 ycmd/tests/java/testdata/simple_gradle_project/gradlew.bat create mode 100644 ycmd/tests/java/testdata/simple_gradle_project/settings.gradle create mode 100644 ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/AbstractTestWidget.java create mode 100644 ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/TestFactory.java create mode 100644 ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/TestLauncher.java create mode 100644 ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/TestWidgetImpl.java create mode 100644 ycmd/tests/java/testdata/simple_gradle_project/src/test/java/com/test/AppTest.java diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 1a48259b24..09a5f15e9c 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -471,17 +471,16 @@ def GetType( self, request_data ): hover_response = self.GetHoverResponse( request_data ) if isinstance( hover_response, list ): - if len( hover_response ): - try: - get_type_java = hover_response[ 0 ][ 'value' ] - except( TypeError ): - raise RuntimeError( 'No information' ) - else: + if not len( hover_response ): + raise RuntimeError( 'No information' ) + + try: + get_type_java = hover_response[ 0 ][ 'value' ] + except( TypeError ): raise RuntimeError( 'No information' ) else: get_type_java = hover_response - return responses.BuildDisplayMessageResponse( get_type_java ) @@ -489,19 +488,19 @@ def GetDoc( self, request_data ): hover_response = self.GetHoverResponse( request_data ) if isinstance( hover_response, list ): - if len( hover_response ): - get_doc_java = '' - for docstring in hover_response: - if not isinstance( docstring, dict ): - get_doc_java += docstring + '\n' - else: + if not len( hover_response ): raise RuntimeError( 'No information' ) + + get_doc_java = '' + for docstring in hover_response: + if not isinstance( docstring, dict ): + get_doc_java += docstring + '\n' else: get_doc_java = hover_response get_doc_java = get_doc_java.rstrip() - if get_doc_java == '': + if not get_doc_java: raise ValueError( NO_DOCUMENTATION_MESSAGE ) return responses.BuildDisplayMessageResponse( get_doc_java.rstrip() ) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 4f817478d2..ad00baff5d 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -850,8 +850,11 @@ def GoToDeclaration( self, request_data ): return LocationListToGoTo( request_data, response ) else: position = response[ 'result' ] - return responses.BuildGoToResponseFromLocation( - PositionToLocation( request_data, position ) ) + try: + return responses.BuildGoToResponseFromLocation( + PositionToLocation( request_data, position ) ) + except KeyError: + raise RuntimeError( 'Cannot jump to location' ) def GoToReferences( self, request_data ): @@ -1088,7 +1091,9 @@ def LocationListToGoTo( request_data, response ): position = response[ 'result' ][ 0 ] return responses.BuildGoToResponseFromLocation( PositionToLocation( request_data, position ) ) - except( IndexError ): + except IndexError: + raise RuntimeError( 'Cannot jump to location' ) + except KeyError: raise RuntimeError( 'Cannot jump to location' ) @@ -1099,12 +1104,19 @@ def PositionToLocation( request_data, position ): def BuildLocation( request_data, filename, loc ): - line_contents = utils.SplitLines( GetFileContents( request_data, filename ) ) - return responses.Location( - line = loc[ 'line' ] + 1, - column = utils.CodepointOffsetToByteOffset( line_contents, - loc[ 'character' ] + 1 ), - filename = filename ) + file_contents = utils.SplitLines( GetFileContents( request_data, filename ) ) + try: + line_value = file_contents[ loc[ 'line' ] ] + column = utils.CodepointOffsetToByteOffset( line_value, + loc[ 'character' ] + 1 ) + except IndexError: + # This can happen when there are stale diagnostics in OnFileReadyToParse, + # just return the value as-is. + column = loc[ 'character' ] + 1 + + return responses.Location( loc[ 'line' ] + 1, + column, + filename = filename ) def BuildRange( request_data, filename, r ): diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index 7602289dc1..c513a93c34 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -117,7 +117,10 @@ def Completion( request_id, request_data ): 'textDocument': { 'uri': FilePathToUri( request_data[ 'filepath' ] ), }, - 'position': Position( request_data ), + 'position': { + 'line': request_data[ 'line_num' ] - 1, + 'character': request_data[ 'start_codepoint' ] - 1, + } } ) @@ -178,7 +181,7 @@ def Position( request_data ): # The API requires 0-based unicode offsets. return { 'line': request_data[ 'line_num' ] - 1, - 'character': request_data[ 'start_codepoint' ] - 1, + 'character': request_data[ 'column_codepoint' ] - 1, } diff --git a/ycmd/tests/java/diagnostics_test.py b/ycmd/tests/java/diagnostics_test.py index a158fe34f2..8861ef7d43 100644 --- a/ycmd/tests/java/diagnostics_test.py +++ b/ycmd/tests/java/diagnostics_test.py @@ -1,4 +1,5 @@ # Copyright (C) 2017 ycmd contributors +# encoding: utf-8 # # This file is part of ycmd. # @@ -58,6 +59,11 @@ def ProjectPath( *args ): TestFactory = ProjectPath( 'TestFactory.java' ) TestLauncher = ProjectPath( 'TestLauncher.java' ) TestWidgetImpl = ProjectPath( 'TestWidgetImpl.java' ) +youcompleteme_Test = PathToTestFile( DEFAULT_PROJECT_DIR, + 'src', + 'com', + 'youcompleteme', + 'Test.java' ) DIAG_MATCHERS_PER_FILE = { TestFactory: contains_inanyorder( @@ -128,26 +134,41 @@ def ProjectPath( *args ): 'text': 'The type new TestLauncher.Launchable(){} must implement the ' 'inherited abstract method TestLauncher.Launchable.launch(' 'TestFactory)', - 'location': LocationMatcher( TestLauncher, 21, 16 ), - 'location_extent': RangeMatch( TestLauncher, ( 21, 16 ), ( 21, 28 ) ), - 'ranges': contains( RangeMatch( TestLauncher, ( 21, 16 ), ( 21, 28 ) ) ), + 'location': LocationMatcher( TestLauncher, 28, 16 ), + 'location_extent': RangeMatch( TestLauncher, ( 28, 16 ), ( 28, 28 ) ), + 'ranges': contains( RangeMatch( TestLauncher, ( 28, 16 ), ( 28, 28 ) ) ), 'fixit_available': False } ), has_entries( { 'kind': 'ERROR', 'text': 'The method launch() of type new TestLauncher.Launchable(){} ' 'must override or implement a supertype method', - 'location': LocationMatcher( TestLauncher, 23, 19 ), - 'location_extent': RangeMatch( TestLauncher, ( 23, 19 ), ( 23, 27 ) ), - 'ranges': contains( RangeMatch( TestLauncher, ( 23, 19 ), ( 23, 27 ) ) ), + 'location': LocationMatcher( TestLauncher, 30, 19 ), + 'location_extent': RangeMatch( TestLauncher, ( 30, 19 ), ( 30, 27 ) ), + 'ranges': contains( RangeMatch( TestLauncher, ( 30, 19 ), ( 30, 27 ) ) ), 'fixit_available': False } ), has_entries( { 'kind': 'ERROR', 'text': 'Cannot make a static reference to the non-static field factory', - 'location': LocationMatcher( TestLauncher, 24, 32 ), - 'location_extent': RangeMatch( TestLauncher, ( 24, 32 ), ( 24, 39 ) ), - 'ranges': contains( RangeMatch( TestLauncher, ( 24, 32 ), ( 24, 39 ) ) ), + 'location': LocationMatcher( TestLauncher, 31, 32 ), + 'location_extent': RangeMatch( TestLauncher, ( 31, 32 ), ( 31, 39 ) ), + 'ranges': contains( RangeMatch( TestLauncher, ( 31, 32 ), ( 31, 39 ) ) ), + 'fixit_available': False + } ), + ), + youcompleteme_Test: contains( + has_entries( { + 'kind': 'ERROR', + 'text': 'The method doUnicødeTes() in the type Test is not applicable ' + 'for the arguments (String)', + 'location': LocationMatcher( youcompleteme_Test, 13, 10 ), + 'location_extent': RangeMatch( youcompleteme_Test, + ( 13, 10 ), + ( 13, 23 ) ), + 'ranges': contains( RangeMatch( youcompleteme_Test, + ( 13, 10 ), + ( 13, 23 ) ) ), 'fixit_available': False } ), ), diff --git a/ycmd/tests/java/get_completions_test.py b/ycmd/tests/java/get_completions_test.py index 3a897d37e2..03396d5d1e 100644 --- a/ycmd/tests/java/get_completions_test.py +++ b/ycmd/tests/java/get_completions_test.py @@ -1,4 +1,5 @@ # Copyright (C) 2017 ycmd contributors +# encoding: utf-8 # # This file is part of ycmd. # @@ -95,7 +96,7 @@ def RunTest( app, test ): assert_that( response.json, test[ 'expect' ][ 'data' ] ) -OBJECT_METHODS = [ +PUBLIC_OBJECT_METHODS = [ CompletionEntryMatcher( 'equals', 'Object', { 'kind': 'Function' } ), CompletionEntryMatcher( 'getClass', 'Object', { 'kind': 'Function' } ), CompletionEntryMatcher( 'hashCode', 'Object', { 'kind': 'Function' } ), @@ -126,7 +127,7 @@ def RunTest( app, test ): # # and focus on what we care about. def WithObjectMethods( *args ): - return list( OBJECT_METHODS ) + list( args ) + return list( PUBLIC_OBJECT_METHODS ) + list( args ) @SharedYcmd @@ -372,7 +373,7 @@ def GetCompletions_RejectMultiLineInsertion_test( app ): 'request': { 'filetype' : 'java', 'filepath' : filepath, - 'line_num' : 21, + 'line_num' : 28, 'column_num' : 16, 'force_semantic': True }, @@ -393,3 +394,39 @@ def GetCompletions_RejectMultiLineInsertion_test( app ): } ) }, } ) + + +@SharedYcmd +def GetCompletions_UnicodeIdentifier_test( app ): + filepath = PathToTestFile( DEFAULT_PROJECT_DIR, + 'src', + 'com', + 'youcompleteme', + 'Test.java' ) + RunTest( app, { + 'description': 'Completion works for unicode identifier', + 'request': { + 'filetype' : 'java', + 'filepath' : filepath, + 'line_num' : 16, + 'column_num' : 35, + 'force_semantic': True + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries( { + 'completion_start_column': 35, + 'completions': contains_inanyorder( *WithObjectMethods( + CompletionEntryMatcher( 'a_test', 'Test.TéstClass', { + 'kind': 'Field', + 'detailed_info': 'a_test : int\n\n', + } ), + CompletionEntryMatcher( 'testywesty', 'Test.TéstClass', { + 'kind': 'Field', + 'detailed_info': 'testywesty : String\n\nTest in the west ', + } ), + ) ), + 'errors': empty(), + } ) + }, + } ) diff --git a/ycmd/tests/java/server_management_test.py b/ycmd/tests/java/server_management_test.py new file mode 100644 index 0000000000..8b1da818a2 --- /dev/null +++ b/ycmd/tests/java/server_management_test.py @@ -0,0 +1,235 @@ +# Copyright (C) 2015 ycmd contributors +# encoding: utf-8 +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import absolute_import +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +from hamcrest import ( assert_that, + contains, + has_entries, + has_entry, + instance_of ) +from ycmd.tests.java import ( PathToTestFile, + IsolatedYcmdInDirectory, + WaitUntilCompleterServerReady ) +from ycmd.tests.test_utils import BuildRequest + + +@IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project' ) ) +def Subcommands_RestartServer_test( app ): + WaitUntilCompleterServerReady( app ) + + eclipse_project = PathToTestFile( 'simple_eclipse_project' ) + maven_project = PathToTestFile( 'simple_maven_project' ) + + # Run the debug info to check that we have the correct project dir + request_data = BuildRequest( filetype = 'java' ) + assert_that( + app.post_json( '/debug_info', request_data ).json, + has_entry( 'completer', has_entries( { + 'name': 'Java', + 'servers': contains( has_entries( { + 'name': 'Java Language Server', + 'is_running': instance_of( bool ), + 'executable': instance_of( str ), + 'pid': instance_of( int ), + 'logfiles': contains( instance_of( str ), + instance_of( str ) ), + 'extras': contains( + has_entries( { 'key': 'Java Path', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Launcher Config.', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Project Directory', + 'value': eclipse_project } ), + has_entries( { 'key': 'Workspace Path', + 'value': instance_of( str ) } ) + ) + } ) ) + } ) ) + ) + + # Restart the server with a different client working directory + filepath = PathToTestFile( 'simple_maven_project', + 'src', + 'main', + 'java', + 'com', + 'test', + 'TestFactory.java' ) + + app.post_json( + '/run_completer_command', + BuildRequest( + filepath = filepath, + filetype = 'java', + working_dir = maven_project, + command_arguments = [ 'RestartServer' ], + ), + ) + + WaitUntilCompleterServerReady( app ) + + app.post_json( + '/event_notification', + BuildRequest( + filepath = filepath, + filetype = 'java', + working_dir = maven_project, + event_name = 'FileReadyToParse', + ) + ) + + # Run the debug info to check that we have the correct project dir + request_data = BuildRequest( filetype = 'java' ) + assert_that( + app.post_json( '/debug_info', request_data ).json, + has_entry( 'completer', has_entries( { + 'name': 'Java', + 'servers': contains( has_entries( { + 'name': 'Java Language Server', + 'is_running': instance_of( bool ), + 'executable': instance_of( str ), + 'pid': instance_of( int ), + 'logfiles': contains( instance_of( str ), + instance_of( str ) ), + 'extras': contains( + has_entries( { 'key': 'Java Path', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Launcher Config.', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Project Directory', + 'value': maven_project } ), + has_entries( { 'key': 'Workspace Path', + 'value': instance_of( str ) } ) + ) + } ) ) + } ) ) + ) + + +@IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project', 'src' ) ) +def Subcommands_ProjectDetection_EclipseParent( app ): + WaitUntilCompleterServerReady( app ) + + project = PathToTestFile( 'simple_eclipse_project' ) + + # Run the debug info to check that we have the correct project dir + request_data = BuildRequest( filetype = 'java' ) + assert_that( + app.post_json( '/debug_info', request_data ).json, + has_entry( 'completer', has_entries( { + 'name': 'Java', + 'servers': contains( has_entries( { + 'name': 'Java Language Server', + 'is_running': instance_of( bool ), + 'executable': instance_of( str ), + 'pid': instance_of( int ), + 'logfiles': contains( instance_of( str ), + instance_of( str ) ), + 'extras': contains( + has_entries( { 'key': 'Java Path', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Launcher Config.', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Project Directory', + 'value': project } ), + has_entries( { 'key': 'Workspace Path', + 'value': instance_of( str ) } ) + ) + } ) ) + } ) ) + ) + + +@IsolatedYcmdInDirectory( PathToTestFile( 'simple_maven_project', + 'src', + 'java', + 'test' ) ) +def Subcommands_ProjectDetection_MavenParent( app ): + WaitUntilCompleterServerReady( app ) + + project = PathToTestFile( 'simple_maven_project' ) + + # Run the debug info to check that we have the correct project dir + request_data = BuildRequest( filetype = 'java' ) + assert_that( + app.post_json( '/debug_info', request_data ).json, + has_entry( 'completer', has_entries( { + 'name': 'Java', + 'servers': contains( has_entries( { + 'name': 'Java Language Server', + 'is_running': instance_of( bool ), + 'executable': instance_of( str ), + 'pid': instance_of( int ), + 'logfiles': contains( instance_of( str ), + instance_of( str ) ), + 'extras': contains( + has_entries( { 'key': 'Java Path', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Launcher Config.', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Project Directory', + 'value': project } ), + has_entries( { 'key': 'Workspace Path', + 'value': instance_of( str ) } ) + ) + } ) ) + } ) ) + ) + + +@IsolatedYcmdInDirectory( PathToTestFile( 'simple_gradle_project', + 'src', + 'java', + 'test' ) ) +def Subcommands_ProjectDetection_GradleParent( app ): + WaitUntilCompleterServerReady( app ) + + project = PathToTestFile( 'simple_gradle_project' ) + + # Run the debug info to check that we have the correct project dir + request_data = BuildRequest( filetype = 'java' ) + assert_that( + app.post_json( '/debug_info', request_data ).json, + has_entry( 'completer', has_entries( { + 'name': 'Java', + 'servers': contains( has_entries( { + 'name': 'Java Language Server', + 'is_running': instance_of( bool ), + 'executable': instance_of( str ), + 'pid': instance_of( int ), + 'logfiles': contains( instance_of( str ), + instance_of( str ) ), + 'extras': contains( + has_entries( { 'key': 'Java Path', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Launcher Config.', + 'value': instance_of( str ) } ), + has_entries( { 'key': 'Project Directory', + 'value': project } ), + has_entries( { 'key': 'Workspace Path', + 'value': instance_of( str ) } ) + ) + } ) ) + } ) ) + ) diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py index b80da6935f..e733a058be 100644 --- a/ycmd/tests/java/subcommands_test.py +++ b/ycmd/tests/java/subcommands_test.py @@ -1,4 +1,5 @@ # Copyright (C) 2015 ycmd contributors +# encoding: utf-8 # # This file is part of ycmd. # @@ -22,15 +23,12 @@ # Not installing aliases from python-future; it's unreliable and slow. from builtins import * # noqa -from hamcrest import ( - assert_that, - contains, - contains_inanyorder, - empty, - has_entries, - has_entry, - instance_of, -) +from hamcrest import ( assert_that, + contains, + contains_inanyorder, + empty, + has_entries, + instance_of ) from nose.tools import eq_ from pprint import pformat import requests @@ -361,6 +359,36 @@ def Subcommands_GetType_Method_test( app ): } ) +@SharedYcmd +def Subcommands_GetType_Unicode_test( app ): + filepath = PathToTestFile( DEFAULT_PROJECT_DIR, + 'src', + 'com', + 'youcompleteme', + 'Test.java' ) + contents = ReadFile( filepath ) + + app.post_json( '/event_notification', + BuildRequest( filepath = filepath, + filetype = 'java', + contents = contents, + event_name = 'FileReadyToParse' ) ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 7, + column_num = 17, + contents = contents, + command_arguments = [ 'GetType' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', event_data ).json + + eq_( response, { + 'message': 'String whåtawîdgé - com.youcompleteme.Test.doUnicødeTes()' + } ) + + @SharedYcmd def Subcommands_GetType_LiteralValue_test( app ): filepath = PathToTestFile( 'simple_eclipse_project', @@ -389,6 +417,34 @@ def Subcommands_GetType_LiteralValue_test( app ): 'No information' ) ) +@IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) +def Subcommands_GoTo_NoLocation_test( app ): + WaitUntilCompleterServerReady( app ) + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'AbstractTestWidget.java' ) + contents = ReadFile( filepath ) + + event_data = BuildRequest( filepath = filepath, + filetype = 'java', + line_num = 18, + column_num = 1, + contents = contents, + command_arguments = [ 'GoTo' ], + completer_target = 'filetype_default' ) + + response = app.post_json( '/run_completer_command', + event_data, + expect_errors = True ) + + eq_( response.status_code, requests.codes.internal_server_error ) + + assert_that( response.json, + ErrorMatcher( RuntimeError, 'Cannot jump to location' ) ) + + @IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) def Subcommands_GoToReferences_NoReferences_test( app ): WaitUntilCompleterServerReady( app ) @@ -456,7 +512,7 @@ def Subcommands_GoToReferences_test( app ): 'TestLauncher.java' ), 'column_num': 11, # 'description': '', - 'line_num': 25 + 'line_num': 32 } ] ) @@ -473,7 +529,7 @@ def Subcommands_RefactorRename_Simple_test( app ): 'command': 'RefactorRename', 'arguments': [ 'renamed_l' ], 'filepath': filepath, - 'line_num': 21, + 'line_num': 28, 'column_num': 5, }, 'expect': { @@ -482,13 +538,13 @@ def Subcommands_RefactorRename_Simple_test( app ): 'fixits': contains( has_entries( { 'chunks': contains( ChunkMatcher( 'renamed_l', - LocationMatcher( filepath, 20, 18 ), - LocationMatcher( filepath, 20, 19 ) ), + LocationMatcher( filepath, 27, 18 ), + LocationMatcher( filepath, 27, 19 ) ), ChunkMatcher( 'renamed_l', - LocationMatcher( filepath, 21, 5 ), - LocationMatcher( filepath, 21, 6 ) ), + LocationMatcher( filepath, 28, 5 ), + LocationMatcher( filepath, 28, 6 ) ), ), - 'location': LocationMatcher( filepath, 21, 5 ) + 'location': LocationMatcher( filepath, 28, 5 ) } ) ) } ) } @@ -524,7 +580,7 @@ def Subcommands_RefactorRename_MultipleFiles_test( app ): 'command': 'RefactorRename', 'arguments': [ 'a-quite-long-string' ], 'filepath': TestLauncher, - 'line_num': 25, + 'line_num': 32, 'column_num': 13, }, 'expect': { @@ -542,14 +598,14 @@ def Subcommands_RefactorRename_MultipleFiles_test( app ): LocationMatcher( TestFactory, 28, 33 ) ), ChunkMatcher( 'a-quite-long-string', - LocationMatcher( TestLauncher, 25, 11 ), - LocationMatcher( TestLauncher, 25, 35 ) ), + LocationMatcher( TestLauncher, 32, 11 ), + LocationMatcher( TestLauncher, 32, 35 ) ), ChunkMatcher( 'a-quite-long-string', LocationMatcher( TestWidgetImpl, 20, 15 ), LocationMatcher( TestWidgetImpl, 20, 39 ) ), ), - 'location': LocationMatcher( TestLauncher, 25, 13 ) + 'location': LocationMatcher( TestLauncher, 32, 13 ) } ) ) } ) } @@ -580,6 +636,45 @@ def Subcommands_RefactorRename_Missing_New_Name_test( app ): } ) +@SharedYcmd +def Subcommands_RefactorRename_Unicode_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'youcompleteme', + 'Test.java' ) + RunTest( app, { + 'description': 'Rename works for unicode identifier', + 'request': { + 'command': 'RefactorRename', + 'arguments': [ 'shorter' ], + 'line_num': 7, + 'column_num': 21, + 'filepath': filepath, + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries ( { + 'fixits': contains( has_entries( { + 'chunks': contains( + ChunkMatcher( + 'shorter', + LocationMatcher( filepath, 7, 12 ), + LocationMatcher( filepath, 7, 25 ) + ), + ChunkMatcher( + 'shorter', + LocationMatcher( filepath, 8, 12 ), + LocationMatcher( filepath, 8, 25 ) + ), + ), + } ) ), + } ), + }, + } ) + + + @SharedYcmd def RunFixItTest( app, description, filepath, line, col, fixits_for_line ): RunTest( app, { @@ -837,165 +932,129 @@ def Subcommands_FixIt_NoDiagnostics_test(): filepath, 1, 1, has_entries( { 'fixits': empty() } ) ) -@IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project' ) ) -def Subcommands_RestartServer_test( app ): - WaitUntilCompleterServerReady( app ) - - eclipse_project = PathToTestFile( 'simple_eclipse_project' ) - maven_project = PathToTestFile( 'simple_maven_project' ) - - # Run the debug info to check that we have the correct project dir - request_data = BuildRequest( filetype = 'java' ) - assert_that( - app.post_json( '/debug_info', request_data ).json, - has_entry( 'completer', has_entries( { - 'name': 'Java', - 'servers': contains( has_entries( { - 'name': 'Java Language Server', - 'is_running': instance_of( bool ), - 'executable': instance_of( str ), - 'pid': instance_of( int ), - 'logfiles': contains( instance_of( str ), - instance_of( str ) ), - 'extras': contains( - has_entries( { 'key': 'Java Path', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Launcher Config.', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Project Directory', - 'value': eclipse_project } ), - has_entries( { 'key': 'Workspace Path', - 'value': instance_of( str ) } ) - ) - } ) ) - } ) ) - ) - - # Restart the server with a different client working directory - filepath = PathToTestFile( 'simple_maven_project', +def Subcommands_FixIt_Unicode_test(): + filepath = PathToTestFile( 'simple_eclipse_project', 'src', - 'main', - 'java', 'com', - 'test', - 'TestFactory.java' ) + 'youcompleteme', + 'Test.java' ) - app.post_json( - '/run_completer_command', - BuildRequest( - filepath = filepath, - filetype = 'java', - working_dir = maven_project, - command_arguments = [ 'RestartServer' ], - ), - ) - - WaitUntilCompleterServerReady( app ) - - app.post_json( - '/event_notification', - BuildRequest( - filepath = filepath, - filetype = 'java', - working_dir = maven_project, - event_name = 'FileReadyToParse', + fixits = has_entries ( { + 'fixits': contains_inanyorder( + has_entries( { + 'text': "Remove argument to match 'doUnicødeTes()'", + 'chunks': contains( + ChunkMatcher( '', + LocationMatcher( filepath, 13, 24 ), + LocationMatcher( filepath, 13, 29 ) ), + ), + } ), + has_entries( { + 'text': "Change method 'doUnicødeTes()': Add parameter 'String'", + 'chunks': contains( + ChunkMatcher( 'String test2', + LocationMatcher( filepath, 6, 31 ), + LocationMatcher( filepath, 6, 31 ) ), + ), + } ), + has_entries( { + 'text': "Create method 'doUnicødeTes(String)'", + 'chunks': contains( + ChunkMatcher( 'private void doUnicødeTes(String test2) {\n}', + LocationMatcher( filepath, 20, 3 ), + LocationMatcher( filepath, 20, 3 ) ), + ChunkMatcher( '\n\n\n', + LocationMatcher( filepath, 20, 3 ), + LocationMatcher( filepath, 20, 3 ) ), + ), + } ), ) - ) - - # Run the debug info to check that we have the correct project dir - request_data = BuildRequest( filetype = 'java' ) - assert_that( - app.post_json( '/debug_info', request_data ).json, - has_entry( 'completer', has_entries( { - 'name': 'Java', - 'servers': contains( has_entries( { - 'name': 'Java Language Server', - 'is_running': instance_of( bool ), - 'executable': instance_of( str ), - 'pid': instance_of( int ), - 'logfiles': contains( instance_of( str ), - instance_of( str ) ), - 'extras': contains( - has_entries( { 'key': 'Java Path', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Launcher Config.', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Project Directory', - 'value': maven_project } ), - has_entries( { 'key': 'Workspace Path', - 'value': instance_of( str ) } ) - ) - } ) ) - } ) ) - ) + } ) + yield ( RunFixItTest, 'FixIts and diagnostics work with unicode strings', + filepath, 13, 1, fixits ) -@IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project', 'src' ) ) -def Subcommands_ProjectDetection_EclipseParent( app ): - WaitUntilCompleterServerReady( app ) - project = PathToTestFile( 'simple_eclipse_project' ) - - # Run the debug info to check that we have the correct project dir - request_data = BuildRequest( filetype = 'java' ) - assert_that( - app.post_json( '/debug_info', request_data ).json, - has_entry( 'completer', has_entries( { - 'name': 'Java', - 'servers': contains( has_entries( { - 'name': 'Java Language Server', - 'is_running': instance_of( bool ), - 'executable': instance_of( str ), - 'pid': instance_of( int ), - 'logfiles': contains( instance_of( str ), - instance_of( str ) ), - 'extras': contains( - has_entries( { 'key': 'Java Path', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Launcher Config.', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Project Directory', - 'value': project } ), - has_entries( { 'key': 'Workspace Path', - 'value': instance_of( str ) } ) - ) - } ) ) - } ) ) - ) +@SharedYcmd +def RunGoToTest( app, description, filepath, line, col, cmd, goto_response ): + RunTest( app, { + 'description': description, + 'request': { + 'command': cmd, + 'line_num': line, + 'column_num': col, + 'filepath': filepath + }, + 'expect': { + 'response': requests.codes.ok, + 'data': goto_response, + } + } ) -@IsolatedYcmdInDirectory( PathToTestFile( 'simple_maven_project', - 'src', - 'java', - 'test' ) ) -def Subcommands_ProjectDetection_MavenParent( app ): - WaitUntilCompleterServerReady( app ) +def Subcommands_GoTo_test(): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestLauncher.java' ) - project = PathToTestFile( 'simple_maven_project' ) - - # Run the debug info to check that we have the correct project dir - request_data = BuildRequest( filetype = 'java' ) - assert_that( - app.post_json( '/debug_info', request_data ).json, - has_entry( 'completer', has_entries( { - 'name': 'Java', - 'servers': contains( has_entries( { - 'name': 'Java Language Server', - 'is_running': instance_of( bool ), - 'executable': instance_of( str ), - 'pid': instance_of( int ), - 'logfiles': contains( instance_of( str ), - instance_of( str ) ), - 'extras': contains( - has_entries( { 'key': 'Java Path', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Launcher Config.', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Project Directory', - 'value': project } ), - has_entries( { 'key': 'Workspace Path', - 'value': instance_of( str ) } ) - ) - } ) ) - } ) ) - ) + unicode_filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'youcompleteme', + 'Test.java' ) + + tests = [ + # Member function local variable + { 'request': { 'line': 28, 'col': 5, 'filepath': filepath }, + 'response': { 'line_num': 27, 'column_num': 18, 'filepath': filepath }, + 'description': 'GoTo works for memeber local variable' }, + # Member variable + { 'request': { 'line': 22, 'col': 7, 'filepath': filepath }, + 'response': { 'line_num': 8, 'column_num': 16, 'filepath': filepath }, + 'description': 'GoTo works for memeber variable' }, + # Method + { 'request': { 'line': 28, 'col': 7, 'filepath': filepath }, + 'response': { 'line_num': 21, 'column_num': 16, 'filepath': filepath }, + 'description': 'GoTo works for method' }, + # Constructor + { 'request': { 'line': 38, 'col': 26, 'filepath': filepath }, + 'response': { 'line_num': 10, 'column_num': 10, 'filepath': filepath }, + 'description': 'GoTo works for jumping to constructor' }, + # Jump to self - main() + { 'request': { 'line': 26, 'col': 22, 'filepath': filepath }, + 'response': { 'line_num': 26, 'column_num': 22, 'filepath': filepath }, + 'description': 'GoTo works for jumping to the same position' }, + # # Static method + { 'request': { 'line': 37, 'col': 11, 'filepath': filepath }, + 'response': { 'line_num': 13, 'column_num': 21, 'filepath': filepath }, + 'description': 'GoTo works for static method' }, + # Static variable + { 'request': { 'line': 14, 'col': 11, 'filepath': filepath }, + 'response': { 'line_num': 12, 'column_num': 21, 'filepath': filepath }, + 'description': 'GoTo works for static variable' }, + # Argument variable + { 'request': { 'line': 23, 'col': 5, 'filepath': filepath }, + 'response': { 'line_num': 21, 'column_num': 32, 'filepath': filepath }, + 'description': 'GoTo works for argument variable' }, + # Class + { 'request': { 'line': 27, 'col': 30, 'filepath': filepath }, + 'response': { 'line_num': 6, 'column_num': 7, 'filepath': filepath }, + 'description': 'GoTo works for jumping to class declaration' }, + # Unicode + { 'request': { 'line': 8, 'col': 12, 'filepath': unicode_filepath }, + 'response': { 'line_num': 7, 'column_num': 12, 'filepath': + unicode_filepath }, + 'description': 'GoTo works for unicode identifiers' } + ] + + for command in [ 'GoTo', 'GoToDefinition', 'GoToDeclaration' ]: + for test in tests: + yield ( RunGoToTest, + test[ 'description' ], + test[ 'request' ][ 'filepath' ], + test[ 'request' ][ 'line' ], + test[ 'request' ][ 'col' ], + command, + test[ 'response' ] ) diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java index 69e20d2bea..53ff73d559 100644 --- a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java @@ -7,6 +7,13 @@ class TestLauncher { private TestFactory factory = new TestFactory(); private Tset tset = new Tset(); + public TestLauncher( int test ) {} + + public static int static_int = 5; + public static int static_method() { + return static_int; + } + private interface Launchable { public void launch( TestFactory f ); } @@ -17,7 +24,7 @@ private void Run( Launchable l ) { } public static void main( String[] args ) { - TestLauncher l = new TestLauncher(); + TestLauncher l = new TestLauncher( 10 ); l.Run( new Launchable() { @Override public void launch() { @@ -27,5 +34,8 @@ public void launch() { System.out.println( "Did something useful: " + w.getWidgetInfo() ); } }); + static_method(); + TestLauncher t = new TestLauncher( 4 ); + t.Run( null ); } } diff --git a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/youcompleteme/Test.java b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/youcompleteme/Test.java index aa758f57d9..ffca520ce5 100644 --- a/ycmd/tests/java/testdata/simple_eclipse_project/src/com/youcompleteme/Test.java +++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/youcompleteme/Test.java @@ -2,4 +2,25 @@ public class Test { public String test; + + public String doUnicødeTes() { + String whåtawîdgé = "Test"; + return whåtawîdgé ; + } + + private int DoWhatever() { + this.doUnicødeTes(); + this.doUnicødeTes( test ); + + TéstClass tésting_with_unicøde = new TéstClass(); + return tésting_with_unicøde.a_test; + } + + + public class TéstClass { + /** Test in the west */ + public String testywesty; + public int a_test; + public boolean åtest; + } } diff --git a/ycmd/tests/java/testdata/simple_gradle_project/.gitignore b/ycmd/tests/java/testdata/simple_gradle_project/.gitignore new file mode 100644 index 0000000000..baa442ba32 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_gradle_project/.gitignore @@ -0,0 +1,5 @@ +.gradle +build/ +.classpath +.project +.settings diff --git a/ycmd/tests/java/testdata/simple_gradle_project/gradle/wrapper/gradle-wrapper.jar b/ycmd/tests/java/testdata/simple_gradle_project/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..7a3265ee94c0ab25cf079ac8ccdf87f41d455d42 GIT binary patch literal 54708 zcmagFV|ZrKvM!pAZQHhO+qP}9lTNj?q^^Y^VFp)SH8qbSJ)2BQ2girk4u zvO<3q)c?v~^Z#E_K}1nTQbJ9gQ9<%vVRAxVj)8FwL5_iTdUB>&m3fhE=kRWl;g`&m z!W5kh{WsV%fO*%je&j+Lv4xxK~zsEYQls$Q-p&dwID|A)!7uWtJF-=Tm1{V@#x*+kUI$=%KUuf2ka zjiZ{oiL1MXE2EjciJM!jrjFNwCh`~hL>iemrqwqnX?T*MX;U>>8yRcZb{Oy+VKZos zLiFKYPw=LcaaQt8tj=eoo3-@bG_342HQ%?jpgAE?KCLEHC+DmjxAfJ%Og^$dpC8Xw zAcp-)tfJm}BPNq_+6m4gBgBm3+CvmL>4|$2N$^Bz7W(}fz1?U-u;nE`+9`KCLuqg} zwNstNM!J4Uw|78&Y9~9>MLf56to!@qGkJw5Thx%zkzj%Ek9Nn1QA@8NBXbwyWC>9H z#EPwjMNYPigE>*Ofz)HfTF&%PFj$U6mCe-AFw$U%-L?~-+nSXHHKkdgC5KJRTF}`G zE_HNdrE}S0zf4j{r_f-V2imSqW?}3w-4=f@o@-q+cZgaAbZ((hn))@|eWWhcT2pLpTpL!;_5*vM=sRL8 zqU##{U#lJKuyqW^X$ETU5ETeEVzhU|1m1750#f}38_5N9)B_2|v@1hUu=Kt7-@dhA zq_`OMgW01n`%1dB*}C)qxC8q;?zPeF_r;>}%JYmlER_1CUbKa07+=TV45~symC*g8 zW-8(gag#cAOuM0B1xG8eTp5HGVLE}+gYTmK=`XVVV*U!>H`~j4+ROIQ+NkN$LY>h4 zqpwdeE_@AX@PL};e5vTn`Ro(EjHVf$;^oiA%@IBQq>R7_D>m2D4OwwEepkg}R_k*M zM-o;+P27087eb+%*+6vWFCo9UEGw>t&WI17Pe7QVuoAoGHdJ(TEQNlJOqnjZ8adCb zI`}op16D@v7UOEo%8E-~m?c8FL1utPYlg@m$q@q7%mQ4?OK1h%ODjTjFvqd!C z-PI?8qX8{a@6d&Lb_X+hKxCImb*3GFemm?W_du5_&EqRq!+H?5#xiX#w$eLti-?E$;Dhu`{R(o>LzM4CjO>ICf z&DMfES#FW7npnbcuqREgjPQM#gs6h>`av_oEWwOJZ2i2|D|0~pYd#WazE2Bbsa}X@ zu;(9fi~%!VcjK6)?_wMAW-YXJAR{QHxrD5g(ou9mR6LPSA4BRG1QSZT6A?kelP_g- zH(JQjLc!`H4N=oLw=f3{+WmPA*s8QEeEUf6Vg}@!xwnsnR0bl~^2GSa5vb!Yl&4!> zWb|KQUsC$lT=3A|7vM9+d;mq=@L%uWKwXiO9}a~gP4s_4Yohc!fKEgV7WbVo>2ITbE*i`a|V!^p@~^<={#?Gz57 zyPWeM2@p>D*FW#W5Q`1`#5NW62XduP1XNO(bhg&cX`-LYZa|m-**bu|>}S;3)eP8_ zpNTnTfm8 ze+7wDH3KJ95p)5tlwk`S7mbD`SqHnYD*6`;gpp8VdHDz%RR_~I_Ar>5)vE-Pgu7^Y z|9Px+>pi3!DV%E%4N;ii0U3VBd2ZJNUY1YC^-e+{DYq+l@cGtmu(H#Oh%ibUBOd?C z{y5jW3v=0eV0r@qMLgv1JjZC|cZ9l9Q)k1lLgm))UR@#FrJd>w^`+iy$c9F@ic-|q zVHe@S2UAnc5VY_U4253QJxm&Ip!XKP8WNcnx9^cQ;KH6PlW8%pSihSH2(@{2m_o+m zr((MvBja2ctg0d0&U5XTD;5?d?h%JcRJp{_1BQW1xu&BrA3(a4Fh9hon-ly$pyeHq zG&;6q?m%NJ36K1Sq_=fdP(4f{Hop;_G_(i?sPzvB zDM}>*(uOsY0I1j^{$yn3#U(;B*g4cy$-1DTOkh3P!LQ;lJlP%jY8}Nya=h8$XD~%Y zbV&HJ%eCD9nui-0cw!+n`V~p6VCRqh5fRX z8`GbdZ@73r7~myQLBW%db;+BI?c-a>Y)m-FW~M=1^|<21_Sh9RT3iGbO{o-hpN%d6 z7%++#WekoBOP^d0$$|5npPe>u3PLvX_gjH2x(?{&z{jJ2tAOWTznPxv-pAv<*V7r$ z6&glt>7CAClWz6FEi3bToz-soY^{ScrjwVPV51=>n->c(NJngMj6TyHty`bfkF1hc zkJS%A@cL~QV0-aK4>Id!9dh7>0IV;1J9(myDO+gv76L3NLMUm9XyPauvNu$S<)-|F zZS}(kK_WnB)Cl`U?jsdYfAV4nrgzIF@+%1U8$poW&h^c6>kCx3;||fS1_7JvQT~CV zQ8Js+!p)3oW>Df(-}uqC`Tcd%E7GdJ0p}kYj5j8NKMp(KUs9u7?jQ94C)}0rba($~ zqyBx$(1ae^HEDG`Zc@-rXk1cqc7v0wibOR4qpgRDt#>-*8N3P;uKV0CgJE2SP>#8h z=+;i_CGlv+B^+$5a}SicVaSeaNn29K`C&=}`=#Nj&WJP9Xhz4mVa<+yP6hkrq1vo= z1rX4qg8dc4pmEvq%NAkpMK>mf2g?tg_1k2%v}<3`$6~Wlq@ItJ*PhHPoEh1Yi>v57 z4k0JMO)*=S`tKvR5gb-(VTEo>5Y>DZJZzgR+j6{Y`kd|jCVrg!>2hVjz({kZR z`dLlKhoqT!aI8=S+fVp(5*Dn6RrbpyO~0+?fy;bm$0jmTN|t5i6rxqr4=O}dY+ROd zo9Et|x}!u*xi~>-y>!M^+f&jc;IAsGiM_^}+4|pHRn{LThFFpD{bZ|TA*wcGm}XV^ zr*C6~@^5X-*R%FrHIgo-hJTBcyQ|3QEj+cSqp#>&t`ZzB?cXM6S(lRQw$I2?m5=wd z78ki`R?%;o%VUhXH?Z#(uwAn9$m`npJ=cA+lHGk@T7qq_M6Zoy1Lm9E0UUysN)I_x zW__OAqvku^>`J&CB=ie@yNWsaFmem}#L3T(x?a`oZ+$;3O-icj2(5z72Hnj=9Z0w% z<2#q-R=>hig*(t0^v)eGq2DHC%GymE-_j1WwBVGoU=GORGjtaqr0BNigOCqyt;O(S zKG+DoBsZU~okF<7ahjS}bzwXxbAxFfQAk&O@>LsZMsZ`?N?|CDWM(vOm%B3CBPC3o z%2t@%H$fwur}SSnckUm0-k)mOtht`?nwsDz=2#v=RBPGg39i#%odKq{K^;bTD!6A9 zskz$}t)sU^=a#jLZP@I=bPo?f-L}wpMs{Tc!m7-bi!Ldqj3EA~V;4(dltJmTXqH0r z%HAWKGutEc9vOo3P6Q;JdC^YTnby->VZ6&X8f{obffZ??1(cm&L2h7q)*w**+sE6dG*;(H|_Q!WxU{g)CeoT z(KY&bv!Usc|m+Fqfmk;h&RNF|LWuNZ!+DdX*L=s-=_iH=@i` z?Z+Okq^cFO4}_n|G*!)Wl_i%qiMBaH8(WuXtgI7EO=M>=i_+;MDjf3aY~6S9w0K zUuDO7O5Ta6+k40~xh~)D{=L&?Y0?c$s9cw*Ufe18)zzk%#ZY>Tr^|e%8KPb0ht`b( zuP@8#Ox@nQIqz9}AbW0RzE`Cf>39bOWz5N3qzS}ocxI=o$W|(nD~@EhW13Rj5nAp; zu2obEJa=kGC*#3=MkdkWy_%RKcN=?g$7!AZ8vBYKr$ePY(8aIQ&yRPlQ=mudv#q$q z4%WzAx=B{i)UdLFx4os?rZp6poShD7Vc&mSD@RdBJ=_m^&OlkEE1DFU@csgKcBifJ zz4N7+XEJhYzzO=86 z#%eBQZ$Nsf2+X0XPHUNmg#(sNt^NW1Y0|M(${e<0kW6f2q5M!2YE|hSEQ*X-%qo(V zHaFwyGZ0on=I{=fhe<=zo{=Og-_(to3?cvL4m6PymtNsdDINsBh8m>a%!5o3s(en) z=1I z6O+YNertC|OFNqd6P=$gMyvmfa`w~p9*gKDESFqNBy(~Zw3TFDYh}$iudn)9HxPBi zdokK@o~nu?%imcURr5Y~?6oo_JBe}t|pU5qjai|#JDyG=i^V~7+a{dEnO<(y>ahND#_X_fcEBNiZ)uc&%1HVtx8Ts z*H_Btvx^IhkfOB#{szN*n6;y05A>3eARDXslaE>tnLa>+`V&cgho?ED+&vv5KJszf zG4@G;7i;4_bVvZ>!mli3j7~tPgybF5|J6=Lt`u$D%X0l}#iY9nOXH@(%FFJLtzb%p zzHfABnSs;v-9(&nzbZytLiqqDIWzn>JQDk#JULcE5CyPq_m#4QV!}3421haQ+LcfO*>r;rg6K|r#5Sh|y@h1ao%Cl)t*u`4 zMTP!deC?aL7uTxm5^nUv#q2vS-5QbBKP|drbDXS%erB>fYM84Kpk^au99-BQBZR z7CDynflrIAi&ahza+kUryju5LR_}-Z27g)jqOc(!Lx9y)e z{cYc&_r947s9pteaa4}dc|!$$N9+M38sUr7h(%@Ehq`4HJtTpA>B8CLNO__@%(F5d z`SmX5jbux6i#qc}xOhumzbAELh*Mfr2SW99=WNOZRZgoCU4A2|4i|ZVFQt6qEhH#B zK_9G;&h*LO6tB`5dXRSBF0hq0tk{2q__aCKXYkP#9n^)@cq}`&Lo)1KM{W+>5mSed zKp~=}$p7>~nK@va`vN{mYzWN1(tE=u2BZhga5(VtPKk(*TvE&zmn5vSbjo zZLVobTl%;t@6;4SsZ>5+U-XEGUZGG;+~|V(pE&qqrp_f~{_1h@5ZrNETqe{bt9ioZ z#Qn~gWCH!t#Ha^n&fT2?{`}D@s4?9kXj;E;lWV9Zw8_4yM0Qg-6YSsKgvQ*fF{#Pq z{=(nyV>#*`RloBVCs;Lp*R1PBIQOY=EK4CQa*BD0MsYcg=opP?8;xYQDSAJBeJpw5 zPBc_Ft9?;<0?pBhCmOtWU*pN*;CkjJ_}qVic`}V@$TwFi15!mF1*m2wVX+>5p%(+R zQ~JUW*zWkalde{90@2v+oVlkxOZFihE&ZJ){c?hX3L2@R7jk*xjYtHi=}qb+4B(XJ z$gYcNudR~4Kz_WRq8eS((>ALWCO)&R-MXE+YxDn9V#X{_H@j616<|P(8h(7z?q*r+ zmpqR#7+g$cT@e&(%_|ipI&A%9+47%30TLY(yuf&*knx1wNx|%*H^;YB%ftt%5>QM= z^i;*6_KTSRzQm%qz*>cK&EISvF^ovbS4|R%)zKhTH_2K>jP3mBGn5{95&G9^a#4|K zv+!>fIsR8z{^x4)FIr*cYT@Q4Z{y}};rLHL+atCgHbfX*;+k&37DIgENn&=k(*lKD zG;uL-KAdLn*JQ?@r6Q!0V$xXP=J2i~;_+i3|F;_En;oAMG|I-RX#FwnmU&G}w`7R{ z788CrR-g1DW4h_`&$Z`ctN~{A)Hv_-Bl!%+pfif8wN32rMD zJDs$eVWBYQx1&2sCdB0!vU5~uf)=vy*{}t{2VBpcz<+~h0wb7F3?V^44*&83Z2#F` z32!rd4>uc63rQP$3lTH3zb-47IGR}f)8kZ4JvX#toIpXH`L%NnPDE~$QI1)0)|HS4 zVcITo$$oWWwCN@E-5h>N?Hua!N9CYb6f8vTFd>h3q5Jg-lCI6y%vu{Z_Uf z$MU{{^o~;nD_@m2|E{J)q;|BK7rx%`m``+OqZAqAVj-Dy+pD4-S3xK?($>wn5bi90CFAQ+ACd;&m6DQB8_o zjAq^=eUYc1o{#+p+ zn;K<)Pn*4u742P!;H^E3^Qu%2dM{2slouc$AN_3V^M7H_KY3H)#n7qd5_p~Za7zAj|s9{l)RdbV9e||_67`#Tu*c<8!I=zb@ z(MSvQ9;Wrkq6d)!9afh+G`!f$Ip!F<4ADdc*OY-y7BZMsau%y?EN6*hW4mOF%Q~bw z2==Z3^~?q<1GTeS>xGN-?CHZ7a#M4kDL zQxQr~1ZMzCSKFK5+32C%+C1kE#(2L=15AR!er7GKbp?Xd1qkkGipx5Q~FI-6zt< z*PTpeVI)Ngnnyaz5noIIgNZtb4bQdKG{Bs~&tf)?nM$a;7>r36djllw%hQxeCXeW^ z(i6@TEIuxD<2ulwLTt|&gZP%Ei+l!(%p5Yij6U(H#HMkqM8U$@OKB|5@vUiuY^d6X zW}fP3;Kps6051OEO(|JzmVU6SX(8q>*yf*x5QoxDK={PH^F?!VCzES_Qs>()_y|jg6LJlJWp;L zKM*g5DK7>W_*uv}{0WUB0>MHZ#oJZmO!b3MjEc}VhsLD~;E-qNNd?x7Q6~v zR=0$u>Zc2Xr}>x_5$-s#l!oz6I>W?lw;m9Ae{Tf9eMX;TI-Wf_mZ6sVrMnY#F}cDd z%CV*}fDsXUF7Vbw>PuDaGhu631+3|{xp<@Kl|%WxU+vuLlcrklMC!Aq+7n~I3cmQ! z`e3cA!XUEGdEPSu``&lZEKD1IKO(-VGvcnSc153m(i!8ohi`)N2n>U_BemYJ`uY>8B*Epj!oXRLV}XK}>D*^DHQ7?NY*&LJ9VSo`Ogi9J zGa;clWI8vIQqkngv2>xKd91K>?0`Sw;E&TMg&6dcd20|FcTsnUT7Yn{oI5V4@Ow~m zz#k~8TM!A9L7T!|colrC0P2WKZW7PNj_X4MfESbt<-soq*0LzShZ}fyUx!(xIIDwx zRHt^_GAWe0-Vm~bDZ(}XG%E+`XhKpPlMBo*5q_z$BGxYef8O!ToS8aT8pmjbPq)nV z%x*PF5ZuSHRJqJ!`5<4xC*xb2vC?7u1iljB_*iUGl6+yPyjn?F?GOF2_KW&gOkJ?w z3e^qc-te;zez`H$rsUCE0<@7PKGW?7sT1SPYWId|FJ8H`uEdNu4YJjre`8F*D}6Wh z|FQ`xf7yiphHIAkU&OYCn}w^ilY@o4larl?^M7&8YI;hzBIsX|i3UrLsx{QDKwCX< zy;a>yjfJ6!sz`NcVi+a!Fqk^VE^{6G53L?@Tif|j!3QZ0fk9QeUq8CWI;OmO-Hs+F zuZ4sHLA3{}LR2Qlyo+{d@?;`tpp6YB^BMoJt?&MHFY!JQwoa0nTSD+#Ku^4b{5SZVFwU9<~APYbaLO zu~Z)nS#dxI-5lmS-Bnw!(u15by(80LlC@|ynj{TzW)XcspC*}z0~8VRZq>#Z49G`I zgl|C#H&=}n-ajxfo{=pxPV(L*7g}gHET9b*s=cGV7VFa<;Htgjk>KyW@S!|z`lR1( zGSYkEl&@-bZ*d2WQ~hw3NpP=YNHF^XC{TMG$Gn+{b6pZn+5=<()>C!N^jncl0w6BJ zdHdnmSEGK5BlMeZD!v4t5m7ct7{k~$1Ie3GLFoHjAH*b?++s<|=yTF+^I&jT#zuMx z)MLhU+;LFk8bse|_{j+d*a=&cm2}M?*arjBPnfPgLwv)86D$6L zLJ0wPul7IenMvVAK$z^q5<^!)7aI|<&GGEbOr=E;UmGOIa}yO~EIr5xWU_(ol$&fa zR5E(2vB?S3EvJglTXdU#@qfDbCYs#82Yo^aZN6`{Ex#M)easBTe_J8utXu(fY1j|R z9o(sQbj$bKU{IjyhosYahY{63>}$9_+hWxB3j}VQkJ@2$D@vpeRSldU?&7I;qd2MF zSYmJ>zA(@N_iK}m*AMPIJG#Y&1KR)6`LJ83qg~`Do3v^B0>fU&wUx(qefuTgzFED{sJ65!iw{F2}1fQ3= ziFIP{kezQxmlx-!yo+sC4PEtG#K=5VM9YIN0z9~c4XTX?*4e@m;hFM!zVo>A`#566 z>f&3g94lJ{r)QJ5m7Xe3SLau_lOpL;A($wsjHR`;xTXgIiZ#o&vt~ zGR6KdU$FFbLfZCC3AEu$b`tj!9XgOGLSV=QPIYW zjI!hSP#?8pn0@ezuenOzoka8!8~jXTbiJ6+ZuItsWW03uzASFyn*zV2kIgPFR$Yzm zE<$cZlF>R8?Nr2_i?KiripBc+TGgJvG@vRTY2o?(_Di}D30!k&CT`>+7ry2!!iC*X z<@=U0_C#16=PN7bB39w+zPwDOHX}h20Ap);dx}kjXX0-QkRk=cr};GYsjSvyLZa-t zzHONWddi*)RDUH@RTAsGB_#&O+QJaaL+H<<9LLSE+nB@eGF1fALwjVOl8X_sdOYme z0lk!X=S(@25=TZHR7LlPp}fY~yNeThMIjD}pd9+q=j<_inh0$>mIzWVY+Z9p<{D^#0Xk+b_@eNSiR8;KzSZ#7lUsk~NGMcB8C2c=m2l5paHPq`q{S(kdA7Z1a zyfk2Y;w?^t`?@yC5Pz9&pzo}Hc#}mLgDmhKV|PJ3lKOY(Km@Fi2AV~CuET*YfUi}u zfInZnqDX(<#vaS<^fszuR=l)AbqG{}9{rnyx?PbZz3Pyu!eSJK`uwkJU!ORQXy4x83r!PNgOyD33}}L=>xX_93l6njNTuqL8J{l%*3FVn3MG4&Fv*`lBXZ z?=;kn6HTT^#SrPX-N)4EZiIZI!0ByXTWy;;J-Tht{jq1mjh`DSy7yGjHxIaY%*sTx zuy9#9CqE#qi>1misx=KRWm=qx4rk|}vd+LMY3M`ow8)}m$3Ggv&)Ri*ON+}<^P%T5 z_7JPVPfdM=Pv-oH<tecoE}(0O7|YZc*d8`Uv_M*3Rzv7$yZnJE6N_W=AQ3_BgU_TjA_T?a)U1csCmJ&YqMp-lJe`y6>N zt++Bi;ZMOD%%1c&-Q;bKsYg!SmS^#J@8UFY|G3!rtyaTFb!5@e(@l?1t(87ln8rG? z--$1)YC~vWnXiW3GXm`FNSyzu!m$qT=Eldf$sMl#PEfGmzQs^oUd=GIQfj(X=}dw+ zT*oa0*oS%@cLgvB&PKIQ=Ok?>x#c#dC#sQifgMwtAG^l3D9nIg(Zqi;D%807TtUUCL3_;kjyte#cAg?S%e4S2W>9^A(uy8Ss0Tc++ZTjJw1 z&Em2g!3lo@LlDyri(P^I8BPpn$RE7n*q9Q-c^>rfOMM6Pd5671I=ZBjAvpj8oIi$! zl0exNl(>NIiQpX~FRS9UgK|0l#s@#)p4?^?XAz}Gjb1?4Qe4?j&cL$C8u}n)?A@YC zfmbSM`Hl5pQFwv$CQBF=_$Sq zxsV?BHI5bGZTk?B6B&KLdIN-40S426X3j_|ceLla*M3}3gx3(_7MVY1++4mzhH#7# zD>2gTHy*%i$~}mqc#gK83288SKp@y3wz1L_e8fF$Rb}ex+`(h)j}%~Ld^3DUZkgez zOUNy^%>>HHE|-y$V@B}-M|_{h!vXpk01xaD%{l{oQ|~+^>rR*rv9iQen5t?{BHg|% zR`;S|KtUb!X<22RTBA4AAUM6#M?=w5VY-hEV)b`!y1^mPNEoy2K)a>OyA?Q~Q*&(O zRzQI~y_W=IPi?-OJX*&&8dvY0zWM2%yXdFI!D-n@6FsG)pEYdJbuA`g4yy;qrgR?G z8Mj7gv1oiWq)+_$GqqQ$(ZM@#|0j7})=#$S&hZwdoijFI4aCFLVI3tMH5fLreZ;KD zqA`)0l~D2tuIBYOy+LGw&hJ5OyE+@cnZ0L5+;yo2pIMdt@4$r^5Y!x7nHs{@>|W(MzJjATyWGNwZ^4j+EPU0RpAl-oTM@u{lx*i0^yyWPfHt6QwPvYpk9xFMWfBFt!+Gu6TlAmr zeQ#PX71vzN*_-xh&__N`IXv6`>CgV#eA_%e@7wjgkj8jlKzO~Ic6g$cT`^W{R{606 zCDP~+NVZ6DMO$jhL~#+!g*$T!XW63#(ngDn#Qwy71yj^gazS{e;3jGRM0HedGD@pt z?(ln3pCUA(ekqAvvnKy0G@?-|-dh=eS%4Civ&c}s%wF@0K5Bltaq^2Os1n6Z3%?-Q zAlC4goQ&vK6TpgtzkHVt*1!tBYt-`|5HLV1V7*#45Vb+GACuU+QB&hZ=N_flPy0TY zR^HIrdskB#<$aU;HY(K{a3(OQa$0<9qH(oa)lg@Uf>M5g2W0U5 zk!JSlhrw8quBx9A>RJ6}=;W&wt@2E$7J=9SVHsdC?K(L(KACb#z)@C$xXD8^!7|uv zZh$6fkq)aoD}^79VqdJ!Nz-8$IrU(_-&^cHBI;4 z^$B+1aPe|LG)C55LjP;jab{dTf$0~xbXS9!!QdcmDYLbL^jvxu2y*qnx2%jbL%rB z{aP85qBJe#(&O~Prk%IJARcdEypZ)vah%ZZ%;Zk{eW(U)Bx7VlzgOi8)x z`rh4l`@l_Ada7z&yUK>ZF;i6YLGwI*Sg#Fk#Qr0Jg&VLax(nNN$u-XJ5=MsP3|(lEdIOJ7|(x3iY;ea)5#BW*mDV%^=8qOeYO&gIdJVuLLN3cFaN=xZtFB=b zH{l)PZl_j^u+qx@89}gAQW7ofb+k)QwX=aegihossZq*+@PlCpb$rpp>Cbk9UJO<~ zDjlXQ_Ig#W0zdD3&*ei(FwlN#3b%FSR%&M^ywF@Fr>d~do@-kIS$e%wkIVfJ|Ohh=zc zF&Rnic^|>@R%v?@jO}a9;nY3Qrg_!xC=ZWUcYiA5R+|2nsM*$+c$TOs6pm!}Z}dfM zGeBhMGWw3$6KZXav^>YNA=r6Es>p<6HRYcZY)z{>yasbC81A*G-le8~QoV;rtKnkx z;+os8BvEe?0A6W*a#dOudsv3aWs?d% z0oNngyVMjavLjtjiG`!007#?62ClTqqU$@kIY`=x^$2e>iqIy1>o|@Tw@)P)B8_1$r#6>DB_5 zmaOaoE~^9TolgDgooKFuEFB#klSF%9-~d2~_|kQ0Y{Ek=HH5yq9s zDq#1S551c`kSiWPZbweN^A4kWiP#Qg6er1}HcKv{fxb1*BULboD0fwfaNM_<55>qM zETZ8TJDO4V)=aPp_eQjX%||Ud<>wkIzvDlpNjqW>I}W!-j7M^TNe5JIFh#-}zAV!$ICOju8Kx)N z0vLtzDdy*rQN!7r>Xz7rLw8J-(GzQlYYVH$WK#F`i_i^qVlzTNAh>gBWKV@XC$T-` z3|kj#iCquDhiO7NKum07i|<-NuVsX}Q}mIP$jBJDMfUiaWR3c|F_kWBMw0_Sr|6h4 zk`_r5=0&rCR^*tOy$A8K;@|NqwncjZ>Y-75vlpxq%Cl3EgH`}^^~=u zoll6xxY@a>0f%Ddpi;=cY}fyG!K2N-dEyXXmUP5u){4VnyS^T4?pjN@Ot4zjL(Puw z_U#wMH2Z#8Pts{olG5Dy0tZj;N@;fHheu>YKYQU=4Bk|wcD9MbA`3O4bj$hNRHwzb zSLcG0SLV%zywdbuwl(^E_!@&)TdXge4O{MRWk2RKOt@!8E{$BU-AH(@4{gxs=YAz9LIob|Hzto0}9cWoz6Tp2x0&xi#$ zHh$dwO&UCR1Ob2w00-2eG7d4=cN(Y>0R#$q8?||q@iTi+7-w-xR%uMr&StFIthC<# zvK(aPduwuNB}oJUV8+Zl)%cnfsHI%4`;x6XW^UF^e4s3Z@S<&EV8?56Wya;HNs0E> z`$0dgRdiUz9RO9Au3RmYq>K#G=X%*_dUbSJHP`lSfBaN8t-~@F>)BL1RT*9I851A3 z<-+Gb#_QRX>~av#Ni<#zLswtu-c6{jGHR>wflhKLzC4P@b%8&~u)fosoNjk4r#GvC zlU#UU9&0Hv;d%g72Wq?Ym<&&vtA3AB##L}=ZjiTR4hh7J)e>ei} zt*u+>h%MwN`%3}b4wYpV=QwbY!jwfIj#{me)TDOG`?tI!%l=AwL2G@9I~}?_dA5g6 zCKgK(;6Q0&P&K21Tx~k=o6jwV{dI_G+Ba*Zts|Tl6q1zeC?iYJTb{hel*x>^wb|2RkHkU$!+S4OU4ZOKPZjV>9OVsqNnv5jK8TRAE$A&^yRwK zj-MJ3Pl?)KA~fq#*K~W0l4$0=8GRx^9+?w z!QT8*-)w|S^B0)ZeY5gZPI2G(QtQf?DjuK(s^$rMA!C%P22vynZY4SuOE=wX2f8$R z)A}mzJi4WJnZ`!bHG1=$lwaxm!GOnRbR15F$nRC-M*H<*VfF|pQw(;tbSfp({>9^5 zw_M1-SJ9eGF~m(0dvp*P8uaA0Yw+EkP-SWqu zqal$hK8SmM7#Mrs0@OD+%_J%H*bMyZiWAZdsIBj#lkZ!l2c&IpLu(5^T0Ge5PHzR} zn;TXs$+IQ_&;O~u=Jz+XE0wbOy`=6>m9JVG} zJ~Kp1e5m?K3x@@>!D)piw^eMIHjD4RebtR`|IlckplP1;r21wTi8v((KqNqn%2CB< zifaQc&T}*M&0i|LW^LgdjIaX|o~I$`owHolRqeH_CFrqCUCleN130&vH}dK|^kC>) z-r2P~mApHotL4dRX$25lIcRh_*kJaxi^%ZN5-GAAMOxfB!6flLPY-p&QzL9TE%ho( zRwftE3sy5<*^)qYzKkL|rE>n@hyr;xPqncY6QJ8125!MWr`UCWuC~A#G1AqF1@V$kv>@NBvN&2ygy*{QvxolkRRb%Ui zsmKROR%{*g*WjUUod@@cS^4eF^}yQ1>;WlGwOli z+Y$(8I`0(^d|w>{eaf!_BBM;NpCoeem2>J}82*!em=}}ymoXk>QEfJ>G(3LNA2-46 z5PGvjr)Xh9>aSe>vEzM*>xp{tJyZox1ZRl}QjcvX2TEgNc^(_-hir@Es>NySoa1g^ zFow_twnHdx(j?Q_3q51t3XI7YlJ4_q&(0#)&a+RUy{IcBq?)eaWo*=H2UUVIqtp&lW9JTJiP&u zw8+4vo~_IJXZIJb_U^&=GI1nSD%e;P!c{kZALNCm5c%%oF+I3DrA63_@4)(v4(t~JiddILp7jmoy+>cD~ivwoctFfEL zP*#2Rx?_&bCpX26MBgp^4G>@h`Hxc(lnqyj!*t>9sOBcXN(hTwEDpn^X{x!!gPX?1 z*uM$}cYRwHXuf+gYTB}gDTcw{TXSOUU$S?8BeP&sc!Lc{{pEv}x#ELX>6*ipI1#>8 zKes$bHjiJ1OygZge_ak^Hz#k;=od1wZ=o71ba7oClBMq>Uk6hVq|ePPt)@FM5bW$I z;d2Or@wBjbTyZj|;+iHp%Bo!Vy(X3YM-}lasMItEV_QrP-Kk_J4C>)L&I3Xxj=E?| zsAF(IfVQ4w+dRRnJ>)}o^3_012YYgFWE)5TT=l2657*L8_u1KC>Y-R{7w^ShTtO;VyD{dezY;XD@Rwl_9#j4Uo!1W&ZHVe0H>f=h#9k>~KUj^iUJ%@wU{Xuy z3FItk0<;}6D02$u(RtEY#O^hrB>qgxnOD^0AJPGC9*WXw_$k%1a%-`>uRIeeAIf3! zbx{GRnG4R$4)3rVmg63gW?4yIWW_>;t3>4@?3}&ct0Tk}<5ljU>jIN1 z&+mzA&1B6`v(}i#vAzvqWH~utZzQR;fCQGLuCN|p0hey7iCQ8^^dr*hi^wC$bTk`8M(JRKtQuXlSf$d(EISvuY0dM z7&ff;p-Ym}tT8^MF5ACG4sZmAV!l;0h&Mf#ZPd--_A$uv2@3H!y^^%_&Iw$*p79Uc5@ZXLGK;edg%)6QlvrN`U7H@e^P*0Atd zQB%>4--B1!9yeF(3vk;{>I8+2D;j`zdR8gd8dHuCQ_6|F(5-?gd&{YhLeyq_-V--4 z(SP#rP=-rsSHJSHDpT1{dMAb7-=9K1-@co_!$dG^?c(R-W&a_C5qy2~m3@%vBGhgnrw|H#g9ABb7k{NE?m4xD?;EV+fPdE>S2g$U(&_zGV+TPvaot>W_ zf8yY@)yP8k$y}UHVgF*uxtjW2zX4Hc3;W&?*}K&kqYpi%FHarfaC$ETHpSoP;A692 zR*LxY1^BO1ry@7Hc9p->hd==U@cuo*CiTnozxen;3Gct=?{5P94TgQ(UJoBb`7z@BqY z;q&?V2D1Y%n;^Dh0+eD)>9<}=A|F5{q#epBu#sf@lRs`oFEpkE%mrfwqJNFCpJC$| zy6#N;GF8XgqX(m2yMM2yq@TxStIR7whUIs2ar$t%Avh;nWLwElVBSI#j`l2$lb-!y zK|!?0hJ1T-wL{4uJhOFHp4?@28J^Oh61DbeTeSWub(|dL-KfxFCp0CjQjV`WaPW|U z=ev@VyC>IS@{ndzPy||b3z-bj5{Y53ff}|TW8&&*pu#?qs?)#&M`ACfb;%m+qX{Or zb+FNNHU}mz!@!EdrxmP_6eb3Cah!mL0ArL#EA1{nCY-!jL8zzz7wR6wAw(8K|IpW; zUvH*b1wbuRlwlUt;dQhx&pgsvJcUpm67rzkNc}2XbC6mZAgUn?VxO6YYg=M!#e=z8 zjX5ZLyMyz(VdPVyosL0}ULO!Mxu>hh`-MItnGeuQ;wGaU0)gIq3ZD=pDc(Qtk}APj z#HtA;?idVKNF)&0r|&w#l7DbX%b91b2;l2=L8q#}auVdk{RuYn3SMDo1%WW0tD*62 zaIj65Y38;?-~@b82AF!?Nra2;PU)t~qYUhl!GDK3*}%@~N0GQH7zflSpfP-ydOwNe zOK~w((+pCD&>f!b!On);5m+zUBFJtQ)mV^prS3?XgPybC2%2LiE5w+S4B|lP z+_>3$`g=%P{IrN|1Oxz30R{kI`}ZL!r|)RS@8Do;ZD3_=PbBrrP~S@EdsD{V+`!4v z{MSF}j!6odl33rA+$odIMaK%ersg%xMz>JQ^R+!qNq$5S{KgmGN#gAApX*3ib)TDsVVi>4ypIX|Ik4d6E}v z=8+hs9J=k3@Eiga^^O|ESMQB-O6i+BL*~*8coxjGs{tJ9wXjGZ^Vw@j93O<&+bzAH z9+N^ALvDCV<##cGoo5fX;wySGGmbH zHsslio)cxlud=iP2y=nM>v8vBn*hJ0KGyNOy7dr8yJKRh zywBOa4Lhh58y06`5>ESYXqLt8ZM1axd*UEp$wl`APU}C9m1H8-ModG!(wfSUQ%}rT3JD*ud~?WJdM}x>84)Cra!^J9wGs6^G^ze~eV(d&oAfm$ z_gwq4SHe=<#*FN}$5(0d_NumIZYaqs|MjFtI_rJb^+ZO?*XQ*47mzLNSL7~Nq+nw8 zuw0KwWITC43`Vx9eB!0Fx*CN9{ea$xjCvtjeyy>yf!ywxvv6<*h0UNXwkEyRxX{!e$TgHZ^db3r;1qhT)+yt@|_!@ zQG2aT`;lj>qjY`RGfQE?KTt2mn=HmSR>2!E38n8PlFs=1zsEM}AMICb z86Dbx(+`!hl$p=Z)*W~+?_HYp+CJacrCS-Fllz!7E>8*!E(yCh-cWbKc7)mPT6xu= zfKpF3I+p%yFXkMIq!ALiXF89-aV{I6v+^k#!_xwtQ*Nl#V|hKg=nP=fG}5VB8Ki7) z;19!on-iq&Xyo#AowvpA)RRgF?YBdDc$J8*)2Wko;Y?V6XMOCqT(4F#U2n1jg*4=< z8$MfDYL|z731iEKB3WW#kz|c3qh7AXjyZ}wtSg9xA(ou-pLoxF{4qk^KS?!d3J0!! zqE#R9NYGUyy>DEs%^xW;oQ5Cs@fomcrsN}rI2Hg^6y9kwLPF`K3llX00aM_r)c?ay zevlHA#N^8N+AI=)vx?4(=?j^ba^{umw140V#g58#vtnh8i7vRs*UD=lge;T+I zl1byCNr5H%DF58I2(rk%8hQ;zuCXs=sipbQy?Hd;umv4!fav@LE4JQ^>J{aZ=!@Gc~p$JudMy%0{=5QY~S8YVP zaP6gRqfZ0>q9nR3p+Wa8icNyl0Zn4k*bNto-(+o@-D8cd1Ed7`}dN3%wezkFxj_#_K zyV{msOOG;n+qbU=jBZk+&S$GEwJ99zSHGz8hF1`Xxa^&l8aaD8OtnIVsdF0cz=Y)? zP$MEdfKZ}_&#AC)R%E?G)tjrKsa-$KW_-$QL}x$@$NngmX2bHJQG~77D1J%3bGK!- zl!@kh5-uKc@U4I_Er;~epL!gej`kdX>tSXVFP-BH#D-%VJOCpM(-&pOY+b#}lOe)Z z0MP5>av1Sy-dfYFy%?`p`$P|`2yDFlv(8MEsa++Qv5M?7;%NFQK0E`Ggf3@2aUwtBpCoh`D}QLY%QAnJ z%qcf6!;cjOTYyg&2G27K(F8l^RgdV-V!~b$G%E=HP}M*Q*%xJV3}I8UYYd)>*nMvw zemWg`K6Rgy+m|y!8&*}=+`STm(dK-#b%)8nLsL&0<8Zd^|# z;I2gR&e1WUS#v!jX`+cuR;+yi(EiDcRCouW0AHNd?;5WVnC_Vg#4x56#0FOwTH6_p z#GILFF0>bb_tbmMM0|sd7r%l{U!fI0tGza&?65_D7+x9G zf3GA{c|mnO(|>}y(}%>|2>p0X8wRS&Eb0g)rcICIctfD_I9Wd+hKuEqv?gzEZBxG-rG~e!-2hqaR$Y$I@k{rLyCccE}3d)7Fn3EvfsEhA|bnJ374&pZDq&i zr(9#eq(g8^tG??ZzVk(#jU+-ce`|yiQ1dgrJ)$|wk?XLEqv&M+)I*OZ*oBCizjHuT zjZ|mW=<1u$wPhyo#&rIO;qH~pu4e3X;!%BRgmX%?&KZ6tNl386-l#a>ug5nHU2M~{fM2jvY*Py< zbR&^o&!T19G6V-pV@CB)YnEOfmrdPG%QByD?=if99ihLxP6iA8$??wUPWzptC{u5H z38Q|!=IW`)5Gef4+pz|9fIRXt>nlW)XQvUXBO8>)Q=$@gtwb1iEkU4EOWI4`I4DN5 zTC-Pk6N>2%7Hikg?`Poj5lkM0T_i zoCXfXB&}{TG%IB)ENSfI_Xg3=lxYc6-P059>oK;L+vGMy_h{y9soj#&^q5E!pl(Oq zl)oCBi56u;YHkD)d`!iOAhEJ0A^~T;uE9~Yp0{E%G~0q|9f34F!`P56-ZF{2hSaWj zio%9RR%oe~he22r@&j_d(y&nAUL*ayBY4#CWG&gZ8ybs#UcF?8K#HzziqOYM-<`C& z1gD?j)M0bp1w*U>X_b1@ag1Fx=d*wlr zEAcpmI#5LtqcX95LeS=LXlzh*l;^yPl_6MKk)zPuTz_p8ynQ5;oIOUAoPED=+M6Q( z8YR!DUm#$zTM9tbNhxZ4)J0L&Hpn%U>wj3z<=g;`&c_`fGufS!o|1%I_sA&;14bRC z3`BtzpAB-yl!%zM{Aiok8*X%lDNrPiAjBnzHbF0=Ua*3Lxl(zN3Thj2x6nWi^H7Jlwd2fxIvnI-SiC%*j z2~wIWWKT^5fYipo-#HSrr;(RkzzCSt?THVEH2EPvV-4c#Gu4&1X% z<1zTAM7ZM(LuD@ZPS?c30Ur`;2w;PXPVevxT)Ti25o}1JL>MN5i1^(aCF3 zbp>RI?X(CkR9*Hnv!({Ti@FBm;`Ip%e*D2tWEOc62@$n7+gWb;;j}@G()~V)>s}Bd zw+uTg^ibA(gsp*|&m7Vm=heuIF_pIukOedw2b_uO8hEbM4l=aq?E-7M_J`e(x9?{5 zpbgu7h}#>kDQAZL;Q2t?^pv}Y9Zlu=lO5e18twH&G&byq9XszEeXt$V93dQ@Fz2DV zs~zm*L0uB`+o&#{`uVYGXd?)Fv^*9mwLW4)IKoOJ&(8uljK?3J`mdlhJF1aK;#vlc zJdTJc2Q>N*@GfafVw45B03)Ty8qe>Ou*=f#C-!5uiyQ^|6@Dzp9^n-zidp*O`YuZ|GO28 zO0bqi;)fspT0dS2;PLm(&nLLV&&=Ingn(0~SB6Fr^AxPMO(r~y-q2>gRWv7{zYW6c zfiuqR)Xc41A7Eu{V7$-yxYT-opPtqQIJzMVkxU)cV~N0ygub%l9iHT3eQtB>nH0c` zFy}Iwd9vocxlm!P)eh0GwKMZ(fEk92teSi*fezYw3qRF_E-EcCh-&1T)?beW?9Q_+pde8&UW*(avPF4P}M#z*t~KlF~#5TT!&nu z>FAKF8vQl>Zm(G9UKi4kTqHj`Pf@Z@Q(bmZkseb1^;9k*`a9lKXceKX#dMd@ds`t| z2~UPsbn2R0D9Nm~G*oc@(%oYTD&yK)scA?36B7mndR9l*hNg!3?6>CR+tF1;6sr?V zzz8FBrZ@g4F_!O2igIGZcWd zRe_0*{d6cyy9QQ(|Ct~WTM1pC3({5qHahk*M*O}IPE6icikx48VZ?!0Oc^FVoq`}eu~ zpRq0MYHaBA-`b_BVID}|oo-bem76;B2zo7j7yz(9JiSY6JTjKz#+w{9mc{&#x}>E? zSS3mY$_|scfP3Mo_F5x;r>y&Mquy*Q1b3eF^*hg3tap~%?@ASeyodYa=dF&k=ZyWy z3C+&C95h|9TAVM~-8y(&xcy0nvl}6B*)j0FOlSz%+bK-}S4;F?P`j55*+ZO0Ogk7D z5q30zE@Nup4lqQoG`L%n{T?qn9&WC94%>J`KU{gHIq?n_L;75kkKyib;^?yXUx6BO zju%DyU(l!Vj(3stJ>!pMZ*NZFd60%oSAD1JUXG0~2GCXpB0Am(YPyhzQda-e)b^+f zzFaEZdVTJRJXPJo%w z$?T;xq^&(XjmO>0bNGsT|1{1UqGHHhasPC;H!oX52(AQ7h9*^npOIRdQbNrS0X5#5G?L4V}WsAYcpq-+JNXhSl)XbxZ)L@5Q+?wm{GAU z9a7X8hAjAo;4r_eOdZfXGL@YpmT|#qECEcPTQ;nsjIkQ;!0}g?T>Zr*Fg}%BZVA)4 zCAzvWr?M&)KEk`t9eyFi_GlPV9a2kj9G(JgiZadd_&Eb~#DyZ%2Zcvrda_A47G&uW z^6TnBK|th;wHSo8ivpScU?AM5HDu2+ayzExMJc@?4{h-c`!b($ExB`ro#vkl<;=BA z961c*n(4OR!ebT*7UV7sqL;rZ3+Z)BYs<1I|9F|TOKebtLPxahl|ZXxj4j!gjj!3*+iSb5Zni&EKVt$S{0?2>A}d@3PSF3LUu)5 z*Y#a1uD6Y!$=_ghsPrOqX!OcIP`IW};tZzx1)h_~mgl;0=n zdP|Te_7)~R?c9s>W(-d!@nzQyxqakrME{Tn@>0G)kqV<4;{Q?Z-M)E-|IFLTc}WQr z1Qt;u@_dN2kru_9HMtz8MQx1aDYINH&3<+|HA$D#sl3HZ&YsjfQBv~S>4=u z7gA2*X6_cI$2}JYLIq`4NeXTz6Q3zyE717#>RD&M?0Eb|KIyF;xj;+3#DhC-xOj~! z$-Kx#pQ)_$eHE3Zg?V>1z^A%3jW0JBnd@z`kt$p@lch?A9{j6hXxt$(3|b>SZiBxOjA%LsIPii{=o(B`yRJ>OK;z_ELTi8xHX)il z--qJ~RWsZ%9KCNuRNUypn~<2+mQ=O)kd59$Lul?1ev3c&Lq5=M#I{ zJby%%+Top_ocqv!jG6O6;r0Xwb%vL6SP{O(hUf@8riADSI<|y#g`D)`x^vHR4!&HY`#TQMqM`Su}2(C|KOmG`wyK>uh@3;(prdL{2^7T3XFGznp{-sNLLJH@mh* z^vIyicj9yH9(>~I-Ev7p=yndfh}l!;3Q65}K}()(jp|tC;{|Ln1a+2kbctWEX&>Vr zXp5=#pw)@-O6~Q|><8rd0>H-}0Nsc|J6TgCum{XnH2@hFB09FsoZ_ow^Nv@uGgz3# z<6dRDt1>>-!kN58&K1HFrgjTZ^q<>hNI#n8=hP&pKAL4uDcw*J66((I?!pE0fvY6N zu^N=X8lS}(=w$O_jlE(;M9F={-;4R(K5qa=P#ZVW>}J&s$d0?JG8DZJwZcx3{CjLg zJA>q-&=Ekous)vT9J>fbnZYNUtvox|!Rl@e^a6ue_4-_v=(sNB^I1EPtHCFEs!>kK6B@-MS!(B zST${=v9q6q8YdSwk4}@c6cm$`qZ86ipntH8G~51qIlsYQ)+2_Fg1@Y-ztI#aa~tFD_QUxb zU-?g5B}wU@`tnc_l+B^mRogRghXs!7JZS=A;In1|f(1T(+xfIi zvjccLF$`Pkv2w|c5BkSj>>k%`4o6#?ygojkV78%zzz`QFE6nh{(SSJ9NzVdq>^N>X zpg6+8u7i(S>c*i*cO}poo7c9%i^1o&3HmjY!s8Y$5aO(!>u1>-eai0;rK8hVzIh8b zL53WCXO3;=F4_%CxMKRN^;ggC$;YGFTtHtLmX%@MuMxvgn>396~ zEp>V(dbfYjBX^!8CSg>P2c5I~HItbe(dl^Ax#_ldvCh;D+g6-%WD|$@S6}Fvv*eHc zaKxji+OG|_KyMe2D*fhP<3VP0J1gTgs6JZjE{gZ{SO-ryEhh;W237Q0 z{yrDobsM6S`bPMUzr|lT|99m6XDI$RzW4tQ$|@C2RjhBYPliEXFV#M*5G4;Kb|J8E z0IH}-d^S-53kFRZ)ZFrd2%~Sth-6BN?hnMa_PC4gdWyW3q-xFw&L^x>j<^^S$y_3_ zdZxouw%6;^mg#jG@7L!g9Kdw}{w^X9>TOtHgxLLIbfEG^Qf;tD=AXozE6I`XmOF=# zGt$Wl+7L<8^VI-eSK%F%dqXieK^b!Z3yEA$KL}X@>fD9)g@=DGt|=d(9W%8@Y@!{PI@`Nd zyF?Us(0z{*u6|X?D`kKSa}}Q*HP%9BtDEA^buTlI5ihwe)CR%OR46b+>NakH3SDbZmB2X>c8na&$lk zYg$SzY+EXtq2~$Ep_x<~+YVl<-F&_fbayzTnf<7?Y-un3#+T~ahT+eW!l83sofNt; zZY`eKrGqOux)+RMLgGgsJdcA3I$!#zy!f<$zL0udm*?M5w=h$Boj*RUk8mDPVUC1RC8A`@7PgoBIU+xjB7 z25vky+^7k_|1n1&jKNZkBWUu1VCmS}a|6_+*;fdUZAaIR4G!wv=bAZEXBhcjch6WH zdKUr&>z^P%_LIx*M&x{!w|gij?nigT8)Ol3VicXRL0tU}{vp2fi!;QkVc#I38op3O z=q#WtNdN{x)OzmH;)j{cor)DQ;2%m>xMu_KmTisaeCC@~rQwQTfMml7FZ_ zU2AR8yCY_CT$&IAn3n#Acf*VKzJD8-aphMg(12O9cv^AvLQ9>;f!4mjyxq_a%YH2+{~=3TMNE1 z#r3@ynnZ#p?RCkPK36?o{ILiHq^N5`si(T_cKvO9r3^4pKG0AgDEB@_72(2rvU^-; z%&@st2+HjP%H)u50t81p>(McL{`dTq6u-{JM|d=G1&h-mtjc2{W0%*xuZVlJpUSP-1=U6@5Q#g(|nTVN0icr-sdD~DWR=s}`$#=Wa zt5?|$`5`=TWZevaY9J9fV#Wh~Fw@G~0vP?V#Pd=|nMpSmA>bs`j2e{)(827mU7rxM zJ@ku%Xqhq!H)It~yXm=)6XaPk=$Rpk*4i4*aSBZe+h*M%w6?3&0>>|>GHL>^e4zR!o%aGzUn40SR+TdN%=Dbn zsRfXzGcH#vjc-}7v6yRhl{V5PhE-r~)dnmNz=sDt?*1knNZ>xI5&vBwrosF#qRL-Y z;{W)4W&cO0XMKy?{^d`Xh(2B?j0ioji~G~p5NQJyD6vouyoFE9w@_R#SGZ1DR4GnN z{b=sJ^8>2mq3W;*u2HeCaKiCzK+yD!^i6QhTU5npwO+C~A#5spF?;iuOE>o&p3m1C zmT$_fH8v+5u^~q^ic#pQN_VYvU>6iv$tqx#Sulc%|S7f zshYrWq7IXCiGd~J(^5B1nGMV$)lo6FCTm1LshfcOrGc?HW7g>pV%#4lFbnt#94&Rg{%Zbg;Rh?deMeOP(du*)HryI zCdhO$3|SeaWK<>(jSi%qst${Z(q@{cYz7NA^QO}eZ$K@%YQ^Dt4CXzmvx~lLG{ef8 zyckIVSufk>9^e_O7*w2z>Q$8me4T~NQDq=&F}Ogo#v1u$0xJV~>YS%mLVYqEf~g*j zGkY#anOI9{(f4^v21OvYG<(u}UM!-k;ziH%GOVU1`$0VuO@Uw2N{$7&5MYjTE?Er) zr?oZAc~Xc==KZx-pmoh9KiF_JKU7u0#b_}!dWgC>^fmbVOjuiP2FMq5OD9+4TKg^2 z>y6s|sQhI`=fC<>BnQYV433-b+jBi+N6unz%6EQR%{8L#=4sktI>*3KhX+qAS>+K#}y5KnJ8YuOuzG(Ea5;$*1P$-9Z+V4guyJ#s) zRPH(JPN;Es;H72%c8}(U)CEN}Xm>HMn{n!d(=r*YP0qo*^APwwU5YTTeHKy#85Xj< zEboiH=$~uIVMPg!qbx~0S=g&LZ*IyTJG$hTN zv%2>XF``@S9lnLPC?|myt#P)%7?%e_j*aU4TbTyxO|3!h%=Udp;THL+^oPp<6;TLlIOa$&xeTG_a*dbRDy+(&n1T=MU z+|G5{2UprrhN^AqODLo$9Z2h(3^wtdVIoSk@}wPajVgIoZipRft}^L)2Y@mu;X-F{LUw|s7AQD-0!otW#W9M@A~08`o%W;Bq-SOQavG*e-sy8) zwtaucR0+64B&Pm++-m56MQ$@+t{_)7l-|`1kT~1s!swfc4D9chbawUt`RUOdoxU|j z$NE$4{Ysr@2Qu|K8pD37Yv&}>{_I5N49a@0<@rGHEs}t zwh_+9T0oh@ptMbjy*kbz<&3>LGR-GNsT8{x1g{!S&V7{5tPYX(GF>6qZh>O&F)%_I zkPE-pYo3dayjNQAG+xrI&yMZy590FA1unQ*k*Zfm#f9Z5GljOHBj-B83KNIP1a?<^1vOhDJkma0o- zs(TP=@e&s6fRrU(R}{7eHL*(AElZ&80>9;wqj{|1YQG=o2Le-m!UzUd?Xrn&qd8SJ0mmEYtW;t(;ncW_j6 zGWh4y|KMK^s+=p#%fWxjXo434N`MY<8W`tNH-aM6x{@o?D3GZM&+6t4V3I*3fZd{a z0&D}DI?AQl{W*?|*%M^D5{E>V%;=-r&uQ>*e)cqVY52|F{ptA*`!iS=VKS6y4iRP6 zKUA!qpElT5vZvN}U5k-IpeNOr6KF`-)lN1r^c@HnT#RlZbi(;yuvm9t-Noh5AfRxL@j5dU-X37(?S)hZhRDbf5cbhDO5nSX@WtApyp` zT$5IZ*4*)h8wShkPI45stQH2Y7yD*CX^Dh@B%1MJSEn@++D$AV^ttKXZdQMU`rxiR z+M#45Z2+{N#uR-hhS&HAMFK@lYBWOzU^Xs-BlqQDyN4HwRtP2$kks@UhAr@wlJii%Rq?qy25?Egs z*a&iAr^rbJWlv+pYAVUq9lor}#Cm|D$_ev2d2Ko}`8kuP(ljz$nv3OCDc7zQp|j6W zbS6949zRvj`bhbO(LN3}Pq=$Ld3a_*9r_24u_n)1)}-gRq?I6pdHPYHgIsn$#XQi~ z%&m_&nnO9BKy;G%e~fa7i9WH#MEDNQ8WCXhqqI+oeE5R7hLZT_?7RWVzEGZNz4*Po ze&*a<^Q*ze72}UM&$c%FuuEIN?EQ@mnILwyt;%wV-MV+|d%>=;3f0(P46;Hwo|Wr0 z>&FS9CCb{?+lDpJMs`95)C$oOQ}BSQEv0Dor%-Qj0@kqlIAm1-qSY3FCO2j$br7_w zlpRfAWz3>Gh~5`Uh?ER?@?r0cXjD0WnTx6^AOFii;oqM?|M9QjHd*GK3WwA}``?dK15`ZvG>_nB2pSTGc{n2hYT6QF^+&;(0c`{)*u*X7L_ zaxqyvVm$^VX!0YdpSNS~reC+(uRqF2o>jqIJQkC&X>r8|mBHvLaduM^Mh|OI60<;G zDHx@&jUfV>cYj5+fAqvv(XSmc(nd@WhIDvpj~C#jhZ6@M3cWF2HywB1yJv2#=qoY| zIiaxLsSQa7w;4YE?7y&U&e6Yp+2m(sb5q4AZkKtey{904rT08pJpanm->Z75IdvW^ z!kVBy|CIUZn)G}92_MgoLgHa?LZJDp_JTbAEq8>6a2&uKPF&G!;?xQ*+{TmNB1H)_ z-~m@CTxDry_-rOM2xwJg{fcZ41YQDh{DeI$4!m8c;6XtFkFyf`fOsREJ`q+Bf4nS~ zKDYs4AE7Gugv?X)tu4<-M8ag{`4pfQ14z<(8MYQ4u*fl*DCpq66+Q1-gxNCQ!c$me zyTrmi7{W-MGP!&S-_qJ%9+e08_9`wWGG{i5yLJ;8qbt-n_0*Q371<^u@tdz|;>fPW zE=&q~;wVD_4IQ^^jyYX;2shIMiYdvIpIYRT>&I@^{kL9Ka2ECG>^l>Ae!GTn{r~o= z|I9=J#wNe)zYRqGZ7Q->L{dfewyC$ZYcLaoNormZ3*gfM=da*{heC)&46{yTS!t10 zn_o0qUbQOs$>YuY>YHi|NG^NQG<_@jD&WnZcW^NTC#mhVE7rXlZ=2>mZkx{bc=~+2 z{zVH=Xs0`*K9QAgq9cOtfQ^BHh-yr=qX8hmW*0~uCup89IJMvWy%#yt_nz@6dTS)L{O3vXye< zW4zUNb6d|Tx`XIVwMMgqnyk?c;Kv`#%F0m^<$9X!@}rI##T{iXFC?(ui{;>_9Din8 z7;(754q!Jx(~sb!6+6Lf*l{fqD7GW*v{>3wp+)@wq2abADBK!kI8To}7zooF%}g-z zJ1-1lp-lQI6w^bov9EfhpxRI}`$PTpJI3uo@ZAV729JJ2Hs68{r$C0U=!d$Bm+s(p z8Kgc(Ixf4KrN%_jjJjTx5`&`Ak*Il%!}D_V)GM1WF!k$rDJ-SudXd_Xhl#NWnET&e-P!rH~*nNZTzxj$?^oo3VWc-Ay^`Phze3(Ft!aNW-f_ zeMy&BfNCP^-FvFzR&rh!w(pP5;z1$MsY9Voozmpa&A}>|a{eu}>^2s)So>&kmi#7$ zJS_-DVT3Yi(z+ruKbffNu`c}s`Uo`ORtNpUHa6Q&@a%I%I;lm@ea+IbCLK)IQ~)JY zp`kdQ>R#J*i&Ljer3uz$m2&Un9?W=Ue|hHv?xlM`I&*-M;2{@so--0OAiraN1TLra z>EYQu#)Q@UszfJj&?kr%RraFyi*eG+HD_(!AWB;hPgB5Gd-#VDRxxv*VWMY0hI|t- zR=;TL%EKEg*oet7GtmkM zgH^y*1bfJ*af(_*S1^PWqBVVbejFU&#m`_69IwO!aRW>Rcp~+7w^ptyu>}WFYUf;) zZrgs;EIN9$Immu`$umY%$I)5INSb}aV-GDmPp!d_g_>Ar(^GcOY%2M)Vd7gY9llJR zLGm*MY+qLzQ+(Whs8-=ty2l)G9#82H*7!eo|B6B$q%ak6eCN%j?{SI9|K$u3)ORoz zw{bAGaWHrMb|X^!UL~_J{jO?l^}lI^|7jIn^p{n%JUq9{tC|{GM5Az3SrrPkuCt_W zq#u0JfDw{`wAq`tAJmq~sz`D_P-8qr>kmms>I|);7Tn zLl^n*Ga7l=U)bQmgnSo5r_&#Pc=eXm~W75X9Cyy0WDO|fbSn5 zLgpFAF4fa90T-KyR4%%iOq6$6BNs@3ZV<~B;7V=u zdlB8$lpe`w-LoS;0NXFFu@;^^bc?t@r3^XTe*+0;o2dt&>eMQeDit(SfDxYxuA$uS z**)HYK7j!vJVRNfrcokVc@&(ke5kJzvi};Lyl7@$!`~HM$T!`O`~MQ1k~ZH??fQr zNP)33uBWYnTntKRUT*5lu&8*{fv>syNgxVzEa=qcKQ86Vem%Lpae2LM=TvcJLs?`=o9%5Mh#k*_7zQD|U7;A%=xo^_4+nX{~b1NJ6@ z*=55;+!BIj1nI+)TA$fv-OvydVQB=KK zrGWLUS_Chm$&yoljugU=PLudtJ2+tM(xj|E>Nk?c{-RD$sGYNyE|i%yw>9gPItE{ zD|BS=M>V^#m8r?-3swQofD8j$h-xkg=F+KM%IvcnIvc)y zl?R%u48Jeq7E*26fqtLe_b=9NC_z|axW#$e0adI#r(Zsui)txQ&!}`;;Z%q?y2Kn! zXzFNe+g7+>>`9S0K1rmd)B_QVMD?syc3e0)X*y6(RYH#AEM9u?V^E0GHlAAR)E^4- zjKD+0K=JKtf5DxqXSQ!j?#2^ZcQoG5^^T+JaJa3GdFeqIkm&)dj76WaqGukR-*&`13ls8lU2ayVIR%;79HYAr5aEhtYa&0}l}eAw~qKjUyz4v*At z?})QplY`3cWB6rl7MI5mZx&#%I0^iJm3;+J9?RA(!JXjl?(XgmA-D#2cY-^?g1c*Q z3GVLh!8Jhe;QqecbMK#XIJxKMb=6dcs?1vbb?@ov-raj`hnYO92y8pv@>RVr=9Y-F zv`BK)9R6!m4Pfllu4uy0WBL+ZaUFFzbZZtI@J8{OoQ^wL-b$!FpGT)jYS-=vf~b-@ zIiWs7j~U2yI=G5;okQz%gh6}tckV5wN;QDbnu|5%%I(#)8Q#)wTq8YYt$#f9=id;D zJbC=CaLUyDIPNOiDcV9+=|$LE9v2;Qz;?L+lG{|g&iW9TI1k2_H;WmGH6L4tN1WL+ zYfSVWq(Z_~u~U=g!RkS|YYlWpKfZV!X%(^I3gpV%HZ_{QglPSy0q8V+WCC2opX&d@eG2BB#(5*H!JlUzl$DayI5_J-n zF@q*Fc-nlp%Yt;$A$i4CJ_N8vyM5fNN`N(CN53^f?rtya=p^MJem>JF2BEG|lW|E) zxf)|L|H3Oh7mo=9?P|Y~|6K`B3>T)Gw`0ESP9R`yKv}g|+qux(nPnU(kQ&&x_JcYg9+6`=; z-EI_wS~l{T3K~8}8K>%Ke`PY!kNt415_x?^3QOvX(QUpW&$LXKdeZM-pCI#%EZ@ta zv(q-(xXIwvV-6~(Jic?8<7ain4itN>7#AqKsR2y(MHMPeL)+f+v9o8Nu~p4ve*!d3 z{Lg*NRTZsi;!{QJknvtI&QtQM_9Cu%1QcD0f!Fz+UH4O#8=hvzS+^(e{iG|Kt7C#u zKYk7{LFc+9Il>d6)blAY-9nMd(Ff0;AKUo3B0_^J&ESV@4UP8PO0no7G6Gp_;Z;YnzW4T-mCE6ZfBy(Y zXOq^Of&?3#Ra?khzc7IJT3!%IKK8P(N$ST47Mr=Gv@4c!>?dQ-&uZihAL1R<_(#T8Y`Ih~soL6fi_hQmI%IJ5qN995<{<@_ z;^N8AGQE+?7#W~6X>p|t<4@aYC$-9R^}&&pLo+%Ykeo46-*Yc(%9>X>eZpb8(_p{6 zwZzYvbi%^F@)-}5%d_z^;sRDhjqIRVL3U3yK0{Q|6z!PxGp?|>!%i(!aQODnKUHsk^tpeB<0Qt7`ZBlzRIxZMWR+|+ z3A}zyRZ%0Ck~SNNov~mN{#niO**=qc(faGz`qM16H+s;Uf`OD1{?LlH!K!+&5xO%6 z5J80-41C{6)j8`nFvDaeSaCu_f`lB z_Y+|LdJX=YYhYP32M556^^Z9MU}ybL6NL15ZTV?kfCFfpt*Pw5FpHp#2|ccrz#zoO zhs=+jQI4fk*H0CpG?{fpaSCmXzU8bB`;kCLB8T{_3t>H&DWj0q0b9B+f$WG=e*89l zzUE)b9a#aWsEpgnJqjVQETpp~R7gn)CZd$1B8=F*tl+(iPH@s9jQtE33$dBDOOr=% ziOpR8R|1eLI?Rn*d+^;_U#d%bi$|#obe0(-HdB;K>=Y=mg{~jTA_WpChe8QquhF`N z>hJ}uV+pH`l_@d>%^KQNm*$QNJ(lufH>zv9M`f+C-y*;hAH(=h;kp@eL=qPBeXrAo zE7my75EYlFB30h9sdt*Poc9)2sNP9@K&4O7QVPQ^m$e>lqzz)IFJWpYrpJs)Fcq|P z5^(gnntu!+oujqGpqgY_o0V&HL72uOF#13i+ngg*YvPcqpk)Hoecl$dx>C4JE4DWp z-V%>N7P-}xWv%9Z73nn|6~^?w$5`V^xSQbZceV<_UMM&ijOoe{Y^<@3mLSq_alz8t zr>hXX;zTs&k*igKAen1t1{pj94zFB;AcqFwV)j#Q#Y8>hYF_&AZ?*ar1u%((E2EfZ zcRsy@s%C0({v=?8oP=DML`QsPgzw3|9|C22Y>;=|=LHSm7~+wQyI|;^WLG0_NSfrf zamq!5%EzdQ&6|aTP2>X=Z^Jl=w6VHEZ@=}n+@yeu^ke2Yurrkg9up3g$0SI8_O-WQu$bCsKc(juv|H;vz6}%7ONww zKF%!83W6zO%0X(1c#BM}2l^ddrAu^*`9g&1>P6m%x{gYRB)}U`40r>6YmWSH(|6Ic zH~QNgxlH*;4jHg;tJiKia;`$n_F9L~M{GiYW*sPmMq(s^OPOKm^sYbBK(BB9dOY`0 z{0!=03qe*Sf`rcp5Co=~pfQyqx|umPHj?a6;PUnO>EZGb!pE(YJgNr{j;s2+nNV(K zDi#@IJ|To~Zw)vqGnFwb2}7a2j%YNYxe2qxLk)VWJIux$BC^oII=xv-_}h@)Vkrg1kpKokCmX({u=lSR|u znu_fA0PhezjAW{#Gu0Mdhe8F4`!0K|lEy+<1v;$ijSP~A9w%q5-4Ft|(l7UqdtKao zs|6~~nmNYS>fc?Nc=yzcvWNp~B0sB5ForO5SsN(z=0uXxl&DQsg|Y?(zS)T|X``&8 z*|^p?~S!vk8 zg>$B{oW}%rYkgXepmz;iqCKY{R@%@1rcjuCt}%Mia@d8Vz5D@LOSCbM{%JU#cmIp! z^{4a<3m%-p@JZ~qg)Szb-S)k{jv92lqB(C&KL(jr?+#ES5=pUH$(;CO9#RvDdErmW z3(|f{_)dcmF-p*D%qUa^yYngNP&Dh2gq5hr4J!B5IrJ?ODsw@*!0p6Fm|(ebRT%l) z#)l22@;4b9RDHl1ys$M2qFc;4BCG-lp2CN?Ob~Be^2wQJ+#Yz}LP#8fmtR%o7DYzoo1%4g4D+=HonK7b!3nvL0f1=oQp93dPMTsrjZRI)HX-T}ApZ%B#B;`s? z9Kng{|G?yw7rxo(T<* z1+O`)GNRmXq3uc(4SLX?fPG{w*}xDCn=iYo2+;5~vhWUV#e5e=Yfn4BoS@3SrrvV9 zrM-dPU;%~+3&>(f3sr$Rcf4>@nUGG*vZ~qnxJznDz0irB(wcgtyATPd&gSuX^QK@+ z)7MGgxj!RZkRnMSS&ypR94FC$;_>?8*{Q110XDZ)L);&SA8n>72s1#?6gL>gydPs` zM4;ert4-PBGB@5E` zBaWT=CJUEYV^kV%@M#3(E8>g8Eg|PXg`D`;K8(u{?}W`23?JgtNcXkUxrH}@H_4qN zw_Pr@g%;CKkgP(`CG6VTIS4ZZ`C22{LO{tGi6+uPvvHkBFK|S6WO{zo1MeK$P zUBe}-)3d{55lM}mDVoU@oGtPQ+a<=wwDol}o=o1z*)-~N!6t09du$t~%MlhM9B5~r zy|zs^LmEF#yWpXZq!+Nt{M;bE%Q8z7L8QJDLie^5MKW|I1jo}p)YW(S#oLf(sWn~* zII>pocNM5#Z+-n2|495>?H?*oyr0!SJIl(}q-?r`Q;Jbqqr4*_G8I7agO298VUr9x z8ZcHdCMSK)ZO@Yr@c0P3{`#GVVdZ{zZ$WTO zuvO4ukug&& ze#AopTVY3$B>c3p8z^Yyo8eJ+(@FqyDWlR;uxy0JnSe`gevLF`+ZN6OltYr>oN(ZV z>76nIiVoll$rDNkck6_eh%po^u16tD)JXcii|#Nn(7=R9mA45jz>v}S%DeMc(%1h> zoT2BlF9OQ080gInWJ3)bO9j$ z`h6OqF0NL4D3Kz?PkE8nh;oxWqz?<3_!TlN_%qy*T7soZ>Pqik?hWWuya>T$55#G9 zxJv=G&=Tm4!|p1#!!hsf*uQe}zWTKJg`hkuj?ADST2MX6fl_HIDL7w`5Dw1Btays1 zz*aRwd&>4*H%Ji2bt-IQE$>sbCcI1Poble0wL`LAhedGRZp>%>X6J?>2F*j>`BX|P zMiO%!VFtr_OV!eodgp-WgcA-S=kMQ^zihVAZc!vdx*YikuDyZdHlpy@Y3i!r%JI85$-udM6|7*?VnJ!R)3Qfm4mMm~Z#cvNrGUy|i0u zb|(7WsYawjBK0u1>@lLhMn}@X>gyDlx|SMXQo|yzkg-!wIcqfGrA!|t<3NC2k` zq;po50dzvvHD>_mG~>W0iecTf@3-)<$PM5W@^yMcu@U;)(^eu@e4jAX7~6@XrSbIE zVG6v2miWY^g8bu5YH$c2QDdLkg2pU8xHnh`EUNT+g->Q8Tp4arax&1$?CH($1W&*} zW&)FQ>k5aCim$`Ph<9Zt?=%|pz&EX@_@$;3lQT~+;EoD(ho|^nSZDh*M0Z&&@9T+e zHYJ;xB*~UcF^*7a_T)9iV5}VTYKda8n*~PSy@>h7c(mH~2AH@qz{LMQCb+-enMhX} z2k0B1JQ+6`?Q3Lx&(*CBQOnLBcq;%&Nf<*$CX2<`8MS9c5zA!QEbUz1;|(Ua%CiuL zF2TZ>@t7NKQ->O#!;0s;`tf$veXYgq^SgG>2iU9tCm5&^&B_aXA{+fqKVQ*S9=58y zddWqy1lc$Y@VdB?E~_B5w#so`r552qhPR649;@bf63_V@wgb!>=ij=%ptnsq&zl8^ zQ|U^aWCRR3TnoKxj0m0QL2QHM%_LNJ(%x6aK?IGlO=TUoS%7YRcY{!j(oPcUq{HP=eR1>0o^(KFl-}WdxGRjsT);K8sGCkK0qVe{xI`# z@f+_kTYmLbOTxRv@wm2TNBKrl+&B>=VaZbc(H`WWLQhT=5rPtHf)#B$Q6m1f8We^)f6ylbO=t?6Y;{?&VL|j$VXyGV!v8eceRk zl>yOWPbk%^wv1t63Zd8X^Ck#12$*|yv`v{OA@2;-5Mj5sk#ptfzeX(PrCaFgn{3*hau`-a+nZhuJxO;Tis51VVeKAwFML#hF9g26NjfzLs8~RiM_MFl1mgDOU z=ywk!Qocatj1Q1yPNB|FW>!dwh=aJxgb~P%%7(Uydq&aSyi?&b@QCBiA8aP%!nY@c z&R|AF@8}p7o`&~>xq9C&X6%!FAsK8gGhnZ$TY06$7_s%r*o;3Y7?CenJUXo#V-Oag z)T$d-V-_O;H)VzTM&v8^Uk7hmR8v0)fMquWHs6?jXYl^pdM#dY?T5XpX z*J&pnyJ<^n-d<0@wm|)2SW9e73u8IvTbRx?Gqfy_$*LI_Ir9NZt#(2T+?^AorOv$j zcsk+t<#!Z!eC|>!x&#l%**sSAX~vFU0|S<;-ei}&j}BQ#ekRB-;c9~vPDIdL5r{~O zMiO3g0&m-O^gB}<$S#lCRxX@c3g}Yv*l)Hh+S^my28*fGImrl<-nbEpOw-BZ;WTHL zgHoq&ftG|~ouV<>grxRO6Z%{!O+j`Cw_4~BIzrjpkdA5jH40{1kDy|pEq#7`$^m*? zX@HxvW`e}$O$mJvm+65Oc4j7W@iVe)rF&-}R>KKz>rF&*Qi3%F0*tz!vNtl@m8L9= zyW3%|X}0KsW&!W<@tRNM-R>~~QHz?__kgnA(G`jWOMiEaFjLzCdRrqzKlP1vYLG`Y zh6_knD3=9$weMn4tBD|5=3a9{sOowXHu(z5y^RYrxJK z|L>TUvbDuO?3=YJ55N5}Kj0lC(PI*Te0>%eLNWLnawD54geX5>8AT(oT6dmAacj>o zC`Bgj-RV0m3Dl2N=w3e0>wWWG5!mcal`Xu<(1=2$b{k(;kC(2~+B}a(w;xaHPk^@V zGzDR|pt%?(1xwNxV!O6`JLCM!MnvpbLoHzKziegT_2LLWAi4}UHIo6uegj#WTQLet z9Dbjyr{8NAk+$(YCw~_@Az9N|iqsliRYtR7Q|#ONIV|BZ7VKcW$phH9`ZAlnMTW&9 zIBqXYuv*YY?g*cJRb(bXG}ts-t0*|HXId4fpnI>$9A?+BTy*FG8f8iRRKYRd*VF_$ zoo$qc+A(d#Lx0@`ck>tt5c$L1y7MWohMnZd$HX++I9sHoj5VXZRZkrq`v@t?dfvC} z>0h!c4HSb8%DyeF#zeU@rJL2uhZ^8dt(s+7FNHJeY!TZJtyViS>a$~XoPOhHsdRH* zwW+S*rIgW0qSPzE6w`P$Jv^5dsyT6zoby;@z=^yWLG^x;e557RnndY>ph!qCF;ov$ ztSW1h3@x{zm*IMRx|3lRWeI3znjpbS-0*IL4LwwkWyPF1CRpQK|s42dJ{ddA#BDDqio-Y+mF-XcP-z4bi zAhfXa2=>F0*b;F0ftEPm&O+exD~=W^qjtv&>|%(4q#H=wbA>7QorDK4X3~bqeeXv3 zV1Q<>_Fyo!$)fD`fd@(7(%6o-^x?&+s=)jjbQ2^XpgyYq6`}ISX#B?{I$a&cRcW?X zhx(i&HWq{=8pxlA2w~7521v-~lu1M>4wL~hDA-j(F2;9ICMg+6;Zx2G)ulp7j;^O_ zQJIRUWQam(*@?bYiRTKR<;l_Is^*frjr-Dj3(fuZtK{Sn8F;d*t*t{|_lnlJ#e=hx zT9?&_n?__2mN5CRQ}B1*w-2Ix_=CF@SdX-cPjdJN+u4d-N4ir*AJn&S(jCpTxiAms zzI5v(&#_#YrKR?B?d~ge1j*g<2yI1kp`Lx>8Qb;aq1$HOX4cpuN{2ti!2dXF#`AG{ zp<iD=Z#qN-yEwLwE7%8w8&LB<&6{WO$#MB-|?aEc@S1a zt%_p3OA|kE&Hs47Y8`bdbt_ua{-L??&}uW zmwE7X4Y%A2wp-WFYPP_F5uw^?&f zH%NCcbw_LKx!c!bMyOBrHDK1Wzzc5n7A7C)QrTj_Go#Kz7%+y^nONjnnM1o5Sw(0n zxU&@41(?-faq?qC^kO&H301%|F9U-Qm(EGd3}MYTFdO+SY8%fCMTPMU3}bY7ML1e8 zrdOF?E~1uT)v?UX(XUlEIUg3*UzuT^g@QAxEkMb#N#q0*;r zF6ACHP{ML*{Q{M;+^4I#5bh#c)xDGaIqWc#ka=0fh*_Hlu%wt1rBv$B z%80@8%MhIwa0Zw$1`D;Uj1Bq`lsdI^g_18yZ9XUz2-u6&{?Syd zHGEh-3~HH-vO<)_2^r|&$(q7wG{@Q~un=3)Nm``&2T99L(P+|aFtu1sTy+|gwL*{z z)WoC4rsxoWhz0H$rG|EwhDT z0zcOAod_k_Ql&Y`YV!#&Mjq{2ln|;LMuF$-G#jX_2~oNioTHb4GqFatn@?_KgsA7T z(ouy$cGKa!m}6$=C1Wmb;*O2p*@g?wi-}X`v|QA4bNDU*4(y8*jZy-Ku)S3iBN(0r ztfLyPLfEPqj6EV}xope=?b0Nyf*~vDz-H-Te@B`{ib?~F<*(MmG+8zoYS77$O*3vayg#1kkKN+Bu9J9;Soev<%2S&J zr8*_PKV4|?RVfb#SfNQ;TZC$8*9~@GR%xFl1 z3MD?%`1PxxupvVO>2w#8*zV<-!m&Lis&B>)pHahPQ@I_;rY~Z$1+!4V1jde&L8y0! zha7@F+rOENF{~0$+a~oId0R|_!PhO=8)$>LcO)ca6YeOQs?ZG;`4O`x=Pd??Bl?Qf zgkaNj7X5@3_==zlQ-u6?omteA!_e-6gfDtw6CBnP2o1wo-7U!Y@89rU1HFb|bIr!I z=qIz=AW(}L^m z=I9RiS{DRtTYS6jsnvt1zs)W;kSVFOK|WMyZ@dxs+8{*W9-aTmS79J4R{Cis>EIqS zw+~gJqwz)(!z>)KDyhS{lM*xQ-8mNvo$A=IwGu+iS564tgX`|MeEuis!aN-=7!L&e zhNs;g1MBqDyx{y@AI&{_)+-?EEg|5C*!=OgD#$>HklRVU+R``HYZZq5{F9C0KKo!d z$bE2XC(G=I^YUxYST+Hk>0T;JP_iAvCObcrPV1Eau865w6d^Wh&B?^#h2@J#!M2xp zLGAxB^i}4D2^?RayxFqBgnZ-t`j+~zVqr+9Cz9Rqe%1a)c*keP#r54AaR2*TH^}7j zmJ48DN);^{7+5|+GmbvY2v#qJy>?$B(lRlS#kyodlxA&Qj#9-y4s&|eq$5} zgI;4u$cZWKWj`VU%UY#SH2M$8?PjO-B-rNPMr=8d=-D(iLW#{RWJ}@5#Z#EK=2(&LvfW&{P4_jsDr^^rg9w#B7h`mBwdL9y)Ni;= zd$jFDxnW7n-&ptjnk#<0zmNNt{;_30vbQW!5CQ7SuEjR1be!vxvO53!30iOermrU1 zXhXaen8=4Q(574KO_h$e$^1khO&tQL59=)Dc^8iPxz8+tC3`G$w|yUzkGd%Wg4(3u zJ<&7r^HAaEfG?F8?2I64j4kPpsNQk7qBJa9_hFT;*j;A%H%;QI@QWqJaiOl=;u>G8 zG`5Ow4K5ifd=OS|7F;EFc1+GzLld0RCQxG>Fn?~5Wl5VHJ=$DeR-2zwBgzSrQsGG0 zBqrILuB+_SgLxh~S~^QNHWW(2P;Z?d!Rd1lnEM=z23xPzyrbO_L0k43zruDkrJO*D zlzN(peBMLji`xfgYUirul-7c#3t(*=x6A^KSU-L|$(0pp9A*43#=Q!cu%9ZHP!$J| zSk8k=Z8cl811Vvn(4p8xx+EdKQV(sjC4_mEvlWeuIfwEVcF2LiC{H!oW)LSW=0ul| zT?$5PCc(pf-zKzUH`p7I7coVvCK;Dv-3_c?%~bPz`#ehbfrSrFf{RAz0I5e*W1S)kTW{0gf5X2v2k=S=W{>pr44tQ?o` zih8gE29VGR_SL~YJtcA)lRLozPg!<3Mh(`Hp)5{bclb)reTScXzJ>7{?i^yR@{(^% z#=$BYXPIX%fhgsofP-T`3b<5#V(TTS)^$vlhV&Kn=(LXOTAADIR1v8UqmW5c`n`S% zC8SOW$e?>&0dwKD%Jt{+67PfCLnqX0{8K^(q_^^2#puPYPkJsyXWMa~?V?p5{flYi z-1!uqI2x%puPG)r7b8y+Pc0Z5C%aA6`Q1_?W9k!YbiVVJVJwGLL?)P0M&vo{^IgEE zrX3eTgrJl_AeXYmiciYX9OP?NPN%-7Ji%z3U`-iXX=T~OI0M=ek|5IvIsvXM$%S&v zKw{`Kj(JVc+Pp^?vLKEyoycfnk)Hd>et78P^Z*{#rBY~_>V7>{gtB$0G99nbNBt+r zyXvEg_2=#jjK+YX1A>cj5NsFz9rjB_LB%hhx4-2I73gr~CW_5pD=H|e`?#CQ2)p4& z^v?Dlxm-_j6bO5~eeYFZGjW3@AGkIxY=XB*{*ciH#mjQ`dgppNk4&AbaRYKKY-1CT z>)>?+ME)AcCM7RRZQsH5)db7y!&jY-qHp%Ex9N|wKbN$!86i>_LzaD=f4JFc6Dp(a z%z>%=q(sXlJ=w$y^|tcTy@j%AP`v1n0oAt&XC|1kA`|#jsW(gwI0vi3a_QtKcL+yh z1Y=`IRzhiUvKeZXH6>>TDej)?t_V8Z7;WrZ_7@?Z=HRhtXY+{hlY?x|;7=1L($?t3 z6R$8cmez~LXopZ^mH9=^tEeAhJV!rGGOK@sN_Zc-vmEr;=&?OBEN)8aI4G&g&gdOb zfRLZ~dVk3194pd;=W|Z*R|t{}Evk&jw?JzVERk%JNBXbMDX82q~|bv%!2%wFP9;~-H?={C1sZ( zuDvY5?M8gGX*DyN?nru)UvdL|Rr&mXzgZ;H<^KYvzIlet!aeFM@I?JduKj=!(+ zM7`37KYhd*^MrKID^Y1}*sZ#6akDBJyKna%xK%vLlBqzDxjQ3}jx8PBOmXkvf@B{@ zc#J;~wQ<6{B;``j+B!#7s$zONYdXunbuKvl@zvaWq;`v2&iCNF2=V9Kl|77-mpCp= z2$SxhcN=pZ?V{GW;t6s)?-cNPAyTi&8O0QMGo#DcdRl#+px!h3ayc*(VOGR95*Anj zL0YaiVN2mifzZ){X+fl`Z^P=_(W@=*cIe~BJd&n@HD@;lRmu8cx7K8}wPbIK)GjF> zQGQ2h#21o6b2FZI1sPl}9_(~R|2lE^h}UyM5A0bJQk2~Vj*O)l-4WC4$KZ>nVZS|d zZv?`~2{uPYkc?254B9**q6tS|>We?uJ&wK3KIww|zzSuj>ncI4D~K z1Y6irVFE{?D-|R{!rLhZxAhs+Ka9*-(ltIUgC;snNek4_5xhO}@+r9Sl*5=7ztnXO zAVZLm$Kdh&rqEtdxxrE9hw`aXW1&sTE%aJ%3VL3*<7oWyz|--A^qvV3!FHBu9B-Jj z4itF)3dufc&2%V_pZsjUnN=;s2B9<^Zc83>tzo)a_Q$!B9jTjS->%_h`ZtQPz@{@z z5xg~s*cz`Tj!ls3-hxgnX}LDGQp$t7#d3E}>HtLa12z&06$xEQfu#k=(4h{+p%aCg zzeudlLc$=MVT+|43#CXUtRR%h5nMchy}EJ;n7oHfTq6wN6PoalAy+S~2l}wK;qg9o zcf#dX>ke;z^13l%bwm4tZcU1RTXnDhf$K3q-cK576+TCwgHl&?9w>>_(1Gxt@jXln zt3-Qxo3ITr&sw1wP%}B>J$Jy>^-SpO#3e=7iZrXCa2!N69GDlD{97|S*og)3hG)Lk zuqxK|PkkhxV$FP45%z*1Z?(LVy+ruMkZx|(@1R(0CoS6`7FWfr4-diailmq&Q#ehn zc)b&*&Ub;7HRtFVjL%((d$)M=^6BV@Kiusmnr1_2&&aEGBpbK7OWs;+(`tRLF8x?n zfKJB3tB^F~N`_ak3^exe_3{=aP)3tuuK2a-IriHcWv&+u7p z_yXsd6kyLV@k=(QoSs=NRiKNYZ>%4wAF;2#iu1p^!6>MZUPd;=2LY~l2ydrx10b#OSAlltILY%OKTp{e{ zzNogSk~SJBqi<_wRa#JqBW8Ok=6vb%?#H(hG}Dv98{JST5^SSh>_GQ@UK-0J`6l#E za}X#ud0W?cp-NQE@jAx>NUv65U~%YYS%BC0Cr$5|2_A)0tW;(nqoGJUHG5R`!-{1M-4T{<^pOE!Dvyuu1x7?Wt#YIgq zA$Vwj`St+M#ZxJXXGkepIF6`xL&XPu^qiFlZcX+@fOAdQ9d(h{^xCiAWJ0Ixp~3&E z(WwdT$O$7ez?pw>Jf{`!T-205_zJv+y~$w@XmQ;CiL8d*-x_z~0@vo4|3xUermJ;Q z9KgxjkN8Vh)xZ2xhX0N@{~@^d@BLoYFW%Uys83=`15+YZ%KecmWXjVV2}YbjBonSh zVOwOfI7^gvlC~Pq$QDHMQ6_Pd10OV{q_Zai^Yg({5XysuT`3}~3K*8u>a2FLBQ%#_YT6$4&6(?ZGwDE*C-p8>bM?hj*XOIoj@C!L5) zH1y!~wZ^dX5N&xExrKV>rEJJjkJDq*$K>qMi`Lrq08l4bQW~!Fbxb>m4qMHu6weTiV6_9(a*mZ23kr9AM#gCGE zBXg8#m8{ad@214=#w0>ylE7qL$4`xm!**E@pw484-VddzN}DK2qg&W~?%hcv3lNHx zg(CE<2)N=p!7->aJ4=1*eB%fbAGJcY65f3=cKF4WOoCgVelH$qh0NpIka5J-6+sY* zBg<5!R=I*5hk*CR@$rY6a8M%yX%o@D%{q1Jn=8wAZ;;}ol>xFv5nXvjFggCQ_>N2} zXHiC~pCFG*oEy!h_sqF$^NJIpQzXhtRU`LR0yU;MqrYUG0#iFW4mbHe)zN&4*Wf)G zV6(WGOq~OpEoq##E{rC?!)8ygAaAaA0^`<8kXmf%uIFfNHAE|{AuZd!HW9C^4$xW; zmIcO#ti!~)YlIU4sH(h&s6}PH-wSGtDOZ+%H2gAO(%2Ppdec9IMViuwwWW)qnqblH9xe1cPQ@C zS4W|atjGDGKKQAQlPUVUi1OvGC*Gh2i&gkh0up%u-9ECa7(Iw}k~0>r*WciZyRC%l z7NX3)9WBXK{mS|=IK5mxc{M}IrjOxBMzFbK59VI9k8Yr$V4X_^wI#R^~RFcme2)l!%kvUa zJ{zpM;;=mz&>jLvON5j>*cOVt1$0LWiV>x)g)KKZnhn=%1|2E|TWNfRQ&n?vZxQh* zG+YEIf33h%!tyVBPj>|K!EB{JZU{+k`N9c@x_wxD7z~eFVw%AyU9htoH6hmo0`%kb z55c#c80D%0^*6y|9xdLG$n4Hn%62KIp`Md9Jhyp8)%wkB8<%RlPEwC&FL z;hrH(yRr(Ke$%TZ09J=gGMC3L?bR2F4ZU!}pu)*8@l(d9{v^^(j>y+GF*nGran5*M z{pl5ig0CVsG1etMB8qlF4MDFRkLAg4N=l{Sc*F>K_^AZQc{dSXkvonBI)qEN1*U&? zKqMr?Wu)q9c>U~CZUG+-ImNrU#c`bS?RpvVgWXqSsOJrCK#HNIJ+k_1Iq^QNr(j|~ z-rz67Lf?}jj^9Ik@VIMBU2tN{Ts>-O%5f?=T^LGl-?iC%vfx{}PaoP7#^EH{6HP!( zG%3S1oaiR;OmlKhLy@yLNns`9K?60Zg7~NyT0JF(!$jPrm^m_?rxt~|J2)*P6tdTU z25JT~k4RH9b_1H3-y?X4=;6mrBxu$6lsb@xddPGKA*6O`Cc^>Ul`f9c&$SHFhHN!* zjj=(Jb`P}R%5X@cC%+1ICCRh1^G&u548#+3NpYTVr54^SbFhjTuO-yf&s%r4VIU!lE!j(JzHSc9zRD_fw@CP0pkL(WX6 zn+}LarmQP9ZGF9So^+jr<(LGLlOxGiCsI^SnuC{xE$S;DA+|z+cUk=j^0ipB(WTZ} zR0osv{abBd)HOjc(SAV&pcP@37SLnsbtADj?bT#cPZq|?W1Ar;4Vg5m!l{@{TA~|g zXYOeU`#h-rT@(#msh%%kH>D=`aN}2Rysez?E@R6|@SB(_gS0}HC>83pE`obNA9vsH zSu^r>6W-FSxJA}?oTuH>-y9!pQg|*<7J$09tH=nq4GTx+5($$+IGlO^bptmxy#=)e zuz^beIPpUB_YK^?eb@gu(D%pJJwj3QUk6<3>S>RN^0iO|DbTZNheFX?-jskc5}Nho zf&1GCbE^maIL$?i=nXwi)^?NiK`Khb6A*kmen^*(BI%Kw&Uv4H;<3ib-2UwG{7M&* zn$qyi8wD9cKOuxWhRmFupwLuFn!G5Vj6PZ#GCNJLlTQuQ?bqAYd7Eva5YR~OBbIim zf(6yXS4pei1Bz4w4rrB6Ke~gKYErlC=l9sm*Zp_vwJe7<+N&PaZe|~kYVO%uChefr%G4-=0eSPS{HNf=vB;p~ z5b9O1R?WirAZqcdRn9wtct>$FU2T8p=fSp;E^P~zR!^C!)WHe=9N$5@DHk6(L|7s@ zcXQ6NM9Q~fan1q-u8{ez;RADoIqwkf4|6LfsMZK6h{ZUGYo>vD%JpY<@w;oIN-*sK zxp4@+d{zxe>Z-pH#_)%|d(AC`fa!@Jq)5K8hd71!;CEG|ZI{I2XI`X~n|ae;B!q{I zJDa#T+fRviR&wAN^Sl{z8Ar1LQOF&$rDs18h0{yMh^pZ#hG?c5OL8v07qRZ-Lj5(0 zjFY(S4La&`3IjOT%Jqx4z~08($iVS;M10d@q~*H=Py)xnKt(+G-*o33c7S3bJ8cmwgj45` zU|b7xCoozC!-7CPOR194J-m9N*g`30ToBo!Io?m>T)S{CusNZx0J^Hu6hOmvv;0~W zFHRYJgyRhP1sM_AQ%pkD!X-dPu_>)`8HunR4_v$4T78~R<})-@K2LBt03PBLnjHzuYY)AK?>0TJe9 zmmOjwSL%CTaLYvYlJ~|w?vc*R+$@vEAYghtgGhZ2LyF+UdOn+v^yvD9R%xbU$fUjK{{VQ4VL&&UqAFa>CZuX4kX zJ)njewLWfKXneB+r}Y$`ezzwDoRT3r{9(@=I3-z>8tT)n3whDyi(r*lAnxQJefj_x z-8lc=r!Vua{b}v;LT)oXW>~6Q03~RAp~R}TZq9sGbeUBMS)?ZrJqiu|E&ZE)uN1uL zXcAj3#aEz zzbcCF)+;Hia#OGBvOatkPQfE{*RtBlO1QFVhi+3q0HeuFa*p+Dj)#8Mq9yGtIx%0A znV5EmN(j!&b%kNz4`Vr-)mX_?$ng&M^a6loFO(G3SA!~eBUEY!{~>C|Ht1Q4cw)X5~dPiEYQJNg?B2&P>bU7N(#e5cr8qc7A{a7J9cdMcRx)N|?;$L~O|E)p~ zIC}oi3iLZKb>|@=ApsDAfa_<$0Nm<3nOPdr+8Y@dnb|u2S<7CUmTGKd{G57JR*JTo zb&?qrusnu}jb0oKHTzh42P00C{i^`v+g=n|Q6)iINjWk4mydBo zf0g=ikV*+~{rIUr%MXdz|9ebUP)<@zR8fgeR_rChk0<^^3^?rfr;-A=x3M?*8|RPz z@}DOF`aXXuZGih9PyAbp|DULSw8PJ`54io)ga6JG@Hgg@_Zo>OfJ)8+TIfgqu%877 z@aFykK*+|%@rSs-t*oAzH6Whyr=TpuQ}B0ptSsMg9p8@ZE5A6LfMk1qdsf8T^zkdC3rUhB$`s zBdanX%L3tF7*YZ4^A8MvOvhfr&B)QOWCLJ^02kw5;P%n~5e`sa6MG{E2N^*2ZX@ge zI2>ve##O?I}sWX)UqK^_bRz@;5HWp5{ziyg?QuEjXfMP!j zpr(McSAQz>ME?M-3NSoCn$91#_iNnULp6tD0NN7Z0s#G~-~xWZFWN-%KUVi^yz~-` zn;AeGvjLJ~{1p#^?$>zM4vu=3mjBI$(_tC~NC0o@6<{zS_*3nGfUsHr3Gdgn%XedF zQUP=j5Mb>9=#f7aPl;cm$=I0u*WP}aVE!lCYw2Ht{Z_j9mp1h>dHGKkEZP6f^6O@J zndJ2+rWjxp|3#<2oO=8v!oHMX{|Vb|^G~pU_A6=ckBQvt>o+dpgYy(D=VCj65GE&jJj{&-*iq?z)PHNee&-@Mie~#LD*={ex8h(-)<@|55 zUr(}L?mz#;d|mrD%zrh<-*=;5*7K$B`zPjJ%m2pwr*G6tf8tN%a

_x$+l{{cH8$W#CT literal 0 HcmV?d00001 diff --git a/ycmd/tests/java/testdata/simple_gradle_project/gradle/wrapper/gradle-wrapper.properties b/ycmd/tests/java/testdata/simple_gradle_project/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..f16d26666b --- /dev/null +++ b/ycmd/tests/java/testdata/simple_gradle_project/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.1-bin.zip diff --git a/ycmd/tests/java/testdata/simple_gradle_project/gradlew b/ycmd/tests/java/testdata/simple_gradle_project/gradlew new file mode 100755 index 0000000000..cccdd3d517 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_gradle_project/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/ycmd/tests/java/testdata/simple_gradle_project/gradlew.bat b/ycmd/tests/java/testdata/simple_gradle_project/gradlew.bat new file mode 100644 index 0000000000..e95643d6a2 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_gradle_project/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ycmd/tests/java/testdata/simple_gradle_project/settings.gradle b/ycmd/tests/java/testdata/simple_gradle_project/settings.gradle new file mode 100644 index 0000000000..6bc3b0b93f --- /dev/null +++ b/ycmd/tests/java/testdata/simple_gradle_project/settings.gradle @@ -0,0 +1,18 @@ +/* + * This settings file was generated by the Gradle 'init' task. + * + * The settings file is used to specify which projects to include in your build. + * In a single project build this file can be empty or even removed. + * + * Detailed information about configuring a multi-project build in Gradle can be found + * in the user guide at https://docs.gradle.org/4.1/userguide/multi_project_builds.html + */ + +/* +// To declare projects as part of a multi-project build use the 'include' method +include 'shared' +include 'api' +include 'services:webservice' +*/ + +rootProject.name = 'simple_gradle_project' diff --git a/ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/AbstractTestWidget.java b/ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/AbstractTestWidget.java new file mode 100644 index 0000000000..9f37902bc1 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/AbstractTestWidget.java @@ -0,0 +1,18 @@ +package com.test; + +public interface AbstractTestWidget { + /** + * Do the actually useful stuff. + * + * Eventually, you have to find the code which is useful, as opposed to just + * boilerplate. + */ + public void doSomethingVaguelyUseful(); + + /** + * Return runtime debugging info. + * + * Useful for finding the actual code which is useful. + */ + public String getWidgetInfo(); +}; diff --git a/ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/TestFactory.java b/ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/TestFactory.java new file mode 100644 index 0000000000..0c01bb8f9e --- /dev/null +++ b/ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/TestFactory.java @@ -0,0 +1,17 @@ +package com.test; + +/** + * @title TestFactory + * + * TestFactory is a pointless thing that OO programmers think is necessary + * because they read about it in a book. + * + * All it does is instantiate the (one and only) concrete AbstractTestWidget + * implementation + */ +public class TestFactory { + public AbstractTestWidget getWidget( String info ) { + AbstractTestWidget w = new TestWidgetImpl( info ); + return w; + } +} diff --git a/ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/TestLauncher.java b/ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/TestLauncher.java new file mode 100644 index 0000000000..f6acdb84e8 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/TestLauncher.java @@ -0,0 +1,17 @@ +package com.test; + +class TestLauncher { + private TestFactory factory = new TestFactory(); + + private void Run() { + AbstractTestWidget w = factory.getWidget( "Test" ); + w.doSomethingVaguelyUseful(); + + System.out.println( "Did something useful: " + w.getWidgetInfo() ); + } + + public static void main( String[] args ) { + TestLauncher l = new TestLauncher(); + l.Run(); + } +} diff --git a/ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/TestWidgetImpl.java b/ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/TestWidgetImpl.java new file mode 100644 index 0000000000..d8e8da7d6d --- /dev/null +++ b/ycmd/tests/java/testdata/simple_gradle_project/src/main/java/com/test/TestWidgetImpl.java @@ -0,0 +1,27 @@ +package com.test; + +/** + * This is the actual code that matters. + * + * This concrete implentation is the equivalent of the main function in other + * languages + */ + + +class TestWidgetImpl implements AbstractTestWidget { + private String info; + + TestWidgetImpl( String info ) { + this.info = info; + } + + @Override + public void doSomethingVaguelyUseful() { + System.out.println( this. ); + } + + @Override + public String getWidgetInfo() { + return this.info; + } +} diff --git a/ycmd/tests/java/testdata/simple_gradle_project/src/test/java/com/test/AppTest.java b/ycmd/tests/java/testdata/simple_gradle_project/src/test/java/com/test/AppTest.java new file mode 100644 index 0000000000..2dd7cf8cb6 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_gradle_project/src/test/java/com/test/AppTest.java @@ -0,0 +1,38 @@ +package com.test; + +import junit.framework.Test; +import junit.framework.TestCase; +import junit.framework.TestSuite; + +/** + * Unit test for simple App. + */ +public class AppTest + extends TestCase +{ + /** + * Create the test case + * + * @param testName name of the test case + */ + public AppTest( String testName ) + { + super( testName ); + } + + /** + * @return the suite of tests being tested + */ + public static Test suite() + { + return new TestSuite( AppTest.class ); + } + + /** + * Rigourous Test :-) + */ + public void testApp() + { + assertTrue( true ); + } +} From 31713691ec86c4a2e50965055f4a59939ad426b3 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Thu, 21 Sep 2017 22:22:56 +0100 Subject: [PATCH 30/51] Always use UTF-8 encoding --- ycmd/completers/java/java_completer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 09a5f15e9c..f97c9f9651 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -336,6 +336,7 @@ def _StartServer( self, working_dir=None ): command = [ PATH_TO_JAVA, + '-Dfile.encoding=UTF-8', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', From e15901515d3e7f8324b6f551479e9cd72bf4506d Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Thu, 21 Sep 2017 22:23:54 +0100 Subject: [PATCH 31/51] Send initialised notification --- ycmd/completers/language_server/language_server_completer.py | 3 +++ ycmd/completers/language_server/lsapi.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index ad00baff5d..ec4547a6ff 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -813,6 +813,9 @@ def _HandleInitialiseInPollThread( self, response ): _logger.info( 'Language Server requires sync type of {0}'.format( self._syncType ) ) + + self.GetConnection().SendNotification( lsapi.Initialised() ) + self._initialise_response = None self._initialise_event.set() diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index c513a93c34..e5227f6eec 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -70,6 +70,10 @@ def Initialise( request_id, project_directory ): } ) +def Initialised(): + return BuildNotification( 'initialized', {} ) + + def Shutdown( request_id ): return BuildRequest( request_id, 'shutdown', None ) From cfed48d23d294bd681aaa1452e71b8bf9e775b5e Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Thu, 21 Sep 2017 22:49:10 +0100 Subject: [PATCH 32/51] Fix printing of raw messages on py2 and py3 Print the bytes of the messages rather than throw an exception. Ref: https://www.simonmweber.com/2014/11/24/python-logging-traps.html --- .../language_server/language_server_completer.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index ec4547a6ff..0bfd11165e 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -229,7 +229,7 @@ def GetResponseAsync( self, request_id, message, response_callback=None ): assert request_id not in self._responses self._responses[ request_id ] = response - _logger.debug( 'TX: Sending message {0}'.format( message ) ) + _logger.debug( 'TX: Sending message: %r', message ) self._Write( message ) return response @@ -241,7 +241,7 @@ def GetResponse( self, request_id, message, timeout ): def SendNotification( self, message ): - _logger.debug( 'TX: Sending Notification {0}'.format( message ) ) + _logger.debug( 'TX: Sending notification: %r', message ) self._Write( message ) @@ -323,6 +323,8 @@ def _ReadMessages( self ): content_read += len( content ) read_bytes = content_to_read + _logger.debug( 'RX: Received message: %r', content ) + # lsapi will convert content to unicode self._DespatchMessage( lsapi.Parse( content ) ) @@ -332,7 +334,6 @@ def _ReadMessages( self ): def _DespatchMessage( self, message ): - _logger.debug( 'RX: Received message: {0}'.format( message ) ) if 'id' in message: with self._responseMutex: assert str( message[ 'id' ] ) in self._responses From f66386210edab3d5c5b4839330d61969e7b5ecf5 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Thu, 21 Sep 2017 22:49:58 +0100 Subject: [PATCH 33/51] If we receive a parse request before the server is initialised, queue it to ensure the server has latest file content --- .../language_server_completer.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 0bfd11165e..7943ea5621 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -477,6 +477,7 @@ def ServerReset( self ): self._syncType = 'Full' self._initialise_response = None self._initialise_event = threading.Event() + self._on_initialise_complete_handlers = list() def ShutdownServer( self ): @@ -626,7 +627,12 @@ def ResolveCompletionItems( self, items, request_data, do_resolve ): def OnFileReadyToParse( self, request_data ): - if not self.ServerIsReady(): + if not self.ServerIsHealthy(): + return + + if not self._initialise_event.is_set(): + self._OnInitialiseComplete( lambda self: self._RefreshFiles( + request_data ) ) return self._RefreshFiles( request_data ) @@ -814,12 +820,20 @@ def _HandleInitialiseInPollThread( self, response ): _logger.info( 'Language Server requires sync type of {0}'.format( self._syncType ) ) - self.GetConnection().SendNotification( lsapi.Initialised() ) self._initialise_response = None self._initialise_event.set() + for handler in self._on_initialise_complete_handlers: + handler( self ) + + self._on_initialise_complete_handlers = list() + + + def _OnInitialiseComplete( self, handler ): + self._on_initialise_complete_handlers.append( handler ) + def GetHoverResponse( self, request_data ): if not self.ServerIsReady(): From 68aa5858b35162c76e22af155322ad2f5630e703 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sat, 30 Sep 2017 15:49:45 +0100 Subject: [PATCH 34/51] Include statup status in debug info, as sometimes it's _really_ slow --- ycmd/completers/java/java_completer.py | 5 +++++ ycmd/tests/java/debug_info_test.py | 2 ++ ycmd/tests/java/server_management_test.py | 10 ++++++++++ 3 files changed, 17 insertions(+) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index f97c9f9651..7b775b5919 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -241,6 +241,7 @@ def GetConnection( self ): def DebugInfo( self, request_data ): items = [ + responses.DebugInfoItem( 'Startup Status', self._server_init_status ), responses.DebugInfoItem( 'Java Path', PATH_TO_JAVA ), responses.DebugInfoItem( 'Launcher Config.', self._launcher_config ), ] @@ -252,6 +253,7 @@ def DebugInfo( self, request_data ): if self._workspace_path: items.append( responses.DebugInfoItem( 'Workspace Path', self._workspace_path ) ) + return responses.BuildDebugInfoResponse( name = "Java", servers = [ @@ -317,6 +319,7 @@ def _Reset( self ): self._workspace_path = None self._project_dir = None self._received_ready_message = threading.Event() + self._server_init_status = 'Not started' self._server_handle = None self._connection = None @@ -454,6 +457,8 @@ def _HandleNotificationInPollThread( self, notification ): _logger.info( 'Java Language Server initialised successfully.' ) self._received_ready_message.set() + self._server_init_status = notification[ 'params' ][ 'message' ] + super( JavaCompleter, self )._HandleNotificationInPollThread( notification ) diff --git a/ycmd/tests/java/debug_info_test.py b/ycmd/tests/java/debug_info_test.py index 6441e65d2f..b91909e512 100644 --- a/ycmd/tests/java/debug_info_test.py +++ b/ycmd/tests/java/debug_info_test.py @@ -47,6 +47,8 @@ def DebugInfo_test( app ): 'logfiles': contains( instance_of( str ), instance_of( str ) ), 'extras': contains( + has_entries( { 'key': 'Startup Status', + 'value': instance_of( str ) } ), has_entries( { 'key': 'Java Path', 'value': instance_of( str ) } ), has_entries( { 'key': 'Launcher Config.', diff --git a/ycmd/tests/java/server_management_test.py b/ycmd/tests/java/server_management_test.py index 8b1da818a2..74d18aa88d 100644 --- a/ycmd/tests/java/server_management_test.py +++ b/ycmd/tests/java/server_management_test.py @@ -55,6 +55,8 @@ def Subcommands_RestartServer_test( app ): 'logfiles': contains( instance_of( str ), instance_of( str ) ), 'extras': contains( + has_entries( { 'key': 'Startup Status', + 'value': instance_of( str ) } ), has_entries( { 'key': 'Java Path', 'value': instance_of( str ) } ), has_entries( { 'key': 'Launcher Config.', @@ -113,6 +115,8 @@ def Subcommands_RestartServer_test( app ): 'logfiles': contains( instance_of( str ), instance_of( str ) ), 'extras': contains( + has_entries( { 'key': 'Startup Status', + 'value': instance_of( str ) } ), has_entries( { 'key': 'Java Path', 'value': instance_of( str ) } ), has_entries( { 'key': 'Launcher Config.', @@ -147,6 +151,8 @@ def Subcommands_ProjectDetection_EclipseParent( app ): 'logfiles': contains( instance_of( str ), instance_of( str ) ), 'extras': contains( + has_entries( { 'key': 'Startup Status', + 'value': instance_of( str ) } ), has_entries( { 'key': 'Java Path', 'value': instance_of( str ) } ), has_entries( { 'key': 'Launcher Config.', @@ -184,6 +190,8 @@ def Subcommands_ProjectDetection_MavenParent( app ): 'logfiles': contains( instance_of( str ), instance_of( str ) ), 'extras': contains( + has_entries( { 'key': 'Startup Status', + 'value': instance_of( str ) } ), has_entries( { 'key': 'Java Path', 'value': instance_of( str ) } ), has_entries( { 'key': 'Launcher Config.', @@ -221,6 +229,8 @@ def Subcommands_ProjectDetection_GradleParent( app ): 'logfiles': contains( instance_of( str ), instance_of( str ) ), 'extras': contains( + has_entries( { 'key': 'Startup Status', + 'value': instance_of( str ) } ), has_entries( { 'key': 'Java Path', 'value': instance_of( str ) } ), has_entries( { 'key': 'Launcher Config.', From df391a585b9c57db563e9ae8b5394487ae6d8ceb Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Mon, 2 Oct 2017 21:46:38 +0100 Subject: [PATCH 35/51] American English spellings --- ycmd/completers/java/java_completer.py | 6 +++--- .../language_server/language_server_completer.py | 12 ++++++------ ycmd/completers/language_server/lsapi.py | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 7b775b5919..0433134f11 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -168,7 +168,7 @@ def __init__( self, user_options ): self._server_keep_logfiles = user_options[ 'server_keep_logfiles' ] self._use_clean_workspace = user_options[ CLEAN_WORKSPACE_OPTION ] - # Used to ensure that starting/stopping of the server is synchronised + # Used to ensure that starting/stopping of the server is synchronized self._server_state_mutex = threading.RLock() @@ -454,7 +454,7 @@ def _HandleNotificationInPollThread( self, notification ): message_type = notification[ 'params' ][ 'type' ] if message_type == 'Started': - _logger.info( 'Java Language Server initialised successfully.' ) + _logger.info( 'Java Language Server initialized successfully.' ) self._received_ready_message.set() self._server_init_status = notification[ 'params' ][ 'message' ] @@ -466,7 +466,7 @@ def _ConvertNotificationToMessage( self, request_data, notification ): if notification[ 'method' ] == 'language/status': message = notification[ 'params' ][ 'message' ] return responses.BuildDisplayMessageResponse( - 'Initialising Java completer: {0}'.format( message ) ) + 'Initializing Java completer: {0}'.format( message ) ) return super( JavaCompleter, self )._ConvertNotificationToMessage( request_data, diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 7943ea5621..997ba5112e 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -820,7 +820,7 @@ def _HandleInitialiseInPollThread( self, response ): _logger.info( 'Language Server requires sync type of {0}'.format( self._syncType ) ) - self.GetConnection().SendNotification( lsapi.Initialised() ) + self.GetConnection().SendNotification( lsapi.Initialized() ) self._initialise_response = None self._initialise_event.set() @@ -837,7 +837,7 @@ def _OnInitialiseComplete( self, handler ): def GetHoverResponse( self, request_data ): if not self.ServerIsReady(): - raise RuntimeError( 'Server is initialising. Please wait.' ) + raise RuntimeError( 'Server is initializing. Please wait.' ) self._RefreshFiles( request_data ) @@ -853,7 +853,7 @@ def GetHoverResponse( self, request_data ): def GoToDeclaration( self, request_data ): if not self.ServerIsReady(): - raise RuntimeError( 'Server is initialising. Please wait.' ) + raise RuntimeError( 'Server is initializing. Please wait.' ) self._RefreshFiles( request_data ) @@ -877,7 +877,7 @@ def GoToDeclaration( self, request_data ): def GoToReferences( self, request_data ): if not self.ServerIsReady(): - raise RuntimeError( 'Server is initialising. Please wait.' ) + raise RuntimeError( 'Server is initializing. Please wait.' ) self._RefreshFiles( request_data ) @@ -893,7 +893,7 @@ def GoToReferences( self, request_data ): def CodeAction( self, request_data, args ): if not self.ServerIsReady(): - raise RuntimeError( 'Server is initialising. Please wait.' ) + raise RuntimeError( 'Server is initializing. Please wait.' ) self._RefreshFiles( request_data ) @@ -956,7 +956,7 @@ def WithinRange( diag ): def Rename( self, request_data, args ): if not self.ServerIsReady(): - raise RuntimeError( 'Server is initialising. Please wait.' ) + raise RuntimeError( 'Server is initializing. Please wait.' ) if len( args ) != 1: raise ValueError( 'Please specify a new name to rename it to.\n' diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index e5227f6eec..b3a74d9061 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -70,7 +70,7 @@ def Initialise( request_id, project_directory ): } ) -def Initialised(): +def Initialized(): return BuildNotification( 'initialized', {} ) From 76f413bcac3b8e543b118126ad53da78b7729283 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Mon, 9 Oct 2017 23:15:48 +0100 Subject: [PATCH 36/51] Branding: jdt.js is the preferred branding according to the developers --- ycmd/completers/java/java_completer.py | 32 ++--- ycmd/tests/java/debug_info_test.py | 2 +- ycmd/tests/java/server_management_test.py | 156 ++++------------------ 3 files changed, 42 insertions(+), 148 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 0433134f11..23dfa5596b 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -86,13 +86,13 @@ def ShouldEnableJavaCompleter(): - _logger.info( 'Looking for java language server (eclipse.jdt.ls)' ) + _logger.info( 'Looking for jdt.ls' ) if not PATH_TO_JAVA: _logger.warning( "Not enabling java completion: Couldn't find java" ) return False if not os.path.exists( LANGUAGE_SERVER_HOME ): - _logger.warning( 'Not using java completion: not installed' ) + _logger.warning( 'Not using java completion: jdt.ls is not installed' ) return False if not _PathToLauncherJar(): @@ -188,7 +188,7 @@ def __init__( self, user_options ): # this requires some additional complexity and state management. self._StartServer() except: - _logger.exception( "The java language server failed to start." ) + _logger.exception( "jdt.ls failed to start." ) self._StopServer() @@ -258,7 +258,7 @@ def DebugInfo( self, request_data ): name = "Java", servers = [ responses.DebugInfoServer( - name = "Java Language Server", + name = "jdt.ls Java Language Server", handle = self._server_handle, executable = self._launcher_path, logfiles = [ @@ -329,7 +329,7 @@ def _Reset( self ): def _StartServer( self, working_dir=None ): with self._server_state_mutex: - _logger.info( 'Starting JDT Language Server...' ) + _logger.info( 'Starting jdt.ls Language Server...' ) self._project_dir = _FindProjectDir( working_dir if working_dir else utils.GetCurrentDirectory() ) @@ -352,7 +352,7 @@ def _StartServer( self, working_dir=None ): _logger.debug( 'Starting java-server with the following command: ' '{0}'.format( ' '.join( command ) ) ) - LOGFILE_FORMAT = 'java_language_server_{pid}_{std}_' + LOGFILE_FORMAT = 'jdt.ls_{pid}_{std}_' self._server_stderr = utils.CreateLogfile( LOGFILE_FORMAT.format( pid = os.getpid(), std = 'stderr' ) ) @@ -364,9 +364,9 @@ def _StartServer( self, working_dir=None ): stderr = stderr ) if self._ServerIsRunning(): - _logger.info( 'JDT Language Server started' ) + _logger.info( 'jdt.ls Language Server started' ) else: - _logger.warning( 'JDT Language Server failed to start' ) + _logger.warning( 'jdt.ls Language Server failed to start' ) return self._connection = ( @@ -381,8 +381,8 @@ def _StartServer( self, working_dir=None ): try: self._connection.AwaitServerConnection() except language_server_completer.LanguageServerConnectionTimeout: - _logger.warn( 'Java language server failed to start, or did not ' - 'connect successfully' ) + _logger.warn( 'jdt.ls failed to start, or did not connect ' + 'successfully' ) self._StopServer() return @@ -425,14 +425,14 @@ def _StopServerCleanly( self ): if self._connection: self._connection.join() - _logger.info( 'JDT Language server stopped' ) + _logger.info( 'jdt.ls Language server stopped' ) except Exception: - _logger.exception( 'Error while stopping java server' ) + _logger.exception( 'Error while stopping jdt.ls server' ) def _StopServerForecefully( self ): if self._ServerIsRunning(): - _logger.info( 'Killing java server with PID {0}'.format( + _logger.info( 'Killing jdt.ls server with PID {0}'.format( self._server_handle.pid ) ) self._server_handle.terminate() @@ -444,9 +444,9 @@ def _StopServerForecefully( self ): if self._connection: self._connection.join() - _logger.info( 'JDT Language server killed' ) + _logger.info( 'jdt.ls Language server killed' ) except Exception: - _logger.exception( 'Error while killing java server' ) + _logger.exception( 'Error while killing jdt.ls server' ) def _HandleNotificationInPollThread( self, notification ): @@ -454,7 +454,7 @@ def _HandleNotificationInPollThread( self, notification ): message_type = notification[ 'params' ][ 'type' ] if message_type == 'Started': - _logger.info( 'Java Language Server initialized successfully.' ) + _logger.info( 'jdt.ls initialized successfully.' ) self._received_ready_message.set() self._server_init_status = notification[ 'params' ][ 'message' ] diff --git a/ycmd/tests/java/debug_info_test.py b/ycmd/tests/java/debug_info_test.py index b91909e512..3dfbf71f3f 100644 --- a/ycmd/tests/java/debug_info_test.py +++ b/ycmd/tests/java/debug_info_test.py @@ -40,7 +40,7 @@ def DebugInfo_test( app ): has_entry( 'completer', has_entries( { 'name': 'Java', 'servers': contains( has_entries( { - 'name': 'Java Language Server', + 'name': 'jdt.ls Java Language Server', 'is_running': instance_of( bool ), 'executable': instance_of( str ), 'pid': instance_of( int ), diff --git a/ycmd/tests/java/server_management_test.py b/ycmd/tests/java/server_management_test.py index 74d18aa88d..a3ade4e875 100644 --- a/ycmd/tests/java/server_management_test.py +++ b/ycmd/tests/java/server_management_test.py @@ -27,13 +27,27 @@ contains, has_entries, has_entry, - instance_of ) + has_item ) from ycmd.tests.java import ( PathToTestFile, IsolatedYcmdInDirectory, WaitUntilCompleterServerReady ) from ycmd.tests.test_utils import BuildRequest +def _ProjectDirectoryMatcher( project_directory ): + return has_entry( + 'completer', + has_entry( 'servers', contains( + has_entry( 'extras', has_item( + has_entries( { + 'key': 'Project Directory', + 'value': project_directory, + } ) + ) ) + ) ) + ) + + @IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project' ) ) def Subcommands_RestartServer_test( app ): WaitUntilCompleterServerReady( app ) @@ -43,32 +57,8 @@ def Subcommands_RestartServer_test( app ): # Run the debug info to check that we have the correct project dir request_data = BuildRequest( filetype = 'java' ) - assert_that( - app.post_json( '/debug_info', request_data ).json, - has_entry( 'completer', has_entries( { - 'name': 'Java', - 'servers': contains( has_entries( { - 'name': 'Java Language Server', - 'is_running': instance_of( bool ), - 'executable': instance_of( str ), - 'pid': instance_of( int ), - 'logfiles': contains( instance_of( str ), - instance_of( str ) ), - 'extras': contains( - has_entries( { 'key': 'Startup Status', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Java Path', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Launcher Config.', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Project Directory', - 'value': eclipse_project } ), - has_entries( { 'key': 'Workspace Path', - 'value': instance_of( str ) } ) - ) - } ) ) - } ) ) - ) + assert_that( app.post_json( '/debug_info', request_data ).json, + _ProjectDirectoryMatcher( eclipse_project ) ) # Restart the server with a different client working directory filepath = PathToTestFile( 'simple_maven_project', @@ -103,32 +93,8 @@ def Subcommands_RestartServer_test( app ): # Run the debug info to check that we have the correct project dir request_data = BuildRequest( filetype = 'java' ) - assert_that( - app.post_json( '/debug_info', request_data ).json, - has_entry( 'completer', has_entries( { - 'name': 'Java', - 'servers': contains( has_entries( { - 'name': 'Java Language Server', - 'is_running': instance_of( bool ), - 'executable': instance_of( str ), - 'pid': instance_of( int ), - 'logfiles': contains( instance_of( str ), - instance_of( str ) ), - 'extras': contains( - has_entries( { 'key': 'Startup Status', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Java Path', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Launcher Config.', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Project Directory', - 'value': maven_project } ), - has_entries( { 'key': 'Workspace Path', - 'value': instance_of( str ) } ) - ) - } ) ) - } ) ) - ) + assert_that( app.post_json( '/debug_info', request_data ).json, + _ProjectDirectoryMatcher( maven_project ) ) @IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project', 'src' ) ) @@ -139,32 +105,8 @@ def Subcommands_ProjectDetection_EclipseParent( app ): # Run the debug info to check that we have the correct project dir request_data = BuildRequest( filetype = 'java' ) - assert_that( - app.post_json( '/debug_info', request_data ).json, - has_entry( 'completer', has_entries( { - 'name': 'Java', - 'servers': contains( has_entries( { - 'name': 'Java Language Server', - 'is_running': instance_of( bool ), - 'executable': instance_of( str ), - 'pid': instance_of( int ), - 'logfiles': contains( instance_of( str ), - instance_of( str ) ), - 'extras': contains( - has_entries( { 'key': 'Startup Status', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Java Path', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Launcher Config.', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Project Directory', - 'value': project } ), - has_entries( { 'key': 'Workspace Path', - 'value': instance_of( str ) } ) - ) - } ) ) - } ) ) - ) + assert_that( app.post_json( '/debug_info', request_data ).json, + _ProjectDirectoryMatcher( project ) ) @IsolatedYcmdInDirectory( PathToTestFile( 'simple_maven_project', @@ -178,32 +120,8 @@ def Subcommands_ProjectDetection_MavenParent( app ): # Run the debug info to check that we have the correct project dir request_data = BuildRequest( filetype = 'java' ) - assert_that( - app.post_json( '/debug_info', request_data ).json, - has_entry( 'completer', has_entries( { - 'name': 'Java', - 'servers': contains( has_entries( { - 'name': 'Java Language Server', - 'is_running': instance_of( bool ), - 'executable': instance_of( str ), - 'pid': instance_of( int ), - 'logfiles': contains( instance_of( str ), - instance_of( str ) ), - 'extras': contains( - has_entries( { 'key': 'Startup Status', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Java Path', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Launcher Config.', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Project Directory', - 'value': project } ), - has_entries( { 'key': 'Workspace Path', - 'value': instance_of( str ) } ) - ) - } ) ) - } ) ) - ) + assert_that( app.post_json( '/debug_info', request_data ).json, + _ProjectDirectoryMatcher( project ) ) @IsolatedYcmdInDirectory( PathToTestFile( 'simple_gradle_project', @@ -217,29 +135,5 @@ def Subcommands_ProjectDetection_GradleParent( app ): # Run the debug info to check that we have the correct project dir request_data = BuildRequest( filetype = 'java' ) - assert_that( - app.post_json( '/debug_info', request_data ).json, - has_entry( 'completer', has_entries( { - 'name': 'Java', - 'servers': contains( has_entries( { - 'name': 'Java Language Server', - 'is_running': instance_of( bool ), - 'executable': instance_of( str ), - 'pid': instance_of( int ), - 'logfiles': contains( instance_of( str ), - instance_of( str ) ), - 'extras': contains( - has_entries( { 'key': 'Startup Status', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Java Path', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Launcher Config.', - 'value': instance_of( str ) } ), - has_entries( { 'key': 'Project Directory', - 'value': project } ), - has_entries( { 'key': 'Workspace Path', - 'value': instance_of( str ) } ) - ) - } ) ) - } ) ) - ) + assert_that( app.post_json( '/debug_info', request_data ).json, + _ProjectDirectoryMatcher( project ) ) From 60c849ed67551a7112ef72637e25e28350e1f4d1 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Tue, 10 Oct 2017 00:40:17 +0100 Subject: [PATCH 37/51] Class documentation and comments. --- .../language_server_completer.py | 354 +++++++++++++++--- 1 file changed, 292 insertions(+), 62 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 997ba5112e..55d97b7ebd 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -49,29 +49,54 @@ class ResponseTimeoutException( Exception ): + """Raised by LanguageServerConnection if a request exceeds the supplied + time-to-live.""" pass class ResponseAbortedException( Exception ): + """Raised by LanguageServerConnection if a request is cancelled due to the + server shutting down.""" pass class ResponseFailedException( Exception ): + """Raised by LanguageServerConnection if a request returns an error""" pass class IncompatibleCompletionException( Exception ): + """Internal exception returned when a completion item is encountered which is + not supported by ycmd, or where the completion item is invalid.""" pass class Response( object ): + """Represents a blocking pending request. + + LanguageServerCompleter handles create an instance of this class for each + request that expects a response and wait for its response synchonously by + calling |AwaitResponse|. + + The LanguageServerConnection message pump thread calls |ResponseReceived| when + the associated response is read, which triggers the |AwaitResponse| method to + handle the actual response""" + def __init__( self, response_callback=None ): + """In order to receive a callback in the message pump thread context, supply + a method taking ( response, message ) in |response_callback|. Note that + |response| is _this object_, not the calling object, and message is the + message that was received. NOTE: This should not normally be used. Instead + users should synchronously wait on AwaitResponse.""" self._event = threading.Event() self._message = None self._response_callback = response_callback def ResponseReceived( self, message ): + """Called by the message pump thread when the response with corresponding ID + is received from the server. Triggers the message received event and calls + any configured message-pump-thread callback.""" self._message = message self._event.set() if self._response_callback: @@ -79,10 +104,17 @@ def ResponseReceived( self, message ): def Abort( self ): + """Called when the server is shutting down.""" self.ResponseReceived( None ) def AwaitResponse( self, timeout ): + """Called by clients to wait syncronously for either a response to be + received of for |timeout| seconds to have passed. + Returns the message, or: + - throws ResponseFailedException if the request fails + - throws ResponseTimeoutException in case of timeout + - throws ResponseAbortedException in case the server is shut down.""" self._event.wait( timeout ) if not self._event.isSet(): @@ -101,10 +133,14 @@ def AwaitResponse( self, timeout ): class LanguageServerConnectionTimeout( Exception ): + """Raised by LanguageServerConnection if the connection to the server is not + established with the specified timeout.""" pass class LanguageServerConnectionStopped( Exception ): + """Internal exception raised by LanguageServerConnection when the server is + successfully shut down according to user request.""" pass @@ -223,6 +259,11 @@ def NextRequestId( self ): def GetResponseAsync( self, request_id, message, response_callback=None ): + """Issue a request to the server and return immediately. If a response needs + to be handled, supply a method taking ( response, message ) in + response_callback. Note |response| is the instance of Response and message + is the message received from the server. + Returns the Response instance created.""" response = Response( response_callback ) with self._responseMutex: @@ -236,17 +277,28 @@ def GetResponseAsync( self, request_id, message, response_callback=None ): def GetResponse( self, request_id, message, timeout ): + """Issue a request to the server and await the response. See + Response.AwaitResponse for return values and exceptions.""" response = self.GetResponseAsync( request_id, message ) return response.AwaitResponse( timeout ) def SendNotification( self, message ): + """Issue a notification to the server. A notification is "fire and forget"; + no response will be received and nothing is returned.""" _logger.debug( 'TX: Sending notification: %r', message ) self._Write( message ) def AwaitServerConnection( self ): + """Language server completer implementations should call this after starting + the server and the message pump (start()) to await successful connection to + the server being established. + + Returns no meaningful value, but may throw LanguageServerConnectionTimeout + in the event that the server does not connect promptly. In that case, + clients should shut down their server and reset their state.""" self._connection_event.wait( timeout = CONNECTION_TIMEOUT ) if not self._connection_event.isSet(): @@ -254,47 +306,21 @@ def AwaitServerConnection( self ): 'Timed out waiting for server to connect' ) - def _ReadHeaders( self, data ): - headers_complete = False - prefix = bytes( b'' ) - headers = {} - - while not headers_complete: - read_bytes = 0 - last_line = 0 - if len( data ) == 0: - data = self._Read() - - while read_bytes < len( data ): - if utils.ToUnicode( data[ read_bytes: ] )[ 0 ] == '\n': - line = prefix + data[ last_line : read_bytes ].strip() - prefix = bytes( b'' ) - last_line = read_bytes - - if not line.strip(): - headers_complete = True - read_bytes += 1 - break - else: - key, value = utils.ToUnicode( line ).split( ':', 1 ) - headers[ key.strip() ] = value.strip() - - read_bytes += 1 - - if not headers_complete: - prefix = data[ last_line : ] - data = bytes( b'' ) - - - return ( data, read_bytes, headers ) + def _ReadMessages( self ): + """Main message pump. Within the message pump thread context, reads messages + from the socket/stream by calling self._Read in a loop and despatch complete + messages by calling self._DespatchMessage. + When the server is shut down cleanly, raises + LanguageServerConnectionStopped""" - def _ReadMessages( self ): data = bytes( b'' ) while True: ( data, read_bytes, headers ) = self._ReadHeaders( data ) if 'Content-Length' not in headers: + # FIXME: We could try and recover this, but actually the message pump + # just fails. raise ValueError( "Missing 'Content-Length' header" ) content_length = int( headers[ 'Content-Length' ] ) @@ -333,7 +359,59 @@ def _ReadMessages( self ): data = data[ read_bytes : ] + def _ReadHeaders( self, data ): + """Starting with the data in |data| read headers from the stream/socket + until a full set of headers has been consumed. Returns a tuple ( + - data: any remaining unused data from |data| or the socket + - read_bytes: the number of bytes of returned data that have been consumed + - headers: a dictionary whose keys are the header names and whose values + are the header values + )""" + # LSP defines only 2 headers, of which only 1 is useful (Content-Length). + # Headers end with an empty line, and there is no guarantee that a single + # socket or stream read will contain only a single message, or even a whole + # message. + + headers_complete = False + prefix = bytes( b'' ) + headers = {} + + while not headers_complete: + read_bytes = 0 + last_line = 0 + if len( data ) == 0: + data = self._Read() + + while read_bytes < len( data ): + if utils.ToUnicode( data[ read_bytes: ] )[ 0 ] == '\n': + line = prefix + data[ last_line : read_bytes ].strip() + prefix = bytes( b'' ) + last_line = read_bytes + + if not line.strip(): + headers_complete = True + read_bytes += 1 + break + else: + key, value = utils.ToUnicode( line ).split( ':', 1 ) + headers[ key.strip() ] = value.strip() + + read_bytes += 1 + + if not headers_complete: + prefix = data[ last_line : ] + data = bytes( b'' ) + + + return ( data, read_bytes, headers ) + + def _DespatchMessage( self, message ): + """Called in the message pump thread context when a complete message was + read. For responses, calls the Response object's ResponseReceived method, or + for notifications (unsolicited messages from the server), simply accumulates + them in a Queue which is polled by the long-polling mechanism in + LanguageServerCompleter.""" if 'id' in message: with self._responseMutex: assert str( message[ 'id' ] ) in self._responses @@ -342,6 +420,8 @@ def _DespatchMessage( self, message ): else: self._notifications.put( message ) + # If there is an immediate (in-message-pump-thread) handler configured, + # call it. if self._notification_handler: self._notification_handler( self, message ) @@ -471,6 +551,9 @@ def __init__( self, user_options): def ServerReset( self ): + """Clean up internal state related to the running server instance. + Implementation sare required to call this after disconnection and killing + the downstream server.""" with self._mutex: self._serverFileState = {} self._latest_diagnostics = collections.defaultdict( list ) @@ -481,6 +564,14 @@ def ServerReset( self ): def ShutdownServer( self ): + """Send the shutdown and possibly exit request to the server. + Implemenations must call this prior to closing the LanguageServerConnection + or killing the downstream server.""" + + # Language server protocol requires orderly shutdown of the downstream + # server by first sending a shutdown request, and on its completion sending + # and exit notification (which does not receive a response). Some buggy + # servers exit on receipt of the shutdown request, so we handle that too. if self.ServerIsReady(): request_id = self.GetConnection().NextRequestId() msg = lsapi.Shutdown( request_id ) @@ -503,6 +594,9 @@ def ShutdownServer( self ): def ServerIsReady( self ): + """Returns True if the server is running and the initialization exchange has + completed successfully. Implementations must not issue requests until this + method returns True.""" if not self.ServerIsHealthy(): return False @@ -519,6 +613,7 @@ def ServerIsReady( self ): def ShouldUseNowInner( self, request_data ): + # We should only do _anything_ after the initialize exchange has completed. return ( self.ServerIsReady() and super( LanguageServerCompleter, self ).ShouldUseNowInner( request_data ) ) @@ -536,20 +631,25 @@ def ComputeCandidatesInner( self, request_data ): msg, REQUEST_TIMEOUT_COMPLETION ) - do_resolve = ( - 'completionProvider' in self._server_capabilities and - self._server_capabilities[ 'completionProvider' ].get( 'resolveProvider', - False ) ) - if isinstance( response[ 'result' ], list ): items = response[ 'result' ] else: items = response[ 'result' ][ 'items' ] - return self.ResolveCompletionItems( items, request_data, do_resolve ) + # The way language server protocol does completions expects us to "resolve" + # items as the user selects them. We don't have any API for that so we + # simply resolve each completion item we get. Should this be a performance + # issue, we could restrict it in future. + # + # Note: ResolveCompletionItems does a lot of work on the actual completion + # text to ensure that the returned text and start_codepoint are applicable + # to our model of a single start column. + return self.ResolveCompletionItems( items, request_data ) - def ResolveCompletionItems( self, items, request_data, do_resolve ): + def ResolveCompletionItems( self, items, request_data ): + """Issue the resolve request for each completion item in |items|, then fix + up the items such that a single start codepoint is used.""" ITEM_KIND = [ None, # 1-based 'Text', @@ -572,6 +672,40 @@ def ResolveCompletionItems( self, items, request_data, do_resolve ): 'Reference', ] + # We might not actually need to issue the resolve request if the server + # claims that it doesn't support it. However, we still might need to fix up + # the completion items. + do_resolve = ( + 'completionProvider' in self._server_capabilities and + self._server_capabilities[ 'completionProvider' ].get( 'resolveProvider', + False ) ) + + # + # Important note on the following logic: + # + # Language server protocol _requires_ that clients support textEdits in + # completion items. It imposes some restrictions on the textEdit, namely: + # * the edit range must cover at least the original requested position, + # * and that it is on a single line. + # + # Importantly there is no restriction that all edits start and end at the + # same point. + # + # ycmd protocol only supports a single start column, so we must post-process + # the completion items as follows: + # * read all completion items text and start codepoint and store them + # * store the minimum textEdit start point encountered + # * go back through the completion items and modify them so that they + # contain enough text to start from the minimum start codepoint + # * set the completion start codepoint to the minimum start point + # + # The last part involves reading the original source text and padding out + # completion items so that they all start at the same point. + # + # This is neither particularly pretty nor efficient, but it is necessary. + # Significant completions, such as imports, do not work without it in + # jdt.ls. + # completions = list() start_codepoints = list() min_start_codepoint = request_data[ 'start_codepoint' ] @@ -604,6 +738,8 @@ def ResolveCompletionItems( self, items, request_data, do_resolve ): min_start_codepoint = min( min_start_codepoint, start_codepoint ) + # Build a ycmd-compatible completion for the text as we received it. Later + # we might mofify insertion_text should we see a lower start codepoint. completions.append( responses.BuildCompletionData( insertion_text, extra_menu_info = item.get( 'detail', None ), @@ -617,6 +753,7 @@ def ResolveCompletionItems( self, items, request_data, do_resolve ): if ( len( completions ) > 1 and min_start_codepoint != request_data[ 'start_codepoint' ] ): + # We need to fix up the completions, go do that return FixUpCompletionPrefixes( completions, start_codepoints, request_data, @@ -630,6 +767,11 @@ def OnFileReadyToParse( self, request_data ): if not self.ServerIsHealthy(): return + # If we haven't finished initializing yet, we need to queue up a call to + # _RefreshFiles. This ensures that the server is up to date as soon as we + # are able to send more messages. This is important because server start up + # can be quite slow and we must not block the user, while we must keep the + # server synchronized. if not self._initialise_event.is_set(): self._OnInitialiseComplete( lambda self: self._RefreshFiles( request_data ) ) @@ -637,11 +779,15 @@ def OnFileReadyToParse( self, request_data ): self._RefreshFiles( request_data ) + # Return the latest diagnostics that we have received. + # # NOTE: We also return diagnostics asynchronously via the long-polling # mechanism to avoid timing issues with the servers asynchronous publication # of diagnostics. + # # However, we _also_ return them here to refresh diagnostics after, say - # changing the active file in the editor. + # changing the active file in the editor, or for clients not supporting the + # polling mechanism. uri = lsapi.FilePathToUri( request_data[ 'filepath' ] ) with self._mutex: if uri in self._latest_diagnostics: @@ -651,6 +797,19 @@ def OnFileReadyToParse( self, request_data ): def PollForMessagesInner( self, request_data ): # scoop up any pending messages into one big list + messages = self._PollForMessagesBlock( request_data ) + + # If we found some messages, return them immediately + if messages: + return messages + + # Otherwise, there are no pending messages. Block until we get one. + return self._PollForMessagesBlock( request_data ) + + + def _PollForMessagesNoBlock( self, request_data, messages ): + """Convert any pending notifications to messages and return them in a list. + If there are no messages pending, returns an empty list.""" messages = list() try: while True: @@ -658,28 +817,26 @@ def PollForMessagesInner( self, request_data ): # The server isn't running or something. Don't re-poll. return False - self._PollForMessagesNoBlock( request_data, messages ) + notification = self.GetConnection()._notifications.get_nowait( ) + message = self._ConvertNotificationToMessage( request_data, + notification ) + + if message: + messages.append( message ) except queue.Empty: # We drained the queue pass - # If we found some messages, return them immediately - if messages: - return messages - - # otherwise, block until we get one - return self._PollForMessagesBlock( request_data ) - - - def _PollForMessagesNoBlock( self, request_data, messages ): - notification = self.GetConnection()._notifications.get_nowait( ) - message = self._ConvertNotificationToMessage( request_data, - notification ) - if message: - messages.append( message ) + return messages def _PollForMessagesBlock( self, request_data ): + """Block until either we receive a notification, or a timeout occurs. + Returns one of the following: + - a list containing a single message + - True if a timeout occurred, and the poll should be restarted + - False if an error occurred, and no further polling should be attempted + """ try: while True: if not self.GetConnection(): @@ -698,15 +855,21 @@ def _PollForMessagesBlock( self, request_data ): def GetDefaultNotificationHandler( self ): + """Return a notification handler method suitable for passing to + LanguageServerConnection constructor""" def handler( server, notification ): self._HandleNotificationInPollThread( notification ) return handler def _HandleNotificationInPollThread( self, notification ): + """Called by the LanguageServerConnection in its message pump context when a + notification message arrives.""" + if notification[ 'method' ] == 'textDocument/publishDiagnostics': # Some clients might not use a message poll, so we must store the - # diagnostics and return them in OnFileReadyToParse + # diagnostics and return them in OnFileReadyToParse. We also need these + # for correct FixIt handling, as they are part of the FixIt context. params = notification[ 'params' ] uri = params[ 'uri' ] with self._mutex: @@ -714,6 +877,12 @@ def _HandleNotificationInPollThread( self, notification ): def _ConvertNotificationToMessage( self, request_data, notification ): + """Convert the supplied server notification to a ycmd message. Returns None + if the notification should be ignored. + + Implementations may override this method to handle custom notifications, but + must always call the base implementation for unrecognised notifications.""" + if notification[ 'method' ] == 'window/showMessage': return responses.BuildDisplayMessageResponse( notification[ 'params' ][ 'message' ] ) @@ -744,6 +913,11 @@ def _ConvertNotificationToMessage( self, request_data, notification ): def _RefreshFiles( self, request_data ): + """Update the server with the current contents of all open buffers, and + close any buffers no longer open. + + This method should be called frequently and in any event before a syncronous + operation.""" with self._mutex: for file_name, file_data in iteritems( request_data[ 'file_data' ] ): file_state = 'New' @@ -783,10 +957,21 @@ def _RefreshFiles( self, request_data ): def _GetProjectDirectory( self ): + """Return the directory in which the server should operate. Language server + protocol and most servers have a concept of a 'project directory'. By + default this is the working directory of the ycmd server, but implemenations + may override this for example if there is a language- or server-specific + notion of a project that can be detected.""" return utils.GetCurrentDirectory() def SendInitialise( self ): + """Sends the initialize request asynchronously. + This must be called immediately after establishing the connection with the + language server. Implementations must not issue further requests to the + server until the initialize exchange has completed. This can be detected by + calling this class's implementation of ServerIsReady.""" + with self._mutex: assert not self._initialise_response @@ -806,6 +991,8 @@ def response_handler( response, message ): def _HandleInitialiseInPollThread( self, response ): + """Called within the context of the LanguageServerConnection's message pump + when the initialize request receives a response.""" with self._mutex: self._server_capabilities = response[ 'result' ][ 'capabilities' ] @@ -817,14 +1004,21 @@ def _HandleInitialiseInPollThread( self, response ): ] self._syncType = SYNC_TYPE[ response[ 'result' ][ 'capabilities' ][ 'textDocumentSync' ] ] - _logger.info( 'Language Server requires sync type of {0}'.format( + _logger.info( 'Language server requires sync type of {0}'.format( self._syncType ) ) + # We must notify the server that we received the initialize response. + # There are other things that could happen here, such as loading custom + # server configuration, but we don't support that (yet). self.GetConnection().SendNotification( lsapi.Initialized() ) + # Notify the other threads that we have completed the initialize exchange. self._initialise_response = None self._initialise_event.set() + # Fire any events that are pending on the completion of the initialize + # exchange. Typically, this will be calls to _RefreshFiles or something that + # occurred while we were waiting. for handler in self._on_initialise_complete_handlers: handler( self ) @@ -832,10 +1026,18 @@ def _HandleInitialiseInPollThread( self, response ): def _OnInitialiseComplete( self, handler ): + """Register a function to be called when the initialize exchange completes. + The function |handler| will be called on successful completion of the + initalize exchange with a single argument |self|, which is the |self| passed + to this method. + If the server is shut down or reset, the callback is not called.""" self._on_initialise_complete_handlers.append( handler ) def GetHoverResponse( self, request_data ): + """Return the raw LSP response to the hover request for the supplied + context. Implementations can use this for e.g. GetDoc and GetType requests, + depending on the particular server response.""" if not self.ServerIsReady(): raise RuntimeError( 'Server is initializing. Please wait.' ) @@ -852,6 +1054,8 @@ def GetHoverResponse( self, request_data ): def GoToDeclaration( self, request_data ): + """Issues the definition request and returns the result as a GoTo + response.""" if not self.ServerIsReady(): raise RuntimeError( 'Server is initializing. Please wait.' ) @@ -876,6 +1080,8 @@ def GoToDeclaration( self, request_data ): def GoToReferences( self, request_data ): + """Issues the references request and returns the result as a GoTo + response.""" if not self.ServerIsReady(): raise RuntimeError( 'Server is initializing. Please wait.' ) @@ -892,6 +1098,8 @@ def GoToReferences( self, request_data ): def CodeAction( self, request_data, args ): + """Performs the codeaction request and returns the result as a FixIt + response.""" if not self.ServerIsReady(): raise RuntimeError( 'Server is initializing. Please wait.' ) @@ -955,6 +1163,7 @@ def WithinRange( diag ): def Rename( self, request_data, args ): + """Issues the rename request and returns the result as a FixIt response.""" if not self.ServerIsReady(): raise RuntimeError( 'Server is initializing. Please wait.' ) @@ -983,8 +1192,8 @@ def FixUpCompletionPrefixes( completions, start_codepoints, request_data, min_start_codepoint ): - # Fix up the insertion texts so they share the same start_codepoint by - # borrowing text from the source + """Fix up the insertion texts so they share the same start_codepoint by + borrowing text from the source.""" line = request_data[ 'line_value' ] for completion, start_codepoint in zip( completions, start_codepoints ): to_borrow = start_codepoint - min_start_codepoint @@ -1003,17 +1212,28 @@ def FixUpCompletionPrefixes( completions, # The start column is the earliest start point that we fixed up plus the # length of the common prefix that we subsequently removed. # - # Phew. That was hard work. + # Phew! That was hard work. request_data[ 'start_codepoint' ] = min_start_codepoint + common_prefix_len return completions def InsertionTextForItem( request_data, item ): + """Determines the insertion text for the completion item |item|, and any + additional FixIts that need to be applied when selecting it. + + Returns a tuple ( + - insertion_text = the text to insert + - 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 + )""" INSERT_TEXT_FORMAT = [ None, # 1-based 'PlainText', 'Snippet' ] + # We explicitly state that we do not support completion types of "Snippet". + # Abort this request if the server is buggy and ignores us. assert INSERT_TEXT_FORMAT[ item.get( 'insertTextFormat', 1 ) ] == 'PlainText' fixits = None @@ -1094,6 +1314,7 @@ def InsertionTextForItem( request_data, item ): def LocationListToGoTo( request_data, response ): + """Convert a LSP list of locations to a ycmd GoTo response.""" if not response: raise RuntimeError( 'Cannot jump to location' ) @@ -1116,12 +1337,15 @@ def LocationListToGoTo( request_data, response ): def PositionToLocation( request_data, position ): + """Convert a LSP position to a ycmd location.""" return BuildLocation( request_data, lsapi.UriToFilePath( position[ 'uri' ] ), position[ 'range' ][ 'start' ] ) def BuildLocation( request_data, filename, loc ): + """Returns a ycmd Location for the supplied filename and LSP location. + Importantly, converts from LSP unicode offset to ycmd byte offset.""" file_contents = utils.SplitLines( GetFileContents( request_data, filename ) ) try: line_value = file_contents[ loc[ 'line' ] ] @@ -1138,11 +1362,13 @@ def BuildLocation( request_data, filename, loc ): def BuildRange( request_data, filename, r ): + """Returns a ycmd range from a LSP range |r|.""" return responses.Range( BuildLocation( request_data, filename, r[ 'start' ] ), BuildLocation( request_data, filename, r[ 'end' ] ) ) def BuildDiagnostic( request_data, uri, diag ): + """Return a ycmd diagnostic from a LSP diagnostic.""" filename = lsapi.UriToFilePath( uri ) r = BuildRange( request_data, filename, diag[ 'range' ] ) SEVERITY = [ @@ -1168,6 +1394,7 @@ def BuildDiagnostic( request_data, uri, diag ): def TextEditToChunks( request_data, uri, text_edit ): + """Returns a list of FixItChunks from a LSP textEdit.""" filepath = lsapi.UriToFilePath( uri ) return [ responses.FixItChunk( change[ 'newText' ], @@ -1179,6 +1406,9 @@ def TextEditToChunks( request_data, uri, text_edit ): def WorkspaceEditToFixIt( request_data, workspace_edit, text='' ): + """Converts a LSP workspace edit to a ycmd FixIt suitable for passing to + responses.BuildFixItResponse.""" + if 'changes' not in workspace_edit: return None From 7eb7b4fff655e49fc1a43b8b22a43e5ea2b46285 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Thu, 12 Oct 2017 21:16:46 +0100 Subject: [PATCH 38/51] Include the text of the line at the reference site, as the _greatly_ improves the utility of the GoToReferences command --- .../language_server_completer.py | 62 +++++++++++++------ ycmd/tests/java/subcommands_test.py | 4 +- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 55d97b7ebd..28fdcf5145 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -1074,7 +1074,7 @@ def GoToDeclaration( self, request_data ): position = response[ 'result' ] try: return responses.BuildGoToResponseFromLocation( - PositionToLocation( request_data, position ) ) + *PositionToLocationAndDescription( request_data, position ) ) except KeyError: raise RuntimeError( 'Cannot jump to location' ) @@ -1323,30 +1323,42 @@ def LocationListToGoTo( request_data, response ): positions = response[ 'result' ] return [ responses.BuildGoToResponseFromLocation( - PositionToLocation( request_data, - position ) ) for position in positions + *PositionToLocationAndDescription( request_data, + position ) ) + for position in positions ] else: position = response[ 'result' ][ 0 ] return responses.BuildGoToResponseFromLocation( - PositionToLocation( request_data, position ) ) + *PositionToLocationAndDescription( request_data, position ) ) except IndexError: raise RuntimeError( 'Cannot jump to location' ) except KeyError: raise RuntimeError( 'Cannot jump to location' ) -def PositionToLocation( request_data, position ): +def PositionToLocationAndDescription( request_data, position ): """Convert a LSP position to a ycmd location.""" - return BuildLocation( request_data, - lsapi.UriToFilePath( position[ 'uri' ] ), - position[ 'range' ][ 'start' ] ) - - -def BuildLocation( request_data, filename, loc ): - """Returns a ycmd Location for the supplied filename and LSP location. + try: + filename = lsapi.UriToFilePath( position[ 'uri' ] ) + file_contents = utils.SplitLines( GetFileContents( request_data, + filename ) ) + except IOError: + file_contents = [] + + return BuildLocationAndDescription( request_data, + filename, + file_contents, + position[ 'range' ][ 'start' ] ) + + +def BuildLocationAndDescription( request_data, filename, file_contents, loc ): + """Returns a tuple of ( + - ycmd Location for the supplied filename and LSP location + - contents of the line at that location + ) Importantly, converts from LSP unicode offset to ycmd byte offset.""" - file_contents = utils.SplitLines( GetFileContents( request_data, filename ) ) + try: line_value = file_contents[ loc[ 'line' ] ] column = utils.CodepointOffsetToByteOffset( line_value, @@ -1354,17 +1366,31 @@ def BuildLocation( request_data, filename, loc ): except IndexError: # This can happen when there are stale diagnostics in OnFileReadyToParse, # just return the value as-is. + line_value = "" column = loc[ 'character' ] + 1 - return responses.Location( loc[ 'line' ] + 1, - column, - filename = filename ) + return ( responses.Location( loc[ 'line' ] + 1, + column, + filename = filename ), + line_value ) def BuildRange( request_data, filename, r ): """Returns a ycmd range from a LSP range |r|.""" - return responses.Range( BuildLocation( request_data, filename, r[ 'start' ] ), - BuildLocation( request_data, filename, r[ 'end' ] ) ) + try: + file_contents = utils.SplitLines( GetFileContents( request_data, + filename ) ) + except IOError: + file_contents = [] + + return responses.Range( BuildLocationAndDescription( request_data, + filename, + file_contents, + r[ 'start' ] )[ 0 ], + BuildLocationAndDescription( request_data, + filename, + file_contents, + r[ 'end' ] )[ 0 ] ) def BuildDiagnostic( request_data, uri, diag ): diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py index e733a058be..122c1a3b3c 100644 --- a/ycmd/tests/java/subcommands_test.py +++ b/ycmd/tests/java/subcommands_test.py @@ -501,7 +501,7 @@ def Subcommands_GoToReferences_test( app ): 'test', 'TestFactory.java' ), 'column_num': 9, - # 'description': '', + 'description': " w.doSomethingVaguelyUseful();", 'line_num': 28 }, { @@ -511,7 +511,7 @@ def Subcommands_GoToReferences_test( app ): 'test', 'TestLauncher.java' ), 'column_num': 11, - # 'description': '', + 'description': " w.doSomethingVaguelyUseful();", 'line_num': 32 } ] ) From 1004f63ca7cd7802e6426f0845ae7393aed82675 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Thu, 12 Oct 2017 21:18:34 +0100 Subject: [PATCH 39/51] jdt.ls can return jdt: scheme for some .class files. Don't crash when this happens --- .../language_server_completer.py | 36 ++++++++++++++----- ycmd/completers/language_server/lsapi.py | 11 +++++- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 28fdcf5145..674f6c84ab 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -901,13 +901,17 @@ def _ConvertNotificationToMessage( self, request_data, notification ): elif notification[ 'method' ] == 'textDocument/publishDiagnostics': params = notification[ 'params' ] uri = params[ 'uri' ] - filepath = lsapi.UriToFilePath( uri ) - response = { - 'diagnostics': [ BuildDiagnostic( request_data, uri, x ) - for x in params[ 'diagnostics' ] ], - 'filepath': filepath - } - return response + try: + filepath = lsapi.UriToFilePath( uri ) + response = { + 'diagnostics': [ BuildDiagnostic( request_data, uri, x ) + for x in params[ 'diagnostics' ] ], + 'filepath': filepath + } + return response + except lsapi.InvalidUriException: + _logger.exception( 'Ignoring diagnostics for unrecognised URI' ) + pass return None @@ -1343,6 +1347,10 @@ def PositionToLocationAndDescription( request_data, position ): filename = lsapi.UriToFilePath( position[ 'uri' ] ) file_contents = utils.SplitLines( GetFileContents( request_data, filename ) ) + except lsapi.InvalidUriException: + _logger.debug( "Invalid URI, file contents not available in GoTo" ) + filename = '' + file_contents = [] except IOError: file_contents = [] @@ -1395,7 +1403,12 @@ def BuildRange( request_data, filename, r ): def BuildDiagnostic( request_data, uri, diag ): """Return a ycmd diagnostic from a LSP diagnostic.""" - filename = lsapi.UriToFilePath( uri ) + try: + filename = lsapi.UriToFilePath( uri ) + except lsapi.InvalidUriException: + _logger.debug( 'Invalid URI received for diagnostic' ) + filename = '' + r = BuildRange( request_data, filename, diag[ 'range' ] ) SEVERITY = [ None, @@ -1421,7 +1434,12 @@ def BuildDiagnostic( request_data, uri, diag ): def TextEditToChunks( request_data, uri, text_edit ): """Returns a list of FixItChunks from a LSP textEdit.""" - filepath = lsapi.UriToFilePath( uri ) + try: + filepath = lsapi.UriToFilePath( uri ) + except lsapi.InvalidUriException: + _logger.debug( 'Invalid filepath received in TextEdit' ) + filepath = '' + return [ responses.FixItChunk( change[ 'newText' ], BuildRange( request_data, diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index b3a74d9061..948b206c9a 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -39,6 +39,13 @@ LAST_VERSION = defaultdict( int ) +class InvalidUriException( Exception ): + """Raised when trying to convert a server URI to a file path but the scheme + was not supported. Only the file: scheme is supported""" + pass + + + def BuildRequest( request_id, method, parameters ): """Builds a JSON RPC request message with the supplied ID, method and method parameters""" @@ -194,7 +201,9 @@ def FilePathToUri( file_name ): def UriToFilePath( uri ): - # NOTE: This assumes the URI starts with file: + if uri [ : 5 ] != "file:": + raise InvalidUriException( uri ) + return os.path.abspath( url2pathname( uri[ 5 : ] ) ) From 01f84e18cac71a0393cf2604ad5d550a016f2939 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Thu, 12 Oct 2017 23:44:51 +0100 Subject: [PATCH 40/51] PR description as a md file for posterity --- JAVA_SUPPORT.md | 227 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 JAVA_SUPPORT.md diff --git a/JAVA_SUPPORT.md b/JAVA_SUPPORT.md new file mode 100644 index 0000000000..d4e9bb1793 --- /dev/null +++ b/JAVA_SUPPORT.md @@ -0,0 +1,227 @@ +This document briefly describes the work done to support Java (and other +Language Server Protocol-based completion engines). + +# Overview + +The [original PR][PR] implemented native support in ycmd for the Java language, +based on [jdt.ls][]. In summary, the following key features were added: + +* Installation of jdt.ls (built from source with `build.py`) +* Management of the jdt.ls server instance, projects etc. +* A generic (ish) implementation of a [Language Server Protocol][lsp] client so + far as is required for jdt.ls (easily extensible to other engines) +* Support for the following Java semantic engine features: + * Semantic code completion, including automatic imports + * As-you-type diagnostics + * GoTo including GoToReferences + * FixIt + * RefactorRename + * GetType + * GetDoc + +See the [trello board][trello] for a more complete picture. + +## Overall design/goals + +Key goals: + +1. Support Java in ycmd and YCM; make it good enough to replace eclim and + javacomplete2 for most people +2. Make it possible/easy to support other [lsp][] servers in future (but, don't + suffer from yagni); prove that this works. + +An overview of the objects involved can be seen on [this +card][design]. In short: + +* 2 classes implement the language server protocol in the + `language_server_completer.py` module: + * `LanguageServerConnection` - an abstraction of the comminication with the + server, which may be over stdio or any number of TCP/IP ports (or a domain + socket, etc.). Only a single implementation is included (stdio), but + [implementations for TCP/IP](https://github.com/puremourning/ycmd-1/commit/f3cd06245692b05031a64745054326273d52d12f) + were written originally and dropped in favour of stdio's simplicity. + * `LanguageServerCompleter` - an abstract base for any completer based on LSP, + which implements as much standard functionality as possible including + completions, diagnostics, goto, fixit, rename, etc. +* The `java_completer` itself implements the `LanguageServerCompleter`, boots + the jdt.ls server, and instantiates a `LanguageServerConnection` for + communication with jdt.ls. + +The overall plan and some general discussion around the project can be found on +the [trello board][trello] I used for development. + +## Threads, and why we need them + +LSP is by its nature an asyncronous protocol. There are request-reply like +`requests` and unsolicited `notifications`. Receipt of the latter is mandatory, +so we cannot rely on their being a `bottle` thread executing a client request. + +So we need a message pump and despatch thread. This is actually the +`LanguageServerConnection`, which implements `thread`. It's main method simply +listens on the socket/stream and despatches complete messages to the +`LanguageServerCompleter`. It does this: + +* For `requests`: similarly to the TypeScript completer, using python `event` + objects, wrapped in our `Response` class +* For `notifications`: via a synchronised `queue`. More on this later. + +A representation of this is on the "Requests and notifications" page +of the [design][], including a rough sketch of the thread interaction. + +### Some handling is done in the message pump. + +While it is perhaps regrettable to do general processing directly in the message +pump, there are certain notifications which we have to handle immediately when +we get them, such as: + +* Initialisation messages +* Diagnostics + +In these cases, we allow some code to be executed inline within the message pump +thread, as there is no other thread guaranteed to execute. These are handled by +callback functions and state is protected mutexes. + +## Startup sequence + +See the 'initialisation sequence' tab on the [design][] for a bit of background. + +In standard LSP, the initialisation sequence consists of an initialise +request-reply, followed by us sending the server an initialised notification. We +must not send any other requests until this has completed. + +An additional wrinkle is that jdt.ls, being based on eclipse has a whole other +initialisation sequence during which time it is not fully functional, so we have +to determine when that has completed too. This is done by jdt.ls-specific +messages and controls the `ServerIsReady` response. + +In order for none of these shenanigans to block the user, we must do them all +asynchronously, effectively in the message pump thread. In addition, we must +queue up any file contents changes during this period to ensure the server is up +to date when we start processing requests proper. + +This is unfortunately complicated, but there were early issues with really bad +UI blocking that we just had to get rid of. + +## Completion + +Language server protocol requires that the client can apply textEdits, +rather than just simple text. This is not an optional feature, but ycmd +clients do not have this ability. + +The protocol, however, restricts that the edit must include the original +requested completion position, so we can perform some simple text +manipulation to apply the edit to the current line and determine the +completion start column based on that. + +In particular, the jdt.ls server returns textEdits that replace the +entered text for import completions, which is one of the most useful +completions. + +We do this super inefficiently by attempting to normalise the TextEdits +into insertion_texts with the same start_codepoint. This is necessary +particularly due to the way that eclipse returns import completions for +packages. + +We also include support for "additionalTextEdits" which +allow automatic insertion of, e.g., import statements when selecting +completion items. These are sent on the completion response as an +additional completer data item called 'fixits'. The client applies the +same logic as a standard FixIt once the selected completion item is +inserted. + +## Diagnostics + +Diagnostics in LSP are delivered asynchronously via `notifications`. Normally, +we would use the `OnFileReadyToParse` response to supply diagnostics, but due to +the lag between refreshing files and receiving diagnostics, this leads to a +horrible user experience where the diagnostics always lag one edit behind. + +To resolve this, we use the long-polling mechanism added here (`ReceiveMessages` +request) to return diagnostics to the client asynchronously. + +We deliver asynchronous diagnostics to the client in the same way that the +language server does, i.e. per-file. The client then fans them out or does +whatever makes sense for the client. This is necessary because it isn't possible +to know when we have received all diagnostics, and combining them into a single +message was becoming clunky and error prone. + +In order to be relatively compatible with other clients, we also return +diagnostics on the file-ready-to-parse event, even though they might be +out of date wrt the code. The client is responsible for ignoring these +diagnostics when it handles the asynchronously delivered ones. This requires +that we hold the "latest" diagnostics for a file. As it turns out, this is also +required for FixIts. + +## Projects + +jdt.ls is based on eclipse. It is in fact an eclipse plugin. So it requires an +eclipse workspace. We try and hide this by creating an ad-hoc workspace for each +ycmd instance. This prevents the possibility of multiple "eclipse" instances +using the same workspace, but can lead to unreasonable startup times for large +projects. + +The jdt.ls team strongly suggest that we should re-use a workspace based on the +hash of the "project directory" (essentially the dir containing the project +file: `.project`, `pom.xml` or `build.gradle`). They also say, however, that +eclipse frequently corrupts its workspace. + +So we have a hidden switch to re-use a workspace as the jdt.ls devs suggest. In +testing at work, this was _mandatory_ due to a slow SAN, but at home, startup +time is not an issue, even for large projects. I think we'll just have to see +how things go to decide which one we want to keep. + +## Subcommands + +### GetDoc/GetType + +There is no GetType in LSP. There's only "hover". The hover response is +hilariously server-specific, so in the base `LanguageServerCompleter` we just +provide the ability to get the `hover` response and `JavaCompleter` extracts the +appropriate info from there. Thanks to @bstaletic for this! + +### FixIt + +FixIts are implemented as code actions, and require the diagnostic they relate +to to be send from us to the server, rather than just a position. We use the +stored diags and find the nearest one based on the `request_data`. + +What's worse is that the LSP provides _no documentation_ for what the "Code +action" response should be, and it is 100% implementation-specific. They just +have this `command` abstraction which is basically "do a thing". + +From what I've seen, most servers just end up with either a `WorkspaceEdit` or a +series of `TextEdits`, which is fine for us as that's what ycmd's protocol looks +like. + +The solution is that we have a callback into the `JavaCompleter` to handle the +(custom) `java.apply.workspaceEdit` "command". + +### GoToReferences + +Annoyingly, jdt.ls sometimes returns references to .class files within jar +archives using a custom `jdt://` protocol. We can't handle that, so we have to +dodge and weave so that we don't crash. + +### Stopping the server + +Much like the initialisation sequence, the LSP shutdown sequence is a bit +fiddly. 2 things are required: + +1. A `shutdown` request-reply. The server tides up and _prepares to die!_ +2. An `exit` notification. We tell the server to die. + +This isn't so bad, but jdt.ls is buggy and actually dies without responding to +the `shutdown` request. So we have a bunch of code to handle that and to ensure +that the server dies eventually, as it had a habbit of getting stuck running, +particularly if we threw an exception. + +[PR]: https://github.com/valloric/ycmd/pull/857 +[jdt.ls]: https://github.com/eclipse/eclipse.jdt.ls +[lsp]: https://github.com/Microsoft/language-server-protocol/ +[eclim]: http://eclim.org +[javacomplete2]: https://github.com/artur-shaik/vim-javacomplete2 +[vscode-javac]: https://github.com/georgewfraser/vscode-javac +[VSCode]: https://code.visualstudio.com +[destign]: https://trello.com/c/78IkFBzp +[trello]: https://trello.com/b/Y6z8xag8/ycm-java-language-server +[client]: https://github.com/puremourning/YouCompleteMe/tree/language-server-java From 689f396e0cc57ef38e3e6230509812d4ef40655d Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sun, 15 Oct 2017 00:53:11 +0100 Subject: [PATCH 41/51] Refactoring and fixes after review Typos and style changes Move mappings to lsapi Don't include an extra newline, as this is not strictly in the protocol Use the generic implementation of WaitUntilCompleterServerReady Fix exception when running debug info and server not running --- build.py | 2 +- ycmd/completers/java/java_completer.py | 42 +++++++------- ycmd/completers/javascript/tern_completer.py | 2 +- .../language_server_completer.py | 56 ++++--------------- ycmd/completers/language_server/lsapi.py | 36 ++++++++++++ ycmd/tests/java/__init__.py | 13 +---- 6 files changed, 73 insertions(+), 78 deletions(-) diff --git a/build.py b/build.py index a24140b222..9c1ba95986 100755 --- a/build.py +++ b/build.py @@ -97,7 +97,7 @@ def FindExecutableOrDie( executable, message ): path = FindExecutable( executable ) if not path: - sys.exit( "ERROR: Unabel to find executable '{0}'. {1}".format( + sys.exit( "ERROR: Unable to find executable '{0}'. {1}".format( executable, message ) ) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 23dfa5596b..5941c39f45 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -31,7 +31,7 @@ import threading from subprocess import PIPE -from ycmd import ( utils, responses ) +from ycmd import utils, responses from ycmd.completers.language_server import language_server_completer NO_DOCUMENTATION_MESSAGE = 'No documentation available for current context' @@ -72,7 +72,7 @@ # larger projects # # Cons: -# - A little more complexity (we has the project path to create the workspace +# - A little more complexity (we hash the project path to create the workspace # dir) # - It breaks our tests which expect the logs to be deleted # - It can lead to multiple jdt.js instances using the same workspace (BAD) @@ -80,7 +80,7 @@ # # So: # - By _default_ we use a clean workspace (see default_settings.json) on each -# Vim instance +# ycmd instance # - An option is available to re-use workspaces CLEAN_WORKSPACE_OPTION = 'java_jdtls_use_clean_workspace' @@ -177,7 +177,7 @@ def __init__( self, user_options ): self._server_handle = None self._server_stderr = None self._workspace_path = None - self._Reset() + self._CleanUp() try : # When we start the server initially, we don't have the request data, so @@ -187,7 +187,9 @@ def __init__( self, user_options ): # FIXME: We could start the server in the FileReadyToParse event, though # this requires some additional complexity and state management. self._StartServer() - except: + except Exception: + # We must catch any exception, to ensure that we do not end up with a + # rogue instance of jdt.ls running. _logger.exception( "jdt.ls failed to start." ) self._StopServer() @@ -263,7 +265,8 @@ def DebugInfo( self, request_data ): executable = self._launcher_path, logfiles = [ self._server_stderr, - os.path.join( self._workspace_path, '.metadata', '.log' ) + ( os.path.join( self._workspace_path, '.metadata', '.log' ) + if self._workspace_path else None ) ], extras = items ) @@ -274,11 +277,8 @@ def Shutdown( self ): self._StopServer() - def ServerIsHealthy( self, request_data = {} ): - if not self._ServerIsRunning(): - return False - - return True + def ServerIsHealthy( self ): + return self._ServerIsRunning() def ServerIsReady( self ): @@ -301,7 +301,7 @@ def _RestartServer( self, request_data ): self._StartServer( request_data.get( 'working_dir' ) ) - def _Reset( self ): + def _CleanUp( self ): if not self._server_keep_logfiles: if self._server_stderr: utils.RemoveIfExists( self._server_stderr ) @@ -363,12 +363,12 @@ def _StartServer( self, working_dir=None ): stdout = PIPE, stderr = stderr ) - if self._ServerIsRunning(): - _logger.info( 'jdt.ls Language Server started' ) - else: - _logger.warning( 'jdt.ls Language Server failed to start' ) + if not self._ServerIsRunning(): + _logger.error( 'jdt.ls Language Server failed to start' ) return + _logger.info( 'jdt.ls Language Server started' ) + self._connection = ( language_server_completer.StandardIOLanguageServerConnection( self._server_handle.stdin, @@ -381,8 +381,8 @@ def _StartServer( self, working_dir=None ): try: self._connection.AwaitServerConnection() except language_server_completer.LanguageServerConnectionTimeout: - _logger.warn( 'jdt.ls failed to start, or did not connect ' - 'successfully' ) + _logger.error( 'jdt.ls failed to start, or did not connect ' + 'successfully' ) self._StopServer() return @@ -404,10 +404,10 @@ def _StopServer( self ): self._StopServerCleanly() # If the server is still running, e.g. due to errors, kill it - self._StopServerForecefully() + self._StopServerForcefully() # Tidy up our internal state - self._Reset() + self._CleanUp() def _StopServerCleanly( self ): @@ -430,7 +430,7 @@ def _StopServerCleanly( self ): _logger.exception( 'Error while stopping jdt.ls server' ) - def _StopServerForecefully( self ): + def _StopServerForcefully( self ): if self._ServerIsRunning(): _logger.info( 'Killing jdt.ls server with PID {0}'.format( self._server_handle.pid ) ) diff --git a/ycmd/completers/javascript/tern_completer.py b/ycmd/completers/javascript/tern_completer.py index e7bf9d9ed1..911f2111c9 100644 --- a/ycmd/completers/javascript/tern_completer.py +++ b/ycmd/completers/javascript/tern_completer.py @@ -290,7 +290,7 @@ def Shutdown( self ): self._StopServer() - def ServerIsHealthy( self, request_data = {} ): + def ServerIsHealthy( self ): if not self._ServerIsRunning(): return False diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 674f6c84ab..d8cf298f4d 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -47,6 +47,13 @@ CONNECTION_TIMEOUT = 5 MESSAGE_POLL_TIMEOUT = 10 +SEVERITY_TO_YCM_SEVERITY = { + 'Error': 'ERROR', + 'Warning': 'WARNING', + 'Information': 'WARNING', + 'Hint': 'WARNING' +} + class ResponseTimeoutException( Exception ): """Raised by LanguageServerConnection if a request exceeds the supplied @@ -455,8 +462,7 @@ def _Close( self ): def _Write( self, data ): - bytes_to_write = data + utils.ToBytes( '\r\n' ) - self._server_stdin.write( bytes_to_write ) + self._server_stdin.write( data ) self._server_stdin.flush() @@ -650,27 +656,6 @@ def ComputeCandidatesInner( self, request_data ): def ResolveCompletionItems( self, items, request_data ): """Issue the resolve request for each completion item in |items|, then fix up the items such that a single start codepoint is used.""" - ITEM_KIND = [ - None, # 1-based - 'Text', - 'Method', - 'Function', - 'Constructor', - 'Field', - 'Variable', - 'Class', - 'Interface', - 'Module', - 'Property', - 'Unit', - 'Value', - 'Enum', - 'Keyword', - 'Snippet', - 'Color', - 'File', - 'Reference', - ] # We might not actually need to issue the resolve request if the server # claims that it doesn't support it. However, we still might need to fix up @@ -747,7 +732,7 @@ def ResolveCompletionItems( self, items, request_data ): '\n\n' + item.get( 'documentation', '' ) ), menu_text = item[ 'label' ], - kind = ITEM_KIND[ item.get( 'kind', 0 ) ], + kind = lsapi.ITEM_KIND[ item.get( 'kind', 0 ) ], extra_data = fixits ) ) start_codepoints.append( start_codepoint ) @@ -1231,14 +1216,10 @@ def InsertionTextForItem( request_data, item ): selecting this completion - start_codepoint = the start column at which the text should be inserted )""" - INSERT_TEXT_FORMAT = [ - None, # 1-based - 'PlainText', - 'Snippet' - ] # We explicitly state that we do not support completion types of "Snippet". # Abort this request if the server is buggy and ignores us. - assert INSERT_TEXT_FORMAT[ item.get( 'insertTextFormat', 1 ) ] == 'PlainText' + assert lsapi.INSERT_TEXT_FORMAT[ + item.get( 'insertTextFormat', 1 ) ] == 'PlainText' fixits = None @@ -1410,26 +1391,13 @@ def BuildDiagnostic( request_data, uri, diag ): filename = '' r = BuildRange( request_data, filename, diag[ 'range' ] ) - SEVERITY = [ - None, - 'Error', - 'Warning', - 'Information', - 'Hint', - ] - SEVERITY_TO_YCM_SEVERITY = { - 'Error': 'ERROR', - 'Warning': 'WARNING', - 'Information': 'WARNING', - 'Hint': 'WARNING' - } return responses.BuildDiagnosticData ( responses.Diagnostic( ranges = [ r ], location = r.start_, location_extent = r, text = diag[ 'message' ], - kind = SEVERITY_TO_YCM_SEVERITY[ SEVERITY[ diag[ 'severity' ] ] ] ) ) + kind = SEVERITY_TO_YCM_SEVERITY[ lsapi.SEVERITY[ diag[ 'severity' ] ] ] ) ) def TextEditToChunks( request_data, uri, text_edit ): diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/lsapi.py index 948b206c9a..a9ce1f0721 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/lsapi.py @@ -38,6 +38,42 @@ # hasn't changed). LAST_VERSION = defaultdict( int ) +INSERT_TEXT_FORMAT = [ + None, # 1-based + 'PlainText', + 'Snippet' +] + +ITEM_KIND = [ + None, # 1-based + 'Text', + 'Method', + 'Function', + 'Constructor', + 'Field', + 'Variable', + 'Class', + 'Interface', + 'Module', + 'Property', + 'Unit', + 'Value', + 'Enum', + 'Keyword', + 'Snippet', + 'Color', + 'File', + 'Reference', +] + +SEVERITY = [ + None, + 'Error', + 'Warning', + 'Information', + 'Hint', +] + class InvalidUriException( Exception ): """Raised when trying to convert a server URI to a file path but the scheme diff --git a/ycmd/tests/java/__init__.py b/ycmd/tests/java/__init__.py index facf436d78..78bedb6b9d 100644 --- a/ycmd/tests/java/__init__.py +++ b/ycmd/tests/java/__init__.py @@ -33,6 +33,7 @@ CurrentWorkingDirectory, SetUpApp, StopCompleterServer ) +from ycmd.tests import test_utils from ycmd.utils import GetCurrentDirectory shared_app = None @@ -111,17 +112,7 @@ def Wrapper( *args, **kwargs ): def WaitUntilCompleterServerReady( app, timeout = 30 ): - expiration = time.time() + timeout - filetype = 'java' - while True: - if time.time() > expiration: - raise RuntimeError( 'Waited for the {0} subserver to be ready for ' - '{1} seconds, aborting.'.format( filetype, timeout ) ) - - if app.get( '/ready', { 'subserver': filetype } ).json: - return - - time.sleep( 0.5 ) + test_utils.WaitUntilCompleterServerReady( app, 'java', timeout ) def PollForMessages( app, request_data ): From 072cfd1d2a01203401e7720ee16eec5294b5e39d Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Wed, 18 Oct 2017 23:23:35 +0100 Subject: [PATCH 42/51] Improve the API and stability for shutdown Close the connection in the handler thread to avoid race condition. Test failed shutdown --- ycmd/completers/java/java_completer.py | 11 +++-- .../language_server_completer.py | 13 +++-- ycmd/tests/java/server_management_test.py | 49 +++++++++++++++++++ 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 5941c39f45..203fbd668f 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -393,6 +393,9 @@ def _StopServer( self ): with self._server_state_mutex: # We don't use utils.CloseStandardStreams, because the stdin/out is # connected to our server connector. Just close stderr. + # + # The other streams are closed by the LanguageServerConnection when we + # call Close. if self._server_handle and self._server_handle.stderr: self._server_handle.stderr.close() @@ -416,14 +419,14 @@ def _StopServerCleanly( self ): _logger.info( 'Stopping java server with PID {0}'.format( self._server_handle.pid ) ) - self.ShutdownServer() - try: + self.ShutdownServer() + utils.WaitUntilProcessIsTerminated( self._server_handle, timeout = 5 ) if self._connection: - self._connection.join() + self._connection.Close() _logger.info( 'jdt.ls Language server stopped' ) except Exception: @@ -442,7 +445,7 @@ def _StopServerForcefully( self ): timeout = 5 ) if self._connection: - self._connection.join() + self._connection.Close() _logger.info( 'jdt.ls Language server killed' ) except Exception: diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index d8cf298f4d..978d69b39b 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -162,7 +162,7 @@ class LanguageServerConnection( threading.Thread ): Implementations of this class are required to provide the following methods: - _TryServerConnectionBlocking: Connect to the server and return when the connection is established - - _Close: Close any sockets or channels prior to the thread exit + - Close: Close any sockets or channels prior to the thread exit - _Write: Write some data to the server - _Read: Read some data from the server, blocking until some data is available @@ -179,8 +179,8 @@ class LanguageServerConnection( threading.Thread ): - Call stop() prior to shutting down the downstream server (see LanguageServerCompleter.ShutdownServer to do that part) - - Call join() after closing down the downstream server to wait for this - thread to exit + - Call Close() to close any remaining streams. Do this in a request thread. + DO NOT CALL THIS FROM THE DISPATCH THREAD. Footnote: Why does this interface exist? @@ -247,14 +247,17 @@ def run( self ): _logger.debug( 'Connection was closed cleanly' ) - self._Close() - def stop( self ): # Note lowercase stop() to match threading.Thread.start() self._stop_event.set() + def Close( self ): + self.join() + self._Close() + + def IsStopped( self ): return self._stop_event.is_set() diff --git a/ycmd/tests/java/server_management_test.py b/ycmd/tests/java/server_management_test.py index a3ade4e875..64c62be36f 100644 --- a/ycmd/tests/java/server_management_test.py +++ b/ycmd/tests/java/server_management_test.py @@ -23,6 +23,8 @@ # Not installing aliases from python-future; it's unreliable and slow. from builtins import * # noqa +from mock import patch + from hamcrest import ( assert_that, contains, has_entries, @@ -137,3 +139,50 @@ def Subcommands_ProjectDetection_GradleParent( app ): request_data = BuildRequest( filetype = 'java' ) assert_that( app.post_json( '/debug_info', request_data ).json, _ProjectDirectoryMatcher( project ) ) + + +@IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project' ) ) +@patch( 'ycmd.completers.java.java_completer.JavaCompleter.ShutdownServer', + side_effect = AssertionError ) +def CloseServer_Unclean_test( app, + stop_server_cleanly ): + WaitUntilCompleterServerReady( app ) + + filepath = PathToTestFile( 'simple_maven_project', + 'src', + 'main', + 'java', + 'com', + 'test', + 'TestFactory.java' ) + + app.post_json( + '/event_notification', + BuildRequest( + filepath = filepath, + filetype = 'java', + working_dir = PathToTestFile( 'simple_eclipse_project' ), + event_name = 'FileReadyToParse', + ) + ) + + WaitUntilCompleterServerReady( app ) + + app.post_json( + '/run_completer_command', + BuildRequest( + filepath = filepath, + filetype = 'java', + working_dir = PathToTestFile( 'simple_eclipse_project' ), + command_arguments = [ 'StopServer' ], + ), + ) + + request_data = BuildRequest( filetype = 'java' ) + assert_that( app.post_json( '/debug_info', request_data ).json, + has_entry( + 'completer', + has_entry( 'servers', contains( + has_entry( 'is_running', False ) + ) ) + ) ) From 8c2f19259c47df80d990b6d451af6ef6acf8a0d2 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Fri, 20 Oct 2017 00:37:22 +0100 Subject: [PATCH 43/51] Improve test coverage Increase the timeout for the shutdown tests for our CI environment Test receive_messages handler without completer support Fix project detection tests Additional tests for things we can't easily test in the CI with real server --- ycmd/completers/java/java_completer.py | 4 +- ycmd/tests/clang/__init__.py | 18 +---- ycmd/tests/java/java_completer_test.py | 69 +++++++++++++++++++ ycmd/tests/java/server_management_test.py | 56 +++++++++++++-- .../testdata/simple_gradle_project/.gitignore | 5 +- .../simple_gradle_project/build.gradle | 1 + ycmd/tests/misc_handlers_test.py | 14 ++++ ycmd/tests/shutdown_test.py | 16 +++-- ycmd/tests/test_utils.py | 14 ++++ ycmd/utils.py | 8 +++ 10 files changed, 175 insertions(+), 30 deletions(-) create mode 100644 ycmd/tests/java/java_completer_test.py create mode 100644 ycmd/tests/java/testdata/simple_gradle_project/build.gradle diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 203fbd668f..51f745af09 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -423,7 +423,7 @@ def _StopServerCleanly( self ): self.ShutdownServer() utils.WaitUntilProcessIsTerminated( self._server_handle, - timeout = 5 ) + timeout = 15 ) if self._connection: self._connection.Close() @@ -442,7 +442,7 @@ def _StopServerForcefully( self ): try: utils.WaitUntilProcessIsTerminated( self._server_handle, - timeout = 5 ) + timeout = 15 ) if self._connection: self._connection.Close() diff --git a/ycmd/tests/clang/__init__.py b/ycmd/tests/clang/__init__.py index 33cac725cf..c16cb787fc 100644 --- a/ycmd/tests/clang/__init__.py +++ b/ycmd/tests/clang/__init__.py @@ -24,11 +24,11 @@ import functools import os -import tempfile import contextlib import json -import shutil + +from ycmd.tests.test_utils import TemporaryTestDir as TemporaryClangTestDir # noqa from ycmd.utils import ToUnicode from ycmd.tests.test_utils import ClearCompletionsCache, IsolatedApp, SetUpApp @@ -91,20 +91,6 @@ def Wrapper( *args, **kwargs ): return Decorator -@contextlib.contextmanager -def TemporaryClangTestDir(): - """Context manager to execute a test with a temporary workspace area. The - workspace is deleted upon completion of the test. This is useful particularly - for testing compilation databases, as they require actual absolute paths. - See also |TemporaryClangProject|. The context manager yields the path of the - temporary directory.""" - tmp_dir = tempfile.mkdtemp() - try: - yield tmp_dir - finally: - shutil.rmtree( tmp_dir ) - - @contextlib.contextmanager def TemporaryClangProject( tmp_dir, compile_commands ): """Context manager to create a compilation database in a directory and delete diff --git a/ycmd/tests/java/java_completer_test.py b/ycmd/tests/java/java_completer_test.py new file mode 100644 index 0000000000..d052270366 --- /dev/null +++ b/ycmd/tests/java/java_completer_test.py @@ -0,0 +1,69 @@ +# Copyright (C) 2017 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +import os + +from hamcrest import assert_that, equal_to, is_not +from mock import patch + +from ycmd.completers.java import java_completer + + +def ShouldEnableJavaCompleter_NoJava_test(): + orig_java_path = java_completer.PATH_TO_JAVA + try: + java_completer.PATH_TO_JAVA = '' + assert_that( java_completer.ShouldEnableJavaCompleter(), equal_to( False ) ) + finally: + java_completer.PATH_TO_JAVA = orig_java_path + + +def ShouldEnableJavaCompleter_NotInstalled_test(): + orig_language_server_home = java_completer.LANGUAGE_SERVER_HOME + try: + java_completer.LANGUAGE_SERVER_HOME = '' + assert_that( java_completer.ShouldEnableJavaCompleter(), equal_to( False ) ) + finally: + java_completer.LANGUAGE_SERVER_HOME = orig_language_server_home + + +@patch( 'glob.glob', return_value = [] ) +def ShouldEnableJavaCompleter_NoLauncherJar_test( glob ): + assert_that( java_completer.ShouldEnableJavaCompleter(), equal_to( False ) ) + glob.assert_called() + + +def WorkspaceDirForProject_HashProjectDir_test(): + assert_that( + java_completer._WorkspaceDirForProject( os.getcwd(), False ), + equal_to( java_completer._WorkspaceDirForProject( os.getcwd(), False ) ) + ) + + +def WorkspaceDirForProject_UniqueDir_test(): + assert_that( + java_completer._WorkspaceDirForProject( os.getcwd(), True ), + is_not( equal_to( java_completer._WorkspaceDirForProject( os.getcwd(), + True ) ) ) + ) diff --git a/ycmd/tests/java/server_management_test.py b/ycmd/tests/java/server_management_test.py index 64c62be36f..7bbb350f06 100644 --- a/ycmd/tests/java/server_management_test.py +++ b/ycmd/tests/java/server_management_test.py @@ -23,8 +23,10 @@ # Not installing aliases from python-future; it's unreliable and slow. from builtins import * # noqa -from mock import patch +import functools +import os +from mock import patch from hamcrest import ( assert_that, contains, has_entries, @@ -33,7 +35,8 @@ from ycmd.tests.java import ( PathToTestFile, IsolatedYcmdInDirectory, WaitUntilCompleterServerReady ) -from ycmd.tests.test_utils import BuildRequest +from ycmd.tests.test_utils import BuildRequest, TemporaryTestDir +from ycmd import utils def _ProjectDirectoryMatcher( project_directory ): @@ -50,6 +53,28 @@ def _ProjectDirectoryMatcher( project_directory ): ) +def TidyJDTProjectFiles( dir_name ): + """Defines a test decorator which deletes the .project etc. files that are + created by the jdt.ls server when it detects a project. This ensures the tests + actually check that jdt.ls detects the project.""" + def decorator( test ): + @functools.wraps( test ) + def Wrapper( *args, **kwargs ): + utils.RemoveIfExists( os.path.join( dir_name, '.project' ) ) + utils.RemoveIfExists( os.path.join( dir_name, '.classpath' ) ) + utils.RemoveDirIfExists( os.path.join( dir_name, '.settings' ) ) + try: + test( *args, **kwargs ) + finally: + utils.RemoveIfExists( os.path.join( dir_name, '.project' ) ) + utils.RemoveIfExists( os.path.join( dir_name, '.classpath' ) ) + utils.RemoveDirIfExists( os.path.join( dir_name, '.settings' ) ) + + return Wrapper + + return decorator + + @IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project' ) ) def Subcommands_RestartServer_test( app ): WaitUntilCompleterServerReady( app ) @@ -100,7 +125,7 @@ def Subcommands_RestartServer_test( app ): @IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project', 'src' ) ) -def Subcommands_ProjectDetection_EclipseParent( app ): +def Subcommands_ProjectDetection_EclipseParent_test( app ): WaitUntilCompleterServerReady( app ) project = PathToTestFile( 'simple_eclipse_project' ) @@ -111,11 +136,14 @@ def Subcommands_ProjectDetection_EclipseParent( app ): _ProjectDirectoryMatcher( project ) ) +@TidyJDTProjectFiles( PathToTestFile( 'simple_maven_project' ) ) @IsolatedYcmdInDirectory( PathToTestFile( 'simple_maven_project', 'src', + 'main', 'java', + 'com', 'test' ) ) -def Subcommands_ProjectDetection_MavenParent( app ): +def Subcommands_ProjectDetection_MavenParent_test( app ): WaitUntilCompleterServerReady( app ) project = PathToTestFile( 'simple_maven_project' ) @@ -126,11 +154,14 @@ def Subcommands_ProjectDetection_MavenParent( app ): _ProjectDirectoryMatcher( project ) ) +@TidyJDTProjectFiles( PathToTestFile( 'simple_maven_project' ) ) @IsolatedYcmdInDirectory( PathToTestFile( 'simple_gradle_project', 'src', + 'main', 'java', + 'com', 'test' ) ) -def Subcommands_ProjectDetection_GradleParent( app ): +def Subcommands_ProjectDetection_GradleParent_test( app ): WaitUntilCompleterServerReady( app ) project = PathToTestFile( 'simple_gradle_project' ) @@ -141,6 +172,21 @@ def Subcommands_ProjectDetection_GradleParent( app ): _ProjectDirectoryMatcher( project ) ) +def Subcommands_ProjectDetection_NoParent_test(): + with TemporaryTestDir() as tmp_dir: + + @IsolatedYcmdInDirectory( tmp_dir ) + def Test( app ): + WaitUntilCompleterServerReady( app ) + + # Run the debug info to check that we have the correct project dir (cwd) + request_data = BuildRequest( filetype = 'java' ) + assert_that( app.post_json( '/debug_info', request_data ).json, + _ProjectDirectoryMatcher( os.path.realpath( tmp_dir ) ) ) + + yield Test + + @IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project' ) ) @patch( 'ycmd.completers.java.java_completer.JavaCompleter.ShutdownServer', side_effect = AssertionError ) diff --git a/ycmd/tests/java/testdata/simple_gradle_project/.gitignore b/ycmd/tests/java/testdata/simple_gradle_project/.gitignore index baa442ba32..b1b47b74e7 100644 --- a/ycmd/tests/java/testdata/simple_gradle_project/.gitignore +++ b/ycmd/tests/java/testdata/simple_gradle_project/.gitignore @@ -1,5 +1,4 @@ -.gradle -build/ +.gradle/ .classpath +.settings/ .project -.settings diff --git a/ycmd/tests/java/testdata/simple_gradle_project/build.gradle b/ycmd/tests/java/testdata/simple_gradle_project/build.gradle new file mode 100644 index 0000000000..bbfeb03c22 --- /dev/null +++ b/ycmd/tests/java/testdata/simple_gradle_project/build.gradle @@ -0,0 +1 @@ +apply plugin: 'java' diff --git a/ycmd/tests/misc_handlers_test.py b/ycmd/tests/misc_handlers_test.py index bd301fa4c8..c963ada380 100644 --- a/ycmd/tests/misc_handlers_test.py +++ b/ycmd/tests/misc_handlers_test.py @@ -197,3 +197,17 @@ def MiscHandlers_DebugInfo_ExtraConfFoundButNotLoaded_test( app ): 'completer': None } ) ) + + +@IsolatedYcmd() +def MiscHandlers_ReceiveMessages_NoCompleter_test( app ): + request_data = BuildRequest() + assert_that( app.post_json( '/receive_messages', request_data ).json, + equal_to( False ) ) + + +@IsolatedYcmd() +def MiscHandlers_ReceiveMessages_NotSupportedByCompleter_test( app ): + request_data = BuildRequest( filetype = 'python' ) + assert_that( app.post_json( '/receive_messages', request_data ).json, + equal_to( False ) ) diff --git a/ycmd/tests/shutdown_test.py b/ycmd/tests/shutdown_test.py index 7916022254..230ce33da7 100644 --- a/ycmd/tests/shutdown_test.py +++ b/ycmd/tests/shutdown_test.py @@ -26,6 +26,14 @@ from ycmd.tests.client_test import Client_test +# Time to wait for all the servers to shutdown. Tweak for the CI environment. +# +# NOTE: The timeout is 1 minute. That is a long time, but the java sub-server +# (jdt.ls) takes a _long time_ to finally actually shut down. This is because it +# is based on eclipse, which must do whatever eclipse must do when it shuts down +# its workspace. +SUBSERVER_SHUTDOWN_TIMEOUT = 60 + class Shutdown_test( Client_test ): @@ -37,7 +45,7 @@ def FromHandlerWithoutSubserver_test( self ): response = self.PostRequest( 'shutdown' ) self.AssertResponse( response ) assert_that( response.json(), equal_to( True ) ) - self.AssertServersShutDown( timeout = 5 ) + self.AssertServersShutDown( timeout = SUBSERVER_SHUTDOWN_TIMEOUT ) self.AssertLogfilesAreRemoved() @@ -59,7 +67,7 @@ def FromHandlerWithSubservers_test( self ): response = self.PostRequest( 'shutdown' ) self.AssertResponse( response ) assert_that( response.json(), equal_to( True ) ) - self.AssertServersShutDown( timeout = 5 ) + self.AssertServersShutDown( timeout = SUBSERVER_SHUTDOWN_TIMEOUT ) self.AssertLogfilesAreRemoved() @@ -68,7 +76,7 @@ def FromWatchdogWithoutSubserver_test( self ): self.Start( idle_suicide_seconds = 2, check_interval_seconds = 1 ) self.AssertServersAreRunning() - self.AssertServersShutDown( timeout = 5 ) + self.AssertServersShutDown( timeout = SUBSERVER_SHUTDOWN_TIMEOUT ) self.AssertLogfilesAreRemoved() @@ -87,5 +95,5 @@ def FromWatchdogWithSubservers_test( self ): self.StartSubserverForFiletype( filetype ) self.AssertServersAreRunning() - self.AssertServersShutDown( timeout = 15 ) + self.AssertServersShutDown( timeout = SUBSERVER_SHUTDOWN_TIMEOUT + 10 ) self.AssertLogfilesAreRemoved() diff --git a/ycmd/tests/test_utils.py b/ycmd/tests/test_utils.py index b0b5db3c32..8315000978 100644 --- a/ycmd/tests/test_utils.py +++ b/ycmd/tests/test_utils.py @@ -36,6 +36,7 @@ import tempfile import time import stat +import shutil from ycmd import extra_conf_store, handlers, user_options_store from ycmd.completers.completer import Completer @@ -305,3 +306,16 @@ def Wrapper( *args, **kwargs ): return Wrapper return decorator + + +@contextlib.contextmanager +def TemporaryTestDir(): + """Context manager to execute a test with a temporary workspace area. The + workspace is deleted upon completion of the test. This is useful particularly + for testing project detection (e.g. compilation databases, etc.), by ensuring + that the directory is empty and not affected by the user's filesystem.""" + tmp_dir = tempfile.mkdtemp() + try: + yield tmp_dir + finally: + shutil.rmtree( tmp_dir ) diff --git a/ycmd/utils.py b/ycmd/utils.py index df8c4153db..7600b7b20c 100644 --- a/ycmd/utils.py +++ b/ycmd/utils.py @@ -202,6 +202,14 @@ def GetUnusedLocalhostPort(): return port +def RemoveDirIfExists( dirname ): + try: + import shutil + shutil.rmtree( dirname ) + except OSError: + pass + + def RemoveIfExists( filename ): try: os.remove( filename ) From dd6799d983f2b77114f134904907614968d938e8 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sun, 22 Oct 2017 23:23:38 +0100 Subject: [PATCH 44/51] More refactoring and fixes after review Rename lsapi to language_server_protocol Tidy build.py Better names for subcommand handlers Use snake case for variables Clarify the threading setup for the language server connection Despatch -> Dispatch Better name for _RefreshFiles Refactor large completion item methods Update API documentation for long poll, and fix the nonblocking pending messages handler rename textEdit to text_edit for coding style Tracing shutdown to help post mortem diagnostics Update copyright year Move all exceptions to he top of the module Public or API methods are not private. All other methods are private More refactoring of completion item resolution code Compute whether or not to resolve completions statically Remove bare except Mention jdt.ls in the ycmd README Fix up comment in example client Typos and stylistic changes Log when we get errors Don't include the optional header Remove import hack laziness for TemporaryClangTestDir Remove extraneous parentheses More trivial code tidy Use correct GoTo response and errors Don't convert the id to string more than once Clarify client capabilitites Add INFORMATION and HINT to the ycmd API rather than mapping it Use 'Unkonwn type' like clang completer --- README.md | 6 +- build.py | 9 +- examples/example_client.py | 4 +- ycmd/completers/completer.py | 29 +- ycmd/completers/java/hook.py | 2 +- ycmd/completers/java/java_completer.py | 51 +- .../language_server_completer.py | 736 ++++++++++-------- .../{lsapi.py => language_server_protocol.py} | 20 +- ycmd/handlers.py | 5 +- ycmd/tests/clang/__init__.py | 4 +- ycmd/tests/clang/debug_info_test.py | 8 +- ycmd/tests/clang/flags_test.py | 20 +- ycmd/tests/java/diagnostics_test.py | 5 +- ycmd/tests/java/get_completions_test.py | 14 +- ycmd/tests/java/server_management_test.py | 2 +- ycmd/tests/java/subcommands_test.py | 20 +- 16 files changed, 512 insertions(+), 423 deletions(-) rename ycmd/completers/language_server/{lsapi.py => language_server_protocol.py} (93%) diff --git a/README.md b/README.md index ceb3940881..c412da5e25 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,9 @@ There are also several semantic engines in YCM. There's a libclang-based completer that provides semantic completion for C-family languages. There's also a Jedi-based completer for semantic completion for Python, an OmniSharp-based completer for C#, a [Gocode][gocode]-based completer for Go -(using [Godef][godef] for jumping to definitions), and a TSServer-based -completer for TypeScript. More will be added with time. +(using [Godef][godef] for jumping to definitions), a TSServer-based completer +for TypeScript and a [jdt.ls][jdtls]-based server for Java. More will be added +with time. There are also other completion engines, like the filepath completer (part of the identifier completer). @@ -339,3 +340,4 @@ This software is licensed under the [GPL v3 license][gpl]. [vscode-you-complete-me]: https://marketplace.visualstudio.com/items?itemName=RichardHe.you-complete-me [gycm]: https://github.com/jakeanq/gycm [nano-ycmd]: https://github.com/orsonteodoro/nano-ycmd +[jdtls]: https://github.com/eclipse/eclipse.jdt.ls diff --git a/build.py b/build.py index 9c1ba95986..08c19ff206 100755 --- a/build.py +++ b/build.py @@ -545,17 +545,14 @@ def EnableJavaCompleter(): 'third_party', 'eclipse.jdt.ls' ) ) - if OnWindows(): - mvnw = 'mvnw.cmd' - else: - mvnw = './mvnw' + mvnw = 'mvnw.cmd' if OnWindows() else './mvnw' # Maven actually just straight up sucks. There is seemingly no way to do # working, reliable incremental builds. It also takes _forever_ doing things # that you _don't want it to do_, like downloading the internet. # Alas, I'm not aware of a better way, and these are the instructions provided - # by the people that made JDT language server, so we waste the user's time - # (somewhat) unnecessarily. + # by the people that made jdt.ls, so we waste the user's time (somewhat) + # unnecessarily. CheckCall( [ mvnw, 'clean', 'package' ] ) diff --git a/examples/example_client.py b/examples/example_client.py index c61eaf49c7..4a1c573638 100755 --- a/examples/example_client.py +++ b/examples/example_client.py @@ -484,7 +484,7 @@ def JavaMessages( server ): test_filename = 'some_java.java', filetype = 'java' ) - # Send the long poll 10 times (only the first N will return any useful + # Send the long poll 5 times (only the first N will return any useful # messages) for i in range(1, 6): server.ReceiveMessages( test_filename = 'some_java.java', @@ -503,7 +503,7 @@ def JavaMessages( server ): test_filename = 'some_java.java', filetype = 'java' ) - # Send the long poll 10 times (only the first N will return any useful + # Send the long poll 5 times (only the first N will return any useful # messages) for i in range(1, 6): server.ReceiveMessages( test_filename = 'some_java.java', diff --git a/ycmd/completers/completer.py b/ycmd/completers/completer.py index 8b51b23486..4e1d3708ea 100644 --- a/ycmd/completers/completer.py +++ b/ycmd/completers/completer.py @@ -31,6 +31,9 @@ NO_USER_COMMANDS = 'This completer does not define any commands.' +# Number of seconds to block before returning True in PollForMessages +MESSAGE_POLL_TIMEOUT = 10 + class Completer( with_metaclass( abc.ABCMeta, object ) ): """A base class for all Completers in YCM. @@ -145,9 +148,24 @@ class Completer( with_metaclass( abc.ABCMeta, object ) ): Override the Shutdown() member function if your Completer subclass needs to do custom cleanup logic on server shutdown. + If the completer server provides unsolicited messages, such as used in + Language Server Protocol, then you can override the PollForMessagesInner + method. This method is called by the client in the "long poll" fashion to + receive unsolicited messages. The method should block until a message is + available and return a message response when one becomes available, or True if + no message becomes available before the timeout. The return value must be one + of the following: + - a list of messages to send to the client + - True if a timeout occurred, and the poll should be restarted + - False if an error occurred, and no further polling should be attempted + If your completer uses an external server process, then it can be useful to implement the ServerIsHealthy member function to handle the /healthy request. - This is very useful for the test suite.""" + This is very useful for the test suite. + + If your server is based on the Language Server Protocol (LSP), take a look at + language_server/language_server_completer, which provides most of the work + necessary to get a LSP-based completion engine up and running.""" def __init__( self, user_options ): self.user_options = user_options @@ -379,11 +397,14 @@ def ServerIsHealthy( self ): def PollForMessages( self, request_data ): - return self.PollForMessagesInner( request_data ) + return self.PollForMessagesInner( request_data, MESSAGE_POLL_TIMEOUT ) - def PollForMessagesInner( self, request_data ): - # Most completers don't implement this + def PollForMessagesInner( self, request_data, timeout ): + # Most completers don't implement this. It's only required where unsolicited + # messages or diagnostics are supported, such as in the Language Server + # Protocol. As such, the default implementation just returns False, meaning + # that unsolicited messages are not supported for this filetype. return False diff --git a/ycmd/completers/java/hook.py b/ycmd/completers/java/hook.py index 6af9c47a7e..ab80376ee5 100644 --- a/ycmd/completers/java/hook.py +++ b/ycmd/completers/java/hook.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 ycmd contributors +# Copyright (C) 2017 ycmd contributors # # This file is part of ycmd. # diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 51f745af09..0fba0e7b9a 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 ycmd contributors +# Copyright (C) 2017 ycmd contributors # # This file is part of ycmd. # @@ -73,9 +73,9 @@ # # Cons: # - A little more complexity (we hash the project path to create the workspace -# dir) +# directory) # - It breaks our tests which expect the logs to be deleted -# - It can lead to multiple jdt.js instances using the same workspace (BAD) +# - It can lead to multiple jdt.ls instances using the same workspace (BAD) # - It breaks our tests which do exactly that # # So: @@ -171,7 +171,6 @@ def __init__( self, user_options ): # Used to ensure that starting/stopping of the server is synchronized self._server_state_mutex = threading.RLock() - with self._server_state_mutex: self._connection = None self._server_handle = None @@ -181,7 +180,7 @@ def __init__( self, user_options ): try : # When we start the server initially, we don't have the request data, so - # we use the ycmd working directory. The RestartServer subcommand uses + # we use the ycmd working directory. The RestartServer sub-command uses # the client's working directory if it is supplied. # # FIXME: We could start the server in the FileReadyToParse event, though @@ -214,11 +213,12 @@ def GetSubcommandsMap( self ): lambda self, request_data, args: self.GoToReferences( request_data ) ), 'FixIt': ( - lambda self, request_data, args: self.CodeAction( request_data, - args ) + lambda self, request_data, args: self.GetCodeActions( request_data, + args ) ), 'RefactorRename': ( - lambda self, request_data, args: self.Rename( request_data, args ) + lambda self, request_data, args: self.RefactorRename( request_data, + args ) ), # Handled by us @@ -352,11 +352,7 @@ def _StartServer( self, working_dir=None ): _logger.debug( 'Starting java-server with the following command: ' '{0}'.format( ' '.join( command ) ) ) - LOGFILE_FORMAT = 'jdt.ls_{pid}_{std}_' - - self._server_stderr = utils.CreateLogfile( - LOGFILE_FORMAT.format( pid = os.getpid(), std = 'stderr' ) ) - + self._server_stderr = utils.CreateLogfile( 'jdt.ls_stderr_' ) with utils.OpenForStdHandle( self._server_stderr ) as stderr: self._server_handle = utils.SafePopen( command, stdin = PIPE, @@ -376,7 +372,7 @@ def _StartServer( self, working_dir=None ): self.GetDefaultNotificationHandler() ) ) - self._connection.start() + self._connection.Start() try: self._connection.AwaitServerConnection() @@ -386,11 +382,12 @@ def _StartServer( self, working_dir=None ): self._StopServer() return - self.SendInitialise() + self.SendInitialize() def _StopServer( self ): with self._server_state_mutex: + _logger.info( 'Shutting down jdt.ls...' ) # We don't use utils.CloseStandardStreams, because the stdin/out is # connected to our server connector. Just close stderr. # @@ -401,7 +398,7 @@ def _StopServer( self ): # Tell the connection to expect the server to disconnect if self._connection: - self._connection.stop() + self._connection.Stop() # Tell the server to exit using the shutdown request. self._StopServerCleanly() @@ -412,6 +409,8 @@ def _StopServer( self ): # Tidy up our internal state self._CleanUp() + _logger.info( 'Shutting down jdt.ls...complete.' ) + def _StopServerCleanly( self ): # Try and shutdown cleanly @@ -452,7 +451,7 @@ def _StopServerForcefully( self ): _logger.exception( 'Error while killing jdt.ls server' ) - def _HandleNotificationInPollThread( self, notification ): + def HandleNotificationInPollThread( self, notification ): if notification[ 'method' ] == 'language/status': message_type = notification[ 'params' ][ 'type' ] @@ -462,16 +461,16 @@ def _HandleNotificationInPollThread( self, notification ): self._server_init_status = notification[ 'params' ][ 'message' ] - super( JavaCompleter, self )._HandleNotificationInPollThread( notification ) + super( JavaCompleter, self ).HandleNotificationInPollThread( notification ) - def _ConvertNotificationToMessage( self, request_data, notification ): + def ConvertNotificationToMessage( self, request_data, notification ): if notification[ 'method' ] == 'language/status': message = notification[ 'params' ][ 'message' ] return responses.BuildDisplayMessageResponse( 'Initializing Java completer: {0}'.format( message ) ) - return super( JavaCompleter, self )._ConvertNotificationToMessage( + return super( JavaCompleter, self ).ConvertNotificationToMessage( request_data, notification ) @@ -481,12 +480,12 @@ def GetType( self, request_data ): if isinstance( hover_response, list ): if not len( hover_response ): - raise RuntimeError( 'No information' ) + raise RuntimeError( 'Unknown type' ) try: get_type_java = hover_response[ 0 ][ 'value' ] - except( TypeError ): - raise RuntimeError( 'No information' ) + except TypeError: + raise RuntimeError( 'Unknown type' ) else: get_type_java = hover_response @@ -498,7 +497,7 @@ def GetDoc( self, request_data ): if isinstance( hover_response, list ): if not len( hover_response ): - raise RuntimeError( 'No information' ) + raise RuntimeError( NO_DOCUMENTATION_MESSAGE ) get_doc_java = '' for docstring in hover_response: @@ -510,9 +509,9 @@ def GetDoc( self, request_data ): get_doc_java = get_doc_java.rstrip() if not get_doc_java: - raise ValueError( NO_DOCUMENTATION_MESSAGE ) + raise RuntimeError( NO_DOCUMENTATION_MESSAGE ) - return responses.BuildDisplayMessageResponse( get_doc_java.rstrip() ) + return responses.BuildDetailedInfoResponse( get_doc_java ) def HandleServerCommand( 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 978d69b39b..a404f5b816 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 ycmd contributors +# Copyright (C) 2017 ycmd contributors # # This file is part of ycmd. # @@ -35,24 +35,17 @@ from ycmd import utils from ycmd import responses -from ycmd.completers.language_server import lsapi +from ycmd.completers.language_server import language_server_protocol as lsp _logger = logging.getLogger( __name__ ) SERVER_LOG_PREFIX = 'Server reported: ' +# All timeout values are in seconds REQUEST_TIMEOUT_COMPLETION = 5 REQUEST_TIMEOUT_INITIALISE = 30 REQUEST_TIMEOUT_COMMAND = 30 CONNECTION_TIMEOUT = 5 -MESSAGE_POLL_TIMEOUT = 10 - -SEVERITY_TO_YCM_SEVERITY = { - 'Error': 'ERROR', - 'Warning': 'WARNING', - 'Information': 'WARNING', - 'Hint': 'WARNING' -} class ResponseTimeoutException( Exception ): @@ -62,7 +55,7 @@ class ResponseTimeoutException( Exception ): class ResponseAbortedException( Exception ): - """Raised by LanguageServerConnection if a request is cancelled due to the + """Raised by LanguageServerConnection if a request is canceled due to the server shutting down.""" pass @@ -78,11 +71,23 @@ class IncompatibleCompletionException( Exception ): pass +class LanguageServerConnectionTimeout( Exception ): + """Raised by LanguageServerConnection if the connection to the server is not + established with the specified timeout.""" + pass + + +class LanguageServerConnectionStopped( Exception ): + """Internal exception raised by LanguageServerConnection when the server is + successfully shut down according to user request.""" + pass + + class Response( object ): """Represents a blocking pending request. LanguageServerCompleter handles create an instance of this class for each - request that expects a response and wait for its response synchonously by + request that expects a response and wait for its response synchronously by calling |AwaitResponse|. The LanguageServerConnection message pump thread calls |ResponseReceived| when @@ -116,7 +121,7 @@ def Abort( self ): def AwaitResponse( self, timeout ): - """Called by clients to wait syncronously for either a response to be + """Called by clients to wait synchronously for either a response to be received of for |timeout| seconds to have passed. Returns the message, or: - throws ResponseFailedException if the request fails @@ -139,88 +144,102 @@ def AwaitResponse( self, timeout ): return self._message -class LanguageServerConnectionTimeout( Exception ): - """Raised by LanguageServerConnection if the connection to the server is not - established with the specified timeout.""" - pass +class LanguageServerConnection( threading.Thread ): + """ + Abstract language server communication object. + This connection runs as a thread and is generally only used directly by + LanguageServerCompleter, but is instantiated, started and stopped by + concrete LanguageServerCompleter implementations. -class LanguageServerConnectionStopped( Exception ): - """Internal exception raised by LanguageServerConnection when the server is - successfully shut down according to user request.""" - pass + Implementations of this class are required to provide the following methods: + - TryServerConnectionBlocking: Connect to the server and return when the + connection is established + - Shutdown: Close any sockets or channels prior to the thread exit + - WriteData: Write some data to the server + - ReadData: Read some data from the server, blocking until some data is + available + Threads: -class LanguageServerConnection( threading.Thread ): - """ - Abstract language server communication object. + LSP is by its nature an asynchronous protocol. There are request-reply like + requests and unsolicited notifications. Receipt of the latter is mandatory, + so we cannot rely on there being a bottle thread executing a client request. - This connection runs as a thread and is generally only used directly by - LanguageServerCompleter, but is instantiated, startd and stopped by Concrete - LanguageServerCompleter implementations. + So we need a message pump and dispatch thread. This is actually the + LanguageServerConnection, which implements Thread. It's main method simply + listens on the socket/stream and dispatches complete messages to the + LanguageServerCompleter. It does this: - Implementations of this class are required to provide the following methods: - - _TryServerConnectionBlocking: Connect to the server and return when the - connection is established - - Close: Close any sockets or channels prior to the thread exit - - _Write: Write some data to the server - - _Read: Read some data from the server, blocking until some data is - available + - For requests: Using python event objects, wrapped in the Response class + - For notifications: via a synchronized Queue. - Using this class in concrete LanguageServerCompleter implementations: + NOTE: Some handling is done in the dispatch thread. There are certain + notifications which we have to handle when we get them, such as: - Startup + - Initialization messages + - Diagnostics - - Call start() and AwaitServerConnection() - - AwaitServerConnection() throws LanguageServerConnectionTimeout if the - server fails to connect in a reasonable time. + In these cases, we allow some code to be executed inline within the dispatch + thread, as there is no other thread guaranteed to execute. These are handled + by callback functions and mutexes. - Shutdown + Using this class in concrete LanguageServerCompleter implementations: - - Call stop() prior to shutting down the downstream server (see - LanguageServerCompleter.ShutdownServer to do that part) - - Call Close() to close any remaining streams. Do this in a request thread. - DO NOT CALL THIS FROM THE DISPATCH THREAD. + Startup - Footnote: Why does this interface exist? + - Call start() and AwaitServerConnection() + - AwaitServerConnection() throws LanguageServerConnectionTimeout if the + server fails to connect in a reasonable time. - Language servers are at liberty to provide their communication interface - over any transport. Typically, this is either stdio or a socket (though some - servers require multiple sockets). This interface abstracts the - implementation detail of the communication from the transport, allowing - concrete completers to choose the right transport according to the - downstream server (i.e. whatever works best). + Shutdown - If in doubt, use the StandardIOLanguageServerConnection as that is the - simplest. Socket-based connections often require the server to connect back - to us, which can lead to complexity and possibly blocking. + - Call stop() prior to shutting down the downstream server (see + LanguageServerCompleter.ShutdownServer to do that part) + - Call Close() to close any remaining streams. Do this in a request thread. + DO NOT CALL THIS FROM THE DISPATCH THREAD. That is, Close() must not be + called from a callback supplied to GetResponseAsync, or in any callback or + method with a name like "*InPollThread". The result would be a deadlock. + + Footnote: Why does this interface exist? + + Language servers are at liberty to provide their communication interface + over any transport. Typically, this is either stdio or a socket (though some + servers require multiple sockets). This interface abstracts the + implementation detail of the communication from the transport, allowing + concrete completers to choose the right transport according to the + downstream server (i.e. Whatever works best). + + If in doubt, use the StandardIOLanguageServerConnection as that is the + simplest. Socket-based connections often require the server to connect back + to us, which can lead to complexity and possibly blocking. """ @abc.abstractmethod - def _TryServerConnectionBlocking( self ): + def TryServerConnectionBlocking( self ): pass @abc.abstractmethod - def _Close( self ): + def Shutdown( self ): pass @abc.abstractmethod - def _Write( self, data ): + def WriteData( self, data ): pass @abc.abstractmethod - def _Read( self, size=-1 ): + def ReadData( self, size=-1 ): pass def __init__( self, notification_handler = None ): super( LanguageServerConnection, self ).__init__() - self._lastId = 0 + self._last_id = 0 self._responses = {} - self._responseMutex = threading.Lock() + self._response_mutex = threading.Lock() self._notifications = queue.Queue() self._connection_event = threading.Event() @@ -233,29 +252,39 @@ def run( self ): # Wait for the connection to fully establish (this runs in the thread # context, so we block until a connection is received or there is a # timeout, which throws an exception) - self._TryServerConnectionBlocking() + self.TryServerConnectionBlocking() self._connection_event.set() - # Blocking loop which reads whole messages and calls _DespatchMessage + # Blocking loop which reads whole messages and calls _DispatchMessage self._ReadMessages( ) except LanguageServerConnectionStopped: # Abort any outstanding requests - with self._responseMutex: + with self._response_mutex: for _, response in iteritems( self._responses ): response.Abort() self._responses.clear() _logger.debug( 'Connection was closed cleanly' ) + except Exception: + _logger.exception( 'The language server communication channel closed ' + 'unexpectedly. Issue a RestartServer command to ' + 'recover.' ) + - def stop( self ): - # Note lowercase stop() to match threading.Thread.start() + def Start( self ): + # Wraps the fact that this class inherits (privately, in a sense) from + # Thread. + self.start() + + + def Stop( self ): self._stop_event.set() def Close( self ): self.join() - self._Close() + self.Shutdown() def IsStopped( self ): @@ -263,9 +292,9 @@ def IsStopped( self ): def NextRequestId( self ): - with self._responseMutex: - self._lastId += 1 - return str( self._lastId ) + with self._response_mutex: + self._last_id += 1 + return str( self._last_id ) def GetResponseAsync( self, request_id, message, response_callback=None ): @@ -276,13 +305,13 @@ def GetResponseAsync( self, request_id, message, response_callback=None ): Returns the Response instance created.""" response = Response( response_callback ) - with self._responseMutex: + with self._response_mutex: assert request_id not in self._responses self._responses[ request_id ] = response _logger.debug( 'TX: Sending message: %r', message ) - self._Write( message ) + self.WriteData( message ) return response @@ -298,7 +327,7 @@ def SendNotification( self, message ): no response will be received and nothing is returned.""" _logger.debug( 'TX: Sending notification: %r', message ) - self._Write( message ) + self.WriteData( message ) def AwaitServerConnection( self ): @@ -318,15 +347,15 @@ def AwaitServerConnection( self ): def _ReadMessages( self ): """Main message pump. Within the message pump thread context, reads messages - from the socket/stream by calling self._Read in a loop and despatch complete - messages by calling self._DespatchMessage. + from the socket/stream by calling self.ReadData in a loop and dispatch + complete messages by calling self._DispatchMessage. When the server is shut down cleanly, raises LanguageServerConnectionStopped""" data = bytes( b'' ) while True: - ( data, read_bytes, headers ) = self._ReadHeaders( data ) + data, read_bytes, headers = self._ReadHeaders( data ) if 'Content-Length' not in headers: # FIXME: We could try and recover this, but actually the message pump @@ -353,7 +382,7 @@ def _ReadMessages( self ): while content_read < content_length: # There is more content to read, but data is exhausted - read more from # the socket - data = self._Read( content_length - content_read ) + data = self.ReadData( content_length - content_read ) content_to_read = min( content_length - content_read, len( data ) ) content += data[ : content_to_read ] content_read += len( content ) @@ -361,8 +390,8 @@ def _ReadMessages( self ): _logger.debug( 'RX: Received message: %r', content ) - # lsapi will convert content to unicode - self._DespatchMessage( lsapi.Parse( content ) ) + # lsp will convert content to Unicode + self._DispatchMessage( lsp.Parse( content ) ) # We only consumed len( content ) of data. If there is more, we start # again with the remainder and look for headers @@ -390,7 +419,7 @@ def _ReadHeaders( self, data ): read_bytes = 0 last_line = 0 if len( data ) == 0: - data = self._Read() + data = self.ReadData() while read_bytes < len( data ): if utils.ToUnicode( data[ read_bytes: ] )[ 0 ] == '\n': @@ -413,20 +442,21 @@ def _ReadHeaders( self, data ): data = bytes( b'' ) - return ( data, read_bytes, headers ) + return data, read_bytes, headers - def _DespatchMessage( self, message ): + def _DispatchMessage( self, message ): """Called in the message pump thread context when a complete message was read. For responses, calls the Response object's ResponseReceived method, or for notifications (unsolicited messages from the server), simply accumulates them in a Queue which is polled by the long-polling mechanism in LanguageServerCompleter.""" if 'id' in message: - with self._responseMutex: - assert str( message[ 'id' ] ) in self._responses - self._responses[ str( message[ 'id' ] ) ].ResponseReceived( message ) - del self._responses[ str( message[ 'id' ] ) ] + with self._response_mutex: + message_id = str( message[ 'id' ] ) + assert message_id in self._responses + self._responses[ message_id ].ResponseReceived( message ) + del self._responses[ message_id ] else: self._notifications.put( message ) @@ -451,12 +481,12 @@ def __init__( self, self._server_stdout = server_stdout - def _TryServerConnectionBlocking( self ): + def TryServerConnectionBlocking( self ): # standard in/out don't need to wait for the server to connect to us return True - def _Close( self ): + def Shutdown( self ): if not self._server_stdin.closed: self._server_stdin.close() @@ -464,12 +494,12 @@ def _Close( self ): self._server_stdout.close() - def _Write( self, data ): + def WriteData( self, data ): self._server_stdin.write( data ) self._server_stdin.flush() - def _Read( self, size=-1 ): + def ReadData( self, size=-1 ): if size > -1: data = self._server_stdout.read( size ) else: @@ -504,7 +534,7 @@ class LanguageServerCompleter( Completer ): Startup - - After starting and connecting to the server, call SendInitialise + - After starting and connecting to the server, call SendInitialize - See also LanguageServerConnection requirements Shutdown @@ -521,22 +551,22 @@ class LanguageServerCompleter( Completer ): - The implementation should not require any code to support diagnostics - Subcommands + Sub-commands - - The subcommands map is bespoke to the implementation, but generally, this + - The sub-commands map is bespoke to the implementation, but generally, this class attempts to provide all of the pieces where it can generically. - The following commands typically don't require any special handling, just call the base implementation as below: - Subcommands -> Handler + Sub-command -> Handler - GoToDeclaration -> GoToDeclaration - GoTo -> GoToDeclaration - GoToReferences -> GoToReferences - - RefactorRename -> Rename + - RefactorRename -> RefactorRename - GetType/GetDoc are bespoke to the downstream server, though this class provides GetHoverResponse which is useful in this context. - - FixIt requests are handled by CodeAction, but the responses are passed to - HandleServerCommand, which must return a FixIt. See WorkspaceEditToFixIt and - TextEditToChunks for some helpers. If the server returns other types of + - FixIt requests are handled by GetCodeActions, but the responses are passed + to HandleServerCommand, which must return a FixIt. See WorkspaceEditToFixIt + and TextEditToChunks for some helpers. If the server returns other types of command that aren't FixIt, either throw an exception or update the ycmd protocol to handle it :) """ @@ -555,26 +585,42 @@ def HandleServerCommand( self, request_data, command ): def __init__( self, user_options): super( LanguageServerCompleter, self ).__init__( user_options ) - self._mutex = threading.Lock() + + # _server_info_mutex synchronises access to the state of the + # LanguageServerCompleter object. There are a number of threads at play + # here which might want to change properties of this object: + # - Each client request (handled by concrete completers) executes in a + # separate thread and might call methods requiring us to synchronise the + # server's view of file state with our own. We protect from clobbering + # by doing all server-file-state operations under this mutex. + # - There are certain events that we handle in the message pump thread. + # These include diagnostics and some parts of initialization. We must + # protect against concurrent access to our internal state (such as the + # server file state, and stored data about the server itself) when we + # are calling methods on this object from the message pump). We + # synchronise on this mutex for that. + self._server_info_mutex = threading.Lock() self.ServerReset() def ServerReset( self ): """Clean up internal state related to the running server instance. - Implementation sare required to call this after disconnection and killing + Implementations are required to call this after disconnection and killing the downstream server.""" - with self._mutex: - self._serverFileState = {} + with self._server_info_mutex: + self._server_file_state = {} self._latest_diagnostics = collections.defaultdict( list ) - self._syncType = 'Full' - self._initialise_response = None - self._initialise_event = threading.Event() - self._on_initialise_complete_handlers = list() + self._sync_type = 'Full' + self._initialize_response = None + self._initialize_event = threading.Event() + self._on_initialize_complete_handlers = list() + self._server_capabilities = None + self._resolve_completion_items = False def ShutdownServer( self ): """Send the shutdown and possibly exit request to the server. - Implemenations must call this prior to closing the LanguageServerConnection + Implementations must call this prior to closing the LanguageServerConnection or killing the downstream server.""" # Language server protocol requires orderly shutdown of the downstream @@ -583,7 +629,7 @@ def ShutdownServer( self ): # servers exit on receipt of the shutdown request, so we handle that too. if self.ServerIsReady(): request_id = self.GetConnection().NextRequestId() - msg = lsapi.Shutdown( request_id ) + msg = lsp.Shutdown( request_id ) try: self.GetConnection().GetResponse( request_id, @@ -599,7 +645,7 @@ def ShutdownServer( self ): _logger.exception( 'Shutdown request failed. Ignoring.' ) if self.ServerIsHealthy(): - self.GetConnection().SendNotification( lsapi.Exit() ) + self.GetConnection().SendNotification( lsp.Exit() ) def ServerIsReady( self ): @@ -609,15 +655,15 @@ def ServerIsReady( self ): if not self.ServerIsHealthy(): return False - if self._initialise_event.is_set(): - # We already got the initialise response + if self._initialize_event.is_set(): + # We already got the initialize response return True - if self._initialise_response is None: - # We never sent the initialise response + if self._initialize_response is None: + # We never sent the initialize response return False - # Initialise request in progress. Will be handled asynchronously. + # Initialize request in progress. Will be handled asynchronously. return False @@ -632,10 +678,10 @@ def ComputeCandidatesInner( self, request_data ): if not self.ServerIsReady(): return None - self._RefreshFiles( request_data ) + self._UpdateServerWithFileContents( request_data ) request_id = self.GetConnection().NextRequestId() - msg = lsapi.Completion( request_id, request_data ) + msg = lsp.Completion( request_id, request_data ) response = self.GetConnection().GetResponse( request_id, msg, REQUEST_TIMEOUT_COMPLETION ) @@ -650,23 +696,41 @@ def ComputeCandidatesInner( self, request_data ): # simply resolve each completion item we get. Should this be a performance # issue, we could restrict it in future. # - # Note: ResolveCompletionItems does a lot of work on the actual completion + # Note: _ResolveCompletionItems does a lot of work on the actual completion # text to ensure that the returned text and start_codepoint are applicable # to our model of a single start column. - return self.ResolveCompletionItems( items, request_data ) + return self._ResolveCompletionItems( items, request_data ) - def ResolveCompletionItems( self, items, request_data ): - """Issue the resolve request for each completion item in |items|, then fix - up the items such that a single start codepoint is used.""" + def _ResolveCompletionItem( self, item ): + try: + resolve_id = self.GetConnection().NextRequestId() + resolve = lsp.ResolveCompletion( resolve_id, item ) + response = self.GetConnection().GetResponse( + resolve_id, + resolve, + REQUEST_TIMEOUT_COMPLETION ) + item = response[ 'result' ] + except ResponseFailedException: + _logger.exception( 'A completion item could not be resolved. Using ' + 'basic data.' ) + + return item + + def _ShouldResolveCompletionItems( self ): # We might not actually need to issue the resolve request if the server # claims that it doesn't support it. However, we still might need to fix up # the completion items. - do_resolve = ( - 'completionProvider' in self._server_capabilities and - self._server_capabilities[ 'completionProvider' ].get( 'resolveProvider', - False ) ) + return ( 'completionProvider' in self._server_capabilities and + self._server_capabilities[ 'completionProvider' ].get( + 'resolveProvider', + False ) ) + + + def _ResolveCompletionItems( self, items, request_data ): + """Issue the resolve request for each completion item in |items|, then fix + up the items such that a single start codepoint is used.""" # # Important note on the following logic: @@ -699,26 +763,16 @@ def ResolveCompletionItems( self, items, request_data ): min_start_codepoint = request_data[ 'start_codepoint' ] # First generate all of the completion items and store their - # start_codepoints. Then, we fix-up the completion texts to use the + # start_codepoints. Then, we fix-up the completion texts to use the # earliest start_codepoint by borrowing text from the original line. for item in items: # First, resolve the completion. - if do_resolve: - try: - resolve_id = self.GetConnection().NextRequestId() - resolve = lsapi.ResolveCompletion( resolve_id, item ) - response = self.GetConnection().GetResponse( - resolve_id, - resolve, - REQUEST_TIMEOUT_COMPLETION ) - item = response[ 'result' ] - except ResponseFailedException: - _logger.exception( 'A completion item could not be resolved. Using ' - 'basic data.' ) + if self._resolve_completion_items: + item = self._ResolveCompletionItem( item ) try: - ( insertion_text, fixits, start_codepoint ) = ( - InsertionTextForItem( request_data, item ) ) + insertion_text, fixits, start_codepoint = ( + _InsertionTextForItem( request_data, item ) ) except IncompatibleCompletionException: _logger.exception( 'Ignoring incompatible completion suggestion ' '{0}'.format( item ) ) @@ -727,25 +781,19 @@ def ResolveCompletionItems( self, items, request_data ): min_start_codepoint = min( min_start_codepoint, start_codepoint ) # Build a ycmd-compatible completion for the text as we received it. Later - # we might mofify insertion_text should we see a lower start codepoint. - completions.append( responses.BuildCompletionData( - insertion_text, - extra_menu_info = item.get( 'detail', None ), - detailed_info = ( item[ 'label' ] + - '\n\n' + - item.get( 'documentation', '' ) ), - menu_text = item[ 'label' ], - kind = lsapi.ITEM_KIND[ item.get( 'kind', 0 ) ], - extra_data = fixits ) ) + # we might modify insertion_text should we see a lower start codepoint. + completions.append( _CompletionItemToCompletionData( insertion_text, + item, + fixits ) ) start_codepoints.append( start_codepoint ) if ( len( completions ) > 1 and min_start_codepoint != request_data[ 'start_codepoint' ] ): # We need to fix up the completions, go do that - return FixUpCompletionPrefixes( completions, - start_codepoints, - request_data, - min_start_codepoint ) + return _FixUpCompletionPrefixes( completions, + start_codepoints, + request_data, + min_start_codepoint ) request_data[ 'start_codepoint' ] = min_start_codepoint return completions @@ -756,16 +804,16 @@ def OnFileReadyToParse( self, request_data ): return # If we haven't finished initializing yet, we need to queue up a call to - # _RefreshFiles. This ensures that the server is up to date as soon as we - # are able to send more messages. This is important because server start up - # can be quite slow and we must not block the user, while we must keep the - # server synchronized. - if not self._initialise_event.is_set(): - self._OnInitialiseComplete( lambda self: self._RefreshFiles( - request_data ) ) + # _UpdateServerWithFileContents. This ensures that the server is up to date + # as soon as we are able to send more messages. This is important because + # server start up can be quite slow and we must not block the user, while we + # must keep the server synchronized. + if not self._initialize_event.is_set(): + self._OnInitializeComplete( + lambda self: self._UpdateServerWithFileContents( request_data ) ) return - self._RefreshFiles( request_data ) + self._UpdateServerWithFileContents( request_data ) # Return the latest diagnostics that we have received. # @@ -776,28 +824,27 @@ def OnFileReadyToParse( self, request_data ): # However, we _also_ return them here to refresh diagnostics after, say # changing the active file in the editor, or for clients not supporting the # polling mechanism. - uri = lsapi.FilePathToUri( request_data[ 'filepath' ] ) - with self._mutex: + uri = lsp.FilePathToUri( request_data[ 'filepath' ] ) + with self._server_info_mutex: if uri in self._latest_diagnostics: - return [ BuildDiagnostic( request_data, uri, diag ) + return [ _BuildDiagnostic( request_data, uri, diag ) for diag in self._latest_diagnostics[ uri ] ] - def PollForMessagesInner( self, request_data ): - # scoop up any pending messages into one big list - messages = self._PollForMessagesBlock( request_data ) - - # If we found some messages, return them immediately + def PollForMessagesInner( self, request_data, timeout ): + # If there are messages pending in the queue, return them immediately + messages = self._GetPendingMessages( request_data ) if messages: return messages - # Otherwise, there are no pending messages. Block until we get one. - return self._PollForMessagesBlock( request_data ) + # Otherwise, block until we get one or we hit the timeout. + return self._AwaitServerMessages( request_data, timeout ) - def _PollForMessagesNoBlock( self, request_data, messages ): + def _GetPendingMessages( self, request_data ): """Convert any pending notifications to messages and return them in a list. - If there are no messages pending, returns an empty list.""" + If there are no messages pending, returns an empty list. Returns False if an + error occurred and no further polling should be attempted.""" messages = list() try: while True: @@ -805,12 +852,12 @@ def _PollForMessagesNoBlock( self, request_data, messages ): # The server isn't running or something. Don't re-poll. return False - notification = self.GetConnection()._notifications.get_nowait( ) - message = self._ConvertNotificationToMessage( request_data, - notification ) + notification = self.GetConnection()._notifications.get_nowait( ) + message = self.ConvertNotificationToMessage( request_data, + notification ) - if message: - messages.append( message ) + if message: + messages.append( message ) except queue.Empty: # We drained the queue pass @@ -818,7 +865,7 @@ def _PollForMessagesNoBlock( self, request_data, messages ): return messages - def _PollForMessagesBlock( self, request_data ): + def _AwaitServerMessages( self, request_data, timeout ): """Block until either we receive a notification, or a timeout occurs. Returns one of the following: - a list containing a single message @@ -833,9 +880,9 @@ def _PollForMessagesBlock( self, request_data ): return False notification = self.GetConnection()._notifications.get( - timeout = MESSAGE_POLL_TIMEOUT ) - message = self._ConvertNotificationToMessage( request_data, - notification ) + timeout = timeout ) + message = self.ConvertNotificationToMessage( request_data, + notification ) if message: return [ message ] except queue.Empty: @@ -846,11 +893,11 @@ def GetDefaultNotificationHandler( self ): """Return a notification handler method suitable for passing to LanguageServerConnection constructor""" def handler( server, notification ): - self._HandleNotificationInPollThread( notification ) + self.HandleNotificationInPollThread( notification ) return handler - def _HandleNotificationInPollThread( self, notification ): + def HandleNotificationInPollThread( self, notification ): """Called by the LanguageServerConnection in its message pump context when a notification message arrives.""" @@ -860,16 +907,16 @@ def _HandleNotificationInPollThread( self, notification ): # for correct FixIt handling, as they are part of the FixIt context. params = notification[ 'params' ] uri = params[ 'uri' ] - with self._mutex: + with self._server_info_mutex: self._latest_diagnostics[ uri ] = params[ 'diagnostics' ] - def _ConvertNotificationToMessage( self, request_data, notification ): + def ConvertNotificationToMessage( self, request_data, notification ): """Convert the supplied server notification to a ycmd message. Returns None if the notification should be ignored. Implementations may override this method to handle custom notifications, but - must always call the base implementation for unrecognised notifications.""" + must always call the base implementation for unrecognized notifications.""" if notification[ 'method' ] == 'window/showMessage': return responses.BuildDisplayMessageResponse( @@ -890,103 +937,104 @@ def _ConvertNotificationToMessage( self, request_data, notification ): params = notification[ 'params' ] uri = params[ 'uri' ] try: - filepath = lsapi.UriToFilePath( uri ) + filepath = lsp.UriToFilePath( uri ) response = { - 'diagnostics': [ BuildDiagnostic( request_data, uri, x ) + 'diagnostics': [ _BuildDiagnostic( request_data, uri, x ) for x in params[ 'diagnostics' ] ], 'filepath': filepath } return response - except lsapi.InvalidUriException: - _logger.exception( 'Ignoring diagnostics for unrecognised URI' ) + except lsp.InvalidUriException: + _logger.exception( 'Ignoring diagnostics for unrecognized URI' ) pass return None - def _RefreshFiles( self, request_data ): + def _UpdateServerWithFileContents( self, request_data ): """Update the server with the current contents of all open buffers, and close any buffers no longer open. - This method should be called frequently and in any event before a syncronous - operation.""" - with self._mutex: + This method should be called frequently and in any event before a + synchronous operation.""" + with self._server_info_mutex: for file_name, file_data in iteritems( request_data[ 'file_data' ] ): file_state = 'New' - if file_name in self._serverFileState: - file_state = self._serverFileState[ file_name ] + if file_name in self._server_file_state: + file_state = self._server_file_state[ file_name ] _logger.debug( 'Refreshing file {0}: State is {1}'.format( file_name, file_state ) ) - if file_state == 'New' or self._syncType == 'Full': - msg = lsapi.DidOpenTextDocument( file_name, - file_data[ 'filetypes' ], - file_data[ 'contents' ] ) + if file_state == 'New' or self._sync_type == 'Full': + msg = lsp.DidOpenTextDocument( file_name, + file_data[ 'filetypes' ], + file_data[ 'contents' ] ) else: # FIXME: DidChangeTextDocument doesn't actually do anything different # from DidOpenTextDocument other than send the right message, because # we don't actually have a mechanism for generating the diffs or - # proper document versioning or lifecycle management. This isn't + # proper document version or life-cycle management. This isn't # strictly necessary, but might lead to performance problems. - msg = lsapi.DidChangeTextDocument( file_name, - file_data[ 'filetypes' ], - file_data[ 'contents' ] ) + msg = lsp.DidChangeTextDocument( file_name, + file_data[ 'filetypes' ], + file_data[ 'contents' ] ) - self._serverFileState[ file_name ] = 'Open' + self._server_file_state[ file_name ] = 'Open' self.GetConnection().SendNotification( msg ) stale_files = list() - for file_name in iterkeys( self._serverFileState ): + for file_name in iterkeys( self._server_file_state ): if file_name not in request_data[ 'file_data' ]: stale_files.append( file_name ) # We can't change the dictionary entries while using iterkeys, so we do # that in a separate loop. for file_name in stale_files: - msg = lsapi.DidCloseTextDocument( file_name ) + msg = lsp.DidCloseTextDocument( file_name ) self.GetConnection().SendNotification( msg ) - del self._serverFileState[ file_name ] + del self._server_file_state[ file_name ] def _GetProjectDirectory( self ): """Return the directory in which the server should operate. Language server protocol and most servers have a concept of a 'project directory'. By - default this is the working directory of the ycmd server, but implemenations - may override this for example if there is a language- or server-specific - notion of a project that can be detected.""" + default this is the working directory of the ycmd server, but + implementations may override this for example if there is a language- or + server-specific notion of a project that can be detected.""" return utils.GetCurrentDirectory() - def SendInitialise( self ): + def SendInitialize( self ): """Sends the initialize request asynchronously. This must be called immediately after establishing the connection with the language server. Implementations must not issue further requests to the server until the initialize exchange has completed. This can be detected by calling this class's implementation of ServerIsReady.""" - with self._mutex: - assert not self._initialise_response + with self._server_info_mutex: + assert not self._initialize_response request_id = self.GetConnection().NextRequestId() - msg = lsapi.Initialise( request_id, self._GetProjectDirectory() ) + msg = lsp.Initialize( request_id, self._GetProjectDirectory() ) def response_handler( response, message ): if message is None: - raise ResponseAbortedException( 'Initialise request aborted' ) + raise ResponseAbortedException( 'Initialize request aborted' ) - self._HandleInitialiseInPollThread( message ) + self._HandleInitializeInPollThread( message ) - self._initialise_response = self.GetConnection().GetResponseAsync( + self._initialize_response = self.GetConnection().GetResponseAsync( request_id, msg, response_handler ) - def _HandleInitialiseInPollThread( self, response ): + def _HandleInitializeInPollThread( self, response ): """Called within the context of the LanguageServerConnection's message pump when the initialize request receives a response.""" - with self._mutex: + with self._server_info_mutex: self._server_capabilities = response[ 'result' ][ 'capabilities' ] + self._resolve_completion_items = self._ShouldResolveCompletionItems() if 'textDocumentSync' in response[ 'result' ][ 'capabilities' ]: SYNC_TYPE = [ @@ -994,36 +1042,36 @@ def _HandleInitialiseInPollThread( self, response ): 'Full', 'Incremental' ] - self._syncType = SYNC_TYPE[ + self._sync_type = SYNC_TYPE[ response[ 'result' ][ 'capabilities' ][ 'textDocumentSync' ] ] _logger.info( 'Language server requires sync type of {0}'.format( - self._syncType ) ) + self._sync_type ) ) # We must notify the server that we received the initialize response. # There are other things that could happen here, such as loading custom # server configuration, but we don't support that (yet). - self.GetConnection().SendNotification( lsapi.Initialized() ) + self.GetConnection().SendNotification( lsp.Initialized() ) # Notify the other threads that we have completed the initialize exchange. - self._initialise_response = None - self._initialise_event.set() + self._initialize_response = None + self._initialize_event.set() # Fire any events that are pending on the completion of the initialize - # exchange. Typically, this will be calls to _RefreshFiles or something that - # occurred while we were waiting. - for handler in self._on_initialise_complete_handlers: + # exchange. Typically, this will be calls to _UpdateServerWithFileContents + # or something that occurred while we were waiting. + for handler in self._on_initialize_complete_handlers: handler( self ) - self._on_initialise_complete_handlers = list() + self._on_initialize_complete_handlers = list() - def _OnInitialiseComplete( self, handler ): + def _OnInitializeComplete( self, handler ): """Register a function to be called when the initialize exchange completes. The function |handler| will be called on successful completion of the - initalize exchange with a single argument |self|, which is the |self| passed - to this method. + initialize exchange with a single argument |self|, which is the |self| + passed to this method. If the server is shut down or reset, the callback is not called.""" - self._on_initialise_complete_handlers.append( handler ) + self._on_initialize_complete_handlers.append( handler ) def GetHoverResponse( self, request_data ): @@ -1033,13 +1081,12 @@ def GetHoverResponse( self, request_data ): if not self.ServerIsReady(): raise RuntimeError( 'Server is initializing. Please wait.' ) - self._RefreshFiles( request_data ) + self._UpdateServerWithFileContents( request_data ) request_id = self.GetConnection().NextRequestId() response = self.GetConnection().GetResponse( request_id, - lsapi.Hover( request_id, - request_data ), + lsp.Hover( request_id, request_data ), REQUEST_TIMEOUT_COMMAND ) return response[ 'result' ][ 'contents' ] @@ -1051,22 +1098,21 @@ def GoToDeclaration( self, request_data ): if not self.ServerIsReady(): raise RuntimeError( 'Server is initializing. Please wait.' ) - self._RefreshFiles( request_data ) + self._UpdateServerWithFileContents( request_data ) request_id = self.GetConnection().NextRequestId() response = self.GetConnection().GetResponse( request_id, - lsapi.Definition( request_id, - request_data ), + lsp.Definition( request_id, request_data ), REQUEST_TIMEOUT_COMMAND ) if isinstance( response[ 'result' ], list ): - return LocationListToGoTo( request_data, response ) + return _LocationListToGoTo( request_data, response ) else: position = response[ 'result' ] try: return responses.BuildGoToResponseFromLocation( - *PositionToLocationAndDescription( request_data, position ) ) + *_PositionToLocationAndDescription( request_data, position ) ) except KeyError: raise RuntimeError( 'Cannot jump to location' ) @@ -1077,25 +1123,24 @@ def GoToReferences( self, request_data ): if not self.ServerIsReady(): raise RuntimeError( 'Server is initializing. Please wait.' ) - self._RefreshFiles( request_data ) + self._UpdateServerWithFileContents( request_data ) request_id = self.GetConnection().NextRequestId() response = self.GetConnection().GetResponse( request_id, - lsapi.References( request_id, - request_data ), + lsp.References( request_id, request_data ), REQUEST_TIMEOUT_COMMAND ) - return LocationListToGoTo( request_data, response ) + return _LocationListToGoTo( request_data, response ) - def CodeAction( self, request_data, args ): - """Performs the codeaction request and returns the result as a FixIt + def GetCodeActions( self, request_data, args ): + """Performs the codeAction request and returns the result as a FixIt response.""" if not self.ServerIsReady(): raise RuntimeError( 'Server is initializing. Please wait.' ) - self._RefreshFiles( request_data ) + self._UpdateServerWithFileContents( request_data ) line_num_ls = request_data[ 'line_num' ] - 1 @@ -1108,9 +1153,9 @@ def WithinRange( diag ): return True - with self._mutex: + with self._server_info_mutex: file_diagnostics = list( self._latest_diagnostics[ - lsapi.FilePathToUri( request_data[ 'filepath' ] ) ] ) + lsp.FilePathToUri( request_data[ 'filepath' ] ) ] ) matched_diagnostics = [ d for d in file_diagnostics if WithinRange( d ) @@ -1120,16 +1165,16 @@ def WithinRange( diag ): if matched_diagnostics: code_actions = self.GetConnection().GetResponse( request_id, - lsapi.CodeAction( request_id, - request_data, - matched_diagnostics[ 0 ][ 'range' ], - matched_diagnostics ), + lsp.CodeAction( request_id, + request_data, + matched_diagnostics[ 0 ][ 'range' ], + matched_diagnostics ), REQUEST_TIMEOUT_COMMAND ) else: code_actions = self.GetConnection().GetResponse( request_id, - lsapi.CodeAction( + lsp.CodeAction( request_id, request_data, # Use the whole line @@ -1154,7 +1199,7 @@ def WithinRange( diag ): return responses.BuildFixItResponse( [ r for r in response if r ] ) - def Rename( self, request_data, args ): + def RefactorRename( self, request_data, args ): """Issues the rename request and returns the result as a FixIt response.""" if not self.ServerIsReady(): raise RuntimeError( 'Server is initializing. Please wait.' ) @@ -1163,27 +1208,36 @@ def Rename( self, request_data, args ): raise ValueError( 'Please specify a new name to rename it to.\n' 'Usage: RefactorRename ' ) - - self._RefreshFiles( request_data ) + self._UpdateServerWithFileContents( request_data ) new_name = args[ 0 ] request_id = self.GetConnection().NextRequestId() response = self.GetConnection().GetResponse( request_id, - lsapi.Rename( request_id, - request_data, - new_name ), + lsp.Rename( request_id, request_data, new_name ), REQUEST_TIMEOUT_COMMAND ) return responses.BuildFixItResponse( [ WorkspaceEditToFixIt( request_data, response[ 'result' ] ) ] ) -def FixUpCompletionPrefixes( completions, - start_codepoints, - request_data, - min_start_codepoint ): +def _CompletionItemToCompletionData( insertion_text, item, fixits ): + return responses.BuildCompletionData( + insertion_text, + extra_menu_info = item.get( 'detail', None ), + detailed_info = ( item[ 'label' ] + + '\n\n' + + item.get( 'documentation', '' ) ), + menu_text = item[ 'label' ], + kind = lsp.ITEM_KIND[ item.get( 'kind', 0 ) ], + extra_data = fixits ) + + +def _FixUpCompletionPrefixes( completions, + start_codepoints, + request_data, + min_start_codepoint ): """Fix up the insertion texts so they share the same start_codepoint by borrowing text from the source.""" line = request_data[ 'line_value' ] @@ -1209,7 +1263,7 @@ def FixUpCompletionPrefixes( completions, return completions -def InsertionTextForItem( request_data, item ): +def _InsertionTextForItem( request_data, item ): """Determines the insertion text for the completion item |item|, and any additional FixIts that need to be applied when selecting it. @@ -1219,15 +1273,16 @@ def InsertionTextForItem( request_data, item ): selecting this completion - start_codepoint = the start column at which the text should be inserted )""" - # We explicitly state that we do not support completion types of "Snippet". + # 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 lsapi.INSERT_TEXT_FORMAT[ + assert lsp.INSERT_TEXT_FORMAT[ item.get( 'insertTextFormat', 1 ) ] == 'PlainText' fixits = None start_codepoint = request_data[ 'start_codepoint' ] - # We will alwyas have one of insertText or label + # We will always have one of insertText or label if 'insertText' in item and item[ 'insertText' ]: insertion_text = item[ 'insertText' ] else: @@ -1241,31 +1296,13 @@ def InsertionTextForItem( request_data, item ): # clients won't be able to apply arbitrary edits (only 'completion', as # opposed to 'content assist'). if 'textEdit' in item and item[ 'textEdit' ]: - textEdit = item[ 'textEdit' ] - edit_range = textEdit[ 'range' ] - start_codepoint = edit_range[ 'start' ][ 'character' ] + 1 - end_codepoint = edit_range[ 'end' ][ 'character' ] + 1 - - # Conservatively rejecting candidates that breach the protocol - if edit_range[ 'start' ][ 'line' ] != edit_range[ 'end' ][ 'line' ]: - raise IncompatibleCompletionException( - "The TextEdit '{0}' spans multiple lines".format( - textEdit[ 'newText' ] ) ) + text_edit = item[ 'textEdit' ] + start_codepoint = _GetCompletionItemStartCodepointOrReject( text_edit, + request_data ) - if start_codepoint > request_data[ 'start_codepoint' ]: - raise IncompatibleCompletionException( - "The TextEdit '{0}' starts after the start position".format( - textEdit[ 'newText' ] ) ) + insertion_text = text_edit[ 'newText' ] - if end_codepoint < request_data[ 'start_codepoint' ]: - raise IncompatibleCompletionException( - "The TextEdit '{0}' ends before the start position".format( - textEdit[ 'newText' ] ) ) - - - insertion_text = textEdit[ 'newText' ] - - if '\n' in textEdit[ 'newText' ]: + if '\n' in insertion_text: # jdt.ls can return completions which generate code, such as # getters/setters and entire anonymous classes. # @@ -1284,24 +1321,48 @@ def InsertionTextForItem( request_data, item ): # # 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( textEdit[ 'newText' ] ) + raise IncompatibleCompletionException( insertion_text ) additional_text_edits.extend( item.get( 'additionalTextEdits', [] ) ) if additional_text_edits: chunks = [ responses.FixItChunk( e[ 'newText' ], - BuildRange( request_data, - request_data[ 'filepath' ], - e[ 'range' ] ) ) + _BuildRange( request_data, + request_data[ 'filepath' ], + e[ 'range' ] ) ) for e in additional_text_edits ] fixits = responses.BuildFixItResponse( [ responses.FixIt( chunks[ 0 ].range.start_, chunks ) ] ) - return ( insertion_text, fixits, start_codepoint ) + return insertion_text, fixits, start_codepoint + +def _GetCompletionItemStartCodepointOrReject( text_edit, request_data ): + edit_range = text_edit[ 'range' ] + start_codepoint = edit_range[ 'start' ][ 'character' ] + 1 + end_codepoint = edit_range[ 'end' ][ 'character' ] + 1 -def LocationListToGoTo( request_data, response ): + # Conservatively rejecting candidates that breach the protocol + if edit_range[ 'start' ][ 'line' ] != edit_range[ 'end' ][ 'line' ]: + raise IncompatibleCompletionException( + "The TextEdit '{0}' spans multiple lines".format( + text_edit[ 'newText' ] ) ) + + if start_codepoint > request_data[ 'start_codepoint' ]: + raise IncompatibleCompletionException( + "The TextEdit '{0}' starts after the start position".format( + text_edit[ 'newText' ] ) ) + + if end_codepoint < request_data[ 'start_codepoint' ]: + raise IncompatibleCompletionException( + "The TextEdit '{0}' ends before the start position".format( + text_edit[ 'newText' ] ) ) + + return start_codepoint + + +def _LocationListToGoTo( request_data, response ): """Convert a LSP list of locations to a ycmd GoTo response.""" if not response: raise RuntimeError( 'Cannot jump to location' ) @@ -1311,45 +1372,48 @@ def LocationListToGoTo( request_data, response ): positions = response[ 'result' ] return [ responses.BuildGoToResponseFromLocation( - *PositionToLocationAndDescription( request_data, - position ) ) + *_PositionToLocationAndDescription( request_data, + position ) ) for position in positions ] else: position = response[ 'result' ][ 0 ] return responses.BuildGoToResponseFromLocation( - *PositionToLocationAndDescription( request_data, position ) ) - except IndexError: - raise RuntimeError( 'Cannot jump to location' ) - except KeyError: + *_PositionToLocationAndDescription( request_data, position ) ) + except ( IndexError, KeyError ): raise RuntimeError( 'Cannot jump to location' ) -def PositionToLocationAndDescription( request_data, position ): +def _PositionToLocationAndDescription( request_data, position ): """Convert a LSP position to a ycmd location.""" try: - filename = lsapi.UriToFilePath( position[ 'uri' ] ) + filename = lsp.UriToFilePath( position[ 'uri' ] ) file_contents = utils.SplitLines( GetFileContents( request_data, filename ) ) - except lsapi.InvalidUriException: + except lsp.InvalidUriException: _logger.debug( "Invalid URI, file contents not available in GoTo" ) filename = '' file_contents = [] except IOError: + # It's possible to receive positions for files which no longer exist (due to + # race condition). UriToFilePath doesn't throw IOError, so we can assume + # that filename is already set. + _logger.exception( "A file could not be found when determining a " + "GoTo location" ) file_contents = [] - return BuildLocationAndDescription( request_data, - filename, - file_contents, - position[ 'range' ][ 'start' ] ) + return _BuildLocationAndDescription( request_data, + filename, + file_contents, + position[ 'range' ][ 'start' ] ) -def BuildLocationAndDescription( request_data, filename, file_contents, loc ): +def _BuildLocationAndDescription( request_data, filename, file_contents, loc ): """Returns a tuple of ( - ycmd Location for the supplied filename and LSP location - contents of the line at that location ) - Importantly, converts from LSP unicode offset to ycmd byte offset.""" + Importantly, converts from LSP Unicode offset to ycmd byte offset.""" try: line_value = file_contents[ loc[ 'line' ] ] @@ -1367,55 +1431,59 @@ def BuildLocationAndDescription( request_data, filename, file_contents, loc ): line_value ) -def BuildRange( request_data, filename, r ): +def _BuildRange( request_data, filename, r ): """Returns a ycmd range from a LSP range |r|.""" try: file_contents = utils.SplitLines( GetFileContents( request_data, filename ) ) except IOError: + # It's possible to receive positions for files which no longer exist (due to + # race condition). + _logger.exception( "A file could not be found when determining a " + "range location" ) file_contents = [] - return responses.Range( BuildLocationAndDescription( request_data, - filename, - file_contents, - r[ 'start' ] )[ 0 ], - BuildLocationAndDescription( request_data, - filename, - file_contents, - r[ 'end' ] )[ 0 ] ) + return responses.Range( _BuildLocationAndDescription( request_data, + filename, + file_contents, + r[ 'start' ] )[ 0 ], + _BuildLocationAndDescription( request_data, + filename, + file_contents, + r[ 'end' ] )[ 0 ] ) -def BuildDiagnostic( request_data, uri, diag ): +def _BuildDiagnostic( request_data, uri, diag ): """Return a ycmd diagnostic from a LSP diagnostic.""" try: - filename = lsapi.UriToFilePath( uri ) - except lsapi.InvalidUriException: + filename = lsp.UriToFilePath( uri ) + except lsp.InvalidUriException: _logger.debug( 'Invalid URI received for diagnostic' ) filename = '' - r = BuildRange( request_data, filename, diag[ 'range' ] ) + r = _BuildRange( request_data, filename, diag[ 'range' ] ) - return responses.BuildDiagnosticData ( responses.Diagnostic( + return responses.BuildDiagnosticData( responses.Diagnostic( ranges = [ r ], location = r.start_, location_extent = r, text = diag[ 'message' ], - kind = SEVERITY_TO_YCM_SEVERITY[ lsapi.SEVERITY[ diag[ 'severity' ] ] ] ) ) + kind = lsp.SEVERITY[ diag[ 'severity' ] ].upper() ) ) def TextEditToChunks( request_data, uri, text_edit ): """Returns a list of FixItChunks from a LSP textEdit.""" try: - filepath = lsapi.UriToFilePath( uri ) - except lsapi.InvalidUriException: + filepath = lsp.UriToFilePath( uri ) + except lsp.InvalidUriException: _logger.debug( 'Invalid filepath received in TextEdit' ) filepath = '' return [ responses.FixItChunk( change[ 'newText' ], - BuildRange( request_data, - filepath, - change[ 'range' ] ) ) + _BuildRange( request_data, + filepath, + change[ 'range' ] ) ) for change in text_edit ] diff --git a/ycmd/completers/language_server/lsapi.py b/ycmd/completers/language_server/language_server_protocol.py similarity index 93% rename from ycmd/completers/language_server/lsapi.py rename to ycmd/completers/language_server/language_server_protocol.py index a9ce1f0721..8136c00745 100644 --- a/ycmd/completers/language_server/lsapi.py +++ b/ycmd/completers/language_server/language_server_protocol.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016 ycmd contributors +# Copyright (C) 2017 ycmd contributors # # This file is part of ycmd. # @@ -101,15 +101,20 @@ def BuildNotification( method, parameters ): } ) -def Initialise( request_id, project_directory ): - """Build the Language Server initialise request""" +def Initialize( request_id, project_directory ): + """Build the Language Server initialize request""" return BuildRequest( request_id, 'initialize', { 'processId': os.getpid(), 'rootPath': project_directory, 'rootUri': FilePathToUri( project_directory ), - 'initializationOptions': { }, - 'capabilities': { 'trace': 'verbose' } + 'initializationOptions': { + # We don't currently support any server-specific options. + }, + 'capabilities': { + # We don't currently support any of the client capabilities, so we don't + # include anything in here. + }, } ) @@ -225,7 +230,7 @@ def References( request_id, request_data ): def Position( request_data ): - # The API requires 0-based unicode offsets. + # The API requires 0-based Unicode offsets. return { 'line': request_data[ 'line_num' ] - 1, 'character': request_data[ 'column_codepoint' ] - 1, @@ -247,10 +252,9 @@ def _BuildMessageData( message ): message[ 'jsonrpc' ] = '2.0' # NOTE: sort_keys=True is needed to workaround a 'limitation' of clangd where # it requires keys to be in a specific order, due to a somewhat naive - # json/yaml parser. + # JSON/YAML parser. data = ToBytes( json.dumps( message, sort_keys=True ) ) packet = ToBytes( 'Content-Length: {0}\r\n' - 'Content-Type: application/vscode-jsonrpc;charset=utf8\r\n' '\r\n'.format( len(data) ) ) + data return packet diff --git a/ycmd/handlers.py b/ycmd/handlers.py index 5089971103..8de4234be5 100644 --- a/ycmd/handlers.py +++ b/ycmd/handlers.py @@ -259,8 +259,9 @@ def ReceiveMessages(): request_data = RequestWrap( request.json ) try: completer = _GetCompleterForRequestData( request_data ) - except: - # No semantic completer for this filetype, don't requery + except Exception: + # No semantic completer for this filetype, don't requery. This is not an + # error. return _JsonResponse( False ) return _JsonResponse( completer.PollForMessages( request_data ) ) diff --git a/ycmd/tests/clang/__init__.py b/ycmd/tests/clang/__init__.py index c16cb787fc..a34aa1c3ea 100644 --- a/ycmd/tests/clang/__init__.py +++ b/ycmd/tests/clang/__init__.py @@ -95,11 +95,11 @@ def Wrapper( *args, **kwargs ): def TemporaryClangProject( tmp_dir, compile_commands ): """Context manager to create a compilation database in a directory and delete it when the test completes. |tmp_dir| is the directory in which to create the - database file (typically used in conjunction with |TemporaryClangTestDir|) and + database file (typically used in conjunction with |TemporaryTestDir|) and |compile_commands| is a python object representing the compilation database. e.g.: - with TemporaryClangTestDir() as tmp_dir: + with TemporaryTestDir() as tmp_dir: database = [ { 'directory': os.path.join( tmp_dir, dir ), diff --git a/ycmd/tests/clang/debug_info_test.py b/ycmd/tests/clang/debug_info_test.py index e44bb68e43..5e6fec1d19 100644 --- a/ycmd/tests/clang/debug_info_test.py +++ b/ycmd/tests/clang/debug_info_test.py @@ -27,8 +27,8 @@ instance_of, matches_regexp ) from ycmd.tests.clang import ( IsolatedYcmd, PathToTestFile, SharedYcmd, - TemporaryClangTestDir, TemporaryClangProject ) -from ycmd.tests.test_utils import BuildRequest + TemporaryClangProject ) +from ycmd.tests.test_utils import BuildRequest, TemporaryTestDir @SharedYcmd @@ -124,7 +124,7 @@ def DebugInfo_FlagsWhenExtraConfNotLoadedAndNoCompilationDatabase_test( @IsolatedYcmd() def DebugInfo_FlagsWhenNoExtraConfAndCompilationDatabaseLoaded_test( app ): - with TemporaryClangTestDir() as tmp_dir: + with TemporaryTestDir() as tmp_dir: compile_commands = [ { 'directory': tmp_dir, @@ -159,7 +159,7 @@ def DebugInfo_FlagsWhenNoExtraConfAndCompilationDatabaseLoaded_test( app ): @IsolatedYcmd() def DebugInfo_FlagsWhenNoExtraConfAndInvalidCompilationDatabase_test( app ): - with TemporaryClangTestDir() as tmp_dir: + with TemporaryTestDir() as tmp_dir: compile_commands = 'garbage' with TemporaryClangProject( tmp_dir, compile_commands ): request_data = BuildRequest( diff --git a/ycmd/tests/clang/flags_test.py b/ycmd/tests/clang/flags_test.py index 424a21fbe5..0124cf16a1 100644 --- a/ycmd/tests/clang/flags_test.py +++ b/ycmd/tests/clang/flags_test.py @@ -29,9 +29,9 @@ from ycmd.completers.cpp import flags from mock import patch, MagicMock from types import ModuleType -from ycmd.tests.test_utils import MacOnly +from ycmd.tests.test_utils import MacOnly, TemporaryTestDir from ycmd.responses import NoExtraConfDetected -from ycmd.tests.clang import TemporaryClangProject, TemporaryClangTestDir +from ycmd.tests.clang import TemporaryClangProject from hamcrest import assert_that, calling, contains, has_item, not_, raises @@ -493,7 +493,7 @@ def Mac_SelectMacToolchain_CommandLineTools_test( *args ): def CompilationDatabase_NoDatabase_test(): - with TemporaryClangTestDir() as tmp_dir: + with TemporaryTestDir() as tmp_dir: assert_that( calling( flags.Flags().FlagsForFile ).with_args( os.path.join( tmp_dir, 'test.cc' ) ), @@ -502,7 +502,7 @@ def CompilationDatabase_NoDatabase_test(): def CompilationDatabase_FileNotInDatabase_test(): compile_commands = [ ] - with TemporaryClangTestDir() as tmp_dir: + with TemporaryTestDir() as tmp_dir: with TemporaryClangProject( tmp_dir, compile_commands ): eq_( flags.Flags().FlagsForFile( os.path.join( tmp_dir, 'test.cc' ) ), @@ -510,7 +510,7 @@ def CompilationDatabase_FileNotInDatabase_test(): def CompilationDatabase_InvalidDatabase_test(): - with TemporaryClangTestDir() as tmp_dir: + with TemporaryTestDir() as tmp_dir: with TemporaryClangProject( tmp_dir, 'this is junk' ): assert_that( calling( flags.Flags().FlagsForFile ).with_args( @@ -519,7 +519,7 @@ def CompilationDatabase_InvalidDatabase_test(): def CompilationDatabase_UseFlagsFromDatabase_test(): - with TemporaryClangTestDir() as tmp_dir: + with TemporaryTestDir() as tmp_dir: compile_commands = [ { 'directory': tmp_dir, @@ -543,7 +543,7 @@ def CompilationDatabase_UseFlagsFromDatabase_test(): def CompilationDatabase_UseFlagsFromSameDir_test(): - with TemporaryClangTestDir() as tmp_dir: + with TemporaryTestDir() as tmp_dir: compile_commands = [ { 'directory': tmp_dir, @@ -590,7 +590,7 @@ def CompilationDatabase_UseFlagsFromSameDir_test(): def CompilationDatabase_HeaderFileHeuristic_test(): - with TemporaryClangTestDir() as tmp_dir: + with TemporaryTestDir() as tmp_dir: compile_commands = [ { 'directory': tmp_dir, @@ -614,7 +614,7 @@ def CompilationDatabase_HeaderFileHeuristic_test(): def CompilationDatabase_HeaderFileHeuristicNotFound_test(): - with TemporaryClangTestDir() as tmp_dir: + with TemporaryTestDir() as tmp_dir: compile_commands = [ { 'directory': tmp_dir, @@ -634,7 +634,7 @@ def CompilationDatabase_HeaderFileHeuristicNotFound_test(): def CompilationDatabase_ExplicitHeaderFileEntry_test(): - with TemporaryClangTestDir() as tmp_dir: + with TemporaryTestDir() as tmp_dir: # Have an explicit header file entry which should take priority over the # corresponding source file compile_commands = [ diff --git a/ycmd/tests/java/diagnostics_test.py b/ycmd/tests/java/diagnostics_test.py index 8861ef7d43..9b1235991c 100644 --- a/ycmd/tests/java/diagnostics_test.py +++ b/ycmd/tests/java/diagnostics_test.py @@ -24,7 +24,7 @@ from builtins import * # noqa from future.utils import iterkeys -from hamcrest import ( assert_that, contains, contains_inanyorder, has_entries ) +from hamcrest import assert_that, contains, contains_inanyorder, has_entries from nose.tools import eq_ from ycmd.tests.java import ( DEFAULT_PROJECT_DIR, @@ -34,8 +34,7 @@ SharedYcmd, WaitUntilCompleterServerReady ) -from ycmd.tests.test_utils import ( BuildRequest, - LocationMatcher ) +from ycmd.tests.test_utils import BuildRequest, LocationMatcher from ycmd.utils import ReadFile from pprint import pformat diff --git a/ycmd/tests/java/get_completions_test.py b/ycmd/tests/java/get_completions_test.py index 03396d5d1e..0b0e1ec8a5 100644 --- a/ycmd/tests/java/get_completions_test.py +++ b/ycmd/tests/java/get_completions_test.py @@ -34,7 +34,7 @@ from pprint import pformat import requests -from ycmd.tests.java import ( DEFAULT_PROJECT_DIR, PathToTestFile, SharedYcmd ) +from ycmd.tests.java import DEFAULT_PROJECT_DIR, PathToTestFile, SharedYcmd from ycmd.tests.test_utils import ( BuildRequest, ChunkMatcher, CompletionEntryMatcher, @@ -162,7 +162,7 @@ def GetCompletions_NoQuery_test( app ): @SharedYcmd def GetCompletions_WithQuery_test( app ): RunTest( app, { - 'description': 'semantic completion works for builtin types (no query)', + 'description': 'semantic completion works for builtin types (with query)', 'request': { 'filetype' : 'java', 'filepath' : ProjectPath( 'TestFactory.java' ), @@ -214,7 +214,7 @@ def GetCompletions_Package_test( app ): @SharedYcmd def GetCompletions_Import_Class_test( app ): RunTest( app, { - 'description': 'completion works for import statements', + 'description': 'completion works for import statements with a single class', 'request': { 'filetype' : 'java', 'filepath' : ProjectPath( 'TestLauncher.java' ), @@ -241,7 +241,7 @@ def GetCompletions_Import_Class_test( app ): def GetCompletions_Import_Classes_test( app ): filepath = ProjectPath( 'TestLauncher.java' ) RunTest( app, { - 'description': 'completion works for import statements', + 'description': 'completion works for imports with multiple classes', 'request': { 'filetype' : 'java', 'filepath' : filepath, @@ -280,7 +280,7 @@ def GetCompletions_Import_Classes_test( app ): def GetCompletions_Import_ModuleAndClass_test( app ): filepath = ProjectPath( 'TestLauncher.java' ) RunTest( app, { - 'description': 'completion works for import statements', + 'description': 'completion works for imports of classes and modules', 'request': { 'filetype' : 'java', 'filepath' : filepath, @@ -311,7 +311,7 @@ def GetCompletions_Import_ModuleAndClass_test( app ): def GetCompletions_WithFixIt_test( app ): filepath = ProjectPath( 'TestFactory.java' ) RunTest( app, { - 'description': 'semantic completion works for builtin types (no query)', + 'description': 'semantic completion with when additional textEdit', 'request': { 'filetype' : 'java', 'filepath' : filepath, @@ -369,7 +369,7 @@ def GetCompletions_WithFixIt_test( app ): def GetCompletions_RejectMultiLineInsertion_test( app ): filepath = ProjectPath( 'TestLauncher.java' ) RunTest( app, { - 'description': 'completion works for import statements', + 'description': 'completion item discarded when not valid', 'request': { 'filetype' : 'java', 'filepath' : filepath, diff --git a/ycmd/tests/java/server_management_test.py b/ycmd/tests/java/server_management_test.py index 7bbb350f06..56a3541dec 100644 --- a/ycmd/tests/java/server_management_test.py +++ b/ycmd/tests/java/server_management_test.py @@ -1,4 +1,4 @@ -# Copyright (C) 2015 ycmd contributors +# Copyright (C) 2017 ycmd contributors # encoding: utf-8 # # This file is part of ycmd. diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py index 122c1a3b3c..d79135ddfe 100644 --- a/ycmd/tests/java/subcommands_test.py +++ b/ycmd/tests/java/subcommands_test.py @@ -1,4 +1,4 @@ -# Copyright (C) 2015 ycmd contributors +# Copyright (C) 2017 ycmd contributors # encoding: utf-8 # # This file is part of ycmd. @@ -130,7 +130,7 @@ def Subcommands_GetDoc_NoDoc_test( app ): eq_( response.status_code, requests.codes.internal_server_error ) assert_that( response.json, - ErrorMatcher( ValueError, NO_DOCUMENTATION_MESSAGE ) ) + ErrorMatcher( RuntimeError, NO_DOCUMENTATION_MESSAGE ) ) @SharedYcmd @@ -153,8 +153,8 @@ def Subcommands_GetDoc_Method_test( app ): response = app.post_json( '/run_completer_command', event_data ).json eq_( response, { - 'message': 'Return runtime debugging info. ' - 'Useful for finding the actual code which is useful.' + 'detailed_info': 'Return runtime debugging info. Useful for finding the ' + 'actual code which is useful.' } ) @@ -178,9 +178,9 @@ def Subcommands_GetDoc_Class_test( app ): response = app.post_json( '/run_completer_command', event_data ).json eq_( response, { - 'message': 'This is the actual code that matters.' - ' This concrete implementation is the equivalent' - ' of the main function in other languages' + 'detailed_info': 'This is the actual code that matters. This concrete ' + 'implementation is the equivalent of the main function in ' + 'other languages' } ) @@ -209,8 +209,7 @@ def Subcommands_GetType_NoKnownType_test( app ): eq_( response.status_code, requests.codes.internal_server_error ) assert_that( response.json, - ErrorMatcher( RuntimeError, - 'No information' ) ) + ErrorMatcher( RuntimeError, 'Unknown type' ) ) @SharedYcmd @@ -413,8 +412,7 @@ def Subcommands_GetType_LiteralValue_test( app ): eq_( response.status_code, requests.codes.internal_server_error ) assert_that( response.json, - ErrorMatcher( RuntimeError, - 'No information' ) ) + ErrorMatcher( RuntimeError, 'Unknown type' ) ) @IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) From 24853c5c5e044b000fbafe91313d436543cd438e Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Mon, 6 Nov 2017 21:42:16 +0000 Subject: [PATCH 45/51] Further improvements to shutdown handling Join the dispatch thread after closing sockets Don't forcefully kill the server as this causes issues on Windows Ensure that we don't start (and not stop) a JediHTTP instance in shutdown tests Use SharedYcmd for handler tests Properly namespace server management tests --- ycmd/completers/java/java_completer.py | 53 +++++++------------ .../language_server_completer.py | 12 +++-- ycmd/tests/clang/__init__.py | 1 - ycmd/tests/java/__init__.py | 16 +++--- ycmd/tests/java/server_management_test.py | 12 ++--- ycmd/tests/misc_handlers_test.py | 11 ++-- 6 files changed, 46 insertions(+), 59 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 0fba0e7b9a..0b5349513d 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -400,55 +400,40 @@ def _StopServer( self ): if self._connection: self._connection.Stop() - # Tell the server to exit using the shutdown request. - self._StopServerCleanly() - - # If the server is still running, e.g. due to errors, kill it - self._StopServerForcefully() - - # Tidy up our internal state - self._CleanUp() - - _logger.info( 'Shutting down jdt.ls...complete.' ) - + if not self._ServerIsRunning(): + _logger.info( 'jdt.ls Language server not running' ) + self._CleanUp() + return - def _StopServerCleanly( self ): - # Try and shutdown cleanly - if self._ServerIsRunning(): _logger.info( 'Stopping java server with PID {0}'.format( self._server_handle.pid ) ) try: self.ShutdownServer() - utils.WaitUntilProcessIsTerminated( self._server_handle, - timeout = 15 ) - + # By this point, the server should have shut down and terminated. To + # ensure that isn't blocked, we close all of our connections and wait + # for the process to exit. + # + # If, after a small delay, the server has not shut down we do NOT kill + # it; we expect that it will shut itself down eventually. This is + # predominantly due to strange process behaviour on Windows. if self._connection: self._connection.Close() + utils.WaitUntilProcessIsTerminated( self._server_handle, + timeout = 15 ) + _logger.info( 'jdt.ls Language server stopped' ) except Exception: _logger.exception( 'Error while stopping jdt.ls server' ) + # We leave the process running. Hopefully it will eventually die of its + # own accord. + # Tidy up our internal state, even if the completer server didn't close + # down cleanly. + self._CleanUp() - def _StopServerForcefully( self ): - if self._ServerIsRunning(): - _logger.info( 'Killing jdt.ls server with PID {0}'.format( - self._server_handle.pid ) ) - - self._server_handle.terminate() - - try: - utils.WaitUntilProcessIsTerminated( self._server_handle, - timeout = 15 ) - - if self._connection: - self._connection.Close() - - _logger.info( 'jdt.ls Language server killed' ) - except Exception: - _logger.exception( 'Error while killing jdt.ls server' ) def HandleNotificationInPollThread( self, notification ): diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index a404f5b816..29a129d002 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -188,13 +188,13 @@ class LanguageServerConnection( threading.Thread ): Startup - - Call start() and AwaitServerConnection() + - Call Start() and AwaitServerConnection() - AwaitServerConnection() throws LanguageServerConnectionTimeout if the server fails to connect in a reasonable time. Shutdown - - Call stop() prior to shutting down the downstream server (see + - Call Stop() prior to shutting down the downstream server (see LanguageServerCompleter.ShutdownServer to do that part) - Call Close() to close any remaining streams. Do this in a request thread. DO NOT CALL THIS FROM THE DISPATCH THREAD. That is, Close() must not be @@ -283,8 +283,12 @@ def Stop( self ): def Close( self ): - self.join() self.Shutdown() + try: + self.join() + except RuntimeError: + _logger.exception( "Shutting down dispatch thread while it isn't active" ) + # This actually isn't a problem in practice. def IsStopped( self ): @@ -332,7 +336,7 @@ def SendNotification( self, message ): def AwaitServerConnection( self ): """Language server completer implementations should call this after starting - the server and the message pump (start()) to await successful connection to + the server and the message pump (Start()) to await successful connection to the server being established. Returns no meaningful value, but may throw LanguageServerConnectionTimeout diff --git a/ycmd/tests/clang/__init__.py b/ycmd/tests/clang/__init__.py index a34aa1c3ea..f43c58471f 100644 --- a/ycmd/tests/clang/__init__.py +++ b/ycmd/tests/clang/__init__.py @@ -28,7 +28,6 @@ import json -from ycmd.tests.test_utils import TemporaryTestDir as TemporaryClangTestDir # noqa from ycmd.utils import ToUnicode from ycmd.tests.test_utils import ClearCompletionsCache, IsolatedApp, SetUpApp diff --git a/ycmd/tests/java/__init__.py b/ycmd/tests/java/__init__.py index 78bedb6b9d..dbabb9ed01 100644 --- a/ycmd/tests/java/__init__.py +++ b/ycmd/tests/java/__init__.py @@ -27,10 +27,10 @@ import time from pprint import pformat -from ycmd import handlers from ycmd.tests.test_utils import ( BuildRequest, ClearCompletionsCache, CurrentWorkingDirectory, + IsolatedApp, SetUpApp, StopCompleterServer ) from ycmd.tests import test_utils @@ -98,14 +98,12 @@ def IsolatedYcmdInDirectory( directory ): def Decorator( test ): @functools.wraps( test ) def Wrapper( *args, **kwargs ): - old_server_state = handlers._server_state - app = SetUpApp() - try: - with CurrentWorkingDirectory( directory ): - test( app, *args, **kwargs ) - finally: - StopCompleterServer( app, 'java' ) - handlers._server_state = old_server_state + with IsolatedApp() as app: + try: + with CurrentWorkingDirectory( directory ): + test( app, *args, **kwargs ) + finally: + StopCompleterServer( app, 'java' ) return Wrapper return Decorator diff --git a/ycmd/tests/java/server_management_test.py b/ycmd/tests/java/server_management_test.py index 56a3541dec..0976b5106c 100644 --- a/ycmd/tests/java/server_management_test.py +++ b/ycmd/tests/java/server_management_test.py @@ -76,7 +76,7 @@ def Wrapper( *args, **kwargs ): @IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project' ) ) -def Subcommands_RestartServer_test( app ): +def ServerManagement_RestartServer_test( app ): WaitUntilCompleterServerReady( app ) eclipse_project = PathToTestFile( 'simple_eclipse_project' ) @@ -125,7 +125,7 @@ def Subcommands_RestartServer_test( app ): @IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project', 'src' ) ) -def Subcommands_ProjectDetection_EclipseParent_test( app ): +def ServerManagement_ProjectDetection_EclipseParent_test( app ): WaitUntilCompleterServerReady( app ) project = PathToTestFile( 'simple_eclipse_project' ) @@ -143,7 +143,7 @@ def Subcommands_ProjectDetection_EclipseParent_test( app ): 'java', 'com', 'test' ) ) -def Subcommands_ProjectDetection_MavenParent_test( app ): +def ServerManagement_ProjectDetection_MavenParent_test( app ): WaitUntilCompleterServerReady( app ) project = PathToTestFile( 'simple_maven_project' ) @@ -161,7 +161,7 @@ def Subcommands_ProjectDetection_MavenParent_test( app ): 'java', 'com', 'test' ) ) -def Subcommands_ProjectDetection_GradleParent_test( app ): +def ServerManagement_ProjectDetection_GradleParent_test( app ): WaitUntilCompleterServerReady( app ) project = PathToTestFile( 'simple_gradle_project' ) @@ -172,7 +172,7 @@ def Subcommands_ProjectDetection_GradleParent_test( app ): _ProjectDirectoryMatcher( project ) ) -def Subcommands_ProjectDetection_NoParent_test(): +def ServerManagement_ProjectDetection_NoParent_test(): with TemporaryTestDir() as tmp_dir: @IsolatedYcmdInDirectory( tmp_dir ) @@ -190,7 +190,7 @@ def Test( app ): @IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project' ) ) @patch( 'ycmd.completers.java.java_completer.JavaCompleter.ShutdownServer', side_effect = AssertionError ) -def CloseServer_Unclean_test( app, +def ServerManagement_CloseServer_Unclean_test( app, stop_server_cleanly ): WaitUntilCompleterServerReady( app ) diff --git a/ycmd/tests/misc_handlers_test.py b/ycmd/tests/misc_handlers_test.py index c963ada380..398424156c 100644 --- a/ycmd/tests/misc_handlers_test.py +++ b/ycmd/tests/misc_handlers_test.py @@ -199,15 +199,16 @@ def MiscHandlers_DebugInfo_ExtraConfFoundButNotLoaded_test( app ): ) -@IsolatedYcmd() +@SharedYcmd def MiscHandlers_ReceiveMessages_NoCompleter_test( app ): request_data = BuildRequest() assert_that( app.post_json( '/receive_messages', request_data ).json, equal_to( False ) ) -@IsolatedYcmd() +@SharedYcmd def MiscHandlers_ReceiveMessages_NotSupportedByCompleter_test( app ): - request_data = BuildRequest( filetype = 'python' ) - assert_that( app.post_json( '/receive_messages', request_data ).json, - equal_to( False ) ) + with PatchCompleter( DummyCompleter, filetype = 'dummy_filetype' ): + request_data = BuildRequest( filetype = 'dummy_filetype' ) + assert_that( app.post_json( '/receive_messages', request_data ).json, + equal_to( False ) ) From 1f8adc4dc5401f4794b58cf59256e83d11ea0458 Mon Sep 17 00:00:00 2001 From: micbou Date: Wed, 22 Nov 2017 11:36:44 +0100 Subject: [PATCH 46/51] Start jdt.ls on FileReadyToParse event --- ycmd/completers/java/java_completer.py | 79 +++++++++-------- .../language_server_completer.py | 11 +-- ycmd/tests/client_test.py | 9 -- ycmd/tests/java/__init__.py | 65 +++++++------- ycmd/tests/java/diagnostics_test.py | 9 +- ycmd/tests/java/server_management_test.py | 86 ++++++++----------- ycmd/tests/java/subcommands_test.py | 28 +++--- 7 files changed, 130 insertions(+), 157 deletions(-) diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 0b5349513d..208d21d95c 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -38,15 +38,16 @@ _logger = logging.getLogger( __name__ ) -LANGUAGE_SERVER_HOME = os.path.join( os.path.dirname( __file__ ), - '..', - '..', - '..', - 'third_party', - 'eclipse.jdt.ls', - 'org.eclipse.jdt.ls.product', - 'target', - 'repository') +LANGUAGE_SERVER_HOME = os.path.abspath( os.path.join( + os.path.dirname( __file__ ), + '..', + '..', + '..', + 'third_party', + 'eclipse.jdt.ls', + 'org.eclipse.jdt.ls.product', + 'target', + 'repository' ) ) PATH_TO_JAVA = utils.PathToFirstExistingExecutable( [ 'java' ] ) @@ -56,12 +57,13 @@ 'build.gradle' ] -WORKSPACE_ROOT_PATH = os.path.join( os.path.dirname( __file__ ), - '..', - '..', - '..', - 'third_party', - 'eclipse.jdt.ls-workspace' ) +WORKSPACE_ROOT_PATH = os.path.abspath( os.path.join( + os.path.dirname( __file__ ), + '..', + '..', + '..', + 'third_party', + 'eclipse.jdt.ls-workspace' ) ) # The authors of jdt.ls say that we should re-use workspaces. They also say that # occasionally, the workspace becomes corrupt, and has to be deleted. This is @@ -171,26 +173,11 @@ def __init__( self, user_options ): # Used to ensure that starting/stopping of the server is synchronized self._server_state_mutex = threading.RLock() - with self._server_state_mutex: - self._connection = None - self._server_handle = None - self._server_stderr = None - self._workspace_path = None - self._CleanUp() - - try : - # When we start the server initially, we don't have the request data, so - # we use the ycmd working directory. The RestartServer sub-command uses - # the client's working directory if it is supplied. - # - # FIXME: We could start the server in the FileReadyToParse event, though - # this requires some additional complexity and state management. - self._StartServer() - except Exception: - # We must catch any exception, to ensure that we do not end up with a - # rogue instance of jdt.ls running. - _logger.exception( "jdt.ls failed to start." ) - self._StopServer() + self._connection = None + self._server_handle = None + self._server_stderr = None + self._workspace_path = None + self._CleanUp() def SupportedFiletypes( self ): @@ -241,6 +228,12 @@ def GetConnection( self ): return self._connection + def OnFileReadyToParse( self, request_data ): + self._StartServer( request_data ) + + return super( JavaCompleter, self ).OnFileReadyToParse( request_data ) + + def DebugInfo( self, request_data ): items = [ responses.DebugInfoItem( 'Startup Status', self._server_init_status ), @@ -287,7 +280,7 @@ def ServerIsReady( self ): super( JavaCompleter, self ).ServerIsReady() ) - def _GetProjectDirectory( self ): + def _GetProjectDirectory( self, request_data ): return self._project_dir @@ -298,7 +291,7 @@ def _ServerIsRunning( self ): def _RestartServer( self, request_data ): with self._server_state_mutex: self._StopServer() - self._StartServer( request_data.get( 'working_dir' ) ) + self._StartServer( request_data ) def _CleanUp( self ): @@ -320,6 +313,7 @@ def _CleanUp( self ): self._project_dir = None self._received_ready_message = threading.Event() self._server_init_status = 'Not started' + self._server_started = False self._server_handle = None self._connection = None @@ -327,12 +321,17 @@ def _CleanUp( self ): self.ServerReset() - def _StartServer( self, working_dir=None ): + def _StartServer( self, request_data ): with self._server_state_mutex: + if self._server_started: + return + + self._server_started = True + _logger.info( 'Starting jdt.ls Language Server...' ) self._project_dir = _FindProjectDir( - working_dir if working_dir else utils.GetCurrentDirectory() ) + os.path.dirname( request_data[ 'filepath' ] ) ) self._workspace_path = _WorkspaceDirForProject( self._project_dir, self._use_clean_workspace ) @@ -382,7 +381,7 @@ def _StartServer( self, working_dir=None ): self._StopServer() return - self.SendInitialize() + self.SendInitialize( request_data ) def _StopServer( self ): diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 29a129d002..7b639309b6 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -999,16 +999,16 @@ def _UpdateServerWithFileContents( self, request_data ): del self._server_file_state[ file_name ] - def _GetProjectDirectory( self ): + def _GetProjectDirectory( self, request_data ): """Return the directory in which the server should operate. Language server protocol and most servers have a concept of a 'project directory'. By - default this is the working directory of the ycmd server, but + default this is the filepath directory of the initial request, but implementations may override this for example if there is a language- or server-specific notion of a project that can be detected.""" - return utils.GetCurrentDirectory() + return os.path.dirname( request_data[ 'filepath' ] ) - def SendInitialize( self ): + def SendInitialize( self, request_data ): """Sends the initialize request asynchronously. This must be called immediately after establishing the connection with the language server. Implementations must not issue further requests to the @@ -1019,7 +1019,8 @@ def SendInitialize( self ): assert not self._initialize_response request_id = self.GetConnection().NextRequestId() - msg = lsp.Initialize( request_id, self._GetProjectDirectory() ) + msg = lsp.Initialize( request_id, + self._GetProjectDirectory( request_data ) ) def response_handler( response, message ): if message is None: diff --git a/ycmd/tests/client_test.py b/ycmd/tests/client_test.py index 7c73b698a2..8f793037a6 100644 --- a/ycmd/tests/client_test.py +++ b/ycmd/tests/client_test.py @@ -32,10 +32,8 @@ import psutil import requests import subprocess -import shutil import sys import time -import tempfile from ycmd.hmac_utils import CreateHmac, CreateRequestHmac, SecureBytesEqual from ycmd.tests import PathToTestFile @@ -71,10 +69,6 @@ def setUp( self ): self._options_dict[ 'hmac_secret' ] = ToUnicode( b64encode( self._hmac_secret ) ) - self._orig_working_dir = os.getcwd() - self._working_dir = tempfile.mkdtemp() - os.chdir( self._working_dir ) - def tearDown( self ): for server in self._servers: @@ -84,9 +78,6 @@ def tearDown( self ): for logfile in self._logfiles: RemoveIfExists( logfile ) - os.chdir( self._orig_working_dir ) - shutil.rmtree( self._working_dir ) - def Start( self, idle_suicide_seconds = 60, check_interval_seconds = 60 * 10 ): diff --git a/ycmd/tests/java/__init__.py b/ycmd/tests/java/__init__.py index dbabb9ed01..0321bf3dfc 100644 --- a/ycmd/tests/java/__init__.py +++ b/ycmd/tests/java/__init__.py @@ -29,15 +29,12 @@ from ycmd.tests.test_utils import ( BuildRequest, ClearCompletionsCache, - CurrentWorkingDirectory, IsolatedApp, SetUpApp, - StopCompleterServer ) -from ycmd.tests import test_utils -from ycmd.utils import GetCurrentDirectory + StopCompleterServer, + WaitUntilCompleterServerReady ) shared_app = None -shared_current_dir = None DEFAULT_PROJECT_DIR = 'simple_eclipse_project' @@ -51,25 +48,30 @@ def setUpPackage(): by all tests using the SharedYcmd decorator in this package. Additional configuration that is common to these tests, like starting a semantic subserver, should be done here.""" - global shared_app, shared_current_dir - - shared_current_dir = GetCurrentDirectory() + global shared_app + shared_app = SetUpApp() # By default, we use the eclipse project for convenience. This means we don't # have to @IsolatedYcmdInDirectory( DEFAULT_PROJECT_DIR ) for every test - os.chdir( PathToTestFile( DEFAULT_PROJECT_DIR ) ) - - shared_app = SetUpApp() - WaitUntilCompleterServerReady( shared_app ) + StartJavaCompleterServerInDirectory( shared_app, + PathToTestFile( DEFAULT_PROJECT_DIR ) ) def tearDownPackage(): """Cleans up the tests using the SharedYcmd decorator in this package. It is executed once after running all the tests in the package.""" - global shared_app, shared_current_dir + global shared_app StopCompleterServer( shared_app, 'java' ) - os.chdir( shared_current_dir ) + + +def StartJavaCompleterServerInDirectory( app, directory ): + app.post_json( '/event_notification', + BuildRequest( + filepath = os.path.join( directory, 'test.java' ), + event_name = 'FileReadyToParse', + filetype = 'java' ) ) + WaitUntilCompleterServerReady( shared_app, 'java' ) def SharedYcmd( test ): @@ -86,31 +88,22 @@ def Wrapper( *args, **kwargs ): return Wrapper -def IsolatedYcmdInDirectory( directory ): +def IsolatedYcmd( test ): """Defines a decorator to be attached to tests of this package. This decorator - passes a unique ycmd application as a parameter running in the directory - supplied. It should be used on tests that change the server state in a - irreversible way (ex: a semantic subserver is stopped or restarted) or expect - a clean state (ex: no semantic subserver started, no .ycm_extra_conf.py - loaded, etc). + passes a unique ycmd application as a parameter. It should be used on tests + that change the server state in a irreversible way (ex: a semantic subserver + is stopped or restarted) or expect a clean state (ex: no semantic subserver + started, no .ycm_extra_conf.py loaded, etc). Do NOT attach it to test generators but directly to the yielded tests.""" - def Decorator( test ): - @functools.wraps( test ) - def Wrapper( *args, **kwargs ): - with IsolatedApp() as app: - try: - with CurrentWorkingDirectory( directory ): - test( app, *args, **kwargs ) - finally: - StopCompleterServer( app, 'java' ) - return Wrapper - - return Decorator - - -def WaitUntilCompleterServerReady( app, timeout = 30 ): - test_utils.WaitUntilCompleterServerReady( app, 'java', timeout ) + @functools.wraps( test ) + def Wrapper( *args, **kwargs ): + with IsolatedApp() as app: + try: + test( app, *args, **kwargs ) + finally: + StopCompleterServer( app, 'java' ) + return Wrapper def PollForMessages( app, request_data ): diff --git a/ycmd/tests/java/diagnostics_test.py b/ycmd/tests/java/diagnostics_test.py index 9b1235991c..ad40b7dc40 100644 --- a/ycmd/tests/java/diagnostics_test.py +++ b/ycmd/tests/java/diagnostics_test.py @@ -28,11 +28,11 @@ from nose.tools import eq_ from ycmd.tests.java import ( DEFAULT_PROJECT_DIR, - IsolatedYcmdInDirectory, + IsolatedYcmd, PathToTestFile, PollForMessages, SharedYcmd, - WaitUntilCompleterServerReady ) + StartJavaCompleterServerInDirectory ) from ycmd.tests.test_utils import BuildRequest, LocationMatcher from ycmd.utils import ReadFile @@ -191,9 +191,10 @@ def FileReadyToParse_Diagnostics_Simple_test( app ): assert_that( results, DIAG_MATCHERS_PER_FILE[ filepath ] ) -@IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) +@IsolatedYcmd def FileReadyToParse_Diagnostics_FileNotOnDisk_test( app ): - WaitUntilCompleterServerReady( app ) + StartJavaCompleterServerInDirectory( app, + PathToTestFile( DEFAULT_PROJECT_DIR ) ) contents = ''' package com.test; diff --git a/ycmd/tests/java/server_management_test.py b/ycmd/tests/java/server_management_test.py index 0976b5106c..bbad049103 100644 --- a/ycmd/tests/java/server_management_test.py +++ b/ycmd/tests/java/server_management_test.py @@ -33,9 +33,11 @@ has_entry, has_item ) from ycmd.tests.java import ( PathToTestFile, - IsolatedYcmdInDirectory, - WaitUntilCompleterServerReady ) -from ycmd.tests.test_utils import BuildRequest, TemporaryTestDir + IsolatedYcmd, + StartJavaCompleterServerInDirectory ) +from ycmd.tests.test_utils import ( BuildRequest, + TemporaryTestDir, + WaitUntilCompleterServerReady ) from ycmd import utils @@ -75,9 +77,10 @@ def Wrapper( *args, **kwargs ): return decorator -@IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project' ) ) +@IsolatedYcmd def ServerManagement_RestartServer_test( app ): - WaitUntilCompleterServerReady( app ) + StartJavaCompleterServerInDirectory( + app, PathToTestFile( 'simple_eclipse_project' ) ) eclipse_project = PathToTestFile( 'simple_eclipse_project' ) maven_project = PathToTestFile( 'simple_maven_project' ) @@ -106,7 +109,7 @@ def ServerManagement_RestartServer_test( app ): ), ) - WaitUntilCompleterServerReady( app ) + WaitUntilCompleterServerReady( app, 'java' ) app.post_json( '/event_notification', @@ -124,9 +127,10 @@ def ServerManagement_RestartServer_test( app ): _ProjectDirectoryMatcher( maven_project ) ) -@IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project', 'src' ) ) +@IsolatedYcmd def ServerManagement_ProjectDetection_EclipseParent_test( app ): - WaitUntilCompleterServerReady( app ) + StartJavaCompleterServerInDirectory( + app, PathToTestFile( 'simple_eclipse_project', 'src' ) ) project = PathToTestFile( 'simple_eclipse_project' ) @@ -137,14 +141,15 @@ def ServerManagement_ProjectDetection_EclipseParent_test( app ): @TidyJDTProjectFiles( PathToTestFile( 'simple_maven_project' ) ) -@IsolatedYcmdInDirectory( PathToTestFile( 'simple_maven_project', - 'src', - 'main', - 'java', - 'com', - 'test' ) ) +@IsolatedYcmd def ServerManagement_ProjectDetection_MavenParent_test( app ): - WaitUntilCompleterServerReady( app ) + StartJavaCompleterServerInDirectory( app, + PathToTestFile( 'simple_maven_project', + 'src', + 'main', + 'java', + 'com', + 'test' ) ) project = PathToTestFile( 'simple_maven_project' ) @@ -155,14 +160,15 @@ def ServerManagement_ProjectDetection_MavenParent_test( app ): @TidyJDTProjectFiles( PathToTestFile( 'simple_maven_project' ) ) -@IsolatedYcmdInDirectory( PathToTestFile( 'simple_gradle_project', - 'src', - 'main', - 'java', - 'com', - 'test' ) ) +@IsolatedYcmd def ServerManagement_ProjectDetection_GradleParent_test( app ): - WaitUntilCompleterServerReady( app ) + StartJavaCompleterServerInDirectory( app, + PathToTestFile( 'simple_gradle_project', + 'src', + 'main', + 'java', + 'com', + 'test' ) ) project = PathToTestFile( 'simple_gradle_project' ) @@ -175,51 +181,29 @@ def ServerManagement_ProjectDetection_GradleParent_test( app ): def ServerManagement_ProjectDetection_NoParent_test(): with TemporaryTestDir() as tmp_dir: - @IsolatedYcmdInDirectory( tmp_dir ) + @IsolatedYcmd def Test( app ): - WaitUntilCompleterServerReady( app ) + StartJavaCompleterServerInDirectory( app, tmp_dir ) # Run the debug info to check that we have the correct project dir (cwd) request_data = BuildRequest( filetype = 'java' ) assert_that( app.post_json( '/debug_info', request_data ).json, - _ProjectDirectoryMatcher( os.path.realpath( tmp_dir ) ) ) + _ProjectDirectoryMatcher( tmp_dir ) ) yield Test -@IsolatedYcmdInDirectory( PathToTestFile( 'simple_eclipse_project' ) ) +@IsolatedYcmd @patch( 'ycmd.completers.java.java_completer.JavaCompleter.ShutdownServer', side_effect = AssertionError ) -def ServerManagement_CloseServer_Unclean_test( app, - stop_server_cleanly ): - WaitUntilCompleterServerReady( app ) - - filepath = PathToTestFile( 'simple_maven_project', - 'src', - 'main', - 'java', - 'com', - 'test', - 'TestFactory.java' ) - - app.post_json( - '/event_notification', - BuildRequest( - filepath = filepath, - filetype = 'java', - working_dir = PathToTestFile( 'simple_eclipse_project' ), - event_name = 'FileReadyToParse', - ) - ) - - WaitUntilCompleterServerReady( app ) +def ServerManagement_CloseServer_Unclean_test( app, stop_server_cleanly ): + StartJavaCompleterServerInDirectory( + app, PathToTestFile( 'simple_eclipse_project' ) ) app.post_json( '/run_completer_command', BuildRequest( - filepath = filepath, filetype = 'java', - working_dir = PathToTestFile( 'simple_eclipse_project' ), command_arguments = [ 'StopServer' ], ), ) diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py index d79135ddfe..38dec94ac6 100644 --- a/ycmd/tests/java/subcommands_test.py +++ b/ycmd/tests/java/subcommands_test.py @@ -35,11 +35,11 @@ from ycmd.utils import ReadFile from ycmd.completers.java.java_completer import NO_DOCUMENTATION_MESSAGE -from ycmd.tests.java import ( PathToTestFile, +from ycmd.tests.java import ( DEFAULT_PROJECT_DIR, + IsolatedYcmd, + PathToTestFile, SharedYcmd, - IsolatedYcmdInDirectory, - WaitUntilCompleterServerReady, - DEFAULT_PROJECT_DIR ) + StartJavaCompleterServerInDirectory ) from ycmd.tests.test_utils import ( BuildRequest, ChunkMatcher, ErrorMatcher, @@ -105,9 +105,10 @@ def CombineRequest( request, data ): assert_that( response.json, test[ 'expect' ][ 'data' ] ) -@IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) +@IsolatedYcmd def Subcommands_GetDoc_NoDoc_test( app ): - WaitUntilCompleterServerReady( app ) + StartJavaCompleterServerInDirectory( app, + PathToTestFile( DEFAULT_PROJECT_DIR ) ) filepath = PathToTestFile( 'simple_eclipse_project', 'src', 'com', @@ -184,9 +185,10 @@ def Subcommands_GetDoc_Class_test( app ): } ) -@IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) +@IsolatedYcmd def Subcommands_GetType_NoKnownType_test( app ): - WaitUntilCompleterServerReady( app ) + StartJavaCompleterServerInDirectory( app, + PathToTestFile( DEFAULT_PROJECT_DIR ) ) filepath = PathToTestFile( 'simple_eclipse_project', 'src', 'com', @@ -415,9 +417,10 @@ def Subcommands_GetType_LiteralValue_test( app ): ErrorMatcher( RuntimeError, 'Unknown type' ) ) -@IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) +@IsolatedYcmd def Subcommands_GoTo_NoLocation_test( app ): - WaitUntilCompleterServerReady( app ) + StartJavaCompleterServerInDirectory( app, + PathToTestFile( DEFAULT_PROJECT_DIR ) ) filepath = PathToTestFile( 'simple_eclipse_project', 'src', 'com', @@ -443,9 +446,10 @@ def Subcommands_GoTo_NoLocation_test( app ): ErrorMatcher( RuntimeError, 'Cannot jump to location' ) ) -@IsolatedYcmdInDirectory( PathToTestFile( DEFAULT_PROJECT_DIR ) ) +@IsolatedYcmd def Subcommands_GoToReferences_NoReferences_test( app ): - WaitUntilCompleterServerReady( app ) + StartJavaCompleterServerInDirectory( app, + PathToTestFile( DEFAULT_PROJECT_DIR ) ) filepath = PathToTestFile( 'simple_eclipse_project', 'src', 'com', From 79d8544d9d7d47523340c6bac396b47eb3449524 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Wed, 27 Dec 2017 23:54:07 +0000 Subject: [PATCH 47/51] Update to jdt.ls v0.11.0 and complete coverage tests This required a number of things, not least implementing a proper model of the server file state and using that to reduce the number of didClose/didChange events to send. This fixes the interpretation of the file_data: only modified buffers are included, so we must read the contents of non-modified (but open) buffers from disk. Finally, we also move to using binary packages to improve the build times, and tidy up the third party area. --- .circleci/config.yml | 1 + .circleci/install_dependencies.sh | 11 + .gitignore | 5 +- .gitmodules | 3 - .travis.yml | 2 + appveyor.yml | 2 + build.py | 76 ++- ci/appveyor/appveyor_install.bat | 12 + ci/travis/travis_install.sh | 16 + docs/openapi.yaml | 18 +- third_party/eclipse.jdt.ls | 1 - ycmd/completers/java/java_completer.py | 84 +++- .../language_server_completer.py | 242 +++++++--- .../language_server_protocol.py | 117 ++++- ycmd/request_wrap.py | 14 +- ycmd/responses.py | 14 +- ycmd/tests/java/__init__.py | 17 +- ycmd/tests/java/diagnostics_test.py | 440 ++++++++++++++++-- ycmd/tests/java/get_completions_test.py | 78 +++- ycmd/tests/java/java_completer_test.py | 166 ++++++- ycmd/tests/java/server_management_test.py | 175 ++++++- ycmd/tests/java/subcommands_test.py | 243 +++++++++- ycmd/tests/language_server/__init__.py | 43 ++ .../language_server_completer_test.py | 388 +++++++++++++++ .../language_server_connection_test.py | 123 +++++ .../language_server_protocol_test.py | 182 ++++++++ ycmd/tests/server_utils_test.py | 1 - ycmd/tests/shutdown_test.py | 4 +- 28 files changed, 2300 insertions(+), 178 deletions(-) delete mode 160000 third_party/eclipse.jdt.ls create mode 100644 ycmd/tests/language_server/__init__.py create mode 100644 ycmd/tests/language_server/language_server_completer_test.py create mode 100644 ycmd/tests/language_server/language_server_connection_test.py create mode 100644 ycmd/tests/language_server/language_server_protocol_test.py diff --git a/.circleci/config.yml b/.circleci/config.yml index ab1a80e0e5..734fb1772d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -31,6 +31,7 @@ aliases: - ~/.pyenv - clang_archives - third_party/racerd/target + - third_party/eclipse.jdt.ls/target/cache restore-cache: &restore-cache restore_cache: key: v3-ycmd-{{ .Environment.CIRCLE_JOB }} diff --git a/.circleci/install_dependencies.sh b/.circleci/install_dependencies.sh index 3f40dd05ac..c79b0deb4c 100755 --- a/.circleci/install_dependencies.sh +++ b/.circleci/install_dependencies.sh @@ -104,4 +104,15 @@ echo "export PATH=${CARGO_PATH}:\$PATH" >> $BASH_ENV npm install -g typescript +################# +# Java 8 setup +################# + +java -version +JAVA_VERSION=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}') +if [[ "$JAVA_VERSION" < "1.8" ]]; then + echo "Java version $JAVA_VERSION is too old" 1>&2 + exit 1 +fi + set +e diff --git a/.gitignore b/.gitignore index 1508269176..727cebfe13 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,5 @@ coverage.xml docs/node_modules docs/package-lock.json -# The JDT java completer requires a workspace (for no explicable reason other -# than "eclipse") -third_party/eclipse.jdt.ls-workspace +# jdt.ls +third_party/eclipse.jdt.ls diff --git a/.gitmodules b/.gitmodules index 01d6342647..8ae61da77c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -32,6 +32,3 @@ [submodule "third_party/python-future"] path = third_party/python-future url = https://github.com/PythonCharmers/python-future -[submodule "third_party/eclipse.jdt.ls"] - path = third_party/eclipse.jdt.ls - url = https://github.com/eclipse/eclipse.jdt.ls diff --git a/.travis.yml b/.travis.yml index efad7407a0..4f89e27cd9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -64,3 +64,5 @@ cache: - $HOME/.pyenv # pyenv - $TRAVIS_BUILD_DIR/clang_archives # Clang downloads - $TRAVIS_BUILD_DIR/third_party/racerd/target # Racerd compilation + # jdt.ls download + - $TRAVIS_BUILD_DIR/third_party/eclipse.jdt.ls/target/cache diff --git a/appveyor.yml b/appveyor.yml index 3512ab4c1e..1ae832940d 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -39,3 +39,5 @@ cache: - '%USERPROFILE%\.cargo' # Cargo package deps - '%APPVEYOR_BUILD_FOLDER%\clang_archives' # Clang downloads - '%APPVEYOR_BUILD_FOLDER%\third_party\racerd\target' # Racerd compilation + # jdt.ls download + - '%APPVEYOR_BUILD_FOLDER%\third_party\eclipse.jdt.ls\target\cache' diff --git a/build.py b/build.py index 08c19ff206..f231feb68b 100755 --- a/build.py +++ b/build.py @@ -19,6 +19,9 @@ import shlex import subprocess import sys +import tarfile +import shutil +import hashlib PY_MAJOR, PY_MINOR = sys.version_info[ 0 : 2 ] if not ( ( PY_MAJOR == 2 and PY_MINOR >= 6 ) or @@ -47,8 +50,10 @@ ) sys.path.insert( 1, p.abspath( p.join( DIR_OF_THIRD_PARTY, 'argparse' ) ) ) +sys.path.insert( 1, p.abspath( p.join( DIR_OF_THIRD_PARTY, 'requests' ) ) ) import argparse +import requests NO_DYNAMIC_PYTHON_ERROR = ( 'ERROR: found static Python library ({library}) but a dynamic one is ' @@ -80,6 +85,12 @@ )$ """ +JDTLS_MILESTONE = '0.11.0' +JDTLS_BUILD_STAMP = '201801162212' +JDTLS_SHA256 = ( + '5afa45d1ba3d38d4c6c9ef172874b430730ee168db365c5e5209b39d53deab23' +) + def OnMac(): return platform.system() == 'Darwin' @@ -541,19 +552,58 @@ def EnableJavaScriptCompleter(): def EnableJavaCompleter(): - os.chdir( p.join( DIR_OF_THIS_SCRIPT, - 'third_party', - 'eclipse.jdt.ls' ) ) - - mvnw = 'mvnw.cmd' if OnWindows() else './mvnw' - - # Maven actually just straight up sucks. There is seemingly no way to do - # working, reliable incremental builds. It also takes _forever_ doing things - # that you _don't want it to do_, like downloading the internet. - # Alas, I'm not aware of a better way, and these are the instructions provided - # by the people that made jdt.ls, so we waste the user's time (somewhat) - # unnecessarily. - CheckCall( [ mvnw, 'clean', 'package' ] ) + TARGET = p.join( DIR_OF_THIRD_PARTY, 'eclipse.jdt.ls', 'target', ) + REPOSITORY = p.join( TARGET, 'repository' ) + CACHE = p.join( TARGET, 'cache' ) + + JDTLS_SERVER_URL_FORMAT = ( 'http://download.eclipse.org/jdtls/milestones/' + '{jdtls_milestone}/{jdtls_package_name}' ) + JDTLS_PACKAGE_NAME_FORMAT = ( 'jdt-language-server-{jdtls_milestone}-' + '{jdtls_build_stamp}.tar.gz' ) + + package_name = JDTLS_PACKAGE_NAME_FORMAT.format( + jdtls_milestone = JDTLS_MILESTONE, + jdtls_build_stamp = JDTLS_BUILD_STAMP ) + url = JDTLS_SERVER_URL_FORMAT.format( + jdtls_milestone = JDTLS_MILESTONE, + jdtls_build_stamp = JDTLS_BUILD_STAMP, + jdtls_package_name = package_name ) + file_name = p.join( CACHE, package_name ) + + if p.exists( REPOSITORY ): + shutil.rmtree( REPOSITORY ) + + os.makedirs( REPOSITORY ) + + if not p.exists( CACHE ): + os.makedirs( CACHE ) + elif p.exists( file_name ): + with open( file_name, 'rb' ) as existing_file: + existing_sha256 = hashlib.sha256( existing_file.read() ).hexdigest() + if existing_sha256 != JDTLS_SHA256: + print( 'Cached tar file does not match checksum. Removing...' ) + os.remove( file_name ) + + + if p.exists( file_name ): + print( 'Using cached jdt.ls: {0}'.format( file_name ) ) + else: + print( "Downloading jdt.ls from {0}...".format( url ) ) + request = requests.get( url, stream = True ) + with open( file_name, 'wb' ) as package_file: + package_file.write( request.content ) + request.close() + + print( "Extracting jdt.ls to {0}...".format( REPOSITORY ) ) + # We can't use tarfile.open as a context manager, as it isn't supported in + # python 2.6 + try: + package_tar = tarfile.open( file_name ) + package_tar.extractall( REPOSITORY ) + finally: + package_tar.close() + + print( "Done installing jdt.ls" ) def WritePythonUsedDuringBuild(): diff --git a/ci/appveyor/appveyor_install.bat b/ci/appveyor/appveyor_install.bat index 0c7c688f5a..67c5ad70e9 100644 --- a/ci/appveyor/appveyor_install.bat +++ b/ci/appveyor/appveyor_install.bat @@ -58,3 +58,15 @@ set PATH=%USERPROFILE%\.cargo\bin;%PATH% rustup update rustc -Vv cargo -V + +:: +:: Java Configuration (Java 8 required for jdt.ls) +:: +if %arch% == 32 ( + set "JAVA_HOME=C:\Program Files (x86)\Java\jdk1.8.0" +) else ( + set "JAVA_HOME=C:\Program Files\Java\jdk1.8.0" +) + +set PATH=%JAVA_HOME%\bin;%PATH% +java -version diff --git a/ci/travis/travis_install.sh b/ci/travis/travis_install.sh index 1a23984ccd..e6e82ec05a 100755 --- a/ci/travis/travis_install.sh +++ b/ci/travis/travis_install.sh @@ -106,4 +106,20 @@ nvm install 4 npm install -g typescript +############### +# Java 8 setup +############### +# Make sure we have the appropriate java for jdt.ls +set +e +jdk_switcher use oraclejdk8 +set -e + +java -version +JAVA_VERSION=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}') +if [[ "$JAVA_VERSION" < "1.8" ]]; then + echo "Java version $JAVA_VERSION is too old" 1>&2 + exit 1 +fi + +# Done. Undo settings which break travis scripts. set +e diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5faf5d11af..149ebf83af 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -167,7 +167,19 @@ definitions: type: object description: |- An object mapping whose keys are the absolute paths to the - files and whose values are data relating to dirty buffers. + files and whose values are data relating unsaved buffers. + + An unsaved buffer is any file that is opened in the editor and has been + changed without saving the contents to disk. + + The file referred to in the request `filepath` entry must _always_ be + included. For most requests this is the user's current buffer, but may + be any buffer (e.g. in the case of closing a buffer which is not current). + + When a file is closed in the editor, a `BufferUnload` event should be sent + and the file should not be included in further `FileDataMap` entries + until (or unless) it is opened and changed again. + additionalProperties: $ref: "#/definitions/FileData" @@ -512,9 +524,9 @@ paths: believes that it is worthwhile reparsing the current file and updating semantic engines'' ASTs and reporting things like updated diagnostics. - - `BufferUnload` (optional) + - `BufferUnload` Call when the user closes a buffer that was previously known to be - open. + open. Closing buffers is important to limit resource usage. - `BufferVisit` (optional) Call when the user focusses on a buffer that is already known. *Note*: The `ultisnips_snippets` property is optional when firing diff --git a/third_party/eclipse.jdt.ls b/third_party/eclipse.jdt.ls deleted file mode 160000 index aec31fbb8f..0000000000 --- a/third_party/eclipse.jdt.ls +++ /dev/null @@ -1 +0,0 @@ -Subproject commit aec31fbb8fa88bc5c6c205ac517a770ad035b38d diff --git a/ycmd/completers/java/java_completer.py b/ycmd/completers/java/java_completer.py index 208d21d95c..625fdb0347 100644 --- a/ycmd/completers/java/java_completer.py +++ b/ycmd/completers/java/java_completer.py @@ -45,7 +45,6 @@ '..', 'third_party', 'eclipse.jdt.ls', - 'org.eclipse.jdt.ls.product', 'target', 'repository' ) ) @@ -63,7 +62,8 @@ '..', '..', 'third_party', - 'eclipse.jdt.ls-workspace' ) ) + 'eclipse.jdt.ls', + 'workspace' ) ) # The authors of jdt.ls say that we should re-use workspaces. They also say that # occasionally, the workspace becomes corrupt, and has to be deleted. This is @@ -462,16 +462,35 @@ def ConvertNotificationToMessage( self, request_data, notification ): def GetType( self, request_data ): hover_response = self.GetHoverResponse( request_data ) - if isinstance( hover_response, list ): - if not len( hover_response ): - raise RuntimeError( 'Unknown type' ) + # The LSP defines the hover response as either: + # - a string + # - a list of strings + # - an object with keys language, value + # - a list of objects with keys language, value + # - an object with keys kind, value + + # That's right. All of the above. + + # However it would appear that jdt.ls only ever returns useful data when it + # is a list of objects-with-keys-language-value, and the type information is + # always in the first such list element, so we only handle that case and + # throw any other time. + + # Strictly we seem to receive: + # - [""] + # when there really is no documentation or type info available + # - [{language:java, value:}] + # when there only the type information is available + # - [{language:java, value:}, + # 'doc line 1', + # 'doc line 2', + # ...] + # when there is type and documentation information available. - try: - get_type_java = hover_response[ 0 ][ 'value' ] - except TypeError: - raise RuntimeError( 'Unknown type' ) - else: - get_type_java = hover_response + try: + get_type_java = hover_response[ 0 ][ 'value' ] + except ( KeyError, TypeError, IndexError ): + raise RuntimeError( 'Unknown type' ) return responses.BuildDisplayMessageResponse( get_type_java ) @@ -479,23 +498,42 @@ def GetType( self, request_data ): def GetDoc( self, request_data ): hover_response = self.GetHoverResponse( request_data ) + # The LSP defines the hover response as either: + # - a string + # - a list of strings + # - an object with keys language, value + # - a list of objects with keys language, value + # - an object with keys kind, value + + # That's right. All of the above. + + # However it would appear that jdt.ls only ever returns useful data when it + # is a list of objects-with-keys-language-value, so we only handle that case + # and throw any other time. + + # Strictly we seem to receive: + # - [""] + # when there really is no documentation or type info available + # - [{language:java, value:}] + # when there only the type information is available + # - [{language:java, value:}, + # 'doc line 1', + # 'doc line 2', + # ...] + # when there is type and documentation information available. + + documentation = '' if isinstance( hover_response, list ): - if not len( hover_response ): - raise RuntimeError( NO_DOCUMENTATION_MESSAGE ) - - get_doc_java = '' - for docstring in hover_response: - if not isinstance( docstring, dict ): - get_doc_java += docstring + '\n' - else: - get_doc_java = hover_response + for item in hover_response: + if isinstance( item, str ): + documentation += item + '\n' - get_doc_java = get_doc_java.rstrip() + documentation = documentation.rstrip() - if not get_doc_java: + if not documentation: raise RuntimeError( NO_DOCUMENTATION_MESSAGE ) - return responses.BuildDetailedInfoResponse( get_doc_java ) + return responses.BuildDetailedInfoResponse( documentation ) def HandleServerCommand( 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 7b639309b6..b50716d8ad 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -51,36 +51,36 @@ class ResponseTimeoutException( Exception ): """Raised by LanguageServerConnection if a request exceeds the supplied time-to-live.""" - pass + pass # pragma: no cover class ResponseAbortedException( Exception ): """Raised by LanguageServerConnection if a request is canceled due to the server shutting down.""" - pass + pass # pragma: no cover class ResponseFailedException( Exception ): """Raised by LanguageServerConnection if a request returns an error""" - pass + pass # pragma: no cover class IncompatibleCompletionException( Exception ): """Internal exception returned when a completion item is encountered which is not supported by ycmd, or where the completion item is invalid.""" - pass + pass # pragma: no cover class LanguageServerConnectionTimeout( Exception ): """Raised by LanguageServerConnection if the connection to the server is not established with the specified timeout.""" - pass + pass # pragma: no cover class LanguageServerConnectionStopped( Exception ): """Internal exception raised by LanguageServerConnection when the server is successfully shut down according to user request.""" - pass + pass # pragma: no cover class Response( object ): @@ -122,7 +122,7 @@ def Abort( self ): def AwaitResponse( self, timeout ): """Called by clients to wait synchronously for either a response to be - received of for |timeout| seconds to have passed. + received or for |timeout| seconds to have passed. Returns the message, or: - throws ResponseFailedException if the request fails - throws ResponseTimeoutException in case of timeout @@ -216,22 +216,22 @@ class LanguageServerConnection( threading.Thread ): """ @abc.abstractmethod def TryServerConnectionBlocking( self ): - pass + pass # pragma: no cover @abc.abstractmethod def Shutdown( self ): - pass + pass # pragma: no cover @abc.abstractmethod def WriteData( self, data ): - pass + pass # pragma: no cover @abc.abstractmethod def ReadData( self, size=-1 ): - pass + pass # pragma: no cover def __init__( self, notification_handler = None ): @@ -270,6 +270,14 @@ def run( self ): 'unexpectedly. Issue a RestartServer command to ' 'recover.' ) + # Abort any outstanding requests + with self._response_mutex: + for _, response in iteritems( self._responses ): + response.Abort() + self._responses.clear() + + # Close any remaining sockets or files + self.Shutdown() def Start( self ): @@ -484,6 +492,15 @@ def __init__( self, self._server_stdin = server_stdin self._server_stdout = server_stdout + # NOTE: All access to the stdin/out objects must be synchronised due to the + # long-running `read` operations that are done on stdout, and how our + # shutdown request will come from another (arbitrary) thread. It is not + # legal in Python to close a stdio file while there is a pending read. This + # can lead to IOErrors due to "concurrent operations' on files. + # See https://stackoverflow.com/q/29890603/2327209 + self._stdin_lock = threading.Lock() + self._stdout_lock = threading.Lock() + def TryServerConnectionBlocking( self ): # standard in/out don't need to wait for the server to connect to us @@ -491,30 +508,36 @@ def TryServerConnectionBlocking( self ): def Shutdown( self ): - if not self._server_stdin.closed: - self._server_stdin.close() + with self._stdin_lock: + if not self._server_stdin.closed: + self._server_stdin.close() - if not self._server_stdout.closed: - self._server_stdout.close() + with self._stdout_lock: + if not self._server_stdout.closed: + self._server_stdout.close() def WriteData( self, data ): - self._server_stdin.write( data ) - self._server_stdin.flush() + with self._stdin_lock: + self._server_stdin.write( data ) + self._server_stdin.flush() def ReadData( self, size=-1 ): - if size > -1: - data = self._server_stdout.read( size ) - else: - data = self._server_stdout.readline() - - if self.IsStopped(): - raise LanguageServerConnectionStopped() + data = None + with self._stdout_lock: + if not self._server_stdout.closed: + if size > -1: + data = self._server_stdout.read( size ) + else: + data = self._server_stdout.readline() if not data: # No data means the connection was severed. Connection severed when (not # self.IsStopped()) means the server died unexpectedly. + if self.IsStopped(): + raise LanguageServerConnectionStopped() + raise RuntimeError( "Connection to server died" ) return data @@ -579,12 +602,12 @@ def GetConnection( sefl ): """Method that must be implemented by derived classes to return an instance of LanguageServerConnection appropriate for the language server in question""" - pass + pass # pragma: no cover @abc.abstractmethod def HandleServerCommand( self, request_data, command ): - pass + pass # pragma: no cover def __init__( self, user_options): @@ -612,7 +635,7 @@ def ServerReset( self ): Implementations are required to call this after disconnection and killing the downstream server.""" with self._server_info_mutex: - self._server_file_state = {} + self._server_file_state = lsp.ServerFileStateStore() self._latest_diagnostics = collections.defaultdict( list ) self._sync_type = 'Full' self._initialize_response = None @@ -955,6 +978,13 @@ def ConvertNotificationToMessage( self, request_data, notification ): return None + def _AnySupportedFileType( self, file_types ): + for supported in self.SupportedFiletypes(): + if supported in file_types: + return True + return False + + def _UpdateServerWithFileContents( self, request_data ): """Update the server with the current contents of all open buffers, and close any buffers no longer open. @@ -962,41 +992,112 @@ def _UpdateServerWithFileContents( self, request_data ): This method should be called frequently and in any event before a synchronous operation.""" with self._server_info_mutex: - for file_name, file_data in iteritems( request_data[ 'file_data' ] ): - file_state = 'New' - if file_name in self._server_file_state: - file_state = self._server_file_state[ file_name ] - - _logger.debug( 'Refreshing file {0}: State is {1}'.format( - file_name, file_state ) ) - if file_state == 'New' or self._sync_type == 'Full': - msg = lsp.DidOpenTextDocument( file_name, - file_data[ 'filetypes' ], - file_data[ 'contents' ] ) - else: - # FIXME: DidChangeTextDocument doesn't actually do anything different - # from DidOpenTextDocument other than send the right message, because - # we don't actually have a mechanism for generating the diffs or - # proper document version or life-cycle management. This isn't - # strictly necessary, but might lead to performance problems. - msg = lsp.DidChangeTextDocument( file_name, - file_data[ 'filetypes' ], - file_data[ 'contents' ] ) - - self._server_file_state[ file_name ] = 'Open' + self._UpdateDirtyFilesUnderLock( request_data ) + files_to_purge = self._UpdateSavedFilesUnderLock( request_data ) + self._PurgeMissingFilesUnderLock( files_to_purge ) + + + def _UpdateDirtyFilesUnderLock( self, request_data ): + for file_name, file_data in iteritems( request_data[ 'file_data' ] ): + if not self._AnySupportedFileType( file_data[ 'filetypes' ] ): + continue + + file_state = self._server_file_state[ file_name ] + action = file_state.GetDirtyFileAction( file_data[ 'contents' ] ) + + _logger.debug( 'Refreshing file {0}: State is {1}/action {2}'.format( + file_name, file_state.state, action ) ) + + if action == lsp.ServerFileState.OPEN_FILE: + msg = lsp.DidOpenTextDocument( file_state, + file_data[ 'filetypes' ], + file_data[ 'contents' ] ) + self.GetConnection().SendNotification( msg ) + elif action == lsp.ServerFileState.CHANGE_FILE: + # FIXME: DidChangeTextDocument doesn't actually do anything + # different from DidOpenTextDocument other than send the right + # message, because we don't actually have a mechanism for generating + # the diffs. This isn't strictly necessary, but might lead to + # performance problems. + msg = lsp.DidChangeTextDocument( file_state, file_data[ 'contents' ] ) - stale_files = list() - for file_name in iterkeys( self._server_file_state ): - if file_name not in request_data[ 'file_data' ]: - stale_files.append( file_name ) + self.GetConnection().SendNotification( msg ) - # We can't change the dictionary entries while using iterkeys, so we do - # that in a separate loop. - for file_name in stale_files: - msg = lsp.DidCloseTextDocument( file_name ) - self.GetConnection().SendNotification( msg ) - del self._server_file_state[ file_name ] + + def _UpdateSavedFilesUnderLock( self, request_data ): + files_to_purge = list() + for file_name, file_state in iteritems( self._server_file_state ): + if file_name in request_data[ 'file_data' ]: + continue + + # We also need to tell the server the contents of any files we have said + # are open, but are not 'dirty' in the editor. This is because after + # sending a didOpen notification, we own the contents of the file. + # + # So for any file that is in the server map, and open, but not supplied in + # the request, we check to see if its on-disk contents match the latest in + # the server. If they don't, we send an update. + # + # FIXME: This is really inefficient currently, as it reads the entire file + # on every update. It might actually be better to close files which have + # been saved and are no longer "dirty", though that would likely be less + # efficient for downstream servers which cache e.g. AST. + try: + contents = GetFileContents( request_data, file_name ) + except IOError: + _logger.exception( 'Error getting contents for open file: {0}'.format( + file_name ) ) + + # The file no longer exists (it might have been a temporary file name) + # or it is no longer accessible, so we should state that it is closed. + # If it were still open it would have been in the request_data. + # + # We have to do this in a separate loop because we can't change + # self._server_file_state while iterating it. + files_to_purge.append( file_name ) + continue + + action = file_state.GetSavedFileAction( contents ) + if action == lsp.ServerFileState.CHANGE_FILE: + msg = lsp.DidChangeTextDocument( file_state, contents ) + self.GetConnection().SendNotification( msg ) + + return files_to_purge + + + def _PurgeMissingFilesUnderLock( self, files_to_purge ): + # ycmd clients only send buffers which have changed, and are required to + # send BufferUnload autocommand when files are closed. + for file_name in files_to_purge: + self._PurgeFileFromServer( file_name ) + + + def OnBufferUnload( self, request_data ): + if not self.ServerIsHealthy(): + return + + # If we haven't finished initializing yet, we need to queue up a call to + # _PurgeFileFromServer. This ensures that the server is up to date + # as soon as we are able to send more messages. This is important because + # server start up can be quite slow and we must not block the user, while we + # must keep the server synchronized. + if not self._initialize_event.is_set(): + self._OnInitializeComplete( + lambda self: self._PurgeFileFromServer( request_data[ 'filepath' ] ) ) + return + + self._PurgeFileFromServer( request_data[ 'filepath' ] ) + + + def _PurgeFileFromServer( self, file_path ): + file_state = self._server_file_state[ file_path ] + action = file_state.GetFileCloseAction() + if action == lsp.ServerFileState.CLOSE_FILE: + msg = lsp.DidCloseTextDocument( file_state ) + self.GetConnection().SendNotification( msg ) + + del self._server_file_state[ file_state.filename ] def _GetProjectDirectory( self, request_data ): @@ -1024,7 +1125,7 @@ def SendInitialize( self, request_data ): def response_handler( response, message ): if message is None: - raise ResponseAbortedException( 'Initialize request aborted' ) + return self._HandleInitializeInPollThread( message ) @@ -1052,11 +1153,18 @@ def _HandleInitializeInPollThread( self, response ): _logger.info( 'Language server requires sync type of {0}'.format( self._sync_type ) ) - # We must notify the server that we received the initialize response. - # There are other things that could happen here, such as loading custom - # server configuration, but we don't support that (yet). + # We must notify the server that we received the initialize response (for + # no apparent reason, other than that's what the protocol says). self.GetConnection().SendNotification( lsp.Initialized() ) + # Some language servers require the use of didChangeConfiguration event, + # even though it is not clear in the specification that it is mandatory, + # nor when it should be sent. VSCode sends it immediately after + # initialized notification, so we do the same. In future, we might + # support getting this config from ycm_extra_conf or the client, but for + # now, we send an empty object. + self.GetConnection().SendNotification( lsp.DidChangeConfiguration( {} ) ) + # Notify the other threads that we have completed the initialize exchange. self._initialize_response = None self._initialize_event.set() @@ -1113,13 +1221,15 @@ def GoToDeclaration( self, request_data ): if isinstance( response[ 'result' ], list ): return _LocationListToGoTo( request_data, response ) - else: + elif response[ 'result' ]: position = response[ 'result' ] try: return responses.BuildGoToResponseFromLocation( *_PositionToLocationAndDescription( request_data, position ) ) except KeyError: raise RuntimeError( 'Cannot jump to location' ) + else: + raise RuntimeError( 'Cannot jump to location' ) def GoToReferences( self, request_data ): @@ -1177,6 +1287,8 @@ def WithinRange( diag ): REQUEST_TIMEOUT_COMMAND ) else: + line_value = request_data[ 'line_value' ] + code_actions = self.GetConnection().GetResponse( request_id, lsp.CodeAction( @@ -1190,7 +1302,7 @@ def WithinRange( diag ): }, 'end': { 'line': line_num_ls, - 'character': len( request_data[ 'line_value' ] ) - 1, + 'character': len( line_value ) - 1, } }, [] ), diff --git a/ycmd/completers/language_server/language_server_protocol.py b/ycmd/completers/language_server/language_server_protocol.py index 8136c00745..61c3f1452a 100644 --- a/ycmd/completers/language_server/language_server_protocol.py +++ b/ycmd/completers/language_server/language_server_protocol.py @@ -24,8 +24,8 @@ import os import json +import hashlib -from collections import defaultdict from ycmd.utils import ( pathname2url, ToBytes, ToUnicode, @@ -33,11 +33,6 @@ urljoin ) -# FIXME: We might need a whole document management system eventually. For now, -# we just update the file version every time we refresh a file (even if it -# hasn't changed). -LAST_VERSION = defaultdict( int ) - INSERT_TEXT_FORMAT = [ None, # 1-based 'PlainText', @@ -81,6 +76,90 @@ class InvalidUriException( Exception ): pass +class ServerFileStateStore( dict ): + """Trivial default-dict-like class to hold ServerFileState for a given + filepath. Language server clients must maintain one of these for each language + server connection.""" + def __missing__( self, key ): + self[ key ] = ServerFileState( key ) + return self[ key ] + + +class ServerFileState( object ): + """State machine for a particular file from the server's perspective, + including version.""" + + # States + OPEN = 'Open' + CLOSED = 'Closed' + + # Actions + CLOSE_FILE = 'Close' + NO_ACTION = 'None' + OPEN_FILE = 'Open' + CHANGE_FILE = 'Change' + + def __init__( self, filename ): + self.filename = filename + self.version = 0 + self.state = ServerFileState.CLOSED + self.checksum = None + + + def GetDirtyFileAction( self, contents ): + """Progress the state for a file to be updated due to being supplied in the + dirty buffers list. Returns any one of the Actions to perform.""" + new_checksum = self._CalculateCheckSum( contents ) + + if ( self.state == ServerFileState.OPEN and + self.checksum.digest() == new_checksum.digest() ): + return ServerFileState.NO_ACTION + elif self.state == ServerFileState.CLOSED: + self.version = 0 + action = ServerFileState.OPEN_FILE + else: + action = ServerFileState.CHANGE_FILE + + return self._SendNewVersion( new_checksum, action ) + + + def GetSavedFileAction( self, contents ): + """Progress the state for a file to be updated due to having previously been + opened, but no longer supplied in the dirty buffers list. Returns one of the + Actions to perform: either NO_ACTION or CHANGE_FILE.""" + # We only need to update if the server state is open + if self.state != ServerFileState.OPEN: + return ServerFileState.NO_ACTION + + new_checksum = self._CalculateCheckSum( contents ) + if self.checksum.digest() == new_checksum.digest(): + return ServerFileState.NO_ACTION + + return self._SendNewVersion( new_checksum, ServerFileState.CHANGE_FILE ) + + + def GetFileCloseAction( self ): + """Progress the state for a file which was closed in the client. Returns one + of the actions to perform: either NO_ACTION or CLOSE_FILE.""" + if self.state == ServerFileState.OPEN: + self.state = ServerFileState.CLOSED + return ServerFileState.CLOSE_FILE + + self.state = ServerFileState.CLOSED + return ServerFileState.NO_ACTION + + + def _SendNewVersion( self, new_checksum, action ): + self.checksum = new_checksum + self.version = self.version + 1 + self.state = ServerFileState.OPEN + + return action + + + def _CalculateCheckSum( self, contents ): + return hashlib.sha1( ToBytes( contents ) ) + def BuildRequest( request_id, method, parameters ): """Builds a JSON RPC request message with the supplied ID, method and method @@ -130,24 +209,28 @@ def Exit(): return BuildNotification( 'exit', None ) -def DidOpenTextDocument( file_name, file_types, file_contents ): - LAST_VERSION[ file_name ] = LAST_VERSION[ file_name ] + 1 +def DidChangeConfiguration( config ): + return BuildNotification( 'workspace/didChangeConfiguration', { + 'settings': config, + } ) + + +def DidOpenTextDocument( file_state, file_types, file_contents ): return BuildNotification( 'textDocument/didOpen', { 'textDocument': { - 'uri': FilePathToUri( file_name ), + 'uri': FilePathToUri( file_state.filename ), 'languageId': '/'.join( file_types ), - 'version': LAST_VERSION[ file_name ], + 'version': file_state.version, 'text': file_contents } } ) -def DidChangeTextDocument( file_name, file_types, file_contents ): - LAST_VERSION[ file_name ] = LAST_VERSION[ file_name ] + 1 +def DidChangeTextDocument( file_state, file_contents ): return BuildNotification( 'textDocument/didChange', { 'textDocument': { - 'uri': FilePathToUri( file_name ), - 'version': LAST_VERSION[ file_name ], + 'uri': FilePathToUri( file_state.filename ), + 'version': file_state.version, }, 'contentChanges': [ { 'text': file_contents }, @@ -155,11 +238,11 @@ def DidChangeTextDocument( file_name, file_types, file_contents ): } ) -def DidCloseTextDocument( file_name ): +def DidCloseTextDocument( file_state ): return BuildNotification( 'textDocument/didClose', { 'textDocument': { - 'uri': FilePathToUri( file_name ), - 'version': LAST_VERSION[ file_name ], + 'uri': FilePathToUri( file_state.filename ), + 'version': file_state.version, }, } ) diff --git a/ycmd/request_wrap.py b/ycmd/request_wrap.py index de17323423..e85f58176c 100644 --- a/ycmd/request_wrap.py +++ b/ycmd/request_wrap.py @@ -31,6 +31,8 @@ SplitLines ) from ycmd.identifier_utils import StartOfLongestIdentifierEndingAtIndex from ycmd.request_validation import EnsureRequestValid +import logging +_logger = logging.getLogger( __name__ ) # TODO: Change the custom computed (and other) keys to be actual properties on @@ -46,7 +48,8 @@ def __init__( self, request, validate = True ): # by setter_method) are cached in _cached_computed. setter_method may be # None for read-only items. self._computed_key = { - # Unicode string representation of the current line + # Unicode string representation of the current line. If the line requested + # is not in the file, returns ''. 'line_value': ( self._CurrentLine, None ), # The calculated start column, as a codepoint offset into the @@ -119,7 +122,14 @@ def _CurrentLine( self ): current_file = self._request[ 'filepath' ] contents = self._request[ 'file_data' ][ current_file ][ 'contents' ] - return SplitLines( contents )[ self._request[ 'line_num' ] - 1 ] + try: + return SplitLines( contents )[ self._request[ 'line_num' ] - 1 ] + except IndexError: + _logger.exception( 'Client returned invalid line number {0} ' + 'for file {1}. Assuming empty.'.format( + self._request[ 'line_num' ], + self._request[ 'filepath' ] ) ) + return '' def _GetCompletionStartColumn( self ): diff --git a/ycmd/responses.py b/ycmd/responses.py index 59726b7b00..ea37884a39 100644 --- a/ycmd/responses.py +++ b/ycmd/responses.py @@ -201,7 +201,19 @@ def __init__ ( self, line, column, filename ): absolute path of the file""" self.line_number_ = line self.column_number_ = column - self.filename_ = os.path.realpath( filename ) + if filename: + self.filename_ = os.path.realpath( filename ) + else: + # When the filename passed (e.g. by a server) can't be recognized or + # parsed, we send an empty filename. This at least allows the client to + # know there _is_ a reference, but not exactly where it is. This can + # happen with the Java completer which sometimes returns references using + # a custom/undocumented URI scheme. Typically, such URIs point to .class + # files or other binary data which clients can't display anyway. + # FIXME: Sending a location with an empty filename could be considered a + # strict breach of our own protocol. Perhaps completers should be required + # to simply skip such a location. + self.filename_ = filename def BuildDiagnosticData( diagnostic ): diff --git a/ycmd/tests/java/__init__.py b/ycmd/tests/java/__init__.py index 0321bf3dfc..55ca93873f 100644 --- a/ycmd/tests/java/__init__.py +++ b/ycmd/tests/java/__init__.py @@ -36,6 +36,7 @@ shared_app = None DEFAULT_PROJECT_DIR = 'simple_eclipse_project' +SERVER_STARTUP_TIMEOUT = 120 # seconds def PathToTestFile( *args ): @@ -71,7 +72,7 @@ def StartJavaCompleterServerInDirectory( app, directory ): filepath = os.path.join( directory, 'test.java' ), event_name = 'FileReadyToParse', filetype = 'java' ) ) - WaitUntilCompleterServerReady( shared_app, 'java' ) + WaitUntilCompleterServerReady( shared_app, 'java', SERVER_STARTUP_TIMEOUT ) def SharedYcmd( test ): @@ -106,13 +107,17 @@ def Wrapper( *args, **kwargs ): return Wrapper -def PollForMessages( app, request_data ): - TIMEOUT = 30 - expiration = time.time() + TIMEOUT +class PollForMessagesTimeoutException( Exception ): + pass + + +def PollForMessages( app, request_data, timeout = 30 ): + expiration = time.time() + timeout while True: if time.time() > expiration: - raise RuntimeError( 'Waited for diagnostics to be ready for ' - '{0} seconds, aborting.'.format( TIMEOUT ) ) + raise PollForMessagesTimeoutException( + 'Waited for diagnostics to be ready for {0} seconds, aborting.'.format( + timeout ) ) default_args = { 'filetype' : 'java', diff --git a/ycmd/tests/java/diagnostics_test.py b/ycmd/tests/java/diagnostics_test.py index ad40b7dc40..8764b96ecf 100644 --- a/ycmd/tests/java/diagnostics_test.py +++ b/ycmd/tests/java/diagnostics_test.py @@ -23,14 +23,24 @@ # Not installing aliases from python-future; it's unreliable and slow. from builtins import * # noqa +import time +import json +import threading from future.utils import iterkeys -from hamcrest import assert_that, contains, contains_inanyorder, has_entries +from hamcrest import ( assert_that, + contains, + contains_inanyorder, + empty, + equal_to, + has_entries, + has_item ) from nose.tools import eq_ from ycmd.tests.java import ( DEFAULT_PROJECT_DIR, IsolatedYcmd, PathToTestFile, PollForMessages, + PollForMessagesTimeoutException, SharedYcmd, StartJavaCompleterServerInDirectory ) @@ -38,6 +48,10 @@ from ycmd.utils import ReadFile from pprint import pformat +from mock import patch +from ycmd.completers.language_server import language_server_protocol as lsp +from ycmd import handlers + def RangeMatch( filepath, start, end ): @@ -55,6 +69,7 @@ def ProjectPath( *args ): *args ) +InternalNonProjectFile = PathToTestFile( DEFAULT_PROJECT_DIR, 'test.java' ) TestFactory = ProjectPath( 'TestFactory.java' ) TestLauncher = ProjectPath( 'TestLauncher.java' ) TestWidgetImpl = ProjectPath( 'TestWidgetImpl.java' ) @@ -65,6 +80,7 @@ def ProjectPath( *args ): 'Test.java' ) DIAG_MATCHERS_PER_FILE = { + InternalNonProjectFile: [], TestFactory: contains_inanyorder( has_entries( { 'kind': 'WARNING', @@ -174,18 +190,63 @@ def ProjectPath( *args ): } +def _WaitForDiagnoticsForFile( app, + filepath, + contents, + diags_filepath, + diags_are_ready = lambda d: True, + **kwargs ): + diags = None + try: + for message in PollForMessages( app, + { 'filepath': filepath, + 'contents': contents }, + **kwargs ): + if ( 'diagnostics' in message and + message[ 'filepath' ] == diags_filepath ): + print( 'Message {0}'.format( pformat( message ) ) ) + diags = message[ 'diagnostics' ] + if diags_are_ready( diags ): + return diags + + # Eventually PollForMessages will throw a timeout exception and we'll fail + # if we don't see the diagnostics go empty + except PollForMessagesTimeoutException as e: + raise AssertionError( + '{0}. Timed out waiting for diagnostics for file {1}. '.format( + e, + diags_filepath ) + ) + + return diags + + +def _WaitForDiagnoticsToBeReaady( app, filepath, contents, **kwargs ): + results = None + for tries in range( 0, 60 ): + event_data = BuildRequest( event_name = 'FileReadyToParse', + contents = contents, + filepath = filepath, + filetype = 'java', + **kwargs ) + + results = app.post_json( '/event_notification', event_data ).json + + if results: + break + + time.sleep( 0.5 ) + + return results + + @SharedYcmd def FileReadyToParse_Diagnostics_Simple_test( app ): filepath = ProjectPath( 'TestFactory.java' ) contents = ReadFile( filepath ) - event_data = BuildRequest( event_name = 'FileReadyToParse', - contents = contents, - filepath = filepath, - filetype = 'java' ) - - results = app.post_json( '/event_notification', event_data ).json - + # It can take a while for the diagnostics to be ready + results = _WaitForDiagnoticsToBeReaady( app, filepath, contents ) print( 'completer response: {0}'.format( pformat( results ) ) ) assert_that( results, DIAG_MATCHERS_PER_FILE[ filepath ] ) @@ -228,8 +289,8 @@ class Test { for message in PollForMessages( app, { 'filepath': filepath, 'contents': contents } ): - print( 'Message {0}'.format( pformat( message ) ) ) if 'diagnostics' in message and message[ 'filepath' ] == filepath: + print( 'Message {0}'.format( pformat( message ) ) ) assert_that( message, has_entries( { 'diagnostics': diag_matcher, 'filepath': filepath @@ -237,37 +298,358 @@ class Test { break # Now confirm that we _also_ get these from the FileReadyToParse request - results = app.post_json( '/event_notification', event_data ).json + for tries in range( 0, 60 ): + results = app.post_json( '/event_notification', event_data ).json + if results: + break + time.sleep( 0.5 ) + print( 'completer response: {0}'.format( pformat( results ) ) ) assert_that( results, diag_matcher ) @SharedYcmd -def Poll_Diagnostics_ProjectWide_test( app ): - filepath = ProjectPath( 'TestLauncher.java' ) +def Poll_Diagnostics_ProjectWide_Eclipse_test( app ): + filepath = TestLauncher contents = ReadFile( filepath ) # Poll until we receive _all_ the diags asynchronously to_see = sorted( iterkeys( DIAG_MATCHERS_PER_FILE ) ) seen = dict() - for message in PollForMessages( app, - { 'filepath': filepath, - 'contents': contents } ): - print( 'Message {0}'.format( pformat( message ) ) ) - if 'diagnostics' in message: - seen[ message[ 'filepath' ] ] = True - if message[ 'filepath' ] not in DIAG_MATCHERS_PER_FILE: - raise AssertionError( - 'Received diagnostics for unexpected file {0}. ' - 'Only expected {1}'.format( message[ 'filepath' ], to_see ) ) - assert_that( message, has_entries( { - 'diagnostics': DIAG_MATCHERS_PER_FILE[ message[ 'filepath' ] ], - 'filepath': message[ 'filepath' ] - } ) ) - if sorted( iterkeys( seen ) ) == to_see: + try: + for message in PollForMessages( app, + { 'filepath': filepath, + 'contents': contents } ): + print( 'Message {0}'.format( pformat( message ) ) ) + if 'diagnostics' in message: + seen[ message[ 'filepath' ] ] = True + if message[ 'filepath' ] not in DIAG_MATCHERS_PER_FILE: + raise AssertionError( + 'Received diagnostics for unexpected file {0}. ' + 'Only expected {1}'.format( message[ 'filepath' ], to_see ) ) + assert_that( message, has_entries( { + 'diagnostics': DIAG_MATCHERS_PER_FILE[ message[ 'filepath' ] ], + 'filepath': message[ 'filepath' ] + } ) ) + + if sorted( iterkeys( seen ) ) == to_see: + break + + # Eventually PollForMessages will throw a timeout exception and we'll fail + # if we don't see all of the expected diags + except PollForMessagesTimeoutException as e: + raise AssertionError( + str( e ) + + 'Timed out waiting for full set of diagnostics. ' + 'Expected to see diags for {0}, but only saw {1}.'.format( + json.dumps( to_see, indent=2 ), + json.dumps( sorted( iterkeys( seen ) ), indent=2 ) ) ) + + +@IsolatedYcmd +@patch( + 'ycmd.completers.language_server.language_server_protocol.UriToFilePath', + side_effect = lsp.InvalidUriException ) +def FileReadyToParse_Diagnostics_InvalidURI_test( app, uri_to_filepath, *args ): + StartJavaCompleterServerInDirectory( app, + PathToTestFile( DEFAULT_PROJECT_DIR ) ) + + filepath = TestFactory + contents = ReadFile( filepath ) + + # It can take a while for the diagnostics to be ready + results = _WaitForDiagnoticsToBeReaady( app, filepath, contents ) + print( 'Completer response: {0}'.format( json.dumps( results, indent=2 ) ) ) + + uri_to_filepath.assert_called() + + assert_that( results, has_item( + has_entries( { + 'kind': 'WARNING', + 'text': 'The value of the field TestFactory.Bar.testString is not used', + 'location': LocationMatcher( '', 15, 19 ), + 'location_extent': RangeMatch( '', ( 15, 19 ), ( 15, 29 ) ), + 'ranges': contains( RangeMatch( '', ( 15, 19 ), ( 15, 29 ) ) ), + 'fixit_available': False + } ), + ) ) + + +@IsolatedYcmd +def FileReadyToParse_ServerNotReady_test( app ): + filepath = TestFactory + contents = ReadFile( filepath ) + + StartJavaCompleterServerInDirectory( app, ProjectPath() ) + + completer = handlers._server_state.GetFiletypeCompleter( [ 'java' ] ) + + # It can take a while for the diagnostics to be ready + for tries in range( 0, 60 ): + event_data = BuildRequest( event_name = 'FileReadyToParse', + contents = contents, + filepath = filepath, + filetype = 'java' ) + + results = app.post_json( '/event_notification', event_data ).json + + if results: + break + + time.sleep( 0.5 ) + + # To make the test fair, we make sure there are some results prior to the + # 'server not running' call + assert results + + # Call the FileReadyToParse handler but pretend that the server isn't running + with patch.object( completer, 'ServerIsHealthy', return_value = False ): + event_data = BuildRequest( event_name = 'FileReadyToParse', + contents = contents, + filepath = filepath, + filetype = 'java' ) + results = app.post_json( '/event_notification', event_data ).json + assert_that( results, empty() ) + + +@IsolatedYcmd +def FileReadyToParse_ChangeFileContents_test( app ): + filepath = TestFactory + contents = ReadFile( filepath ) + + StartJavaCompleterServerInDirectory( app, ProjectPath() ) + + # It can take a while for the diagnostics to be ready + for tries in range( 0, 60 ): + event_data = BuildRequest( event_name = 'FileReadyToParse', + contents = contents, + filepath = filepath, + filetype = 'java' ) + + results = app.post_json( '/event_notification', event_data ).json + + if results: break - # Eventually PollForMessages will throw a timeout exception and we'll fail - # if we don't see all of the expected diags + time.sleep( 0.5 ) + + # To make the test fair, we make sure there are some results prior to the + # 'server not running' call + assert results + + # Call the FileReadyToParse handler but pretend that the server isn't running + contents = 'package com.test; class TestFactory {}' + # It can take a while for the diagnostics to be ready + event_data = BuildRequest( event_name = 'FileReadyToParse', + contents = contents, + filepath = filepath, + filetype = 'java' ) + + app.post_json( '/event_notification', event_data ) + + diags = None + try: + for message in PollForMessages( app, + { 'filepath': filepath, + 'contents': contents } ): + print( 'Message {0}'.format( pformat( message ) ) ) + if 'diagnostics' in message and message[ 'filepath' ] == filepath: + diags = message[ 'diagnostics' ] + if not diags: + break + + # Eventually PollForMessages will throw a timeout exception and we'll fail + # if we don't see the diagnostics go empty + except PollForMessagesTimeoutException as e: + raise AssertionError( + '{0}. Timed out waiting for diagnostics to clear for updated file. ' + 'Expected to see none, but diags were: {1}'.format( e, diags ) ) + + assert_that( diags, empty() ) + + # Close the file (ensuring no exception) + event_data = BuildRequest( event_name = 'BufferUnload', + contents = contents, + filepath = filepath, + filetype = 'java' ) + result = app.post_json( '/event_notification', event_data ).json + assert_that( result, equal_to( {} ) ) + + # Close the file again, someone erroneously (ensuring no exception) + event_data = BuildRequest( event_name = 'BufferUnload', + contents = contents, + filepath = filepath, + filetype = 'java' ) + result = app.post_json( '/event_notification', event_data ).json + assert_that( result, equal_to( {} ) ) + + +@IsolatedYcmd +def FileReadyToParse_ChangeFileContentsFileData_test( app ): + filepath = TestFactory + contents = ReadFile( filepath ) + unsaved_buffer_path = TestLauncher + file_data = { + unsaved_buffer_path: { + 'contents': 'package com.test; public class TestLauncher {}', + 'filetypes': [ 'java' ], + } + } + + StartJavaCompleterServerInDirectory( app, ProjectPath() ) + + # It can take a while for the diagnostics to be ready + results = _WaitForDiagnoticsToBeReaady( app, + filepath, + contents ) + assert results + + # Check that we have diagnostics for the saved file + diags = _WaitForDiagnoticsForFile( app, + filepath, + contents, + unsaved_buffer_path, + lambda d: d ) + assert_that( diags, DIAG_MATCHERS_PER_FILE[ unsaved_buffer_path ] ) + + # Now update the unsaved file with new contents + event_data = BuildRequest( event_name = 'FileReadyToParse', + contents = contents, + filepath = filepath, + filetype = 'java', + file_data = file_data ) + app.post_json( '/event_notification', event_data ) + + # Check that we have no diagnostics for the dirty file + diags = _WaitForDiagnoticsForFile( app, + filepath, + contents, + unsaved_buffer_path, + lambda d: not d ) + assert_that( diags, empty() ) + + # Now send the request again, but don't include the unsaved file. It should be + # read from disk, casuing the diagnostics for that file to appear. + event_data = BuildRequest( event_name = 'FileReadyToParse', + contents = contents, + filepath = filepath, + filetype = 'java' ) + app.post_json( '/event_notification', event_data ) + + # Check that we now have diagnostics for the previously-dirty file + diags = _WaitForDiagnoticsForFile( app, + filepath, + contents, + unsaved_buffer_path, + lambda d: d ) + + assert_that( diags, DIAG_MATCHERS_PER_FILE[ unsaved_buffer_path ] ) + + +@SharedYcmd +def OnBufferUnload_ServerNotRunning_test( app ): + filepath = TestFactory + contents = ReadFile( filepath ) + completer = handlers._server_state.GetFiletypeCompleter( [ 'java' ] ) + + with patch.object( completer, 'ServerIsHealthy', return_value = False ): + event_data = BuildRequest( event_name = 'BufferUnload', + contents = contents, + filepath = filepath, + filetype = 'java' ) + result = app.post_json( '/event_notification', event_data ).json + assert_that( result, equal_to( {} ) ) + + +@IsolatedYcmd +def PollForMessages_InvalidUri_test( app, *args ): + StartJavaCompleterServerInDirectory( + app, + PathToTestFile( 'simple_eclipse_project' ) ) + + filepath = TestFactory + contents = ReadFile( filepath ) + + with patch( + 'ycmd.completers.language_server.language_server_protocol.UriToFilePath', + side_effect = lsp.InvalidUriException ): + + for tries in range( 0, 5 ): + response = app.post_json( '/receive_messages', + BuildRequest( + filetype = 'java', + filepath = filepath, + contents = contents ) ).json + if response is True: + break + elif response is False: + raise AssertionError( 'Message poll was aborted unexpectedly' ) + elif 'diagnostics' in response: + raise AssertionError( 'Did not expect diagnostics when file paths ' + 'are invalid' ) + + time.sleep( 0.5 ) + + assert_that( response, equal_to( True ) ) + + +@IsolatedYcmd +def PollForMessages_ServerNotRunning_test( app ): + StartJavaCompleterServerInDirectory( + app, + PathToTestFile( 'simple_eclipse_project' ) ) + + filepath = TestFactory + contents = ReadFile( filepath ) + app.post_json( + '/run_completer_command', + BuildRequest( + filetype = 'java', + command_arguments = [ 'StopServer' ], + ), + ) + + response = app.post_json( '/receive_messages', + BuildRequest( + filetype = 'java', + filepath = filepath, + contents = contents ) ).json + + assert_that( response, equal_to( False ) ) + + +@IsolatedYcmd +def PollForMessages_AbortedWhenServerDies_test( app ): + StartJavaCompleterServerInDirectory( + app, + PathToTestFile( 'simple_eclipse_project' ) ) + + filepath = TestFactory + contents = ReadFile( filepath ) + + def AwaitMessages(): + for tries in range( 0, 5 ): + response = app.post_json( '/receive_messages', + BuildRequest( + filetype = 'java', + filepath = filepath, + contents = contents ) ).json + if response is False: + return + + raise AssertionError( 'The poll request was not aborted in 5 tries' ) + + message_poll_task = threading.Thread( target=AwaitMessages ) + message_poll_task.start() + + app.post_json( + '/run_completer_command', + BuildRequest( + filetype = 'java', + command_arguments = [ 'StopServer' ], + ), + ) + + message_poll_task.join() diff --git a/ycmd/tests/java/get_completions_test.py b/ycmd/tests/java/get_completions_test.py index 0b0e1ec8a5..8b9d39858a 100644 --- a/ycmd/tests/java/get_completions_test.py +++ b/ycmd/tests/java/get_completions_test.py @@ -34,12 +34,14 @@ from pprint import pformat import requests +from ycmd import handlers from ycmd.tests.java import DEFAULT_PROJECT_DIR, PathToTestFile, SharedYcmd from ycmd.tests.test_utils import ( BuildRequest, ChunkMatcher, CompletionEntryMatcher, LocationMatcher ) from ycmd.utils import ReadFile +from mock import patch def _CombineRequest( request, data ): @@ -423,10 +425,84 @@ def GetCompletions_UnicodeIdentifier_test( app ): } ), CompletionEntryMatcher( 'testywesty', 'Test.TéstClass', { 'kind': 'Field', - 'detailed_info': 'testywesty : String\n\nTest in the west ', } ), ) ), 'errors': empty(), } ) }, } ) + + +@SharedYcmd +def GetCompletions_ResolveFailed_test( app ): + filepath = PathToTestFile( DEFAULT_PROJECT_DIR, + 'src', + 'com', + 'youcompleteme', + 'Test.java' ) + + from ycmd.completers.language_server import language_server_protocol as lsapi + + def BrokenResolveCompletion( request_id, completion ): + return lsapi.BuildRequest( request_id, 'completionItem/FAIL', completion ) + + with patch( 'ycmd.completers.language_server.language_server_protocol.' + 'ResolveCompletion', + side_effect = BrokenResolveCompletion ): + RunTest( app, { + 'description': 'Completion works for unicode identifier', + 'request': { + 'filetype' : 'java', + 'filepath' : filepath, + 'line_num' : 16, + 'column_num' : 35, + 'force_semantic': True + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries( { + 'completion_start_column': 35, + 'completions': contains_inanyorder( *WithObjectMethods( + CompletionEntryMatcher( 'a_test', 'Test.TéstClass', { + 'kind': 'Field', + 'detailed_info': 'a_test : int\n\n', + } ), + CompletionEntryMatcher( 'testywesty', 'Test.TéstClass', { + 'kind': 'Field', + } ), + ) ), + 'errors': empty(), + } ) + }, + } ) + + +@SharedYcmd +def Subcommands_ServerNotReady_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'AbstractTestWidget.java' ) + + completer = handlers._server_state.GetFiletypeCompleter( [ 'java' ] ) + + with patch.object( completer, 'ServerIsReady', return_value = False ): + RunTest( app, { + 'description': 'Completion works for unicode identifier', + 'request': { + 'filetype' : 'java', + 'filepath' : filepath, + 'line_num' : 16, + 'column_num' : 35, + 'force_semantic': True + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries( { + 'errors': empty(), + 'completions': empty(), + 'completion_start_column': 6 + } ), + } + } ) diff --git a/ycmd/tests/java/java_completer_test.py b/ycmd/tests/java/java_completer_test.py index d052270366..6897f75460 100644 --- a/ycmd/tests/java/java_completer_test.py +++ b/ycmd/tests/java/java_completer_test.py @@ -24,10 +24,16 @@ import os -from hamcrest import assert_that, equal_to, is_not +from hamcrest import assert_that, equal_to, calling, has_entries, is_not, raises from mock import patch -from ycmd.completers.java import java_completer +from ycmd import handlers +from ycmd.tests.test_utils import BuildRequest +from ycmd.tests.java import ( PathToTestFile, + SharedYcmd, + StartJavaCompleterServerInDirectory ) +from ycmd.completers.java import java_completer, hook +from ycmd.completers.java.java_completer import NO_DOCUMENTATION_MESSAGE def ShouldEnableJavaCompleter_NoJava_test(): @@ -67,3 +73,159 @@ def WorkspaceDirForProject_UniqueDir_test(): is_not( equal_to( java_completer._WorkspaceDirForProject( os.getcwd(), True ) ) ) ) + + +@SharedYcmd +def JavaCompleter_GetType_test( app ): + StartJavaCompleterServerInDirectory( app, PathToTestFile() ) + completer = handlers._server_state.GetFiletypeCompleter( [ 'java' ] ) + + # The LSP defines the hover response as either: + # - a string + # - a list of strings + # - an object with keys language, value + # - a list of objects with keys language, value + # = an object with keys kind, value + + with patch.object( completer, 'GetHoverResponse', return_value = '' ): + assert_that( calling( completer.GetType ).with_args( BuildRequest() ), + raises( RuntimeError, 'Unknown type' ) ) + + with patch.object( completer, 'GetHoverResponse', return_value = 'string' ): + assert_that( calling( completer.GetType ).with_args( BuildRequest() ), + raises( RuntimeError, 'Unknown type' ) ) + + with patch.object( completer, 'GetHoverResponse', return_value = [] ): + assert_that( calling( completer.GetType ).with_args( BuildRequest() ), + raises( RuntimeError, 'Unknown type' ) ) + + with patch.object( completer, + 'GetHoverResponse', + return_value = [ 'a', 'b' ] ): + assert_that( calling( completer.GetType ).with_args( BuildRequest() ), + raises( RuntimeError, 'Unknown type' ) ) + + with patch.object( completer, + 'GetHoverResponse', + return_value = { 'language': 'java', 'value': 'test' } ): + assert_that( calling( completer.GetType ).with_args( BuildRequest() ), + raises( RuntimeError, 'Unknown type' ) ) + + with patch.object( + completer, + 'GetHoverResponse', + return_value = [ { 'language': 'java', 'value': 'test' } ] ): + assert_that( completer.GetType( BuildRequest() ), + has_entries( { 'message': 'test' } ) ) + + with patch.object( + completer, + 'GetHoverResponse', + return_value = [ { 'language': 'java', 'value': 'test' }, + { 'language': 'java', 'value': 'not test' } ] ): + assert_that( completer.GetType( BuildRequest() ), + has_entries( { 'message': 'test' } ) ) + + with patch.object( + completer, + 'GetHoverResponse', + return_value = [ { 'language': 'java', 'value': 'test' }, + 'line 1', + 'line 2' ] ): + assert_that( completer.GetType( BuildRequest() ), + has_entries( { 'message': 'test' } ) ) + + + with patch.object( completer, + 'GetHoverResponse', + return_value = { 'kind': 'plaintext', 'value': 'test' } ): + assert_that( calling( completer.GetType ).with_args( BuildRequest() ), + raises( RuntimeError, 'Unknown type' ) ) + + +@SharedYcmd +def JavaCompleter_GetDoc_test( app ): + StartJavaCompleterServerInDirectory( app, PathToTestFile() ) + completer = handlers._server_state.GetFiletypeCompleter( [ 'java' ] ) + + # The LSP defines the hover response as either: + # - a string + # - a list of strings + # - an object with keys language, value + # - a list of objects with keys language, value + # = an object with keys kind, value + + with patch.object( completer, 'GetHoverResponse', return_value = '' ): + assert_that( calling( completer.GetDoc ).with_args( BuildRequest() ), + raises( RuntimeError, NO_DOCUMENTATION_MESSAGE) ) + + with patch.object( completer, 'GetHoverResponse', return_value = 'string' ): + assert_that( calling( completer.GetDoc ).with_args( BuildRequest() ), + raises( RuntimeError, NO_DOCUMENTATION_MESSAGE) ) + + with patch.object( completer, 'GetHoverResponse', return_value = [] ): + assert_that( calling( completer.GetDoc ).with_args( BuildRequest() ), + raises( RuntimeError, NO_DOCUMENTATION_MESSAGE) ) + + with patch.object( completer, + 'GetHoverResponse', + return_value = [ 'a', 'b' ] ): + assert_that( completer.GetDoc( BuildRequest() ), + has_entries( { 'detailed_info': 'a\nb' } ) ) + + with patch.object( completer, + 'GetHoverResponse', + return_value = { 'language': 'java', 'value': 'test' } ): + assert_that( calling( completer.GetDoc ).with_args( BuildRequest() ), + raises( RuntimeError, NO_DOCUMENTATION_MESSAGE ) ) + + with patch.object( + completer, + 'GetHoverResponse', + return_value = [ { 'language': 'java', 'value': 'test' } ] ): + assert_that( calling( completer.GetDoc ).with_args( BuildRequest() ), + raises( RuntimeError, NO_DOCUMENTATION_MESSAGE ) ) + + with patch.object( + completer, + 'GetHoverResponse', + return_value = [ { 'language': 'java', 'value': 'test' }, + { 'language': 'java', 'value': 'not test' } ] ): + assert_that( calling( completer.GetDoc ).with_args( BuildRequest() ), + raises( RuntimeError, NO_DOCUMENTATION_MESSAGE ) ) + + with patch.object( + completer, + 'GetHoverResponse', + return_value = [ { 'language': 'java', 'value': 'test' }, + 'line 1', + 'line 2' ] ): + assert_that( completer.GetDoc( BuildRequest() ), + has_entries( { 'detailed_info': 'line 1\nline 2' } ) ) + + + with patch.object( completer, + 'GetHoverResponse', + return_value = { 'kind': 'plaintext', 'value': 'test' } ): + assert_that( calling( completer.GetDoc ).with_args( BuildRequest() ), + raises( RuntimeError, NO_DOCUMENTATION_MESSAGE ) ) + + +@SharedYcmd +def JavaCompleter_UnknownCommand_test( app ): + StartJavaCompleterServerInDirectory( app, PathToTestFile() ) + completer = handlers._server_state.GetFiletypeCompleter( [ 'java' ] ) + + notification = { + 'command': 'this_is_not_a_real_command', + 'params': {} + } + assert_that( completer.HandleServerCommand( BuildRequest(), notification ), + equal_to( None ) ) + + + +@patch( 'ycmd.completers.java.java_completer.ShouldEnableJavaCompleter', + return_value = False ) +def JavaHook_JavaNotEnabled(): + assert_that( hook.GetCompleter(), equal_to( None ) ) diff --git a/ycmd/tests/java/server_management_test.py b/ycmd/tests/java/server_management_test.py index bbad049103..db0133ede1 100644 --- a/ycmd/tests/java/server_management_test.py +++ b/ycmd/tests/java/server_management_test.py @@ -25,6 +25,9 @@ import functools import os +import psutil +import time +import threading from mock import patch from hamcrest import ( assert_that, @@ -38,7 +41,7 @@ from ycmd.tests.test_utils import ( BuildRequest, TemporaryTestDir, WaitUntilCompleterServerReady ) -from ycmd import utils +from ycmd import utils, handlers def _ProjectDirectoryMatcher( project_directory ): @@ -159,7 +162,7 @@ def ServerManagement_ProjectDetection_MavenParent_test( app ): _ProjectDirectoryMatcher( project ) ) -@TidyJDTProjectFiles( PathToTestFile( 'simple_maven_project' ) ) +@TidyJDTProjectFiles( PathToTestFile( 'simple_gradle_project' ) ) @IsolatedYcmd def ServerManagement_ProjectDetection_GradleParent_test( app ): StartJavaCompleterServerInDirectory( app, @@ -194,8 +197,7 @@ def Test( app ): @IsolatedYcmd -@patch( 'ycmd.completers.java.java_completer.JavaCompleter.ShutdownServer', - side_effect = AssertionError ) +@patch( 'ycmd.utils.WaitUntilProcessIsTerminated', side_effect = RuntimeError ) def ServerManagement_CloseServer_Unclean_test( app, stop_server_cleanly ): StartJavaCompleterServerInDirectory( app, PathToTestFile( 'simple_eclipse_project' ) ) @@ -216,3 +218,168 @@ def ServerManagement_CloseServer_Unclean_test( app, stop_server_cleanly ): has_entry( 'is_running', False ) ) ) ) ) + + +@IsolatedYcmd +def ServerManagement_StopServerTwice_test( app ): + StartJavaCompleterServerInDirectory( + app, PathToTestFile( 'simple_eclipse_project' ) ) + + app.post_json( + '/run_completer_command', + BuildRequest( + filetype = 'java', + command_arguments = [ 'StopServer' ], + ), + ) + + request_data = BuildRequest( filetype = 'java' ) + assert_that( app.post_json( '/debug_info', request_data ).json, + has_entry( + 'completer', + has_entry( 'servers', contains( + has_entry( 'is_running', False ) + ) ) + ) ) + + + # Stopping a stopped server is a no-op + app.post_json( + '/run_completer_command', + BuildRequest( + filetype = 'java', + command_arguments = [ 'StopServer' ], + ), + ) + + request_data = BuildRequest( filetype = 'java' ) + assert_that( app.post_json( '/debug_info', request_data ).json, + has_entry( + 'completer', + has_entry( 'servers', contains( + has_entry( 'is_running', False ) + ) ) + ) ) + + +@IsolatedYcmd +def ServerManagement_ServerDies_test( app ): + StartJavaCompleterServerInDirectory( + app, + PathToTestFile( 'simple_eclipse_project' ) ) + + request_data = BuildRequest( filetype = 'java' ) + debug_info = app.post_json( '/debug_info', request_data ).json + print( 'Debug info: {0}'.format( debug_info ) ) + pid = debug_info[ 'completer' ][ 'servers' ][ 0 ][ 'pid' ] + print( 'pid: {0}'.format( pid ) ) + process = psutil.Process( pid ) + process.terminate() + + for tries in range( 0, 10 ): + request_data = BuildRequest( filetype = 'java' ) + debug_info = app.post_json( '/debug_info', request_data ).json + if not debug_info[ 'completer' ][ 'servers' ][ 0 ][ 'is_running' ]: + break + + time.sleep( 0.5 ) + + assert_that( debug_info, + has_entry( + 'completer', + has_entry( 'servers', contains( + has_entry( 'is_running', False ) + ) ) + ) ) + + +@IsolatedYcmd +def ServerManagement_ServerDiesWhileShuttingDown_test( app ): + StartJavaCompleterServerInDirectory( + app, + PathToTestFile( 'simple_eclipse_project' ) ) + + request_data = BuildRequest( filetype = 'java' ) + debug_info = app.post_json( '/debug_info', request_data ).json + print( 'Debug info: {0}'.format( debug_info ) ) + pid = debug_info[ 'completer' ][ 'servers' ][ 0 ][ 'pid' ] + print( 'pid: {0}'.format( pid ) ) + process = psutil.Process( pid ) + + + def StopServerInAnotherThread(): + app.post_json( + '/run_completer_command', + BuildRequest( + filetype = 'java', + command_arguments = [ 'StopServer' ], + ), + ) + + completer = handlers._server_state.GetFiletypeCompleter( [ 'java' ] ) + + # In this test we mock out the sending method so that we don't actually send + # the shutdown request. We then assisted-suicide the downstream server, which + # causes the shutdown request to be aborted. This is interpreted by the + # shutdown code as a successful shutdown. We need to do the shutdown and + # terminate in parallel as the post_json is a blocking call. + with patch.object( completer.GetConnection(), 'WriteData' ): + stop_server_task = threading.Thread( target=StopServerInAnotherThread ) + stop_server_task.start() + process.terminate() + stop_server_task.join() + + request_data = BuildRequest( filetype = 'java' ) + debug_info = app.post_json( '/debug_info', request_data ).json + assert_that( debug_info, + has_entry( + 'completer', + has_entry( 'servers', contains( + has_entry( 'is_running', False ) + ) ) + ) ) + + +@IsolatedYcmd +def ServerManagement_ConnectionRaisesWhileShuttingDown_test( app ): + StartJavaCompleterServerInDirectory( + app, + PathToTestFile( 'simple_eclipse_project' ) ) + + request_data = BuildRequest( filetype = 'java' ) + debug_info = app.post_json( '/debug_info', request_data ).json + print( 'Debug info: {0}'.format( debug_info ) ) + pid = debug_info[ 'completer' ][ 'servers' ][ 0 ][ 'pid' ] + print( 'pid: {0}'.format( pid ) ) + process = psutil.Process( pid ) + + completer = handlers._server_state.GetFiletypeCompleter( [ 'java' ] ) + + # In this test we mock out the GetResponse method, which is used to send the + # shutdown request. This means we only send the exit notification. It's + # possible that the server won't like this, but it seems reasonable for it to + # actually exit at that point. + with patch.object( completer.GetConnection(), + 'GetResponse', + side_effect = RuntimeError ): + app.post_json( + '/run_completer_command', + BuildRequest( + filetype = 'java', + command_arguments = [ 'StopServer' ], + ), + ) + + request_data = BuildRequest( filetype = 'java' ) + debug_info = app.post_json( '/debug_info', request_data ).json + assert_that( debug_info, + has_entry( + 'completer', + has_entry( 'servers', contains( + has_entry( 'is_running', False ) + ) ) + ) ) + + if process.is_running(): + process.terminate() + raise AssertionError( 'jst.ls process is still running after exit handler' ) diff --git a/ycmd/tests/java/subcommands_test.py b/ycmd/tests/java/subcommands_test.py index 38dec94ac6..1f79987154 100644 --- a/ycmd/tests/java/subcommands_test.py +++ b/ycmd/tests/java/subcommands_test.py @@ -23,6 +23,7 @@ # Not installing aliases from python-future; it's unreliable and slow. from builtins import * # noqa +import time from hamcrest import ( assert_that, contains, contains_inanyorder, @@ -44,6 +45,13 @@ ChunkMatcher, ErrorMatcher, LocationMatcher ) +from mock import patch +from ycmd.completers.language_server import language_server_protocol as lsp +from ycmd import handlers +from ycmd.completers.language_server.language_server_completer import ( + ResponseTimeoutException, + ResponseFailedException +) @SharedYcmd @@ -59,8 +67,45 @@ def Subcommands_DefinedSubcommands_test( app ): 'GoToReferences', 'RefactorRename', 'RestartServer' ] ), - app.post_json( '/defined_subcommands', - subcommands_data ).json ) + app.post_json( '/defined_subcommands', subcommands_data ).json ) + + +def Subcommands_ServerNotReady_test(): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'AbstractTestWidget.java' ) + + completer = handlers._server_state.GetFiletypeCompleter( [ 'java' ] ) + + @SharedYcmd + @patch.object( completer, 'ServerIsReady', return_value = False ) + def Test( app, cmd, arguments, *args ): + RunTest( app, { + 'description': 'Subcommand ' + cmd + ' handles server not ready', + 'request': { + 'command': cmd, + 'line_num': 1, + 'column_num': 1, + 'filepath': filepath, + 'arguments': arguments, + }, + 'expect': { + 'response': requests.codes.internal_server_error, + 'data': ErrorMatcher( RuntimeError, + 'Server is initializing. Please wait.' ), + } + } ) + + yield Test, 'GoTo', [] + yield Test, 'GoToDeclaration', [] + yield Test, 'GoToDefinition', [] + yield Test, 'GoToReferences', [] + yield Test, 'GetType', [] + yield Test, 'GetDoc', [] + yield Test, 'FixIt', [] + yield Test, 'RefactorRename', [ 'test' ] def RunTest( app, test, contents = None ): @@ -977,6 +1022,63 @@ def Subcommands_FixIt_Unicode_test(): filepath, 13, 1, fixits ) +@SharedYcmd +def Subcommands_FixIt_InvalidURI_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestFactory.java' ) + + fixits = has_entries ( { + 'fixits': contains( + has_entries( { + 'text': "Change type of 'test' to 'boolean'", + 'chunks': contains( + # For some reason, eclipse returns modifies as deletes + adds, + # although overlapping ranges aren't allowed. + ChunkMatcher( 'boolean', + LocationMatcher( '', 14, 12 ), + LocationMatcher( '', 14, 12 ) ), + ChunkMatcher( '', + LocationMatcher( '', 14, 12 ), + LocationMatcher( '', 14, 15 ) ), + ), + } ), + ) + } ) + + contents = ReadFile( filepath ) + # Wait for jdt.ls to have parsed the file and returned some diagnostics + for tries in range( 0, 60 ): + results = app.post_json( '/event_notification', + BuildRequest( filepath = filepath, + filetype = 'java', + contents = contents, + event_name = 'FileReadyToParse' ) ) + if results.json: + break + + time.sleep( .25 ) + + 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, + } + } ) + + @SharedYcmd def RunGoToTest( app, description, filepath, line, col, cmd, goto_response ): RunTest( app, { @@ -1060,3 +1162,140 @@ def Subcommands_GoTo_test(): test[ 'request' ][ 'col' ], command, test[ 'response' ] ) + + +@SharedYcmd +@patch( 'ycmd.completers.language_server.language_server_completer.' + 'REQUEST_TIMEOUT_COMMAND', + 5 ) +def Subcommands_RequestTimeout_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'youcompleteme', + 'Test.java' ) + + with patch.object( + handlers._server_state.GetFiletypeCompleter( [ 'java' ] ).GetConnection(), + 'WriteData' ): + RunTest( app, { + 'description': 'Request timeout throws an error', + 'request': { + 'command': 'FixIt', + 'line_num': 1, + 'column_num': 1, + 'filepath': filepath, + }, + 'expect': { + 'response': requests.codes.internal_server_error, + 'data': ErrorMatcher( ResponseTimeoutException, 'Response Timeout' ) + } + } ) + + +@SharedYcmd +def Subcommands_RequestFailed_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'youcompleteme', + 'Test.java' ) + + connection = handlers._server_state.GetFiletypeCompleter( + [ 'java' ] ).GetConnection() + + def WriteJunkToServer( data ): + junk = data.replace( bytes( b'textDocument/codeAction' ), + bytes( b'textDocument/codeFAILED' ) ) + + with connection._stdin_lock: + connection._server_stdin.write( junk ) + connection._server_stdin.flush() + + + with patch.object( connection, 'WriteData', side_effect = WriteJunkToServer ): + RunTest( app, { + 'description': 'Response errors propagate to the client', + 'request': { + 'command': 'FixIt', + 'line_num': 1, + 'column_num': 1, + 'filepath': filepath, + }, + 'expect': { + 'response': requests.codes.internal_server_error, + 'data': ErrorMatcher( ResponseFailedException ) + } + } ) + + +@SharedYcmd +def Subcommands_IndexOutOfRange_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'youcompleteme', + 'Test.java' ) + + RunTest( app, { + 'description': 'Request error handles the error', + 'request': { + 'command': 'FixIt', + 'line_num': 99, + 'column_num': 99, + 'filepath': filepath, + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries( { 'fixits': empty() } ), + } + } ) + + +@SharedYcmd +def Subcommands_DifferntFileTypesUpdate_test( app ): + filepath = PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'youcompleteme', + 'Test.java' ) + + RunTest( app, { + 'description': 'Request error handles the error', + 'request': { + 'command': 'FixIt', + 'line_num': 99, + 'column_num': 99, + 'filepath': filepath, + 'file_data': { + '!/bin/sh': { + 'filetypes': [], + 'contents': 'this should be ignored by the completer', + }, + '/path/to/non/project/file': { + 'filetypes': [ 'c' ], + 'contents': 'this should be ignored by the completer', + }, + PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestLauncher.java' ): { + 'filetypes': [ 'some', 'java', 'junk', 'also' ], + 'contents': ReadFile( PathToTestFile( 'simple_eclipse_project', + 'src', + 'com', + 'test', + 'TestLauncher.java' ) ), + }, + '!/usr/bin/sh': { + 'filetypes': [ 'java' ], + 'contents': '\n', + }, + } + }, + 'expect': { + 'response': requests.codes.ok, + 'data': has_entries( { 'fixits': empty() } ), + } + } ) diff --git a/ycmd/tests/language_server/__init__.py b/ycmd/tests/language_server/__init__.py new file mode 100644 index 0000000000..59eae519f8 --- /dev/null +++ b/ycmd/tests/language_server/__init__.py @@ -0,0 +1,43 @@ +# Copyright (C) 2017 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +from ycmd.completers.language_server import language_server_completer as lsc + + +class MockConnection( lsc.LanguageServerConnection ): + + def TryServerConnectionBlocking( self ): + return True + + + def Shutdown( self ): + pass + + + def WriteData( self, data ): + pass + + + def ReadData( self, size = -1 ): + return bytes( b'' ) diff --git a/ycmd/tests/language_server/language_server_completer_test.py b/ycmd/tests/language_server/language_server_completer_test.py new file mode 100644 index 0000000000..17d027abdb --- /dev/null +++ b/ycmd/tests/language_server/language_server_completer_test.py @@ -0,0 +1,388 @@ +# Copyright (C) 2017 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +from mock import patch +from hamcrest import ( assert_that, + calling, + equal_to, + contains, + has_entries, + has_items, + raises ) + +from ycmd.completers.language_server import language_server_completer as lsc +from ycmd.completers.language_server import language_server_protocol as lsp +from ycmd.tests.language_server import MockConnection +from ycmd.request_wrap import RequestWrap +from ycmd.tests.test_utils import ( BuildRequest, + ChunkMatcher, + DummyCompleter, + LocationMatcher ) +from ycmd import handlers, utils, responses + + +class MockCompleter( lsc.LanguageServerCompleter, DummyCompleter ): + def __init__( self ): + self._connection = MockConnection() + super( MockCompleter, self ).__init__( + handlers._server_state._user_options ) + + + def GetConnection( self ): + return self._connection + + + def HandleServerCommand( self, request_data, command ): + return super( MockCompleter, self ).HandleServerCommand( request_data, + command ) + + + def ServerIsHealthy( self ): + return True + + +def LanguageServerCompleter_Initialise_Aborted_test(): + completer = MockCompleter() + request_data = RequestWrap( BuildRequest() ) + + with patch.object( completer.GetConnection(), + 'ReadData', + side_effect = RuntimeError ): + + assert_that( completer.ServerIsReady(), equal_to( False ) ) + + completer.SendInitialize( request_data ) + + with patch.object( completer, '_HandleInitializeInPollThread' ) as handler: + completer.GetConnection().run() + handler.assert_not_called() + + assert_that( completer._initialize_event.is_set(), equal_to( False ) ) + assert_that( completer.ServerIsReady(), equal_to( False ) ) + + + with patch.object( completer, 'ServerIsHealthy', return_value = False ): + assert_that( completer.ServerIsReady(), equal_to( False ) ) + + +def LanguageServerCompleter_Initialise_Shutdown_test(): + completer = MockCompleter() + request_data = RequestWrap( BuildRequest() ) + + with patch.object( completer.GetConnection(), + 'ReadData', + side_effect = lsc.LanguageServerConnectionStopped ): + + assert_that( completer.ServerIsReady(), equal_to( False ) ) + + completer.SendInitialize( request_data ) + + with patch.object( completer, '_HandleInitializeInPollThread' ) as handler: + completer.GetConnection().run() + handler.assert_not_called() + + assert_that( completer._initialize_event.is_set(), equal_to( False ) ) + assert_that( completer.ServerIsReady(), equal_to( False ) ) + + + with patch.object( completer, 'ServerIsHealthy', return_value = False ): + assert_that( completer.ServerIsReady(), equal_to( False ) ) + + +def LanguageServerCompleter_GoToDeclaration_test(): + if utils.OnWindows(): + filepath = 'C:\\test.test' + uri = 'file:///c:/test.test' + else: + filepath = '/test.test' + uri = 'file:/test.test' + + contents = 'line1\nline2\nline3' + + completer = MockCompleter() + request_data = RequestWrap( BuildRequest( + filetype = 'ycmtest', + filepath = filepath, + contents = contents + ) ) + + @patch.object( completer, 'ServerIsReady', return_value = True ) + def Test( response, checker, throws, *args ): + with patch.object( completer.GetConnection(), + 'GetResponse', + return_value = response ): + if throws: + assert_that( + calling( completer.GoToDeclaration ).with_args( request_data ), + raises( checker ) + ) + else: + result = completer.GoToDeclaration( request_data ) + print( 'Result: {0}'.format( result ) ) + assert_that( result, checker ) + + + location = { + 'uri': uri, + 'range': { + 'start': { 'line': 0, 'character': 0 }, + 'end': { 'line': 0, 'character': 0 }, + } + } + + goto_response = has_entries( { + 'filepath': filepath, + 'column_num': 1, + 'line_num': 1, + 'description': 'line1' + } ) + + cases = [ + ( { 'result': None }, RuntimeError, True ), + ( { 'result': location }, goto_response, False ), + ( { 'result': {} }, RuntimeError, True ), + ( { 'result': [] }, RuntimeError, True ), + ( { 'result': [ location ] }, goto_response, False ), + ( { 'result': [ location, location ] }, + contains( goto_response, goto_response ), + False ), + ] + + for response, checker, throws in cases: + yield Test, response, checker, throws + + + with patch( + 'ycmd.completers.language_server.language_server_protocol.UriToFilePath', + side_effect = lsp.InvalidUriException ): + yield Test, { + 'result': { + 'uri': uri, + 'range': { + 'start': { 'line': 0, 'character': 0 }, + 'end': { 'line': 0, 'character': 0 }, + } + } + }, has_entries( { + 'filepath': '', + 'column_num': 1, + 'line_num': 1, + } ), False + + with patch( 'ycmd.completers.completer_utils.GetFileContents', + side_effect = lsp.IOError ): + yield Test, { + 'result': { + 'uri': uri, + 'range': { + 'start': { 'line': 0, 'character': 0 }, + 'end': { 'line': 0, 'character': 0 }, + } + } + }, has_entries( { + 'filepath': filepath, + 'column_num': 1, + 'line_num': 1, + } ), False + + +def GetCompletions_RejectInvalid_test(): + if utils.OnWindows(): + filepath = 'C:\\test.test' + else: + filepath = '/test.test' + + contents = 'line1.\nline2.\nline3.' + + request_data = RequestWrap( BuildRequest( + filetype = 'ycmtest', + filepath = filepath, + contents = contents, + line_num = 1, + column_num = 7 + ) ) + + text_edit = { + 'newText': 'blah', + 'range': { + 'start': { 'line': 0, 'character': 6 }, + 'end': { 'line': 0, 'character': 6 }, + } + } + + assert_that( lsc._GetCompletionItemStartCodepointOrReject( text_edit, + request_data ), + equal_to( 7 ) ) + + text_edit = { + 'newText': 'blah', + 'range': { + 'start': { 'line': 0, 'character': 6 }, + 'end': { 'line': 1, 'character': 6 }, + } + } + + assert_that( + calling( lsc._GetCompletionItemStartCodepointOrReject ).with_args( + text_edit, request_data ), + raises( lsc.IncompatibleCompletionException ) ) + + text_edit = { + 'newText': 'blah', + 'range': { + 'start': { 'line': 0, 'character': 20 }, + 'end': { 'line': 0, 'character': 20 }, + } + } + + assert_that( + calling( lsc._GetCompletionItemStartCodepointOrReject ).with_args( + text_edit, request_data ), + raises( lsc.IncompatibleCompletionException ) ) + + text_edit = { + 'newText': 'blah', + 'range': { + 'start': { 'line': 0, 'character': 6 }, + 'end': { 'line': 0, 'character': 5 }, + } + } + + assert_that( + calling( lsc._GetCompletionItemStartCodepointOrReject ).with_args( + text_edit, request_data ), + raises( lsc.IncompatibleCompletionException ) ) + + +def WorkspaceEditToFixIt_test(): + if utils.OnWindows(): + filepath = 'C:\\test.test' + uri = 'file:///c:/test.test' + else: + filepath = '/test.test' + uri = 'file:/test.test' + + contents = 'line1\nline2\nline3' + + request_data = RequestWrap( BuildRequest( + filetype = 'ycmtest', + filepath = filepath, + contents = contents + ) ) + + + # We don't support versioned documentChanges + assert_that( lsc.WorkspaceEditToFixIt( request_data, + { 'documentChanges': [] } ), + equal_to( None ) ) + + workspace_edit = { + 'changes': { + uri: [ + { + 'newText': 'blah', + 'range': { + 'start': { 'line': 0, 'character': 5 }, + 'end': { 'line': 0, 'character': 5 }, + } + }, + ] + } + } + + response = responses.BuildFixItResponse( [ + lsc.WorkspaceEditToFixIt( request_data, workspace_edit, 'test' ) + ] ) + + print( 'Response: {0}'.format( response ) ) + print( 'Type Response: {0}'.format( type( response ) ) ) + + assert_that( + response, + has_entries( { + 'fixits': contains( has_entries( { + 'text': 'test', + 'chunks': contains( ChunkMatcher( 'blah', + LocationMatcher( filepath, 1, 6 ), + LocationMatcher( filepath, 1, 6 ) ) ) + } ) ) + } ) + ) + + +def LanguageServerCompleter_DelayedInitialization_test(): + completer = MockCompleter() + request_data = RequestWrap( BuildRequest( filepath = 'Test.ycmtest' ) ) + + with patch.object( completer, '_UpdateServerWithFileContents' ) as update: + with patch.object( completer, '_PurgeFileFromServer' ) as purge: + completer.SendInitialize( request_data ) + completer.OnFileReadyToParse( request_data ) + completer.OnBufferUnload( request_data ) + update.assert_not_called() + purge.assert_not_called() + + # Simulate recept of response and initialization complete + initialize_response = { + 'result': { + 'capabilities': {} + } + } + completer._HandleInitializeInPollThread( initialize_response ) + + update.assert_called_with( request_data ) + purge.assert_called_with( 'Test.ycmtest' ) + + +def LanguageServerCompleter_ShowMessage_test(): + completer = MockCompleter() + request_data = RequestWrap( BuildRequest() ) + notification = { + 'method': 'window/showMessage', + 'params': { + 'message': 'this is a test' + } + } + assert_that( completer.ConvertNotificationToMessage( request_data, + notification ), + has_entries( { 'message': 'this is a test' } ) ) + + +def LanguageServerCompleter_GetCompletions_List_test(): + completer = MockCompleter() + request_data = RequestWrap( BuildRequest() ) + + completion_response = { 'result': [ { 'label': 'test' } ] } + + resolve_responses = [ + { 'result': { 'label': 'test' } }, + ] + + with patch.object( completer, 'ServerIsReady', return_value = True ): + with patch.object( completer.GetConnection(), + 'GetResponse', + side_effect = [ completion_response ] + + resolve_responses ): + assert_that( completer.ComputeCandidatesInner( request_data ), + has_items( has_entries( { 'insertion_text': 'test' } ) ) ) diff --git a/ycmd/tests/language_server/language_server_connection_test.py b/ycmd/tests/language_server/language_server_connection_test.py new file mode 100644 index 0000000000..6cf0f61c9c --- /dev/null +++ b/ycmd/tests/language_server/language_server_connection_test.py @@ -0,0 +1,123 @@ +# Copyright (C) 2017 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +from mock import patch, MagicMock +from ycmd.completers.language_server import language_server_completer as lsc +from hamcrest import assert_that, calling, equal_to, raises +from ycmd.tests.language_server import MockConnection + + +def LanguageServerConnection_ReadPartialMessage_test(): + connection = MockConnection() + + return_values = [ + bytes( b'Content-Length: 10\n\n{"abc":' ), + bytes( b'""}' ), + lsc.LanguageServerConnectionStopped + ] + + with patch.object( connection, 'ReadData', side_effect = return_values ): + with patch.object( connection, '_DispatchMessage' ) as dispatch_message: + connection.run() + dispatch_message.assert_called_with( { 'abc': '' } ) + + +def LanguageServerConnection_MissingHeader_test(): + connection = MockConnection() + + return_values = [ + bytes( b'Content-NOTLENGTH: 10\n\n{"abc":' ), + bytes( b'""}' ), + lsc.LanguageServerConnectionStopped + ] + + with patch.object( connection, 'ReadData', side_effect = return_values ): + assert_that( calling( connection._ReadMessages ), raises( ValueError ) ) + + +def LanguageServerConnection_RequestAbortCallback_test(): + connection = MockConnection() + + return_values = [ + lsc.LanguageServerConnectionStopped + ] + + with patch.object( connection, 'ReadData', side_effect = return_values ): + callback = MagicMock() + response = connection.GetResponseAsync( 1, + bytes( b'{"test":"test"}' ), + response_callback = callback ) + connection.run() + callback.assert_called_with( response, None ) + + +def LanguageServerConnection_RequestAbortAwait_test(): + connection = MockConnection() + + return_values = [ + lsc.LanguageServerConnectionStopped + ] + + with patch.object( connection, 'ReadData', side_effect = return_values ): + response = connection.GetResponseAsync( 1, + bytes( b'{"test":"test"}' ) ) + connection.run() + assert_that( calling( response.AwaitResponse ).with_args( 10 ), + raises( lsc.ResponseAbortedException ) ) + + +def LanguageServerConnection_ServerConnectionDies_test(): + connection = MockConnection() + + return_values = [ + IOError + ] + + with patch.object( connection, 'ReadData', side_effect = return_values ): + # No exception is thrown + connection.run() + + +@patch( 'ycmd.completers.language_server.language_server_completer.' + 'CONNECTION_TIMEOUT', + 0.5 ) +def LanguageServerConnection_ConnectionTimeout_test(): + connection = MockConnection() + with patch.object( connection, + 'TryServerConnectionBlocking', + side_effect=RuntimeError ): + connection.Start() + assert_that( calling( connection.AwaitServerConnection ), + raises( lsc.LanguageServerConnectionTimeout ) ) + + assert_that( connection.isAlive(), equal_to( False ) ) + + +def LanguageServerConnection_CloseTwice_test(): + connection = MockConnection() + with patch.object( connection, + 'TryServerConnectionBlocking', + side_effect=RuntimeError ): + connection.Close() + connection.Close() diff --git a/ycmd/tests/language_server/language_server_protocol_test.py b/ycmd/tests/language_server/language_server_protocol_test.py new file mode 100644 index 0000000000..1de8e893d7 --- /dev/null +++ b/ycmd/tests/language_server/language_server_protocol_test.py @@ -0,0 +1,182 @@ +# Copyright (C) 2017 ycmd contributors +# +# This file is part of ycmd. +# +# ycmd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ycmd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ycmd. If not, see . + +from __future__ import unicode_literals +from __future__ import print_function +from __future__ import division +from __future__ import absolute_import +# Not installing aliases from python-future; it's unreliable and slow. +from builtins import * # noqa + +from ycmd.completers.language_server import language_server_protocol as lsp +from hamcrest import assert_that, equal_to, calling, is_not, raises +from ycmd.tests.test_utils import UnixOnly, WindowsOnly + + +def ServerFileStateStore_RetrieveDelete_test(): + store = lsp.ServerFileStateStore() + + # New state object created + file1_state = store[ 'file1' ] + assert_that( file1_state.version, equal_to( 0 ) ) + assert_that( file1_state.checksum, equal_to( None ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.CLOSED ) ) + + # Retrieve again unchanged + file1_state = store[ 'file1' ] + assert_that( file1_state.version, equal_to( 0 ) ) + assert_that( file1_state.checksum, equal_to( None ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.CLOSED ) ) + + # Retrieve/create another one (we don't actually open this one) + file2_state = store[ 'file2' ] + assert_that( file2_state.version, equal_to( 0 ) ) + assert_that( file2_state.checksum, equal_to( None ) ) + assert_that( file2_state.state, equal_to( lsp.ServerFileState.CLOSED ) ) + + # Checking for refresh on closed file is no-op + assert_that( file1_state.GetSavedFileAction( 'blah' ), + equal_to( lsp.ServerFileState.NO_ACTION ) ) + assert_that( file1_state.version, equal_to( 0 ) ) + assert_that( file1_state.checksum, equal_to( None ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.CLOSED ) ) + + + # Checking the next action progresses the state + assert_that( file1_state.GetDirtyFileAction( 'test contents' ), + equal_to( lsp.ServerFileState.OPEN_FILE ) ) + assert_that( file1_state.version, equal_to( 1 ) ) + assert_that( file1_state.checksum, is_not( equal_to( None ) ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.OPEN ) ) + + # Replacing the same file is no-op + assert_that( file1_state.GetDirtyFileAction( 'test contents' ), + equal_to( lsp.ServerFileState.NO_ACTION ) ) + assert_that( file1_state.version, equal_to( 1 ) ) + assert_that( file1_state.checksum, is_not( equal_to( None ) ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.OPEN ) ) + + # Changing the file creates a new version + assert_that( file1_state.GetDirtyFileAction( 'test contents changed' ), + equal_to( lsp.ServerFileState.CHANGE_FILE ) ) + assert_that( file1_state.version, equal_to( 2 ) ) + assert_that( file1_state.checksum, is_not( equal_to( None ) ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.OPEN ) ) + + # Replacing the same file is no-op + assert_that( file1_state.GetDirtyFileAction( 'test contents changed' ), + equal_to( lsp.ServerFileState.NO_ACTION ) ) + assert_that( file1_state.version, equal_to( 2 ) ) + assert_that( file1_state.checksum, is_not( equal_to( None ) ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.OPEN ) ) + + # Checking for refresh without change is no-op + assert_that( file1_state.GetSavedFileAction( 'test contents changed' ), + equal_to( lsp.ServerFileState.NO_ACTION ) ) + assert_that( file1_state.version, equal_to( 2 ) ) + assert_that( file1_state.checksum, is_not( equal_to( None ) ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.OPEN ) ) + + # Changing the same file is a new version + assert_that( file1_state.GetDirtyFileAction( 'test contents changed again' ), + equal_to( lsp.ServerFileState.CHANGE_FILE ) ) + assert_that( file1_state.version, equal_to( 3 ) ) + assert_that( file1_state.checksum, is_not( equal_to( None ) ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.OPEN ) ) + + # Checking for refresh with change is a new version + assert_that( file1_state.GetSavedFileAction( 'test changed back' ), + equal_to( lsp.ServerFileState.CHANGE_FILE ) ) + assert_that( file1_state.version, equal_to( 4 ) ) + assert_that( file1_state.checksum, is_not( equal_to( None ) ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.OPEN ) ) + + # Closing an open file progressed the state + assert_that( file1_state.GetFileCloseAction(), + equal_to( lsp.ServerFileState.CLOSE_FILE ) ) + assert_that( file1_state.version, equal_to( 4 ) ) + assert_that( file1_state.checksum, is_not( equal_to( None ) ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.CLOSED ) ) + + # Replacing a closed file opens it + assert_that( file1_state.GetDirtyFileAction( 'test contents again2' ), + equal_to( lsp.ServerFileState.OPEN_FILE ) ) + assert_that( file1_state.version, equal_to( 1 ) ) + assert_that( file1_state.checksum, is_not( equal_to( None ) ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.OPEN ) ) + + # Closing an open file progressed the state + assert_that( file1_state.GetFileCloseAction(), + equal_to( lsp.ServerFileState.CLOSE_FILE ) ) + assert_that( file1_state.version, equal_to( 1 ) ) + assert_that( file1_state.checksum, is_not( equal_to( None ) ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.CLOSED ) ) + + # You can del a closed file + del store[ file1_state.filename ] + + # Replacing a del'd file opens it again + file1_state = store[ 'file1' ] + assert_that( file1_state.GetDirtyFileAction( 'test contents again3' ), + equal_to( lsp.ServerFileState.OPEN_FILE ) ) + assert_that( file1_state.version, equal_to( 1 ) ) + assert_that( file1_state.checksum, is_not( equal_to( None ) ) ) + assert_that( file1_state.state, equal_to( lsp.ServerFileState.OPEN ) ) + + # You can del an open file (though you probably shouldn't) + del store[ file1_state.filename ] + + # Closing a closed file is a noop + assert_that( file2_state.GetFileCloseAction(), + equal_to( lsp.ServerFileState.NO_ACTION ) ) + assert_that( file2_state.version, equal_to( 0 ) ) + assert_that( file2_state.checksum, equal_to( None ) ) + assert_that( file2_state.state, equal_to( lsp.ServerFileState.CLOSED ) ) + + +@UnixOnly +def UriToFilePath_Unix_test(): + assert_that( calling( lsp.UriToFilePath ).with_args( 'test' ), + raises( lsp.InvalidUriException ) ) + + assert_that( lsp.UriToFilePath( 'file:/usr/local/test/test.test' ), + equal_to( '/usr/local/test/test.test' ) ) + assert_that( lsp.UriToFilePath( 'file:///usr/local/test/test.test' ), + equal_to( '/usr/local/test/test.test' ) ) + + +@WindowsOnly +def UriToFilePath_Windows_test(): + assert_that( calling( lsp.UriToFilePath ).with_args( 'test' ), + raises( lsp.InvalidUriException ) ) + + assert_that( lsp.UriToFilePath( 'file:c:/usr/local/test/test.test' ), + equal_to( 'C:\\usr\\local\\test\\test.test' ) ) + assert_that( lsp.UriToFilePath( 'file://c:/usr/local/test/test.test' ), + equal_to( 'C:\\usr\\local\\test\\test.test' ) ) + + +@UnixOnly +def FilePathToUri_Unix_test(): + assert_that( lsp.FilePathToUri( '/usr/local/test/test.test' ), + equal_to( 'file:///usr/local/test/test.test' ) ) + + +@WindowsOnly +def FilePathToUri_Windows_test(): + assert_that( lsp.FilePathToUri( 'C:\\usr\\local\\test\\test.test' ), + equal_to( 'file:///C:/usr/local/test/test.test' ) ) diff --git a/ycmd/tests/server_utils_test.py b/ycmd/tests/server_utils_test.py index c3555eb9e3..15df101a2e 100644 --- a/ycmd/tests/server_utils_test.py +++ b/ycmd/tests/server_utils_test.py @@ -50,7 +50,6 @@ os.path.join( DIR_OF_THIRD_PARTY, 'tern_runtime' ), os.path.join( DIR_OF_THIRD_PARTY, 'waitress' ), os.path.join( DIR_OF_THIRD_PARTY, 'eclipse.jdt.ls' ), - os.path.join( DIR_OF_THIRD_PARTY, 'eclipse.jdt.ls-workspace' ), ) diff --git a/ycmd/tests/shutdown_test.py b/ycmd/tests/shutdown_test.py index 230ce33da7..eb39664a0a 100644 --- a/ycmd/tests/shutdown_test.py +++ b/ycmd/tests/shutdown_test.py @@ -28,11 +28,11 @@ # Time to wait for all the servers to shutdown. Tweak for the CI environment. # -# NOTE: The timeout is 1 minute. That is a long time, but the java sub-server +# NOTE: The timeout is 2 minutes. That is a long time, but the java sub-server # (jdt.ls) takes a _long time_ to finally actually shut down. This is because it # is based on eclipse, which must do whatever eclipse must do when it shuts down # its workspace. -SUBSERVER_SHUTDOWN_TIMEOUT = 60 +SUBSERVER_SHUTDOWN_TIMEOUT = 120 class Shutdown_test( Client_test ): From c42d5e8f196e1f216da429ca9f80f4308db0d0c4 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Fri, 29 Dec 2017 14:44:21 +0000 Subject: [PATCH 48/51] Update API docs for mesage poll request --- docs/openapi.yaml | 152 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 149ebf83af..d10df6494b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -307,9 +307,13 @@ definitions: enum: - WARNING - ERROR + - INFORMATION + - HINT description: |- The type of diagnostic being reported. Typically semantic engines will - differentiate between warnings and fatal errors. + differentiate between warnings and fatal errors. Informational and + hint messages should be treated as warnings where the client does not + differentiate. fixit_available: type: boolean description: |- @@ -509,6 +513,71 @@ definitions: items: $ref: "#/definitions/ItemData" + MessagePollResponse: + type: boolean + description: |- + When `true` is returned, the request timed out (meaning no + messages were returned in the poll period). Clients should + send another `receive_messages` request immediately. + + When `false` is returned, the server determined that message + polling should abort for the current file type context. Clients + should not re-send this request until the filetype being edited + changes or the server is restarted. + + MessageList: + type: array + description: |- + A list of messages in the sequence they should be handled. + + The type of message in each item is determined by the property name: + + - An object with a property `message` is a *simple display message* where + the property value is the message. + - An object with a property `diagnostics` contains diagnotics for a + project file. The value of the property is described below. + items: + $ref: '#/definitions/Message' + + Message: + type: object + description: + An object containing a single asynchronous message. + + It is either a `SimpleDisplayMessage` or a `DiagnosticsMessage` + properties: + message: + $ref: '#/definitions/SimpleDisplayMessage' + description: If present, this object is a `SimpleDisplayMessage` + diagnostics: + $ref: '#/definitions/DiagnosticsMessage' + description: If present, this object is a `DiagnosticsMessage` + + SimpleDisplayMessage: + type: string + description: |- + A message for display to the user. Note: the message should be displayed + discreetly (such as in a status bar) and should not block the user or + interrupt them. + + DiagnosticsMessage: + type: object + description: |- + Diagnostics for a particular file. Note: diagnostics may be supplied for + any arbitrary file. The client is responsible for displaying the + diagnostics in an appropriate manner. The server supplies an empty set of + diagnostics to clear the diagnostics for a particular file. + required: + - filepath + - diagnostics + properties: + filpath: + $ref: '#/definitions/FilePath' + diagnotics: + type: array + items: + $ref: "#/definitions/DiagnosticData" + paths: /event_notification: post: @@ -1156,3 +1225,84 @@ paths: description: An error occurred. schema: $ref: "#/definitions/ExceptionResponse" + /receive_messages: + post: + summary: Long poll for asynchronous server messages. + description: |- + Return asynchronous messages from the server. This request is + used by clients in a "long poll" style, and does not return until + either: + + - A message (or messages) becomes available, in which case a list of + messages is returned, or + - a timeout occurs (after 60s), in which case `true` is returned and + the client should re-send this request, or + - for some reason the server determined that the client should stop + sending `receive_messages` requests, in which case `false` is + returned, and the client should only send the request again when + something substantial changes such as a new file type is opened, or + the completer server is manually restarted. + + The following types of event are delivered asynchronously for certain + filetypes: + + - Status messages to be displayed unobtrusively to the user + - Diagnostics (for Java only) + + This message is optional. Clients do not require to implement this + method, but it is strongly recommended for certain languages to offer + the best user experience. + produces: + application/json + parameters: + - name: request_data + in: body + description: |- + The context data, including the current cursor position, and details + of dirty buffers. + required: true + schema: + $ref: "#/definitions/SimpleRequest" + responses: + 200: + description: |- + Messages are ready, the request timed out, or the request + is not supported and should not be retried. + + The response may be **one of** `MessagePollResponse` or + `MessagesList`. + schema: + allOf: + - $ref: '#/definitions/MessagePollResponse' + - $ref: '#/definitions/MessageList' + examples: + application/json: + - message: 'Initializing: 19% complete' + - message: 'Initializing: Done.' + - diagonostics: + filepath: '/file' + diagnotics: + - ranges: + - start: { line_num: 10, column_num: 11, filepath: '/file' } + end: { line_num: 10, column_num: 20, filepath: '/file' } + location: { line_num: 10, column_num: 11, filepath: '/file' } + location_extent: + start: { line_num: 10, column_num: 11, filepath: '/file' } + end: { line_num: 10, column_num: 11, filepath: '/file' } + text: Very naughty code! + kind: WARNING + fixit_available: false + - ranges: + - start: { line_num: 19, column_num: 11, filepath: '/file' } + end: { line_num: 19, column_num: 20, filepath: '/file' } + location: { line_num: 19, column_num: 11, filepath: '/file' } + location_extent: + start: { line_num: 19, column_num: 11, filepath: '/file' } + end: { line_num: 19, column_num: 11, filepath: '/file' } + text: Very dangerous code! + kind: ERROR + fixit_available: true + 500: + description: An error occurred. + schema: + $ref: "#/definitions/ExceptionResponse" From 9b08789cecf134d3b82fc3e63d086dab3094e4bb Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sat, 20 Jan 2018 18:47:58 +0000 Subject: [PATCH 49/51] Ensure that the message poll doesn't fail on first request Since we start the server on the FileReadyToParse event, the client might send the initial poll request before we've attempted to start the server. So in that case we wait at least one poll interval for the initialize sequence to complete before starting the poll loop. --- .../language_server_completer.py | 29 +++++++++++++++++-- ycmd/tests/java/diagnostics_test.py | 2 ++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index b50716d8ad..6fe733ea4d 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -129,7 +129,7 @@ def AwaitResponse( self, timeout ): - throws ResponseAbortedException in case the server is shut down.""" self._event.wait( timeout ) - if not self._event.isSet(): + if not self._event.is_set(): raise ResponseTimeoutException( 'Response Timeout' ) if self._message is None: @@ -352,7 +352,7 @@ def AwaitServerConnection( self ): clients should shut down their server and reset their state.""" self._connection_event.wait( timeout = CONNECTION_TIMEOUT ) - if not self._connection_event.isSet(): + if not self._connection_event.is_set(): raise LanguageServerConnectionTimeout( 'Timed out waiting for server to connect' ) @@ -674,6 +674,14 @@ def ShutdownServer( self ): if self.ServerIsHealthy(): self.GetConnection().SendNotification( lsp.Exit() ) + # If any threads are waiting for the initialize exchange to complete, + # release them, as there is no chance of getting a response now. + if ( self._initialize_response is not None and + not self._initialize_event.is_set() ): + with self._server_info_mutex: + self._initialize_response = None + self._initialize_event.set() + def ServerIsReady( self ): """Returns True if the server is running and the initialization exchange has @@ -873,6 +881,12 @@ def _GetPendingMessages( self, request_data ): If there are no messages pending, returns an empty list. Returns False if an error occurred and no further polling should be attempted.""" messages = list() + + if not self._initialize_event.is_set(): + # The request came before we started up, there cannot be any messages + # pending, and in any case they will be handled later. + return messages + try: while True: if not self.GetConnection(): @@ -901,6 +915,17 @@ def _AwaitServerMessages( self, request_data, timeout ): """ try: while True: + if not self._initialize_event.is_set(): + # The request came before we started up, wait for startup to complete, + # then tell the client to re-send the request. Note, we perform this + # check on every iteration, as the server may be legitimately + # restarted while this loop is running. + self._initialize_event.wait( timeout=timeout ) + + # If the timeout is hit waiting for the server to be ready, we return + # False and kill the message poll. + return self._initialize_event.is_set() + if not self.GetConnection(): # The server isn't running or something. Don't re-poll, as this will # just cause errors. diff --git a/ycmd/tests/java/diagnostics_test.py b/ycmd/tests/java/diagnostics_test.py index 8764b96ecf..475c9bbec5 100644 --- a/ycmd/tests/java/diagnostics_test.py +++ b/ycmd/tests/java/diagnostics_test.py @@ -46,6 +46,7 @@ from ycmd.tests.test_utils import BuildRequest, LocationMatcher from ycmd.utils import ReadFile +from ycmd.completers import completer from pprint import pformat from mock import patch @@ -596,6 +597,7 @@ def PollForMessages_InvalidUri_test( app, *args ): @IsolatedYcmd +@patch.object( completer, 'MESSAGE_POLL_TIMEOUT', 2 ) def PollForMessages_ServerNotRunning_test( app ): StartJavaCompleterServerInDirectory( app, From feba227704a41f95ce1b9897601b09239401dec7 Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sat, 20 Jan 2018 19:18:48 +0000 Subject: [PATCH 50/51] Use UTF16 code unit offsets not codepoint offsets Per the language server protocol, we need to count UTF16 code units. --- .../language_server_completer.py | 24 +++++++----- .../language_server_protocol.py | 38 +++++++++++++++++++ .../language_server_completer_test.py | 10 ++--- .../language_server_protocol_test.py | 25 ++++++++++++ 4 files changed, 81 insertions(+), 16 deletions(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 6fe733ea4d..c544e6b71e 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -1327,7 +1327,9 @@ def WithinRange( diag ): }, 'end': { 'line': line_num_ls, - 'character': len( line_value ) - 1, + 'character': lsp.CodepointsToUTF16CodeUnits( + line_value, + len( line_value ) ) - 1, } }, [] ), @@ -1482,8 +1484,6 @@ def _InsertionTextForItem( request_data, item ): def _GetCompletionItemStartCodepointOrReject( text_edit, request_data ): edit_range = text_edit[ 'range' ] - start_codepoint = edit_range[ 'start' ][ 'character' ] + 1 - end_codepoint = edit_range[ 'end' ][ 'character' ] + 1 # Conservatively rejecting candidates that breach the protocol if edit_range[ 'start' ][ 'line' ] != edit_range[ 'end' ][ 'line' ]: @@ -1491,16 +1491,19 @@ def _GetCompletionItemStartCodepointOrReject( text_edit, request_data ): "The TextEdit '{0}' spans multiple lines".format( text_edit[ 'newText' ] ) ) + file_contents = utils.SplitLines( + GetFileContents( request_data, request_data[ 'filepath' ] ) ) + line_value = file_contents[ edit_range[ 'start' ][ 'line' ] ] + + start_codepoint = lsp.UTF16CodeUnitsToCodepoints( + line_value, + edit_range[ 'start' ][ 'character' ] + 1 ) + if start_codepoint > request_data[ 'start_codepoint' ]: raise IncompatibleCompletionException( "The TextEdit '{0}' starts after the start position".format( text_edit[ 'newText' ] ) ) - if end_codepoint < request_data[ 'start_codepoint' ]: - raise IncompatibleCompletionException( - "The TextEdit '{0}' ends before the start position".format( - text_edit[ 'newText' ] ) ) - return start_codepoint @@ -1559,8 +1562,9 @@ def _BuildLocationAndDescription( request_data, filename, file_contents, loc ): try: line_value = file_contents[ loc[ 'line' ] ] - column = utils.CodepointOffsetToByteOffset( line_value, - loc[ 'character' ] + 1 ) + column = utils.CodepointOffsetToByteOffset( + line_value, + lsp.UTF16CodeUnitsToCodepoints( line_value, loc[ 'character' ] + 1 ) ) except IndexError: # This can happen when there are stale diagnostics in OnFileReadyToParse, # just return the value as-is. diff --git a/ycmd/completers/language_server/language_server_protocol.py b/ycmd/completers/language_server/language_server_protocol.py index 61c3f1452a..52c45dc7db 100644 --- a/ycmd/completers/language_server/language_server_protocol.py +++ b/ycmd/completers/language_server/language_server_protocol.py @@ -345,3 +345,41 @@ def _BuildMessageData( message ): def Parse( data ): """Reads the raw language server message payload into a Python dictionary""" return json.loads( ToUnicode( data ) ) + + +def CodepointsToUTF16CodeUnits( line_value, codepoint_offset ): + """Return the 1-based UTF16 code unit offset equivalent to the 1-based unicode + icodepoint offset |codepoint_offset| in the the Unicode string |line_value|""" + # Language server protocol requires offsets to be in utf16 code _units_. + # Each code unit is 2 bytes. + # So we re-encode the line as utf-16 and divide the length in bytes by 2. + # + # Of course, this is a terrible API, but until all the servers support any + # change out of + # https://github.com/Microsoft/language-server-protocol/issues/376 then we + # have to jump through hoops. + if codepoint_offset > len( line_value ): + return ( len( line_value.encode( 'utf-16-le' ) ) + 2 ) // 2 + + value_as_utf16 = line_value[ : codepoint_offset ].encode( 'utf-16-le' ) + return len( value_as_utf16 ) // 2 + + +def UTF16CodeUnitsToCodepoints( line_value, code_unit_offset ): + """Return the 1-based codepoint offset into the unicode string |line_value| + equivalent to the 1-based UTF16 code unit offset |code_unit_offset| into a + utf16 encoded version of |line_value|""" + # As above, LSP returns offsets in utf16 code units. So we convert the line to + # UTF16, snip everything up to the code_unit_offset * 2 bytes (each code unit + # is 2 bytes), then re-encode as unicode and return the length (in + # codepoints). + value_as_utf16_bytes = ToBytes( line_value.encode( 'utf-16-le' ) ) + + byte_offset_utf16 = code_unit_offset * 2 + if byte_offset_utf16 > len( value_as_utf16_bytes ): + # If the offset points off the end of the string, then the codepoint offset + # is one-past-the-end of the string in unicode codepoints + return len( line_value ) + 1 + + bytes_included = value_as_utf16_bytes[ : code_unit_offset * 2 ] + return len( bytes_included.decode( 'utf-16-le' ) ) diff --git a/ycmd/tests/language_server/language_server_completer_test.py b/ycmd/tests/language_server/language_server_completer_test.py index 17d027abdb..41f514bece 100644 --- a/ycmd/tests/language_server/language_server_completer_test.py +++ b/ycmd/tests/language_server/language_server_completer_test.py @@ -257,9 +257,8 @@ def GetCompletions_RejectInvalid_test(): } assert_that( - calling( lsc._GetCompletionItemStartCodepointOrReject ).with_args( - text_edit, request_data ), - raises( lsc.IncompatibleCompletionException ) ) + lsc._GetCompletionItemStartCodepointOrReject( text_edit, request_data ), + equal_to( 7 ) ) text_edit = { 'newText': 'blah', @@ -270,9 +269,8 @@ def GetCompletions_RejectInvalid_test(): } assert_that( - calling( lsc._GetCompletionItemStartCodepointOrReject ).with_args( - text_edit, request_data ), - raises( lsc.IncompatibleCompletionException ) ) + lsc._GetCompletionItemStartCodepointOrReject( text_edit, request_data ), + equal_to( 7 ) ) def WorkspaceEditToFixIt_test(): diff --git a/ycmd/tests/language_server/language_server_protocol_test.py b/ycmd/tests/language_server/language_server_protocol_test.py index 1de8e893d7..ba27404639 100644 --- a/ycmd/tests/language_server/language_server_protocol_test.py +++ b/ycmd/tests/language_server/language_server_protocol_test.py @@ -1,3 +1,5 @@ +# coding: utf-8 +# # Copyright (C) 2017 ycmd contributors # # This file is part of ycmd. @@ -180,3 +182,26 @@ def FilePathToUri_Unix_test(): def FilePathToUri_Windows_test(): assert_that( lsp.FilePathToUri( 'C:\\usr\\local\\test\\test.test' ), equal_to( 'file:///C:/usr/local/test/test.test' ) ) + + +def CodepointsToUTF16CodeUnitsAndReverse_test(): + def Test( line_value, codepoints, code_units ): + assert_that( lsp.CodepointsToUTF16CodeUnits( line_value, codepoints ), + equal_to( code_units ) ) + assert_that( lsp.UTF16CodeUnitsToCodepoints( line_value, code_units ), + equal_to( codepoints ) ) + + tests = ( + ( '', 0, 0 ), + ( 'abcdef', 1, 1 ), + ( 'abcdef', 2, 2 ), + ( 'abc', 4, 4 ), + ( '😉test', len( '😉' ), 2 ), + ( '😉', len( '😉' ), 2 ), + ( '😉test', len( '😉' ) + 1, 3 ), + ( 'te😉st', 1, 1 ), + ( 'te😉st', 2 + len( '😉' ) + 1, 5 ), + ) + + for test in tests: + yield Test, test[ 0 ], test[ 1 ], test[ 2 ] From e03836f30e0ee9d2a688726c2f924d29568d59ff Mon Sep 17 00:00:00 2001 From: Ben Jackson Date: Sat, 20 Jan 2018 21:57:49 +0000 Subject: [PATCH 51/51] Only resolve up to 100 items On larger projects and slower systems, resolving many items can be very costly, and not really all that useful. --- .../language_server/language_server_completer.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index c544e6b71e..787296927e 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -797,12 +797,17 @@ def _ResolveCompletionItems( self, items, request_data ): start_codepoints = list() min_start_codepoint = request_data[ 'start_codepoint' ] + # Resolving takes some time, so only do it if there are fewer than 100 + # candidates. + resolve_completion_items = ( len( items ) < 100 and + self._resolve_completion_items ) + # First generate all of the completion items and store their # start_codepoints. Then, we fix-up the completion texts to use the # earliest start_codepoint by borrowing text from the original line. for item in items: # First, resolve the completion. - if self._resolve_completion_items: + if resolve_completion_items: item = self._ResolveCompletionItem( item ) try: