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 ab68152069..727cebfe13 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,3 +67,6 @@ coverage.xml
# API docs
docs/node_modules
docs/package-lock.json
+
+# jdt.ls
+third_party/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/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
diff --git a/README.md b/README.md
index 7eba100b21..c412da5e25 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,11 +87,12 @@ 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 for TypeScript. More will be added with time.
+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), 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).
@@ -338,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/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 ee44cd2d93..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
@@ -30,17 +33,27 @@
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(
'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' ) ) )
+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 '
@@ -72,6 +85,12 @@
)$
"""
+JDTLS_MILESTONE = '0.11.0'
+JDTLS_BUILD_STAMP = '201801162212'
+JDTLS_SHA256 = (
+ '5afa45d1ba3d38d4c6c9ef172874b430730ee168db365c5e5209b39d53deab23'
+)
+
def OnMac():
return platform.system() == 'Darwin'
@@ -85,6 +104,17 @@ def OnCiService():
return 'CI' in os.environ
+def FindExecutableOrDie( executable, message ):
+ path = FindExecutable( executable )
+
+ if not path:
+ sys.exit( "ERROR: Unable 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 +293,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 +498,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 +527,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 +551,61 @@ def EnableJavaScriptCompleter():
CheckCall( [ npm, 'install', '--production' ] )
+def EnableJavaCompleter():
+ 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():
path = p.join( DIR_OF_THIS_SCRIPT, 'PYTHON_USED_DURING_BUILDING' )
with open( path, 'w' ) as f:
@@ -542,6 +626,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/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..d10df6494b 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"
@@ -295,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: |-
@@ -497,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:
@@ -512,9 +593,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
@@ -1144,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"
diff --git a/examples/example_client.py b/examples/example_client.py
index 6cf79fe015..4a1c573638 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 5 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 5 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
+ }
+}
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/completer.py b/ycmd/completers/completer.py
index 06c3235930..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
@@ -378,6 +396,18 @@ def ServerIsHealthy( self ):
return True
+ def PollForMessages( self, request_data ):
+ return self.PollForMessagesInner( request_data, MESSAGE_POLL_TIMEOUT )
+
+
+ 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
+
+
class CompletionsCache( object ):
"""Completions for a particular request. Importantly, columns are byte
offsets, not unicode codepoints."""
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..ab80376ee5
--- /dev/null
+++ b/ycmd/completers/java/hook.py
@@ -0,0 +1,33 @@
+# 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.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..625fdb0347
--- /dev/null
+++ b/ycmd/completers/java/java_completer.py
@@ -0,0 +1,546 @@
+# 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 glob
+import hashlib
+import logging
+import os
+import shutil
+import tempfile
+import threading
+from subprocess import PIPE
+
+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.abspath( os.path.join(
+ os.path.dirname( __file__ ),
+ '..',
+ '..',
+ '..',
+ 'third_party',
+ 'eclipse.jdt.ls',
+ 'target',
+ 'repository' ) )
+
+PATH_TO_JAVA = utils.PathToFirstExistingExecutable( [ 'java' ] )
+
+PROJECT_FILE_TAILS = [
+ '.project',
+ 'pom.xml',
+ 'build.gradle'
+]
+
+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
+# frustrating.
+#
+# Pros for re-use:
+# - Startup time is significantly improved. This could be very meaningful on
+# larger projects
+#
+# Cons:
+# - A little more complexity (we hash the project path to create the workspace
+# directory)
+# - It breaks our tests which expect the logs to be deleted
+# - It can lead to multiple jdt.ls 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
+# ycmd instance
+# - An option is available to re-use workspaces
+CLEAN_WORKSPACE_OPTION = 'java_jdtls_use_clean_workspace'
+
+
+def ShouldEnableJavaCompleter():
+ _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: jdt.ls is 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.
+ 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( launcher_jars ) )
+
+ if not launcher_jars:
+ return None
+
+ return launcher_jars[ 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 ) )
+
+
+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 synchronized
+ self._server_state_mutex = threading.RLock()
+
+ self._connection = None
+ self._server_handle = None
+ self._server_stderr = None
+ self._workspace_path = None
+ self._CleanUp()
+
+
+ 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.GetCodeActions( request_data,
+ args )
+ ),
+ 'RefactorRename': (
+ lambda self, request_data, args: self.RefactorRename( request_data,
+ args )
+ ),
+
+ # Handled by us
+ 'RestartServer': (
+ lambda self, request_data, args: self._RestartServer( request_data )
+ ),
+ '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 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 ),
+ 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 = "jdt.ls Java Language Server",
+ handle = self._server_handle,
+ executable = self._launcher_path,
+ logfiles = [
+ self._server_stderr,
+ ( os.path.join( self._workspace_path, '.metadata', '.log' )
+ if self._workspace_path else None )
+ ],
+ extras = items
+ )
+ ] )
+
+
+ def Shutdown( self ):
+ self._StopServer()
+
+
+ def ServerIsHealthy( self ):
+ return self._ServerIsRunning()
+
+
+ def ServerIsReady( self ):
+ return ( self.ServerIsHealthy() and
+ self._received_ready_message.is_set() and
+ super( JavaCompleter, self ).ServerIsReady() )
+
+
+ def _GetProjectDirectory( self, request_data ):
+ return self._project_dir
+
+
+ def _ServerIsRunning( self ):
+ return utils.ProcessIsRunning( self._server_handle )
+
+
+ def _RestartServer( self, request_data ):
+ with self._server_state_mutex:
+ self._StopServer()
+ self._StartServer( request_data )
+
+
+ def _CleanUp( self ):
+ if not self._server_keep_logfiles:
+ if self._server_stderr:
+ utils.RemoveIfExists( self._server_stderr )
+ self._server_stderr = None
+
+ if self._workspace_path and self._use_clean_workspace:
+ try:
+ shutil.rmtree( self._workspace_path )
+ except OSError:
+ _logger.exception( 'Failed to clean up workspace dir {0}'.format(
+ self._workspace_path ) )
+
+ self._launcher_path = _PathToLauncherJar()
+ self._launcher_config = _LauncherConfiguration()
+ self._workspace_path = None
+ 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
+
+ self.ServerReset()
+
+
+ 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(
+ os.path.dirname( request_data[ 'filepath' ] ) )
+ self._workspace_path = _WorkspaceDirForProject(
+ self._project_dir,
+ self._use_clean_workspace )
+
+ 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',
+ '-Dlog.level=ALL',
+ '-jar', self._launcher_path,
+ '-configuration', self._launcher_config,
+ '-data', self._workspace_path,
+ ]
+
+ _logger.debug( 'Starting java-server with the following command: '
+ '{0}'.format( ' '.join( command ) ) )
+
+ self._server_stderr = utils.CreateLogfile( 'jdt.ls_stderr_' )
+ with utils.OpenForStdHandle( self._server_stderr ) as stderr:
+ self._server_handle = utils.SafePopen( command,
+ stdin = PIPE,
+ stdout = PIPE,
+ stderr = stderr )
+
+ 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,
+ self._server_handle.stdout,
+ self.GetDefaultNotificationHandler() )
+ )
+
+ self._connection.Start()
+
+ try:
+ self._connection.AwaitServerConnection()
+ except language_server_completer.LanguageServerConnectionTimeout:
+ _logger.error( 'jdt.ls failed to start, or did not connect '
+ 'successfully' )
+ self._StopServer()
+ return
+
+ self.SendInitialize( request_data )
+
+
+ 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.
+ #
+ # 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()
+
+ # Tell the connection to expect the server to disconnect
+ if self._connection:
+ self._connection.Stop()
+
+ if not self._ServerIsRunning():
+ _logger.info( 'jdt.ls Language server not running' )
+ self._CleanUp()
+ return
+
+ _logger.info( 'Stopping java server with PID {0}'.format(
+ self._server_handle.pid ) )
+
+ try:
+ self.ShutdownServer()
+
+ # 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 HandleNotificationInPollThread( self, notification ):
+ if notification[ 'method' ] == 'language/status':
+ message_type = notification[ 'params' ][ 'type' ]
+
+ if message_type == 'Started':
+ _logger.info( 'jdt.ls initialized successfully.' )
+ self._received_ready_message.set()
+
+ self._server_init_status = notification[ 'params' ][ 'message' ]
+
+ super( JavaCompleter, self ).HandleNotificationInPollThread( 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(
+ request_data,
+ notification )
+
+
+ def GetType( 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, 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 ( KeyError, TypeError, IndexError ):
+ raise RuntimeError( 'Unknown type' )
+
+ return responses.BuildDisplayMessageResponse( get_type_java )
+
+
+ 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 ):
+ for item in hover_response:
+ if isinstance( item, str ):
+ documentation += item + '\n'
+
+ documentation = documentation.rstrip()
+
+ if not documentation:
+ raise RuntimeError( NO_DOCUMENTATION_MESSAGE )
+
+ return responses.BuildDetailedInfoResponse( documentation )
+
+
+ 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
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/__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..787296927e
--- /dev/null
+++ b/ycmd/completers/language_server/language_server_completer.py
@@ -0,0 +1,1663 @@
+# 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 future.utils import iteritems, iterkeys
+import abc
+import collections
+import logging
+import os
+import queue
+import threading
+
+from ycmd.completers.completer import Completer
+from ycmd.completers.completer_utils import GetFileContents
+from ycmd import utils
+from ycmd import responses
+
+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
+
+
+class ResponseTimeoutException( Exception ):
+ """Raised by LanguageServerConnection if a request exceeds the supplied
+ time-to-live."""
+ pass # pragma: no cover
+
+
+class ResponseAbortedException( Exception ):
+ """Raised by LanguageServerConnection if a request is canceled due to the
+ server shutting down."""
+ pass # pragma: no cover
+
+
+class ResponseFailedException( Exception ):
+ """Raised by LanguageServerConnection if a request returns an error"""
+ 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 # pragma: no cover
+
+
+class LanguageServerConnectionTimeout( Exception ):
+ """Raised by LanguageServerConnection if the connection to the server is not
+ established with the specified timeout."""
+ pass # pragma: no cover
+
+
+class LanguageServerConnectionStopped( Exception ):
+ """Internal exception raised by LanguageServerConnection when the server is
+ successfully shut down according to user request."""
+ pass # pragma: no cover
+
+
+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 synchronously 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:
+ self._response_callback( self, message )
+
+
+ def Abort( self ):
+ """Called when the server is shutting down."""
+ self.ResponseReceived( None )
+
+
+ def AwaitResponse( self, timeout ):
+ """Called by clients to wait synchronously for either a response to be
+ received or 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.is_set():
+ raise ResponseTimeoutException( 'Response Timeout' )
+
+ if self._message is None:
+ raise ResponseAbortedException( 'Response Aborted' )
+
+ if 'error' in self._message:
+ error = self._message[ 'error' ]
+ raise ResponseFailedException( 'Request failed: {0}: {1}'.format(
+ error.get( 'code', 0 ),
+ error.get( 'message', 'No message' ) ) )
+
+ return self._message
+
+
+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.
+
+ 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:
+
+ 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.
+
+ 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:
+
+ - For requests: Using python event objects, wrapped in the Response class
+ - For notifications: via a synchronized Queue.
+
+ NOTE: Some handling is done in the dispatch thread. There are certain
+ notifications which we have to handle when we get them, such as:
+
+ - Initialization messages
+ - Diagnostics
+
+ 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.
+
+ 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 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 ):
+ pass # pragma: no cover
+
+
+ @abc.abstractmethod
+ def Shutdown( self ):
+ pass # pragma: no cover
+
+
+ @abc.abstractmethod
+ def WriteData( self, data ):
+ pass # pragma: no cover
+
+
+ @abc.abstractmethod
+ def ReadData( self, size=-1 ):
+ pass # pragma: no cover
+
+
+ def __init__( self, notification_handler = None ):
+ super( LanguageServerConnection, self ).__init__()
+
+ self._last_id = 0
+ self._responses = {}
+ self._response_mutex = threading.Lock()
+ self._notifications = queue.Queue()
+
+ self._connection_event = threading.Event()
+ self._stop_event = threading.Event()
+ 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 _DispatchMessage
+ self._ReadMessages( )
+ except LanguageServerConnectionStopped:
+ # Abort any outstanding requests
+ 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.' )
+
+ # 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 ):
+ # 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.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 ):
+ return self._stop_event.is_set()
+
+
+ def NextRequestId( self ):
+ with self._response_mutex:
+ self._last_id += 1
+ return str( self._last_id )
+
+
+ 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._response_mutex:
+ assert request_id not in self._responses
+ self._responses[ request_id ] = response
+
+ _logger.debug( 'TX: Sending message: %r', message )
+
+ self.WriteData( message )
+ return response
+
+
+ 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.WriteData( 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.is_set():
+ raise LanguageServerConnectionTimeout(
+ 'Timed out waiting for server to connect' )
+
+
+ def _ReadMessages( self ):
+ """Main message pump. Within the message pump thread context, reads messages
+ 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 )
+
+ 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' ] )
+
+ # 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.ReadData( 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
+
+ _logger.debug( 'RX: Received message: %r', 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
+ 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.ReadData()
+
+ 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 _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._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 )
+
+ # If there is an immediate (in-message-pump-thread) handler configured,
+ # call it.
+ if self._notification_handler:
+ self._notification_handler( self, message )
+
+
+class StandardIOLanguageServerConnection( LanguageServerConnection ):
+ """Concrete language server connection using stdin/stdout to communicate with
+ the server. This should be the default choice for concrete completers."""
+
+ 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
+
+ # 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
+ return True
+
+
+ def Shutdown( self ):
+ with self._stdin_lock:
+ if not self._server_stdin.closed:
+ self._server_stdin.close()
+
+ with self._stdout_lock:
+ if not self._server_stdout.closed:
+ self._server_stdout.close()
+
+
+ def WriteData( self, data ):
+ with self._stdin_lock:
+ self._server_stdin.write( data )
+ self._server_stdin.flush()
+
+
+ def ReadData( self, size=-1 ):
+ 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
+
+
+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 SendInitialize
+ - 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
+
+ Sub-commands
+
+ - 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:
+ Sub-command -> Handler
+ - GoToDeclaration -> GoToDeclaration
+ - GoTo -> GoToDeclaration
+ - GoToReferences -> GoToReferences
+ - 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 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 :)
+ """
+ @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 # pragma: no cover
+
+
+ @abc.abstractmethod
+ def HandleServerCommand( self, request_data, command ):
+ pass # pragma: no cover
+
+
+ def __init__( self, user_options):
+ super( LanguageServerCompleter, self ).__init__( user_options )
+
+ # _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.
+ Implementations are required to call this after disconnection and killing
+ the downstream server."""
+ with self._server_info_mutex:
+ self._server_file_state = lsp.ServerFileStateStore()
+ self._latest_diagnostics = collections.defaultdict( 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.
+ Implementations 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 = lsp.Shutdown( request_id )
+
+ try:
+ 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.
+ return
+ except Exception:
+ # Ignore other exceptions from the server and send the exit request
+ # anyway
+ _logger.exception( 'Shutdown request failed. Ignoring.' )
+
+ 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
+ completed successfully. Implementations must not issue requests until this
+ method returns True."""
+ if not self.ServerIsHealthy():
+ return False
+
+ if self._initialize_event.is_set():
+ # We already got the initialize response
+ return True
+
+ if self._initialize_response is None:
+ # We never sent the initialize response
+ return False
+
+ # Initialize request in progress. Will be handled asynchronously.
+ return False
+
+
+ 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 ) )
+
+
+ def ComputeCandidatesInner( self, request_data ):
+ if not self.ServerIsReady():
+ return None
+
+ self._UpdateServerWithFileContents( request_data )
+
+ request_id = self.GetConnection().NextRequestId()
+ msg = lsp.Completion( request_id, request_data )
+ response = self.GetConnection().GetResponse( request_id,
+ msg,
+ REQUEST_TIMEOUT_COMPLETION )
+
+ if isinstance( response[ 'result' ], list ):
+ items = response[ 'result' ]
+ else:
+ items = response[ 'result' ][ 'items' ]
+
+ # 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 _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.
+ 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:
+ #
+ # 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' ]
+
+ # 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 resolve_completion_items:
+ item = self._ResolveCompletionItem( item )
+
+ try:
+ insertion_text, fixits, start_codepoint = (
+ _InsertionTextForItem( request_data, item ) )
+ except IncompatibleCompletionException:
+ _logger.exception( 'Ignoring incompatible completion suggestion '
+ '{0}'.format( item ) )
+ continue
+
+ min_start_codepoint = min( min_start_codepoint, start_codepoint )
+
+ # Build a ycmd-compatible completion for the text as we received it. Later
+ # 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 )
+
+ request_data[ 'start_codepoint' ] = min_start_codepoint
+ return completions
+
+
+ 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
+ # _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._UpdateServerWithFileContents( 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, or for clients not supporting the
+ # polling mechanism.
+ uri = lsp.FilePathToUri( request_data[ 'filepath' ] )
+ with self._server_info_mutex:
+ if uri in self._latest_diagnostics:
+ return [ _BuildDiagnostic( request_data, uri, diag )
+ for diag in self._latest_diagnostics[ uri ] ]
+
+
+ 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, block until we get one or we hit the timeout.
+ return self._AwaitServerMessages( request_data, timeout )
+
+
+ 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. 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():
+ # 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 )
+
+ if message:
+ messages.append( message )
+ except queue.Empty:
+ # We drained the queue
+ pass
+
+ return messages
+
+
+ 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
+ - 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._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.
+ return False
+
+ notification = self.GetConnection()._notifications.get(
+ timeout = timeout )
+ message = self.ConvertNotificationToMessage( request_data,
+ notification )
+ if message:
+ return [ message ]
+ except queue.Empty:
+ return True
+
+
+ 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. We also need these
+ # for correct FixIt handling, as they are part of the FixIt context.
+ params = notification[ 'params' ]
+ uri = params[ 'uri' ]
+ with self._server_info_mutex:
+ self._latest_diagnostics[ uri ] = params[ 'diagnostics' ]
+
+
+ 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 unrecognized notifications."""
+
+ 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' ] )
+ elif notification[ 'method' ] == 'textDocument/publishDiagnostics':
+ params = notification[ 'params' ]
+ uri = params[ 'uri' ]
+ try:
+ filepath = lsp.UriToFilePath( uri )
+ response = {
+ 'diagnostics': [ _BuildDiagnostic( request_data, uri, x )
+ for x in params[ 'diagnostics' ] ],
+ 'filepath': filepath
+ }
+ return response
+ except lsp.InvalidUriException:
+ _logger.exception( 'Ignoring diagnostics for unrecognized URI' )
+ pass
+
+ 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.
+
+ This method should be called frequently and in any event before a
+ synchronous operation."""
+ with self._server_info_mutex:
+ 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' ] )
+
+ self.GetConnection().SendNotification( msg )
+
+
+ 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 ):
+ """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 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 os.path.dirname( request_data[ 'filepath' ] )
+
+
+ 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
+ server until the initialize exchange has completed. This can be detected by
+ calling this class's implementation of ServerIsReady."""
+
+ with self._server_info_mutex:
+ assert not self._initialize_response
+
+ request_id = self.GetConnection().NextRequestId()
+ msg = lsp.Initialize( request_id,
+ self._GetProjectDirectory( request_data ) )
+
+ def response_handler( response, message ):
+ if message is None:
+ return
+
+ self._HandleInitializeInPollThread( message )
+
+ self._initialize_response = self.GetConnection().GetResponseAsync(
+ request_id,
+ msg,
+ response_handler )
+
+
+ def _HandleInitializeInPollThread( self, response ):
+ """Called within the context of the LanguageServerConnection's message pump
+ when the initialize request receives a response."""
+ 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 = [
+ 'None',
+ 'Full',
+ 'Incremental'
+ ]
+ self._sync_type = SYNC_TYPE[
+ response[ 'result' ][ 'capabilities' ][ 'textDocumentSync' ] ]
+ _logger.info( 'Language server requires sync type of {0}'.format(
+ self._sync_type ) )
+
+ # 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()
+
+ # Fire any events that are pending on the completion of the initialize
+ # 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_initialize_complete_handlers = list()
+
+
+ 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
+ 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_initialize_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.' )
+
+ self._UpdateServerWithFileContents( request_data )
+
+ request_id = self.GetConnection().NextRequestId()
+ response = self.GetConnection().GetResponse(
+ request_id,
+ lsp.Hover( request_id, request_data ),
+ REQUEST_TIMEOUT_COMMAND )
+
+ return response[ 'result' ][ 'contents' ]
+
+
+ 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.' )
+
+ self._UpdateServerWithFileContents( request_data )
+
+ request_id = self.GetConnection().NextRequestId()
+ response = self.GetConnection().GetResponse(
+ request_id,
+ lsp.Definition( request_id, request_data ),
+ REQUEST_TIMEOUT_COMMAND )
+
+ if isinstance( response[ 'result' ], list ):
+ return _LocationListToGoTo( request_data, response )
+ 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 ):
+ """Issues the references request and returns the result as a GoTo
+ response."""
+ if not self.ServerIsReady():
+ raise RuntimeError( 'Server is initializing. Please wait.' )
+
+ self._UpdateServerWithFileContents( request_data )
+
+ request_id = self.GetConnection().NextRequestId()
+ response = self.GetConnection().GetResponse(
+ request_id,
+ lsp.References( request_id, request_data ),
+ REQUEST_TIMEOUT_COMMAND )
+
+ return _LocationListToGoTo( request_data, response )
+
+
+ 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._UpdateServerWithFileContents( request_data )
+
+ line_num_ls = request_data[ 'line_num' ] - 1
+
+ def WithinRange( diag ):
+ start = diag[ 'range' ][ 'start' ]
+ end = diag[ 'range' ][ 'end' ]
+
+ if line_num_ls < start[ 'line' ] or line_num_ls > end[ 'line' ]:
+ return False
+
+ return True
+
+ with self._server_info_mutex:
+ file_diagnostics = list( self._latest_diagnostics[
+ lsp.FilePathToUri( request_data[ 'filepath' ] ) ] )
+
+ matched_diagnostics = [
+ d for d in file_diagnostics if WithinRange( d )
+ ]
+
+ request_id = self.GetConnection().NextRequestId()
+ if matched_diagnostics:
+ code_actions = self.GetConnection().GetResponse(
+ request_id,
+ lsp.CodeAction( request_id,
+ request_data,
+ matched_diagnostics[ 0 ][ 'range' ],
+ matched_diagnostics ),
+ REQUEST_TIMEOUT_COMMAND )
+
+ else:
+ line_value = request_data[ 'line_value' ]
+
+ code_actions = self.GetConnection().GetResponse(
+ request_id,
+ lsp.CodeAction(
+ request_id,
+ request_data,
+ # Use the whole line
+ {
+ 'start': {
+ 'line': line_num_ls,
+ 'character': 0,
+ },
+ 'end': {
+ 'line': line_num_ls,
+ 'character': lsp.CodepointsToUTF16CodeUnits(
+ line_value,
+ len( line_value ) ) - 1,
+ }
+ },
+ [] ),
+ REQUEST_TIMEOUT_COMMAND )
+
+ response = [ self.HandleServerCommand( request_data, c )
+ for c in code_actions[ 'result' ] ]
+
+ # 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 ] )
+
+
+ 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.' )
+
+ if len( args ) != 1:
+ raise ValueError( 'Please specify a new name to rename it to.\n'
+ 'Usage: RefactorRename ' )
+
+ self._UpdateServerWithFileContents( request_data )
+
+ new_name = args[ 0 ]
+
+ request_id = self.GetConnection().NextRequestId()
+ response = self.GetConnection().GetResponse(
+ request_id,
+ lsp.Rename( request_id, request_data, new_name ),
+ REQUEST_TIMEOUT_COMMAND )
+
+ return responses.BuildFixItResponse(
+ [ WorkspaceEditToFixIt( request_data, response[ 'result' ] ) ] )
+
+
+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' ]
+ 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 ):
+ """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
+ )"""
+ # We do not support completion types of "Snippet". This is implicit in that we
+ # don't say it is a "capability" in the initialize request.
+ # Abort this request if the server is buggy and ignores us.
+ assert lsp.INSERT_TEXT_FORMAT[
+ item.get( 'insertTextFormat', 1 ) ] == 'PlainText'
+
+ fixits = None
+
+ start_codepoint = request_data[ 'start_codepoint' ]
+ # We will always have one of insertText or label
+ if 'insertText' in item and item[ 'insertText' ]:
+ insertion_text = item[ 'insertText' ]
+ 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. 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' ]:
+ text_edit = item[ 'textEdit' ]
+ start_codepoint = _GetCompletionItemStartCodepointOrReject( text_edit,
+ request_data )
+
+ insertion_text = text_edit[ 'newText' ]
+
+ if '\n' in insertion_text:
+ # jdt.ls can return completions which generate code, such as
+ # getters/setters and entire anonymous classes.
+ #
+ # 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( 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' ] ) )
+ for e in additional_text_edits ]
+
+ fixits = responses.BuildFixItResponse(
+ [ responses.FixIt( chunks[ 0 ].range.start_, chunks ) ] )
+
+ return insertion_text, fixits, start_codepoint
+
+
+def _GetCompletionItemStartCodepointOrReject( text_edit, request_data ):
+ edit_range = text_edit[ 'range' ]
+
+ # 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' ] ) )
+
+ 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' ] ) )
+
+ 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' )
+
+ try:
+ if len( response[ 'result' ] ) > 1:
+ positions = response[ 'result' ]
+ return [
+ responses.BuildGoToResponseFromLocation(
+ *_PositionToLocationAndDescription( request_data,
+ position ) )
+ for position in positions
+ ]
+ else:
+ position = response[ 'result' ][ 0 ]
+ return responses.BuildGoToResponseFromLocation(
+ *_PositionToLocationAndDescription( request_data, position ) )
+ except ( IndexError, KeyError ):
+ raise RuntimeError( 'Cannot jump to location' )
+
+
+def _PositionToLocationAndDescription( request_data, position ):
+ """Convert a LSP position to a ycmd location."""
+ try:
+ filename = lsp.UriToFilePath( position[ 'uri' ] )
+ file_contents = utils.SplitLines( GetFileContents( request_data,
+ filename ) )
+ 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' ] )
+
+
+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."""
+
+ try:
+ line_value = file_contents[ loc[ 'line' ] ]
+ 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.
+ line_value = ""
+ column = loc[ 'character' ] + 1
+
+ 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|."""
+ 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 ] )
+
+
+def _BuildDiagnostic( request_data, uri, diag ):
+ """Return a ycmd diagnostic from a LSP diagnostic."""
+ try:
+ filename = lsp.UriToFilePath( uri )
+ except lsp.InvalidUriException:
+ _logger.debug( 'Invalid URI received for diagnostic' )
+ filename = ''
+
+ r = _BuildRange( request_data, filename, diag[ 'range' ] )
+
+ return responses.BuildDiagnosticData( responses.Diagnostic(
+ ranges = [ r ],
+ location = r.start_,
+ location_extent = r,
+ text = diag[ 'message' ],
+ kind = lsp.SEVERITY[ diag[ 'severity' ] ].upper() ) )
+
+
+def TextEditToChunks( request_data, uri, text_edit ):
+ """Returns a list of FixItChunks from a LSP textEdit."""
+ try:
+ 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' ] ) )
+ for change in 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
+
+ chunks = list()
+ # 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 ] ) )
+
+ 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/language_server_protocol.py b/ycmd/completers/language_server/language_server_protocol.py
new file mode 100644
index 0000000000..52c45dc7db
--- /dev/null
+++ b/ycmd/completers/language_server/language_server_protocol.py
@@ -0,0 +1,385 @@
+# 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
+import json
+import hashlib
+
+from ycmd.utils import ( pathname2url,
+ ToBytes,
+ ToUnicode,
+ url2pathname,
+ urljoin )
+
+
+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
+ was not supported. Only the file: scheme is supported"""
+ 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
+ parameters"""
+ return _BuildMessageData( {
+ 'id': request_id,
+ 'method': method,
+ 'params': parameters,
+ } )
+
+
+def BuildNotification( method, parameters ):
+ """Builds a JSON RPC notification message with the supplied method and
+ method parameters"""
+ return _BuildMessageData( {
+ 'method': method,
+ 'params': parameters,
+ } )
+
+
+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': {
+ # 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.
+ },
+ } )
+
+
+def Initialized():
+ return BuildNotification( 'initialized', {} )
+
+
+def Shutdown( request_id ):
+ return BuildRequest( request_id, 'shutdown', None )
+
+
+def Exit():
+ return BuildNotification( 'exit', None )
+
+
+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_state.filename ),
+ 'languageId': '/'.join( file_types ),
+ 'version': file_state.version,
+ 'text': file_contents
+ }
+ } )
+
+
+def DidChangeTextDocument( file_state, file_contents ):
+ return BuildNotification( 'textDocument/didChange', {
+ 'textDocument': {
+ 'uri': FilePathToUri( file_state.filename ),
+ 'version': file_state.version,
+ },
+ 'contentChanges': [
+ { 'text': file_contents },
+ ],
+ } )
+
+
+def DidCloseTextDocument( file_state ):
+ return BuildNotification( 'textDocument/didClose', {
+ 'textDocument': {
+ 'uri': FilePathToUri( file_state.filename ),
+ 'version': file_state.version,
+ },
+ } )
+
+
+def Completion( request_id, request_data ):
+ return BuildRequest( request_id, 'textDocument/completion', {
+ 'textDocument': {
+ 'uri': FilePathToUri( request_data[ 'filepath' ] ),
+ },
+ 'position': {
+ 'line': request_data[ 'line_num' ] - 1,
+ 'character': request_data[ 'start_codepoint' ] - 1,
+ }
+ } )
+
+
+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 CodeAction( request_id, request_data, best_match_range, diagnostics ):
+ return BuildRequest( request_id, 'textDocument/codeAction', {
+ 'textDocument': {
+ 'uri': FilePathToUri( 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': FilePathToUri( request_data[ 'filepath' ] ),
+ },
+ 'newName': new_name,
+ 'position': Position( request_data ),
+ } )
+
+
+def BuildTextDocumentPositionParams( request_data ):
+ return {
+ 'textDocument': {
+ 'uri': FilePathToUri( request_data[ 'filepath' ] ),
+ },
+ 'position': Position( request_data ),
+ }
+
+
+def References( request_id, request_data ):
+ request = BuildTextDocumentPositionParams( request_data )
+ request[ 'context' ] = { 'includeDeclaration': True }
+ return BuildRequest( request_id, 'textDocument/references', request )
+
+
+def Position( request_data ):
+ # The API requires 0-based Unicode offsets.
+ return {
+ 'line': request_data[ 'line_num' ] - 1,
+ 'character': request_data[ 'column_codepoint' ] - 1,
+ }
+
+
+def FilePathToUri( file_name ):
+ return urljoin( 'file:', pathname2url( file_name ) )
+
+
+def UriToFilePath( uri ):
+ if uri [ : 5 ] != "file:":
+ raise InvalidUriException( uri )
+
+ return os.path.abspath( url2pathname( uri[ 5 : ] ) )
+
+
+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'
+ '\r\n'.format( len(data) ) ) + data
+ return packet
+
+
+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/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/handlers.py b/ycmd/handlers.py
index 02d1d378d1..8de4234be5 100644
--- a/ycmd/handlers.py
+++ b/ycmd/handlers.py
@@ -250,6 +250,23 @@ 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 Exception:
+ # No semantic completer for this filetype, don't requery. This is not an
+ # error.
+ 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,
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/clang/__init__.py b/ycmd/tests/clang/__init__.py
index 33cac725cf..f43c58471f 100644
--- a/ycmd/tests/clang/__init__.py
+++ b/ycmd/tests/clang/__init__.py
@@ -24,10 +24,9 @@
import functools
import os
-import tempfile
import contextlib
import json
-import shutil
+
from ycmd.utils import ToUnicode
from ycmd.tests.test_utils import ClearCompletionsCache, IsolatedApp, SetUpApp
@@ -91,29 +90,15 @@ 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
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/__init__.py b/ycmd/tests/java/__init__.py
new file mode 100644
index 0000000000..55ca93873f
--- /dev/null
+++ b/ycmd/tests/java/__init__.py
@@ -0,0 +1,144 @@
+# 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 pprint import pformat
+
+from ycmd.tests.test_utils import ( BuildRequest,
+ ClearCompletionsCache,
+ IsolatedApp,
+ SetUpApp,
+ StopCompleterServer,
+ WaitUntilCompleterServerReady )
+
+shared_app = None
+DEFAULT_PROJECT_DIR = 'simple_eclipse_project'
+SERVER_STARTUP_TIMEOUT = 120 # seconds
+
+
+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_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
+ 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
+
+ StopCompleterServer( shared_app, 'java' )
+
+
+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', SERVER_STARTUP_TIMEOUT )
+
+
+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."""
+ @functools.wraps( test )
+ def Wrapper( *args, **kwargs ):
+ with IsolatedApp() as app:
+ try:
+ test( app, *args, **kwargs )
+ finally:
+ StopCompleterServer( app, 'java' )
+ return Wrapper
+
+
+class PollForMessagesTimeoutException( Exception ):
+ pass
+
+
+def PollForMessages( app, request_data, timeout = 30 ):
+ expiration = time.time() + timeout
+ while True:
+ if time.time() > expiration:
+ raise PollForMessagesTimeoutException(
+ '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/debug_info_test.py b/ycmd/tests/java/debug_info_test.py
new file mode 100644
index 0000000000..3dfbf71f3f
--- /dev/null
+++ b/ycmd/tests/java/debug_info_test.py
@@ -0,0 +1,63 @@
+# 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': 'jdt.ls 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': instance_of( str ) } ),
+ has_entries( { 'key': 'Workspace Path',
+ 'value': 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..475c9bbec5
--- /dev/null
+++ b/ycmd/tests/java/diagnostics_test.py
@@ -0,0 +1,657 @@
+# Copyright (C) 2017 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 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
+
+import time
+import json
+import threading
+from future.utils import iterkeys
+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 )
+
+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
+from ycmd.completers.language_server import language_server_protocol as lsp
+from ycmd import handlers
+
+
+
+def RangeMatch( filepath, start, end ):
+ return has_entries( {
+ 'start': LocationMatcher( filepath, *start ),
+ 'end': LocationMatcher( filepath, *end ),
+ } )
+
+
+def ProjectPath( *args ):
+ return PathToTestFile( DEFAULT_PROJECT_DIR,
+ 'src',
+ 'com',
+ 'test',
+ *args )
+
+
+InternalNonProjectFile = PathToTestFile( DEFAULT_PROJECT_DIR, 'test.java' )
+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 = {
+ InternalNonProjectFile: [],
+ TestFactory: contains_inanyorder(
+ has_entries( {
+ 'kind': 'WARNING',
+ 'text': 'The value of the field TestFactory.Bar.testString is not used',
+ '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': 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': 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': LocationMatcher( TestFactory, 27, 10 ),
+ 'location_extent': RangeMatch( TestFactory, ( 27, 10 ), ( 27, 16 ) ),
+ '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( {
+ 'kind': 'WARNING',
+ 'text': 'The value of the local variable a is not used',
+ 'location': LocationMatcher( TestWidgetImpl, 15, 9 ),
+ 'location_extent': RangeMatch( TestWidgetImpl, ( 15, 9 ), ( 15, 10 ) ),
+ 'ranges': contains( RangeMatch( TestWidgetImpl, ( 15, 9 ), ( 15, 10 ) ) ),
+ 'fixit_available': False
+ } ),
+ ),
+ TestLauncher: contains_inanyorder (
+ has_entries( {
+ 'kind': 'ERROR',
+ 'text': 'The type new TestLauncher.Launchable(){} must implement the '
+ 'inherited abstract method TestLauncher.Launchable.launch('
+ 'TestFactory)',
+ '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, 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, 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
+ } ),
+ ),
+}
+
+
+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 )
+
+ # 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 ] )
+
+
+@IsolatedYcmd
+def FileReadyToParse_Diagnostics_FileNotOnDisk_test( app ):
+ StartJavaCompleterServerInDirectory( app,
+ PathToTestFile( DEFAULT_PROJECT_DIR ) )
+
+ contents = '''
+ package com.test;
+ class Test {
+ public String test
+ }
+ '''
+ filepath = ProjectPath( 'Test.java' )
+
+ 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 when the
+ # initial parse request is sent. We receive these asynchronously.
+ eq_( results, {} )
+
+ diag_matcher = contains( has_entries( {
+ 'kind': 'ERROR',
+ 'text': 'Syntax error, insert ";" to complete ClassBodyDeclarations',
+ 'location': LocationMatcher( filepath, 4, 21 ),
+ 'location_extent': RangeMatch( filepath, ( 4, 21 ), ( 4, 25 ) ),
+ 'ranges': contains( RangeMatch( filepath, ( 4, 21 ), ( 4, 25 ) ) ),
+ 'fixit_available': False
+ } ) )
+
+ # Poll until we receive the diags
+ for message in PollForMessages( app,
+ { 'filepath': filepath,
+ 'contents': contents } ):
+ if 'diagnostics' in message and message[ 'filepath' ] == filepath:
+ print( 'Message {0}'.format( pformat( message ) ) )
+ assert_that( message, has_entries( {
+ 'diagnostics': diag_matcher,
+ 'filepath': filepath
+ } ) )
+ break
+
+ # Now confirm that we _also_ get these from the FileReadyToParse request
+ 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_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()
+
+ 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
+
+ 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
+@patch.object( completer, 'MESSAGE_POLL_TIMEOUT', 2 )
+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
new file mode 100644
index 0000000000..8b9d39858a
--- /dev/null
+++ b/ycmd/tests/java/get_completions_test.py
@@ -0,0 +1,508 @@
+# Copyright (C) 2017 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 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 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 ):
+ return BuildRequest( **_Merge( request, data ) )
+
+
+def _Merge( request, data ):
+ kw = dict( request )
+ kw.update( 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
+
+ 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' ] )
+
+
+PUBLIC_OBJECT_METHODS = [
+ 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',
+ } ),
+]
+
+
+# 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( PUBLIC_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' : ProjectPath( 'TestFactory.java' ),
+ 'line_num' : 27,
+ 'column_num': 12,
+ },
+ 'expect': {
+ 'response': requests.codes.ok,
+ 'data': has_entries( {
+ 'completions': contains_inanyorder(
+ *WithObjectMethods(
+ CompletionEntryMatcher( 'test', 'TestFactory.Bar', {
+ 'kind': 'Field'
+ } ),
+ CompletionEntryMatcher( 'testString', 'TestFactory.Bar', {
+ 'kind': 'Field'
+ } )
+ )
+ ),
+ 'errors': empty(),
+ } )
+ },
+ } )
+
+
+@SharedYcmd
+def GetCompletions_WithQuery_test( app ):
+ RunTest( app, {
+ 'description': 'semantic completion works for builtin types (with query)',
+ 'request': {
+ 'filetype' : 'java',
+ 'filepath' : ProjectPath( 'TestFactory.java' ),
+ 'line_num' : 27,
+ 'column_num': 15,
+ },
+ 'expect': {
+ 'response': requests.codes.ok,
+ 'data': has_entries( {
+ 'completions': contains_inanyorder(
+ CompletionEntryMatcher( 'test', 'TestFactory.Bar', {
+ 'kind': 'Field'
+ } ),
+ CompletionEntryMatcher( 'testString', 'TestFactory.Bar', {
+ 'kind': 'Field'
+ } )
+ ),
+ 'errors': empty(),
+ } )
+ },
+ } )
+
+
+@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': 9,
+ 'completions': contains(
+ CompletionEntryMatcher( 'com.test.wobble', None, {
+ 'kind': 'Module'
+ } ),
+ ),
+ 'errors': empty(),
+ } )
+ },
+ } )
+
+
+@SharedYcmd
+def GetCompletions_Import_Class_test( app ):
+ RunTest( app, {
+ 'description': 'completion works for import statements with a single class',
+ '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(
+ CompletionEntryMatcher( 'Tset;', None, {
+ 'menu_text': 'Tset - com.youcompleteme.testing',
+ 'kind': 'Class',
+ } )
+ ),
+ 'errors': empty(),
+ } )
+ },
+ } )
+
+
+@SharedYcmd
+def GetCompletions_Import_Classes_test( app ):
+ filepath = ProjectPath( 'TestLauncher.java' )
+ RunTest( app, {
+ 'description': 'completion works for imports with multiple classes',
+ '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',
+ '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(),
+ } )
+ },
+ } )
+
+
+@SharedYcmd
+def GetCompletions_Import_ModuleAndClass_test( app ):
+ filepath = ProjectPath( 'TestLauncher.java' )
+ RunTest( app, {
+ 'description': 'completion works for imports of classes and modules',
+ 'request': {
+ 'filetype' : 'java',
+ 'filepath' : filepath,
+ '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',
+ 'kind': 'Module',
+ } ),
+ CompletionEntryMatcher( 'Test;', None, {
+ 'menu_text': 'Test - com.youcompleteme',
+ 'kind': 'Class',
+ } ),
+ ),
+ 'errors': empty(),
+ } )
+ },
+ } )
+
+
+@SharedYcmd
+def GetCompletions_WithFixIt_test( app ):
+ filepath = ProjectPath( 'TestFactory.java' )
+ RunTest( app, {
+ 'description': 'semantic completion with when additional textEdit',
+ 'request': {
+ 'filetype' : 'java',
+ 'filepath' : filepath,
+ 'line_num' : 19,
+ 'column_num': 25,
+ },
+ 'expect': {
+ 'response': requests.codes.ok,
+ 'data': has_entries( {
+ 'completion_start_column': 22,
+ 'completions': contains_inanyorder(
+ CompletionEntryMatcher( 'CUTHBERT', 'com.test.wobble.Wibble',
+ {
+ 'kind': 'Field',
+ '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 ?
+ 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.
+ 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 ) ),
+ ),
+ } ) ),
+ } ),
+ } ),
+ ),
+ 'errors': empty(),
+ } )
+ },
+ } )
+
+
+@SharedYcmd
+def GetCompletions_RejectMultiLineInsertion_test( app ):
+ filepath = ProjectPath( 'TestLauncher.java' )
+ RunTest( app, {
+ 'description': 'completion item discarded when not valid',
+ 'request': {
+ 'filetype' : 'java',
+ 'filepath' : filepath,
+ 'line_num' : 28,
+ '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', {
+ '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
+ # AdditionalTextEdit) which we don't support.
+ ),
+ 'errors': empty(),
+ } )
+ },
+ } )
+
+
+@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',
+ } ),
+ ) ),
+ '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
new file mode 100644
index 0000000000..6897f75460
--- /dev/null
+++ b/ycmd/tests/java/java_completer_test.py
@@ -0,0 +1,231 @@
+# 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, calling, has_entries, is_not, raises
+from mock import patch
+
+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():
+ 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 ) ) )
+ )
+
+
+@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
new file mode 100644
index 0000000000..db0133ede1
--- /dev/null
+++ b/ycmd/tests/java/server_management_test.py
@@ -0,0 +1,385 @@
+# Copyright (C) 2017 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
+
+import functools
+import os
+import psutil
+import time
+import threading
+
+from mock import patch
+from hamcrest import ( assert_that,
+ contains,
+ has_entries,
+ has_entry,
+ has_item )
+from ycmd.tests.java import ( PathToTestFile,
+ IsolatedYcmd,
+ StartJavaCompleterServerInDirectory )
+from ycmd.tests.test_utils import ( BuildRequest,
+ TemporaryTestDir,
+ WaitUntilCompleterServerReady )
+from ycmd import utils, handlers
+
+
+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,
+ } )
+ ) )
+ ) )
+ )
+
+
+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
+
+
+@IsolatedYcmd
+def ServerManagement_RestartServer_test( app ):
+ StartJavaCompleterServerInDirectory(
+ app, PathToTestFile( 'simple_eclipse_project' ) )
+
+ 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,
+ _ProjectDirectoryMatcher( eclipse_project ) )
+
+ # 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, 'java' )
+
+ 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,
+ _ProjectDirectoryMatcher( maven_project ) )
+
+
+@IsolatedYcmd
+def ServerManagement_ProjectDetection_EclipseParent_test( app ):
+ StartJavaCompleterServerInDirectory(
+ app, PathToTestFile( 'simple_eclipse_project', 'src' ) )
+
+ 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,
+ _ProjectDirectoryMatcher( project ) )
+
+
+@TidyJDTProjectFiles( PathToTestFile( 'simple_maven_project' ) )
+@IsolatedYcmd
+def ServerManagement_ProjectDetection_MavenParent_test( app ):
+ StartJavaCompleterServerInDirectory( app,
+ PathToTestFile( 'simple_maven_project',
+ 'src',
+ 'main',
+ 'java',
+ 'com',
+ 'test' ) )
+
+ 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,
+ _ProjectDirectoryMatcher( project ) )
+
+
+@TidyJDTProjectFiles( PathToTestFile( 'simple_gradle_project' ) )
+@IsolatedYcmd
+def ServerManagement_ProjectDetection_GradleParent_test( app ):
+ StartJavaCompleterServerInDirectory( app,
+ PathToTestFile( 'simple_gradle_project',
+ 'src',
+ 'main',
+ 'java',
+ 'com',
+ 'test' ) )
+
+ 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,
+ _ProjectDirectoryMatcher( project ) )
+
+
+def ServerManagement_ProjectDetection_NoParent_test():
+ with TemporaryTestDir() as tmp_dir:
+
+ @IsolatedYcmd
+ def Test( 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( tmp_dir ) )
+
+ yield Test
+
+
+@IsolatedYcmd
+@patch( 'ycmd.utils.WaitUntilProcessIsTerminated', side_effect = RuntimeError )
+def ServerManagement_CloseServer_Unclean_test( app, stop_server_cleanly ):
+ 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 )
+ ) )
+ ) )
+
+
+@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
new file mode 100644
index 0000000000..1f79987154
--- /dev/null
+++ b/ycmd/tests/java/subcommands_test.py
@@ -0,0 +1,1301 @@
+# Copyright (C) 2017 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
+
+import time
+from hamcrest import ( assert_that,
+ contains,
+ contains_inanyorder,
+ empty,
+ has_entries,
+ instance_of )
+from nose.tools import eq_
+from pprint import pformat
+import requests
+
+from ycmd.utils import ReadFile
+from ycmd.completers.java.java_completer import NO_DOCUMENTATION_MESSAGE
+from ycmd.tests.java import ( DEFAULT_PROJECT_DIR,
+ IsolatedYcmd,
+ PathToTestFile,
+ SharedYcmd,
+ StartJavaCompleterServerInDirectory )
+from ycmd.tests.test_utils import ( BuildRequest,
+ 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
+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 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 ):
+ 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' ] )
+
+
+@IsolatedYcmd
+def Subcommands_GetDoc_NoDoc_test( app ):
+ StartJavaCompleterServerInDirectory( app,
+ PathToTestFile( DEFAULT_PROJECT_DIR ) )
+ 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( RuntimeError, NO_DOCUMENTATION_MESSAGE ) )
+
+
+@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, {
+ 'detailed_info': '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, {
+ 'detailed_info': 'This is the actual code that matters. This concrete '
+ 'implementation is the equivalent of the main function in '
+ 'other languages'
+ } )
+
+
+@IsolatedYcmd
+def Subcommands_GetType_NoKnownType_test( app ):
+ StartJavaCompleterServerInDirectory( app,
+ PathToTestFile( DEFAULT_PROJECT_DIR ) )
+ 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, 'Unknown type' ) )
+
+
+@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()'
+ } )
+
+
+@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',
+ '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, 'Unknown type' ) )
+
+
+@IsolatedYcmd
+def Subcommands_GoTo_NoLocation_test( app ):
+ StartJavaCompleterServerInDirectory( app,
+ PathToTestFile( DEFAULT_PROJECT_DIR ) )
+ 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' ) )
+
+
+@IsolatedYcmd
+def Subcommands_GoToReferences_NoReferences_test( app ):
+ StartJavaCompleterServerInDirectory( app,
+ PathToTestFile( DEFAULT_PROJECT_DIR ) )
+ 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
+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': " w.doSomethingVaguelyUseful();",
+ 'line_num': 28
+ },
+ {
+ 'filepath': PathToTestFile( 'simple_eclipse_project',
+ 'src',
+ 'com',
+ 'test',
+ 'TestLauncher.java' ),
+ 'column_num': 11,
+ 'description': " w.doSomethingVaguelyUseful();",
+ 'line_num': 32
+ } ] )
+
+
+@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': 28,
+ 'column_num': 5,
+ },
+ 'expect': {
+ 'response': requests.codes.ok,
+ 'data': has_entries ( {
+ 'fixits': contains( has_entries( {
+ 'chunks': contains(
+ ChunkMatcher( 'renamed_l',
+ LocationMatcher( filepath, 27, 18 ),
+ LocationMatcher( filepath, 27, 19 ) ),
+ ChunkMatcher( 'renamed_l',
+ LocationMatcher( filepath, 28, 5 ),
+ LocationMatcher( filepath, 28, 6 ) ),
+ ),
+ 'location': LocationMatcher( filepath, 28, 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': 32,
+ 'column_num': 13,
+ },
+ '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, 32, 11 ),
+ LocationMatcher( TestLauncher, 32, 35 ) ),
+ ChunkMatcher(
+ 'a-quite-long-string',
+ LocationMatcher( TestWidgetImpl, 20, 15 ),
+ LocationMatcher( TestWidgetImpl, 20, 39 ) ),
+ ),
+ 'location': LocationMatcher( TestLauncher, 32, 13 )
+ } ) )
+ } )
+ }
+ } )
+
+
+@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 ' ),
+ }
+ } )
+
+
+@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, {
+ '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() } ) )
+
+
+def Subcommands_FixIt_Unicode_test():
+ filepath = PathToTestFile( 'simple_eclipse_project',
+ 'src',
+ 'com',
+ 'youcompleteme',
+ 'Test.java' )
+
+ 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 ) ),
+ ),
+ } ),
+ )
+ } )
+
+ yield ( RunFixItTest, 'FixIts and diagnostics work with unicode strings',
+ 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, {
+ 'description': description,
+ 'request': {
+ 'command': cmd,
+ 'line_num': line,
+ 'column_num': col,
+ 'filepath': filepath
+ },
+ 'expect': {
+ 'response': requests.codes.ok,
+ 'data': goto_response,
+ }
+ } )
+
+
+def Subcommands_GoTo_test():
+ filepath = PathToTestFile( 'simple_eclipse_project',
+ 'src',
+ 'com',
+ 'test',
+ 'TestLauncher.java' )
+
+ 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' ] )
+
+
+@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/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..b4df9a0323
--- /dev/null
+++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestFactory.java
@@ -0,0 +1,33 @@
+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();
+ }
+ if ( b.test ) { w.doSomethingVaguelyUseful( b ); }
+ 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..53ff73d559
--- /dev/null
+++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/test/TestLauncher.java
@@ -0,0 +1,41 @@
+package com.test;
+
+import com.youcompleteme.*; import com.test.wobble.*;
+import com.youcompleteme.testing.Tset;
+
+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 );
+ }
+
+ private void Run( Launchable l ) {
+ tset.getTset().add( new Test() );
+ l.launch( factory );
+ }
+
+ public static void main( String[] args ) {
+ TestLauncher l = new TestLauncher( 10 );
+ l.Run( new Launchable() {
+ @Override
+ public void launch() {
+ AbstractTestWidget w = factory.getWidget( "Test" );
+ w.doSomethingVaguelyUseful();
+
+ 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/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/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 {
+
+}
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_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..ffca520ce5
--- /dev/null
+++ b/ycmd/tests/java/testdata/simple_eclipse_project/src/com/youcompleteme/Test.java
@@ -0,0 +1,26 @@
+package com.youcompleteme;
+
+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_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;
+ }
+}
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..b1b47b74e7
--- /dev/null
+++ b/ycmd/tests/java/testdata/simple_gradle_project/.gitignore
@@ -0,0 +1,4 @@
+.gradle/
+.classpath
+.settings/
+.project
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/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 0000000000..7a3265ee94
Binary files /dev/null and b/ycmd/tests/java/testdata/simple_gradle_project/gradle/wrapper/gradle-wrapper.jar differ
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 );
+ }
+}
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/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..41f514bece
--- /dev/null
+++ b/ycmd/tests/language_server/language_server_completer_test.py
@@ -0,0 +1,386 @@
+# 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(
+ lsc._GetCompletionItemStartCodepointOrReject( text_edit, request_data ),
+ equal_to( 7 ) )
+
+ text_edit = {
+ 'newText': 'blah',
+ 'range': {
+ 'start': { 'line': 0, 'character': 6 },
+ 'end': { 'line': 0, 'character': 5 },
+ }
+ }
+
+ assert_that(
+ lsc._GetCompletionItemStartCodepointOrReject( text_edit, request_data ),
+ equal_to( 7 ) )
+
+
+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..ba27404639
--- /dev/null
+++ b/ycmd/tests/language_server/language_server_protocol_test.py
@@ -0,0 +1,207 @@
+# coding: utf-8
+#
+# 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' ) )
+
+
+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 ]
diff --git a/ycmd/tests/misc_handlers_test.py b/ycmd/tests/misc_handlers_test.py
index bd301fa4c8..398424156c 100644
--- a/ycmd/tests/misc_handlers_test.py
+++ b/ycmd/tests/misc_handlers_test.py
@@ -197,3 +197,18 @@ def MiscHandlers_DebugInfo_ExtraConfFoundButNotLoaded_test( app ):
'completer': None
} )
)
+
+
+@SharedYcmd
+def MiscHandlers_ReceiveMessages_NoCompleter_test( app ):
+ request_data = BuildRequest()
+ assert_that( app.post_json( '/receive_messages', request_data ).json,
+ equal_to( False ) )
+
+
+@SharedYcmd
+def MiscHandlers_ReceiveMessages_NotSupportedByCompleter_test( app ):
+ 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 ) )
diff --git a/ycmd/tests/server_utils_test.py b/ycmd/tests/server_utils_test.py
index fb51981d1e..15df101a2e 100644
--- a/ycmd/tests/server_utils_test.py
+++ b/ycmd/tests/server_utils_test.py
@@ -48,7 +48,8 @@
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' ),
)
diff --git a/ycmd/tests/shutdown_test.py b/ycmd/tests/shutdown_test.py
index afa531449c..eb39664a0a 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 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 = 120
+
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()
@@ -47,6 +55,7 @@ def FromHandlerWithSubservers_test( self ):
filetypes = [ 'cs',
'go',
+ 'java',
'javascript',
'python',
'typescript',
@@ -58,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()
@@ -67,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()
@@ -77,6 +86,7 @@ def FromWatchdogWithSubservers_test( self ):
filetypes = [ 'cs',
'go',
+ 'java',
'javascript',
'python',
'typescript',
@@ -85,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 5ff9426afa..7600b7b20c 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
@@ -199,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 )