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 )