├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── lib ├── adapters │ ├── apply-edit-adapter.ts │ ├── autocomplete-adapter.ts │ ├── code-action-adapter.ts │ ├── code-format-adapter.ts │ ├── code-highlight-adapter.ts │ ├── datatip-adapter.ts │ ├── definition-adapter.ts │ ├── document-sync-adapter.ts │ ├── find-references-adapter.ts │ ├── linter-push-v2-adapter.ts │ ├── logging-console-adapter.ts │ ├── notifications-adapter.ts │ ├── outline-view-adapter.ts │ ├── rename-adapter.ts │ └── signature-help-adapter.ts ├── auto-languageclient.ts ├── convert.ts ├── download-file.ts ├── languageclient.ts ├── logger.ts ├── main.ts ├── server-manager.ts └── utils.ts ├── package-lock.json ├── package.json ├── script └── cibuild ├── test ├── adapters │ ├── apply-edit-adapter.test.ts │ ├── autocomplete-adapter.test.ts │ ├── code-action-adapter.test.ts │ ├── code-format-adapter.test.ts │ ├── code-highlight-adapter.test.ts │ ├── custom-linter-push-v2-adapter.test.ts │ ├── datatip-adapter.test.ts │ ├── document-sync-adapter.test.ts │ ├── linter-push-v2-adapter.test.ts │ ├── outline-view-adapter.test.ts │ └── signature-help-adapter.test.ts ├── auto-languageclient.test.ts ├── convert.test.ts ├── helpers.ts ├── languageclient.test.ts ├── runner.js └── utils.test.ts ├── tsconfig.json ├── tslint.json └── typings ├── atom-ide └── index.d.ts └── atom └── index.d.ts /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | env: 6 | CI: true 7 | 8 | jobs: 9 | Test: 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, macos-latest, windows-latest] 13 | channel: [stable, beta] 14 | runs-on: ${{ matrix.os }} 15 | steps: 16 | - uses: actions/checkout@v1 17 | - uses: UziTech/action-setup-atom@v2 18 | with: 19 | version: ${{ matrix.channel }} 20 | - name: Install windows-build-tools 21 | if: ${{ matrix.os == 'windows-latest' }} 22 | run: | 23 | npm i windows-build-tools@4.0.0 24 | - name: Install dependencies 25 | run: apm install 26 | - name: Run tests 27 | run: atom --test test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | build/ 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .npmignore 2 | .gitignore 3 | .travis.yml 4 | appveyor.yml 5 | appveyor.ps1 6 | lib/ 7 | test/ 8 | script/ 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.9.9 2 | 3 | - Fixes (bugs introduced in v0.9.8) 4 | - Logging expands the parameters out again #245 5 | - CompletionItems returning null handled again 6 | - Removed 7 | - Atom-mocha-test-runner because of vulnerable deps 8 | - Flow libraries - no longer supported here 9 | 10 | ## v0.9.8 11 | 12 | - Support CodeActions that do not return Commands #239 13 | - AutoComplete 14 | - Trigger on last character in multi-character sequences (e.g. `::`) #241 15 | - Outline view 16 | - Add icon mappings for Struct and EnumMember #236 17 | - Add textDocument/documentSymbol support for hierarchical outlines #237 18 | - Logging #242 19 | - Can now be filtered and intercepted by packages 20 | - Defaults to logging warnings and errors to console in non-dev mode 21 | - Dependencies updated (including tweaks to make it work where necessary) 22 | - TypeScript (3.1.6) 23 | - vscode-jsonrpc (4.0.0) 24 | - vscode-languageserver-protocol/types (3.12.0) 25 | - sinon, mocha, chai, tslint, @types/* (latest) 26 | 27 | ## v0.9.7 28 | 29 | - Lock package.json to avoid compiler errors/warnings with more recent TypeScript defs 30 | - Fix compiler warning related to options.env 31 | 32 | ## v0.9.6 33 | 34 | - Add document formatting handlers #231 35 | - Correctly dispose config observer when servers are closed #219 36 | - Clean up atom typings #214 37 | - TypeScript refactorings and clean-up #213 38 | - Compare actual notification message text to determine duplicates #210 39 | - Do not use autocomplete cache unless explicit trigger character is set #209 40 | - Set a timeout on the willSaveWaitUntil handler #203 41 | 42 | ## v0.9.5 43 | 44 | - Respect server document sync capabilities #202 45 | - Implementation of willSaveWaitUntil #193 46 | - Tree-sitter grammars now work correctly with autocomplete-plus https://github.com/atom/autocomplete-plus/issues/962 47 | 48 | ## v0.9.4 49 | 50 | - Correctly handle multi-sequence symbols from autocomplete plus that could prevent triggering 51 | 52 | ## v0.9.3 53 | 54 | - Display buttons on showRequestMessage LSP calls - fixes many prompts from LSP packages 55 | - logMessages from language servers are now available in the Atom IDE UI Console window 56 | 57 | ## v0.9.2 58 | 59 | - Fix issue when completionItem documentation is returned as string 60 | - Export ActiveServer and LanguageClientConnection types for TypeScript users 61 | 62 | ## v0.9.1 63 | 64 | - AutoComplete on a trigger character with no further filtering now does not remove the trigger char 65 | 66 | ## v0.9.0 67 | 68 | - AutoComplete now triggers based on settings in autocomplete-plus (min word length) 69 | - AutoComplete now always filters results based on typed prefix (in case the server does not) 70 | - AutoComplete static methods have changed - this might be breaking if your package was using some of them 71 | - Converted project to TypeScript including some TypeScript type definitions for all the things! 72 | - Filter out document symbols that are missing a name to better handle badly behaved language servers 73 | - Duplicate visible notifications are now suppressed 74 | 75 | ## v0.8.3 76 | 77 | - Ensure that triggerChars is correctly sent or not sent depending on whether it was auto-triggered 78 | 79 | ## v0.8.2 80 | 81 | - Prevent ServerManager from hanging on a failed server startup promise #174 (thanks @alexheretic!) 82 | 83 | ## v0.8.1 84 | 85 | ### New 86 | 87 | - Auto-restart language servers that crash (up to 5 times in 3 minutes) #172 88 | - API to restart your language servers (e.g. after downloading new server, changing config) #172 89 | - Configuration change monitoring via workspace/didChangeConfiguration #167 90 | - API to get the connection associated with an editor to send custom messages #173 91 | 92 | ### Changes 93 | 94 | - Trigger autocomplete all the time instead of just on triggerchars\ 95 | 96 | ### Fixes 97 | 98 | - Do not send non-null initialization parameters #171 99 | - Clean up after unexpected server shutdown #169 100 | 101 | ## v0.8.0 102 | 103 | This update improves auto complete support in a number of ways; 104 | 105 | - Automatic triggering only if a trigger character specified by the server is typed (this should improve performance as well as cut down connection issues caused by crashing servers) 106 | - Filtering is performed by atom-languageclient when server reports results are complete (perf, better results) 107 | - Resolve is now called only if the language server supports it #162 108 | - CompletionItemKinds defined in v3 of the protocol are now mapped 109 | - Allows customization of the conversion between LSP and autocomplete-plus suggestions via a hook #137 110 | - New onDidInsertSuggestion override available when autocomplete request inserted #115 111 | - Use `CompletionItem.textEdit` field for snippet content #165 112 | 113 | Additional changes include; 114 | 115 | - CancellationToken support for cancelling pending requests #160 116 | - Automatic cancellation for incomplete resolve and autocomplete requests #160 117 | - Improved debug logging (stderr in #158 as well and signal report on exit) 118 | 119 | ## v0.7.3 120 | 121 | - AutoCompleteAdapter now takes an [optional function for customizing suggestions](https://github.com/atom/atom-languageclient/pull/137) 122 | 123 | ## v0.7.2 124 | 125 | - AutoComplete to CompletionItems now actually work on Atom 1.24 not just a previous PR 126 | 127 | ## v0.7.1 128 | 129 | - AutoComplete to CompletionItems now support resolve when using Atom 1.24 or later 130 | 131 | ## v0.7.0 132 | 133 | - Support snippet type completion items 134 | - Move completionItem detail to right for consistency with VSCode 135 | - Make ServerManager restartable 136 | - Sort completion results 137 | - LSP v3 flow types plus wiring up of willSave 138 | - Support TextDocumentEdit in ApplyEditAdapter for v3 139 | - Upgrade flow, remove prettier 140 | - Busy Signals added for start and shutdown 141 | - Dispose connection on server stop, prevent rpc errors in console 142 | 143 | ## v0.6.7 144 | 145 | - Update vscode-jsonrpc from 3.3.1 to 3.4.1 146 | - Allow file: uri without // or /// from the server 147 | 148 | ## v0.6.6 149 | 150 | - Allow filtering for didChangeWatchedFiles to workaround language servers triggering full rebuilds on changes to .git/build folders etc. 151 | - Add flow type definitions for Atom IDE UI's busy signal 152 | 153 | ## v0.6.5 154 | 155 | - Send rootUri along with rootPath on initialize for compatibility with LSP v3 servers 156 | - New signature helper adapter for `textDocument/signatureHelp` 157 | - Upgrade various npm runtime and dev dependencies 158 | - Revert to using item.label when no item.insertText for AutoComplete+ 159 | - Take priority over built-in AutoComplete+ provider 160 | 161 | ## v0.6.4 162 | 163 | - Capture error messages from child process launch/exit for better logging 164 | - New `workspace/applyEdit` adapter 165 | - New `document/codeAction` adapter 166 | - Order OutlineView depending on source line & position 167 | 168 | ## v0.6.3 169 | 170 | - Additional error logging 171 | 172 | ## v0.6.2 173 | 174 | - Clear linter messages on shutdown 175 | 176 | ## v0.6.1 177 | 178 | - Accidental republish of v0.6.0 179 | 180 | ## v0.6.0 181 | 182 | - Handle duplicate change events for incremental doc syncing 183 | - Handle files opened multiple times in different windows/editors 184 | - Fix GitHub repo link in package.json 185 | - Ensure child process killed on exit/reload 186 | - Do not convert http:// and https:// uri's as if they were file:// 187 | 188 | ## v0.5.0 189 | 190 | - Allow duplicate named nodes in OutlineView 191 | - Do not npm publish pre-transpiled sources or misc files 192 | - Send LSP `exit` notification after `shutdown` 193 | - Use `atom.project.onDidChangeFiles` for fs monitoring instead of faking on save 194 | 195 | ## v0.4.1 196 | 197 | - New `document/codeHighlights` adapter 198 | - Change nuclide flowtypes to atomIde 199 | - Remove redundant log messaging 200 | - Add eslint to build and make files compliant 201 | 202 | 203 | ## v0.4.0 204 | 205 | - Switch code format to new range provider 206 | - Remove postInstall now project is released 207 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 GitHub Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ##### Atom and all repositories under Atom will be archived on December 15, 2022. Learn more in our [official announcement](https://github.blog/2022-06-08-sunsetting-atom/) 2 | # Atom Language Server Protocol Client 3 | 4 | :warning: Development of atom-languageclient has officially moved to [atom-community/atom-languageclient](https://github.com/atom-community/atom-languageclient) :warning: 5 | 6 | :warning: Submit your issues or pull requests in that repository. :warning: 7 | 8 | -------------------- 9 | ![CI Status](https://github.com/atom/atom-languageclient/actions/workflows/main.yml/badge.svg) 10 | 11 | Provide integration support for adding Language Server Protocol servers to Atom. 12 | 13 | ## Background 14 | 15 | [Language Server Protocol (LSP)](https://microsoft.github.io/language-server-protocol/) is a JSON-RPC based mechanism whereby a client (IDE) may connect to an out-of-process server that can provide rich analysis, refactoring and interactive features for a given programming language. 16 | 17 | ## Implementation 18 | 19 | This npm package can be used by Atom package authors wanting to integrate LSP-compatible language servers with Atom. It provides: 20 | 21 | * Conversion routines between Atom and LSP types 22 | * A FlowTyped wrapper around JSON-RPC for **v3** of the LSP protocol 23 | * All necessary FlowTyped input and return structures for LSP, notifications etc. 24 | * A number of adapters to translate communication between Atom/Atom-IDE and the LSP's capabilities 25 | * Automatic wiring up of adapters based on the negotiated capabilities of the language server 26 | * Helper functions for downloading additional non-npm dependencies 27 | 28 | ## Capabilities 29 | 30 | The language server protocol consists of a number of capabilities. Some of these already have a counterpoint we can connect up to today while others do not. The following table shows each capability in v2 and how it is exposed via Atom; 31 | 32 | | Capability | Atom interface | 33 | |---------------------------------|-------------------------------| 34 | | window/showMessage | Notifications package | 35 | | window/showMessageRequest | Notifications package | 36 | | window/logMessage | Atom-IDE console | 37 | | telemetry/event | Ignored | 38 | | workspace/didChangeWatchedFiles | Atom file watch API | 39 | | textDocument/publishDiagnostics | Linter v2 push/indie | 40 | | textDocument/completion | AutoComplete+ | 41 | | completionItem/resolve | AutoComplete+ (Atom 1.24+) | 42 | | textDocument/hover | Atom-IDE data tips | 43 | | textDocument/signatureHelp | Atom-IDE signature help | 44 | | textDocument/definition | Atom-IDE definitions | 45 | | textDocument/findReferences | Atom-IDE findReferences | 46 | | textDocument/documentHighlight | Atom-IDE code highlights | 47 | | textDocument/documentSymbol | Atom-IDE outline view | 48 | | workspace/symbol | TBD | 49 | | textDocument/codeAction | Atom-IDE code actions | 50 | | textDocument/codeLens | TBD | 51 | | textDocument/formatting | Format File command | 52 | | textDocument/rangeFormatting | Format Selection command | 53 | | textDocument/onTypeFormatting | Atom-IDE on type formatting | 54 | | textDocument/onSaveFormatting | Atom-IDE on save formatting | 55 | | textDocument/rename | TBD | 56 | | textDocument/didChange | Send on save | 57 | | textDocument/didOpen | Send on open | 58 | | textDocument/didSave | Send after save | 59 | | textDocument/willSave | Send before save | 60 | | textDocument/didClose | Send on close | 61 | 62 | ## Developing packages 63 | 64 | The underlying JSON-RPC communication is handled by the [vscode-jsonrpc npm module](https://www.npmjs.com/package/vscode-jsonrpc). 65 | 66 | ### Minimal example 67 | 68 | A minimal implementation can be illustrated by the Omnisharp package here which has only npm-managed dependencies. You simply provide the scope name, language name and server name as well as start your process and AutoLanguageClient takes care of interrogating your language server capabilities and wiring up the appropriate services within Atom to expose them. 69 | 70 | ```javascript 71 | const {AutoLanguageClient} = require('atom-languageclient') 72 | 73 | class CSharpLanguageClient extends AutoLanguageClient { 74 | getGrammarScopes () { return [ 'source.cs' ] } 75 | getLanguageName () { return 'C#' } 76 | getServerName () { return 'OmniSharp' } 77 | 78 | startServerProcess () { 79 | return super.spawnChildNode([ require.resolve('omnisharp-client/languageserver/server') ]) 80 | } 81 | } 82 | 83 | module.exports = new CSharpLanguageClient() 84 | ``` 85 | 86 | You can get this code packaged up with the necessary package.json etc. from the [ide-csharp](https://github.com/atom/ide-csharp) provides C# support via [Omnisharp (node-omnisharp)](https://github.com/OmniSharp/omnisharp-node-client) repo. 87 | 88 | Note that you will also need to add various entries to the `providedServices` and `consumedServices` section of your package.json (for now). You can [obtain these entries here](https://github.com/atom/ide-csharp/tree/master/package.json). 89 | 90 | ### Using other connection types 91 | 92 | The default connection type is *stdio* however both *ipc* and *sockets* are also available. 93 | 94 | #### IPC 95 | 96 | To use ipc simply return *ipc* from getConnectionType(), e.g. 97 | 98 | ```javascript 99 | class ExampleLanguageClient extends AutoLanguageClient { 100 | getGrammarScopes () { return [ 'source.js', 'javascript' ] } 101 | getLanguageName () { return 'JavaScript' } 102 | getServerName () { return 'JavaScript Language Server' } 103 | 104 | getConnectionType() { return 'ipc' } 105 | 106 | startServerProcess () { 107 | const startServer = require.resolve('@example/js-language-server') 108 | return super.spawnChildNode([startServer, '--node-ipc'], { 109 | stdio: [null, null, null, 'ipc'] 110 | }) 111 | } 112 | } 113 | ``` 114 | 115 | #### Sockets 116 | 117 | Sockets are a little more complex because you need to allocate a free socket. The [ide-php package](https://github.com/atom/ide-php/blob/master/lib/main.js) contains an example of this. 118 | 119 | ### Debugging 120 | 121 | Atom-LanguageClient can log all sent and received messages nicely formatted to the Developer Tools Console within Atom. To do so simply enable it with `atom.config.set('core.debugLSP', true)`, e.g. 122 | 123 | ### Tips 124 | 125 | Some more elaborate scenarios can be found in the [ide-java](https://github.com/atom/ide-java) package which includes: 126 | 127 | * Downloading and unpacking non-npm dependencies (in this case a .tar.gz containing JAR files) 128 | * Platform-specific start-up configuration 129 | * Wiring up custom extensions to the protocol (language/status to Atom Status-Bar, language/actionableNotification to Atom Notifications) 130 | 131 | ### Available packages 132 | 133 | Right now we have the following experimental Atom LSP packages in development. They are mostly usable but are missing some features that either the LSP server doesn't support or expose functionality that is as yet unmapped to Atom (TODO and TBD in the capabilities table above). 134 | 135 | ### Official packages 136 | 137 | * [ide-csharp](https://github.com/atom/ide-csharp) provides C# support via [Omnisharp (node-omnisharp)](https://github.com/OmniSharp/omnisharp-node-client) 138 | * [ide-flowtype](https://github.com/flowtype/ide-flowtype) provides Flow support via [Flow Language Server](https://github.com/flowtype/flow-language-server) 139 | * [ide-java](https://github.com/atom/ide-java) provides Java support via [Java Eclipse JDT](https://github.com/eclipse/eclipse.jdt.ls) 140 | * [ide-typescript](https://github.com/atom/ide-typescript) provides TypeScript and Javascript support via [SourceGraph Typescript Language Server](https://github.com/sourcegraph/javascript-typescript-langserver) 141 | 142 | ### Community packages 143 | 144 | Our [full list of Atom IDE packages](https://github.com/atom/atom-languageclient/wiki/List-of-Atom-packages-using-Atom-LanguageClient) includes the community packages. 145 | 146 | ### Other language servers 147 | 148 | Additional LSP servers that might be of interest to be packaged with this for Atom can be found at [LangServer.org](http://langserver.org) 149 | 150 | ## Contributing 151 | 152 | ### Running from source 153 | 154 | If you want to run from source you will need to perform the following steps (you will need node and npm intalled): 155 | 156 | 1. Check out the source 157 | 2. From the source folder type `npm link` to build and link 158 | 3. From the folder where your package lives type `npm link atom-languageclient` 159 | 160 | If you want to switch back to the production version of atom-languageclient type `npm unlink atom-languageclient` from the folder where your package lives. 161 | 162 | ### Before sending a PR 163 | 164 | We have various unit tests and some linter rules - you can run both of these locally using `npm test` to ensure your CI will get a clean build. 165 | 166 | ### Guidance 167 | 168 | Always feel free to help out! Whether it's [filing bugs and feature requests](https://github.com/atom/atom-languageclient/issues/new) or working on some of the [open issues](https://github.com/atom/atom-languageclient/issues), Atom's [contributing guide](https://github.com/atom/atom/blob/master/CONTRIBUTING.md) will help get you started while the [guide for contributing to packages](https://github.com/atom/atom/blob/master/docs/contributing-to-packages.md) has some extra information. 169 | 170 | ## License 171 | 172 | MIT License. See [the license](LICENSE.md) for more details. 173 | -------------------------------------------------------------------------------- /lib/adapters/apply-edit-adapter.ts: -------------------------------------------------------------------------------- 1 | import * as atomIde from 'atom-ide'; 2 | import Convert from '../convert'; 3 | import { 4 | LanguageClientConnection, 5 | ApplyWorkspaceEditParams, 6 | ApplyWorkspaceEditResponse, 7 | } from '../languageclient'; 8 | import { 9 | TextBuffer, 10 | TextEditor, 11 | } from 'atom'; 12 | 13 | /** Public: Adapts workspace/applyEdit commands to editors. */ 14 | export default class ApplyEditAdapter { 15 | /** Public: Attach to a {LanguageClientConnection} to receive edit events. */ 16 | public static attach(connection: LanguageClientConnection) { 17 | connection.onApplyEdit((m) => ApplyEditAdapter.onApplyEdit(m)); 18 | } 19 | 20 | /** 21 | * Tries to apply edits and reverts if anything goes wrong. 22 | * Returns the checkpoint, so the caller can revert changes if needed. 23 | */ 24 | public static applyEdits( 25 | buffer: TextBuffer, 26 | edits: atomIde.TextEdit[], 27 | ): number { 28 | const checkpoint = buffer.createCheckpoint(); 29 | try { 30 | // Sort edits in reverse order to prevent edit conflicts. 31 | edits.sort((edit1, edit2) => -edit1.oldRange.compare(edit2.oldRange)); 32 | edits.reduce((previous: atomIde.TextEdit | null, current) => { 33 | ApplyEditAdapter.validateEdit(buffer, current, previous); 34 | buffer.setTextInRange(current.oldRange, current.newText); 35 | return current; 36 | }, null); 37 | buffer.groupChangesSinceCheckpoint(checkpoint); 38 | return checkpoint; 39 | } catch (err) { 40 | buffer.revertToCheckpoint(checkpoint); 41 | throw err; 42 | } 43 | } 44 | 45 | public static async onApplyEdit(params: ApplyWorkspaceEditParams): Promise { 46 | 47 | let changes = params.edit.changes || {}; 48 | 49 | if (params.edit.documentChanges) { 50 | changes = {}; 51 | params.edit.documentChanges.forEach((change) => { 52 | if (change && change.textDocument) { 53 | changes[change.textDocument.uri] = change.edits; 54 | } 55 | }); 56 | } 57 | 58 | const uris = Object.keys(changes); 59 | 60 | // Keep checkpoints from all successful buffer edits 61 | const checkpoints: Array<{ buffer: TextBuffer, checkpoint: number }> = []; 62 | 63 | const promises = uris.map(async (uri) => { 64 | const path = Convert.uriToPath(uri); 65 | const editor = await atom.workspace.open( 66 | path, { 67 | searchAllPanes: true, 68 | // Open new editors in the background. 69 | activatePane: false, 70 | activateItem: false, 71 | }, 72 | ) as TextEditor; 73 | const buffer = editor.getBuffer(); 74 | // Get an existing editor for the file, or open a new one if it doesn't exist. 75 | const edits = Convert.convertLsTextEdits(changes[uri]); 76 | const checkpoint = ApplyEditAdapter.applyEdits(buffer, edits); 77 | checkpoints.push({ buffer, checkpoint }); 78 | }); 79 | 80 | // Apply all edits or fail and revert everything 81 | const applied = await Promise.all(promises) 82 | .then(() => true) 83 | .catch((err) => { 84 | atom.notifications.addError('workspace/applyEdits failed', { 85 | description: 'Failed to apply edits.', 86 | detail: err.message, 87 | }); 88 | checkpoints.forEach(({ buffer, checkpoint }) => { 89 | buffer.revertToCheckpoint(checkpoint); 90 | }); 91 | return false; 92 | }); 93 | 94 | return { applied }; 95 | } 96 | 97 | /** Private: Do some basic sanity checking on the edit ranges. */ 98 | private static validateEdit( 99 | buffer: TextBuffer, 100 | edit: atomIde.TextEdit, 101 | prevEdit: atomIde.TextEdit | null, 102 | ): void { 103 | const path = buffer.getPath() || ''; 104 | if (prevEdit && edit.oldRange.end.compare(prevEdit.oldRange.start) > 0) { 105 | throw Error(`Found overlapping edit ranges in ${path}`); 106 | } 107 | const startRow = edit.oldRange.start.row; 108 | const startCol = edit.oldRange.start.column; 109 | const lineLength = buffer.lineLengthForRow(startRow); 110 | if (lineLength == null || startCol > lineLength) { 111 | throw Error(`Out of range edit on ${path}:${startRow + 1}:${startCol + 1}`); 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /lib/adapters/code-action-adapter.ts: -------------------------------------------------------------------------------- 1 | import * as atomIde from 'atom-ide'; 2 | import LinterPushV2Adapter from './linter-push-v2-adapter'; 3 | import assert = require('assert'); 4 | import Convert from '../convert'; 5 | import ApplyEditAdapter from './apply-edit-adapter'; 6 | import { 7 | CodeAction, 8 | CodeActionParams, 9 | Command, 10 | LanguageClientConnection, 11 | ServerCapabilities, 12 | WorkspaceEdit, 13 | } from '../languageclient'; 14 | import { 15 | Range, 16 | TextEditor, 17 | } from 'atom'; 18 | 19 | export default class CodeActionAdapter { 20 | /** 21 | * @returns A {Boolean} indicating this adapter can adapt the server based on the 22 | * given serverCapabilities. 23 | */ 24 | public static canAdapt(serverCapabilities: ServerCapabilities): boolean { 25 | return serverCapabilities.codeActionProvider === true; 26 | } 27 | 28 | /** 29 | * Public: Retrieves code actions for a given editor, range, and context (diagnostics). 30 | * Throws an error if codeActionProvider is not a registered capability. 31 | * 32 | * @param connection A {LanguageClientConnection} to the language server that provides highlights. 33 | * @param serverCapabilities The {ServerCapabilities} of the language server that will be used. 34 | * @param editor The Atom {TextEditor} containing the diagnostics. 35 | * @param range The Atom {Range} to fetch code actions for. 36 | * @param diagnostics An {Array} to fetch code actions for. 37 | * This is typically a list of diagnostics intersecting `range`. 38 | * @returns A {Promise} of an {Array} of {atomIde$CodeAction}s to display. 39 | */ 40 | public static async getCodeActions( 41 | connection: LanguageClientConnection, 42 | serverCapabilities: ServerCapabilities, 43 | linterAdapter: LinterPushV2Adapter | undefined, 44 | editor: TextEditor, 45 | range: Range, 46 | diagnostics: atomIde.Diagnostic[], 47 | ): Promise { 48 | if (linterAdapter == null) { 49 | return []; 50 | } 51 | assert(serverCapabilities.codeActionProvider, 'Must have the textDocument/codeAction capability'); 52 | 53 | const params = CodeActionAdapter.createCodeActionParams(linterAdapter, editor, range, diagnostics); 54 | const actions = await connection.codeAction(params); 55 | return actions.map((action) => CodeActionAdapter.createCodeAction(action, connection)); 56 | } 57 | 58 | private static createCodeAction( 59 | action: Command | CodeAction, 60 | connection: LanguageClientConnection, 61 | ): atomIde.CodeAction { 62 | return { 63 | async apply() { 64 | if (CodeAction.is(action)) { 65 | CodeActionAdapter.applyWorkspaceEdit(action.edit); 66 | await CodeActionAdapter.executeCommand(action.command, connection); 67 | } else { 68 | await CodeActionAdapter.executeCommand(action, connection); 69 | } 70 | }, 71 | getTitle(): Promise { 72 | return Promise.resolve(action.title); 73 | }, 74 | // tslint:disable-next-line:no-empty 75 | dispose(): void { }, 76 | }; 77 | } 78 | 79 | private static applyWorkspaceEdit( 80 | edit: WorkspaceEdit | undefined, 81 | ): void { 82 | if (WorkspaceEdit.is(edit)) { 83 | ApplyEditAdapter.onApplyEdit({ edit }); 84 | } 85 | } 86 | 87 | private static async executeCommand( 88 | command: any, 89 | connection: LanguageClientConnection, 90 | ): Promise { 91 | if (Command.is(command)) { 92 | await connection.executeCommand({ 93 | command: command.command, 94 | arguments: command.arguments, 95 | }); 96 | } 97 | } 98 | 99 | private static createCodeActionParams( 100 | linterAdapter: LinterPushV2Adapter, 101 | editor: TextEditor, 102 | range: Range, 103 | diagnostics: atomIde.Diagnostic[], 104 | ): CodeActionParams { 105 | return { 106 | textDocument: Convert.editorToTextDocumentIdentifier(editor), 107 | range: Convert.atomRangeToLSRange(range), 108 | context: { 109 | diagnostics: diagnostics.map((diagnostic) => { 110 | // Retrieve the stored diagnostic code if it exists. 111 | // Until the Linter API provides a place to store the code, 112 | // there's no real way for the code actions API to give it back to us. 113 | const converted = Convert.atomIdeDiagnosticToLSDiagnostic(diagnostic); 114 | if (diagnostic.range != null && diagnostic.text != null) { 115 | const code = linterAdapter.getDiagnosticCode(editor, diagnostic.range, diagnostic.text); 116 | if (code != null) { 117 | converted.code = code; 118 | } 119 | } 120 | return converted; 121 | }), 122 | }, 123 | }; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /lib/adapters/code-format-adapter.ts: -------------------------------------------------------------------------------- 1 | import * as atomIde from 'atom-ide'; 2 | import Convert from '../convert'; 3 | import { 4 | LanguageClientConnection, 5 | DocumentFormattingParams, 6 | DocumentRangeFormattingParams, 7 | DocumentOnTypeFormattingParams, 8 | FormattingOptions, 9 | ServerCapabilities, 10 | } from '../languageclient'; 11 | import { 12 | TextEditor, 13 | Range, 14 | Point, 15 | } from 'atom'; 16 | 17 | /** 18 | * Public: Adapts the language server protocol "textDocument/completion" to the 19 | * Atom IDE UI Code-format package. 20 | */ 21 | export default class CodeFormatAdapter { 22 | /** 23 | * Public: Determine whether this adapter can be used to adapt a language server 24 | * based on the serverCapabilities matrix containing either a documentFormattingProvider 25 | * or a documentRangeFormattingProvider. 26 | * 27 | * @param serverCapabilities The {ServerCapabilities} of the language server to consider. 28 | * @returns A {Boolean} indicating this adapter can adapt the server based on the 29 | * given serverCapabilities. 30 | */ 31 | public static canAdapt(serverCapabilities: ServerCapabilities): boolean { 32 | return ( 33 | serverCapabilities.documentRangeFormattingProvider === true || 34 | serverCapabilities.documentFormattingProvider === true 35 | ); 36 | } 37 | 38 | /** 39 | * Public: Format text in the editor using the given language server connection and an optional range. 40 | * If the server does not support range formatting then range will be ignored and the entire document formatted. 41 | * 42 | * @param connection A {LanguageClientConnection} to the language server that will format the text. 43 | * @param serverCapabilities The {ServerCapabilities} of the language server that will be used. 44 | * @param editor The Atom {TextEditor} containing the text that will be formatted. 45 | * @param range The optional Atom {Range} containing the subset of the text to be formatted. 46 | * @returns A {Promise} of an {Array} of {Object}s containing the AutoComplete+ 47 | * suggestions to display. 48 | */ 49 | public static format( 50 | connection: LanguageClientConnection, 51 | serverCapabilities: ServerCapabilities, 52 | editor: TextEditor, 53 | range: Range, 54 | ): Promise { 55 | if (serverCapabilities.documentRangeFormattingProvider) { 56 | return CodeFormatAdapter.formatRange(connection, editor, range); 57 | } 58 | 59 | if (serverCapabilities.documentFormattingProvider) { 60 | return CodeFormatAdapter.formatDocument(connection, editor); 61 | } 62 | 63 | throw new Error('Can not format document, language server does not support it'); 64 | } 65 | 66 | /** 67 | * Public: Format the entire document of an Atom {TextEditor} by using a given language server. 68 | * 69 | * @param connection A {LanguageClientConnection} to the language server that will format the text. 70 | * @param editor The Atom {TextEditor} containing the document to be formatted. 71 | * @returns A {Promise} of an {Array} of {TextEdit} objects that can be applied to the Atom TextEditor 72 | * to format the document. 73 | */ 74 | public static async formatDocument( 75 | connection: LanguageClientConnection, 76 | editor: TextEditor, 77 | ): Promise { 78 | const edits = await connection.documentFormatting(CodeFormatAdapter.createDocumentFormattingParams(editor)); 79 | return Convert.convertLsTextEdits(edits); 80 | } 81 | 82 | /** 83 | * Public: Create {DocumentFormattingParams} to be sent to the language server when requesting an 84 | * entire document is formatted. 85 | * 86 | * @param editor The Atom {TextEditor} containing the document to be formatted. 87 | * @returns A {DocumentFormattingParams} containing the identity of the text document as well as 88 | * options to be used in formatting the document such as tab size and tabs vs spaces. 89 | */ 90 | public static createDocumentFormattingParams(editor: TextEditor): DocumentFormattingParams { 91 | return { 92 | textDocument: Convert.editorToTextDocumentIdentifier(editor), 93 | options: CodeFormatAdapter.getFormatOptions(editor), 94 | }; 95 | } 96 | 97 | /** 98 | * Public: Format a range within an Atom {TextEditor} by using a given language server. 99 | * 100 | * @param connection A {LanguageClientConnection} to the language server that will format the text. 101 | * @param range The Atom {Range} containing the range of text that should be formatted. 102 | * @param editor The Atom {TextEditor} containing the document to be formatted. 103 | * @returns A {Promise} of an {Array} of {TextEdit} objects that can be applied to the Atom TextEditor 104 | * to format the document. 105 | */ 106 | public static async formatRange( 107 | connection: LanguageClientConnection, 108 | editor: TextEditor, 109 | range: Range, 110 | ): Promise { 111 | const edits = await connection.documentRangeFormatting( 112 | CodeFormatAdapter.createDocumentRangeFormattingParams(editor, range), 113 | ); 114 | return Convert.convertLsTextEdits(edits); 115 | } 116 | 117 | /** 118 | * Public: Create {DocumentRangeFormattingParams} to be sent to the language server when requesting an 119 | * entire document is formatted. 120 | * 121 | * @param editor The Atom {TextEditor} containing the document to be formatted. 122 | * @param range The Atom {Range} containing the range of text that should be formatted. 123 | * @returns A {DocumentRangeFormattingParams} containing the identity of the text document, the 124 | * range of the text to be formatted as well as the options to be used in formatting the 125 | * document such as tab size and tabs vs spaces. 126 | */ 127 | public static createDocumentRangeFormattingParams( 128 | editor: TextEditor, 129 | range: Range, 130 | ): DocumentRangeFormattingParams { 131 | return { 132 | textDocument: Convert.editorToTextDocumentIdentifier(editor), 133 | range: Convert.atomRangeToLSRange(range), 134 | options: CodeFormatAdapter.getFormatOptions(editor), 135 | }; 136 | } 137 | 138 | /** 139 | * Public: Format on type within an Atom {TextEditor} by using a given language server. 140 | * 141 | * @param connection A {LanguageClientConnection} to the language server that will format the text. 142 | * @param editor The Atom {TextEditor} containing the document to be formatted. 143 | * @param point The {Point} at which the document to be formatted. 144 | * @param character A character that triggered formatting request. 145 | * @returns A {Promise} of an {Array} of {TextEdit} objects that can be applied to the Atom TextEditor 146 | * to format the document. 147 | */ 148 | public static async formatOnType( 149 | connection: LanguageClientConnection, 150 | editor: TextEditor, 151 | point: Point, 152 | character: string, 153 | ): Promise { 154 | const edits = await connection.documentOnTypeFormatting( 155 | CodeFormatAdapter.createDocumentOnTypeFormattingParams(editor, point, character), 156 | ); 157 | return Convert.convertLsTextEdits(edits); 158 | } 159 | 160 | /** 161 | * Public: Create {DocumentOnTypeFormattingParams} to be sent to the language server when requesting an 162 | * entire document is formatted. 163 | * 164 | * @param editor The Atom {TextEditor} containing the document to be formatted. 165 | * @param point The {Point} at which the document to be formatted. 166 | * @param character A character that triggered formatting request. 167 | * @returns A {DocumentOnTypeFormattingParams} containing the identity of the text document, the 168 | * position of the text to be formatted, the character that triggered formatting request 169 | * as well as the options to be used in formatting the document such as tab size and tabs vs spaces. 170 | */ 171 | public static createDocumentOnTypeFormattingParams( 172 | editor: TextEditor, 173 | point: Point, 174 | character: string, 175 | ): DocumentOnTypeFormattingParams { 176 | return { 177 | textDocument: Convert.editorToTextDocumentIdentifier(editor), 178 | position: Convert.pointToPosition(point), 179 | ch: character, 180 | options: CodeFormatAdapter.getFormatOptions(editor), 181 | }; 182 | } 183 | 184 | /** 185 | * Public: Create {DocumentRangeFormattingParams} to be sent to the language server when requesting an 186 | * entire document is formatted. 187 | * 188 | * @param editor The Atom {TextEditor} containing the document to be formatted. 189 | * @param range The Atom {Range} containing the range of document that should be formatted. 190 | * @returns The {FormattingOptions} to be used containing the keys: 191 | * * `tabSize` The number of spaces a tab represents. 192 | * * `insertSpaces` {True} if spaces should be used, {False} for tab characters. 193 | */ 194 | public static getFormatOptions(editor: TextEditor): FormattingOptions { 195 | return { 196 | tabSize: editor.getTabLength(), 197 | insertSpaces: editor.getSoftTabs(), 198 | }; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /lib/adapters/code-highlight-adapter.ts: -------------------------------------------------------------------------------- 1 | import assert = require('assert'); 2 | import Convert from '../convert'; 3 | import { 4 | Point, 5 | TextEditor, 6 | Range, 7 | } from 'atom'; 8 | import { 9 | LanguageClientConnection, 10 | ServerCapabilities, 11 | } from '../languageclient'; 12 | 13 | export default class CodeHighlightAdapter { 14 | /** 15 | * @returns A {Boolean} indicating this adapter can adapt the server based on the 16 | * given serverCapabilities. 17 | */ 18 | public static canAdapt(serverCapabilities: ServerCapabilities): boolean { 19 | return serverCapabilities.documentHighlightProvider === true; 20 | } 21 | 22 | /** 23 | * Public: Creates highlight markers for a given editor position. 24 | * Throws an error if documentHighlightProvider is not a registered capability. 25 | * 26 | * @param connection A {LanguageClientConnection} to the language server that provides highlights. 27 | * @param serverCapabilities The {ServerCapabilities} of the language server that will be used. 28 | * @param editor The Atom {TextEditor} containing the text to be highlighted. 29 | * @param position The Atom {Point} to fetch highlights for. 30 | * @returns A {Promise} of an {Array} of {Range}s to be turned into highlights. 31 | */ 32 | public static async highlight( 33 | connection: LanguageClientConnection, 34 | serverCapabilities: ServerCapabilities, 35 | editor: TextEditor, 36 | position: Point, 37 | ): Promise { 38 | assert(serverCapabilities.documentHighlightProvider, 'Must have the documentHighlight capability'); 39 | const highlights = await connection.documentHighlight(Convert.editorToTextDocumentPositionParams(editor, position)); 40 | return highlights.map((highlight) => { 41 | return Convert.lsRangeToAtomRange(highlight.range); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/adapters/datatip-adapter.ts: -------------------------------------------------------------------------------- 1 | import * as atomIde from 'atom-ide'; 2 | import Convert from '../convert'; 3 | import * as Utils from '../utils'; 4 | import { 5 | Hover, 6 | LanguageClientConnection, 7 | MarkupContent, 8 | MarkedString, 9 | ServerCapabilities, 10 | } from '../languageclient'; 11 | import { 12 | Point, 13 | TextEditor, 14 | } from 'atom'; 15 | 16 | /** 17 | * Public: Adapts the language server protocol "textDocument/hover" to the 18 | * Atom IDE UI Datatip package. 19 | */ 20 | export default class DatatipAdapter { 21 | /** 22 | * Public: Determine whether this adapter can be used to adapt a language server 23 | * based on the serverCapabilities matrix containing a hoverProvider. 24 | * 25 | * @param serverCapabilities The {ServerCapabilities} of the language server to consider. 26 | * @returns A {Boolean} indicating adapter can adapt the server based on the 27 | * given serverCapabilities. 28 | */ 29 | public static canAdapt(serverCapabilities: ServerCapabilities): boolean { 30 | return serverCapabilities.hoverProvider === true; 31 | } 32 | 33 | /** 34 | * Public: Get the Datatip for this {Point} in a {TextEditor} by querying 35 | * the language server. 36 | * 37 | * @param connection A {LanguageClientConnection} to the language server that will be queried 38 | * for the hover text/datatip. 39 | * @param editor The Atom {TextEditor} containing the text the Datatip should relate to. 40 | * @param point The Atom {Point} containing the point within the text the Datatip should relate to. 41 | * @returns A {Promise} containing the {Datatip} to display or {null} if no Datatip is available. 42 | */ 43 | public async getDatatip( 44 | connection: LanguageClientConnection, 45 | editor: TextEditor, 46 | point: Point, 47 | ): Promise { 48 | const documentPositionParams = Convert.editorToTextDocumentPositionParams(editor, point); 49 | 50 | const hover = await connection.hover(documentPositionParams); 51 | if (hover == null || DatatipAdapter.isEmptyHover(hover)) { 52 | return null; 53 | } 54 | 55 | const range = 56 | hover.range == null ? Utils.getWordAtPosition(editor, point) : Convert.lsRangeToAtomRange(hover.range); 57 | 58 | const markedStrings = (Array.isArray(hover.contents) ? hover.contents : [hover.contents]).map((str) => 59 | DatatipAdapter.convertMarkedString(editor, str), 60 | ); 61 | 62 | return { range, markedStrings }; 63 | } 64 | 65 | private static isEmptyHover(hover: Hover): boolean { 66 | return hover.contents == null || 67 | (typeof hover.contents === 'string' && hover.contents.length === 0) || 68 | (Array.isArray(hover.contents) && 69 | (hover.contents.length === 0 || hover.contents[0] === "")); 70 | } 71 | 72 | private static convertMarkedString( 73 | editor: TextEditor, 74 | markedString: MarkedString | MarkupContent, 75 | ): atomIde.MarkedString { 76 | if (typeof markedString === 'string') { 77 | return { type: 'markdown', value: markedString }; 78 | } 79 | 80 | if ((markedString as MarkupContent).kind) { 81 | return { 82 | type: 'markdown', 83 | value: markedString.value, 84 | }; 85 | } 86 | 87 | // Must check as <{language: string}> to disambiguate between 88 | // string and the more explicit object type because MarkedString 89 | // is a union of the two types 90 | if ((markedString as { language: string }).language) { 91 | return { 92 | type: 'snippet', 93 | // TODO: find a better mapping from language -> grammar 94 | grammar: 95 | atom.grammars.grammarForScopeName( 96 | `source.${(markedString as { language: string }).language}`) || editor.getGrammar(), 97 | value: markedString.value, 98 | }; 99 | } 100 | 101 | // Catch-all case 102 | return { type: 'markdown', value: markedString.toString() }; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/adapters/definition-adapter.ts: -------------------------------------------------------------------------------- 1 | import * as atomIde from 'atom-ide'; 2 | import Convert from '../convert'; 3 | import * as Utils from '../utils'; 4 | import { 5 | LanguageClientConnection, 6 | Location, 7 | ServerCapabilities, 8 | } from '../languageclient'; 9 | import { 10 | Point, 11 | TextEditor, 12 | Range, 13 | } from 'atom'; 14 | 15 | /** 16 | * Public: Adapts the language server definition provider to the 17 | * Atom IDE UI Definitions package for 'Go To Definition' functionality. 18 | */ 19 | export default class DefinitionAdapter { 20 | /** 21 | * Public: Determine whether this adapter can be used to adapt a language server 22 | * based on the serverCapabilities matrix containing a definitionProvider. 23 | * 24 | * @param serverCapabilities The {ServerCapabilities} of the language server to consider. 25 | * @returns A {Boolean} indicating adapter can adapt the server based on the 26 | * given serverCapabilities. 27 | */ 28 | public static canAdapt(serverCapabilities: ServerCapabilities): boolean { 29 | return serverCapabilities.definitionProvider === true; 30 | } 31 | 32 | /** 33 | * Public: Get the definitions for a symbol at a given {Point} within a 34 | * {TextEditor} including optionally highlighting all other references 35 | * within the document if the langauge server also supports highlighting. 36 | * 37 | * @param connection A {LanguageClientConnection} to the language server that will provide definitions and highlights. 38 | * @param serverCapabilities The {ServerCapabilities} of the language server that will be used. 39 | * @param languageName The name of the programming language. 40 | * @param editor The Atom {TextEditor} containing the symbol and potential highlights. 41 | * @param point The Atom {Point} containing the position of the text that represents the symbol 42 | * for which the definition and highlights should be provided. 43 | * @returns A {Promise} indicating adapter can adapt the server based on the 44 | * given serverCapabilities. 45 | */ 46 | public async getDefinition( 47 | connection: LanguageClientConnection, 48 | serverCapabilities: ServerCapabilities, 49 | languageName: string, 50 | editor: TextEditor, 51 | point: Point, 52 | ): Promise { 53 | const documentPositionParams = Convert.editorToTextDocumentPositionParams(editor, point); 54 | const definitionLocations = DefinitionAdapter.normalizeLocations( 55 | await connection.gotoDefinition(documentPositionParams), 56 | ); 57 | if (definitionLocations == null || definitionLocations.length === 0) { 58 | return null; 59 | } 60 | 61 | let queryRange; 62 | if (serverCapabilities.documentHighlightProvider) { 63 | const highlights = await connection.documentHighlight(documentPositionParams); 64 | if (highlights != null && highlights.length > 0) { 65 | queryRange = highlights.map((h) => Convert.lsRangeToAtomRange(h.range)); 66 | } 67 | } 68 | 69 | return { 70 | queryRange: queryRange || [Utils.getWordAtPosition(editor, point)], 71 | definitions: DefinitionAdapter.convertLocationsToDefinitions(definitionLocations, languageName), 72 | }; 73 | } 74 | 75 | /** 76 | * Public: Normalize the locations so a single {Location} becomes an {Array} of just 77 | * one. The language server protocol return either as the protocol evolved between v1 and v2. 78 | * 79 | * @param locationResult Either a single {Location} object or an {Array} of {Locations}. 80 | * @returns An {Array} of {Location}s or {null} if the locationResult was null. 81 | */ 82 | public static normalizeLocations(locationResult: Location | Location[]): Location[] | null { 83 | if (locationResult == null) { 84 | return null; 85 | } 86 | return (Array.isArray(locationResult) ? locationResult : [locationResult]).filter((d) => d.range.start != null); 87 | } 88 | 89 | /** 90 | * Public: Convert an {Array} of {Location} objects into an Array of {Definition}s. 91 | * 92 | * @param locations An {Array} of {Location} objects to be converted. 93 | * @param languageName The name of the language these objects are written in. 94 | * @returns An {Array} of {Definition}s that represented the converted {Location}s. 95 | */ 96 | public static convertLocationsToDefinitions(locations: Location[], languageName: string): atomIde.Definition[] { 97 | return locations.map((d) => ({ 98 | path: Convert.uriToPath(d.uri), 99 | position: Convert.positionToPoint(d.range.start), 100 | range: Range.fromObject(Convert.lsRangeToAtomRange(d.range)), 101 | language: languageName, 102 | })); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/adapters/find-references-adapter.ts: -------------------------------------------------------------------------------- 1 | import * as atomIde from 'atom-ide'; 2 | import Convert from '../convert'; 3 | import { 4 | Point, 5 | TextEditor, 6 | } from 'atom'; 7 | import { 8 | LanguageClientConnection, 9 | Location, 10 | ServerCapabilities, 11 | ReferenceParams, 12 | } from '../languageclient'; 13 | 14 | /** 15 | * Public: Adapts the language server definition provider to the 16 | * Atom IDE UI Definitions package for 'Go To Definition' functionality. 17 | */ 18 | export default class FindReferencesAdapter { 19 | /** 20 | * Public: Determine whether this adapter can be used to adapt a language server 21 | * based on the serverCapabilities matrix containing a referencesProvider. 22 | * 23 | * @param serverCapabilities The {ServerCapabilities} of the language server to consider. 24 | * @returns A {Boolean} indicating adapter can adapt the server based on the 25 | * given serverCapabilities. 26 | */ 27 | public static canAdapt(serverCapabilities: ServerCapabilities): boolean { 28 | return serverCapabilities.referencesProvider === true; 29 | } 30 | 31 | /** 32 | * Public: Get the references for a specific symbol within the document as represented by 33 | * the {TextEditor} and {Point} within it via the language server. 34 | * 35 | * @param connection A {LanguageClientConnection} to the language server that will be queried 36 | * for the references. 37 | * @param editor The Atom {TextEditor} containing the text the references should relate to. 38 | * @param point The Atom {Point} containing the point within the text the references should relate to. 39 | * @returns A {Promise} containing a {FindReferencesReturn} with all the references the language server 40 | * could find. 41 | */ 42 | public async getReferences( 43 | connection: LanguageClientConnection, 44 | editor: TextEditor, 45 | point: Point, 46 | projectRoot: string | null, 47 | ): Promise { 48 | const locations = await connection.findReferences( 49 | FindReferencesAdapter.createReferenceParams(editor, point), 50 | ); 51 | if (locations == null) { 52 | return null; 53 | } 54 | 55 | const references: atomIde.Reference[] = locations.map(FindReferencesAdapter.locationToReference); 56 | return { 57 | type: 'data', 58 | baseUri: projectRoot || '', 59 | referencedSymbolName: FindReferencesAdapter.getReferencedSymbolName(editor, point, references), 60 | references, 61 | }; 62 | } 63 | 64 | /** 65 | * Public: Create a {ReferenceParams} from a given {TextEditor} for a specific {Point}. 66 | * 67 | * @param editor A {TextEditor} that represents the document. 68 | * @param point A {Point} within the document. 69 | * @returns A {ReferenceParams} built from the given parameters. 70 | */ 71 | public static createReferenceParams(editor: TextEditor, point: Point): ReferenceParams { 72 | return { 73 | textDocument: Convert.editorToTextDocumentIdentifier(editor), 74 | position: Convert.pointToPosition(point), 75 | context: { includeDeclaration: true }, 76 | }; 77 | } 78 | 79 | /** 80 | * Public: Convert a {Location} into a {Reference}. 81 | * 82 | * @param location A {Location} to convert. 83 | * @returns A {Reference} equivalent to the given {Location}. 84 | */ 85 | public static locationToReference(location: Location): atomIde.Reference { 86 | return { 87 | uri: Convert.uriToPath(location.uri), 88 | name: null, 89 | range: Convert.lsRangeToAtomRange(location.range), 90 | }; 91 | } 92 | 93 | /** Public: Get a symbol name from a {TextEditor} for a specific {Point} in the document. */ 94 | public static getReferencedSymbolName( 95 | editor: TextEditor, 96 | point: Point, 97 | references: atomIde.Reference[], 98 | ): string { 99 | if (references.length === 0) { 100 | return ''; 101 | } 102 | const currentReference = references.find((r) => r.range.containsPoint(point)) || references[0]; 103 | return editor.getBuffer().getTextInRange(currentReference.range); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /lib/adapters/linter-push-v2-adapter.ts: -------------------------------------------------------------------------------- 1 | import * as linter from 'atom/linter'; 2 | import * as atom from 'atom'; 3 | import Convert from '../convert'; 4 | import { 5 | Diagnostic, 6 | DiagnosticCode, 7 | DiagnosticSeverity, 8 | LanguageClientConnection, 9 | PublishDiagnosticsParams, 10 | } from '../languageclient'; 11 | 12 | /** 13 | * Public: Listen to diagnostics messages from the language server and publish them 14 | * to the user by way of the Linter Push (Indie) v2 API supported by Atom IDE UI. 15 | */ 16 | export default class LinterPushV2Adapter { 17 | private _diagnosticMap: Map = new Map(); 18 | private _diagnosticCodes: Map> = new Map(); 19 | private _indies: Set = new Set(); 20 | 21 | /** 22 | * Public: Create a new {LinterPushV2Adapter} that will listen for diagnostics 23 | * via the supplied {LanguageClientConnection}. 24 | * 25 | * @param connection A {LanguageClientConnection} to the language server that will provide diagnostics. 26 | */ 27 | constructor(connection: LanguageClientConnection) { 28 | connection.onPublishDiagnostics(this.captureDiagnostics.bind(this)); 29 | } 30 | 31 | /** Dispose this adapter ensuring any resources are freed and events unhooked. */ 32 | public dispose(): void { 33 | this.detachAll(); 34 | } 35 | 36 | /** 37 | * Public: Attach this {LinterPushV2Adapter} to a given {V2IndieDelegate} registry. 38 | * 39 | * @param indie A {V2IndieDelegate} that wants to receive messages. 40 | */ 41 | public attach(indie: linter.IndieDelegate): void { 42 | this._indies.add(indie); 43 | this._diagnosticMap.forEach((value, key) => indie.setMessages(key, value)); 44 | indie.onDidDestroy(() => { 45 | this._indies.delete(indie); 46 | }); 47 | } 48 | 49 | /** Public: Remove all {V2IndieDelegate} registries attached to this adapter and clear them. */ 50 | public detachAll(): void { 51 | this._indies.forEach((i) => i.clearMessages()); 52 | this._indies.clear(); 53 | } 54 | 55 | /** 56 | * Public: Capture the diagnostics sent from a langguage server, convert them to the 57 | * Linter V2 format and forward them on to any attached {V2IndieDelegate}s. 58 | * 59 | * @param params The {PublishDiagnosticsParams} received from the language server that should 60 | * be captured and forwarded on to any attached {V2IndieDelegate}s. 61 | */ 62 | public captureDiagnostics(params: PublishDiagnosticsParams): void { 63 | const path = Convert.uriToPath(params.uri); 64 | const codeMap = new Map(); 65 | const messages = params.diagnostics.map((d) => { 66 | const linterMessage = this.diagnosticToV2Message(path, d); 67 | codeMap.set(getCodeKey(linterMessage.location.position, d.message), d.code); 68 | return linterMessage; 69 | }); 70 | this._diagnosticMap.set(path, messages); 71 | this._diagnosticCodes.set(path, codeMap); 72 | this._indies.forEach((i) => i.setMessages(path, messages)); 73 | } 74 | 75 | /** 76 | * Public: Convert a single {Diagnostic} received from a language server into a single 77 | * {V2Message} expected by the Linter V2 API. 78 | * 79 | * @param path A string representing the path of the file the diagnostic belongs to. 80 | * @param diagnostics A {Diagnostic} object received from the language server. 81 | * @returns A {V2Message} equivalent to the {Diagnostic} object supplied by the language server. 82 | */ 83 | public diagnosticToV2Message(path: string, diagnostic: Diagnostic): linter.Message { 84 | return { 85 | location: { 86 | file: path, 87 | position: Convert.lsRangeToAtomRange(diagnostic.range), 88 | }, 89 | excerpt: diagnostic.message, 90 | linterName: diagnostic.source, 91 | severity: LinterPushV2Adapter.diagnosticSeverityToSeverity(diagnostic.severity || -1), 92 | }; 93 | } 94 | 95 | /** 96 | * Public: Convert a diagnostic severity number obtained from the language server into 97 | * the textual equivalent for a Linter {V2Message}. 98 | * 99 | * @param severity A number representing the severity of the diagnostic. 100 | * @returns A string of 'error', 'warning' or 'info' depending on the severity. 101 | */ 102 | public static diagnosticSeverityToSeverity(severity: number): 'error' | 'warning' | 'info' { 103 | switch (severity) { 104 | case DiagnosticSeverity.Error: 105 | return 'error'; 106 | case DiagnosticSeverity.Warning: 107 | return 'warning'; 108 | case DiagnosticSeverity.Information: 109 | case DiagnosticSeverity.Hint: 110 | default: 111 | return 'info'; 112 | } 113 | } 114 | 115 | /** 116 | * Private: Get the recorded diagnostic code for a range/message. 117 | * Diagnostic codes are tricky because there's no suitable place in the Linter API for them. 118 | * For now, we'll record the original code for each range/message combination and retrieve it 119 | * when needed (e.g. for passing back into code actions) 120 | */ 121 | public getDiagnosticCode(editor: atom.TextEditor, range: atom.Range, text: string): DiagnosticCode | null { 122 | const path = editor.getPath(); 123 | if (path != null) { 124 | const diagnosticCodes = this._diagnosticCodes.get(path); 125 | if (diagnosticCodes != null) { 126 | return diagnosticCodes.get(getCodeKey(range, text)) || null; 127 | } 128 | } 129 | return null; 130 | } 131 | } 132 | 133 | function getCodeKey(range: atom.Range, text: string): string { 134 | return ([] as any[]).concat(...range.serialize(), text).join(','); 135 | } 136 | -------------------------------------------------------------------------------- /lib/adapters/logging-console-adapter.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleApi } from 'atom-ide'; 2 | import { 3 | LanguageClientConnection, 4 | LogMessageParams, 5 | MessageType, 6 | } from '../languageclient'; 7 | 8 | /** Adapts Atom's user notifications to those of the language server protocol. */ 9 | export default class LoggingConsoleAdapter { 10 | private _consoles: Set = new Set(); 11 | 12 | /** 13 | * Create a new {LoggingConsoleAdapter} that will listen for log messages 14 | * via the supplied {LanguageClientConnection}. 15 | * 16 | * @param connection A {LanguageClientConnection} to the language server that will provide log messages. 17 | */ 18 | constructor(connection: LanguageClientConnection) { 19 | connection.onLogMessage(this.logMessage.bind(this)); 20 | } 21 | 22 | /** Dispose this adapter ensuring any resources are freed and events unhooked. */ 23 | public dispose(): void { 24 | this.detachAll(); 25 | } 26 | 27 | /** 28 | * Public: Attach this {LoggingConsoleAdapter} to a given {ConsoleApi}. 29 | * 30 | * @param console A {ConsoleApi} that wants to receive messages. 31 | */ 32 | public attach(console: ConsoleApi): void { 33 | this._consoles.add(console); 34 | } 35 | 36 | /** Public: Remove all {ConsoleApi}'s attached to this adapter. */ 37 | public detachAll(): void { 38 | this._consoles.clear(); 39 | } 40 | 41 | /** 42 | * Log a message using the Atom IDE UI Console API. 43 | * 44 | * @param params The {LogMessageParams} received from the language server 45 | * indicating the details of the message to be loggedd. 46 | */ 47 | private logMessage(params: LogMessageParams): void { 48 | switch (params.type) { 49 | case MessageType.Error: { 50 | this._consoles.forEach((c) => c.error(params.message)); 51 | return; 52 | } 53 | case MessageType.Warning: { 54 | this._consoles.forEach((c) => c.warn(params.message)); 55 | return; 56 | } 57 | case MessageType.Info: { 58 | this._consoles.forEach((c) => c.info(params.message)); 59 | return; 60 | } 61 | case MessageType.Log: { 62 | this._consoles.forEach((c) => c.log(params.message)); 63 | return; 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/adapters/notifications-adapter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LanguageClientConnection, 3 | MessageType, 4 | MessageActionItem, 5 | ShowMessageParams, 6 | ShowMessageRequestParams, 7 | } from '../languageclient'; 8 | import { 9 | Notification, 10 | NotificationOptions, 11 | NotificationExt, 12 | } from 'atom'; 13 | 14 | /** Public: Adapts Atom's user notifications to those of the language server protocol. */ 15 | export default class NotificationsAdapter { 16 | /** 17 | * Public: Attach to a {LanguageClientConnection} to recieve events indicating 18 | * when user notifications should be displayed. 19 | */ 20 | public static attach( 21 | connection: LanguageClientConnection, 22 | name: string, 23 | projectPath: string, 24 | ) { 25 | connection.onShowMessage((m) => NotificationsAdapter.onShowMessage(m, name, projectPath)); 26 | connection.onShowMessageRequest((m) => NotificationsAdapter.onShowMessageRequest(m, name, projectPath)); 27 | } 28 | 29 | /** 30 | * Public: Show a notification message with buttons using the Atom notifications API. 31 | * 32 | * @param params The {ShowMessageRequestParams} received from the language server 33 | * indicating the details of the notification to be displayed. 34 | * @param name The name of the language server so the user can identify the 35 | * context of the message. 36 | * @param projectPath The path of the current project. 37 | */ 38 | public static onShowMessageRequest( 39 | params: ShowMessageRequestParams, 40 | name: string, 41 | projectPath: string, 42 | ): Promise { 43 | return new Promise((resolve, _reject) => { 44 | const options: NotificationOptions = { 45 | dismissable: true, 46 | detail: `${name} ${projectPath}`, 47 | }; 48 | if (params.actions) { 49 | options.buttons = params.actions.map((a) => ({ 50 | text: a.title, 51 | onDidClick: () => { 52 | resolve(a); 53 | if (notification != null) { 54 | notification.dismiss(); 55 | } 56 | }, 57 | })); 58 | } 59 | 60 | const notification = addNotificationForMessage( 61 | params.type, 62 | params.message, 63 | options); 64 | 65 | if (notification != null) { 66 | notification.onDidDismiss(() => { 67 | resolve(null); 68 | }); 69 | } 70 | }); 71 | } 72 | 73 | /** 74 | * Public: Show a notification message using the Atom notifications API. 75 | * 76 | * @param params The {ShowMessageParams} received from the language server 77 | * indicating the details of the notification to be displayed. 78 | * @param name The name of the language server so the user can identify the 79 | * context of the message. 80 | * @param projectPath The path of the current project. 81 | */ 82 | public static onShowMessage( 83 | params: ShowMessageParams, 84 | name: string, 85 | projectPath: string, 86 | ): void { 87 | addNotificationForMessage(params.type, params.message, { 88 | dismissable: true, 89 | detail: `${name} ${projectPath}`, 90 | }); 91 | } 92 | 93 | /** 94 | * Public: Convert a {MessageActionItem} from the language server into an 95 | * equivalent {NotificationButton} within Atom. 96 | * 97 | * @param actionItem The {MessageActionItem} to be converted. 98 | * @returns A {NotificationButton} equivalent to the {MessageActionItem} given. 99 | */ 100 | public static actionItemToNotificationButton( 101 | actionItem: MessageActionItem, 102 | ) { 103 | return { 104 | text: actionItem.title, 105 | }; 106 | } 107 | } 108 | 109 | function messageTypeToString( 110 | messageType: number, 111 | ): string { 112 | switch (messageType) { 113 | case MessageType.Error: return 'error'; 114 | case MessageType.Warning: return 'warning'; 115 | default: return 'info'; 116 | } 117 | } 118 | 119 | function addNotificationForMessage( 120 | messageType: number, 121 | message: string, 122 | options: NotificationOptions, 123 | ): Notification | null { 124 | function isDuplicate(note: NotificationExt): boolean { 125 | const noteDismissed = note.isDismissed && note.isDismissed(); 126 | const noteOptions = note.getOptions && note.getOptions() || {}; 127 | return !noteDismissed && 128 | note.getType() === messageTypeToString(messageType) && 129 | note.getMessage() === message && 130 | noteOptions.detail === options.detail; 131 | } 132 | if (atom.notifications.getNotifications().some(isDuplicate)) { 133 | return null; 134 | } 135 | 136 | switch (messageType) { 137 | case MessageType.Error: 138 | return atom.notifications.addError(message, options); 139 | case MessageType.Warning: 140 | return atom.notifications.addWarning(message, options); 141 | case MessageType.Log: 142 | // console.log(params.message); 143 | return null; 144 | case MessageType.Info: 145 | default: 146 | return atom.notifications.addInfo(message, options); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /lib/adapters/outline-view-adapter.ts: -------------------------------------------------------------------------------- 1 | import * as atomIde from 'atom-ide'; 2 | import Convert from '../convert'; 3 | import * as Utils from '../utils'; 4 | import { CancellationTokenSource } from 'vscode-jsonrpc'; 5 | import { 6 | LanguageClientConnection, 7 | SymbolKind, 8 | ServerCapabilities, 9 | SymbolInformation, 10 | DocumentSymbol, 11 | } from '../languageclient'; 12 | import { 13 | Point, 14 | TextEditor, 15 | } from 'atom'; 16 | 17 | /** 18 | * Public: Adapts the documentSymbolProvider of the language server to the Outline View 19 | * supplied by Atom IDE UI. 20 | */ 21 | export default class OutlineViewAdapter { 22 | 23 | private _cancellationTokens: WeakMap = new WeakMap(); 24 | 25 | /** 26 | * Public: Determine whether this adapter can be used to adapt a language server 27 | * based on the serverCapabilities matrix containing a documentSymbolProvider. 28 | * 29 | * @param serverCapabilities The {ServerCapabilities} of the language server to consider. 30 | * @returns A {Boolean} indicating adapter can adapt the server based on the 31 | * given serverCapabilities. 32 | */ 33 | public static canAdapt(serverCapabilities: ServerCapabilities): boolean { 34 | return serverCapabilities.documentSymbolProvider === true; 35 | } 36 | 37 | /** 38 | * Public: Obtain the Outline for document via the {LanguageClientConnection} as identified 39 | * by the {TextEditor}. 40 | * 41 | * @param connection A {LanguageClientConnection} to the language server that will be queried 42 | * for the outline. 43 | * @param editor The Atom {TextEditor} containing the text the Outline should represent. 44 | * @returns A {Promise} containing the {Outline} of this document. 45 | */ 46 | public async getOutline(connection: LanguageClientConnection, editor: TextEditor): Promise { 47 | const results = await Utils.doWithCancellationToken(connection, this._cancellationTokens, (cancellationToken) => 48 | connection.documentSymbol({ textDocument: Convert.editorToTextDocumentIdentifier(editor) }, cancellationToken), 49 | ); 50 | 51 | if (results.length === 0) { 52 | return { 53 | outlineTrees: [], 54 | }; 55 | } 56 | 57 | if ((results[0] as DocumentSymbol).selectionRange !== undefined) { 58 | // If the server is giving back the newer DocumentSymbol format. 59 | return { 60 | outlineTrees: OutlineViewAdapter.createHierarchicalOutlineTrees( 61 | results as DocumentSymbol[]), 62 | }; 63 | } else { 64 | // If the server is giving back the original SymbolInformation format. 65 | return { 66 | outlineTrees: OutlineViewAdapter.createOutlineTrees( 67 | results as SymbolInformation[]), 68 | }; 69 | } 70 | } 71 | 72 | /** 73 | * Public: Create an {Array} of {OutlineTree}s from the Array of {DocumentSymbol} recieved 74 | * from the language server. This includes converting all the children nodes in the entire 75 | * hierarchy. 76 | * 77 | * @param symbols An {Array} of {DocumentSymbol}s received from the language server that 78 | * should be converted to an {Array} of {OutlineTree}. 79 | * @returns An {Array} of {OutlineTree} containing the given symbols that the Outline View can display. 80 | */ 81 | public static createHierarchicalOutlineTrees(symbols: DocumentSymbol[]): atomIde.OutlineTree[] { 82 | // Sort all the incoming symbols 83 | symbols.sort((a, b) => { 84 | if (a.range.start.line !== b.range.start.line) { 85 | return a.range.start.line - b.range.start.line; 86 | } 87 | 88 | if (a.range.start.character !== b.range.start.character) { 89 | return a.range.start.character - b.range.start.character; 90 | } 91 | 92 | if (a.range.end.line !== b.range.end.line) { 93 | return a.range.end.line - b.range.end.line; 94 | } 95 | 96 | return a.range.end.character - b.range.end.character; 97 | }); 98 | 99 | return symbols.map((symbol) => { 100 | const tree = OutlineViewAdapter.hierarchicalSymbolToOutline(symbol); 101 | 102 | if (symbol.children != null) { 103 | tree.children = OutlineViewAdapter.createHierarchicalOutlineTrees( 104 | symbol.children); 105 | } 106 | 107 | return tree; 108 | }); 109 | } 110 | 111 | /** 112 | * Public: Create an {Array} of {OutlineTree}s from the Array of {SymbolInformation} recieved 113 | * from the language server. This includes determining the appropriate child and parent 114 | * relationships for the hierarchy. 115 | * 116 | * @param symbols An {Array} of {SymbolInformation}s received from the language server that 117 | * should be converted to an {OutlineTree}. 118 | * @returns An {OutlineTree} containing the given symbols that the Outline View can display. 119 | */ 120 | public static createOutlineTrees(symbols: SymbolInformation[]): atomIde.OutlineTree[] { 121 | symbols.sort( 122 | (a, b) => 123 | (a.location.range.start.line === b.location.range.start.line 124 | ? a.location.range.start.character - b.location.range.start.character 125 | : a.location.range.start.line - b.location.range.start.line), 126 | ); 127 | 128 | // Temporarily keep containerName through the conversion process 129 | // Also filter out symbols without a name - it's part of the spec but some don't include it 130 | const allItems = symbols.filter((symbol) => symbol.name).map((symbol) => ({ 131 | containerName: symbol.containerName, 132 | outline: OutlineViewAdapter.symbolToOutline(symbol), 133 | })); 134 | 135 | // Create a map of containers by name with all items that have that name 136 | const containers = allItems.reduce((map, item) => { 137 | const name = item.outline.representativeName; 138 | if (name != null) { 139 | const container = map.get(name); 140 | if (container == null) { 141 | map.set(name, [item.outline]); 142 | } else { 143 | container.push(item.outline); 144 | } 145 | } 146 | return map; 147 | }, new Map()); 148 | 149 | const roots: atomIde.OutlineTree[] = []; 150 | 151 | // Put each item within its parent and extract out the roots 152 | for (const item of allItems) { 153 | const containerName = item.containerName; 154 | const child = item.outline; 155 | if (containerName == null || containerName === '') { 156 | roots.push(item.outline); 157 | } else { 158 | const possibleParents = containers.get(containerName); 159 | let closestParent = OutlineViewAdapter._getClosestParent(possibleParents, child); 160 | if (closestParent == null) { 161 | closestParent = { 162 | plainText: containerName, 163 | representativeName: containerName, 164 | startPosition: new Point(0, 0), 165 | children: [child], 166 | }; 167 | roots.push(closestParent); 168 | if (possibleParents == null) { 169 | containers.set(containerName, [closestParent]); 170 | } else { 171 | possibleParents.push(closestParent); 172 | } 173 | } else { 174 | closestParent.children.push(child); 175 | } 176 | } 177 | } 178 | 179 | return roots; 180 | } 181 | 182 | private static _getClosestParent( 183 | candidates: atomIde.OutlineTree[] | null, 184 | child: atomIde.OutlineTree, 185 | ): atomIde.OutlineTree | null { 186 | if (candidates == null || candidates.length === 0) { 187 | return null; 188 | } 189 | 190 | let parent: atomIde.OutlineTree | undefined; 191 | for (const candidate of candidates) { 192 | if ( 193 | candidate !== child && 194 | candidate.startPosition.isLessThanOrEqual(child.startPosition) && 195 | (candidate.endPosition === undefined || 196 | (child.endPosition && candidate.endPosition.isGreaterThanOrEqual(child.endPosition))) 197 | ) { 198 | if ( 199 | parent === undefined || 200 | (parent.startPosition.isLessThanOrEqual(candidate.startPosition) || 201 | (parent.endPosition != null && 202 | candidate.endPosition && 203 | parent.endPosition.isGreaterThanOrEqual(candidate.endPosition))) 204 | ) { 205 | parent = candidate; 206 | } 207 | } 208 | } 209 | 210 | return parent || null; 211 | } 212 | 213 | /** 214 | * Public: Convert an individual {DocumentSymbol} from the language server 215 | * to an {OutlineTree} for use by the Outline View. It does NOT recursively 216 | * process the given symbol's children (if any). 217 | * 218 | * @param symbol The {DocumentSymbol} to convert to an {OutlineTree}. 219 | * @returns The {OutlineTree} corresponding to the given {DocumentSymbol}. 220 | */ 221 | public static hierarchicalSymbolToOutline(symbol: DocumentSymbol): atomIde.OutlineTree { 222 | const icon = OutlineViewAdapter.symbolKindToEntityKind(symbol.kind); 223 | 224 | return { 225 | tokenizedText: [ 226 | { 227 | kind: OutlineViewAdapter.symbolKindToTokenKind(symbol.kind), 228 | value: symbol.name, 229 | }, 230 | ], 231 | icon: icon != null ? icon : undefined, 232 | representativeName: symbol.name, 233 | startPosition: Convert.positionToPoint(symbol.selectionRange.start), 234 | endPosition: Convert.positionToPoint(symbol.selectionRange.end), 235 | children: [], 236 | }; 237 | } 238 | 239 | /** 240 | * Public: Convert an individual {SymbolInformation} from the language server 241 | * to an {OutlineTree} for use by the Outline View. 242 | * 243 | * @param symbol The {SymbolInformation} to convert to an {OutlineTree}. 244 | * @returns The {OutlineTree} equivalent to the given {SymbolInformation}. 245 | */ 246 | public static symbolToOutline(symbol: SymbolInformation): atomIde.OutlineTree { 247 | const icon = OutlineViewAdapter.symbolKindToEntityKind(symbol.kind); 248 | return { 249 | tokenizedText: [ 250 | { 251 | kind: OutlineViewAdapter.symbolKindToTokenKind(symbol.kind), 252 | value: symbol.name, 253 | }, 254 | ], 255 | icon: icon != null ? icon : undefined, 256 | representativeName: symbol.name, 257 | startPosition: Convert.positionToPoint(symbol.location.range.start), 258 | endPosition: Convert.positionToPoint(symbol.location.range.end), 259 | children: [], 260 | }; 261 | } 262 | 263 | /** 264 | * Public: Convert a symbol kind into an outline entity kind used to determine 265 | * the styling such as the appropriate icon in the Outline View. 266 | * 267 | * @param symbol The numeric symbol kind received from the language server. 268 | * @returns A string representing the equivalent OutlineView entity kind. 269 | */ 270 | public static symbolKindToEntityKind(symbol: number): string | null { 271 | switch (symbol) { 272 | case SymbolKind.Array: 273 | return 'type-array'; 274 | case SymbolKind.Boolean: 275 | return 'type-boolean'; 276 | case SymbolKind.Class: 277 | return 'type-class'; 278 | case SymbolKind.Constant: 279 | return 'type-constant'; 280 | case SymbolKind.Constructor: 281 | return 'type-constructor'; 282 | case SymbolKind.Enum: 283 | return 'type-enum'; 284 | case SymbolKind.Field: 285 | return 'type-field'; 286 | case SymbolKind.File: 287 | return 'type-file'; 288 | case SymbolKind.Function: 289 | return 'type-function'; 290 | case SymbolKind.Interface: 291 | return 'type-interface'; 292 | case SymbolKind.Method: 293 | return 'type-method'; 294 | case SymbolKind.Module: 295 | return 'type-module'; 296 | case SymbolKind.Namespace: 297 | return 'type-namespace'; 298 | case SymbolKind.Number: 299 | return 'type-number'; 300 | case SymbolKind.Package: 301 | return 'type-package'; 302 | case SymbolKind.Property: 303 | return 'type-property'; 304 | case SymbolKind.String: 305 | return 'type-string'; 306 | case SymbolKind.Variable: 307 | return 'type-variable'; 308 | case SymbolKind.Struct: 309 | return 'type-class'; 310 | case SymbolKind.EnumMember: 311 | return 'type-constant'; 312 | default: 313 | return null; 314 | } 315 | } 316 | 317 | /** 318 | * Public: Convert a symbol kind to the appropriate token kind used to syntax 319 | * highlight the symbol name in the Outline View. 320 | * 321 | * @param symbol The numeric symbol kind received from the language server. 322 | * @returns A string representing the equivalent syntax token kind. 323 | */ 324 | public static symbolKindToTokenKind(symbol: number): atomIde.TokenKind { 325 | switch (symbol) { 326 | case SymbolKind.Class: 327 | return 'type'; 328 | case SymbolKind.Constructor: 329 | return 'constructor'; 330 | case SymbolKind.Method: 331 | case SymbolKind.Function: 332 | return 'method'; 333 | case SymbolKind.String: 334 | return 'string'; 335 | default: 336 | return 'plain'; 337 | } 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /lib/adapters/rename-adapter.ts: -------------------------------------------------------------------------------- 1 | import * as atomIde from 'atom-ide'; 2 | import Convert from '../convert'; 3 | import { 4 | Point, 5 | TextEditor, 6 | } from 'atom'; 7 | import { 8 | LanguageClientConnection, 9 | RenameParams, 10 | ServerCapabilities, 11 | TextDocumentEdit, 12 | TextEdit, 13 | } from '../languageclient'; 14 | 15 | export default class RenameAdapter { 16 | public static canAdapt(serverCapabilities: ServerCapabilities): boolean { 17 | return serverCapabilities.renameProvider === true; 18 | } 19 | 20 | public static async getRename( 21 | connection: LanguageClientConnection, 22 | editor: TextEditor, 23 | point: Point, 24 | newName: string, 25 | ): Promise | null> { 26 | const edit = await connection.rename( 27 | RenameAdapter.createRenameParams(editor, point, newName), 28 | ); 29 | if (edit === null) { 30 | return null; 31 | } 32 | 33 | if (edit.documentChanges) { 34 | return RenameAdapter.convertDocumentChanges(edit.documentChanges); 35 | } else if (edit.changes) { 36 | return RenameAdapter.convertChanges(edit.changes); 37 | } else { 38 | return null; 39 | } 40 | } 41 | 42 | public static createRenameParams(editor: TextEditor, point: Point, newName: string): RenameParams { 43 | return { 44 | textDocument: Convert.editorToTextDocumentIdentifier(editor), 45 | position: Convert.pointToPosition(point), 46 | newName, 47 | }; 48 | } 49 | 50 | public static convertChanges( 51 | changes: { [uri: string]: TextEdit[] }, 52 | ): Map { 53 | const result = new Map(); 54 | Object.keys(changes).forEach((uri) => { 55 | result.set( 56 | Convert.uriToPath(uri), 57 | Convert.convertLsTextEdits(changes[uri]), 58 | ); 59 | }); 60 | return result; 61 | } 62 | 63 | public static convertDocumentChanges( 64 | documentChanges: TextDocumentEdit[], 65 | ): Map { 66 | const result = new Map(); 67 | documentChanges.forEach((documentEdit) => { 68 | result.set( 69 | Convert.uriToPath(documentEdit.textDocument.uri), 70 | Convert.convertLsTextEdits(documentEdit.edits), 71 | ); 72 | }); 73 | return result; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/adapters/signature-help-adapter.ts: -------------------------------------------------------------------------------- 1 | import * as atomIde from 'atom-ide'; 2 | import assert = require('assert'); 3 | import Convert from '../convert'; 4 | import { ActiveServer } from '../server-manager'; 5 | import { 6 | CompositeDisposable, 7 | Point, 8 | TextEditor, 9 | } from 'atom'; 10 | import { 11 | LanguageClientConnection, 12 | ServerCapabilities, 13 | SignatureHelp, 14 | } from '../languageclient'; 15 | 16 | export default class SignatureHelpAdapter { 17 | private _disposables: CompositeDisposable = new CompositeDisposable(); 18 | private _connection: LanguageClientConnection; 19 | private _capabilities: ServerCapabilities; 20 | private _grammarScopes: string[]; 21 | 22 | constructor(server: ActiveServer, grammarScopes: string[]) { 23 | this._connection = server.connection; 24 | this._capabilities = server.capabilities; 25 | this._grammarScopes = grammarScopes; 26 | } 27 | 28 | /** 29 | * @returns A {Boolean} indicating this adapter can adapt the server based on the 30 | * given serverCapabilities. 31 | */ 32 | public static canAdapt(serverCapabilities: ServerCapabilities): boolean { 33 | return serverCapabilities.signatureHelpProvider != null; 34 | } 35 | 36 | public dispose() { 37 | this._disposables.dispose(); 38 | } 39 | 40 | public attach(register: atomIde.SignatureHelpRegistry): void { 41 | const { signatureHelpProvider } = this._capabilities; 42 | assert(signatureHelpProvider != null); 43 | 44 | let triggerCharacters: Set | undefined; 45 | if (signatureHelpProvider && Array.isArray(signatureHelpProvider.triggerCharacters)) { 46 | triggerCharacters = new Set(signatureHelpProvider.triggerCharacters); 47 | } 48 | 49 | this._disposables.add( 50 | register({ 51 | priority: 1, 52 | grammarScopes: this._grammarScopes, 53 | triggerCharacters, 54 | getSignatureHelp: this.getSignatureHelp.bind(this), 55 | }), 56 | ); 57 | } 58 | 59 | /** Public: Retrieves signature help for a given editor and position. */ 60 | public getSignatureHelp(editor: TextEditor, point: Point): Promise { 61 | return this._connection.signatureHelp(Convert.editorToTextDocumentPositionParams(editor, point)); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/convert.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as ls from './languageclient'; 3 | import * as URL from 'url'; 4 | import { 5 | Point, 6 | FilesystemChange, 7 | Range, 8 | TextEditor, 9 | } from 'atom'; 10 | import { 11 | Diagnostic, 12 | DiagnosticType, 13 | TextEdit, 14 | } from 'atom-ide'; 15 | 16 | /** 17 | * Public: Class that contains a number of helper methods for general conversions 18 | * between the language server protocol and Atom/Atom packages. 19 | */ 20 | export default class Convert { 21 | /** 22 | * Public: Convert a path to a Uri. 23 | * 24 | * @param filePath A file path to convert to a Uri. 25 | * @returns The Uri corresponding to the path. e.g. file:///a/b/c.txt 26 | */ 27 | public static pathToUri(filePath: string): string { 28 | let newPath = filePath.replace(/\\/g, '/'); 29 | if (newPath[0] !== '/') { 30 | newPath = `/${newPath}`; 31 | } 32 | return encodeURI(`file://${newPath}`).replace(/[?#]/g, encodeURIComponent); 33 | } 34 | 35 | /** 36 | * Public: Convert a Uri to a path. 37 | * 38 | * @param uri A Uri to convert to a file path. 39 | * @returns A file path corresponding to the Uri. e.g. /a/b/c.txt 40 | * If the Uri does not begin file: then it is returned as-is to allow Atom 41 | * to deal with http/https sources in the future. 42 | */ 43 | public static uriToPath(uri: string): string { 44 | const url = URL.parse(uri); 45 | if (url.protocol !== 'file:' || url.path === undefined) { 46 | return uri; 47 | } 48 | 49 | let filePath = decodeURIComponent(url.path); 50 | if (process.platform === 'win32') { 51 | // Deal with Windows drive names 52 | if (filePath[0] === '/') { 53 | filePath = filePath.substr(1); 54 | } 55 | return filePath.replace(/\//g, '\\'); 56 | } 57 | return filePath; 58 | } 59 | 60 | /** 61 | * Public: Convert an Atom {Point} to a language server {Position}. 62 | * 63 | * @param point An Atom {Point} to convert from. 64 | * @returns The {Position} representation of the Atom {PointObject}. 65 | */ 66 | public static pointToPosition(point: Point): ls.Position { 67 | return { line: point.row, character: point.column }; 68 | } 69 | 70 | /** 71 | * Public: Convert a language server {Position} into an Atom {PointObject}. 72 | * 73 | * @param position A language server {Position} to convert from. 74 | * @returns The Atom {PointObject} representation of the given {Position}. 75 | */ 76 | public static positionToPoint(position: ls.Position): Point { 77 | return new Point(position.line, position.character); 78 | } 79 | 80 | /** 81 | * Public: Convert a language server {Range} into an Atom {Range}. 82 | * 83 | * @param range A language server {Range} to convert from. 84 | * @returns The Atom {Range} representation of the given language server {Range}. 85 | */ 86 | public static lsRangeToAtomRange(range: ls.Range): Range { 87 | return new Range(Convert.positionToPoint(range.start), Convert.positionToPoint(range.end)); 88 | } 89 | 90 | /** 91 | * Public: Convert an Atom {Range} into an language server {Range}. 92 | * 93 | * @param range An Atom {Range} to convert from. 94 | * @returns The language server {Range} representation of the given Atom {Range}. 95 | */ 96 | public static atomRangeToLSRange(range: Range): ls.Range { 97 | return { 98 | start: Convert.pointToPosition(range.start), 99 | end: Convert.pointToPosition(range.end), 100 | }; 101 | } 102 | 103 | /** 104 | * Public: Create a {TextDocumentIdentifier} from an Atom {TextEditor}. 105 | * 106 | * @param editor A {TextEditor} that will be used to form the uri property. 107 | * @returns A {TextDocumentIdentifier} that has a `uri` property with the Uri for the 108 | * given editor's path. 109 | */ 110 | public static editorToTextDocumentIdentifier(editor: TextEditor): ls.TextDocumentIdentifier { 111 | return { uri: Convert.pathToUri(editor.getPath() || '') }; 112 | } 113 | 114 | /** 115 | * Public: Create a {TextDocumentPositionParams} from a {TextEditor} and optional {Point}. 116 | * 117 | * @param editor A {TextEditor} that will be used to form the uri property. 118 | * @param point An optional {Point} that will supply the position property. If not specified 119 | * the current cursor position will be used. 120 | * @returns A {TextDocumentPositionParams} that has textDocument property with the editors {TextDocumentIdentifier} 121 | * and a position property with the supplied point (or current cursor position when not specified). 122 | */ 123 | public static editorToTextDocumentPositionParams( 124 | editor: TextEditor, 125 | point?: Point, 126 | ): ls.TextDocumentPositionParams { 127 | return { 128 | textDocument: Convert.editorToTextDocumentIdentifier(editor), 129 | position: Convert.pointToPosition(point != null ? point : editor.getCursorBufferPosition()), 130 | }; 131 | } 132 | 133 | /** 134 | * Public: Create a string of scopes for the atom text editor using the data-grammar 135 | * selector from an {Array} of grammarScope strings. 136 | * 137 | * @param grammarScopes An {Array} of grammar scope string to convert from. 138 | * @returns A single comma-separated list of CSS selectors targetting the grammars of Atom text editors. 139 | * e.g. `['c', 'cpp']` => 140 | * `'atom-text-editor[data-grammar='c'], atom-text-editor[data-grammar='cpp']` 141 | */ 142 | public static grammarScopesToTextEditorScopes(grammarScopes: string[]): string { 143 | return grammarScopes 144 | .map((g) => `atom-text-editor[data-grammar="${Convert.encodeHTMLAttribute(g.replace(/\./g, ' '))}"]`) 145 | .join(', '); 146 | } 147 | 148 | /** 149 | * Public: Encode a string so that it can be safely used within a HTML attribute - i.e. replacing all 150 | * quoted values with their HTML entity encoded versions. e.g. `Hello"` becomes `Hello"` 151 | * 152 | * @param s A string to be encoded. 153 | * @returns A string that is HTML attribute encoded by replacing &, <, >, " and ' with their HTML entity 154 | * named equivalents. 155 | */ 156 | public static encodeHTMLAttribute(s: string): string { 157 | const attributeMap: { [key: string]: string } = { 158 | '&': '&', 159 | '<': '<', 160 | '>': '>', 161 | '"': '"', 162 | "'": ''', 163 | }; 164 | return s.replace(/[&<>'"]/g, (c) => attributeMap[c]); 165 | } 166 | 167 | /** 168 | * Public: Convert an Atom File Event as received from atom.project.onDidChangeFiles and convert 169 | * it into an Array of Language Server Protocol {FileEvent} objects. Normally this will be a 1-to-1 170 | * but renames will be represented by a deletion and a subsequent creation as LSP does not know about 171 | * renames. 172 | * 173 | * @param fileEvent An {atom$ProjectFileEvent} to be converted. 174 | * @returns An array of LSP {ls.FileEvent} objects that equivalent conversions to the fileEvent parameter. 175 | */ 176 | public static atomFileEventToLSFileEvents(fileEvent: FilesystemChange): ls.FileEvent[] { 177 | switch (fileEvent.action) { 178 | case 'created': 179 | return [{ uri: Convert.pathToUri(fileEvent.path), type: ls.FileChangeType.Created }]; 180 | case 'modified': 181 | return [{ uri: Convert.pathToUri(fileEvent.path), type: ls.FileChangeType.Changed }]; 182 | case 'deleted': 183 | return [{ uri: Convert.pathToUri(fileEvent.path), type: ls.FileChangeType.Deleted }]; 184 | case 'renamed': { 185 | const results: Array<{ uri: string, type: ls.FileChangeType }> = []; 186 | if (fileEvent.oldPath) { 187 | results.push({ uri: Convert.pathToUri(fileEvent.oldPath), type: ls.FileChangeType.Deleted }); 188 | } 189 | if (fileEvent.path) { 190 | results.push({ uri: Convert.pathToUri(fileEvent.path), type: ls.FileChangeType.Created }); 191 | } 192 | return results; 193 | } 194 | default: 195 | return []; 196 | } 197 | } 198 | 199 | public static atomIdeDiagnosticToLSDiagnostic(diagnostic: Diagnostic): ls.Diagnostic { 200 | return { 201 | range: Convert.atomRangeToLSRange(diagnostic.range), 202 | severity: Convert.diagnosticTypeToLSSeverity(diagnostic.type), 203 | source: diagnostic.providerName, 204 | message: diagnostic.text || '', 205 | }; 206 | } 207 | 208 | public static diagnosticTypeToLSSeverity(type: DiagnosticType): ls.DiagnosticSeverity { 209 | switch (type) { 210 | case 'Error': 211 | return ls.DiagnosticSeverity.Error; 212 | case 'Warning': 213 | return ls.DiagnosticSeverity.Warning; 214 | case 'Info': 215 | return ls.DiagnosticSeverity.Information; 216 | default: 217 | throw Error(`Unexpected diagnostic type ${type}`); 218 | } 219 | } 220 | 221 | /** 222 | * Public: Convert an array of language server protocol {TextEdit} objects to an 223 | * equivalent array of Atom {TextEdit} objects. 224 | * 225 | * @param textEdits The language server protocol {TextEdit} objects to convert. 226 | * @returns An {Array} of Atom {TextEdit} objects. 227 | */ 228 | public static convertLsTextEdits(textEdits: ls.TextEdit[] | null): TextEdit[] { 229 | return (textEdits || []).map(Convert.convertLsTextEdit); 230 | } 231 | 232 | /** 233 | * Public: Convert a language server protocol {TextEdit} object to the 234 | * Atom equivalent {TextEdit}. 235 | * 236 | * @param textEdits The language server protocol {TextEdit} objects to convert. 237 | * @returns An Atom {TextEdit} object. 238 | */ 239 | public static convertLsTextEdit(textEdit: ls.TextEdit): TextEdit { 240 | return { 241 | oldRange: Convert.lsRangeToAtomRange(textEdit.range), 242 | newText: textEdit.newText, 243 | }; 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /lib/download-file.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | /** 4 | * Public: Download a file and store it on a file system using streaming with appropriate progress callback. 5 | * 6 | * @param sourceUrl Url to download from. 7 | * @param targetFile File path to save to. 8 | * @param progressCallback Callback function that will be given a {ByteProgressCallback} object containing 9 | * both bytesDone and percent. 10 | * @param length File length in bytes if you want percentage progress indication and the server is 11 | * unable to provide a Content-Length header and whitelist CORS access via a 12 | * `Access-Control-Expose-Headers "content-length"` header. 13 | * @returns A {Promise} that will accept when complete. 14 | */ 15 | export default (async function downloadFile( 16 | sourceUrl: string, 17 | targetFile: string, 18 | progressCallback?: ByteProgressCallback, 19 | length?: number, 20 | ): Promise { 21 | const request = new Request(sourceUrl, { 22 | headers: new Headers({ 'Content-Type': 'application/octet-stream' }), 23 | }); 24 | 25 | const response = await fetch(request); 26 | if (!response.ok) { 27 | throw Error(`Unable to download, server returned ${response.status} ${response.statusText}`); 28 | } 29 | 30 | const body = response.body; 31 | if (body == null) { 32 | throw Error('No response body'); 33 | } 34 | 35 | const finalLength = length || parseInt(response.headers.get('Content-Length') || '0', 10); 36 | const reader = body.getReader(); 37 | const writer = fs.createWriteStream(targetFile); 38 | 39 | await streamWithProgress(finalLength, reader, writer, progressCallback); 40 | writer.end(); 41 | }); 42 | 43 | /** 44 | * Stream from a {ReadableStreamReader} to a {WriteStream} with progress callback. 45 | * 46 | * @param length File length in bytes. 47 | * @param reader A {ReadableStreamReader} to read from. 48 | * @param writer A {WriteStream} to write to. 49 | * @param progressCallback Callback function that will be given a {ByteProgressCallback} object containing 50 | * both bytesDone and percent. 51 | * @returns A {Promise} that will accept when complete. 52 | */ 53 | async function streamWithProgress( 54 | length: number, 55 | reader: ReadableStreamReader, 56 | writer: fs.WriteStream, 57 | progressCallback?: ByteProgressCallback, 58 | ): Promise { 59 | let bytesDone = 0; 60 | 61 | while (true) { 62 | const result = await reader.read(); 63 | if (result.done) { 64 | if (progressCallback != null) { 65 | progressCallback(length, 100); 66 | } 67 | return; 68 | } 69 | 70 | const chunk = result.value; 71 | if (chunk == null) { 72 | throw Error('Empty chunk received during download'); 73 | } else { 74 | writer.write(Buffer.from(chunk)); 75 | if (progressCallback != null) { 76 | bytesDone += chunk.byteLength; 77 | const percent: number | undefined = length === 0 ? undefined : Math.floor(bytesDone / length * 100); 78 | progressCallback(bytesDone, percent); 79 | } 80 | } 81 | } 82 | } 83 | 84 | /** 85 | * Public: Progress callback function signature indicating the bytesDone and 86 | * optional percentage when length is known. 87 | */ 88 | export type ByteProgressCallback = (bytesDone: number, percent?: number) => void; 89 | -------------------------------------------------------------------------------- /lib/logger.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-console 2 | 3 | export interface Logger { 4 | warn(...args: any[]): void; 5 | error(...args: any[]): void; 6 | info(...args: any[]): void; 7 | log(...args: any[]): void; 8 | debug(...args: any[]): void; 9 | } 10 | 11 | export class ConsoleLogger { 12 | public prefix: string; 13 | 14 | constructor(prefix: string) { 15 | this.prefix = prefix; 16 | } 17 | 18 | public warn(...args: any[]): void { 19 | console.warn(...this.format(args)); 20 | } 21 | 22 | public error(...args: any[]): void { 23 | console.error(...this.format(args)); 24 | } 25 | 26 | public info(...args: any[]): void { 27 | console.info(...this.format(args)); 28 | } 29 | 30 | public debug(...args: any[]): void { 31 | console.debug(...this.format(args)); 32 | } 33 | 34 | public log(...args: any[]): void { 35 | console.log(...this.format(args)); 36 | } 37 | 38 | public format(args_: any): any { 39 | const args = args_.filter((a: any) => a != null); 40 | if (typeof args[0] === 'string') { 41 | if (args.length === 1) { 42 | return [`${this.prefix} ${args[0]}`]; 43 | } else if (args.length === 2) { 44 | return [`${this.prefix} ${args[0]}`, args[1]]; 45 | } else { 46 | return [`${this.prefix} ${args[0]}`, args.slice(1)]; 47 | } 48 | } 49 | 50 | return [`${this.prefix}`, args]; 51 | } 52 | } 53 | 54 | export class NullLogger { 55 | public warn(..._args: any[]): void { } 56 | public error(..._args: any[]): void { } 57 | public info(..._args: any[]): void { } 58 | public log(..._args: any[]): void { } 59 | public debug(..._args: any[]): void { } 60 | } 61 | 62 | export class FilteredLogger { 63 | private _logger: Logger; 64 | private _predicate: (level: string, args: any[]) => boolean; 65 | 66 | public static UserLevelFilter = (level: string, _args: any[]) => level === 'warn' || level === 'error'; 67 | public static DeveloperLevelFilter = (_level: string, _args: any[]) => true; 68 | 69 | constructor(logger: Logger, predicate?: (level: string, args: any[]) => boolean) { 70 | this._logger = logger; 71 | this._predicate = predicate || ((_level, _args) => true); 72 | } 73 | 74 | public warn(...args: any[]): void { 75 | if (this._predicate('warn', args)) { 76 | this._logger.warn(...args); 77 | } 78 | } 79 | 80 | public error(...args: any[]): void { 81 | if (this._predicate('error', args)) { 82 | this._logger.error(...args); 83 | } 84 | } 85 | 86 | public info(...args: any[]): void { 87 | if (this._predicate('info', args)) { 88 | this._logger.info(...args); 89 | } 90 | } 91 | 92 | public debug(...args: any[]): void { 93 | if (this._predicate('debug', args)) { 94 | this._logger.debug(...args); 95 | } 96 | } 97 | 98 | public log(...args: any[]): void { 99 | if (this._predicate('log', args)) { 100 | this._logger.log(...args); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /lib/main.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-reference 2 | /// 3 | /// 4 | // tslint:enable:no-reference 5 | 6 | import AutoLanguageClient from './auto-languageclient'; 7 | import Convert from './convert'; 8 | import { Logger, ConsoleLogger, FilteredLogger } from './logger'; 9 | import DownloadFile from './download-file'; 10 | import LinterPushV2Adapter from './adapters/linter-push-v2-adapter'; 11 | 12 | export * from './auto-languageclient'; 13 | export { 14 | AutoLanguageClient, 15 | Convert, 16 | Logger, 17 | ConsoleLogger, 18 | FilteredLogger, 19 | DownloadFile, 20 | LinterPushV2Adapter, 21 | }; 22 | -------------------------------------------------------------------------------- /lib/server-manager.ts: -------------------------------------------------------------------------------- 1 | import Convert from './convert'; 2 | import * as path from 'path'; 3 | import * as stream from 'stream'; 4 | import * as ls from './languageclient'; 5 | import { EventEmitter } from 'events'; 6 | import { Logger } from './logger'; 7 | import { 8 | CompositeDisposable, 9 | FilesystemChangeEvent, 10 | TextEditor, 11 | } from 'atom'; 12 | import { ReportBusyWhile } from './utils'; 13 | 14 | /** 15 | * Public: Defines the minimum surface area for an object that resembles a 16 | * ChildProcess. This is used so that language packages with alternative 17 | * language server process hosting strategies can return something compatible 18 | * with AutoLanguageClient.startServerProcess. 19 | */ 20 | export interface LanguageServerProcess extends EventEmitter { 21 | stdin: stream.Writable; 22 | stdout: stream.Readable; 23 | stderr: stream.Readable; 24 | pid: number; 25 | 26 | kill(signal?: string): void; 27 | on(event: 'error', listener: (err: Error) => void): this; 28 | on(event: 'exit', listener: (code: number, signal: string) => void): this; 29 | } 30 | 31 | /** The necessary elements for a server that has started or is starting. */ 32 | export interface ActiveServer { 33 | disposable: CompositeDisposable; 34 | projectPath: string; 35 | process: LanguageServerProcess; 36 | connection: ls.LanguageClientConnection; 37 | capabilities: ls.ServerCapabilities; 38 | } 39 | 40 | interface RestartCounter { 41 | restarts: number; 42 | timerId: NodeJS.Timer; 43 | } 44 | 45 | /** 46 | * Manages the language server lifecycles and their associated objects necessary 47 | * for adapting them to Atom IDE. 48 | */ 49 | export class ServerManager { 50 | private _activeServers: ActiveServer[] = []; 51 | private _startingServerPromises: Map> = new Map(); 52 | private _restartCounterPerProject: Map = new Map(); 53 | private _stoppingServers: ActiveServer[] = []; 54 | private _disposable: CompositeDisposable = new CompositeDisposable(); 55 | private _editorToServer: Map = new Map(); 56 | private _normalizedProjectPaths: string[] = []; 57 | private _isStarted = false; 58 | 59 | constructor( 60 | private _startServer: (projectPath: string) => Promise, 61 | private _logger: Logger, 62 | private _startForEditor: (editor: TextEditor) => boolean, 63 | private _changeWatchedFileFilter: (filePath: string) => boolean, 64 | private _reportBusyWhile: ReportBusyWhile, 65 | private _languageServerName: string, 66 | ) { 67 | this.updateNormalizedProjectPaths(); 68 | } 69 | 70 | public startListening(): void { 71 | if (!this._isStarted) { 72 | this._disposable = new CompositeDisposable(); 73 | this._disposable.add(atom.textEditors.observe(this.observeTextEditors.bind(this))); 74 | this._disposable.add(atom.project.onDidChangePaths(this.projectPathsChanged.bind(this))); 75 | if (atom.project.onDidChangeFiles) { 76 | this._disposable.add(atom.project.onDidChangeFiles(this.projectFilesChanged.bind(this))); 77 | } 78 | } 79 | } 80 | 81 | public stopListening(): void { 82 | if (this._isStarted) { 83 | this._disposable.dispose(); 84 | this._isStarted = false; 85 | } 86 | } 87 | 88 | private observeTextEditors(editor: TextEditor): void { 89 | // Track grammar changes for opened editors 90 | const listener = editor.observeGrammar((_grammar) => this._handleGrammarChange(editor)); 91 | this._disposable.add(editor.onDidDestroy(() => listener.dispose())); 92 | // Try to see if editor can have LS connected to it 93 | this._handleTextEditor(editor); 94 | } 95 | 96 | private async _handleTextEditor(editor: TextEditor): Promise { 97 | if (!this._editorToServer.has(editor)) { 98 | // editor hasn't been processed yet, so process it by allocating LS for it if necessary 99 | const server = await this.getServer(editor, { shouldStart: true }); 100 | if (server != null) { 101 | // There LS for the editor (either started now and already running) 102 | this._editorToServer.set(editor, server); 103 | this._disposable.add( 104 | editor.onDidDestroy(() => { 105 | this._editorToServer.delete(editor); 106 | this.stopUnusedServers(); 107 | }), 108 | ); 109 | } 110 | } 111 | } 112 | 113 | private _handleGrammarChange(editor: TextEditor) { 114 | if (this._startForEditor(editor)) { 115 | // If editor is interesting for LS process the editor further to attempt to start LS if needed 116 | this._handleTextEditor(editor); 117 | } else { 118 | // Editor is not supported by the LS 119 | const server = this._editorToServer.get(editor); 120 | // If LS is running for the unsupported editor then disconnect the editor from LS and shut down LS if necessary 121 | if (server) { 122 | // Remove editor from the cache 123 | this._editorToServer.delete(editor); 124 | // Shut down LS if it's used by any other editor 125 | this.stopUnusedServers(); 126 | } 127 | } 128 | } 129 | 130 | public getActiveServers(): ActiveServer[] { 131 | return this._activeServers.slice(); 132 | } 133 | 134 | public async getServer( 135 | textEditor: TextEditor, 136 | { shouldStart }: { shouldStart?: boolean } = { shouldStart: false }, 137 | ): Promise { 138 | const finalProjectPath = this.determineProjectPath(textEditor); 139 | if (finalProjectPath == null) { 140 | // Files not yet saved have no path 141 | return null; 142 | } 143 | 144 | const foundActiveServer = this._activeServers.find((s) => finalProjectPath === s.projectPath); 145 | if (foundActiveServer) { 146 | return foundActiveServer; 147 | } 148 | 149 | const startingPromise = this._startingServerPromises.get(finalProjectPath); 150 | if (startingPromise) { 151 | return startingPromise; 152 | } 153 | 154 | return shouldStart && this._startForEditor(textEditor) ? await this.startServer(finalProjectPath) : null; 155 | } 156 | 157 | public async startServer(projectPath: string): Promise { 158 | this._logger.debug(`Server starting "${projectPath}"`); 159 | const startingPromise = this._startServer(projectPath); 160 | this._startingServerPromises.set(projectPath, startingPromise); 161 | try { 162 | const startedActiveServer = await startingPromise; 163 | this._activeServers.push(startedActiveServer); 164 | this._startingServerPromises.delete(projectPath); 165 | this._logger.debug(`Server started "${projectPath}" (pid ${startedActiveServer.process.pid})`); 166 | return startedActiveServer; 167 | } catch (e) { 168 | this._startingServerPromises.delete(projectPath); 169 | throw e; 170 | } 171 | } 172 | 173 | public async stopUnusedServers(): Promise { 174 | const usedServers = new Set(this._editorToServer.values()); 175 | const unusedServers = this._activeServers.filter((s) => !usedServers.has(s)); 176 | if (unusedServers.length > 0) { 177 | this._logger.debug(`Stopping ${unusedServers.length} unused servers`); 178 | await Promise.all(unusedServers.map((s) => this.stopServer(s))); 179 | } 180 | } 181 | 182 | public async stopAllServers(): Promise { 183 | for (const [projectPath, restartCounter] of this._restartCounterPerProject) { 184 | clearTimeout(restartCounter.timerId); 185 | this._restartCounterPerProject.delete(projectPath); 186 | } 187 | 188 | await Promise.all(this._activeServers.map((s) => this.stopServer(s))); 189 | } 190 | 191 | public async restartAllServers(): Promise { 192 | this.stopListening(); 193 | await this.stopAllServers(); 194 | this._editorToServer = new Map(); 195 | this.startListening(); 196 | } 197 | 198 | public hasServerReachedRestartLimit(server: ActiveServer) { 199 | let restartCounter = this._restartCounterPerProject.get(server.projectPath); 200 | 201 | if (!restartCounter) { 202 | restartCounter = { 203 | restarts: 0, 204 | timerId: setTimeout(() => { 205 | this._restartCounterPerProject.delete(server.projectPath); 206 | }, 3 * 60 * 1000 /* 3 minutes */), 207 | }; 208 | 209 | this._restartCounterPerProject.set(server.projectPath, restartCounter); 210 | } 211 | 212 | return ++restartCounter.restarts > 5; 213 | } 214 | 215 | public async stopServer(server: ActiveServer): Promise { 216 | await this._reportBusyWhile( 217 | `Stopping ${this._languageServerName} for ${path.basename(server.projectPath)}`, 218 | async () => { 219 | this._logger.debug(`Server stopping "${server.projectPath}"`); 220 | // Immediately remove the server to prevent further usage. 221 | // If we re-open the file after this point, we'll get a new server. 222 | this._activeServers.splice(this._activeServers.indexOf(server), 1); 223 | this._stoppingServers.push(server); 224 | server.disposable.dispose(); 225 | if (server.connection.isConnected) { 226 | await server.connection.shutdown(); 227 | } 228 | 229 | for (const [editor, mappedServer] of this._editorToServer) { 230 | if (mappedServer === server) { 231 | this._editorToServer.delete(editor); 232 | } 233 | } 234 | 235 | this.exitServer(server); 236 | this._stoppingServers.splice(this._stoppingServers.indexOf(server), 1); 237 | }, 238 | ); 239 | } 240 | 241 | public exitServer(server: ActiveServer): void { 242 | const pid = server.process.pid; 243 | try { 244 | if (server.connection.isConnected) { 245 | server.connection.exit(); 246 | server.connection.dispose(); 247 | } 248 | } finally { 249 | server.process.kill(); 250 | } 251 | this._logger.debug(`Server stopped "${server.projectPath}" (pid ${pid})`); 252 | } 253 | 254 | public terminate(): void { 255 | this._stoppingServers.forEach((server) => { 256 | this._logger.debug(`Server terminating "${server.projectPath}"`); 257 | this.exitServer(server); 258 | }); 259 | } 260 | 261 | public determineProjectPath(textEditor: TextEditor): string | null { 262 | const filePath = textEditor.getPath(); 263 | if (filePath == null) { 264 | return null; 265 | } 266 | return this._normalizedProjectPaths.find((d) => filePath.startsWith(d)) || null; 267 | } 268 | 269 | public updateNormalizedProjectPaths(): void { 270 | this._normalizedProjectPaths = atom.project.getDirectories().map((d) => this.normalizePath(d.getPath())); 271 | } 272 | 273 | public normalizePath(projectPath: string): string { 274 | return !projectPath.endsWith(path.sep) ? path.join(projectPath, path.sep) : projectPath; 275 | } 276 | 277 | public async projectPathsChanged(projectPaths: string[]): Promise { 278 | const pathsSet = new Set(projectPaths.map(this.normalizePath)); 279 | const serversToStop = this._activeServers.filter((s) => !pathsSet.has(s.projectPath)); 280 | await Promise.all(serversToStop.map((s) => this.stopServer(s))); 281 | this.updateNormalizedProjectPaths(); 282 | } 283 | 284 | public projectFilesChanged(fileEvents: FilesystemChangeEvent): void { 285 | if (this._activeServers.length === 0) { 286 | return; 287 | } 288 | 289 | for (const activeServer of this._activeServers) { 290 | const changes: ls.FileEvent[] = []; 291 | for (const fileEvent of fileEvents) { 292 | if (fileEvent.path.startsWith(activeServer.projectPath) && this._changeWatchedFileFilter(fileEvent.path)) { 293 | changes.push(Convert.atomFileEventToLSFileEvents(fileEvent)[0]); 294 | } 295 | if ( 296 | fileEvent.action === 'renamed' && 297 | fileEvent.oldPath.startsWith(activeServer.projectPath) && 298 | this._changeWatchedFileFilter(fileEvent.oldPath) 299 | ) { 300 | changes.push(Convert.atomFileEventToLSFileEvents(fileEvent)[1]); 301 | } 302 | } 303 | if (changes.length > 0) { 304 | activeServer.connection.didChangeWatchedFiles({ changes }); 305 | } 306 | } 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Point, 3 | TextBuffer, 4 | TextEditor, 5 | Range, 6 | BufferScanResult, 7 | } from 'atom'; 8 | import { 9 | CancellationToken, 10 | CancellationTokenSource, 11 | } from 'vscode-jsonrpc'; 12 | 13 | export type ReportBusyWhile = ( 14 | title: string, 15 | f: () => Promise, 16 | ) => Promise; 17 | 18 | /** 19 | * Obtain the range of the word at the given editor position. 20 | * Uses the non-word characters from the position's grammar scope. 21 | */ 22 | export function getWordAtPosition(editor: TextEditor, position: Point): Range { 23 | const nonWordCharacters = escapeRegExp(editor.getNonWordCharacters(position)); 24 | const range = _getRegexpRangeAtPosition( 25 | editor.getBuffer(), 26 | position, 27 | new RegExp(`^[\t ]*$|[^\\s${nonWordCharacters}]+`, 'g'), 28 | ); 29 | if (range == null) { 30 | return new Range(position, position); 31 | } 32 | return range; 33 | } 34 | 35 | export function escapeRegExp(string: string): string { 36 | // From atom/underscore-plus. 37 | return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); 38 | } 39 | 40 | function _getRegexpRangeAtPosition(buffer: TextBuffer, position: Point, wordRegex: RegExp): Range | null { 41 | const { row, column } = position; 42 | const rowRange = buffer.rangeForRow(row, false); 43 | let matchData: BufferScanResult | undefined | null; 44 | // Extract the expression from the row text. 45 | buffer.scanInRange(wordRegex, rowRange, (data) => { 46 | const { range } = data; 47 | if ( 48 | position.isGreaterThanOrEqual(range.start) && 49 | // Range endpoints are exclusive. 50 | position.isLessThan(range.end) 51 | ) { 52 | matchData = data; 53 | data.stop(); 54 | return; 55 | } 56 | // Stop the scan if the scanner has passed our position. 57 | if (range.end.column > column) { 58 | data.stop(); 59 | } 60 | }); 61 | return matchData == null ? null : matchData.range; 62 | } 63 | 64 | /** 65 | * For the given connection and cancellationTokens map, cancel the existing 66 | * CancellationToken for that connection then create and store a new 67 | * CancellationToken to be used for the current request. 68 | */ 69 | export function cancelAndRefreshCancellationToken( 70 | key: T, 71 | cancellationTokens: WeakMap): CancellationToken { 72 | 73 | let cancellationToken = cancellationTokens.get(key); 74 | if (cancellationToken !== undefined && !cancellationToken.token.isCancellationRequested) { 75 | cancellationToken.cancel(); 76 | } 77 | 78 | cancellationToken = new CancellationTokenSource(); 79 | cancellationTokens.set(key, cancellationToken); 80 | return cancellationToken.token; 81 | } 82 | 83 | export async function doWithCancellationToken( 84 | key: T1, 85 | cancellationTokens: WeakMap, 86 | work: (token: CancellationToken) => Promise, 87 | ): Promise { 88 | const token = cancelAndRefreshCancellationToken(key, cancellationTokens); 89 | const result: T2 = await work(token); 90 | cancellationTokens.delete(key); 91 | return result; 92 | } 93 | 94 | export function assertUnreachable(_: never): never { 95 | return _; 96 | } 97 | 98 | export function promiseWithTimeout(ms: number, promise: Promise): Promise { 99 | return new Promise((resolve, reject) => { 100 | // create a timeout to reject promise if not resolved 101 | const timer = setTimeout(() => { 102 | reject(new Error(`Timeout after ${ms}ms`)); 103 | }, ms); 104 | 105 | promise.then((res) => { 106 | clearTimeout(timer); 107 | resolve(res); 108 | }).catch((err) => { 109 | clearTimeout(timer); 110 | reject(err); 111 | }); 112 | }); 113 | } 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atom-languageclient", 3 | "version": "0.9.9", 4 | "description": "Integrate Language Servers with Atom", 5 | "repository": "https://github.com/atom/atom-languageclient", 6 | "license": "MIT", 7 | "main": "./build/lib/main", 8 | "types": "./build/lib/main.d.ts", 9 | "scripts": { 10 | "clean": "rm -rf build", 11 | "compile": "tsc", 12 | "watch": "tsc -watch", 13 | "lint": "tslint -c tslint.json 'lib/**/*.ts' 'test/**/*.ts' 'typings/**/*.d.ts'", 14 | "prepare": "npm run clean && npm run compile", 15 | "test": "npm run compile && npm run lint && atom --test build/test" 16 | }, 17 | "atomTestRunner": "./test/runner", 18 | "dependencies": { 19 | "fuzzaldrin-plus": "^0.6.0", 20 | "vscode-jsonrpc": "4.0.0", 21 | "vscode-languageserver-protocol": "3.12.0", 22 | "vscode-languageserver-types": "3.12.0" 23 | }, 24 | "devDependencies": { 25 | "@atom/mocha-test-runner": "^1.5.0", 26 | "@types/atom": "^1.31.0", 27 | "@types/chai": "^4.1.7", 28 | "@types/fuzzaldrin-plus": "0.6.0", 29 | "@types/mocha": "^5.2.5", 30 | "@types/node": "8.9.3", 31 | "@types/sinon": "^4.1.3", 32 | "chai": "^4.2.0", 33 | "mocha": "^5.2.0", 34 | "mocha-appveyor-reporter": "^0.4.2", 35 | "sinon": "^7.0.0", 36 | "tslint": "^5.11.0", 37 | "typescript": "~3.1.6" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /script/cibuild: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Exit if any single command fails. 4 | set -e 5 | 6 | echo "Downloading latest Atom release..." 7 | ATOM_CHANNEL="${ATOM_CHANNEL:=stable}" 8 | 9 | if [ "${TRAVIS_OS_NAME}" = "osx" ]; then 10 | curl -s -L "https://atom.io/download/mac?channel=${ATOM_CHANNEL}" \ 11 | -H 'Accept: application/octet-stream' \ 12 | -o "atom.zip" 13 | mkdir atom 14 | unzip -q atom.zip -d atom 15 | if [ "${ATOM_CHANNEL}" = "stable" ]; then 16 | export ATOM_APP_NAME="Atom.app" 17 | export ATOM_SCRIPT_NAME="atom.sh" 18 | export ATOM_SCRIPT_PATH="./atom/${ATOM_APP_NAME}/Contents/Resources/app/atom.sh" 19 | else 20 | export ATOM_APP_NAME="Atom ${ATOM_CHANNEL}.app" 21 | export ATOM_SCRIPT_NAME="atom-${ATOM_CHANNEL}" 22 | export ATOM_SCRIPT_PATH="./atom-${ATOM_CHANNEL}" 23 | ln -s "./atom/${ATOM_APP_NAME}/Contents/Resources/app/atom.sh" "${ATOM_SCRIPT_PATH}" 24 | fi 25 | export ATOM_PATH="./atom" 26 | export APM_SCRIPT_PATH="./atom/${ATOM_APP_NAME}/Contents/Resources/app/apm/node_modules/.bin/apm" 27 | export NPM_SCRIPT_PATH="./atom/${ATOM_APP_NAME}/Contents/Resources/app/apm/node_modules/.bin/npm" 28 | elif [ "${TRAVIS_OS_NAME}" = "linux" ]; then 29 | curl -s -L "https://atom.io/download/deb?channel=${ATOM_CHANNEL}" \ 30 | -H 'Accept: application/octet-stream' \ 31 | -o "atom-amd64.deb" 32 | /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16 33 | export DISPLAY=":99" 34 | dpkg-deb -x atom-amd64.deb "${HOME}/atom" 35 | if [ "${ATOM_CHANNEL}" = "stable" ]; then 36 | export ATOM_SCRIPT_NAME="atom" 37 | export APM_SCRIPT_NAME="apm" 38 | else 39 | export ATOM_SCRIPT_NAME="atom-${ATOM_CHANNEL}" 40 | export APM_SCRIPT_NAME="apm-${ATOM_CHANNEL}" 41 | fi 42 | export ATOM_SCRIPT_PATH="${HOME}/atom/usr/bin/${ATOM_SCRIPT_NAME}" 43 | export APM_SCRIPT_PATH="${HOME}/atom/usr/bin/${APM_SCRIPT_NAME}" 44 | export NPM_SCRIPT_PATH="${HOME}/atom/usr/share/${ATOM_SCRIPT_NAME}/resources/app/apm/node_modules/.bin/npm" 45 | elif [ "${CIRCLECI}" = "true" ]; then 46 | curl -s -L "https://atom.io/download/deb?channel=${ATOM_CHANNEL}" \ 47 | -H 'Accept: application/octet-stream' \ 48 | -o "atom-amd64.deb" 49 | sudo dpkg --install atom-amd64.deb || true 50 | sudo apt-get update 51 | sudo apt-get -f install 52 | export ATOM_SCRIPT_PATH="atom" 53 | export APM_SCRIPT_PATH="apm" 54 | export NPM_SCRIPT_PATH="/usr/share/atom/resources/app/apm/node_modules/.bin/npm" 55 | else 56 | echo "Unknown CI environment, exiting!" 57 | exit 1 58 | fi 59 | 60 | echo "Using Atom version:" 61 | "${ATOM_SCRIPT_PATH}" -v 62 | echo "Using APM version:" 63 | "${APM_SCRIPT_PATH}" -v 64 | 65 | echo "Downloading package dependencies..." 66 | "${APM_SCRIPT_PATH}" clean 67 | 68 | if [ "${ATOM_LINT_WITH_BUNDLED_NODE:=true}" = "true" ]; then 69 | "${APM_SCRIPT_PATH}" install 70 | 71 | # Override the PATH to put the Node bundled with APM first 72 | if [ "${TRAVIS_OS_NAME}" = "osx" ]; then 73 | export PATH="./atom/${ATOM_APP_NAME}/Contents/Resources/app/apm/bin:${PATH}" 74 | elif [ "${CIRCLECI}" = "true" ]; then 75 | # Since CircleCI is a fully installed environment, we use the system path to apm 76 | export PATH="/usr/share/atom/resources/app/apm/bin:${PATH}" 77 | else 78 | export PATH="${HOME}/atom/usr/share/${ATOM_SCRIPT_NAME}/resources/app/apm/bin:${PATH}" 79 | fi 80 | else 81 | export NPM_SCRIPT_PATH="npm" 82 | "${APM_SCRIPT_PATH}" install --production 83 | 84 | # Use the system NPM to install the devDependencies 85 | echo "Using Node version:" 86 | node --version 87 | echo "Using NPM version:" 88 | npm --version 89 | echo "Installing remaining dependencies..." 90 | npm install 91 | echo "Using TSC version:" 92 | tsc --version 93 | fi 94 | 95 | if [ -n "${APM_TEST_PACKAGES}" ]; then 96 | echo "Installing atom package dependencies..." 97 | for pack in ${APM_TEST_PACKAGES}; do 98 | "${APM_SCRIPT_PATH}" install "${pack}" 99 | done 100 | fi 101 | 102 | echo "Running lint..." 103 | npm run lint 104 | 105 | echo "Running specs..." 106 | "$ATOM_SCRIPT_PATH" --test build/test 107 | -------------------------------------------------------------------------------- /test/adapters/apply-edit-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as path from 'path'; 3 | import * as sinon from 'sinon'; 4 | import ApplyEditAdapter from '../../lib/adapters/apply-edit-adapter'; 5 | import Convert from '../../lib/convert'; 6 | import { TextEditor } from 'atom'; 7 | 8 | const TEST_PATH1 = normalizeDriveLetterName(path.join(__dirname, 'test.txt')); 9 | const TEST_PATH2 = normalizeDriveLetterName(path.join(__dirname, 'test2.txt')); 10 | const TEST_PATH3 = normalizeDriveLetterName(path.join(__dirname, 'test3.txt')); 11 | const TEST_PATH4 = normalizeDriveLetterName(path.join(__dirname, 'test4.txt')); 12 | 13 | function normalizeDriveLetterName(filePath: string): string { 14 | if (process.platform === 'win32') { 15 | return filePath.replace(/^([a-z]):/, ([driveLetter]) => driveLetter.toUpperCase() + ':'); 16 | } else { 17 | return filePath; 18 | } 19 | } 20 | 21 | describe('ApplyEditAdapter', () => { 22 | describe('onApplyEdit', () => { 23 | beforeEach(() => { 24 | sinon.spy(atom.notifications, 'addError'); 25 | }); 26 | 27 | afterEach(() => { 28 | (atom as any).notifications.addError.restore(); 29 | }); 30 | 31 | it('works for open files', async () => { 32 | const editor = await atom.workspace.open(TEST_PATH1) as TextEditor; 33 | editor.setText('abc\ndef\n'); 34 | 35 | const result = await ApplyEditAdapter.onApplyEdit({ 36 | edit: { 37 | changes: { 38 | [Convert.pathToUri(TEST_PATH1)]: [ 39 | { 40 | range: { 41 | start: { line: 0, character: 0 }, 42 | end: { line: 0, character: 3 }, 43 | }, 44 | newText: 'def', 45 | }, 46 | { 47 | range: { 48 | start: { line: 1, character: 0 }, 49 | end: { line: 1, character: 3 }, 50 | }, 51 | newText: 'ghi', 52 | }, 53 | ], 54 | }, 55 | }, 56 | }); 57 | 58 | expect(result.applied).to.equal(true); 59 | expect(editor.getText()).to.equal('def\nghi\n'); 60 | 61 | // Undo should be atomic. 62 | editor.getBuffer().undo(); 63 | expect(editor.getText()).to.equal('abc\ndef\n'); 64 | }); 65 | 66 | it('works with TextDocumentEdits', async () => { 67 | const editor = await atom.workspace.open(TEST_PATH1) as TextEditor; 68 | editor.setText('abc\ndef\n'); 69 | 70 | const result = await ApplyEditAdapter.onApplyEdit({ 71 | edit: { 72 | documentChanges: [{ 73 | textDocument: { 74 | version: 1, 75 | uri: Convert.pathToUri(TEST_PATH1), 76 | }, 77 | edits: [ 78 | { 79 | range: { 80 | start: { line: 0, character: 0 }, 81 | end: { line: 0, character: 3 }, 82 | }, 83 | newText: 'def', 84 | }, 85 | { 86 | range: { 87 | start: { line: 1, character: 0 }, 88 | end: { line: 1, character: 3 }, 89 | }, 90 | newText: 'ghi', 91 | }, 92 | ], 93 | }], 94 | }, 95 | }); 96 | 97 | expect(result.applied).to.equal(true); 98 | expect(editor.getText()).to.equal('def\nghi\n'); 99 | 100 | // Undo should be atomic. 101 | editor.getBuffer().undo(); 102 | expect(editor.getText()).to.equal('abc\ndef\n'); 103 | }); 104 | 105 | it('opens files that are not already open', async () => { 106 | const result = await ApplyEditAdapter.onApplyEdit({ 107 | edit: { 108 | changes: { 109 | [TEST_PATH2]: [ 110 | { 111 | range: { 112 | start: { line: 0, character: 0 }, 113 | end: { line: 0, character: 0 }, 114 | }, 115 | newText: 'abc', 116 | }, 117 | ], 118 | }, 119 | }, 120 | }); 121 | 122 | expect(result.applied).to.equal(true); 123 | const editor = await atom.workspace.open(TEST_PATH2) as TextEditor; 124 | expect(editor.getText()).to.equal('abc'); 125 | }); 126 | 127 | it('fails with overlapping edits', async () => { 128 | const editor = await atom.workspace.open(TEST_PATH3) as TextEditor; 129 | editor.setText('abcdef\n'); 130 | 131 | const result = await ApplyEditAdapter.onApplyEdit({ 132 | edit: { 133 | changes: { 134 | [TEST_PATH3]: [ 135 | { 136 | range: { 137 | start: { line: 0, character: 0 }, 138 | end: { line: 0, character: 3 }, 139 | }, 140 | newText: 'def', 141 | }, 142 | { 143 | range: { 144 | start: { line: 0, character: 2 }, 145 | end: { line: 0, character: 4 }, 146 | }, 147 | newText: 'ghi', 148 | }, 149 | ], 150 | }, 151 | }, 152 | }); 153 | 154 | expect(result.applied).to.equal(false); 155 | expect( 156 | (atom as any).notifications.addError.calledWith('workspace/applyEdits failed', { 157 | description: 'Failed to apply edits.', 158 | detail: `Found overlapping edit ranges in ${TEST_PATH3}`, 159 | }), 160 | ).to.equal(true); 161 | // No changes. 162 | expect(editor.getText()).to.equal('abcdef\n'); 163 | }); 164 | 165 | it('fails with out-of-range edits', async () => { 166 | const result = await ApplyEditAdapter.onApplyEdit({ 167 | edit: { 168 | changes: { 169 | [TEST_PATH4]: [ 170 | { 171 | range: { 172 | start: { line: 0, character: 1 }, 173 | end: { line: 0, character: 2 }, 174 | }, 175 | newText: 'def', 176 | }, 177 | ], 178 | }, 179 | }, 180 | }); 181 | 182 | expect(result.applied).to.equal(false); 183 | const errorCalls = (atom as any).notifications.addError.getCalls(); 184 | expect(errorCalls.length).to.equal(1); 185 | expect(errorCalls[0].args[1].detail).to.equal(`Out of range edit on ${TEST_PATH4}:1:2`); 186 | }); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /test/adapters/code-action-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import { Range } from 'atom'; 2 | import { expect } from 'chai'; 3 | import * as sinon from 'sinon'; 4 | import * as ls from '../../lib/languageclient'; 5 | import CodeActionAdapter from '../../lib/adapters/code-action-adapter'; 6 | import LinterPushV2Adapter from '../../lib/adapters/linter-push-v2-adapter'; 7 | import { createSpyConnection, createFakeEditor } from '../helpers.js'; 8 | 9 | describe('CodeActionAdapter', () => { 10 | describe('canAdapt', () => { 11 | it('returns true if range formatting is supported', () => { 12 | const result = CodeActionAdapter.canAdapt({ 13 | codeActionProvider: true, 14 | }); 15 | expect(result).to.be.true; 16 | }); 17 | 18 | it('returns false it no formatting supported', () => { 19 | const result = CodeActionAdapter.canAdapt({}); 20 | expect(result).to.be.false; 21 | }); 22 | }); 23 | 24 | describe('getCodeActions', () => { 25 | it('fetches code actions from the connection', async () => { 26 | const connection = createSpyConnection(); 27 | const languageClient = new ls.LanguageClientConnection(connection); 28 | const testCommand: ls.Command = { 29 | command: 'testCommand', 30 | title: 'Test Command', 31 | arguments: ['a', 'b'], 32 | }; 33 | sinon.stub(languageClient, 'codeAction').returns(Promise.resolve([testCommand])); 34 | sinon.spy(languageClient, 'executeCommand'); 35 | 36 | const linterAdapter = new LinterPushV2Adapter(languageClient); 37 | sinon.stub(linterAdapter, 'getDiagnosticCode').returns('test code'); 38 | 39 | const testPath = '/test.txt'; 40 | const actions = await CodeActionAdapter.getCodeActions( 41 | languageClient, 42 | { codeActionProvider: true }, 43 | linterAdapter, 44 | createFakeEditor(testPath), 45 | new Range([1, 2], [3, 4]), 46 | [ 47 | { 48 | filePath: testPath, 49 | type: 'Error', 50 | text: 'test message', 51 | range: new Range([1, 2], [3, 3]), 52 | providerName: 'test linter', 53 | }, 54 | ], 55 | ); 56 | 57 | expect((languageClient as any).codeAction.called).to.be.true; 58 | const args = (languageClient as any).codeAction.getCalls()[0].args; 59 | const params: ls.CodeActionParams = args[0]; 60 | expect(params.textDocument.uri).to.equal('file://' + testPath); 61 | expect(params.range).to.deep.equal({ 62 | start: { line: 1, character: 2 }, 63 | end: { line: 3, character: 4 }, 64 | }); 65 | expect(params.context.diagnostics).to.deep.equal([ 66 | { 67 | range: { 68 | start: { line: 1, character: 2 }, 69 | end: { line: 3, character: 3 }, 70 | }, 71 | severity: ls.DiagnosticSeverity.Error, 72 | code: 'test code', 73 | source: 'test linter', 74 | message: 'test message', 75 | }, 76 | ]); 77 | 78 | expect(actions.length).to.equal(1); 79 | const codeAction = actions[0]; 80 | expect(await codeAction.getTitle()).to.equal('Test Command'); 81 | await codeAction.apply(); 82 | expect((languageClient as any).executeCommand.called).to.be.true; 83 | expect((languageClient as any).executeCommand.getCalls()[0].args).to.deep.equal([ 84 | { 85 | command: 'testCommand', 86 | arguments: ['a', 'b'], 87 | }, 88 | ]); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/adapters/code-format-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import { Range } from 'atom'; 2 | import { expect } from 'chai'; 3 | import * as sinon from 'sinon'; 4 | import Convert from '../../lib/convert'; 5 | import * as ls from '../../lib/languageclient'; 6 | import CodeFormatAdapter from '../../lib/adapters/code-format-adapter'; 7 | import { createSpyConnection, createFakeEditor } from '../helpers.js'; 8 | 9 | describe('CodeFormatAdapter', () => { 10 | let fakeEditor: any; 11 | let connection: any; 12 | let range: any; 13 | 14 | beforeEach(() => { 15 | connection = new ls.LanguageClientConnection(createSpyConnection()); 16 | fakeEditor = createFakeEditor(); 17 | range = new Range([0, 0], [100, 100]); 18 | }); 19 | 20 | describe('canAdapt', () => { 21 | it('returns true if range formatting is supported', () => { 22 | const result = CodeFormatAdapter.canAdapt({ 23 | documentRangeFormattingProvider: true, 24 | }); 25 | expect(result).to.be.true; 26 | }); 27 | 28 | it('returns true if document formatting is supported', () => { 29 | const result = CodeFormatAdapter.canAdapt({ 30 | documentFormattingProvider: true, 31 | }); 32 | expect(result).to.be.true; 33 | }); 34 | 35 | it('returns false it no formatting supported', () => { 36 | const result = CodeFormatAdapter.canAdapt({}); 37 | expect(result).to.be.false; 38 | }); 39 | }); 40 | 41 | describe('format', () => { 42 | it('prefers range formatting if available', () => { 43 | const rangeStub = sinon.spy(connection, 'documentRangeFormatting'); 44 | const docStub = sinon.spy(connection, 'documentFormatting'); 45 | CodeFormatAdapter.format( 46 | connection, 47 | { 48 | documentRangeFormattingProvider: true, 49 | documentFormattingProvider: true, 50 | }, 51 | fakeEditor, 52 | range, 53 | ); 54 | expect(rangeStub.called).to.be.true; 55 | expect(docStub.called).to.be.false; 56 | }); 57 | 58 | it('falls back to document formatting if range formatting not available', () => { 59 | const rangeStub = sinon.spy(connection, 'documentRangeFormatting'); 60 | const docStub = sinon.spy(connection, 'documentFormatting'); 61 | CodeFormatAdapter.format(connection, { documentFormattingProvider: true }, fakeEditor, range); 62 | expect(rangeStub.called).to.be.false; 63 | expect(docStub.called).to.be.true; 64 | }); 65 | 66 | it('throws if neither range or document formatting are supported', () => { 67 | expect(() => CodeFormatAdapter.format(connection, {}, fakeEditor, range)).to.throw(''); 68 | }); 69 | }); 70 | 71 | describe('formatDocument', () => { 72 | it('converts the results from the connection', async () => { 73 | sinon.stub(connection, 'documentFormatting').resolves([ 74 | { 75 | range: { 76 | start: { line: 0, character: 1 }, 77 | end: { line: 0, character: 2 }, 78 | }, 79 | newText: 'abc', 80 | }, 81 | { 82 | range: { 83 | start: { line: 5, character: 10 }, 84 | end: { line: 15, character: 20 }, 85 | }, 86 | newText: 'def', 87 | }, 88 | ]); 89 | const actual = await CodeFormatAdapter.formatDocument(connection, fakeEditor); 90 | expect(actual.length).to.equal(2); 91 | expect(actual[0].newText).to.equal('abc'); 92 | expect(actual[1].oldRange.start.row).to.equal(5); 93 | expect(actual[1].oldRange.start.column).to.equal(10); 94 | expect(actual[1].oldRange.end.row).to.equal(15); 95 | expect(actual[1].oldRange.end.column).to.equal(20); 96 | expect(actual[1].newText).to.equal('def'); 97 | }); 98 | }); 99 | 100 | describe('createDocumentFormattingParams', () => { 101 | it('returns the tab size from the editor', () => { 102 | sinon.stub(fakeEditor, 'getPath').returns('/a/b/c/d.txt'); 103 | sinon.stub(fakeEditor, 'getTabLength').returns(1); 104 | sinon.stub(fakeEditor, 'getSoftTabs').returns(false); 105 | 106 | const actual = CodeFormatAdapter.createDocumentFormattingParams(fakeEditor); 107 | 108 | expect(actual.textDocument).to.eql({ uri: 'file:///a/b/c/d.txt' }); 109 | expect(actual.options.tabSize).to.equal(1); 110 | expect(actual.options.insertSpaces).to.equal(false); 111 | }); 112 | }); 113 | 114 | describe('formatRange', () => { 115 | it('converts the results from the connection', async () => { 116 | sinon.stub(connection, 'documentRangeFormatting').resolves([ 117 | { 118 | range: { 119 | start: { line: 0, character: 1 }, 120 | end: { line: 0, character: 2 }, 121 | }, 122 | newText: 'abc', 123 | }, 124 | { 125 | range: { 126 | start: { line: 5, character: 10 }, 127 | end: { line: 15, character: 20 }, 128 | }, 129 | newText: 'def', 130 | }, 131 | ]); 132 | const actual = await CodeFormatAdapter.formatRange(connection, fakeEditor, new Range([0, 0], [1, 1])); 133 | expect(actual.length).to.equal(2); 134 | expect(actual[0].newText).to.equal('abc'); 135 | expect(actual[1].oldRange.start.row).to.equal(5); 136 | expect(actual[1].oldRange.start.column).to.equal(10); 137 | expect(actual[1].oldRange.end.row).to.equal(15); 138 | expect(actual[1].oldRange.end.column).to.equal(20); 139 | expect(actual[1].newText).to.equal('def'); 140 | }); 141 | }); 142 | 143 | describe('createDocumentRangeFormattingParams', () => { 144 | it('returns the tab size from the editor', () => { 145 | sinon.stub(fakeEditor, 'getPath').returns('/a/b/c/d.txt'); 146 | sinon.stub(fakeEditor, 'getTabLength').returns(1); 147 | sinon.stub(fakeEditor, 'getSoftTabs').returns(false); 148 | 149 | const actual = CodeFormatAdapter.createDocumentRangeFormattingParams(fakeEditor, new Range([1, 0], [2, 3])); 150 | 151 | expect(actual.textDocument).to.eql({ uri: 'file:///a/b/c/d.txt' }); 152 | expect(actual.range).to.eql({ 153 | start: { line: 1, character: 0 }, 154 | end: { line: 2, character: 3 }, 155 | }); 156 | expect(actual.options.tabSize).to.equal(1); 157 | expect(actual.options.insertSpaces).to.equal(false); 158 | }); 159 | }); 160 | 161 | describe('getFormatOptions', () => { 162 | it('returns the tab size from the editor', () => { 163 | sinon.stub(fakeEditor, 'getTabLength').returns(17); 164 | const options = CodeFormatAdapter.getFormatOptions(fakeEditor); 165 | expect(options.tabSize).to.equal(17); 166 | }); 167 | 168 | it('returns the soft tab setting from the editor', () => { 169 | sinon.stub(fakeEditor, 'getSoftTabs').returns(true); 170 | const options = CodeFormatAdapter.getFormatOptions(fakeEditor); 171 | expect(options.insertSpaces).to.be.true; 172 | }); 173 | }); 174 | 175 | describe('convertLsTextEdit', () => { 176 | it('returns oldRange and newText from a textEdit', () => { 177 | const textEdit = { 178 | range: { 179 | start: { line: 1, character: 0 }, 180 | end: { line: 2, character: 3 }, 181 | }, 182 | newText: 'abc-def', 183 | }; 184 | const actual = Convert.convertLsTextEdit(textEdit); 185 | expect(actual.oldRange).to.eql(new Range([1, 0], [2, 3])); 186 | expect(actual.newText).to.equal('abc-def'); 187 | }); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /test/adapters/code-highlight-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import * as invariant from 'assert'; 2 | import { Point, Range } from 'atom'; 3 | import { expect } from 'chai'; 4 | import * as sinon from 'sinon'; 5 | import * as ls from '../../lib/languageclient'; 6 | import CodeHighlightAdapter from '../../lib/adapters/code-highlight-adapter'; 7 | import { createSpyConnection, createFakeEditor } from '../helpers.js'; 8 | 9 | describe('CodeHighlightAdapter', () => { 10 | let fakeEditor: any; 11 | let connection: any; 12 | 13 | beforeEach(() => { 14 | connection = new ls.LanguageClientConnection(createSpyConnection()); 15 | fakeEditor = createFakeEditor(); 16 | }); 17 | 18 | describe('canAdapt', () => { 19 | it('returns true if document highlights are supported', () => { 20 | const result = CodeHighlightAdapter.canAdapt({ 21 | documentHighlightProvider: true, 22 | }); 23 | expect(result).to.be.true; 24 | }); 25 | 26 | it('returns false it no formatting supported', () => { 27 | const result = CodeHighlightAdapter.canAdapt({}); 28 | expect(result).to.be.false; 29 | }); 30 | }); 31 | 32 | describe('highlight', () => { 33 | it('highlights some ranges', async () => { 34 | const highlightStub = sinon.stub(connection, 'documentHighlight').returns( 35 | Promise.resolve([ 36 | { 37 | range: { 38 | start: { line: 0, character: 1 }, 39 | end: { line: 0, character: 2 }, 40 | }, 41 | }, 42 | ]), 43 | ); 44 | const result = await CodeHighlightAdapter.highlight( 45 | connection, 46 | { documentHighlightProvider: true }, 47 | fakeEditor, 48 | new Point(0, 0), 49 | ); 50 | expect(highlightStub.called).to.be.true; 51 | 52 | invariant(result != null); 53 | if (result) { 54 | expect(result.length).to.equal(1); 55 | expect(result[0].isEqual(new Range([0, 1], [0, 2]))).to.be.true; 56 | } 57 | }); 58 | 59 | it('throws if document highlights are not supported', async () => { 60 | const result = await CodeHighlightAdapter.highlight(connection, {}, fakeEditor, new Point(0, 0)).catch( 61 | (err) => err, 62 | ); 63 | expect(result).to.be.an.instanceof(Error); 64 | invariant(result instanceof Error); 65 | expect(result.message).to.equal('Must have the documentHighlight capability'); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/adapters/custom-linter-push-v2-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import LinterPushV2Adapter from '../../lib/adapters/linter-push-v2-adapter'; 2 | import * as ls from '../../lib/languageclient'; 3 | import { expect } from 'chai'; 4 | import { Point, Range } from 'atom'; 5 | 6 | const messageUrl = 'dummy'; 7 | const messageSolutions: any[] = ['dummy']; 8 | 9 | class CustomLinterPushV2Adapter extends LinterPushV2Adapter { 10 | public diagnosticToV2Message(path: string, diagnostic: ls.Diagnostic) { 11 | const message = super.diagnosticToV2Message(path, diagnostic); 12 | message.url = messageUrl; 13 | message.solutions = messageSolutions; 14 | return message; 15 | } 16 | } 17 | 18 | describe('CustomLinterPushV2Adapter', () => { 19 | describe('diagnosticToMessage', () => { 20 | it('converts Diagnostic and path to a linter$Message', () => { 21 | const filePath = '/a/b/c/d'; 22 | const diagnostic: ls.Diagnostic = { 23 | message: 'This is a message', 24 | range: { 25 | start: { line: 1, character: 2 }, 26 | end: { line: 3, character: 4 }, 27 | }, 28 | source: 'source', 29 | code: 'code', 30 | severity: ls.DiagnosticSeverity.Information, 31 | }; 32 | 33 | const connection: any = { onPublishDiagnostics() { } }; 34 | const adapter = new CustomLinterPushV2Adapter(connection); 35 | const result = adapter.diagnosticToV2Message(filePath, diagnostic); 36 | 37 | expect(result.excerpt).equals(diagnostic.message); 38 | expect(result.linterName).equals(diagnostic.source); 39 | expect(result.location.file).equals(filePath); 40 | expect(result.location.position).deep.equals(new Range(new Point(1, 2), new Point(3, 4))); 41 | expect(result.severity).equals('info'); 42 | expect(result.url).equals(messageUrl); 43 | expect(result.solutions).deep.equals(messageSolutions); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/adapters/datatip-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import invariant = require('assert'); 2 | import { Point } from 'atom'; 3 | import { expect } from 'chai'; 4 | import * as sinon from 'sinon'; 5 | import * as ls from '../../lib/languageclient'; 6 | import DatatipAdapter from '../../lib/adapters/datatip-adapter'; 7 | import { createSpyConnection, createFakeEditor } from '../helpers.js'; 8 | 9 | describe('DatatipAdapter', () => { 10 | let fakeEditor: any; 11 | let connection: any; 12 | 13 | beforeEach(() => { 14 | connection = new ls.LanguageClientConnection(createSpyConnection()); 15 | fakeEditor = createFakeEditor(); 16 | }); 17 | 18 | describe('canAdapt', () => { 19 | it('returns true if hoverProvider is supported', () => { 20 | const result = DatatipAdapter.canAdapt({ hoverProvider: true }); 21 | expect(result).to.be.true; 22 | }); 23 | 24 | it('returns false if hoverProvider not supported', () => { 25 | const result = DatatipAdapter.canAdapt({}); 26 | expect(result).to.be.false; 27 | }); 28 | }); 29 | 30 | describe('getDatatip', () => { 31 | it('calls LSP document/hover at the given position', async () => { 32 | sinon.stub(connection, 'hover').resolves({ 33 | range: { 34 | start: { line: 0, character: 1 }, 35 | end: { line: 0, character: 2 }, 36 | }, 37 | contents: ['test', { language: 'testlang', value: 'test snippet' }], 38 | }); 39 | 40 | const grammarSpy = sinon.spy(atom.grammars, 'grammarForScopeName'); 41 | 42 | const datatipAdapter = new DatatipAdapter(); 43 | const datatip = await datatipAdapter.getDatatip(connection, fakeEditor, new Point(0, 0)); 44 | expect(datatip).to.be.ok; 45 | invariant(datatip != null); 46 | 47 | if (datatip) { 48 | expect(datatip.range.start.row).equal(0); 49 | expect(datatip.range.start.column).equal(1); 50 | expect(datatip.range.end.row).equal(0); 51 | expect(datatip.range.end.column).equal(2); 52 | 53 | expect(datatip.markedStrings).to.have.lengthOf(2); 54 | expect(datatip.markedStrings[0]).eql({ type: 'markdown', value: 'test' }); 55 | 56 | const snippet = datatip.markedStrings[1]; 57 | expect(snippet.type).equal('snippet'); 58 | invariant(snippet.type === 'snippet'); 59 | expect((snippet as any).grammar.scopeName).equal('text.plain.null-grammar'); 60 | expect(snippet.value).equal('test snippet'); 61 | 62 | expect(grammarSpy.calledWith('source.testlang')).to.be.true; 63 | } 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/adapters/document-sync-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { TextDocumentSyncKind, TextDocumentSyncOptions } from '../../lib/languageclient'; 3 | import DocumentSyncAdapter from '../../lib/adapters/document-sync-adapter'; 4 | 5 | describe('DocumentSyncAdapter', () => { 6 | describe('canAdapt', () => { 7 | it('returns true if v2 incremental change notifications are supported', () => { 8 | const result = DocumentSyncAdapter.canAdapt({ 9 | textDocumentSync: TextDocumentSyncKind.Incremental, 10 | }); 11 | expect(result).to.be.true; 12 | }); 13 | 14 | it('returns true if v2 full change notifications are supported', () => { 15 | const result = DocumentSyncAdapter.canAdapt({ 16 | textDocumentSync: TextDocumentSyncKind.Full, 17 | }); 18 | expect(result).to.be.true; 19 | }); 20 | 21 | it('returns false if v2 none change notifications are supported', () => { 22 | const result = DocumentSyncAdapter.canAdapt({ 23 | textDocumentSync: TextDocumentSyncKind.None, 24 | }); 25 | expect(result).to.be.false; 26 | }); 27 | 28 | it('returns true if v3 incremental change notifications are supported', () => { 29 | const result = DocumentSyncAdapter.canAdapt({ 30 | textDocumentSync: { change: TextDocumentSyncKind.Incremental }, 31 | }); 32 | expect(result).to.be.true; 33 | }); 34 | 35 | it('returns true if v3 full change notifications are supported', () => { 36 | const result = DocumentSyncAdapter.canAdapt({ 37 | textDocumentSync: { change: TextDocumentSyncKind.Full }, 38 | }); 39 | expect(result).to.be.true; 40 | }); 41 | 42 | it('returns false if v3 none change notifications are supported', () => { 43 | const result = DocumentSyncAdapter.canAdapt({ 44 | textDocumentSync: { change: TextDocumentSyncKind.None }, 45 | }); 46 | expect(result).to.be.false; 47 | }); 48 | }); 49 | 50 | describe('constructor', () => { 51 | function create(textDocumentSync?: TextDocumentSyncKind | TextDocumentSyncOptions) { 52 | return new DocumentSyncAdapter(null as any, () => false, textDocumentSync, (_t, f) => f()); 53 | } 54 | 55 | it('sets _documentSync.change correctly Incremental for v2 capabilities', () => { 56 | const result = create(TextDocumentSyncKind.Incremental)._documentSync.change; 57 | expect(result).equals(TextDocumentSyncKind.Incremental); 58 | }); 59 | 60 | it('sets _documentSync.change correctly Full for v2 capabilities', () => { 61 | const result = create(TextDocumentSyncKind.Full)._documentSync.change; 62 | expect(result).equals(TextDocumentSyncKind.Full); 63 | }); 64 | 65 | it('sets _documentSync.change correctly Incremental for v3 capabilities', () => { 66 | const result = create({ change: TextDocumentSyncKind.Incremental })._documentSync.change; 67 | expect(result).equals(TextDocumentSyncKind.Incremental); 68 | }); 69 | 70 | it('sets _documentSync.change correctly Full for v3 capabilities', () => { 71 | const result = create({ change: TextDocumentSyncKind.Full })._documentSync.change; 72 | expect(result).equals(TextDocumentSyncKind.Full); 73 | }); 74 | 75 | it('sets _documentSync.change correctly Full for unset capabilities', () => { 76 | const result = create()._documentSync.change; 77 | expect(result).equals(TextDocumentSyncKind.Full); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/adapters/linter-push-v2-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import LinterPushV2Adapter from '../../lib/adapters/linter-push-v2-adapter'; 2 | import Convert from '../../lib/convert'; 3 | import * as ls from '../../lib/languageclient'; 4 | import * as path from 'path'; 5 | import * as sinon from 'sinon'; 6 | import { expect } from 'chai'; 7 | import { Point, Range } from 'atom'; 8 | import { createSpyConnection, createFakeEditor } from '../helpers.js'; 9 | 10 | describe('LinterPushV2Adapter', () => { 11 | describe('constructor', () => { 12 | it('subscribes to onPublishDiagnostics', () => { 13 | const languageClient = new ls.LanguageClientConnection(createSpyConnection()); 14 | sinon.spy(languageClient, 'onPublishDiagnostics'); 15 | new LinterPushV2Adapter(languageClient); 16 | expect((languageClient as any).onPublishDiagnostics.called).equals(true); 17 | }); 18 | }); 19 | 20 | describe('diagnosticToMessage', () => { 21 | it('converts Diagnostic and path to a linter$Message', () => { 22 | const filePath = '/a/b/c/d'; 23 | const diagnostic: ls.Diagnostic = { 24 | message: 'This is a message', 25 | range: { 26 | start: { line: 1, character: 2 }, 27 | end: { line: 3, character: 4 }, 28 | }, 29 | source: 'source', 30 | code: 'code', 31 | severity: ls.DiagnosticSeverity.Information, 32 | }; 33 | 34 | const connection: any = { onPublishDiagnostics() { } }; 35 | const adapter = new LinterPushV2Adapter(connection); 36 | const result = adapter.diagnosticToV2Message(filePath, diagnostic); 37 | 38 | expect(result.excerpt).equals(diagnostic.message); 39 | expect(result.linterName).equals(diagnostic.source); 40 | expect(result.location.file).equals(filePath); 41 | expect(result.location.position).deep.equals(new Range(new Point(1, 2), new Point(3, 4))); 42 | expect(result.severity).equals('info'); 43 | }); 44 | }); 45 | 46 | describe('diagnosticSeverityToSeverity', () => { 47 | it('converts DiagnosticSeverity.Error to "error"', () => { 48 | const severity = LinterPushV2Adapter.diagnosticSeverityToSeverity(ls.DiagnosticSeverity.Error); 49 | expect(severity).equals('error'); 50 | }); 51 | 52 | it('converts DiagnosticSeverity.Warning to "warning"', () => { 53 | const severity = LinterPushV2Adapter.diagnosticSeverityToSeverity(ls.DiagnosticSeverity.Warning); 54 | expect(severity).equals('warning'); 55 | }); 56 | 57 | it('converts DiagnosticSeverity.Information to "info"', () => { 58 | const severity = LinterPushV2Adapter.diagnosticSeverityToSeverity(ls.DiagnosticSeverity.Information); 59 | expect(severity).equals('info'); 60 | }); 61 | 62 | it('converts DiagnosticSeverity.Hint to "info"', () => { 63 | const severity = LinterPushV2Adapter.diagnosticSeverityToSeverity(ls.DiagnosticSeverity.Hint); 64 | expect(severity).equals('info'); 65 | }); 66 | }); 67 | 68 | describe('captureDiagnostics', () => { 69 | it('stores diagnostic codes and allows their retrival', () => { 70 | const languageClient = new ls.LanguageClientConnection(createSpyConnection()); 71 | const adapter = new LinterPushV2Adapter(languageClient); 72 | const testPath = path.join(__dirname, 'test.txt'); 73 | adapter.captureDiagnostics({ 74 | uri: Convert.pathToUri(testPath), 75 | diagnostics: [ 76 | { 77 | message: 'Test message', 78 | range: { 79 | start: { line: 1, character: 2 }, 80 | end: { line: 3, character: 4 }, 81 | }, 82 | source: 'source', 83 | code: 'test code', 84 | severity: ls.DiagnosticSeverity.Information, 85 | }, 86 | ], 87 | }); 88 | 89 | const mockEditor = createFakeEditor(testPath); 90 | expect(adapter.getDiagnosticCode(mockEditor, new Range([1, 2], [3, 4]), 'Test message')).to.equal('test code'); 91 | expect(adapter.getDiagnosticCode(mockEditor, new Range([1, 2], [3, 4]), 'Test message2')).to.not.exist; 92 | expect(adapter.getDiagnosticCode(mockEditor, new Range([1, 2], [3, 5]), 'Test message')).to.not.exist; 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /test/adapters/outline-view-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import OutlineViewAdapter from '../../lib/adapters/outline-view-adapter'; 2 | import * as ls from '../../lib/languageclient'; 3 | import { expect } from 'chai'; 4 | import { Point } from 'atom'; 5 | 6 | describe('OutlineViewAdapter', () => { 7 | const createRange = (a: any, b: any, c: any, d: any) => ( 8 | { start: { line: a, character: b }, end: { line: c, character: d } } 9 | ); 10 | 11 | const createLocation = (a: any, b: any, c: any, d: any) => ({ 12 | uri: '', 13 | range: createRange(a, b, c, d), 14 | }); 15 | 16 | describe('canAdapt', () => { 17 | it('returns true if documentSymbolProvider is supported', () => { 18 | const result = OutlineViewAdapter.canAdapt({ documentSymbolProvider: true }); 19 | expect(result).to.be.true; 20 | }); 21 | 22 | it('returns false if documentSymbolProvider not supported', () => { 23 | const result = OutlineViewAdapter.canAdapt({}); 24 | expect(result).to.be.false; 25 | }); 26 | }); 27 | 28 | describe('createHierarchicalOutlineTrees', () => { 29 | it('creates an empty array given an empty array', () => { 30 | const result = OutlineViewAdapter.createHierarchicalOutlineTrees([]); 31 | expect(result).to.deep.equal([]); 32 | }); 33 | 34 | it('converts symbols without the children field', () => { 35 | const sourceItem = { 36 | name: 'test', 37 | kind: ls.SymbolKind.Function, 38 | range: createRange(1, 1, 2, 2), 39 | selectionRange: createRange(1, 1, 2, 2), 40 | }; 41 | 42 | const expected = [OutlineViewAdapter.hierarchicalSymbolToOutline(sourceItem)]; 43 | const result = OutlineViewAdapter.createHierarchicalOutlineTrees([sourceItem]); 44 | 45 | expect(result).to.deep.equal(expected); 46 | }); 47 | 48 | it('converts symbols with an empty children list', () => { 49 | const sourceItem = { 50 | name: 'test', 51 | kind: ls.SymbolKind.Function, 52 | range: createRange(1, 1, 2, 2), 53 | selectionRange: createRange(1, 1, 2, 2), 54 | children: [], 55 | }; 56 | 57 | const expected = [OutlineViewAdapter.hierarchicalSymbolToOutline(sourceItem)]; 58 | const result = OutlineViewAdapter.createHierarchicalOutlineTrees([sourceItem]); 59 | 60 | expect(result).to.deep.equal(expected); 61 | }); 62 | 63 | it('sorts symbols by location', () => { 64 | const sourceA = { 65 | name: 'test', 66 | kind: ls.SymbolKind.Function, 67 | range: createRange(2, 2, 3, 3), 68 | selectionRange: createRange(2, 2, 3, 3), 69 | }; 70 | 71 | const sourceB = { 72 | name: 'test', 73 | kind: ls.SymbolKind.Function, 74 | range: createRange(1, 1, 2, 2), 75 | selectionRange: createRange(1, 1, 2, 2), 76 | }; 77 | 78 | const expected = [ 79 | OutlineViewAdapter.hierarchicalSymbolToOutline(sourceB), 80 | OutlineViewAdapter.hierarchicalSymbolToOutline(sourceA), 81 | ]; 82 | 83 | const result = OutlineViewAdapter.createHierarchicalOutlineTrees([ 84 | sourceA, 85 | sourceB, 86 | ]); 87 | 88 | expect(result).to.deep.equal(expected); 89 | }); 90 | 91 | it('converts symbols with children', () => { 92 | const sourceChildA = { 93 | name: 'childA', 94 | kind: ls.SymbolKind.Function, 95 | range: createRange(2, 2, 3, 3), 96 | selectionRange: createRange(2, 2, 3, 3), 97 | }; 98 | 99 | const sourceChildB = { 100 | name: 'childB', 101 | kind: ls.SymbolKind.Function, 102 | range: createRange(1, 1, 2, 2), 103 | selectionRange: createRange(1, 1, 2, 2), 104 | }; 105 | 106 | const sourceParent = { 107 | name: 'parent', 108 | kind: ls.SymbolKind.Function, 109 | range: createRange(1, 1, 3, 3), 110 | selectionRange: createRange(1, 1, 3, 3), 111 | children: [sourceChildA, sourceChildB], 112 | }; 113 | 114 | const expectedParent = OutlineViewAdapter.hierarchicalSymbolToOutline( 115 | sourceParent); 116 | 117 | expectedParent.children = [ 118 | OutlineViewAdapter.hierarchicalSymbolToOutline(sourceChildB), 119 | OutlineViewAdapter.hierarchicalSymbolToOutline(sourceChildA), 120 | ]; 121 | 122 | const result = OutlineViewAdapter.createHierarchicalOutlineTrees([ 123 | sourceParent, 124 | ]); 125 | 126 | expect(result).to.deep.equal([expectedParent]); 127 | }); 128 | }); 129 | 130 | describe('createOutlineTrees', () => { 131 | it('creates an empty array given an empty array', () => { 132 | const result = OutlineViewAdapter.createOutlineTrees([]); 133 | expect(result).to.deep.equal([]); 134 | }); 135 | 136 | it('creates a single converted root item from a single source item', () => { 137 | const sourceItem = { kind: ls.SymbolKind.Namespace, name: 'R', location: createLocation(5, 6, 7, 8) }; 138 | const expected = OutlineViewAdapter.symbolToOutline(sourceItem); 139 | const result = OutlineViewAdapter.createOutlineTrees([sourceItem]); 140 | expect(result).to.deep.equal([expected]); 141 | }); 142 | 143 | it('creates an empty root container with a single source item when containerName missing', () => { 144 | const sourceItem: ls.SymbolInformation = { 145 | kind: ls.SymbolKind.Class, 146 | name: 'Program', 147 | location: createLocation(1, 2, 3, 4), 148 | }; 149 | const expected = OutlineViewAdapter.symbolToOutline(sourceItem); 150 | sourceItem.containerName = 'missing'; 151 | const result = OutlineViewAdapter.createOutlineTrees([sourceItem]); 152 | expect(result.length).to.equal(1); 153 | expect(result[0].representativeName).to.equal('missing'); 154 | expect(result[0].startPosition.row).to.equal(0); 155 | expect(result[0].startPosition.column).to.equal(0); 156 | expect(result[0].children).to.deep.equal([expected]); 157 | }); 158 | 159 | // tslint:disable-next-line:max-line-length 160 | it('creates an empty root container with a single source item when containerName is missing and matches own name', () => { 161 | const sourceItem: ls.SymbolInformation = { 162 | kind: ls.SymbolKind.Class, 163 | name: 'simple', 164 | location: createLocation(1, 2, 3, 4), 165 | }; 166 | const expected = OutlineViewAdapter.symbolToOutline(sourceItem); 167 | sourceItem.containerName = 'simple'; 168 | const result = OutlineViewAdapter.createOutlineTrees([sourceItem]); 169 | expect(result.length).to.equal(1); 170 | expect(result[0].representativeName).to.equal('simple'); 171 | expect(result[0].startPosition.row).to.equal(0); 172 | expect(result[0].startPosition.column).to.equal(0); 173 | expect(result[0].children).to.deep.equal([expected]); 174 | }); 175 | 176 | it('creates a simple named hierarchy', () => { 177 | const sourceItems = [ 178 | { kind: ls.SymbolKind.Namespace, name: 'java.com', location: createLocation(1, 0, 10, 0) }, 179 | { 180 | kind: ls.SymbolKind.Class, 181 | name: 'Program', 182 | location: createLocation(2, 0, 7, 0), 183 | containerName: 'java.com', 184 | }, 185 | { 186 | kind: ls.SymbolKind.Function, 187 | name: 'main', 188 | location: createLocation(4, 0, 5, 0), 189 | containerName: 'Program', 190 | }, 191 | ]; 192 | const result = OutlineViewAdapter.createOutlineTrees(sourceItems); 193 | expect(result.length).to.equal(1); 194 | expect(result[0].children.length).to.equal(1); 195 | expect(result[0].children[0].representativeName).to.equal('Program'); 196 | expect(result[0].children[0].children.length).to.equal(1); 197 | expect(result[0].children[0].children[0].representativeName).to.equal('main'); 198 | }); 199 | 200 | it('retains duplicate named items', () => { 201 | const sourceItems = [ 202 | { kind: ls.SymbolKind.Namespace, name: 'duplicate', location: createLocation(1, 0, 5, 0) }, 203 | { kind: ls.SymbolKind.Namespace, name: 'duplicate', location: createLocation(6, 0, 10, 0) }, 204 | { 205 | kind: ls.SymbolKind.Function, 206 | name: 'main', 207 | location: createLocation(7, 0, 8, 0), 208 | containerName: 'duplicate', 209 | }, 210 | ]; 211 | const result = OutlineViewAdapter.createOutlineTrees(sourceItems); 212 | expect(result.length).to.equal(2); 213 | expect(result[0].representativeName).to.equal('duplicate'); 214 | expect(result[1].representativeName).to.equal('duplicate'); 215 | }); 216 | 217 | it('disambiguates containerName based on range', () => { 218 | const sourceItems = [ 219 | { kind: ls.SymbolKind.Namespace, name: 'duplicate', location: createLocation(1, 0, 5, 0) }, 220 | { kind: ls.SymbolKind.Namespace, name: 'duplicate', location: createLocation(6, 0, 10, 0) }, 221 | { 222 | kind: ls.SymbolKind.Function, 223 | name: 'main', 224 | location: createLocation(7, 0, 8, 0), 225 | containerName: 'duplicate', 226 | }, 227 | ]; 228 | const result = OutlineViewAdapter.createOutlineTrees(sourceItems); 229 | expect(result[1].children.length).to.equal(1); 230 | expect(result[1].children[0].representativeName).to.equal('main'); 231 | }); 232 | 233 | it("does not become it's own parent", () => { 234 | const sourceItems = [ 235 | { kind: ls.SymbolKind.Namespace, name: 'duplicate', location: createLocation(1, 0, 10, 0) }, 236 | { 237 | kind: ls.SymbolKind.Namespace, 238 | name: 'duplicate', 239 | location: createLocation(6, 0, 7, 0), 240 | containerName: 'duplicate', 241 | }, 242 | ]; 243 | 244 | const result = OutlineViewAdapter.createOutlineTrees(sourceItems); 245 | expect(result.length).to.equal(1); 246 | 247 | const outline = result[0]; 248 | expect(outline.endPosition).to.not.be.undefined; 249 | if (outline.endPosition) { 250 | expect(outline.endPosition.row).to.equal(10); 251 | expect(outline.children.length).to.equal(1); 252 | 253 | const outlineChild = outline.children[0]; 254 | expect(outlineChild.endPosition).to.not.be.undefined; 255 | if (outlineChild.endPosition) { 256 | expect(outlineChild.endPosition.row).to.equal(7); 257 | } 258 | } 259 | }); 260 | 261 | it('parents to the innnermost named container', () => { 262 | const sourceItems = [ 263 | { kind: ls.SymbolKind.Namespace, name: 'turtles', location: createLocation(1, 0, 10, 0) }, 264 | { 265 | kind: ls.SymbolKind.Namespace, 266 | name: 'turtles', 267 | location: createLocation(4, 0, 8, 0), 268 | containerName: 'turtles', 269 | }, 270 | { kind: ls.SymbolKind.Class, name: 'disc', location: createLocation(4, 0, 5, 0), containerName: 'turtles' }, 271 | ]; 272 | const result = OutlineViewAdapter.createOutlineTrees(sourceItems); 273 | expect(result.length).to.equal(1); 274 | 275 | const outline = result[0]; 276 | expect(outline).to.not.be.undefined; 277 | if (outline) { 278 | expect(outline.endPosition).to.not.be.undefined; 279 | if (outline.endPosition) { 280 | expect(outline.endPosition.row).to.equal(10); 281 | expect(outline.children.length).to.equal(1); 282 | 283 | const outlineChild = outline.children[0]; 284 | expect(outlineChild.endPosition).to.not.be.undefined; 285 | if (outlineChild.endPosition) { 286 | expect(outlineChild.endPosition.row).to.equal(8); 287 | expect(outlineChild.children.length).to.equal(1); 288 | 289 | const outlineGrandChild = outlineChild.children[0]; 290 | expect(outlineGrandChild.endPosition).to.not.be.undefined; 291 | if (outlineGrandChild.endPosition) { 292 | expect(outlineGrandChild.endPosition.row).to.equal(5); 293 | } 294 | } 295 | } 296 | } 297 | }); 298 | }); 299 | 300 | describe('hierarchicalSymbolToOutline', () => { 301 | it('converts an individual item', () => { 302 | const sourceItem = { 303 | name: 'test', 304 | kind: ls.SymbolKind.Function, 305 | range: createRange(1, 1, 2, 2), 306 | selectionRange: createRange(1, 1, 2, 2), 307 | }; 308 | 309 | const expected = { 310 | tokenizedText: [ 311 | { 312 | kind: 'method', 313 | value: 'test', 314 | }, 315 | ], 316 | icon: 'type-function', 317 | representativeName: 'test', 318 | startPosition: new Point(1, 1), 319 | endPosition: new Point(2, 2), 320 | children: [], 321 | }; 322 | 323 | const result = OutlineViewAdapter.hierarchicalSymbolToOutline(sourceItem); 324 | 325 | expect(result).to.deep.equal(expected); 326 | }); 327 | }); 328 | 329 | describe('symbolToOutline', () => { 330 | it('converts an individual item', () => { 331 | const sourceItem = { kind: ls.SymbolKind.Class, name: 'Program', location: createLocation(1, 2, 3, 4) }; 332 | const result = OutlineViewAdapter.symbolToOutline(sourceItem); 333 | expect(result.icon).to.equal('type-class'); 334 | expect(result.representativeName).to.equal('Program'); 335 | expect(result.children).to.deep.equal([]); 336 | expect(result.tokenizedText).to.not.be.undefined; 337 | if (result.tokenizedText) { 338 | expect(result.tokenizedText[0].kind).to.equal('type'); 339 | expect(result.tokenizedText[0].value).to.equal('Program'); 340 | expect(result.startPosition.row).to.equal(1); 341 | expect(result.startPosition.column).to.equal(2); 342 | expect(result.endPosition).to.not.be.undefined; 343 | if (result.endPosition) { 344 | expect(result.endPosition.row).to.equal(3); 345 | expect(result.endPosition.column).to.equal(4); 346 | } 347 | } 348 | }); 349 | }); 350 | }); 351 | -------------------------------------------------------------------------------- /test/adapters/signature-help-adapter.test.ts: -------------------------------------------------------------------------------- 1 | import { Disposable, Point } from 'atom'; 2 | import SignatureHelpAdapter from '../../lib/adapters/signature-help-adapter'; 3 | import { createFakeEditor, createSpyConnection } from '../helpers'; 4 | import { expect } from 'chai'; 5 | import * as sinon from 'sinon'; 6 | 7 | describe('SignatureHelpAdapter', () => { 8 | describe('canAdapt', () => { 9 | it('checks for signatureHelpProvider', () => { 10 | expect(SignatureHelpAdapter.canAdapt({})).to.equal(false); 11 | expect(SignatureHelpAdapter.canAdapt({ signatureHelpProvider: {} })).to.equal(true); 12 | }); 13 | }); 14 | 15 | describe('can attach to a server', () => { 16 | it('subscribes to onPublishDiagnostics', async () => { 17 | const connection = createSpyConnection(); 18 | (connection as any).signatureHelp = sinon.stub().resolves({ signatures: [] }); 19 | 20 | const adapter = new SignatureHelpAdapter( 21 | { 22 | connection, 23 | capabilities: { 24 | signatureHelpProvider: { 25 | triggerCharacters: ['(', ','], 26 | }, 27 | }, 28 | } as any, 29 | ['source.js'], 30 | ); 31 | const spy = sinon.stub().returns(new Disposable()); 32 | adapter.attach(spy); 33 | expect(spy.calledOnce).to.be.true; 34 | const provider = spy.firstCall.args[0]; 35 | expect(provider.priority).to.equal(1); 36 | expect(provider.grammarScopes).to.deep.equal(['source.js']); 37 | expect(provider.triggerCharacters).to.deep.equal(new Set(['(', ','])); 38 | expect(typeof provider.getSignatureHelp).to.equal('function'); 39 | 40 | const result = await provider.getSignatureHelp(createFakeEditor('test.txt'), new Point(0, 1)); 41 | expect((connection as any).signatureHelp.calledOnce).to.be.true; 42 | const params = (connection as any).signatureHelp.firstCall.args[0]; 43 | expect(params).to.deep.equal({ 44 | textDocument: { uri: 'file:///test.txt' }, 45 | position: { line: 0, character: 1 }, 46 | }); 47 | expect(result).to.deep.equal({ signatures: [] }); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/auto-languageclient.test.ts: -------------------------------------------------------------------------------- 1 | import AutoLanguageClient from '../lib/auto-languageclient'; 2 | import { expect } from 'chai'; 3 | 4 | describe('AutoLanguageClient', () => { 5 | describe('shouldSyncForEditor', () => { 6 | class CustomAutoLanguageClient extends AutoLanguageClient { 7 | public getGrammarScopes() { 8 | return ['Java', 'Python']; 9 | } 10 | } 11 | 12 | const client = new CustomAutoLanguageClient(); 13 | 14 | function mockEditor(uri: string, scopeName: string): any { 15 | return { 16 | getPath: () => uri, 17 | getGrammar: () => { 18 | return { scopeName }; 19 | }, 20 | }; 21 | } 22 | 23 | it('selects documents in project and in supported language', () => { 24 | const editor = mockEditor('/path/to/somewhere', client.getGrammarScopes()[0]); 25 | expect(client.shouldSyncForEditor(editor, '/path/to/somewhere')).equals(true); 26 | }); 27 | 28 | it('does not select documents outside of project', () => { 29 | const editor = mockEditor('/path/to/elsewhere/file', client.getGrammarScopes()[0]); 30 | expect(client.shouldSyncForEditor(editor, '/path/to/somewhere')).equals(false); 31 | }); 32 | 33 | it('does not select documents in unsupported language', () => { 34 | const editor = mockEditor('/path/to/somewhere', client.getGrammarScopes()[0] + '-dummy'); 35 | expect(client.shouldSyncForEditor(editor, '/path/to/somewhere')).equals(false); 36 | }); 37 | 38 | it('does not select documents in unsupported language outside of project', () => { 39 | const editor = mockEditor('/path/to/elsewhere/file', client.getGrammarScopes()[0] + '-dummy'); 40 | expect(client.shouldSyncForEditor(editor, '/path/to/somewhere')).equals(false); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/convert.test.ts: -------------------------------------------------------------------------------- 1 | import * as ls from '../lib/languageclient'; 2 | import Convert from '../lib/convert'; 3 | import { expect } from 'chai'; 4 | import { 5 | Point, 6 | Range, 7 | TextEditor, 8 | FilesystemChange, 9 | } from 'atom'; 10 | 11 | let originalPlatform: NodeJS.Platform; 12 | const setProcessPlatform = (platform: any) => { 13 | Object.defineProperty(process, 'platform', { value: platform }); 14 | }; 15 | 16 | const createFakeEditor = (path: string, text?: string): TextEditor => { 17 | const editor = new TextEditor(); 18 | editor.getBuffer().setPath(path); 19 | if (text != null) { 20 | editor.setText(text); 21 | } 22 | return editor; 23 | }; 24 | 25 | describe('Convert', () => { 26 | beforeEach(() => { 27 | originalPlatform = process.platform; 28 | }); 29 | afterEach(() => { 30 | Object.defineProperty(process, 'platform', { value: originalPlatform }); 31 | }); 32 | 33 | describe('pathToUri', () => { 34 | it('prefixes an absolute path with file://', () => { 35 | expect(Convert.pathToUri('/a/b/c/d.txt')).equals('file:///a/b/c/d.txt'); 36 | }); 37 | 38 | it('prefixes an relative path with file:///', () => { 39 | expect(Convert.pathToUri('a/b/c/d.txt')).equals('file:///a/b/c/d.txt'); 40 | }); // TODO: Maybe don't do this in the function - should never be called with relative 41 | 42 | it('replaces backslashes with forward slashes', () => { 43 | expect(Convert.pathToUri('a\\b\\c\\d.txt')).equals('file:///a/b/c/d.txt'); 44 | }); 45 | 46 | it('does not encode Windows drive specifiers', () => { 47 | expect(Convert.pathToUri('d:\\ee\\ff.txt')).equals('file:///d:/ee/ff.txt'); 48 | }); 49 | 50 | it('URI encodes special characters', () => { 51 | expect(Convert.pathToUri('/a/sp ace/do$lar')).equals('file:///a/sp%20ace/do$lar'); 52 | }); 53 | }); 54 | 55 | describe('uriToPath', () => { 56 | it("does not convert http: and https: uri's", () => { 57 | setProcessPlatform('darwin'); 58 | expect(Convert.uriToPath('http://atom.io/a')).equals('http://atom.io/a'); 59 | expect(Convert.uriToPath('https://atom.io/b')).equals('https://atom.io/b'); 60 | }); 61 | 62 | it('converts a file:// path to an absolute path', () => { 63 | setProcessPlatform('darwin'); 64 | expect(Convert.uriToPath('file:///a/b/c/d.txt')).equals('/a/b/c/d.txt'); 65 | }); 66 | 67 | it('converts forward slashes to backslashes on Windows', () => { 68 | setProcessPlatform('win32'); 69 | expect(Convert.uriToPath('file:///a/b/c/d.txt')).equals('a\\b\\c\\d.txt'); 70 | }); 71 | 72 | it('decodes Windows drive specifiers', () => { 73 | setProcessPlatform('win32'); 74 | expect(Convert.uriToPath('file:///d:/ee/ff.txt')).equals('d:\\ee\\ff.txt'); 75 | }); 76 | 77 | it('URI decodes special characters', () => { 78 | setProcessPlatform('darwin'); 79 | expect(Convert.uriToPath('file:///a/sp%20ace/do$lar')).equals('/a/sp ace/do$lar'); 80 | }); 81 | 82 | it('parses URI without double slash in the beginning', () => { 83 | setProcessPlatform('darwin'); 84 | expect(Convert.uriToPath('file:/a/b/c/d.txt')).equals('/a/b/c/d.txt'); 85 | }); 86 | 87 | it('parses URI without double slash in the beginning on Windows', () => { 88 | setProcessPlatform('win32'); 89 | expect(Convert.uriToPath('file:/x:/a/b/c/d.txt')).equals('x:\\a\\b\\c\\d.txt'); 90 | }); 91 | }); 92 | 93 | describe('pointToPosition', () => { 94 | it('converts an Atom Point to a LSP position', () => { 95 | const point = new Point(1, 2); 96 | const position = Convert.pointToPosition(point); 97 | expect(position.line).equals(point.row); 98 | expect(position.character).equals(point.column); 99 | }); 100 | }); 101 | 102 | describe('positionToPoint', () => { 103 | it('converts a LSP position to an Atom Point-array', () => { 104 | const position = { line: 3, character: 4 }; 105 | const point = Convert.positionToPoint(position); 106 | expect(point.row).equals(position.line); 107 | expect(point.column).equals(position.character); 108 | }); 109 | }); 110 | 111 | describe('lsRangeToAtomRange', () => { 112 | it('converts a LSP range to an Atom Range-array', () => { 113 | const lspRange = { 114 | start: { character: 5, line: 6 }, 115 | end: { line: 7, character: 8 }, 116 | }; 117 | const atomRange = Convert.lsRangeToAtomRange(lspRange); 118 | expect(atomRange.start.row).equals(lspRange.start.line); 119 | expect(atomRange.start.column).equals(lspRange.start.character); 120 | expect(atomRange.end.row).equals(lspRange.end.line); 121 | expect(atomRange.end.column).equals(lspRange.end.character); 122 | }); 123 | }); 124 | 125 | describe('atomRangeToLSRange', () => { 126 | it('converts an Atom range to a LSP Range-array', () => { 127 | const atomRange = new Range(new Point(9, 10), new Point(11, 12)); 128 | const lspRange = Convert.atomRangeToLSRange(atomRange); 129 | expect(lspRange.start.line).equals(atomRange.start.row); 130 | expect(lspRange.start.character).equals(atomRange.start.column); 131 | expect(lspRange.end.line).equals(atomRange.end.row); 132 | expect(lspRange.end.character).equals(atomRange.end.column); 133 | }); 134 | }); 135 | 136 | describe('editorToTextDocumentIdentifier', () => { 137 | it('uses getath which returns a path to create the URI', () => { 138 | const path = '/c/d/e/f/g/h/i/j.txt'; 139 | const identifier = Convert.editorToTextDocumentIdentifier(createFakeEditor(path)); 140 | expect(identifier.uri).equals('file://' + path); 141 | }); 142 | }); 143 | 144 | describe('editorToTextDocumentPositionParams', () => { 145 | it('uses the editor cursor position when none specified', () => { 146 | const path = '/c/d/e/f/g/h/i/j.txt'; 147 | const editor = createFakeEditor(path, 'abc\ndefgh\nijkl'); 148 | editor.setCursorBufferPosition(new Point(1, 2)); 149 | const params = Convert.editorToTextDocumentPositionParams(editor); 150 | expect(params.textDocument.uri).equals('file://' + path); 151 | expect(params.position).deep.equals({ line: 1, character: 2 }); 152 | }); 153 | 154 | it('uses the cursor position parameter when specified', () => { 155 | const path = '/c/d/e/f/g/h/i/j.txt'; 156 | const specifiedPoint = new Point(911, 112); 157 | const editor = createFakeEditor(path, 'abcdef\nghijkl\nmnopq'); 158 | editor.setCursorBufferPosition(new Point(1, 1)); 159 | const params = Convert.editorToTextDocumentPositionParams(editor, specifiedPoint); 160 | expect(params.textDocument.uri).equals('file://' + path); 161 | expect(params.position).deep.equals({ line: 911, character: 112 }); 162 | }); 163 | }); 164 | 165 | describe('grammarScopesToTextEditorScopes', () => { 166 | it('converts a single grammarScope to an atom-text-editor scope', () => { 167 | const grammarScopes = ['abc.def']; 168 | const textEditorScopes = Convert.grammarScopesToTextEditorScopes(grammarScopes); 169 | expect(textEditorScopes).equals('atom-text-editor[data-grammar="abc def"]'); 170 | }); 171 | 172 | it('converts a multiple grammarScopes to a comma-seperated list of atom-text-editor scopes', () => { 173 | const grammarScopes = ['abc.def', 'ghi.jkl']; 174 | const textEditorScopes = Convert.grammarScopesToTextEditorScopes(grammarScopes); 175 | expect(textEditorScopes).equals( 176 | 'atom-text-editor[data-grammar="abc def"], atom-text-editor[data-grammar="ghi jkl"]', 177 | ); 178 | }); 179 | 180 | it('converts grammarScopes containing HTML sensitive characters to escaped sequences', () => { 181 | const grammarScopes = ['abc { 190 | it('encodes characters that are not safe inside a HTML attribute', () => { 191 | const stringToEncode = 'a"b\'c&d>e { 198 | it('converts a created event', () => { 199 | const source: FilesystemChange = { path: '/a/b/c/d.txt', action: 'created' }; 200 | const converted = Convert.atomFileEventToLSFileEvents(source); 201 | expect(converted[0]).deep.equals({ uri: 'file:///a/b/c/d.txt', type: ls.FileChangeType.Created }); 202 | }); 203 | 204 | it('converts a modified event', () => { 205 | const source: FilesystemChange = { path: '/a/b/c/d.txt', action: 'modified' }; 206 | const converted = Convert.atomFileEventToLSFileEvents(source); 207 | expect(converted[0]).deep.equals({ uri: 'file:///a/b/c/d.txt', type: ls.FileChangeType.Changed }); 208 | }); 209 | 210 | it('converts a deleted event', () => { 211 | const source: FilesystemChange = { path: '/a/b/c/d.txt', action: 'deleted' }; 212 | const converted = Convert.atomFileEventToLSFileEvents(source); 213 | expect(converted[0]).deep.equals({ uri: 'file:///a/b/c/d.txt', type: ls.FileChangeType.Deleted }); 214 | }); 215 | 216 | it('converts a renamed event', () => { 217 | const source: FilesystemChange = { path: '/a/b/c/d.txt', oldPath: '/a/z/e.lst', action: 'renamed' }; 218 | const converted = Convert.atomFileEventToLSFileEvents(source); 219 | expect(converted[0]).deep.equals({ uri: 'file:///a/z/e.lst', type: ls.FileChangeType.Deleted }); 220 | expect(converted[1]).deep.equals({ uri: 'file:///a/b/c/d.txt', type: ls.FileChangeType.Created }); 221 | }); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon'; 2 | import * as rpc from 'vscode-jsonrpc'; 3 | import { TextEditor } from 'atom'; 4 | 5 | export function createSpyConnection(): rpc.MessageConnection { 6 | return { 7 | listen: sinon.spy(), 8 | onClose: sinon.spy(), 9 | onError: sinon.spy(), 10 | onDispose: sinon.spy(), 11 | onUnhandledNotification: sinon.spy(), 12 | onRequest: sinon.spy(), 13 | onNotification: sinon.spy(), 14 | dispose: sinon.spy(), 15 | sendRequest: sinon.spy(), 16 | sendNotification: sinon.spy(), 17 | trace: sinon.spy(), 18 | inspect: sinon.spy(), 19 | }; 20 | } 21 | 22 | export function createFakeEditor(path?: string): TextEditor { 23 | const editor = new TextEditor(); 24 | sinon.stub(editor, 'getSelectedBufferRange'); 25 | sinon.spy(editor, 'setTextInBufferRange'); 26 | editor.setTabLength(4); 27 | editor.setSoftTabs(true); 28 | editor.getBuffer().setPath(path || '/a/b/c/d.js'); 29 | return editor; 30 | } 31 | -------------------------------------------------------------------------------- /test/languageclient.test.ts: -------------------------------------------------------------------------------- 1 | import * as ls from '../lib/languageclient'; 2 | import * as sinon from 'sinon'; 3 | import { expect } from 'chai'; 4 | import { createSpyConnection } from './helpers.js'; 5 | import { NullLogger } from '../lib/logger'; 6 | 7 | describe('LanguageClientConnection', () => { 8 | it('listens to the RPC connection it is given', () => { 9 | const rpc = createSpyConnection(); 10 | 11 | new ls.LanguageClientConnection(rpc, new NullLogger()); 12 | expect((rpc as any).listen.called).equals(true); 13 | }); 14 | 15 | it('disposes of the connection when it is disposed', () => { 16 | const rpc = createSpyConnection(); 17 | const lc = new ls.LanguageClientConnection(rpc, new NullLogger()); 18 | expect((rpc as any).dispose.called).equals(false); 19 | lc.dispose(); 20 | expect((rpc as any).dispose.called).equals(true); 21 | }); 22 | 23 | describe('send requests', () => { 24 | const textDocumentPositionParams: ls.TextDocumentPositionParams = { 25 | textDocument: { uri: 'file:///1/z80.asm' }, 26 | position: { line: 24, character: 32 }, 27 | }; 28 | let lc: any; 29 | 30 | beforeEach(() => { 31 | lc = new ls.LanguageClientConnection(createSpyConnection(), new NullLogger()); 32 | sinon.spy(lc, '_sendRequest'); 33 | }); 34 | 35 | it('sends a request for initialize', async () => { 36 | const params = { capabilities: {} }; 37 | await lc.initialize(params); 38 | 39 | expect(lc._sendRequest.called).equals(true); 40 | expect(lc._sendRequest.getCall(0).args[0]).equals('initialize'); 41 | expect(lc._sendRequest.getCall(0).args[1]).equals(params); 42 | }); 43 | 44 | it('sends a request for shutdown', async () => { 45 | await lc.shutdown(); 46 | 47 | expect(lc._sendRequest.called).equals(true); 48 | expect(lc._sendRequest.getCall(0).args[0]).equals('shutdown'); 49 | }); 50 | 51 | it('sends a request for completion', async () => { 52 | await lc.completion(textDocumentPositionParams); 53 | 54 | expect(lc._sendRequest.called).equals(true); 55 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/completion'); 56 | expect(lc._sendRequest.getCall(0).args[1]).equals(textDocumentPositionParams); 57 | }); 58 | 59 | it('sends a request for completionItemResolve', async () => { 60 | const completionItem: ls.CompletionItem = { label: 'abc' }; 61 | await lc.completionItemResolve(completionItem); 62 | 63 | expect(lc._sendRequest.called).equals(true); 64 | expect(lc._sendRequest.getCall(0).args[0]).equals('completionItem/resolve'); 65 | expect(lc._sendRequest.getCall(0).args[1]).equals(completionItem); 66 | }); 67 | 68 | it('sends a request for hover', async () => { 69 | await lc.hover(textDocumentPositionParams); 70 | 71 | expect(lc._sendRequest.called).equals(true); 72 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/hover'); 73 | expect(lc._sendRequest.getCall(0).args[1]).equals(textDocumentPositionParams); 74 | }); 75 | 76 | it('sends a request for signatureHelp', async () => { 77 | await lc.signatureHelp(textDocumentPositionParams); 78 | 79 | expect(lc._sendRequest.called).equals(true); 80 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/signatureHelp'); 81 | expect(lc._sendRequest.getCall(0).args[1]).equals(textDocumentPositionParams); 82 | }); 83 | 84 | it('sends a request for gotoDefinition', async () => { 85 | await lc.gotoDefinition(textDocumentPositionParams); 86 | 87 | expect(lc._sendRequest.called).equals(true); 88 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/definition'); 89 | expect(lc._sendRequest.getCall(0).args[1]).equals(textDocumentPositionParams); 90 | }); 91 | 92 | it('sends a request for findReferences', async () => { 93 | await lc.findReferences(textDocumentPositionParams); 94 | 95 | expect(lc._sendRequest.called).equals(true); 96 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/references'); 97 | expect(lc._sendRequest.getCall(0).args[1]).equals(textDocumentPositionParams); 98 | }); 99 | 100 | it('sends a request for documentHighlight', async () => { 101 | await lc.documentHighlight(textDocumentPositionParams); 102 | 103 | expect(lc._sendRequest.called).equals(true); 104 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/documentHighlight'); 105 | expect(lc._sendRequest.getCall(0).args[1]).equals(textDocumentPositionParams); 106 | }); 107 | 108 | it('sends a request for documentSymbol', async () => { 109 | await lc.documentSymbol(textDocumentPositionParams); 110 | 111 | expect(lc._sendRequest.called).equals(true); 112 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/documentSymbol'); 113 | expect(lc._sendRequest.getCall(0).args[1]).equals(textDocumentPositionParams); 114 | }); 115 | 116 | it('sends a request for workspaceSymbol', async () => { 117 | const params: ls.WorkspaceSymbolParams = { query: 'something' }; 118 | await lc.workspaceSymbol(params); 119 | 120 | expect(lc._sendRequest.called).equals(true); 121 | expect(lc._sendRequest.getCall(0).args[0]).equals('workspace/symbol'); 122 | expect(lc._sendRequest.getCall(0).args[1]).equals(params); 123 | }); 124 | 125 | it('sends a request for codeAction', async () => { 126 | const params: ls.CodeActionParams = { 127 | textDocument: textDocumentPositionParams.textDocument, 128 | range: { 129 | start: { line: 1, character: 1 }, 130 | end: { line: 24, character: 32 }, 131 | }, 132 | context: { diagnostics: [] }, 133 | }; 134 | await lc.codeAction(params); 135 | 136 | expect(lc._sendRequest.called).equals(true); 137 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/codeAction'); 138 | expect(lc._sendRequest.getCall(0).args[1]).equals(params); 139 | }); 140 | 141 | it('sends a request for codeLens', async () => { 142 | const params: ls.CodeLensParams = { 143 | textDocument: textDocumentPositionParams.textDocument, 144 | }; 145 | await lc.codeLens(params); 146 | 147 | expect(lc._sendRequest.called).equals(true); 148 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/codeLens'); 149 | expect(lc._sendRequest.getCall(0).args[1]).equals(params); 150 | }); 151 | 152 | it('sends a request for codeLensResolve', async () => { 153 | const params: ls.CodeLens = { 154 | range: { 155 | start: { line: 1, character: 1 }, 156 | end: { line: 24, character: 32 }, 157 | }, 158 | }; 159 | await lc.codeLensResolve(params); 160 | 161 | expect(lc._sendRequest.called).equals(true); 162 | expect(lc._sendRequest.getCall(0).args[0]).equals('codeLens/resolve'); 163 | expect(lc._sendRequest.getCall(0).args[1]).equals(params); 164 | }); 165 | 166 | it('sends a request for documentLink', async () => { 167 | const params: ls.DocumentLinkParams = { 168 | textDocument: textDocumentPositionParams.textDocument, 169 | }; 170 | await lc.documentLink(params); 171 | 172 | expect(lc._sendRequest.called).equals(true); 173 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/documentLink'); 174 | expect(lc._sendRequest.getCall(0).args[1]).equals(params); 175 | }); 176 | 177 | it('sends a request for documentLinkResolve', async () => { 178 | const params: ls.DocumentLink = { 179 | range: { 180 | start: { line: 1, character: 1 }, 181 | end: { line: 24, character: 32 }, 182 | }, 183 | target: 'abc.def.ghi', 184 | }; 185 | await lc.documentLinkResolve(params); 186 | 187 | expect(lc._sendRequest.called).equals(true); 188 | expect(lc._sendRequest.getCall(0).args[0]).equals('documentLink/resolve'); 189 | expect(lc._sendRequest.getCall(0).args[1]).equals(params); 190 | }); 191 | 192 | it('sends a request for documentFormatting', async () => { 193 | const params: ls.DocumentFormattingParams = { 194 | textDocument: textDocumentPositionParams.textDocument, 195 | options: { tabSize: 6, insertSpaces: true, someValue: 'optional' }, 196 | }; 197 | await lc.documentFormatting(params); 198 | 199 | expect(lc._sendRequest.called).equals(true); 200 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/formatting'); 201 | expect(lc._sendRequest.getCall(0).args[1]).equals(params); 202 | }); 203 | 204 | it('sends a request for documentRangeFormatting', async () => { 205 | const params: ls.DocumentRangeFormattingParams = { 206 | textDocument: textDocumentPositionParams.textDocument, 207 | range: { 208 | start: { line: 1, character: 1 }, 209 | end: { line: 24, character: 32 }, 210 | }, 211 | options: { tabSize: 6, insertSpaces: true, someValue: 'optional' }, 212 | }; 213 | await lc.documentRangeFormatting(params); 214 | 215 | expect(lc._sendRequest.called).equals(true); 216 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/rangeFormatting'); 217 | expect(lc._sendRequest.getCall(0).args[1]).equals(params); 218 | }); 219 | 220 | it('sends a request for documentOnTypeFormatting', async () => { 221 | const params: ls.DocumentOnTypeFormattingParams = { 222 | textDocument: textDocumentPositionParams.textDocument, 223 | position: { line: 1, character: 1 }, 224 | ch: '}', 225 | options: { tabSize: 6, insertSpaces: true, someValue: 'optional' }, 226 | }; 227 | await lc.documentOnTypeFormatting(params); 228 | 229 | expect(lc._sendRequest.called).equals(true); 230 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/onTypeFormatting'); 231 | expect(lc._sendRequest.getCall(0).args[1]).equals(params); 232 | }); 233 | 234 | it('sends a request for rename', async () => { 235 | const params: ls.RenameParams = { 236 | textDocument: { uri: 'file:///a/b.txt' }, 237 | position: { line: 1, character: 2 }, 238 | newName: 'abstractConstructorFactory', 239 | }; 240 | await lc.rename(params); 241 | 242 | expect(lc._sendRequest.called).equals(true); 243 | expect(lc._sendRequest.getCall(0).args[0]).equals('textDocument/rename'); 244 | expect(lc._sendRequest.getCall(0).args[1]).equals(params); 245 | }); 246 | }); 247 | 248 | describe('send notifications', () => { 249 | const textDocumentItem: ls.TextDocumentItem = { 250 | uri: 'file:///best/bits.js', 251 | languageId: 'javascript', 252 | text: 'function a() { return "b"; };', 253 | version: 1, 254 | }; 255 | const versionedTextDocumentIdentifier: ls.VersionedTextDocumentIdentifier = { 256 | uri: 'file:///best/bits.js', 257 | version: 1, 258 | }; 259 | 260 | let lc: any; 261 | 262 | beforeEach(() => { 263 | lc = new ls.LanguageClientConnection(createSpyConnection(), new NullLogger()); 264 | sinon.stub(lc, '_sendNotification'); 265 | }); 266 | 267 | it('exit sends notification', () => { 268 | lc.exit(); 269 | 270 | expect(lc._sendNotification.called).equals(true); 271 | expect(lc._sendNotification.getCall(0).args[0]).equals('exit'); 272 | expect(lc._sendNotification.getCall(0).args.length).equals(1); 273 | }); 274 | 275 | it('initialized sends notification', () => { 276 | lc.initialized(); 277 | 278 | expect(lc._sendNotification.called).equals(true); 279 | expect(lc._sendNotification.getCall(0).args[0]).equals('initialized'); 280 | const expected: ls.InitializedParams = {}; 281 | expect(lc._sendNotification.getCall(0).args[1]).to.deep.equal(expected); 282 | }); 283 | 284 | it('didChangeConfiguration sends notification', () => { 285 | const params: ls.DidChangeConfigurationParams = { 286 | settings: { a: { b: 'c' } }, 287 | }; 288 | lc.didChangeConfiguration(params); 289 | 290 | expect(lc._sendNotification.called).equals(true); 291 | expect(lc._sendNotification.getCall(0).args[0]).equals('workspace/didChangeConfiguration'); 292 | expect(lc._sendNotification.getCall(0).args[1]).equals(params); 293 | }); 294 | 295 | it('didOpenTextDocument sends notification', () => { 296 | const params: ls.DidOpenTextDocumentParams = { 297 | textDocument: textDocumentItem, 298 | }; 299 | lc.didOpenTextDocument(params); 300 | 301 | expect(lc._sendNotification.called).equals(true); 302 | expect(lc._sendNotification.getCall(0).args[0]).equals('textDocument/didOpen'); 303 | expect(lc._sendNotification.getCall(0).args[1]).equals(params); 304 | }); 305 | 306 | it('didChangeTextDocument sends notification', () => { 307 | const params: ls.DidChangeTextDocumentParams = { 308 | textDocument: versionedTextDocumentIdentifier, 309 | contentChanges: [], 310 | }; 311 | lc.didChangeTextDocument(params); 312 | 313 | expect(lc._sendNotification.called).equals(true); 314 | expect(lc._sendNotification.getCall(0).args[0]).equals('textDocument/didChange'); 315 | expect(lc._sendNotification.getCall(0).args[1]).equals(params); 316 | }); 317 | 318 | it('didCloseTextDocument sends notification', () => { 319 | const params: ls.DidCloseTextDocumentParams = { 320 | textDocument: textDocumentItem, 321 | }; 322 | lc.didCloseTextDocument(params); 323 | 324 | expect(lc._sendNotification.called).equals(true); 325 | expect(lc._sendNotification.getCall(0).args[0]).equals('textDocument/didClose'); 326 | expect(lc._sendNotification.getCall(0).args[1]).equals(params); 327 | }); 328 | 329 | it('didSaveTextDocument sends notification', () => { 330 | const params: ls.DidSaveTextDocumentParams = { 331 | textDocument: textDocumentItem, 332 | }; 333 | lc.didSaveTextDocument(params); 334 | 335 | expect(lc._sendNotification.called).equals(true); 336 | expect(lc._sendNotification.getCall(0).args[0]).equals('textDocument/didSave'); 337 | expect(lc._sendNotification.getCall(0).args[1]).equals(params); 338 | }); 339 | 340 | it('didChangeWatchedFiles sends notification', () => { 341 | const params: ls.DidChangeWatchedFilesParams = { changes: [] }; 342 | lc.didChangeWatchedFiles(params); 343 | 344 | expect(lc._sendNotification.called).equals(true); 345 | expect(lc._sendNotification.getCall(0).args[0]).equals('workspace/didChangeWatchedFiles'); 346 | expect(lc._sendNotification.getCall(0).args[1]).equals(params); 347 | }); 348 | }); 349 | 350 | describe('notification methods', () => { 351 | let lc: any; 352 | const eventMap: { [key: string]: any } = {}; 353 | 354 | beforeEach(() => { 355 | lc = new ls.LanguageClientConnection(createSpyConnection(), new NullLogger()); 356 | sinon.stub(lc, '_onNotification').callsFake((message, callback) => { 357 | eventMap[message.method] = callback; 358 | }); 359 | }); 360 | 361 | it('onShowMessage calls back on window/showMessage', () => { 362 | let called = false; 363 | lc.onShowMessage(() => { 364 | called = true; 365 | }); 366 | eventMap['window/showMessage'](); 367 | expect(called).equals(true); 368 | }); 369 | }); 370 | }); 371 | -------------------------------------------------------------------------------- /test/runner.js: -------------------------------------------------------------------------------- 1 | const { TestRunnerParams } = require("atom"); 2 | const { createRunner } = require('@atom/mocha-test-runner'); 3 | 4 | module.exports = createRunner({ 5 | htmlTitle: `atom-languageclient Tests - pid ${process.pid}`, 6 | reporter: process.env.MOCHA_REPORTER || 'list', 7 | }, 8 | (mocha) => { 9 | mocha.timeout(parseInt(process.env.MOCHA_TIMEOUT || '5000', 10)); 10 | if (process.env.APPVEYOR_API_URL) { 11 | mocha.reporter(require('mocha-appveyor-reporter')); 12 | } 13 | }, 14 | ); 15 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import * as Utils from '../lib/utils'; 2 | import { createFakeEditor } from './helpers'; 3 | import { expect } from 'chai'; 4 | import { Point } from 'atom'; 5 | 6 | describe('Utils', () => { 7 | describe('getWordAtPosition', () => { 8 | let editor: any; 9 | beforeEach(() => { 10 | editor = createFakeEditor('test.txt'); 11 | editor.setText('blah test1234 test-two'); 12 | }); 13 | 14 | it('gets the word at position from a text editor', () => { 15 | // "blah" 16 | let range = Utils.getWordAtPosition(editor, new Point(0, 0)); 17 | expect(range.serialize()).eql([[0, 0], [0, 4]]); 18 | 19 | // "test1234" 20 | range = Utils.getWordAtPosition(editor, new Point(0, 7)); 21 | expect(range.serialize()).eql([[0, 5], [0, 13]]); 22 | 23 | // "test" 24 | range = Utils.getWordAtPosition(editor, new Point(0, 14)); 25 | expect(range.serialize()).eql([[0, 14], [0, 18]]); 26 | }); 27 | 28 | it('returns empty ranges for non-words', () => { 29 | const range = Utils.getWordAtPosition(editor, new Point(0, 4)); 30 | expect(range.serialize()).eql([[0, 4], [0, 4]]); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "build", 6 | "lib": ["es7", "dom"], 7 | "declaration": true, 8 | "inlineSources": true, 9 | "inlineSourceMap": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitReturns": true, 14 | "noUnusedLocals": true, 15 | "baseUrl": "./" 16 | }, 17 | "include": [ 18 | "typings/**/*.ts", 19 | "lib/**/*.ts", 20 | "test/**/*.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "quotemark": false, 9 | "object-literal-sort-keys": false, 10 | "ordered-imports": false, 11 | "member-ordering": false, 12 | "one-line": false, 13 | "interface-name": false, 14 | "variable-name": false, 15 | "max-classes-per-file": false, 16 | "no-unused-expression": false, 17 | "no-empty": false, 18 | "one-variable-per-declaration": false, 19 | "whitespace": [ 20 | true, 21 | "check-branch", 22 | "check-decl", 23 | "check-operator", 24 | "check-separator", 25 | "check-type", 26 | "check-typecast", 27 | "check-module" 28 | ] 29 | }, 30 | "rulesDirectory": [] 31 | } 32 | -------------------------------------------------------------------------------- /typings/atom-ide/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'atom-ide' { 2 | import { Disposable, Grammar, Point, Range, TextEditor } from 'atom'; 3 | import * as ac from 'atom/autocomplete-plus'; 4 | 5 | export interface OutlineProvider { 6 | name: string; 7 | /** 8 | * If there are multiple providers for a given grammar, the one with the highest priority will be 9 | * used. 10 | */ 11 | priority: number; 12 | grammarScopes: string[]; 13 | updateOnEdit?: boolean; 14 | getOutline: (editor: TextEditor) => Promise; 15 | } 16 | 17 | export interface OutlineTree { 18 | /** from atom$Octicon | atom$OcticonsPrivate (types not allowed over rpc so we use string) */ 19 | icon?: string; 20 | 21 | /** Must have `plainText` or the `tokenizedText` property. If both are present, `tokenizedText` is preferred. */ 22 | plainText?: string; 23 | /** Must have `plainText` or the `tokenizedText` property. If both are present, `tokenizedText` is preferred. */ 24 | tokenizedText?: TokenizedText; 25 | representativeName?: string; 26 | 27 | startPosition: Point; 28 | endPosition?: Point; 29 | children: OutlineTree[]; 30 | } 31 | 32 | export interface Outline { 33 | outlineTrees: OutlineTree[]; 34 | } 35 | 36 | export type TokenKind = 37 | | 'keyword' 38 | | 'class-name' 39 | | 'constructor' 40 | | 'method' 41 | | 'param' 42 | | 'string' 43 | | 'whitespace' 44 | | 'plain' 45 | | 'type'; 46 | 47 | export interface TextToken { 48 | kind: TokenKind; 49 | value: string; 50 | } 51 | 52 | export type TokenizedText = TextToken[]; 53 | 54 | export interface DefinitionProvider { 55 | name: string; 56 | priority: number; 57 | grammarScopes: string[]; 58 | getDefinition: (editor: TextEditor, position: Point) => Promise; 59 | } 60 | 61 | export type IdeUri = string; 62 | 63 | export interface Definition { 64 | path: IdeUri; 65 | position: Point; 66 | range?: Range; 67 | id?: string; 68 | name?: string; 69 | language: string; 70 | projectRoot?: IdeUri; 71 | } 72 | 73 | export interface DefinitionQueryResult { 74 | queryRange: Range[]; 75 | definitions: Definition[]; 76 | } 77 | 78 | export interface FindReferencesProvider { 79 | /** Return true if your provider supports finding references for the provided TextEditor. */ 80 | isEditorSupported(editor: TextEditor): boolean | Promise; 81 | 82 | /** 83 | * `findReferences` will only be called if `isEditorSupported` previously returned true 84 | * for the given TextEditor. 85 | */ 86 | findReferences(editor: TextEditor, position: Point): Promise; 87 | } 88 | 89 | export interface Reference { 90 | /** URI of the file path */ 91 | uri: IdeUri; 92 | /** Name of calling method / function / symbol */ 93 | name: string | null; 94 | range: Range; 95 | } 96 | 97 | export interface FindReferencesData { 98 | type: 'data'; 99 | baseUri: IdeUri; 100 | referencedSymbolName: string; 101 | references: Reference[]; 102 | } 103 | 104 | export interface FindReferencesError { 105 | type: 'error'; 106 | message: string; 107 | } 108 | 109 | export type FindReferencesReturn = FindReferencesData | FindReferencesError; 110 | 111 | export type MarkedString = 112 | | { 113 | type: 'markdown', 114 | value: string, 115 | } 116 | | { 117 | type: 'snippet', 118 | grammar: Grammar, 119 | value: string, 120 | }; 121 | 122 | // This omits the React variant. 123 | export interface Datatip { 124 | markedStrings: MarkedString[]; 125 | range: Range; 126 | pinnable?: boolean; 127 | } 128 | 129 | export interface DatatipProvider { 130 | datatip( 131 | editor: TextEditor, 132 | bufferPosition: Point, 133 | /** 134 | * The mouse event that triggered the datatip. 135 | * This is null for manually toggled datatips. 136 | */ 137 | mouseEvent: MouseEvent | null, 138 | ): Promise; 139 | validForScope(scopeName: string): boolean; 140 | /** 141 | * A unique name for the provider to be used for analytics. 142 | * It is recommended that it be the name of the provider's package. 143 | */ 144 | providerName: string; 145 | priority: number; 146 | grammarScopes: string[]; 147 | } 148 | 149 | export interface DatatipService { 150 | addProvider(provider: DatatipProvider): Disposable; 151 | } 152 | 153 | export interface FileCodeFormatProvider { 154 | formatEntireFile: (editor: TextEditor, range: Range) => Promise; 155 | priority: number; 156 | grammarScopes: string[]; 157 | } 158 | 159 | export interface RangeCodeFormatProvider { 160 | formatCode: (editor: TextEditor, range: Range) => Promise; 161 | priority: number; 162 | grammarScopes: string[]; 163 | } 164 | 165 | export interface OnSaveCodeFormatProvider { 166 | formatOnSave: (editor: TextEditor) => Promise; 167 | priority: number; 168 | grammarScopes: string[]; 169 | } 170 | 171 | export interface OnTypeCodeFormatProvider { 172 | formatAtPosition: (editor: TextEditor, position: Point, character: string) => Promise; 173 | priority: number; 174 | grammarScopes: string[]; 175 | } 176 | 177 | export interface TextEdit { 178 | oldRange: Range; 179 | newText: string; 180 | /** If included, this will be used to verify that the edit still applies cleanly. */ 181 | oldText?: string; 182 | } 183 | 184 | export interface CodeHighlightProvider { 185 | highlight(editor: TextEditor, bufferPosition: Point): Promise; 186 | priority: number; 187 | grammarScopes: string[]; 188 | } 189 | 190 | export type DiagnosticType = 'Error' | 'Warning' | 'Info'; 191 | 192 | export interface Diagnostic { 193 | providerName: string; 194 | type: DiagnosticType; 195 | filePath: string; 196 | text?: string; 197 | range: Range; 198 | } 199 | 200 | export interface CodeAction { 201 | apply(): Promise; 202 | getTitle(): Promise; 203 | dispose(): void; 204 | } 205 | 206 | export interface CodeActionProvider { 207 | grammarScopes: string[]; 208 | priority: number; 209 | getCodeActions( 210 | editor: TextEditor, 211 | range: Range, 212 | diagnostics: Diagnostic[], 213 | ): Promise; 214 | } 215 | 216 | export interface RefactorProvider { 217 | grammarScopes: string[]; 218 | priority: number; 219 | rename?(editor: TextEditor, position: Point, newName: string): Promise | null>; 220 | } 221 | 222 | export interface BusySignalOptions { 223 | /** 224 | * Can say that a busy signal will only appear when a given file is open. 225 | * Default = null, meaning the busy signal applies to all files. 226 | */ 227 | onlyForFile?: IdeUri; 228 | /** 229 | * Is user waiting for computer to finish a task? (traditional busy spinner) 230 | * or is the computer waiting for user to finish a task? (action required) 231 | * @defaultValue `'computer'` 232 | */ 233 | waitingFor?: 'computer' | 'user'; 234 | /** Debounce it? default = true for busy-signal, and false for action-required. */ 235 | debounce?: boolean; 236 | /** 237 | * If onClick is set, then the tooltip will be clickable. 238 | * @defaultValue `null` 239 | */ 240 | onDidClick?: () => void; 241 | } 242 | 243 | export interface BusySignalService { 244 | /** 245 | * Activates the busy signal with the given title and returns the promise 246 | * from the provided callback. 247 | * The busy signal automatically deactivates when the returned promise 248 | * either resolves or rejects. 249 | */ 250 | reportBusyWhile( 251 | title: string, 252 | f: () => Promise, 253 | options?: BusySignalOptions, 254 | ): Promise; 255 | 256 | /** 257 | * Activates the busy signal. Set the title in the returned BusySignal 258 | * object (you can update the title multiple times) and dispose it when done. 259 | */ 260 | reportBusy(title: string, options?: BusySignalOptions): BusyMessage; 261 | 262 | /** 263 | * This is a no-op. When someone consumes the busy service, they get back a 264 | * reference to the single shared instance, so disposing of it would be wrong. 265 | */ 266 | dispose(): void; 267 | } 268 | 269 | export interface BusyMessage { 270 | /** You can set/update the title. */ 271 | setTitle(title: string): void; 272 | /** Dispose of the signal when done to make it go away. */ 273 | dispose(): void; 274 | } 275 | 276 | export type SignatureHelpRegistry = (provider: SignatureHelpProvider) => Disposable; 277 | 278 | /** 279 | * Signature help is activated when: 280 | * - upon keystroke, any provider with a matching grammar scope contains 281 | * the pressed key inside its triggerCharacters set 282 | * - the signature-help:show command is manually activated 283 | * 284 | * Once signature help has been triggered, the provider will be queried immediately 285 | * with the current cursor position, and then repeatedly upon cursor movements 286 | * until a null/empty signature is returned. 287 | * 288 | * Returned signatures will be displayed in a small datatip at the current cursor. 289 | * The highest-priority provider with a non-null result will be used. 290 | */ 291 | export interface SignatureHelpProvider { 292 | priority: number; 293 | grammarScopes: string[]; 294 | 295 | /** 296 | * A set of characters that will trigger signature help when typed. 297 | * If a null/empty set is provided, only manual activation of the command works. 298 | */ 299 | triggerCharacters?: Set; 300 | 301 | getSignatureHelp(editor: TextEditor, point: Point): Promise; 302 | } 303 | 304 | export interface SignatureHelp { 305 | signatures: Signature[]; 306 | activeSignature?: number; 307 | activeParameter?: number; 308 | } 309 | 310 | export interface Signature { 311 | label: string; 312 | documentation?: string; 313 | parameters?: SignatureParameter[]; 314 | } 315 | 316 | export interface SignatureParameter { 317 | label: string; 318 | documentation?: string; 319 | } 320 | 321 | export interface SourceInfo { 322 | id: string; 323 | name: string; 324 | start?: () => void; 325 | stop?: () => void; 326 | } 327 | 328 | // Console service 329 | 330 | export type ConsoleService = (options: SourceInfo) => ConsoleApi; 331 | 332 | export interface ConsoleApi { 333 | setStatus(status: OutputProviderStatus): void; 334 | append(message: Message): void; 335 | dispose(): void; 336 | log(object: string): void; 337 | error(object: string): void; 338 | warn(object: string): void; 339 | info(object: string): void; 340 | } 341 | 342 | export type OutputProviderStatus = 'starting' | 'running' | 'stopped'; 343 | 344 | export interface Message { 345 | text: string; 346 | level: Level; 347 | tags?: string[] | null; 348 | kind?: MessageKind | null; 349 | scopeName?: string | null; 350 | } 351 | 352 | export type TaskLevelType = 'info' | 'log' | 'warning' | 'error' | 'debug' | 'success'; 353 | export type Level = TaskLevelType | Color; 354 | type Color = 355 | | 'red' 356 | | 'orange' 357 | | 'yellow' 358 | | 'green' 359 | | 'blue' 360 | | 'purple' 361 | | 'violet' 362 | | 'rainbow'; 363 | 364 | export type MessageKind = 'message' | 'request' | 'response'; 365 | 366 | // Autocomplete service 367 | 368 | /** Adds LSP specific properties to the Atom SuggestionBase type */ 369 | interface SuggestionBase extends ac.SuggestionBase { 370 | /** 371 | * A string that is used when filtering and sorting a set of 372 | * completion items with a prefix present. When `falsy` the 373 | * [displayText](#ac.SuggestionBase.displayText) is used. When 374 | * no prefix, the `sortText` property is used. 375 | */ 376 | filterText?: string; 377 | 378 | /** 379 | * String representing the replacement prefix from the suggestion's 380 | * custom start point to the original buffer position the suggestion 381 | * was gathered from. 382 | */ 383 | customReplacmentPrefix?: string; 384 | } 385 | 386 | export type TextSuggestion = SuggestionBase & ac.TextSuggestion; 387 | 388 | export type SnippetSuggestion = SuggestionBase & ac.SnippetSuggestion; 389 | 390 | export type Suggestion = TextSuggestion | SnippetSuggestion; 391 | } 392 | -------------------------------------------------------------------------------- /typings/atom/index.d.ts: -------------------------------------------------------------------------------- 1 | export { }; 2 | declare module 'atom' { 3 | interface TextEditor { 4 | getNonWordCharacters(position: Point): string; 5 | } 6 | 7 | /** Non-public Notification api */ 8 | interface NotificationExt extends Notification { 9 | isDismissed?: () => boolean; 10 | getOptions?: () => NotificationOptions | null; 11 | } 12 | } 13 | --------------------------------------------------------------------------------